본문 바로가기

프로젝트 레퍼런스

하나의 저장소를 composite 프로젝트 여러 개로 나누고 references로 의존 관계를 적은 뒤, tsc -b가 참조의 그래프를 따라 필요한 프로젝트만 순서대로 빌드하는 과정과, tsc -p와의 차이, 참조가 실행 중의 import 해석을 대신하지 않는다는 점을 작은 모노레포로 확인합니다.

저장소가 커지면 하나의 tsconfig.json으로 모든 코드를 한꺼번에 검사하고 출력하기가 부담스러워집니다. 파일 하나를 고쳐도 전체를 다시 컴파일해야 하고, 서로 관계없는 코드끼리도 한 프로그램 안에서 섞여 버리기 때문입니다. 여러 패키지를 한 저장소에서 관리하는 모노레포(monorepo)에서는 이 문제가 더 두드러집니다.

프로젝트 레퍼런스(project references)는 저장소를 각자 tsconfig.json을 가진 작은 프로젝트 여러 개로 나누고, 어느 프로젝트가 어느 프로젝트에 의존하는지를 설정에 적게 하는 기능입니다. 이 글은 두 프로젝트로 이루어진 작은 모노레포를 설정하고, tsc -b가 참조를 따라 빌드하는 과정과 그 한계를 확인합니다.

참조의 그래프와 빌드 순서

solution에서 server와 client로, 두 프로젝트에서 shared로 참조 화살표가 이어진다. 모든 참조 대상은 composite이다. 실제 빌드는 shared부터이며 build info는 프로젝트별 증분 상태다.

REFERENCE GRAPH / BUILD ORDER

참조 화살표와 실제 빌드 순서는 반대다

solution이 server와 client를 참조하고 두 소비자가 shared를 참조하는 예입니다. 화살표는 의존 대상이며, tsc -b는 shared를 먼저 준비한 뒤 오래된 소비자를 빌드합니다.

참조 화살표와 실제 빌드 순서는 반대다solution에서 server와 client로, 두 프로젝트에서 shared로 참조 화살표가 이어진다. 모든 참조 대상은 composite이다. 실제 빌드는 shared부터이며 build info는 프로젝트별 증분 상태다.solution tsconfigfiles: [] · referencesservercomposite: trueclientcomposite: trueshared · compositedeclaration producer화살표: 참조 방향 · 빌드: shared부터
소비자의 참조와 생산자 우선 빌드solution은 server와 client를 참조하고, 두 소비자는 shared를 참조합니다. 네 참조 방향을 유지하며 실제 빌드는 shared부터입니다.solution tsconfigfiles: [] · referencesservercomposite: trueclientcomposite: trueshared · compositedeclarationproducer화살표: 참조 방향빌드: shared부터

REFERENCE

소비자 → 생산자

server/client의 references는 shared 설정을 가리킵니다. 이 그림은 본문의 ui → common 한 갈래를 두 소비자로 확장한 예입니다.

CONTRACT

참조 대상 모두 composite

루트가 참조하는 server/client도 composite: true입니다. 참조 대상은 선언 출력과 구현 파일의 include/files 조건을 만족해야 합니다.

BUILD

의존 대상부터 빌드

tsc -b는 그래프를 읽고 오래된 프로젝트를 올바른 순서로 빌드합니다. references 배열 순서가 곧 빌드 순서는 아닙니다.

CACHE

프로젝트별 증분 정보

.tsbuildinfo는 각 프로젝트의 재사용 상태입니다. shared 하나만 만드는 공통 배포 파일이 아니며 runtime 패키지 구성도 하지 않습니다.

공개 선언 변화와 옵션·산출물·캐시 상태에 따라 재빌드 범위가 달라집니다.

위 그림은 전체를 묶는 설정이 server와 client를 참조하고, 두 프로젝트가 모두 shared를 참조하는 예입니다. 화살표는 "이 프로젝트가 저 프로젝트에 의존한다"는 방향이므로, 실제로 빌드할 때는 화살표를 거꾸로 거슬러 의존 대상인 shared부터 준비하고 그다음 소비하는 쪽을 빌드합니다. 그리고 각 프로젝트는 자기의 빌드 정보를 따로 남겨 두어, 바뀐 것이 없는 프로젝트는 다시 빌드하지 않습니다.

이렇게 나누면 바뀐 프로젝트와 그것에 의존하는 프로젝트만 다시 빌드하므로 빌드가 빨라지고, 프로젝트끼리의 의존 관계가 설정에 드러나 구조를 파악하기도 쉬워집니다. 편집기도 작업 중인 프로젝트와 그 의존 대상만 분석하면 되므로 더 가볍게 동작합니다.

