Data

클로저 테이블 전략으로 설계하는 계층형 댓글 시스템

jin@catsriding.com
May 25, 2024
Published byJin
클로저 테이블 전략으로 설계하는 계층형 댓글 시스템

Designing a Scalable Nested Comment System with Closure Tables in Spring Boot

댓글 시스템은 웹사이트, 블로그, 유튜브, 소셜 네트워크 등 다양한 온라인 플랫폼에서 사용자 간 소통의 중심 역할을 합니다. 이를 위해 적절히 설계된 데이터베이스와 효율적으로 구현된 API는 필수적입니다. 이들 요소가 잘 조합될 때, 사용자 간의 상호작용을 원활하게 지원하는 댓글 시스템을 만들 수 있습니다.

이 블로그 포스트에서는 데이터베이스 설계부터 시작해, 기본적인 댓글 작성과 조회 기능을 구현하는 과정을 단계별로 살펴보겠습니다.

1. 댓글 계층 구조를 위한 데이터 모델링 전략

댓글 시스템을 구현하기 위한 첫 번째 단계는 데이터베이스 스키마를 설계하는 것입니다.댓글 시스템의 핵심은 계층적 구조를 효과적으로 표현하고 관리하는 것입니다. 이를 위해 적절한 데이터베이스 설계 전략이 필요합니다. 이 섹션에서는 계층 구조를 표현하기 위한 대표적인 두 가지 접근 방식인 자기 참조 전략과 클로저 테이블 전략에 대해 살펴보겠습니다.

1-1. 자기 참조 전략

자기 참조 전략은 동일 테이블 내에서 레코드 간의 부모-자식 관계를 표현하는 방식으로, 댓글과 대댓글 간의 계층 구조를 간단하게 구현할 수 있는 가장 직관적인 방법입니다.

design-a-robust-comments-system-with-spring_00.png

예를 들어 parent_id 필드를 통해 한 댓글이 다른 댓글을 참조하도록 설계하면, 다음과 같은 방식으로 대댓글을 구성할 수 있습니다.

SELF_REF_COMMENTS
+----+-----------+---------------+
| id | parent_id | content       |
+----+-----------+---------------+
| 1  | null      | Great article!|
| 2  | 1         | Thanks!       |
| 3  | 1         | I agree.      |
| 4  | 3         | Me too.       |
+----+-----------+---------------+

이 전략의 장점은 설계가 단순하고, 구현 및 유지보수가 비교적 쉬운 점입니다. 부모-자식 관계를 직관적으로 표현할 수 있어, 많은 시스템에서 기본적으로 채택하는 방식입니다.

하지만 다단계 계층 구조를 다룰 때는 몇 가지 한계가 존재합니다. 예를 들어 특정 댓글의 모든 하위 댓글을 한 번에 조회하거나, 전체 트리 구조를 구성하려면 재귀 쿼리 또는 반복적인 조회가 필요합니다. 이로 인해 성능 저하나 복잡한 쿼리 로직이 발생할 수 있습니다.

1-2. 클로저 테이블 전략

클로저 테이블 전략은 계층 구조를 정규화하여, 모든 조상-자손 관계를 별도의 테이블에 명시적으로 저장하는 방식입니다. 이를 통해 각 댓글이 어떤 상위 댓글에 속해 있는지를 빠르게 조회할 수 있으며, 복잡한 계층 구조를 효율적으로 탐색할 수 있습니다.

design-a-robust-comments-system-with-spring_01.png

이 전략에서는 COMMENTS 테이블 외에 모든 계층 관계를 관리하는 COMMENT_CLOSURE 테이블을 추가로 정의합니다. 이 테이블에는 각 댓글의 조상과 자손 관계가 전부 저장되며, 동일한 댓글의 자기 자신에 대한 관계도 포함됩니다.

클로저 테이블 전략은 다음과 같은 장점을 제공합니다:

  • 특정 댓글의 모든 자식 또는 조상 댓글을 단일 쿼리로 조회 가능
  • 계층의 깊이와 관계를 쉽게 파악할 수 있음
  • 순환 참조나 무한 루프 같은 오류 발생 가능성이 낮음

하지만 모든 관계를 명시적으로 저장해야 하므로, 댓글이 추가될 때 클로저 테이블에 다수의 레코드를 삽입해야 하며, 이에 따라 쓰기 연산 비용과 저장 공간이 증가하는 단점도 존재합니다. 따라서 데이터 양이 많고 조회 성능이 중요한 시스템일수록 이 전략이 더 적합합니다.

클로저 테이블(Closure Table) 전략을 댓글 생성 시나리오에 적용해보겠습니다. 예를 들어, 클라이언트가 댓글 C를 생성하고 그 부모가 댓글 B인 상황을 가정해봅니다.

  1. 클라이언트는 댓글 C를 생성하면서, 부모 댓글 ID로 댓글 B의 ID를 함께 전달합니다.
  2. 서버는 먼저 COMMENTS 테이블에 댓글 C를 저장합니다.
  3. 이어서 COMMENT_CLOSURE 테이블에 댓글 C가 자기 자신을 참조하는 초기 관계 레코드를 추가합니다:
COMMENT_CLOSURE
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| C           | C             | 0     |
+-------------+---------------+-------+
  1. 다음으로, 댓글 B에 대한 모든 조상 관계를 COMMENT_CLOSURE 테이블에서 조회합니다. 이때 댓글 B가 속한 전체 계층 구조를 파악할 수 있습니다:
select * from COMMENT_CLOSURE where descendant_id = :parentId

+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A           | B             | 1     |
| B           | B             | 0     |
+-------------+---------------+-------+
  1. 조회된 각 조상(댓글 A, B)에 대해, 새로운 댓글 C와의 관계를 다음과 같이 테이블에 추가합니다. 이를 통해 기존 계층에 댓글 C가 자연스럽게 연결됩니다:
