junglast
Published on

Vue2 + Nuxt 기반 레거시 프로젝트에 타입스크립트 적용하기

운영 중이던 Vue 2 기반의 Nuxt.js 프로젝트에 타입스크립트를 도입하게 되었습니다. 작은 규모로 기획 되었던 프로젝트였고, 구성원들의 타입스크립트 언어 자체에 대한 숙련도 문제 등의 이유로 초기 개발시 타입스크립트를 사용하지 않았으나 서비스 볼륨이 늘어남에 따라 프로젝트의 크기도 예전보다 다소 커지게 되어 엄격한 타입 정의가 필요함을 느끼게 되었습니다. 특히, 타입스크립트가 가지는 기본적인 이점 이외에도 프로젝트 특성상

  • 동일한 데이터 모델이 여러 컴포넌트에서 빈번하게 재사용 되는 점
  • 서비스 내의 일부 기능에서 다양한 타입의 데이터에 대해 반복적인 CRUD를 제공해야 한다는 점

등의 문제가 있었습니다.

또한 코드 레벨에서는 일부 데이터 모델을 타입스크립트의 열거형과 유사한 객체를 만들어 관리하고 있었는데 이는 타입 정의와는 전혀 관련이 없는 코드일 뿐만 아니라 동일한 기능을 하는 코드가 이곳저곳에 흩어져 있어 관리가 쉽지 않았습니다.

const DELIVERY_CODE = {
  FULFILLED: "FULFILLED",
  DELIVERING: "DELIVERING",
  DELIVERED: "DELIVERED",
}
...

if (deliveryStatus === DELIVERY_CODE.DELIVERED) return
...

때문에 추가적인 개발이 진행되기 전에 타입스크립트를 도입하기로 결정했습니다.

하지만 또 한 가지 고려해야 할 점은, 실제로 서비스가 되고 있는 프로젝트였기에(프로젝트 내에 약 600개 가량의 Vue 컴포넌트를 가지고 있었고, 기존 컴포넌트에 대한 수정도 빈번히 있던 상황) 기존 서비스의 운영이나 유지보수에 영향을 끼치지 않는 범위 내에서 점진적으로 마이그레이션을 해야 한다는 것이었습니다.

마이그레이션 범위

마이그레이션 대상이 될 프로젝트는 Nuxt.js로 만들어진 Vue 2 기반의 프로젝트입니다. 많은 분들도 동일하게 느끼시겠지만 Vue는 3버전에 이르러서야 타입스크립트를 완전하게 지원한다는 느낌이 듭니다. 가령 Vue 2에서는 vuex의 타입을 편리하게 정의하기 위해 별도의 라이브러리를 설치하는 등 개인적으로는 매끄럽지 않은 부분이 꽤 있었습니다.

어쨌든 타입스크립트로 전환할 기존 코드 베이스를 다음과 같이 나누어 생각했고, 한 번 진행해본 결과 이 순서대로 마이그레이션을 진행하는 것이 기존 프로젝트에 영향을 미치지 않을 수 있는 최선의 방법이었습니다.

  • 빌드시 Vue 컴포넌트를 타입스크립트로 컴파일하기 위한 설정 및 Linting 설정
  • Vue 플러그인에 대한 타입 정의
  • 일반 자바스크립트 코드(*.js): API 레이어, 유틸 함수 등
  • Vue 컴포넌트(*.vue)
  • vuex 관련 코드
  • Nuxt를 사용하는 경우 nuxt.config.js, 미들웨어(/middlewares)에 대한 타입 정의

후술할 내용은 Nuxt로 작성된 Vue 프로젝트의 코드 베이스를 타입스크립트로 이관했던 절차 및 방법을 담고 있습니다.

1. 타입스크립트 빌드를 위한 설정

Nuxt에서 타입스크립트 사용을 위해 다음과 같은 패키지를 설치했습니다.

yarn add --dev @nuxt/typescript-build @nuxt/types

