📚 NestJS 실전 트러블슈팅 #4

NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지

NestJS에서 DTO를 interface로 만들면 400 에러와 Swagger 스키마 누락이 동시에 발생합니다. class-validator와 @ApiProperty가 작동하는 원리부터 해결까지 정리했습니다.

NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지

TypeScript 개발자라면 타입 정의에 interface를 쓰는 게 습관이다. 간결하고, 확장이 쉽고, 컴파일 타임에 타입 체크도 잘 된다.

그런데 NestJS DTO에 interface를 쓰면? 두 가지가 동시에 터진다.

  • 400 Bad Request — 분명 맞는 필드를 보냈는데 “should not exist”
  • Swagger UI — 응답 스키마가 텅 비어있다

원인은 같다. interface는 컴파일하면 사라진다.


🔍 증상 1: 400 Bad Request “should not exist”

분명 잘 되던 코드가 갑자기 안 될 때

API에 POST 요청을 보냈다. Body에 title, type, status 필드를 넣었다. 분명 맞는 필드인데 400이 돌아온다.

POST /api/v1/admin/items - 400
property title should not exist, property type should not exist, property status should not exist

모든 필드가 “존재하면 안 된다”고 한다. DTO에 분명 정의해뒀는데?

확인해보니 DTO가 이렇게 되어 있었다.

// ❌ interface로 정의한 DTO
export interface CreateItemDto {
  title: string;
  type: string;
  status: string;
}

NestJS의 ValidationPipeclass-validator 데코레이터가 붙은 프로퍼티만 허용한다. forbidNonWhitelisted: true 옵션이 켜져 있으면, 데코레이터가 없는 필드는 전부 “should not exist”로 거부한다.

interface에는 데코레이터를 붙일 수 없으니, 모든 필드가 미등록 → 전부 거부.


🔍 증상 2: Swagger 응답 스키마 비어있음

내 코드에서 버그 발견했을 때 멘붕

다음으로 응답 DTO도 interface로 만들어뒀다.

// ❌ interface로 정의한 응답 DTO
export interface ItemResponseDto {
  id: number;
  title: string;
  summary: { totalCount: number; activeCount: number };
}

Swagger UI(/api/docs)를 열어보면 응답 예시가 없다. 스키마도 비어있다. “Successful response”라고만 뜬다.

NestJS Swagger 모듈은 @ApiProperty() 데코레이터에서 메타데이터를 추출해 스키마를 생성한다. interface는 컴파일 시 사라지므로, Swagger가 읽을 수 있는 메타데이터가 아예 없다.


🧠 원인: TypeScript의 interface는 런타임에 없다

로그 한 줄에서 근본 원인을 찾은 순간

핵심은 TypeScript의 컴파일 특성이다.

// 이 코드가...
export interface CreateItemDto {
  title: string;
}

// 컴파일 후 JavaScript에서는...
// (아무것도 없음. 완전히 사라짐)
// 반면 class는...
export class CreateItemDto {
  title: string;
}

// 컴파일 후에도 남아있다
// class CreateItemDto {}

NestJS의 두 핵심 라이브러리가 모두 런타임에 동작한다.

라이브러리하는 일필요한 것
class-validator요청 body 검증데코레이터 메타데이터 (런타임)
@nestjs/swaggerAPI 문서 스키마 생성데코레이터 메타데이터 (런타임)

interface → 컴파일 시 소멸 → 런타임 메타데이터 없음 → 둘 다 작동 불가.


✅ 해결: class + 데코레이터

빌드 성공 후 해피댄스

✅ 입력 DTO — class-validator 데코레이터

// ✅ class로 정의 + class-validator 데코레이터
import { IsString, IsOptional, IsIn } from 'class-validator';

export class CreateItemDto {
  @IsString()
  title: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsIn(['ACTIVE', 'INACTIVE'])
  status: string;
}

이제 titlestatus는 허용 필드로 인식되고, 타입 검증도 런타임에 동작한다.

✅ 응답 DTO — @ApiProperty 데코레이터

// ✅ class로 정의 + @ApiProperty
import { ApiProperty } from '@nestjs/swagger';

export class ItemSummary {
  @ApiProperty({ example: 10, description: '전체 수' })
  totalCount: number;

  @ApiProperty({ example: 7, description: '활성 수' })
  activeCount: number;
}

export class ItemResponseDto {
  @ApiProperty({ example: 'uuid-123' })
  id: string;

  @ApiProperty({ example: '제목 예시' })
  title: string;

  @ApiProperty({ type: () => ItemSummary })
  summary: ItemSummary;
}

주의: 중첩 객체는 별도 class로 분리하고 type: () => NestedClass 형태로 참조해야 Swagger가 제대로 파싱한다.

✅ 컨트롤러 — @ApiResponse에 type 추가

// ❌ type 없이 — Swagger에 스키마 표시 안 됨
@ApiResponse({ status: 200, description: '아이템 목록' })

// ✅ type 추가 — Swagger에 스키마 + 예시 표시됨
@ApiResponse({ status: 200, description: '아이템 목록', type: ItemResponseDto })

이 한 줄 빠지면 DTO를 아무리 잘 만들어도 Swagger에 안 뜬다.


🛡️ 예방: 새 API 구현 체크리스트

원인 추적해보니 3개월 전 내 커밋

매번 같은 실수를 반복하지 않으려면 체크리스트화하는 게 최선이다.

DTO 작성 시:

  • class로 정의했는가? (interface 금지)
  • 입력 DTO: class-validator 데코레이터 붙였는가?
  • 응답 DTO: @ApiProperty({ example: ... }) 붙였는가?
  • optional 필드에 @IsOptional() 또는 nullable: true 추가했는가?

컨트롤러 작성 시:

  • @ApiOperation({ summary: '...' }) 추가
  • @ApiResponse({ type: ResponseDto }) 추가
  • @ApiQuery(), @ApiParam() 필요한 곳에 추가

최종 확인:

  • /api/docs에서 스키마가 정상 표시되는가?
  • 실제 API 호출 시 400 에러 없이 동작하는가?

팁: ESLint 룰이나 커스텀 lint로 “DTO 파일에서 interface export 감지” 규칙을 추가하면 코드 리뷰 전에 잡을 수 있다.


📋 정리

오래된 버그 드디어 해결

상황안티패턴권장 패턴
입력 DTO 정의export interfaceexport class + @IsString()
응답 DTO 정의export interfaceexport class + @ApiProperty()
컨트롤러 응답 문서화@ApiResponse({})@ApiResponse({ type: Dto })
중첩 객체인라인 타입별도 class + type: () => Class

한 줄 교훈: NestJS DTO는 class다. interface를 쓰는 순간, 런타임에서는 존재하지 않는 유령 타입이 된다.