Search

    NestJS dist/main not found
    2026.04.20 7 min read

    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
      }
    }
    .tsbuildinfo

    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.jsondeleteOutDir도 동시에 활성화되어 있었다는 것입니다.

    {
      "compilerOptions": {
        "deleteOutDir": true
      }
    }

    이 옵션은 빌드 시작 시 결과물(dist)을 제거합니다. 이전 빌드 산출물이 남아서 발생하는 문제를 방지하기 위함입니다.


    왜 문제가 되는가?

    incrementaldeleteOutDir를 동시에 활성화한 상태에서 nest start --watch 명령을 실행하면 다음 흐름을 따라가게 됩니다.

    1. 서버 첫 실행 시 정상적인 빌드와 함께 .tsbuildinfo 생성

    2. 서버를 종료하고 재시작하면:

      • deleteOutDir에 의해 dist 삭제
      • .tsbuildinfo는 루트에 그대로 남아있음
    3. 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 환경에서는 증분 빌드를 유지해 반복적인 컴파일 성능을 유지할 수 있습니다.


    References

    TypeScript 공식 문서 - tsBuildInfoFile

    TAGS

    NestJS
    TypeScript
    트러블슈팅