Decorator-Driven Development
ASAPJS는 TypeScript 데코레이터를 프레임워크의 핵심 인터페이스로 사용합니다. 라우트 등록, Swagger 문서 생성, 인증 미들웨어 적용이 모두 데코레이터 한 줄로 이루어집니다. 별도의 라우팅 설정 파일이나 Swagger YAML을 관리할 필요가 없습니다.
라우트 데코레이터
@asapjs/router는 네 가지 HTTP 메서드 데코레이터를 제공합니다.
| 데코레이터 | HTTP 메서드 |
|---|---|
@Get | GET |
@Post | POST |
@Put | PUT |
@Delete | DELETE |
모든 데코레이터는 동일한 시그니처를 공유합니다: (path: string, options: IOptions).
// packages/router/src/decorator/index.ts
export function Get(path: string, options: IOptions) {
return Route(path, 'get', options);
}
export function Post(path: string, options: IOptions) {
return Route(path, 'post', options);
}
export function Put(path: string, options: IOptions) {
return Route(path, 'put', options);
}
export function Delete(path: string, options: IOptions) {
return Route(path, 'delete', options);
}네 데코레이터 모두 내부적으로 Route() 함수를 호출합니다. Route()는 메서드, 경로, 옵션을 메타데이터 배열에 저장하는 역할만 합니다.
IOptions — 데코레이터 옵션
각 데코레이터의 두 번째 인자인 IOptions는 라우트의 동작과 문서를 동시에 정의합니다.
| 옵션 | 타입 | 설명 |
|---|---|---|
title | string | Swagger 문서의 operation 제목 |
description | string | Swagger 문서의 상세 설명 |
summary | string | Swagger 문서의 operation 요약 |
auth | boolean | true이면 런타임 JWT 인증 미들웨어 적용. 기본값 false |
body | DtoOrTypeIs | 요청 본문 스키마 (DTO 클래스 또는 TypeIs 표현식) |
query | DtoOrTypeIs | 쿼리 파라미터 스키마 |
response | DtoOrTypeIs | 응답 스키마 |
errors | ErrorCreator[] | @asapjs/error의 error()로 정의한 에러 목록. Swagger에 에러 응답 스키마가 자동 등록됩니다 |
middleware | any[] | 커스텀 Express 미들웨어 배열 |
bodyContentType | string | 'application/json' 또는 'multipart/form-data' |
deprecated | boolean | Swagger에서 deprecated 표시 |
body, query, response에 전달하는 DTO 클래스나 TypeIs 표현식은 Swagger 스키마와 런타임 타입 변환 모두에 사용됩니다. 하나를 지정하면 두 가지가 동시에 동작합니다.
errors에 ErrorCreator를 전달하면, 각 에러의 상태 코드별로 Swagger 에러 응답 스키마가 자동 생성됩니다. 에러 코드, 메시지, 데이터 구조가 모두 OpenAPI 스펙에 반영됩니다.
데코레이터의 내부 동작
데코레이터가 적용되면 즉시 라우트가 등록되는 것이 아닙니다. 메타데이터만 수집됩니다.
// packages/router/src/decorator/index.ts
function Route(path: string, method: MethodType, options: IOptions) {
return function (target: InstanceType<typeof RouterController>, propertyKey: string) {
if (!(target as any).routes) {
(target as any).routes = [];
}
(target as any).routes.push({
method,
path,
...options,
methodName: propertyKey,
});
};
}데코레이터는 routes 배열에 { method, path, options, methodName } 객체를 추가합니다. 실제 Express 라우트 등록은 컨트롤러 생성자에서 registerRoutes()를 호출할 때 일어납니다.
RouterController — 베이스 클래스
모든 컨트롤러는 RouterController를 상속합니다. 이 클래스가 데코레이터 메타데이터를 실제 Express 라우트로 변환하는 역할을 합니다.
// packages/router/src/express/router.ts
import { wrapWithEffect } from '@asapjs/error';
export default class RouterController {
public basePath = '/';
public expressRouter: ExpressRouter;
constructor() {
this.expressRouter = ExpressRouter();
}
protected registerRoutes() {
const routes: IDecoratorPreference[] = (this as any).routes;
if (routes) {
for (const route of routes) {
this.excute(route.method)({
...route,
// wrapWithEffect: Effect 기반 실행/트레이싱 및 next(error) 전달
excute: wrapWithEffect((this as any)[route.methodName].bind(this)),
});
}
}
}
}registerRoutes()는 수집된 메타데이터를 순회하며 각 라우트에 대해 다음을 수행합니다:
- 데코레이터 옵션에서 Swagger 문서를 생성하고 전역 Swagger 스펙에 등록
jwtVerification(auth)미들웨어를 라우트 체인에 추가- 커스텀
middleware배열을 체인에 추가 - 핸들러 메서드를
Wrapper()로 감싸서 Express 라우터에 등록
최종 미들웨어 체인은 다음과 같습니다:
jwtVerification(auth) → middleware[0] → middleware[1] → ... → Wrapper(handler)Wrapper와 ExecuteArgs
Wrapper는 Express의 (req, res, next) 인터페이스를 ASAPJS의 ExecuteArgs 인터페이스로 변환하는 어댑터입니다. 모든 라우트 핸들러는 ExecuteArgs 하나만 받습니다.
// packages/router/src/utils/wrapper.ts
export interface ExecuteArgs<P = {}, Q = {}, B = {}> {
req: Request;
res: Response;
path?: P | { [key: string]: any };
query: Q & { [key: string]: any };
body: B & { [key: string]: any };
files?: { [key: string]: any };
user?: any;
paging: PaginationQueryType;
}Wrapper가 추출하는 값:
| 필드 | 소스 | 설명 |
|---|---|---|
body | req.body | 파싱된 요청 본문 |
query | req.query | URL 쿼리 파라미터 |
path | req.params | URL 경로 파라미터 (:id 등) |
user | req.user | JWT 디코딩된 사용자 정보. auth: false이면 undefined |
paging | req.query.page, req.query.limit | 자동 파싱된 페이지네이션. 기본값 { page: 0, limit: 20 } |
files | req.files | Wrapper가 자동 추출하지 않음. args.req.files로 직접 접근 필요 |
핸들러가 값을 반환하면 Wrapper가 res.status(200).json(output)으로 자동 응답합니다. 에러를 throw하면 Wrapper가 err.status에 따라 적절한 HTTP 에러 응답을 생성합니다.
실제 사용 예시
CRUD 컨트롤러
PostController는 네 가지 데코레이터를 모두 사용하는 전형적인 CRUD 컨트롤러입니다:
// example/src/post/controller/PostController.ts
import { RouterController, Get, Post, Put, Delete, ExecuteArgs } from '@asapjs/router';
import { PaginationQueryDto, TypeIs } from '@asapjs/sequelize';
import PostApplication from '../application/PostApplication';
import CreatePostDto from '../dto/CreatePostDto';
import UpdatePostDto from '../dto/UpdatePostDto';
import PostInfoDto from '../dto/PostInfoDto';
export default class PostController extends RouterController {
public tag = 'Post';
public basePath = '/posts';
private postService: PostApplication;
constructor() {
super();
this.registerRoutes();
this.postService = new PostApplication();
}
@Get('/', {
title: '게시글 목록 조회',
description: '게시글 목록을 조회합니다',
query: PaginationQueryDto,
response: TypeIs.PAGING(PostInfoDto),
})
async getPosts({ paging }: ExecuteArgs) {
return await this.postService.getPosts(paging);
}
@Post('/', {
title: '게시글 작성',
description: '새로운 게시글을 작성합니다',
auth: true,
body: CreatePostDto,
response: PostInfoDto,
})
async createPost({ body, user }: ExecuteArgs) {
const dto = body as CreatePostDto;
return await this.postService.createPost(user, dto);
}
@Get('/:postId', {
title: '게시글 상세 조회',
description: '게시글의 상세 정보를 조회합니다',
response: PostInfoDto,
})
async getPost({ path }: ExecuteArgs) {
const postId = parseInt((path as any)?.postId as string, 10);
return await this.postService.getPost(postId);
}
@Put('/:postId', {
title: '게시글 수정',
description: '게시글을 수정합니다',
auth: true,
body: UpdatePostDto,
response: PostInfoDto,
})
async updatePost({ path, body, user }: ExecuteArgs) {
const postId = parseInt((path as any)?.postId as string, 10);
const dto = body as UpdatePostDto;
return await this.postService.updatePost(postId, user, dto);
}
@Delete('/:postId', {
title: '게시글 삭제',
description: '게시글을 삭제합니다',
auth: true,
})
async deletePost({ path, user }: ExecuteArgs) {
const postId = parseInt((path as any)?.postId as string, 10);
await this.postService.deletePost(postId, user);
return { success: true };
}
}주요 관찰 포인트:
basePath = '/posts'— 이 컨트롤러의 모든 라우트는/posts아래에 등록됩니다.registerRoutes()— 생성자에서 반드시 호출해야 합니다. 이 시점에 데코레이터 메타데이터가 Express 라우트로 변환됩니다.auth: true— 쓰기 작업(POST,PUT,DELETE)에만 적용되고, 읽기 작업(GET)은 인증 없이 접근 가능합니다.response: TypeIs.PAGING(PostInfoDto)— 페이지네이션 응답 스키마를TypeIs.PAGING으로 감싸서 선언합니다.- 핸들러 본문은 1~2줄 — 데이터 추출과 Application 호출만 합니다. HTTP 관련 로직은 없습니다.
에러 선언이 포함된 컨트롤러
실제 프로젝트의 UserController는 errors 옵션으로 발생 가능한 에러를 선언합니다:
// packages/example/src/user/controller/UserController.ts
import { ExecuteArgs, Get, Post, RouterController } from '@asapjs/router';
import { UserApplication } from '../application/UserApplication';
import CreateUserDto from '../dto/CreateUserDto';
import GetUserListQueryDto from '../dto/GetUserListQueryDto';
import UserDto from '../dto/UserDto';
import { UserErrors } from '../errors/UserErrors';
export default class UserController extends RouterController {
public basePath = '/users';
public tag = 'users';
private userService: UserApplication;
constructor() {
super();
this.registerRoutes();
this.userService = new UserApplication();
}
@Get('/:userId', {
title: '사용자 상세 조회',
description: '특정 사용자의 상세 정보를 조회합니다.',
response: UserDto,
errors: [UserErrors.NOT_FOUND],
})
public getUserById = async ({ path }: ExecuteArgs<{ userId: string }, {}, {}>) => {
const result = await this.userService.info(path?.userId);
return { result };
};
@Post('/', {
title: '사용자 생성',
description: '새로운 사용자를 생성합니다.',
body: CreateUserDto,
response: UserDto,
errors: [UserErrors.EMAIL_DUPLICATE, UserErrors.INVALID_DATA],
})
public createUser = async ({ body, user }: ExecuteArgs<{}, {}, CreateUserDto>) => {
const result = await this.userService.create(body, user);
return { result };
};
}errors: [UserErrors.NOT_FOUND]는 Swagger에 404 에러 응답 스키마를 자동으로 등록합니다. errors: [UserErrors.EMAIL_DUPLICATE, UserErrors.INVALID_DATA]는 409와 400 에러 응답을 각각 등록합니다. 별도의 Swagger 설정 없이 코드에서 선언하는 것만으로 에러 문서가 완성됩니다.
데코레이터에서 Swagger까지
데코레이터의 IOptions에 전달한 정보는 자동으로 Swagger 문서가 됩니다. 별도의 Swagger 설정이 필요하지 않습니다.
@Post('/', {
title: '사용자 생성', → Swagger operation summary
description: '새로운 사용자를 생성합니다.', → Swagger operation description
body: CreateUserDto, → Swagger requestBody schema ($ref)
response: UserDto, → Swagger 200 response schema ($ref)
errors: [UserErrors.EMAIL_DUPLICATE], → Swagger 409 error response schema
})RouterController.excute() 메서드 내부에서 addPaths()를 호출하여 전역 Swagger 스펙에 라우트 정보를 추가합니다. DTO 클래스가 body나 response에 전달되면, DTO의 generateScheme()이 호출되어 각 TypeIs.* 필드에서 toSwagger()를 읽어 OpenAPI 스키마를 생성합니다. errors에 전달된 ErrorCreator는 generateErrorSchemes()를 통해 각 에러의 상태 코드, 에러 코드, 메시지, 데이터 스키마가 Swagger 에러 응답으로 자동 등록됩니다.
auth 옵션은 런타임 JWT 미들웨어만 제어하며, Swagger 문서에는 default-swagger.json에 정의된 전역 security가 모든 엔드포인트에 동일하게 적용됩니다. 즉 auth: true가 per-endpoint Swagger security requirement를 생성하지는 않습니다.
결과적으로 코드를 작성하면 API 문서가 자동으로 만들어지며, 코드와 문서가 항상 동기화된 상태를 유지합니다.
에러 처리
데코레이터 기반 핸들러에서 에러를 던지면 Wrapper가 자동으로 처리합니다. ASAPJS는 @asapjs/error 패키지의 error() 팩토리로 타입 안전한 에러를 정의하는 것을 권장합니다.
@asapjs/error 패턴 (권장)
error() 팩토리로 에러를 선언하면 상태 코드, 에러 코드, 메시지 템플릿, 데이터 스키마를 한 곳에서 정의할 수 있습니다:
// packages/example/src/user/errors/UserErrors.ts
import { error } from '@asapjs/error';
import { TypeIs } from '@asapjs/schema';
export class UserErrors {
static NOT_FOUND = error(
404,
'USER_NOT_FOUND',
'사용자를 찾을 수 없습니다. ID: {userId}',
{
userId: TypeIs.INT({ comment: '사용자 ID' }),
}
);
static EMAIL_DUPLICATE = error(
409,
'USER_EMAIL_DUPLICATE',
'이미 사용 중인 이메일입니다: {email}',
{
email: TypeIs.STRING({ comment: '중복된 이메일' }),
existingUserId: TypeIs.INT({ comment: '기존 사용자 ID' }),
}
);
}Application 레이어에서 에러를 throw할 때 데이터를 전달합니다:
// Application 레이어에서:
throw UserErrors.NOT_FOUND({ userId: 42 });
// → HTTP 404 { status: 404, errorCode: 'USER_NOT_FOUND', message: '사용자를 찾을 수 없습니다. ID: 42', data: { userId: 42 } }이 에러를 데코레이터의 errors 옵션에 전달하면 Swagger에 에러 응답 스키마가 자동 등록됩니다.
HttpException 패턴
간단한 에러의 경우 HttpException을 사용할 수도 있습니다:
import { HttpException } from '@asapjs/router';
throw new HttpException(404, 'User not found');
// → HTTP 404 { status: 404, message: 'User not found' }HttpException이 아닌 일반 Error를 throw하면 Wrapper가 HTTP 500으로 처리하며, Sentry가 설정되어 있으면 자동으로 에러를 보고합니다.
요약
ASAPJS의 데코레이터 기반 개발은 네 가지를 하나로 통합합니다:
- 라우트 등록 —
@Get,@Post,@Put,@Delete한 줄로 Express 라우트 등록 - API 문서 —
IOptions의title,body,response가 자동으로 Swagger 스펙 생성 - 에러 문서 —
errors옵션에ErrorCreator를 전달하면 Swagger 에러 응답 스키마 자동 등록 - 미들웨어 체인 —
auth,middleware옵션이 인증과 커스텀 미들웨어를 자동 적용
개발자는 비즈니스 로직에만 집중하고, 프레임워크가 HTTP 바운더리의 반복적인 작업을 처리합니다.