타입스크립트에서 Vue 컴포넌트를 개발하는 방법


여기까지 오기까지

내가 처음 SPA 프로젝트를 진행했던 때는 2012년이다. 그때 제일 핫했던 백본(Backbone)을 기반으로 구축한 꽤 큰 규모의 프로젝트였다. 그때는 지금처럼 프론트엔드 개발환경이 좋지 않았고 SPA라는 개념 자체가 겨우 슬슬 알려지기 시작한 시기였다. MVC에서 벗어나질 못한 프레임웍들이 주를 이루었던 프론트엔드 진영이었다. 앵귤러JS(AngularJS)가 MVC가 맞느니 아니니 박터지는 가운데 리액트(React)라는 아종이 등장했다. 리액티브와 함수형 패러다임의 영향을 받은 이 프레임웍은 빠르게 성장했다. 그걸 이끈 개발자들이 나보다 나이가 어려서 배가 아프다. 그리고 엄청난 생략이지만 Vue가 나왔다. Vue는 여러 가지 프레임웍에서 영향을 받았다. 코드의 모습은 리액트와 비슷했지만 스테이트 측면에서 리액티브를 적극적으로 활용했다. Vue는 사용하기 편하다는 이유로 리액트에 버금가는 인기의 프레임웍으로 성장하게 된다. 리액트와 리덕스는 웹개발자들이 그 근간인 불변성(Immutable)을 이해했다 쳐도 막상 실무에 적용할때 걸림돌이 많았을 것이다. Vue의 사용율은 날이 갈수록 증가하고 있다.

프론트엔드 진영의 갈증은 자바스크립트의 언어적인 단점을 극복하기에 이른다. 커피스크립트, 플로우(Flow), 바벨(Babel), 그리고 MS의 타입스크립트가 바로 그러한 노력이다. 물론 바벨은 표준 스펙의 호환성을 높이는 도구이기 때문에 언어 자체를 독자적으로 개선한 경우에 해당하지 않아 성격이 다르다고 볼 수 있다. 아무튼 초창기 이 분야를 이끌던 커피스크립트는 몇 가지 걸출한 컨셉을 ES6에 물려주는 위대한 성과를 남기고 화려하게 은퇴하는 중이다. 플로우와 타입스크립트는 자바스크립트(ECMAScript) 슈퍼셋으로 기존의 문법 위에 엄격한 타입을 적용하려 했다. 런타임만 존재하는 인터프리터 언어에 컴파일 타임에 해당하는 검증 단계를 도입했다. 플로우와 타입스크립트의 보이지 않는 치열한 싸움에서는 결국 타입스크립트가 승리했고 트랜스파일 언어로는 타입스크립트가 독보적인 존재가 되었다.

엄격한 타입에 대해서는 개인적으로 긍정적인 견해를 갖고 있지 않다. 하지만 다형성을 위한 프로토콜 혹은 인터페이스 수준의 명시적인 모듈 시그니처는 분명 도움 된다고 생각한다. 호불호가 있지만 요즘 자바스크립트라는 괴이했던 언어는 점점 준수한 언어로 발전하고 있다. 이 언어에 타입이 없는 것까지 비판한다면 칭찬할만한 구석은 정말이지 하나도 없는 언어일 것이다. 뭐든 되지만 이도 저도 아니고 바로 그 점이 이 언어의 장점이라고 생각한다. 타입스크립트가 대세가 될지 혹은 또 다른 커피스크립트가 될지 나는 모르겠다. 다만 프론트엔드를 개발하는 입장에서 타입스크립트에 대한 관심과 기본적인 학습은 필요하다고 생각했다. 그래서 초창기에 시간을 투자해 어느 정도 학습해두었다. 언어의 이해와 개념이 얼추 잡힌 상태에도 타입스크립트에 그리 호의적이진 않았고 특별히 실무에 사용할 생각도 없었다.

