/tripllo_vue

Trello + Trip = Tripllo. 여행 계획 공유 서비스.

Primary LanguageVue

📌 Tripllo

Trello Clone, 계획 공유 서비스

AWS 프리티어 만료 및 도메인 만료로 메뉴얼만 보실 수 있습니다. 메뉴얼에는 프로젝트 GIF와 기능 설명이 있습니다.

간단 메뉴얼 : https://pozafly.github.io/tripllo-manual/

메인 : https://tripllo.tech

API : https://api.tripllo.tech/swagger-ui.html


목차

  1. 제작 기간 & 참여 인원
  2. 사용 기술
  3. ERD 설계
  4. 핵심 기능
  5. 핵심 트러블 슈팅
  6. 그 외 트러블 슈팅

1. 제작 기간 & 참여 인원

  • 2020년 12월 7일 ~ 2021년 2월 15일
  • 개인 프로젝트

2. 사용 기술

Front-end

Back-end

  • Java 8
  • SpringBoot 2.1.9
    • Spring Security + JWT
    • Websocket
    • Swagger 2
    • Spring Mail
    • Spring Cloud-AWS
  • Mysql 8.0.22
  • MyBatis

Deploy

  • AWS-EC2 (Amazon Linux 2)
  • AWS-RDS
  • AWS-S3
  • AWS-CloudFront
  • AWS-Route53
  • AWS-CodeDeploy
  • Travis
  • Nginx
  • Let's Encrypt(SSL)

3. ERD 설계

tripllo


4. 핵심 기능

  1. 전체 흐름
  2. 계획 등록
  3. 사용자 초대
  4. 소셜 기능
핵심 기능 설명 펼치기

4.1 전체 흐름

  • Frontend

Frontend-process

  • Backend

Backend-process


4.2 계획 등록

  • 카드 기능

    • Location(구글맵 API)

      • 구글맵 API를 사용해서 card에서는 static 이미지를 불러오며 클릭 시, 구글맵 전체를 볼 수 있습니다.
      • 구글맵 상세 페이지에서는 해당 Board에서 등록된 모든 location이 지도에 표시되는 클러스터 기능이 구현되어 있습니다. 📌 코드 확인
    • Attachment

      • 파일 업로드 시 local에 파일을 저장 후 S3에 올린 다음 local에 남은 파일을 지웁니다.
      • Spring Cloud AWS를 이용해 S3에 static_[유저이름] 으로 된 폴더를 생성해 파일을 저장합니다. 📌 코드 확인
      • 파일은 권한 체크 후 다운받거나 삭제할 수 있습니다. 📌 코드 확인
    • Checklist

      • KProgress 모듈을 사용해 체크 목록이 변화할 때마다 게이지가 변합니다. 📌 코드 확인
      • 이름을 변경할 때 event.relatedTaget으로 이벤트가 일어난 DOM을 체크해 메서드를 실행합니다. 📌 코드 확인
    • Comments

      • 답글(대댓글)을 위한 group_num, dept 칼럼을 두어 답글을 표현합니다.
      • 삭제 시 댓글에 답글이 없을 경우는 화면에서 사라지지만, 답글이 존재하는 경우 삭제된 메세지 입니다. 라고 표시됩니다. 📌 코드 확인
    • 그 외 기능 - Description(메모), Labels(라벨링), dueDate(날짜 지정)

  • 드래그 앤 드롭

    • dragula 모듈을 사용해, List와 Card를 드래그해서 위치를 변화시킬 수 있습니다.
    • 대상의 이전 DOM과 다음 DOM을 비교해서 pos(포지션) 값을 지정 후 UPDATE 합니다. 📌 코드 확인
  • BoardPage & CardModal 화면 연동

    • Board 혹은 Card를 수정 했을 시 api 함수 호출 후 Component를 다시 그려줄 수 있는 Action 함수를 호출합니다. 📌 코드 확인
    • 1:N 관계를 가진 컴포넌트가 readBoardDetail 이라는 쿼리문 조회된 후 리랜더링 됩니다. 📌 코드 확인

