Vue & Vuex convention

Photo by Dwain Norsa on Unsplash

Vue & Vuex convention

이 포스트는 2020년 9월 30일에 발행되었습니다.

Vue 프로젝트를 진행하면서 만든 Convention들을 정리해본다. Vue2와 관련된 설정이니 (나중에 이 글을 보게된) Vue3의 사용자들은 감안해주시길.

Project Structure

vue-cli를 쓰지 않을 이유가 없다. 심지어 create-react-app보다 더 완성도 있다고 생각한다. 물론, React 진영은 워낙 스택이 중구난방이라 모든 옵션을 다 넣어준다는 것도 이상하긴 하다만... 아무튼 이 상황에서 우리가 신경써야하는 structure는 다음과 같다. 손쉬운 이해를 위하여 User가 Post에 Comment를 작성하는 간단한 프로젝트를 상상해보자.

public/
src/
  - components/
  - layouts/
  - [models/]
    - User.js
    - Post.js
    - Comment.js
  - pages/
    - PostCollectionPage/
      - components/
        - CommentItem.vue
        - PostItem.vue
      - index.vue
      - PostListSection.vue
      - CommentSection.vue
  - router/
  - store/
    - comment/
      - actions.js
      - getters.js
      - index.js
      - mutations.js
    - post/
    - user/
  1. public/: index.html, favicon, open graph image등을 넣는다.
  2. src/components/: 두 개 이상의 페이지에서 공통적으로 쓰이는 컴포넌트를 넣는다. 혹시 하나의 페이지에서만 쓰이는 컴포넌트라도 충분히 범용적인 경우 이곳에 위치한다.
    • e.g.) LoadingButton, InlineTextEditor, ...
  3. src/layouts/: Navbar, Sidebar, Footer등을 넣는다.
  4. src/models/: 만들지 않아도 된다. vuex-orm을 사용한다면 이 곳에 모델을 넣는다.
  5. src/pages/: vue-router에 걸리는 큼지막큼지막한 뷰 컴포넌트들을 넣는다. 이 구조는 next.js에서 많이 참고했다.
    • ./PostCollectionPage/: 위에서 언급된 "큼지막큼지막"한 뷰 컴포넌트(index.vue)와 그와 관련된 컴포넌트가 담긴다.
      • ./components/: PostCollectionPage에서만 사용되는 하위 컴포넌트를 담는다.
      • ./[Some]Section.vue: PostCollectionPage에서 크게 나눠지는 구역을 구분하기 위해 사용한다. 내부적으로 ./components/에 담긴 하위 컴포넌트들을 활용한다.
      • ./index.vue: ./SomeSection.vue 와 같은 섹션 컴포넌트들을 활용한다.
  6. src/router/: 라우팅 정보를 담는다.
  7. src/store/: vuex 스토어 정보를 담는다.
    • ./comment: comment module에 관련된 vuex 코드를 담는다.

Vue Convention

  1. 일단, Vue Component의 object key 순서는 아무리 지키려고해도 제대로 지켜지지 않는다. 내가 지키려고 노력(만)하는 key 순서의 느낌은 다음과 같다.
    • name, components, props는 최대한 상단에.
    • mounted, updated와 같은 life-cycle hook은 최대한 하단에.
    • watch는 life-cycle hook 바로 위에.
    • (나머지) data, computed, methods 등은 watch 또는 life-cycle hook 바로 위에.
  2. React의 경우 컴포넌트에서 노출하는 이벤트의 키는 onSomething (e.g. onClick)으로 통일하고 있으며, 해당 이벤트를 처리하는 핸들러는 handleSomething (e.g. handleClick)으로 통일하고 있다. 이를 적극적으로 참고하여 나는 Vue에서 이벤트의 키는 click으로 통일하고, 핸들러는 handleClick과 같이 처리하는데, Vue의 경우에는 템플릿에서 컴포넌트로 이벤트 핸들러를 등록할 때 @click="handleClick"과 같이 @문자를 사용하여 처리하기 때문에 on을 생략한다.
  3. 단순하지 않은 이벤트 같은 경우에는 어떡할까?
    • click과 같은 한 단어로 이루어지지 않은 popState, beforeLoad와 같은 이벤트들은 어떻게 처리하는 것이 좋을까? 컴포넌트 내부에 복수의 "click"able 컴포넌트가 존재하는 경우에는 어떻게 처리하는 것이 좋을까?
    • 나는 switch-case에서 fall-through를 이용해서 깔끔하게 코드가 나오는 경우가 아니고선 handleClickSubmit, handleClickCancel과 같이 서로 다른 함수로 만드는 쪽을 선호하며, handle[EventName][ComponentDescription]과 같이 처리하는 편이다.
  4. Vue 컴포넌트는 template, script, style 순서로 작성한다.
    • style태그의 lang은 scss또는 less를 선호하며 대부분의 경우 scoped를 넣는다.
    • 다만 이 부분은 협업하는 웹 퍼블리셔가 style에 관한 선호가 있다면 그것을 우선시한다.
  5. self-closing tag로 처리할 수 있는 컴포넌트들은 self-closing으로 처리하는 편이다. 즉, <MyLoading></MyLoading> 보다는 <MyLoading />을 선호한다.
  6. Vue 컴포넌트 파일은 Upper Camel Case (= Pascal Case)로 처리한다.
    • 만약에 폴더로 만들어야 하는 경우, some/path/to/MyComponent/index.vue와 같이 처리한다.

