Skip to Content
문서가이드게시글과 관계 (Posts with Relations)

게시글과 관계 (Posts with Relations)

이 가이드에서는 사용자(User)와 게시글(Post) 간의 관계를 설정하고, 게시글 CRUD와 페이지네이션, 작성자 권한 검증까지 구현합니다. ASAPJS의 TypeIs.FOREIGNKEYTypeIs.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.FOREIGNKEYTypeIs.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-typescriptaddForeignKey를 호출하여 Sequelize 관계 메타데이터를 등록합니다.
  • 별도의 type 지정이 없으면 기본적으로 INT 타입으로 처리됩니다.

TypeIs.BELONGSTO — 관계 객체를 정의합니다.

@TypeIs.BELONGSTO(() => UsersTable, 'user_id') user: UsersTable;
  • 첫 번째 인자: 연관 테이블을 반환하는 함수
  • 두 번째 인자: 외래 키 컬럼명 (user_id)
  • 내부적으로 sequelize-typescriptBelongsToAssociation을 생성하고 등록합니다.
  • 이 필드를 통해 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에서 pagelimit을 받아 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.pagingPaginationQueryDto 객체로 전달합니다.

경로 파라미터: /: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 패턴
Last updated on