- 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.js
의 buildModule
항목에 @nuxt/typescript-build
를 추가합니다.
동시에 모듈에 대한 설정에 typeCheck
항목을 아래와 같이 추가합니다.
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와 플러그인에 대한 정의도 이 파일에 추가하면 됩니다.
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.js
의 extends
값에 @nuxtjs/eslint-config-typescript
를 추가하면 되는데, 기존에 prettier
관련 플러그인을 쓰고 있었다면 순서를 잘 고려해서 값을 설정해야 합니다.
저는 다음과 같은 순서로 extends
값을 설정하였습니다.
extends: [
...
"plugin:nuxt/recommended",
"plugin:vue/essential",
"@nuxtjs/eslint-config-typescript",
"plugin:prettier/recommended",
"plugin:cypress/recommended",
"prettier/vue",
...
],
2. 타입스크립트 설정
프로젝트 내의 .js
파일과 .ts
파일을 동시에 사용할 수 있도록 compilerOptions
의 allowJs
옵션을 일단 true
로 설정합니다.
이 옵션을 켜지 않으면 .js
파일과 .ts
간의 모듈 import가 제대로 되지 않습니다. 또, 경우에 따라 .js
파일 내에서의 타입 오류를 검사하는 checkJs
옵션을 false
로 설정해야 할 수도 있습니다.
{
...
"compilerOptions": {
"allowJs": true,
"checkJs": false,
}
...
}
위 과정까지 수행했다면 프로젝트 내에 타입스크립트 코드와 자바스크립트 코드가 혼재되어 있더라도 nuxt dev
명령어로 오류 없이 개발 서버를 시작할 수 있을 것입니다.
3. 플러그인 타입 정의
프로젝트의 Vue 인스턴스에 전역으로 등록한 플러그인(전역 객체/컴포넌트/필터 등)에 대한 타입도 설정해야 합니다.
플러그인의 타입을 설정하면 각 컴포넌트 내에서 this
키워드를 통해 플러그인을 사용할 때 IDE의 도움도 받을 수 있습니다.
플러그인을 정의한 곳에 타입 정의를 같이 추가할 수도 있지만, 여기서는 1. 타입스크립트 빌드를 위한 설정
에서 작성한 .d.ts
파일에 한꺼번에 정의해주었습니다.
또, 동일한 타입 정의를 Vue
인터페이스와 Context
인터페이스에 선언 병합(Declaration Merging) 해주어야 합니다.
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
는 기존에는 사실상 아무 타입 힌트도 얻을 수 없었는데, 이 부분이 개선되면서 안정성과 생산성 향상에도 많은 도움이 되었습니다.
.js
파일을 .ts
로 변경
4. 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
은 위에서 구성한 기본적인 설정만으로 타입을 지정했습니다)
typed-vuex
패키지 설치
1) 이 부분 부터는 프로젝트가 Nuxt로 작성되었는지, 혹은 일반 Vue로 작성되었는지에 따라 세팅 방법이 다소 상이합니다. 아래 항목은 Nuxt 기준입니다.
yarn add nuxt-typed-vuex
설치 이후 nuxt.config.js
의 buildModules
에 nuxt-typed-vuex
를 추가합니다.
buildModules: [
...
'nuxt-typed-vuex',
],
accessor
의 타입 정의
2) typed-vuex
패키지는 Vue 루트 인스턴스에 $accessor
라는 객체를 주입합니다. 이후 컴포넌트에서 Vuex 스토어에 접근할 때 타입 체크를 받으려면 반드시 $accessor
객체를 사용해야 합니다.
따라서, $accessor
객체에 대한 타입 정의가 필요합니다.
/store
경로에 index.ts 파일을 생성하고, 스토어에 존재하는 state
, getters
, mutation
, actions
를 추가합니다. 기존에 /store/index.js
가 있다면 파일 이름을 index.ts
로 바꾸고 기존 내용 뒤에 다음 내용을 추가합니다.
import { accessorType } from "store"
export const accessorType = getAccessorType({
/* root state, getters, mutations, actions */
state,
getters,
mutations,
actions,
})
여러 하위 모듈이 있는 구조로 스토어를 사용하고 있다면 그에 맞게 추가가 필요합니다. 추가된 서브모듈 역시 다음과 같이 state
, getters
, mutation
, actions
를 각각 가지도록 import를 해주어야 합니다.
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
파일에 다음 내용을 추가합니다.
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
...
}
}
...
}
/store
디렉토리 내 파일의 타입 정의
3) 실제 스토어의 각 모듈은 다음과 같이 타입스크립트로 옮기면 되는데, 앞서 언급한대로 Vuex action 부분만 typed-vuex
패키지를 활용했습니다.
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.모듈명
처럼 쓸 수 있습니다.