Skip to content

Commit c873a42

Browse files
committed
Feat: 댓글 목록 조회 API 구현
1 parent a89df69 commit c873a42

File tree

7 files changed

+266
-3
lines changed

7 files changed

+266
-3
lines changed

src/main/java/com/back/domain/board/controller/CommentController.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package com.back.domain.board.controller;
22

3+
import com.back.domain.board.dto.CommentListResponse;
34
import com.back.domain.board.dto.CommentRequest;
45
import com.back.domain.board.dto.CommentResponse;
6+
import com.back.domain.board.dto.PageResponse;
57
import com.back.domain.board.service.CommentService;
68
import com.back.global.common.dto.RsData;
79
import com.back.global.security.user.CustomUserDetails;
810
import jakarta.validation.Valid;
911
import lombok.RequiredArgsConstructor;
12+
import org.springframework.data.domain.Pageable;
13+
import org.springframework.data.domain.Sort;
14+
import org.springframework.data.web.PageableDefault;
1015
import org.springframework.http.HttpStatus;
1116
import org.springframework.http.ResponseEntity;
1217
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -34,6 +39,21 @@ public ResponseEntity<RsData<CommentResponse>> createComment(
3439
));
3540
}
3641

42+
// 댓글 다건 조회
43+
@GetMapping
44+
public ResponseEntity<RsData<PageResponse<CommentListResponse>>> getComments(
45+
@PathVariable Long postId,
46+
@PageableDefault(sort = "createdAt", direction = Sort.Direction.ASC) Pageable pageable
47+
) {
48+
PageResponse<CommentListResponse> response = commentService.getComments(postId, pageable);
49+
return ResponseEntity
50+
.status(HttpStatus.OK)
51+
.body(RsData.success(
52+
"댓글 목록이 조회되었습니다.",
53+
response
54+
));
55+
}
56+
3757
// 댓글 수정
3858
@PutMapping("/{commentId}")
3959
public ResponseEntity<RsData<CommentResponse>> updateComment(
@@ -62,8 +82,8 @@ public ResponseEntity<RsData<Void>> deleteComment(
6282
return ResponseEntity
6383
.status(HttpStatus.OK)
6484
.body(RsData.success(
65-
"댓글이 삭제되었습니다.",
66-
null
85+
"댓글이 삭제되었습니다.",
86+
null
6787
));
6888
}
6989
}

src/main/java/com/back/domain/board/dto/AuthorResponse.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.back.domain.board.dto;
22

33
import com.back.domain.user.entity.User;
4+
import com.querydsl.core.annotations.QueryProjection;
45

56
/**
67
* 작성자 응답 DTO
@@ -12,6 +13,9 @@ public record AuthorResponse(
1213
Long id,
1314
String nickname
1415
) {
16+
@QueryProjection
17+
public AuthorResponse {}
18+
1519
public static AuthorResponse from(User user) {
1620
return new AuthorResponse(
1721
user.getId(),
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.back.domain.board.dto;
2+
3+
import com.querydsl.core.annotations.QueryProjection;
4+
import lombok.Getter;
5+
import lombok.Setter;
6+
7+
import java.time.LocalDateTime;
8+
import java.util.List;
9+
10+
/**
11+
* 댓글 목록 응답 DTO
12+
*/
13+
@Getter
14+
public class CommentListResponse {
15+
private final Long commentId;
16+
private final Long postId;
17+
private final Long parentId;
18+
private final AuthorResponse author;
19+
private final String content;
20+
21+
@Setter
22+
private long likeCount;
23+
24+
private final LocalDateTime createdAt;
25+
private final LocalDateTime updatedAt;
26+
27+
@Setter
28+
private List<CommentListResponse> children;
29+
30+
@QueryProjection
31+
public CommentListResponse(Long commentId,
32+
Long postId,
33+
Long parentId,
34+
AuthorResponse author,
35+
String content,
36+
long likeCount,
37+
LocalDateTime createdAt,
38+
LocalDateTime updatedAt,
39+
List<CommentListResponse> children) {
40+
this.commentId = commentId;
41+
this.postId = postId;
42+
this.parentId = parentId;
43+
this.author = author;
44+
this.content = content;
45+
this.likeCount = likeCount;
46+
this.createdAt = createdAt;
47+
this.updatedAt = updatedAt;
48+
this.children = children;
49+
}
50+
}

src/main/java/com/back/domain/board/repository/CommentRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
import org.springframework.stereotype.Repository;
66