COMMENT_CLOSURE
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A           | C             | 2     |
| B           | C             | 1     |
+-------------+---------------+-------+
  1. 이 모든 관계를 반영하면, 클로저 테이블의 최종 상태는 다음과 같이 구성됩니다:
COMMENT_CLOSURE
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A           | A             | 0     |
| B           | B             | 0     |
| A           | B             | 1     |
| C           | C             | 0     |
| A           | C             | 2     |
| B           | C             | 1     |
+-------------+---------------+-------+

이처럼 클로저 테이블은 댓글 간의 모든 상하 관계를 명시적으로 저장하므로, 특정 댓글을 기준으로 모든 하위 댓글을 단일 쿼리로 쉽게 조회할 수 있습니다. 예를 들어, 댓글 A로부터 파생된 모든 후손 댓글을 조회하는 쿼리는 다음과 같습니다:

select
    *
from
    COMMENT_CLOSURE
where
    ancestor_id = 'A' -- 최상위 댓글 노드 ID
order by
    depth;

해당 쿼리를 실행하면 다음과 같은 결과를 얻을 수 있습니다:

COMMENT_CLOSURE
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A           | A             | 0     |
| A           | B             | 1     |
| A           | C             | 2     |
+-------------+---------------+-------+

이처럼 클로저 테이블 전략은 계층적 구조를 갖는 댓글 시스템에서, 깊이에 관계없이 효율적인 트리 조회 성능을 제공하는 매우 강력한 방식입니다.

2. 댓글 시스템 구현

이제 블로그 게시글에 댓글 기능을 추가하는 실질적인 시나리오를 바탕으로 댓글 기능을 구현해 보면서, 클로저 테이블 전략이 계층형 댓글 구조를 어떻게 효과적으로 관리할 수 있는지를 함께 살펴보겠습니다.

2-1. 데이터베이스 스키마 정의 및 프로젝트 초기화

댓글 시스템의 구현을 위해 가장 먼저 해야 할 일은 데이터 모델을 구체화하는 것입니다. 이 단계에서는 클로저 테이블 전략을 기반으로 하는 데이터베이스 스키마를 정의하고, 이를 적용할 Spring Boot 프로젝트를 초기화합니다.

클로저 테이블 전략을 적용하기 위해, 댓글 시스템에 필요한 테이블을 먼저 설계합니다.

design-a-robust-comments-system-with-spring_03.png

데이터베이스 구성은 다음과 같은 다섯 개의 주요 테이블로 이루어져 있습니다:

  • USERS: 사용자 정보를 저장합니다. 댓글과 게시글의 작성자를 구분하기 위해 사용됩니다.
  • POSTS: 게시글 정보를 저장합니다. 각 댓글은 특정 게시글에 종속되므로, 댓글과의 연결이 필요합니다.
  • COMMENTS: 개별 댓글의 내용과 작성자 정보를 저장합니다.
  • COMMENT_CLOSURE: 댓글 간의 상하 관계를 저장하며, 클로저 테이블 전략의 핵심이 되는 테이블입니다.
  • POST_COMMENTS: 댓글이 어느 게시글에 속해 있는지를 명시적으로 나타내는 연결 테이블입니다.

다음은 위 구조를 반영한 테이블 생성 SQL입니다:

MySQL
create table USERS
(
    id         bigint unsigned not null auto_increment primary key,
    username   varchar(60)     not null,
    updated_at datetime        not null default current_timestamp,
    created_at datetime        not null default current_timestamp
);

create table POSTS
(
    id         bigint unsigned not null auto_increment primary key,
    user_id    bigint unsigned not null,
    title      varchar(255)    not null,
    content    text            not null,
    is_deleted bit(1)          not null default 0,
    updated_at datetime        not null default current_timestamp,
    created_at datetime        not null default current_timestamp,
    foreign key (user_id) references USERS(id)
);

create table COMMENTS
(
    id         bigint unsigned not null auto_increment primary key,
    user_id    bigint unsigned not null,
    content    varchar(255)    not null,
    is_deleted bit(1)          not null default 0,
    updated_at datetime        not null default current_timestamp,
    created_at datetime        not null default current_timestamp,
    foreign key (user_id) references USERS(id)
);

create table COMMENT_CLOSURE
(
    id            bigint unsigned not null auto_increment primary key,
    ancestor_id   bigint unsigned not null,
    descendant_id bigint unsigned not null,
    depth         int             not null,
    updated_at    datetime        not null default current_timestamp,
    created_at    datetime        not null default current_timestamp,
    unique key UK_COMMENT_CLOSURE_ANCESTOR_ID_DESCENDANT_ID(ancestor_id, descendant_id),
    foreign key (ancestor_id) references COMMENTS(id),
    foreign key (descendant_id) references COMMENTS(id)
);

create table POST_COMMENTS
(
    id         bigint unsigned not null auto_increment primary key,
    post_id    bigint unsigned not null,
    comment_id bigint unsigned not null,
    is_deleted bit(1)          not null default 0,
    updated_at datetime        not null default current_timestamp,
    created_at datetime        not null default current_timestamp,
    unique key UK_POST_COMMENTS_POST_ID_COMMENT_ID(post_id, comment_id),
    foreign key (post_id) references POSTS(id),
    foreign key (comment_id) references COMMENTS(id)
);

이제 테이블 스키마가 준비되었으므로, 이를 기반으로 실제 프로젝트를 구성해 나갑니다.

