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

작성일:2024.10.15|조회수:0

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

내가 개인적으로 사용하는 독후감 기록 및 공유 서비스가 하나 있다. 이름은 onef다. 홍보에는 거의 신경 쓰지 않았기 때문에 실제 사용자는 나와 몇몇 후배들 정도에 불과하다. 하지만 전혀 문제는 없다. 이 서비스는 사용자 수를 늘리기 위한 제품이라기보다는, 구현해보고 싶은 기능을 부담 없이 실험해볼 수 있는 테스트베드에 가깝기 때문이다.

프론트엔드는 Next.js, 백엔드는 NestJS로 구현했고 데이터베이스는 PostgreSQL을 사용했다. SQL에 익숙하지 않아서 ORM으로는 Prisma를 선택했다.

재귀적 관계 테이블

최근 이 서비스에 댓글 기능을 추가하면서 자연스럽게 대댓글 구조를 고민하게 되었다. 일반적인 댓글 기능은 어렵지 않다. 하지만 대댓글은 이야기가 조금 달라진다. 이를 어떻게 모델링해야 할지 찾아보다가, RDB에서 말하는 재귀적 관계(recursive relation)라는 개념을 알게 되었다.

이는 하나의 테이블이 자기 자신의 레코드를 참조하는 구조를 의미한다. 즉, 같은 테이블의 PK를 FK로 다시 참조하는 방식이다. Prisma로 이를 표현하면 아래와 같은 모델이 된다.

JAVA
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 서버에서 댓글을 조회하는 코드다. 코드 전체를 첨부했지만, 실제로 핵심이 되는 부분은 서비스 레이어다.

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

CommentServicegetComments 메서드는 자기 자신을 재귀적으로 호출하면서 댓글의 댓글을 끝까지 조회한다. Nest 환경에서 메서드를 재귀적으로 호출해본 적은 처음이었지만, 의도한 대로 문제없이 동작했다.

이 방식의 가장 큰 한계는 페이지네이션이다. 재귀적으로 모든 댓글을 불러오는 구조에서는 전통적인 페이지네이션을 적용하기가 거의 불가능하다. 물론 꼼수를 쓰면 구현 자체는 가능하겠지만, 여기서는 “재귀적으로 구현해본다”는 목적에 집중하기로 했다.

재귀적 타입

댓글 목록을 요청하면 서버에서는 다음과 같은 형태의 응답을 내려준다.

CSS
{ comments: TComments }

문제는 이 comments 내부에 replies가 무한히 중첩될 수 있다는 점이다. 다행히 타입스크립트에서는 이런 구조를 표현하기 위해 재귀적 타입을 사용할 수 있다.

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

replies가 다시 TComments를 참조하고 있기 때문에, 깊이에 제한이 없는 대댓글 구조를 타입 레벨에서 안전하게 표현할 수 있다. 이로써 서버 응답의 타입은 { comments: TComments }로 정의할 수 있다.

재귀 컴포넌트

이 글을 쓰게 된 가장 큰 이유는 사실 이 부분이다. 모델, 쿼리, 타입까지 재귀적으로 구현했다면, 컴포넌트도 재귀적으로 만들 수 있지 않을까라는 생각이 들었다.

결론부터 말하면 가능하다. 컴포넌트는 자기 자신을 호출할 수 있고, 이를 통해 무한히 깊은 대댓글 구조를 자연스럽게 렌더링할 수 있다.

아래는 이를 검증하기 위해 작성한 CommentBox 컴포넌트다.

TSX
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를 사용한 댓글 기능 구현이다.

더 읽어보기

  • 2026.03.01

    렌더링 전략 정리

    리액트와 Next.js에서 렌더링 전략은 단순한 옵션 선택이 아니다. 이는 서비스의 초기 로딩 속도, 서버 비용, 캐싱 전략, SEO 노출, 개발 복잡도까지 동시에 좌우하는 아키텍처 결정이다. 프로젝트 규모가 커질수록 “어디에서 HTML을 생성하는가”, “언제 자바스크립트를 실행하는가”…

  • 2026.02.22

    View Transition API

    웹 애플리케이션에서 전환 품질은 기능 완성도와 동등한 수준으로 중요하다. 사용자가 목록에서 항목을 선택해 상세 화면으로 이동할 때, 화면이 자연스럽게 이어지면 서비스는 빠르고 안정적으로 느껴진다. 반대로 동일한 기능이라도 전환이 끊기면 체감 성능과 신뢰도는 동시에 하락한다. 전환은 부가…

  • 2025.10.08

    useEffectEvent

    React를 사용하면서 useEffect 안에서 상태를 참조할 때 의외로 자주 겪는 문제가 있다. 바로 stale closure 문제다. 예를 들어 어떤 값이 변경되었는데, useEffect 내부의 콜백에서는 여전히 이전 값을 읽고 있는 현상이다. 이는 React의 클로저 구조상 자연스…

  • 2026.04.11

    Trie 자료구조

    문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…

  • 2026.03.19

    Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까

    웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…

댓글

댓글을 불러오는 중...