프로젝트 레퍼런스
대규모 코드를 여러 composite 프로젝트로 나누고 references와 빌드 순서를 설정해 모노레포 의존 경계를 명확히 합니다.
대규모 타입스크립트 프로젝트를 운영하다 보면
하나의 거대한 tsconfig.json과 단일 컴파일 흐름만으로는
효율적인 관리가 어려워지는 시점이 옵니다.
특히 모노레포(Monorepo)에서 여러 하위 프로젝트가 서로 의존할 때 이 문제가 더 뚜렷하게 드러납니다.
모든 코드를 한 번에 컴파일하면 시간이 오래 걸리고, 작은 변경도 전체 빌드를 다시 시작하게 만들어 생산성을 떨어뜨릴 수 있습니다.
타입스크립트 3.0부터 도입된 프로젝트 레퍼런스(Project References) 기능은 이러한 문제를 해결하기 위한 방법입니다.
이 기능은 큰 타입스크립트 프로젝트를 각각 tsconfig.json 파일을 가진 여러 개의 작은 하위 프로젝트로 분할하고, 이들이 서로의 의존성을 명시적으로 참조할 수 있도록 합니다.
왜 프로젝트 레퍼런스를 사용해야 하는가?
프로젝트 레퍼런스를 사용하면 다음과 같은 주요 이점을 얻을 수 있습니다.
- 가장 큰 장점입니다. 변경된 하위 프로젝트와 그에 의존하는 프로젝트만 다시 컴파일하여 전체 빌드 시간을 크게 단축합니다. 대규모 프로젝트에서는 반복 빌드 비용을 줄이는 기준이 됩니다.
- 각 하위 프로젝트가 명확한 경계를 가지므로, 코드 베이스의 모듈화 수준을 높이고 의존성을 명확하게 파악할 수 있습니다. 이는 아키텍처 설계와 유지보수에 도움이 됩니다.
- 논리적으로 분리된 하위 프로젝트 덕분에 코드 베이스를 리팩터링하거나 새로운 기능을 추가할 때 훨씬 유연하게 대처할 수 있습니다.
- IDE는 전체 프로젝트가 아닌 현재 작업 중인 하위 프로젝트와 그 의존성만 분석하여, 자동 완성 및 타입 검사 성능을 향상시킬 수 있습니다.
프로젝트 레퍼런스 설정 방법
그림은 두 소비자가 shared에 의존하는 확장 구조입니다. 아래 코드는 이를 ui → common 한 갈래로 줄인 예입니다. 화살표는 참조 방향이며 실제 빌드는 의존 대상부터 수행합니다.
REFERENCE GRAPH / BUILD ORDER
참조 화살표와 실제 빌드 순서는 반대다
solution이 server와 client를 참조하고 두 소비자가 shared를 참조하는 예입니다. 화살표는 의존 대상이며, tsc -b는 shared를 먼저 준비한 뒤 오래된 소비자를 빌드합니다.
REFERENCE
소비자 → 생산자
server/client의 references는 shared 설정을 가리킵니다. 이 그림은 본문의 ui → common 한 갈래를 두 소비자로 확장한 예입니다.
CONTRACT
참조 대상 모두 composite
루트가 참조하는 server/client도 composite: true입니다. 참조 대상은 선언 출력과 구현 파일의 include/files 조건을 만족해야 합니다.
BUILD
의존 대상부터 빌드
tsc -b는 그래프를 읽고 오래된 프로젝트를 올바른 순서로 빌드합니다. references 배열 순서가 곧 빌드 순서는 아닙니다.
CACHE
프로젝트별 증분 정보
.tsbuildinfo는 각 프로젝트의 재사용 상태입니다. shared 하나만 만드는 공통 배포 파일이 아니며 runtime 패키지 구성도 하지 않습니다.
공개 선언 변화와 옵션·산출물·캐시 상태에 따라 재빌드 범위가 달라집니다.
프로젝트 레퍼런스를 사용하려면, 의존성을 가지는 각 하위 프로젝트의 tsconfig.json 파일에 몇 가지 변경 사항을 적용해야 합니다.
common 프로젝트 (생산자) 설정
common 프로젝트는 다른 프로젝트가 의존할 타입 정의 파일(.d.ts)을 생성해야 합니다.
{
"compilerOptions": {
"target": "es2020",
"module": "esnext",
"outDir": "./dist", // 컴파일된 JS 파일과 .d.ts 파일이 저장될 곳
"rootDir": "./src",
"declaration": true, // 필수: .d.ts 파일 생성 활성화
"declarationMap": true, // 선택: .d.ts.map 파일 생성 (원본 TS 파일로 이동 가능)
"composite": true, // 필수: 이 프로젝트가 다른 프로젝트에 의해 참조될 것임을 나타냄
"strict": true
},
"include": [
"src/**/*.ts"
]
}"declaration": true: 이 프로젝트가 다른 프로젝트에서 사용할 타입 정보를 포함하는.d.ts파일을 생성하도록 지시합니다."composite": true: 이 프로젝트가 다른tsconfig.json에 의해 참조될 수 있는 프로젝트임을 나타내는 플래그입니다. 이 옵션에서는 선언 출력이 필요하며declaration의 기본값이true가 됩니다.declarationMap은 별도로 켜야 합니다.
ui 프로젝트 (소비자) 설정
ui 프로젝트는 common 프로젝트가 제공하는 타입에 의존합니다.
references 배열을 사용하여 이 의존성을 명시합니다.
{
"compilerOptions": {
"target": "es2020",
"module": "esnext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"composite": true
},
"include": [
"src/**/*.ts"
],
"references": [
{ "path": "../common" } // common 프로젝트를 참조합니다.
]
}"references": []: 이 배열 안에 현재 프로젝트가 의존하는 다른tsconfig.json파일의 경로를 지정합니다. 경로는 현재tsconfig.json파일을 기준으로 합니다.
tsconfig.json (빌드 관리)
모노레포의 루트에 있는 tsconfig.json은 일반적으로 직접 코드를 컴파일하기보다는, 하위 프로젝트들의 빌드 순서를 관리하고 증분 빌드를 트리거하는 역할을 합니다.
{
"files": [], // 이 tsconfig 자체는 컴파일할 파일이 없으므로 비워둡니다.
"references": [
{ "path": "./packages/common" }, // common 프로젝트를 먼저 빌드해야 합니다.
{ "path": "./packages/ui" } // ui 프로젝트는 common에 의존하므로 그 다음에 빌드됩니다.
]
}-
"files": []: 이 루트tsconfig.json은 직접 소스 파일을 컴파일하지 않고, 단지 하위 프로젝트들을 참조만 하므로files나include를 비워두는 것이 일반적입니다. 참조와 import는 서로 다른 선언입니다.references는 프로젝트의 타입·빌드 의존성을 지정하지만, 소비자의 코드에 import를 자동으로 추가하지는 않습니다. import를 적었다고 참조 대상이 자동 빌드되는 것도 아닙니다.tsc -p packages/ui는 ui를 검사하는 명령이고, 필요한 생산자까지 준비하는 명령은tsc -b입니다. 루트 배열에서 ui를 먼저 적어도 ui의 참조 그래프가 common을 가리키면 common부터 빌드합니다. -
"references": []: 여기에 모든 하위 프로젝트를 나열합니다.tsc --build명령을 사용하면 TypeScript는 이references배열을 기반으로 올바른 빌드 순서를 자동으로 추론합니다.
프로젝트 레퍼런스 사용하기
루트에서 참조하는 common과 ui는 모두 composite: true로 구성했습니다. 설정을 완성한 후 다음 명령을 사용합니다.
# 루트 디렉토리에서 실행
tsc --build # 또는 tsc -btsc --build 명령은 다음과 같은 작업을 수행합니다.
my-monorepo/tsconfig.json 파일을 찾습니다.
references 배열에 나열된 모든 프로젝트를 탐색합니다.
프로젝트 간의 의존성 그래프를 생성하여 올바른 빌드 순서를 결정합니다. (예: common -> ui)
각 프로젝트를 증분 방식으로 컴파일합니다.
만약 common 프로젝트의 소스가 변경되지 않았다면, ui 프로젝트만 다시 빌드되거나 아예 빌드되지 않을 수 있습니다.
common/types 같은 지정자를 타입 검사에서 해석하는 한 방법이 paths입니다. 실제 workspace 패키지의 exports/타입 선언을 구성하는 방법도 있으며, 아래 별칭만으로 Node.js 런타임의 패키지가 만들어지지는 않습니다.
my-monorepo/packages/ui/tsconfig.json 수정
{
"compilerOptions": {
"target": "es2020",
"module": "esnext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"composite": true,
"baseUrl": ".", // paths의 기준 디렉토리로 사용; baseUrl 자체가 필수는 아님
"paths": {
"common/*": ["../common/src/*"] // common 모듈을 common/src에서 찾도록 매핑
}
},
"include": [
"src/**/*.ts"
],
"references": [
{ "path": "../common" }
]
}예시의 버전 조건도 확인합니다. 위 baseUrl 설정은 기존 5.x 프로젝트의 해석 방식을 설명하기 위한 것입니다. TypeScript 6.0에서는 폐기 예정 옵션이므로 새 설정은 이를 생략하고 paths에 ../common/src/* 같은 명시적 상대 경로를 적을 수 있습니다. module: "esnext"만 보고 어떤 런타임에서나 같은 해석이 된다고 단정하지 않고, moduleResolution도 실행 도구에 맞춥니다.
이제 ui 프로젝트의 소스 코드에서 import { MyType } from 'common/types';와 같이 임포트할 수 있습니다. (./src/types/index.ts에 있다면 common/types로 임포트)
모노레포에서의 타입 가져오기
ui 프로젝트에서 common 프로젝트의 타입을 가져오는 방법은 두 가지입니다.
소스 파일 직접 참조:
import { CommonType } from '../../common/src/types';
프로젝트 참조가 설정되면 이 경로로 가져와도 컴파일러는 참조 대상의 선언 출력을 사용합니다. 편집기의 인메모리 선언 지원과 명령행 빌드 조건은 구분해야 합니다.
컴파일된 .d.ts 파일 참조 (권장):
import type { CommonType } from 'common/types'; (타입 전용 사용 예)
이 방식은 common 프로젝트가 먼저 빌드되어 .d.ts 파일이 생성되어야 합니다.
패키지 경계로 타입을 소비하되, 값 import가 남는 경우에는 실제 로더가 common/types를 찾을 수 있도록 패키지 또는 번들러 설정도 맞춰야 합니다.
references와 paths는 출력 import를 다시 쓰지 않습니다. 타입 검사·빌드 순서·실제 모듈 해석을 나누어 확인합니다.
TYPE PLANE / RUNTIME PLANE
타입 선언 소비와 실행 import 해석을 나누어 확인한다
consumer는 참조 대상의 선언으로 타입을 검사합니다. 값 import가 출력에 남으면 실제 loader나 bundler가 그 지정자를 해석해야 하며, references와 paths는 이를 대신하지 않습니다.
TYPE
참조 대상의 선언을 사용
references와 빌드 설정은 생산자의 .d.ts를 준비하고 소비자의 타입 검사를 잇습니다. 편집기 인메모리 선언과 명령행 빌드를 구별합니다.
RUNTIME
남은 import 문자열을 해석
paths·references는 출력 import 문자열을 바꾸지 않습니다. 실제 package exports나 bundler alias가 지정자를 찾을 수 있어야 합니다.
TYPE-ONLY
타입 전용 import
import type은 JS 출력에서 제거됩니다. 값 import가 출력에 남는 경우에 runtime 해석 계약이 필요합니다.
VERIFY
검사 성공과 실행 성공을 함께 본다
타입 검사가 통과했어도 loader가 같은 지정자를 모르면 실행은 실패합니다. 선언 경계·빌드 순서·실제 패키지를 각각 확인합니다.
한 평면의 성공으로 다른 평면의 성공을 추론하지 않습니다.
주의사항 및 고려사항
composite: true의 영향:composite프로젝트는 선언 출력과 모든 구현 파일의include/files포함 조건을 만족해야 합니다.noImplicitAny를 자동으로 강화하는 옵션은 아닙니다.- 순환 의존성: 프로젝트 레퍼런스는 순환 의존성을 허용하지 않습니다. 만약 A가 B에 의존하고 B가 A에 의존하는 순환 의존성이 발생하면 빌드 오류가 발생합니다. 이는 아키텍처 문제를 진단하는 데 도움이 됩니다.
- 번들러와의 통합:
tsc --build는 각 프로젝트를 개별적으로 컴파일하고.js파일을 생성하지만, 최종적으로 이들을 하나로 묶는 것은 여전히 웹팩, Vite, Rollup과 같은 번들러의 역할입니다. 번들러 설정에서도paths매핑 등을 동일하게 구성해야 합니다. solutiontsconfig.json: 모든 하위 프로젝트를 포함하고 빌드를 조정하는 최상위tsconfig.json을 "Solutiontsconfig.json"이라고 부르기도 합니다.
프로젝트 레퍼런스는 대규모 타입스크립트 프로젝트와 모노레포에서 하위 프로젝트 간 의존성을 명시하고 증분 빌드를 가능하게 하는 기능입니다.
각 하위 프로젝트의 독립성을 유지하면서 타입 안전한 의존 관계를 구성할 때 사용합니다.
선언이 없는 첫 빌드부터 추적합니다. 다음은 위 ui → common 구조에 실제 소스를 채운 사례입니다. packages/common/src/types.ts와 packages/ui/src/button.ts의 내용은 아래와 같고, 다른 구현 파일이나 package.json은 없습니다. 두 하위 설정의 target, rootDir, outDir, strict, composite는 앞 예시 그대로 두되, 이번 사례에서는 각각 module: "Node16", moduleResolution: "Node16", types: [], noEmitOnError: true를 명시합니다(TypeScript 4.7 이상). common의 declaration과 declarationMap도 그대로 유지합니다. 별칭 설정 대신 다음의 상대 타입 import를 사용합니다.
export interface CommonType { label: string; }import type { CommonType } from "../../common/src/types";
export const button: CommonType = { label: "save" };루트는 빌드 순서를 배열 순서로 외우지 않도록 다음처럼 일부러 ui를 먼저 적었습니다. 초기 dist·.tsbuildinfo가 전혀 없는 새 프로젝트에서 시작합니다.
{
"files": [],
"references": [
{ "path": "./packages/ui" },
{ "path": "./packages/common" }
]
}- 최초에
tsc -p packages/ui만 실행하면 common을 먼저 빌드할까요? 편집기에서 타입이 보인다는 사실만으로 명령행 검사도 통과한다고 판단해도 될까요? - 이어서 루트의
tsc -b를 실행하면 두 프로젝트 중 무엇이 먼저 빌드되나요? ui의 JavaScript에 위 타입 import가 남나요? 아무 파일·옵션·산출물도 바꾸지 않은 두 번째tsc -b는 다시 모든 파일을 출력해야 할까요? - 정상 첫 빌드 후 common의
label: string만label: number로 바꾸고 ui의 코드는 그대로 둔 채 다시tsc -b를 실행합니다. 무엇이 실패하며 어떤 소스를 고쳐야 하나요?noEmitOnError를 켰는데도 이전 ui/dist가 남아 있다면 새 빌드가 통과했다는 증거일까요?
해설
-p는 의존 프로젝트를 자동 빌드하지 않습니다. 이 조건에서는 common의 선언 산출물이 없어 ui가 참조할.d.ts를 찾지 못하는 진단(TS6305)이 나옵니다. 일부 편집기는 인메모리 선언을 만들어 탐색을 지원하지만, 그것은 명령행에서 선언을 출력한 것과 다릅니다.- ui가 common을 참조하므로 common부터 빌드하고, 준비된 선언으로 ui를 검사합니다. 루트의 배열 순서보다 의존 관계가 기준입니다.
import type은 ui의 JS에서 제거됩니다. common은 JS·.d.ts·선언 맵을 출력하고 각 composite 프로젝트는 빌드 정보를 관리합니다. 모든 상태가 그대로인 두 번째 빌드는 최신 산출물과 정보를 재사용할 수 있어 불필요하게 다시 컴파일하지 않습니다. 파일 시각·옵션·출력·캐시 상태를 바꾼 경우까지 같은 결과라고 확대하지 않습니다. - 생산자의 공개 선언이 바뀌어 ui의
{ label: "save" }가 새number계약을 위반합니다. 소비자의 값을 숫자로 바꾸거나, 요구사항에 맞게 생산자의 계약을 다시 정해야 합니다. 이번 조건의noEmitOnError: true는 오류가 있는 ui의 새 JavaScript·선언 출력을 막지만 예전 산출물을 삭제하지 않습니다. 따라서 dist 존재만 보지 말고 종료 상태·진단과 실제 새 산출물을 확인해야 합니다.