From 9cbf9f091786da2d6eca3b3027312eb4842e3a5b Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sat, 17 Jan 2026 17:41:26 +0900 Subject: [PATCH 01/21] =?UTF-8?q?[feat]=20comment=20entity=20=EB=B3=B5?= =?UTF-8?q?=ED=95=A9=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 별 루트 댓글 조회 성능을 위한 (post_id, parent_id) 복합 인덱스를 comments 테이블에 추가 --- .../thip/comment/adapter/out/jpa/CommentJpaEntity.java | 7 ++++++- .../resources/db/migration/V260107__Add_index_comments.sql | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V260107__Add_index_comments.sql diff --git a/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java b/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java index c0cafe272..82ebbfcf3 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java @@ -11,7 +11,12 @@ import org.hibernate.annotations.SQLDelete; @Entity -@Table(name = "comments") +@Table( + name = "comments", + indexes = { + @Index(name = "idx_comments_post_parent", columnList = "post_id, parent_id") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor diff --git a/src/main/resources/db/migration/V260107__Add_index_comments.sql b/src/main/resources/db/migration/V260107__Add_index_comments.sql new file mode 100644 index 000000000..9b886aa49 --- /dev/null +++ b/src/main/resources/db/migration/V260107__Add_index_comments.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_comments_post_parent + ON comments (post_id, parent_id); \ No newline at end of file From 820169d00f203753267a2dea4005d0c28ba30e1a Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sat, 17 Jan 2026 17:46:33 +0900 Subject: [PATCH 02/21] =?UTF-8?q?[refactor]=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 루트 댓글 최신순 조회 시, 페이징 처리를 위한 cursor를 created_at -> PK 로 변경 - 루트 댓글 조회 시, postType에 대한 where 절 제거 (post_id 로만 검증해도 충분) --- .../out/persistence/CommentQueryPersistenceAdapter.java | 8 ++++---- .../persistence/repository/CommentQueryRepository.java | 2 +- .../repository/CommentQueryRepositoryImpl.java | 9 ++++----- .../application/port/in/dto/CommentShowAllQuery.java | 2 +- .../comment/application/port/out/CommentQueryPort.java | 2 +- .../application/service/CommentShowAllService.java | 2 +- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java index 3c10850db..400cd1415 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java @@ -22,14 +22,14 @@ public class CommentQueryPersistenceAdapter implements CommentQueryPort { private final CommentMapper commentMapper; @Override - public CursorBasedList findLatestRootCommentsWithDeleted(Long postId, String postTypeStr, Cursor cursor) { - LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + public CursorBasedList findLatestRootCommentsWithDeleted(Long postId, Cursor cursor) { + Long lastRootCommentId = cursor.isFirstRequest() ? null : cursor.getLong(0); int size = cursor.getPageSize(); - List commentQueryDtos = commentJpaRepository.findRootCommentsWithDeletedByCreatedAtDesc(postId, postTypeStr, lastCreatedAt, size); + List commentQueryDtos = commentJpaRepository.findRootCommentsWithDeletedByCreatedAtDesc(postId, lastRootCommentId, size); return CursorBasedList.of(commentQueryDtos, size, commentQueryDto -> { - Cursor nextCursor = new Cursor(List.of(commentQueryDto.createdAt().toString())); + Cursor nextCursor = new Cursor(List.of(commentQueryDto.commentId().toString())); return nextCursor.toEncodedString(); }); } diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java index f10f7af76..69a7badb8 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java @@ -9,7 +9,7 @@ public interface CommentQueryRepository { - List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, String postTypeStr, LocalDateTime lastCreatedAt, int size); + List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, Long lastRootCommentId, int size); List findAllActiveChildCommentsByCreatedAtAsc(Long rootCommentId); diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java index 193ef0795..8e18c31df 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java @@ -34,7 +34,7 @@ public class CommentQueryRepositoryImpl implements CommentQueryRepository { // 최상위 댓글 조회 (삭제된 댓글 포함, 최신순, 페이징) @Override - public List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, String postTypeStr, LocalDateTime lastCreatedAt, int size) { + public List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, Long lastRootCommentId, int size) { // 최상위 댓글(size+1) 프로젝션 생성 QCommentQueryDto proj = new QCommentQueryDto( comment.commentId, @@ -49,11 +49,10 @@ public List findRootCommentsWithDeletedByCreatedAtDesc(Long pos // WHERE 절 분리 BooleanExpression whereClause = comment.postJpaEntity.postId.eq(postId) - .and(comment.postJpaEntity.dtype.eq(postTypeStr)) // dType 필터링 추가 .and(comment.parent.isNull()) // 게시글의 최상위 댓글 조회 .and(commentCreator.status.eq(ACTIVE)) // 댓글 작성자 ACTIVE - .and(lastCreatedAt != null // 최신순 정렬 - ? comment.createdAt.lt(lastCreatedAt) + .and(lastRootCommentId != null // 최신순 정렬 + ? comment.commentId.lt(lastRootCommentId) : Expressions.TRUE ); @@ -63,7 +62,7 @@ public List findRootCommentsWithDeletedByCreatedAtDesc(Long pos .from(comment) .join(comment.userJpaEntity, commentCreator) .where(whereClause) - .orderBy(comment.createdAt.desc()) + .orderBy(comment.commentId.desc()) .limit(size + 1) // size + 1 개 조회 .fetch(); } diff --git a/src/main/java/konkuk/thip/comment/application/port/in/dto/CommentShowAllQuery.java b/src/main/java/konkuk/thip/comment/application/port/in/dto/CommentShowAllQuery.java index 41da6c4f2..de9eda121 100644 --- a/src/main/java/konkuk/thip/comment/application/port/in/dto/CommentShowAllQuery.java +++ b/src/main/java/konkuk/thip/comment/application/port/in/dto/CommentShowAllQuery.java @@ -6,7 +6,7 @@ public record CommentShowAllQuery( Long postId, Long userId, - PostType postType, + PostType postType, // PostType : 유효성 검사용 String cursorStr ) { public static CommentShowAllQuery of(Long postId, Long userId, String postTypeStr, String cursorStr) { diff --git a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java index 388d303d6..97e368a7c 100644 --- a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java +++ b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java @@ -10,7 +10,7 @@ public interface CommentQueryPort { - CursorBasedList findLatestRootCommentsWithDeleted(Long postId, String postTypeStr, Cursor cursor); + CursorBasedList findLatestRootCommentsWithDeleted(Long postId, Cursor cursor); List findAllActiveChildCommentsOldestFirst(Long rootCommentId); diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java b/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java index 69dbc40ef..73c5d4aac 100644 --- a/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java +++ b/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java @@ -33,7 +33,7 @@ public CommentForSinglePostResponse showAllCommentsOfPost(CommentShowAllQuery qu Cursor cursor = Cursor.from(query.cursorStr(), PAGE_SIZE); // 1. size 크기만큼의 루트 댓글 최신순 조회 -> 삭제된 루트 댓글 포함해서 전부 조회 - CursorBasedList commentQueryDtoCursorBasedList = commentQueryPort.findLatestRootCommentsWithDeleted(query.postId(), query.postType().getType(), cursor); + CursorBasedList commentQueryDtoCursorBasedList = commentQueryPort.findLatestRootCommentsWithDeleted(query.postId(), cursor); List rootsInOrder = commentQueryDtoCursorBasedList.contents(); // 2. 조회한 루트 댓글들의 전체 자식 댓귿들을(깊이 무관) 작성 시간순으로 조회 From f8b25ffc7099e656b06125412fb6e43a4fad13e1 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 18 Jan 2026 01:56:00 +0900 Subject: [PATCH 03/21] =?UTF-8?q?[feat]=20=EB=A3=A8=ED=8A=B8=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=EC=9D=98=20=EB=AA=A8=EB=93=A0=20=EC=9E=90=EC=8B=9D?= =?UTF-8?q?=EB=8C=93=EA=B8=80=EB=93=A4=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20API=20controller=20=EA=B5=AC=ED=98=84=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/CommentQueryController.java | 20 +++++++++++++++ .../web/response/ChildCommentsResponse.java | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/main/java/konkuk/thip/comment/adapter/in/web/response/ChildCommentsResponse.java diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java index 586d113c6..1e31b7be9 100644 --- a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java @@ -3,8 +3,11 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import konkuk.thip.comment.adapter.in.web.response.ChildCommentsResponse; import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; +import konkuk.thip.comment.application.port.in.ChildCommentsShowUseCase; import konkuk.thip.comment.application.port.in.CommentShowAllUseCase; +import konkuk.thip.comment.application.port.in.dto.ChildCommentsShowQuery; import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; @@ -20,6 +23,7 @@ public class CommentQueryController { public final CommentShowAllUseCase commentShowAllUseCase; + public final ChildCommentsShowUseCase childCommentsShowUseCase; @Operation( summary = "댓글 전체 조회", @@ -38,4 +42,20 @@ public BaseResponse showAllCommentsOfPost( CommentShowAllQuery.of(postId, userId, postType, cursor) )); } + + @Operation( + summary = "특정 댓글의 대댓글 조회", + description = "특정 루트 댓글의 모든 대댓글(자식 댓글)을 작성 시각순으로 조회합니다." + ) + @GetMapping("/comments/replies/{rootCommentId}") + public BaseResponse showChildComments( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "부모 댓글(루트 댓글)의 id값") + @PathVariable("rootCommentId") final Long rootCommentId, + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") + @RequestParam(value = "cursor", required = false) final String cursor) { + return BaseResponse.ok(childCommentsShowUseCase.showChildComments( + ChildCommentsShowQuery.of(rootCommentId, userId, cursor) + )); + } } diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/response/ChildCommentsResponse.java b/src/main/java/konkuk/thip/comment/adapter/in/web/response/ChildCommentsResponse.java new file mode 100644 index 000000000..769c2c88e --- /dev/null +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/response/ChildCommentsResponse.java @@ -0,0 +1,25 @@ +package konkuk.thip.comment.adapter.in.web.response; + +import java.util.List; + +public record ChildCommentsResponse( + List childComments, + String nextCursor, + boolean isLast +) { + public record ChildCommentDto( + Long commentId, + String parentCommentCreatorNickname, + Long creatorId, + String creatorProfileImageUrl, + String creatorNickname, + String aliasName, + String aliasColor, + String postDate, // 댓글 작성 시각 (~ 전 형식) + String content, + int likeCount, + boolean isLike, + boolean isWriter + ) {} +} + From d755e122f5490eda196550b169487f379987e011 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 18 Jan 2026 02:01:44 +0900 Subject: [PATCH 04/21] =?UTF-8?q?[feat]=20=EB=A3=A8=ED=8A=B8=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=EC=9D=98=20=EB=AA=A8=EB=93=A0=20=EC=9E=90=EC=8B=9D?= =?UTF-8?q?=EB=8C=93=EA=B8=80=EB=93=A4=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20API=20use=20case=20=EA=B5=AC=ED=98=84=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/in/ChildCommentsShowUseCase.java | 9 +++ .../port/in/dto/ChildCommentsShowQuery.java | 12 ++++ .../service/ChildCommentsShowService.java | 64 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 src/main/java/konkuk/thip/comment/application/port/in/ChildCommentsShowUseCase.java create mode 100644 src/main/java/konkuk/thip/comment/application/port/in/dto/ChildCommentsShowQuery.java create mode 100644 src/main/java/konkuk/thip/comment/application/service/ChildCommentsShowService.java diff --git a/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentsShowUseCase.java b/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentsShowUseCase.java new file mode 100644 index 000000000..c822a6204 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentsShowUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.comment.application.port.in; + +import konkuk.thip.comment.adapter.in.web.response.ChildCommentsResponse; +import konkuk.thip.comment.application.port.in.dto.ChildCommentsShowQuery; + +public interface ChildCommentsShowUseCase { + ChildCommentsResponse showChildComments(ChildCommentsShowQuery query); +} + diff --git a/src/main/java/konkuk/thip/comment/application/port/in/dto/ChildCommentsShowQuery.java b/src/main/java/konkuk/thip/comment/application/port/in/dto/ChildCommentsShowQuery.java new file mode 100644 index 000000000..425edb455 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/port/in/dto/ChildCommentsShowQuery.java @@ -0,0 +1,12 @@ +package konkuk.thip.comment.application.port.in.dto; + +public record ChildCommentsShowQuery( + Long rootCommentId, + Long userId, + String cursorStr +) { + public static ChildCommentsShowQuery of(Long rootCommentId, Long userId, String cursorStr) { + return new ChildCommentsShowQuery(rootCommentId, userId, cursorStr); + } +} + diff --git a/src/main/java/konkuk/thip/comment/application/service/ChildCommentsShowService.java b/src/main/java/konkuk/thip/comment/application/service/ChildCommentsShowService.java new file mode 100644 index 000000000..a45d40187 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/service/ChildCommentsShowService.java @@ -0,0 +1,64 @@ +package konkuk.thip.comment.application.service; + +import konkuk.thip.comment.adapter.in.web.response.ChildCommentsResponse; +import konkuk.thip.comment.application.mapper.CommentQueryMapper; +import konkuk.thip.comment.application.port.in.ChildCommentsShowUseCase; +import konkuk.thip.comment.application.port.in.dto.ChildCommentsShowQuery; +import konkuk.thip.comment.application.port.out.CommentLikeQueryPort; +import konkuk.thip.comment.application.port.out.CommentQueryPort; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ChildCommentsShowService implements ChildCommentsShowUseCase { + + private static final int PAGE_SIZE = 10; + private final CommentQueryPort commentQueryPort; + private final CommentLikeQueryPort commentLikeQueryPort; + private final CommentQueryMapper commentQueryMapper; + + @Override + @Transactional(readOnly = true) + public ChildCommentsResponse showChildComments(ChildCommentsShowQuery query) { + Cursor cursor = Cursor.from(query.cursorStr(), PAGE_SIZE); + + // 1. 특정 루트 댓글의 자식 댓글을 최신순으로 페이징 조회 + CursorBasedList childCommentsCursorBasedList = commentQueryPort.findChildComments(query.rootCommentId(), cursor); + List childComments = childCommentsCursorBasedList.contents(); + + // 2. 유저가 좋아한 댓글 조회 + Set childCommentIds = childComments.stream() + .map(CommentQueryDto::commentId) + .collect(Collectors.toUnmodifiableSet()); + Set likedCommentIds = commentLikeQueryPort.findCommentIdsLikedByUser(childCommentIds, query.userId()); + + // 3. response 매핑 + List childCommentResponses = buildChildCommentResponses( + childComments, likedCommentIds, query.userId() + ); + + return new ChildCommentsResponse( + childCommentResponses, + childCommentsCursorBasedList.nextCursor(), + childCommentsCursorBasedList.isLast() + ); + } + + private List buildChildCommentResponses( + List childComments, + Set likedCommentIds, + Long userId) { + return childComments.stream() + .map(child -> commentQueryMapper.toChildComment(child, likedCommentIds, userId)) + .toList(); + } +} From 1fe8414c30d68368b43232581127299cc715cf4d Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 18 Jan 2026 02:02:43 +0900 Subject: [PATCH 05/21] =?UTF-8?q?[feat]=20=EB=A3=A8=ED=8A=B8=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=EC=9D=98=20=EB=AA=A8=EB=93=A0=20=EC=9E=90=EC=8B=9D?= =?UTF-8?q?=EB=8C=93=EA=B8=80=EB=93=A4=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20API=20queryDsl=20=EC=BD=94=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CommentQueryPersistenceAdapter.java | 16 ++++++-- .../repository/CommentQueryRepository.java | 3 +- .../CommentQueryRepositoryImpl.java | 37 ++++++++++++++++++- .../port/out/CommentQueryPort.java | 2 + 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java index 400cd1415..ef3ab6dcb 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java @@ -1,6 +1,5 @@ package konkuk.thip.comment.adapter.out.persistence; -import konkuk.thip.comment.adapter.out.mapper.CommentMapper; import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; import konkuk.thip.comment.application.port.out.CommentQueryPort; import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; @@ -9,7 +8,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Set; @@ -19,7 +17,6 @@ public class CommentQueryPersistenceAdapter implements CommentQueryPort { private final CommentJpaRepository commentJpaRepository; - private final CommentMapper commentMapper; @Override public CursorBasedList findLatestRootCommentsWithDeleted(Long postId, Cursor cursor) { @@ -44,6 +41,19 @@ public Map> findAllActiveChildCommentsOldestFirst(Se return commentJpaRepository.findAllActiveChildCommentsByCreatedAtAsc(rootCommentIds); } + @Override + public CursorBasedList findChildComments(Long rootCommentId, Cursor cursor) { + Long lastChildCommentId = cursor.isFirstRequest() ? null : cursor.getLong(0); + int size = cursor.getPageSize(); + + List commentQueryDtos = commentJpaRepository.findChildCommentsByCreatedAtAsc(rootCommentId, lastChildCommentId, size); + + return CursorBasedList.of(commentQueryDtos, size, commentQueryDto -> { + Cursor nextCursor = new Cursor(List.of(commentQueryDto.commentId().toString())); + return nextCursor.toEncodedString(); + }); + } + @Override public CommentQueryDto findRootCommentById(Long rootCommentId) { return commentJpaRepository.findRootCommentId(rootCommentId); diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java index 69a7badb8..410b58686 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java @@ -2,7 +2,6 @@ import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; -import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Set; @@ -15,6 +14,8 @@ public interface CommentQueryRepository { Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds); + List findChildCommentsByCreatedAtAsc(Long rootCommentId, Long lastChildCommentId, int size); + CommentQueryDto findRootCommentId(Long commentId); CommentQueryDto findChildCommentId(Long rootCommentId, Long commentId); diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java index 8e18c31df..4074d8f38 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java @@ -11,7 +11,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -118,6 +117,42 @@ public List findAllActiveChildCommentsByCreatedAtAsc(Long rootC return allDescendants; } + @Override + public List findChildCommentsByCreatedAtAsc(Long rootCommentId, Long lastChildCommentId, int size) { + // 자식 댓글(size+1) 프로젝션 생성 + QCommentQueryDto proj = new QCommentQueryDto( + comment.commentId, + comment.parent.commentId, + parentCommentCreator.nickname, + commentCreator.userId, + commentCreator.alias, + commentCreator.nickname, + comment.createdAt, + comment.content, + comment.likeCount, + comment.status.eq(StatusType.INACTIVE) + ); + + // WHERE 절 분리 + BooleanExpression whereClause = comment.parent.commentId.eq(rootCommentId) + .and(lastChildCommentId != null + ? comment.commentId.gt(lastChildCommentId) + : Expressions.TRUE + ); + + // 조회 및 반환 + return queryFactory + .select(proj) + .from(comment) + .join(comment.parent, parentComment) + .join(parentComment.userJpaEntity, parentCommentCreator) + .join(comment.userJpaEntity, commentCreator) + .where(whereClause) + .orderBy(comment.commentId.asc()) + .limit(size + 1) + .fetch(); + } + @Override public Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds) { // 1) 루트 ID별로 최상위 매핑 초기화 diff --git a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java index 97e368a7c..808e33cb2 100644 --- a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java +++ b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java @@ -16,6 +16,8 @@ public interface CommentQueryPort { Map> findAllActiveChildCommentsOldestFirst(Set rootCommentIds); + CursorBasedList findChildComments(Long rootCommentId, Cursor cursor); + CommentQueryDto findRootCommentById(Long rootCommentId); CommentQueryDto findChildCommentById(Long rootCommentId , Long replyCommentId); From 22bae8fafbe31711e393c1033c525714063e194d Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 18 Jan 2026 02:03:12 +0900 Subject: [PATCH 06/21] =?UTF-8?q?[feat]=20=EB=A3=A8=ED=8A=B8=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=EC=9D=98=20=EB=AA=A8=EB=93=A0=20=EC=9E=90=EC=8B=9D?= =?UTF-8?q?=EB=8C=93=EA=B8=80=EB=93=A4=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20API=20query=20mapper=20=EA=B5=AC=ED=98=84=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - query dto -> response dto 변환 로직 추가 --- .../application/mapper/CommentQueryMapper.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java b/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java index 3b2544781..b9ea8de0a 100644 --- a/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java +++ b/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java @@ -1,5 +1,6 @@ package konkuk.thip.comment.application.mapper; +import konkuk.thip.comment.adapter.in.web.response.ChildCommentsResponse; import konkuk.thip.comment.adapter.in.web.response.CommentCreateResponse; import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; @@ -53,6 +54,15 @@ public interface CommentQueryMapper { @Mapping(target = "isWriter", source = "child.creatorId", qualifiedByName = "isWriter") CommentCreateResponse.ReplyCommentCreateDto toReply(CommentQueryDto child, @Context Long userId); + /** + * 자식 댓글 조회 API용 매핑 + */ + @Mapping(target = "isLike", expression = "java(likedCommentIds.contains(child.commentId()))") + @Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(child.createdAt()))") + @Mapping(target = "aliasName", source = "child.alias") + @Mapping(target = "isWriter", source = "child.creatorId", qualifiedByName = "isWriter") + ChildCommentsResponse.ChildCommentDto toChildComment(CommentQueryDto child, @Context Set likedCommentIds, @Context Long userId); + /** * 답글 리스트 헬퍼 */ @@ -80,7 +90,7 @@ default CommentForSinglePostResponse.RootCommentDto toRootCommentResponseWithChi default CommentCreateResponse toRootCommentResponseWithChildren( CommentQueryDto root, CommentQueryDto children, boolean isLikedParentComment, @Context Long userId) { - CommentCreateResponse.ReplyCommentCreateDto replyDto = toReply(children,userId); + CommentCreateResponse.ReplyCommentCreateDto replyDto = toReply(children, userId); CommentCreateResponse rootDto = toRoot(root, isLikedParentComment, userId); rootDto.replyList().add(replyDto); From 5411d5c49a63bb5f3af9b7fc97fc9310b0d12929 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 18 Jan 2026 02:03:26 +0900 Subject: [PATCH 07/21] =?UTF-8?q?[test]=20=EB=A3=A8=ED=8A=B8=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=EC=9D=98=20=EB=AA=A8=EB=93=A0=20=EC=9E=90=EC=8B=9D?= =?UTF-8?q?=EB=8C=93=EA=B8=80=EB=93=A4=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20API=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/ChildCommentShowApiTest.java | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentShowApiTest.java diff --git a/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentShowApiTest.java b/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentShowApiTest.java new file mode 100644 index 000000000..3bea13d23 --- /dev/null +++ b/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentShowApiTest.java @@ -0,0 +1,278 @@ +package konkuk.thip.comment.adapter.in.web; + +import com.jayway.jsonpath.JsonPath; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; +import konkuk.thip.post.domain.PostType; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 자식 댓글 조회 api 통합 테스트") +@Transactional +class ChildCommentShowApiTest { + + @Autowired private MockMvc mockMvc; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private CommentJpaRepository commentJpaRepository; + @Autowired private CommentLikeJpaRepository commentLikeJpaRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("특정 루트 댓글의 자식 댓글을 조회할 수 있다.") + void child_comment_show_test() throws Exception { + //given + Alias a0 = TestEntityFactory.createScienceAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + LocalDateTime base = LocalDateTime.now(); + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글1", 5)); + CommentJpaEntity comment1_1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, me, PostType.FEED, comment1, "댓글1_답글1", 8)); + CommentJpaEntity comment1_2 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "댓글1_답글2", 3)); + + commentLikeJpaRepository.save(TestEntityFactory.createCommentLike(comment1_1, me)); + + feedJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE posts SET created_at = ? WHERE post_id = ?", + Timestamp.valueOf(base.minusMinutes(50)), f1.getPostId()); + + commentJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(40)), comment1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(30)), comment1_1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(20)), comment1_2.getCommentId()); + + //when //then + mockMvc.perform(get("/comments/replies/{rootCommentId}", comment1.getCommentId().intValue()) + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.childComments", hasSize(2))) + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.childComments[0].commentId", is(comment1_1.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.childComments[0].parentCommentCreatorNickname", is(user1.getNickname()))) + .andExpect(jsonPath("$.data.childComments[0].creatorNickname", is(me.getNickname()))) + .andExpect(jsonPath("$.data.childComments[0].content", is(comment1_1.getContent()))) + .andExpect(jsonPath("$.data.childComments[0].likeCount", is(comment1_1.getLikeCount()))) + .andExpect(jsonPath("$.data.childComments[0].isLike", is(true))) + .andExpect(jsonPath("$.data.childComments[1].commentId", is(comment1_2.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.childComments[1].parentCommentCreatorNickname", is(user1.getNickname()))) + .andExpect(jsonPath("$.data.childComments[1].creatorNickname", is(user1.getNickname()))) + .andExpect(jsonPath("$.data.childComments[1].content", is(comment1_2.getContent()))) + .andExpect(jsonPath("$.data.childComments[1].likeCount", is(comment1_2.getLikeCount()))) + .andExpect(jsonPath("$.data.childComments[1].isLike", is(false))); + } + + @Test + @DisplayName("자식 댓글이 없는 루트 댓글을 조회하면 빈 리스트를 반환한다.") + void child_comment_show_empty_test() throws Exception { + //given + Alias a0 = TestEntityFactory.createScienceAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글1", 5)); + + //when //then + mockMvc.perform(get("/comments/replies/{rootCommentId}", comment1.getCommentId().intValue()) + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.childComments", hasSize(0))) + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.nextCursor", nullValue())); + } + + @Test + @DisplayName("자식 댓글이 많을 경우, 커서 기반 페이징으로 10개씩 조회한다.") + void child_comment_show_paging_test() throws Exception { + //given + Alias a0 = TestEntityFactory.createScienceAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + LocalDateTime base = LocalDateTime.now(); + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "루트댓글", 5)); + + CommentJpaEntity comment1_1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식1", 1)); + CommentJpaEntity comment1_2 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식2", 1)); + CommentJpaEntity comment1_3 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식3", 1)); + CommentJpaEntity comment1_4 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식4", 1)); + CommentJpaEntity comment1_5 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식5", 1)); + CommentJpaEntity comment1_6 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식6", 1)); + CommentJpaEntity comment1_7 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식7", 1)); + CommentJpaEntity comment1_8 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식8", 1)); + CommentJpaEntity comment1_9 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식9", 1)); + CommentJpaEntity comment1_10 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식10", 1)); + CommentJpaEntity comment1_11 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식11", 1)); + CommentJpaEntity comment1_12 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식12", 1)); + CommentJpaEntity comment1_13 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식13", 1)); + CommentJpaEntity comment1_14 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식14", 1)); + CommentJpaEntity comment1_15 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식15", 1)); + + feedJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE posts SET created_at = ? WHERE post_id = ?", + Timestamp.valueOf(base.minusMinutes(50)), f1.getPostId()); + + commentJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(40)), comment1.getCommentId()); + + for (int i = 1; i <= 15; i++) { + CommentJpaEntity childComment = commentJpaRepository.findById((long)(comment1_1.getCommentId() + i - 1)).orElse(null); + if (childComment != null) { + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(30 - i)), childComment.getCommentId()); + } + } + + //when //then - 첫 번째 페이지 조회 + MvcResult firstResult = mockMvc.perform(get("/comments/replies/{rootCommentId}", comment1.getCommentId().intValue()) + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.childComments", hasSize(10))) + .andExpect(jsonPath("$.data.isLast", is(false))) + .andExpect(jsonPath("$.data.nextCursor", notNullValue())) + .andExpect(jsonPath("$.data.childComments[0].commentId", is(comment1_1.getCommentId().intValue()))) + .andReturn(); + + String responseBody = firstResult.getResponse().getContentAsString(); + String nextCursor = JsonPath.read(responseBody, "$.data.nextCursor"); + + // when //then - 두 번째 페이지 조회 + mockMvc.perform(get("/comments/replies/{rootCommentId}", comment1.getCommentId().intValue()) + .requestAttr("userId", me.getUserId()) + .param("cursor", nextCursor)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.childComments", hasSize(5))) + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.nextCursor", nullValue())) + .andExpect(jsonPath("$.data.childComments[0].commentId", is(comment1_11.getCommentId().intValue()))); + } + + @Test + @DisplayName("자식 댓글은 작성 시각순(오래된 순)으로 정렬되어 반환된다.") + void child_comment_show_ordering_test() throws Exception { + //given + Alias a0 = TestEntityFactory.createScienceAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + UserJpaEntity user2 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user2")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + LocalDateTime base = LocalDateTime.now(); + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "루트댓글", 5)); + CommentJpaEntity comment1_1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식1", 1)); + CommentJpaEntity comment1_2 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user2, PostType.FEED, comment1, "자식2", 2)); + CommentJpaEntity comment1_3 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, me, PostType.FEED, comment1, "자식3", 3)); + + feedJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE posts SET created_at = ? WHERE post_id = ?", + Timestamp.valueOf(base.minusMinutes(50)), f1.getPostId()); + + commentJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(40)), comment1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(10)), comment1_1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(5)), comment1_2.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base), comment1_3.getCommentId()); + + //when //then + mockMvc.perform(get("/comments/replies/{rootCommentId}", comment1.getCommentId().intValue()) + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.childComments", hasSize(3))) + .andExpect(jsonPath("$.data.childComments[0].commentId", is(comment1_1.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.childComments[0].content", is(comment1_1.getContent()))) + .andExpect(jsonPath("$.data.childComments[1].commentId", is(comment1_2.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.childComments[1].content", is(comment1_2.getContent()))) + .andExpect(jsonPath("$.data.childComments[2].commentId", is(comment1_3.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.childComments[2].content", is(comment1_3.getContent()))); + } + + @Test + @DisplayName("자식 댓글 조회 시 사용자가 좋아한 댓글을 표시한다.") + void child_comment_show_like_status_test() throws Exception { + //given + Alias a0 = TestEntityFactory.createScienceAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "루트댓글", 5)); + CommentJpaEntity comment1_1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식1", 1)); + CommentJpaEntity comment1_2 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식2", 1)); + CommentJpaEntity comment1_3 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1, "자식3", 1)); + + commentLikeJpaRepository.save(TestEntityFactory.createCommentLike(comment1_1, me)); + commentLikeJpaRepository.save(TestEntityFactory.createCommentLike(comment1_3, me)); + + //when //then + mockMvc.perform(get("/comments/replies/{rootCommentId}", comment1.getCommentId().intValue()) + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.childComments", hasSize(3))) + .andExpect(jsonPath("$.data.childComments[0].isLike", is(true))) + .andExpect(jsonPath("$.data.childComments[1].isLike", is(false))) + .andExpect(jsonPath("$.data.childComments[2].isLike", is(true))); + } +} From 2dd495aec954ed81b24380c8e1c95bcbbb698893 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Fri, 23 Jan 2026 17:51:53 +0900 Subject: [PATCH 08/21] =?UTF-8?q?[feat]=20comments=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=20root=5Fcomment=5Fid,=20descendant=5Fcount?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - root_comment_id : 현재 댓글의 루트 댓글 ID 값 (루트 댓글인 경우는 null) - descendant_count : 현재 댓글의 모든 자손 댓글의 개수 (루트 댓글이 아닌 경우는 0) --- .../adapter/out/jpa/CommentJpaEntity.java | 33 +++++++++++++++++++ .../adapter/out/mapper/CommentMapper.java | 8 +++-- .../konkuk/thip/comment/domain/Comment.java | 13 ++++++++ .../V260119__Add_root_comment_id_column.sql | 33 +++++++++++++++++++ .../V260123__Add_descendant_count.sql | 17 ++++++++++ 5 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/migration/V260119__Add_root_comment_id_column.sql create mode 100644 src/main/resources/db/migration/V260123__Add_descendant_count.sql diff --git a/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java b/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java index 82ebbfcf3..ff72c903c 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentJpaEntity.java @@ -45,6 +45,14 @@ public class CommentJpaEntity extends BaseJpaEntity { @Column(name = "like_count", nullable = false) private int likeCount = 0; + /** + * 루트 댓글의 자식 댓글 수 (루트 댓글만 사용) + * 자식 댓글인 경우 항상 0 + */ + @Builder.Default + @Column(name = "descendant_count", nullable = false) + private int descendantCount = 0; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "post_id", nullable = false) private PostJpaEntity postJpaEntity; @@ -64,6 +72,15 @@ public class CommentJpaEntity extends BaseJpaEntity { @JoinColumn(name = "parent_id") private CommentJpaEntity parent; + /** + * 루트 댓글 참조 (모든 자손 댓글이 루트를 직접 참조) + * 루트 댓글인 경우: null (nullable) + * 자식 댓글인 경우: 최상위 루트 댓글 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "root_comment_id") + private CommentJpaEntity root; + public CommentJpaEntity updateFrom(Comment comment) { this.reportCount = comment.getReportCount(); this.likeCount = comment.getLikeCount(); @@ -75,4 +92,20 @@ public CommentJpaEntity updateFrom(Comment comment) { public void updateLikeCount(int likeCount) { this.likeCount = likeCount; } + + /** + * 자식 댓글 수 증가 (루트 댓글만 호출) + */ + public void incrementDescendantCount() { + this.descendantCount++; + } + + /** + * 자식 댓글 수 감소 (루트 댓글만 호출) + */ + public void decrementDescendantCount() { + if (this.descendantCount > 0) { + this.descendantCount--; + } + } } diff --git a/src/main/java/konkuk/thip/comment/adapter/out/mapper/CommentMapper.java b/src/main/java/konkuk/thip/comment/adapter/out/mapper/CommentMapper.java index 0f37523bc..71711e99b 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/mapper/CommentMapper.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/mapper/CommentMapper.java @@ -9,15 +9,17 @@ @Component public class CommentMapper { - public CommentJpaEntity toJpaEntity(Comment comment, PostJpaEntity postJpaEntity, UserJpaEntity userJpaEntity, CommentJpaEntity commentJpaEntity) { + public CommentJpaEntity toJpaEntity(Comment comment, PostJpaEntity postJpaEntity, UserJpaEntity userJpaEntity, CommentJpaEntity parentCommentJpaEntity, CommentJpaEntity rootCommentJpaEntity) { return CommentJpaEntity.builder() .content(comment.getContent()) .likeCount(comment.getLikeCount()) .reportCount(comment.getReportCount()) + .descendantCount(comment.getDescendantCount()) .postJpaEntity(postJpaEntity) .postType(comment.getPostType()) .userJpaEntity(userJpaEntity) - .parent(commentJpaEntity) + .parent(parentCommentJpaEntity) + .root(rootCommentJpaEntity) .build(); } @@ -27,10 +29,12 @@ public Comment toDomainEntity(CommentJpaEntity commentJpaEntity) { .content(commentJpaEntity.getContent()) .reportCount(commentJpaEntity.getReportCount()) .likeCount(commentJpaEntity.getLikeCount()) + .descendantCount(commentJpaEntity.getDescendantCount()) .targetPostId(commentJpaEntity.getPostJpaEntity().getPostId()) .postType(commentJpaEntity.getPostType()) .creatorId(commentJpaEntity.getUserJpaEntity().getUserId()) .parentCommentId(commentJpaEntity.getParent() != null ? commentJpaEntity.getParent().getCommentId() : null) + .rootCommentId(commentJpaEntity.getRoot() != null ? commentJpaEntity.getRoot().getCommentId() : null) .createdAt(commentJpaEntity.getCreatedAt()) .modifiedAt(commentJpaEntity.getModifiedAt()) .status(commentJpaEntity.getStatus()) diff --git a/src/main/java/konkuk/thip/comment/domain/Comment.java b/src/main/java/konkuk/thip/comment/domain/Comment.java index ebf83622e..1e058899b 100644 --- a/src/main/java/konkuk/thip/comment/domain/Comment.java +++ b/src/main/java/konkuk/thip/comment/domain/Comment.java @@ -26,12 +26,25 @@ public class Comment extends BaseDomainEntity { @Builder.Default private int likeCount = 0; + /** + * 루트 댓글에서만 의미있는 값 + */ + @Builder.Default + private int descendantCount = 0; + private Long targetPostId; private Long creatorId; private Long parentCommentId; + /** + * PersistenceAdapter 에서 Comment -> CommentJpaEntity 변환 시에 rootCommentId 값 세팅 + * 코드 수정 최소화를 위해 Builder Default 로 null 설정 + */ + @Builder.Default + private Long rootCommentId = null; + private PostType postType; @Override diff --git a/src/main/resources/db/migration/V260119__Add_root_comment_id_column.sql b/src/main/resources/db/migration/V260119__Add_root_comment_id_column.sql new file mode 100644 index 000000000..5fbb5d66f --- /dev/null +++ b/src/main/resources/db/migration/V260119__Add_root_comment_id_column.sql @@ -0,0 +1,33 @@ +-- Step 1: 컬럼 추가 (Nullable) +ALTER TABLE comments ADD COLUMN root_comment_id BIGINT; + +-- Step 2: 자식 댓글 데이터 마이그레이션 (Recursive CTE) +-- MySQL 8.0+ +WITH RECURSIVE comment_path AS ( + -- Anchor: 부모가 없는 루트 댓글들 + -- 주의: 루트 댓글의 root_comment_id 컬럼은 NULL이지만, 자식들에게는 이 루트의 ID(comment_id)가 root_id가 됨 + SELECT comment_id, comment_id as root_id, 1 as depth + FROM comments + WHERE parent_id IS NULL + + UNION ALL + + -- Recursive: 부모를 따라가며 자식 찾기 + SELECT c.comment_id, cp.root_id, cp.depth + 1 + FROM comments c + INNER JOIN comment_path cp ON c.parent_id = cp.comment_id + WHERE cp.depth < 1000 -- 무한 루프 방지용 깊이 제한 +) +UPDATE comments c + INNER JOIN comment_path path ON c.comment_id = path.comment_id + SET c.root_comment_id = path.root_id +-- [중요] 루트 댓글(parent_id IS NULL)은 root_comment_id가 NULL이어야 하므로 업데이트 대상에서 제외 +WHERE c.parent_id IS NOT NULL; + +-- Step 3: 외래 키 제약 조건 추가 +-- MySQL InnoDB에서는 FK 생성 시 해당 컬럼에 인덱스가 없으면 자동으로 생성해줍니다. +ALTER TABLE comments + ADD CONSTRAINT fk_comments_root_comment_id + FOREIGN KEY (root_comment_id) REFERENCES comments(comment_id) + ON DELETE RESTRICT + ON UPDATE CASCADE; \ No newline at end of file diff --git a/src/main/resources/db/migration/V260123__Add_descendant_count.sql b/src/main/resources/db/migration/V260123__Add_descendant_count.sql new file mode 100644 index 000000000..4194b2199 --- /dev/null +++ b/src/main/resources/db/migration/V260123__Add_descendant_count.sql @@ -0,0 +1,17 @@ +/* descendant_count 컬럼 추가 및 초기화 */ + +-- 1. 컬럼 추가 +ALTER TABLE comments ADD COLUMN descendant_count INT NOT NULL DEFAULT 0; + +-- 2. 루트 댓글별 자식 수 집계 및 업데이트 (MySQL 호환 JOIN 문법) +UPDATE comments parent + JOIN ( + -- 루트 댓글 별 자손 댓글 개수 count + SELECT root_comment_id, COUNT(*) as child_cnt + FROM comments + WHERE root_comment_id IS NOT NULL -- 자식 댓글들만 + AND status = 'ACTIVE' -- JPA 엔티티 로직 반영 + GROUP BY root_comment_id -- 루트 댓글로 그룹핑 + ) stats ON parent.comment_id = stats.root_comment_id +SET parent.descendant_count = stats.child_cnt +WHERE parent.parent_id IS NULL; -- 루트 댓글만 업데이트 \ No newline at end of file From d4c063b0b62ea4591fccfd048444b4bdb8e5cab3 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 15 Feb 2026 17:28:34 +0900 Subject: [PATCH 09/21] =?UTF-8?q?[refactor]=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EB=B6=84=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20controller=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 루트 댓글 조회, 자손 댓글 조회 API 분리에 따른 use case 네이밍 수정 - 루트 댓글 조회 API에 spring transactional 제거 (for 조회 성능) --- .../in/web/CommentQueryController.java | 20 ++--- .../CommentForSinglePostResponse.java | 61 ------------- .../in/web/response/RootCommentsResponse.java | 47 ++++++++++ ...Case.java => ChildCommentShowUseCase.java} | 2 +- .../port/in/CommentShowAllUseCase.java | 9 -- .../port/in/RootCommentShowUseCase.java | 9 ++ ...vice.java => ChildCommentShowService.java} | 11 ++- .../service/CommentShowAllService.java | 86 ------------------- .../service/RootCommentShowService.java | 78 +++++++++++++++++ 9 files changed, 152 insertions(+), 171 deletions(-) delete mode 100644 src/main/java/konkuk/thip/comment/adapter/in/web/response/CommentForSinglePostResponse.java create mode 100644 src/main/java/konkuk/thip/comment/adapter/in/web/response/RootCommentsResponse.java rename src/main/java/konkuk/thip/comment/application/port/in/{ChildCommentsShowUseCase.java => ChildCommentShowUseCase.java} (86%) delete mode 100644 src/main/java/konkuk/thip/comment/application/port/in/CommentShowAllUseCase.java create mode 100644 src/main/java/konkuk/thip/comment/application/port/in/RootCommentShowUseCase.java rename src/main/java/konkuk/thip/comment/application/service/{ChildCommentsShowService.java => ChildCommentShowService.java} (86%) delete mode 100644 src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java create mode 100644 src/main/java/konkuk/thip/comment/application/service/RootCommentShowService.java diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java index 1e31b7be9..93425fb52 100644 --- a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java @@ -4,9 +4,9 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import konkuk.thip.comment.adapter.in.web.response.ChildCommentsResponse; -import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; -import konkuk.thip.comment.application.port.in.ChildCommentsShowUseCase; -import konkuk.thip.comment.application.port.in.CommentShowAllUseCase; +import konkuk.thip.comment.adapter.in.web.response.RootCommentsResponse; +import konkuk.thip.comment.application.port.in.ChildCommentShowUseCase; +import konkuk.thip.comment.application.port.in.RootCommentShowUseCase; import konkuk.thip.comment.application.port.in.dto.ChildCommentsShowQuery; import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; import konkuk.thip.common.dto.BaseResponse; @@ -22,15 +22,15 @@ @RequiredArgsConstructor public class CommentQueryController { - public final CommentShowAllUseCase commentShowAllUseCase; - public final ChildCommentsShowUseCase childCommentsShowUseCase; + public final RootCommentShowUseCase rootCommentShowUseCase; + public final ChildCommentShowUseCase childCommentShowUseCase; @Operation( - summary = "댓글 전체 조회", - description = "특정 게시글(= 피드, 기록, 투표) 의 댓글과 대댓글들을 전체 조회합니다." + summary = "루트 댓글 조회", + description = "특정 게시글(= 피드, 기록, 투표) 에 직접 달린 루트 댓글을 조회합니다." ) @GetMapping("/comments/{postId}") - public BaseResponse showAllCommentsOfPost( + public BaseResponse showRootCommentsOfPost( @Parameter(hidden = true) @UserId final Long userId, @Parameter(description = "댓글을 조회할 게시글(= FEED, RECORD, VOTE)의 id값") @PathVariable("postId") final Long postId, @@ -38,7 +38,7 @@ public BaseResponse showAllCommentsOfPost( @RequestParam(value = "postType") final String postType, @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") @RequestParam(value = "cursor", required = false) final String cursor) { - return BaseResponse.ok(commentShowAllUseCase.showAllCommentsOfPost( + return BaseResponse.ok(rootCommentShowUseCase.showRootCommentsOfPost( CommentShowAllQuery.of(postId, userId, postType, cursor) )); } @@ -54,7 +54,7 @@ public BaseResponse showChildComments( @PathVariable("rootCommentId") final Long rootCommentId, @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") @RequestParam(value = "cursor", required = false) final String cursor) { - return BaseResponse.ok(childCommentsShowUseCase.showChildComments( + return BaseResponse.ok(childCommentShowUseCase.showChildComments( ChildCommentsShowQuery.of(rootCommentId, userId, cursor) )); } diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/response/CommentForSinglePostResponse.java b/src/main/java/konkuk/thip/comment/adapter/in/web/response/CommentForSinglePostResponse.java deleted file mode 100644 index fca2d2b7f..000000000 --- a/src/main/java/konkuk/thip/comment/adapter/in/web/response/CommentForSinglePostResponse.java +++ /dev/null @@ -1,61 +0,0 @@ -package konkuk.thip.comment.adapter.in.web.response; - -import java.util.List; - -public record CommentForSinglePostResponse( - List commentList, - String nextCursor, - boolean isLast -) { - public record RootCommentDto( - Long commentId, - Long creatorId, - String creatorProfileImageUrl, - String creatorNickname, - String aliasName, - String aliasColor, - String postDate, // 댓글 작성 시각 (~ 전 형식) - String content, - int likeCount, - boolean isLike, - boolean isDeleted, // 삭제된 댓글인지 아닌지 - boolean isWriter, - List replyList - ) { - public record ReplyDto( - Long commentId, - String parentCommentCreatorNickname, - Long creatorId, - String creatorProfileImageUrl, - String creatorNickname, - String aliasName, - String aliasColor, - String postDate, // 댓글 작성 시각 (~ 전 형식) - String content, - int likeCount, - boolean isLike, - boolean isWriter - ) {} - - /** - * 삭제된 루트 댓글에 매핑되는 response dto - * isDelete 제외 나머지 데이터는 모두 쓰레기 값으로 - */ - public static RootCommentDto createDeletedRootCommentDto(List replyList) { - return new RootCommentDto( - null, - null, - null, - null, - null, - null, - null, - null, - 0, - false, - true, // true - false, - replyList); - } - } -} diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/response/RootCommentsResponse.java b/src/main/java/konkuk/thip/comment/adapter/in/web/response/RootCommentsResponse.java new file mode 100644 index 000000000..6e16de01f --- /dev/null +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/response/RootCommentsResponse.java @@ -0,0 +1,47 @@ +package konkuk.thip.comment.adapter.in.web.response; + +import java.util.List; + +public record RootCommentsResponse( + List commentList, + String nextCursor, + boolean isLast +) { + public record RootCommentDto( + Long commentId, + Long creatorId, + String creatorProfileImageUrl, + String creatorNickname, + String aliasName, + String aliasColor, + String postDate, // 댓글 작성 시각 (~ 전 형식) + String content, + int likeCount, + boolean isLike, + boolean isDeleted, // 삭제된 댓글인지 아닌지 + boolean isWriter, + int descendantCount // 자식 댓글 수 + ) { + /** + * 삭제된 루트 댓글 생성용 정적 팩토리 메서드 + * descendantCount와 isDeleted만 실제 값이고, 나머지는 모두 쓰레기 값 + */ + public static RootCommentDto createDeletedRootCommentDto(int descendantCount) { + return new RootCommentDto( + null, // commentId + null, // creatorId + null, // creatorProfileImageUrl + null, // creatorNickname + null, // aliasName + null, // aliasColor + null, // postDate + null, // content + 0, // likeCount + false, // isLike + true, // isDeleted (삭제됨) + false, // isWriter + descendantCount // descendantCount (실제 자식 댓글 수) + ); + } + } +} diff --git a/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentsShowUseCase.java b/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentShowUseCase.java similarity index 86% rename from src/main/java/konkuk/thip/comment/application/port/in/ChildCommentsShowUseCase.java rename to src/main/java/konkuk/thip/comment/application/port/in/ChildCommentShowUseCase.java index c822a6204..4ba84e71b 100644 --- a/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentsShowUseCase.java +++ b/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentShowUseCase.java @@ -3,7 +3,7 @@ import konkuk.thip.comment.adapter.in.web.response.ChildCommentsResponse; import konkuk.thip.comment.application.port.in.dto.ChildCommentsShowQuery; -public interface ChildCommentsShowUseCase { +public interface ChildCommentShowUseCase { ChildCommentsResponse showChildComments(ChildCommentsShowQuery query); } diff --git a/src/main/java/konkuk/thip/comment/application/port/in/CommentShowAllUseCase.java b/src/main/java/konkuk/thip/comment/application/port/in/CommentShowAllUseCase.java deleted file mode 100644 index f9d3bd21e..000000000 --- a/src/main/java/konkuk/thip/comment/application/port/in/CommentShowAllUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package konkuk.thip.comment.application.port.in; - -import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; -import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; - -public interface CommentShowAllUseCase { - - CommentForSinglePostResponse showAllCommentsOfPost(CommentShowAllQuery query); -} diff --git a/src/main/java/konkuk/thip/comment/application/port/in/RootCommentShowUseCase.java b/src/main/java/konkuk/thip/comment/application/port/in/RootCommentShowUseCase.java new file mode 100644 index 000000000..353951782 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/port/in/RootCommentShowUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.comment.application.port.in; + +import konkuk.thip.comment.adapter.in.web.response.RootCommentsResponse; +import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; + +public interface RootCommentShowUseCase { + + RootCommentsResponse showRootCommentsOfPost(CommentShowAllQuery query); +} diff --git a/src/main/java/konkuk/thip/comment/application/service/ChildCommentsShowService.java b/src/main/java/konkuk/thip/comment/application/service/ChildCommentShowService.java similarity index 86% rename from src/main/java/konkuk/thip/comment/application/service/ChildCommentsShowService.java rename to src/main/java/konkuk/thip/comment/application/service/ChildCommentShowService.java index a45d40187..30e04abef 100644 --- a/src/main/java/konkuk/thip/comment/application/service/ChildCommentsShowService.java +++ b/src/main/java/konkuk/thip/comment/application/service/ChildCommentShowService.java @@ -2,7 +2,7 @@ import konkuk.thip.comment.adapter.in.web.response.ChildCommentsResponse; import konkuk.thip.comment.application.mapper.CommentQueryMapper; -import konkuk.thip.comment.application.port.in.ChildCommentsShowUseCase; +import konkuk.thip.comment.application.port.in.ChildCommentShowUseCase; import konkuk.thip.comment.application.port.in.dto.ChildCommentsShowQuery; import konkuk.thip.comment.application.port.out.CommentLikeQueryPort; import konkuk.thip.comment.application.port.out.CommentQueryPort; @@ -19,20 +19,23 @@ @Service @RequiredArgsConstructor -public class ChildCommentsShowService implements ChildCommentsShowUseCase { +public class ChildCommentShowService implements ChildCommentShowUseCase { private static final int PAGE_SIZE = 10; private final CommentQueryPort commentQueryPort; private final CommentLikeQueryPort commentLikeQueryPort; private final CommentQueryMapper commentQueryMapper; + /** + * ACTIVE 인 comments 데이터만 조회 -> status filter 자동 적용 + */ @Override @Transactional(readOnly = true) public ChildCommentsResponse showChildComments(ChildCommentsShowQuery query) { Cursor cursor = Cursor.from(query.cursorStr(), PAGE_SIZE); - // 1. 특정 루트 댓글의 자식 댓글을 최신순으로 페이징 조회 - CursorBasedList childCommentsCursorBasedList = commentQueryPort.findChildComments(query.rootCommentId(), cursor); + // root 댓글의 모든 자손 조회 (depth 상관없이) + CursorBasedList childCommentsCursorBasedList = commentQueryPort.findAllDescendantComments(query.rootCommentId(), cursor); List childComments = childCommentsCursorBasedList.contents(); // 2. 유저가 좋아한 댓글 조회 diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java b/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java deleted file mode 100644 index 73c5d4aac..000000000 --- a/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java +++ /dev/null @@ -1,86 +0,0 @@ -package konkuk.thip.comment.application.service; - -import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; -import konkuk.thip.comment.application.mapper.CommentQueryMapper; -import konkuk.thip.comment.application.port.in.CommentShowAllUseCase; -import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; -import konkuk.thip.comment.application.port.out.CommentLikeQueryPort; -import konkuk.thip.comment.application.port.out.CommentQueryPort; -import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; -import konkuk.thip.common.annotation.persistence.Unfiltered; -import konkuk.thip.common.util.Cursor; -import konkuk.thip.common.util.CursorBasedList; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.*; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class CommentShowAllService implements CommentShowAllUseCase { - - private static final int PAGE_SIZE = 10; - private final CommentQueryPort commentQueryPort; - private final CommentLikeQueryPort commentLikeQueryPort; - private final CommentQueryMapper commentQueryMapper; - - @Override - @Transactional(readOnly = true) - @Unfiltered - public CommentForSinglePostResponse showAllCommentsOfPost(CommentShowAllQuery query) { - Cursor cursor = Cursor.from(query.cursorStr(), PAGE_SIZE); - - // 1. size 크기만큼의 루트 댓글 최신순 조회 -> 삭제된 루트 댓글 포함해서 전부 조회 - CursorBasedList commentQueryDtoCursorBasedList = commentQueryPort.findLatestRootCommentsWithDeleted(query.postId(), cursor); - List rootsInOrder = commentQueryDtoCursorBasedList.contents(); - - // 2. 조회한 루트 댓글들의 전체 자식 댓귿들을(깊이 무관) 작성 시간순으로 조회 - Set rootCommentIds = rootsInOrder.stream() - .map(CommentQueryDto::commentId) - .collect(Collectors.toUnmodifiableSet()); - - Map> childrenMap = commentQueryPort.findAllActiveChildCommentsOldestFirst(rootCommentIds); - - // 3. 반환할 모든 댓글(루트 + 자식 모두 포함) 중 유저가 좋아한 댓글 조회 - Set allCommentIds = parseAllCommentIds(childrenMap); - Set likedCommentIds = commentLikeQueryPort.findCommentIdsLikedByUser(allCommentIds, query.userId()); - - // 4. response 매핑 - List rootCommentResponses = buildRootCommentResponses(rootsInOrder, childrenMap, likedCommentIds, query.userId()); - - return new CommentForSinglePostResponse( - rootCommentResponses, - commentQueryDtoCursorBasedList.nextCursor(), - commentQueryDtoCursorBasedList.isLast() - ); - } - - private Set parseAllCommentIds(Map> childrenMap) { - Set allCommentIds = new HashSet<>(childrenMap.keySet()); // 루트 댓글들 - for (Long rootCommentId : childrenMap.keySet()) { - childrenMap.get(rootCommentId).stream() - .map(CommentQueryDto::commentId) - .forEach(allCommentIds::add); - } - return allCommentIds; - } - - private List buildRootCommentResponses( - List roots, - Map> childrenMap, - Set likedCommentIds, - Long userId) { - List responses = new ArrayList<>(); - for (CommentQueryDto root : roots) { - List children = childrenMap.getOrDefault(root.commentId(), Collections.emptyList()); - // 삭제된 루트 댓글이면서 자식이 없는 경우 건너뛰기 - if (root.isDeleted() && children.isEmpty()) { - continue; - } - responses.add(commentQueryMapper.toRootCommentResponseWithChildren(root, children, likedCommentIds, userId)); - } - return responses; - } -} diff --git a/src/main/java/konkuk/thip/comment/application/service/RootCommentShowService.java b/src/main/java/konkuk/thip/comment/application/service/RootCommentShowService.java new file mode 100644 index 000000000..faf6e4984 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/service/RootCommentShowService.java @@ -0,0 +1,78 @@ +package konkuk.thip.comment.application.service; + +import konkuk.thip.comment.adapter.in.web.response.RootCommentsResponse; +import konkuk.thip.comment.application.mapper.CommentQueryMapper; +import konkuk.thip.comment.application.port.in.RootCommentShowUseCase; +import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; +import konkuk.thip.comment.application.port.out.CommentLikeQueryPort; +import konkuk.thip.comment.application.port.out.CommentQueryPort; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import konkuk.thip.common.annotation.persistence.Unfiltered; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class RootCommentShowService implements RootCommentShowUseCase { + + private static final int PAGE_SIZE = 10; + private final CommentQueryPort commentQueryPort; + private final CommentLikeQueryPort commentLikeQueryPort; + private final CommentQueryMapper commentQueryMapper; + + @Override +// @Transactional(readOnly = true) +// @Unfiltered + public RootCommentsResponse showRootCommentsOfPost(CommentShowAllQuery query) { + Cursor cursor = Cursor.from(query.cursorStr(), PAGE_SIZE); + + // 1. 루트 댓글 조회 (Port에서 캐싱 여부 자동 판단) + CursorBasedList commentQueryDtoCursorBasedList = + commentQueryPort.findLatestRootCommentsWithDeleted(query.postId(), cursor); + List rootsInOrder = commentQueryDtoCursorBasedList.contents(); + + // 2. 반환할 루트 댓글 중 유저가 좋아한 댓글 조회 + Set rootCommentIds = rootsInOrder.stream() + .map(CommentQueryDto::commentId) + .collect(Collectors.toUnmodifiableSet()); + Set likedCommentIds = commentLikeQueryPort.findCommentIdsLikedByUser(rootCommentIds, query.userId()); + + // 3. response 매핑 + List rootCommentResponses = + buildRootCommentResponses(rootsInOrder, likedCommentIds, query.userId()); + + return new RootCommentsResponse( + rootCommentResponses, + commentQueryDtoCursorBasedList.nextCursor(), + commentQueryDtoCursorBasedList.isLast() + ); + } + + private List buildRootCommentResponses( + List roots, + Set likedCommentIds, + Long userId) { + List responses = new ArrayList<>(); + for (CommentQueryDto root : roots) { + // 삭제된 루트 댓글 처리 + if (root.isDeleted()) { + // 자식 댓글이 있는 경우: 쓰레기 값으로 반환 (descendantCount와 isDeleted만 실제 값) + if (root.descendantCount() > 0) { + responses.add(RootCommentsResponse.RootCommentDto.createDeletedRootCommentDto(root.descendantCount())); + } + // 자식 댓글이 없는 경우: 응답에서 제외 + continue; + } + + // 정상 루트 댓글: 매퍼로 변환 + responses.add(commentQueryMapper.toRootCommentResponse(root, likedCommentIds, userId)); + } + return responses; + } +} From d85d8a9ddc1f77bcaf0fa15b7690525752938fcb Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 15 Feb 2026 17:30:32 +0900 Subject: [PATCH 10/21] =?UTF-8?q?[feat]=20comment=5Flike=20table=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유저별 좋아하는 댓글 목록 조회를 위한 복합 인데스 추가 --- .../comment/adapter/out/jpa/CommentLikeJpaEntity.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentLikeJpaEntity.java b/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentLikeJpaEntity.java index 59f664ab0..7c9875b99 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentLikeJpaEntity.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/jpa/CommentLikeJpaEntity.java @@ -7,7 +7,13 @@ @Entity -@Table(name = "comment_likes") +@Table( + name = "comment_likes", + indexes = { + // 유저별 좋아하는 댓글 목록 복합 인덱스 + @Index(name = "idx_comment_like_user_comment", columnList = "user_id, comment_id") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor From 9c0ad468c0ca2af871b0224763a8a5f58ebb7b7f Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 15 Feb 2026 17:40:45 +0900 Subject: [PATCH 11/21] =?UTF-8?q?[refactor]=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EB=B6=84=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20comment=20query=20persistence=20adapter=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CommentQueryPersistenceAdapter.java | 39 ++-- .../repository/CommentQueryRepository.java | 8 +- .../CommentQueryRepositoryImpl.java | 184 ++++-------------- .../mapper/CommentQueryMapper.java | 42 +--- .../port/out/CommentQueryPort.java | 10 +- .../port/out/dto/CommentQueryDto.java | 10 +- 6 files changed, 68 insertions(+), 225 deletions(-) diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java index ef3ab6dcb..6646dacf5 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java @@ -6,25 +6,40 @@ import konkuk.thip.common.util.Cursor; import konkuk.thip.common.util.CursorBasedList; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; import java.util.List; -import java.util.Map; -import java.util.Set; +@Slf4j @Repository @RequiredArgsConstructor public class CommentQueryPersistenceAdapter implements CommentQueryPort { private final CommentJpaRepository commentJpaRepository; +// private final CommentCacheAdapter commentCacheAdapter; + /** + * 루트 댓글 조회 (페이징) + * - 첫 페이지: CommentCacheAdapter를 통한 캐시 조회 + * - 2페이지 이후: DB 직접 조회 (캐싱하지 않음) + */ @Override public CursorBasedList findLatestRootCommentsWithDeleted(Long postId, Cursor cursor) { - Long lastRootCommentId = cursor.isFirstRequest() ? null : cursor.getLong(0); int size = cursor.getPageSize(); - + Long lastRootCommentId = cursor.isFirstRequest() ? null : cursor.getLong(0); List commentQueryDtos = commentJpaRepository.findRootCommentsWithDeletedByCreatedAtDesc(postId, lastRootCommentId, size); +// List commentQueryDtos; +// if (cursor.isFirstRequest()) { +// // 첫 페이지: 캐시 조회 +// commentQueryDtos = commentCacheAdapter.findFirstPageRootCommentsFromCache(postId, size); +// } else { +// // 2페이지 이후: DB 직접 조회 +// Long lastRootCommentId = cursor.getLong(0); +// commentQueryDtos = commentJpaRepository.findRootCommentsWithDeletedByCreatedAtDesc(postId, lastRootCommentId, size); +// } + return CursorBasedList.of(commentQueryDtos, size, commentQueryDto -> { Cursor nextCursor = new Cursor(List.of(commentQueryDto.commentId().toString())); return nextCursor.toEncodedString(); @@ -32,21 +47,11 @@ public CursorBasedList findLatestRootCommentsWithDeleted(Long p } @Override - public List findAllActiveChildCommentsOldestFirst(Long rootCommentId) { - return commentJpaRepository.findAllActiveChildCommentsByCreatedAtAsc(rootCommentId); - } - - @Override - public Map> findAllActiveChildCommentsOldestFirst(Set rootCommentIds) { - return commentJpaRepository.findAllActiveChildCommentsByCreatedAtAsc(rootCommentIds); - } - - @Override - public CursorBasedList findChildComments(Long rootCommentId, Cursor cursor) { - Long lastChildCommentId = cursor.isFirstRequest() ? null : cursor.getLong(0); + public CursorBasedList findAllDescendantComments(Long rootCommentId, Cursor cursor) { + Long lastCommentId = cursor.isFirstRequest() ? null : cursor.getLong(0); int size = cursor.getPageSize(); - List commentQueryDtos = commentJpaRepository.findChildCommentsByCreatedAtAsc(rootCommentId, lastChildCommentId, size); + List commentQueryDtos = commentJpaRepository.findAllDescendantCommentsByCreatedAtAsc(rootCommentId, lastCommentId, size); return CursorBasedList.of(commentQueryDtos, size, commentQueryDto -> { Cursor nextCursor = new Cursor(List.of(commentQueryDto.commentId().toString())); diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java index 410b58686..7f7249db5 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java @@ -3,18 +3,12 @@ import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; import java.util.List; -import java.util.Map; -import java.util.Set; public interface CommentQueryRepository { List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, Long lastRootCommentId, int size); - List findAllActiveChildCommentsByCreatedAtAsc(Long rootCommentId); - - Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds); - - List findChildCommentsByCreatedAtAsc(Long rootCommentId, Long lastChildCommentId, int size); + List findAllDescendantCommentsByCreatedAtAsc(Long rootCommentId, Long lastCommentId, int size); CommentQueryDto findRootCommentId(Long commentId); diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java index 4074d8f38..e4a9dcf37 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java @@ -43,7 +43,8 @@ public List findRootCommentsWithDeletedByCreatedAtDesc(Long pos comment.createdAt, comment.content, comment.likeCount, - comment.status.eq(StatusType.INACTIVE) // 루트 댓글이 삭제된 상태인지 아닌지 + comment.status.eq(StatusType.INACTIVE), // 루트 댓글이 삭제된 상태인지 아닌지 + comment.descendantCount // 자식 댓글 수 ); // WHERE 절 분리 @@ -67,111 +68,35 @@ public List findRootCommentsWithDeletedByCreatedAtDesc(Long pos } @Override - public List findAllActiveChildCommentsByCreatedAtAsc(Long rootCommentId) { - List allDescendants = new ArrayList<>(); // 결과 누적용 리스트 - - // 1) 부모 ID 집합에 루트 댓글 ID 추가 - Set parentIds = new HashSet<>(); - parentIds.add(rootCommentId); - - // 2) 자손 댓글용 프로젝션 (부모 댓글 ID·작성자 닉네임 포함) - QCommentQueryDto childProj = new QCommentQueryDto( - comment.commentId, - comment.parent.commentId, - parentCommentCreator.nickname, - commentCreator.userId, - commentCreator.alias, - commentCreator.nickname, - comment.createdAt, - comment.content, - comment.likeCount, - comment.status.eq(StatusType.INACTIVE) - ); - - // 3) 단계별 자식 댓글 조회 - while (!parentIds.isEmpty()) { - List children = queryFactory - .select(childProj) - .from(comment) - .leftJoin(comment.parent, parentComment) - .leftJoin(parentComment.userJpaEntity, parentCommentCreator) - .join(comment.userJpaEntity, commentCreator) - .where( - comment.parent.commentId.in(parentIds), // parentIds 하위의 모든 자식 댓글 조회 - comment.status.eq(ACTIVE), // 자식 댓글은 ACTIVE인 것만 조회 - commentCreator.status.eq(ACTIVE) // 자식 댓글 작성자 ACTIVE - ) - .fetch(); - - if (children.isEmpty()) break; - - // 4) 누적 및 다음 단계 부모 ID 집합 갱신 - allDescendants.addAll(children); - parentIds = children.stream() - .map(CommentQueryDto::commentId) - .collect(Collectors.toSet()); - } - - // 5) 전체 자손 댓글을 깊이와 상관없이 작성 순으로 재정렬 - allDescendants.sort(Comparator.comparing(CommentQueryDto::createdAt)); - return allDescendants; - } + public CommentQueryDto findRootCommentId(Long rootCommentId) { - @Override - public List findChildCommentsByCreatedAtAsc(Long rootCommentId, Long lastChildCommentId, int size) { - // 자식 댓글(size+1) 프로젝션 생성 QCommentQueryDto proj = new QCommentQueryDto( comment.commentId, - comment.parent.commentId, - parentCommentCreator.nickname, commentCreator.userId, commentCreator.alias, commentCreator.nickname, comment.createdAt, comment.content, comment.likeCount, - comment.status.eq(StatusType.INACTIVE) + comment.status.eq(StatusType.INACTIVE), + comment.descendantCount // 자식 댓글 수 ); - // WHERE 절 분리 - BooleanExpression whereClause = comment.parent.commentId.eq(rootCommentId) - .and(lastChildCommentId != null - ? comment.commentId.gt(lastChildCommentId) - : Expressions.TRUE - ); - - // 조회 및 반환 return queryFactory .select(proj) .from(comment) - .join(comment.parent, parentComment) - .join(parentComment.userJpaEntity, parentCommentCreator) .join(comment.userJpaEntity, commentCreator) - .where(whereClause) - .orderBy(comment.commentId.asc()) - .limit(size + 1) - .fetch(); + .where( + comment.commentId.eq(rootCommentId), + comment.status.eq(ACTIVE) + ) + .fetchOne(); } @Override - public Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds) { - // 1) 루트 ID별로 최상위 매핑 초기화 - Map idToRoot = new HashMap<>(); - for (Long rootId : rootCommentIds) { - idToRoot.put(rootId, rootId); // 초기화 - } - - // 2) 결과 맵 초기화 - Map> resultMap = new HashMap<>(); - for (Long rootId : rootCommentIds) { - resultMap.put(rootId, new ArrayList<>()); - } - - // 3) 단계별 조회용 parentIds 초기화 - Set parentIds = new HashSet<>(rootCommentIds); + public CommentQueryDto findChildCommentId(Long rootCommentId, Long replyCommentId) { - // 4) 자손 댓글용 프로젝션 정의 - QCommentQueryDto childProj = new QCommentQueryDto( + QCommentQueryDto proj = new QCommentQueryDto( comment.commentId, comment.parent.commentId, parentCommentCreator.nickname, @@ -184,70 +109,24 @@ public Map> findAllActiveChildCommentsByCreatedAtAsc comment.status.eq(StatusType.INACTIVE) ); - // 5) 루프를 돌며 모든 깊이의 자식 댓글 조회 및 매핑 - while (!parentIds.isEmpty()) { - List children = queryFactory - .select(childProj) - .from(comment) - .leftJoin(comment.parent, parentComment) - .leftJoin(parentComment.userJpaEntity, parentCommentCreator) - .join(comment.userJpaEntity, commentCreator) - .where( - comment.parent.commentId.in(parentIds), // parentIds 하위의 모든 자식 댓글 조회 - comment.status.eq(ACTIVE), // 자식 댓글은 ACTIVE인 것만 조회 - commentCreator.status.eq(ACTIVE) // 자식 댓글 작성자 ACTIVE - ) - .fetch(); - - if (children.isEmpty()) break; - - Set nextParentIds = new HashSet<>(); - for (CommentQueryDto child : children) { // 조회한 자식 댓글들에 대하여 - Long rootId = idToRoot.get(child.parentCommentId()); // 현재 자식댓글의 루트 댓글(부모 아님, 루트임) - - resultMap.get(rootId).add(child); // 해당 루트 ID의 리스트에 자식 댓글 추가 - - // 현재 자식 댓글도 다음 단계의 parentIds로 사용하기 위해 매핑 저장 - idToRoot.put(child.commentId(), rootId); - nextParentIds.add(child.commentId()); - } - parentIds = nextParentIds; // 한단계 아래 계층에서 활용할 부모 댓글들 - } - - // 6) 각 루트별 value 리스트를 작성시간순으로 정렬 - resultMap.values().forEach(list -> list.sort(Comparator.comparing(CommentQueryDto::createdAt))); - - return resultMap; - } - - @Override - public CommentQueryDto findRootCommentId(Long rootCommentId) { - - QCommentQueryDto proj = new QCommentQueryDto( - comment.commentId, - commentCreator.userId, - commentCreator.alias, - commentCreator.nickname, - comment.createdAt, - comment.content, - comment.likeCount, - comment.status.eq(StatusType.INACTIVE) - ); - return queryFactory .select(proj) .from(comment) + .join(comment.parent, parentComment) + .join(parentComment.userJpaEntity, parentCommentCreator) .join(comment.userJpaEntity, commentCreator) .where( - comment.commentId.eq(rootCommentId), - comment.status.eq(ACTIVE) + comment.parent.commentId.eq(rootCommentId), + parentComment.status.eq(ACTIVE), + comment.status.eq(ACTIVE), + comment.commentId.eq(replyCommentId) ) .fetchOne(); } @Override - public CommentQueryDto findChildCommentId(Long rootCommentId, Long replyCommentId) { - + public List findAllDescendantCommentsByCreatedAtAsc(Long rootCommentId, Long lastCommentId, int size) { + // 자손 댓글용 프로젝션 (부모 댓글 ID·작성자 닉네임 포함) QCommentQueryDto proj = new QCommentQueryDto( comment.commentId, comment.parent.commentId, @@ -261,18 +140,23 @@ public CommentQueryDto findChildCommentId(Long rootCommentId, Long replyCommentI comment.status.eq(StatusType.INACTIVE) ); + // WHERE 절: root_comment_id 기반 + BooleanExpression whereClause = comment.root.commentId.eq(rootCommentId) + .and(lastCommentId != null + ? comment.commentId.gt(lastCommentId) + : Expressions.TRUE + ); + + // 쿼리: 한 번에 끝! return queryFactory .select(proj) .from(comment) - .join(comment.parent, parentComment) - .join(parentComment.userJpaEntity, parentCommentCreator) + .leftJoin(comment.parent, parentComment) + .leftJoin(parentComment.userJpaEntity, parentCommentCreator) .join(comment.userJpaEntity, commentCreator) - .where( - comment.parent.commentId.eq(rootCommentId), - parentComment.status.eq(ACTIVE), - comment.status.eq(ACTIVE), - comment.commentId.eq(replyCommentId) - ) - .fetchOne(); + .where(whereClause) + .orderBy(comment.commentId.asc()) + .limit(size + 1) + .fetch(); } } diff --git a/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java b/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java index b9ea8de0a..f5a5d4b1c 100644 --- a/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java +++ b/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java @@ -2,13 +2,11 @@ import konkuk.thip.comment.adapter.in.web.response.ChildCommentsResponse; import konkuk.thip.comment.adapter.in.web.response.CommentCreateResponse; -import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; +import konkuk.thip.comment.adapter.in.web.response.RootCommentsResponse; import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; import konkuk.thip.common.util.DateUtil; import org.mapstruct.*; -import java.util.Collections; -import java.util.List; import java.util.Set; @Mapper( @@ -19,15 +17,14 @@ public interface CommentQueryMapper { /** - * 정상(root) 댓글 매핑 (답글 제외) + * 루트 댓글 조회 API용 매핑 */ - @Mapping(target = "replyList", expression = "java(new java.util.ArrayList<>())") @Mapping(target = "isLike", expression = "java(likedCommentIds.contains(root.commentId()))") @Mapping(target = "isDeleted", constant = "false") @Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(root.createdAt()))") @Mapping(target = "aliasName", source = "root.alias") @Mapping(target = "isWriter", source = "root.creatorId", qualifiedByName = "isWriter") - CommentForSinglePostResponse.RootCommentDto toRoot(CommentQueryDto root, @Context Set likedCommentIds, @Context Long userId); + RootCommentsResponse.RootCommentDto toRootCommentResponse(CommentQueryDto root, @Context Set likedCommentIds, @Context Long userId); // 댓글/답글 생성시 루트 댓글 매핑 @Mapping(target = "replyList", expression = "java(new java.util.ArrayList<>())") @@ -38,15 +35,6 @@ public interface CommentQueryMapper { @Mapping(target = "isWriter", source = "root.creatorId", qualifiedByName = "isWriter") CommentCreateResponse toRoot(CommentQueryDto root, boolean isLike, @Context Long userId); - /** - * 개별 답글 매핑 - */ - @Mapping(target = "isLike", expression = "java(likedCommentIds.contains(child.commentId()))") - @Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(child.createdAt()))") - @Mapping(target = "aliasName", source = "child.alias") - @Mapping(target = "isWriter", source = "child.creatorId", qualifiedByName = "isWriter") - CommentForSinglePostResponse.RootCommentDto.ReplyDto toReply(CommentQueryDto child, @Context Set likedCommentIds, @Context Long userId); - // 답글 생성시 답글 매핑 @Mapping(target = "isLike", constant = "false") @Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(child.createdAt()))") @@ -64,30 +52,8 @@ public interface CommentQueryMapper { ChildCommentsResponse.ChildCommentDto toChildComment(CommentQueryDto child, @Context Set likedCommentIds, @Context Long userId); /** - * 답글 리스트 헬퍼 + * 댓글 생성 시 루트 댓글과 답글을 함께 반환 */ - default List mapReplies(List children, @Context Set likedCommentIds, @Context Long userId) { - if (children == null || children.isEmpty()) { - return Collections.emptyList(); - } - return children.stream() - .map(child -> toReply(child, likedCommentIds, userId)) - .toList(); - } - - default CommentForSinglePostResponse.RootCommentDto toRootCommentResponseWithChildren( - CommentQueryDto root, List children, @Context Set likedCommentIds, @Context Long userId) { - List replyDtos = mapReplies(children, likedCommentIds, userId); - - if (root.isDeleted()) { // 삭제된 루트 & children 이 존재하는 경우 - return CommentForSinglePostResponse.RootCommentDto.createDeletedRootCommentDto(replyDtos); - } - - CommentForSinglePostResponse.RootCommentDto rootDto = toRoot(root, likedCommentIds, userId); - rootDto.replyList().addAll(replyDtos); - return rootDto; - } - default CommentCreateResponse toRootCommentResponseWithChildren( CommentQueryDto root, CommentQueryDto children, boolean isLikedParentComment, @Context Long userId) { CommentCreateResponse.ReplyCommentCreateDto replyDto = toReply(children, userId); diff --git a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java index 808e33cb2..dff4a0fe1 100644 --- a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java +++ b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java @@ -4,19 +4,11 @@ import konkuk.thip.common.util.Cursor; import konkuk.thip.common.util.CursorBasedList; -import java.util.List; -import java.util.Map; -import java.util.Set; - public interface CommentQueryPort { CursorBasedList findLatestRootCommentsWithDeleted(Long postId, Cursor cursor); - List findAllActiveChildCommentsOldestFirst(Long rootCommentId); - - Map> findAllActiveChildCommentsOldestFirst(Set rootCommentIds); - - CursorBasedList findChildComments(Long rootCommentId, Cursor cursor); + CursorBasedList findAllDescendantComments(Long rootCommentId, Cursor cursor); CommentQueryDto findRootCommentById(Long rootCommentId); diff --git a/src/main/java/konkuk/thip/comment/application/port/out/dto/CommentQueryDto.java b/src/main/java/konkuk/thip/comment/application/port/out/dto/CommentQueryDto.java index 79194d84d..be4720da7 100644 --- a/src/main/java/konkuk/thip/comment/application/port/out/dto/CommentQueryDto.java +++ b/src/main/java/konkuk/thip/comment/application/port/out/dto/CommentQueryDto.java @@ -18,7 +18,8 @@ public record CommentQueryDto( LocalDateTime createdAt, // 댓글 작성 시각 String content, int likeCount, - Boolean isDeleted + Boolean isDeleted, + int descendantCount // 자식 댓글 수 (루트 댓글만 사용, 자식 댓글은 0) ) { /** * child comment @@ -38,7 +39,7 @@ public CommentQueryDto ( ) { this(commentId, parentCommentId, parentCommentCreatorNickname, creatorId, creatorAlias.getImageUrl(), creatorNickname, creatorAlias.getValue(), creatorAlias.getColor(), - createdAt, content, likeCount, isDeleted); + createdAt, content, likeCount, isDeleted, 0); // 자식 댓글은 descendantCount 0 } /** @@ -53,10 +54,11 @@ public CommentQueryDto ( LocalDateTime createdAt, // 댓글 작성 시각 String content, int likeCount, - boolean isDeleted + boolean isDeleted, + int descendantCount ) { this(commentId, null, null, creatorId, creatorAlias.getImageUrl(), creatorNickname, creatorAlias.getValue(), creatorAlias.getColor(), - createdAt, content, likeCount, isDeleted); + createdAt, content, likeCount, isDeleted, descendantCount); } } From afe0717ef56b1f077496ffe2777910daa984218f Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 15 Feb 2026 17:47:00 +0900 Subject: [PATCH 12/21] =?UTF-8?q?[refactor]=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EB=B6=84=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/ChildCommentShowApiTest.java | 40 ++++++++ ...iTest.java => RootCommentShowApiTest.java} | 99 +++++++++---------- .../thip/common/util/TestEntityFactory.java | 14 +++ .../in/web/FeedCreateControllerTest.java | 1 - .../web/RoomVerifyPasswordControllerTest.java | 9 +- 5 files changed, 103 insertions(+), 60 deletions(-) rename src/test/java/konkuk/thip/comment/adapter/in/web/{CommentShowAllApiTest.java => RootCommentShowApiTest.java} (78%) diff --git a/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentShowApiTest.java b/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentShowApiTest.java index 3bea13d23..37603966e 100644 --- a/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentShowApiTest.java +++ b/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentShowApiTest.java @@ -48,6 +48,46 @@ class ChildCommentShowApiTest { @Autowired private CommentLikeJpaRepository commentLikeJpaRepository; @Autowired private JdbcTemplate jdbcTemplate; + @Test + @DisplayName("Depth가 3단계 이상인 대댓글들도 모두 평탄화(Flat)되어 루트 댓글 하위로 조회된다.") + void child_comment_show_depth_test() throws Exception { + //given + Alias a0 = TestEntityFactory.createScienceAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + + // 1. 루트 댓글 생성 + CommentJpaEntity root = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "ROOT", 0)); + + // 2. Depth 1 자식 생성 (Parent: Root) + CommentJpaEntity depth1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, me, PostType.FEED, root, "Depth1", 0)); + + // 3. Depth 2 자식 생성 (Parent: Depth1) -> Factory에 의해 Root는 'root'로 설정됨 + CommentJpaEntity depth2 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, depth1, "Depth2", 0)); + + // 4. Depth 3 자식 생성 (Parent: Depth2) -> Factory에 의해 Root는 'root'로 설정됨 + CommentJpaEntity depth3 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, me, PostType.FEED, depth2, "Depth3", 0)); + + // 5. Depth 1 형제 생성 (Parent: Root) + CommentJpaEntity depth1_sibling = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, root, "Depth1_Sibling", 0)); + + //when //then + mockMvc.perform(get("/comments/replies/{rootCommentId}", root.getCommentId().intValue()) + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.childComments", hasSize(4))) // 총 4개의 자손 댓글 + // 순서 검증 (ID 생성순 = 작성순) + .andExpect(jsonPath("$.data.childComments[0].content", is("Depth1"))) + .andExpect(jsonPath("$.data.childComments[1].content", is("Depth2"))) + .andExpect(jsonPath("$.data.childComments[2].content", is("Depth3"))) + .andExpect(jsonPath("$.data.childComments[3].content", is("Depth1_Sibling"))) + // 계층 구조 검증 (평탄화되었지만 부모 닉네임은 직계 부모를 따라가야 함) + .andExpect(jsonPath("$.data.childComments[1].parentCommentCreatorNickname", is("me"))) // Depth2의 부모는 Depth1(me) + .andExpect(jsonPath("$.data.childComments[2].parentCommentCreatorNickname", is("user1"))); // Depth3의 부모는 Depth2(user1) + } + @Test @DisplayName("특정 루트 댓글의 자식 댓글을 조회할 수 있다.") void child_comment_show_test() throws Exception { diff --git a/src/test/java/konkuk/thip/comment/adapter/in/web/CommentShowAllApiTest.java b/src/test/java/konkuk/thip/comment/adapter/in/web/RootCommentShowApiTest.java similarity index 78% rename from src/test/java/konkuk/thip/comment/adapter/in/web/CommentShowAllApiTest.java rename to src/test/java/konkuk/thip/comment/adapter/in/web/RootCommentShowApiTest.java index 4b7a7637a..58f4765e8 100644 --- a/src/test/java/konkuk/thip/comment/adapter/in/web/CommentShowAllApiTest.java +++ b/src/test/java/konkuk/thip/comment/adapter/in/web/RootCommentShowApiTest.java @@ -38,7 +38,7 @@ @AutoConfigureMockMvc(addFilters = false) @DisplayName("[통합] 댓글 조회 api 통합 테스트") @Transactional -class CommentShowAllApiTest { +class RootCommentShowApiTest { @Autowired private MockMvc mockMvc; @Autowired private UserJpaRepository userJpaRepository; @@ -51,7 +51,7 @@ class CommentShowAllApiTest { private static final String FEED_POST_TYPE = PostType.FEED.getType(); @Test - @DisplayName("댓글 조회 요청에 대하여, 특정 게시글(= 피드, 기록, 투표)의 루트 댓글, 루트 댓글의 모든 자식 댓글의 데이터를 구분하여 반환한다.") + @DisplayName("댓글 조회 요청에 대하여, 특정 게시글(= 피드, 기록, 투표)의 루트 댓글만 반환하고, 자식 댓글 수(descendantCount)를 포함한다.") void comment_show_all_test() throws Exception { //given Alias a0 = TestEntityFactory.createScienceAlias(); @@ -81,6 +81,11 @@ void comment_show_all_test() throws Exception { "UPDATE comments SET created_at = ? WHERE comment_id = ?", Timestamp.valueOf(base.minusMinutes(30)), comment1_1.getCommentId()); + // descendantCount 업데이트 (자식 댓글 1개) + jdbcTemplate.update( + "UPDATE comments SET descendant_count = 1 WHERE comment_id = ?", + comment1.getCommentId()); + //when //then mockMvc.perform(get("/comments/{postId}", f1.getPostId().intValue()) .requestAttr("userId", me.getUserId()) @@ -88,26 +93,20 @@ void comment_show_all_test() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.commentList", hasSize(1))) /** - * 루트 댓글 : 댓글 정보, 댓글 작성자 정보, 좋아요 수, 삭제된 댓글 여부 등을 반환한다 - * 자식 댓글 : 부모 댓글의 작성자 정보(@ 표시를 위함), 댓글 정보, 댓글 작성자 정보, 좋아요 수 등을 반환한다 + * 루트 댓글 : 댓글 정보, 댓글 작성자 정보, 좋아요 수, 삭제된 댓글 여부, 자식 댓글 수 등을 반환한다 + * 자식 댓글 리스트는 반환하지 않는다 (별도 API로 조회) */ .andExpect(jsonPath("$.data.commentList[0].commentId", is(comment1.getCommentId().intValue()))) .andExpect(jsonPath("$.data.commentList[0].creatorNickname", is(user1.getNickname()))) .andExpect(jsonPath("$.data.commentList[0].content", is(comment1.getContent()))) .andExpect(jsonPath("$.data.commentList[0].likeCount", is(comment1.getLikeCount()))) .andExpect(jsonPath("$.data.commentList[0].isLike", is(true))) // me가 comment1을 좋아함 - .andExpect(jsonPath("$.data.commentList[0].replyList", hasSize(1))) // 자식 댓글 1개 존재 - - .andExpect(jsonPath("$.data.commentList[0].replyList[0].parentCommentCreatorNickname", is(user1.getNickname()))) // comment1_1의 부모 댓글(= comment1) 의 작성자 = user1 - .andExpect(jsonPath("$.data.commentList[0].replyList[0].commentId", is(comment1_1.getCommentId().intValue()))) - .andExpect(jsonPath("$.data.commentList[0].replyList[0].creatorNickname", is(me.getNickname()))) - .andExpect(jsonPath("$.data.commentList[0].replyList[0].content", is(comment1_1.getContent()))) - .andExpect(jsonPath("$.data.commentList[0].replyList[0].likeCount", is(comment1_1.getLikeCount()))) - .andExpect(jsonPath("$.data.commentList[0].replyList[0].isLike", is(false))); // me가 comment1_1을 좋아하지 않음 + .andExpect(jsonPath("$.data.commentList[0].isDeleted", is(false))) + .andExpect(jsonPath("$.data.commentList[0].descendantCount", is(1))); // 자식 댓글 1개 } @Test - @DisplayName("루트 댓글은 최신순, 루트 댓글의 모든 자식 댓글은 작성 시각순으로 정렬하여 반환한다.") + @DisplayName("루트 댓글은 최신순으로 정렬하여 반환하고, 각 루트 댓글의 자식 댓글 수(descendantCount)를 포함한다.") void comment_show_all_ordering_test() throws Exception { //given Alias a0 = TestEntityFactory.createScienceAlias(); @@ -149,6 +148,10 @@ void comment_show_all_ordering_test() throws Exception { "UPDATE comments SET created_at = ? WHERE comment_id = ?", Timestamp.valueOf(base.minusMinutes(5)), comment1_1_1.getCommentId()); + // descendantCount 업데이트 (comment1은 자식 댓글 3개, comment2는 0개) + jdbcTemplate.update( + "UPDATE comments SET descendant_count = 3 WHERE comment_id = ?", + comment1.getCommentId()); //when //then mockMvc.perform(get("/comments/{postId}", f1.getPostId().intValue()) @@ -157,29 +160,19 @@ void comment_show_all_ordering_test() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.commentList", hasSize(2))) /** - * 정렬 조건 - * 게시글에 바로 달린 댓글들(= 루트 댓글) : 최신순 정렬 - * 루트 댓글의 모든 하위 댓글들 : 작성 시간 순 정렬 (최신순 역순) + * 정렬 조건: 게시글에 바로 달린 댓글들(= 루트 댓글)은 최신순 정렬 + * 루트 댓글의 자식 댓글 리스트는 반환하지 않고, descendantCount만 반환 */ - // 루트 댓글 정렬 확인 + // 루트 댓글 정렬 확인 (최신순: comment2 -> comment1) .andExpect(jsonPath("$.data.commentList[0].commentId", is(comment2.getCommentId().intValue()))) - .andExpect(jsonPath("$.data.commentList[1].commentId", is(comment1.getCommentId().intValue()))) - // 루트 댓글의 모든 자식 댓글 정렬 확인 - .andExpect(jsonPath("$.data.commentList[0].replyList", hasSize(0))) // comment2 는 자식 댓글 없음 - .andExpect(jsonPath("$.data.commentList[1].replyList", hasSize(3))) // comment1 은 총 3개의 자식 댓글 있음 - - .andExpect(jsonPath("$.data.commentList[1].replyList[0].commentId", is(comment1_1.getCommentId().intValue()))) - .andExpect(jsonPath("$.data.commentList[1].replyList[0].parentCommentCreatorNickname", is(user1.getNickname()))) // comment1_1의 부모 댓글(= comment1) 작성자 = user1 + .andExpect(jsonPath("$.data.commentList[0].descendantCount", is(0))) // comment2는 자식 댓글 없음 - .andExpect(jsonPath("$.data.commentList[1].replyList[1].commentId", is(comment1_2.getCommentId().intValue()))) - .andExpect(jsonPath("$.data.commentList[1].replyList[1].parentCommentCreatorNickname", is(user1.getNickname()))) // comment1_1의 부모 댓글(= comment1) 작성자 = user1 - - .andExpect(jsonPath("$.data.commentList[1].replyList[2].commentId", is(comment1_1_1.getCommentId().intValue()))) - .andExpect(jsonPath("$.data.commentList[1].replyList[2].parentCommentCreatorNickname", is(user3.getNickname()))); // comment1_1_1의 부모 댓글(= comment1_1) 작성자 = user3 + .andExpect(jsonPath("$.data.commentList[1].commentId", is(comment1.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[1].descendantCount", is(3))); // comment1은 총 3개의 자식 댓글 } @Test - @DisplayName("삭제된 루트 댓글의 경우, 자식 댓글이 있으면 반환하고, 없으면 반환하지 않는다.") + @DisplayName("삭제된 루트 댓글의 경우, 자식 댓글 개수가 0보다 크면 쓰레기 값으로 반환하고, 0이면 응답에서 제외한다.") void comment_show_all_deleted_root_comment_test() throws Exception { //given Alias a0 = TestEntityFactory.createScienceAlias(); @@ -191,9 +184,9 @@ void comment_show_all_deleted_root_comment_test() throws Exception { // 피드, 댓글, 자식 댓글 생성 및 생성일 직접 설정 LocalDateTime base = LocalDateTime.now(); FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment2 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글2", 5)); CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글1", 5)); CommentJpaEntity comment1_1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, me, PostType.FEED, comment1, "댓글1_답글1", 8)); - CommentJpaEntity comment2 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글2", 5)); feedJpaRepository.flush(); jdbcTemplate.update( @@ -203,17 +196,20 @@ void comment_show_all_deleted_root_comment_test() throws Exception { commentJpaRepository.flush(); jdbcTemplate.update( "UPDATE comments SET created_at = ? WHERE comment_id = ?", - Timestamp.valueOf(base.minusMinutes(40)), comment1.getCommentId()); + Timestamp.valueOf(base.minusMinutes(40)), comment2.getCommentId()); jdbcTemplate.update( "UPDATE comments SET created_at = ? WHERE comment_id = ?", - Timestamp.valueOf(base.minusMinutes(30)), comment1_1.getCommentId()); + Timestamp.valueOf(base.minusMinutes(30)), comment1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(20)), comment1_1.getCommentId()); - // comment1, 2 soft delete + // comment1, comment2 soft delete (descendantCount는 업데이트하지 않고 테스트) jdbcTemplate.update( - "UPDATE comments SET status = 'INACTIVE' WHERE comment_id = ?", + "UPDATE comments SET status = 'INACTIVE', descendant_count = 1 WHERE comment_id = ?", comment1.getCommentId()); jdbcTemplate.update( - "UPDATE comments SET status = 'INACTIVE' WHERE comment_id = ?", + "UPDATE comments SET status = 'INACTIVE', descendant_count = 0 WHERE comment_id = ?", comment2.getCommentId()); //when //then @@ -221,23 +217,17 @@ void comment_show_all_deleted_root_comment_test() throws Exception { .requestAttr("userId", me.getUserId()) .param("postType", FEED_POST_TYPE)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.commentList", hasSize(1))) // comment1 만 조회된다 + .andExpect(jsonPath("$.data.commentList", hasSize(1))) // comment1만 조회됨 (descendantCount > 0), comment2는 제외 (descendantCount == 0) /** - * 루트 댓글 : - * 자식 댓글 : 부모 댓글의 작성자 정보(@ 표시를 위함), 댓글 정보, 댓글 작성자 정보, 좋아요 수 등을 반환한다 + * 삭제된 루트 댓글 처리: + * - descendantCount > 0: 쓰레기 값으로 반환 (isDeleted=true, descendantCount=실제값, 나머지는 null) + * - descendantCount == 0: 응답에서 제외 */ .andExpect(jsonPath("$.data.commentList[0].commentId", nullValue())) .andExpect(jsonPath("$.data.commentList[0].creatorNickname", nullValue())) .andExpect(jsonPath("$.data.commentList[0].content", nullValue())) .andExpect(jsonPath("$.data.commentList[0].isDeleted", is(true))) - .andExpect(jsonPath("$.data.commentList[0].replyList", hasSize(1))) // 자식 댓글 1개 존재 - - .andExpect(jsonPath("$.data.commentList[0].replyList[0].parentCommentCreatorNickname", is(user1.getNickname()))) // comment1_1의 부모 댓글(= comment1) 의 작성자 = user1 - .andExpect(jsonPath("$.data.commentList[0].replyList[0].commentId", is(comment1_1.getCommentId().intValue()))) - .andExpect(jsonPath("$.data.commentList[0].replyList[0].creatorNickname", is(me.getNickname()))) - .andExpect(jsonPath("$.data.commentList[0].replyList[0].content", is(comment1_1.getContent()))) - .andExpect(jsonPath("$.data.commentList[0].replyList[0].likeCount", is(comment1_1.getLikeCount()))) - .andExpect(jsonPath("$.data.commentList[0].replyList[0].isLike", is(false))); // me가 comment1_1을 좋아하지 않음 + .andExpect(jsonPath("$.data.commentList[0].descendantCount", is(1))); // 자식 댓글 1개 (실제 값) } @Test @@ -318,12 +308,14 @@ void comment_show_all_page_test() throws Exception { .andExpect(jsonPath("$.data.isLast", is(false))) .andExpect(jsonPath("$.data.commentList", hasSize(10))) /** - * 루트 댓글 : 댓글 정보, 댓글 작성자 정보, 좋아요 수, 삭제된 댓글 여부 등을 반환한다 - * 자식 댓글 : 부모 댓글의 작성자 정보(@ 표시를 위함), 댓글 정보, 댓글 작성자 정보, 좋아요 수 등을 반환한다 + * 루트 댓글만 반환, 자식 댓글 리스트는 포함하지 않음 + * descendantCount는 모두 0 (자식 댓글이 없음) */ - // 루트 댓글 정렬 확인 + // 루트 댓글 정렬 확인 (최신순) .andExpect(jsonPath("$.data.commentList[0].commentId", is(comment12.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[0].descendantCount", is(0))) .andExpect(jsonPath("$.data.commentList[1].commentId", is(comment11.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[1].descendantCount", is(0))) .andExpect(jsonPath("$.data.commentList[2].commentId", is(comment10.getCommentId().intValue()))) .andExpect(jsonPath("$.data.commentList[3].commentId", is(comment9.getCommentId().intValue()))) .andExpect(jsonPath("$.data.commentList[4].commentId", is(comment8.getCommentId().intValue()))) @@ -346,11 +338,12 @@ void comment_show_all_page_test() throws Exception { .andExpect(jsonPath("$.data.isLast", is(true))) .andExpect(jsonPath("$.data.commentList", hasSize(2))) /** - * 루트 댓글 : 댓글 정보, 댓글 작성자 정보, 좋아요 수, 삭제된 댓글 여부 등을 반환한다 - * 자식 댓글 : 부모 댓글의 작성자 정보(@ 표시를 위함), 댓글 정보, 댓글 작성자 정보, 좋아요 수 등을 반환한다 + * 루트 댓글만 반환, descendantCount 포함 */ // 루트 댓글 정렬 확인 .andExpect(jsonPath("$.data.commentList[0].commentId", is(comment2.getCommentId().intValue()))) - .andExpect(jsonPath("$.data.commentList[1].commentId", is(comment1.getCommentId().intValue()))); + .andExpect(jsonPath("$.data.commentList[0].descendantCount", is(0))) + .andExpect(jsonPath("$.data.commentList[1].commentId", is(comment1.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[1].descendantCount", is(0))); } } diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index a70d734f4..edacaef48 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -239,6 +239,8 @@ public static CommentJpaEntity createComment(PostJpaEntity post, UserJpaEntity u .likeCount(0) .reportCount(0) .postType(postType) + .parent(null) // 루트 댓글이므로 parent는 null + .root(null) // 루트 댓글이므로 root는 null .build(); } @@ -253,10 +255,17 @@ public static CommentJpaEntity createComment(PostJpaEntity post, UserJpaEntity u .likeCount(likeCount) .reportCount(0) .postType(postType) + .parent(null) // 루트 댓글이므로 parent는 null + .root(null) // 루트 댓글이므로 root는 null .build(); } public static CommentJpaEntity createReplyComment(PostJpaEntity post, UserJpaEntity user,PostType postType,CommentJpaEntity parentComment) { + // [수정] 대댓글 생성 시 Root 댓글 자동 계산 로직 + // 1. 부모가 이미 Root를 가지고 있다면(대댓글의 답글인 경우) -> 그 Root를 승계 + // 2. 부모가 Root가 없다면(부모가 최상위 댓글인 경우) -> 부모 자체가 Root + CommentJpaEntity root = parentComment.getRoot() != null ? parentComment.getRoot() : parentComment; + return CommentJpaEntity.builder() .content("댓글 내용") .postJpaEntity(post) @@ -265,6 +274,7 @@ public static CommentJpaEntity createReplyComment(PostJpaEntity post, UserJpaEnt .reportCount(0) .postType(postType) .parent(parentComment) + .root(root) // [추가] root 필드 세팅 .build(); } @@ -272,6 +282,9 @@ public static CommentJpaEntity createReplyComment(PostJpaEntity post, UserJpaEnt * 자식 댓글 내용, likeCount 커스텀 */ public static CommentJpaEntity createReplyComment(PostJpaEntity post, UserJpaEntity user, PostType postType, CommentJpaEntity parentComment, String content, int likeCount) { + // [수정] 동일한 Root 계산 로직 적용 + CommentJpaEntity root = parentComment.getRoot() != null ? parentComment.getRoot() : parentComment; + return CommentJpaEntity.builder() .content(content) .postJpaEntity(post) @@ -280,6 +293,7 @@ public static CommentJpaEntity createReplyComment(PostJpaEntity post, UserJpaEnt .reportCount(0) .postType(postType) .parent(parentComment) + .root(root) // [추가] root 필드 세팅 .build(); } diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java index 0e7f3e521..bb0bdb936 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java @@ -44,7 +44,6 @@ private Map buildValidRequest() { request.put("isbn", "9788954682152"); request.put("contentBody", "테스트 콘텐츠"); request.put("isPublic", true); - request.put("category", "문학"); request.put("tagList", List.of(Tag.PHYSICS.getValue(), Tag.CHEMISTRY.getValue())); return request; } diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomVerifyPasswordControllerTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomVerifyPasswordControllerTest.java index bc930cf66..a62bfede6 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomVerifyPasswordControllerTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomVerifyPasswordControllerTest.java @@ -34,15 +34,13 @@ class RoomVerifyPasswordControllerTest { private Map buildValidRequest() { Map request = new HashMap<>(); - request.put("userId", 1L); - request.put("roomId", 1L); request.put("password", "1234"); return request; } private void assertBad(Map req, String msg) throws Exception { - mockMvc.perform(post("/rooms/{roomId}/password", req.get("roomId")) - .requestAttr("userId", req.get("userId")) + mockMvc.perform(post("/rooms/{roomId}/password", 1L) + .requestAttr("userId", 1L) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isBadRequest()) @@ -79,9 +77,8 @@ class RoomIdValidation { @DisplayName("roomId가 없을 때 400 error") void missing_roomId() throws Exception { Map req = buildValidRequest(); - req.remove("roomId"); mockMvc.perform(post("/rooms//password") - .requestAttr("userId", req.get("userId")) + .requestAttr("userId", 1L) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().is4xxClientError()); From 8d8a135293e41ff308b1a52899d7f2aaf3c97f39 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 16 Feb 2026 16:32:38 +0900 Subject: [PATCH 13/21] =?UTF-8?q?[feat]=20=EB=A3=A8=ED=8A=B8=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20API=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - persistence adapter 게층에서 '첫 페이지의 루트 댓글 조회' 시 캐시 조회하도록 설계 - 실제 코드에 반영하지는 않음 (CommentQueryPersistenceAdapter에서 주석 처리) --- .../out/persistence/CommentCacheAdapter.java | 54 +++++++++++ .../out/persistence/CommentCacheKey.java | 15 +++ .../java/konkuk/thip/config/CacheConfig.java | 75 +++++++++++++++ .../out/persistence/CommentCacheTest.java | 96 +++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheAdapter.java create mode 100644 src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheKey.java create mode 100644 src/main/java/konkuk/thip/config/CacheConfig.java create mode 100644 src/test/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheTest.java diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheAdapter.java new file mode 100644 index 000000000..c15e12325 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheAdapter.java @@ -0,0 +1,54 @@ +package konkuk.thip.comment.adapter.out.persistence; + +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static konkuk.thip.comment.adapter.out.persistence.CommentCacheKey.ROOT_COMMENTS; + +/** + * 댓글 캐시 전담 컴포넌트 + * - Spring AOP 프록시가 정상 동작하도록 별도 컴포넌트로 분리 + * - CommentQueryPersistenceAdapter에서 외부 호출을 통해 캐싱 적용 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentCacheAdapter { + + private final CommentJpaRepository commentJpaRepository; + + /** + * 루트 댓글 첫 페이지 조회 (캐싱) + * - 캐시 키: postId + * - 캐시 조건: 결과가 null이 아니고 비어있지 않을 때만 + */ + @Cacheable( + value = CommentCacheKey.ROOT_COMMENTS, + key = "#postId", + unless = "#result == null || #result.isEmpty()" + ) + public List findFirstPageRootCommentsFromCache(Long postId, int pageSize) { + log.debug("Cache miss - Loading first page root comments from DB for postId: {}", postId); + + return commentJpaRepository.findRootCommentsWithDeletedByCreatedAtDesc(postId, null, pageSize); + } + + /** + * 루트 댓글 캐시 삭제 (Evict) + */ + @CacheEvict( + value = CommentCacheKey.ROOT_COMMENTS, + key = "#postId" + ) + public void evictRootCommentsCache(Long postId) { + log.debug("Cache Evict - Removing root comments cache for postId: {}", postId); + } +} + diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheKey.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheKey.java new file mode 100644 index 000000000..958fa7f55 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheKey.java @@ -0,0 +1,15 @@ +package konkuk.thip.comment.adapter.out.persistence; + +/** + * 댓글 관련 캐시 키 상수 + */ +public final class CommentCacheKey { + + private CommentCacheKey() { } + + /** + * 루트 댓글 캐시 키 + * Format: root_comments:{postId} + */ + public static final String ROOT_COMMENTS = "root_comments"; +} diff --git a/src/main/java/konkuk/thip/config/CacheConfig.java b/src/main/java/konkuk/thip/config/CacheConfig.java new file mode 100644 index 000000000..ba4296530 --- /dev/null +++ b/src/main/java/konkuk/thip/config/CacheConfig.java @@ -0,0 +1,75 @@ +package konkuk.thip.config; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class CacheConfig { + + /** + * 캐시 전용 ObjectMapper 설정 + * - LocalDateTime 직렬화를 위한 JavaTimeModule 추가 + * - 타임스탬프 대신 ISO-8601 형식 사용 + * - 타입 정보 포함 (Record 역직렬화를 위함) + * - 주의: 이 ObjectMapper는 캐시 전용이며, HTTP 응답 직렬화에는 영향을 주지 않음 + */ + private ObjectMapper cacheObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + // 타입 정보를 포함하여 직렬화/역직렬화 + BasicPolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class) + .build(); + + mapper.activateDefaultTyping( + validator, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + + return mapper; + } + + /** + * Redis 캐시 매니저 설정 + * - TTL: 5분 (300초) + * - Key: String 직렬화 + * - Value: JSON 직렬화 + */ + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)) // TTL 5분 + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer(cacheObjectMapper()) + ) + ); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } +} + diff --git a/src/test/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheTest.java b/src/test/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheTest.java new file mode 100644 index 000000000..fc82bfa3e --- /dev/null +++ b/src/test/java/konkuk/thip/comment/adapter/out/persistence/CommentCacheTest.java @@ -0,0 +1,96 @@ +package konkuk.thip.comment.adapter.out.persistence; + +import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; +import konkuk.thip.post.domain.PostType; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.cache.CacheManager; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; + +import java.util.List; + +import static konkuk.thip.common.util.TestEntityFactory.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +/** + * 루트 댓글 조회 캐싱 동작 검증 테스트 + * - CommentCacheAdapter를 통한 캐싱 동작 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("[통합] CommentCacheAdapter 테스트") +class CommentCacheTest { + + @Autowired + private CommentCacheAdapter commentCacheAdapter; + + @MockitoSpyBean + private CommentJpaRepository commentJpaRepository; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private EntityManager entityManager; + + @Test + @DisplayName("첫페이지 루트 댓글 조회 시, 캐시를 먼저 조회한다.") + void rootCommentCachingTest() { + // given: 테스트 데이터 준비 + UserJpaEntity user = createUser(Alias.WRITER, "테스터"); + entityManager.persist(user); + + PostJpaEntity post = createRecord(user, null); + entityManager.persist(post); + + CommentJpaEntity comment1 = createComment(post, user, PostType.RECORD, "댓글1", 0); + CommentJpaEntity comment2 = createComment(post, user, PostType.RECORD, "댓글2", 0); + entityManager.persist(comment1); + entityManager.persist(comment2); + entityManager.flush(); + entityManager.clear(); + + Long postId = post.getPostId(); + int pageSize = 10; + + // when: 첫 번째 조회 (Cache Miss - DB 호출) + List firstResult = commentCacheAdapter.findFirstPageRootCommentsFromCache(postId, pageSize); + + // then: DB 조회 1회 발생 + verify(commentJpaRepository, times(1)) + .findRootCommentsWithDeletedByCreatedAtDesc(postId, null, pageSize); + + // then: 첫 번째 조회 결과 검증 + assertThat(firstResult).isNotNull(); + assertThat(firstResult).hasSize(2); + assertThat(firstResult.get(0).content()).isEqualTo("댓글2"); + assertThat(firstResult.get(1).content()).isEqualTo("댓글1"); + + // when: 두 번째 조회 (Cache Hit - DB 호출 안 함) + List secondResult = commentCacheAdapter.findFirstPageRootCommentsFromCache(postId, pageSize); + + // then: DB 조회가 추가로 발생하지 않음 (여전히 1회) - 캐시에서 조회되었음을 의미 + verify(commentJpaRepository, times(1)) + .findRootCommentsWithDeletedByCreatedAtDesc(postId, null, pageSize); + + // 두 번째 조회 결과도 동일한 사이즈와 내용이어야 함 + assertThat(secondResult).isNotNull(); + assertThat(secondResult).hasSize(2); + assertThat(secondResult.get(0).content()).isEqualTo("댓글2"); + assertThat(secondResult.get(1).content()).isEqualTo("댓글1"); + } +} + From 794e70ad5c59dcf41b37c72d17258076f2792692 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 16 Feb 2026 16:46:14 +0900 Subject: [PATCH 14/21] =?UTF-8?q?[refactor]=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20->=20=EB=A3=A8=ED=8A=B8/=EC=9E=90=EC=86=90?= =?UTF-8?q?=20=EB=8C=93=EA=B8=80=20=EC=83=9D=EC=84=B1=20API=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 엔드포인트 분리 --- .../in/web/CommentCommandController.java | 39 ++++++++++++------- .../request/ChildCommentCreateRequest.java | 26 +++++++++++++ .../in/web/request/CommentCreateRequest.java | 37 ------------------ .../web/request/RootCommentCreateRequest.java | 21 ++++++++++ 4 files changed, 71 insertions(+), 52 deletions(-) create mode 100644 src/main/java/konkuk/thip/comment/adapter/in/web/request/ChildCommentCreateRequest.java delete mode 100644 src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentCreateRequest.java create mode 100644 src/main/java/konkuk/thip/comment/adapter/in/web/request/RootCommentCreateRequest.java diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentCommandController.java b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentCommandController.java index ccc5555ee..41e67d040 100644 --- a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentCommandController.java +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentCommandController.java @@ -4,12 +4,14 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import konkuk.thip.comment.adapter.in.web.request.CommentCreateRequest; +import konkuk.thip.comment.adapter.in.web.request.RootCommentCreateRequest; +import konkuk.thip.comment.adapter.in.web.request.ChildCommentCreateRequest; import konkuk.thip.comment.adapter.in.web.request.CommentIsLikeRequest; import konkuk.thip.comment.adapter.in.web.response.CommentDeleteResponse; import konkuk.thip.comment.adapter.in.web.response.CommentCreateResponse; import konkuk.thip.comment.adapter.in.web.response.CommentIsLikeResponse; -import konkuk.thip.comment.application.port.in.CommentCreateUseCase; +import konkuk.thip.comment.application.port.in.RootCommentCreateUseCase; +import konkuk.thip.comment.application.port.in.ChildCommentCreateUseCase; import konkuk.thip.comment.application.port.in.CommentDeleteUseCase; import konkuk.thip.comment.application.port.in.CommentLikeUseCase; import konkuk.thip.common.dto.BaseResponse; @@ -25,28 +27,35 @@ @RequiredArgsConstructor public class CommentCommandController { - private final CommentCreateUseCase commentCreateUseCase; + private final RootCommentCreateUseCase rootCommentCreateUseCase; + private final ChildCommentCreateUseCase childCommentCreateUseCase; private final CommentLikeUseCase commentLikeUseCase; private final CommentDeleteUseCase commentDeleteUseCase; - /** - * 댓글/답글 작성 - * parentId:{Long},isReplyRequest:true 답글 - * parentId:null,isReplyRequest:false 댓글 - */ @Operation( - summary = "댓글 작성", - description = "사용자가 댓글을 작성합니다.\n" + - "답글 작성 시 parentId를 지정하고 isReplyRequest를 true로 설정합니다. " + - "댓글 작성 시 parentId는 null로 설정하고 isReplyRequest를 false로 설정합니다." + summary = "루트 댓글 작성", + description = "특정 게시글에 루트 댓글을 작성합니다." ) @ExceptionDescription(COMMENT_CREATE) @PostMapping("/comments/{postId}") - public BaseResponse createComment( - @RequestBody @Valid final CommentCreateRequest request, + public BaseResponse createRootComment( + @RequestBody @Valid final RootCommentCreateRequest request, @Parameter(description = "댓글을 작성하려는 게시물 ID", example = "1") @PathVariable("postId") final Long postId, @Parameter(hidden = true) @UserId final Long userId) { - return BaseResponse.ok(commentCreateUseCase.createComment(request.toCommand(userId,postId))); + return BaseResponse.ok(rootCommentCreateUseCase.createRootComment(request.toCommand(userId, postId))); + } + + @Operation( + summary = "답글 작성", + description = "특정 댓글에 답글(자식 댓글)을 작성합니다." + ) + @ExceptionDescription(COMMENT_CREATE) + @PostMapping("/comments/replies/{parentCommentId}") + public BaseResponse createChildComment( + @RequestBody @Valid final ChildCommentCreateRequest request, + @Parameter(description = "부모 댓글 ID", example = "1") @PathVariable("parentCommentId") final Long parentCommentId, + @Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok(childCommentCreateUseCase.createChildComment(request.toCommand(userId, parentCommentId))); } @Operation( diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/request/ChildCommentCreateRequest.java b/src/main/java/konkuk/thip/comment/adapter/in/web/request/ChildCommentCreateRequest.java new file mode 100644 index 000000000..f72b8b2e1 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/request/ChildCommentCreateRequest.java @@ -0,0 +1,26 @@ +package konkuk.thip.comment.adapter.in.web.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import konkuk.thip.comment.application.port.in.dto.ChildCommentCreateCommand; + +@Schema(description = "자식 댓글(답글) 작성 요청 DTO") +public record ChildCommentCreateRequest( + + @Schema(description = "댓글 내용", example = "좋은 의견이네요!") + @NotBlank(message = "댓글 내용은 필수입니다.") + String content, + + @Schema(description = "게시물 타입 (RECORD, VOTE, FEED)", example = "RECORD") + @NotBlank(message = "게시물 타입은 필수입니다.") + String postType, + + @Schema(description = "게시물 ID", example = "1") + @NotNull(message = "게시물 ID는 필수입니다.") + Long postId +) { + public ChildCommentCreateCommand toCommand(Long userId, Long parentCommentId) { + return new ChildCommentCreateCommand(content, postType, postId, userId, parentCommentId); + } +} diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentCreateRequest.java b/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentCreateRequest.java deleted file mode 100644 index 976864530..000000000 --- a/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentCreateRequest.java +++ /dev/null @@ -1,37 +0,0 @@ -package konkuk.thip.comment.adapter.in.web.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import konkuk.thip.comment.application.port.in.dto.CommentCreateCommand; - -@Schema(description = "댓글 작성 요청 DTO") -public record CommentCreateRequest( - - @Schema(description = "댓글 내용", example = "이 게시물 정말 좋아요!") - @NotBlank(message = "댓글 내용은 필수입니다.") - String content, - - @Schema(description = "답글 여부", example = "true") - @NotNull(message = "답글 여부는 필수입니다.") - Boolean isReplyRequest, - - @Schema(description = "답글의 부모 댓글 ID (답글이 아닐 경우 null)", example = "1") - Long parentId, - - @Schema(description = "게시물 타입 (RECORD, VOTE, FEED)", example = "RECORD") - @NotBlank(message = "게시물 타입은 필수입니다.") - String postType - -) { - public CommentCreateCommand toCommand(Long userId, Long postId) { - return new CommentCreateCommand( - content, - isReplyRequest, - parentId, - postType, - postId, - userId - ); - } -} diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/request/RootCommentCreateRequest.java b/src/main/java/konkuk/thip/comment/adapter/in/web/request/RootCommentCreateRequest.java new file mode 100644 index 000000000..a0913430a --- /dev/null +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/request/RootCommentCreateRequest.java @@ -0,0 +1,21 @@ +package konkuk.thip.comment.adapter.in.web.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import konkuk.thip.comment.application.port.in.dto.RootCommentCreateCommand; + +@Schema(description = "루트 댓글 작성 요청 DTO") +public record RootCommentCreateRequest( + + @Schema(description = "댓글 내용", example = "이 게시물 정말 좋아요!") + @NotBlank(message = "댓글 내용은 필수입니다.") + String content, + + @Schema(description = "게시물 타입 (RECORD, VOTE, FEED)", example = "RECORD") + @NotBlank(message = "게시물 타입은 필수입니다.") + String postType +) { + public RootCommentCreateCommand toCommand(Long userId, Long postId) { + return new RootCommentCreateCommand(content, postType, postId, userId); + } +} From 718c00771103e3fb915b6d232a1d52b08ee287dc Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 16 Feb 2026 16:48:34 +0900 Subject: [PATCH 15/21] =?UTF-8?q?[feat]=20=EB=A3=A8=ED=8A=B8/=EC=9E=90?= =?UTF-8?q?=EC=86=90=20=EB=8C=93=EA=B8=80=20=EC=83=9D=EC=84=B1=20API=20ser?= =?UTF-8?q?vice=20=EC=B6=94=EA=B0=80=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 service 에서 분기처리해서 수행하던 작업을 2개의 service로 분리 --- .../port/in/ChildCommentCreateUseCase.java | 9 ++ .../port/in/CommentCreateUseCase.java | 8 -- .../port/in/RootCommentCreateUseCase.java | 9 ++ .../in/dto/ChildCommentCreateCommand.java | 10 ++ ...and.java => RootCommentCreateCommand.java} | 13 +-- ...ce.java => ChildCommentCreateService.java} | 95 +++++++------------ .../service/RootCommentCreateService.java | 84 ++++++++++++++++ .../konkuk/thip/comment/domain/Comment.java | 44 ++------- 8 files changed, 155 insertions(+), 117 deletions(-) create mode 100644 src/main/java/konkuk/thip/comment/application/port/in/ChildCommentCreateUseCase.java delete mode 100644 src/main/java/konkuk/thip/comment/application/port/in/CommentCreateUseCase.java create mode 100644 src/main/java/konkuk/thip/comment/application/port/in/RootCommentCreateUseCase.java create mode 100644 src/main/java/konkuk/thip/comment/application/port/in/dto/ChildCommentCreateCommand.java rename src/main/java/konkuk/thip/comment/application/port/in/dto/{CommentCreateCommand.java => RootCommentCreateCommand.java} (59%) rename src/main/java/konkuk/thip/comment/application/service/{CommentCreateService.java => ChildCommentCreateService.java} (54%) create mode 100644 src/main/java/konkuk/thip/comment/application/service/RootCommentCreateService.java diff --git a/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentCreateUseCase.java b/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentCreateUseCase.java new file mode 100644 index 000000000..96a293b56 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/port/in/ChildCommentCreateUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.comment.application.port.in; + +import konkuk.thip.comment.adapter.in.web.response.CommentCreateResponse; +import konkuk.thip.comment.application.port.in.dto.ChildCommentCreateCommand; + +public interface ChildCommentCreateUseCase { + + CommentCreateResponse createChildComment(ChildCommentCreateCommand command); +} diff --git a/src/main/java/konkuk/thip/comment/application/port/in/CommentCreateUseCase.java b/src/main/java/konkuk/thip/comment/application/port/in/CommentCreateUseCase.java deleted file mode 100644 index 8bcda9582..000000000 --- a/src/main/java/konkuk/thip/comment/application/port/in/CommentCreateUseCase.java +++ /dev/null @@ -1,8 +0,0 @@ -package konkuk.thip.comment.application.port.in; - -import konkuk.thip.comment.adapter.in.web.response.CommentCreateResponse; -import konkuk.thip.comment.application.port.in.dto.CommentCreateCommand; - -public interface CommentCreateUseCase { - CommentCreateResponse createComment(CommentCreateCommand command); -} diff --git a/src/main/java/konkuk/thip/comment/application/port/in/RootCommentCreateUseCase.java b/src/main/java/konkuk/thip/comment/application/port/in/RootCommentCreateUseCase.java new file mode 100644 index 000000000..def7b63db --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/port/in/RootCommentCreateUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.comment.application.port.in; + +import konkuk.thip.comment.adapter.in.web.response.CommentCreateResponse; +import konkuk.thip.comment.application.port.in.dto.RootCommentCreateCommand; + +public interface RootCommentCreateUseCase { + + CommentCreateResponse createRootComment(RootCommentCreateCommand command); +} diff --git a/src/main/java/konkuk/thip/comment/application/port/in/dto/ChildCommentCreateCommand.java b/src/main/java/konkuk/thip/comment/application/port/in/dto/ChildCommentCreateCommand.java new file mode 100644 index 000000000..2eca7d21c --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/port/in/dto/ChildCommentCreateCommand.java @@ -0,0 +1,10 @@ +package konkuk.thip.comment.application.port.in.dto; + +public record ChildCommentCreateCommand( + String content, + String postType, + Long postId, + Long userId, + Long parentCommentId +) { +} diff --git a/src/main/java/konkuk/thip/comment/application/port/in/dto/CommentCreateCommand.java b/src/main/java/konkuk/thip/comment/application/port/in/dto/RootCommentCreateCommand.java similarity index 59% rename from src/main/java/konkuk/thip/comment/application/port/in/dto/CommentCreateCommand.java rename to src/main/java/konkuk/thip/comment/application/port/in/dto/RootCommentCreateCommand.java index 37df203c2..d5f3ca218 100644 --- a/src/main/java/konkuk/thip/comment/application/port/in/dto/CommentCreateCommand.java +++ b/src/main/java/konkuk/thip/comment/application/port/in/dto/RootCommentCreateCommand.java @@ -1,18 +1,9 @@ package konkuk.thip.comment.application.port.in.dto; -public record CommentCreateCommand( - +public record RootCommentCreateCommand( String content, - - Boolean isReplyRequest, - - Long parentId, - String postType, - Long postId, - Long userId -) -{ +) { } diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java b/src/main/java/konkuk/thip/comment/application/service/ChildCommentCreateService.java similarity index 54% rename from src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java rename to src/main/java/konkuk/thip/comment/application/service/ChildCommentCreateService.java index 5fa211bdc..e37e227e3 100644 --- a/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java +++ b/src/main/java/konkuk/thip/comment/application/service/ChildCommentCreateService.java @@ -2,8 +2,8 @@ import konkuk.thip.comment.adapter.in.web.response.CommentCreateResponse; import konkuk.thip.comment.application.mapper.CommentQueryMapper; -import konkuk.thip.comment.application.port.in.CommentCreateUseCase; -import konkuk.thip.comment.application.port.in.dto.CommentCreateCommand; +import konkuk.thip.comment.application.port.in.ChildCommentCreateUseCase; +import konkuk.thip.comment.application.port.in.dto.ChildCommentCreateCommand; import konkuk.thip.comment.application.port.out.CommentCommandPort; import konkuk.thip.comment.application.port.out.CommentLikeQueryPort; import konkuk.thip.comment.application.port.out.CommentQueryPort; @@ -24,11 +24,10 @@ import org.springframework.transaction.annotation.Transactional; import static konkuk.thip.common.exception.code.ErrorCode.INVALID_COMMENT_CREATE; -import static konkuk.thip.post.domain.PostType.*; @Service @RequiredArgsConstructor -public class CommentCreateService implements CommentCreateUseCase { +public class ChildCommentCreateService implements ChildCommentCreateUseCase { private final CommentCommandPort commentCommandPort; private final CommentQueryPort commentQueryPort; @@ -44,62 +43,58 @@ public class CommentCreateService implements CommentCreateUseCase { @Override @Transactional - public CommentCreateResponse createComment(CommentCreateCommand command) { + public CommentCreateResponse createChildComment(ChildCommentCreateCommand command) { + PostType type = PostType.from(command.postType()); - // 1. 댓글/답글 생성 선행검증 및 작성하려는 게시글 타입 검증 - Comment.validateCommentCreate(command.isReplyRequest(), command.parentId()); - PostType type = from(command.postType()); + // 1. 부모 댓글 조회 및 존재 검증 + Comment parentComment = commentCommandPort.findById(command.parentCommentId()) + .orElseThrow(() -> new InvalidStateException( + INVALID_COMMENT_CREATE, + new IllegalArgumentException("parentId에 해당하는 부모 댓글이 존재해야 합니다.") + )); - // 2. 게시물 타입에 맞게 조회 + // 2. 게시물 조회 및 댓글 생성 권한 검증 CountUpdatable post = postHandler.findPost(type, command.postId()); - // 2-1. 게시글 타입에 따른 댓글 생성 권한 검증 commentAuthorizationValidator.validateUserCanAccessPostForComment(type, post, command.userId()); - // 2-2. 댓글 생성 푸쉬 알림 전송 (게시글 작성자에게) + // 3. 알림 전송 (게시글 작성자에게) PostQueryDto postQueryDto = postHandler.getPostQueryDto(type, post.getId()); User actorUser = userCommandPort.findById(command.userId()); sendNotificationsToPostWriter(postQueryDto, actorUser); - // 3. 댓글 생성 - Long savedCommentId = createCommentDomain(command); - - //TODO 게시물의 댓글 수 증가/감소 동시성 제어 로직 추가해야됨 + // 4. 자식 댓글 생성 + Comment comment = Comment.createChildComment( + command.content(), command.postId(), command.userId(), parentComment, type + ); + Long savedCommentId = commentCommandPort.save(comment); - // 4. 게시글 댓글 수 증가 - // 4-1. 도메인 게시물 댓글 수 증가 + // 5. 게시글 댓글 수 증가 post.increaseCommentCount(); - // 4-2 Jpa엔티티 게시물 댓글 수 증가 postHandler.updatePost(type, post); - // 5. 매퍼로 DTO 변환 후 반환 - if (command.isReplyRequest()) { - // 부모 댓글 조회 - CommentQueryDto parentCommentDto = commentQueryPort.findRootCommentById(command.parentId()); + // 6. 응답 매핑 (부모 댓글 + 생성된 답글) + CommentQueryDto parentCommentDto = commentQueryPort.findRootCommentById(command.parentCommentId()); - // 답글 생성 푸쉬 알림 전송 (부모 댓글 작성자에게) - sendNotificationsToParentCommentWriter(postQueryDto, parentCommentDto, actorUser); + // 부모 댓글 작성자에게 알림 전송 + sendNotificationsToParentCommentWriter(postQueryDto, parentCommentDto, actorUser); - // 사용자 부모 댓글 좋아요 여부 조회 - boolean isLikedParentComment = commentLikeQueryPort.isLikedCommentByUser(command.userId(),parentCommentDto.commentId()); + // 부모 댓글 좋아요 여부 조회 + boolean isLikedParentComment = commentLikeQueryPort.isLikedCommentByUser(command.userId(), parentCommentDto.commentId()); - CommentQueryDto savedReplyCommentDto = commentQueryPort.findChildCommentById(command.parentId(), savedCommentId); - return commentQueryMapper.toRootCommentResponseWithChildren(parentCommentDto, savedReplyCommentDto,isLikedParentComment,command.userId()); - } else { - CommentQueryDto savedCommentDto = commentQueryPort.findRootCommentById(savedCommentId); - return commentQueryMapper.toRoot(savedCommentDto, false, command.userId()); - } + CommentQueryDto savedReplyCommentDto = commentQueryPort.findChildCommentById(command.parentCommentId(), savedCommentId); + return commentQueryMapper.toRootCommentResponseWithChildren(parentCommentDto, savedReplyCommentDto, isLikedParentComment, command.userId()); } private void sendNotificationsToPostWriter(PostQueryDto postQueryDto, User actorUser) { - if (postQueryDto.creatorId().equals(actorUser.getId())) return; // 자신이 작성한 게시글 제외 + if (postQueryDto.creatorId().equals(actorUser.getId())) return; PostType postType = PostType.from(postQueryDto.postType()); switch (postType) { - case FEED -> // 피드 댓글 알림 이벤트 발행 + case FEED -> feedNotificationOrchestrator.notifyFeedCommented( postQueryDto.creatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.postId() ); - case RECORD, VOTE -> // 모임방 게시글 댓글 알림 이벤트 발행 + case RECORD, VOTE -> roomNotificationOrchestrator.notifyRoomPostCommented( postQueryDto.creatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.roomId(), postQueryDto.page(), postQueryDto.postId(), postType @@ -108,43 +103,19 @@ private void sendNotificationsToPostWriter(PostQueryDto postQueryDto, User actor } private void sendNotificationsToParentCommentWriter(PostQueryDto postQueryDto, CommentQueryDto parentCommentDto, User actorUser) { - if (parentCommentDto.creatorId().equals(actorUser.getId())) return; // 자신이 작성한 댓글 제외 + if (parentCommentDto.creatorId().equals(actorUser.getId())) return; PostType postType = PostType.from(postQueryDto.postType()); switch (postType) { - case FEED -> // 피드 답글 알림 이벤트 발행 + case FEED -> feedNotificationOrchestrator.notifyFeedReplied( parentCommentDto.creatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.postId() ); - case RECORD, VOTE -> // 모임방 게시글 답글 알림 이벤트 발행 + case RECORD, VOTE -> roomNotificationOrchestrator.notifyRoomPostCommentReplied( parentCommentDto.creatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.roomId(), postQueryDto.page(), postQueryDto.postId(), postType ); } } - - private Long createCommentDomain(CommentCreateCommand command) { - - // 3-1. (답글일 경우) 부모 댓글 조회 - Comment parentComment = null; - if (command.isReplyRequest()) { - parentComment = commentCommandPort.findById(command.parentId()).orElseThrow(() - -> new InvalidStateException(INVALID_COMMENT_CREATE, new IllegalArgumentException("parentId에 해당하는 부모 댓글이 존재해야 합니다."))); - } - - // 3-2. 도메인 댓글 생성 (유효성 검증 포함됨) - Comment comment = Comment.createComment( - command.content(), - command.postId(), - command.userId(), - command.postType(), - command.isReplyRequest(), - command.parentId(), - parentComment - ); - - return commentCommandPort.save(comment); - } - } diff --git a/src/main/java/konkuk/thip/comment/application/service/RootCommentCreateService.java b/src/main/java/konkuk/thip/comment/application/service/RootCommentCreateService.java new file mode 100644 index 000000000..926c2e74c --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/service/RootCommentCreateService.java @@ -0,0 +1,84 @@ +package konkuk.thip.comment.application.service; + +import konkuk.thip.comment.adapter.in.web.response.CommentCreateResponse; +import konkuk.thip.comment.application.mapper.CommentQueryMapper; +import konkuk.thip.comment.application.port.in.RootCommentCreateUseCase; +import konkuk.thip.comment.application.port.in.dto.RootCommentCreateCommand; +import konkuk.thip.comment.application.port.out.CommentCommandPort; +import konkuk.thip.comment.application.port.out.CommentQueryPort; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import konkuk.thip.comment.application.service.validator.CommentAuthorizationValidator; +import konkuk.thip.comment.domain.Comment; +import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; +import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; +import konkuk.thip.post.application.port.out.dto.PostQueryDto; +import konkuk.thip.post.domain.CountUpdatable; +import konkuk.thip.post.application.service.handler.PostHandler; +import konkuk.thip.post.domain.PostType; +import konkuk.thip.user.application.port.out.UserCommandPort; +import konkuk.thip.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RootCommentCreateService implements RootCommentCreateUseCase { + + private final CommentCommandPort commentCommandPort; + private final CommentQueryPort commentQueryPort; + private final CommentQueryMapper commentQueryMapper; + private final UserCommandPort userCommandPort; + + private final PostHandler postHandler; + private final CommentAuthorizationValidator commentAuthorizationValidator; + + private final FeedNotificationOrchestrator feedNotificationOrchestrator; + private final RoomNotificationOrchestrator roomNotificationOrchestrator; + + @Override + @Transactional + public CommentCreateResponse createRootComment(RootCommentCreateCommand command) { + PostType type = PostType.from(command.postType()); + + // 1. 게시물 조회 및 댓글 생성 권한 검증 + CountUpdatable post = postHandler.findPost(type, command.postId()); + commentAuthorizationValidator.validateUserCanAccessPostForComment(type, post, command.userId()); + + // 2. 게시글 작성자에게 알림 전송 + PostQueryDto postQueryDto = postHandler.getPostQueryDto(type, post.getId()); + User actorUser = userCommandPort.findById(command.userId()); + sendNotificationsToPostWriter(postQueryDto, actorUser); + + // 3. 루트 댓글 생성 + Comment comment = Comment.createRootComment( + command.content(), command.postId(), command.userId(), type + ); + Long savedCommentId = commentCommandPort.save(comment); + + // 4. 게시글 댓글 수 증가 + post.increaseCommentCount(); + postHandler.updatePost(type, post); + + // 5. 응답 매핑 + CommentQueryDto savedCommentDto = commentQueryPort.findRootCommentById(savedCommentId); + return commentQueryMapper.toRoot(savedCommentDto, false, command.userId()); + } + + private void sendNotificationsToPostWriter(PostQueryDto postQueryDto, User actorUser) { + if (postQueryDto.creatorId().equals(actorUser.getId())) return; + + PostType postType = PostType.from(postQueryDto.postType()); + switch (postType) { + case FEED -> + feedNotificationOrchestrator.notifyFeedCommented( + postQueryDto.creatorId(), actorUser.getId(), actorUser.getNickname(), postQueryDto.postId() + ); + case RECORD, VOTE -> + roomNotificationOrchestrator.notifyRoomPostCommented( + postQueryDto.creatorId(), actorUser.getId(), actorUser.getNickname(), + postQueryDto.roomId(), postQueryDto.page(), postQueryDto.postId(), postType + ); + } + } +} diff --git a/src/main/java/konkuk/thip/comment/domain/Comment.java b/src/main/java/konkuk/thip/comment/domain/Comment.java index 1e058899b..dc5c5b9f3 100644 --- a/src/main/java/konkuk/thip/comment/domain/Comment.java +++ b/src/main/java/konkuk/thip/comment/domain/Comment.java @@ -60,28 +60,11 @@ public int hashCode() { return Objects.hash(id); } - - public static Comment createComment(String content, Long postId, Long creatorId, String type, - boolean isReplyRequest, Long parentId, Comment parent) { - - // 댓글/답글 생성 검증 - validateCommentCreate(isReplyRequest,parentId); - PostType postType = PostType.from(type); - - if (isReplyRequest) { - // 답글 생성 검증 - validateReplyCommentCreate(postId, parent); - return withoutIdReplyComment(content, postId, creatorId, parent, postType); - } - return withoutIdRootComment(content, postId, creatorId, postType); - } - - - private static Comment withoutIdRootComment(String content, Long targetPostId, Long creatorId, PostType postType) { + public static Comment createRootComment(String content, Long postId, Long creatorId, PostType postType) { return Comment.builder() .id(null) .content(content) - .targetPostId(targetPostId) + .targetPostId(postId) .creatorId(creatorId) .parentCommentId(null) .postType(postType) @@ -90,11 +73,12 @@ private static Comment withoutIdRootComment(String content, Long targetPostId, L .build(); } - private static Comment withoutIdReplyComment(String content, Long targetPostId, Long creatorId, Comment parentComment, PostType postType) { + public static Comment createChildComment(String content, Long postId, Long creatorId, Comment parentComment, PostType postType) { + validateParentComment(postId, parentComment); return Comment.builder() .id(null) .content(content) - .targetPostId(targetPostId) + .targetPostId(postId) .creatorId(creatorId) .parentCommentId(parentComment.getId()) .postType(postType) @@ -103,26 +87,14 @@ private static Comment withoutIdReplyComment(String content, Long targetPostId, .build(); } - private static void validateReplyCommentCreate(Long targetPostId, Comment parentComment) { + private static void validateParentComment(Long targetPostId, Comment parentComment) { if (parentComment == null) { throw new InvalidStateException( - INVALID_COMMENT_CREATE,new IllegalArgumentException("parentId에 해당하는 부모 댓글이 존재해야 합니다.")); + INVALID_COMMENT_CREATE, new IllegalArgumentException("parentId에 해당하는 부모 댓글이 존재해야 합니다.")); } if (!targetPostId.equals(parentComment.getTargetPostId())) { throw new InvalidStateException( - INVALID_COMMENT_CREATE,new IllegalArgumentException("댓글과 부모 댓글의 게시글이 일치하지 않습니다.")); - } - } - - public static void validateCommentCreate(boolean isReplyRequest, Long parentId) { - if (isReplyRequest && parentId == null) { - throw new InvalidStateException( - INVALID_COMMENT_CREATE, new IllegalArgumentException("답글 작성 시 parentId는 필수입니다.")); - - } - if (!isReplyRequest && parentId != null) { - throw new InvalidStateException( - INVALID_COMMENT_CREATE, new IllegalArgumentException("일반 댓글에는 parentId가 없어야 합니다.")); + INVALID_COMMENT_CREATE, new IllegalArgumentException("댓글과 부모 댓글의 게시글이 일치하지 않습니다.")); } } From 16c51e199faee56a29cd8aabe64de311ff7b794f Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 16 Feb 2026 16:51:06 +0900 Subject: [PATCH 16/21] =?UTF-8?q?[refactor]=20CommentCommandPersistenceAda?= =?UTF-8?q?pter=20=EC=88=98=EC=A0=95=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - comments 테이블 스키마 변경에 따른 comments 레코드 생성 로직 변경 - 부모/루트 댓글 조회 로직 추가 - 자손 수 변경 로직 추가 - jpa entity <-> domain entity의 매핑 오버헤드를 줄이고자, adapter 계층에서 [루트 댓글 설정, 자손 수 업데이트] 로직 수행 --- .../CommentCommandPersistenceAdapter.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapter.java index 5c31fc429..134e3ca95 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentCommandPersistenceAdapter.java @@ -46,15 +46,26 @@ public Long save(Comment comment) { // 2. 게시물(Post) 조회 및 존재 검증 PostJpaEntity postJpaEntity = findPostJpaEntity(comment.getPostType(), comment.getTargetPostId()); - // 3. 부모 댓글 조회 (있을 경우) + // 3. 부모, 루트 댓글 조회 (있을 경우) CommentJpaEntity parentCommentJpaEntity = null; + CommentJpaEntity rootCommentJpaEntity = null; + if (comment.getParentCommentId() != null) { parentCommentJpaEntity = commentJpaRepository.findByCommentId(comment.getParentCommentId()) .orElseThrow(() -> new EntityNotFoundException(COMMENT_NOT_FOUND)); + + // 부모가 루트가 아닌 경우 : 부모의 root를 가져온다 + // 부모가 루트 댓글인 경우 : 부모를 루트로 설정한다 + rootCommentJpaEntity = parentCommentJpaEntity.getRoot() != null + ? parentCommentJpaEntity.getRoot() + : parentCommentJpaEntity; + + // 루트의 descendant_count 컬럼 값 업데이트 + rootCommentJpaEntity.incrementDescendantCount(); } return commentJpaRepository.save( - commentMapper.toJpaEntity(comment, postJpaEntity, userJpaEntity,parentCommentJpaEntity) + commentMapper.toJpaEntity(comment, postJpaEntity, userJpaEntity, parentCommentJpaEntity, rootCommentJpaEntity) ).getCommentId(); } From 44eb60193244f554273443203aa8b0f4ff3481e5 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 16 Feb 2026 16:52:01 +0900 Subject: [PATCH 17/21] =?UTF-8?q?[refactor]=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - controller 단위 테스트, API 통합 테스트, Comment 도메인 단위 테스트 수정 --- .../in/web/ChildCommentCreateApiTest.java | 171 ++++++++++++++++++ .../in/web/CommentCreateControllerTest.java | 24 --- ...est.java => RootCommentCreateApiTest.java} | 101 +++++------ .../thip/comment/domain/CommentTest.java | 90 ++------- 4 files changed, 233 insertions(+), 153 deletions(-) create mode 100644 src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentCreateApiTest.java rename src/test/java/konkuk/thip/comment/adapter/in/web/{CommentCreateApiTest.java => RootCommentCreateApiTest.java} (59%) diff --git a/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentCreateApiTest.java b/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentCreateApiTest.java new file mode 100644 index 000000000..9467f0216 --- /dev/null +++ b/src/test/java/konkuk/thip/comment/adapter/in/web/ChildCommentCreateApiTest.java @@ -0,0 +1,171 @@ +package konkuk.thip.comment.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.domain.value.RoomParticipantRole; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.room.domain.value.Category; +import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; +import konkuk.thip.roompost.adapter.out.jpa.VoteJpaEntity; +import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; +import konkuk.thip.roompost.adapter.out.persistence.repository.vote.VoteJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +import static konkuk.thip.post.domain.PostType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +@DisplayName("[통합] 자식 댓글(답글) 생성 API 통합 테스트") +class ChildCommentCreateApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private VoteJpaRepository voteJpaRepository; + @Autowired private RecordJpaRepository recordJpaRepository; + @Autowired private CommentJpaRepository commentJpaRepository; + @Autowired private RoomJpaRepository roomJpaRepository; + @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; + + private UserJpaEntity user; + private FeedJpaEntity feed; + private RecordJpaEntity record; + private VoteJpaEntity vote; + private CommentJpaEntity feedRootComment; + private CommentJpaEntity recordRootComment; + private CommentJpaEntity voteRootComment; + + @BeforeEach + void setUp() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + Category category = TestEntityFactory.createLiteratureCategory(); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + RoomJpaEntity room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + feed = feedJpaRepository.save(TestEntityFactory.createFeed(user, book, true)); + record = recordJpaRepository.save(TestEntityFactory.createRecord(user, room)); + vote = voteJpaRepository.save(TestEntityFactory.createVote(user, room)); + roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room, user, RoomParticipantRole.HOST, 0.0)); + + // 루트 댓글 생성 + feedRootComment = commentJpaRepository.save(TestEntityFactory.createComment(feed, user, FEED)); + recordRootComment = commentJpaRepository.save(TestEntityFactory.createComment(record, user, RECORD)); + voteRootComment = commentJpaRepository.save(TestEntityFactory.createComment(vote, user, VOTE)); + } + + private String toChildCommentJson(String content, String postType, Long postId) throws Exception { + Map req = new HashMap<>(); + req.put("content", content); + req.put("postType", postType); + req.put("postId", postId); + return objectMapper.writeValueAsString(req); + } + + @Test + @DisplayName("Feed 게시물의 루트 댓글에 답글을 생성할 수 있다.") + void createChildCommentOnFeedRootComment() throws Exception { + // given & when & then + mockMvc.perform(post("/comments/replies/{parentCommentId}", feedRootComment.getCommentId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toChildCommentJson("피드 답글입니다", "feed", feed.getPostId())) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").exists()); + } + + @Test + @DisplayName("Record 게시물의 루트 댓글에 답글을 생성할 수 있다.") + void createChildCommentOnRecordRootComment() throws Exception { + // given & when & then + mockMvc.perform(post("/comments/replies/{parentCommentId}", recordRootComment.getCommentId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toChildCommentJson("기록 답글입니다", "record", record.getPostId())) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").exists()); + } + + @Test + @DisplayName("Vote 게시물의 루트 댓글에 답글을 생성할 수 있다.") + void createChildCommentOnVoteRootComment() throws Exception { + // given & when & then + mockMvc.perform(post("/comments/replies/{parentCommentId}", voteRootComment.getCommentId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toChildCommentJson("투표 답글입니다", "vote", vote.getPostId())) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").exists()); + } + + @Test + @DisplayName("각 게시물 타입별로 루트 댓글에 답글을 생성할 수 있다.") + void createChildCommentEachPostType() throws Exception { + // given + String[] postTypes = {"feed", "record", "vote"}; + Long[] postIds = {feed.getPostId(), record.getPostId(), vote.getPostId()}; + Long[] parentCommentIds = { + feedRootComment.getCommentId(), + recordRootComment.getCommentId(), + voteRootComment.getCommentId() + }; + + // when & then + for (int i = 0; i < postTypes.length; i++) { + mockMvc.perform(post("/comments/replies/{parentCommentId}", parentCommentIds[i]) + .contentType(MediaType.APPLICATION_JSON) + .content(toChildCommentJson("답글입니다", postTypes[i], postIds[i])) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").exists()); + } + } + + @Test + @DisplayName("답글에 대한 답글(depth 2 이상)을 생성할 수 있다.") + void createChildCommentOnChildComment() throws Exception { + // given - 1단계 답글 생성 + CommentJpaEntity childComment = commentJpaRepository.save( + TestEntityFactory.createReplyComment(feed, user, FEED, feedRootComment) + ); + + // when & then - 2단계 답글 생성 (답글의 답글) + mockMvc.perform(post("/comments/replies/{parentCommentId}", childComment.getCommentId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toChildCommentJson("답글의 답글입니다", "feed", feed.getPostId())) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").exists()); + } +} diff --git a/src/test/java/konkuk/thip/comment/adapter/in/web/CommentCreateControllerTest.java b/src/test/java/konkuk/thip/comment/adapter/in/web/CommentCreateControllerTest.java index 4eb90f0d1..fe4882f41 100644 --- a/src/test/java/konkuk/thip/comment/adapter/in/web/CommentCreateControllerTest.java +++ b/src/test/java/konkuk/thip/comment/adapter/in/web/CommentCreateControllerTest.java @@ -85,8 +85,6 @@ record = recordJpaRepository.save(TestEntityFactory.createRecord(user,room)); private Map buildValidRequest() { Map req = new HashMap<>(); req.put("content", "정상 댓글"); - req.put("isReplyRequest", false); - req.put("parentId", null); req.put("postType", "feed"); return req; } @@ -101,16 +99,6 @@ private void assertBadRequest(Map req, String expectedMessage) t .andExpect(jsonPath("$.message", containsString(expectedMessage))); } - private void assertBadCommentCreateRequest(Map req, String expectedMessage) throws Exception { - mockMvc.perform(post("/comments/{postId}", feed.getPostId()) - .requestAttr("userId", user.getUserId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsBytes(req))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(INVALID_COMMENT_CREATE.getCode())) - .andExpect(jsonPath("$.message", containsString(expectedMessage))); - } - @Nested @DisplayName("댓글 내용(content) 검증") class ContentValidation { @@ -123,18 +111,6 @@ void blankContent() throws Exception { } } - @Nested - @DisplayName("isReplyRequest(답글 여부) 검증") - class IsReplyRequestValidation { - @Test - @DisplayName("누락될 경우 400 error") - void missingIsReplyRequest() throws Exception { - Map req = buildValidRequest(); - req.remove("isReplyRequest"); - assertBadRequest(req, "답글 여부는 필수입니다."); - } - } - @Nested @DisplayName("postType(게시물 타입) 검증") class PostTypeValidation { diff --git a/src/test/java/konkuk/thip/comment/adapter/in/web/CommentCreateApiTest.java b/src/test/java/konkuk/thip/comment/adapter/in/web/RootCommentCreateApiTest.java similarity index 59% rename from src/test/java/konkuk/thip/comment/adapter/in/web/CommentCreateApiTest.java rename to src/test/java/konkuk/thip/comment/adapter/in/web/RootCommentCreateApiTest.java index 379e1c787..29b4da4a3 100644 --- a/src/test/java/konkuk/thip/comment/adapter/in/web/CommentCreateApiTest.java +++ b/src/test/java/konkuk/thip/comment/adapter/in/web/RootCommentCreateApiTest.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; -import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; import konkuk.thip.common.util.TestEntityFactory; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; @@ -34,7 +33,6 @@ import java.util.HashMap; import java.util.Map; -import static konkuk.thip.post.domain.PostType.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -43,9 +41,8 @@ @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) @Transactional -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) -@DisplayName("[통합] 댓글 생성 api 통합 테스트") -class CommentCreateApiTest { +@DisplayName("[통합] 루트 댓글 생성 API 통합 테스트") +class RootCommentCreateApiTest { @Autowired private MockMvc mockMvc; @@ -56,89 +53,85 @@ class CommentCreateApiTest { @Autowired private FeedJpaRepository feedJpaRepository; @Autowired private VoteJpaRepository voteJpaRepository; @Autowired private RecordJpaRepository recordJpaRepository; - @Autowired private CommentJpaRepository commentJpaRepository; @Autowired private RoomJpaRepository roomJpaRepository; @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; - private Alias alias; private UserJpaEntity user; - private Category category; private FeedJpaEntity feed; - private BookJpaEntity book; private RecordJpaEntity record; private VoteJpaEntity vote; - private RoomJpaEntity room; @BeforeEach void setUp() { - alias = TestEntityFactory.createLiteratureAlias(); + Alias alias = TestEntityFactory.createLiteratureAlias(); user = userJpaRepository.save(TestEntityFactory.createUser(alias)); - category = TestEntityFactory.createLiteratureCategory(); - book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); - room = roomJpaRepository.save(TestEntityFactory.createRoom(book,category)); - feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); - record = recordJpaRepository.save(TestEntityFactory.createRecord(user,room)); - vote = voteJpaRepository.save(TestEntityFactory.createVote(user,room)); + Category category = TestEntityFactory.createLiteratureCategory(); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + RoomJpaEntity room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + feed = feedJpaRepository.save(TestEntityFactory.createFeed(user, book, true)); + record = recordJpaRepository.save(TestEntityFactory.createRecord(user, room)); + vote = voteJpaRepository.save(TestEntityFactory.createVote(user, room)); roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room, user, RoomParticipantRole.HOST, 0.0)); } - // 공통 JSON 생성 함수 - private String toJson(String content, boolean isReply, Long parentId, String postType) throws Exception { + private String toRootCommentJson(String content, String postType) throws Exception { Map req = new HashMap<>(); req.put("content", content); - req.put("isReplyRequest", isReply); - req.put("parentId", parentId); req.put("postType", postType); return objectMapper.writeValueAsString(req); } @Test - @DisplayName("각 게시물 타입별로 존재하는 게시물에 대해 (루트)댓글 생성을 할 수 있다.") - void createRootCommentEachPostType() throws Exception { - - // given - String[] postTypes = {"feed", "record", "vote"}; - Long[] postIds = {feed.getPostId(), record.getPostId(), vote.getPostId()}; - - // when & then - for (int i = 0; i < postTypes.length; i++) { - mockMvc.perform(post("/comments/{postId}", postIds[i]) - .contentType(MediaType.APPLICATION_JSON) - .content(toJson("루트 댓글입니다", false, null, postTypes[i])) - .requestAttr("userId", user.getUserId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.commentId").exists()); - } + @DisplayName("Feed 게시물에 루트 댓글을 생성할 수 있다.") + void createRootCommentOnFeed() throws Exception { + // given & when & then + mockMvc.perform(post("/comments/{postId}", feed.getPostId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toRootCommentJson("피드에 루트 댓글입니다", "feed")) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").exists()); } + @Test + @DisplayName("Record 게시물에 루트 댓글을 생성할 수 있다.") + void createRootCommentOnRecord() throws Exception { + // given & when & then + mockMvc.perform(post("/comments/{postId}", record.getPostId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toRootCommentJson("기록에 루트 댓글입니다", "record")) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").exists()); + } @Test - @DisplayName("각 게시물 타입별로 존재하는 게시물 및 댓글에 대해 답글 생성을 할 수 있다.") - void createReplyCommentEachPostType() throws Exception { + @DisplayName("Vote 게시물에 루트 댓글을 생성할 수 있다.") + void createRootCommentOnVote() throws Exception { + // given & when & then + mockMvc.perform(post("/comments/{postId}", vote.getPostId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toRootCommentJson("투표에 루트 댓글입니다", "vote")) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentId").exists()); + } + @Test + @DisplayName("각 게시물 타입별로 루트 댓글을 생성할 수 있다.") + void createRootCommentEachPostType() throws Exception { // given - //부모 댓글 생성 - Long feedParentId = commentJpaRepository.save(TestEntityFactory.createComment(feed,user, FEED)).getCommentId(); - Long recordParentId = commentJpaRepository.save(TestEntityFactory.createComment(record,user,RECORD)).getCommentId(); - Long voteParentId = commentJpaRepository.save(TestEntityFactory.createComment(vote,user,VOTE)).getCommentId(); - - // 답글 생성 요청 - Map[] replyRequests = new Map[]{ - Map.of("content", "Feed 답글", "isReplyRequest", true, "parentId", feedParentId, "postType", "feed"), - Map.of("content", "Record 답글", "isReplyRequest", true, "parentId", recordParentId, "postType", "record"), - Map.of("content", "Vote 답글", "isReplyRequest", true, "parentId", voteParentId, "postType", "vote") - }; - + String[] postTypes = {"feed", "record", "vote"}; Long[] postIds = {feed.getPostId(), record.getPostId(), vote.getPostId()}; - for (int i = 0; i < replyRequests.length; i++) { + // when & then + for (int i = 0; i < postTypes.length; i++) { mockMvc.perform(post("/comments/{postId}", postIds[i]) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(replyRequests[i])) + .content(toRootCommentJson("루트 댓글입니다", postTypes[i])) .requestAttr("userId", user.getUserId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.commentId").exists()); } } - } diff --git a/src/test/java/konkuk/thip/comment/domain/CommentTest.java b/src/test/java/konkuk/thip/comment/domain/CommentTest.java index c608f474e..0242e3245 100644 --- a/src/test/java/konkuk/thip/comment/domain/CommentTest.java +++ b/src/test/java/konkuk/thip/comment/domain/CommentTest.java @@ -32,17 +32,9 @@ private Comment createParentComment(Long postId) { } @Test - @DisplayName("createComment: 일반 댓글 생성 시 parentId는 null이면 정상적으로 Comment가 생성된다.") + @DisplayName("createRootComment: 루트 댓글 생성 시 정상적으로 Comment가 생성된다.") void createRootComment_valid() { - Comment comment = Comment.createComment( - CONTENT, - POST_ID, - CREATOR_ID, - "feed", - false, - null, - null - ); + Comment comment = Comment.createRootComment(CONTENT, POST_ID, CREATOR_ID, FEED); assertNotNull(comment); assertNull(comment.getParentCommentId()); @@ -52,19 +44,11 @@ void createRootComment_valid() { } @Test - @DisplayName("createComment: 답글 생성 시 parentComment 존재 + 게시글 ID 일치하면 정상적으로 Comment가 생성된다.") - void createReplyComment_valid() { + @DisplayName("createChildComment: 답글 생성 시 parentComment 존재 + 게시글 ID 일치하면 정상적으로 Comment가 생성된다.") + void createChildComment_valid() { Comment parent = createParentComment(POST_ID); - Comment reply = Comment.createComment( - "답글입니다.", - POST_ID, - CREATOR_ID, - "feed", - true, - parent.getId(), - parent - ); + Comment reply = Comment.createChildComment("답글입니다.", POST_ID, CREATOR_ID, parent, FEED); assertNotNull(reply); assertEquals(parent.getId(), reply.getParentCommentId()); @@ -72,67 +56,23 @@ void createReplyComment_valid() { } @Test - @DisplayName("createComment: 일반 댓글 생성 시 parentId가 존재하면 InvalidStateException 이 발생한다.") - void createRootComment_withParentId_shouldFail() { - InvalidStateException ex = assertThrows(InvalidStateException.class, () -> Comment.createComment( - CONTENT, - POST_ID, - CREATOR_ID, - "feed", - false, - 99L, - null - )); - - assertEquals("일반 댓글에는 parentId가 없어야 합니다.", ex.getCause().getMessage()); - } - - @Test - @DisplayName("createComment: 답글 생성 시 parentId가 null이면 InvalidStateException 이 발생한다.") - void createReplyComment_missingParentId_shouldFail() { - InvalidStateException ex = assertThrows(InvalidStateException.class, () -> Comment.createComment( - CONTENT, - POST_ID, - CREATOR_ID, - "feed", - true, - null, - null - )); - - assertEquals("답글 작성 시 parentId는 필수입니다.", ex.getCause().getMessage()); - } - - @Test - @DisplayName("createComment: 답글 생성 시 parentComment 가 null 이면 InvalidStateException 이 발생한다.") - void createReplyComment_missingParentComment_shouldFail() { - InvalidStateException ex = assertThrows(InvalidStateException.class, () -> Comment.createComment( - CONTENT, - POST_ID, - CREATOR_ID, - "feed", - true, - 1L, - null // parentComment 누락 - )); + @DisplayName("createChildComment: 답글 생성 시 parentComment 가 null 이면 InvalidStateException 이 발생한다.") + void createChildComment_missingParentComment_shouldFail() { + InvalidStateException ex = assertThrows(InvalidStateException.class, + () -> Comment.createChildComment(CONTENT, POST_ID, CREATOR_ID, null, FEED) + ); assertEquals("parentId에 해당하는 부모 댓글이 존재해야 합니다.", ex.getCause().getMessage()); } @Test - @DisplayName("createComment: 답글 생성 시 부모 댓글과 게시글 ID가 일치하지 않으면 InvalidStateException 이 발생한다.") - void createReplyComment_parentPostMismatch_shouldFail() { + @DisplayName("createChildComment: 답글 생성 시 부모 댓글과 게시글 ID가 일치하지 않으면 InvalidStateException 이 발생한다.") + void createChildComment_parentPostMismatch_shouldFail() { Comment parent = createParentComment(999L); // 다른 postId - InvalidStateException ex = assertThrows(InvalidStateException.class, () -> Comment.createComment( - CONTENT, - POST_ID, - CREATOR_ID, - "feed", - true, - parent.getId(), - parent - )); + InvalidStateException ex = assertThrows(InvalidStateException.class, + () -> Comment.createChildComment(CONTENT, POST_ID, CREATOR_ID, parent, FEED) + ); assertEquals("댓글과 부모 댓글의 게시글이 일치하지 않습니다.", ex.getCause().getMessage()); } From fa5fe3baaac650166fbe1610c46359ceb6635944 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 16 Feb 2026 16:52:48 +0900 Subject: [PATCH 18/21] =?UTF-8?q?[test]=20=EB=A3=A8=ED=8A=B8=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20API=20=EB=B6=80=ED=95=98=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- loadtest/comment/root_comment_show.js | 90 +++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 loadtest/comment/root_comment_show.js diff --git a/loadtest/comment/root_comment_show.js b/loadtest/comment/root_comment_show.js new file mode 100644 index 000000000..f7473312b --- /dev/null +++ b/loadtest/comment/root_comment_show.js @@ -0,0 +1,90 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +// --- 환경 설정 --- +const BASE_URL = 'http://localhost:8080'; +const TARGET_POST_ID = 1; +const POST_TYPE = 'FEED'; + +export const options = { + // --- 시나리오 설정 (Constant Arrival Rate) --- + scenarios: { + // 1단계: 초당 50 요청 (Warm-up) + warm_up: { + executor: 'constant-arrival-rate', + rate: 50, + timeUnit: '1s', + duration: '30s', + preAllocatedVUs: 10, + maxVUs: 50, + }, + // 2단계: 초당 150 요청 (Target Load) + load_test: { + executor: 'constant-arrival-rate', + rate: 150, + timeUnit: '1s', + duration: '1m', + startTime: '30s', + preAllocatedVUs: 30, + maxVUs: 100, + }, + // 3단계: 초당 300 요청 (Stress Test) + stress_test: { + executor: 'constant-arrival-rate', + rate: 300, + timeUnit: '1s', + duration: '30s', + startTime: '1m30s', + preAllocatedVUs: 50, + maxVUs: 200, + }, + }, + + // --- [핵심 수정] Thresholds 타겟팅 --- + // setup() 단계의 요청은 무시하고, 'root_comment_show' 태그가 있는 요청만 평가합니다. + thresholds: { + // 1. 응답 시간: 해당 태그 요청의 95%가 50ms 이내여야 함 + 'http_req_duration{name:root_comment_show}': ['p(95)<50'], + + // 2. 에러율: 해당 태그 요청의 실패율이 1% 미만이어야 함 + 'http_req_failed{name:root_comment_show}': ['rate<0.01'], + }, +}; + +// --- Setup: 토큰 발급 (성능 측정 제외 대상) --- +export function setup() { + const MAX_SETUP_VUS = 200; + console.log(`🚀 토큰 ${MAX_SETUP_VUS}개 발급 시작...`); + const tokens = []; + + for (let userId = 1; userId <= MAX_SETUP_VUS; userId++) { + // *주의* 여기에는 tags를 붙이지 않습니다. 따라서 thresholds 평가에서 자동 제외됩니다. + const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`); + if (res.status === 200 && res.body.length > 0) { + tokens.push(res.body); + } + } + console.log(`✅ 토큰 ${tokens.length}개 발급 완료`); + return { tokens }; +} + +// --- Main Logic: 루트 댓글 조회 (성능 측정 대상) --- +export default function (data) { + // 토큰 랜덤 선택 + const token = data.tokens[Math.floor(Math.random() * data.tokens.length)]; + + const params = { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + // [중요] 이 태그(name)를 기준으로 thresholds가 작동합니다. + tags: { name: 'root_comment_show' }, + }; + + const res = http.get(`${BASE_URL}/comments/${TARGET_POST_ID}?postType=${POST_TYPE}`, params); + + check(res, { + 'status is 200': (r) => r.status === 200, + }); +} \ No newline at end of file From 8040bde1132422787c3aa97ceea44731c8d4fb92 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 16 Feb 2026 16:53:50 +0900 Subject: [PATCH 19/21] =?UTF-8?q?[chore]=20gitignore=20=EC=97=90=20/monito?= =?UTF-8?q?ring=20=EC=B6=94=EA=B0=80=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로컬 부하 테스트 환경 구성을 위한 /monitoring 디렉토리 추가 및 git ignore 설정 --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e971d692c..e16e59a51 100644 --- a/.gitignore +++ b/.gitignore @@ -122,4 +122,8 @@ $RECYCLE.BIN/ *.msp # Windows shortcuts -*.lnk \ No newline at end of file +*.lnk + +# monitoring files for local development +/monitoring/ + From a660c44b919ce24895cc98c5bb19420af6f59905 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 16 Feb 2026 17:01:38 +0900 Subject: [PATCH 20/21] =?UTF-8?q?[chore]=20Dockerfile=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고정 크기 메모리 할당이 아니라, 비율 기반으로 메모리 할당하도록 수정 --- Dockerfile | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9e419ade4..098693754 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,14 @@ FROM amazoncorretto:17 -ARG PORT=8000 -ENV JAVA_TOOL_OPTIONS="-Xms512m -Xmx2g -XX:+ExitOnOutOfMemoryError" +# 작업 디렉토리 생성 +WORKDIR /app -EXPOSE ${PORT} +# 컨테이너 환경에 맞춰 힙 메모리를 자동 조절하는 옵션 적용 +# MaxRAMPercentage=75.0 : 컨테이너 메모리의 75%를 힙으로 사용 (나머지는 메타스페이스, 스레드, OS 등을 위해 남겨둠) +ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError" -COPY ./build/libs/*.jar ./app.jar +# 빌드된 JAR 파일 복사 +COPY ./build/libs/*.jar app.jar +# 실행 ENTRYPOINT ["java", "-jar", "app.jar"] From 02c1df101a0bf34930996f1060fd13a571b65221 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Wed, 18 Feb 2026 11:36:41 +0900 Subject: [PATCH 21/21] =?UTF-8?q?[chore]=20ci=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REDIS 실행 방식을 외부 액션에서 Service 컨테이너로 변경 - 기존 `supercharge` 액션 내부의 Docker 클라이언트 버전(1.40) 노후화로 인해 CI 빌드가 실패하는 문제가 발생 - 이에 외부 액션 의존성을 제거하고, GitHub Actions의 native 기능인 `services`를 사용하여 Redis를 실행하도록 변경 --- .github/workflows/ci-workflow.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index 615cc801c..b52aaea24 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -19,6 +19,19 @@ env: jobs: build: runs-on: ubuntu-latest + + # Redis를 서비스로 실행 (GitHub Runner -> Docker 컨테이너 통신) + services: + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 @@ -46,11 +59,6 @@ jobs: - name: 👏🏻 grant execute permission for gradlew run: chmod +x gradlew - - name: 🚀 Start Redis - uses: supercharge/redis-github-action@1.7.0 - with: - redis-version: 7 - - name: 🐘 build with Gradle run: ./gradlew build --parallel --stacktrace