프로젝트 기능
이번에 트랙에 대한 사용자의 댓글을 만들면서 만들고 싶었던 무한 대댓글 로직을 만들어 보았다.
먼저 제대로 된 이야기 전에
트랙과 Comment의 관계에 대해 살펴보자
Track과 Comment는 1:다 관계로 이루어져 있다.

페이징을 고려하지 않고 만든 로직부터
내 페이징의 기준에 맞춰서 만들었던 로직의 절차
거기서 나온 문제점들에 관하여 이야기 해보자
1. 페이징을 고려하지 않고 만들었던 로직
public List<CommentResponseDTO> getComments(Long trackId) {
Track track = trackRepository.findById(trackId)
.orElseThrow(() -> new TrackNotFoundException(TRACK_NOT_FOUND));
List<CommentResponseDTO> commentResponseDTOList = new ArrayList<>();
Map<Long, CommentResponseDTO> commentResponseDTOMap = new HashMap<>();
track.getComments().forEach(comment -> {
CommentResponseDTO commentResponseDTO = CommentResponseDTO.convertCommentToDto(comment);
commentResponseDTOMap.put(comment.getCommentId(), commentResponseDTO);
if (comment.getParent() == null) {
commentResponseDTOList.add(commentResponseDTO);
} else {
commentResponseDTOMap.get(comment.getParent().getCommentId()).getChildren().add(commentResponseDTO);
}
});
return commentResponseDTOList;
}
코드 설명 (궁금한 분만 참고바란다!!)
해당 로직은 간단하다 페이징을 고려하지 않았기 때문에
Track 엔티티를 통해 전체 댓글에 대한 데이터를 가지고 와서
commentResponseDTOMap에 댓글 ID를 키(commentId)로, 해당 댓글의 DTO를 값으로 저장한다.
→ 이를 통해 부모 댓글을 빠르게 찾을 수 있는 장점이 있다.
(페이징 고려 방식에서도 한번 이 부분을 다룬다.)
댓글의 부모 유무로 최상위 댓글을 구분하면서 대댓글인 경우 부모의 키 값으로
부모 CommentResponseDTO 객체를 가져와서 children리스트에 현재 댓글을 추가하는 방식이다.
부모-자식 관계를 맵(Map)을 이용하여 적절하게 처리 했다 생각한다.
문제점
1. 댓글의 정렬 순서를 고려하지 X
2. 만약 처음에 hashmap에 저장되는 comment 객체가 대댓글이라면
부모 댓글이 아직 Map에 추가되지 않았기 때문에 큰 문제가 발생할 수 있다.
querydsl이나 Spring Data JPA를 통해서 정렬과 nullFirst()를 통해 고려해볼 필요가 있다.
2. 페이징을 고려하면서 생긴 문제점들과 최적의 트레이드 오프
무한 대댓글을 구현한 후 페이징을 하는 과정에서 많은 고민과 이슈들이 있었다
내가 댓글을 페이징하는 기준은 최상위 댓글 즉 부모가 NULL 인 애들을 기준으로 10개씩
페이징하는 것이였다
그러면 밑에 자식들은?
자식들은 페이징에 영향이 가지 않도록 해야한다.
무조건 최상위 댓글을 기준으로
자식들은 모두 가져 올 수 있도록
현재 요구사항에 나와 있는 대로 최적의 방법을 택해보자!!!
KEEP
요구사항 변경 불가능을 전제하에 작성한다.
ex) 페이징 방법을 변경한다던가..(더보기 방식)
ex) 대댓글을 포함해서 10개를 가져온다던가 방식
ex) 페이징 처리 X
내가 구현 했던 여러 방식을 소개하고 단계별로 있었던 문제들과 개선했던 방식에 대해 소개 해보겠다!!
1. 최상위 댓글을 가져온 후 자식 댓글들을 재귀적으로 계층화
페이징을 고려하면서 생각 했던 첫번째 방식은 최상위 댓글(parent가 null)인 댓글을 먼저 가지고 와서
관련된 나머지 자식들을 재귀적으로 계층화하는 로직을 구현하였다.
@Override
public Page<Comment> findParentCommentByTrack(Pageable pageable, Track track) {
QMember qMember = QMember.member;
QComment qComment = QComment.comment;
QComment qChildComment = new QComment("childComment");
QTrack qTrack = QTrack.track;
List<Comment> content = queryFactory.selectFrom(qComment)
.join(qComment.member, qMember).fetchJoin()
.where(qComment.track.eq(track)
.and(qComment.parent.isNull()))
.orderBy(qComment.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long count = queryFactory.select(qComment.count())
.from(qComment)
.fetchOne();
return new PageImpl<>(content, pageable, count);
}
public List<CommentResponseDTO> getComments(Long trackId, int page) {
Track track = trackRepository.findById(trackId)
.orElseThrow(() -> new TrackNotFoundException(TRACK_NOT_FOUND));
Pageable pageable = PageRequest.of(page, 10);
// 부모 댓글 10개 가져오기
Page<Comment> parentComments = commentRepository.findParentCommentByTrack(pageable, track);
List<CommentResponseDTO> commentResponseDTOList = new ArrayList<>();
// 부모 댓글에 대해 처리
parentComments.forEach(parentComment -> {
CommentResponseDTO parentDto = CommentResponseDTO.convertCommentToDto(parentComment);
commentResponseDTOList.add(parentDto);
// 부모 댓글이 자식 댓글을 가지고 있다면, 자식 댓글을 재귀적으로 추가
if (!parentComment.getChildren().isEmpty()) {
addRepliesToParentComment(parentComment, parentDto);
}
});
return commentResponseDTOList;
}
private void addRepliesToParentComment(Comment parentComment, CommentResponseDTO parentDto) {
// 자식 댓글을 재귀적으로 추가
for (Comment child : parentComment.getChildren()) {
CommentResponseDTO childDto = CommentResponseDTO.convertCommentToDto(child);
parentDto.getChildren().add(childDto); // 부모 DTO에 자식 댓글 추가
if (!child.getChildren().isEmpty()) {
addRepliesToParentComment(child, childDto); // 자식 댓글이 또 자식 댓글을 가지고 있다면 재귀 호출
}
}
}
→ 1+N 문제 발생, 자식 댓글들 순서가 고려 X, 스택 오버플로우 발생 가능성
private void addRepliesToParentComment(Comment parentComment, CommentResponseDTO parentDto) {
// 자식 댓글을 정렬
//문제는 N+1 문제가 계속 발생
List<Comment> sortedChildren = parentComment.getChildren().stream()
.sorted(Comparator.comparing(Comment::getCreatedAt).reversed())
.collect(Collectors.toList());
for (Comment child : sortedChildren) {
CommentResponseDTO childDto = CommentResponseDTO.convertCommentToDto(child);
parentDto.getChildren().add(childDto); // 부모 DTO에 자식 댓글 추가
if (!child.getChildren().isEmpty()) {
addRepliesToParentComment(child, childDto); // 자식 댓글이 또 자식 댓글을 가지고 있다면 재귀 호출
}
}
}
자식 댓글들의 순서 해결
어플리케이션 레벨에서 sort() 함수를 사용하여 정렬하고 계층화하는 방식으로 해결이 되었다.
이 방식은 댓글을 부모-자식 관계에 맞게 적절히 구조화하고,
댓글 생성 시간순으로 정렬하여 사용자에게 자연스럽게 보여줄 수 있도록 했다.
여전히 1+N 문제와 스택 오버 플로우 발생 가능성은 개선 하지 못했다.
모든 댓글을 한 번에 가져오는 것이 아니라,
페이징 처리를 위해서 데이터를 가져오는데 한계가 있어
1+N 문제 어떻게 해결해야하는지 떠오르지 않았다.
특히, 댓글 구조가 무한 대댓글로 되어 있어 자식 댓글의 순차적인 로딩과 계층화가 필수적인데,
그러면?
무한 고민의 알고리즘

2. 최상위 댓글 조회와 자식 댓글 조회 분리(최상위 댓글을 제외한 자식 댓글을 한번에 조회)
이 방법은 최상위 댓글을 따로 조회하고(페이징을 위해)
부모가 null이 아닌 즉 최상위 댓글을 제외한 모든 자식 댓글을 가져오는 방법을 택하였다..
댓글 생성 로직
기존 코드에서 depth 필드 추가
private Integer depth;
Comment 생성시 depth 값을 설정하는 비즈니스 로직 추가
private Integer createDepth(Comment parent) {
return parent == null ? 0 : parent.getDepth() + 1;
}
댓글 조회 로직
@Query("SELECT c FROM Comment c " +
"JOIN FETCH c.member cm " +
"WHERE c.track = :track AND c.parent IS NOT NULL " +
"ORDER BY c.depth ASC, c.createdAt DESC")
List<Comment> findChildCommentByTrack(@Param("track") Track track);
위의 코드는 최상위 댓글 즉 부모가 null 이 아닌 모든 자식 댓글을 가지고 오는 코드이다.
여기서 depth 필드가 Comment에 추가 되었는데 그 이유는 depth 없이
생성일자 순으로 내림차순으로 가져오면 예상치 못한 버그가 발생할 수 있다.
밑에 코드에서 이야기 하겠다!!
public List<CommentResponseDTO> getComments(Long trackId, int page) {
Track track = trackRepository.findById(trackId)
.orElseThrow(() -> new TrackNotFoundException(TRACK_NOT_FOUND));
Pageable pageable = PageRequest.of(page, 10);
// 부모 댓글 10개 가져오기
Page<Comment> parentComments = commentRepository.findParentCommentByTrack(pageable, track);
List<CommentResponseDTO> commentResponseDTOList = new ArrayList<>();
HashMap<Long, CommentResponseDTO> childMap = new HashMap<>();
parentComments.forEach(parentComment -> {
CommentResponseDTO parentCommentResponseDTO = CommentResponseDTO.convertCommentToDto(parentComment);
commentResponseDTOList.add(parentCommentResponseDTO);
childMap.put(parentComment.getCommentId(), parentCommentResponseDTO);
});
List<Comment> childComments = commentRepository.findChildCommentByTrack(track);
childComments.forEach(childComment -> {
CommentResponseDTO parentDto = childMap.get(childComment.getParent().getCommentId());
if (parentDto != null) {
CommentResponseDTO childDto = CommentResponseDTO.convertCommentToDto(childComment);
parentDto.getChildren().add(childDto);
childMap.put(childComment.getCommentId(), childDto);
}
});
return commentResponseDTOList;
}
만약에 생성일자 기준으로만 가지고 왔을 때 depth가 더 깊은 자식들이 부모보다 먼저 나오기 때문에

위를 보면 자식들이 먼저 나오는 것을 알 수 있다.
map에 부모가 들어가 있지 않아서 NullPointerException 이 발생할 수 있다..
물론 어플리케이션 레벨에서 코드로 임시 저장소를 만들어서 해결할 수 있겠지만
코드의 가독성과 유지보수 면에서 좋은 선택은 아니라고 생각 했다..
만약에 depth 필드 하나를 추가하게 된다면 자식보다 부모가 무조건 먼저 나올 수 밖에 없기 때문에
간단하게 문제가 해결된다.. (depth 순 정렬 후 생성 일자 순으로 정렬)
코드 설명 (궁금한 분만)
위의 코드를 설명하자면 부모 댓글 10개를 가져온 후
commentResponseDTOList 에 추가해주고
Map에다가 넣어준다 → Map 을 사용하는 이유는 자식의 부모 키 값을 통해
부모 ResponseDTO 를 빠르게 찾아 자식 객체를 리스트에 넣어주기 위함이다.
최상위 댓글을 제외한 모든 자식 댓글을 가져온 후 위의 설명을 통해서 체이닝 방식으로
모든 부모와 자식 관계를 계층화 시킨다.
개선된 점
1. 1+N 문제를 해결
2. DB레벨 정렬 완료
3. 단 두개의 쿼리로 페이징과 계층화를 모두 해결
4. HashMap을 통해 스택오버플로우 방지
해결하지 못한 점
1. NULL 이 아닌 모든 댓글을 메모리에 적재
2. 댓글 수가 많아 질 때마다 메모리 과부하
3. 댓글마다 최상위 댓글을 알고 있다면? --> 댓글의 생성시 최상위 댓글 추가후 최상위 댓글 고유 식별자로 자식들을 조회
이 방법은 댓글 생성시 현재 댓글의 최상위 댓글이 무엇인지 필드를 추가하여 조회시
모든 댓글을 가져오는 방식에서
최상위 댓글을 기준으로 필요한 데이터만 조회할 수 있는 방법이다.
1번 2번 방식의 문제점을 다시 한번 살펴보자
1. 1+N 문제가 발생, 스택오버플로우 발생 가능성
2. 모든 자식을 메모리에 가져온 후 작업 진행 댓글 수 증가에 비례하여 메모리 과부하
댓글 생성 로직
기존의 Comment 엔티티에 rootId 필드 추가
//최상위 댓글 고유 식별자
private Long rootId;
댓글 생성시 최상위 댓글 유무 판단 후 rootId 반환 메서드 추가
//최상위 댓글이 존재하는지 안하는지 판단 후 rootId 반환
private Long getRootIdIfExists(Long rootId) {
//만약에 최상위 댓글 Id 가 null 이거나 최상위 댓글이 실제로 존재하지 않으면
if (rootId == null || !commentRepository.existsById(rootId)) {
return null;
}
return rootId;
}
댓글 조회 로직
기존의 조회 방식에서 최상위 댓글 고유 식별자만 추출
//최상위 댓글 고유 식별자 가져오는 메소드
private List<Long> getRootIds(Page<Comment> rootComments) {
List<Long> rootIds = rootComments.stream()
.map(Comment::getCommentId)
.collect(Collectors.toList());
return rootIds;
}
조회 로직은 2번방법과 거의 동일하다고 보면 된다.
다만 IN 절로 위에서 최상위 댓글 (1~10)개의 모든 자식들만 조회.
@Query("SELECT c FROM Comment c " +
"JOIN FETCH c.member cm " +
"WHERE c.track = :track AND c.rootId IN :rootIds " +
"ORDER BY c.depth ASC, c.createdAt desc")
List<Comment> findChildCommentByRootIds(@Param("track") Track track, @Param("rootIds") List<Long> rootIds);
public List<CommentResponseDTO> getComments(Long trackId, int page) {
Track track = trackRepository.findById(trackId)
.orElseThrow(() -> new TrackNotFoundException(TRACK_NOT_FOUND));
Pageable pageable = PageRequest.of(page, 10);
//최상위 댓글 10개 가져오기
Page<Comment> rootComments = commentRepository.findParentCommentByTrack(pageable, track);
List<CommentResponseDTO> commentResponseDTOList = new ArrayList<>();
HashMap<Long, CommentResponseDTO> parentMap = new HashMap<>();
rootComments.forEach(rootComment -> {
CommentResponseDTO parentCommentDTO = CommentResponseDTO.convertCommentToDto(rootComment);
commentResponseDTOList.add(parentCommentDTO);
parentMap.put(rootComment.getCommentId(), parentCommentDTO);
});
//최상위 댓글 Id 리스트 뽑아오기
List<Long> rootIds = getRootIds(rootComments);
List<Comment> childComments = commentRepository.findChildCommentByRootIds(track, rootIds);
childComments.forEach(childComment -> {
CommentResponseDTO parentDTO = parentMap.get(childComment.getParent().getCommentId());
CommentResponseDTO childDTO = CommentResponseDTO.convertCommentToDto(childComment);
parentDTO.getChildren().add(childDTO);
parentMap.put(childComment.getCommentId(), childDTO);
});
return commentResponseDTOList;
}
개선된 점
1. 1+N 문제와 함께 2개의 쿼리로 성능 개선
2. 댓글 수 증가와 함께 비례해서 모든 댓글을 메모리에 가져오는 방식에서 필요한 자식 댓글만 조회하도록 개선
'프로젝트' 카테고리의 다른 글
| 안전한 파일 업로드에 대한 처리란 무엇일까? chapter 2 (0) | 2024.11.27 |
|---|---|
| 좋아요 버튼의 숨겨진 딜레마: @Transactional과 synchronized가 함께 풀지 못한 동시성 이슈 (0) | 2024.11.26 |
| 동적 쿼리와 배치 사이즈 최적화로 1+N 문제 해결 및 성능 개선 (0) | 2024.11.23 |
| 안전한 파일 업로드에 대한 처리란 무엇일까? chapter 1 (0) | 2024.11.10 |
| 경광등 테스트 프로그램: 실전 프로젝트 경험 공유 (0) | 2024.01.30 |