이번 프로젝트는 Java 17과 Spring Boot 3.2.4를 기반으로 하며, Gradle을 빌드 도구로 사용합니다. MySQL을 데이터베이스로 설정하고, 댓글 계층 구조를 효율적으로 쿼리하기 위해 Querydsl을 함께 도입합니다. 추가로 유효성 검사, 로깅, JPA 설정 등을 위해 필요한 의존성을 함께 설정합니다.

build.gradle 파일에는 다음과 같이 의존성을 추가합니다:

build.gradle
dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

이후 application.yml에 데이터베이스 연결 설정을 추가하고, Hibernate 관련 속성들을 구성합니다. 개발 과정에서 SQL 쿼리 로그를 확인할 수 있도록 Hibernate SQL 로그 레벨도 함께 설정해 둡니다:

application.yml
spring:
  profiles:
    active: catsriding

  output:
    ansi:
      enabled: always

  datasource:
    url: jdbc:mysql://localhost:3306/playgrounds
    username: catsriding
    password: catsriding

  jpa:
    database: mysql
    open-in-view: false
    generate-ddl: false
    properties:
      hibernate:
        default_batch_fetch_size: 1000
        format_sql: true
    hibernate:
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

logging:
  level:
    org:
      hibernate:
        SQL: debug
        type:
          descriptor:
            sql: trace

다음은 앞서 정의한 테이블 스키마에 맞춰 JPA 엔티티를 작성합니다. 각 테이블에 대응되는 엔티티 클래스들을 생성하여, 애플리케이션에서 데이터를 객체로 다룰 수 있도록 합니다.

Entities
@Entity @Table(name = "USERS") public class UserEntity {...}
@Entity @Table(name = "POSTS") public class PostEntity {...}
@Entity @Table(name = "COMMENTS") public class CommentEntity {...}
@Entity @Table(name = "COMMENT_CLOSURE") public class CommentClosureEntity {...}
@Entity @Table(name = "POST_COMMENTS") public class PostCommentEntity {...}

엔티티가 준비되면, 이들을 관리할 리포지토리 인터페이스를 정의합니다. 각 리포지토리는 JpaRepository를 상속받아 기본적인 CRUD 기능을 제공하며, 도메인에 특화된 로직이 필요한 경우를 대비해 커스텀 확장 인터페이스도 함께 정의해 둡니다.

Repositories
public interface UserJpaRepository extends JpaRepository<UserEntity, Long>, UserJpaRepositoryExtension {...}
public interface PostJpaRepository extends JpaRepository<PostEntity, Long>, PostJpaRepositoryExtension {...}
public interface CommentJpaRepository extends JpaRepository<CommentEntity, Long>, CommentJpaRepositoryExtension {...}
public interface CommentClosureJpaRepository extends JpaRepository<CommentClosureEntity, Long>, CommentClosureJpaRepositoryExtension {...}
public interface PostCommentJpaRepository extends JpaRepository<PostCommentEntity, Long>, PostCommentJpaRepositoryExtension {...}

이로써 스키마 설계, 프로젝트 기본 설정, 그리고 엔티티 및 리포지토리 구성까지 댓글 시스템을 구성하기 위한 기초적인 개발 환경과 데이터 모델링 작업이 마무리되었습니다. 이제 이 구조를 바탕으로 실제 댓글 작성 및 조회 기능을 단계적으로 구현해 나가겠습니다.

2-2. 댓글 작성 API 구현

댓글 작성 API는 클라이언트로부터 댓글 입력을 받아 저장하고, 클로저 테이블을 통해 계층 구조를 동적으로 갱신하는 기능을 포함합니다.

먼저, 댓글 생성을 위한 요청 데이터를 담는 DTO를 정의합니다.

PostCommentCreateRequest.java
@Slf4j
@Getter
@Builder
public class PostCommentCreateRequest {

    @NotNull(message = "User ID is a required field")
    @Positive(message = "User ID must be a positive number")
    private final Long userId;

    @Positive(message = "Parent ID must be a positive number if provided")
    private final Long parentId;

    @NotBlank(message = "Content is a required field")
    @Size(max = 255, message = "Content must be less than or equal to 255 characters")
    private final String content;

}

실제 환경에서는 보통 사용자 정보가 인증 토큰에서 추출되지만, 예제에서는 인증을 생략하고 요청 본문에서 직접 userId를 받도록 구성했습니다. parentId는 선택값이며, 지정되지 않은 경우 최상위 댓글로 간주합니다.

컨트롤러는 요청 유효성을 검사한 뒤, 실제 로직 처리는 서비스 계층으로 위임합니다.

PostCommentController.java
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/posts/{postId}/comments")
public class PostCommentController {

    private final PostCommentService service;

    @PostMapping
    public ResponseEntity<?> postsCommentCreateApi(
                @PathVariable Long postId,
                @Valid @RequestBody PostCommentCreateRequest request) {
        PostCommentCreateResponse response = service.createPostComment(
                request.toCommentCreate(),
                request.toPostCommentCreate());
        return ResponseEntity
                .ok(response);
    }
}

그리고 서비스에서는 댓글 저장과 클로저 테이블 구성, 게시물 및 댓글 연결까지 포함된 전체 흐름을 관리합니다.

PostCommentServiceImpl.java
@Slf4j
@Service
@RequiredArgsConstructor
public class PostCommentServiceImpl implements PostCommentService {

    private final PostCommentRepository postCommentRepository;

    private final PostService postService;
    private final CommentService commentService;
    private final ClockHolder clock;

    @Override
    @Transactional
    public PostCommentCreateResponse createPostComment(
            CommentCreate commentCreate,
            PostCommentCreate postCommentCreate) {
        Post post = postService.retrievePostById(postCommentCreate.getPostId());
        Comment comment = commentService.createComment(commentCreate);
        PostComment postComment = postCommentRepository.save(PostComment.from(post, comment, clock.now()));

        log.info(
                "createPostComment: Successfully created post comment - postId={} commentId={} postCommentId={}",
                post.getId(),
                comment.getId(),
                postComment.getId());

        return PostCommentCreateResponse.response(post.getId(), comment.getId());
    }
}

