Skip to Content

DTO System

DTO(Data Transfer Object)는 ASAPJS에서 데이터의 형태를 정의하고, 그 정의로부터 Swagger 스키마 생성, Sequelize 쿼리 설정, 런타임 데이터 변환을 자동으로 수행하는 시스템입니다. Entity가 데이터베이스의 전체 테이블을 표현한다면, DTO는 특정 API 엔드포인트에서 필요한 필드만 선택적으로 노출합니다.


ExtendableDto — 베이스 클래스

모든 DTO는 ExtendableDto를 상속합니다. 이 클래스는 네 가지 핵심 기능을 제공합니다.

// packages/sequelize/src/dto/ExtendableDto.ts export default class ExtendableDto { public init() // Swagger 스키마 등록 public map(data) // 데이터 변환 (fixValue 적용) public pagingMap(data) // 페이지네이션 데이터 변환 public middleware(as?, user?, extraAs?) // Sequelize 쿼리 설정 생성 public generateScheme() // OpenAPI 스키마 생성 public swagger() // $ref 참조 반환 }
메서드역할
init()DTO의 TypeIs 필드를 순회하여 Swagger 스키마를 생성하고 전역 스키마 레지스트리에 등록
map(data)원시 데이터에 각 필드의 fixValue()를 적용하여 타입 변환. 중첩 DTO는 재귀적으로 매핑
pagingMap(data){ data: [...], page, ... } 형태의 페이지네이션 응답에서 data 배열을 map()으로 변환
middleware()DTO 필드 정보로부터 Sequelize attributesinclude 설정을 자동 생성
generateScheme()모든 TypeIs 필드에서 toSwagger()를 호출하여 OpenAPI 스키마 객체 반환
swagger(){ $ref: '#/components/schemas/DtoName' } 형태의 참조 반환

DTO 정의하기

DTO를 만들려면 ExtendableDto를 상속하고, TypeIs.* 데코레이터로 필드를 선언합니다. 메타데이터 등록에는 두 가지 방식이 있으며, @Dto 데코레이터 방식을 권장합니다.

권장 패턴 — @Dto 데코레이터

@Dto 데코레이터를 사용하면 메타데이터 등록과 콘솔 레지스트리 등록이 자동으로 처리됩니다. 현재 example 프로젝트의 모든 DTO가 이 패턴을 사용합니다.

요청 DTO — CreateUserDto

클라이언트가 서버에 보내는 데이터의 형태를 정의합니다:

// packages/example/src/user/dto/CreateUserDto.ts import { Dto, ExtendableDto, TypeIs } from '@asapjs/sequelize'; import UsersTable, { UserTypeEnum } from '../domain/entity/UsersTable'; @Dto({ name: 'create_user_dto', defineTable: UsersTable }) export default class CreateUserDto extends ExtendableDto { @TypeIs.ENUM({ values: Object.keys(UserTypeEnum), comment: '유저 유형' }) type: UserTypeEnum; @TypeIs.STRING({ comment: '이메일' }) email: string; @TypeIs.PASSWORD({ comment: '비밀번호' }) password: string; @TypeIs.STRING({ comment: '이름' }) name: string; @TypeIs.STRING({ comment: '전화번호' }) phone: string; }

주요 관찰 포인트:

  • @Dto({ defineTable: UsersTable }) — 이 DTO가 어떤 Entity의 프로젝션인지 지정합니다. middleware()가 Sequelize 쿼리를 생성할 때 이 테이블 정보를 사용합니다.
  • @Dto 데코레이터Reflect.defineMetadataaddDto 콘솔 레지스트리 등록을 자동으로 처리합니다. 생성자에서 수동으로 호출할 필요가 없습니다.
  • init() 자동 호출 — DTO 파일명이 *Dto.ts이면, initSequelizeModule()이 해당 파일을 스캔하여 new Dto()init()을 자동으로 호출합니다. 생성자에서 수동 init() 호출이 불필요합니다.
  • @TypeIs.PASSWORD — Swagger에서 format: 'password'로 표시되어 UI에서 마스킹됩니다.

응답 DTO — UserDto

서버가 클라이언트에 반환하는 데이터의 형태를 정의합니다:

// packages/example/src/user/dto/UserDto.ts import { ExtendableDto, Dto, TypeIs } from '@asapjs/sequelize'; import UsersTable, { UserTypeEnum } from '../domain/entity/UsersTable'; @Dto({ name: 'user_dto', defineTable: UsersTable }) export default class UserDto extends ExtendableDto { @TypeIs.INT({ comment: '아이디' }) id: number; @TypeIs.ENUM({ values: Object.keys(UserTypeEnum), comment: '유저 유형' }) type: UserTypeEnum; @TypeIs.STRING({ comment: '이메일' }) email: string; @TypeIs.STRING({ comment: '이름' }) name: string; @TypeIs.STRING({ comment: '전화번호' }) phone: string; @TypeIs.BOOLEAN({ comment: '활성 상태' }) is_active: boolean; @TypeIs.DATETIME({ comment: '생성일' }) created_at: Date; @TypeIs.DATETIME({ comment: '수정일' }) updated_at: Date; }

CreateUserDto에 있던 password 필드가 UserDto에는 없습니다. 이것이 DTO의 핵심 역할입니다 — Entity의 전체 필드 중 API에 필요한 것만 선택적으로 노출합니다.

레거시 패턴 — 생성자에서 직접 메타데이터 등록

@Dto 데코레이터 대신, 생성자에서 Reflect.defineMetadata를 직접 호출하고 this.init()을 명시적으로 호출하는 방식도 동작합니다:

export default class UserInfoDto extends ExtendableDto { @TypeIs.INT({ comment: '사용자 ID' }) id: number; @TypeIs.STRING({ comment: '이메일' }) email: string; }

이 방식은 인스턴스별 제어가 필요한 특수한 경우에 사용할 수 있지만, 대부분의 경우 @Dto 데코레이터 방식이 더 간결합니다.


map() — 데이터 변환

map() 메서드는 원시 데이터(DB 조회 결과 또는 요청 본문)를 DTO 정의에 따라 변환합니다.

// packages/sequelize/src/dto/ExtendableDto.ts (핵심 로직) public map = (data: any): any => { const types = getTypesData(this); const o: any = data?.dataValues || data; if (Array.isArray(o)) { return o.map((item: any) => this.map(item)); } return Object.keys(types).reduce((p, key) => { const property = types[key]; if (property.__name === 'dto') { // 중첩 DTO: 재귀적으로 map 호출 p[key] = !!o?.[key] ? new data.dto().map(o?.[key]) : null; } else if (property.__name === 'query') { // QUERY 타입: fixValue만 적용 p[key] = property?.fixValue?.(o?.[key]); } else { // 일반 타입: fixValue 적용 p[key] = property?.fixValue?.(o?.[key]) || o?.[key]; } return p; }, {}); };

동작 과정:

  1. getTypesData(this)로 DTO에 선언된 모든 TypeIs 메타데이터를 가져옵니다
  2. 입력이 Sequelize Model이면 dataValues를 추출합니다
  3. 배열이면 각 요소에 대해 재귀적으로 map()을 호출합니다
  4. 각 필드에 대해 해당 TypeIs의 fixValue()를 적용하여 타입을 변환합니다
    • TypeIs.INTparseInt(String(o), 10)
    • TypeIs.STRINGString(o)
    • TypeIs.BOOLEAN'true'/'false' 문자열도 올바른 boolean으로 변환
  5. TypeIs.DTO 필드는 중첩 DTO의 map()을 재귀 호출합니다

middleware() — Sequelize 쿼리 설정

middleware() 메서드는 DTO의 필드 정의를 Sequelize의 findAll/findOne 옵션으로 변환합니다. Application 레이어에서 직접 attributesinclude를 나열하지 않아도 됩니다.

// packages/sequelize/src/dto/ExtendableDto.ts (핵심 로직) public middleware = (as?, user?, extraAs?) => { const modelInfo = Reflect.getMetadata('sequelize::dtoInfo', this); const types = getTypesData(this); // 1. 일반 필드 → attributes 배열 const attributes = Object.entries(types) .filter(([key, v]) => !['dto', 'query'].includes(v.__name)) .map(([key]) => key); // 2. QUERY 필드 → literal SQL로 attributes에 추가 // [literal('SELECT ...'), 'fieldName'] // 3. DTO 필드 → include 배열 (재귀) const include = Object.entries(types) .filter(([key, v]) => v.__name === 'dto') .map(([key, v]) => new v.toSequelize().dto().middleware(key, user)); return { model: modelInfo.defineTable, attributes, include, as }; };

