From c2474b129369be4fb06aca461d64835d87a48093 Mon Sep 17 00:00:00 2001 From: dlchdaud123 Date: Fri, 27 Feb 2026 17:30:37 +0900 Subject: [PATCH] =?UTF-8?q?[FIX]=EB=8C=93=EA=B8=80=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=AC=EB=B6=80=20=ED=91=9C=EC=8B=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/dto/response/CommentResponse.java | 31 +---- .../repository/CommentRepositoryCustom.java | 3 +- .../impl/CommentRepositoryImpl.java | 126 ++++++++++++++---- .../service/impl/CommentQueryServiceImpl.java | 13 +- .../CommentQueryControllerTest.java | 13 +- .../impl/CommentQueryServiceImplTest.java | 41 +++++- 6 files changed, 164 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/project200/undabang/comment/dto/response/CommentResponse.java b/src/main/java/com/project200/undabang/comment/dto/response/CommentResponse.java index f07a3564..6e6e5247 100644 --- a/src/main/java/com/project200/undabang/comment/dto/response/CommentResponse.java +++ b/src/main/java/com/project200/undabang/comment/dto/response/CommentResponse.java @@ -1,9 +1,6 @@ package com.project200.undabang.comment.dto.response; -import com.project200.undabang.comment.entity.Comment; - import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -15,33 +12,7 @@ public record CommentResponse( String memberThumbnailUrl, String content, Integer likesCount, + Boolean commentIsLiked, LocalDateTime createdAt, List children) { - - public static CommentResponse from(Comment comment) { - return of(comment, new ArrayList<>()); - } - - public static CommentResponse of(Comment comment, List children) { - String profileImageUrl = null; - String thumbnailUrl = null; - - if (comment.getMember().getMemberPicture() != null) { - profileImageUrl = comment.getMember().getMemberPicture().getMemberPicturesUrl(); - if (comment.getMember().getMemberPicture().getPicture() != null) { - thumbnailUrl = null; // 썸네일은 추후 개발 예정 - } - } - - return new CommentResponse( - comment.getId(), - comment.getMember().getMemberId(), - comment.getMember().getMemberNickname(), - profileImageUrl, - thumbnailUrl, - comment.getContent(), - comment.getLikesCount(), - comment.getCreatedAt(), - children); - } } diff --git a/src/main/java/com/project200/undabang/comment/repository/CommentRepositoryCustom.java b/src/main/java/com/project200/undabang/comment/repository/CommentRepositoryCustom.java index 949781a0..86bf4dee 100644 --- a/src/main/java/com/project200/undabang/comment/repository/CommentRepositoryCustom.java +++ b/src/main/java/com/project200/undabang/comment/repository/CommentRepositoryCustom.java @@ -1,10 +1,11 @@ package com.project200.undabang.comment.repository; import com.project200.undabang.comment.dto.response.CommentResponse; +import com.project200.undabang.member.entity.Member; import java.util.List; public interface CommentRepositoryCustom { - List findCommentsWithChildrenByFeedId(Long feedId); + List findCommentsWithChildrenByFeedId(Long feedId, Member currentMember); } diff --git a/src/main/java/com/project200/undabang/comment/repository/impl/CommentRepositoryImpl.java b/src/main/java/com/project200/undabang/comment/repository/impl/CommentRepositoryImpl.java index 4768cab1..1ec15ebe 100644 --- a/src/main/java/com/project200/undabang/comment/repository/impl/CommentRepositoryImpl.java +++ b/src/main/java/com/project200/undabang/comment/repository/impl/CommentRepositoryImpl.java @@ -1,17 +1,23 @@ package com.project200.undabang.comment.repository.impl; import com.project200.undabang.comment.dto.response.CommentResponse; -import com.project200.undabang.comment.entity.Comment; import com.project200.undabang.comment.entity.QComment; import com.project200.undabang.comment.repository.CommentRepositoryCustom; import com.project200.undabang.common.entity.QPicture; +import com.project200.undabang.like.entity.QCommentLike; +import com.project200.undabang.member.entity.Member; import com.project200.undabang.member.entity.QMember; import com.project200.undabang.member.entity.QMemberPicture; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -22,19 +28,31 @@ public class CommentRepositoryImpl implements CommentRepositoryCustom { private final JPAQueryFactory queryFactory; - @Override - public List findCommentsWithChildrenByFeedId(Long feedId) { - QComment comment = QComment.comment; - QMember member = QMember.member; - QMemberPicture memberPicture = QMemberPicture.memberPicture; - QPicture picture = QPicture.picture; + private final QComment comment = QComment.comment; + private final QMember member = QMember.member; + private final QMemberPicture memberPicture = QMemberPicture.memberPicture; + private final QPicture picture = QPicture.picture; + private final QCommentLike commentLike = QCommentLike.commentLike; + @Override + public List findCommentsWithChildrenByFeedId(Long feedId, Member currentMember) { // 1. 부모 댓글 조회 (parent가 null인 것만) - List parentComments = queryFactory - .selectFrom(comment) - .leftJoin(comment.member, member).fetchJoin() - .leftJoin(member.memberPicture, memberPicture).fetchJoin() - .leftJoin(memberPicture.picture, picture).fetchJoin() + List parentComments = queryFactory + .select(Projections.constructor(CommentResponse.class, + comment.id, + member.memberId, + member.memberNickname, + memberPicture.memberPicturesUrl, + Expressions.nullExpression(String.class), // 썸네일은 추후 개발 예정 + comment.content, + comment.likesCount, + isCommentLikedExpression(currentMember), + comment.createdAt, + Expressions.constant(Collections.emptyList()))) + .from(comment) + .leftJoin(comment.member, member) + .leftJoin(member.memberPicture, memberPicture) + .leftJoin(memberPicture.picture, picture) .where( comment.feed.id.eq(feedId), comment.parent.isNull(), @@ -48,32 +66,90 @@ public List findCommentsWithChildrenByFeedId(Long feedId) { // 2. 부모 댓글 ID 목록 추출 List parentIds = parentComments.stream() - .map(Comment::getId) + .map(CommentResponse::commentId) .collect(Collectors.toList()); // 3. 대댓글 조회 - List childComments = queryFactory - .selectFrom(comment) - .leftJoin(comment.member, member).fetchJoin() - .leftJoin(member.memberPicture, memberPicture).fetchJoin() - .leftJoin(memberPicture.picture, picture).fetchJoin() + List childComments = queryFactory + .select(Projections.constructor(CommentResponse.class, + comment.id, + member.memberId, + member.memberNickname, + memberPicture.memberPicturesUrl, + Expressions.nullExpression(String.class), + comment.content, + comment.likesCount, + isCommentLikedExpression(currentMember), + comment.createdAt, + Expressions.constant(Collections.emptyList()))) + .from(comment) + .leftJoin(comment.member, member) + .leftJoin(member.memberPicture, memberPicture) + .leftJoin(memberPicture.picture, picture) .where( comment.parent.id.in(parentIds), comment.deletedAt.isNull()) .orderBy(comment.createdAt.asc()) .fetch(); - // 4. 부모 ID별 대댓글 그룹핑 - Map> childrenMap = childComments.stream() - .collect(Collectors.groupingBy( - c -> c.getParent().getId(), - Collectors.mapping(CommentResponse::from, Collectors.toList()))); + // 4. 대댓글의 부모 ID를 가져오기 위한 매핑 쿼리 + Map> childrenMap = buildChildrenMap(childComments, parentIds); // 5. 부모 댓글에 대댓글 조립 return parentComments.stream() - .map(parent -> CommentResponse.of(parent, - childrenMap.getOrDefault(parent.getId(), new ArrayList<>()))) + .map(parent -> new CommentResponse( + parent.commentId(), + parent.memberId(), + parent.memberNickname(), + parent.memberProfileImageUrl(), + parent.memberThumbnailUrl(), + parent.content(), + parent.likesCount(), + parent.commentIsLiked(), + parent.createdAt(), + childrenMap.getOrDefault(parent.commentId(), new ArrayList<>()))) .collect(Collectors.toList()); } + /** + * 대댓글을 부모 댓글 ID별로 그룹핑합니다. + */ + private Map> buildChildrenMap(List childComments, + List parentIds) { + if (childComments.isEmpty()) { + return Collections.emptyMap(); + } + + // 대댓글의 부모 ID 매핑을 위해 별도 조회 + QComment c = QComment.comment; + Map childToParentMap = queryFactory + .select(c.id, c.parent.id) + .from(c) + .where(c.parent.id.in(parentIds), c.deletedAt.isNull()) + .fetch() + .stream() + .collect(Collectors.toMap( + tuple -> tuple.get(c.id), + tuple -> tuple.get(c.parent.id))); + + return childComments.stream() + .collect(Collectors.groupingBy( + child -> childToParentMap.getOrDefault(child.commentId(), -1L))); + } + + /** + * 현재 사용자가 특정 댓글을 좋아요 했는지 확인하는 조건식을 생성합니다. + */ + private BooleanExpression isCommentLikedExpression(Member currentMember) { + if (currentMember == null) { + return Expressions.FALSE; + } + + return JPAExpressions + .selectOne() + .from(commentLike) + .where(commentLike.comment.id.eq(comment.id) + .and(commentLike.member.memberId.eq(currentMember.getMemberId()))) + .exists(); + } } diff --git a/src/main/java/com/project200/undabang/comment/service/impl/CommentQueryServiceImpl.java b/src/main/java/com/project200/undabang/comment/service/impl/CommentQueryServiceImpl.java index fa5c2f2d..642638a8 100644 --- a/src/main/java/com/project200/undabang/comment/service/impl/CommentQueryServiceImpl.java +++ b/src/main/java/com/project200/undabang/comment/service/impl/CommentQueryServiceImpl.java @@ -3,14 +3,18 @@ import com.project200.undabang.comment.dto.response.CommentResponse; import com.project200.undabang.comment.repository.CommentRepository; import com.project200.undabang.comment.service.CommentQueryService; +import com.project200.undabang.common.context.UserContextHolder; import com.project200.undabang.common.web.exception.CustomException; import com.project200.undabang.common.web.exception.ErrorCode; import com.project200.undabang.feed.repository.FeedRepository; +import com.project200.undabang.member.entity.Member; +import com.project200.undabang.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -19,6 +23,7 @@ public class CommentQueryServiceImpl implements CommentQueryService { private final CommentRepository commentRepository; private final FeedRepository feedRepository; + private final MemberRepository memberRepository; @Override public List getComments(Long feedId) { @@ -27,6 +32,12 @@ public List getComments(Long feedId) { throw new CustomException(ErrorCode.FEED_NOT_FOUND); } - return commentRepository.findCommentsWithChildrenByFeedId(feedId); + // 현재 사용자 조회 (비로그인 시 null) + UUID currentUserId = UserContextHolder.getUserId(); + Member currentMember = memberRepository + .findByMemberIdAndMemberDeletedAtNull(currentUserId) + .orElse(null); + + return commentRepository.findCommentsWithChildrenByFeedId(feedId, currentMember); } } diff --git a/src/test/java/com/project200/undabang/comment/controller/CommentQueryControllerTest.java b/src/test/java/com/project200/undabang/comment/controller/CommentQueryControllerTest.java index 51b8bb47..84495b64 100644 --- a/src/test/java/com/project200/undabang/comment/controller/CommentQueryControllerTest.java +++ b/src/test/java/com/project200/undabang/comment/controller/CommentQueryControllerTest.java @@ -49,6 +49,7 @@ private List createSampleComments() { null, "대댓글 내용입니다.", 3, + false, LocalDateTime.now().minusMinutes(30), new ArrayList<>()); @@ -60,6 +61,7 @@ private List createSampleComments() { null, "부모 댓글 내용입니다.", 5, + true, LocalDateTime.now().minusHours(1), List.of(reply)); @@ -81,10 +83,11 @@ void getComments_success() throws Exception { BDDMockito.given(commentQueryService.getComments(feedId)).willReturn(comments); // when - String response = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/feeds/{feedId}/comments", feedId) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .headers(getCommonApiHeaders(testMemberId))) + String response = mockMvc + .perform(MockMvcRequestBuilders.get("/api/v1/feeds/{feedId}/comments", feedId) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .headers(getCommonApiHeaders(testMemberId))) .andExpect(status().isOk()) .andDo(document.document( requestHeaders(HEADER_ACCESS_TOKEN), @@ -98,6 +101,7 @@ void getComments_success() throws Exception { fieldWithPath("data[].memberThumbnailUrl").type(JsonFieldType.STRING).description("댓글 작성자 프로필 썸네일 URL입니다.").optional(), fieldWithPath("data[].content").type(JsonFieldType.STRING).description("댓글 내용입니다."), fieldWithPath("data[].likesCount").type(JsonFieldType.NUMBER).description("댓글 좋아요 수 입니다."), + fieldWithPath("data[].commentIsLiked").type(JsonFieldType.BOOLEAN).description("현재 사용자의 댓글 좋아요 여부입니다."), fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("댓글이 작성된 시간입니다."), fieldWithPath("data[].children").type(JsonFieldType.ARRAY).description("대댓글 목록입니다."), fieldWithPath("data[].children[].commentId").type(JsonFieldType.NUMBER).description("대댓글 고유 식별자(ID) 입니다."), @@ -107,6 +111,7 @@ void getComments_success() throws Exception { fieldWithPath("data[].children[].memberThumbnailUrl").type(JsonFieldType.STRING).description("대댓글 작성자 프로필 썸네일 URL").optional(), fieldWithPath("data[].children[].content").type(JsonFieldType.STRING).description("대댓글 내용"), fieldWithPath("data[].children[].likesCount").type(JsonFieldType.NUMBER).description("대댓글 좋아요 수"), + fieldWithPath("data[].children[].commentIsLiked").type(JsonFieldType.BOOLEAN).description("현재 사용자의 대댓글 좋아요 여부"), fieldWithPath("data[].children[].createdAt").type(JsonFieldType.STRING).description("대댓글 작성 시간"), fieldWithPath("data[].children[].children").type(JsonFieldType.ARRAY).description("대댓글의 대댓글 (현재 미지원)"))))) .andReturn().getResponse().getContentAsString(); diff --git a/src/test/java/com/project200/undabang/comment/service/impl/CommentQueryServiceImplTest.java b/src/test/java/com/project200/undabang/comment/service/impl/CommentQueryServiceImplTest.java index b78fc365..32768fb4 100644 --- a/src/test/java/com/project200/undabang/comment/service/impl/CommentQueryServiceImplTest.java +++ b/src/test/java/com/project200/undabang/comment/service/impl/CommentQueryServiceImplTest.java @@ -2,25 +2,33 @@ import com.project200.undabang.comment.dto.response.CommentResponse; import com.project200.undabang.comment.repository.CommentRepository; +import com.project200.undabang.common.context.UserContextHolder; import com.project200.undabang.common.web.exception.CustomException; import com.project200.undabang.common.web.exception.ErrorCode; import com.project200.undabang.feed.repository.FeedRepository; +import com.project200.undabang.member.entity.Member; +import com.project200.undabang.member.repository.MemberRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mockStatic; @ExtendWith(MockitoExtension.class) class CommentQueryServiceImplTest { @@ -34,6 +42,27 @@ class CommentQueryServiceImplTest { @Mock private FeedRepository feedRepository; + @Mock + private MemberRepository memberRepository; + + @Mock + private Member currentMember; + + private MockedStatic userContextHolderMock; + private UUID testUserId; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + userContextHolderMock = mockStatic(UserContextHolder.class); + userContextHolderMock.when(UserContextHolder::getUserId).thenReturn(testUserId); + } + + @AfterEach + void tearDown() { + userContextHolderMock.close(); + } + private List createSampleComments() { CommentResponse reply = new CommentResponse( 2L, @@ -43,6 +72,7 @@ private List createSampleComments() { null, "대댓글 내용입니다.", 3, + false, LocalDateTime.now().minusMinutes(30), new ArrayList<>()); @@ -54,6 +84,7 @@ private List createSampleComments() { null, "부모 댓글 내용입니다.", 5, + true, LocalDateTime.now().minusHours(1), List.of(reply)); @@ -72,7 +103,10 @@ void returnsComments_Success() { List mockComments = createSampleComments(); given(feedRepository.existsById(feedId)).willReturn(true); - given(commentRepository.findCommentsWithChildrenByFeedId(feedId)).willReturn(mockComments); + given(memberRepository.findByMemberIdAndMemberDeletedAtNull(testUserId)) + .willReturn(Optional.of(currentMember)); + given(commentRepository.findCommentsWithChildrenByFeedId(feedId, currentMember)) + .willReturn(mockComments); // when List result = commentQueryService.getComments(feedId); @@ -103,7 +137,10 @@ void returnsEmptyList_WhenNoComments() { Long feedId = 1L; given(feedRepository.existsById(feedId)).willReturn(true); - given(commentRepository.findCommentsWithChildrenByFeedId(feedId)).willReturn(new ArrayList<>()); + given(memberRepository.findByMemberIdAndMemberDeletedAtNull(testUserId)) + .willReturn(Optional.of(currentMember)); + given(commentRepository.findCommentsWithChildrenByFeedId(feedId, currentMember)) + .willReturn(new ArrayList<>()); // when List result = commentQueryService.getComments(feedId);