Vuex Convention (without vuex-orm)

  1. http request를 보내는 service들은 큰 이유가 없으면 굳이 별도의 파일을 만들지 않고 vuex action에 포함한다.
    • 다만, axios의 baseURL, header(jwt) 등의 설정을 위해 공통적인 axios instance를 공유해야할 필요성이 있으므로, src/http.js 파일 정도를 작성하여 해당 인스턴스를 import하여 작성한다. 이 파일의 이름을 http로 한 이유는 import http from '@/http'; 로 import하여 http.get(...)과 같이 쓰면 깔끔해보인다는 개인적인 취향.
    • 큰 이유라 함은 아무래도 service 부분을 git submodule로 별도로 빼서 다른 프로젝트에서도 해당 모듈을 재사용하는 경우인데, 아직 그 경우를 제외하고는 괜한 짓거리라는 생각이 든다. 혹시 다른 의견이 있으시면 댓글 부탁드립니다.
  2. trivial action들은 [HttpMethod][Modulename]을 따른다.
    • e.g. getComments, postComment
  3. custom action들은 [CustomActionName][ModuleName]을 따른다.
    • e.g. moveComment, copyComment
  4. action은 해당 action이 읽어온, 생성된, 삭제된, 수정한 Resource를 Resolve하는 Promise를 반환하는 함수로 짜거나, 그것을 반환하는 async 함수로 작성한다.
  5. action이 throw된 경우, 해당 처리를 action에서 처리하는 것을 원칙으로 하고, 필요한 경우 try-catch로 감싸지 않거나, catch에서 다시 한 번 더 throw한다.
    • 대부분의 경우 catch에 빠졌을 때는 notification을 띄우는 수준으로 처리하면 크게 문제가 없으며, 만약에 web, electron 두 가지 빌드를 사용하고 있는 경우 noti를 한 번 감싸서 environment에 맞게 notification을 처리하면 깔끔하다.
  6. trivial mutation들은 추가, 수정, 삭제이며 이들은 각각,
    • [add|append|prepend|insertAt][ModuleName], set[ModuleName], remove[ModuleName] 으로 처리한다.
    • 특히, action의 name space와 충돌하지 않도록 주의하는 것이 좋은데, dispatch와 commit을 헷갈리거나, mapMutations나 mapActions를 한꺼번에 사용하는 경우 위험할 수 있기 때문이다.
    • 비슷한 이유로, 나는 mapMutations, mapActions를 사용하지 않고 this.$store.dispatch(actionName, actionParams)와 같이 명시적으로 dispatch하는 것을 선호한다.

