이전 게시글에서 확장자 문제에 부딪혔고, 이런 문제를 해결해보고자 했다.
이를 이해하기 위해서는 이 문서를 읽는게 직빵이었는데, 이 글에 해당 내용을 담으려고 읽다가 보니 그냥 번역해서 오픈소스에 기여해보는게 좋을거란 생각이 들어서 해보았다.
https://github.com/microsoft/TypeScript-Website-Localizations/pull/224
이 글은 위 PR의 번역파일에 추가 설명을 더하고, 내용을 요약한 것이다.
typescript 컴파일러는 모듈에 대해 도대체 무슨 일을 하는가?
typescript의 주요목표는 컴파일시 런타임 오류를 미리 파악하여 방지하는 것이다.
이걸 생각해보면 typescript는 컴파일타임에 런타임 환경에 대해 알아야한다는 것을 알 수 있다.
그래서 모듈이 포함된 코드를 프로젝트에서 작성하게되면, ts컴파일러가 그냥 코드를 변환하는 작업을 수행하기에는 모호한점들이 발생하게된다.
예를들어 아래 코드를보자.
import sayHello from "greetings";
sayHello("world");
이 코드만을 봤을때 그냥 변환시키기에는 여러가지 모호한 점이 있다는 것을 알 수 있다.
1. greeting에 대한 타입스크립트 파일을 직접 로드할까(greeting.ts)? 아니면 TS컴파일러가 생성한 결과물 JS파일을 불러올까(greeting.js)?
2. greeting이라는건 파일이름인가? 아니면 디렉토리+파일의 별칭인가?
3. greeting 모듈은 ESM일까? CJS일까?
4. 이 코드 자체는 ESM으로 변환시킬까? 아니면 CJS로 변환시킬까?
5.greeting모듈이 분석됐을때, greeting의 어느부분이 어떻게 sayHello로 바인딩되는가?
이건 비단 typescript혼자서 결정하거나 대답할 수 있는게 아니다.
이 코드를 결국 사용하는게 node.js인지, 번들러인지, 혹은 브라우저인지에 따라 이게 달라지게 되는 것이다.
타입스크립트 docs는 트랜스파일링된 코드를 최종적으로 사용하는 곳을 호스트라고 정의했다.
호스트의 종류
- 트랜스 파일링된 결과 코드가 Node.js와 같은 런타임에서 직접 실행되는 경우 런타임이 호스트가 된다.
- 런타임이 TypeScript 파일을 변환업이 바로 사용하거나,"트랜스파일링된 JS 코드"가 없는 경우에도 런타임이 호스트이다.
- 번들러가 TypeScript 입력 또는 출력을 소비하고 번들을 생성하는 경우, 번들러가 호스트이다.
- 다른 트랜스파일러, 옵티마이저 또는 포맷터가 TypeScript의 트랜스파일링 결과를 실행하는경우에는 imports와 exports를 바꾸지 않는한 TypeScript가 신경 쓰는 호스트가 아니다.
- TypeScript 컴파일러 자체는 다른 호스트를 모델링하는 것 외에 모듈과 관련된 어떠한 동작도 제공하지 않으므로 호스트가 아니다
typescript 컴파일러가 하는 일
import sayHello from "greetings";
sayHello("world");
다시 돌아와서 이런 코드를 트랜스파일링할때 타입스크립트는 호스트를 고려해서 아래의 일을 수행한다고 정리할 수있다.
- 파일을 유효한 모듈 형식(ESM, CJS)으로 변환해준다.
- 결과 코드에서 import가 실제로 잘 되는지 확인한다. (import한 코드 조각이 유효한지)
- import한 요소의 타입을 할당한다. (ex) sayHello에는 string타입의 인자가 하나 들어오는구나!)
이번 게시글 및 docs에서는 module관련한 내용만 다루기때문에 3번에 대한 내용은 다루지 않는다.
1, 2번을 알아가보자.
1. typescript 컴파일러는 파일을 유효한 모듈 형식(ESM, CJS)으로 변환해준다.
tsconfig의 module필드
우리는 기본적으로 tsconfig의 module옵션을 통해 typescript컴파일러에게 어떤 형태의 모듈로 ts코드를 js로 트랜스파일링할 것인지를 변경시킬 수 있다.
이 module 옵션은 ESM이냐 CJS냐, ESM이면 몇 버전이냐만을 결정하는것 뿐만 아니라, moduleResolution방식에도 영향을 미친다.
예를들어 module옵션이 CommonJS인데, moduleResolution을 Bundler로 설정할 수는 없다.
또한 설정한 module버전에따라 ESM의 경우에는 top-level await와 같은 기능에도 영향을 미친다.
typescrit 컴파일러의 모듈 종류 자동 감지
typescript 컴파일러는 tsconfig뿐만 아니라 해당 파일의 파일확장자와 package.json을 읽어서 모듈 종류를 자동으로 감지하기도 한다.
그런데 이건 NodeJS와 동일한 방식으로 이뤄지므로, NodeJS의 모듈 종류 감지 방법을 먼저 이해해야한다.
NodeJS는 ESM과 CJS를 모두 이해한다. 하지만 어떻게 구성하냐에 따라 오류가 발생할 수 있다.
1. package.json의 type필드
package.json의 type필드를 module로 설정하면 근처 파일은 ESM으로 인식한다.
그게 아니라면, 즉, type필드의 값이 module이 아니거나 비어있다면 CJS가 되는 것이다.
2. .cjs, .mjs으로 설정하기
두번째는 package.json보다 우선순위가 높다. 바로 파일확장자를 통해 설정하는 방법이다.
파일 확장자를 cjs로 설정하거나, mjs로 설정하면 각각 CJS, ESM모듈로 해석한다.
typescript 컴파일러는?
타입스크립트 컴파일러는 tsconfig의 module이 node16이나 nodenext로 되어있을때, package.json의 type필드를 확인해서 모듈 종류를 파악한다.
또한, 파일 확장자가 .mts,.cjs 인경우 NodeJS와 동일하게 ESM, CJS로 이해하며 각각 트랜스파일링시 확장자가 .mjs, .cjs로 변경되게 된다.
2. typescript 컴파일러는 결과 코드에서 import가 실제로 잘 되는지 확인한다
이 부분이 가장 핵심이다. 얼핏 생각하기로는 ts컴파일러는 타입체크와 모듈 종류를 바꾸는 것에 특화되어있다고 오해하기가 쉬운데, 되돌아보면 import를 잘못하는 경우, tsc를 했을때 ts는 바로 화를 내준다.
ts컴파일러는 모듈이 실제로 있는지, import가 잘되는 것인지를 확인해주는 것이다.
모듈의 식별자(경로, 이름, 확장자)는 변환되지 않는다.
컴파일러 옵션의 module은 import, export 등을 CJS, ESM과 같은 다양한 모듈 종류로 변환할 수 있지만, 모듈의 경로, 이름, 확장자를 변환하지 않는다.
예를들어 아래와 같은 파일이 있다고 가정하자.
import { add } from "./math.mjs";
add(1, 2);
이걸 CJS로 변환해버리면 이렇게 변환이 된다.
const math_1 = require("./math.mjs");
math_1.add(1, 2);
모듈의 식별자(이름, 경로, 확장자)가 유지된 것이다.
즉, 모듈의 식별자는 컴파일러 옵션에 관계없이 항상 입력파일, 즉 ts파일에서 사용된 것 과 동일하다.
모듈 식별자를 변환, 대체, 또는 재작성할 수 있는 타입스크립트 컴파일러 옵션은 없다.
왜 변환해주지 않는가?
모듈을 실제로 import하여 소비하는 것인 호스트
모듈을 실제로 import하여 소비하는 것인 호스트이기 때문이다. 타입스크립트는 확인만 해줄 뿐이다.
ts코드 -> js코드 -> 소비의 흐름인데, ts코드에서 js코드로 넘어갈때 모듈의 식별자가 바뀌어버린다면, 최종 소비자는 예상치 못하게 오류를 맞이할 수 있고, 이는 타입스크립트의 제작 의도와는 정말 정 반대의 결과가 되어버린다.
따라서 변환해주지않는다.
모듈의 체크방식
ts는 어떻게 import "모듈"이 정상적인 구문인지 확인하는걸까?
아래와 같은 코드가 있다고 하자
// @Filename: math.ts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: main.ts
import { add } from "./math";
add(1, 2);
이러면 ./math가 유효한지 확인하는 방식을 생각해볼 수 있다.
이렇게 되는게 아닐까? 라고 하면 완전히 틀린건 아니지만 ,실제로는 이렇게 작동한다고한다.
실제로는 변환된 js파일을 통해 확인하는 것이다.
이걸 이해하면 이런 경우도 이해할 수 있다.
// @moduleResolution: node16
// @rootDir: src
// @outDir: dist
// @Filename: src/math.mts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: src/main.mts
import { add } from "./math.mjs";
add(1, 2);
main.mts파일에서 add함수를 math.mjs 에서 꺼내서 쓰고 있다...?
원래라면 뭔가 이상하다 느껴야 하지만, 위의 그림으로 이해하면 충분히 이해할 수 있다.
자 그럼 이 중간에 파일의 확장자를 마구 바꿔버린다면?
ts컴파일러가 제대로 import구문의 유효성을 검사할 수 없게된다.
따라서 확장자를 바꾸지 않는것이다.
그럼 라이브러리 코드는 어떻게 유효성 검사함?
라이브러리 코드는 tsc를 할수가 없다. 보통은 ts파일형태로 배포하지 않기때문이다.
그럼 ts컴파일러는 어떻게 라이브러리 코드에 대한 import문이 유효한지 확인할 수 있을까?
d.ts가 등장할때.
지금이 바로 d.ts가 활약할 때다.
d.ts는 수동으로 쓰기도하지만 보통은 tsc --declaration을 통해 만들어진다.
해당 명령어를 수행시키면 js파일과 d.ts파일이 하나 나오게된다.
이렇게 해서 만들어지는 d.ts파일이기때문에 d.ts파일이 있으면 typescript 컴파일러는 해당 d.ts에 해당하는 .js파일이 있다고 가정해버린다.
모든 module resolution에서 typescript는 d.ts파일을 찾고, 이걸 먼저 찾으면 더이상 .js파일을 찾지않는다. 왜냐하면 둘다 tsc를 통해 만들어지기 때문이다.
+) 라이브러리를 위한 컴파일 옵션
라이브러리 제작자라면, 어찌됐던 많은 곳에서 작동되기를 원한다.
moduleResolution을 bundler나 node10, esnext으로 설정해버리면, 확장자가 안붙는다. (ts 컴파일러는 확장자를 변환하지 않는다.)
따라서 번들러가 없으면 작동하지 않는것이다.
그래서 tsdocs에서는 moduleResolution을 nodenext로 설정하는걸 권장한다.
이를 통해 라이브러리 코드에 확장자를 붙여주면, 확장자가 들어가기때문에 번들러가 없는 nodeJS환경에서도 잘 작동되는 것을 확인할 수 있다.
후기
라이브러리를 만들다가 컴파일 결과에 도대체 왜 확장자가 안붙는건가? 고민하다가 여기까지 오게되었다.
꽤나 딥다이브해서 ts의 컴파일러에 대한 이해도를 높힐 수 있었고, 번역해서 최초로 오픈소스에 PR도 해볼 수 있어서 굉장히 시간은 많이 들었지만, 그만큼 많이 성장할 수 있었다.