Skip to content

Commit 54a3a3e

Browse files
committed
refact: 게시글 댓글 조회 리팩토링
1 parent 6cf3a58 commit 54a3a3e

File tree

4 files changed

+241
-103
lines changed

4 files changed

+241
-103
lines changed

src/main/java/targeter/aim/domain/post/controller/CommentController.java

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
import io.swagger.v3.oas.annotations.tags.Tag;
66
import jakarta.validation.Valid;
77
import lombok.RequiredArgsConstructor;
8+
import org.springdoc.core.annotations.ParameterObject;
9+
import org.springframework.data.domain.Pageable;
10+
import org.springframework.data.web.PageableDefault;
811
import org.springframework.http.MediaType;
912
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1013
import org.springframework.web.bind.annotation.*;
1114
import targeter.aim.domain.challenge.repository.CommentSortType;
1215
import targeter.aim.domain.post.dto.CommentDto;
16+
import targeter.aim.domain.post.dto.PostDto;
1317
import targeter.aim.domain.post.service.CommentService;
1418
import targeter.aim.system.exception.model.ErrorCode;
1519
import targeter.aim.system.exception.model.RestException;
@@ -36,27 +40,16 @@ public CommentDto.CommentCreateResponse createComment(
3640
return commentService.createComment(postId, request, userDetails);
3741
}
3842

39-
// @GetMapping
40-
// @Operation(
41-
// summary = "게시글 댓글 목록 조회",
42-
// description = "게시글 댓글 및 대댓글을 조회합니다."
43-
// )
44-
// public CommentDto.CommentListResponse getComments(
45-
// @PathVariable Long postId,
46-
// @Parameter(description = "정렬 기준", example = "LATEST")
47-
// @RequestParam(defaultValue = "LATEST") CommentSortType sort,
48-
// @Parameter(description = "정렬 방향", example = "DESC")
49-
// @RequestParam(defaultValue = "DESC") SortOrder order,
50-
// @Parameter(description = "페이지 번호(0부터 시작)", example = "0")
51-
// @RequestParam(defaultValue = "0") int page,
52-
// @Parameter(description = "페이지 크기", example = "10")
53-
// @RequestParam(defaultValue = "10") int size,
54-
// @AuthenticationPrincipal UserDetails userDetails
55-
// ) {
56-
// if (userDetails == null) {
57-
// throw new RestException(ErrorCode.AUTH_LOGIN_REQUIRED);
58-
// }
59-
//
60-
// return commentService.getComments(postId, sort, order, page, size, userDetails);
61-
// }
43+
@GetMapping
44+
@Operation(
45+
summary = "게시글 댓글 목록 조회",
46+
description = "게시글 댓글 및 대댓글을 조회합니다."
47+
)
48+
public CommentDto.CommentPageResponse getComments(
49+
@PathVariable Long postId,
50+
@PageableDefault @ParameterObject Pageable pageable,
51+
@AuthenticationPrincipal UserDetails userDetails
52+
) {
53+
return commentService.getComments(postId, pageable, userDetails);
54+
}
6255
}

src/main/java/targeter/aim/domain/post/dto/CommentDto.java

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import org.springframework.web.multipart.MultipartFile;
77
import targeter.aim.domain.file.dto.FileDto;
88
import targeter.aim.domain.post.entity.Comment;
9+
import targeter.aim.domain.user.dto.TierDto;
910
import targeter.aim.domain.user.dto.UserDto;
11+
import targeter.aim.domain.user.entity.User;
1012

1113
import java.time.LocalDateTime;
1214
import java.util.List;
@@ -30,36 +32,55 @@ public static class PageInfo {
3032

3133
@Schema(description = "전체 페이지 수", example = "13")
3234
private int totalPages;
35+
}
3336

