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

김한결김한결
4 min read

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))
    }
}

핵심 개선점:

  • 의도 명확화: DocumentData로 네이밍 개선

  • 팩토리 패턴: 변환 로직을 엔트리 포인트 계층에서 담당

  • 타입 안전성: 컴파일 타임에 불일치 감지 가능

성능 최적화 고려사항

대용량 데이터 처리 시 문제

// ❌ 성능 문제 발생 코드
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.fieldresponse.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 분리는 책임 분리, 성능, 유지보수성을 모두 고려해야 하는 복잡한 문제다.

핵심 원칙:

  1. 명확한 변환 책임: 엔트리 포인트 계층에서 응답 변환 담당

  2. 타입 안전성: 컴파일 타임 검증으로 런타임 에러 예방

  3. 성능 고려: 대용량 데이터 처리 시 메모리/CPU 최적화

  4. 실무 적용성: 팀 컨벤션과 비즈니스 요구사항 균형

패턴을 맹신하지 말고, 프로젝트 특성에 맞는 최적해를 찾는 것이 중요하다.

0
Subscribe to my newsletter

Read articles from 김한결 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

김한결
김한결