본문 바로가기

카테고리 없음

NestJS에서 build와 start는 각각 뭘 하는 걸까

 

pnpm run build를 치면 뭔가 돌아가고, pnpm run start를 하면 서버가 뜬다.

매일 치는 명령어인데, 이 두 단계에서 정확히 뭐가 일어나는 건지 궁금해졌다. "컴파일타임 에러"랑 "런타임 에러"라는 말도 자주 듣는데 이것도 명확하게 하기 위해 함께 정리해본다.


1. 컴파일타임 vs 런타임

가장 기본적인 구분

코드가 실행되기까지는 두 단계가 있다.

컴파일타임(Compile-time) 은 코드가 "변환"되는 시점이다. 아직 프로그램이 실행되는 게 아니다. 소스 코드를 분석하고, 문법 오류를 체크하고, 다른 형태의 코드로 바꾸는 단계다.

런타임(Runtime) 은 코드가 "실행"되는 시점이다. 실제로 메모리에 올라가서 동작한다. 변수에 값이 할당되고, 함수가 호출되고, API 요청이 날아간다.

NestJS 기준으로 보면:

  • 컴파일타임 = pnpm run build (TypeScript → JavaScript 변환)
  • 런타임 = pnpm run start (서버 실행)

에러로 구분하기

가장 실용적인 구분법은 에러가 언제 발생하는지 보는 것이다.

// 컴파일타임 에러: IDE에서 빨간 줄이 뜬다
const user: { name: string } = { name: 123 };
// Type 'number' is not assignable to type 'string'

위 코드는 저장하자마자 빨간 줄이 뜬다. 실행하기도 전에 잡히는 에러다. 이게 컴파일타임 에러다.

// 런타임 에러: 실행해야 터짐
const data = JSON.parse(userInput);
console.log(data.name.toUpperCase());
// Cannot read property 'toUpperCase' of undefined

위 코드는 문법적으로 아무 문제 없다. userInput에 뭐가 들어올지는 실행해봐야 안다. 실행했더니 data.name이 undefined라서 터진다. 이게 런타임 에러다.

빨간 줄이 뜨면 컴파일타임, 실행해야 터지면 런타임. 이렇게 기억하면 된다.

왜 이 구분이 중요한가?

TypeScript를 쓰는 가장 큰 이유가 여기 있다. 런타임에 터질 에러를 컴파일타임에 미리 잡아주기 때문이다.

런타임 에러는 사용자가 경험한다. API가 500 에러를 뱉거나, 서버가 갑자기 죽는다. 컴파일타임 에러는 개발자만 경험한다. 코드를 저장하는 순간 IDE가 알려준다. 에러를 컴파일타임으로 끌어올릴수록 안전한 코드가 된다.


2. pnpm run build - 컴파일타임

nest build가 하는 일

pnpm run build를 실행하면 내부적으로 nest build가 호출된다.

// package.json
{
  "scripts": {
    "build": "nest build"
  }
}

nest build는 TypeScript 컴파일러(tsc)를 래핑한 명령어다. 핵심적으로 하는 일은 두 가지다.

첫째, 타입 체크다. 코드에 타입 오류가 없는지 검사한다.

둘째, JavaScript 변환이다. .ts 파일을 .js 파일로 바꾼다. Node.js는 TypeScript를 직접 실행할 수 없기 때문이다.

# 빌드 전
src/
├── main.ts
├── app.module.ts
├── app.controller.ts
└── app.service.ts

# 빌드 후
dist/
├── main.js
├── app.module.js
├── app.controller.js
└── app.service.js

타입 소거(Type Erasure)

TypeScript의 타입 정보는 컴파일이 끝나면 완전히 사라진다. 이걸 "타입 소거"라고 부른다.

// 컴파일 전 (src/user.service.ts)

interface User {
  name: string;
  age: number;
}

function createUser(user: User): User {
  return user;
}
// 컴파일 후 (dist/user.service.js)

function createUser(user) {
  return user;
}

interface User가 통째로 사라졌다. 함수 파라미터의 : User도, 리턴 타입도 전부 없다. JavaScript 세계에는 User라는 타입이 존재하지 않는다. TypeScript의 타입은 컴파일러를 위한 "메모"일 뿐이다.

근데 데코레이터는?

NestJS에서 쓰는 데코레이터는 좀 다르다.

// 컴파일 전
@Controller('users')
export class UserController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return `User ${id}`;
  }
}