34-
public static PageInfo from(Page<?> page) {
35-
return PageInfo.builder()
36-
.page(page.getNumber())
37-
.size(page.getSize())
38-
.totalElements(page.getTotalElements())
39-
.totalPages(page.getTotalPages())
40-
.build();
37+
38+
@Data
39+
@AllArgsConstructor
40+
@NoArgsConstructor
41+
@Schema(description = "게시글 댓글 목록 페이지 응답")
42+
public static class CommentPageResponse {
43+
private List<CommentResponse> content;
44+
private PageInfo page;
45+
46+
public static CommentPageResponse from(Page<CommentResponse> page) {
47+
return new CommentPageResponse(
48+
page.getContent(),
49+
new PageInfo(
50+
page.getSize(),
51+
page.getNumber(),
52+
page.getTotalElements(),
53+
page.getTotalPages()
54+
)
55+
);
4156
}
4257
}
4358

4459
@Data
45-
@AllArgsConstructor
4660
@NoArgsConstructor
61+
@AllArgsConstructor
4762
@Builder
48-
@Schema(description = "챌린지 주차별 댓글 목록 조회 응답")
49-
public static class CommentListResponse {
50-
@Schema(description = "댓글 목록(부모 댓글 + childrenComments 포함)")
51-
private List<CommentDto.CommentResponse> comments;
52-
53-
@Schema(description = "페이지 메타 정보")
54-
private PageInfo pageInfo;
55-
56-
public static CommentDto.CommentListResponse of(
57-
Page<?> page,
58-
List<CommentDto.CommentResponse> comments
59-
) {
60-
return CommentDto.CommentListResponse.builder()
61-
.pageInfo(PageInfo.from(page))
62-
.comments(comments)
63+
@Schema(description = "댓글 작성자 정보")
64+
public static class UserResponse {
65+
66+
@Schema(description = "유저 아이디", example = "1")
67+
private Long userId;
68+
69+
@Schema(description = "유저 닉네임", example = "닉네임")
70+
private String nickname;
71+
72+
@Schema(description = "티어명", example = "BRONZE")
73+
private TierDto.TierResponse tier;
74+
75+
@Schema(description = "프로필 이미지")
76+
private FileDto.FileResponse profileImage;
77+
78+
public static UserResponse from(User user) {
79+
return UserResponse.builder()
80+
.userId(user.getId())
81+
.nickname(user.getNickname())
82+
.tier(TierDto.TierResponse.from(user.getTier()))
83+
.profileImage(FileDto.FileResponse.from(user.getProfileImage()))
6384
.build();
6485
}
6586
}
@@ -68,16 +89,16 @@ public static CommentDto.CommentListResponse of(
6889
@AllArgsConstructor
6990
@NoArgsConstructor
7091
@Builder
71-
@Schema(description = "챌린지 주차별 댓글 조회 응답")
92+
@Schema(description = "게시글 댓글 조회 응답")
7293
public static class CommentResponse {
7394
@Schema(description = "댓글 아이디", example = "1")
7495
private Long commentId;
7596

7697
@Schema(description = "댓글/대댓글 깊이(1: 댓글, 2:대댓글)", example = "1")
7798
private Integer depth;
7899

79-
@Schema(description = "작성자 정보, 여기서 nickname, profileImage, tier 사용")
80-
private UserDto.UserResponse writerInfo;
100+
@Schema(description = "작성자 정보")
101+
private UserResponse writerInfo;
81102

82103
@Schema(description = "댓글 내용", example = "댓글 내용")
83104
private String content;
@@ -91,17 +112,14 @@ public static class CommentResponse {
91112
@Schema(description = "댓글 작성 날짜", example = "ISO DateTime")
92113
private LocalDateTime createdAt;
93114

94-
@Schema(description = "최종 수정 날짜", example = "ISO DateTime")
95-
private LocalDateTime updatedAt;
96-
97115
@Schema(description = "자식 댓글 목록")
98116
private List<CommentDto.CommentResponse> childrenComments;
99117

100118
public static CommentDto.CommentResponse from(Comment comment) {
101119
return CommentDto.CommentResponse.builder()
102120
.commentId(comment.getId())
103121
.depth(comment.getDepth())
104-
.writerInfo(UserDto.UserResponse.from(comment.getUser()))
122+
.writerInfo(UserResponse.from(comment.getUser()))
105123
.content(comment.getContents())
106124
.attachedImages(comment.getAttachedImages().stream()
107125
.map(FileDto.FileResponse::from)
@@ -110,7 +128,6 @@ public static CommentDto.CommentResponse from(Comment comment) {
110128
.map(FileDto.FileResponse::from)
111129
.toList())
112130
.createdAt(comment.getCreatedAt())
113-
.updatedAt(comment.getLastModifiedAt())
114131
.childrenComments(List.of())
115132
.build();
116133
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package targeter.aim.domain.post.repository;
2+
3+
import com.querydsl.jpa.impl.JPAQueryFactory;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.PageImpl;
7+
import org.springframework.data.domain.Pageable;
8+
import org.springframework.stereotype.Repository;
9+
import targeter.aim.domain.file.dto.FileDto;
10+
import targeter.aim.domain.post.dto.CommentDto;
11+
import targeter.aim.domain.post.entity.Comment;
12+
13+
import java.util.Collections;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.stream.Collectors;
17+
import java.util.stream.Stream;
18+
19+
import static targeter.aim.domain.file.entity.QCommentAttachedFile.commentAttachedFile;
20+
import static targeter.aim.domain.file.entity.QCommentImage.commentImage;
21+
import static targeter.aim.domain.file.entity.QProfileImage.profileImage;
22+
import static targeter.aim.domain.post.entity.QComment.comment;
23+
import static targeter.aim.domain.user.entity.QTier.tier;
24+
import static targeter.aim.domain.user.entity.QUser.user;
25+
26+
@Repository
27+
@RequiredArgsConstructor
28+
public class CommentQueryRepository {
29+
30+
private final JPAQueryFactory queryFactory;
31+
32+
public Page<CommentDto.CommentResponse> paginateByPostId(
33+
Long postId,
34+
Pageable pageable) {
35+
36+
List<Comment> parentComments = queryFactory
37+
.selectFrom(comment)
38+
.leftJoin(comment.user, user).fetchJoin()
39+
.leftJoin(user.tier, tier).fetchJoin()
40+
.leftJoin(user.profileImage, profileImage).fetchJoin()
41+
.where(
42+
comment.post.id.eq(postId),
43+
comment.parent.isNull()
44+
)
45+
.orderBy(comment.createdAt.asc())
46+
.offset(pageable.getOffset())
47+
.limit(pageable.getPageSize())
48+
.fetch();
49+
50+
Long total = queryFactory
51+
.select(comment.count())
52+
.from(comment)
53+
.where(
54+
comment.post.id.eq(postId),
55+
comment.parent.isNull()
56+
)
57+
.fetchOne();
58+
59+
if (parentComments.isEmpty()) {
60+
return new PageImpl<>(Collections.emptyList(), pageable, total != null ? total : 0);
61+
}
62+
63+
List<Long> parentIds = parentComments.stream()
64+
.map(Comment::getId)
65+
.toList();
66+
67+
List<Comment> childComments = queryFactory
68+
.selectFrom(comment)
69+
.leftJoin(comment.user, user).fetchJoin()
70+
.leftJoin(user.tier, tier).fetchJoin()
71+
.leftJoin(user.profileImage, profileImage).fetchJoin()
72+
.where(comment.parent.id.in(parentIds))
73+
.orderBy(comment.createdAt.asc())
74+
.fetch();
75+
76+
List<Long> allCommentIds = Stream.concat(parentComments.stream(), childComments.stream())
77+
.map(Comment::getId)
78+
.distinct()
79+
.toList();
80+
81+
Map<Long, List<FileDto.FileResponse>> imageMap = fetchImages(allCommentIds);
82+
Map<Long, List<FileDto.FileResponse>> fileMap = fetchFiles(allCommentIds);
83+
84+
List<CommentDto.CommentResponse> content = assembleCommentHierarchy(parentComments, childComments, imageMap, fileMap);
85+
86+
return new PageImpl<>(content, pageable, total != null ? total : 0);
87+
}
88+
89+
private List<CommentDto.CommentResponse> assembleCommentHierarchy(
90+
List<Comment> parents,
91+
List<Comment> children,
92+
Map<Long, List<FileDto.FileResponse>> imageMap,
93+
Map<Long, List<FileDto.FileResponse>> fileMap
94+
) {
95+
Map<Long, List<Comment>> childrenMap = children.stream()
96+
.collect(Collectors.groupingBy(c -> c.getParent().getId()));
97+
98+
return parents.stream()
99+
.map(parent -> {
100+
CommentDto.CommentResponse parentDto = mapToDto(parent, imageMap, fileMap);
101+
102+
List<Comment> myChildren = childrenMap.getOrDefault(parent.getId(), Collections.emptyList());
103+
104+
List<CommentDto.CommentResponse> childDtos = myChildren.stream()
105+
.map(child -> mapToDto(child, imageMap, fileMap))
106+
.toList();
107+
108+
parentDto.setChildrenComments(childDtos);
109+
110+
return parentDto;
111+
})
112+
.toList();
113+
}
114+
115+
private CommentDto.CommentResponse mapToDto(
116+
Comment c,
117+
Map<Long, List<FileDto.FileResponse>> imageMap,
118+
Map<Long, List<FileDto.FileResponse>> fileMap
119+
) {
120+
// 기존 엔티티 내의 컬렉션을 쓰지 않고, 배치 조회한 Map에서 가져옵니다 (N+1 방지)
121+
List<FileDto.FileResponse> images = imageMap.getOrDefault(c.getId(), Collections.emptyList());
122+
List<FileDto.FileResponse> files = fileMap.getOrDefault(c.getId(), Collections.emptyList());
123+
124+
// 빌더 패턴을 사용하여 DTO 생성 (기존 from 메서드 로직 참고하여 재구성)
125+
return CommentDto.CommentResponse.builder()
126+
.commentId(c.getId())
127+
.depth(c.getDepth())
128+
.writerInfo(CommentDto.UserResponse.from(c.getUser()))
129+
.content(c.getContents())
130+
.attachedImages(images)
131+
.attachedFiles(files)
132+
.createdAt(c.getCreatedAt())
133+
.childrenComments(Collections.emptyList()) // 초기화
134+
.build();
135+
}
136+
137+
private Map<Long, List<FileDto.FileResponse>> fetchImages(List<Long> commentIds) {
138+
return queryFactory
139+
.selectFrom(commentImage)
140+
.where(commentImage.comment.id.in(commentIds))
141+
.fetch()
142+
.stream()
143+
.collect(Collectors.groupingBy(
144+
img -> img.getComment().getId(),
145+
Collectors.mapping(FileDto.FileResponse::from, Collectors.toList())
146+
));
147+
}
148+
149+
private Map<Long, List<FileDto.FileResponse>> fetchFiles(List<Long> commentIds) {
150+
return queryFactory
151+
.selectFrom(commentAttachedFile)
152+
.where(commentAttachedFile.comment.id.in(commentIds))
153+
.fetch()
154+
.stream()
155+
.collect(Collectors.groupingBy(
156+
file -> file.getComment().getId(),
157+
Collectors.mapping(FileDto.FileResponse::from, Collectors.toList())
158+
));
159+
}
160+
}

0 commit comments

Comments
 (0)