Vuex Convention (with vuex-orm)

  1. @vuex-orm/core@vuex-orm/axios를 사용하면 장단점이 있다. 컨벤션을 설명하기에 앞서 가볍게 언급해보자면,
    • 단점
      1. 아무래도 vuex에 더해서 추가적인 러닝커브가 늘어난다는 점.
      2. 많이 사용되는 라이브러리가 아니라서 best practice를 찾기 힘들다는 것.
      3. getter에서 rootState를 접근할 수 없다. 이는 직접 store 객체를 import하여 해결할 수 있는 문제이지만, 썩 맘에 들지 않는 구조다.
        • 물론, 변론해주자면 vuex-orm의 철학은 getter 대신에 vuex-orm의 join을 통해서 해당 데이터를 retrieve하는 것을 권장한다는 것.
        • 하지만, vuex store의 모든 데이터가 vuex-orm의 모델일 수는 없다. 따라서 나는 이를 단점으로 본다.
    • 그럼에도 불구하고 사용하게 되는 장점은 다음과 같다.
      1. vuex에서도 "비교적" 불변적인 코드를 쉽게 작성할 수 있다. (즉, vuex-orm과 관련된 코드를 가변적으로 짜는 "실수"도 가능하다.)
      2. deeply nested된 데이터를 쉽게 다룰 수 있다. 단순히 트리 구조가 아닌 그래프 구조로 가게 되는 경우, 즉, cyclic한 데이터 구조를 다루는 데 매우 유용하다.
      3. back-end의 join overhead가 매우 줄어든다. thin-server를 만드는 데 매우 적합하다.
        • 다만, 프론트엔드 개발자가 database에 대한 이해, 도메인에 대한 이해가 부족하면 사용하기 힘들다. thick-client의 단점이기도 하지만, SPA를 개발하면서 thin-client를 찾는 것도 사실 말이 안된다. ㅎ,ㅎ
      4. @vuex-orm/axios를 사용하면 대부분의 trivial action, mutation들을 위한 코드들이 많이 줄어든다. 즉, (delete action을 제외하고) vuex-orm/axios으로 정의한 api를 호출하는 것 만으로 관련된 모델을 vuex store에 반영할 수 있다.
        • delete action이 제외된 이유는 삭제가 까다로운 까닭이다. SQL로 치면 ON DELETE CASCADE와 같은 것들을 사용자가 직접 vuex-orm 모델들에 반영해줘야한다.
      5. 웬만한 ORM들에서 지원하는 기능을 거의 다 지원한다.
        • 1:1, 1:n, n:m은 물론이고 폴리모픽 또한 지원한다.
        • 모든 레벨에 ORDER BY가 섞인 nested query를 작성하는 것도 가능하다.
  2. trivial action들은 vuex-orm에서 제시하는 두 가지 방법이 있다. vuex-orm의 Model을 상속하는 내 모델 클래스의 static method로 넣을 것이냐, 또는 static apiConfig = { actions: {...} };로 넣을 것이냐. 나는 후자를 선호하는데 이유는 다음과 같다.
    1. vuex-orm에서 정의하는 Model이 가진 method name이 이미 action과 겹치는 것이 많다.
      • e.g.) create, update, delete, ...
    2. Model.customAction()보다 Model.api().customAction()이 타이핑은 조금 더 귀찮으나, vuex-orm/core가 아닌 vuex-core/axios로부터 파생된 코드라는 힌트 겸 개념적 분리가 깔끔하다.
  3. vuex-orm/axios에서 정의하는 action의 이름은 rails convention을 따른다.
    • GET /posts/${post_id}: show(postId)
    • GET /posts: index()
    • POST /posts: create({/* post params */})
    • UPDATE|PATCH /posts/${post_id}: update(postId, {/* post params */})
      • post params에 postId를 포함시키는 것도 추천한다. 다만 나는 postId를 분리하는 것을 선호한다.
    • DELETE /posts/${post_id}: destroy(postId)
  4. vuex-orm/axios에서 정의하는 getter의 이름은 action을 그대로 따른다. 즉, show, index를 사용한다.
    • vuex-orm의 getter는 action과 naming space collision이 일어날 수 없는 구조기 때문에 동일하게 가져가는 것이 낫다고 생각한다.
    • 다만, query는 절대 사용하지 말 것! vuex-orm 내부적으로 사용하는 getter의 이름과 겹치기 때문에 query가 query를 부르는 infinite recursive call로 이어져 call stack이 터진다.
    • index에 filter, sorting 등을 위한 파라미터를 넣고, 해당 파라미터들이 isNil 인 경우 통상적인 index로 사용하고, 파라미터들이 truthy 값인 경우 query처럼 사용했다. just like trivial back-end API.