4.3 사용자 초대

  • 유저 검색

    • 모달 창에서 초대하고 싶은 회원의 ID를 검색합니다. filter를 사용해 자신과 이미 초대된 사람은 목록에 뜨지 않습니다. 📌 코드 확인
  • 실시간 messaging

    • sockjs-client로 Connection을 실행합니다. 📌 코드 확인
    • Spring WebSocket에서 HandshakeInterceptor 를 통해 socket 세션을 받아온 후, 현재 접속자 끼리 초대장을 보낼 수 있습니다. 📌 코드 확인
    • 초대장을 받고, Notification 처리와, 초대장 개수를 표현합니다. 📌 코드 확인
  • 초대 수락

    • 유저가 초대된 Board의 invitedUser 목록에 추가되고, 해당 유저의 invitedBoard 목록에 추가됩니다. 📌 코드 확인
    • 이때, 초대한 사람의 Board가 수정되어야 하므로 Spring Interceptor에서 권한 체크를 합니다. 📌 코드 확인

4.4 소셜 기능

  • 해시태그

    • Array - push, splice를 통해 해시태그를 지정, 삭제할 수 있습니다. 📌 코드 확인
    • Board를 만든 주인만 해시태그를 수정할 수 있도록 화면 숨김 처리되어 있습니다. 📌 코드 확인
    • N:M 관계를 board_has_hashtag 중간 테이블을 두고 1:N 관계로 풀어서 조회합니다. 📌 코드 확인
  • 좋아요

    • 좋아요 순서로 Public Tab의 상단에 표현됩니다.
    • Board 조회 시, 유저의 좋아요 클릭 여부를 판단하기 위해 own_like 칼럼을 표현합니다. 📌 코드 확인

5. 핵심 트러블 슈팅

5.1 무한 스크롤 적용 문제

  • Board 조회 시, Data를 한 번에 조회하는 방식이었습니다.
  • 무한 스크롤을 적용할 때 전체를 조회하는 것이 아니라 이어지는 일부분을 가져와야 했습니다.
  • 커서 기반 페이지네이션을 읽고 MySQL의 limit와 offset을 사용해서 들고 오면, Table 전체를 조회 후 offset에 맞는 Data를 가져오게 되므로 성능상 문제가 생긴다는 사실을 알게 되었습니다.
기존SQL
<select id="readPersonalBoardList" parameterType="String" resultType="com.pozafly.tripllo.board.model.Board">
    select
        a.id,
        a.title,
        a.bg_color,
        a.public_yn,
        a.hashtag,
        a.like_count,
        a.created_at,
        a.created_by,
        EXISTS
        (
            select 1
            from board_has_like
            where board_id = a.id and user_id = #{userId}
        ) as own_like
    from board a
    where a.created_by = #{userId}
    order by created_at desc
</select>
  • 커서(기준)는 정렬하고 있는 대상인 created_at 이며
  • 처음 조회 시 lastCreatedAt 변수에 firstCall 문자열을 주어, 14개의 데이터만 조회했습니다.
  • 이후 조회 시 lastCreatedAt 변수에 화면에 뿌려진 마지막 DOM의 createdAt로 조회하면, 커서(기준)보다 작은 순서로 Data를 가져옵니다.
수정된 SQL
<select id="readPersonalBoardList" parameterType="Map" resultType="com.pozafly.tripllo.board.model.Board">
    select
        a.id,
        a.title,
        a.bg_color,
        a.public_yn,
        a.hashtag,
        a.like_count,
        a.created_at,
        a.created_by,
        EXISTS
        (
            select 1
            from board_has_like
            where board_id = a.id and user_id = #{userId}
        ) as own_like
    from board a
    where a.created_by = #{userId}
    <choose>
        <when test='"firstCall".equals(lastCreatedAt)'>
            order by created_at desc
            limit 14
        </when>
        <otherwise>
            and created_at <![CDATA[ < ]]> #{lastCreatedAt}
            order by created_at desc
            limit 6
        </otherwise>
    </choose>
</select>
  • Vue에서는 vue-infinite-loading 패키지를 설치하고, <infinite-loading> 를 이용해 infiniteHandler 메서드를 호출하도록 했습니다.
Vue templete 코드
<infinite-loading @infinite="infiniteHandler" spinner="waveDots">
  <div
    slot="no-more"
    style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px;"
  >
    목록의 끝입니다 :)
  </div>
</infinite-loading>
  • script에서는 lastCreatedAt 변수에 담길 값을 저장합니다.
  • 이때, vue-infinite-loading는 $state.loaded$state.complete 로 무한스크롤이 끝났는지 지속해야 하는지 판단합니다.