처음 타입스크립트를 학습하고 시간이 몇 년 즈음 흘러 내 머릿속에서 지워질 무렵 타입스크립트에 대한 관심이 높아지고 있었다. 심지어 사내에서는 프론트 개발자가 아닌 개발자들에 의해 실무에 적용되는 케이스가 많아졌다. 그만큼 타입스크립트의 영역이 더 커진 것이다. 엄격한 타입의 언어를 다루던 개발자들이 자바스크립트를 다뤄야 할 때 그 중간의 완충 역할로 타입스크립트를 선택했다. 타입을 사용한다는 건 단순하게 문법을 쓰고 안 쓰고의 문제가 아닌 어플리케이션 설계 전체에 영향을 주는 문제기에 충분히 공감이 갔다. 사내의 요청도 있었지만 팀 내에서도 타입스크립트에 대한 전문성이 필요한 상황이었다. 그래서 당시 프로토타입으로 개발 중이던 프로젝트의 정식 버전은 타입스크립트를 사용하는 것으로 결정했다. 그 프로젝트는 타입스크립트와 동일한 이유로 Vue 프레임웍을 사용했다.

Vue 진영은 현재 개발 중인 3.0의 코드베이스를 모두 타입스크립트로 변경할 정도로 타입스크립트에 호의적이다. 아직 2.x 대의 버전이지만 그래도 Vue와 타입스크립트는 잘 어울릴 것으로 예상했다. 프로젝트에서의 첫 고민은 오브젝트 기반의 컴포넌트와 클래스 기반의 컴포넌트 선택이었다. 초반의 경험으로는 선택할 수 없다고 판단하고 제일 밑 베이스 컴포넌트에 해당하는 컴포넌트는 오브젝트 기반으로 그 외 나머지는 클래스 기반 컴포넌트로 진행하기로 했다. 베이스 컴포넌트들의 개수가 더 적었다. 결국 클래스 기반의 컴포넌트로 가지 않을까 하는 추측에서였다. 진행 과정에서 알게 되었지만 결정에는 Vuex도 한몫을 하고 있었다. 이 글은 선택에 대한 경험과 전반적인 결과를 공유하고자 작성했다.

자바스크립트와 Vue 컴포넌트

서론이 길었다. 이렇게 서론이 길었던 핑계를 대자면 격세지감을 느껴서다. 지금 내가 만드는 코드들은 SFC라는 브라우저는 이해할 수 없는 구조의 파일에 담겨있고 심지어 언어도 자바스크립트가 아니다. 브라우저에서 디버깅할 때 보이는 코드들은 브라우저가 실제로 사용하는 코드가 아니고 소스 맵이 재구성한 유령 같은 코드다. 이렇게 된 것도 오래전 일이라 특별할 것도 없지만 격세지감을 느낀다. 사실 프론트엔드에서 격세지감을 느끼는 것도 새삼스럽다.

SFC(Single File Component)는 Vue가 권장하는 Vue 컴포넌트 전용 파일 포맷이다. 한 파일 안에 템플릿과 자바스크립트 그리고 CSS까지 정의한다. Vue는 개발자가 컴포넌트를 개발할 때 클래스를 정의하기보다는 클래스를 만들 수 있는 옵션을 정의하는 형태로 개발한다.

<template>
  <div>
    <input type="text" v-model="newTodo" @keyup.enter="onEnter">
    <ul>
      <li v-for="todo in todos" :key="todo">{{todo}}</li>
    </ul>
  </div>
</template>
<script>
export default {
  data() {
    return {
      todos: ['TASK1'],
      newTodo: ''
    };
  },
  methods: {
    onEnter(ev) {
      this.addTodo(this.newTodo);
    },
    addTodo(title) {
      this.todos.push(title);
    }
  }
};
</script>

간단한 Todolist 컴포넌트를 구현했다. Todo 목록을 가지고 있는 데이터는 todos 라는 배열이다. 배열안에 string 타입으로 할 일을 저장한다. 템플릿에서는 todos를 순회하며 li 엘리먼트로 Todolist를 그린다. 인풋 박스에 새로 할 일을 입력하고 엔터를 치면 todos 배열을 업데이트한다. 단순한 기능의 컴포넌트고 제대로 동작한다. 이제 여기에 타입스크립트를 적용해보자.

Vue.extend

타입스크립트를 Vue 컴포넌트에 적용하는 방법에는 두 가지 방법이 있다. Vue.extend 를 이용해 객체로 만드는 방법과 Class로 만드는 방법이 있다. 현재 진행하고 있는 프로젝트에서는 장단을 비교해볼 목적으로 컴포넌트 성격에 따라 구분을 지어 둘 다 사용해봤다. Vue.extend 를 이용하는 방법을 먼저 다룬다. Vue.extend 를 이용해서 컴포넌트를 정의하는 방법은 타입스크립트를 사용하지 않았을 때의 컴포넌트와 거의 비슷하다.