작은 모노레포 설정하기

common 프로젝트가 타입을 제공하고, ui 프로젝트가 그 타입을 쓰는 구조를 만들어 봅니다. 새 폴더 ts-monorepo를 다음과 같이 구성합니다.

ts-monorepo/
├── package.json
├── tsconfig.json
└── packages/
    ├── common/
    │   ├── tsconfig.json
    │   └── src/types.ts
    └── ui/
        ├── tsconfig.json
        └── src/button.ts
package.json
{
  "private": true,
  "type": "commonjs"
}

다른 프로젝트가 참조할 프로젝트에는 composite를 켭니다. composite는 이 프로젝트를 참조의 대상으로 쓸 수 있게 하는 옵션으로, 참조하는 쪽이 읽을 선언 파일을 반드시 출력하도록 declaration을 함께 켜고, 프로젝트의 모든 소스 파일이 include나 files에 들어 있기를 요구합니다.

packages/common/tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "composite": true,
    "declarationMap": true,
    "noEmitOnError": true
  },
  "include": ["src/**/*.ts"]
}
packages/common/src/types.ts
export interface CommonType {
  label: string;
}

declarationMap은 선언 파일에서 원래 소스로 가는 지도를 만들어, 편집기에서 CommonType의 정의로 이동할 때 .d.ts가 아닌 types.ts를 열게 합니다. 소비하는 쪽인 ui는 references에 의존하는 프로젝트의 폴더를 적습니다.

packages/ui/tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "composite": true,
    "noEmitOnError": true
  },
  "include": ["src/**/*.ts"],
  "references": [{ "path": "../common" }]
}
packages/ui/src/button.ts
import type { CommonType } from "../../common/src/types";

export const button: CommonType = { label: "save" };
console.log(button.label);

ui는 common의 소스 경로로 가져오지만, 참조가 설정되어 있으면 컴파일러는 그 소스 대신 common이 출력한 선언 파일을 읽어 검사합니다. 마지막으로 저장소의 맨 위에 두 프로젝트를 묶는 설정을 둡니다. 순서를 외우지 않도록 일부러 ui를 먼저 적었습니다.

tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./packages/ui" },
    { "path": "./packages/common" }
  ]
}

이 설정은 자기 소스 파일 없이 하위 프로젝트들을 모아 빌드를 조정하는 역할만 하며, 이런 설정을 솔루션(solution) 설정이라고도 부릅니다. references가 있으므로 빈 files를 두어도 1편에서 본 TS18002가 나오지 않습니다.

tsc -b로 빌드하기

dist와 빌드 정보가 하나도 없는 처음 상태에서 다음을 실행합니다. 첫 명령은 프로젝트 하나를 고르는 tsc -p와 달리 맨 위의 솔루션 설정에서 출발하고, 둘째 명령은 빌드된 ui 패키지의 출력을 Node.js로 실행합니다. 출력 폴더가 없는 상태에서 시작하므로, 실행에 쓰이는 파일은 모두 이 한 번의 tsc -b가 만든 것입니다.

tsc -b
node packages/ui/dist/button.js
save

tsc -b는 빌드 모드(build mode)로, 맨 위 설정의 참조를 따라가 의존 관계의 그래프를 만들고 그 순서대로 빌드합니다. ui가 common을 참조하므로 배열의 순서와 관계없이 common을 먼저 빌드해 dist에 types.js와 types.d.ts, types.d.ts.map을 만들고, 그 선언으로 ui를 검사한 뒤 button.js를 출력합니다. import type은 출력에서 지워지므로, button.js는 실행 중에 common을 불러오지 않습니다.

아무것도 바꾸지 않고 tsc -b를 한 번 더 실행하면, 각 프로젝트의 출력이 입력보다 새롭다는 것을 빌드 정보로 확인하고 다시 컴파일하지 않습니다. 이때 tsc -b --verbose를 쓰면 프로젝트마다 다시 빌드하는지와 그 이유를 출력하므로, 무엇이 빌드되었는지 확인할 수 있습니다. tsc -b --clean은 빌드 모드로 만든 출력을 지웁니다.

빌드 모드가 아닌 tsc -p packages/ui는 다릅니다. 이 명령은 ui 하나만 검사하고 참조한 프로젝트를 대신 빌드해 주지 않으므로, 처음 상태에서 실행하면 common의 선언 파일이 아직 없어 Output file로 시작하고 has not been built from source file로 끝나는 TS6305가 나옵니다. 편집기는 참조한 프로젝트의 선언을 메모리에서 만들어 보여 주기도 하므로, 편집기에서 타입이 보인다고 명령줄의 검사도 통과한다고 판단하지 않습니다.