데코레이터는 컴파일 후에도 메타데이터로 남는다. Reflect.metadata를 통해 클래스에 정보가 붙어있다.

// 개념적으로 이런 식
Reflect.defineMetadata('path', 'users', UserController);
Reflect.defineMetadata('method', 'GET', UserController.prototype.findOne);

이 메타데이터는 런타임에 NestJS가 읽어서 라우터를 구성하는 데 사용한다. 그래서 tsconfig.json에 이 옵션이 필수다:

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  }
}

이 단계에서 읽히는 설정 파일

파일 누가 읽나 역할

tsconfig.json tsc 어떻게 컴파일할지 (target, module 등)
nest-cli.json nest build 빌드 옵션 (entryFile, sourceRoot 등)

빌드가 끝나면 이 파일들은 더 이상 역할이 없다.


3. pnpm run start - 런타임

서버가 시작되기까지

pnpm run start를 실행하면 빌드된 JavaScript 파일이 실행된다.

// package.json
{
  "scripts": {
    "start": "node dist/main.js",
    "start:dev": "nest start --watch",
    "start:prod": "node dist/main.js"
  }
}

main.js가 실행되면서 NestJS 앱이 부트스트랩된다.

// src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

이 짧은 코드 안에서 많은 일이 일어난다.

실행 순서

1. node dist/main.js 실행
   │
2. NestFactory.create(AppModule) 호출
   │
   ├─ AppModule 로드
   ├─ imports에 있는 모듈들 재귀적으로 로드
   ├─ ConfigModule이 .env 파일 읽음  ← 여기서 환경변수 로드
   │
3. DI 컨테이너 구성
   │
   ├─ 모든 @Injectable() 클래스 인스턴스화
   ├─ 의존성 주입 처리
   │
4. 데코레이터 메타데이터 읽기
   │
   ├─ @Controller() → 라우터 등록
   ├─ @Get(), @Post() → HTTP 메서드 매핑
   │
5. app.listen(3000)
   │
   └─ HTTP 서버 시작, 요청 대기

ConfigModule과 .env

.env 파일은 런타임에 읽힌다. 빌드할 때가 아니다.

// app.module.ts
@Module({
  imports: [
    ConfigModule.forRoot(),  // 런타임에 .env 읽음
  ],
})
export class AppModule {}
// some.service.ts
@Injectable()
export class SomeService {
  constructor(private configService: ConfigService) {}

  someMethod() {
    const port = this.configService.get('PORT');  // 런타임에 값 가져옴
  }
}

그래서 같은 빌드 결과물(dist/)을 개발 서버와 프로덕션 서버에서 다른 .env로 실행할 수 있다.

데코레이터는 런타임에 "동작"한다

앞서 데코레이터가 컴파일 후에도 메타데이터로 남는다고 했다. 이 메타데이터는 런타임에 NestJS가 읽어서 실제로 동작하게 만든다.

@Controller('users')
export class UserController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return `User ${id}`;
  }
}

NestJS가 시작할 때:

  1. UserController 클래스의 메타데이터를 읽는다
  2. "아, 이건 /users 경로를 담당하는 컨트롤러구나"
  3. "findOne 메서드는 GET /users/:id 요청을 처리하는구나"
  4. Express/Fastify 라우터에 등록한다

컴파일타임에 "기록"되고, 런타임에 "읽혀서 동작"한다.


4. 설정 파일은 언제 읽히나?

정리하면 이렇다.

파일 읽히는 시점 누가 읽나 역할

tsconfig.json 컴파일타임 tsc TS → JS 변환 옵션
nest-cli.json 빌드타임 nest build 빌드 설정
.env 런타임 ConfigModule 환경 변수
package.json 여러 시점 npm/pnpm, Node.js 스크립트, 의존성

자주 하는 실수

.env에 값을 바꿨는데 반영이 안 된다?

→ 빌드를 다시 할 필요는 없다. 서버를 재시작하면 된다. .env는 런타임에 읽히니까.

tsconfig.json을 바꿨는데 반영이 안 된다?

빌드를 다시 해야 한다. tsconfig.json은 컴파일타임에 읽히니까.


정리

- TypeScript의 타입은 컴파일타임에만 존재하고, 런타임에는 사라진다.

- 하지만 NestJS의 데코레이터는 메타데이터로 남아서 런타임에 동작한다.

설정 파일이 언제 읽히는지 알면 뭘 바꿨을 때 빌드를 다시 해야 하는지 서버만 재시작하면 되는지 판단할 수 있다.