<template>
  <div>
    <input type="text" v-model="newTodo" @keyup.enter="onEnter">
    <ul>
      <li v-for="todo in todos" :key="todo">{{todo}}</li>
    </ul>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';

export default Vue.extend({
  data() {
    return {
      todos: ['TASK1'],
      newTodo: ''
    };
  },
  methods: {
    onEnter(ev: UIEvent) {
      this.addTodo(this.newTodo);
    },
    addTodo(title: string) {
      this.todos.push(title);
    }
  }
});
</script>

자바스크립트에서의 Vue 컴포넌트 정의 방법과 동일하게 컴포넌트 생성 옵션 객체로 컴포넌트를 정의한다. 다만 타입이 선언된 Vue.extend 를 사용하게 되어 타입의 도움을 받을 수 있다. vscode 에서는 extend 위에 마우스 오버하는 것으로 간단하게 타입 정보를 볼 수 있고 Peek Definition으로 아래 이미지와 같이 extend가 어떤 시그니처를 가질 수 있는지 모두 확인할 수 있다.

image1

Vue 프로젝트 @type 디렉토리에 정의된 타입 선언에 의해 extend를 사용하면 컴포넌트를 개발할 때 타입스크립트의 도움을 받게 된다. 허용되지 않은 컴포넌트 옵션에는 경고와 API나 컴포넌트 멤버의 잘못된 사용에 대한 경고 역시 정상적으로 동작한다. 그리고 데이터도 정상적으로 타입이 추론된다. 초반에 이 정도의 테스트를 할 때는 매우 행복하게 진행했다. 하지만 곧 큰 문제를 발견하게 된다.

interface Todo {
  title: string;
}

컴포넌트 데이터로 사용할 Todo 타입을 선언했다. Todo 타입의 데이터를 부모 컴포넌트에서 프롭으로 전달받을 목적으로 컴포넌트에서 프로퍼티를 정의할 때 type 으로 Todo타입을 사용할 수 있을 것으로 기대했다. 아무 의심 없이 배열의 제너릭 타입 Todo를 사용한다는 의미로 Todo[]을 사용했다.