@nuxt/types는 Nuxt에서 사용하는 여러 객체들의 타입 정의를 담고 있는 패키지이고, @nuxt/typescript-build는 이름에서 알 수 있듯이 타입스크립트가 포함된 Vue 컴포넌트를 빌드하게 해주는 패키지입니다.

이후 nuxt.config.jsbuildModule 항목에 @nuxt/typescript-build를 추가합니다. 동시에 모듈에 대한 설정에 typeCheck 항목을 아래와 같이 추가합니다.

nuxt.config.js
buildModules: [
  ...[
    "@nuxt/typescript-build",
    {
      typeCheck: {
        typescript: {
          extensions: {
            vue: true,
          },
        },
      },
    },
  ],
]

@nuxt/typescript-build는 내부적으로 fork-ts-checker-webpack-plugin 이라는 웹팩 플러그인을 사용하는데, 이 플러그인에서 *.vue 파일에 대한 옵션을 true로 설정하지 않는다면 Vue 컴포넌트가 정상적으로 빌드되지 않고 아래와 같이 Maximum call stack Exception이 발생했습니다.

그 다음, 프로젝트 루트에 Vue 인스턴스의 타입을 정의하기 위한 d.ts 파일을 아래와 같이 만들어 줍니다. 이후 vuex와 플러그인에 대한 정의도 이 파일에 추가하면 됩니다.

index.d.ts
declare module "*.vue" {
  import Vue from "vue"
  export default Vue
}

ESLint를 사용하는 프로젝트인 경우 기존에 설치 되어 있던 @nuxtjs/eslint-config를 삭제하고 @nuxtjs/eslint-config-typescript를 설치해 줍니다.

yarn add --dev @nuxtjs/eslint-config-typescript

그 다음 .eslintrc.jsextends 값에 @nuxtjs/eslint-config-typescript를 추가하면 되는데, 기존에 prettier 관련 플러그인을 쓰고 있었다면 순서를 잘 고려해서 값을 설정해야 합니다. 저는 다음과 같은 순서로 extends 값을 설정하였습니다.

.eslintrc.js
extends: [
    ...
    "plugin:nuxt/recommended",
    "plugin:vue/essential",
    "@nuxtjs/eslint-config-typescript",
    "plugin:prettier/recommended",
    "plugin:cypress/recommended",
    "prettier/vue",
    ...
],

2. 타입스크립트 설정

프로젝트 내의 .js 파일과 .ts 파일을 동시에 사용할 수 있도록 compilerOptionsallowJs 옵션을 일단 true로 설정합니다. 이 옵션을 켜지 않으면 .js 파일과 .ts 간의 모듈 import가 제대로 되지 않습니다. 또, 경우에 따라 .js 파일 내에서의 타입 오류를 검사하는 checkJs 옵션을 false로 설정해야 할 수도 있습니다.

tsconfig.json
{
  ...
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
  }
  ...
}

위 과정까지 수행했다면 프로젝트 내에 타입스크립트 코드와 자바스크립트 코드가 혼재되어 있더라도 nuxt dev 명령어로 오류 없이 개발 서버를 시작할 수 있을 것입니다.

3. 플러그인 타입 정의

프로젝트의 Vue 인스턴스에 전역으로 등록한 플러그인(전역 객체/컴포넌트/필터 등)에 대한 타입도 설정해야 합니다. 플러그인의 타입을 설정하면 각 컴포넌트 내에서 this 키워드를 통해 플러그인을 사용할 때 IDE의 도움도 받을 수 있습니다.

플러그인을 정의한 곳에 타입 정의를 같이 추가할 수도 있지만, 여기서는 1. 타입스크립트 빌드를 위한 설정에서 작성한 .d.ts 파일에 한꺼번에 정의해주었습니다.

또, 동일한 타입 정의를 Vue 인터페이스와 Context 인터페이스에 선언 병합(Declaration Merging) 해주어야 합니다.

index.d.ts
declare module "vue/types/vue" {
  interface Vue {
    $MSG: string[]
    $alert: ElMessageBoxShortcutMethod
  }
}

