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

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


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

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

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

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


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

🔍 증상 1: 400 Bad Request "should not exist" 하다가 버그를 마주친 순간
🔍 증상 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
// TypeScript 컴파일 후 이 코드는 완전히 사라진다 — 런타임에 아무것도 남지 않음
export interface CreateItemDto {
  title: string;
  type: string;
  status: string;
}

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

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


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

분명 잘 되던 건데, 🔍 증상 2: Swagger 응답 스키마 비어있음에서 갑자기 안 될 때
분명 잘 되던 건데, 🔍 증상 2: Swagger 응답 스키마 비어있음에서 갑자기 안 될 때

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

// ❌ interface로 정의한 응답 DTO
// Swagger 모듈은 @ApiProperty 데코레이터에서 메타데이터를 읽는데, interface에는 붙일 수 없다
export interface ItemResponseDto {
  id: number;
  title: string;
  summary: { totalCount: number; activeCount: number };
}

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

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


🔎 탐색: 왜 interface만 안 되는 건지 추적

모니터를 노려보며 🔎 탐색: 왜 interface만 안 되는 건지 추적 디버깅 중
모니터를 노려보며 🔎 탐색: 왜 interface만 안 되는 건지 추적 디버깅 중

처음엔 ValidationPipe 설정 문제인 줄 알았다. forbidNonWhitelisted를 끄면 400은 사라지지만, 그러면 검증 자체가 무력화된다. 근본 해결이 아니었다.

그다음 의심한 건 class-transformerplainToClass 변환이었다. interface로 선언하면 plainToClass가 인스턴스를 만들 수 없으니 데코레이터 메타데이터를 읽을 대상이 없다.

컴파일된 JavaScript를 직접 열어보고 확신했다.

// 이 코드가...
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
// 중첩 객체는 반드시 별도 class로 분리 — Swagger가 재귀적으로 스키마를 파싱
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;

  // type: () => Class 형태로 참조해야 순환 참조 없이 Swagger가 파싱 가능
  @ApiProperty({ type: () => ItemSummary })
  summary: ItemSummary;
}

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

검증: 수정 전 vs 후

수정 전:

{
  "statusCode": 400,
  "message": ["property title should not exist"]
}

수정 후: Swagger UI에 스키마 + 예시가 정상 표시되고, API 호출도 200 OK.

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

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

// ✅ type 추가 — Swagger에 스키마 + 예시 표시됨
// 이 한 줄 빠지면 DTO를 아무리 잘 만들어도 Swagger에 안 뜬다
@ApiResponse({ status: 200, description: '아이템 목록', type: ItemResponseDto })

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

다시는 🛡️ 예방: 새 API 구현 체크리스트 실수를 반복하지 않겠다는 다짐
다시는 🛡️ 예방: 새 API 구현 체크리스트 실수를 반복하지 않겠다는 다짐

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

DTO 작성 시:

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

컨트롤러 작성 시:

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

팁: 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를 쓰는 순간, 런타임에서는 존재하지 않는 유령 타입이 된다.