77
@Repository
8-
public interface CommentRepository extends JpaRepository<Comment, Long> {
8+
public interface CommentRepository extends JpaRepository<Comment, Long>, CommentRepositoryCustom {
99
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.back.domain.board.repository;
2+
3+
import com.back.domain.board.dto.CommentListResponse;
4+
import org.springframework.data.domain.Page;
5+
import org.springframework.data.domain.Pageable;
6+
7+
public interface CommentRepositoryCustom {
8+
Page<CommentListResponse> getCommentsByPostId(Long postId, Pageable pageable);
9+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package com.back.domain.board.repository;
2+
3+
import com.back.domain.board.dto.CommentListResponse;
4+
import com.back.domain.board.dto.QAuthorResponse;
5+
import com.back.domain.board.dto.QCommentListResponse;
6+
import com.back.domain.board.entity.Comment;
7+
import com.back.domain.board.entity.QComment;
8+
import com.back.domain.board.entity.QCommentLike;
9+
import com.back.domain.user.entity.QUser;
10+
import com.back.domain.user.entity.QUserProfile;
11+
import com.querydsl.core.types.Order;
12+
import com.querydsl.core.types.OrderSpecifier;
13+
import com.querydsl.core.types.dsl.BooleanExpression;
14+
import com.querydsl.core.types.dsl.Expressions;
15+
import com.querydsl.core.types.dsl.PathBuilder;
16+
import com.querydsl.jpa.impl.JPAQueryFactory;
17+
import lombok.RequiredArgsConstructor;
18+
import org.springframework.data.domain.Page;
19+
import org.springframework.data.domain.PageImpl;
20+
import org.springframework.data.domain.Pageable;
21+
import org.springframework.data.domain.Sort;
22+
23+
import java.util.*;
24+
import java.util.stream.Collectors;
25+
26+
@RequiredArgsConstructor
27+
public class CommentRepositoryImpl implements CommentRepositoryCustom {
28+
private final JPAQueryFactory queryFactory;
29+
30+
@Override
31+
public Page<CommentListResponse> getCommentsByPostId(Long postId, Pageable pageable) {
32+
QComment comment = QComment.comment;
33+
QCommentLike commentLike = QCommentLike.commentLike;
34+
35+
// 정렬 조건
36+
List<OrderSpecifier<?>> orders = buildOrderSpecifiers(pageable, comment, commentLike);
37+
38+
// 부모 댓글 조회
39+
List<CommentListResponse> parents = fetchComments(
40+
comment.post.id.eq(postId).and(comment.parent.isNull()),
41+
orders,
42+
pageable.getOffset(),
43+
pageable.getPageSize()
44+
);
45+
46+
if (parents.isEmpty()) {
47+
return new PageImpl<>(parents, pageable, 0);
48+
}
49+
50+
// 부모 id 수집
51+
List<Long> parentIds = parents.stream()
52+
.map(CommentListResponse::getCommentId)
53+
.toList();
54+
55+
// 자식 댓글 조회
56+
List<CommentListResponse> children = fetchComments(
57+
comment.parent.id.in(parentIds),
58+
List.of(comment.createdAt.asc()),
59+
null,
60+
null
61+
);
62+
63+
// 부모 + 자식 id 합쳐서 likeCount 한 번에 조회
64+
List<Long> allIds = new ArrayList<>(parentIds);
65+
allIds.addAll(children.stream().map(CommentListResponse::getCommentId).toList());
66+
67+
Map<Long, Long> likeCountMap = queryFactory
68+
.select(commentLike.comment.id, commentLike.count())
69+
.from(commentLike)
70+
.where(commentLike.comment.id.in(allIds))
71+
.groupBy(commentLike.comment.id)
72+
.fetch()
73+
.stream()
74+
.collect(Collectors.toMap(
75+
tuple -> tuple.get(commentLike.comment.id),
76+
tuple -> tuple.get(commentLike.count())
77+
));
78+
79+
// likeCount 세팅
80+
parents.forEach(p -> p.setLikeCount(likeCountMap.getOrDefault(p.getCommentId(), 0L)));
81+
children.forEach(c -> c.setLikeCount(likeCountMap.getOrDefault(c.getCommentId(), 0L)));
82+
83+
// parentId → children 매핑
84+
Map<Long, List<CommentListResponse>> childMap = children.stream()
85+
.collect(Collectors.groupingBy(CommentListResponse::getParentId));
86+
87+
parents.forEach(p ->
88+
p.setChildren(childMap.getOrDefault(p.getCommentId(), List.of()))
89+
);
90+
91+
// 총 개수 (부모 댓글만 카운트)
92+
Long total = queryFactory
93+
.select(comment.count())
94+
.from(comment)
95+
.where(comment.post.id.eq(postId).and(comment.parent.isNull()))
96+
.fetchOne();
97+
98+
return new PageImpl<>(parents, pageable, total != null ? total : 0L);
99+
}
100+
101+
/**
102+
* 공통 댓글 조회 메서드 (부모/자식 공통)
103+
*/
104+
private List<CommentListResponse> fetchComments(
105+
BooleanExpression condition,
106+
List<OrderSpecifier<?>> orders,
107+
Long offset,
108+
Integer limit
109+
) {
110+
QComment comment = QComment.comment;
111+
QUser user = QUser.user;
112+
QUserProfile profile = QUserProfile.userProfile;
113+
114+
var query = queryFactory
115+
.select(new QCommentListResponse(
116+
comment.id,
117+
comment.post.id,
118+
comment.parent.id,
119+
new QAuthorResponse(user.id, profile.nickname),
120+
comment.content,
121+
Expressions.constant(0L), // likeCount placeholder
122+
comment.createdAt,
123+
comment.updatedAt,
124+
Expressions.constant(Collections.emptyList())
125+
))
126+
.from(comment)
127+
.leftJoin(comment.user, user)
128+
.leftJoin(user.userProfile, profile)
129+
.where(condition)
130+
.orderBy(orders.toArray(new OrderSpecifier[0]));
131+
132+
if (offset != null && limit != null) {
133+
query.offset(offset).limit(limit);
134+
}
135+
136+
return query.fetch();
137+
}
138+
139+
/**
140+
* 정렬 조건 처리
141+
*/
142+
private List<OrderSpecifier<?>> buildOrderSpecifiers(Pageable pageable, QComment comment, QCommentLike commentLike) {
143+
PathBuilder<Comment> entityPath = new PathBuilder<>(Comment.class, comment.getMetadata());
144+
List<OrderSpecifier<?>> orders = new ArrayList<>();
145+
146+
for (Sort.Order order : pageable.getSort()) {
147+
Order direction = order.isAscending() ? Order.ASC : Order.DESC;
148+
String prop = order.getProperty();
149+
150+
switch (prop) {
151+
case "likeCount" -> orders.add(new OrderSpecifier<>(direction, commentLike.id.countDistinct()));
152+
default -> orders.add(new OrderSpecifier<>(direction,
153+
entityPath.getComparable(prop, Comparable.class)));
154+
}
155+
}
156+
return orders;
157+
}
158+
}

src/main/java/com/back/domain/board/service/CommentService.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.back.domain.board.service;
22

3+
import com.back.domain.board.dto.CommentListResponse;
34
import com.back.domain.board.dto.CommentRequest;
45
import com.back.domain.board.dto.CommentResponse;
6+
import com.back.domain.board.dto.PageResponse;
57
import com.back.domain.board.entity.Comment;
68
import com.back.domain.board.entity.Post;
79
import com.back.domain.board.repository.CommentRepository;
@@ -11,6 +13,8 @@
1113
import com.back.global.exception.CustomException;
1214
import com.back.global.exception.ErrorCode;
1315
import lombok.RequiredArgsConstructor;
16+
import org.springframework.data.domain.Page;
17+
import org.springframework.data.domain.Pageable;
1418
import org.springframework.stereotype.Service;
1519
import org.springframework.transaction.annotation.Transactional;
1620

@@ -46,6 +50,24 @@ public CommentResponse createComment(Long postId, CommentRequest request, Long u
4650
return CommentResponse.from(comment);
4751
}
4852

53+
/**
54+
* 댓글 다건 조회 서비스
55+
* 1. Post 조회
56+
* 2. 해당 Post의 댓글 전체 조회 (대댓글 포함, 페이징)
57+
* 3. PageResponse 반환
58+
*/
59+
@Transactional(readOnly = true)
60+
public PageResponse<CommentListResponse> getComments(Long postId, Pageable pageable) {
61+
// Post 조회
62+
Post post = postRepository.findById(postId)
63+
.orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND));
64+
65+
// 댓글 목록 조회
66+
Page<CommentListResponse> comments = commentRepository.getCommentsByPostId(postId, pageable);
67+
68+
return PageResponse.from(comments);
69+
}
70+
4971
/**
5072
* 댓글 수정 서비스
5173
* 1. Post 조회

0 commit comments

Comments
 (0)