참조는 순환할 수 없습니다. common이 다시 ui를 참조하면 Project references may not form a circular graph.로 시작하는 진단이 나오며, 이것은 두 프로젝트를 나눈 방식에 문제가 있다는 신호입니다.

더 깊이: 의존 대상에 오류가 있을 때의 빌드 모드

TypeScript 5.5까지의 빌드 모드는 noEmitOnError가 켜진 것처럼 동작해, 어느 프로젝트에서 오류가 나면 거기서 빌드를 멈췄습니다. 그래서 common에 오류가 있으면 ui는 검사조차 받지 못했습니다. 오류가 있는 프로젝트는 그 프로젝트에 기대는 쪽에게 믿을 만한 상태가 아니라는 생각이었지만, 실제로는 의존 대상의 업그레이드가 끝날 때까지 소비하는 쪽이 아무 작업도 못 하게 막는 문제가 있었습니다.

TypeScript 5.6부터 빌드 모드는 의존 대상에 오류가 있어도 나머지 프로젝트를 끝까지 빌드합니다. 오류는 빠짐없이 보고하고, 출력 파일은 할 수 있는 만큼 만듭니다. 예전처럼 처음 오류가 난 프로젝트에서 멈추고 싶다면 --stopOnBuildErrors를 붙이며, 릴리스 노트는 CI나 다른 프로젝트가 많이 기대는 프로젝트를 고치는 중에 이 옵션이 쓸모 있다고 적습니다. 이 동작을 위해 5.6부터 빌드 모드는 incremental이나 composite를 켜지 않은 프로젝트에도 언제나 .tsbuildinfo를 기록합니다.

참조와 실행 중의 import

references는 프로젝트 사이의 타입과 빌드의 의존 관계를 정할 뿐, 출력한 import의 경로를 바꾸거나 실행할 때 모듈을 찾아 주지 않습니다. 앞의 예는 타입만 가져왔기 때문에 출력에 import가 남지 않았지만, common의 값을 가져온다면 출력된 require("../../common/src/types")가 실행 중에 정말 그 위치의 자바스크립트를 찾을 수 있는지 따로 맞추어야 합니다.

타입 평면에서 producer d.ts→consumer check, 실행 평면에서 emitted import→loader bundler의 독립된 두 관계를 비교한다.

TYPE PLANE / RUNTIME PLANE

타입 선언 소비와 실행 import 해석을 나누어 확인한다

consumer는 참조 대상의 선언으로 타입을 검사합니다. 값 import가 출력에 남으면 실제 loader나 bundler가 그 지정자를 해석해야 하며, references와 paths는 이를 대신하지 않습니다.

타입 선언 소비와 실행 import 해석을 나누어 확인한다타입 평면에서 producer d.ts→consumer check, 실행 평면에서 emitted import→loader bundler의 독립된 두 관계를 비교한다.타입 검사producer .d.ts공개 타입 계약consumer check선언으로 검사실행 해석 · 값 import가 남는 경우emitted importJavaScript specifierloader · bundlerexports · alias 해석
타입 선언과 실행 해석의 두 독립 관계타입 평면은 producer d.ts에서 consumer check로, 실행 평면은 emitted import에서 loader bundler로 향합니다. 값 import가 남는 경우에만 두 번째 평면을 확인합니다. 두 평면 사이의 간선은 없습니다.producer .d.ts공개 타입 계약consumer check선언으로 검사emitted importJavaScript specifierloader · bundlerexports · alias 해석타입 검사실행 해석값 import가 남는 경우

TYPE

참조 대상의 선언을 사용

references와 빌드 설정은 생산자의 .d.ts를 준비하고 소비자의 타입 검사를 잇습니다. 편집기 인메모리 선언과 명령행 빌드를 구별합니다.

RUNTIME

남은 import 문자열을 해석

paths·references는 출력 import 문자열을 바꾸지 않습니다. 실제 package exports나 bundler alias가 지정자를 찾을 수 있어야 합니다.

TYPE-ONLY

타입 전용 import

import type은 JS 출력에서 제거됩니다. 값 import가 출력에 남는 경우에 runtime 해석 계약이 필요합니다.

VERIFY

검사 성공과 실행 성공을 함께 본다

타입 검사가 통과했어도 loader가 같은 지정자를 모르면 실행은 실패합니다. 선언 경계·빌드 순서·실제 패키지를 각각 확인합니다.

