diff --git a/src/main/java/com/back/domain/board/controller/CommentController.java b/src/main/java/com/back/domain/board/controller/CommentController.java index cb47bcc6..a643e294 100644 --- a/src/main/java/com/back/domain/board/controller/CommentController.java +++ b/src/main/java/com/back/domain/board/controller/CommentController.java @@ -1,12 +1,17 @@ package com.back.domain.board.controller; +import com.back.domain.board.dto.CommentListResponse; import com.back.domain.board.dto.CommentRequest; import com.back.domain.board.dto.CommentResponse; +import com.back.domain.board.dto.PageResponse; import com.back.domain.board.service.CommentService; import com.back.global.common.dto.RsData; import com.back.global.security.user.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -34,6 +39,21 @@ public ResponseEntity> createComment( )); } + // 댓글 다건 조회 + @GetMapping + public ResponseEntity>> getComments( + @PathVariable Long postId, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.ASC) Pageable pageable + ) { + PageResponse response = commentService.getComments(postId, pageable); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "댓글 목록이 조회되었습니다.", + response + )); + } + // 댓글 수정 @PutMapping("/{commentId}") public ResponseEntity> updateComment( @@ -62,8 +82,8 @@ public ResponseEntity> deleteComment( return ResponseEntity .status(HttpStatus.OK) .body(RsData.success( - "댓글이 삭제되었습니다.", - null + "댓글이 삭제되었습니다.", + null )); } } diff --git a/src/main/java/com/back/domain/board/controller/CommentControllerDocs.java b/src/main/java/com/back/domain/board/controller/CommentControllerDocs.java index abfaebb9..0b1af463 100644 --- a/src/main/java/com/back/domain/board/controller/CommentControllerDocs.java +++ b/src/main/java/com/back/domain/board/controller/CommentControllerDocs.java @@ -1,7 +1,9 @@ package com.back.domain.board.controller; +import com.back.domain.board.dto.CommentListResponse; import com.back.domain.board.dto.CommentRequest; import com.back.domain.board.dto.CommentResponse; +import com.back.domain.board.dto.PageResponse; import com.back.global.common.dto.RsData; import com.back.global.security.user.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; @@ -10,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -142,6 +145,115 @@ ResponseEntity> createComment( @AuthenticationPrincipal CustomUserDetails user ); + @Operation( + summary = "댓글 목록 조회", + description = "특정 게시글에 달린 댓글 목록을 조회합니다. " + + "부모 댓글 기준으로 페이징되며, 각 댓글의 대댓글(children) 목록이 함께 포함됩니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "댓글 목록 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "댓글 목록이 조회되었습니다.", + "data": { + "content": [ + { + "commentId": 1, + "postId": 101, + "parentId": null, + "author": { + "id": 5, + "nickname": "홍길동" + }, + "content": "부모 댓글", + "likeCount": 2, + "createdAt": "2025-09-22T11:30:00", + "updatedAt": "2025-09-22T11:30:00", + "children": [ + { + "commentId": 2, + "postId": 101, + "parentId": 1, + "author": { + "id": 5, + "nickname": "홍길동" + }, + "content": "자식 댓글", + "likeCount": 0, + "createdAt": "2025-09-22T11:35:00", + "updatedAt": "2025-09-22T11:35:00", + "children": [] + } + ] + } + ], + "pageNumber": 0, + "pageSize": 10, + "totalElements": 1, + "totalPages": 1, + "last": true + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (파라미터 오류)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity>> getComments( + @PathVariable Long postId, + Pageable pageable + ); + @Operation( summary = "댓글 수정", description = "로그인한 사용자가 자신이 작성한 댓글을 수정합니다." diff --git a/src/main/java/com/back/domain/board/dto/AuthorResponse.java b/src/main/java/com/back/domain/board/dto/AuthorResponse.java index e5e0a874..9019a0f1 100644 --- a/src/main/java/com/back/domain/board/dto/AuthorResponse.java +++ b/src/main/java/com/back/domain/board/dto/AuthorResponse.java @@ -1,6 +1,7 @@ package com.back.domain.board.dto; import com.back.domain.user.entity.User; +import com.querydsl.core.annotations.QueryProjection; /** * 작성자 응답 DTO @@ -12,6 +13,9 @@ public record AuthorResponse( Long id, String nickname ) { + @QueryProjection + public AuthorResponse {} + public static AuthorResponse from(User user) { return new AuthorResponse( user.getId(), diff --git a/src/main/java/com/back/domain/board/dto/CommentListResponse.java b/src/main/java/com/back/domain/board/dto/CommentListResponse.java new file mode 100644 index 00000000..4ee2982c --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/CommentListResponse.java @@ -0,0 +1,50 @@ +package com.back.domain.board.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 댓글 목록 응답 DTO + */ +@Getter +public class CommentListResponse { + private final Long commentId; + private final Long postId; + private final Long parentId; + private final AuthorResponse author; + private final String content; + + @Setter + private long likeCount; + + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + @Setter + private List children; + + @QueryProjection + public CommentListResponse(Long commentId, + Long postId, + Long parentId, + AuthorResponse author, + String content, + long likeCount, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List children) { + this.commentId = commentId; + this.postId = postId; + this.parentId = parentId; + this.author = author; + this.content = content; + this.likeCount = likeCount; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.children = children; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/entity/Comment.java b/src/main/java/com/back/domain/board/entity/Comment.java index 2ea6d859..d1e81993 100644 --- a/src/main/java/com/back/domain/board/entity/Comment.java +++ b/src/main/java/com/back/domain/board/entity/Comment.java @@ -41,6 +41,13 @@ public Comment(Post post, User user, String content) { this.user = user; this.content = content; } + + public Comment(Post post, User user, String content, Comment parent) { + this.post = post; + this.user = user; + this.content = content; + this.parent = parent; + } // -------------------- 비즈니스 메서드 -------------------- // 댓글 업데이트 diff --git a/src/main/java/com/back/domain/board/repository/CommentRepository.java b/src/main/java/com/back/domain/board/repository/CommentRepository.java index 1a606af0..25457552 100644 --- a/src/main/java/com/back/domain/board/repository/CommentRepository.java +++ b/src/main/java/com/back/domain/board/repository/CommentRepository.java @@ -5,5 +5,5 @@ import org.springframework.stereotype.Repository; @Repository -public interface CommentRepository extends JpaRepository { +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { } diff --git a/src/main/java/com/back/domain/board/repository/CommentRepositoryCustom.java b/src/main/java/com/back/domain/board/repository/CommentRepositoryCustom.java new file mode 100644 index 00000000..2ab1193e --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/CommentRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.dto.CommentListResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CommentRepositoryCustom { + Page getCommentsByPostId(Long postId, Pageable pageable); +} diff --git a/src/main/java/com/back/domain/board/repository/CommentRepositoryImpl.java b/src/main/java/com/back/domain/board/repository/CommentRepositoryImpl.java new file mode 100644 index 00000000..ec7f5f47 --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/CommentRepositoryImpl.java @@ -0,0 +1,158 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.dto.CommentListResponse; +import com.back.domain.board.dto.QAuthorResponse; +import com.back.domain.board.dto.QCommentListResponse; +import com.back.domain.board.entity.Comment; +import com.back.domain.board.entity.QComment; +import com.back.domain.board.entity.QCommentLike; +import com.back.domain.user.entity.QUser; +import com.back.domain.user.entity.QUserProfile; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.*; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public Page getCommentsByPostId(Long postId, Pageable pageable) { + QComment comment = QComment.comment; + QCommentLike commentLike = QCommentLike.commentLike; + + // 정렬 조건 + List> orders = buildOrderSpecifiers(pageable, comment, commentLike); + + // 부모 댓글 조회 + List parents = fetchComments( + comment.post.id.eq(postId).and(comment.parent.isNull()), + orders, + pageable.getOffset(), + pageable.getPageSize() + ); + + if (parents.isEmpty()) { + return new PageImpl<>(parents, pageable, 0); + } + + // 부모 id 수집 + List parentIds = parents.stream() + .map(CommentListResponse::getCommentId) + .toList(); + + // 자식 댓글 조회 + List children = fetchComments( + comment.parent.id.in(parentIds), + List.of(comment.createdAt.asc()), + null, + null + ); + + // 부모 + 자식 id 합쳐서 likeCount 한 번에 조회 + List allIds = new ArrayList<>(parentIds); + allIds.addAll(children.stream().map(CommentListResponse::getCommentId).toList()); + + Map likeCountMap = queryFactory + .select(commentLike.comment.id, commentLike.count()) + .from(commentLike) + .where(commentLike.comment.id.in(allIds)) + .groupBy(commentLike.comment.id) + .fetch() + .stream() + .collect(Collectors.toMap( + tuple -> tuple.get(commentLike.comment.id), + tuple -> tuple.get(commentLike.count()) + )); + + // likeCount 세팅 + parents.forEach(p -> p.setLikeCount(likeCountMap.getOrDefault(p.getCommentId(), 0L))); + children.forEach(c -> c.setLikeCount(likeCountMap.getOrDefault(c.getCommentId(), 0L))); + + // parentId → children 매핑 + Map> childMap = children.stream() + .collect(Collectors.groupingBy(CommentListResponse::getParentId)); + + parents.forEach(p -> + p.setChildren(childMap.getOrDefault(p.getCommentId(), List.of())) + ); + + // 총 개수 (부모 댓글만 카운트) + Long total = queryFactory + .select(comment.count()) + .from(comment) + .where(comment.post.id.eq(postId).and(comment.parent.isNull())) + .fetchOne(); + + return new PageImpl<>(parents, pageable, total != null ? total : 0L); + } + + /** + * 공통 댓글 조회 메서드 (부모/자식 공통) + */ + private List fetchComments( + BooleanExpression condition, + List> orders, + Long offset, + Integer limit + ) { + QComment comment = QComment.comment; + QUser user = QUser.user; + QUserProfile profile = QUserProfile.userProfile; + + var query = queryFactory + .select(new QCommentListResponse( + comment.id, + comment.post.id, + comment.parent.id, + new QAuthorResponse(user.id, profile.nickname), + comment.content, + Expressions.constant(0L), // likeCount placeholder + comment.createdAt, + comment.updatedAt, + Expressions.constant(Collections.emptyList()) + )) + .from(comment) + .leftJoin(comment.user, user) + .leftJoin(user.userProfile, profile) + .where(condition) + .orderBy(orders.toArray(new OrderSpecifier[0])); + + if (offset != null && limit != null) { + query.offset(offset).limit(limit); + } + + return query.fetch(); + } + + /** + * 정렬 조건 처리 + */ + private List> buildOrderSpecifiers(Pageable pageable, QComment comment, QCommentLike commentLike) { + PathBuilder entityPath = new PathBuilder<>(Comment.class, comment.getMetadata()); + List> orders = new ArrayList<>(); + + for (Sort.Order order : pageable.getSort()) { + Order direction = order.isAscending() ? Order.ASC : Order.DESC; + String prop = order.getProperty(); + + switch (prop) { + case "likeCount" -> orders.add(new OrderSpecifier<>(direction, commentLike.id.countDistinct())); + default -> orders.add(new OrderSpecifier<>(direction, + entityPath.getComparable(prop, Comparable.class))); + } + } + return orders; + } +} diff --git a/src/main/java/com/back/domain/board/service/CommentService.java b/src/main/java/com/back/domain/board/service/CommentService.java index 9dbc3d67..e6010a4c 100644 --- a/src/main/java/com/back/domain/board/service/CommentService.java +++ b/src/main/java/com/back/domain/board/service/CommentService.java @@ -1,7 +1,9 @@ package com.back.domain.board.service; +import com.back.domain.board.dto.CommentListResponse; import com.back.domain.board.dto.CommentRequest; import com.back.domain.board.dto.CommentResponse; +import com.back.domain.board.dto.PageResponse; import com.back.domain.board.entity.Comment; import com.back.domain.board.entity.Post; import com.back.domain.board.repository.CommentRepository; @@ -11,6 +13,8 @@ import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,6 +50,24 @@ public CommentResponse createComment(Long postId, CommentRequest request, Long u return CommentResponse.from(comment); } + /** + * 댓글 다건 조회 서비스 + * 1. Post 조회 + * 2. 해당 Post의 댓글 전체 조회 (대댓글 포함, 페이징) + * 3. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getComments(Long postId, Pageable pageable) { + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 댓글 목록 조회 + Page comments = commentRepository.getCommentsByPostId(postId, pageable); + + return PageResponse.from(comments); + } + /** * 댓글 수정 서비스 * 1. Post 조회 diff --git a/src/test/java/com/back/domain/board/controller/CommentControllerTest.java b/src/test/java/com/back/domain/board/controller/CommentControllerTest.java index 19efc375..ac0c13c3 100644 --- a/src/test/java/com/back/domain/board/controller/CommentControllerTest.java +++ b/src/test/java/com/back/domain/board/controller/CommentControllerTest.java @@ -207,6 +207,65 @@ void createComment_noToken() throws Exception { .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); } + // ====================== 댓글 조회 테스트 ====================== + + @Test + @DisplayName("댓글 목록 조회 성공 → 200 OK") + void getComments_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + // 부모 댓글 + Comment parent = new Comment(post, user, "부모 댓글", null); + commentRepository.save(parent); + + // 자식 댓글 + Comment child = new Comment(post, user, "자식 댓글", parent); + commentRepository.save(child); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/posts/{postId}/comments", post.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.items[0].content").value("부모 댓글")) + .andExpect(jsonPath("$.data.items[0].children[0].content").value("자식 댓글")); + } + + @Test + @DisplayName("댓글 목록 조회 실패 - 존재하지 않는 게시글 → 404 Not Found") + void getComments_postNotFound() throws Exception { + // given + User user = User.createUser("ghost", "ghost@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "유저", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/posts/{postId}/comments", 999L) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + // ====================== 댓글 수정 테스트 ====================== @Test @@ -214,7 +273,7 @@ void createComment_noToken() throws Exception { void updateComment_success() throws Exception { // given: 유저 + 게시글 + 댓글 User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); - user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000,1,1), 1000)); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); user.setUserStatus(UserStatus.ACTIVE); userRepository.save(user); @@ -390,6 +449,7 @@ void updateComment_noToken() throws Exception { .andExpect(jsonPath("$.code").value("AUTH_001")) .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); } + // ====================== 댓글 삭제 테스트 ====================== @Test diff --git a/src/test/java/com/back/domain/board/service/CommentServiceTest.java b/src/test/java/com/back/domain/board/service/CommentServiceTest.java index 7d15dd3c..014460b5 100644 --- a/src/test/java/com/back/domain/board/service/CommentServiceTest.java +++ b/src/test/java/com/back/domain/board/service/CommentServiceTest.java @@ -1,7 +1,9 @@ package com.back.domain.board.service; +import com.back.domain.board.dto.CommentListResponse; import com.back.domain.board.dto.CommentRequest; import com.back.domain.board.dto.CommentResponse; +import com.back.domain.board.dto.PageResponse; import com.back.domain.board.entity.Comment; import com.back.domain.board.entity.Post; import com.back.domain.board.repository.CommentRepository; @@ -16,6 +18,9 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @@ -100,6 +105,52 @@ void createComment_fail_postNotFound() { .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); } + // ====================== 댓글 조회 테스트 ====================== + + @Test + @DisplayName("댓글 목록 조회 성공 - 부모 + 자식 포함") + void getComments_success() { + // given: 유저 + 게시글 + User user = User.createUser("writer", "writer@example.com", "pwd"); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + // 부모 댓글 + Comment parent = new Comment(post, user, "부모 댓글", null); + commentRepository.save(parent); + + // 자식 댓글 + Comment child = new Comment(post, user, "자식 댓글", parent); + commentRepository.save(child); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "createdAt")); + + // when + PageResponse response = commentService.getComments(post.getId(), pageable); + + // then + assertThat(response.items()).hasSize(1); // 부모만 페이징 결과 + CommentListResponse parentRes = response.items().getFirst(); + assertThat(parentRes.getContent()).isEqualTo("부모 댓글"); + assertThat(parentRes.getChildren()).hasSize(1); + assertThat(parentRes.getChildren().getFirst().getContent()).isEqualTo("자식 댓글"); + } + + @Test + @DisplayName("댓글 목록 조회 실패 - 게시글 없음") + void getComments_fail_postNotFound() { + Pageable pageable = PageRequest.of(0, 10); + + assertThatThrownBy(() -> + commentService.getComments(999L, pageable) + ).isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + // ====================== 댓글 수정 테스트 ====================== @Test @@ -200,6 +251,7 @@ void updateComment_fail_noPermission() { ).isInstanceOf(CustomException.class) .hasMessage(ErrorCode.COMMENT_NO_PERMISSION.getMessage()); } + // ====================== 댓글 삭제 테스트 ====================== @Test