서비스 로직의 핵심 흐름은 다음과 같습니다:

  • 먼저 댓글이 작성될 게시글 정보를 조회하여 존재 여부를 확인합니다.
  • 사용자의 입력을 바탕으로 샤로운 댓글을 생성합니다.
  • 새로 생성된 댓글에 대해 클로저 테이블에 자기 자신을 조상으로 하는 초기 관계(depth = 0) 를 등록합니다.
  • 만약 부모 댓글이 존재하는 경우, 해당 부모의 모든 조상 관계를 조회한 뒤, 이를 기반으로 새 댓글과 조상 간의 계층 관계를 클로저 테이블에 추가합니다.
  • 마지막으로, 생성된 댓글이 어떤 게시글에 속하는지를 명시적으로 연결하기 위해 연관 정보를 저장합니다.

이 과정에서 클로저 테이블은 댓글 간 계층 구조를 표현하는 핵심 도구로 활용됩니다. 클로저 테이블의 구성은 자기 자신과의 관계를 기록하는 것부터 시작되며, 이는 모든 댓글의 계층 구조가 출발하는 기준점 역할을 합니다. 댓글이 생성되면 가장 먼저 자기 자신을 조상으로 하는 레코드를 추가하고, 이후 부모 댓글이 존재할 경우 그 조상 관계를 따라가며 트리 구조를 확장해 나가게 됩니다.

CommentClosure.java
@Getter
public class CommentClosure {

    private final Long id;
    private final Comment ancestor;
    private final Comment descendant;
    private final Integer depth;
    private final LocalDateTime updatedAt;
    private final LocalDateTime createdAt;

    @Builder
    private CommentClosure(
            Long id,
            Comment ancestor,
            Comment descendant,
            Integer depth,
            LocalDateTime updatedAt,
            LocalDateTime createdAt) {
        this.id = id;
        this.ancestor = ancestor;
        this.descendant = descendant;
        this.depth = depth;
        this.updatedAt = updatedAt;
        this.createdAt = createdAt;
    }

    public static CommentClosure initClosure(Comment selfNode, LocalDateTime now) {
        return CommentClosure.builder()
                .ancestor(selfNode)
                .descendant(selfNode)
                .depth(initializeDepth())
                .updatedAt(now)
                .createdAt(now)
                .build();
    }

    private static int initializeDepth() {
        return 0;
    }
}

이렇게 댓글이 생성되면 동시에 클로저 레코드도 함께 생성되며, 해당 댓글은 클로저 테이블 상에서 자기 자신을 루트로 하는 독립 노드로 등록됩니다. 이후 이 노드를 기준으로 부모와의 연결이 추가되면서 전체 댓글 트리의 일원이 되어 갑니다.

CommentServiceImpl.java
@Transactional
public Comment createComment(CommentCreate commentCreate) {
    ...
    
+   CommentClosure selfClosure = CommentClosure.initClosure(comment, clock.now());
+   selfClosure = commentClosureRepository.save(selfClosure);

    return comment;
}

이 초기 연결은 단순해 보일 수 있지만, 이후 계층 구조를 확장해 나가기 위한 모든 관계의 기준점이 됩니다. 이후에는 이 노드를 중심으로, 부모 댓글 및 상위 조상들과의 관계를 재귀적으로 추가하게 되며, 클로저 테이블 기반 트리 구조의 핵심이 바로 이 단계에서 출발합니다.

초기 관계가 등록된 이후, 요청에 부모 ID를 함께 전달한 경우에는 해당 댓글이 특정 부모 댓글 아래에 작성된 것으로 간주하고, 부모 댓글과 그 조상들과의 계층 관계를 클로저 테이블에 반영해야 합니다. 클로저 테이블은 단일 연결만 저장하는 것이 아니라, 모든 조상-자식 간의 경로 정보를 포함하기 때문에, 하나의 댓글이 추가되면 그 부모를 포함한 전체 상위 구조에 대한 연결이 함께 갱신되어야 합니다.

이를 위해, 부모 댓글의 ID를 기준으로 클로저 테이블에서 해당 댓글의 모든 조상 노드를 조회합니다.

CommentClosureJpaRepositoryImpl.java
@Slf4j
@RequiredArgsConstructor
public class CommentClosureJpaRepositoryImpl implements CommentClosureJpaRepositoryExtension {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<CommentClosureEntity> fetchAllAncestorsBy(ParentCommentId parentCommentId) {
        QCommentEntity ancestor = new QCommentEntity("ancestor");
        QCommentEntity descendant = new QCommentEntity("descendant");
        QUserEntity ancestorUser = new QUserEntity("ancestorUser");
        QUserEntity descendantUser = new QUserEntity("descendantUser");

        return queryFactory
                .select(commentClosureEntity)
                .from(commentClosureEntity)
                .innerJoin(commentClosureEntity.ancestor, ancestor).fetchJoin()
                .innerJoin(ancestor.user, ancestorUser).fetchJoin()
                .innerJoin(commentClosureEntity.descendant, descendant).fetchJoin()
                .innerJoin(descendant.user, descendantUser).fetchJoin()
                .where(
                        descendant.id.eq(parentCommentId.getId()),
                        ancestor.isDeleted.isFalse(),
                        descendant.isDeleted.isFalse()
                )
                .fetch();
    }
}

조상 노드들이 조회되면, 이제 이들과 새로 생성된 자식 댓글 간의 연결 관계를 생성해야 합니다. 각 조상 댓글과 자식 댓글 사이에 새로운 클로저 레코드를 구성하되, 이때의 depth는 조상 댓글에서 자식 댓글까지의 거리이므로, 기존 조상 관계의 depth 값에 +1을 더하여 설정합니다.