declare module "@nuxt/types" {
  interface Context {
    $MSG: string[]
    $alert: ElMessageBoxShortcutMethod
  }
}

정의를 완료하면 다음과 같이 Vue 컴포넌트(*.vue) 내에서 this 키워드로 해당 플러그인을 사용했을 때 타입 체크를 받을 수 있습니다.

다음으로, Nuxt가 제공하는 전역 헬퍼 객체인 $nuxt의 타입도 정의해 줍니다.

import { NuxtApp } from "@nuxt/types/app"

...

declare global {
  const $nuxt: NuxtApp
}

이제 .vue이 아닌 .ts, .js 파일에서도 $nuxt 객체를 type safe하게 사용할 수 있습니다.

특히 $nuxt는 기존에는 사실상 아무 타입 힌트도 얻을 수 없었는데, 이 부분이 개선되면서 안정성과 생산성 향상에도 많은 도움이 되었습니다.


4. .js 파일을 .ts로 변경

Vue 컴포넌트가 아닌 일반 자바스크립트 코드를 타입스크립트로 컨버팅합니다.

5. Vue 컴포넌트 타입 정의

.vue 파일에 타입스크립트를 적용할 차례입니다. 먼저, Vue 2 버전 기준 Vue 컴포넌트를 작성하는 방법인 Options API, Class API, Composition API 중 제가 진행하던 프로젝트는 가장 일반적인 Options API 작성되었으므로 이에 맞춰 내용을 진행합니다.

타입스크립트 문법을 사용할 수 있도록 <script> 부분을 <script lang="ts">로 바꾸어줍니다.

props의 타이핑을 위해서는 Vue.extend로 Vue 인스턴스를 export해줍니다.

그리고 컴포넌트의 지역 상태(data)와 props에 타입을 지정할 수 있습니다. 객체인 props 값의 경우에는 아래와 같이 PropType 타입을 이용하여 타이핑합니다.

import Vue from 'vue'
import type { PropType } from 'vue'

interface Product {
  productId: string
  productName: string
}

export default Vue.extend({
  props: {
    currentProduct: {
      type: Object as PropType<Product>,
      required: true
    },
  },

  ...
})

또한, created, mounted 등의 라이프사이클 메소드와 methods 내에 정의된 인스턴스 메소드들에서도 일반 타입스크립트와 동일하게 코드를 작성할 수 있습니다. 다른 부분과 마찬가지로 lang="ts"로 작성된 컴포넌트와 그렇지 않은 컴포넌트가 프로젝트 내에 혼재해 있어도 문제 없이 개발이 가능했습니다.


6. Vuex 타입 정의

타입스크립트로 전환 중 가장 어려웠던 부분이 다름 아닌 Vuex입니다. 명확하게 정해진 타이핑 방법이 없을 뿐만 아니라 Vue 컴포넌트 내에서 스토어를 구독할 때 타입 체킹을 가능하게 하기 위해 추가적인 작업이 필요하기도 했기에 어려움이 있었습니다.

특히, 타입스크립트를 도입하는 과정에서 추가적인 패키지의 사용을 지양하려고 했지만 일부 예외적인 경우에 한해서typed-vuex라는 패키지를 사용해 타입을 설정했습니다.

이렇게 추가적인 패키지를 설치한 이유는 다음과 같습니다.

  • 각 Vue 컴포넌트에서 Vuex store를 참조할 때 명확하게 타이핑이 가능해야 한다.
  • 특히 Vuex action의 경우 외부 패키지를 사용하지 않은 일반적인 방법으로는 타이핑 과정에서 코드가 너무 장황해진다. (하지만 action을 제외한 state, getter, mutation은 위에서 구성한 기본적인 설정만으로 타입을 지정했습니다)

1) typed-vuex 패키지 설치

이 부분 부터는 프로젝트가 Nuxt로 작성되었는지, 혹은 일반 Vue로 작성되었는지에 따라 세팅 방법이 다소 상이합니다. 아래 항목은 Nuxt 기준입니다.

