JavaScript에서 마이그레이션

JavaScript에서 마이그레이션 (Migrating from JavaScript)

기존 JavaScript 코드베이스를 TypeScript로 옮기는 과정을 처음부터 끝까지 안내하는 문서예요. 디렉터리 설정, tsconfig 작성, 파일 이름 바꾸기, 그리고 늘어나는 에러를 하나씩 다듬는 방법까지 실제 작업 순서대로 설명할게요.

출처: TypeScript 핸드북

TypeScript는 공허에서 존재하는 게 아니에요. JavaScript 생태계를 염두에 두고 만들어졌고, 지금도 JavaScript가 아주 많죠. JavaScript 코드베이스를 TypeScript로 바꾸는 일은 좀 지루할 수는 있어도, 보통 어렵지는 않습니다. 이 튜토리얼에서는 어떻게 시작할 수 있는지 살펴볼게요. 핸드북을 충분히 읽어서 새로운 TypeScript 코드를 쓸 수 있는 상태라고 가정합니다.

React 프로젝트를 변환하려는 분이라면, 먼저 React Conversion Guide를 보는 걸 추천해요.

디렉터리 설정하기 (Setting up your Directories)

순수 JavaScript로 작성하고 있다면, 아마 .js 파일들이 src, lib, 또는 dist 디렉터리에 있고 그대로 실행했을 거예요.

그렇다면 여러분이 작성한 파일들이 TypeScript의 입력으로 사용되고, TypeScript가 만들어내는 출력을 실행하게 됩니다. JS에서 TS로 마이그레이션하는 동안, 입력 파일들을 분리해야 TypeScript가 그 파일들을 덮어쓰지 않아요. 출력 파일이 특정 디렉터리에 있어야 한다면, 그게 출력 디렉터리가 됩니다.

혹시 JavaScript에 번들링이나 Babel 같은 다른 트랜스파일러를 쓰는 중간 단계를 실행하고 있을 수도 있어요. 그렇다면 이미 이런 폴더 구조가 잡혀 있을 거예요.

이제부터는 디렉터리가 다음과 같이 구성되어 있다고 가정할게요:

projectRoot
├── src
│   ├── file1.js
│   └── file2.js
├── built
└── tsconfig.json

src 디렉터리 밖에 tests 폴더가 있다면, srctsconfig.json 하나, tests에도 하나를 두는 방식이 좋습니다.

설정 파일 작성하기 (Writing a Configuration File)

TypeScript는 tsconfig.json이라는 파일로 프로젝트의 옵션을 관리해요. 어떤 파일을 포함할지, 어떤 검사를 수행할지 같은 것들이죠. 우리 프로젝트를 위한 최소한의 설정을 만들어 볼게요:

{
  "compilerOptions": {
    "outDir": "./built",
    "allowJs": true,
    "target": "es5"
  },
  "include": ["./src/**/*"]
}

여기서 TypeScript에 몇 가지를 지정하고 있답니다:

  1. src 디렉터리에서 이해할 수 있는 파일들을 읽어들이기 (include)
  2. JavaScript 파일을 입력으로 허용하기 (allowJs)
  3. 모든 출력 파일을 built에 생성하기 (outDir)
  4. 더 새로운 JavaScript 구문을 ECMAScript 5 같은 이전 버전으로 내리기 (target)

이 시점에 프로젝트 루트에서 tsc를 실행하면, built 디렉터리에 출력 파일이 생기는 걸 확인할 수 있어요. built의 파일 배치는 src의 배치와 똑같아야 합니다. 이제 TypeScript가 프로젝트에서 동작하게 된 겁니다.

초기의 장점들 (Early Benefits)

이 시점에서도 TypeScript가 프로젝트를 이해함으로써 얻는 좋은 혜택이 있어요. VS CodeVisual Studio 같은 에디터를 열면, 자동완성 같은 툴링 지원을 종종 받을 수 있어요. 또한 다음 같은 옵션으로 특정 버그를 잡아낼 수도 있고요:

TypeScript는 도달할 수 없는 코드(unreachable code)와 레이블에 대해서도 경고하는데, 각각 allowUnreachableCodeallowUnusedLabels로 끌 수 있어요.

빌드 도구와의 통합 (Integrating with Build Tools)

파이프라인에 빌드 단계가 더 있을 수도 있어요. 각 파일에 어떤 것을 연결(concatenate)한다든가 하는 식이죠. 빌드 도구마다 다르지만, 핵심은 최대한 다루려고 할게요.

Gulp

