게시글과 댓글, 왜 굳이 분리했을까
개요
프로젝트를 진행하면서 가장 고민이 됐던 문제 중 하나가 게시글과 댓글의 관계였다.
처음에는 게시글이 없으면 댓글도 없으니 당연히 Post 엔티티가 Comment를 리스트(OneToMany)로 들고 있는게 직관적이라고 생각했다. 하지만 프로젝트를 진행하면서 실무에 가까운 설계를 목표로 삼고, 성능·확장성·정책 변경 가능성 같은 리스크를 계속 고민하다보니 이 구조가 생각보다 위험할 수 있겠다라고 생각이 들었다.
이 글은 내가 왜 Post - Comment의 연관관계를 제거하고 Aggregate를 분리했는지에 대한 기록이다.
처음 고려한 방식(Post 내부의 @OneToMay)
가장 일반적인 형태이며, Post가 Comment의 생명주리를 모두 관리하는 구조이다.
1
2
3
4
5
6
7
8
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "post", cascade = ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
}
왜 처음에 이 방식을 선택했나?
- 직관성
- “게시글을 지우면 댓글도 지워진다”는 비즈니스 로직이
casecade = ALL한줄로 끝난다.
- “게시글을 지우면 댓글도 지워진다”는 비즈니스 로직이
- 편의성
post.getComments()로 게시글에 달린 모든 댓글을 객체 그래프 탐색으로 바로 가져올 수 있다.
내가 생각한 문제점
- 메모리 사용 리스크
만약 댓글이 10만개 달린 게시글이 있다면?
1
post.getComments().add(comment);
이 한 줄을 실행하는 순간, 기존 댓글 10만개가 로딩되고 영속성 컨텍스트에 모두 올라가 메모리 사용량이 급격히 증가한다.
특히 OneToMany 컬렉션은 fetch join을 통한 페이징이 불가능하다.
트랜잭션 경합 가능성 댓글을 추가하는 행위는 결국 동일한 Post를 여러 트랜잭션이 동시에 건드리는 구조로 이어진다.
댓글이 많아질수록 트랜잭션 충돌 가능성이 함께 커진다.정책 변경에 대한 유연성 부족 실제 서비스에서는 이런 요구사항이 생길 수 있다.
- 게시글은 삭제하지만, 댓글은 남겨두자.
- 작성자가 탈퇴해도 댓글은 유지하자.
만약 Post와 Comment가 강하게 묶여 있으면 이런 정책 변경에 대응하기가 어렵다.
내가 선택한 방법: ID 참조를 통한 Aggregate 분리
여러 선택지를 고민한 끝에, 나는 Post와 Comment를 완전히 분리된 Aggregate로 두기로 했다.
이 구조를 선택하기 전에 먼저 도메인 관점에서 아래와 같은 기준을 세웠다.
Comment는 Post의 일부가 아니라, Post와 느슨하게 연결된 독립적인 도메인이다.
이 기준을 가지고 객체 연관 관계를 모두 제거하고 데이터 베이스 수준의 ID만 남겼다.
1
2
3
4
5
6
7
8
9
@Entity
public class Comment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long postId; // Post 엔티티 대신 ID값만 저장
private String content;
private Long parentId; // 대댓글 역시 ID 참조
}
이 구조에서 Comment는 Post 객체를 알지 못하고 Post의 상태나 규칙에도 접근하지 않는다
오직 “어떤 게시글에 속한 댓글인가” 라는 식별 정보만 알고 있다.
이 설계를 선택한 이유
댓글의 생명주기를 Post에서 떼어내고 싶었다.
댓글을 보면 Post 내부 상태라고 보기에는 애매한 경우가 많았다.
게시글이 삭제돼도 댓글은 남길 수도 있고, 작성자가 탈퇴해도 댓글은 유지될 수 있다.
이런 특성을 보면 댓글은 Post에 종속된 구성 요소라기보다는 독립적인 도메인에 더 가깝다고 느꼈다.
ID로만 Post를 참조하게 한 이유도 댓글이 어떤 게시글에 속해 있는지는 알되, 그 이상으로 Post 내부로 들어가게 하고 싶지는 않았다.
댓글을 다루는 작업이 Post를 건드리지 않았으면 했다.
OneToMany 구조에서는 댓글 하나를 추가하거나 삭제하는 것도 결국 Post Aggregate를 수정하는 작업이 된다. ID 참조 방식을 통해 정말 댓글만 다루고 싶었다.
1
commentRepository.save(comment);
이 코드는 정말로 댓글만 다룬다.
Post 엔티티가 로딩되지도 않고, Post와 관련된 트랜잭션이 열리지도 않는다.
댓글을 처리하는 코드가 댓글만 신경 쓰는 구조가 된다는 점이 마음에 들었다.
“댓글을 일부만 조회하고 싶다”는 요구가 설계를 흔들었다.
설계를 고민하다 보니 이런 질문이 생겼다.
“댓글이 100개 달린 게시글에서, 최근 댓글 10개만 보여주려면 어떻게 하지?”
Post와 Comment를 하나의 Aggregate로 묶어두면 이 질문에 대한 답이 애매해진다.
Aggregate 내부 엔티티는 Root를 통해서만 접근해야 하고, 일관성 단위로 다뤄진다.
그런데 “댓글 10개만 조회한다”는 요구는 Aggregate 내부 상태를 부분적으로 잘라서 조회한다는 의미가 된다.
기술적으로는 JPQL을 사용해서 Comment만 조회할 수는 있지만, 그 순간 이미 Post Aggregate 내부를 외부에서 직접 다루고 있는 셈이다. 그래서 어떤 방법이 있을까 하다가 이런 생각을 했다.
그럼 굳이 하나의 Aggregate로 묶어둘 필요가 있나?
댓글을 부분 조회해야 한다는 요구가 있다면 Comment는 애초에 Post의 내부 구성 요소가 아니라 독립적인 Aggregate로 보는게 더 나은 설계라고 판단했다.
이 선택이 항상 정답은 아니다.
이 설계에 대해 회사 과장님께 의견을 여쭤본 적이 있다.
솔직히 어느 정도는 “잘 고민했다”는 답을 기대했는데, 돌아온 말은 내 예상과 조금 달랐다.
과장님은 이렇게 말씀하셨다.
“그건 서비스 성격마다 달라.”
댓글 수가 많지 않고, 기능도 단순한 CRUD 수준이라면 Post가 Comment를 OneToMany로 들고 가는 구조가 오히려 더 직관적이고 관리하기 쉬울 수 있다는 이야기였다.
설계에는 정답이 있는 게 아니라 항상 전제와 상황이 있고, 그 상황에 따라 최선의 선택이 달라진다는 점을 다시 한번 느꼈다. 그래서 이 글에서 이야기한 ID 참조 기반 설계도 “무조건 이렇게 해야 한다”는 결론은 아니다.
다만 이 프로젝트에서는
- 댓글 수가 계속 늘어날 가능성이 있었고
- 댓글에 독립적인 정책이 붙을 여지가 있었으며
- Aggregate 경계를 명확히 가져가고 싶었기 때문에
이 방식이 더 잘 맞는 선택이라고 판단했다.