사용자 CRUD
이 가이드에서는 ASAPJS의 레이어드 아키텍처(Controller → Application → Entity)를 따라 사용자 도메인의 회원가입, 로그인, 내 정보 조회 기능을 처음부터 구현합니다.
디렉토리 구조
ASAPJS에서 각 도메인은 다음 구조를 따릅니다.
src/user/
├── controller/
│ └── UserController.ts # HTTP 경계 — 라우트 데코레이터
├── application/
│ └── UserApplication.ts # 비즈니스 로직 — 검증, 해싱, 토큰 발급
├── domain/
│ └── entity/
│ └── UsersTable.ts # 데이터 경계 — @Table 모델
└── dto/
├── CreateUserDto.ts # 회원가입 요청 형태
├── LoginRequestDto.ts # 로그인 요청 형태
├── LoginResponseDto.ts # 로그인 응답 형태
└── UserInfoDto.ts # 사용자 정보 응답 형태구현은 Entity → DTO → Application → Controller 순서로 진행합니다. 하위 레이어부터 만들어야 상위 레이어에서 import할 수 있습니다.
Step 1: Entity 정의
@Table과 TypeIs.* 데코레이터로 users 테이블의 스키마를 정의합니다.
// example/src/user/domain/entity/UsersTable.ts
import { Model } from 'sequelize-typescript';
import { Table, TypeIs } from '@asapjs/sequelize';
@Table({
tableName: 'users',
timestamps: true,
})
export default class UsersTable extends Model {
@TypeIs.INT({ primaryKey: true, autoIncrement: true, comment: '사용자 ID' })
id: number;
@TypeIs.STRING({ unique: true, comment: '이메일 (고유)' })
email: string;
@TypeIs.PASSWORD({ comment: '비밀번호 (bcrypt)' })
password: string;
@TypeIs.STRING({ comment: '사용자 이름' })
name: string;
@TypeIs.DATETIME({ comment: '생성 일시' })
created_at: Date;
@TypeIs.DATETIME({ comment: '수정 일시' })
updated_at: Date;
}핵심 포인트
@Table({ timestamps: true }): Sequelize가created_at/updated_at을 자동 관리합니다.TypeIs.INT({ primaryKey: true, autoIncrement: true }): 자동 증가 기본키를 선언합니다.TypeIs.STRING({ unique: true }): 유니크 제약 조건을 추가합니다.TypeIs.PASSWORD: Swagger에서format: password로 표시되며, Sequelize 컬럼 타입은STRING과 동일합니다.TypeIs.DATETIME: 날짜/시간 컬럼과 Swaggerformat: date-time을 동시에 정의합니다.
각 TypeIs.* 데코레이터는 Sequelize 컬럼 정의와 Swagger 스키마 속성을 하나의 선언으로 처리합니다.
Step 2: DTO 정의
DTO는 ExtendableDto를 확장하여 요청/응답의 형태를 정의합니다. Entity와 동일한 TypeIs.* 데코레이터를 사용하지만, 필요한 필드만 선택적으로 포함합니다.
회원가입 요청 DTO
// example/src/user/dto/CreateUserDto.ts
import { ExtendableDto, Dto, TypeIs } from '@asapjs/sequelize';
import UsersTable from '../domain/entity/UsersTable';
@Dto({ name: 'create_user_dto', defineTable: UsersTable, timestamps: false })
export default class CreateUserDto extends ExtendableDto {
@TypeIs.STRING({ comment: '이메일' })
email: string;
@TypeIs.PASSWORD({ comment: '비밀번호' })
password: string;
@TypeIs.STRING({ comment: '사용자 이름' })
name: string;
}사용자 정보 응답 DTO
// example/src/user/dto/UserInfoDto.ts
import { ExtendableDto, Dto, TypeIs } from '@asapjs/sequelize';
import UsersTable from '../domain/entity/UsersTable';
@Dto({ name: 'user_info_dto', defineTable: UsersTable, timestamps: false })
export default class UserInfoDto extends ExtendableDto {
@TypeIs.INT({ comment: '사용자 ID' })
id: number;
@TypeIs.STRING({ comment: '이메일' })
email: string;
@TypeIs.STRING({ comment: '사용자 이름' })
name: string;
}DTO 작성 패턴
모든 DTO는 동일한 구조를 따릅니다:
ExtendableDto를 확장합니다.@Dto데코레이터로 연관 테이블과 이름을 지정합니다.@Dto가Reflect.defineMetadata와init()호출을 자동으로 처리합니다.TypeIs.*데코레이터로 각 필드를 선언합니다.timestamps: false로 설정하면created_at/updated_at이 DTO에 포함되지 않습니다.
요청 DTO에는 클라이언트가 보내는 필드만 포함합니다 (email, password, name).
응답 DTO에는 클라이언트에게 반환할 필드만 포함합니다 (id, email, name — password 제외).
Step 3: Application 레이어
비즈니스 로직을 담당합니다. Express에 대한 의존성이 없으며, 순수 TypeScript 클래스입니다.
// example/src/user/application/UserApplication.ts
import bcrypt from 'bcrypt';
import UsersTable from '../domain/entity/UsersTable';
import CreateUserDto from '../dto/CreateUserDto';
import LoginRequestDto from '../dto/LoginRequestDto';
import { jwtSign } from '../../utils/jwt';
export default class UserApplication {
private users: typeof UsersTable;
constructor() {
this.users = UsersTable;
}
async register(dto: CreateUserDto) {
// 중복 체크
const existingUser = await this.users.findOne({ where: { email: dto.email } });
if (existingUser) {
throw new Error('Email already exists');
}
// 비밀번호 해싱
const hashedPassword = await bcrypt.hash(dto.password, 10);
// 사용자 생성
const user = await this.users.create({
email: dto.email,
password: hashedPassword,
name: dto.name,
} as any);
return {
id: user.id,
email: user.email,
name: user.name,
};
}
async login(dto: LoginRequestDto) {
// 사용자 조회
const user = await this.users.findOne({ where: { email: dto.email } });
if (!user) {
throw new Error('Invalid email or password');
}
// 비밀번호 검증
const isPasswordValid = await bcrypt.compare(dto.password, user.password);
if (!isPasswordValid) {
throw new Error('Invalid email or password');
}
// JWT 토큰 생성
const payload = { userId: user.id, email: user.email };
const accessToken = jwtSign(payload, '1d');
const refreshToken = jwtSign(payload, '7d');
return {
accessToken,
refreshToken,
};
}
async getUserInfo(user: any) {
if (!user || !user.userId) {
throw new Error('Unauthorized');
}
const userRecord = await this.users.findByPk(user.userId);
if (!userRecord) {
throw new Error('User not found');
}
return {
id: userRecord.id,
email: userRecord.email,
name: userRecord.name,
};
}
}설계 원칙
- Express 무의존:
Request,Response,next를 import하지 않습니다. HTTP 없이 단위 테스트가 가능합니다. - 에러 위임:
throw new Error()로 던진 에러는Wrapper가 잡아서 HTTP 에러 응답으로 자동 변환합니다. - Sequelize 정적 API:
findOne,findByPk,create등 Entity의 정적 메서드를 직접 호출합니다. - 보안:
register에서 비밀번호를 bcrypt로 해싱하고,login에서bcrypt.compare로 비교합니다.
Step 4: Controller 레이어
HTTP 경계를 담당합니다. 라우트 데코레이터로 경로와 Swagger 문서를 동시에 정의하고, 모든 로직을 Application에 위임합니다.
// example/src/user/controller/UserController.ts
import { RouterController, Get, Post, ExecuteArgs } from '@asapjs/router';
import UserApplication from '../application/UserApplication';
import CreateUserDto from '../dto/CreateUserDto';
import LoginRequestDto from '../dto/LoginRequestDto';
import LoginResponseDto from '../dto/LoginResponseDto';
import UserInfoDto from '../dto/UserInfoDto';
export default class UserController extends RouterController {
public tag = 'User';
public basePath = '/users';
private userService: UserApplication;
constructor() {
super();
this.registerRoutes();
this.userService = new UserApplication();
}
@Post('/register', {
title: '회원 가입',
description: '새로운 사용자를 등록합니다',
body: CreateUserDto,
response: UserInfoDto,
})
async register({ body }: ExecuteArgs) {
const dto = body as CreateUserDto;
return await this.userService.register(dto);
}
@Post('/login', {
title: '로그인',
description: '이메일과 비밀번호로 로그인합니다',
body: LoginRequestDto,
response: LoginResponseDto,
})
async login({ body }: ExecuteArgs) {
const dto = body as LoginRequestDto;
return await this.userService.login(dto);
}
@Get('/me', {
title: '내 정보 조회',
description: '현재 로그인한 사용자의 정보를 조회합니다',
auth: true,
response: UserInfoDto,
})
async getMe({ user }: ExecuteArgs) {
return await this.userService.getUserInfo(user);
}
}핵심 포인트
registerRoutes(): 반드시constructor에서super()다음에 호출해야 합니다. 데코레이터가 수집한 라우트 메타데이터를 Express Router에 바인딩합니다.tag: Swagger UI에서 이 컨트롤러의 모든 라우트를 그룹화하는 라벨입니다.basePath: 모든 라우트의 URL 접두어입니다./register는 실제로/users/register가 됩니다.- 핸들러 본문: 1~2줄이 이상적입니다.
body를 DTO로 캐스팅하고 Application에 위임합니다. auth: true:@Get('/me', ...)에만 설정하여 이 라우트만 JWT 토큰을 요구합니다.
Step 5: 라우트 등록
컨트롤러를 route.ts에 등록하면 RouterModule이 자동으로 Express에 마운트합니다.
// example/src/route.ts
import UserController from './user/controller/UserController';
export default [new UserController()];요청 흐름 추적
POST /api/users/register 요청이 처리되는 과정을 단계별로 살펴봅니다.
POST /api/users/register
Content-Type: application/json
{ "email": "alice@example.com", "password": "secret123", "name": "Alice" }1. Express 라우트 매칭
RouterModule이 시작 시 등록한 UserController.expressRouter에서 /users/register POST 경로를 찾습니다.
2. jwtVerification 실행
auth 옵션이 설정되지 않았으므로(기본값 false) JWT 검증을 건너뜁니다.
3. Wrapper 실행
req.body, req.query, req.params를 ExecuteArgs 객체로 조합하고, PaginationQueryDto를 생성합니다.
4. Controller 핸들러
async register({ body }: ExecuteArgs) {
const dto = body as CreateUserDto;
return await this.userService.register(dto);
}5. Application 로직
UserApplication.register가 이메일 중복 체크 → 비밀번호 해싱 → UsersTable.create → 결과 반환을 수행합니다.
6. 응답 반환
Wrapper가 반환값을 res.status(200).json(output)으로 전송합니다.
{
"id": 1,
"email": "alice@example.com",
"name": "Alice"
}에러 경로: Application에서 throw new Error('Email already exists')가 발생하면 Wrapper가 잡아서 { status: 500, message: '알 수 없는 오류가 발생했습니다.' }로 응답합니다.
API 엔드포인트 요약
| 메서드 | 경로 | 인증 | 설명 |
|---|---|---|---|
POST | /api/users/register | 불필요 | 새 사용자 생성 |
POST | /api/users/login | 불필요 | JWT 토큰 발급 |
GET | /api/users/me | 필수 | 로그인한 사용자 정보 조회 |
관련 문서
- 인증 — JWT 설정과
auth옵션 상세 - 게시글과 관계 — FOREIGNKEY/BELONGSTO를 활용한 관계 모델링
- Layered Architecture — Controller → Application → Entity 패턴