Vue script 코드
data() {
  return {
    ...
    lastCreatedAt: 'firstCall',   // 초기 값.
  }
}
...
async infiniteHandler($state) {
  try {
    const { data } = await readPersonalBoardAPI(this.lastCreatedAt);

    if (data.data === null) {
      this.isInfinity = false;
      $state.complete(); // 데이터는 모두 소진되고 다시 가져올 필요가 없다는 것을 알려준다.
    } else {
      (...)
      setTimeout(() => {
        const boardItem = data.data;
        // BoardItem의 마지막 값을 가져옴
        const lastCreatedAt = boardItem[boardItem.length - 1].createdAt;
        this.lastCreatedAt = lastCreatedAt;
        $state.loaded(); // 계속 데이터가 남아있다는 것을 infinity에게 알려준다.
      }, 1000);
    }
  } catch (error) {
    console.log(error);
    alert('Personal 보드를 가져오지 못했습니다.');
  }
},

5.2 vue watch 사용 시 객체 감지 & lodash debounce 문제

  • 회원가입 페이지에서 input을 조작할 때, 동적으로 validation 체크와 button 활성화 기능을 넣고 싶었습니다.
  • vue의 watch를 통한 데이터를 감지와 input 태그에 debounce를 걸어 약간의 딜레이를 주고자 했습니다.
  • 하지만, vue data에 선언된 userData가 객체형태였고 객체의 요소 하나라도 변하면 메서드가 실행되는 문제가 발생했습니다.
기존 코드
data() {
  return {
    userData: {
      id: '',
      password: '',
      email: '',
      name: '',
      response: '',
      name: '',
    },
  },
}
...
watch: {
  userData: {
    id: function() {
      () => {
        _.debounce(function(e)) {
          this.validUserId(e);
        }
      },
    ...
    },
  },
},
  • 아래와 같이
  • 객체 내부의 변수 1개만 감지 : '객체.변수명': [some function]
  • 객체 내부 요소가 하나라도 변화할 때 감지 : handler(e), deep: true
  • debounce는 즉시 실행 함수로 선언하는 것이 아니라, 함수 자체를 등록해줘야 한다는 것을 알게 되어 개선할 수 있었습니다.
개선된 코드
watch: {
  userData: {
    handler(e) {
      ...
      e.id !== '' && e.password !== '' && e.email !== '' && e.name !== ''
        ? (this.btnDisabled = false)
      : (this.btnDisabled = true);
    },
    deep: true,
  },
  'userData.id': _.debounce(function(e) {
    this.validUserId(e);
  }, 750),
  'userData.password': _.debounce(function(e) {
    this.validatePw(e);
  }, 750),
  (...)
},

5.3 새로고침 시 state가 사라지는 문제(webStorage 사용)

  • Vue는 SPA이므로 새로고침 했을 때, state에 jwt(token), user 정보 등의 데이터가 지워져 여러 오류를 발생시켰습니다.
  • 이를 해결하기 위해서 브라우저 저장소(쿠키)를 이용 하여 문제를 해결했습니다.
  • 하지만, 쿠키는 4kb밖에 되지 않고 서버에 계속해서 쿠키를 보내기 때문에 제외하고 webStorage를 사용하기로 했습니다.
  • localStorage는 user와 token 정보를 저장합니다. 재접속 시 localStorage에 user 관련 정보가 있다면, 라우터 가드에서 main 페이지로 이동시킵니다. 로그인된 상태로 이용하게 하기 위함입니다. 📌 코드 확인
  • localStorage는 userInfo와 token을 저장하고 인코딩 합니다. 또한 sessionStorage는 새롭게 api를 연동해야 하는 휘발성이 있는 객체들을 저장합니다. 📌 코드 확인
  • 새로고침 시, state에서 webStorage에 저장된 Data를 가져오도록 했습니다.
state 코드
const state = {
  token: getTokenFromLocalStorage() || '',
  user: getUserFromLocalStorage() || '',
  board: getSessionStorage('board') || {},
  card: getSessionStorage('card') || {},
  bgColor: getSessionStorage('bgColor') || '',
  (...)
};

