nest start 실행 오류 Cannot find module dist/main
문제 상황
nest start --watch로 잘 동작하던 서버를 종료한 뒤 다시 실행했는데 다음과 같은 에러가 발생했습니다.
[10:52:22 AM] Starting compilation in watch mode...
[10:52:22 AM] Found 0 errors. Watching for file changes.
node:internal/modules/cjs/loader:1386
throw err;
^
Error: Cannot find module '/Users/.../dist/main'
컴파일 에러는 없다고 나오는데, 정작 실행에 dist/main 파일을 찾을 수 없다고 합니다.
즉, 컴파일은 성공했는데 결과물이 생성되지 않은 상태라는 것입니다.
Nest CLI 실행 방식
이 문제는 Nest CLI의 동작 방식을 이해하면 자연스럽게 풀립니다.
NestJS는 ts-node처럼 TypeScript를 런타임에서 직접 실행하지 않습니다.
내부적으로 tsc나 빌더로 사전 컴파일 후 빌드된 결과물(dist)을 실행합니다.
가령 nest start --watch를 실행하면 아래와 같은 흐름을 따릅니다.
코드 변경 감지 → TS 컴파일 → dist 생성/갱신 → Node 실행 (프로세스 재시작)
--watch 모드라고 해서 런타임에서 TS를 바로 실행하는 것이 아니라
매번 빌드 결과를 기반으로 서버를 재시작하는 구조입니다.
그래서 컴파일 상 에러가 없더라도 dist/main이 없으면 런타임에서 터집니다.
원인
문제의 원인은 incremental 캐시와 deleteOutDir 옵션이 충돌해서 발생한 것이었습니다.
- incremental: 이전 빌드 상태를 기준으로 변경된 파일만 컴파일 수행
- deleteOutDir: 빌드 전 dist를 통째로 삭제
incremental이 하는 일
tsconfig.json에서 incremental을 활성화하면 TypeScript는 .tsbuildinfo 파일을 생성합니다.
{
"compilerOptions": {
"incremental": true
}
}
TypeScript가 다음 컴파일을 최적화하기 위해 이전 컴파일 상태를 저장해두는 캐시 파일
tsconfig.build.json에는 아래와 같이 rootDir, outDir를 정의한 상태였습니다.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
}
}
TypeScript 공식 문서에 따르면 tsBuildInfoFile 필드를 따로 지정하지 않고 rootDir, outDir이 설정된 경우, .tsbuildinfo는 아래 경로에 생성된다고 합니다.
<outDir>/<rootDir로부터 tsconfig 위치까지의 상대 경로>/<config name>.tsbuildinfo
현재 설정에 대입하면 dist/../tsconfig.build.tsbuildinfo가 되고, 결과적으로는 dist가 상쇄되면서 .tsbuildinfo는 루트에 생성됩니다.
root/
├─ dist/
├─ src/
├─ tsconfig.json
├─ tsconfig.build.json
└─ tsconfig.build.tsbuildinfo
deleteOutDir 설정
문제는 nest-cli.json에 deleteOutDir도 동시에 활성화되어 있었다는 것입니다.
{
"compilerOptions": {
"deleteOutDir": true
}
}
이 옵션은 빌드 시작 시 결과물(dist)을 제거합니다. 이전 빌드 산출물이 남아서 발생하는 문제를 방지하기 위함입니다.
왜 문제가 되는가?
incremental과 deleteOutDir를 동시에 활성화한 상태에서 nest start --watch 명령을 실행하면 다음 흐름을 따라가게 됩니다.
-
서버 첫 실행 시 정상적인 빌드와 함께
.tsbuildinfo생성 -
서버를 종료하고 재시작하면:
- deleteOutDir에 의해
dist삭제 .tsbuildinfo는 루트에 그대로 남아있음
- deleteOutDir에 의해
-
TypeScript는
.tsbuildinfo를 기준으로 변경된 파일이 없다고 판단하여 emit(파일 생성) 생략
→ 결과적으로 dist는 비어 있는데 Node가 존재하지도 않는 dist/main을 실행하려고 하니 에러가 발생했던 것이었습니다.
해결 방법
방법 1: .tsbuildinfo를 dist 내부로 이동
tsBuildInfoFile 옵션을 사용하면 .tsbuildinfo의 생성 위치를 직접 지정할 수 있습니다.
// tsconfig.build.json
{
"compilerOptions": {
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo"
}
}
캐시 파일을 dist 내부에 생성하도록 해서 빌드 산출물과 캐시의 생명주기를 동일하게 맞추는 방식입니다.
dist를 삭제할 때 .tsbuildinfo도 함께 제거되므로 이전 빌드의 잔여 캐시가 남아 있는 상태를 방지할 수 있습니다.
방법 2: 빌드 경로 incremental 비활성화
// tsconfig.build.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false
}
}
여기서 중요한 포인트는 tsconfig.json은 그대로 두고 빌드용 config에서만 옵션을 끈다는 점입니다.
이렇게 하면 역할이 분리됩니다.
- 타입 체크(
tsc -p) → incremental 유지 - Nest build/watch → 항상 전체 빌드
즉, 개발 경험과 실행 안정성을 분리해서 가져가는 구조입니다.
어떤 방법을 적용하였는가?
원인이 incremental 빌드 자체가 아니라 캐시와 산출물의 생명주기가 어긋난 상태였기 때문에 1번 방식인 캐시 파일을 dist 내부로 이동하는 방법이 더 적절하다고 판단했습니다.
캐시를 비활성화하는 방법(방법 2)도 문제를 해결할 수는 있지만, 프로젝트 규모가 커질수록 개발 생산성이 낮아질 수 있다는 점이 아쉬웠습니다.
반면 방법 1은 캐시와 산출물의 사이클을 동일하게 가져가 문제를 해결하면서도
동시에 --watch 환경에서는 증분 빌드를 유지해 반복적인 컴파일 성능을 유지할 수 있습니다.