한 평면의 성공으로 다른 평면의 성공을 추론하지 않습니다.

위 그림처럼 두 관계는 서로 다른 평면에 있습니다. 타입의 평면에서는 의존 대상이 출력한 선언으로 소비하는 쪽을 검사하고, 실행의 평면에서는 출력에 남은 값의 import를 Node.js나 번들러가 해석합니다. 모노레포에서는 보통 각 프로젝트를 npm의 workspaces(작업 공간)로 묶은 패키지로 만들어 package.json의 exports로 실행할 파일과 선언을 알리고, import { CommonType } from "@my/common"처럼 패키지 이름으로 가져와 두 평면을 함께 맞춥니다. 16장의 실전 프로젝트는 workspaces 대신 paths와 import type으로 타입만 공유하며, 값까지 공유하려면 Vite의 resolve.alias나 workspaces로 실행 중의 경로도 연결해야 한다는 점을 그 장에서 다시 확인합니다.

정리

  • 프로젝트 레퍼런스는 저장소를 tsconfig.json을 가진 작은 프로젝트로 나누고, references로 의존 관계를 적는 기능입니다.
  • 참조될 프로젝트에는 composite를 켜며, 이 옵션은 선언 파일의 출력과 모든 소스 파일의 포함을 요구합니다.
  • tsc -b는 참조의 그래프를 따라 의존 대상부터 빌드하고, 바뀐 것이 없는 프로젝트는 빌드 정보를 보고 건너뜁니다.
  • tsc -p는 한 프로젝트만 검사하고 참조한 프로젝트를 빌드하지 않으므로, 선언이 없으면 TS6305가 나옵니다.
  • references는 실행 중의 import 해석을 대신하지 않으므로, 값을 가져온다면 패키지의 exports나 번들러로 실행의 경로도 맞춥니다.

연습 문제

1. 생산자의 타입이 바뀌었을 때

처음 빌드에 성공한 뒤 common의 label: string을 label: number로 바꾸고 tsc -b를 실행했습니다. 어디서 진단이 나오며, ui의 예전 dist/button.js는 어떻게 되나요?

해설 보기

common은 오류 없이 다시 빌드되어 새 선언을 출력하고, 그 선언으로 검사한 ui의 { label: "save" }에서 Type 'string' is not assignable to type 'number'.가 나옵니다. noEmitOnError 때문에 ui의 새 출력은 만들어지지 않지만 예전 button.js는 지워지지 않고 남습니다. 그래서 dist에 파일이 있다는 것만 보지 말고, 빌드 명령이 진단 없이 끝났는지를 확인해야 합니다.

2. 바꾼 프로젝트만 다시 빌드하기

처음 빌드에 성공한 뒤 ui의 button.ts에서 "save"를 "load"로만 바꾸었습니다. tsc -b --verbose를 실행하면 두 프로젝트 가운데 무엇이 다시 빌드되나요?

해설 보기

common의 입력은 바뀌지 않았으므로 최신 상태로 판단되어 건너뛰고, ui만 다시 빌드됩니다. 반대로 common의 공개 타입을 바꾸면, common을 다시 빌드한 뒤 그 선언에 의존하는 ui도 다시 검사합니다. --verbose의 출력에서 프로젝트마다 최신 상태인지, 무엇 때문에 다시 빌드하는지를 확인할 수 있습니다.

3. 값을 가져오는 소비자

common/src/types.ts에 export const DEFAULT_LABEL = "ok";을 더하고, ui에서 import { DEFAULT_LABEL } from "../../common/src/types";로 값을 가져와 출력했습니다. tsc -b는 성공했는데 node packages/ui/dist/button.js를 실행하면 어떻게 되나요?

해설 보기

값을 가져왔으므로 출력된 button.js에는 require("../../common/src/types")가 남는데, ui/dist를 기준으로 한 이 경로에는 자바스크립트 파일이 없어 모듈을 찾지 못하고 멈춥니다. 컴파일러는 참조 덕분에 common의 선언으로 검사했지만, 실행 중의 경로까지 바꾸어 주지는 않기 때문입니다. common을 workspaces로 묶은 패키지로 만들어 패키지 이름으로 가져오거나, 출력된 파일의 위치를 기준으로 경로를 맞추어야 합니다.

다음 글 증분 컴파일과 빌드 최적화에서는 이 글의 빌드 정보가 무엇을 기록하는지와, 빌드가 느릴 때 원인을 재고 줄이는 방법을 다룹니다.