DTO Layering and Swagger Isolation in NestJS: A Practical Guide to Clean API Design


Killer Pass 프로젝트는 하이 레벨에서 아래와 같은 프로젝트 구조를 갖는다.
entry_point
application
domain
infrastructure
shared
클린 아키텍처, CQRS, DDD 등의 패턴을 적용할 때 가장 골치 아픈 문제 중 하나가 계층간 DTO 설계다. 특히 책임 분리와 응답 중복 제거, 그리고 Swagger 코드 오염 문제를 동시에 해결해야 한다.
문제 상황
일반적인 계층별 DTO 구조는 다음과 같다:
계층 | DTO |
엔트리 포인트 | XxxHttpRequest/Response |
애플리케이션 | XxxCmd/XxxResult |
함정에 빠지기 쉬운 코드:
export class XxxHttpResponse {
@ApiProperty({ type: XxxResult })
public readonly data: XxxResult
}
export class XxxResult {
@ApiProperty() public readonly id: string
@ApiProperty() public readonly information: string
@ApiProperty() public readonly created_at: number
}
이 코드의 치명적 문제: 애플리케이션 계층의 XxxResult
가 API 응답 명세 책임까지 떠안으면서 Swagger 코드로 오염되었다.
해결 방법 비교
방법 1: Destructuring 방식
export class XxxHttpResponse {
@ApiProperty()
public readonly data: {
id: string
information: string
created_at: number
}
}
장점: 책임 분리 달성
단점:
변경사항 발생 시 수정 포인트 증가
필드 많아질수록 보일러플레이트 폭증
타입 안전성 없음 (컴파일러가 불일치 감지 못함)
방법 2: Document 타입 분리
export class XxxResultDocument {
@ApiProperty() public readonly id: string
@ApiProperty() public readonly information: string
@ApiProperty() public readonly created_at: number
}
export class XxxHttpResponse {
@ApiProperty({ type: XxxResultDocument })
public readonly data: XxxResult
}
장점: 책임 분리 + 중복 코드 해결
단점:
Swagger 전용 타입 대량 생성으로 보일러플레이트 지옥
실제 응답 타입과 문서 타입 불일치 위험
런타임 에러 가능성
방법 3: 의도 명확화 + 팩토리 패턴 (권장)
// 애플리케이션 계층
export class XxxResult {
constructor(
public readonly id: string,
public readonly information: string,
public readonly created_at: number
) {}
}
// 엔트리 포인트 계층
export class XxxResultData {
@ApiProperty() readonly id: string
@ApiProperty() readonly information: string
@ApiProperty() readonly created_at: number
private constructor(
id: string,
information: string,
created_at: number
) {
this.id = id
this.information = information
this.created_at = created_at
}
public static from(result: XxxResult): XxxResultData {
return new XxxResultData(
result.id,
result.information,
result.created_at
)
}
}
export class XxxHttpResponse {
@ApiProperty({ type: XxxResultData })
public readonly data: XxxResultData
public static from(result: XxxResult): XxxHttpResponse {
return new XxxHttpResponse(XxxResultData.from(result))
}
}
핵심 개선점:
의도 명확화:
Document
→Data
로 네이밍 개선팩토리 패턴: 변환 로직을 엔트리 포인트 계층에서 담당
타입 안전성: 컴파일 타임에 불일치 감지 가능
성능 최적화 고려사항
대용량 데이터 처리 시 문제
// ❌ 성능 문제 발생 코드
async function getUsers(): Promise<UserHttpResponse[]> {
const users = await userService.findAll(); // 10,000개
return users.map(user => UserHttpResponse.from(user)); // 객체 2만개 생성
}
문제점:
원본 객체 10,000개 + 변환된 객체 10,000개 = 메모리 사용량 2배
팩토리 메서드 10,000번 호출 = CPU 오버헤드
성능 최적화 패턴
1. 스트림 기반 변환
async function getUsers(): Promise<UserHttpResponse[]> {
const userStream = userService.findAllStream();
return userStream
.map(user => UserHttpResponse.from(user))
.toArray(); // 스트림으로 변환하여 메모리 효율화
}
2. 선택적 필드 매핑
export class UserHttpResponse {
@ApiProperty() readonly id: string
@ApiProperty() readonly name: string
// created_at 등 불필요한 필드는 제외
public static from(user: User): UserHttpResponse {
return new UserHttpResponse(user.id, user.name); // 필요한 것만
}
}
3. 타입 안전성 보장
// 제네릭으로 타입 안전성 확보
type ResponseFields<T> = Pick<T, keyof T>;
export class XxxHttpResponse implements ResponseFields<XxxResult> {
@ApiProperty() readonly id: string
@ApiProperty() readonly information: string
@ApiProperty() readonly created_at: number
// XxxResult와 필드 불일치 시 컴파일 에러
}
실무 번외: 플랫 구조 적용
실무에서는 보통 data
래핑을 제거하고 응답 객체에 직접 풀어쓴다:
export class XxxHttpResponse {
@ApiProperty() public readonly id: string
@ApiProperty() public readonly information: string
@ApiProperty() public readonly created_at: number
public static from(result: XxxResult): XxxHttpResponse {
return new XxxHttpResponse(
result.id,
result.information,
result.created_at
)
}
}
장점:
응답 구조 단순화
클라이언트 사이드에서
response.data.field
→response.field
로 접근 간소화번들 사이즈 최적화 (중첩 객체 제거)
Generic 기반 공통 응답 래퍼
일관된 응답 구조가 필요한 경우:
export class BaseHttpResponse<T> {
@ApiProperty() readonly status: 'success' | 'error'
@ApiProperty() readonly data: T
@ApiProperty() readonly timestamp: number
constructor(data: T, status: 'success' | 'error' = 'success') {
this.status = status
this.data = data
this.timestamp = Date.now()
}
static success<T>(data: T): BaseHttpResponse<T> {
return new BaseHttpResponse(data, 'success')
}
static error<T>(error: T): BaseHttpResponse<T> {
return new BaseHttpResponse(error, 'error')
}
}
Swagger 설정:
@ApiTags('Xxx')
@ApiExtraModels(BaseHttpResponse, XxxHttpResponse)
@Controller('api/xxx')
export class XxxHttpApi {
@Get()
@ApiOkResponse({
description: '정상 응답',
schema: {
allOf: [
{ $ref: getSchemaPath(BaseHttpResponse) },
{
properties: {
data: { $ref: getSchemaPath(XxxHttpResponse) },
},
},
],
},
})
public getSomething(): BaseHttpResponse<XxxHttpResponse> {
const result = this.service.getSomething()
const response = XxxHttpResponse.from(result)
return BaseHttpResponse.success(response)
}
}
⚠️ Nestia 같은 라이브러리 사용 시 이런 복잡성 대부분이 해결되지만, 기본기 습득 후 도입하는 것을 목표로 하고 있다.
결론
계층간 DTO 분리는 책임 분리, 성능, 유지보수성을 모두 고려해야 하는 복잡한 문제다.
핵심 원칙:
명확한 변환 책임: 엔트리 포인트 계층에서 응답 변환 담당
타입 안전성: 컴파일 타임 검증으로 런타임 에러 예방
성능 고려: 대용량 데이터 처리 시 메모리/CPU 최적화
실무 적용성: 팀 컨벤션과 비즈니스 요구사항 균형
패턴을 맹신하지 말고, 프로젝트 특성에 맞는 최적해를 찾는 것이 중요하다.
Subscribe to my newsletter
Read articles from 김한결 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