General Guideline

  1. 당신은 ui-related flag(= state)들을 최대한 vue에서 들고 있을 것이냐, 아니면 vuex로 다 빼버릴 것이냐 둘 중에 골라야한다. 이 중간은 없다. 애매하게 나눠도 ui flag, ui state들은 결국에 서로가 서로를 참조할 수 밖에 없다.
    • redux에서 방구깨나 뀌는(?) 라이브러리인 redux-form에서도 README Attention에서 자신들의 라이브러리를 쓰는 것을 다시 한번 고려해보라는 문구(you should not put your form state in Redux)가 있을 정도로 폼 값들을 굳이 store로 끌고 오는 것은 추천하지 않는다. 나는 여기서 더 나아가 폼 값 뿐만이 아니라 대부분의 ui에만 관련된 변수에도 통용되는 범용적인 철학이라고 생각한다.
  2. template에서 kebab-case의 사용은 최대한 피한다.
    • 그냥 다 lowerCamelCase로 도배하는 것이 코드 에디터에서 cmd + d 등의 처리를 하기에도 훨씬 편리하다. HTML에서만 쓰이는 convention을 따를 필요는 없다. 어차피 front-end는 부분에 따라 다른 naming convention을 사용하는 분야다.
  3. vuex-orm에서 정의한 Model과 동일한 이름을 갖는 Vue Component의 작성을 피한다.
    • 예컨대, <Comment /> 보다는 <CommentItem />을 쓰는 것이 낫다.
  4. Vue2와 TypeScript를 쓰는 것은 생각보다 별로다. Vuex에서 애초에 문자열로 getter, mutation, action을 등록하고, 그것들 다시 문자열로 불러오는 과정을 깔끔하게 TypeScript로 해결한 라이브러리는 아직 만나보질 못했다. 알아서 잘 정의하고, 가져온 다음에도 다시 내가 수동으로 타입을 붙여야하는 원시적인 수준이다. 만약에 그 과정에서 mutation를 가리키는 문자열 키에 오타가 났다면? 문자열 키로 가져온 getter의 타입을 잘못 붙였다면? getter의 타입에 변경이 발생했는데 해당 getter를 활용하는 곳이 아주 많다면? TS의 type inference를 활용하지 못하는 수준에서는 사실 TypeScript Support라는 이름표를 붙이고 있기 부족하다.
    • 만약에 Vue2에서 이 문제를 해결한 라이브러리를 아신다면 꼭 댓글을 남겨주시길...
  5. 한 컴포넌트가 지나치게 커지는 것을 경계하자. style을 제외하고 template + script가 300~400줄이 넘어가면 리팩토링을 고민해야하는 시점이라고 생각한다. 500줄이 넘었다면 반드시 컴포넌트를 잘게 쪼개자. 다른 곳에서 사용되지 않고, 한 곳에서만 사용될 컴포넌트여도 개념적으로 분리해낼 수 있다면 분리해내는 것이 맞다.

Vue의 인기가 점점 줄어들고 있다고 느낀다. 특히 React Native의 등장 이후로 그런 경향이 더 심해졌다. NativeScript, Ionic도 Vue에 대한 지원이 미흡하다. Weex의 중국어 문서는 국문/영문에 익숙한 나에게 엄청난 장벽이다. 이런 상황에서 우리는 Vue의 사용을 멈춰야할까? 어차피 모바일 앱에 대한 view 컴포넌트는 새롭게 짤 수 밖에 없다. 이는, 내가 애정하는 antd에서도 모바일은 별도의 레포지토리가 있을 정도로 당연한 일이다. 결국에, 우리는 React를 할 수 있는 스킬을 가지고 새로운 React Native 앱을 만든다. 이런 상황에서 대단한 ui interaction이 필요한 것이 아니고서는 굳이 Native로 가까이 갔을 때 얻는 장점보다는 단점이 더 많다고 생각한다. 웹 개발의 기술로 하이브리드 웹앱 개발하는 것이 더 현명하다고 느낀다.

나는 Vue로 진행하던 프로젝트의 기능을 전부 Mobile로 옮겨본 경험이 없다. angular.js (Angular 1)로 작성한 데스크탑 SPA를 아이오닉으로 포팅해보거나, Angular(Angular 2) + Ionic 2로 앱, 그리고 전혀 다른 기능을 가진 어드민 데스크탑 SPA를 Vue2로 작성해본 것이 전부다. 이런 경험을 가진 내가 Vue로 작성한 프로젝트를 Mobile로 옮기게 된다면 어떨까? Weex 또는 Ionic (with Angular) 둘 중에 선택하게 될 것 같다. 나는 웹뷰에서 돌아가는 앱으로도 충분한 UX를 제공할 수 있다고 생각한다. 만약의 두 선택지가 정말 마음에 안든다면 Cordova(밑바닥...?)에서부터 시작하는 것도 재미있는 경험이 될 것 같다. 물론, 생산성은 처참하겠지만.

앞으로 더 발전할 Vue3의 미래가 궁금하다. 작은 프로젝트, 소수의 인원이 진행하는 프로젝트일 수록 React보다는 Vue가 적합하다고 느낀다. TypeScript Support를 포함한 Vue3의 생태계가 어느 정도 GA Release까지 올라오게 되면 개인적인 토이 프로젝트에 써볼 생각이다. 여담으로, React를 이용해 간단한 토이 프로젝트를 진행 해봤는데, 이 후기도 열심히 작성 중이다. 즐거운 한가위 보내시길

끝.