이 프로젝트는 이것저것 적용해서 테스트하기 위한 프로젝트입니다. 이번에는 투표 기능 구현에 들어가기 전에 기본적인 게시물 CRUD를 구현했습니다.
식사 메뉴 투표 커뮤니티 프로젝트를 진행하면서, 투표 기능을 구현하기 전에 먼저 기본적인 게시물 관리 기능부터 차근차근 구현하기로 했습니다. 한 번에 다 구현하는 게 아니라 조금씩 살을 붙이면서 견고한 기반을 다지는 것이 빠르게 구현이 가능할 것 같다고 생각했습니다.
// backend/prisma/schema.prisma
model User {
id String @id @default(uuid())
email String @unique
password String
nickname String?
socialId String? @unique
refreshToken String? @unique
refreshTokenExpiresAt DateTime?
createdAt DateTime @default(now())
posts Post[] // 추가된 관계
@@map("users")
}
model Post {
id String @id @default(uuid())
title String
content String @db.Text
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@map("posts")
}
API 통신과 타입 안전성을 위해 필요한 인터페이스들을 정의했습니다.
사실 DTO 패턴은 Nest.js에서 사용했었는데, Express에서도 살짝 사용해볼까해서 구현했습니다.
// backend/src/types/post.types.ts
export interface CreatePostDto {
title: string;
content: string;
}
export interface UpdatePostDto {
title?: string;
content?: string;
}
export interface PostResponse {
id: string;
title: string;
content: string;
authorId: string;
author: {
id: string;
nickname: string | null;
email: string;
};
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
}
export interface PostListResponse {
posts: PostResponse[];
total: number;
page: number;
limit: number;
}
비즈니스 로직을 담당하는 PostService를 구현했습니다.
1. 권한 체크: 작성자만 자신의 게시물 수정/삭제 가능
2. 검색 기능: 제목과 내용에서 OR 조건으로 검색
3. 페이지네이션: skip/take를 활용한 효율적인 데이터 조회
4. 성능 최적화: Promise.all로 병렬 처리
5. 데이터 선택: 민감한 정보 제외하고 필요한 데이터만 조회
// backend/src/services/post.service.ts
export class PostService {
// 게시물 생성
async createPost(authorId: string, data: CreatePostDto): Promise<PostResponse> {
const { title, content } = data;
const post = await prisma.post.create({
data: {
title,
content,
authorId,
},
include: {
author: {
select: {
id: true,
nickname: true,
email: true,
},
},
},
});
return post;
}
// 게시물 목록 조회 (페이지네이션 포함)
async getPosts(query: GetPostsQuery): Promise<PostListResponse> {
const { page = 1, limit = 10, search } = query;
const skip = (page - 1) * limit;
// 삭제되지 않았고, search에 해당하는 게시물만 조회
const where = {
deletedAt: null,
...(search && {
OR: [{ title: { contains: search } }, { content: { contains: search } }],
}),
};
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
include: {
author: {
select: {
id: true,
nickname: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
skip,
take: limit,
}),
prisma.post.count({ where }),
]);
return {
posts,
total,
page,
limit,
};
}
// 게시물 단일 조회
async getPostById(id: string): Promise<PostResponse> {
const post = await prisma.post.findUnique({
where: {
id,
deletedAt: null,
},
include: {
author: {
select: {
id: true,
nickname: true,
email: true,
},
},
},
});
if (!post) {
throw new Error('게시물을 찾을 수 없습니다.');
}
return post;
}
// 게시물 수정
async updatePost(id: string, authorId: string, data: UpdatePostDto): Promise<PostResponse> {
// 게시물 존재 확인 및 작성자 검증 (삭제되지 않은 게시물만)
const existingPost = await prisma.post.findFirst({
where: {
id,
deletedAt: null,
},
});
if (!existingPost) {
throw new Error('게시물을 찾을 수 없습니다.');
}
if (existingPost.authorId !== authorId) {
throw new Error('게시물을 수정할 권한이 없습니다.');
}
const updatedPost = await prisma.post.update({
where: { id },
data,
include: {
author: {
select: {
id: true,
nickname: true,
email: true,
},
},
},
});
return updatedPost;
}
// 게시물 삭제
async deletePost(id: string, authorId: string): Promise<void> {
// 게시물 존재 확인 및 작성자 검증 (삭제되지 않은 게시물만)
const existingPost = await prisma.post.findFirst({
where: {
id,
deletedAt: null,
},
});
if (!existingPost) {
throw new Error('게시물을 찾을 수 없습니다.');
}
if (existingPost.authorId !== authorId) {
throw new Error('게시물을 삭제할 권한이 없습니다.');
}
// Soft Delete
await prisma.post.update({
where: { id },
data: {
deletedAt: new Date(),
},
});
}
}
HTTP 요청을 처리하는 PostController를 구현했습니다.
1. 입력값 검증: 클라이언트 데이터의 유효성 검사
2. 에러 처리: 적절한 HTTP 상태 코드와 메시지 반환
3. 타입 안전성: TypeScript를 활용한 컴파일 타임 검증
4. 일관된 응답: ApiResponse 인터페이스 활용
5. 보안: JWT 토큰을 통한 사용자 인증
// backend/src/controllers/post.controller.ts
export class PostController {
constructor(private postService: PostService) {}
// 게시물 생성
createPost = async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => {
try {
const { title, content }: CreatePostDto = req.body;
const authorId = req.user.id;
// 입력값 검증
if (!title || !content) {
const errorResponse: ErrorResponseDTO = {
success: false,
message: '제목과 내용을 입력해주세요.',
};
res.status(400).json(errorResponse);
return;
}
if (title.length > 200) {
const errorResponse: ErrorResponseDTO = {
success: false,
message: '제목은 200자를 초과할 수 없습니다.',
};
res.status(400).json(errorResponse);
return;
}
const post = await this.postService.createPost(authorId, { title, content });
const successResponse: ApiResponse = {
success: true,
message: '게시물이 생성되었습니다.',
data: { post },
};
res.status(201).json(successResponse);
} catch (error) {
if (error instanceof Error) {
const errorResponse: ErrorResponseDTO = {
success: false,
message: error.message,
};
res.status(400).json(errorResponse);
return;
}
next(error);
}
};
// 게시물 목록 조회
getPosts = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { page, limit, search }: GetPostsQuery = req.query;
const pageNum = page ? parseInt(String(page), 10) : 1;
const limitNum = limit ? parseInt(String(limit), 10) : 10;
// 페이지네이션 검증
if (pageNum < 1) {
const errorResponse: ErrorResponseDTO = {
success: false,
message: '페이지 번호는 1 이상이어야 합니다.',
};
res.status(400).json(errorResponse);
return;
}
if (limitNum < 1 || limitNum > 100) {
const errorResponse: ErrorResponseDTO = {
success: false,
message: 'limit은 1 이상 100 이하여야 합니다.',
};
res.status(400).json(errorResponse);
return;
}
const result = await this.postService.getPosts({
page: pageNum,
limit: limitNum,
search: typeof search === 'string' ? search : undefined,
});
const successResponse: ApiResponse = {
success: true,
data: result,
};
res.json(successResponse);
} catch (error) {
// 에러 처리 로직...
}
};
}
처음에는 Controller → Service → Repository 3계층 구조를 고려했지만, 다음과 같은 이유로 Controller → Service 구조를 선택했습니다.