junyeokk
Blog
DevOps·2026. 01. 10

tsc-alias

TypeScript 프로젝트에서 import 경로를 깔끔하게 관리하기 위해 tsconfig.jsonpaths 옵션을 사용하는 경우가 많다. 이렇게 하면 코드에서 ../../../utils/helper 같은 상대 경로 지옥 대신 @utils/helper처럼 깔끔한 경로를 쓸 수 있다.

json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@utils/*": ["src/utils/*"],
      "@services/*": ["src/services/*"],
      "@entities/*": ["src/entities/*"]
    }
  }
}

문제는 이 paths 설정이 TypeScript 컴파일러(tsc)의 빌드 결과물에는 반영되지 않는다는 것이다.

문제 상황

TypeScript 소스 코드에서 이렇게 작성했다고 하자:

typescript
import { UserService } from '@services/user.service';
import { Logger } from '@utils/logger';

tsc로 컴파일하면 출력된 JavaScript 파일에도 그대로 남는다:

javascript
const { UserService } = require("@services/user.service");
const { Logger } = require("@utils/logger");

Node.js 런타임은 @services/user.service가 뭔지 모른다. tsconfig.jsonpaths는 TypeScript 컴파일러가 타입 체크 시점에만 사용하는 설정이고, 실제 모듈 해석(resolution)에는 관여하지 않기 때문이다. 결과적으로 빌드는 성공하지만 실행하면 MODULE_NOT_FOUND 에러가 발생한다.

Error: Cannot find module '@services/user.service'

기존 해결 방법들과 한계

1. module-alias

런타임에 모듈 경로를 재매핑하는 패키지다. package.json에 alias 매핑을 정의하고, 애플리케이션 진입점에서 require('module-alias/register')를 호출한다.

json
Error: Cannot find module '@services/user.service'

동작은 하지만 문제가 있다:

  • tsconfig.jsonpathspackage.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.jsonpaths 설정을 읽어서 자동으로 변환한다는 점이다. 별도의 설정 파일이 필요 없고, 이미 정의한 paths를 그대로 활용한다.

설치와 사용

bash
{
  "_moduleAliases": {
    "@utils": "dist/utils",
    "@services": "dist/services"
  }
}

package.json의 빌드 스크립트에서 tsc 뒤에 tsc-alias를 붙인다:

json
소스 코드 (TypeScript)     →  tsc 컴파일  →  tsc-alias 변환  →  실행 가능한 JS
@services/user.service       @services/user.service       ./services/user.service

이게 전부다. tsconfig.jsonpaths가 정의되어 있으면 자동으로 읽어서 변환한다.

변환 전 (tsc 출력)

javascript
npm install -D tsc-alias

변환 후 (tsc-alias 처리)

javascript
{
  "scripts": {
    "build": "tsc && tsc-alias",
    "build:watch": "tsc && (concurrently \"tsc -w\" \"tsc-alias -w\")"
  }
}

상대 경로는 각 파일의 위치에 맞게 자동 계산된다. 같은 @utils/logger라도 파일 위치에 따라 ./utils/logger가 될 수도 있고 ../../utils/logger가 될 수도 있다.

주요 옵션

tsconfig 경로 지정

기본적으로 프로젝트 루트의 tsconfig.json을 사용하지만, 별도의 tsconfig 파일을 지정할 수 있다.

bash
"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.jsonoutDir을 읽어서 변환 대상 디렉토리를 결정하지만, 명시적으로 지정할 수도 있다.

bash
"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를 변환한다.

bash
tsc-alias -p tsconfig.build.json

watch 모드에서는 tsc가 파일을 재컴파일할 때마다 해당 파일의 alias를 다시 변환한다.

replacer 함수

기본 변환 로직으로 충분하지 않은 경우, 커스텀 replacer 함수를 사용할 수 있다. tsconfig.json에 설정한다:

json
tsc-alias --outDir dist

replacer 파일에서 default export로 함수를 내보내면 된다:

javascript
tsc-alias -w

이 기능은 paths로 표현할 수 없는 복잡한 경로 매핑이 필요할 때 사용한다. 예를 들어, 환경에 따라 다른 경로로 매핑하거나, 특정 패턴의 import를 완전히 다른 모듈로 교체하는 경우다.

verbose 모드

디버깅할 때 --verbose 플래그를 사용하면 어떤 파일에서 어떤 경로가 변환되었는지 상세히 출력한다.

bash
{
  "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의 내부 동작은 다음과 같다:

  1. tsconfig.json을 파싱해서 compilerOptions.pathscompilerOptions.outDir을 읽는다
  2. outDir 디렉토리 아래의 모든 .js 파일을 재귀적으로 탐색한다
  3. 각 파일에서 require() 호출과 import 문을 정규식으로 찾는다
  4. 매칭된 경로가 paths에 정의된 alias와 일치하는지 확인한다
  5. 일치하면 해당 파일의 위치를 기준으로 상대 경로를 계산한다
  6. 원본 파일의 해당 부분을 계산된 상대 경로로 교체한다

.d.ts 파일도 함께 처리하기 때문에, 라이브러리를 만들 때 타입 선언 파일의 경로도 정상적으로 변환된다.

ESM 환경에서의 주의점

ESM(ES Modules)을 사용하는 프로젝트에서는 추가 고려사항이 있다. Node.js의 ESM 모듈 해석은 .js 확장자를 명시해야 하는데, TypeScript 소스에서는 확장자 없이 import하는 것이 일반적이다.

typescript
// 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 플래그를 지원한다:

bash
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를 조합하는 패턴이 가장 깔끔하다:

json
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(빌드 후처리) 조합이 깔끔하다

관련 문서