이 메서드는 세 종류의 TypeIs 필드를 구분하여 처리합니다:

TypeIs 종류처리 방식
일반 필드 (STRING, INT 등)attributes 배열에 필드명 추가
QUERY 필드literal() SQL 표현식으로 attributes에 추가
DTO 필드include 배열에 중첩 쿼리 설정 추가 (재귀)

@Dto 데코레이터

@Dto는 클래스 레벨 데코레이터로, DTO와 Entity 테이블의 연결 정보를 메타데이터로 저장하고 콘솔 레지스트리에도 등록합니다. 현재 example 프로젝트의 모든 DTO가 이 패턴을 사용하며, 권장 방식입니다.

// packages/sequelize/src/dto/index.ts export function Dto(info: { timestamps?: boolean; name?: string; defineTable: typeof Model; }) { return function (target: any): void { const args = { ...info, timestamps: info.timestamps === undefined, }; Reflect.defineMetadata('sequelize::dtoInfo', args, target.prototype); const types = getTypesData(target); addDto(target.name, target.name, types); // 콘솔 레지스트리 등록 }; }

@Dto 데코레이터를 사용하면 생성자에서 Reflect.defineMetadata를 직접 호출하지 않아도 됩니다. 또한 addDto를 통해 DTO의 타입 정보가 콘솔 레지스트리에 자동 등록됩니다.


PaginationQueryDto와 PaginationQueryType

ASAPJS는 페이지네이션을 위한 기본 DTO와 타입을 내장으로 제공합니다:

// packages/sequelize/src/dto/PaginationQueryDto.ts import { TypeIsDecorators as TypeIs } from '../types/decorators'; import { ExtendableDto } from './index'; /** 페이지네이션 쿼리 DTO. 인자 타입은 PaginationQueryType 사용. */ export default class PaginationQueryDto extends ExtendableDto { @TypeIs.INT({ comment: '페이지' }) page: number; @TypeIs.INT({ comment: '한 페이지당 표시 개수' }) limit: number; } export type PaginationQueryType = Pick<PaginationQueryDto, 'page' | 'limit'>;

PaginationQueryTypePaginationQueryDto에서 pagelimit 필드만 추출한 타입 별칭입니다. Wrapper는 모든 요청에서 ?page=?limit= 쿼리 파라미터를 자동으로 파싱하여 PaginationQueryType 타입의 plain object를 생성합니다 (DTO 인스턴스가 아닙니다). 기본값은 page: 0, limit: 20입니다.

// packages/router/src/utils/wrapper.ts (내부 동작) import type { PaginationQueryType } from '@asapjs/sequelize'; const paging: PaginationQueryType = { page, limit };

ExecuteArgspaging 필드 타입이 PaginationQueryType이므로, Application/Repository 레이어에서 타입 안전하게 사용할 수 있습니다:

import type { PaginationQueryType } from '@asapjs/sequelize'; async list(paging: PaginationQueryType) { // paging.page, paging.limit 사용 }

라우트 데코레이터에서 query: PaginationQueryDto(또는 이를 상속한 DTO)를 지정하면 Swagger 문서에 쿼리 파라미터가 표시됩니다:

// packages/example/src/user/controller/UserController.ts @Get('/', { title: '사용자 목록 조회', description: '페이지네이션을 지원하는 사용자 목록을 조회합니다.', query: GetUserListQueryDto, // PaginationQueryDto를 상속한 커스텀 쿼리 DTO response: UserDto, }) public getUserList = async ({ paging, user }: ExecuteArgs<{}, GetUserListQueryDto, {}>) => { const result = await this.userService.list(paging, user); return { result }; };

DTO와 라우트 데코레이터의 연결

DTO가 라우트 데코레이터의 body, query, response 옵션에 전달되면, 프레임워크가 자동으로 세 가지를 수행합니다:

1. Swagger 스키마 등록

DTO의 generateScheme()이 호출되어 각 TypeIs 필드의 toSwagger() 출력을 모아 OpenAPI 스키마를 만듭니다.

// CreateUserDto의 generateScheme() 결과 { type: 'object', properties: { email: { type: 'string', description: '이메일' }, password: { type: 'string', format: 'password', description: '비밀번호' }, name: { type: 'string', description: '사용자 이름' }, } }

2. 요청/응답 스키마 참조

라우트의 Swagger 문서에서 DTO를 $ref로 참조합니다:

requestBody: { $ref: '#/components/schemas/CreateUserDto' } responses.200: { $ref: '#/components/schemas/UserInfoDto' }

3. TypeIs.PAGING 컴포지션

TypeIs.PAGING(PostInfoDto)처럼 컴포지션 타입을 사용하면, Swagger에 페이지네이션 래퍼 스키마가 자동 등록됩니다:

// TypeIs.PAGING(PostInfoDto)가 생성하는 Swagger 스키마 { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/PostInfoDto' } }, page: { type: 'integer', description: '현재 페이지' }, page_size: { type: 'integer', description: '페이지당 표시 개수' }, max_page: { type: 'integer', description: '최대 페이지' }, has_prev: { type: 'boolean', description: '이전 이동가능 여부' }, has_next: { type: 'boolean', description: '다음 이동가능 여부' }, total_elements: { type: 'integer', description: '전체 레코드 개수' }, } }

특수 TypeIs 타입 — DTO 전용

일반적인 TypeIs.STRING, TypeIs.INT 등은 Entity와 DTO 모두에서 사용됩니다. 하지만 일부 TypeIs 타입은 DTO에서만 의미를 가집니다:

TypeIs.DTO — 중첩 객체

관련된 다른 DTO를 중첩하여 포함할 때 사용합니다:

@TypeIs.DTO({ dto: UserInfoDto, as: 'author' }) author: UserInfoDto;
  • map() 시 중첩 DTO의 map()을 재귀 호출
  • middleware() 시 Sequelize include에 중첩 쿼리 추가
  • Swagger에서 $ref로 참조

TypeIs.QUERY — 계산된 필드

SQL 표현식으로 계산되는 가상 필드를 정의합니다:

@TypeIs.QUERY({ query: ({ association }) => `(SELECT COUNT(*) FROM comments WHERE comments.post_id = ${association}.id)`, type: () => TypeIs.INT(), }) commentCount: number;
  • middleware()literal() SQL로 attributes에 추가
  • map() 시 결과 값에 fixValue() 적용
  • Swagger에서 type 옵션의 toSwagger() 출력 사용

TypeIs.ARRAY — 배열 래퍼

DTO나 TypeIs 타입을 배열로 감쌉니다:

response: TypeIs.ARRAY(PostInfoDto) // Swagger: { type: 'array', items: { $ref: '#/components/schemas/PostInfoDto' } }

TypeIs.PAGING — 페이지네이션 래퍼

DTO를 페이지네이션 응답 구조로 감쌉니다:

response: TypeIs.PAGING(PostInfoDto) // Swagger: { data: PostInfoDto[], page, page_size, max_page, has_prev, has_next, total_elements } // 참고: 현재 Swagger 스키마(paging.ts)에서는 'has_Next'로 정의되어 있으나, // Repository 실제 반환값은 'has_next'입니다. 향후 코드 수정으로 통일될 예정입니다.

요약

DTO 시스템은 ASAPJS에서 데이터 흐름의 중심에 있습니다:

  1. 형태 정의TypeIs.* 데코레이터로 필드를 선언하면, 해당 필드의 Swagger 타입, Sequelize 컬럼, 런타임 변환이 모두 결정됩니다
  2. 선택적 노출 — Entity의 전체 필드 중 API에 필요한 것만 DTO에 선언하여, 민감한 정보(비밀번호 등)가 응답에 포함되지 않도록 합니다
  3. 자동 쿼리 생성middleware() 메서드가 DTO 필드 정의로부터 Sequelize attributesinclude를 자동 생성합니다
  4. 타입 안전한 변환map() 메서드가 각 필드의 fixValue()를 적용하여 런타임 타입 변환을 수행합니다
Last updated on