export default Vue.extend({
  props: {
    todos: {
      type: Todo[],
      required: true,
      default: []
    }
  },
  ...

하지만 이런 코드는 곧 에러를 만나게 된다.

image2

타입으로 사용되야 하는 Todo가 값으로 사용되고 있다고 에러를 출력한다. 즉 type: Todo[] 은 타입을 선언한 것이 아니고 typeTodo[] 라는 값을 할당한 것이다. 생각해보니 문법적으로 맞는 말이었다. 그리고 타입스크립트의 타입들은 개발 중 혹은 컴파일 과정에서만 도움을 줄 뿐 실제 트렌스파일된 자바스크립트 코드에서는 남아있지 않기 때문에 값으로는 사용할 수 없다. 자바스크립트 기본 타입을 사용했을 때는 NumberArray 같이 실제 값으로 쓸 수 있는 대상이 있었기에 가능했다. 검색해보면 몇 가지 방법들이 논의되고 있는데 아직은 마음에 드는 방법이 없다.

**타입스크립트에서 객체 형태로 컴포넌트를 정의할 때 props 의 타입으로 타입스크립트의 타입을 사용할 수 없다는 이 치명적인 단점은 현재 해답을 찾지 못했다.** 진행중인 프로젝트에서는 말단 베이스 컴포넌트에 해당하는 컴포넌트들만 객체 형태로 컴포넌트를 정의하고 있는데 이 문제로 타입을 사용하지 않고 자바스크립트 기본 타입으로 풀어서 받는다.

export default Vue.extend({
  props: {
    title: {
      type: String,
      required: true,
      default: []
    }
  },

Vuex를 연동하면서 또 다른 문제에 봉착했다. 이 문제는 다른 양상으로 class 기반 컴포넌트에서도 발생한다. Vuex의 맵핑핼퍼는 스토어에 정의한 각종 데이터 관련 기능들을 간단히 컴포넌트의 멤버로 맵핑해준다. 맵핑핼퍼는 자바스크립트 기반으로 작업할 때 거의 모든 스토어의 기능에 사용했다. 맵핑핼퍼를 타입스크립트에서 사용하면 메서드나 데이터가 모두 String값과 기타 맵핑핼퍼 옵션으로 세탁되어 버리기 때문에 타입스크립트는 해당 멤버가 컴포넌트에 존재한다고 추론할 수 없다.

methods: {
  ...mapActions(['addTodo']),
  onEnter(ev: UIEvent) {
    this.addTodo(this.newTodo);
  }
}

image3

그래서 맵핑핼퍼를 사용하지 못한다. 직접 인다이렉션 메서드를 만들거나 액션의 경우 dispatch를 직접 실행하는 방법으로 스토어를 통해 접근해야 한다.

methods: {
  addTodo(todo: string) {
    this.$store.dispatch(‘addTodo’, todo);
  },
  onEnter(ev: UIEvent) {
    this.addTodo(this.newTodo);
  }
}

"Vue.extend 는 타입스크립트와 좋은 조합이 아니다" 라는 결론을 내렸다.

Class based component

Vue.extend 와 객체를 이용하는 방법은 타입스크립트와 문제가 있기도 했지만 타입스크립트 환경에서는 컴포넌트도 class로 작성하는 게 옳지 않을까 하는 생각도 있었다. Vue.extend 는 Vue 프레임웍에 무게를 준 방향이고 Class 기반 컴포넌트는 언어에 무게를 더 준 방향이라고 생각했다. ES6에서도 Class 기반의 컴포넌트를 사용할 수 있지만 Vue는 컴포넌트를 컴포넌트 생성 옵션 객체로 정의하는 방법을 기반으로 디자인되었기 때문에 현재까진 ES6에서도 객체 형태가 제일 적합하다. 버전 3 부터는 어찌 될지 모르지만 Vue가 지향하는 바도 그렇다고 생각한다. 클래스 기반의 Vue 컴포넌트는 Vue 커뮤니티에서도 옳은 해답이 나왔다기보다는 현재 해답을 찾아가는 과정에 있다. 아마도 클래스 기반 컴포넌트는 타입스크립트가 아니었다면 전혀 고려하지 않았을 것이다.

<template></template>
<script lang="ts">
import {Component, Vue, Prop} from 'vue-property-decorator';
import {mapActions} from 'vuex';

@Component({
  methods: {
    ...mapActions(['addTodo'])
  }
})
export default class Todolist extends Vue {
  public newTodo: string = '';

  public addTodo!: (title: string) => void;

  @Prop({required: true})
  public todos!: Todo[];

  public onEnter(ev: UIEvent) {
    this.addTodo(this.newTodo);
  }
}
</script>

클래스에서는 getset 으로 computed를 정의하고 메서드는 클래스 메서드로 직접 사용되며 클래스 안에서의 데이터 필드는 그대로 Vue 데이터로 사용된다. 그 외의 watcher나 props와 같은 Vue의 컨셉은 데코레이터를 사용한다. 데코레이터로 Vue 옵션을 클래스 기반 컴포넌트에 제공한다. 클래스 기반의 컴포넌트로 오면서 컴포넌트 프로퍼티의 타입 문제는 아주 깔끔하게 해결되었다.

@Prop({required: true})
public todos!: Todo[];

Prop 데코레이터를 사용해 todos가 prop임을 정의하고 prop 옵션을 인자로 전달한다. 그리고 타입은 옵션이 아니라 타입스크립트의 온전한 문법으로 선언할 수 있다. Prop 데코레이터가 있는 vue-property-decorator가 Vue의 공식 모듈이 아니라는 점만 빼면 안정적이고 명확한 방법이라고 생각한다. 이제 todos 프롭을 컴포넌트에서 사용할 때 타입의 도움을 받을 수 있다.

하지만 이번에도 문제는 Vuex 맵핑핼퍼를 사용할 때 발생했다. 컴포넌트에서 mapAction 핼퍼를 사용하기 위해 Component 데코레이터를 이용했다.

@Component({
  methods: {
    ...mapActions(['addTodo'])
  }
})

물론 스토어의 addTodo라는 액션은 타입스크립트의 타입을 사용해 정확하게 시그니처 정의를 해두었다고 가정하자. 하지만 이번에도 addTodo는 맵핑핼퍼의 스트링 인자로 세탁되어 버렸기 때문에 컴포넌트는 addTodo의 존재를 유추할 수 없다. 그래서 바로 addTodo를 컴포넌트에서 사용하려 들면 컴포넌트에 없는 메서드를 사용하고 있다고 타입 에러를 발생한다. Vue.extend 같은 경우는 맵핑핼퍼를 사용할 방법이 전혀 없었지만 클래스 기반의 컴포넌트에서는 사용할 방법이 있다. 클래스에서 한번 더 시그니처를 정의하는 것이다.

public addTodo!: (title: string) => void;

메서드의 시그니처를 중복으로 정의해서 억지로 끼어맞춘 셈이다. 중복의 문제점은 로직이 있는 코드에서뿐 아니라 타입 정의에서도 동일하게 존재한다. 타입 정의는 한 번만 하고 컴파일러가 유추할 수 있게 해야 한다. 여러 가지 대안을 생각해보고 찾아도 봤지만 특별히 다른 방법은 찾을 수 없었다.

결론

고민을 거듭한 끝에 코드 중복만은 피해야 한다는 결론을 내렸고 **맵핑핼퍼를 사용하지 않기로 결정했다.** 맵핑핼퍼로 맵핑되는 스토어 요소들은 컴포넌트에 따라 적지 않은 양이 될 수 있는데 타입까지 중복으로 정의하면 쓸데없이 코드가 복잡해졌다. 심지어 타입 변경에 대한 대응을 생각하면 이건 답이 없었다. 현재 프로젝트의 컴포넌트들은 아래와 같은 모습을 하고 있다.

<template></template>
<script lang="ts">
import {Component, Vue, Prop} from 'vue-property-decorator';

@Component
export default class Todolist extends Vue {
  public newTodo: string = '';

  @Prop({required: true})
  public todos!: Todo[]

  get schedule(): Schedule {
    return this.$store.state.schedule;
  }

  public onEnter(ev: UIEvent) {
    this.$store.dispatch('addTodo', this.newTodo);
  }
}
</script>

맵핑핼퍼를 사용하지 않는 것 외에 아래와 같이 Vuex 사용 컨벤션을 정했다.

  • action이나 mutation은 특별한 경우가 아니라면 인다이렉션 메서드로 사용하지 않고 각각 dispatch와 commit 메서드로 직접 호출한다. 잘못된 타입의 값이 인자로 사용되었을 경우에 타입의 도움을 받을 수 없겠지만 타입의 중복은 제거할 수 있고 적어도 잘못된 이름의 action이나 mutation이 실행되면 프레임웍이 에러로 알려주니 그것만으로 충분하다.
  • state나 getter는 상황에 따라 computed로 정의하거나 혹은 $store 를 통해 직접 사용하지만 템플릿에서 $store 의 사용은 피한다. 템플릿의 복잡도를 증가시킬 수 있다.

이런 컨벤션을 지키며 현재는 고민 없이 타입스크립트 환경에서 Vue 컴포넌트를 개발하고 있다. 프로젝트 막바지까지 별다른 문제점이 없는 것으로 보아 Vue 3가 나오기 전까지는 이대로 계속 유지할 것 같다.

현시점에서 타입스크립트는 Vue보다 리액트와 더 잘 맞는다고 생각한다. 지금까지 언급한 것 외에도 이런 생각을 뒷받침할 큰 차이가 있다. 타입스크립트는 TSX라는 이름으로 JSX를 지원하기 때문에 JSX의 컴포넌트 타입까지 검증해줄 수 있는 반면 Vue의 SFC나 템플릿은 아직 지원하지 않는다. 그래서 컴포넌트 Props의 타입을 아무리 잘 지정한다고 해도 아직은 반쪽짜리 사용이라고 볼 수밖에 없다. 타입 스크립트와 Vue 프레임웍은 아직은 시간이 더 필요할 듯 보인다. 하지만 Vue 3는 모든 코드 베이스가 타입스크립트로 개발될 정도로 커뮤니티에서 타입스크립트를 선호하고 있다. 아마도 Vue 3에는 더 좋은 방법이 제시될 것 같다. 얼마 전에는 Vue의 개선 방향을 고민하는 RFC문서에 Class API RFC가 추가되었다. 아직 논의가 더 필요하겠지만 전반적으로 내용을 살펴보니 꽤 괜찮아 보인다. 관심 있다면 참고하기 바란다. 혹시라도 제안할 좋은 대안이 있다면 언제든지 환영한다.(shiren@nhn.com)