아래는 이 관계를 표현하기 위한 정적 팩토리 메서드입니다:

CommentClosure.java
@Getter
public class CommentClosure {

    ...

    public static CommentClosure mergeClosure(
            CommentClosure ancestorNode,
            CommentClosure descendantNode,
            LocalDateTime now) {
        return CommentClosure.builder()
                .ancestor(ancestorNode.getAncestor())
                .descendant(descendantNode.getDescendant())
                .depth(increaseDepth(ancestorNode.getDepth()))
                .updatedAt(now)
                .createdAt(now)
                .build();
    }
 
    private static int increaseDepth(Integer depth) {
        return depth + 1;
    }
}

이렇게 생성된 연결 관계는 모두 데이터베이스에 저장됩니다. 아래는 전체 관계를 순회하며 클로저 테이블에 반영하는 서비스 메서드입니다:

CommentServiceImpl.java
private void linkClosures(CommentCreate commentCreate, CommentClosure descendant) {
    if (!commentCreate.hasParentId()) return;

    List<Long> ids = commentClosureRepository.fetchAncestors(commentCreate.getParentCommentId())
            .stream()
            .map(ancestor -> CommentClosure.mergeClosure(ancestor, descendant, clock.now()))
            .map(commentClosureRepository::save)
            .map(CommentClosure::getId)
            .toList();

    log.info("linkClosures: Successfully created closures - ids={}", ids);
}

이 과정을 통해, 새로 생성된 댓글은 부모 댓글뿐 아니라 모든 조상 댓글과의 관계를 명시적으로 기록하게 됩니다. 이는 단순히 직접적인 상하 관계만을 저장하는 방식과 달리, 트리 구조의 전체 계층을 명확하게 추적할 수 있도록 도와줍니다.

클로저 테이블의 가장 큰 장점은 바로 이러한 구조적 이점에 있습니다. 트리의 깊이나 위치에 관계없이, 특정 댓글의 모든 상위 또는 하위 노드를 단일 쿼리로 효율적으로 조회할 수 있으며, depth 정보를 통해 노드 간의 상대적 거리도 쉽게 파악할 수 있습니다.

이제 마지막으로, 생성된 댓글을 어느 게시글에 속하는지 명시하기 위해 POST_COMMENTS 테이블에 연관 정보를 저장합니다. 이 과정을 통해 댓글 계층 구조와 포스트 간의 관계를 분리하고, 구조적으로 더욱 응집력 있는 설계를 유지할 수 있습니다.

PostCommentService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class PostCommentServiceImpl implements PostCommentService {

    ...

    @Override
    @Transactional
    public PostCommentCreateResponse createPostComment(
            CommentCreate commentCreate,
            PostCommentCreate postCommentCreate) {
        ...

        PostComment postComment = postCommentRepository.save(PostComment.from(post, comment, clock.now()));

        ...
    }
}

지금까지의 과정을 통해 댓글 생성 API는 단순한 저장 기능을 넘어, 계층 구조 기반의 관계 관리와 효율적인 조회가 가능한 확장성 높은 댓글 시스템으로 완성됩니다. 클로저 테이블을 중심으로 한 이 설계는, 복잡한 트리 구조를 가진 댓글 환경에서도 뛰어난 성능과 유지보수성을 제공합니다.

2-3. 댓글 작성 API 테스트

앞서 구현한 댓글 작성 API가 의도한 대로 동작하는지 확인해보겠습니다. HTTP 클라이언트 도구를 활용하여 실제 요청을 전송하고, 응답 결과와 함께 데이터베이스의 상태 변화를 살펴봅니다.

먼저, 테스트에 사용할 사용자 및 게시글 데이터를 데이터베이스에 삽입합니다. 이는 댓글 생성을 위한 기본 전제 조건입니다.

MySQL
insert into USERS (username)
values ('Alice'),
       ('Bob'),
       ('Charlie'),
       ('Dave'),
       ('Eve'),
       ('Frank'),
       ('Grace'),
       ('Heidi'),
       ('Igor'),
       ('Judy');


insert into POSTS (user_id, title, content, is_deleted)
values (1, 'My First Post', 'This is my first post on this platform', 0),
       (2, 'Getting Started', 'This post will guide you on how to get started with this platform', 0),
       (3, 'Tips and Tricks', 'This post shares some tips and tricks for new users', 0),
       (4, 'About Me', 'This post is all about me', 0),
       (5, 'My Journey', 'This post narrates my journey on this platform', 0);

이제 첫 번째 댓글을 게시글에 작성해보겠습니다. parentId는 명시하지 않아 최상위 댓글로 등록되도록 합니다.

POST /posts/1/comments HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.0 (Macintosh; OS X/14.4.1) GCDHTTPRequest
Content-Length: 52

{"userId":1,"content":"A"}

요청이 성공적으로 처리되면, 다음과 같이 관련 테이블에 데이터가 반영됩니다:

console
# COMMENTS
+--+-------+-------+
|id|user_id|content|
+--+-------+-------+
|1 |1      |A      |
+--+-------+-------+

# COMMENT_CLOSURE
+--+-----------+-------------+-----+
|id|ancestor_id|descendant_id|depth|
+--+-----------+-------------+-----+
|1 |1          |1            |0    |
+--+-----------+-------------+-----+

# POST_COMMENTS
+--+-------+----------------+
|id|user_id|content         |
+--+-------+----------------+
|1 |1      |hello, comments!|
+--+-------+----------------+

이번에는 댓글 A에 대한 답글을 작성해보겠습니다. parentId1로 설정하여 A 댓글의 하위에 연결되도록 요청을 전송합니다.

