Ayden's journal

모든 것을 재귀적으로 구현한 댓글 기능

내가 쓸 목적으로 만든 독후감 기록 및 공유 서비스가 하나 있다. 이름은 onef인데, 홍보에 그렇게 열중하지 않았더니 쓰는 사람이 나랑 내 후배들 정도 뿐이다. 하지만 상관 없다. 구현해보고 싶은 기능을 부담 없이 해볼 수 있는 테스트베드로서의 기능은 충분히 만족하고 있으니까. 이 서비스의 프론트와 백엔드는 모두 내가 구현했는데, 각각 NextJS와 NestJS를 사용했다. 데이터베이스는 postgreSQL을 썼는데, SQL을 모르는 관계로 ORM인 Prisma를 사용했다.

 

재귀적 관계 테이블

댓글 기능을 추가하는 건 간단하다. 그런데 대댓글은 어떻게 구현해야 할까. 이에 대해 찾아보다가 RDB에는 재귀적 관계라는 게 있다는 걸 알게되었다. 풀어서 쓰면 자기 자신을 참조하는 모델 연결이라고 해야겠다. 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 서버가 댓글을 조회할 수 있도록 처리한 코드 전체이다. 혹시나 궁금한 사람이 있을까봐 코드 전체를 올렸지만, 실제로 중요한 부분은 서비스 레이어다.

// 컨트롤러
@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 };
  }
}
// 서비스
@Injectable()
export class CommentService {
  constructor(private commentRepository: CommentRepository) {}

  async getComments(id: string) {
    const comments = await this.commentRepository.getComments(id);

    console.log(comments);

    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;
  }
}
// 레포지토리
@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에서 메소드를 재귀적으로 호출해본 적은 없어서 될까 싶었는데, 다행히 문제 없이 동작한다.

물론 이렇게 처리하면 페이지네이션은 사실상 물 건너갔다고 보는 게 맞겠다. 여러 꼼수를 사용해서 페이지네이션을 구현할 수야 있겠지만, 지금은 그저 '재귀적'으로 댓글 기능을 구현하는 게 목적이니 일단은 이대로 계속 진행하겠다.

 

재귀적 타입

독후감에 대한 댓글을 요청하면 { comment: Array<???> }의 형태로 응답을 받게 된다. 그런데 이 응답 값 내부에는 무한히 깊은 replies가 존재할 수 있다. 다행히 무한히 깊은 타입을 손수 작성할 필요는 없다. 이런 형태의 값을 처리하기 위해 타입스크립트에는 재귀적 타입이라는 개념이 존재하니까. 이는 말 그대로 스스로를 참조하는 타입이라는 뜻인데, 아래의 TComments 타입을 살펴보면 이해가 쉬울 것이다.

type TComments = Array<{
  id: string;
  comment: string;
  user: {
    id: string
    nickname: string
  }
  createdAt: string;
  updatedAt: string;
  replies: TComments;
}>

TComment 타입 선언을 살펴보면 replies가 또 다시 TComment로 정의되어있는 것을 알 수 있다. 이처럼 스스로의 구조를 끊임없이 참조하고 있기 때문에 무한히 깊은 대댓글 구조에 대해서도 타입스크립트적으로 대응할 수 있다. 따라서 서버 응답의 타입은 { comments: TComments }가 되는 것이다.

 

재귀 컴포넌트

재귀 컴포넌트라는 개념은 사실상 이 포스트를 쓰게 된 이유기도 하다. 처음에 댓글 기능의 모든 것을 재귀적으로 구현하고자 생각하였을 때, 즉각 들었던 생각은 이거였다. "고차 컴포넌트가 컴포넌트를 고차 함수처럼 사용한 거라면, 컴포넌트를 재귀 함수처럼 사용하는 재귀 컴포넌트도 존재할 수 있지 않을까?" 될 것 같기는 한데 확신하기는 어려웠다.

결론부터 말하자면 재귀 컴포넌트는 가능하다. 컴포넌트가 스스로를 무한히 호출하며, 무한한 깊이의 대댓글에 대응할 수 있다. 아래의 CommentBox 컴포넌트는 재귀 컴포넌트의 개념 실증을 위해 작성되었다(인라인 스타일 집어넣었다고 까지 말라는 뜻). CommentBox 컴포넌트 안에서 CommentBox 컴포넌트를 호출하고 있지만 전혀 문제 없이 동작한다.

depth prop 역시 재귀의 흐름을 따라 1씩 상승하게 된다. 따라서 해당 댓글이 어느 정도 깊이에 존재하는지를 컴포넌트 내에서 직접적으로 확인할 수 있는 일종의 인디케이터 역할을 하게 된다. 지금은 depth가 0인지 아닌지만을 확인하고 있지만, 실제로는 깊이 5 이상인 경우 margin-left를 0으로 하려고 한다(그렇지 않으면 어느 순간 댓글 상자의 좌우 폭이 높이보다 좁을 수도 있다).

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>
      ))}
    </>
  );
};

 

결론

댓글 기능의 모든 부분은 재귀적으로 구현이 가능하다. 물론 실제 CommentBox는 재귀적으로 구현하기 위해 Context API를 사용해야 하고, 그 외에도 귀찮은 부분이 추가되어야 하지만, 이론상 가능하다는 사실을 확인했다.

하지만 실제로 댓글을 재귀적으로 구현하는 것은 여러모로 문제가 있으며, 이론상 가능하다는 것이 실제 프로젝트에서 써먹을만하다는 의미가 되지는 못한다. 가장 문제가 되는 것은 쿼리를 재귀적으로 호출하는 부분인데, 깊이가 무한하다면 선택할 수 있는 로직 중에서는 가장 깔끔하지만, 애당초 대댓글을 구현하기 위해 깊이가 무한해야할 이유는 없다. 이는 초기에 재귀적 관계 테이블을 설정하여 생긴 부수적 문제이므로, 나의 결론은 이렇다.

 

재귀적 관계 테이블을 사용해 무한한 대댓글을 구현하는 것은 접근 자체가 틀렸다. 나는 lexical order를 사용했어야 했다. 그런 의미에서 다음 포스트는 lexical order를 사용한 댓글 기능 구현이다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기