PUBLISHED
모든 것을 재귀적으로 구현한 댓글 기능
작성일: 2024.10.15

내가 개인적으로 사용하는 독후감 기록 및 공유 서비스가 하나 있다. 이름은 onef다. 홍보에는 거의 신경 쓰지 않았기 때문에 실제 사용자는 나와 몇몇 후배들 정도에 불과하다. 하지만 전혀 문제는 없다. 이 서비스는 사용자 수를 늘리기 위한 제품이라기보다는, 구현해보고 싶은 기능을 부담 없이 실험해볼 수 있는 테스트베드에 가깝기 때문이다.
프론트엔드는 Next.js, 백엔드는 NestJS로 구현했고 데이터베이스는 PostgreSQL을 사용했다. SQL에 익숙하지 않아서 ORM으로는 Prisma를 선택했다.
재귀적 관계 테이블
최근 이 서비스에 댓글 기능을 추가하면서 자연스럽게 대댓글 구조를 고민하게 되었다. 일반적인 댓글 기능은 어렵지 않다. 하지만 대댓글은 이야기가 조금 달라진다. 이를 어떻게 모델링해야 할지 찾아보다가, RDB에서 말하는 재귀적 관계(recursive relation)라는 개념을 알게 되었다.
이는 하나의 테이블이 자기 자신의 레코드를 참조하는 구조를 의미한다. 즉, 같은 테이블의 PK를 FK로 다시 참조하는 방식이다. Prisma로 이를 표현하면 아래와 같은 모델이 된다.
model Comment {
id String @id @default(uuid())
comment String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id])
reportId String?
report Report? @relation(fields: [reportId], references: [id])
parentId String?
parent Comment? @relation("CommentToComment", fields: [parentId], references: [id])
replies Comment[] @relation("CommentToComment")
}이 모델을 만들고 나서 이런 생각이 들었다.
“재귀적 관계로 모델을 만들었다면, 나머지도 전부 재귀적으로 구현해볼 수 있지 않을까?”
재귀적인 쿼리 기반 조회
아래는 Nest.js 서버에서 댓글을 조회하는 코드다. 코드 전체를 첨부했지만, 실제로 핵심이 되는 부분은 서비스 레이어다.
// Controller
@Controller('comment')
export class CommentController {
constructor(private commentService: CommentService) {}
@Get(':ReportId')
async getComments(@Param('ReportId') reportId: string) {
const comments = await this.commentService.getComments(reportId);
return { comments };
}
}// Service
@Injectable()
export class CommentService {
constructor(private commentRepository: CommentRepository) {}
async getComments(id: string) {
const comments = await this.commentRepository.getComments(id);
const replies = await Promise.all(
comments.map(async (comment) => {
if (comment.replies.length === 0) return comment;
const replies = await this.getComments(comment.id);
return { ...comment, replies };
}),
);
return replies;
}
}// Repository
@Injectable()
export class CommentRepository {
constructor(private prisma: PrismaService) {}
getComments(id: string) {
return this.prisma.comment.findMany({
where: { OR: [{ parentId: id }, { reportId: id }] },
include: {
replies: true,
user: {
select: {
id: true,
nickname: true,
},
},
},
});
}
}CommentService의 getComments 메서드는 자기 자신을 재귀적으로 호출하면서 댓글의 댓글을 끝까지 조회한다. Nest 환경에서 메서드를 재귀적으로 호출해본 적은 처음이었지만, 의도한 대로 문제없이 동작했다.

이 방식의 가장 큰 한계는 페이지네이션이다. 재귀적으로 모든 댓글을 불러오는 구조에서는 전통적인 페이지네이션을 적용하기가 거의 불가능하다. 물론 꼼수를 쓰면 구현 자체는 가능하겠지만, 여기서는 “재귀적으로 구현해본다”는 목적에 집중하기로 했다.
재귀적 타입
댓글 목록을 요청하면 서버에서는 다음과 같은 형태의 응답을 내려준다.
{ comments: TComments }문제는 이 comments 내부에 replies가 무한히 중첩될 수 있다는 점이다. 다행히 타입스크립트에서는 이런 구조를 표현하기 위해 재귀적 타입을 사용할 수 있다.
type TComments = Array<{
id: string;
comment: string;
user: {
id: string;
nickname: string;
};
createdAt: string;
updatedAt: string;
replies: TComments;
}>;replies가 다시 TComments를 참조하고 있기 때문에, 깊이에 제한이 없는 대댓글 구조를 타입 레벨에서 안전하게 표현할 수 있다. 이로써 서버 응답의 타입은 { comments: TComments }로 정의할 수 있다.
재귀 컴포넌트
이 글을 쓰게 된 가장 큰 이유는 사실 이 부분이다. 모델, 쿼리, 타입까지 재귀적으로 구현했다면, 컴포넌트도 재귀적으로 만들 수 있지 않을까라는 생각이 들었다.
결론부터 말하면 가능하다. 컴포넌트는 자기 자신을 호출할 수 있고, 이를 통해 무한히 깊은 대댓글 구조를 자연스럽게 렌더링할 수 있다.
아래는 이를 검증하기 위해 작성한 CommentBox 컴포넌트다.
const CommentBox = ({
comments,
depth = 0,
}: {
comments: TComments;
depth?: number;
}) => {
const isDepthZero = depth === 0;
return (
<>
{comments.map(({ user, id, comment, replies }) => (
<div style={{ marginLeft: `${isDepthZero ? 0 : 1}rem` }} key={id}>
<div>
{isDepthZero ? '' : '↪'} {user.nickname}: {comment}
</div>
<CommentBox comments={replies} depth={depth + 1} />
</div>
))}
</>
);
};depth는 재귀 흐름을 따라 증가하며, 현재 댓글의 깊이를 판단하는 인디케이터 역할을 한다. 예를 들어 깊이가 일정 수준 이상일 경우 들여쓰기를 제한하는 식의 UI 제어도 가능하다.
결론
댓글 기능은 모델, 쿼리, 타입, 컴포넌트까지 모든 단계에서 재귀적으로 구현할 수 있다. 이론적으로는 완결된 구조다. 하지만 이 방식이 실제 서비스에 적합한지는 별개의 문제다. 특히 쿼리를 재귀적으로 호출하는 구조는 깊이가 깊어질수록 성능과 확장성 측면에서 부담이 커진다. 애초에 대댓글이 무한히 깊어야 할 이유도 없다.
이는 초기에 재귀적 관계 테이블을 설정하여 생긴 부수적 문제이므로, 나의 결론은 이렇다. 재귀적 관계 테이블을 사용해 무한한 대댓글을 구현하는 것은 접근 자체가 틀렸다. 나는 lexical order를 사용했어야 했다. 그런 의미에서 다음 포스트는 lexical order를 사용한 댓글 기능 구현이다.