5.4 API 요청 시 JWT 인증 문제

  • axios interceptor에서, 로그인 후 받아온 JWT token을 header에 담아 백엔드로 보내 인증을 하고 싶었습니다.
interceptor.js
// request
instance.interceptors.request.use(
  config => {
    config.headers.Authorization = store.state.token;
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);
  • SpringSecurity의 JwtTokenProvider class에서 아래와 같이 token을 받고 있었습니다.
  • 이는 token 앞에 "TOKEN" 이라는 문자열을 가진 header를 읽는 코드입니다.
기존 코드
// Request의 Header에서 token 값을 가져옵니다. "TOKEN" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
  return request.getHeader("TOKEN");
}
  • 사진과 같이 크롬 Network 탭의 Request Header를 확인해보면,
  • token을, Authorization 이라는 이름으로 보내고 있었기 때문에 JwtTokenProvider에서 이를 불러오지 못하고 있었습니다.

스크린샷 2021-02-17 오후 2 45 26

  • 따라서 JwtTokenProvider class에서 request.getHeader("Authorization") 코드로 token을 받겠다고 명시해 주어 문제를 해결했습니다.

6. 그 외 트러블 슈팅

6.1 Frontend

Dev 서버가 실행되지 않는 문제(PostCSS)
Uncaught Error: Module build failed (from ./node_modules/postcss-loader/src/index.js) :
Error: PostCSS received undefined instead of CSS string
...
  • PostCSS는 자바스크립트로 CSS 변환을 해주는 도구이며, CSS 작성 경험을 향상 시켜주는 도구.
  • npm을 업데이트했는데 node-sass, sass-loader 두 가지는 npm 버전을 많이 가린다고 알고 있었음.
  • npm 설치가 안되는 에러 를 참고하여 node-module을 지우고 다시 설치로 해결.
JSON.parse 문제
  • JSON.parse는 문자열을 json 형태로 만들어주는데, 이때, null 값이 들어가게 되면 파싱 오류를 뱉음.
  • 따라서 공통 함수를 만들어 파싱 전 if문으로 null 체크를 해줌. 📌 코드 보기
vue-google-login 플러그인 문제
  • 커뮤니티에 링크를 공유 후 다른 접속자들의 환경에서는 접속이 안 된다는 제보를 받음.
  • test시 크롬은 동작하는데 사파리에서 구글 로그인을 사용하니 아무 동작을 하지 않음.
  • 플러그인을 지우고, Google 공식 버전으로 직접 코딩 후 해결. 📌 코드 보기
모달 외부 클릭 시 닫히지 않는 문제
  • 모달 외부 wrapper에 click 이벤트를 걸어, 모달 DOM을 제외한 곳을 click시 닫히도록 함. 📌 코드 보기
  • v-click-outside 모듈 사용.
actions 로직파악 어려운 문제
  • Vuex의 action안에서 다른 action을 부르기 때문에 actions가 굉장히 복잡했다. actions를 사용할 필요가 없는 것은 전부 컴포넌트 단으로 api 함수를 옮겼고, actions에 Mutation를 발생시키는 commit 메서드만 남기도록 수정함.
  • actions.js가 415줄에서 73줄이 되었다. 📌 코드 보기
Vue 파일 내, Script 로드 문제
  • 외부 API( ex) 소셜로그인, 구글맵) 를 사용할 때 index.html에 script 태그를 선언하게 되면 모든 페이지에서 스크립트가 로드된다.
  • 성능 낭비이기 때문에, 하나의 vue 컴포넌트에서만 script를 load 하고 싶었다. 해당 vue 파일에서 script load가 필요한 상황.
  • vue-plugin-load-script 모듈을 다운받아 플러그인 화하여 사용했음. 📌 코드 보기
event 중첩 문제
  • 프로젝트 내 input 수정 로직은 Enter를 누르거나, input에서 포커스를 벗어나면 UPDATE 되는 방식을 사용함.

  • input 태그에 @keyup.enter와 @blur를 사용하는데 keyup 이벤트가 발생하면 blur 이벤트까지 같이 일어나 api가 2번 요청되는 이슈가 있었음.

    기존 코드
    <input ... @keyup.enter="onSubmitTitle" @blur="onSubmitTitle" />
  • 이때, 2개 모두 같은 method를 등록하는 것이 아니라 @keyup.enter 이벤트에는 blur 이벤트가 트리거 되는 이벤트를 따로 등록시켜주어 개선할 수 있었음.

    개선된 코드
    <input ... @keypress.enter="onKeyupEnter" @blur="onSubmitTitle" /> ...
    onKeyupEnter(event) { event.target.blur(); },
