NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지
NestJS에서 DTO를 interface로 만들면 400 에러와 Swagger 스키마 누락이 동시에 발생합니다. class-validator와 @ApiProperty가 작동하는 원리부터 해결까지 정리했습니다.
📚 NestJS 실전 트러블슈팅 시리즈 (4편)
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의 ValidationPipe는 class-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/swagger | API 문서 스키마 생성 | 데코레이터 메타데이터 (런타임) |
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;
}
이제 title과 status는 허용 필드로 인식되고, 타입 검증도 런타임에 동작한다.
✅ 응답 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 구현 체크리스트

매번 같은 실수를 반복하지 않으려면 체크리스트화하는 게 최선이다.
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 interface | export class + @IsString() 등 |
| 응답 DTO 정의 | export interface | export class + @ApiProperty() |
| 컨트롤러 응답 문서화 | @ApiResponse({}) | @ApiResponse({ type: Dto }) |
| 중첩 객체 | 인라인 타입 | 별도 class + type: () => Class |
한 줄 교훈: NestJS DTO는 class다. interface를 쓰는 순간, 런타임에서는 존재하지 않는 유령 타입이 된다.
📚 NestJS 실전 트러블슈팅 시리즈 (4편)
- 1. NestJS + Prisma에서 N+1 쿼리 문제 해결하기
- 2. NestJS CORS 삽질 총정리 — PATCH만 안 되는 이유
- 3. NestJS Prisma 마이그레이션 실수 방지 — 운영 DB 컬럼 누락 트러블슈팅
- 4. NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지