Module Resolution
최근 회사에서 서비스 패키지에서 저희 쪽 라이브러리의 모듈을 찾지 못하는 문제가 있었어요. ts 에러를 확인했고 모듈 해석의 문제로 보여, typescript 버전을 5 버전대로 올린 후 tsconfig.json 파일에서 moduleResolution 옵션을 bundler로 변경해 빠르게 해결 방법을 찾을 수 있었습니다.
빠르게 해결은 했지만, 이게 왜 해결 방법이 되었는지 그리고 그 전에는 왜 모듈 해석이 불가능했던 것인지 궁금했어요. 그래서 이번 글을 통해 tsconfig.json에 대해 톺아보고 모듈 해석에 대해서도 제대로 알아보려고 합니다.
모듈 해석이란
모듈 해석(module resolution)은 코드에서 모듈 경로를 지정해서 불러올 때 실제 파일 경로나 패키지로 찾아내는 과정을 말합니다. 즉, import 혹은 require로 지정한 경로에서 모듈을 찾아내는 과정을 의미합니다.
모듈을 찾을 때는 경로 기반 해석과 패키지 기반 해석 방식이 있습니다. 경로 기반은 경로 문자열(./, ../, alias 등)을 사용해 파일 경로를 직접 지정하는 방식이에요. 패키지 기반은 불러오기 대상이 패키지 이름일 때 모듈 해석 규칙에 따라 패키지를 찾게 되는데요. 일반적으로는 node_modules 에서 탐색하지만, PnP의 경우 물리적 디렉토리 없이 .pnp.cjs 같은 매핑 정보로 패키지를 찾게 됩니다.
결론적으로는 "모듈 해석이 안 되었다"는 지정한 경로에서 모듈을 찾지 못했다라는 말이 되겠네요.
모듈 해석 전략
모듈을 해석하는 방식도 여러 가지가 있어요. 같은 불러오기 문이라도 결과가 달라질 수 있습니다.
이 전략은 tsconfig.json 파일에서 moduleResolution 옵션으로 설정할 수 있어요.
node10(이전 명칭node):node.js10버전까지 사용되던 cjs 중심 모듈 방식을 따릅니다. esm 관련 설정(exports,type)을 사용할 수 없어요.node16또는nodenext:node.js16버전 이상에서 도입된 esm과 cjs를 모두 지원하는 최신 모듈 해석 방식입니다. mjs, cjs 확장자를 구분할 수 있어요.bundler:typescript5버전대 이상부터 지원하는 방식으로, 번들러와 같은 방식으로 모듈을 해석하도록 해요.
bundler 방식은 어떤 방식일까?
일단 Typescript 컴파일러에서 이런 방식을 제공하는 이유는, 실제 실행과 타입 검사 결과의 불일치를 없애기 위함이라고 해요. 코드 실행에는 문제가 없지만 타입 에러가 나는 경우가 있을 수 있는데 이런 잘못된 타입 에러를 방지하는 것이죠.
bundler로 설정하면 Typescript 내부에 번들러들이 일반적으로 사용하는 모듈 해석 규칙을 구현해 둔 별도의 해석 로직을 사용하게 된다고 합니다. Node.js 기본 규칙보다 유연하다고 하는데요.
bundler 방식은 어떻게 해석하는걸까?
bunlder 방식은 Typescript 5.0부터 지원하는 방식이에요.
node.js 모듈 해석 방식이 가지고 있던 esm 제약사항을 해결하기 위해 더 유연하고 강력한 기능을 제공한다고 해요. 예를 들어, 확장자를 명시하지 않아도 자동 탐색 가능하고 폴더 단위 import도 가능해 /index.js로 명시하지 않아도 index 파일을 탐색하는 것이 가능하다고 합니다.
https://github.com/microsoft/TypeScript/pull/51669
즉, 번들러의 모듈 해석 방식과 타입을 해석하는 방식을 동일하게 해 사용자가 불러온 모듈과 TypeScript 컴파일러가 해석한 모듈이 일치하게 되는 것이죠.
번들러가 모듈을 해석하는 것과 Typescript 컴파일러가 모듈을 해석하는 것은 어떤 관계?
그렇다면 이 둘은 어떤 관계일까요? 번들러는 빌드 시 모듈을 해석해서 번들링을 하고, Typescript 컴파일러는 컴파일 시 타입 정보를 통해 해석하게 됩니다. 이때 moduleResolution 옵션을 bundler로 사용하면 동일한 해석 규칙을 사용하게 되는 것이고 이로써 모듈 해석 방식을 맞추는 것이죠.
Typescript 컴파일러 모듈 해석 기본 원리 (+ 타입 체크)
살펴보았을 때 전략마다 조금씩 해석 방식이 다른 것이지 결국에는 "해석한다"는 것을 동일합니다. 모듈 해석은 파일 시스템 기반의 심볼 조회 과정이고, 타입 체커가 타입을 확인할 때 필요한 전 단계라고 볼 수 있을 것 같아요.
대략적인 과정은 다음과 같다고 해요.
- 소스코드를 Syntax Tree로 만들기
- 타입 검사
가장 먼저 소스코드를 Syntax Tree로 변경합니다. 변경하는 이유는 먼저 구분 분석 과정을 거쳐 계층적이고 구조화된 자료구조로 만들어야 이후 타입 검사와 트랜스파일링을 진행할 수 있습니다.
const sum = a + b * 2;위 소스코드를 Syntax Tree로 변경하면 다음과 같아요. (TypeScript AST Viewer)
VariableDeclaration
name: sum
initializer:
BinaryExpression
left: a
operator: +
right:
BinaryExpression
left: b
operator: *
right: 2Syntax Tree로 무엇을 할 수 있는걸까?
최근 회사에서 codemod를 지원하기 위해 tsmorph라는 라이브러리를 통해 AST를 분석하고 조작하는 작업을 했었어요. tsmorph는 AST를 쉽게 탐색하고 수정하고 생성할 수 있게 해주는 라이브러리인데요. 이 라이브러리를 사용하면서 AST를 통해 정말 많은 것들을 구분해서 탐색할 수 있었습니다.
변수나 속성을 선언할 때 초기값을 지정하는 부분을 나타내는 노드를 initializer라고 합니다. 쉽게 생각하면 구문의 오른쪽 값이에요. 또, AST에서는 Syntax kind라는 개념이 있는데, 이 값이 string인지, number인지, 변수인지도 모두 알 수 있어요. 종류가 300개 이상으로 정말 정교하게 구분할 수 있었어요. 그리고 단순히 코드 구조만 보는 것이 아니라 이름이 붙은 요소들이 실제로 무엇을 의미하는지를 해석해야 하는데요, 우리가 Typescrpt를 사용할 때 import한 변수의 타입도 추론되는 것처럼요. 이럴 때도 Symbol과 Declaration이라는 노드가 있어 이름이 붙은 요소가 선언된 위치와 내용을 파악할 수 있습니다.
Typescript 컴파일러의 이런 자료구조를 만드는 과정은 어떻게 될까?
Typescript 컴파일러의 이런 자료구조를 만드는 과정은 scanner.ts와 parser.ts 파일에서 확인할 수 있습니다. scanner에서는 소스코드를 위에서 봤던 어떤 Variable인지 어떤 Identifier인지 토큰으로 변환하고 parser에서는 이 토큰을 기준으로 트리를 만든다고 해요.
타입 검사는 어떻게 진행될까?
앞선 과정은 Javascript 컴파일러에도 존재하는 과정이였다면, Typescript만의 특별한 과정인 타입 검사가 다음으로 존재합니다. binder와 checker라는 두 가지 프로세스를 통해 타입 검사를 진행합니다. binder는 전체 Syntax Tree를 읽어서 타입 검사에 필요한 데이터를 수집하는 과정이라고 해요.
const message: string = 'Hello, world!';
welcome(message);
function welcome(str: string) {
console.log(str);
}위 소스코드를 살펴보면 크게 global scope와 function scope로 나눌 수 있는데요.
- global scope:
message,welcome - function scope:
str
binder는 구문 트리를 순회하면서 어디서 선언되고 사용되는지를 파악한다고 해요. 각 Symbol도 Symbol 테이블에 등록하여 이후 타입 검사 단계에서 참조할 수 있도록 합니다. 즉, 타입 검사에 필요한 데이터를 수집하는 과정이죠.
checker는 실제로 타입을 체크 진행합니다. 핵심 과정이며 github file에서도 대략 42,000줄의 코드가 포함되어 있어요. 타입 검사, 해석, 진단, 병합 등 Typescript의 타입 시스템을 구현하는 핵심 역할을 살펴볼 수 있는 메인 파일이라고 할 수 있겠습니다.
상당 부분 Typescript validation이 checker에서 이루어진다
Syntax Tree 노드 종류마다 checker 함수가 있다고 해요.
const message: string = 'Hello, world';VariableStatementVariableDeclarationListVariableDeclarationIdentifierStringKeywordStringLiteral
checker.checkSourceElementWorker
checker.checkVariableStatement
checker.checkGrammarVariableDeclarationList
checker.checkVariableDeclaration
checker.checkVariableLikeDeclaration
checker.checkTypeAssignableToAndOptionallyElaborate
checker.isTypeRelatedTo
이렇게 선언문 자체를 체크하는데, 할당 가능한 타입인지 검사하면서 타입 체크를 수행하게 됩니다.
tsconfig.json 설정
Typescript는 프로젝트를 컴파일할 때마다 불러오기 문(import/require)이 실제 파일 시스템의 어떤 파일을 가리키는지 찾아내려고 합니다. 이 과정은 tsconfig.json 설정을 기반으로 시작되며 파일의 위치를 찾고 확장자를 확인하는 등 다양한 전략을 적용해요.
tsconfig.json 파일은 Typescript 컴파일러가 모듈을 해석하는 데 필요한 정보를 담고 있어요. 불러오기의 루트 경로를 설정하거나 아예 컴파일 경로에 포함하거나 제외할 경로를 지정할 수도 있고, 추가 검사 옵션을 설정할 수도 있어요. 이런 설정을 통해 컴파일러가 모듈을 해석하는 방식을 조정할 수 있습니다.
중요한 건 프로젝트 내 tsconfig.json 설정을 기반으로 모듈을 해석하게 된다라는 사실인데요. 즉, 라이브러리에서 어떤 것을 지원한다고 해도 그걸 사용하는 프로젝트 tsconfig.json 설정에 따라 그 지원을 해석하지 못할 수 있습니다.
실제로 겪은, 지원은 하는데 그걸 사용하지 못하는 경우
exports 필드로 정의한 sub path를 해석하지 못하는 경우였는데요. 예를 들어 import { Foo } from '@temp/foo' 같이 "패키지명/exports 필드로 정의한 경로"를 해석하지 못하는 경우였어요. Are the types wrong? 에서 확인했을 때, node10에서는 sub path 해석이 안 되는 것을 확인할 수 있었어요.
Are the types wrong? - @chakra-ui/react
처음에는 패키지 자체의 지원 문제인가? 했지만 알고보니 node10 모듈 해석 방식에서는 exports 필드를 지원하지 않는다는 것을 알게 되었습니다. 프로젝트의 tsconfig.json을 확인해보니 모듈 해석 방식이 node로 설정되어 있었고, 이를 bundler로 변경하여 문제를 해결할 수 있었어요.
덕분에 사용처의 tsconfig.json 설정에 따라 모듈을 해석 자체에 문제가 생길 수 있음을 알게 되었습니다.
출력과 해석
호스트 관점에서 "출력"과 "해석"에 대해 생각해볼 수 있는데요. 아, 호스트는 공식 문서에서 표현하는 모듈 로딩 동작을 결정하는 시스템을 의미해요.
- 출력: 호스트가 컴파일한 결과물
- 해석: 호스트의 프로젝트 내 모듈을 해석하는 방식
tsconfig.json 설정으로 module과 moduleResolution 옵션을 설정할 수 있어요. 각각 출력과 해석 방식에 대한 옵션입니다. 예를 들어, module을 ESNext로 설정하면 컴파일 시 esm 모듈 형식의 Javascript 파일이 출력되고, moduleResolution을 node로 설정하면 프로젝트 내에서 해석 방식이 cjs 모듈 해석 방식을 사용해요.
둘을 맞춰서 설정할 필요가 있을까?
Typescript 5.2.0 버전부터 맞추지 않을 경우 에러가 발생합니다. 공식 문서에서 허용되는 조합을 확인할 수 있습니다. 내부 모듈의 해석 방식과 출력되는 모듈의 방식을 일치시켜, 출력되는 파일의 형식을 예측 가능하게 하기 위함이라고 하네요.
컴파일 에러가 나는 모습
package.json의 type과 tsconfig.json의 moduleResolution
둘 다 해석 방식을 결정해요. 근데 주체가 다릅니다.
package.json의 type은 Node.js가 실제 코드 실행 시 .js를 esm/cjs로 판단하는 기준이고, tsconfig.json의 moduleResolution은 Typescript 컴파일러가 컴파일 시 타입 체크와 import 해석과 같은 모듈 해석 방식을 결정하는 기준이에요.
라이브러리를 사용한다고 생각해보면, import 문을 보고 node_modules에서 모듈을 찾을텐데요. 다음과 같이 "해석"의 주체가 다릅니다.
- Node.js: 라이브러리의
package.json의type필드를 확인"type": "module"이면, esm 모듈로 실행"type": "commonjs"또는 없으면, cjs 모듈로 실행
- Typescript 컴파일러:
tsconfig.json의moduleResolution필드를 확인- 모듈 파일과 타입 정의를 찾아내는 방식을 결정
- 타입 체크과
import경로 검증을 수행
참고로 Node.js v12 이상에서는 cjs/esm 모듈 모두 지원하지만, package.json 파일을 탐색해서 형식을 결정하고 파일 내용이 예상 형식과 일치하지 않으면 오류를 발생시킨다고 합니다. 가령 예를 들면 type이 module이라고 명시되어 있는데 import 문에서 확장자가 명시되어 있지 않으면 런타임 오류가 발생합니다.
결론적으로는, package.json의 type은 어떻게 실행할지를 결정하고, tsconfig.json의 moduleResolution은 호스트의 프로젝트에서 컴파일 시에 모듈을 어떻게 해석할지를 결정하는 것이라고 할 수 있겠네요.
어떻게 실행할지를 결정해야 하는 이유?
문법이나 동작 방식에 차이가 있기 때문에 실행 방식을 결정해야 합니다. 예를 들어, esm은 import/export 문을 사용하고 cjs는 require/module.exports를 사용하며 esm은 비동기 모듈 로딩을 지원하고 정적 분석이 가능한 반면, cjs는 동기식 로딩과 동적 모듈 해석을 사용해요. 이런 차이점 때문에 Node.js가 코드를 실행할 때 어떤 방식으로 해석하고 실행할지 알아야 한다고 합니다.
esm 프로젝트에서 cjs 파일을 사용하면?
esm 프로젝트라고 해서 모든 파일이 esm이어야 하는 건 아니에요. esm 프로젝트라고 하면 .js 확장자를 기본적으로 esm 방식으로 실행하는 것이고 cjs 방식을 사용해야 한다면 .cjs 확장자를 사용하면 됩니다. 출력 모듈이 esm 방식이라면 컴파일러에 의해 esm으로 출력될 수 있어요.
// cjs
const PI = 3.14159;
const add = (a, b) => a + b;
const multiply = (a, b) => a * b;
exports.PI = PI;
exports.add = add;
exports.multiply = multiply;
module.exports = {
PI,
add,
multiply,
divide: (a, b) => a / b,
Calculator: class Calculator {
constructor() {
this.result = 0;
}
add(value) {
this.result += value;
return this;
}
multiply(value) {
this.result *= value;
return this;
}
getResult() {
return this.result;
}
},
};
esm으로 출력되는 것을 확인할 수 있었어요
번들러에서 모듈 다루기
Rollup 공식 문서 내용 중 The Why 부분을 보면 esm은 최신 브라우저에서만 구현되어 있다고 말하면서 Rollup을 사용하면 다른 모듈 형식으로 컴파일할 수 있다고 이야기 하고 있어요. 즉, 번들링하는 과정에서 런타임에서 곧잘 실행할 수 있도록 해준다고 처리해준다고 생각할 수 있어요.
이걸 모듈을 resolve 한다고 표현합니다. 예를 들면 from '/some'이라면 from '/some/index.js'로 변경하는 것 같은 것이죠. 혹은 esm에서는 파일 확장자를 명시해야 하는데 명시가 되어 있지 않다면 이에 대한 처리도 해줄 수 있다고 하네요.
런타임에서 모듈 실행
드디어 모듈 실행 이야기를 해보려고 해요. 모듈 실행은 런타임에서 이루어집니다. 브라우저 환경 기준으로 이야기 해보자면, 브라우저가 index.html을 파싱하면서 <script> 태그를 만나면 해당 파일을 로드하고 실행하는 것이 모듈 실행이라고 할 수 있어요.
브라우저가 esm 모듈을 실행할 수 있냐는 <script type="module"> 태그와 import/export를 이해할 수 있느냐를 의미한텐데요. can i use에서 esm이 도입된 es6에 대해 확인해보면 2017년 이전 브라우저에서는 미지원해요.
can i use - es6 module
미지원 브라우저에 대해 어떻게 대응할 수 있을까?
@vitejs/plugin-legacy 처럼 구형 브라우저에서도 사용할 수 있도록 별도로 청크를 만들어두면 되는데요. 플러그인 README를 살펴보면, 런타임 검사를 삽입하고 esm을 지원하지 못하는 경우 레거시 번들을 로드하는 방식으로 작동한다고 하네요.
이렇게 부딪혔던 문제를 계기로 모듈 해석에 대해 알아보았습니다. 😌 패키지를 개발하고 서빙하는 것도 중요하지만, 패키지를 사용하는 서비스 코드에서 허우적거렸던 저를 보면서 해석 자체에 대해 알아볼 필요가 있다고 생각이 들었던 것 같아요. (아직도 허우적 거리긴 합니다 ㅎㅎ)
최근에 읽고 있는 『소프트웨어 아키텍처 101』 책에서도 시스템 엔지니어는 실무 능력을 유지하는 것이 중요하다고 강조하고 있어요. 이는 제가 이 글을 쓰게 된 계기와도 일맥상통하는데요. 제품을 개발하는 것도 중요하지만, 그 제품이 실제로 사용되는 환경에서 발생하는 문제들을 정확히 이해하고 해결할 수 있는 능력이 얼마나 중요한지를 이번 경험을 통해 느꼈던 것 같아요.
제품을 설계하는 것만큼이나 이런 문제를 만나는 것도 재밌는 것 같아요. 😉
Reference
- TypeScript 공식 문서 - Module Resolution
- TypeScript 5.0: new mode bundler & ESM
- https://github.com/microsoft/TypeScript/pull/51669
- TypeScript 5.0 -
--moduleResolution bundler| Release Report - What TypeScript Does with Module Resolution
- 타입스크립트 컴파일 설정 - tsconfig 옵션 총정리
- 타입스크립트 컴파일러는 어떻게 동작하는가?
- TypeScript Deep Dive - TypeScript Compiler Internals
- How the TypeScript Compiler Compiles - understanding the compiler internal
- 간단한 유틸 함수 NPM 라이브러리 배포해보기 (feat. TypeScript 지원, ESM 지원)
- Modules - Theory
- Pure ESM package