[tsconfig의 모든 것] Compiler options / Modules

    [tsconfig의 모든 것] Compiler options / Modules


    이번 포스팅에서는 지난 [tsconfig의 모든 것] Compiler options / Type Checking 포스팅에 이어 tsconfig의 컴파일 옵션 중 모듈과 관련된 옵션들에 대한 이야기를 해보려고 한다.

    이 옵션들은 타입스크립트를 컴파일할 때 모듈들이 어떤 모듈 시스템을 따르도록 할 것인지, 어떤 경로에 있는 파일들을 컴파일 할 것인지, 빌드된 자바스크립트 파일들이 어떤 모듈 방식을 따르게 할 것인지 등을 컨트롤 할 수 있는 것들인데, 일반적인 서비스를 만드는 경우보다는 타입스크립트로 작성된 라이브러리를 만들 때 자주 다루게 되는 옵션들이기도 하다.

    allowUmdGlobalAccess

    설명
    true umd 모듈로의 접근을 허용한다
    false (default) umd 모듈로의 접근을 허용하지 않는다

    allowUmdGlobalAccess 옵션은 타입스크립트 모듈이 전역 객체에 모듈을 포함시켜 내보내는 UMD(Universal Module Definition)형태의 모듈에 접근이 가능하게 할 것인지를 컨트롤 하는 옵션이다.

    만약 이 옵션이 꺼져 있다면 jQuery의 $와 같은 전역 변수에 그냥 접근하는 것이 불가능해지고, 무조건 import 문을 통해서 모듈을 가져와야 한다.

    UMD 방식을 사용하여 만들어진 라이브러리들은 보통 이런 형태의 글로벌 타입 선언을 가지고 있다.

    export const doThing1: () => number;
    export const version: string;
    
    export interface AnInterface {
      foo: string;
    }
    
    export as namespace myLibrary;

    이렇게 타입 파일이 네임스페이스를 export 하도록 선언되어 있는 경우, 타입스크립트는 이 모듈이 암묵적으로 전역 변수인 myLibrary에 할당된다고 판단한다. 그렇기 때문에 이런 타입 선언을 내 소스에 포함시키면 myLibrary라는 네임스페이스를 통해 이 모듈의 내용물에 접근할 수가 있는 것이다.

    만약 allowUmdGlobalAccess 옵션이 꺼져있는 상태라면 이런 에러를 만나게 된다.

    const b = myLibrary.doThing1();
    // 'myLibrary' refers to a UMD global, but the current file is a module. Consider adding an import instead.ts(2686)
    
    export {}

    이때 allowUmdGlobalAccess 옵션을 켜게 되면 네임스페이스로 export된 UMD 모듈에 접근하는 것이 허용된다. 하지만 굳이 암묵적으로 전역 변수에 선언되어있는 모듈에 접근해서 사용하는 것보다 import 키워드로 명시적으로 모듈을 가져와서 사용하는 것이 더 안전하니, 피치 못하는 경우가 아니라면 가급적 해당 옵션을 켜두도록 하자.

    baseUrl

    baseUrl 옵션은 상대 경로로 모듈의 경로를 지정할 때 기준이 되는 위치를 지정할 수 있는 옵션이다.

    myProject
    ├── index.ts
    ├── utils
    │   └── foo.ts
    └── tsconfig.json

    예를 들어 위와 같은 상황일 때, index.ts에서 상대 경로를 사용하여 foo.ts 모듈을 가져오려면 ./utils/foo와 같이 현재 기준이 되는 위치를 ./ 처럼 지정한 후 해당 모듈에 접근하게 된다.

    하지만 잘 생각해보면 아무리 상대 경로를 사용한다고 해도, 기준이 되는 위치 자체가 변하는 경우는 많지 않다는 것을 알 수 있다.

    myProject
    ├── index.ts
    ├── utils
    │   └── foo.ts
    ├── remotes
    │   └── bar.ts
    └── tsconfig.json

    만약 이런 구조의 프로젝트의 remotes/bar 모듈에서 utils/foo 모듈에 접근하려고 한다면 결국 ../utils/foo 처럼 프로젝트의 루트까지 올라간 후 다시 내려오는 방식으로 접근을 하기 때문이다.

    그래서 baseUrl 옵션은 상대경로를 사용할 때마다 반복되는 “루트로의 여정”을 없애주는 역할을 한다.

    {
      "include": ["src/*"],
      "compilerOptions": {
        "baseUrl": "./"
      }
    }

    이때 baseUrl에 입력하는 상대 경로의 기준은 tsconfig가 위치하는 곳이기 때문에 일반적으로는 프로젝트의 루트가 된다. 즉, 이렇게 설정한 후 우리가 모듈에 접근하기 위해 상대 경로를 사용하게 되면, 타입스크립트는 baseUrl에 입력된 루트의 위치에서부터 해당 모듈을 찾아가게 되는 것이다.

    import { foo } from 'utils/foo'; // 사실 .(루트)/utils/foo
    import { bar } from 'remotes/bar'; // 사실 .(루트)/remotes/bar

    이렇게 baseUrl 옵션을 사용하면 매번 ./../ 등을 사용하여 루트를 먼저 찾은 후 모듈에 접근하는 상대 경로를 절대 경로처럼 사용할 수 있다.

    paths

    paths 옵션은 특정한 모듈 이름을 지정했을 때 컴파일러가 어디서 부터 모듈을 탐색해야 할 지를 지정할 수 있는 맵을 제공한다.

    {
      "compilerOptions": {
        "baseUrl": "./src",
        "paths": {
          "app/*": ["app/*"],
          "config/*": ["app/_config/*"],
          "environment/*": ["environments/*"],
          "shared/*": ["app/_shared/*"],
          "helpers/*": ["helpers/*"],
          "tests/*": ["tests/*"]
        }
      }
    }

    맵에 포함되는 경로들은 baseUrl을 기준으로 상대 경로로 계산되기 때문에, paths 옵션을 사용하기 위해서는 반드시 baseUrl 옵션에 값을 채워주어야 한다. 즉, 위 예시의 app/*./src/app/*를 의미하며, 모듈을 불러올 때 import foo from 'app/math'와 같이 접근하게 되면, 타입스크립트는 자동으로 맵에 해당하는 경로인 ./src/app/math를 탐색한 후 모듈을 가져오게 된다.

    만약 paths 옵션에 설정된 위치들을 전부 탐색했는데도 모듈을 찾지 못 했다면, moduleResolution 옵션에서 설정한 전략에 따라 추가적인 탐색을 진행하게 된다.

    사실 패스 매핑 자체는 워낙 간단한 설정이기 때문에 크게 어려울 것이 없지만, 종종 패스 매핑이 반드시 baseUrl을 기준으로 시작한다는 점을 까먹어서 실수를 하곤 한다.

    myProject
    ├── src
    │   └── index.ts
    ├── node_modules
    │   └── foo
    └── tsconfig.json

    위와 같은 구조의 프로젝트에서 baseUrl./src를 지정한 상황을 생각해보자. 우리가 foo라는 패스에 node_modules/foo를 매핑하려면 어떻게 해야할까?

    이때 자주 하는 실수는 tsconfig.json의 위치를 기준으로 "foo": "./node_modules/foo"라고 설정하는 것이다. 아무래도 내가 지금 패스 매핑 작업을 하고 있는 파일이 tsconfig.json이니 별 생각없이 현재 파일 기준으로 경로를 지정하는 것이다.

    하지만 패스 매핑은 baseUrl을 기준으로 진행되기 때문에 이 경우에는 src 디렉토리가 기준점이 된다. 즉, "foo": "../node_modules/foo"로 매핑을 진행해주어야 한다는 것이다.

    간혹 이 점을 잊고 tsconfig.json의 위치를 기준으로 패스 매핑을 진행했다가 모듈 탐색에 실패하는 경우가 왕왕 있으니 주의하도록 하자.

    module

    설명
    CommonJS (default) CommonJS 형식으로 모듈을 컴파일한다.
    AMD Asynchronous Module Definition 형식으로 모듈을 컴파일한다.
    UMD Universal Module Definition 디자인 패턴을 사용하여 모듈을 컴파일한다.
    System System.js 형식으로 모듈을 컴파일한다.
    ES6, ES2015, ES2020, ESNext ESM(ES Module) 방식으로 모듈을 컴파일한다.

    module 옵션은 컴파일을 마친 자바스크립트 모듈이 어떤 모듈 시스템을 사용할 지를 설정하는 옵션이다.

    물론 ECMAScript에서 지정한 공식 모듈 시스템은 import, export 키워드를 사용하는 ESM 방식이기는 하지만, 현실적으로 이러한 모듈 시스템을 지원하는 브라우저가 아직 많지 않고, NodeJS 같은 경우 지난 12.0.0 버전에서 --experimental-modules 플래그 없이 ESM을 사용할 수 있는 기능이 추가되기는 했지만, 아직 생태계 전체에 ESM 시스템이 퍼져있는 상황은 아니다. (NodeJS 진영은 아직 CommonJS를 많이 사용한다)

    이런 이유들로 인해 우리의 모듈이 무조건 ESM 시스템을 사용하도록 컴파일하기는 현실적으로 어렵기 때문에, 적절히 상황에 맞는 모듈 시스템을 선택할 수 있어야 하는 것이다.

    이 포스팅에서 자바스크립트의 모듈 시스템에 대한 모든 것을 다룰 수는 없으니, 각 모듈 시스템들의 특징 정도만 간단하게 알아보고 넘어가도록 하겠다. 한번 간단하게 두 개의 모듈로 구성된 어플리케이션을 상상해보자.

    // utils/math.ts
    export const add = (x: number) => (y: number) => x + y;
    // index.ts
    import { add } from './utils/math';
    
    export const add2 = add(2);

    위 어플리케이션의 index.ts 모듈은 utils/math.ts 모듈에서 add라는 함수를 가져와서 커링을 통해 add2 함수를 생성하고 다시 내보내는 모듈이다. 이제 이 어플리케이션을 각각의 모듈 시스템을 사용하도록 컴파일하면 index.ts가 어떻게 변경되는지, 그리고 각 모듈 시스템의 특징이 무엇인지 살펴보도록 하자.

    CommonJS

    "use strict";
    exports.__esModule = true;
    exports.add2 = void 0;
    var math_1 = require("./utils/math");
    exports.add2 = (0, math_1.add)(2);

    CommonJS는 Common이라는 이름답게, 자바스크립트 모듈을 브라우저 뿐 아니라 서버 환경이나 데스크탑 어플리케이션 내에서도 자유롭게 사용하는 것을 표방하고 있는 모듈 시스템이다.

    그런 이유로 서버 사이드에서 주로 사용하는 런타임인 NodeJS 같은 경우에는 아직까지 CommonJS 시스템을 사용하고 있는 환경이 많기 때문에 서버 사이드에서 사용할 라이브러리 등을 만든다면 해당 CommonJS 시스템을 사용하는 것을 고민해봐야 한다.

    CommonJS는 exports 또는 module.exports 객체의 프로퍼티에 모듈을 할당하고, 전역함수 require를 통해 동기적으로 모듈을 직접 가져오는 방식을 사용한다. 위 예시에서도 math_1이라는 변수에 require라는 함수를 통해 모듈을 할당하고, exports.add2 프로퍼티에 add2 함수를 다시 할당하고 있는 것을 볼 수 있다.

    이렇게 require 함수가 실행될 때 동기적으로 모듈을 가져오는 CommonJS의 특징 덕분에 if 문을 사용하여 “이럴 땐 A 모듈, 저럴 땐 B 모듈을 가져와!” 같은 짓을 하는 것도 가능하다.

    AMD

    define(["require", "exports", "./utils/math"], function (require, exports, math_1) {
        "use strict";
        exports.__esModule = true;
        exports.add2 = void 0;
        exports.add2 = (0, math_1.add)(2);
    });

    AMD(Asynchronous Module Definition)는 이름에 걸맞게 비동기적으로 모듈을 가져오는 방식을 사용하는 모듈 시스템이다.

    기존의 CommonJS는 동기적으로 모듈을 가져오는 것을 전제로 개발되었기 때문에 항상 비동기적으로 모듈을 가져오는 방식을 구현하기 위한 활발한 논의가 있었는데, 이 논의 과정 속에서 기존의 CommonJS의 정신인 “모든 환경에서 작동하는 자바스크립트 모듈”에 공감하지 못하는 사람들이 나왔고, 이 사람들이 따로 독립하여 AMD 그룹을 만들게 되었다.

    사실 브라우저 환경은 애초에 서버와 다르게, 필요한 모듈들을 서버로부터 받아와서 사용해야 하는 환경이다. 그렇기 때문에 필요한 모든 모듈을 한번에 받아서 실행하는 것보다 모듈 중에서 필요한 부분만 서버로부터 비동기적으로 가져와서 사용하는 것이 더 효율적인 것이다. 하지만 “모든 환경에서 작동하는 자바스크립트 모듈”을 목표로 하는 CommonJS 그룹에서는 이런 환경 차이를 통합하기가 쉽지 않았다.

    이 과정에서 “브라우저만이라도 제대로 해보자”라는 의견을 가진 사람들이 독립하여 AMD 시스템이 탄생하게 되었고, 그런 이유로 AMD 시스템은 브라우저에서의 비동기 모듈 호출에 초점을 맞추고 개발되었다. (물론 CommonJS도 이후 비동기 모듈 로딩 기능을 따로 추가했다)

    하지만 결국 CommonJS에서 떨어져 나온 그룹인만큼 CommonJS와 AMD는 서로 호환할 수 있는 기능들을 많이 제공하고 있기 때문에, 기존의 CommonJS 모듈을 AMD 방식으로 래핑해서 사용하는 등의 응용도 가능하다.

    위 예시에서도 AMD 시스템의 define 함수가 기본적으로 CommonJS의 require, export 함수를 가져와서 사용하고 있는 모습을 볼 수 있다. 기본적으로 내부 구조는 CommonJS와 비슷하지만, require함수를 통해 모듈을 가져오는 것이 아니라, define 함수의 3번째 인자인 math_1을 통해 모듈을 주입받고 있다는 것이 CommonJS 시스템과의 결정적인 차이이다.

    UMD

    (function (factory) {
        if (typeof module === "object" && typeof module.exports === "object") {
            var v = factory(require, exports);
            if (v !== undefined) module.exports = v;
        }
        else if (typeof define === "function" && define.amd) {
            define(["require", "exports", "./utils/math"], factory);
        }
    })(function (require, exports) {
        "use strict";
        exports.__esModule = true;
        exports.add2 = void 0;
        var math_1 = require("./utils/math");
        exports.add2 = (0, math_1.add)(2);
    });

    UMD(Universal Module Definition)은 모듈 시스템이라기보다 다양한 환경에서 Universal, 즉, 범용으로 사용할 수 있는 형태의 디자인 패턴이라고 볼 수 있다.

    그렇기 때문에 UMD 패턴은 보통 RequireJS 같은 라이브러리를 통해 사용하는 AMD나 CommonJS와 다르게, 직접 개발자가 직접 UMD 패턴을 사용한 코드를 작성해줘야 한다. 쉽게 말해 위의 예시처럼 IIFE(Immediately Invoked Function Expression, 즉시실행함수)를 사용하여 개발자가 직접 소스 코드 내에서 분기를 쳐주는 것이 결국 UMD 패턴이라는 것이다.

    위 예시를 보면 modulemodule.exports가 존재한다면 CommonJS 시스템을, define 함수가 존재한다면 AMD 시스템을 사용하는 것을 볼 수 있다. 이처럼 UMD 패턴은 모듈이 사용되는 환경이 어떤 모듈 시스템을 사용하는지 여부와 상관없이 항상 동일한 경험을 제공할 수 있다는 장점이 있다.

    all taken UMD === 뭘 좋아할 지 몰라서 모든 모듈 시스템을 다 준비했어

    추가적으로, 모듈을 사용하는 환경이 CommonJS, AMD 두 시스템 모두 지원하지 않는 경우에는 UMD 패턴을 사용하여 전역 객체인 globalThis, window, global 등의 프로퍼티로 모듈을 넣는 최후의 방법도 있기는 하다.

    하지만 이 방법은 전역 스코프를 오염시키기 때문에 가급적이면 피하는 것이 좋기도 하고, 최근의 자바스크립트 런타임 환경에서 모듈 시스템 자체가 지원되지 않는 환경이 강제되는 경우는 흔치 않으므로 타입스크립트는 이런 방법까지는 사용하지 않는 것으로 보인다.

    System

    System.register(["./utils/math"], function (exports_1, context_1) {
        "use strict";
        var math_1, add2;
        var __moduleName = context_1 && context_1.id;
        return {
            setters: [
                function (math_1_1) {
                    math_1 = math_1_1;
                }
            ],
            execute: function () {
                exports_1("add2", add2 = math_1.add(2));
            }
        };
    });

    UMD 패턴이 “네가 뭘 좋아할 지 몰라서 다 준비했어”의 디자인 패턴 버전이라면, SystemJS는 이걸 라이브러리로 구현한 모듈 로더이다. (한 술 더 떴다)

    즉, SystemJS는 모듈 로더이기 때문에 모듈을 어떻게 정의하는지에 대한 것은 관여하지 않고, 그저 이미 CommonJS, AMD, ESM 방식으로 정의된 모듈을 로드해주기만 하는 녀석이다.

    SystemJS는 2016년 ECMA 재단이 ES6의 공식 모듈 스펙으로 import, export 키워드를 사용하는 ESM 패턴을 발표했을 때 즈음 꽤나 많이 사용되던 녀석인데, 그 이유는 이 당시 브라우저들이 이 스펙을 지원하지 않았기 때문이다.

    모듈링에 대한 공식 스펙은 정해졌으나 정작 그걸 실행시킬 브라우저 벤더들의 대응이 늦는 상황이라 ESM을 사용하고 싶어도 할 수가 없는 상황이었는데, 이때 SystemJS가 이 중간 다리 역할을 해줬었다. 당시에는 es-module-loader라는 폴리필을 사용하여 ESM 방식의 모듈을 불러오도록 구현되어있었다.

    그런데 여기서 한 가지 의문이 드는 것이 “브라우저가 지원하지 않는 모듈을 불러와도 트랜스파일링을 하지 않으면 실행을 시킬 수 없는 경우도 있을텐데, SystemJS는 이 문제를 어떻게 해결하는 걸까?”라는 것인데, 정답은 의외로 가까운 곳에 있었다.

    cool 복잡한 건 잘 모르겠고, 그냥 babel 가져와서 런타임에 쿨하게 트랜스파일링 돌리자

    그렇다. 이 방법을 쓰면 불러올 대상 모듈의 언어가 ES2020나 타입스크립트라도 아무 문제가 없고, 모듈 시스템으로 CommonJS를 사용하던 AMD를 사용하던 아무 문제가 없다. 물론 이 기능은 SystemJS의 기본 기능이 아니라서 systemjs-babel이라는 익스텐션을 사용해야 하지만, 문제 해결 방식 자체가 상당히 화끈한 것이 인상적이다.

    하지만 런타임에 모듈을 트랜스파일링한다는 것은 단순히 트랜스파일링만의 문제가 아니라 모듈 간의 의존관계도 파악해야하고, 심지어 타입스크립트를 사용하는 경우에는 정적 타입 체크를 거친 컴파일까지 해야하기 때문에, 당연히 빌드 타임에 이런 무거운 작업을 수행해버리면 퍼포먼스가 떨어질 수 밖에 없다.

    2021년 현재, 모두가 알다시피 이런 무거운 작업들은 모두 빌드 타임에 진행해도 아무 문제가 없으니, 굳이 런타임에 이런 짓을 벌이는 SystemJS는 특수한 상황이 아니면 쓰이지 않는 분위기인 것 같다.

    ES Module

    import { add } from './utils/math';
    export var add2 = add(2);

    ESM(ES Module) 방식은 ECMA 재단에서 공식으로 정의한 자바스크립트 생태계의 모듈 시스템이다.

    그런 이유로 requiredefine 같은 별도의 함수에 의존하여 모듈을 불러오는 CommonJS나 AMD와 다르게 import, export라는 키워드를 사용하여 모듈을 불러오는 것을 볼 수 있다.

    2015년에 등장한 ESM은 2009년부터 사용하던 CommonJS나 AMD에 비하면 후발 주자인 주제에, 모듈에 use strict 디렉티브가 반드시 포함되어야 한다던가, this가 전역 객체인 window를 바라보지 않는 등의 변경 사항이 많았던 스펙이기도 했다.

    그래서 이런 제약이 없는 CommonJS나 AMD 시스템을 사용하던 어플리케이션들이 손쉽게 마이그레이션을 할 수 있는 상황이 아니였고, 그 상황이 지금까지도 이어져오고 있다.

    그런 이유로 오늘 날에도 네이티브 자바스크립트 환경에서 ESM을 사용하기 위해서 script 태그에 type="module" 속성을 추가하거나, package.json"type": "module"이라는 필드를 추가해줘야 하는 등 별도의 작업이 필요한 것이다.

    물론 예전에 비하면 최근 많은 벤더들이 ESM을 지원하고 있지만, 그래도 아직까지 ESM을 안전하게 사용하기 위해서는 Webpack이나 Babel같은 번들러와 트랜스파일러를 조합하여 빌드 타임에 모듈 간의 의존 관계를 파악하고 런타임이 알아들을 수 있는 형태로 변환해주는 과정이 필요하기 때문에, 모듈을 사용하는 환경이 어떤 환경인지에 따라서 때로는 사용하기 번거로운 포맷이 될 수 있다는 것을 염두에 두어야 한다.

    다만 ESM은 이런 단점을 모두 씹어먹을 수 있을 정도의 한 가지 장점을 가지고 있는데, 바로 Webpack으로 모듈을 번들링할 때 트리쉐이킹이 수월하다는 것이다.

    Webpack의 ModuleConcatenationPlugin은 모듈들을 하나의 클로저로 통합하여 브라우저 환경에서 더 높은 퍼포먼스를 만들어내는데, 이때 CommonJS와 ESM 중 어떤 모듈 시스템을 사용하냐에 따라 결과물이 많이 달라지게 된다.

    // CommonJS
    
    (() => {
      "use strict";
      /* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(12);
      var add2 = (0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .DG)(2);
    })();

    Webpack을 통해 번들링 했을 때 CommonJS 시스템을 사용한 모듈은 __webpack_require__라는 함수를 통해 불러와지게 되는데, 문제는 이게 모듈 내부에 있는 add 함수만 불러오는 것이 아니라 전체 모듈을 다 불러오는 코드라는 것이다.

    다음 라인인 (0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .DG)를 보면, 불러온 모듈의 DG라는 프로퍼티에 접근하고 있는 것을 볼 수 있는데, 이 DG가 난독화된 add 함수의 이름이다.

    애초에 CommonJS는 exports라는 전역 객체의 프로퍼티에 값들을 할당하는 방식을 사용하기 때문에, “이 모듈에서 A라는 함수만 사용한다”라는 것을 파악하기가 쉽지가 않은 것이다.

    하지만 ESM 시스템을 사용한 모듈은 각각의 export 키워드를 사용하여 원하는 값을 따로 내보낼 수 있고, 그로 인해 상세한 의존 관계를 파악하기가 한결 수월하다.

    // ESM
    
    /******/ (() => { // webpackBootstrap
    /******/    "use strict";
    // CONCATENATED MODULE: ./utils/math.js**
    var add = (a) => (b) => a + b;
    // CONCATENATED MODULE: ./index.js**
    var add2 = add(2);
    /******/ })();

    Webpack을 통해 번들링한 ESM 시스템의 모듈을 보면, 애초에 모듈을 불러오는 코드 자체가 없고 심지어 모듈 내부에 있던 함수를 인라인으로 박아버렸다.

    물론 필자가 불러온 add 함수는 매우 작은 함수라 이런 식으로 표현될 수 있는 것이기는 하지만, 중요한 점은 모듈 내부에 있는 “특정 함수”만 가져왔다는 것이다.

    앞서 이야기 했듯이 ESM은 import, export 키워드를 통한 상세 의존 관계 파악이 쉬운 편이기 때문에 빌드 타임에 효율적으로 사용하는 코드와 사용하지 않는 코드를 발라내는 것이 가능하다.

    이렇게 트리쉐이킹이 쉽다는 강력한 이유 때문에 Lodash를 제외한 유명한 라이브러리들은 대부분 ESM 방식을 공식적으로 지원하고 있다.

    moduleResolution

    설명
    Node Node 전략을 사용하여 모듈을 탐색한다. module 옵션이 CommonJS일 때 Default.
    Classic Classic 전략을 사용하여 모듈을 탐색한다. module 옵션이 CommonJS가 아닐 때 Default.

    moduleResolution 옵션은 타입스크립트가 모듈을 불러올 때 이 모듈이 정확히 무엇을 참조하는 지를 확인하는 프로세스를 다루는 옵션이다. 이건 그냥 말로 설명하면 너무 어려우니 직접 코드를 보면서 알아보도록 하자.

    우선 타입스크립트는 크게 상대 경로와 절대 경로, 2가지 방법을 사용하여 모듈의 경로를 정의하는 방법을 사용하고 있다.

    import { add } from './utils/math'; // 상대 경로
    import { debounce } from 'lodash'; // 절대 경로

    상대 경로의 경우에는 현재 위치를 나타내는 .이나 상위 디렉토리를 나타내는 .. 등의 식별자를 사용하여 탐색을 위한 기점을 지정함으로써 정확한 모듈의 위치를 나타낼 수 있는 반면, 절대 경로의 경우에는 단순히 모듈의 이름만 적고 있기 때문에 정확한 모듈의 위치를 찾기 위해서 특정한 규칙을 기반으로 모듈을 찾아나서는 여행을 떠나야 한다.

    또한 상대 경로, 절대 경로 두 방법 모두 모듈의 확장자까지는 적고 있지 않기 때문에, 이 모듈이 math.ts, math.d.ts 등 어떤 확장자를 가지고 있는 파일인지도 알아내어야 한다.

    그렇기 때문에 타입스크립트는 모듈의 정확한 위치를 찾아내는 전략을 세워야 하는데, 이때 moduleResolution 옵션으로 Classic과 Node 중 어떤 전략을 사용할 것인지를 선택할 수 있는 것이다.

    사실 두 전략 모두 모듈의 위치를 찾는 기본적인 방법 자체는 크게 다르지 않다.

    만약 import { add } from './math'와 같이 상대 경로를 사용한 모듈을 찾을 때는 찾아 볼 디렉토리가 명확하기 때문에 해당 디렉토리 내에서만 파일을 찾아보고, import { add } from 'math'와 같이 절대 경로를 사용한 모듈을 찾을 때는 찾아야 할 디렉토리가 명확하지 않으니, 우선 해당 모듈을 불러온 파일이 위치한 디렉토리부터 뒤져보고, 없다면 한 단계 상위 디렉토리로, 그래도 없다면 또 한 단계 상위 디렉토리로 거슬러 올라가는 방식으로 모듈을 찾는다.

    이때 찾고자 하는 파일의 확장자가 무엇인지, 그리고 모듈을 찾을 때 어디를 먼저 찾아보는지에 따라서 Classic 전략과 Node 전략 간의 차이가 발생한다.

    Classic

    Classic 전략은 사실 상 타입스크립트의 기본 모듈 탐색 전략으로, 예전부터 사용하던 전략이기도 해서 하위 버전의 타입스크립트과의 호환성을 맞출 때에도 사용한다.

    Classic 전략은 상대 경로를 사용하여 불러온 모듈을 찾을 때, 해당 모듈을 불러오는 모듈의 위치를 기점으로 *.ts, *.d.ts의 순서로 탐색을 시작한다.

    // /root/src/index.ts
    import { add } from './math';
    1. /root/src/math.ts
    2. /root/src/math.d.ts

    앞서 이야기 했듯이 상대 경로를 사용한 경우에는 찾아봐야 하는 디렉토리의 위치가 명확하기 때문에 후보가 되는 확장자만 탐색을 진행한 후, 해당 모듈이 없는 경우 탐색을 종료하게 된다.

    반면 import { add } from 'math'와 같이 절대 경로를 사용한 모듈의 경우에는 현재 모듈을 불러온 경로부터 시작해서 한 단계씩 부모 디렉토리로 거슬러 올라가면서 탐색을 진행하게 된다.

    // /root/src/index.ts
    import { add } from 'math';
    1. /root/src/math.ts
    2. /root/src/math.d.ts
    3. /root/math.ts
    4. /root/math.d.ts
    5. /math.ts

    절대 경로를 사용한 경우, 가장 처음으로 모듈을 불러온 파일이 위치한 /root/src에서부터 탐색을 시작한다. 이후 이 디렉토리에서 모듈을 찾지 못 하면 한 단계 씩 거슬러 올라가며 다시 탐색을 진행하고, 루트까지 탐색했는데도 해당 모듈이 없는 경우 탐색을 종료하게 된다.

    Node

    Node 전략은 이름 그대로 NodeJS가 모듈을 찾는 방식을 그대로 모방하는 전략이며, Node 전략은 Classic 전략과 다르게 *.ts*.d.ts 확장자를 탐색하는 것을 넘어서서 조금 더 다양한 형태의 모듈까지 탐색을 한다.

    상대 경로를 사용한 경우는 Classic 전략과 비슷하게, 지정된 디렉토리 내부에서 다음과 같은 순서로 파일을 탐색하게 된다.

    // /root/src/index.ts
    import { add } from './math';
    1. /root/src/math.ts
    2. /root/src/math.tsx
    3. /root/src/math.d.ts
    4. /root/src/math/package.json (types 필드를 사용하는 경우에 한해)
    5. root/src/math/index.ts
    6. root/src/math/index.tsx
    7. root/src/math/index.d.ts

    뭔가 많이 추가된 것 같지만, 기본적으로는 Classic 전략과 마찬가지로 지정된 디렉토리 내부의 파일을 찾되, *.tsx 확장자를 가진 모듈과 package.json 내부의 types 속성, 그리고 모듈 이름과 동일한 디렉토리의 index.* 파일을 추가로 탐색할 뿐이다.

    이처럼 상대 경로를 사용한 모듈을 탐색할 때는 Node 전략과 Classic 전략 모두 비슷한 순서로 디렉토리 트리를 탐색하지만, 절대 경로로 지정된 모듈을 찾을 때는 차이가 커진다.

    왜냐하면 Node 전략은 Classic과 다르게, 절대 경로를 사용한 모듈을 찾을 때는 node_modules 디렉토리를 탐색하기 때문이다.

    // /root/src/index.ts
    import { add } from 'math';
    1. /root/src/node_modules/math.ts
    2. /root/src/node_modules/math.tsx
    3. /root/src/node_modules/math.d.ts
    4. /root/src/node_modules/math/package.json (types 필드를 사용하는 경우에 한해)
    5. /root/src/node_modules/@types/math.d.ts
    6. /root/src/node_modules/math/index.ts
    7. /root/src/node_modules/math/index.tsx
    8. /root/src/node_modules/math/index.d.ts
    9. /root/node_modules/math.ts (부모 디렉토리로 이동 후 반복)

    Node 전략을 사용하게 되면 가장 먼저 math 모듈을 호출한 파일이 위치한 /root/src 디렉토리에 있는 node_modules 디렉토리에서 탐색을 진행한다.

    이때 상대 경로에서와 동일하게 *.ts, *.tsx, *.d.ts 파일과 package.jsontypes 필드를 찾아보며, 그 이후에는 @types 디렉토리와 모듈 이름과 동일한 이름을 가진 디렉토리 밑에 있는 index 파일을 탐색한다.

    이렇게 한 번의 탐색 과정이 끝나도 원하는 모듈을 찾지 못했다면, 부모 디렉토리로 이동한 후 이 과정을 다시 반복한다. 그 후 루트에 도달하여 로컬 머신에 전역 설치된 모듈이 있는지까지 탐색했는데도 해당 모듈이 없다면 탐색을 종료하게 된다.

    이러한 탐색 과정은 NodeJS가 모듈을 찾는 과정과 동일하지는 않지만, 매우 비슷하다. NodeJS는 먼저 모듈과 동일한 이름의 파일을 찾은 후, package.jsonmain 필드에 적힌 경로의 파일을 찾아 보고, 마지막으로 모듈과 동일한 디렉토리 밑에 있는 index 파일을 탐색하는데, 타입스크립트의 Node 전략이 바로 이 탐색 과정을 모방한 것이기 때문이다.

    타입스크립트 공홈에는 NodeJS가 사용하는 방식과 비교해서 크게 복잡하지 않으니 걱정말라고 하지만, 사실 애초에 NodeJS가 사용하고 있는 패키지 탐색 방식 자체가 비효율적이기는 하다. 그런 이유로 타입스크립트 4.0 버전부터는 불러오지 않은 모듈에 대해서는 더 이상 위와 같은 과정을 통해 타입 정보를 찾지 않도록 업데이트가 되었다.

    noResolve

    설명
    false (default) 어플리케이션에 포함된 모든 모듈을 해석한다.
    true 명시적으로 어플리케이션에 포함하기로한 모듈만 해석한다.

    기본적으로 타입스크립트 컴파일러는 어플리케이션에 포함된 모든 모듈을 컴파일하려고 시도한다. 즉, tsconfig 루트의 includefiles 필드에 포함하지 않은 파일이라고 해도, 어플리케이션 내에서 직접 import 문이나 /// <reference path="..." /> 같은 디렉티브를 사용하여 모듈을 불러왔다면, 그 모듈 또한 컴파일 대상이라는 것이다.

    어찌보면 암시적으로 모듈을 컴파일 대상에 포함시킨다는 이야기인데, 이때 noResolve 옵션을 true로 설정하면 이런 암시적인 모듈 컴파일을 막을 수 있다.

    // tsconfig.json
    {
      "include": ["src/index.ts"],
      "compilerOptions": {
        "outDir": "./dist",
        "noResolve": true
      }
    }
    // src/index.ts
    
    import { add } from './utils/math';
    export const add2 = add(2);

    위 예시의 include 필드에는 src/index.ts만 포함되어 있고, utils/math 모듈은 포함되어있지 않다.

    이런 경우일 때 noResolve 옵션의 값이 false라면 index.ts에서 사용하는 utils/math 모듈도 아무 문제 없이 컴파일되지만, true인 경우에는 해당 모듈을 찾을 수 없다는 에러가 발생하게 된다.

    src/index.ts:1:21 - error TS2307: Cannot find module './utils/math' or its corresponding type declarations.
    
    1 import { add } from './utils/math';

    즉, 반드시 명시적으로 includefiles 필드에 선언된 모듈들만 컴파일을 하고 있는 것이다. 이와 마찬가지 이유로 /// <reference path="..." /> 같이 디렉티브를 사용하여 불러온 모듈들도 해당 필드에 포함되어 있지 않기 때문에 컴파일 대상에서 제외된다.

    noResolve 옵션은 개발자가 타입스크립트의 컴파일 대상을 명시적으로 선언하게 만듦으로써 수월한 모듈 관리를 도와주기는 하지만, 디렉티브를 사용하여 불러온 모듈까지 컴파일 대상에서 제외한다는 특성 때문에 디렉티브를 사용하여 타입 정의 파일을 불러오는 NextJS 같은 라이브러리를 사용하고 있는 상황에서는 그냥 기본 값인 false로 사용하는 것이 정신 건강에 이롭다.

    resolveJsonModule

    설명
    false (default) *.json 확장자로 끝나는 모듈의 import를 허용하지 않는다.
    true *.json 확장자로 끝나는 모듈의 import를 허용한다.

    resolveJsonModule 옵션은 이름 그대로 JSON 파일로 구현된 모듈을 끌어다 쓸 수 있게 허용할 것인지에 대한 여부를 결정한다.

    만약 해당 옵션이 false라면, JSON 모듈을 가져왔을 때 타입스크립트는 해당 모듈을 찾을 수 없다는 에러를 발생시킨다.

    // me.json
    {
      "name": "evan-moon",
      "age": 12,
      "role": "Frontend Engineer"
    }
    import me from './me.json';
    // Cannot find module './settings.json'. Consider using '--resolveJsonModule' to import module with '.json' extension.

    resolveJsonModule 옵션을 켜게 되면 일반적인 타입스크립트 모듈과 동일하게 JSON 모듈을 가져와서 사용할 수 있게 되고, 심지어 해당 파일을 분석하여 자동으로 타입 추론까지 해준다.

    하지만 이 경우 당연히 Enum이나 Union Type을 사용한 추론은 불가능하기 때문에 모든 값들은 string이나 number 등의 원시 타입으로 추론된다. 그러니 만약 강력한 타입 선언을 강제해야하는 경우라면 JSON 모듈이 아니라 타입스크립트 모듈을 사용하여 모델을 선언해주는 것이 좋다.

    rootDir

    rootDir 옵션은 모듈을 컴파일 한 이후 어떤 디렉토리를 루트로 하여 현재 구조를 유지할 것 인지를 결정한다.

    기본적으로 타입스크립트는 컴파일을 수행할 때 입력된 디렉토리의 구조를 그대로 유지하며 컴파일된 파일들을 출력하는데, 이때 rootDir 옵션을 사용하여 어떤 디렉토리를 루트로 설정할 것인지를 정하는 것이다.

    만약 이 옵션을 따로 설정하지 않는다면, 타입스크립트는 자동으로 해당 모듈의 엔트리 포인트가 되는 파일을 찾고, 해당 파일이 위치한 디렉토리를 루트로 설정하여 출력 디렉토리 구조를 설정하게 된다.

    myProject
    ├── src
    │   ├── index.ts
    │   └── utils
    │       └── math.ts
    └── tsconfig.json
    // tsconfig.json
    {
      "compilerOptions": {
        "outDir": "./dist"
      }
    }

    위와 같은 구조의 프로젝트가 있다고 생각해보자. rootDir 옵션이 주어지지 않았을 때의 타입스크립트는 이 어플리케이션의 엔트리 포인트인 src/index.ts를 찾아내고, 이 파일의 위치인 src를 루트로 인식하게 된다.

    그렇기 때문에 출력 디렉토리는 루트를 src 디렉토리로 하는 다음과 같은 구조를 가지게 된다.

    dist
    ├── index.ts
    └── utils
        └── math.ts

    그러나 만약 이 상태에서 rootDir 옵션을 현재 경로를 의미하는 .로 설정하게 되면, 출력 디렉토리의 구조가 변경되게 된다.

    // tsconfig.json
    {
      "compilerOptions": {
        "outDir": "./dist",
        "rootDir": "."
      }
    }
    dist
    └── src
        ├── index.ts
        └── utils
            └── math.ts

    필자가 루트 디렉토리를 현재 tsconfig.json이 위치한 myProject 디렉토리로 변경했기 때문에, 출력 디렉토리인 dist는 기존 프로젝트 디렉토리와 완전히 동일한 구조를 가지게 되고, 이로 인해 디렉토리 내부에 src 디렉토리까지 함께 생성된 형태로 출력되게 된다.

    어찌보면 굉장히 간단한 동작이지만, 이 옵션을 사용할 때는 한 가지 주의해야 할 점이 있다. 바로 rootDir 옵션은 컴파일 대상에 아무런 영향을 끼치지 않는다는 것이다.

    즉, 만약 rootDir 옵션을 사용한다면 모든 컴파일 대상 파일은 해당 디렉토리 밑에 위치해야 한다는 것이다.

    myProject
    ├── src
    │   ├── index.ts
    │   └── utils
    │       └── math.ts
    ├── foo.ts
    └── tsconfig.json
    {
      "compilerOptions": {
        "outDir": "./dist",
        "rootDir": "./src",
        "include": "*"
      }
    }

    위 설정을 살펴보면 루트 디렉토리로 src 디렉토리를 설정하고, include 옵션을 사용하여 모든 파일을 컴파일 할 것이라고 설정해주었다.

    문제는 이 “모든 파일”의 대상 중 하나인 foo.tssrc 디렉토리에 들어가 있지 않은 녀석이라는 것이다. 즉, 설정에 모순이 발생한 것이다.

    이렇게 rootDir 옵션으로 루트 디렉토리를 정했다고 해도, 타입스크립트는 자동으로 foo.ts를 컴파일 대상에 포함시키지 않고, 이런 에러를 발생시킨다.

    File ‘/Users/john/myProject/foo.ts’ is not under ‘rootDir’ ‘/Users/john/myProejct/src’. ‘rootDir’ is expected to contain all source files.

    이 에러를 보면 알 수 있듯이, rootDir 옵션을 사용하여 루트 디렉토리를 설정했다면 반드시 모든 소스 파일들은 루트 디렉토리 내부에 들어있어야 하며, 만약 include 등을 사용하여 루트 디렉토리 밖에 있는 파일을 컴파일 대상으로 지정했다고 해도 자동으로 컴파일 해주거나 하지 않는다.

    rootDirs

    rootDirs 옵션은 일종의 가상 루트를 만들어 줄 수 있는 옵션이다. 이 옵션은 말로 설명하기 보다 코드로 보는 것이 훨씬 이해가 편하니, 바로 예시를 보도록 하자.

    myProject
    ├── core
    │   └── index.ts
    ├── utils
    │   └── math.ts
    └── tsconfig.json

    만약 이런 구조의 어플리케이션이 있다고 생각해보자. 만약 core/index.ts에서 utils/math.ts 모듈을 가져오고 싶다면 어떻게 해야할까?

    만약 paths 옵션을 사용하지 않았다면, 상대 경로를 사용하여 한 단계 상위 디렉토리로 거슬러 올라가서 해당 모듈에 접근할 것이다.

    // core/index.ts
    import math from '../utils/math';

    rootDirs 옵션은 이런 상황일 때 가상의 루트를 만들어서, core 디렉토리와 utils 디렉토리 내부에 있는 모듈들이 마치 “하나의 디렉토리” 내부에 있는 것처럼 사용할 수 있도록 만들어준다.

    {
      "compilerOptions": {
        "outDir": "./dist",
        "rootDirs": ["core", "utils"]
      }
    }
    // core/index.ts
    import math from './utils/math'; // 마치 같은 디렉토리에 있는 것처럼 사용한다

    만약 core/components/Foo/index.tsx와 같이 디렉토리 깊이가 깊다고 해도 rootDirs에 해당 디렉토리를 등록하게 되면, rootDirs에 등록된 디렉토리 끼리는 항상 같은 디렉토리에 있는 것처럼 모듈을 불러올 수 있다.

    그리고 rootDirs 옵션은 일종의 “가상 디렉토리”를 만들어서 이런 기능을 구현하는 방식을 사용하기 때문에, 컴파일한 이후의 출력 디렉토리 구조에는 전혀 영향을 주지 않는다. 말 그대로 가상이다.

    이처럼 rootDirs 옵션은 디렉토리의 깊이가 깊은 상황에도 간단하게 상대 경로를 사용할 수 있도록 만들어 주기 때문에 어찌 보면 편하다고 생각할 수도 있다.

    하지만 이런 설정을 사용하게 되면 실제 디렉토리 구조와 코드에서 모듈에 접근하기 위해 사용하는 경로 간의 괴리가 발생하게 됨으로써 직관적인 이해가 어려운 코드가 될 수도 있으며, 심지어 이 괴리의 원인을 확인하기 위해서는 tsconfig.json을 까봐야 하는 슬픈 상황이 발생할 수도 있다는 점을 꼭 염두에 두도록 하자.

    typeRoots

    기본적으로 타입스크립트는 @types 패키지 디렉토리 밑에 있는 파일들을 자동으로 컴파일 대상으로 포함한다. 이때 앞서 설명했던 resolve 전략에 따라 ./node_modules/@types, ../node_modules/@types 등 디렉토리를 거슬러 올라가면서 node_modules 내부에 있는 @types 디렉토리를 탐색하는 것이다.

    하지만 typeRoots 옵션을 사용하면 타입스크립트가 찾아 헤매는 타입 파일들이 특정한 곳에 있다고 지정할 수 있다.

    {
      "compilerOptions": {
        "typeRoots": ["./typings", "./node_modules/@types"]
      }
    }

    typeRoots 옵션에 적용하는 경로는 tsconfig.json 기준의 상대 경로이다. 또한 위 예시에서는 ./node_modules/@types 디렉토리를 옵션에 포함시켰지만, 사실 없어도 아무 문제 없다.

    이렇게 typeRoots를 지정한 경우, 타입스크립트는 기존의 모듈 탐색 전략을 버리고 배열에 들어있는 경로에서만 타입 선언 모듈들을 찾기 때문에, 계속 부모 디렉토리로 거슬러 올라가며 타입 선언 모듈을 찾는 기존의 모듈 탐색 전략보다 효율적인 탐색 전략을 가져갈 수 있다.

    types

    앞서 설명했듯이 타입스크립트는 @types 패키지 디렉토리 밑에 있는 모든 파일들을 자동으로 컴파일 대상으로 포함하고, 이 과정에서 타입 선언을 전역 스코프에 뿌려버린다.

    이 타입 선언이 전역 스코프에 존재하기 때문에 우리가 @types/node 같은 모듈 내부에 포함된 process 객체 같은 녀석들을 별도의 타입 선언 없이도 사용할 수 있는 것이다.

    하지만 types 옵션을 사용하면, 특정한 패키지들의 타입만 전역 스코프에 포함시킬 수 있다.

    {
      "compilerOptions": {
        // @types/node, @types/jest, @types/express만 가져온다
        "types": ["node", "jest", "express"]
      }
    }

    위와 같이 설정할 경우, node, jest, express 패키지의 타입은 전역 스코프에 포함되어 import express from 'express';라는 구문만 적어도 자동으로 타입 평가가 진행되지만, 여기에 포함되지 않은 다른 라이브러리들은 직접 타입 선언 모듈을 가져와야 한다.

    여기서 주의해야할 점은 types 옵션의 대상 자체가 애초에 @types 패키지 디렉토리 내부에 존재하는 타입 선언 모듈들이라는 것이다.

    예를 들어 날짜 관련 라이브러리인 moment@types/moment 같은 타입 패키지를 추가적으로 설치하지 않고, 자체적으로 내장하고 있는 타입 선언 모듈을 사용한다.

    {
        "name": "moment",
        // ...
        "main": "./moment.js",
        "jsnext:main": "./dist/moment.js",
        "typings": "./moment.d.ts",
        // 자체적으로 타입 선언 파일을 포함하고 있다
    }

    이런 경우는 import moment from 'moment' 구문으로 이 라이브러리를 가져옴과 동시에 moment.d.ts도 자동으로 컴파일 대상으로 포함되므로, 당연히 제대로 타입을 사용할 수 있다.

    즉, types 옵션의 대상은 어디까지나 @types/* 패키지 내부에 포함된 타입 선언 모듈이고, 타입스크립트가 해당 타입 선언 모듈을 전역 공간에 뿌리는 경우에만 해당된다는 점을 헷갈리지 말자.

    마치며

    이렇게 tsconfig 3번째 시리즈인 Modules 편을 마무리 했다. 모듈과 관련된 옵션들의 개수 자체는 많지 않지만, 아무래도 컴파일 과정에서 어떤 모듈 시스템을 사용할 지, 어떤 모듈 탐색 전략을 사용할 지 등을 다루는 옵션이다보니 부연 설명이 길어진 것 같다.

    그리고 여기까지 적고 나서 새삼스럽게 드는 생각은…

    tsconfig 어...? 아직도 여기까지밖에 못 썼다고...?

    사실 tsconfig 옵션이 많은 줄은 이미 알고 있었지만 이렇게까지 힘들 줄은 상상도 못 했다. (글 쓰다가 손목이 아픈 적은 또 처음…)

    물론 공식 문서를 번역하는 느낌으로 쭉쭉 써내려간다면 금방 끝내겠지만, 애초에 이 포스팅 시리즈를 시작한 것은 그 정도의 정보를 원해서가 아니라 tsconfig를 완전 분석해보자는 목적이었으므로 한번 달려보도록 하겠다.

    다음 포스팅에서는 타입스크립트가 출력 파일을 생성할 때의 동작들을 다루는 방법에 대한 옵션들에 대해서 이야기해볼 예정이다.

    이상으로 [tsconfig의 모든 것] Compiler options / Modules 포스팅을 마친다.

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.