NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
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”

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의 ValidationPipe는 class-validator 데코레이터가 붙은 프로퍼티만 허용한다. forbidNonWhitelisted: true 옵션이 켜져 있으면, 데코레이터가 없는 필드는 전부 “should not exist”로 거부한다.
interface에는 데코레이터를 붙일 수 없으니, 모든 필드가 미등록 → 전부 거부.
🔍 증상 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만 안 되는 건지 추적

처음엔 ValidationPipe 설정 문제인 줄 알았다. forbidNonWhitelisted를 끄면 400은 사라지지만, 그러면 검증 자체가 무력화된다. 근본 해결이 아니었다.
그다음 의심한 건 class-transformer의 plainToClass 변환이었다. interface로 선언하면 plainToClass가 인스턴스를 만들 수 없으니 데코레이터 메타데이터를 읽을 대상이 없다.
컴파일된 JavaScript를 직접 열어보고 확신했다.
// 이 코드가...
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
// 중첩 객체는 반드시 별도 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 구현 체크리스트

매번 같은 실수를 반복하지 않으려면 체크리스트화하는 게 최선이다.
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 interface | export class + @IsString() 등 |
| 응답 DTO 정의 | export interface | export class + @ApiProperty() |
| 컨트롤러 응답 문서화 | @ApiResponse({}) | @ApiResponse({ type: Dto }) |
| 중첩 객체 | 인라인 타입 | 별도 class + type: () => Class |
한 줄 교훈: NestJS DTO는 class다. interface를 쓰는 순간, 런타임에서는 존재하지 않는 유령 타입이 된다.
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
- 1. NestJS + Prisma에서 N+1 쿼리 문제 해결하기
- 2. NestJS CORS 삽질 총정리 — PATCH만 안 되는 이유
- 3. Prisma 마이그레이션 실수 방지 — 컬럼 누락 해결기
- 4. NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지
- 5. NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기
- 6. Prisma enum vs 도메인 타입 캐스팅 함정 — TypeScript 타입 불일치 해결기
- 7. Seed 데이터 FK 삭제 순서 삽질 — Prisma deleteMany가 터지는 이유
- 8. NestJS DI 에러 디버깅 — Nest can't resolve dependencies 3가지 원인과 서버 기동 테스트
- 9. Docker 빌드에서 pnpm 모노레포 삽질 — 데코레이터 에러 3132개의 정체
- 10. NestJS 재귀 호출 무한루프 — API 504 타임아웃의 숨겨진 원인 찾기
- 11. Soft Delete 필터가 빠진 곳 찾기 — 삭제한 데이터가 되살아나는 미스터리
- 12. prisma generate 누락 — 빌드는 되는데 런타임 에러가 나는 이유