yarn add nuxt-typed-vuex

설치 이후 nuxt.config.jsbuildModulesnuxt-typed-vuex를 추가합니다.

nuxt.config.js
buildModules: [
  ...
  'nuxt-typed-vuex',
],

2) accessor의 타입 정의

typed-vuex 패키지는 Vue 루트 인스턴스에 $accessor 라는 객체를 주입합니다. 이후 컴포넌트에서 Vuex 스토어에 접근할 때 타입 체크를 받으려면 반드시 $accessor 객체를 사용해야 합니다. 따라서, $accessor 객체에 대한 타입 정의가 필요합니다.

/store 경로에 index.ts 파일을 생성하고, 스토어에 존재하는 state, getters, mutation, actions를 추가합니다. 기존에 /store/index.js가 있다면 파일 이름을 index.ts로 바꾸고 기존 내용 뒤에 다음 내용을 추가합니다.

/store/index.ts
import { accessorType } from "store"

export const accessorType = getAccessorType({
  /* root state, getters, mutations, actions */
  state,
  getters,
  mutations,
  actions,
})

여러 하위 모듈이 있는 구조로 스토어를 사용하고 있다면 그에 맞게 추가가 필요합니다. 추가된 서브모듈 역시 다음과 같이 state, getters, mutation, actions를 각각 가지도록 import를 해주어야 합니다.

/store/index.ts
export const accessorType = getAccessorType({
  /* root state, getters, mutations, actions */
  modules: {
    product: {
      state,
      getters,
      mutations,
      actions,
    },

    auth: {
      state,
      getters,
      mutations,
      actions,
    }
  }
})

유의할만한 점은 getAccessorType에 현재 사용하고 있는 스토어의 구조를 100% 동일하게 옮겨 놓지 않아도 됩니다. 실제로 제 프로젝트에서도 먼저 타입스크립트로 이관이 끝난 파일이나 모듈만 추가하는 식으로 진행했었고, 이 곳에 추가된 파일만 타입 체크를 받을 수 있다는 점을 제외하면 기존 Vuex 코드나 Vue 컴포넌트에 있는 헬퍼 코드(mapState), $store 객체 등을 수정하지 않고도 동시에 실행이 가능합니다.

그다음 $accessor 객체의 타입을 지정하기 위해 1.번 과정에서 작성한 index.d.ts 파일에 다음 내용을 추가합니다.

index.d.ts
import { getAccessorType } from 'typed-vuex'

...

declare module "vue/types/vue" {
  interface Vue {
    ...

    $accessor: typeof accessorType
  }

  declare module "@nuxt/types" {
    interface NuxtAppOptions {
      $accessor: typeof accessorType
    }

    ...

    interface Context {
      $accessor: typeof accessorType

      ...
    }
  }

  ...
}

3) /store 디렉토리 내 파일의 타입 정의

실제 스토어의 각 모듈은 다음과 같이 타입스크립트로 옮기면 되는데, 앞서 언급한대로 Vuex action 부분만 typed-vuex 패키지를 활용했습니다.

auth.ts
import { actionTree } from "typed-vuex"

/* state */
export const state = () => ({
  userName: string,
  age: number,
})

export type ModuleState = ReturnType<typeof state>


/* getters */
export const getters = {
  newName: (state: ModuleState) => "new" + state.userName,
}

/* mutations */
export const mutations = {
  CHANGE_NAME: (state: ModuleState, newName: string) => {
    state.userName = newName
  },
}

/* actions */
export const actions = actionTree(
  { state, getters, mutations },
  {
    async fetchProfile(): Promise<void> {
      await api.getProfile()
      ...
      this.app.$accessor.auth.CHANGE_NAME("...")
    },
  }
)

typed-vuex 패키지의 actionTree를 이용하는데, 액션 내부에서 다른 모듈의 스토어에 접근하고 싶다면 this.app.$accessor.모듈명 처럼 쓸 수 있습니다.