Spring 댓글 서비스 구현하기
Design a Robust Comment System with Spring
댓글 시스템은 웹사이트, 블로그, 유튜브, 소셜 네트워크 등 다양한 온라인 플랫폼에서 사용자 간 소통의 중심 역할을 합니다. 이를 위해 적절히 설계된 데이터베이스와 효율적으로 구현된 API는 필수적입니다. 이들 요소가 잘 조합될 때, 사용자 간의 상호작용을 원활하게 지원하는 댓글 시스템을 만들 수 있습니다.
이 블로그 포스트에서는 데이터베이스 설계부터 시작해, 기본적인 댓글 작성과 조회 기능을 구현하는 과정을 단계별로 살펴보겠습니다.
Designing Database Schema
댓글 시스템을 구현하기 위한 첫 번째 단계는 데이터베이스 스키마를 설계하는 것입니다.
Self-Reference Pattern
자기 참조 패턴(Self-Reference Pattern)은 트리, 그래프, 링크드 리스트 등의 데이터 구조에 주로 사용되며, 계층이나 순환 관계를 표현하는 데이터 모델에 적합합니다. 데이터베이스 테이블 설계에 적용할 경우, 동일한 테이블 내의 다른 레코드를 참조하는 구조를 생성할 수 있습니다.
만약 댓글 시스템에 이 패턴을 적용한다면, 다음과 같은 테이블 구조를 가질 수 있을 것입니다:
이 테이블에서 parent_id
필드는 부모 댓글과 자식 댓글 간의 관계를 나타냅니다. parent_id
가 null
인 레코드는 최상위 댓글이며, null
이 아닌 레코드는 부모 댓글의 id
를 참조합니다. 이런 구조를 활용하면, 대댓글(Nested-Comments)을 구현할 수 있습니다.
+----+-----------+---------------+
| id | parent_id | content |
+----+-----------+---------------+
| 1 | null | Great article!|
| 2 | 1 | Thanks! |
| 3 | 1 | I agree. |
| 4 | 3 | Me too. |
+----+-----------+---------------+
자기 참조 패턴의 장점은 구조가 단순하며 구현 측면에서 큰 어려움이 없다는 점입니다. 특히 부모와 자식의 관계를 직접적으로 모델링하는데 유용하여, 공통 코드 테이블 등에서 자주 활용되는 구조입니다.
그러나, 이 패턴은 단순하고 얕은 계층 구조에는 적합하지만, 복잡하고 다중 계층 구조를 표현하는 데에는 한계를 가질 수 있습니다. 특정 노드를 찾기 위해서는 전체 트리를 검색해야 하며, 이는 상당한 리소스를 필요로 합니다. 또한, 복잡한 관계를 SQL 쿼리로 표현하는 것이 어려울 수 있습니다. 이런 경우에는 클로저 패턴과 같은 다른 접근법을 고려해 볼 수 있습니다.
Closure Pattern
클로저 패턴(Closure Pattern)은 계층적인 데이터 구조를 효율적으로 관리하기 위한 설계 방식입니다. 일반적으로 클로저라는 개념은 함수와 그 함수가 참조하는 외부 변수들의 조합을 의미합니다. 데이터 모델링에서 클로저 패턴은 이 개념을 확장해 모든 가능한 상위와 하위의 관계를 미리 계산하여 저장하는 기법을 사용합니다.
이 패턴은 객체 지향 설계, 작업 순서 관리, 네트워크 그래프 등 여러 분야에서 활용될 수 있으며, 특히 데이터 모델링 분야에서는 트리나 그래프 같은 복잡한 계층적 구조를 효과적으로 처리하는 데 큰 도움이 됩니다. 클로저 패턴을 구현하는 데 중심적인 역할을 하는 것이 바로 클로저 테이블(Closure Table)입니다. 이 테이블은 계층 구조 내의 모든 노드 및 그들 사이의 관계를 포괄적으로 저장하며, 데이터 접근 및 관리를 간소화합니다.
댓글 시스템에 클로저 패턴을 적용한다면 다음 두 가지 테이블이 필요합니다:
댓글의 상세 데이터를 보관하는 COMMENTS
테이블과, 댓글 간의 모든 관계를 저장하는 클로저 테이블입니다. 부모 댓글과 자식 댓글 사이의 모든 연결 경로를 보관함으로써 계층적인 관계를 효율적으로 표현합니다.
클로저 패턴은 모든 상위 및 하위 관계를 효율적으로 추출할 수 있으며, 자기 참조 패턴에서 자주 발생하는 복잡한 쿼리 문제인 순환 참조와 경로 조회를 단순화하는 데 유리합니다. 그러나 이 패턴을 도입하는 데는 몇 가지 비용이 수반됩니다. 모든 관계를 기록하기 위해 필요한 쓰기 연산의 복잡성이 증가하고, 추가적인 저장 공간을 요구하기 때문입니다.
클로저 패턴의 도입 여부를 결정할 때는 이러한 비용과 장점을 면밀히 고려해야 합니다. 대규모 시스템에서는 이러한 비용을 감수하고서라도, 쿼리의 복잡성을 줄이고 계층적 조회를 간소화 할 수 있는 이점이 있어 클로저 패턴을 사용하는 경우가 많습니다. 이는 시스템의 전반적인 성능 최적화와 데이터 관리의 용이성을 향상시키는 데 기여합니다.
Blueprint
지금까지 자기 참조 패턴과 클로저 패턴에 대해 살펴보았습니다. 이 두 패턴에 대한 이해를 바탕으로, 웹 애플리케이션에서 매우 일반적인 기능인 댓글 시스템 구현에 클로저 패턴을 적용해 보도록 하겠습니다.
댓글 시스템은 사용자들 간의 의견 공유와 소통을 가능하게 하는 중요한 기능입니다. 주어진 댓글 아래에 더 많은 답글이 달릴 수 있기 때문에, 댓글 시스템은 본질적으로 계층적 구조를 형성합니다. 이와 같은 구조를 효과적으로 다루기 위해 클로저 패턴은 좋은 방법이 될 수 있습니다. 이 패턴을 이용하면, 댓글 간의 부모 ⇄ 자식 관계를 명확히 할 수 있고, 어떤 댓글이 주어진 댓글의 부모 또는 자식인지 효율적으로 조회할 수 있습니다.
API 개발 전, 댓글의 계층 구조와 클로저 테이블의 변화를 먼저 살펴보겠습니다. 댓글 A
와 그의 자식 댓글인 댓글 B
의 관계가 이미 존재하고, 이 상태에서 댓글 B
에 대한 새로운 댓글 C
가 추가될 때의 변화 과정은 다음과 같습니다.
- 클라이언트는
댓글 C
의 생성 요청을 전달합니다. 이 요청 페이로드에는댓글 B
의 ID가 부모 댓글 ID로 포함되어 있습니다. - 서버에서는
댓글 C
를COMMENTS
테이블에 추가합니다. - 그 다음, 서버에서는
COMMENT_CLOSURE
테이블에댓글 C
의 자기 관계를 나타내는 레코드를 추가합니다. 여기서ancestor_id
와descendant_id
는댓글 C
자신의 ID로 설정되고depth
는0
으로 초기화됩니다.
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| C | C | 0 |
+-------------+---------------+-------+
- 이제
댓글 B
와댓글 C
의 관계를 설정하겠습니다.COMMENT_CLOSURE
테이블의descendant_id
가댓글 B
인 모든 레코드를 조회합니다.
select * from COMMENT_CLOSURE where descendant_id = :parentId
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A | B | 1 |
| B | B | 0 |
+-------------+---------------+-------+
- 위 조회에서 반환된 각 레코드에 대해,
ancestor_id
를 유지하고descendant_id
를댓글 C
의 ID로 하는 새로운 레코드를COMMENT_CLOSURE
테이블에 추가합니다. 이때depth
는 조상 레코드의depth
에+1
을 더한 값으로 설정합니다. 따라서 추가되는 레코드는 다음과 같습니다:
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A | C | 2 |
| B | C | 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 |
+-------------+---------------+-------+
이러한 절차를 통해, 댓글 간의 모든 가능한 관계를 클로저 테이블에 저장하게 되었습니다. 이 클로저 테이블을 기반으로 특정 댓글 아래의 모든 댓글들을 아래와 같이 간결한 쿼리만으로 조회할 수 있습니다:
select
*
from
COMMENT_CLOSURE
where
ancestor_id = 'A' -- 최상위 댓글 노드 ID
order by
depth;
위 쿼리를 실행한 결과는 다음과 같은 출력을 얻을 수 있습니다:
+-------------+---------------+-------+
| ancestor_id | descendant_id | depth |
+-------------+---------------+-------+
| A | A | 0 |
| A | B | 1 |
| A | C | 2 |
+-------------+---------------+-------+
그럼 이제, 블로그 포스트에 댓글 기능 추가라는 실질적인 시나리오를 구현해 보겠습니다. 이 과정을 위해 다음과 같은 핵심 데이터만 포함하는 간결한 테이블을 생각해보았습니다:
이 구조를 바탕으로 댓글 시스템에 필요한 API를 구현해보도록 하겠습니다.
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)
);
Initializing Project
새로운 Spring Boot 프로젝트를 생성합니다. 프로젝트의 구성은 다음과 같습니다:
- Java 17
- Spring Boot 3.2.4
- Gradle 8
- MySQL 8
- Dependencies
- Spring Web
- Spring Data JPA
- Querydsl
- Validation
- Lombok
- MySQL Driver
Spring Initializr에서 지원하지 않는 Querydsl은 프로젝트 초기화가 완료된 이후에 build.gradle
에 의존성을 직접 추가합니다.
dependencies {
// Querydsl
+ 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'
}
프로젝트에서 Querydsl을 편리하게 사용하기 위해서는 JpaQueryFactory
를 Spring Bean으로 등록해야 합니다:
@Slf4j
@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
그리고 application.yml
에는 데이터베이스 연결 정보 및 다양한 환경 구성 속성을 추가합니다. 실제 쿼리를 콘솔에서 확인하기 위해 Hibernate 로깅 레벨을 trace
로 설정하였습니다.
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
설정 완료 후, 애플리케이션을 실행하여 프로젝트 초기화가 올바르게 이루어졌는지 확인해야 합니다.
Mapping Entity Classes
기본적인 프로젝트 환경 구성이 완료되었다면, 데이터베이스 테이블을 매핑하는 엔티티 클래스들을 추가합니다.
@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 {...}
애플리케이션에서 각 엔티티에 대한 데이터를 처리하려면, 해당 엔티티를 위한 JPA 리포지토리를 생성해야 합니다.
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 {...}
Developing Comments Creation API
블로그 포스트에 새로운 댓글을 추가하는 API를 구현해보겠습니다.
Handling Comment Creation Request
먼저, 새로운 댓글을 추가하기 위해 필요한 요청 데이터를 바인딩하는 객체를 추가합니다.
@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;
}
일반적으로 사용자 정보는 인증 토큰과 같은 방식으로 추출하지만, 이번 글의 주요 관심사가 아니기 때문에 요청 페이로드를 통해 직접 정보를 받도록 구성했습니다. 또한, parentId
는 옵션 항목으로 설정하여, 페이로드에 포함되지 않을 경우 해당 댓글은 최상위 댓글로 처리합니다.
이 객체를 이용해서, API 요청을 처리하는 컨트롤러를 다음과 같이 구현합니다:
@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);
}
}
컨트롤러는 기본적인 검증 루틴을 실행하고, 요청 페이로드의 데이터를 적절한 형태로 가공합니다. 그리고 나서, 비즈니스 로직을 담당하는 서비스 객체로 요청 처리를 위임합니다.
Processing Comment Creation
블로그 포스트에 새로운 댓글을 추가하는 주요 작업 흐름은 아래와 같습니다:
@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());
}
}
- 댓글을 추가할 사용자 정보를 조회합니다.
- 댓글을 추가하려는 블로그 포스트를 조회합니다.
- 새로운 댓글 레코드를 데이터베이스에 저장합니다.
- 이 댓글에 대한 모든 관계 클로저 테이블을 저장합니다.
- 블로그 포스트와 댓글 연결 레코드를 저장합니다.
- 응답 데이터를 작성하고 반환합니다.
Retrieving User Record
댓글 작성자인 사용자 정보를 Querydsl을 사용하여 조회합니다.
@Override
public Optional<UserEntity> fetchBy(UserId userId) {
return Optional.ofNullable(
queryFactory
.select(userEntity)
.from(userEntity)
.where(userEntity.id.eq(userId.getId()))
.fetchOne());
}
이 레코드는 비즈니스 로직 처리에 반드시 있어야 하므로, 만약 조회 결과가 없다면 Optional
을 사용하여 예외로 처리합니다.
@Slf4j
@Repository
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepository {
private final UserJpaRepository userJpaRepository;
@Override
@Transactional(readOnly = true)
public User fetchBy(UserId userId) {
return userJpaRepository.fetchBy(userId)
.orElseThrow(exception(userId))
.toModel();
}
private static Supplier<RuntimeException> exception(UserId userId) {
return () -> {
log.info("fetchBy: Does not found user by userId={}", userId.getId());
return new PostCommentAdditionException(
"Unable to retrieve the user for adding a comment. "
+ "Please verify that the post exists and try again.");
};
}
}
Retrieving Post Record
다음으로, 댓글의 대상인 블로그 포스트 레코드를 조회합니다.
@Override
public Optional<PostEntity> fetchBy(PostId postId) {
return Optional.ofNullable(
queryFactory
.select(postEntity)
.from(postEntity)
.innerJoin(postEntity.user, userEntity).fetchJoin()
.where(
postEntity.id.eq(postId.getId()),
postEntity.isDeleted.isFalse())
.fetchOne());
}
블로그 포스트 레코드는 댓글을 추가하는 대상이기 때문에 사용자 정보와 마찬가지로 반드시 존재해야 합니다. 따라서 이 레코드를 찾을 수 없는 경우 예외로 처리하였습니다.
@Override
public Post fetchBy(PostId postId) {
return jpaRepository.fetchBy(postId)
.orElseThrow(exception(postId))
.toModel();
}
private static Supplier<RuntimeException> exception(PostId postId) {
return () -> {
log.info("fetchBy: Does not found post by postId={}", postId.getId());
return new PostCommentAdditionException(
"Unable to retrieve the post for adding a comment. "
+ "Please verify that the post exists and try again.");
};
}
Inserting Comment
이제 댓글을 추가하는데 필요한 모든 정보를 준비하였습니다. 이 정보를 바탕으로 새로운 레코드를 생성하겠습니다. 우선, 수집한 데이터를 사용해 새로운 댓글 엔티티 객체를 만들고, 이 객체를 JpaRepository
를 통해 데이터베이스에 저장합니다.
private final CommentRepository commentRepository;
private final UserService userService;
private final ClockHolder clock;
@Transactional
public Comment createComment(CommentCreate commentCreate) {
User user = userService.retrieveUserById(commentCreate.getUserId());
Comment comment = Comment.from(user, commentCreate, clock.now());
comment = commentRepository.save(comment);
return comment;
}
Linking Closure Relations
다음으로, 댓글 시스템의 핵심 요소인 계층 구조를 클로저 테이블에 기록하는 단계입니다. 클로저 테이블은 해당 댓글과 그 댓글의 상위 댓글들에 대한 연결 관계를 저장하는 공간입니다. 따라서 첫 단계는, 자신을 조상으로 가지는 댓글의 연결 관계를 클로저 테이블에 추가하는 것입니다.
이 작업을 위해 먼저 자기 자신을 조상으로 가진 객체를 생성해야 합니다. 여기서 조상이 자기 자신이므로, 연결의 깊이를 나타내는 depth
는 0
으로 초기화합니다.
@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;
}
}
그리고 이렇게 생성한 새로운 클로저 엔티티를 COMMENT_CLOSURE
테이블에 저장합니다.
@Transactional
public Comment createComment(CommentCreate commentCreate) {
...
+ CommentClosure selfClosure = CommentClosure.initClosure(comment, clock.now());
+ selfClosure = commentClosureRepository.save(selfClosure);
return comment;
}
이후에는 해당 객체의 모든 조상 노드에 대한 관계를 추가해야 합니다. 사용자로부터 받은 요청 페이로드에 포함된 parentId
를 사용해 클로저 테이블에서 모든 조상 노드를 검색합니다.
@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
을 추가한 값으로 설정합니다.
@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;
}
}
이렇게 구성한 모든 관계들을 데이터베이스에 저장합니다.
@Transactional
public Comment createComment(CommentCreate commentCreate) {
...
linkClosures(commentCreate, selfClosure);
...
}
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
필드를 활용하여 노드 간의 경로가 몇 단계를 필요로 하는지를 파악할 수 있습니다.
Linking Comments Relations
마지막으로, 블로그 포스트와 댓글의 관계를 정의하는 POST_COMMENTS
테이블에 레코드를 추가합니다.
@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의 구현이 완료되었습니다. 이 기능은 댓글들의 계층적 관계를 적절하게 관리하고, 각 댓글의 연결 정보 및 경로 정보를 저장하는 역할을 합니다.
Testing Comments Creation API
API가 제대로 작동하는지 확인하기 위해, HTTP 클라이언트 도구를 사용해 직접 테스트를 진행하겠습니다.
먼저, 테스트를 위해 더미 유저와 블로그 포스트 데이터를 데이터베이스에 추가할 필요가 있습니다.
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);
더미 데이터를 입력한 후, 블로그 포스트에 첫 댓글을 추가하기 위해 다음과 같이 요청 페이로드를 작성하여 API를 호출해 보겠습니다:
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"}
API 요청에 성공적으로 응답을 받았다면, 이는 새 댓글이 데이터베이스에 성공적으로 추가되었다는 것을 의미합니다. 데이터베이스에 추가된 레코드를 검토하여 데이터가 예상대로 입력되었는지 확인해보겠습니다:
# 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
에 답글을 추가해보겠습니다. 요청 페이로드에서 댓글 A
의 ID를 parentId
로 지정하여 API를 호출합니다.
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
간의 관계를 확인해 보겠습니다.
# 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
와 그 부모인 댓글 A
의 관계가 데이터베이스의 COMMENT_CLOSURE
테이블에 정상적으로 기록되었습니다. 이제, 계층 구조를 한 단계 더 확장하기 위해 댓글 B
에 대한 답글을 추가해보겠습니다. 이를 위해 이번에는 parentId
필드에 댓글 B
의 ID를 입력하고 API를 호출합니다:
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
의 계층 관계를 다음과 같이 확인할 수 있습니다:
# 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 |
+--+-------+----------+
댓글 C
가 추가되면서 COMMENT_CLOSURE
테이블에 세 개의 새로운 레코드가 등록되었습니다. 이 레코드들은 댓글 C
와 그 모든 상위 댓글들과의 관계를 명확히 나타냅니다. 댓글 C
는 계층 구조의 세 번째 레벨에 위치하며, 댓글 A
와는 깊이 2의 관계, 댓글 B
와는 깊이 1의 관계에 있습니다. 클로저 테이블에 이러한 계층적 관계가 모두 저장되어 있어, 전체 구조를 빠르고 효율적으로 이해하고 탐색할 수 있습니다.
Implementing Comments Fetching API
댓글 저장 기능에 이어 이제 계층 구조에서 최상위에 위치한 댓글을 조회하는 기능을 구현할 차례입니다. 예를 들어, 댓글 생성 API 테스트 과정에서 추가된 댓글 A
가 조회 대상입니다. 이 최상위 댓글에 연결된 하위 댓글들은 별도의 대댓글 조회 API로 구현할 계획입니다.
이러한 설계는 댓글이 많아지는 경우를 고려한 것입니다. 대량의 댓글 데이터를 한 번에 불러오는 대신, 하위 댓글은 '더 보기'와 같은 버튼을 통해 사용자가 추가로 조회하고자 할 때만 데이터를 불러올 수 있도록 합니다. 이는 일반적으로 중요도가 낮은 대댓글의 정보를 사용자가 선택적으로 열람할 수 있게 하여, 사용자 경험을 향상시키고 서버 부하를 줄이는 전략입니다.
Handling Comments Fetching Request
블로그 포스트에 달린 댓글을 조회하는데 필요한 요소는 해당 포스트의 키값입니다. 댓글이 많은 서비스라면, 요청 파라미터를 추가하여 페이징 처리를 수행하는 것도 필요할 수 있지만, 지금 단계에서는 조회 로직에 중점을 두도록 하겠습니다.
이전에 생성한 PostCommentController
에 API 요청을 처리할 로직을 추가합시다:
@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);
}
}
위 코드에서는 클라이언트에서 입력한 경로 변수(Path Variables)나 쿼리 문자열(Query String)을 PostCommentRequest
객체에 바인딩하여 데이터를 처리하고 있습니다. API 개발 시 파라미터 값이 많아지면, 이들을 개별적으로 관리하는 대신 HTTP 요청 바디(Request Body)와 같이 단일 객체로 관리하는 방식을 선호합니다. 이와 같은 래퍼 클래스의 사용은 코드의 일관성을 유지하며 관리를 훨씬 수월하게 합니다. 이러한 접근 방식을 통해 복잡한 파라미터 관리를 간소화하고, API의 가독성 및 유지보수성을 크게 향상시킬 수 있습니다.
클라이언트로 부터 전달받은 데이터에 대한 기본 검증을 수행한 후, 실제 처리를 위해 서비스 객체에 이를 위임합니다.
Processing Comments Fetching
블로그 포스트의 댓글 조회 기능에 필요한 데이터로는 댓글의 내용, 작성자, 대댓글 존재 여부, 그리고 해당 포스트에 달린 댓글의 총 수 등이 있을 것입니다. 이를 수행하기 위해 비즈니스 로직에서는 아래와 같은 처리 작업을 진행합니다.
@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);
}
}
- 댓글 목록을 조회합니다.
- 각 댓글의 대댓글이 존재하는지를 확인합니다.
- 해당 포스트의 댓글 총 개수를 카운트합니다.
- 조회한 결과를 가공하여 응답 데이터를 구성하고 이를 반환합니다.
Retrieving Comments
조회해야 하는 댓글 대상은 계층구조에서 최상위에 위치한 댓글입니다. 현재의 테이블 구조에서 최상위 댓글만을 조회하기 위해서는 아래와 같은 쿼리를 작성할 수 있습니다.
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
COMMENTS
테이블을 클로저 테이블과 left join
하는 과정에서, depth > 0
이라는 조건을 추가했습니다. 최상위 계층의 댓글은 depth
가 0
이므로, 이 조건에 따라 클로저 테이블의 데이터는 모두 null
이 됩니다. 이 결과를 활용하여 where
절에 클로저 테이블의 ID가 null
인 경우를 필터링 조건으로 추가함으로써, 최상위 계층의 댓글만을 선택할 수 있습니다.
+--+-------+----------------+-------+----------+----+-----------+-------------+-----+----------+----------+
|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을 사용해 필요한 쿼리를 작성합니다. 효율적으로 필요한 데이터만을 추출하기 위해서는, 다음과 같이 결과를 바인딩할 클래스를 먼저 정의해야 합니다:
@Slf4j
@Getter
public class PostCommentItemResult {
private final Long postId;
private final Long userId;
private final String username;
private final Long commentId;
private final Long ancestorId;
private final String content;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
public PostCommentItemResult(
Long postId,
Long userId,
String username,
Long commentId,
String content,
LocalDateTime createdAt,
LocalDateTime updatedAt) {
this.postId = postId;
this.userId = userId;
this.username = username;
this.commentId = commentId;
this.ancestorId = commentId;
this.content = content;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
}
Querydsl의 Projections.constructor()
메소드를 사용하여 데이터를 바인딩하기 위해 해당 클래스에 select
문에 대응하는 생성자를 추가했습니다. 이를 통해 쿼리의 결과를 타입 세이프하게 객체에 매핑할 수 있습니다. 이러한 구성을 통해 데이터를 효과적으로 처리하고 관리할 수 있으며, 안정적인 코드 작성이 가능해집니다.
이 객체를 활용하여 Querydsl 쿼리를 작성합니다:
@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();
}
}
Retrieving Stats
다음은 상위 댓글의 ID를 사용하여 해당 댓글에 하위 댓글이 있는지 여부를 확인해 보겠습니다. 클로저 테이블에서 조상 컬럼에 최상위 댓글의 ID가 두 번 이상 나타나면, 그것은 하위 댓글이 존재한다는 것을 의미합니다. 이를 확인하는 쿼리를 작성하겠습니다:
@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();
}
NestedCommentCountResult
객체는 각 최상위 댓글의 ID와 해당 댓글에 대한 대댓글의 존재 여부를 포함합니다. 이 데이터에 효율적으로 액세스하기 위해 Java Stream API를 활용하여 Map<K, V>
타입으로 변환합니다.
@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));
}
Counting Total
이제 해당 블로그 포스트에 추가된 총 댓글 수를 계산하는 기능을 구현해 보겠습니다. 페이징 기능을 추가할 예정이라면 최상위 댓글의 수만을 따로 계산해야 할 수 있지만, 현재는 페이징 기능을 고려하지 않기 때문에 모든 댓글을 포함한 총 댓글 수를 집계하려고 합니다.
이를 위해 POST_COMMENTS
테이블에서 블로그 포스트 ID를 사용하여 삭제되지 않은 댓글들만을 필터링하여 레코드 수를 조회합니다:
@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();
}
Writing Response Payload
지금까지 조회한 결과를 모아 적절하게 가공하여 API 응답 페이로드를 작성합니다. API 응답 페이로드를 정의할 때, 저는 클래스 내에서만 사용 가능한 Private Inner Class를 사용하는 것을 선호합니다. 이 방법은 클래스 파일의 개수를 줄일 수 있을 뿐만 아니라, 모든 데이터를 한 곳에 모아 API 응답 사양을 한눈에 파악할 수 있게 해주며, 데이터를 필요한 형태로 가공하기도 편리합니다.
@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();
}
}
}
Testing Comments Fetching API
지금까지 댓글 리스트 조회 API를 구현를 구현하였습니다. 이제, 이 API가 올바르게 동작하는지 확인하기 위해서 HTTP 클라이언트 도구를 사용하여 테스트를 진행하겠습니다.
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
API 요청에 성공했다면 아래와 같이 최상위 댓글의 정보가 반환되어야 합니다:
{
"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
필드를 활용하여 ‘더 보기’ 버튼과 같은 기능을 활성화할 수 있습니다. 또한, data[].id
를 통해 이후 대댓글 목록을 조회할 수 있습니다.
All's Well That Ends Well
지금까지 블로그 포스트에 댓글 기능을 추가하는 과정을 살펴보았습니다. 복잡한 계층 구조를 위해 도입된 클로저 패턴은 레코드 추가 과정을 복잡하게 만들었지만, 조회 쿼리는 상당히 간소화된 것을 확인하였습니다. 전반적으로 다수의 서비스에서 읽기 작업은 쓰기 작업보다 훨씬 더 빈번하게 발생하기 때문에, 이러한 기법을 이해하고 적용할 수 있어야 여러 가지 요구 사항에 효과적으로 대응할 수 있을 것 같습니다. 💡
이 글에서 제시된 코드는 중요한 개념을 중점으로 하여 일부만 포함되어 있습니다. 🐙 GitHub Repository에서 해당 프로젝트의 전체 코드를 확인할 수 있습니다.
- Spring
- Architecture