POST /posts/1/comments HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.0 (Macintosh; OS X/14.4.1) GCDHTTPRequest
Content-Length: 65

{"userId":3,"parentId":1,"content":"B"}

요청이 처리되면, 댓글 B는 댓글 A의 하위 노드로 계층 구조에 포함되며, 클로저 테이블에도 관계가 다음과 같이 반영됩니다:

console
# COMMENTS
+--+-------+-------+
|id|user_id|content|
+--+-------+-------+
|1 |1      |A      |
|2 |3      |B      |
+--+-------+-------+

# COMMENT_CLOSURE
+--+-----------+-------------+-----+
|id|ancestor_id|descendant_id|depth|
+--+-----------+-------------+-----+
|1 |1          |1            |0    |
|2 |2          |2            |0    |
|3 |1          |2            |1    |
+--+-----------+-------------+-----+

# POST_COMMENTS
+--+-------+----------+
|id|post_id|comment_id|
+--+-------+----------+
|1 |1      |1         |
|2 |1      |2         |
+--+-------+----------+

이제 댓글 B에 대해 다시 답글을 작성하여, 3단계 계층 구조가 정확히 반영되는지 확인합니다.

POST /posts/1/comments HTTP/1.1
Content-Type: application/json
Host: localhost:8085
Connection: close
User-Agent: RapidAPI/4.2.0 (Macintosh; OS X/14.4.1) GCDHTTPRequest
Content-Length: 65

{"userId":2,"parentId":2,"content":"C"}

댓글 C가 성공적으로 생성되면, 클로저 테이블은 다음과 같은 상태가 됩니다. 각 댓글 간의 조상-자손 관계와 depth 정보가 명시적으로 추가되어 있는 것을 확인할 수 있습니다:

console
# COMMENTS
+--+-------+-------+
|id|user_id|content|
+--+-------+-------+
|1 |1      |A      |
|2 |3      |B      |
|3 |2      |C      |
+--+-------+-------+

# COMMENT_CLOSURE
+--+-----------+-------------+-----+
|id|ancestor_id|descendant_id|depth|
+--+-----------+-------------+-----+
|1 |1          |1            |0    |
|2 |2          |2            |0    |
|3 |1          |2            |1    |
|4 |3          |3            |0    |
|5 |1          |3            |2    |
|6 |2          |3            |1    |
+--+-----------+-------------+-----+

# POST_COMMENTS
+--+-------+----------+
|id|post_id|comment_id|
+--+-------+----------+
|1 |1      |1         |
|2 |1      |2         |
|3 |1      |3         |
+--+-------+----------+

이처럼 클로저 테이블은 댓글이 추가될 때마다, 해당 댓글과 모든 조상 댓글 간의 연결 관계를 자동으로 생성합니다. depth 값은 각 댓글이 계층 구조 내에서 어떤 깊이에 속하는지를 명확하게 보여주며, 이를 기반으로 계층적 정렬, 트리 렌더링, 경로 기반 탐색 등의 기능을 효율적으로 구현할 수 있습니다.

2-4. 최상위 댓글 리스트 조회 API 구현

이제 댓글 작성 기능에 이어, 특정 게시글에 달린 최상위 댓글 목록을 조회하는 API를 구현해보겠습니다. 이 API는 루트 댓글(즉, parentId가 없는 댓글)만 반환하며, 각 댓글에 하위 댓글이 존재하는지 여부도 함께 포함됩니다.

하위 댓글은 한 번에 모두 가져오는 것이 아니라, ‘더보기’와 같은 사용자 인터페이스를 통해 점진적으로 로드되도록 설계했습니다. 이는 깊은 트리 구조를 가진 댓글 시스템에서 초기 응답 크기를 줄이고, UI 렌더링 성능을 개선하기 위한 전략입니다. 또한, 댓글 하나하나에 대해 독립적으로 하위 댓글을 조회할 수 있게 함으로써, 프론트엔드와 백엔드의 책임 분리에도 도움이 됩니다.

먼저, 댓글 리스트 조회 요청을 처리할 컨트롤러를 작성합니다. 클라이언트로부터 전달받은 postId를 기준으로, 해당 게시글에 달린 최상위 댓글을 반환합니다.

PostCommentController.java
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/posts/{postId}/comments")
public class PostCommentController {

    @GetMapping
    public ResponseEntity<?> postsCommentsApi(
            @PathVariable Long postId) {
        PostCommentsRequest request = PostCommentsRequest.from(postId);
        PostCommentsResponse response = service.retrievePostComments(request.toPostId());
        return ResponseEntity
                .ok(response);
    }
}

컨트롤러로부터 요청을 위임받은 서비스 계층에서는, 다음과 같은 순서로 비즈니스 로직을 수행합니다:

  1. 해당 포스트에 속한 최상위 댓글 목록을 조회합니다.
  2. 각 댓글에 하위 댓글이 존재하는지 여부를 확인합니다.
  3. 게시글 전체에 달린 총 댓글 수를 계산합니다.
  4. 위의 데이터를 API 응답 객체로 변환하여 반환합니다.
PostCommentServiceImpl.java
@Slf4j
@Service
@RequiredArgsConstructor
public class PostCommentServiceImpl implements PostCommentService {

    @Override
    @Transactional(readOnly = true)
    public PostCommentsResponse retrievePostComments(PostId postId) {
        List<PostCommentItem> comments = postCommentRepository.fetchBy(from(postId));
        List<Long> commentIds = transform(comments, PostCommentItem::getCommentId);
        Map<Long, Boolean> statuses = commentService.hasNestedComments(commentIds);
        long totalCount = postCommentRepository.countTotal(postId);

        return PostCommentsResponse.from(comments, statuses, totalCount);
    }

}