Gulp를 어떤 방식으로든 사용하고 있다면, TypeScript와 함께 Gulp 사용, 그리고 Browserify, Babelify, Uglify 같은 일반적인 빌드 도구와의 통합에 대한 튜토리얼이 있어요. 거기서 더 읽어볼 수 있습니다.

Webpack

Webpack 통합은 꽤 간단해요. TypeScript 로더인 ts-loadersource-map-loader와 함께 쓰면 디버깅이 더 쉬워집니다. 그냥 실행하세요:

npm install ts-loader source-map-loader

그리고 webpack.config.js 파일에 다음 옵션들을 병합하면 됩니다:

module.exports = {
  entry: "./src/index.ts",
  output: {
    filename: "./dist/bundle.js",
  },

  // Enable sourcemaps for debugging webpack's output.
  devtool: "source-map",

  resolve: {
    // Add '.ts' and '.tsx' as resolvable extensions.
    extensions: ["", ".webpack.js", ".web.js", ".ts", ".tsx", ".js"],
  },

  module: {
    rules: [
      // All files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'.
      { test: /\.tsx?$/, loader: "ts-loader" },

      // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'.
      { test: /\.js$/, loader: "source-map-loader" },
    ],
  },

  // Other options...
};

ts-loader는 .js 파일을 다루는 다른 로더보다 먼저 실행돼야 한다는 점을 꼭 기억하세요.

React와 Webpack에 대한 튜토리얼에서 Webpack 사용 예시를 볼 수 있습니다.

TypeScript 파일로 옮기기 (Moving to TypeScript Files)

이쯤 되면 TypeScript 파일을 쓰기 시작할 준비가 됐을 거예요. 첫 단계는 .js 파일 중 하나를 .ts로 이름을 바꾸는 것입니다. 파일이 JSX를 사용한다면 .tsx로 바꿔야 합니다.

그 단계를 끝냈다고요? 좋아요! 파일 하나를 JavaScript에서 TypeScript로 성공적으로 마이그레이션했어요!

물론, 뭔가 어색하게 느껴질 수도 있어요. TypeScript를 지원하는 에디터에서 그 파일을 열면 (또는 tsc --pretty를 실행하면) 특정 줄에 빨간 물결 표시가 보일 거예요. 이건 Microsoft Word 같은 에디터의 빨간 물결과 같은 방식으로 생각하면 됩니다. TypeScript는 여전히 코드를 변역하고, 마치 Word가 문서 인쇄를 허용하듯이요.

그게 너무 느슨하다고 느껴진다면, 그 동작을 더 엄격하게 조일 수 있어요. 예를 들어 에러가 있을 때 TypeScript가 JavaScript로 컴파일하지 않길 원한다면, noEmitOnError 옵션을 쓸 수 있습니다. 그런 의미에서 TypeScript는 엄격함에 다이얼이 있고, 그 손잡이를 원하는 만큼 높일 수 있어요.

더 엄격한 설정을 쓸 계획이라면 지금 켜 두는 게 좋습니다 (아래 Getting Stricter Checks 참고). 예를 들어 내가 명시적으로 말하지 않았는데 TypeScript가 조용히 어떤 타입을 any로 추론하는 걸 원하지 않는다면, 파일을 수정하기 전에 noImplicitAny를 켜두면 돼요. 다소 압도적으로 느껴질 수 있지만, 장기적인 이득이 훨씬 빨리 드러납니다.

에러 뽑아내기 (Weeding out Errors)

앞서 언급했듯이, 변환 후 에러 메시지를 받는 것은 예상 밖의 일이 아니에요. 중요한 건 이런 에러들을 하나씩 실제로 살펴보고 어떻게 처리할지 결정하는 것입니다. 종종 이건 진짜 버그이지만, 가끔은 TypeScript에게 여러분이 하려는 일을 조금 더 잘 설명해 줘야 할 때도 있어요.

모듈에서 가져오기 (Importing from Modules)

Cannot find name 'require'., Cannot find name 'define'. 같은 에러를 잔뜩 받기 시작할 수도 있어요. 이런 경우라면 아마 모듈을 사용하고 있는 것일 거예요. TypeScript에게 그런 것들이 존재한다고 납득시키고 싶다면 이렇게 작성할 수도 있지만:

// For Node/CommonJS
declare function require(path: string): any;

또는:

// For RequireJS/AMD
declare function define(...args: any[]): any;

그보다는 그런 호출을 없애고 TypeScript 문법으로 import를 쓰는 게 좋아요.

먼저 TypeScript의 module 옵션으로 어떤 모듈 시스템을 사용할지 켜야 합니다. 유효한 옵션은 commonjs, amd, system, umd예요.

다음과 같은 Node/CommonJS 코드가 있다면:

var foo = require("foo");

foo.doStuff();

또는 다음과 같은 RequireJS/AMD 코드가 있다면:

define(["foo"], function (foo) {
  foo.doStuff();
});

다음과 같은 TypeScript 코드를 작성하면 됩니다:

import foo = require("foo");

foo.doStuff();

선언 파일 얻기 (Getting Declaration Files)

TypeScript import로 변환하기 시작했다면 Cannot find module 'foo'. 같은 에러를 만나게 될 거예요. 문제는 라이브러리를 설명하는 선언 파일(declaration files) 이 없다는 것이죠. 다행히 이것은 꽤 쉽습니다. TypeScript가 lodash 같은 패키지에 대해 불평한다면, 그냥 이렇게 작성하면 돼요:

npm install -S @types/lodash

commonjs가 아닌 다른 모듈 옵션을 쓰고 있다면, moduleResolution 옵션을 node로 설정해야 합니다.

그러면 문제없이 lodash를 import하고 정확한 자동완성을 받을 수 있어요.

모듈에서 내보내기 (Exporting from Modules)

일반적으로 모듈에서 내보내기는 exportsmodule.exports 같은 값에 속성을 추가하는 방식으로 이루어집니다. TypeScript는 최상위 export 문을 허용해요. 예를 들어 함수를 이렇게 export했다면:

module.exports.feedPets = function (pets) {
  // ...
};

이렇게 쓸 수 있습니다:

export function feedPets(pets) {
  // ...
}

가끔은 exports 객체를 통째로 덮어쓰기도 합니다. 모듈을 즉시 호출 가능하게 만들려고 사람들이 흔히 쓰는 패턴이에요. 이 스니펫처럼요:

var express = require("express");
var app = express();

이전에는 이렇게 작성했을 수도 있어요:

function foo() {
  // ...
}
module.exports = foo;

TypeScript에서는 export = 구문으로 이걸 모델링할 수 있어요:

function foo() {
  // ...
}
export = foo;

인자가 너무 많거나 적을 때 (Too many/too few arguments)

가끔 함수를 인자가 너무 많거나 적게 호출하게 되는 일이 있어요. 보통 이건 버그지만, 어떤 경우에는 파라미터를 쓰지 않고 arguments 객체를 사용하는 함수를 선언했기 때문일 수도 있습니다:

function myCoolFunction() {
  if (arguments.length == 2 && !Array.isArray(arguments[1])) {
    var f = arguments[0];
    var arr = arguments[1];
    // ...
  }
  // ...
}

myCoolFunction(
  function (x) {
    console.log(x);
  },
  [1, 2, 3, 4]
);
myCoolFunction(
  function (x) {
    console.log(x);
  },
  1,
  2,
  3,
  4
);

이 경우, 함수 오버로드를 사용해서 myCoolFunction이 호출되는 여러 방식을 호출자에게 알리도록 TypeScript를 사용해야 합니다.

function myCoolFunction(f: (x: number) => void, nums: number[]): void;
function myCoolFunction(f: (x: number) => void, ...nums: number[]): void;
function myCoolFunction() {
  if (arguments.length == 2 && !Array.isArray(arguments[1])) {
    var f = arguments[0];
    var arr = arguments[1];
    // ...
  }
  // ...
}

myCoolFunction에 오버로드 시그니처 두 개를 추가했어요. 첫 번째는 myCoolFunction이 함수(number를 받는) 하나와 number 목록 하나를 받는다고 명시합니다. 두 번째는 함수 하나를 받고, 나머지 인자 뒤에 오는 것은 모두 number여야 한다고 나머지 파라미터(...nums)로 표현합니다.

순차적으로 추가되는 속성 (Sequentially Added Properties)

어떤 사람들은 객체를 만들고 즉시 속성을 추가하는 방식을 더 미적으로 좋아해요:

var options = {};
options.color = "red";
options.volume = 11;

TypeScript는 colorvolume에 할당할 수 없다고 말할 거예요. 왜냐하면 먼저 options의 타입을 속성이 하나도 없는 {}로 추론했기 때문이죠. 대신 선언을 객체 리터럴 안으로 옮기면 에러가 없습니다:

let options = {
  color: "red",
  volume: 11,
};

options의 타입을 정의하고 객체 리터럴에 타입 단언을 추가할 수도 있어요:

interface Options {
  color: string;
  volume: number;
}

let options = {} as Options;
options.color = "red";
options.volume = 11;

또는, 가장 쉬운 방법으로 optionsany 타입이라고 하면 되는데, 그건 이득이 가장 적어요.

any, Object, {}

