위리브 프로젝트에서 댓글 기능을 맡았다. 구현 범위는 댓글 생성, 수정, 삭제였고, 민원과 공지사항에 공통으로 쓰이는 구조였다.
이전에 간단한 Express 프로젝트를 할 때는 라우터에서 바로 DB를 호출하거나, 기껏해야 service 파일 하나 정도만 분리했었다. 이번 프로젝트는 route → controller → service → repository 4계층으로 나뉘어 있어서 처음에는 파일이 너무 많다고 느꼈는데, 작업하면서 이 구조가 왜 좋은지 체감할 수 있었다.
예를 들어 "댓글을 찾을 수 없습니다" 같은 에러 처리를 service에서 하고, 실제 DB 쿼리는 repository에서만 담당하니까 역할 분리가 명확했다. 나중에 테스트 코드 짤 때도 service 로직만 테스트하면 됐고, DB 접근은 repository로 감싸져 있으니까 mocking도 훨씬 쉬웠다.
comment.route.ts → 어떤 URL로 들어오는지
comment.controller.ts → 요청/응답 처리
comment.service.ts → 비즈니스 로직
comment.repository.ts → DB 쿼리
처음에는 formatComment 함수에서 comment: any로 타입을 지정했는데, PR 리뷰에서 지적을 받았다. any를 쓰면 TypeScript의 타입 안전성이 사라지기 때문이다.
수정한 방법은 Prisma가 자동 생성해주는 GetPayload 유틸리티 타입을 활용하는 것이었다.
// 수정 전
const formatComment = (comment: any): CommentResponse => ({ ... });
// 수정 후
type CommentWithAuthor = Prisma.CommentGetPayload<{
include: { author: { select: { id: true; name: true } } };
}>;
const formatComment = (comment: CommentWithAuthor): CommentResponse => ({ ... });
GetPayload는 Prisma 쿼리에서 include 옵션을 쓸 때 반환되는 정확한 타입을 추론해준다. 직접 인터페이스를 만들면 DB 스키마가 바뀔 때마다 타입도 수동으로 수정해야 하는데, 이 방법을 쓰면 스키마가 바뀌면 타입도 자동으로 따라온다.
처음에는 댓글 생성 시 boardId가 실제로 존재하는지 확인하지 않고 그냥 DB에 저장했다. 없는 boardId를 넣으면 FK 에러가 터지긴 하지만, 에러 메시지가 사용자에게 노출되는 형태가 아니어서 문제였다.
PR 리뷰 후 service에서 미리 board 존재 여부와 boardType 일치 여부를 확인하는 로직을 추가했다.
const board = await commentRepository.findBoardById(body.boardId);
if (!board) throw new NotFoundError("게시판을 찾을 수 없습니다.");
if (board.type !== body.boardType)
throw new BadRequestError("boardType이 실제 게시판 유형과 일치하지 않습니다.");
단순히 에러를 잡는 게 아니라, 예측 가능한 에러는 미리 명시적으로 던지는 것이 좋은 코드라는 걸 이번에 배웠다.