DTO (데이터 전송 객체)
ASAPJS의 DTO는 HTTP 레이어와 데이터베이스 레이어 사이를 흐르는 데이터의 형태를 정의합니다. DTO는 엔티티 위에 놓여 특정 작업에 필요한 필드만을 선택합니다. DTO는 동시에 세 가지를 담당합니다: 데이터베이스 쿼리를 위한 Sequelize attributes 및 include 옵션, 라우트의 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.defineMetadata로 sequelize::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(): voidthis.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[]
): DtoMiddlewareReturnDTO의 필드 선언에서 파생된 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에 추가 |
파라미터:
| 파라미터 | 타입 | 설명 |
|---|---|---|
as | string | 연관 별칭. 이 DTO가 TypeIs.DTO를 통해 다른 DTO 내부에 중첩될 때 사용됩니다. result.as를 설정하며 TypeIs.QUERY 함수에 전달되는 association 경로의 일부가 됩니다. |
user | any | 인증된 사용자 객체(JWT 페이로드에서). 사용자 스코프 계산 컬럼을 위해 TypeIs.QUERY의 query() 함수에 전달됩니다. |
extraAs | string[] | 깊이 중첩된 관계를 위한 추가 연관 경로 세그먼트. 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파라미터
| 파라미터 | 타입 | 필수 여부 | 설명 |
|---|---|---|---|
defineTable | typeof Model | 예 | 이 DTO가 매핑되는 Sequelize 모델 클래스. middleware()가 반환값의 model을 채우고 TypeIs.QUERY가 연관 경로를 해석하는 데 사용됩니다. |
timestamps | boolean | 아니오 | 호출에서 생략하면 undefined가 기본값이며 데코레이터는 내부적으로 timestamps: true로 설정합니다. 비활성화하려면 false를 전달하세요. 참고: 이는 메타데이터만 제어하며 컬럼을 추가하거나 제거하지 않습니다. |
name | string | 아니오 | 콘솔 레지스트리에 저장되는 선택적 레이블. 런타임 동작에 영향을 주지 않습니다. |
동작
@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.prototype | this (인스턴스) |
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'>;PaginationQueryType은 PaginationQueryDto에서 page와 limit 필드만 추출한 타입입니다. @asapjs/router의 Wrapper가 생성하는 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);DtoOrTypeIs는 IOptions(라우트 데코레이터 옵션)의 body, query, response 필드와 TypeIs.ARRAY, TypeIs.PAGING이 받는 유니온 타입입니다.
| 분기 | 사용 시점 | 예시 |
|---|---|---|
typeof ExtendableDto | DTO 클래스 — 이름이 있는 필드를 가진 구조화된 객체 | body: CreateUserDto |
() => TypeIsData | 순수 TypeIs.*() 호출 — 기본형 또는 복합 응답 | response: TypeIs.BOOLEAN() |
TypeIs.ARRAY와 TypeIs.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);
}