값이 어떤 속성이든 가질 수 있다는 걸 표현하기 위해 Object{}를 쓰고 싶은 유혹이 있을 수 있어요. 왜냐하면 Object는 대부분의 목적에서 가장 일반적인 타입이기 때문이죠. 하지만 그런 상황에서 실제로 원하는 타입은 any 입니다. 가장 유연한 타입이니까요.

예를 들어 Object로 타입이 지정된 값은 toLowerCase() 같은 메서드를 호출할 수 없어요. 더 일반적(일반적)이라는 건 보통 타입으로 더 적게 할 수 있다는 뜻이지만, any는 특별해요. 가장 일반적인 타입이면서도 여전히 무엇이든 할 수 있거든요. 호출하고, 생성하고, 속성에 접근하는 것도 가능합니다. 다만 any를 쓸 때마다 TypeScript가 주는 대부분의 에러 검사와 에디터 지원을 잃는다는 점은 기억하세요.

Object{} 사이에서 결정해야 한다면, {}를 선호하세요. 대부분 같지만, 기술적으로는 어떤 난해한 경우에 {}Object보다 더 일반적인 타입입니다.

더 엄격한 검사 켜기 (Getting Stricter Checks)

TypeScript에는 프로그램에 더 많은 안전성과 분석을 주는 특정 검사들이 함께 제공돼요. 코드베이스를 TypeScript로 변환하고 나면, 더 큰 안전을 위해 이 검사들을 켜기 시작할 수 있습니다.

No Implicit any

TypeScript가 어떤 타입이어야 할지 알아낼 수 없는 경우가 있어요. 최대한 관대하게, 자리에 any 타입을 사용하기로 결정합니다. 마이그레이션에는 좋지만, any를 쓴다는 건 타입 안전을 전혀 얻지 못하고, 다른 곳에서 받는 것과 같은 툴링 지원도 받지 못한다는 뜻이에요. noImplicitAny 옵션으로 TypeScript가 이런 위치를 표시하고 에러를 주도록 할 수 있습니다.

Strict null & undefined 검사

기본적으로 TypeScript는 nullundefined가 모든 타입의 도메인에 있다고 가정해요. 즉 number 타입으로 선언된 어떤 것이든 null이나 undefined일 수 있다는 뜻이죠. nullundefined는 JavaScript와 TypeScript에서 버그의 매우 흔한 원인이기 때문에, TypeScript는 strictNullChecks 옵션으로 이런 걱정에서 벗어나게 해줍니다.

strictNullChecks가 켜지면, nullundefined는 각각 null, undefined라는 자신만의 타입을 갖게 됩니다. 어떤 것이 아마도 null일 때마다, 원래 타입과의 유니온 타입을 사용할 수 있어요. 예를 들어 어떤 것이 numbernull일 수 있다면, 타입을 number | null로 작성하면 됩니다.

TypeScript가 어떤 값을 아마도 null/undefined라고 생각하는데, 여러분은 더 잘 알고 있다면, 후위 ! 연산자로 그렇지 않다고 알려줄 수 있어요.

declare var foo: string[] | null;

foo.length; // error - 'foo' is possibly 'null'

foo!.length; // okay - 'foo!' just has type 'string[]'

참고로, strictNullChecks를 쓸 때는 의존성들도 strictNullChecks를 쓰도록 업데이트가 필요할 수 있습니다.

this에 대한 No Implicit any

클래스 밖에서 this 키워드를 쓰면 기본적으로 any 타입이에요. 예를 들어 Point 클래스와, 메서드로 추가하고 싶은 함수를 상상해 보세요:

class Point {
  constructor(public x, public y) {}
  getDistance(p: Point) {
    let dx = p.x - this.x;
    let dy = p.y - this.y;
    return Math.sqrt(dx ** 2 + dy ** 2);
  }
}
// ...

// Reopen the interface.
interface Point {
  distanceFromOrigin(): number;
}
Point.prototype.distanceFromOrigin = function () {
  return this.getDistance({ x: 0, y: 0 });
};

이건 위에서 언급한 것과 같은 문제가 있어요. getDistance를 잘못 적어도 에러가 나지 않을 수 있죠. 그래서 TypeScript에는 noImplicitThis 옵션이 있어요. 이 옵션이 켜지면, this가 명시적(또는 추론된) 타입 없이 사용될 때 TypeScript가 에러를 냅니다. 해결책은 인터페이스나 함수 자체에서 this 파라미터로 명시적 타입을 주는 것입니다:

Point.prototype.distanceFromOrigin = function (this: Point) {
  return this.getDistance({ x: 0, y: 0 });
};

더 알아보기 (Learn more)