한글 문자열 입력 시 함수가 2번 실행되는 문제.
  • keyup은 키보드에서 손을 떼었을 때 실행되며, keypress는 키보드를 눌렀을 때 실행됨.
  • @keyup.enter 대신 @keypress.enter 으로 해결. 📌 코드 보기
MySQL 글자 수 제한으로 인해 input 입력값이 등록되지 않는 문제.
  • input 속성으로 maxlength를 걸어주었음. 📌 commit 보기
display sticky 시, 다른 컴포넌트를 붙였을 때 ui가 틀어지는 문제
  • 상위 태그의 height가 auto 일 경우, height 값에 따라서 sticky가 위치를 조정함.
  • height를 100%로 주어 하위 컴포넌트들이 높이 값을 상속받게 하여 해결. 📌 commit 보기
무한 스크롤 시 한번 멈춰버리면 같은 페이지 내 다른 컴포넌트에서 동작하지 않는 문제
  • 무한 스크롤이 $state.complete 코드를 만나면 다음 탭에서 동작하지 않음.
  • infinite-loading 태그의 :identifier 속성을 선언해서, 탭이 바뀌면 infiniteId를 변화시켜주어 다른 컴포넌트에서도 재동작 하도록 수정. 📌 commit 보기

6.2 Backend

SpringSecurity와 Swagger 문제
  • Security 적용 후 Swagger가 동작하지 않아, WebSecurityConfig class에 ignore 처리로 해결. 📌코드 보기
Java 타입 문제
  • 소수점이 붙은 String 형("12.0")의 숫자가 long 형으로 바로 변환이 안 되어 Double 타입으로 변경 후 long 타입으로 변경.

    long listId = (long)Double.parseDouble(String.valueOf(requestBody.get("listId")));
    1. requestBody.get("listId") : Object 형
    2. String.valueOf : String 형으로 변환
    3. Double.parseDouble : Double 형으로 파싱
    4. (long) : long 형으로 변환
@AuthenticationPrincipal 현재 접속한 userId 가져오기
  • token으로 해당 User의 ID를 자동으로 받을 수 없을까 고민했음.
  • 보안상으로 클라이언트가 직접 userId를 매개변수로 하여 api를 호출하면 다른 user의 정보가 변경될 수 있으므로.
  • JwtTokenProvider에 있는 getUserPk() 메서드를 static화 하여 Contorller에서 끌어다 사용하기로 했음. (Controller에서 @RequestHeader(value = "Authorization")을 통해 token을 얻고 getUserPK() 메서드로 userId를 가져오는 방식) 📌 commit 보기
  • 하지만, SpringSecurity에서 제공하는 @AuthenticationPrincipal을 통해 손쉽게 가져오는 방법을 사용. 📌 commit 보기
Java 배열 요소 삭제 문제
  • 배열의 요소를 삭제 해야 했음. for문을 사용하고 싶지 않고 forEach로 배열을 순회하여 작업하고 싶었음.
  • 하지만 오류 문도 없이 배열의 요소가 삭제되지 않았는데, 컬렉션에서 원소 삭제하기 를 참고하여 removeIf() 메서드 사용으로 문제를 해결. 📌코드 보기
Interceptor에서 request body 사용 문제
  • 프로젝트에서 권한 문제는 큰 문제였으므로, SpringSecurity의 role을 이용하여 권한을 줄 수 있을지 고민.
  • 하지만 role은 각기 다른 도메인에 부여할 수 없는 것. 도메인별 Interceptor를 만들어야겠다고 생각.
  • Interceptor에서 권한을 체크하기 위해 Controller로 들어오는 @ReqeustBody를 끌어와야 했다. 그러려면 HttpServletRequestWrapper 객체를 상속받아 재구현해야 했다. 참고자료 : Interceptor에서 권한 관리하기, RequestBody의 내용을 로그로 남기고 싶다.
  • ReadableRequestWrapper class 생성으로 해결. 📌 코드 보기
