Skip to Content

DTO (데이터 전송 객체)

ASAPJS의 DTO는 HTTP 레이어와 데이터베이스 레이어 사이를 흐르는 데이터의 형태를 정의합니다. DTO는 엔티티 위에 놓여 특정 작업에 필요한 필드만을 선택합니다. DTO는 동시에 세 가지를 담당합니다: 데이터베이스 쿼리를 위한 Sequelize attributesinclude 옵션, 라우트의 OpenAPI/Swagger 스키마, 그리고 클라이언트로부터 수신하거나 데이터베이스에서 반환된 데이터를 정제하는 런타임 타입 변환입니다.

ExtendableDto 기본 클래스

모든 DTO는 ExtendableDto를 상속해야 합니다. @asapjs/sequelize에서 임포트합니다:

import { ExtendableDto } from '@asapjs/sequelize';

클래스 개요

class ExtendableDto { static isInitlized: boolean; public init(): void; public map(data: any): any; public pagingMap(data: any): any; public middleware(as?: string, user?: any, extraAs?: string[]): DtoMiddlewareReturn; public generateScheme(): object; public swagger(): object; }

생성자 패턴

ASAPJS DTO는 두 가지 패턴 중 하나를 사용하여 DTO 메타데이터를 등록합니다. 두 패턴 모두 init()을 호출하기 전에 Reflect.defineMetadatasequelize::dtoInfo 키를 설정해야 합니다.

패턴 A — @Dto 클래스 데코레이터 (권장, 예제 앱에서 사용):

@Dto({ defineTable: UsersTable, timestamps: false, name: 'user_dto' }) 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; }

@Dto 데코레이터는 동일한 sequelize::dtoInfo 메타데이터를 target.prototype에 기록하고, addDto를 통해 콘솔 레지스트리에도 등록합니다. @Dto를 사용할 때 init()을 수동으로 호출할 필요가 없습니다 — DTO 파일명이 *Dto.ts이면, initSequelizeModule()이 해당 파일을 스캔하여 자동으로 init()을 호출합니다.

패턴 B — Reflect.defineMetadata를 사용한 생성자 (레거시):

export default class UserInfoDto extends ExtendableDto { @TypeIs.INT({ comment: 'User ID' }) id: number; @TypeIs.STRING({ comment: 'Email' }) email: string; }

생성자에서 Reflect.defineMetadata를 직접 호출하고 this.init()을 명시적으로 호출하는 방식입니다. 인스턴스별 제어가 필요한 특수한 경우에 사용할 수 있습니다.


init()

public init(): void

this.constructor.name에서 DTO의 클래스 이름을 읽고 addScheme으로 OpenAPI 스키마를 등록합니다. sequelize::dtoInfo 메타데이터를 설정한 뒤 생성자 내부에서 DTO 클래스당 한 번 호출하세요. 여러 번 호출해도 무해하지만 중복입니다.


map(data)

public map(data: any): any

원시 객체나 Sequelize 모델 인스턴스를 DTO에 선언된 키만 포함하는 일반 객체로 변환합니다.

TypeIs 종류별 동작:

필드 종류변환 방식
스칼라 TypeIs (INT, STRING 등)fixValue가 정의된 경우 fixValue(o[key]) 호출; 그렇지 않으면 o[key] 직접 반환
TypeIs.DTO 필드중첩 DTO를 인스턴스화하고 중첩 데이터에 재귀적으로 map()을 호출; 중첩 값이 없으면 null 반환
TypeIs.QUERY 필드QUERY 타입 자체의 fixValue 위임을 통해 fixValue(o[key]) 호출
입력의 알 수 없는 키조용히 제거 — DTO에 선언된 키만 출력

입력 처리:

  • data.dataValues 프로퍼티가 있으면(Sequelize 모델 인스턴스) .dataValues 객체를 소스로 사용합니다.
  • data가 배열이면 map()이 각 요소에 적용되어 배열을 반환합니다.
const user = await UsersTable.findOne({ where: { id: 1 } }); const dto = new UserInfoDto().map(user); // dto = { id: 1, email: 'a@example.com', name: 'Alice' } // password 및 목록에 없는 필드는 제외

일반 객체도 전달 가능합니다:

const raw = { id: '5', email: 'b@example.com', name: 'Bob', password: 'secret' }; const dto = new UserInfoDto().map(raw); // dto = { id: 5, email: 'b@example.com', name: 'Bob' } // id는 TypeIs.INT의 fixValue에 의해 문자열 '5'에서 정수 5로 변환 // password는 UserInfoDto에 선언되지 않아 제거

