레이어드 아키텍처
ASAPJS 애플리케이션의 모든 도메인은 동일한 계층 패턴을 따릅니다. 이것은 따르도록 권장되는 관례가 아니라, 프레임워크의 추상화가 전제하는 구조입니다. 이 구조에서 벗어나면 프레임워크와 싸우게 됩니다.
HTTP Request
|
v
[ Controller ] — HTTP 경계: 데코레이터, ExecuteArgs, 반환값
|
v
[ Application ] — 비즈니스 로직: 유효성 검사, 오케스트레이션, 규칙
|
v
[ Repository ] — 데이터 접근: 쿼리 추상화, 페이지네이션, DTO 매핑
|
v
[ Entity ] — 데이터 경계: @Table 모델, TypeIs 컬럼, Sequelize레이어 1 — Controller
패키지: @asapjs/router
역할: HTTP 경계만 처리합니다.
Controller 클래스는 RouterController를 확장하며 라우트 핸들러를 데코레이터가 붙은 메서드로 선언합니다. 각 핸들러는 Wrapper 유틸리티가 미리 채워준 단일 ExecuteArgs 인자를 받으며, 반환값은 자동으로 JSON 응답으로 직렬화됩니다.
Controller의 역할은 하나입니다: HTTP 요청을 Application 레이어 호출로 변환하고 결과를 반환하는 것입니다. 비즈니스 규칙을 검증하거나, 데이터베이스를 직접 조회하거나, 도메인 객체를 생성하지 않습니다.
// 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('/', {
title: '사용자 목록 조회',
description: '페이지네이션을 지원하는 사용자 목록을 조회합니다.',
query: GetUserListQueryDto,
response: UserDto,
})
public getUserList = async ({ paging, user }: ExecuteArgs<{}, GetUserListQueryDto, {}>) => {
const result = await this.userService.list(paging, user);
return { result };
};
@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 };
};
}주요 사항:
registerRoutes()는 서비스가 인스턴스화되기 전에 생성자에서 반드시 호출해야 합니다.- 핸들러 본문은 한두 줄입니다. Application을 호출하고 결과를 반환합니다.
- 각 데코레이터의
IOptions객체(title,body,response,auth,errors)는 추가 코드 없이 인증 미들웨어, Swagger 문서, 에러 응답 스키마에 활용됩니다. errors옵션에ErrorCreator를 전달하면 Swagger에 에러 응답 스키마가 자동 등록됩니다.ExecuteArgs의 제네릭 타입 파라미터(<P, Q, B>)로 path, query, body에 대한 타입 안전성을 확보합니다.
레이어 2 — Application
패키지: (없음 — 순수 TypeScript 클래스) 역할: 비즈니스 로직, 유효성 검사, 오케스트레이션.
Application 클래스는 Express를 알지 못합니다. 순수하게 타입이 지정된 인자를 받아 순수한 타입 객체를 반환합니다. 비즈니스 규칙이 여기에 위치합니다: 중복 검사, 비밀번호 해싱, 권한 결정, 그리고 하나 이상의 Repository 또는 Entity 호출이 이에 해당합니다.
// packages/example/src/user/application/UserApplication.ts
import type { PaginationQueryType } from '@asapjs/sequelize';
import UsersTable from '../domain/entity/UsersTable';
import UserDto from '../dto/UserDto';
import UserTableRepository from '../infra/UserTableRepository';
import { UserErrors } from '../errors/UserErrors';
import CreateUserDto from '../dto/CreateUserDto';
export class UserApplication {
private users: typeof UsersTable;
private usersRepository: UserTableRepository;
constructor() {
this.users = UsersTable;
this.usersRepository = new UserTableRepository();
}
public list = async (paging: PaginationQueryType, user: UserDto) => {
const raws = await this.usersRepository.list(paging, user);
return raws;
};
public info = async (userId: number) => {
const raw = await this.usersRepository.info(userId);
if (!raw) throw UserErrors.NOT_FOUND({ userId: userId });
return raw;
};
public create = async (body: CreateUserDto, user: UserDto) => {
const data = new CreateUserDto().map(body);
const raw = await this.users.create(data);
return new UserDto().map(raw);
};
}주요 사항:
Request,Response,next임포트가 없습니다. 이 클래스는 HTTP 서버 없이도 테스트할 수 있습니다.- 에러는
@asapjs/error의error()팩토리로 정의된 타입 안전한 에러를 사용합니다.UserErrors.NOT_FOUND({ userId })는 상태 코드, 에러 코드, 메시지 템플릿, 데이터를 포함하는HttpError를 생성합니다. - Application은 Repository 계층을 통해 데이터에 접근합니다. 단순한 경우 Entity의 Sequelize 정적 API(
create등)를 직접 호출할 수도 있습니다. - DTO의
map()메서드로 데이터를 변환합니다.new UserDto().map(raw)는 Entity 데이터를 DTO 형식으로 매핑합니다.
레이어 3 — Repository (Infrastructure)
패키지: @asapjs/sequelize (Repository 클래스)
역할: 데이터 접근 쿼리를 추상화하고, 페이지네이션과 DTO 매핑을 처리합니다.
Repository 클래스는 @asapjs/sequelize의 Repository를 확장합니다. this.repository가 제공하는 findAll, findOne 등의 메서드로 Sequelize 쿼리를 수행하며, 페이지네이션(PaginationQueryType)과 DTO 변환(exportTo)을 내장 지원합니다.
// packages/example/src/user/infra/UserTableRepository.ts
import { Repository } from '@asapjs/sequelize';
import type { PaginationQueryType } from '@asapjs/sequelize';
import UsersTable from '../domain/entity/UsersTable';
import UserDto from '../dto/UserDto';
export default class UserTableRepository extends Repository {
private users: typeof UsersTable;
constructor() {
super();
this.users = UsersTable;
}
public list = async (paging: PaginationQueryType, user: UserDto) => {
const users = await this.repository.findAll(this.users, {
exportTo: UserDto,
user,
paging,
});
return new UserDto().pagingMap(users);
};
public info = async (userId: number) => {
const user = await this.repository.findOne(this.users, {
exportTo: UserDto,
where: { id: userId },
});
if (!user) return null;
return new UserDto().map(user);
};
}주요 사항:
Repository클래스를 확장하면this.repository를 통해 페이지네이션, DTO 변환이 내장된 쿼리 메서드를 사용할 수 있습니다.exportTo: UserDto로 조회 결과가 자동으로 DTO 스키마에 맞게 변환됩니다.pagingMap()은 페이지네이션 메타데이터(total,page,limit등)를 포함하는 응답 객체를 생성합니다.- Repository는 선택적 계층입니다. 단순한 도메인에서는 Application이 Entity를 직접 호출할 수 있지만, 복잡한 쿼리 로직이 있는 경우 Repository로 분리하는 것을 권장합니다.
레이어 4 — Entity
패키지: @asapjs/sequelize
역할: 데이터베이스 스키마를 정의하고 데이터 접근 계약으로 기능합니다.
Entity는 @Table 데코레이터와 TypeIs.* 컬럼 데코레이터로 주석이 달린 Sequelize 모델 클래스입니다. 데이터베이스에 존재하는 것에 대한 단일 진실 공급원(single source of truth)입니다. SQL도, 날것의 쿼리 문자열도, 스키마 마이그레이션 파일도 없습니다 — TypeIs.* 데코레이터에 Sequelize가 컬럼을 이해하는 데 필요한 모든 정보가 담겨 있습니다.
// packages/example/src/user/domain/entity/UsersTable.ts
import { Model } from 'sequelize-typescript';
import { Table, TypeIs } from '@asapjs/sequelize';
export enum UserTypeEnum {
ADMIN = 'ADMIN',
USER = 'USER',
}
@Table({ tableName: 'users' })
export default class UsersTable extends Model {
@TypeIs.ENUM({ values: Object.keys(UserTypeEnum), comment: '유저 유형', defaultValue: UserTypeEnum.USER })
type!: UserTypeEnum;
@TypeIs.STRING({ comment: '이메일', unique: true })
email!: string;
@TypeIs.PASSWORD({ comment: '비밀번호' })
password!: string;
@TypeIs.STRING({ comment: '이름' })
name!: string;
@TypeIs.STRING({ comment: '전화번호', allowNull: true })
phone!: string;
@TypeIs.BOOLEAN({ comment: '활성 상태', defaultValue: true })
is_active!: boolean;
@TypeIs.DATETIME({ comment: '생성일' })
created_at!: Date;
@TypeIs.DATETIME({ comment: '수정일' })
updated_at!: Date;
}주요 사항:
- 각
TypeIs.*데코레이터는 단일 선언으로 SequelizeDataTypes호출, Swagger 스키마 프로퍼티,fixValue강제 변환기를 생성합니다. - Entity는 Controller에서 임포트되지 않습니다. Application 또는 Repository만 Entity에 접근합니다.
- 타임스탬프(
created_at,updated_at)는 Swagger 문서에 포함될 수 있도록TypeIs.DATETIME으로 명시적으로 선언됩니다.
요청 처음부터 끝까지 추적하기
클라이언트가 POST /users를 전송할 때 어떤 일이 일어나는지 살펴봅니다:
POST /users
Content-Type: application/json
{ "type": "USER", "email": "alice@example.com", "password": "secret", "name": "Alice", "phone": "010-1234-5678" }단계 1 — Express가 요청을 수신합니다.
라우터는 시작 시 등록되었습니다: app.use(UserController.expressRouter). Express가 경로를 매칭하고 @Post('/', ...)가 설치한 미들웨어 체인을 호출합니다.
단계 2 — jwtVerification이 실행됩니다.
데코레이터 옵션 auth가 설정되지 않았으므로(기본값 false) JWT 검증이 건너뜁니다.
단계 3 — Wrapper가 실행됩니다.
Wrapper(createUser)는 req.body를 args.body로, req.query를 args.query로 추출하고, ?page / ?limit로부터 PaginationQueryType을 구성합니다. 그런 다음 핸들러를 호출합니다.
단계 4 — Controller 핸들러가 실행됩니다.
public createUser = async ({ body, user }: ExecuteArgs<{}, {}, CreateUserDto>) => {
const result = await this.userService.create(body, user);
return { result };
};핸들러는 바디와 사용자 정보를 Application에 위임합니다.
단계 5 — Application 레이어가 실행됩니다.
UserApplication.create는 new CreateUserDto().map(body)로 바디 데이터를 매핑하고, UsersTable.create(data)로 레코드를 생성한 뒤, new UserDto().map(raw)로 응답 DTO를 반환합니다.
단계 6 — Wrapper가 응답을 전송합니다.
Wrapper는 반환된 객체를 받아 res.status(200).json(output)을 호출합니다.
에러 경로: 이메일이 중복되면 Application에서 UserErrors.EMAIL_DUPLICATE({ email, existingUserId })를 throw합니다. 이 에러는 @asapjs/error의 HttpError 인스턴스로, Wrapper가 캐치하여 errorToResponse(err, res)를 통해 상태 코드(409), 에러 코드(USER_EMAIL_DUPLICATE), 메시지, 데이터를 포함하는 구조화된 JSON 응답으로 변환합니다.
이 분리가 중요한 이유
테스트 가능성. Application 레이어에는 HTTP 의존성이 없습니다. 테스트에서 UserApplication을 직접 임포트하고, register(dto)를 호출하고, 서버를 실행하거나 Request/Response 객체를 모킹하지 않고도 반환값을 검증할 수 있습니다.
유지보수성. 비즈니스 규칙이 변경되면 Application을 수정합니다. HTTP 경로나 Swagger 설명이 변경되면 Controller를 수정합니다. 데이터베이스 컬럼이 추가되면 Entity를 수정합니다. 변경 사항이 각자의 영역 안에 머뭅니다.
예측 가능성. 모든 ASAPJS 프로젝트의 모든 도메인이 동일하게 보입니다. 하나의 도메인에 익숙한 개발자는 다른 어떤 도메인도 즉시 파악할 수 있습니다.
프레임워크 정합성. Wrapper, ExecuteArgs, TypeIs, Repository 추상화는 모두 이 계층 경계를 염두에 두고 설계되었습니다. 이 구조 안에서 작업하면 프레임워크가 자동으로 더 많은 것을 처리해줍니다.