MyBatis selectKey 문제
  • 테이블의 PK는 주로 auto_increment로 설정되어 레코드가 추가될 때마다 자동으로 1씩 올라가는 구조.
  • insert 후, 이 PK 값을 사용해야 될 때가 있는데 MyBatis의 selectKey 태그를 이용해 PK값을 가져와서 사용. 📌코드 보기
Gson, JsonParser 라이브러리 호환 문제
  • Gradlew 명령어를 사용하여, Gradle 6.6.1 -> Gradle 4.10.2 로 변경하여 해결.
Test ID 비밀번호 변경 문제
  • Spring Scheduler를 사용하여 Test ID를 만들고, 7-23시 사이에 2시간 간격으로 Test ID의 모든 데이터가 재구성되도록 만들어 놓았음.
  • 하지만 누군가 Test ID의 비밀번호를 바꾸는 바람에 접속할 수 없게 되었음.
  • SpringSecurity에서 제공하는 passwordEncoder의 BCrypt 방식으로 비밀번호를 저장하고 login 시 복호화하여 login 하므로 쿼리문으로 비밀번호를 원상태로 돌리는 것은 불가능함.
  • 미리 만들어둔 ApplicationRunner를 구현한 class가 있었기 때문에 다시 build 후 원상복구 시킨 뒤, 방어 로직을 추가함. 📌 코드 보기

6.3 배포

EC2 access key 노출로 ssh 접속 후, 지속적 끊김 문제
  • EC2 - amazon linux 2로 인스턴스를 만들고 SpringBoot와 연동하는 도중, Github에 secret key를 노출하는 사건이 발생.
  • ssh 접속이 되어도 15분 안으로 끊어지는 이슈. secret key가 노출되었다고 aws로부터 여러 개의 이메일이 와있었음.
  • Git reset HEAD 를 사용하여 commit을 삭제, aws에 알렸는데도 불구하고 ssh 접속이 끊기는 현상은 없어지지 않았음.
  • 계정 삭제 후 다시 처음부터 세팅. 이 사건으로 secret key는 반드시 ec2 내에 옮겨두고 SpringBoot로 부터 build시 ec2 내 따로 생성해둔 environment(properties) 파일을 함께 묶어 build가 되도록 함.
linux 메모리 문제
Mixed Content 문제
  • Mixed Content는 https, http 간 통신 규약이 매칭되지 않을 때 생기는 문제.
  • Frontend는 AWS-CloudFront와 AWS-Certificate Manager를 사용해 SSL이 적용되어 https url을 갖게 되었지만, Backend는 http url 이었으므로, Backend를 https url로 변경시켜주어야 했다.
  • let's encrypt 로 무료 SSL 인증서를 발급받고 nginx의 Reverse Proxy를 사용하여 적용.
  • 참고자료 : nginx와 let's encrypt로 SSL 적용하기(+자동 갱신), nginx를 활용해 AWS EC2에 https 적용하기
Build 자동화 문제(Travis)
  • SpringBoot의 Build 자동화로 Travis를 사용하는데 build 에러가 남.
  • AWS-RDS MySQL datasource가 SpringBoot단의 properties 파일에 있고, github 소스에 올릴 때는 해당 properties가 올라가지 않기 때문이다. (ec2에 따로 지정해둠)
  • local에서는 properties가 존재하기 때문에 문제없이 build 되었지만 Github과 연동된 Travis는 Datasource가 없다며 빌드에러를 낸 것.
  • h2를 적용하기로 했다. 메모리 DB인 h2는 Datasource가 존재하지 않아도 에러를 내지 않기 때문에.
  • gradle에 따로 h2 라이브러리를 로드 받아 build 하여 문제를 해결함.
S3 File upload 시 local 파일 저장 권한 문제
  • SpringBoot에서 S3로 파일을 올릴 때 반드시 local 어딘가에 File을 저장 후 올리고 나서 지우는 작업을 하는 구조.
  • mac 환경에서는 SpringBoot 폴더 내 파일이 생겼다가 지워지는데, 배포 후 linux에는 permission 문제가 생겼다.
  • 따라서 SpringBoot의 properties에 환경별 path를 지정하고, @Value를 통해 디렉토리를 지정함.
  • 그리고 linux 환경에서 해당 디렉토리를 만들어 chmod로 권한을 부여해 해결.

프로젝트 문제점 및 후기

Frontend(vue) 리팩토링