tsc-alias
TypeScript 프로젝트에서 import 경로를 깔끔하게 관리하기 위해 tsconfig.json의 paths 옵션을 사용하는 경우가 많다. 이렇게 하면 코드에서 ../../../utils/helper 같은 상대 경로 지옥 대신 @utils/helper처럼 깔끔한 경로를 쓸 수 있다.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@utils/*": ["src/utils/*"],
"@services/*": ["src/services/*"],
"@entities/*": ["src/entities/*"]
}
}
}
문제는 이 paths 설정이 TypeScript 컴파일러(tsc)의 빌드 결과물에는 반영되지 않는다는 것이다.
문제 상황
TypeScript 소스 코드에서 이렇게 작성했다고 하자:
import { UserService } from '@services/user.service';
import { Logger } from '@utils/logger';
tsc로 컴파일하면 출력된 JavaScript 파일에도 그대로 남는다:
const { UserService } = require("@services/user.service");
const { Logger } = require("@utils/logger");
Node.js 런타임은 @services/user.service가 뭔지 모른다. tsconfig.json의 paths는 TypeScript 컴파일러가 타입 체크 시점에만 사용하는 설정이고, 실제 모듈 해석(resolution)에는 관여하지 않기 때문이다. 결과적으로 빌드는 성공하지만 실행하면 MODULE_NOT_FOUND 에러가 발생한다.
Error: Cannot find module '@services/user.service'
기존 해결 방법들과 한계
1. module-alias
런타임에 모듈 경로를 재매핑하는 패키지다. package.json에 alias 매핑을 정의하고, 애플리케이션 진입점에서 require('module-alias/register')를 호출한다.
Error: Cannot find module '@services/user.service'
동작은 하지만 문제가 있다:
tsconfig.json의paths와package.json의_moduleAliases에 같은 내용을 이중으로 관리해야 한다- 런타임 의존성이 추가된다 (프로덕션 번들에 포함)
require()메커니즘을 monkey-patch하는 방식이라 예측하기 어려운 부작용이 있을 수 있다
2. Webpack / esbuild 등 번들러 사용
번들러는 자체 모듈 해석 시스템이 있어서 alias를 처리할 수 있다. 하지만 NestJS 같은 서버 사이드 프레임워크에서는 번들링이 필수가 아니고, 오히려 번들링이 문제를 일으키는 경우가 있다 (예: 동적 모듈 로딩, decorator 메타데이터 손실). 서버 코드는 tsc로 컴파일하고 그대로 실행하는 것이 가장 깔끔한 경우가 많다.
3. 상대 경로로 돌아가기
물론 가장 확실한 방법이지만, 프로젝트가 커지면 ../../../../common/interfaces/user.interface 같은 경로가 등장하고, 파일 위치를 변경할 때마다 수많은 import를 수정해야 한다.
tsc-alias가 해결하는 방식
tsc-alias는 빌드 후처리(post-processing) 도구다. tsc 컴파일이 끝난 뒤에 출력된 .js 파일들을 순회하면서, path alias를 실제 상대 경로로 치환한다.
소스 코드 (TypeScript) → tsc 컴파일 → tsc-alias 변환 → 실행 가능한 JS
@services/user.service @services/user.service ./services/user.service
핵심은 tsconfig.json의 paths 설정을 읽어서 자동으로 변환한다는 점이다. 별도의 설정 파일이 필요 없고, 이미 정의한 paths를 그대로 활용한다.
설치와 사용
{
"_moduleAliases": {
"@utils": "dist/utils",
"@services": "dist/services"
}
}
package.json의 빌드 스크립트에서 tsc 뒤에 tsc-alias를 붙인다:
소스 코드 (TypeScript) → tsc 컴파일 → tsc-alias 변환 → 실행 가능한 JS
@services/user.service @services/user.service ./services/user.service
이게 전부다. tsconfig.json에 paths가 정의되어 있으면 자동으로 읽어서 변환한다.
변환 전 (tsc 출력)
npm install -D tsc-alias
변환 후 (tsc-alias 처리)
{
"scripts": {
"build": "tsc && tsc-alias",
"build:watch": "tsc && (concurrently \"tsc -w\" \"tsc-alias -w\")"
}
}
상대 경로는 각 파일의 위치에 맞게 자동 계산된다. 같은 @utils/logger라도 파일 위치에 따라 ./utils/logger가 될 수도 있고 ../../utils/logger가 될 수도 있다.
주요 옵션
tsconfig 경로 지정
기본적으로 프로젝트 루트의 tsconfig.json을 사용하지만, 별도의 tsconfig 파일을 지정할 수 있다.
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const user_service_1 = require("@services/user.service");
const logger_1 = require("@utils/logger");
monorepo 환경에서 패키지별로 다른 tsconfig를 사용할 때 유용하다.
outDir 지정
tsconfig.json의 outDir을 읽어서 변환 대상 디렉토리를 결정하지만, 명시적으로 지정할 수도 있다.
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const user_service_1 = require("./services/user.service");
const logger_1 = require("../../utils/logger");
watch 모드
-w 플래그로 watch 모드를 활성화하면, tsc -w와 함께 사용해서 파일 변경 시 자동으로 alias를 변환한다.
tsc-alias -p tsconfig.build.json
watch 모드에서는 tsc가 파일을 재컴파일할 때마다 해당 파일의 alias를 다시 변환한다.
replacer 함수
기본 변환 로직으로 충분하지 않은 경우, 커스텀 replacer 함수를 사용할 수 있다. tsconfig.json에 설정한다:
tsc-alias --outDir dist
replacer 파일에서 default export로 함수를 내보내면 된다:
tsc-alias -w
이 기능은 paths로 표현할 수 없는 복잡한 경로 매핑이 필요할 때 사용한다. 예를 들어, 환경에 따라 다른 경로로 매핑하거나, 특정 패턴의 import를 완전히 다른 모듈로 교체하는 경우다.
verbose 모드
디버깅할 때 --verbose 플래그를 사용하면 어떤 파일에서 어떤 경로가 변환되었는지 상세히 출력한다.
{
"compilerOptions": { ... },
"tsc-alias": {
"replacers": {
"my-replacer": {
"enabled": true,
"file": "./my-replacer.js"
}
}
}
}
Replaced @services/user.service -> ./services/user.service in dist/controllers/user.controller.js
Replaced @utils/logger -> ../../utils/logger in dist/controllers/user.controller.js
동작 원리
tsc-alias의 내부 동작은 다음과 같다:
tsconfig.json을 파싱해서compilerOptions.paths와compilerOptions.outDir을 읽는다outDir디렉토리 아래의 모든.js파일을 재귀적으로 탐색한다- 각 파일에서
require()호출과import문을 정규식으로 찾는다 - 매칭된 경로가
paths에 정의된 alias와 일치하는지 확인한다 - 일치하면 해당 파일의 위치를 기준으로 상대 경로를 계산한다
- 원본 파일의 해당 부분을 계산된 상대 경로로 교체한다
.d.ts 파일도 함께 처리하기 때문에, 라이브러리를 만들 때 타입 선언 파일의 경로도 정상적으로 변환된다.
ESM 환경에서의 주의점
ESM(ES Modules)을 사용하는 프로젝트에서는 추가 고려사항이 있다. Node.js의 ESM 모듈 해석은 .js 확장자를 명시해야 하는데, TypeScript 소스에서는 확장자 없이 import하는 것이 일반적이다.
// my-replacer.js
function myReplacer({ orig, file, config }) {
// orig: 원본 import 경로
// file: 현재 처리 중인 파일 경로
// 변환된 경로를 반환하거나, undefined를 반환하면 기본 로직 사용
if (orig.startsWith('@custom/')) {
return orig.replace('@custom/', './custom-path/');
}
return undefined;
}
module.exports = myReplacer;
tsc-alias는 기본적으로 경로만 치환하고 확장자는 추가하지 않는다. ESM 환경에서 확장자 문제를 해결하려면 pathsToModuleNameMapper(Jest 설정) 또는 별도의 replacer를 사용해야 한다.
다만 tsc-alias의 최신 버전에서는 --resolve-full-paths 플래그를 지원한다:
tsc-alias --verbose
이 옵션을 사용하면 변환된 경로에 .js 확장자가 자동으로 추가되어 ESM 환경에서도 정상 동작한다.
monorepo에서의 활용
monorepo 구조에서 여러 패키지가 각각의 tsconfig.json을 가지고 있을 때, 각 패키지의 빌드 스크립트에 tsc-alias를 추가하면 된다.
my-project/
├── packages/
│ ├── server/
│ │ ├── tsconfig.json # paths: @server/*
│ │ └── package.json # build: tsc && tsc-alias
│ ├── crawler/
│ │ ├── tsconfig.json # paths: @crawler/*
│ │ └── package.json # build: tsc && tsc-alias
│ └── worker/
│ ├── tsconfig.json # paths: @worker/*
│ └── package.json # build: tsc && tsc-alias
각 패키지가 독립적인 paths 설정을 가지고 있어도 tsc-alias가 각각의 tsconfig.json을 읽어서 올바르게 변환한다. Turborepo 같은 빌드 오케스트레이터와 함께 사용하면 각 패키지의 빌드가 병렬로 실행되면서도 alias가 정상적으로 처리된다.
대안 비교
| 도구 | 방식 | 장점 | 단점 |
|---|---|---|---|
| tsc-alias | 빌드 후처리 | 설정 최소, tsconfig 재사용 | tsc 의존 |
| module-alias | 런타임 패치 | 번들러 불필요 | 이중 설정, 런타임 의존성 |
| tsconfig-paths | 런타임 해석 | ts-node과 궁합 좋음 | 프로덕션에 런타임 의존성 |
| Webpack/esbuild | 번들링 시 해석 | 트리 셰이킹 등 추가 최적화 | 서버 코드 번들링의 부작용 |
| SWC | 컴파일러 자체 지원 | 매우 빠름 | tsc 대체 필요 |
tsconfig-paths는 개발 환경(ts-node)에서 런타임으로 경로를 해석하는 도구다. ts-node와 함께 사용하면 컴파일 없이 바로 실행할 때도 alias가 동작한다. 하지만 프로덕션 환경에서는 불필요한 런타임 오버헤드를 추가하므로, 프로덕션 빌드에는 tsc-alias를 사용하는 것이 일반적이다.
개발 환경에서는 tsconfig-paths, 프로덕션 빌드에서는 tsc-alias를 조합하는 패턴이 가장 깔끔하다:
Replaced @services/user.service -> ./services/user.service in dist/controllers/user.controller.js
Replaced @utils/logger -> ../../utils/logger in dist/controllers/user.controller.js
이렇게 하면 개발 시에는 컴파일 없이 바로 실행하면서 alias가 동작하고, 프로덕션 빌드에서는 alias가 상대 경로로 변환된 깨끗한 JavaScript가 출력된다.
정리
- tsc는 paths alias를 빌드 결과물에 반영하지 않으므로, 후처리 도구가 필요하다
- tsc-alias는 tsconfig.json의 paths를 읽어 .js/.d.ts 파일의 import 경로를 상대 경로로 자동 치환한다
- 개발 환경에서는 tsconfig-paths(런타임 해석), 프로덕션 빌드에서는 tsc-alias(빌드 후처리) 조합이 깔끔하다