최상위 댓글만을 추출하기 위해서는, COMMENT_CLOSURE 테이블과의 조인을 통해 자신보다 상위 댓글이 없는 댓글을 필터링합니다. 아래는 이를 SQL로 표현한 예시입니다:

MySQL
select
    *
from
    COMMENTS comments
    inner join POST_COMMENTS post_comments
            on comments.id = post_comments.comment_id
    left join COMMENT_CLOSURE closures
            on comments.id = closures.descendant_id
            and closures.depth > 0
where
      post_comments.post_id = :postId
  and closures.id is null

이 쿼리는 클로저 테이블에서 depth > 0인 연결을 조건으로 필터링한 뒤, 해당 조건에 부합하는 레코드가 없는 경우만을 최상위 댓글로 간주합니다.

console
+--+-------+----------------+-------+----------+----+-----------+-------------+-----+----------+----------+
|id|user_id|content         |post_id|comment_id|id  |ancestor_id|descendant_id|depth|updated_at|created_at|
+--+-------+----------------+-------+----------+----+-----------+-------------+-----+----------+----------+
|1 |1      |A               |1      |1         |null|null       |null         |null |null      |null      |
+--+-------+----------------+-------+----------+----+-----------+-------------+-----+----------+----------+

이 결과를 기반으로 Querydsl을 사용해 필요한 쿼리를 작성합니다.

PostCommentJpaRepositoryImpl.java
@Slf4j
@RequiredArgsConstructor
public class PostCommentJpaRepositoryImpl implements PostCommentJpaRepositoryExtension {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<PostCommentItemResult> fetchBy(PostCommentPageableCond cond) {
        return queryFactory
                .select(Projections.constructor(
                        PostCommentItemResult.class,
                        postEntity.id.as("postId"),
                        userEntity.id.as("userId"),
                        userEntity.username.as("username"),
                        commentEntity.id.as("commentId"),
                        commentEntity.content.as("content"),
                        commentEntity.createdAt.as("createdAt"),
                        commentEntity.updatedAt.as("updatedAt")
                ))
                .from(postCommentEntity)
                .innerJoin(postCommentEntity.post, postEntity)
                .innerJoin(postCommentEntity.comment, commentEntity)
                .innerJoin(commentEntity.user, userEntity)
                .leftJoin(commentClosureEntity)
                .on(commentClosureEntity.descendant.eq(commentEntity).and(commentClosureEntity.depth.gt(0)))
                .where(
                        postEntity.id.eq(cond.getPostId()),
                        commentClosureEntity.id.isNull(),
                        postEntity.isDeleted.isFalse(),
                        commentEntity.isDeleted.isFalse()
                )
                .fetch();
    }
}

이제 조회된 최상위 댓글 각각에 대해 하위 댓글이 존재하는지를 확인하는 로직을 작성합니다. 댓글이 클로저 테이블에서 같은 조상 ID로 두 번 이상 나타나면, 하위 댓글이 있다는 의미입니다.

CommentClosureJpaRepositoryImpl.java
@Slf4j
@RequiredArgsConstructor
public class CommentClosureJpaRepositoryImpl implements CommentClosureJpaRepositoryExtension {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<NestedCommentStatResult> fetchNestedCommentStats(List<Long> commentIds) {
        QCommentEntity ancestor = new QCommentEntity("ancestor");
        QCommentEntity descendant = new QCommentEntity("descendant");
        CaseBuilder caseBuilder = new CaseBuilder();

        return queryFactory
                .select(Projections.constructor(
                        NestedCommentStatResult.class,
                        ancestor.id.as("id"),
                        caseBuilder
                                .when(ancestor.id.count().gt(1)).then(true)
                                .otherwise(false)
                                .as("nestedExists")
                ))
                .from(commentClosureEntity)
                .innerJoin(commentClosureEntity.ancestor, ancestor)
                .innerJoin(commentClosureEntity.descendant, descendant)
                .where(
                        ancestor.id.in(commentIds),
                        descendant.isDeleted.isFalse()
                )
                .groupBy(ancestor.id)
                .fetch();
    }

이 결과는 Map<Long, Boolean> 형식으로 변환하여, 각 댓글 ID에 대해 하위 댓글 존재 여부를 빠르게 참조할 수 있도록 구성합니다.

CommentServiceImpl.java
@Override
public Map<Long, Boolean> hasNestedComments(List<Long> commentIds) {
    List<CommentStat> stats = commentClosureRepository.retrieveCommentStatsBy(commentIds);
    return stats.stream()
            .collect(Collectors.toMap(CommentStat::getId, CommentStat::isNestedExists));
}

이어서, 해당 게시글에 등록된 전체 댓글 수를 집계합니다.

PostCommentJpaRepositoryImpl.java
@Override
public long countTotal(PostId postId) {
    return queryFactory
            .select(postCommentEntity.id.count())
            .from(postCommentEntity)
            .innerJoin(postCommentEntity.post, postEntity)
            .innerJoin(postCommentEntity.comment, commentEntity)
            .where(
                    postEntity.id.eq(postId.getId()),
                    postEntity.isDeleted.isFalse(),
                    commentEntity.isDeleted.isFalse(),
                    postCommentEntity.isDeleted.isFalse()
            )
            .fetchFirst();
}

마지막으로, 이렇게 수집한 데이터를 하나의 응답 객체로 조합합니다. 응답 구조는 최상위 댓글 리스트와 전체 댓글 수로 구성되며, 각 댓글에는 작성자 정보와 하위 댓글 존재 여부가 포함됩니다.

PostCommentsResponse.java
@Slf4j
@Getter
@Builder
public class PostCommentsResponse {

    private final List<Comment> data;
    private final long totalCount;

