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 attributes와 include 설정을 자동 생성 |
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.defineMetadata와addDto콘솔 레지스트리 등록을 자동으로 처리합니다. 생성자에서 수동으로 호출할 필요가 없습니다.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;
}, {});
};동작 과정:
getTypesData(this)로 DTO에 선언된 모든 TypeIs 메타데이터를 가져옵니다- 입력이 Sequelize Model이면
dataValues를 추출합니다 - 배열이면 각 요소에 대해 재귀적으로
map()을 호출합니다 - 각 필드에 대해 해당 TypeIs의
fixValue()를 적용하여 타입을 변환합니다TypeIs.INT→parseInt(String(o), 10)TypeIs.STRING→String(o)TypeIs.BOOLEAN→'true'/'false'문자열도 올바른 boolean으로 변환
TypeIs.DTO필드는 중첩 DTO의map()을 재귀 호출합니다
middleware() — Sequelize 쿼리 설정
middleware() 메서드는 DTO의 필드 정의를 Sequelize의 findAll/findOne 옵션으로 변환합니다. Application 레이어에서 직접 attributes와 include를 나열하지 않아도 됩니다.
// 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'>;PaginationQueryType은 PaginationQueryDto에서 page와 limit 필드만 추출한 타입 별칭입니다. 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 };ExecuteArgs의 paging 필드 타입이 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()시 Sequelizeinclude에 중첩 쿼리 추가- 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에서 데이터 흐름의 중심에 있습니다:
- 형태 정의 —
TypeIs.*데코레이터로 필드를 선언하면, 해당 필드의 Swagger 타입, Sequelize 컬럼, 런타임 변환이 모두 결정됩니다 - 선택적 노출 — Entity의 전체 필드 중 API에 필요한 것만 DTO에 선언하여, 민감한 정보(비밀번호 등)가 응답에 포함되지 않도록 합니다
- 자동 쿼리 생성 —
middleware()메서드가 DTO 필드 정의로부터 Sequelizeattributes와include를 자동 생성합니다 - 타입 안전한 변환 —
map()메서드가 각 필드의fixValue()를 적용하여 런타임 타입 변환을 수행합니다