pagingMap(data)

public pagingMap(data: { data: any[]; page: number; page_size: number; [key: string]: any }): any

페이지네이션된 쿼리 결과를 위한 편의 래퍼입니다. data.data의 모든 항목에 map()을 호출하고 결과를 원본 엔벨로프 객체에 다시 합칩니다.

const result = await someService.getPosts(paging); // result = { data: [...Sequelize 모델...], page: 1, page_size: 10, max_page: 5, ... } const dto = new PostInfoDto().pagingMap(result); // dto = { data: [...매핑된 DTO...], page: 1, page_size: 10, max_page: 5, ... }

{ data, page, page_size, max_page, has_prev, has_next, total_elements } 형태를 반환하는 Repository 헬퍼로 페이지네이션 래퍼가 생성될 때 pagingMap을 사용하세요.

참고: 현재 Swagger 스키마(paging.ts)에서는 has_Next(대문자 N)로 정의되어 있으나, Repository의 실제 반환값은 has_next(소문자 n)입니다. 향후 코드 수정으로 통일될 예정입니다.


middleware(as?, user?, extraAs?)

public middleware( as?: string, user?: any, extraAs?: string[] ): DtoMiddlewareReturn

DTO의 필드 선언에서 파생된 Sequelize 쿼리 옵션을 생성합니다. 이 메서드는 중첩된 TypeIs.DTO 필드를 해석할 때 ExtendableDto 내부에서 호출되지만, 쿼리를 수동으로 구성할 때 직접 호출할 수도 있습니다.

반환 타입:

interface DtoMiddlewareReturn { as?: string; // 제공된 경우 연관 별칭 model: typeof Model; // defineTable의 엔티티 클래스 attributes: any[]; // Sequelize attributes 배열 (컬럼 + literal 표현식) include?: any[]; // TypeIs.DTO 필드의 중첩 include 옵션 }

필드 처리:

필드 종류반환값에 미치는 영향
스칼라 필드 (DTO 또는 QUERY가 아닌)attributes 배열에 키 추가
TypeIs.QUERY 필드[Sequelize.literal(sql), alias] 튜플을 attributes에 추가; query() 함수는 { association, user }를 받음
TypeIs.DTO 필드중첩 DTO에 재귀적으로 middleware()를 호출하고 결과를 include에 추가

파라미터:

파라미터타입설명
asstring연관 별칭. 이 DTO가 TypeIs.DTO를 통해 다른 DTO 내부에 중첩될 때 사용됩니다. result.as를 설정하며 TypeIs.QUERY 함수에 전달되는 association 경로의 일부가 됩니다.
userany인증된 사용자 객체(JWT 페이로드에서). 사용자 스코프 계산 컬럼을 위해 TypeIs.QUERYquery() 함수에 전달됩니다.
extraAsstring[]깊이 중첩된 관계를 위한 추가 연관 경로 세그먼트. TypeIs.QUERY를 위한 전체 연관 체인 문자열을 구성하기 위해 as와 결합됩니다.
// 애플리케이션 레이어에서 직접 사용 const options = new PostInfoDto().middleware(); const posts = await PostsTable.findAll({ ...options, where: { user_id: userId }, });

generateScheme()

public generateScheme(): { type: 'object'; properties: Record<string, any> }

DTO의 모든 TypeIs 데코레이터가 적용된 필드를 순회하며 각각에 toSwagger()를 호출하여 OpenAPI 스키마 객체를 반환합니다.

{ type: 'object', properties: { id: { type: 'integer', format: 'int32', description: 'User ID' }, email: { type: 'string', description: 'Email' }, name: { type: 'string', description: 'Display name' }, } }

TypeIs.DTO 필드는 $ref 항목을 생성합니다. TypeIs.QUERY 필드는 하위 type TypeIs의 Swagger 출력을 생성합니다. toSwagger 메서드가 없는 TypeIs 구현의 필드는 로거에 경고를 내보내고 null을 생성합니다.


swagger()

public swagger(): { $ref: string }

이 DTO의 클래스 이름으로 등록된 스키마 컴포넌트를 가리키는 OpenAPI $ref 객체를 반환합니다.

new UserInfoDto().swagger() // { $ref: '#/components/schemas/UserInfoDto' }

TypeIs.DTO, TypeIs.ARRAY, TypeIs.PAGING이 부모 스키마에 DTO 참조를 삽입할 때 내부적으로 사용됩니다.


@Dto 데코레이터

import { Dto } from '@asapjs/sequelize';

시그니처

function Dto(info: { defineTable: typeof Model; // 필수 — 이 DTO가 쿼리할 엔티티 클래스 timestamps?: boolean; // 기본값: 생략 시 true name?: string; // 선택적 사람이 읽을 수 있는 이름 }): ClassDecorator

파라미터

파라미터타입필수 여부설명
defineTabletypeof Model이 DTO가 매핑되는 Sequelize 모델 클래스. middleware()가 반환값의 model을 채우고 TypeIs.QUERY가 연관 경로를 해석하는 데 사용됩니다.
timestampsboolean아니오호출에서 생략하면 undefined가 기본값이며 데코레이터는 내부적으로 timestamps: true로 설정합니다. 비활성화하려면 false를 전달하세요. 참고: 이는 메타데이터만 제어하며 컬럼을 추가하거나 제거하지 않습니다.
namestring아니오콘솔 레지스트리에 저장되는 선택적 레이블. 런타임 동작에 영향을 주지 않습니다.

동작

@Dto 데코레이터는 해석된 info 객체를 'sequelize::dtoInfo' reflect-metadata 키 아래 target.prototype에 기록합니다. 또한 addDto를 통해 DTO의 타입 데이터를 콘솔 레지스트리에 등록합니다.

@Dto({ defineTable: UsersTable, timestamps: false, name: 'user_info_dto' }) export default class UserInfoDto extends ExtendableDto { @TypeIs.INT({ comment: 'User ID' }) id: number; @TypeIs.STRING({ comment: 'Email' }) email: string; @TypeIs.STRING({ comment: 'Display name' }) name: string; }

@Dto vs. 생성자 방식

예제 앱은 @Dto 데코레이터 방식을 사용합니다. 두 방식 모두 유효하지만, @Dto를 권장합니다:

@Dto 데코레이터 (권장)생성자 Reflect.defineMetadata (레거시)
메타데이터 대상target.prototypethis (인스턴스)
init() 호출파일명이 *Dto.ts이면 initSequelizeModule()에서 자동 호출생성자에서 명시적 호출
콘솔 레지스트리addDto를 통해 자동 등록수동 등록 필요
사용 시점대부분의 경우 (권장)인스턴스별 제어나 init() 타이밍이 중요할 때

PaginationQueryDto와 PaginationQueryType

PaginationQueryDto@asapjs/sequelize가 제공하는 내장 DTO로, 페이지네이션 쿼리 파라미터를 표준화합니다. PaginationQueryType은 이 DTO에서 파생된 타입 별칭입니다.

import { PaginationQueryDto, type PaginationQueryType } from '@asapjs/sequelize';

정의

class PaginationQueryDto extends ExtendableDto { @TypeIs.INT({ comment: '페이지' }) page: number; @TypeIs.INT({ comment: '한 페이지당 표시 개수' }) limit: number; } type PaginationQueryType = Pick<PaginationQueryDto, 'page' | 'limit'>;

PaginationQueryTypePaginationQueryDto에서 pagelimit 필드만 추출한 타입입니다. @asapjs/routerWrapper가 생성하는 paging 객체의 타입이며, ExecuteArgs.paging의 타입입니다. Wrapper는 DTO 인스턴스가 아닌 PaginationQueryType 타입의 plain object를 생성합니다.

라우트에서의 사용법

라우트의 query 옵션으로 PaginationQueryDto(또는 이를 상속한 커스텀 DTO)를 전달하면 ?page=&limit= 쿼리 스트링 파라미터를 문서화합니다:

@Get('/', { title: '사용자 목록 조회', query: GetUserListQueryDto, // PaginationQueryDto를 상속 response: UserDto, }) public getUserList = async ({ paging, user }: ExecuteArgs<{}, GetUserListQueryDto, {}>) => { const result = await this.userService.list(paging, user); return { result }; };

DtoOrTypeIs 타입

type DtoOrTypeIs = typeof ExtendableDto | (() => TypeIsData);

DtoOrTypeIsIOptions(라우트 데코레이터 옵션)의 body, query, response 필드와 TypeIs.ARRAY, TypeIs.PAGING이 받는 유니온 타입입니다.

분기사용 시점예시
typeof ExtendableDtoDTO 클래스 — 이름이 있는 필드를 가진 구조화된 객체body: CreateUserDto
() => TypeIsData순수 TypeIs.*() 호출 — 기본형 또는 복합 응답response: TypeIs.BOOLEAN()

TypeIs.ARRAYTypeIs.PAGING 함수는 런타임에 isClass 검사로 어느 분기인지 판별합니다:

// DTO 분기 — 네임드 스키마에 대한 $ref 생성 TypeIs.ARRAY(UserInfoDto) // TypeIs 분기 — 인라인 배열 스키마 생성 TypeIs.ARRAY(TypeIs.INT()) TypeIs.ARRAY(TypeIs.STRING())

전체 DTO 예제

요청 DTO (생성 작업)

// src/user/dto/CreateUserDto.ts import { ExtendableDto, TypeIs } from '@asapjs/sequelize'; import UsersTable from '../domain/entity/UsersTable'; export default class CreateUserDto extends ExtendableDto { @TypeIs.STRING({ comment: 'Email address' }) email: string; @TypeIs.PASSWORD({ comment: 'Password' }) password: string; @TypeIs.STRING({ comment: 'Display name' }) name: string; }

@Post 라우트의 body DTO로 사용:

@Post('/register', { title: 'Register', auth: false, body: CreateUserDto, response: UserInfoDto, }) async register({ body }: ExecuteArgs) { return await this.userService.register(body as CreateUserDto); }

응답 DTO (조회 작업)

// src/user/dto/UserInfoDto.ts import { ExtendableDto, TypeIs } from '@asapjs/sequelize'; import UsersTable from '../domain/entity/UsersTable'; export default class UserInfoDto extends ExtendableDto { @TypeIs.INT({ comment: 'User ID' }) id: number; @TypeIs.STRING({ comment: 'Email' }) email: string; @TypeIs.STRING({ comment: 'Display name' }) name: string; }

서비스에서 Sequelize 모델을 변환하는 데 사용:

// src/user/application/UserApplication.ts async getUser(userId: number): Promise<UserInfoDto> { const dto = new UserInfoDto(); const user = await UsersTable.findOne({ ...dto.middleware(), where: { id: userId }, }); return dto.map(user); }

중첩 DTO (관계형 데이터)

TypeIs.DTO를 통해 다른 DTO를 내장하여 하나의 쿼리로 관련 데이터를 포함하는 DTO:

// src/post/dto/PostInfoDto.ts import { ExtendableDto, TypeIs } from '@asapjs/sequelize'; import PostsTable from '../domain/entity/PostsTable'; import UserInfoDto from '../../user/dto/UserInfoDto'; export default class PostInfoDto extends ExtendableDto { @TypeIs.INT({ comment: 'Post ID' }) id: number; @TypeIs.STRING({ comment: 'Title' }) title: string; @TypeIs.TEXT({ comment: 'Content' }) content: string; // 'user'는 PostsTable의 BELONGSTO 별칭과 일치해야 함 @TypeIs.DTO({ dto: UserInfoDto, as: 'user', comment: 'Author' }) user: UserInfoDto; @TypeIs.DATETIME({ comment: 'Created at' }) created_at: Date; }

PostInfoDto에서 middleware()를 호출하면 다음이 생성됩니다:

{ model: PostsTable, attributes: ['id', 'title', 'content', 'created_at'], include: [ { model: UsersTable, as: 'user', attributes: ['id', 'email', 'name'], } ] }

페이지네이션 목록 응답

// 애플리케이션 레이어 async getPosts(paging: { page: number; limit: number }) { const dto = new PostInfoDto(); const { count, rows } = await PostsTable.findAndCountAll({ ...dto.middleware(), limit: paging.limit, offset: (paging.page - 1) * paging.limit, order: [['created_at', 'DESC']], }); const max_page = Math.ceil(count / paging.limit); return dto.pagingMap({ data: rows, page: paging.page, page_size: paging.limit, max_page, has_prev: paging.page > 1, has_next: paging.page < max_page, total_elements: count, }); }

라우트는 TypeIs.PAGING으로 엔벨로프 응답을 문서화합니다:

@Get('/', { title: 'List posts', query: PaginationQueryDto, response: TypeIs.PAGING(PostInfoDto), }) async getPosts({ paging }: ExecuteArgs) { return await this.postService.getPosts(paging); }

관련 항목

Last updated on