    public static PostCommentsResponse from(
            List<PostCommentItem> comments,
            Map<Long, Boolean> statuses,
            long totalCount) {
        List<Comment> data = comments.stream()
                .map(element -> Comment.from(element, statuses.get(element.getCommentId())))
                .toList();

        return PostCommentsResponse.builder()
                .data(data)
                .totalCount(totalCount)
                .build();
    }

    @Getter
    @Builder
    private static class Comment {

        private final Long id;
        private final Author author;
        private final String content;
        private final LocalDateTime createdAt;
        private final LocalDateTime updatedAt;
        private final boolean hasNested;

        private static Comment from(PostCommentItem comment, boolean hasNested) {
            return Comment.builder()
                    .id(comment.getCommentId())
                    .author(Author.from(comment))
                    .content(comment.getContent())
                    .createdAt(comment.getCreatedAt())
                    .updatedAt(comment.getUpdatedAt())
                    .hasNested(hasNested)
                    .build();
        }
    }

    @Getter
    @Builder
    private static class Author {

        private final Long id;
        private final String username;

        private static Author from(PostCommentItem comment) {
            return Author.builder()
                    .id(comment.getUserId())
                    .username(comment.getUsername())
                    .build();
        }
    }
}

이처럼 댓글 트리 구조를 의식한 조회 API를 구성하면, 단순한 리스트를 반환하는 것을 넘어 댓글 계층과 연관된 구조적 의미를 함께 제공할 수 있습니다. 최상위 댓글만 가져오고 하위 댓글은 별도 API로 분리함으로써, 트리 구조 렌더링의 유연성과 응답 페이로드 최적화를 동시에 달성할 수 있습니다.

2-5. 최상위 댓글 리스트 조회 API 테스트

지금까지 최상위 댓글 리스트 조회 API를 구현를 구현하였습니다. 이제 실제로 이 API가 정상적으로 동작하는지 확인해보겠습니다. HTTP 클라이언트 도구를 사용하여 요청을 전송하고, 응답 결과를 검증합니다.

아래는 테스트용 GET 요청 예시입니다:

GET /posts/1/comments HTTP/1.1
Content-Type: application/json
Host: localhost:8080
Connection: close
User-Agent: RapidAPI/4.2.0 (Macintosh; OS X/14.4.1) GCDHTTPRequest

요청이 성공적으로 처리되면, 다음과 같은 형태의 JSON 응답이 반환됩니다:

Response
{
  "data": [
    {
      "id": 1,
      "author": {
        "id": 1,
        "username": "Alice"
      },
      "content": "A",
      "createdAt": "2024-04-23T01:39:30",
      "updatedAt": "2024-04-23T01:39:30",
      "hasNested": true
    },
    {
      "id": 4,
      "author": {
        "id": 7,
        "username": "Grace"
      },
      "content": "D",
      "createdAt": "2024-04-23T01:42:10",
      "updatedAt": "2024-04-23T01:42:10",
      "hasNested": false
    },
    {
      "id": 5,
      "author": {
        "id": 5,
        "username": "Eve"
      },
      "content": "E",
      "createdAt": "2024-04-23T01:42:13",
      "updatedAt": "2024-04-23T01:42:13",
      "hasNested": false
    }
  ],
  "totalCount": 5
}

응답 필드 중 hasNested 값은 각 댓글이 하위 댓글을 가지고 있는지 여부를 나타냅니다. 이 값을 활용하여 클라이언트에서는 ‘더보기’ 버튼의 활성화 여부를 결정하거나, 하위 댓글 조회 API를 호출할지 여부를 판단할 수 있습니다.

또한 data[].id 값을 기준으로 각 댓글의 대댓글 목록을 조회하는 후속 요청을 구성할 수 있으므로, 클라이언트 측에서는 이 응답만으로도 계층형 UI 구현에 필요한 기초 정보를 충분히 확보할 수 있습니다.

3. 정리하며

지금까지 블로그 포스트에 댓글 기능을 추가하는 전체 흐름을 따라가며, 클로저 테이블 전략을 기반으로 한 계층형 댓글 시스템의 설계 및 구현 과정을 살펴보았습니다.

복잡한 계층 구조를 다루기 위해 도입한 클로저 테이블 전략은 초기 레코드 작성 과정을 다소 복잡하게 만들지만, 그만큼 조회 쿼리는 단순하고 효율적으로 구성할 수 있습니다. 특히 댓글 트리의 깊이나 구조와 무관하게 단일 쿼리로 상위 또는 하위 댓글을 빠르게 조회할 수 있다는 점은, 성능과 확장성 측면에서 큰 장점을 제공합니다.

이번 글에서는 최상위 댓글 리스트 조회 API까지만 다루었지만, 앞서 설명한 클로저 테이블 구성 원리를 그대로 응용하면 특정 댓글에 대한 하위 댓글 목록 조회 API도 어렵지 않게 구현할 수 있습니다. 클로저 테이블에서 ancestor_id = :parentId 조건으로 depth > 0인 레코드를 조회하면, 바로 해당 댓글의 모든 하위 댓글을 계층적으로 가져올 수 있습니다.

댓글 시스템은 단순한 기능처럼 보이지만, 구현 방식에 따라 유지보수성과 확장성이 크게 달라질 수 있습니다. 전반적으로, 쓰기 복잡도를 감수하고서라도 읽기 성능을 극대화하는 설계 전략은 많은 서비스에서 유효하며, 그만큼 이와 같은 구조적 접근을 이해하고 활용할 수 있는 역량이 중요해집니다.

이 글에서 소개한 코드는 핵심 개념을 중심으로 일부만 발췌된 예시이며, 전체 구현 내용은 GitHub Repository 에서 확인할 수 있습니다.