게시글과 관계 (Posts with Relations)
이 가이드에서는 사용자(User)와 게시글(Post) 간의 관계를 설정하고, 게시글 CRUD와 페이지네이션, 작성자 권한 검증까지 구현합니다. ASAPJS의 TypeIs.FOREIGNKEY와 TypeIs.BELONGSTO 데코레이터를 활용하여 Sequelize 관계를 선언적으로 정의합니다.
사전 준비
이 가이드는 사용자 CRUD 가이드에서 구현한 UsersTable Entity가 이미 있다고 가정합니다.
디렉토리 구조
src/post/
├── controller/
│ └── PostController.ts # HTTP 경계 — 5개 CRUD 엔드포인트
├── application/
│ └── PostApplication.ts # 비즈니스 로직 — 페이징, 작성자 검증
├── domain/
│ └── entity/
│ └── PostsTable.ts # 데이터 경계 — FOREIGNKEY/BELONGSTO 관계
└── dto/
├── CreatePostDto.ts # 게시글 생성 요청
├── UpdatePostDto.ts # 게시글 수정 요청
└── PostInfoDto.ts # 게시글 응답Step 1: Entity와 관계 정의
PostsTable에서 TypeIs.FOREIGNKEY와 TypeIs.BELONGSTO를 사용하여 UsersTable과의 관계를 설정합니다.
// example/src/post/domain/entity/PostsTable.ts
import { Model } from 'sequelize-typescript';
import { Table, TypeIs } from '@asapjs/sequelize';
import UsersTable from '../../../user/domain/entity/UsersTable';
@Table({
tableName: 'posts',
timestamps: true,
})
export default class PostsTable extends Model {
@TypeIs.INT({ primaryKey: true, autoIncrement: true, comment: '게시글 ID' })
id: number;
@TypeIs.STRING({ comment: '게시글 제목' })
title: string;
@TypeIs.TEXT({ comment: '게시글 내용' })
content: string;
@TypeIs.FOREIGNKEY({ table: () => UsersTable, comment: '작성자 ID' })
user_id: number;
@TypeIs.BELONGSTO(() => UsersTable, 'user_id')
user: UsersTable;
@TypeIs.DATETIME({ comment: '생성 일시' })
created_at: Date;
@TypeIs.DATETIME({ comment: '수정 일시' })
updated_at: Date;
}FOREIGNKEY와 BELONGSTO 상세
TypeIs.FOREIGNKEY — 외래 키 컬럼을 정의합니다.
@TypeIs.FOREIGNKEY({ table: () => UsersTable, comment: '작성자 ID' })
user_id: number;table: 참조할 테이블을 함수로 감싸서 전달합니다. 순환 참조를 방지하기 위한 lazy evaluation 패턴입니다.- 내부적으로
sequelize-typescript의addForeignKey를 호출하여 Sequelize 관계 메타데이터를 등록합니다. - 별도의
type지정이 없으면 기본적으로INT타입으로 처리됩니다.
TypeIs.BELONGSTO — 관계 객체를 정의합니다.
@TypeIs.BELONGSTO(() => UsersTable, 'user_id')
user: UsersTable;- 첫 번째 인자: 연관 테이블을 반환하는 함수
- 두 번째 인자: 외래 키 컬럼명 (
user_id) - 내부적으로
sequelize-typescript의BelongsToAssociation을 생성하고 등록합니다. - 이 필드를 통해
post.user로 연관된 사용자 객체에 접근할 수 있습니다.
관계 선언 패턴 요약
// 1:N 관계에서 N쪽 (자식 테이블)에 선언
@TypeIs.FOREIGNKEY({ table: () => ParentTable })
parent_id: number;
@TypeIs.BELONGSTO(() => ParentTable, 'parent_id')
parent: ParentTable;두 데코레이터는 항상 쌍으로 사용합니다. FOREIGNKEY는 실제 컬럼을, BELONGSTO는 Sequelize가 관리하는 관계 객체를 정의합니다.
Step 2: DTO 정의
게시글 생성 DTO
// example/src/post/dto/CreatePostDto.ts
import { ExtendableDto, TypeIs } from '@asapjs/sequelize';
import PostsTable from '../domain/entity/PostsTable';
export default class CreatePostDto extends ExtendableDto {
@TypeIs.STRING({ comment: '게시글 제목' })
title: string;
@TypeIs.TEXT({ comment: '게시글 내용' })
content: string;
}user_id는 DTO에 포함하지 않습니다. 작성자 ID는 JWT 토큰에서 추출하여 Application 레이어에서 설정합니다.
게시글 수정 DTO
// example/src/post/dto/UpdatePostDto.ts
import { ExtendableDto, TypeIs } from '@asapjs/sequelize';
import PostsTable from '../domain/entity/PostsTable';
export default class UpdatePostDto extends ExtendableDto {
@TypeIs.STRING({ comment: '게시글 제목 (선택사항)', allowNull: true })
title?: string;
@TypeIs.TEXT({ comment: '게시글 내용 (선택사항)', allowNull: true })
content?: string;
}allowNull: true와 TypeScript의 ?(optional) 표기를 함께 사용하여 부분 업데이트를 지원합니다. 클라이언트는 변경할 필드만 보내면 됩니다.
게시글 정보 DTO
// example/src/post/dto/PostInfoDto.ts
import { ExtendableDto, TypeIs } from '@asapjs/sequelize';
import PostsTable from '../domain/entity/PostsTable';
export default class PostInfoDto extends ExtendableDto {
@TypeIs.INT({ comment: '게시글 ID' })
id: number;
@TypeIs.STRING({ comment: '게시글 제목' })
title: string;
@TypeIs.TEXT({ comment: '게시글 내용' })
content: string;
@TypeIs.INT({ comment: '작성자 ID' })
user_id: number;
@TypeIs.DATETIME({ comment: '생성 일시' })
created_at: Date;
}Step 3: Application 레이어
게시글 생성, 수정, 삭제에서 작성자 권한 검증을 수행하고, 목록 조회에서 페이지네이션을 처리합니다.
// example/src/post/application/PostApplication.ts
import { PaginationQueryDto } from '@asapjs/sequelize';
import PostsTable from '../domain/entity/PostsTable';
import CreatePostDto from '../dto/CreatePostDto';
import UpdatePostDto from '../dto/UpdatePostDto';
export default class PostApplication {
private posts: typeof PostsTable;
constructor() {
this.posts = PostsTable;
}
async createPost(user: any, dto: CreatePostDto) {
if (!user || !user.userId) {
throw new Error('Unauthorized');
}
const post = await this.posts.create({
title: dto.title,
content: dto.content,
user_id: user.userId, // JWT 페이로드의 userId 필드
} as any);
return {
id: post.id,
title: post.title,
content: post.content,
user_id: post.user_id,
created_at: post.created_at,
};
}
async updatePost(postId: number, user: any, dto: UpdatePostDto) {
if (!user || !user.userId) {
throw new Error('Unauthorized');
}
const post = await this.posts.findByPk(postId);
if (!post) {
throw new Error('Post not found');
}
// 작성자 검증
if (post.user_id !== user.userId) {
throw new Error('Unauthorized: You are not the author of this post');
}
// 부분 업데이트
if (dto.title !== undefined) post.title = dto.title;
if (dto.content !== undefined) post.content = dto.content;
await post.save();
return {
id: post.id,
title: post.title,
content: post.content,
user_id: post.user_id,
created_at: post.created_at,
};
}
async deletePost(postId: number, user: any) {
if (!user || !user.userId) {
throw new Error('Unauthorized');
}
const post = await this.posts.findByPk(postId);
if (!post) {
throw new Error('Post not found');
}
// 작성자 검증
if (post.user_id !== user.userId) {
throw new Error('Unauthorized: You are not the author of this post');
}
await post.destroy();
}
async getPost(postId: number) {
const post = await this.posts.findByPk(postId);
if (!post) {
throw new Error('Post not found');
}
return {
id: post.id,
title: post.title,
content: post.content,
user_id: post.user_id,
created_at: post.created_at,
};
}
async getPosts(paging: PaginationQueryDto) {
const offset = (paging.page - 1) * paging.limit;
const { rows, count } = await this.posts.findAndCountAll({
limit: paging.limit,
offset,
order: [['created_at', 'DESC']],
});
return {
data: rows.map((post) => ({
id: post.id,
title: post.title,
content: post.content,
user_id: post.user_id,
created_at: post.created_at,
})),
paging: {
page: paging.page,
limit: paging.limit,
total: count,
totalPages: Math.ceil(count / paging.limit),
},
};
}
}핵심 패턴
작성자 검증: 수정과 삭제 시 post.user_id !== user.userId를 비교하여 작성자만 자신의 게시글을 변경할 수 있도록 합니다. user는 JWT 페이로드({ userId, email })에서 Wrapper가 자동으로 추출합니다.
부분 업데이트: UpdatePostDto의 각 필드가 undefined가 아닌 경우에만 값을 변경하고, post.save()로 저장합니다.
페이지네이션: PaginationQueryDto에서 page와 limit을 받아 offset을 계산합니다. Sequelize의 findAndCountAll로 데이터와 전체 개수를 한 번에 조회합니다.
Step 4: Controller 레이어
5개의 CRUD 엔드포인트를 데코레이터로 정의합니다.
// example/src/post/controller/PostController.ts
import { RouterController, Get, Post, Put, Delete } from '@asapjs/router';
import { 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 };
}
}데코레이터 옵션 분석
페이지네이션 응답: response: TypeIs.PAGING(PostInfoDto)를 사용하면 Swagger 문서에 페이지네이션 형태의 응답 스키마가 자동 생성됩니다.
쿼리 파라미터: query: PaginationQueryDto를 지정하면 ?page=1&limit=20 형태의 쿼리 파라미터가 Swagger에 등록됩니다. Wrapper가 이 값을 파싱하여 ExecuteArgs.paging에 PaginationQueryDto 객체로 전달합니다.
경로 파라미터: /:postId로 정의한 경로 파라미터는 path.postId로 접근합니다. Swagger에도 path parameter로 자동 등록됩니다.
인증 분류:
- 읽기 (
GET /,GET /:postId):auth생략 → 공개 - 쓰기 (
POST /,PUT /:postId,DELETE /:postId):auth: true→ 로그인 필수
Step 5: 라우트 등록
// example/src/route.ts
import UserController from './user/controller/UserController';
import PostController from './post/controller/PostController';
export default [new UserController(), new PostController()];API 엔드포인트 요약
| 메서드 | 경로 | 인증 | 설명 |
|---|---|---|---|
GET | /api/posts | 불필요 | 게시글 목록 (페이지네이션) |
POST | /api/posts | 필수 | 게시글 작성 |
GET | /api/posts/:postId | 불필요 | 게시글 상세 조회 |
PUT | /api/posts/:postId | 필수 | 게시글 수정 (작성자만) |
DELETE | /api/posts/:postId | 필수 | 게시글 삭제 (작성자만) |
사용 예시
게시글 작성
curl -X POST http://localhost:3000/api/posts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <accessToken>" \
-d '{"title": "첫 번째 게시글", "content": "안녕하세요!"}'{
"id": 1,
"title": "첫 번째 게시글",
"content": "안녕하세요!",
"user_id": 1,
"created_at": "2026-02-21T12:00:00.000Z"
}게시글 목록 (페이지네이션)
curl "http://localhost:3000/api/posts?page=1&limit=10"{
"data": [
{
"id": 2,
"title": "두 번째 게시글",
"content": "반갑습니다!",
"user_id": 1,
"created_at": "2026-02-21T13:00:00.000Z"
},
{
"id": 1,
"title": "첫 번째 게시글",
"content": "안녕하세요!",
"user_id": 1,
"created_at": "2026-02-21T12:00:00.000Z"
}
],
"paging": {
"page": 1,
"limit": 10,
"total": 2,
"totalPages": 1
}
}게시글 수정
curl -X PUT http://localhost:3000/api/posts/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <accessToken>" \
-d '{"title": "수정된 제목"}'게시글 삭제
curl -X DELETE http://localhost:3000/api/posts/1 \
-H "Authorization: Bearer <accessToken>"{
"success": true
}관련 문서
- 사용자 CRUD — User 도메인 구현 (이 가이드의 선행 단계)
- 인증 — JWT 설정과
auth옵션 상세 - Routing — HTTP 메서드 데코레이터와
IOptions - Layered Architecture — Controller → Application → Entity 패턴