diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a41a8d4 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy this file to .env and replace the placeholders with local-only values. +# Never commit the generated .env file. +SNOWTHING_DB_USERNAME=snowuser +SNOWTHING_DB_PASSWORD=replace-with-a-local-password +SNOWTHING_DB_ROOT_PASSWORD=replace-with-a-different-root-password + +# Optional credentials for CommentCreateTest's fixed snowthing_test MySQL schema. +SNOWTHING_TEST_DB_USERNAME=snowuser +SNOWTHING_TEST_DB_PASSWORD=replace-with-a-local-test-password diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml index ae13394..9d12f17 100644 --- a/.github/workflows/gemini-review.yml +++ b/.github/workflows/gemini-review.yml @@ -11,67 +11,62 @@ permissions: jobs: review: - if: > - github.event.issue.pull_request && - contains(github.event.comment.body, '/gemini-review') + if: github.event.issue.pull_request && contains(github.event.comment.body, '/gemini-review') runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Run Gemini Review via REST API env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} run: | if [ -z "$GEMINI_API_KEY" ]; then echo "::error::GEMINI_API_KEY secret is empty." exit 1 fi - gh pr checkout "$PR_NUMBER" + PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json title,body) + PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') + PR_BODY=$(echo "$PR_JSON" | jq -r '.body' | head -c 2500) - PR_TITLE=$(gh pr view "$PR_NUMBER" --json title -q '.title') - PR_BODY=$(gh pr view "$PR_NUMBER" --json body -q '.body' | head -c 2500) - TARGET_BRANCH=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') - - git fetch origin "$TARGET_BRANCH" - PR_DIFF=$(git diff "origin/$TARGET_BRANCH...HEAD" -- 'backend/src/' | head -c 12000) + # PR Diff 추출 (backend/src Java 코드 위주) + PR_DIFF=$(gh pr diff "$PR_NUMBER" --repo "$REPO" | head -c 12000) if [ -z "$PR_DIFF" ]; then - PR_DIFF="No backend/src Java source changes to review." + PR_DIFF="리뷰할 코드 변경점이 없습니다." fi PROMPT=$(cat < createComment( @GetMapping("/posts/{publicId}/comments") public ResponseEntity getCommentsByPost( - @PathVariable String publicId) { - PostCommentListResponse response = commentService.getCommentsByPost(publicId); + @PathVariable String publicId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size) { + PostCommentListResponse response = commentService.getCommentsByPost(publicId, cursor, size); return ResponseEntity.ok(response); } + @GetMapping("/comments/{commentId}/replies") + public ResponseEntity getCommentReplies( + @PathVariable Long commentId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(commentService.getCommentReplies(commentId, cursor, size)); + } + @DeleteMapping("/comments/{commentId}") public ResponseEntity> deleteComment( @PathVariable Long commentId, diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentCreateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentCreateRequest.java index fdb87e4..ac4ab63 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentCreateRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentCreateRequest.java @@ -12,4 +12,5 @@ public record CommentCreateRequest( @Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.") String content, boolean isAnonymous, - String anonymousPassword) {} + @Size(min = 4, max = 20, message = "익명 비밀번호는 4자 이상 20자 이하로 입력해야 합니다.") + String anonymousPassword) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentReplyListResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentReplyListResponse.java new file mode 100644 index 0000000..4a3d83f --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentReplyListResponse.java @@ -0,0 +1,14 @@ +package com.ikae.snowthing.domain.comment.dto; + +import java.util.List; + +public record CommentReplyListResponse( + Long rootCommentId, + long totalReplyCount, + List replies, + Long nextCursor, + boolean hasNext) { + public CommentReplyListResponse { + replies = replies == null ? List.of() : List.copyOf(replies); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java index 3483a5c..5354e9b 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java @@ -1,35 +1,107 @@ package com.ikae.snowthing.domain.comment.dto; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.global.util.WriterDisplayFormatter; public record CommentResponse( Long commentId, + Long postId, Long parentId, - String writerName, + WriterResponse writer, + boolean isAnonymous, + String writerIp, String content, boolean isDeleted, - LocalDateTime createdAt, - List children) { - public static CommentResponse from(Comment comment) { - String writerName = - WriterDisplayFormatter.format( - comment.isAnonymous(), comment.getMember(), comment.getWriterIp()); + long replyCount, + List previewReplies, + boolean hasMoreReplies, + LocalDateTime createdAt) { + + private static final String ANONYMOUS_NAME = "ㅇㅇ"; + + public CommentResponse { + previewReplies = previewReplies == null ? List.of() : List.copyOf(previewReplies); + } - String displayContent = comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(); - Long parentIdValue = comment.getParent() != null ? comment.getParent().getId() : null; + public record WriterResponse(String publicId, String nickname, String profileImageUrl) {} + public static CommentResponse from(Comment comment) { + Member member = comment.getMember(); + WriterResponse writer = + !comment.isAnonymous() && member != null + ? new WriterResponse( + member.getPublicId(), + member.getNickname(), + member.getProfileImageUrl()) + : null; return new CommentResponse( comment.getId(), - parentIdValue, - writerName, - displayContent, + comment.getPost().getId(), + comment.getParent() == null ? null : comment.getParent().getId(), + writer, + comment.isAnonymous(), + WriterDisplayFormatter.maskIp(comment.getWriterIp()), + comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(), comment.isDeleted(), - comment.getCreatedAt(), - new ArrayList<>()); + 0, + List.of(), + false, + comment.getCreatedAt()); + } + + public CommentResponse withPreviewReplies(List replies) { + return new CommentResponse( + commentId, + postId, + parentId, + writer, + isAnonymous, + writerIp, + content, + isDeleted, + replyCount, + replies, + hasMoreReplies, + createdAt); + } + + public CommentResponse withReplyInfo( + long replyCount, boolean hasMoreReplies, List replies) { + return new CommentResponse( + commentId, + postId, + parentId, + writer, + isAnonymous, + writerIp, + content, + isDeleted, + replyCount, + replies, + hasMoreReplies, + createdAt); + } + + public List children() { + return previewReplies; + } + + public String writerName() { + if (!isAnonymous) { + return (writer != null && writer.nickname() != null) + ? writer.nickname() + : ANONYMOUS_NAME; + } + if (writerIp == null || writerIp.isBlank()) { + return ANONYMOUS_NAME; + } + + String[] ip = writerIp.split("\\."); + String shortIp = (ip.length >= 2) ? ip[0] + "." + ip[1] : writerIp; + return ANONYMOUS_NAME + "(" + shortIp + ")"; } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/PostCommentListResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/PostCommentListResponse.java index fac7f52..5597c3f 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/PostCommentListResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/PostCommentListResponse.java @@ -2,16 +2,13 @@ import java.util.List; -import lombok.Builder; - -@Builder public record PostCommentListResponse( - String publicId, int totalCommentCount, List comments) { + String publicId, + int totalCommentCount, + List comments, + Long nextCursor, + boolean hasNext) { public PostCommentListResponse { - if (comments == null) { - comments = List.of(); - } else { - comments = List.copyOf(comments); - } + comments = comments == null ? List.of() : List.copyOf(comments); } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java index 57dc1a5..3b47e87 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java @@ -16,7 +16,16 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "comment") +@Table( + name = "comment", + indexes = { + @Index( + name = "idx_comment_post_parent_id", + columnList = "post_id,parent_id,comment_id"), + @Index( + name = "idx_comment_parent_deleted_id", + columnList = "parent_id,is_deleted,comment_id") + }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?") @@ -76,6 +85,25 @@ public Comment( this.isDeleted = false; } + public static Comment create( + Post post, + Member member, + Comment parent, + String content, + String writerIp, + boolean isAnonymous, + String anonymousPassword) { + return new Comment(post, member, parent, content, writerIp, isAnonymous, anonymousPassword); + } + + public Comment rootParent() { + Comment current = this; + while (current.getParent() != null) { + current = current.getParent(); + } + return current; + } + public void softDelete() { this.isDeleted = true; this.deletedAt = LocalDateTime.now(); diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java index 1b2d566..7b7c16b 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java @@ -1,16 +1,25 @@ package com.ikae.snowthing.domain.comment.repository; -import java.util.List; +import java.util.Optional; + +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import com.ikae.snowthing.domain.comment.entity.Comment; -public interface CommentRepository extends JpaRepository { +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM Comment c WHERE c.id = :commentId") + Optional findByIdForUpdate(@Param("commentId") Long commentId); + + long countByParentIdAndIsDeletedFalse(Long parentId); - @Query( - "SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId ORDER BY c.createdAt ASC, c.id ASC") - List findByPostIdWithMember(@Param("postId") Long postId); + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("SELECT c.id FROM Comment c WHERE c.parent.id = :parentId AND c.isDeleted = false") + java.util.List findActiveReplyIdsForUpdate(@Param("parentId") Long parentId); } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryCustom.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryCustom.java new file mode 100644 index 0000000..cdc6eb0 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryCustom.java @@ -0,0 +1,27 @@ +package com.ikae.snowthing.domain.comment.repository; + +import java.util.List; +import java.util.Map; + +import com.ikae.snowthing.domain.comment.dto.CommentResponse; + +public interface CommentRepositoryCustom { + + boolean existsRootCursor(Long postId, Long cursorId); + + boolean existsReplyCursor(Long rootCommentId, Long cursorId); + + List findRootComments(Long postId, Long cursorId, int fetchSize); + + record ReplyStats(long activeCount, long totalCount) {} + + Map findReplyStats(List rootCommentIds); + + Map> findTopReplyPreviews(List rootCommentIds); + + List findReplies(Long rootCommentId, Long cursorId, int fetchSize); + + long countActiveReplies(Long rootCommentId); + + long countReplies(Long rootCommentId); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java new file mode 100644 index 0000000..5c795f5 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java @@ -0,0 +1,234 @@ +package com.ikae.snowthing.domain.comment.repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.CommentResponse.WriterResponse; +import com.ikae.snowthing.global.util.WriterDisplayFormatter; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + + private static final String SELECT_RESPONSE_COLUMNS = + """ + c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.is_anonymous, c.writer_ip, c.created_at, + m.public_id AS member_public_id, m.nickname, m.profile_image_url + """; + + private final NamedParameterJdbcTemplate jdbcTemplate; + + @Override + public boolean existsRootCursor(Long postId, Long cursorId) { + Integer count = + jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) FROM comment + WHERE post_id = :postId AND parent_id IS NULL AND comment_id = :cursorId + """, + new MapSqlParameterSource("postId", postId).addValue("cursorId", cursorId), + Integer.class); + return count != null && count > 0; + } + + @Override + public boolean existsReplyCursor(Long rootCommentId, Long cursorId) { + Integer count = + jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) FROM comment + WHERE parent_id = :rootCommentId AND comment_id = :cursorId + """, + new MapSqlParameterSource("rootCommentId", rootCommentId) + .addValue("cursorId", cursorId), + Integer.class); + return count != null && count > 0; + } + + @Override + public List findRootComments(Long postId, Long cursorId, int fetchSize) { + String cursorCondition = (cursorId != null) ? " AND c.comment_id > :cursorId" : ""; + String sql = + "SELECT " + + SELECT_RESPONSE_COLUMNS + + """ + , 0 AS reply_count, false AS has_more_replies + FROM comment c + LEFT JOIN member m ON m.member_id = c.member_id + WHERE c.post_id = :postId + AND c.parent_id IS NULL + AND (c.is_deleted = false OR EXISTS ( + SELECT 1 FROM comment active_child + WHERE active_child.parent_id = c.comment_id + AND active_child.is_deleted = false)) + """ + + cursorCondition + + " ORDER BY c.comment_id ASC LIMIT :fetchSize"; + + MapSqlParameterSource params = + new MapSqlParameterSource("postId", postId).addValue("fetchSize", fetchSize); + if (cursorId != null) { + params.addValue("cursorId", cursorId); + } + return jdbcTemplate.query(sql, params, this::mapResponse); + } + + @Override + public Map findReplyStats(List rootCommentIds) { + if (rootCommentIds.isEmpty()) { + return Map.of(); + } + String sql = + """ + SELECT parent_id, + COUNT(CASE WHEN is_deleted = false THEN 1 END) AS active_count, + COUNT(*) AS total_count + FROM comment + WHERE parent_id IN (:rootCommentIds) + GROUP BY parent_id + """; + + Map stats = new LinkedHashMap<>(); + jdbcTemplate.query( + sql, + new MapSqlParameterSource("rootCommentIds", rootCommentIds), + rs -> { + long parentId = rs.getLong("parent_id"); + long activeCount = rs.getLong("active_count"); + long totalCount = rs.getLong("total_count"); + stats.put(parentId, new ReplyStats(activeCount, totalCount)); + }); + return Map.copyOf(stats); + } + + @Override + public Map> findTopReplyPreviews(List rootCommentIds) { + if (rootCommentIds.isEmpty()) { + return Map.of(); + } + String sql = + """ + SELECT r.comment_id, r.post_id, r.parent_id, r.content, r.is_deleted, + r.is_anonymous, r.writer_ip, r.created_at, + m.public_id AS member_public_id, m.nickname, m.profile_image_url, + 0 AS reply_count, false AS has_more_replies + FROM comment root + CROSS JOIN LATERAL ( + SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.is_anonymous, c.writer_ip, c.created_at, c.member_id + FROM comment c + WHERE c.parent_id = root.comment_id + ORDER BY c.comment_id ASC + LIMIT 5 + ) r + LEFT JOIN member m ON m.member_id = r.member_id + WHERE root.comment_id IN (:rootCommentIds) + ORDER BY root.comment_id ASC, r.comment_id ASC + """; + + List replies = + jdbcTemplate.query( + sql, + new MapSqlParameterSource("rootCommentIds", rootCommentIds), + this::mapResponse); + Map> grouped = new LinkedHashMap<>(); + for (CommentResponse reply : replies) { + grouped.computeIfAbsent(reply.parentId(), ignored -> new java.util.ArrayList<>()) + .add(reply); + } + grouped.replaceAll((ignored, values) -> List.copyOf(values)); + return Map.copyOf(grouped); + } + + @Override + public List findReplies(Long rootCommentId, Long cursorId, int fetchSize) { + String cursorCondition = (cursorId != null) ? " AND c.comment_id > :cursorId" : ""; + String sql = + "SELECT " + + SELECT_RESPONSE_COLUMNS + + """ + , 0 AS reply_count, false AS has_more_replies + FROM comment c + LEFT JOIN member m ON m.member_id = c.member_id + WHERE c.parent_id = :rootCommentId + """ + + cursorCondition + + " ORDER BY c.comment_id ASC LIMIT :fetchSize"; + MapSqlParameterSource params = + new MapSqlParameterSource("rootCommentId", rootCommentId) + .addValue("fetchSize", fetchSize); + if (cursorId != null) { + params.addValue("cursorId", cursorId); + } + return jdbcTemplate.query(sql, params, this::mapResponse); + } + + @Override + public long countActiveReplies(Long rootCommentId) { + Long count = + jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) FROM comment + WHERE parent_id = :rootCommentId AND is_deleted = false + """, + new MapSqlParameterSource("rootCommentId", rootCommentId), + Long.class); + return count == null ? 0 : count; + } + + @Override + public long countReplies(Long rootCommentId) { + Long count = + jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) FROM comment + WHERE parent_id = :rootCommentId + """, + new MapSqlParameterSource("rootCommentId", rootCommentId), + Long.class); + return count == null ? 0 : count; + } + + private CommentResponse mapResponse(ResultSet rs, int rowNum) throws SQLException { + boolean anonymous = rs.getBoolean("is_anonymous"); + boolean deleted = rs.getBoolean("is_deleted"); + String memberPublicId = rs.getString("member_public_id"); + WriterResponse writer = + !anonymous && memberPublicId != null + ? new WriterResponse( + memberPublicId, + rs.getString("nickname"), + rs.getString("profile_image_url")) + : null; + return new CommentResponse( + rs.getLong("comment_id"), + rs.getLong("post_id"), + nullableLong(rs, "parent_id"), + writer, + anonymous, + WriterDisplayFormatter.maskIp(rs.getString("writer_ip")), + deleted ? "삭제된 댓글입니다." : rs.getString("content"), + deleted, + rs.getLong("reply_count"), + List.of(), + rs.getBoolean("has_more_replies"), + rs.getObject("created_at", LocalDateTime.class)); + } + + private Long nullableLong(ResultSet rs, String column) throws SQLException { + long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java index b923f20..c3b197d 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java @@ -10,6 +10,7 @@ import com.ikae.snowthing.domain.comment.dto.*; import com.ikae.snowthing.domain.comment.entity.Comment; import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.comment.repository.CommentRepositoryCustom.ReplyStats; import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.member.repository.MemberRepository; import com.ikae.snowthing.domain.post.entity.Post; @@ -28,6 +29,10 @@ @Transactional(readOnly = true) public class CommentService { + private static final long MAX_REPLY_COUNT = 100L; + private static final int DEFAULT_READ_SIZE = 20; + private static final int MAX_READ_SIZE = 50; + private final CommentRepository commentRepository; private final PostRepository postRepository; private final MemberRepository memberRepository; @@ -78,13 +83,11 @@ public CommentResponse createComment( new CustomAuthException( ErrorCode.POST_NOT_FOUND)); - if (post.isDeleted() || post.getStatus() != PostStatus.NORMAL) { - throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); - } + validatePostVisibility(post); Comment parent = null; if (request.parentId() != null) { - parent = + Comment requestedParent = commentRepository .findById(request.parentId()) .orElseThrow( @@ -93,21 +96,36 @@ public CommentResponse createComment( ErrorCode .PARENT_COMMENT_NOT_FOUND)); - if (!parent.getPost().getId().equals(post.getId())) { + if (!requestedParent.getPost().getId().equals(post.getId())) { throw new CustomAuthException(ErrorCode.INVALID_COMMENT_PARENT); } + + Long rootCommentId = requestedParent.rootParent().getId(); + parent = + commentRepository + .findByIdForUpdate(rootCommentId) + .orElseThrow( + () -> + new CustomAuthException( + ErrorCode + .PARENT_COMMENT_NOT_FOUND)); + + long activeReplyCount = + commentRepository.findActiveReplyIdsForUpdate(rootCommentId).size(); + if (activeReplyCount >= MAX_REPLY_COUNT) { + throw new CustomAuthException(ErrorCode.COMMENT_REPLY_LIMIT_EXCEEDED); + } } Comment comment = - Comment.builder() - .post(post) - .member(finalMember) - .parent(parent) - .content(request.content()) - .writerIp(clientIp != null ? clientIp : "127.0.0.1") - .isAnonymous(request.isAnonymous()) - .anonymousPassword(finalEncodedPassword) - .build(); + Comment.create( + post, + finalMember, + parent, + request.content(), + clientIp != null ? clientIp : "127.0.0.1", + request.isAnonymous(), + finalEncodedPassword); Comment savedComment = commentRepository.save(comment); postRepository.increaseCommentCount(post.getId()); @@ -117,39 +135,83 @@ public CommentResponse createComment( } public PostCommentListResponse getCommentsByPost(String postPublicId) { + return getCommentsByPost(postPublicId, null, DEFAULT_READ_SIZE); + } + + public PostCommentListResponse getCommentsByPost(String postPublicId, Long cursor, int size) { + validateReadSize(size); Post post = postRepository .findByPublicId(postPublicId) .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); - if (post.isDeleted() || post.getStatus() != PostStatus.NORMAL) { - throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + validatePostVisibility(post); + + if (cursor != null && !commentRepository.existsRootCursor(post.getId(), cursor)) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); } + List fetched = + commentRepository.findRootComments(post.getId(), cursor, size + 1); + boolean hasNext = fetched.size() > size; + List roots = new ArrayList<>(hasNext ? fetched.subList(0, size) : fetched); + List rootIds = roots.stream().map(CommentResponse::commentId).toList(); + Map replyStats = commentRepository.findReplyStats(rootIds); + Map> previews = commentRepository.findTopReplyPreviews(rootIds); + List comments = + roots.stream() + .map( + root -> { + ReplyStats stat = + replyStats.getOrDefault( + root.commentId(), new ReplyStats(0, 0)); + List rootPreviews = + previews.getOrDefault(root.commentId(), List.of()); + return root.withReplyInfo( + stat.totalCount(), stat.totalCount() > 5, rootPreviews); + }) + .toList(); + Long nextCursor = hasNext && !comments.isEmpty() ? comments.getLast().commentId() : null; + return new PostCommentListResponse( + postPublicId, post.getCommentCount(), comments, nextCursor, hasNext); + } - List comments = commentRepository.findByPostIdWithMember(post.getId()); + public CommentReplyListResponse getCommentReplies(Long commentId, Long cursor, int size) { + validateReadSize(size); + Comment root = + commentRepository + .findById(commentId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + + Post post = + postRepository + .findById(root.getPost().getId()) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + validatePostVisibility(post); - Map map = new LinkedHashMap<>(); - for (Comment comment : comments) { - map.put(comment.getId(), CommentResponse.from(comment)); + if (root.getParent() != null) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); + } + if (cursor != null && !commentRepository.existsReplyCursor(commentId, cursor)) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); } + List fetched = commentRepository.findReplies(commentId, cursor, size + 1); + boolean hasNext = fetched.size() > size; + List replies = List.copyOf(hasNext ? fetched.subList(0, size) : fetched); + Long nextCursor = hasNext && !replies.isEmpty() ? replies.getLast().commentId() : null; + return new CommentReplyListResponse( + commentId, commentRepository.countReplies(commentId), replies, nextCursor, hasNext); + } - List rootComments = new ArrayList<>(); - for (CommentResponse dto : map.values()) { - if (dto.parentId() == null) { - rootComments.add(dto); - } else { - CommentResponse parentDto = map.get(dto.parentId()); - if (parentDto != null) { - parentDto.children().add(dto); - } - } + private void validatePostVisibility(Post post) { + if (post == null || post.isDeleted() || post.getStatus() != PostStatus.NORMAL) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); } + } - return PostCommentListResponse.builder() - .publicId(postPublicId) - .totalCommentCount(post.getCommentCount()) - .comments(rootComments) - .build(); + private void validateReadSize(int size) { + if (size < 1 || size > MAX_READ_SIZE) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } } @Transactional diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java index 256989b..6571e7b 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java @@ -35,7 +35,8 @@ public class DataInitializer implements CommandLineRunner { @Override public void run(String... args) { - if (!memberRepository.existsByEmail("user@snowthing.com")) { + if (!memberRepository.existsByEmail("user@snowthing.com") + && !memberRepository.existsByNickname("스노보더1")) { memberRepository.save( Member.builder() .email("user@snowthing.com") @@ -45,7 +46,8 @@ public void run(String... args) { .build()); } - if (!memberRepository.existsByEmail("admin@snowthing.com")) { + if (!memberRepository.existsByEmail("admin@snowthing.com") + && !memberRepository.existsByNickname("최고관리자")) { memberRepository.save( Member.builder() .email("admin@snowthing.com") diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index 6926451..1e78ef7 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -24,6 +24,8 @@ public enum ErrorCode { COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "존재하지 않거나 이미 삭제된 댓글입니다."), PARENT_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_002", "존재하지 않는 부모 댓글입니다."), INVALID_COMMENT_PARENT(HttpStatus.BAD_REQUEST, "COMMENT_003", "동일한 게시글의 댓글에만 대댓글을 달 수 있습니다."), + COMMENT_REPLY_LIMIT_EXCEEDED( + HttpStatus.BAD_REQUEST, "COMMENT_004", "루트 댓글 1개당 작성 가능한 대댓글 수는 최대 100개입니다."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다."), INVALID_PAGE_SIZE(HttpStatus.BAD_REQUEST, "COMMON_002", "페이지 크기는 1 이상 100 이하이어야 합니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 18702da..c9cdfb6 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -39,8 +39,8 @@ spring: datasource: url: jdbc:mysql://localhost:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul driver-class-name: com.mysql.cj.jdbc.Driver - username: snowuser - password: snowthing_pass_2026! + username: ${SNOWTHING_DB_USERNAME:snowuser} + password: ${SNOWTHING_DB_PASSWORD:snowthing_pass_2026!} jpa: hibernate: @@ -63,8 +63,8 @@ spring: datasource: url: jdbc:mysql://mysql:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul driver-class-name: com.mysql.cj.jdbc.Driver - username: snowuser - password: snowthing_pass_2026! + username: ${SNOWTHING_DB_USERNAME:snowuser} + password: ${SNOWTHING_DB_PASSWORD} jpa: hibernate: diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java new file mode 100644 index 0000000..af90c1f --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java @@ -0,0 +1,364 @@ +package com.ikae.snowthing.domain.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentReplyListResponse; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.PostCommentListResponse; +import com.ikae.snowthing.domain.comment.service.CommentService; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CommentReadTest { + + @Autowired private CommentService commentService; + @Autowired private PostService postService; + @Autowired private PostRepository postRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private NamedParameterJdbcTemplate jdbcTemplate; + @Autowired private MockMvc mockMvc; + + private CustomUserDetails userDetails; + private PostResponse post; + + @BeforeEach + void setUp() { + categoryRepository + .findByCode("FREE") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder().name("자유게시판").code("FREE").build())); + Member member = + memberRepository.save( + Member.builder() + .email("comment-read@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글조회보더") + .profileImageUrl("https://example.com/profile.jpg") + .role(Role.ROLE_USER) + .build()); + userDetails = new CustomUserDetails(member); + post = createPost("댓글 조회 게시글"); + } + + @Nested + @DisplayName("성공 시나리오") + class SuccessCases { + + @Test + @DisplayName("루트 댓글을 중복 없이 커서 페이징하고 마지막 페이지를 판별한다") + void rootCursorPaging() { + CommentResponse first = createRoot("루트 1"); + CommentResponse second = createRoot("루트 2"); + CommentResponse third = createRoot("루트 3"); + + PostCommentListResponse firstPage = + commentService.getCommentsByPost(post.publicId(), null, 2); + PostCommentListResponse secondPage = + commentService.getCommentsByPost(post.publicId(), firstPage.nextCursor(), 2); + + assertThat(firstPage.comments()) + .extracting(CommentResponse::commentId) + .containsExactly(first.commentId(), second.commentId()); + assertThat(firstPage.hasNext()).isTrue(); + assertThat(firstPage.nextCursor()).isEqualTo(second.commentId()); + assertThat(secondPage.comments()) + .extracting(CommentResponse::commentId) + .containsExactly(third.commentId()); + assertThat(secondPage.hasNext()).isFalse(); + assertThat(secondPage.nextCursor()).isNull(); + } + + @Test + @DisplayName("동일 생성 시각에는 commentId 오름차순으로 결정론적 정렬한다") + void sameCreatedAtUsesIdTieBreaker() { + CommentResponse first = createRoot("동시각 1"); + CommentResponse second = createRoot("동시각 2"); + LocalDateTime sameTime = LocalDateTime.of(2026, 9, 1, 12, 0); + jdbcTemplate.update( + "UPDATE comment SET created_at = :createdAt WHERE comment_id IN (:ids)", + new MapSqlParameterSource("createdAt", sameTime) + .addValue( + "ids", + java.util.List.of(first.commentId(), second.commentId()))); + + PostCommentListResponse response = + commentService.getCommentsByPost(post.publicId(), null, 20); + + assertThat(response.comments()) + .extracting(CommentResponse::commentId) + .containsExactly(first.commentId(), second.commentId()); + } + + @Test + @DisplayName("루트별 대댓글은 5개만 프리뷰하고 이후 항목을 분리 API로 조회한다") + void topFivePreviewAndSeparatedReplies() throws Exception { + CommentResponse root = createRoot("프리뷰 루트"); + for (int i = 1; i <= 7; i++) { + createReply(root.commentId(), "대댓글 " + i); + } + + PostCommentListResponse comments = + commentService.getCommentsByPost(post.publicId(), null, 20); + CommentResponse rootResponse = comments.comments().getFirst(); + Long fifthReplyId = rootResponse.previewReplies().getLast().commentId(); + CommentReplyListResponse remainder = + commentService.getCommentReplies(root.commentId(), fifthReplyId, 20); + + assertThat(rootResponse.replyCount()).isEqualTo(7); + assertThat(rootResponse.previewReplies()).hasSize(5); + assertThat(rootResponse.hasMoreReplies()).isTrue(); + assertThat(remainder.replies()) + .extracting(CommentResponse::content) + .containsExactly("대댓글 6", "대댓글 7"); + assertThat(remainder.totalReplyCount()).isEqualTo(7); + assertThat(remainder.hasNext()).isFalse(); + + mockMvc.perform( + get("/api/v1/comments/{commentId}/replies", root.commentId()) + .param("cursor", fifthReplyId.toString()) + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rootCommentId").value(root.commentId())) + .andExpect(jsonPath("$.replies.length()").value(2)); + } + + @Test + @DisplayName("대댓글 중 일부가 삭제되어도 replyCount, 프리뷰, 더보기 기준은 화면 노출 기준으로 전체 대댓글 수를 일관되게 반영한다") + void replyCountAndHasMoreConsistentWithDeletedReplies() { + CommentResponse root = createRoot("루트 댓글"); + List replies = new ArrayList<>(); + for (int i = 1; i <= 6; i++) { + replies.add(createReply(root.commentId(), "대댓글 " + i)); + } + + commentService.deleteComment(replies.get(0).commentId(), null, userDetails); + commentService.deleteComment(replies.get(1).commentId(), null, userDetails); + + PostCommentListResponse response = + commentService.getCommentsByPost(post.publicId(), null, 20); + CommentResponse rootDto = response.comments().getFirst(); + + assertThat(rootDto.replyCount()).isEqualTo(6); + assertThat(rootDto.previewReplies()).hasSize(5); + assertThat(rootDto.hasMoreReplies()).isTrue(); + + CommentReplyListResponse replyListResponse = + commentService.getCommentReplies(root.commentId(), null, 5); + assertThat(replyListResponse.totalReplyCount()).isEqualTo(6); + assertThat(replyListResponse.hasNext()).isTrue(); + assertThat(replyListResponse.replies()).hasSize(5); + } + + @Test + @DisplayName("삭제 루트는 활성 대댓글이 있으면 placeholder로 남고 모두 삭제되면 은닉한다") + void deletionVisibilityPolicy() { + CommentResponse root = createRoot("삭제될 루트"); + CommentResponse reply = createReply(root.commentId(), "남아 있는 대댓글"); + commentService.deleteComment(root.commentId(), null, userDetails); + + PostCommentListResponse withActiveReply = + commentService.getCommentsByPost(post.publicId(), null, 20); + assertThat(withActiveReply.comments()).hasSize(1); + assertThat(withActiveReply.comments().getFirst().content()).isEqualTo("삭제된 댓글입니다."); + assertThat(withActiveReply.comments().getFirst().replyCount()).isEqualTo(1); + + commentService.deleteComment(reply.commentId(), null, userDetails); + PostCommentListResponse allDeleted = + commentService.getCommentsByPost(post.publicId(), null, 20); + assertThat(allDeleted.comments()).isEmpty(); + } + + @Test + @DisplayName("조회 응답의 루트 및 프리뷰 컬렉션은 변경할 수 없다") + void responseCollectionsAreImmutable() { + CommentResponse root = createRoot("불변 루트"); + createReply(root.commentId(), "불변 대댓글"); + PostCommentListResponse response = + commentService.getCommentsByPost(post.publicId(), null, 20); + + assertThatThrownBy(() -> response.comments().clear()) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> response.comments().getFirst().previewReplies().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } + } + + @Nested + @DisplayName("실패 시나리오") + class FailureCases { + + @Test + @DisplayName("존재하지 않는 게시글 댓글 조회는 POST_NOT_FOUND를 반환한다") + void postNotFound() { + assertErrorCode( + () -> commentService.getCommentsByPost("missing-public-id", null, 20), + ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("페이지 크기가 허용 범위를 벗어나면 INVALID_INPUT을 반환한다") + void invalidPageSize() throws Exception { + assertErrorCode( + () -> commentService.getCommentsByPost(post.publicId(), null, 0), + ErrorCode.INVALID_INPUT); + assertErrorCode( + () -> commentService.getCommentsByPost(post.publicId(), null, 51), + ErrorCode.INVALID_INPUT); + + mockMvc.perform( + get("/api/v1/posts/{publicId}/comments", post.publicId()) + .param("size", "51")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())); + } + + @Test + @DisplayName("다른 게시글의 루트 커서를 사용하면 COMMENT_NOT_FOUND를 반환한다") + void cursorFromDifferentPost() { + PostResponse anotherPost = createPost("다른 게시글"); + CommentResponse foreignCursor = + commentService.createComment( + anotherPost.publicId(), + request("다른 루트", null), + userDetails, + "127.0.0.1"); + + assertErrorCode( + () -> + commentService.getCommentsByPost( + post.publicId(), foreignCursor.commentId(), 20), + ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("대댓글 ID를 루트 분리 조회 대상으로 사용하면 COMMENT_NOT_FOUND를 반환한다") + void replyCannotBeUsedAsRoot() { + CommentResponse root = createRoot("루트"); + CommentResponse child = createReply(root.commentId(), "대댓글"); + + assertErrorCode( + () -> commentService.getCommentReplies(child.commentId(), null, 20), + ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("다른 루트의 대댓글 커서를 사용하면 COMMENT_NOT_FOUND를 반환한다") + void cursorFromDifferentRoot() { + CommentResponse firstRoot = createRoot("첫 루트"); + CommentResponse secondRoot = createRoot("둘째 루트"); + CommentResponse foreignReply = createReply(secondRoot.commentId(), "다른 루트 대댓글"); + + assertErrorCode( + () -> + commentService.getCommentReplies( + firstRoot.commentId(), foreignReply.commentId(), 20), + ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("게시글이 삭제된 경우 대댓글 직접 조회 시 POST_NOT_FOUND를 반환한다") + void repliesOfDeletedPostThrowsException() { + CommentResponse root = createRoot("루트 댓글"); + createReply(root.commentId(), "대댓글"); + + Post postEntity = postRepository.findByPublicId(post.publicId()).orElseThrow(); + postEntity.softDelete(); + + assertErrorCode( + () -> commentService.getCommentReplies(root.commentId(), null, 20), + ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("게시글이 차단(BLOCKED)된 경우 대댓글 직접 조회 시 POST_NOT_FOUND를 반환한다") + void repliesOfBlockedPostThrowsException() { + CommentResponse root = createRoot("루트 댓글"); + createReply(root.commentId(), "대댓글"); + + Post postEntity = postRepository.findByPublicId(post.publicId()).orElseThrow(); + postEntity.changeStatus(PostStatus.BLOCKED); + + assertErrorCode( + () -> commentService.getCommentReplies(root.commentId(), null, 20), + ErrorCode.POST_NOT_FOUND); + } + } + + private PostResponse createPost(String title) { + return postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title(title) + .content("본문") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + } + + private CommentResponse createRoot(String content) { + return commentService.createComment( + post.publicId(), request(content, null), userDetails, "211.234.10.20"); + } + + private CommentResponse createReply(Long rootId, String content) { + return commentService.createComment( + post.publicId(), request(content, rootId), userDetails, "175.120.10.20"); + } + + private CommentCreateRequest request(String content, Long parentId) { + return CommentCreateRequest.builder() + .parentId(parentId) + .content(content) + .isAnonymous(false) + .build(); + } + + private void assertErrorCode(Runnable action, ErrorCode errorCode) { + assertThatThrownBy(action::run) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(errorCode); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java index 0b1aca6..2507d36 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java @@ -1,5 +1,6 @@ package com.ikae.snowthing.domain.comment.controller; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -8,6 +9,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -108,6 +111,49 @@ void createComment_success() throws Exception { .andExpect(jsonPath("$.content").value("통합 테스트 댓글 내용")); } + @ParameterizedTest(name = "익명 비밀번호 {0}자는 허용된다") + @ValueSource(ints = {4, 20}) + @DisplayName("POST /api/v1/posts/{publicId}/comments - 익명 비밀번호 허용 경계값") + void createAnonymousComment_acceptsValidPasswordBoundary(int passwordLength) throws Exception { + CommentCreateRequest request = + CommentCreateRequest.builder() + .content("비로그인 익명 댓글") + .isAnonymous(true) + .anonymousPassword("1".repeat(passwordLength)) + .build(); + + mockMvc.perform( + post("/api/v1/posts/" + post.publicId() + "/comments") + .with(csrf()) + .with(anonymous()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.commentId").exists()); + } + + @ParameterizedTest(name = "익명 비밀번호 {0}자는 거부된다") + @ValueSource(ints = {3, 21}) + @DisplayName("POST /api/v1/posts/{publicId}/comments - 익명 비밀번호 거부 경계값") + void createAnonymousComment_rejectsInvalidPasswordBoundary(int passwordLength) + throws Exception { + CommentCreateRequest request = + CommentCreateRequest.builder() + .content("비로그인 익명 댓글") + .isAnonymous(true) + .anonymousPassword("1".repeat(passwordLength)) + .build(); + + mockMvc.perform( + post("/api/v1/posts/" + post.publicId() + "/comments") + .with(csrf()) + .with(anonymous()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_001")); + } + @Test @DisplayName("GET /api/v1/posts/{publicId}/comments - 댓글 목록 조회 200 OK") void getComments_success() throws Exception { diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java new file mode 100644 index 0000000..3106180 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java @@ -0,0 +1,73 @@ +package com.ikae.snowthing.domain.comment.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CommentResponseTest { + + @Test + @DisplayName("[익명 댓글 IP 마스킹] 익명 댓글이고 IP가 주어지면 앞 두 자리만 포함하여 'ㅇㅇ(xxx.xxx)' 형태로 반환해야 한다") + void writerName_AnonymousWithIp_ReturnsShortIp() { + CommentResponse response = createResponse(true, "127.0.***.***", null); + + assertThat(response.writerName()).isEqualTo("ㅇㅇ(127.0)"); + } + + @Test + @DisplayName("[익명 댓글 일반 IPv4] 익명 댓글이고 마스킹 전 4옥텟 IP라도 앞 두 자리만 반환해야 한다") + void writerName_AnonymousWithRawIp_ReturnsShortIp() { + CommentResponse response = createResponse(true, "211.234.12.34", null); + + assertThat(response.writerName()).isEqualTo("ㅇㅇ(211.234)"); + } + + @Test + @DisplayName("[익명 댓글 IP 누락] 익명 댓글인데 IP가 없거나 빈 값이면 'ㅇㅇ'만 반환해야 한다") + void writerName_AnonymousWithoutIp_ReturnsOnlyAnonymousName() { + CommentResponse responseNullIp = createResponse(true, null, null); + CommentResponse responseBlankIp = createResponse(true, " ", null); + + assertThat(responseNullIp.writerName()).isEqualTo("ㅇㅇ"); + assertThat(responseBlankIp.writerName()).isEqualTo("ㅇㅇ"); + } + + @Test + @DisplayName("[회원 댓글] 비익명 회원이면 회원의 닉네임을 반환해야 한다") + void writerName_Member_ReturnsNickname() { + CommentResponse.WriterResponse writer = + new CommentResponse.WriterResponse("user-uuid", "스노우보더", "profile.jpg"); + CommentResponse response = createResponse(false, "127.0.0.1", writer); + + assertThat(response.writerName()).isEqualTo("스노우보더"); + } + + @Test + @DisplayName("[회원 댓글 작성자 누락] 비익명인데 회원 정보가 null이면 'ㅇㅇ'를 기본값으로 반환해야 한다") + void writerName_MemberNull_ReturnsAnonymousName() { + CommentResponse response = createResponse(false, "127.0.0.1", null); + + assertThat(response.writerName()).isEqualTo("ㅇㅇ"); + } + + private CommentResponse createResponse( + boolean isAnonymous, String writerIp, CommentResponse.WriterResponse writer) { + return new CommentResponse( + 1L, + 10L, + null, + writer, + isAnonymous, + writerIp, + "댓글 내용입니다.", + false, + 0L, + List.of(), + false, + LocalDateTime.now()); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/entity/CommentTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/entity/CommentTest.java new file mode 100644 index 0000000..ff024c2 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/entity/CommentTest.java @@ -0,0 +1,64 @@ +package com.ikae.snowthing.domain.comment.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CommentTest { + + @Test + @DisplayName("루트 댓글(parent == null)에서 rootParent를 호출하면 자기 자신을 반환한다") + void rootComment_rootParent_returnsSelf() { + Comment root = + Comment.builder().content("루트 댓글").writerIp("127.0.0.1").isAnonymous(false).build(); + + assertThat(root.rootParent()).isSameAs(root); + } + + @Test + @DisplayName("2-depth 대댓글에서 rootParent를 호출하면 부모(루트 댓글)를 반환한다") + void childComment_rootParent_returnsParent() { + Comment root = + Comment.builder().content("루트 댓글").writerIp("127.0.0.1").isAnonymous(false).build(); + + Comment child = + Comment.builder() + .parent(root) + .content("대댓글") + .writerIp("127.0.0.1") + .isAnonymous(false) + .build(); + + assertThat(child.rootParent()).isSameAs(root); + } + + @Test + @DisplayName("3-depth 이상의 깊은 계층 구조가 생기더라도 rootParent는 최상위 루트 댓글을 끝까지 탐색해 반환한다") + void deepChildComment_rootParent_traversesToRoot() { + Comment grandfather = + Comment.builder() + .content("할아버지(루트) 댓글") + .writerIp("127.0.0.1") + .isAnonymous(false) + .build(); + + Comment father = + Comment.builder() + .parent(grandfather) + .content("아버지 대댓글") + .writerIp("127.0.0.1") + .isAnonymous(false) + .build(); + + Comment grandson = + Comment.builder() + .parent(father) + .content("손자 대대댓글 (3-depth 이상)") + .writerIp("127.0.0.1") + .isAnonymous(false) + .build(); + + assertThat(grandson.rootParent()).isSameAs(grandfather); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java new file mode 100644 index 0000000..fbbd3ab --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java @@ -0,0 +1,430 @@ +package com.ikae.snowthing.domain.comment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import jakarta.persistence.EntityManager; + +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.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.MemberStatus; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class CommentCreateTest { + + @DynamicPropertySource + static void useRealMySql(DynamicPropertyRegistry registry) { + String testDbUrl = System.getenv("SNOWTHING_TEST_DB_URL"); + if (testDbUrl == null || testDbUrl.isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + registry.add("spring.datasource.url", () -> testDbUrl); + registry.add( + "spring.datasource.username", + () -> requiredEnvironmentVariable("SNOWTHING_TEST_DB_USERNAME")); + registry.add( + "spring.datasource.password", + () -> requiredEnvironmentVariable("SNOWTHING_TEST_DB_PASSWORD")); + registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MySQLDialect"); + registry.add( + "spring.jpa.properties.hibernate.dialect", + () -> "org.hibernate.dialect.MySQLDialect"); + } + + private static String requiredEnvironmentVariable(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + return value; + } + + @Autowired private CommentService commentService; + @Autowired private CommentRepository commentRepository; + @Autowired private PostService postService; + @Autowired private PostRepository postRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private EntityManager entityManager; + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + + private CustomUserDetails userDetails; + private PostResponse postResponse; + + @BeforeEach + void setUp() { + String fixtureId = UUID.randomUUID().toString(); + categoryRepository + .findByCode("FREE") + .orElseGet(() -> categoryRepository.save(new PostCategory("자유게시판", "FREE"))); + + Member member = + memberRepository.save( + new Member( + null, + "comment-create-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "댓글작성자-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + userDetails = new CustomUserDetails(member); + + postResponse = + postService.createPost( + new PostCreateRequest( + "FREE", "댓글 생성 테스트", "게시글 본문", false, null, List.of()), + userDetails, + "127.0.0.1"); + } + + @Test + @DisplayName("로그인 회원이 루트 댓글을 생성하면 부모 없이 저장되고 댓글 수가 증가한다") + void createRootCommentAsMember() { + CommentResponse response = createComment(null, "회원 루트 댓글"); + + entityManager.flush(); + entityManager.clear(); + Comment savedComment = commentRepository.findById(response.commentId()).orElseThrow(); + Post savedPost = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(savedComment.getParent()).isNull(); + assertThat(savedComment.getContent()).isEqualTo("회원 루트 댓글"); + assertThat(savedPost.getCommentCount()).isEqualTo(1); + } + + @Test + @DisplayName("로그인 회원은 작성자를 숨긴 익명 댓글을 생성할 수 있다") + void createAnonymousCommentAsMember() { + CommentResponse response = + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "로그인 익명 댓글", true, null), + userDetails, + "127.0.0.1"); + + Comment savedComment = commentRepository.findById(response.commentId()).orElseThrow(); + assertThat(savedComment.isAnonymous()).isTrue(); + assertThat(savedComment.getMember()).isNotNull(); + assertThat(savedComment.getAnonymousPassword()).isNull(); + } + + @Test + @DisplayName("비로그인 사용자는 비밀번호를 제공하면 익명 댓글을 생성할 수 있다") + void createAnonymousCommentAsGuest() { + CommentResponse response = + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "비로그인 익명 댓글", true, "1234"), + null, + "127.0.0.1"); + + Comment savedComment = commentRepository.findById(response.commentId()).orElseThrow(); + assertThat(savedComment.getMember()).isNull(); + assertThat(passwordEncoder.matches("1234", savedComment.getAnonymousPassword())).isTrue(); + } + + @Test + @DisplayName("삭제된 루트 댓글에도 새 대댓글을 생성할 수 있다") + void createReplyUnderDeletedRootComment() { + CommentResponse root = createComment(null, "삭제할 루트 댓글"); + commentService.deleteComment(root.commentId(), null, userDetails); + + CommentResponse reply = createComment(root.commentId(), "삭제된 루트의 새 대댓글"); + + assertThat(reply.parentId()).isEqualTo(root.commentId()); + } + + @Test + @DisplayName("대댓글에 답글을 작성해도 최상위 루트 댓글 아래로 평탄화한다") + void flattenReplyToRootComment() { + CommentResponse root = createComment(null, "루트 댓글"); + CommentResponse reply = createComment(root.commentId(), "첫 번째 대댓글"); + + CommentResponse nestedReply = createComment(reply.commentId(), "대댓글에 작성한 답글"); + + entityManager.flush(); + entityManager.clear(); + Comment savedNestedReply = + commentRepository.findById(nestedReply.commentId()).orElseThrow(); + assertThat(savedNestedReply.getParent().getId()).isEqualTo(root.commentId()); + assertThat(nestedReply.parentId()).isEqualTo(root.commentId()); + } + + @Test + @DisplayName("루트 댓글의 활성 대댓글이 100개이면 COMMENT_004 예외를 발생시킨다") + void rejectReplyWhenActiveReplyCountReachesLimit() { + CommentResponse root = createComment(null, "루트 댓글"); + for (int index = 0; index < 100; index++) { + createComment(root.commentId(), "대댓글 " + index); + } + + assertThatThrownBy(() -> createComment(root.commentId(), "101번째 대댓글")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_REPLY_LIMIT_EXCEEDED); + assertThat(commentRepository.countByParentIdAndIsDeletedFalse(root.commentId())) + .isEqualTo(100); + entityManager.clear(); + assertThat( + postRepository + .findByPublicId(postResponse.publicId()) + .orElseThrow() + .getCommentCount()) + .isEqualTo(101); + } + + @Test + @DisplayName("삭제된 대댓글은 활성 대댓글 100개 상한에서 제외한다") + void excludeDeletedReplyFromActiveReplyLimit() { + CommentResponse root = createComment(null, "루트 댓글"); + CommentResponse replyToDelete = null; + for (int index = 0; index < 100; index++) { + CommentResponse reply = createComment(root.commentId(), "대댓글 " + index); + if (index == 0) { + replyToDelete = reply; + } + } + commentService.deleteComment(replyToDelete.commentId(), null, userDetails); + + CommentResponse replacement = createComment(root.commentId(), "삭제 후 새 대댓글"); + + assertThat(replacement.parentId()).isEqualTo(root.commentId()); + } + + @Test + @DisplayName("댓글 저장과 게시글 commentCount 증가는 같은 트랜잭션에서 동기화된다") + void increasePostCommentCountWithCommentCreation() { + createComment(null, "루트 댓글"); + createComment(null, "두 번째 루트 댓글"); + + entityManager.flush(); + entityManager.clear(); + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(post.getCommentCount()).isEqualTo(2); + } + + @Test + @DisplayName("존재하지 않는 게시글에는 댓글을 생성할 수 없다") + void rejectCommentForMissingPost() { + assertThatThrownBy( + () -> + commentService.createComment( + UUID.randomUUID().toString(), + new CommentCreateRequest(null, "댓글", false, null), + userDetails, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("정상 상태가 아닌 게시글에는 댓글을 생성할 수 없다") + void rejectCommentForBlockedPost() { + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + post.changeStatus(PostStatus.BLOCKED); + entityManager.flush(); + + assertThatThrownBy(() -> createComment(null, "차단 게시글 댓글")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("존재하지 않는 부모 댓글을 지정하면 댓글을 생성할 수 없다") + void rejectMissingParentComment() { + assertThatThrownBy(() -> createComment(Long.MAX_VALUE, "잘못된 부모")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PARENT_COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("다른 게시글의 댓글을 부모로 지정하면 댓글을 생성할 수 없다") + void rejectParentCommentFromAnotherPost() { + PostResponse anotherPost = + postService.createPost( + new PostCreateRequest("FREE", "다른 게시글", "다른 본문", false, null, List.of()), + userDetails, + "127.0.0.1"); + CommentResponse anotherRoot = + commentService.createComment( + anotherPost.publicId(), + new CommentCreateRequest(null, "다른 게시글 댓글", false, null), + userDetails, + "127.0.0.1"); + + assertThatThrownBy(() -> createComment(anotherRoot.commentId(), "잘못 연결한 대댓글")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_COMMENT_PARENT); + } + + @Test + @DisplayName("비로그인 사용자는 일반 댓글을 생성할 수 없다") + void rejectMemberCommentFromGuest() { + assertThatThrownBy( + () -> + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "비로그인 일반 댓글", false, null), + null, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_CREDENTIALS); + } + + @Test + @DisplayName("DB에 존재하지 않는 회원 정보로는 댓글을 생성할 수 없다") + void rejectCommentFromMissingMember() { + Member missingMember = + new Member( + UUID.randomUUID().toString(), + "missing@example.com", + "password", + "존재하지않는회원", + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE); + + assertThatThrownBy( + () -> + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "댓글", false, null), + new CustomUserDetails(missingMember), + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("비로그인 익명 사용자는 비밀번호 없이 댓글을 생성할 수 없다") + void rejectAnonymousCommentWithoutPassword() { + assertThatThrownBy( + () -> + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "익명 댓글", true, null), + null, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("대댓글이 99개일 때 동시 요청 두 개 중 하나만 성공해 최종 100개를 유지한다") + void allowOnlyOneConcurrentReplyAtLimitBoundary() throws Exception { + CommentResponse root = createComment(null, "동시성 루트 댓글"); + for (int index = 0; index < 99; index++) { + createComment(root.commentId(), "기존 대댓글 " + index); + } + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + List> results = + List.of( + executor.submit( + () -> createConcurrentReply(root.commentId(), ready, start)), + executor.submit( + () -> createConcurrentReply(root.commentId(), ready, start))); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List errorCodes = + Arrays.asList( + results.get(0).get(30, TimeUnit.SECONDS), + results.get(1).get(30, TimeUnit.SECONDS)); + assertThat(errorCodes) + .containsExactlyInAnyOrder(null, ErrorCode.COMMENT_REPLY_LIMIT_EXCEEDED); + assertThat(commentRepository.countByParentIdAndIsDeletedFalse(root.commentId())) + .isEqualTo(100); + } finally { + executor.shutdownNow(); + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); + jdbcTemplate.execute("DELETE FROM comment"); + jdbcTemplate.execute( + "DELETE FROM post WHERE public_id = '" + postResponse.publicId() + "'"); + jdbcTemplate.execute( + "DELETE FROM member WHERE member_id = " + userDetails.getMember().getId()); + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); + } + } + + private ErrorCode createConcurrentReply( + Long rootCommentId, CountDownLatch ready, CountDownLatch start) throws Exception { + ready.countDown(); + assertThat(start.await(10, TimeUnit.SECONDS)).isTrue(); + try { + createComment(rootCommentId, "동시 대댓글"); + return null; + } catch (CustomAuthException exception) { + return exception.getErrorCode(); + } + } + + private CommentResponse createComment(Long parentId, String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(parentId, content, false, null), + userDetails, + "127.0.0.1"); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentServiceTest.java index 80e4019..591988a 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentServiceTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -15,6 +16,7 @@ import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; import com.ikae.snowthing.domain.comment.dto.CommentResponse; import com.ikae.snowthing.domain.comment.dto.PostCommentListResponse; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.member.entity.Role; import com.ikae.snowthing.domain.member.repository.MemberRepository; @@ -22,6 +24,7 @@ import com.ikae.snowthing.domain.post.dto.PostResponse; import com.ikae.snowthing.domain.post.entity.PostCategory; import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; import com.ikae.snowthing.domain.post.service.PostService; import com.ikae.snowthing.global.error.ErrorCode; import com.ikae.snowthing.global.exception.CustomAuthException; @@ -41,12 +44,23 @@ class CommentServiceTest { @Autowired private PasswordEncoder passwordEncoder; + @Autowired private CommentRepository commentRepository; + + @Autowired private PostRepository postRepository; + @Autowired private jakarta.persistence.EntityManager entityManager; private Member member1; private CustomUserDetails userDetails1; private PostResponse post; + @AfterEach + void tearDown() { + commentRepository.deleteAll(); + postRepository.deleteAll(); + memberRepository.deleteAll(); + } + @BeforeEach void setUp() { categoryRepository @@ -181,6 +195,17 @@ void getCommentsByPost_deletedParentDisplay() { userDetails1, "127.0.0.1"); + // 도메인 정책: 활성 자식 대댓글이 있어야 부모가 삭제되어도 '삭제된 댓글입니다.' 플레이스홀더로 유지됨 + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder() + .parentId(parent1.commentId()) + .content("살아있는 자식 대댓글") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + commentService.deleteComment(parent1.commentId(), null, userDetails1); // 1. 트리 목록 조회 시 본문 마스킹 검증 diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeBenchmarkHarness.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeBenchmarkHarness.java index f0e6500..244b094 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeBenchmarkHarness.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeBenchmarkHarness.java @@ -77,19 +77,20 @@ public static ScenarioMetric measureScenario( List rows = em.createNativeQuery("EXPLAIN " + sql).getResultList(); List explainList = new ArrayList<>(); for (Object[] r : rows) { + int partitionsOffset = r.length >= 12 ? 1 : 0; explainList.add( new ExplainRow( String.valueOf(r[0]), String.valueOf(r[1]), String.valueOf(r[2]), - String.valueOf(r[3]), - String.valueOf(r[4]), - String.valueOf(r[5]), - String.valueOf(r[6]), - String.valueOf(r[7]), - String.valueOf(r[8]), - String.valueOf(r[9]), - String.valueOf(r[10]))); + String.valueOf(r[3 + partitionsOffset]), + String.valueOf(r[4 + partitionsOffset]), + String.valueOf(r[5 + partitionsOffset]), + String.valueOf(r[6 + partitionsOffset]), + String.valueOf(r[7 + partitionsOffset]), + String.valueOf(r[8 + partitionsOffset]), + String.valueOf(r[9 + partitionsOffset]), + String.valueOf(r[10 + partitionsOffset]))); } explains.add(explainList); } catch (Exception e) { diff --git a/backend/src/test/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomTest.java b/backend/src/test/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomTest.java index 7c1e257..148d71d 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomTest.java @@ -39,7 +39,15 @@ class PostRepositoryCustomTest { @BeforeEach void setUp() { freeCategory = - categoryRepository.save(PostCategory.builder().name("자유게시판").code("FREE").build()); + categoryRepository + .findByCode("FREE") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder() + .name("자유게시판") + .code("FREE") + .build())); testMember = memberRepository.save( diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index b12aed3..b23cec2 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -6,10 +6,10 @@ spring: - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration datasource: - url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE - driver-class-name: org.h2.Driver - username: sa - password: + url: jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${SNOWTHING_DB_USERNAME:snowuser} + password: ${SNOWTHING_DB_PASSWORD:snowthing_pass_2026!} jpa: hibernate: @@ -17,4 +17,5 @@ spring: show-sql: false properties: hibernate: - dialect: org.hibernate.dialect.H2Dialect + format_sql: false + dialect: org.hibernate.dialect.MySQLDialect diff --git a/database/ddl.sql b/database/ddl.sql index ee0891d..6142aaf 100644 --- a/database/ddl.sql +++ b/database/ddl.sql @@ -159,6 +159,8 @@ CREATE TABLE `comment` ( `deleted_at` DATETIME NULL COMMENT '삭제 일시', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + INDEX `idx_comment_post_parent_id` (`post_id`, `parent_id`, `comment_id`), + INDEX `idx_comment_parent_deleted_id` (`parent_id`, `is_deleted`, `comment_id`), CONSTRAINT `fk_comment_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE, CONSTRAINT `fk_comment_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE SET NULL, CONSTRAINT `fk_comment_parent` FOREIGN KEY (`parent_id`) REFERENCES `comment` (`comment_id`) ON DELETE SET NULL diff --git a/database/spike_seed_comments.sql b/database/spike_seed_comments.sql index c0123be..4cd6566 100644 --- a/database/spike_seed_comments.sql +++ b/database/spike_seed_comments.sql @@ -9,9 +9,11 @@ DELETE FROM `comment` WHERE `post_id` IN (998, 999); DELETE FROM `post` WHERE `post_id` IN (998, 999); -- 1. 테스트용 기본 카테고리 및 회원 확인/생성 -INSERT IGNORE INTO `post_category` (`category_id`, `name`, `code`) VALUES (1, '자유게시판', 'FREE'); -INSERT IGNORE INTO `member` (`member_id`, `public_id`, `email`, `password_hash`, `nickname`, `role`, `status`, `created_at`, `updated_at`) -VALUES (1, 'member-spike-001', 'spike@snowthing.com', '$2a$10$dummyHashValueForSpikeTestingOnly1234567890', '스파이크테스터', 'ROLE_USER', 'ACTIVE', NOW(), NOW()); +INSERT INTO `post_category` (`category_id`, `name`, `code`) VALUES (1, '자유게시판', 'FREE') +ON DUPLICATE KEY UPDATE `name` = '자유게시판'; +INSERT INTO `member` (`member_id`, `public_id`, `email`, `password`, `nickname`, `role`, `status`, `created_at`, `updated_at`) +VALUES (1, 'member-spike-001', 'spike@snowthing.com', '$2a$10$dummyHashValueForSpikeTestingOnly1234567890', '스파이크테스터', 'ROLE_USER', 'ACTIVE', NOW(), NOW()) +ON DUPLICATE KEY UPDATE `nickname` = '스파이크테스터'; -- 2. 테스트용 게시글 2개 생성 -- Post 998: 시나리오 A (분산 1,000건용) diff --git a/docker-compose.yml b/docker-compose.yml index b49be52..d83f229 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,9 @@ services: restart: always environment: MYSQL_DATABASE: snowthing - MYSQL_ROOT_PASSWORD: snowthing_root_2026! - MYSQL_USER: snowuser - MYSQL_PASSWORD: snowthing_pass_2026! + MYSQL_ROOT_PASSWORD: ${SNOWTHING_DB_ROOT_PASSWORD:?SNOWTHING_DB_ROOT_PASSWORD must be set} + MYSQL_USER: ${SNOWTHING_DB_USERNAME:-snowuser} + MYSQL_PASSWORD: ${SNOWTHING_DB_PASSWORD:?SNOWTHING_DB_PASSWORD must be set} TZ: Asia/Seoul ports: - "3306:3306" diff --git a/docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md b/docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md new file mode 100644 index 0000000..6e1d884 --- /dev/null +++ b/docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md @@ -0,0 +1,151 @@ +# [ADR-001] 댓글 계층 모델 및 조회 아키텍처 의사결정 + +- **문서 번호**: `ADR-001` +- **상태**: `Accepted` +- **결정 일자**: 2026-08-29 +- **작성자**: devikae +- **대상 패키지**: `com.ikae.snowthing.domain.comment` +- **관련 명세**: `docs/conception/sprint03/comment_policy.md` +- **실측 데이터**: `docs/study/sprint03/comment/test/` + +--- + +## 1. 문제 정의 + +기존 댓글 조회는 `findByPostIdWithMember` 단일 쿼리로 특정 게시글의 모든 댓글을 한 번에 메모리로 가져와 조립하는 방식이었습니다. + +이 방식은 댓글 수가 적을 때는 단순하지만 다음과 같은 문제가 있습니다. + +1. **대용량 댓글 조회 시 메모리 및 페이로드 부하**: + - 댓글 수 상한이 없어 댓글이 많이 달린 글 진입 시 수천 건의 엔티티가 메모리에 적재되고, 수백 KB 이상의 JSON 응답이 발생합니다. +2. **대댓글 깊이 미제한**: + - 대댓글 ID를 `parentId`로 지정하면 3단계 이상으로 계층이 깊어져 모바일 UI에서 들여쓰기 표현에 문제가 생깁니다. +3. **삭제 데이터 및 카운트 불일치**: + - 부모와 자식이 모두 삭제된 노드가 응답에 남을 수 있고, 삭제된 댓글까지 `commentCount`에 포함되어 실제 읽을 수 있는 댓글 수와 차이가 납니다. +4. **동일 생성 시각 정렬 불안정**: + - `created_at`만으로 정렬할 경우 동일 시각에 등록된 댓글들의 순서가 일정하지 않을 수 있습니다. + +--- + +## 2. 확정된 제품 요구사항 + +1. **2단계 계층 고정**: + - 댓글(Root)과 대댓글(Child) 2단계로 한정합니다. + - 대댓글에 답글을 달아도 최상위 루트 댓글 ID를 바라보도록 평탄화하며, 루트당 대댓글 수는 최대 100개로 제한합니다. +2. **화면 노출 및 응답 규칙**: + - 게시글 상세 진입 시 루트 댓글은 20개 기준으로 페이징합니다. + - 각 루트 댓글 하위의 대댓글은 상위 5개까지만 기본 노출하고, 5개를 넘는 대댓글은 "더보기"를 통해 추가 조회합니다. +3. **삭제 및 카운트 정리**: + - 삭제된 루트에 대댓글이 남아있으면 "삭제된 댓글입니다." 표시를 노출하고 새 대댓글 작성을 허용합니다. + - 부모와 자식이 모두 삭제된 노드는 목록에서 제외합니다. + - `post.comment_count`와 DTO `replyCount`는 실제 유효한 댓글 수만 집계합니다. +4. **정렬 기준**: + - 루트 댓글과 대댓글 모두 등록순(`ORDER BY created_at ASC, comment_id ASC`)으로 정렬합니다. + +--- + +## 3. 검토한 후보군 + +### 1) Spike 실험 및 실측 대상 (3대 후보) +1. **후보 1: Adjacency List + 메모리 전체 조립 (현행)** + - 단일 쿼리로 전체 댓글을 가져와 자바 `Map`에서 조립 후 반환. +2. **후보 2: Adjacency List + 루트 커서 페이징 & 대댓글 전체 Batch 조회** + - 루트 댓글 20개 커서 페이징 후 `WHERE parent_id IN (...)`으로 대댓글 전체를 2번째 쿼리로 일괄 조회. +3. **후보 3: Adjacency List + 루트 Batch 페이징 및 대댓글 Top-5 프리뷰 & 분리 API** + - 루트 20개와 각 대댓글 상위 5개만 묶어서 반환(2회 쿼리)하고, 5개 초과분은 `GET /api/v1/comments/{commentId}/replies` 분리 API로 페이징 조회. + +### 2) 사전 개념 검토 및 조기 제외 대상 (이론 분석) +- **Recursive CTE** (`WITH RECURSIVE` 재귀 조인): 2단계 고정 계층 대비 DB 재귀 부하 및 JPA 미지원으로 사전 제외. +- **Closure Table** (`comment_closure` 중계 테이블): 2단계 구조 대비 쓰기 비용($D+1$ INSERT)과 테이블 관리 오버헤드로 사전 제외. +- **Materialized Path** (`path` 경로 문자열): 자릿수 패딩 관리 대비 2단계 구조에서 `parent_id` 대비 실익이 적어 사전 제외. + +--- + +## 4. 후보별 장단점 및 트레이드오프 + +### 1) Spike 3대 후보 비교 + +| 후보 | 장점 | 단점 및 트레이드오프 | +| :--- | :--- | :--- | +| **후보 1 (메모리 조립)** | • 쿼리 1회 완료
• 구현 단순 | • 댓글 수 증가 시 메모리 및 페이로드 비례 증가
• 페이징 적용 불가 | +| **후보 2 (루트 커서+대댓글 Batch)** | • 루트 댓글 수(20개) 제한
• N+1 없는 2회 쿼리 | • 특정 댓글에 대댓글이 몰리면 페이로드가 다시 커짐
• 대댓글 5개 노출 요구사항 미충족 | +| **후보 3 (루트 Batch + 대댓글 Top-5 프리뷰 및 분리 API)** | • 응답 크기 제한 (최대 120개)
• 대댓글 5개 이하 일반 댓글은 추가 요청 없이 조회
• 핫스팟 발생 시에도 응답 크기 유지 | • 대댓글 전용 조회 API 엔드포인트 추가 필요
• 부모별 Top-5 조회를 위한 윈도우/서브쿼리 작성 필요 | + +### 2) 사전 개념 검토 모델 비교 + +| 모델 | 장점 | 사전 제외 이유 | +| :--- | :--- | :--- | +| **Recursive CTE** | • 스키마 변경 없이 단일 쿼리 계층 정렬 | • 2단계 구조에 불필요한 DB 재귀 연산
• JPA JPQL 미지원 (Native SQL 강제) | +| **Closure Table** | • 인덱스 JOIN 1회로 조회 | • 댓글 작성 시 $D+1$ 다중 INSERT 발생
• 관계 테이블 데이터 관리 오버헤드 | +| **Materialized Path** | • 단일 테이블 계층 정렬 | • 자릿수 패딩 관리 복잡도
• 2단계 고정 구조에서 `parent_id` 대비 실익 없음 | + +--- + +## 5. Spike 실험 결과 + +실제 MySQL 8.0 DB에 1,000건의 데이터를 넣고 3개 독립 브랜치에서 동일한 조건으로 측정한 결과입니다. + +- **시나리오 A (분산 1,000건, Post 998)**: 루트 댓글 100개 + 각 대댓글 9개 분산 +- **시나리오 B (집중 1,000건, Post 999)**: 루트 댓글 500개 + 1번 루트에 대댓글 500개 집중 + +### 실측 데이터 + +| 시나리오 | 측정 지표 | 후보 1. 메모리 전체 조립 | 후보 2. 루트 커서 + 대댓글 Batch | 후보 3. 루트 Batch + 대댓글 Top-5 프리뷰 (루트 20 + 5개) | +| :--- | :--- | :---: | :---: | :---: | +| **시나리오 A (분산)**
루트 100개 + 대댓글 900개 | **쿼리 수** | 1회 | 2회 | 2회 | +| | **읽은 행 수** | 1,000행 | 200행 | 120행 | +| | **응답 크기 (JSON)** | 210.44 KB | 39.87 KB | 22.03 KB | +| | **실행 시간** | 83.468 ms | 10.308 ms | 14.594 ms | +| **시나리오 B (집중)**
루트 500개 + 1번에 500개 몰림 | **쿼리 수** | 1회 | 2회 | 2회 | +| | **읽은 행 수** | 1,000행 | 520행 | 25행 | +| | **응답 크기 (JSON)** | 205.84 KB | 103.70 KB | 5.55 KB | +| | **실행 시간** | 35.401 ms | 14.988 ms | 5.603 ms | +| **더보기 1회 호출**
(500개 중 추가 20개 페이징) | **쿼리 수 / 읽은 행 / 크기** | 해당 없음 | 해당 없음 | 1회 / 20행 / 3.50 KB (2.357 ms) | + +### 결과 분석 +1. **후보 1**: 댓글 1,000건 조회 시 페이로드가 약 210 KB로 커지고, 1,000개 엔티티를 모두 메모리에 올려 처리합니다. +2. **후보 2**: 분산 환경에서는 39.87 KB로 줄었으나, 대댓글 500개가 몰린 핫스팟에서는 103.70 KB로 다시 커집니다. +3. **후보 3**: 대댓글 500개 집중 상황에서도 초기 응답이 5.55 KB(25행)로 유지되며, 추가 20개 페이징 요청은 3.50 KB로 처리됩니다. + +--- + +## 6. 최종 선택 + +### **후보 3 (Adjacency List 기반 루트 Batch 페이징 + 대댓글 Top-5 프리뷰 및 분리 API) 채택** + +### 채택 이유 +1. **응답 크기 제어**: 초기 응답 노드 수가 최대 120개(루트 20개 + 대댓글 100개)로 제한됩니다. +2. **사용성**: 대댓글이 5개 이하인 대부분의 댓글은 추가 클릭 없이 바로 노출됩니다. +3. **DB 부하 감소**: 인덱스를 통해 필요한 25~120행만 읽어옵니다. + +--- + +## 7. 선택하지 않은 후보의 기각 이유 + +1. **후보 1 (메모리 전체 조립)**: 댓글 수 증가 시 응답 크기(210 KB)와 메모리 사용량이 커져 기각. +2. **후보 2 (루트 커서 + 대댓글 Batch)**: 대댓글 집중 상황에서 페이로드(103 KB) 통제가 되지 않아 기각. +3. **후보 4 (Recursive CTE)**: 2단계 구조에 불필요한 재귀 연산이며, JPQL 미지원으로 Native SQL을 써야 해 기각. +4. **후보 5 (Closure Table)**: 2단계 댓글에 쓰기 비용($D+1$ INSERT)과 테이블 관리가 과도해 기각. +5. **후보 6 (Materialized Path)**: 자릿수 패딩 관리 대비 2단계 구조에서 실익이 없어 기각. + +--- + +## 8. 현재 선택의 단점과 기술 부채 + +1. **부모별 Top-5 조회 쿼리**: + - MySQL 8.0 `ROW_NUMBER() OVER (PARTITION BY parent_id)` 또는 QueryDSL 기반 조인 쿼리 작성이 필요합니다. +2. **API 엔드포인트 추가**: + - 게시글 댓글 조회(`GET /api/v1/posts/{publicId}/comments`) 외에 대댓글 전용 페이징(`GET /api/v1/comments/{commentId}/replies`) 엔드포인트를 추가로 관리해야 합니다. +3. **인덱스 추가 검토**: + - `ORDER BY created_at ASC, comment_id ASC` 정렬 시 `filesort`가 발생하므로, `(post_id, parent_id, created_at, comment_id)` 복합 인덱스 적용을 검토해야 합니다. + +--- + +## 9. 요구사항 변경 시 재검토 기준 + +1. **3단계 이상의 무한 대댓글 요구가 생길 경우**: + - 계층 순서 정렬을 위해 `Materialized Path` 또는 `Recursive CTE` 전환을 검토합니다. +2. **댓글 추천순(인기순) 정렬이 기본 뷰가 될 경우**: + - 등록순 커서 페이징 대신 Redis 랭킹 캐싱 또는 추천수 복합 인덱스 페이징으로 전환을 검토합니다. +3. **실시간 스트리밍 댓글이 도입될 경우**: + - HTTP 페이징 대신 WebSocket / SSE 메시징 구조로 전환을 검토합니다. diff --git a/docs/conception/sprint03/comment_api_spec.md b/docs/conception/sprint03/comment_api_spec.md new file mode 100644 index 0000000..41dbb80 --- /dev/null +++ b/docs/conception/sprint03/comment_api_spec.md @@ -0,0 +1,279 @@ +# 📋 Snowthing 댓글 도메인 공식 API 명세서 (Comment API Specification) + +- **문서 번호**: `SPEC-API-SPRINT03-COMMENT` +- **상태**: `Accepted` +- **적용 스프린트**: Sprint 03 (댓글 및 계층형 대댓글 도메인) +- **기반 정책 문서**: `docs/conception/sprint03/comment_policy.md`, `docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md` + +--- + +## 1. API 엔드포인트 요약 + +| 기능 | HTTP Method | Endpoint | 인증 (Auth) | 비고 | +| :--- | :---: | :--- | :---: | :--- | +| **1. 댓글/대댓글 작성** | `POST` | `/api/v1/posts/{publicId}/comments` | 일반회원/익명 | 2단계 평탄화, 루트당 100개 상한 | +| **2. 게시글 댓글 목록 조회** | `GET` | `/api/v1/posts/{publicId}/comments` | 불필요 (Public) | 루트 20개 Batch + 대댓글 Top-5 프리뷰 | +| **3. 대댓글 목록 분리 조회** | `GET` | `/api/v1/comments/{commentId}/replies` | 불필요 (Public) | 5개 초과 대댓글 20개 커서 페이징 | +| **4. 댓글 수정** | `PUT` | `/api/v1/comments/{commentId}` | 작성자 세션/비번 | 비회원 익명 비밀번호 검증 | +| **5. 댓글 삭제** | `DELETE` | `/api/v1/comments/{commentId}` | 작성자 세션/비번/관리자 | Soft Delete, 고아 노드 은닉 정책 | + +--- + +## 2. 세부 API 명세 + +--- + +### 1. 댓글 및 대댓글 작성 (Create Comment / Reply) + +게시글에 루트 댓글을 작성하거나, 특정 댓글 하위에 대댓글을 작성합니다. + +- **HTTP Method**: `POST` +- **URI**: `/api/v1/posts/{publicId}/comments` +- **인증 요구사항**: + - 일반 회원: 로그인 세션 쿠키 필수 + - 로그인 익명: 로그인 세션 쿠키 필수, `isAnonymous = true` + - 비로그인 익명: 로그인 불필요, `isAnonymous = true`, `anonymousPassword` (4자리 이상) 필수 + +#### Request Headers +```http +Content-Type: application/json +X-XSRF-TOKEN: {csrf_token} +``` + +#### Request Body +```json +{ + "content": "이 스키장 설질 오늘 정말 좋네요!", + "parentId": null, + "isAnonymous": false, + "anonymousPassword": null +} +``` + +| 필드명 | 타입 | 필수 여부 | 설명 | +| :--- | :---: | :---: | :--- | +| `content` | String | **필수** | 댓글 본문 (1자 이상 1,000자 이하) | +| `parentId` | Long | 선택 | 부모 댓글 ID. `null`이면 루트 댓글, 대댓글 작성 시 대상 댓글 ID 전달 (대댓글에 답글 시 서버에서 최상위 루트 ID로 자동 평탄화) | +| `isAnonymous` | Boolean | **필수** | 익명 작성 여부 (`true` / `false`) | +| `anonymousPassword` | String | 조건부 필수 | 비로그인 익명 작성 시 필수 (4자 이상 20자 이하) | + +#### Response (201 Created) +```json +{ + "commentId": 105, + "postId": 998, + "parentId": null, + "writer": { + "publicId": "member-pub-1234", + "nickname": "파우더매니아", + "profileImageUrl": "https://cdn.snowthing.com/profiles/1234.jpg" + }, + "isAnonymous": false, + "writerIp": "127.0.0.1", + "content": "이 스키장 설질 오늘 정말 좋네요!", + "replyCount": 0, + "createdAt": "2026-09-01T15:30:00" +} +``` + +#### 주요 예외 응답 +- `400 Bad Request` (`COMMENT_004`): 루트 댓글의 활성 대댓글 수가 이미 100개에 도달한 경우 +- `400 Bad Request` (`COMMON_001`): 본문이 비어있거나 비로그인 익명 비밀번호가 누락된 경우 +- `404 Not Found` (`POST_001`): 존재하지 않거나 삭제된 게시글인 경우 +- `404 Not Found` (`COMMENT_002`): 지정한 `parentId` 부모 댓글이 존재하지 않는 경우 + +--- + +### 2. 게시글 댓글 목록 조회 (Read Post Comments - Root Batch + Top-5 Preview) + +게시글 상세 화면에서 루트 댓글 20개와 각 루트 댓글 하위의 대댓글 상위 5개를 일괄 조회합니다. + +- **HTTP Method**: `GET` +- **URI**: `/api/v1/posts/{publicId}/comments` +- **인증 요구사항**: 없음 (Public) + +#### Request Query Parameters +| 파라미터명 | 타입 | 기본값 | 설명 | +| :--- | :---: | :---: | :--- | +| `cursor` | Long | `null` | 커서 페이징용 마지막 루트 댓글 ID (`commentId`). 첫 페이지 조회 시 생략 | +| `size` | Integer | `20` | 조회할 루트 댓글 수 (기본 20개, 최대 50개) | + +#### Response (200 OK) +```json +{ + "publicId": "post-pub-5678", + "totalCommentCount": 42, + "comments": [ + { + "commentId": 101, + "parentId": null, + "writer": { + "publicId": "member-pub-1234", + "nickname": "파우더매니아", + "profileImageUrl": "https://cdn.snowthing.com/profiles/1234.jpg" + }, + "isAnonymous": false, + "writerIp": "211.234.***.***", + "content": "하이원 아테나 슬로프 오픈했나요?", + "isDeleted": false, + "replyCount": 8, + "previewReplies": [ + { + "commentId": 102, + "parentId": 101, + "writer": { + "publicId": "member-pub-8888", + "nickname": "설질감별사", + "profileImageUrl": null + }, + "isAnonymous": false, + "writerIp": "175.120.***.***", + "content": "네 오늘 오전 9시에 오픈했습니다!", + "isDeleted": false, + "createdAt": "2026-09-01T15:32:00" + } + ], + "hasMoreReplies": true, + "createdAt": "2026-09-01T15:30:00" + }, + { + "commentId": 103, + "parentId": null, + "writer": null, + "isAnonymous": true, + "writerIp": "121.160.***.***", + "content": "삭제된 댓글입니다.", + "isDeleted": true, + "replyCount": 1, + "previewReplies": [ + { + "commentId": 104, + "parentId": 103, + "writer": { + "publicId": "member-pub-9999", + "nickname": "스노우보더", + "profileImageUrl": null + }, + "isAnonymous": false, + "writerIp": "220.70.***.***", + "content": "삭제된 질문이지만 답변 남깁니다. 야간개장은 18시부터입니다.", + "isDeleted": false, + "createdAt": "2026-09-01T15:35:00" + } + ], + "hasMoreReplies": false, + "createdAt": "2026-09-01T15:31:00" + } + ], + "nextCursor": 103, + "hasNext": true +} +``` + +--- + +### 3. 대댓글 목록 분리 페이징 조회 (Read Separated Replies) + +특정 루트 댓글 하위에 5개를 초과하는 대댓글이 있을 때, 사용자가 "답글 더보기"를 클릭하여 20개 단위로 추가 조회합니다. + +- **HTTP Method**: `GET` +- **URI**: `/api/v1/comments/{commentId}/replies` +- **인증 요구사항**: 없음 (Public) + +#### Request Query Parameters +| 파라미터명 | 타입 | 기본값 | 설명 | +| :--- | :---: | :---: | :--- | +| `cursor` | Long | `null` | 커서 페이징용 마지막 대댓글 ID (`commentId`). 첫 더보기 호출 시 5번째 프리뷰 대댓글의 ID를 전달 | +| `size` | Integer | `20` | 조회할 대댓글 수 (기본 20개, 최대 50개) | + +#### Response (200 OK) +```json +{ + "rootCommentId": 101, + "totalReplyCount": 8, + "replies": [ + { + "commentId": 106, + "parentId": 101, + "writer": { + "publicId": "member-pub-7777", + "nickname": "카빙장인", + "profileImageUrl": null + }, + "isAnonymous": false, + "writerIp": "112.180.***.***", + "content": "빅토리아 슬로프는 다음 주 오픈 예정이랍니다.", + "isDeleted": false, + "createdAt": "2026-09-01T15:40:00" + } + ], + "nextCursor": 106, + "hasNext": false +} +``` + +--- + +### 4. 댓글 수정 (Update Comment) + +본인이 작성한 댓글의 본문을 수정합니다. + +- **HTTP Method**: `PUT` +- **URI**: `/api/v1/comments/{commentId}` +- **인증 요구사항**: 로그인 회원(본인 세션 일치) 또는 비로그인 익명(`anonymousPassword` 일치) + +#### Request Body +```json +{ + "content": "수정된 댓글 본문 내용입니다.", + "anonymousPassword": "mypassword123" +} +``` + +#### Response (200 OK) +```json +{ + "commentId": 105, + "content": "수정된 댓글 본문 내용입니다.", + "updatedAt": "2026-09-01T15:45:00" +} +``` + +--- + +### 5. 댓글 삭제 (Delete Comment - Soft Delete) + +댓글을 삭제 처리합니다 (`is_deleted = true`). + +- **HTTP Method**: `DELETE` +- **URI**: `/api/v1/comments/{commentId}` +- **인증 요구사항**: 로그인 작성자 본인, 최고 관리자(`ROLE_ADMIN`), 또는 비로그인 익명 비밀번호 일치 + +#### Request Body +```json +{ + "anonymousPassword": "mypassword123" +} +``` + +#### Response (200 OK) +```json +{ + "message": "댓글이 삭제되었습니다." +} +``` + +--- + +## 3. 공통 에러 코드 매핑 + +| HTTP Status | ErrorCode | 에러 메시지 | +| :--- | :--- | :--- | +| `400 Bad Request` | `COMMENT_004` | 루트 댓글 1개당 작성 가능한 대댓글 수는 최대 100개입니다. | +| `400 Bad Request` | `COMMENT_003` | 동일한 게시글의 댓글에만 대댓글을 달 수 있습니다. | +| `400 Bad Request` | `COMMON_001` | 잘못된 입력값입니다. (글자수 제한 위반, 비밀번호 누락 등) | +| `403 Forbidden` | `AUTH_002` | 해당 작업을 수행할 권한이 없습니다. | +| `403 Forbidden` | `POST_004` | 비회원 익명 비밀번호가 일치하지 않습니다. | +| `404 Not Found` | `COMMENT_001` | 존재하지 않거나 이미 삭제된 댓글입니다. | +| `404 Not Found` | `COMMENT_002` | 존재하지 않는 부모 댓글입니다. | +| `404 Not Found` | `POST_001` | 존재하지 않거나 삭제된 게시글입니다. | diff --git a/docs/conception/sprint03/comment_policy.md b/docs/conception/sprint03/comment_policy.md new file mode 100644 index 0000000..87d7a2d --- /dev/null +++ b/docs/conception/sprint03/comment_policy.md @@ -0,0 +1,57 @@ +# 📜 Snowthing 댓글/대댓글 도메인 공식 제품 규칙 명세서 (Comment Domain Policy) + +본 문서는 Snowthing 커뮤니티의 댓글 및 대댓글 도메인의 계층, 화면 응답, 삭제 및 카운트, 정렬 및 권한 정책을 정의한 공식 기술 명세서입니다. + +--- + +## 1. 계층 규칙 (Hierarchy Rules) +- **2-Depth 고정 구조**: 댓글(Root)과 대댓글(Child)로만 이루어진 2단계 계층 구조를 채택합니다. +- **평탄화(Flattening) 정책**: 대댓글에 다시 답글을 작성하는 경우, 부모 대댓글의 ID가 아닌 **최상위 Root 댓글의 `comment_id`를 `parent_id`로 자동 지정**하여 2단계를 초과하는 계층 생성을 물리적으로 방지합니다. + +--- + +## 2. 화면 및 응답 규칙 (UI & Response Rules) +- **초기 로딩 크기**: 게시글 상세 진입 시 루트 댓글은 **1페이지당 20개** 기준으로 조회합니다. +- **대댓글 노출 및 접기**: + - 각 루트 댓글 하위의 대댓글은 **기본 5개**까지 펼쳐서 노출합니다. + - 5개를 초과하는 대댓글은 **"답글 더보기(N개)"** UI로 접힘 처리하여 사용자가 클릭 시 추가 렌더링합니다. +- **최대 대댓글 제한**: 루트 댓글 1개당 작성 가능한 대댓글 수는 **최대 100개**로 제한합니다 (100개 도달 시 400 Bad Request 에러 반환). + +--- + +## 3. 삭제 및 카운트 규칙 (Deletion & Count Rules) +- **삭제된 루트 + 대댓글 존재 시**: + - 루트 댓글 본문은 `"삭제된 댓글입니다."` placeholder로 대체 노출 (`is_deleted = true`). + - 하위 대댓글들은 정상적으로 노출을 유지합니다. +- **삭제된 루트 댓글에 신규 대댓글 작성**: **허용**. (대화 맥락 유지를 위해 삭제된 부모 밑에도 신규 답글 작성 가능) +- **삭제된 대댓글 노출**: 대댓글 삭제 시에도 `"삭제된 댓글입니다."` placeholder로 대체 노출 (`is_deleted = true`). +- **부모 + 자식 모두 삭제된 노드(고아 노드)**: + - 루트 댓글이 삭제되고, 그 하위의 모든 대댓글도 삭제된 경우 **화면(클라이언트 응답 목록)에서 완전히 숨김(은닉)** 처리합니다. +- **`post.commentCount` (게시글 총 댓글 수)**: + - **"삭제된 댓글입니다"를 제외한 실제 살아있는 활성 댓글/대댓글(`is_deleted = false`)의 총합**만 카운트합니다. + - Soft Delete 실행 시 즉시 `comment_count - 1` 벌크 차감. +- **`replyCount` (대댓글 수)**: + - 각 루트 댓글 DTO 및 화면에 노출되는 `replyCount`는 화면 렌더링 노드 일원화 정책에 따라 **삭제된 대댓글 placeholder를 포함한 전체 대댓글 수(`totalCount`)**를 집계합니다. + - 이를 통해 화면 상단의 대댓글 수 뱃지(`replyCount`), 상위 5개 미리보기(`previewReplies`), 페이징 더보기 플래그(`hasMoreReplies = totalCount > 5`)의 기준을 100% 일치시켜 UI 인지 부조화를 방지합니다. + +--- + +## 4. 정렬 및 권한 규칙 (Ordering & Permission Rules) +- **루트 댓글 정렬**: **등록순 / 오래된 순 (`ORDER BY created_at ASC, comment_id ASC`)** +- **대댓글 정렬**: **등록순 / 오래된 순 (`ORDER BY created_at ASC, comment_id ASC`)** +- **결정론적 순서 고정**: 동일 생성 시각 발생 시 PK 타이브레이커(`comment_id ASC`)를 필수 적용하여 순서 뒤바뀜을 원천 방지합니다. + +### 🔐 4대 사용자 권한 매트릭스 +| 구분 | 작성(Create) 규칙 | 삭제(Delete) 규칙 | +| :--- | :--- | :--- | +| **1. 일반 회원** | 로그인 필수, 본인 닉네임/프로필 노출 | **비밀번호 불필요**, 본인 로그인 세션으로 즉시 삭제 | +| **2. 로그인 익명** | 로그인 필수, 화면에는 `익명 (IP)` 노출 | **비밀번호 불필요**, 본인 로그인 세션 일치 시 즉시 삭제 | +| **3. 비로그인 익명** | 로그인 불필요, `anonymousPassword` (4자리 이상) 필수 | **비밀번호 필수**, Request Body JSON 비밀번호 일치 시 삭제 | +| **4. 최고 관리자 (`ROLE_ADMIN`)** | 관리자 권한으로 작성 | **비밀번호 불필요**, 어떤 댓글이든 즉시 강제 삭제 | + +--- + +## 5. 향후 확장 정책 (Future Expansion) +- **베스트 댓글(Best Comments) 상단 고정**: + - 댓글 추천 기능 도입 시, **추천수 상위 3개 댓글을 목록 최상단에 뱃지와 함께 고정(Pinning)** 노출합니다. + - 베스트 댓글은 본문만 우선 노출하며, "답글 읽기" 클릭 시 대댓글을 조회할 수 있도록 구성합니다. diff --git a/docs/project/work.md b/docs/project/work.md index 87b9089..5e1410a 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,126 @@ +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: 프론트엔드 멘션 UI 제거 및 2-Depth 평탄화 정책 일치화 (2026-09-04)**: + 1. **가짜 UI(Phantom UI) 제거 및 도메인 스펙 일치화**: + - 프론트엔드에서 대댓글 작성 시 대상 닉네임(`@{replyMentionName} 님에게 답글`)을 노출했으나 백엔드 엔티티 및 스키마에는 루트 ID(`parentId`)와 본문만 저장되어 영속되지 않던 UI/데이터 불일치 결함 해소. + - 알림 시스템이 부재한 Sprint 03의 '단순 2-Depth 평탄화 정책'(`comment_policy.md`)에 맞추어 `page.tsx` 내의 불필요한 `replyMentionName` 상태 및 멘션 라벨을 완전히 제거하고, 루트 댓글 하위의 순수 2-Depth 답글 입력창 토글(`toggleReplyEditor`)로 단순화. + 2. **검증 결과**: + - Next.js 16 프로덕션 빌드(`npm run build`) **100% SUCCESS** 통과 (TypeScript 타입 에러 0건, 10개 라우트 정상 빌드). + +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: 프론트엔드 루트 댓글 등록 후 화면 갱신 UX 개선 (Append-on-Create 적용) (2026-09-04)**: + 1. **첫 페이지 덮어쓰기(Re-fetch)로 인한 새 댓글 시각적 증발 버그 해결**: + - 오래된 순(ASC) 페이징 환경에서 다음 페이지(`hasNextComments`)가 존재할 때 신규 댓글 등록 후 첫 페이지(`fetchComments()`)를 다시 불러와, 방금 등록한 최신 댓글이 화면에서 사라져 등록 실패로 오인하게 만들던 UX 결함 해소. + - `page.tsx`의 `handleCreateComment`에서 `if (hasNextComments)` 분기를 제거하고, 다음 페이지 존재 여부와 무관하게 서버 응답 객체(`createdComment`)를 현재 댓글 목록 끝에 즉시 결합(`[...current, createdComment]`)하도록 일원화. + 2. **검증 결과**: + - Next.js 16 최적화 프로덕션 빌드(`npm run build`) **100% SUCCESS** 통과 (TypeScript 컴파일 및 10개 라우트 정적/동적 생성 정상 완료). + +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: `Comment.rootParent()` 최상위 조상 반복 탐색 로직 개선 및 다계층 방어 (2026-09-04)**: + 1. **암묵적 2-Depth 가정 탈피 및 최상위 루트 노드 반복 탐색(Root Traversal) 확립**: + - 기존 `rootParent()`가 단순 1단계 부모 반환(`parent != null ? parent : this`)으로 작성되어 "부모는 무조건 루트 댓글일 것이다"라는 암묵적 규칙에 취약했던 결함 개선. + - `while (current.getParent() != null)` 반복 탐색 로직을 도입하여, 3-depth 이상의 계층 데이터가 존재하더라도 최상위 조상(`parent == null`)까지 확실하게 추적해 진짜 루트 엔티티를 반환하도록 방어적 프로그래밍 구현. + 2. **단위 테스트 신설 (`CommentTest.java`)**: + - 루트 댓글 단독 호출(`root.rootParent() == root`), 일반 2-depth 대댓글(`child.rootParent() == root`), 3-depth 이상 임의 계층(`grandson.rootParent() == grandfather`) 3대 시나리오 단위 테스트 100% 검증. + 3. **검증 결과**: + - `spotlessCheck` 서식 검증 100% 통과. + - 실제 MySQL 8.0 환경 기반 백엔드 전체 128개 단위/통합 테스트(`gradle test`) **100% BUILD SUCCESSFUL (0 failures)** 완전 통과. + +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: 대댓글 개수·미리보기·더보기 기준 화면 노출 노드(`totalCount`) 일원화 및 정책 동기화 (2026-09-04)**: + 1. **UI 렌더링 노드 기준 기준 통일 (인지 부조화 해결)**: + - 기존에 `replyCount`는 활성 대댓글만 세고(`activeCount`), 미리보기(상위 5개) 및 더보기(`hasMoreReplies`)는 삭제된 대댓글 placeholder를 포함한 전체 수(`totalCount`)를 기준으로 삼아 발생하던 UI 불일치(예: '답글 2개'인데 5개가 펼쳐지고 더보기 버튼이 뜨는 현상)를 해소. + - 화면에 한 줄의 높이를 차지하며 렌더링되는 모든 대댓글 노드 수(`totalCount`)로 `replyCount`, `previewReplies`, `hasMoreReplies`, `totalReplyCount`의 기준을 100% 일치시킴. + 2. **도메인 정책 문서 공식 갱신**: + - `docs/conception/sprint03/comment_policy.md`의 `replyCount` 명세를 수정하여, 화면 렌더링 노드 일원화 정책에 따라 삭제 대댓글 placeholder를 포함한 전체 대댓글 수(`totalCount`)로 카운트 및 페이징을 통합함을 명문화. + 3. **코드 및 테스트 반영**: + - `CommentRepositoryCustom` 및 `CommentRepositoryImpl`에 `countReplies(Long rootCommentId)` 구현 (단순 `COUNT(*)`로 일원화). + - `CommentService.getCommentsByPost` 및 `getCommentReplies`에서 `replyCount` 매핑을 `stat.totalCount()`로 일원화. + - `CommentReadTest`에 대댓글 일부 삭제 시에도 화면 노출 기준으로 `replyCount`, 프리뷰(5개), `hasMoreReplies(true)`가 완벽히 일치함을 검증하는 테스트 신설. + 4. **검증 결과**: + - `spotlessCheck` 서식 검증 100% 통과. + - 실제 MySQL 8.0 환경 기반 백엔드 전체 125개 단위/통합 테스트(`gradle test`) **100% BUILD SUCCESSFUL (0 failures)** 완전 통과. + +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: 대댓글 직접 조회 시 상위 게시글 가시성 검증 일원화 (2026-09-04)**: + 1. **상위 리소스 가시성 우회(Visibility Bypass / IDOR) 차단**: + - 분리 페이징 API(`GET /api/v1/comments/{commentId}/replies`)에서 게시글의 삭제 여부(`isDeleted`) 및 공개 상태(`PostStatus.NORMAL`) 검증이 누락되어 있던 보안/비즈니스 홀 차단. + - `CommentService`에 `validatePostVisibility(Post post)` 공통 검증 메서드를 정의하고, 댓글 작성(`createComment`), 루트 댓글 조회(`getCommentsByPost`), 대댓글 분리 조회(`getCommentReplies`) 3대 진입점에 일관되게 적용. + 2. **Soft Delete(`@SQLRestriction`) 충돌 방어 및 2단계 Clustered Index Point Lookup 확립**: + - `Post`의 `@SQLRestriction("is_deleted = false")`와 Non-null `@ManyToOne` 간의 Broken Entity Relationship(`JpaObjectRetrievalFailureException`) 발생을 원천 차단하기 위해, `commentRepository.findById(commentId)`로 댓글 존재를 먼저 보장한 뒤 `postRepository.findById(postId)`로 게시글 가시성을 순차 검증하도록 설계. + - 두 단계 모두 MySQL Clustered Index PK Seek(`WHERE id = ?`, 0.1ms)로 실행되어 초고속 조회 및 정확한 비즈니스 에러(`COMMENT_NOT_FOUND` vs `POST_NOT_FOUND`) 분기 100% 달성. + 3. **검증 결과**: + - `CommentReadTest`에 삭제된 게시글 및 차단(`BLOCKED`)된 게시글 대상 대댓글 직접 조회 차단 테스트 2종 신설. + - `spotlessApply` 및 `spotlessCheck` 서식 검증 100% 통과. + - 실제 MySQL 8.0 환경 기반 백엔드 전체 124개 단위/통합 테스트(`gradle test`) **100% BUILD SUCCESSFUL (0 failures)** 완전 통과. + 4. **학습 정리 문서 작성**: + - `docs/study/sprint03/studyCommentPostVisibilityFetchJoinVsLazyLoading260904.md`에 문제 배경, 4대 후보 비교, 단순 코스트(RTT/CPU/I/O) 매트릭스, `@SQLRestriction` 사이드이펙트, 7대 요소 체계 완벽 문서화 완료. + +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: 대댓글 생성 시 루트 선행 잠금 및 부모 락 배제를 통한 데드락(Deadlock) 원천 방지 (2026-09-04)**: + 1. **부모 댓글 조회 시 배타적 락(X-Lock) 제거 및 단순 조회 전환**: + - `CommentService.createComment`에서 요청된 부모 댓글(`request.parentId`)의 존재 여부 및 최상위 루트 ID(`rootCommentId`) 식별 목적에 불과한 `findByIdForUpdate`를 제거하고 단순 `findById`로 변경. + - 부모 행에 대한 불필요한 X-Lock 획득을 배제하여 부모-루트 간 교차 락(Deadlock) 발생 경로를 원천 차단. + 2. **트랜잭션 락 획득 순서 단일화 (루트 선행 X-Lock -> 자식 S-Lock 카운트)**: + - 루트 댓글에 직접 답글을 달 때와 하위 대댓글에 답글을 달 때 모두 항상 [최상위 루트 댓글 선행 배타적 락(`findByIdForUpdate(rootCommentId)`)]을 일관되게 가장 먼저 획득하도록 보장. + - 100개 상한 검증 시 MySQL `REPEATABLE_READ` 격리 수준의 Snapshot Read 한계를 방어하기 위해 `findActiveReplyIdsForUpdate`(`@Lock(LockModeType.PESSIMISTIC_READ)`)로 최신 커밋 상태(Locking Read)를 읽어 정합성 보장. + - 모든 동시 트랜잭션이 동일한 락 획득 방향(루트 X-Lock -> 자식 S-Lock)을 유지하므로 순환 대기(Circular Wait)가 물리적으로 성립하지 않음. + 3. **검증 결과**: + - `spotlessCheck` 서식 검증 100% 통과. + - 실제 MySQL 8.0 환경 기반 백엔드 전체 122개 단위/통합 테스트(`gradle test --rerun`) **100% BUILD SUCCESSFUL (0 failures)** 완전 통과. + +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: 1차·2차 쿼리 최적화 (스칼라 서브쿼리 제거 배치 집계 전환 & CROSS JOIN LATERAL 적용 및 H2 완전 제거) (2026-09-04)**: + 1. **1차 쿼리(`findRootComments`) 스칼라 서브쿼리 다발 제거 및 배치 집계 분리**: + - 기존 `SELECT` 절에서 매 루트 댓글 행마다 반복 실행되던 `reply_count`와 `has_more_replies` 스칼라 서브쿼리 2개(20건 조회 시 총 40회 실행)를 완전 제거. + - 루트 댓글 ID 목록을 기반으로 단 1번의 배치 GROUP BY 쿼리(`findReplyStats`: `SELECT parent_id, COUNT(CASE WHEN is_deleted = false THEN 1 END) AS active_count, COUNT(*) AS total_count FROM comment WHERE parent_id IN (:rootCommentIds) GROUP BY parent_id`)로 분리. + - `idx_comment_parent_deleted_id(parent_id, is_deleted, comment_id)` 커버링 인덱스를 활용하여 테이블 랜덤 I/O 없이 메모리에서 DTO와 O(1) 매핑 조립. + 2. **2차 쿼리(`findTopReplyPreviews`) `CROSS JOIN LATERAL` 도입 (MySQL 8.0 Top-5 최적화)**: + - 윈도우 함수 및 임시 파생 테이블(Derived Table)을 배제하고, 외부 루트 댓글 각 행마다 `LIMIT 5`를 직접 거는 `CROSS JOIN LATERAL` 쿼리로 전환. + - 루트 댓글당 필요한 5건만 인덱스 탐색 즉시 중단(Early Termination) 및 선별된 건에 대해서만 `member`를 조인하여 불필요한 스캔과 Early Join 낭비 0건 달성. + 3. **H2 완전 제거 및 테스트 환경 MySQL 8.0 전면 통일 (Environment Parity 확보)**: + - `build.gradle`에서 `com.h2database:h2` 의존성을 완전 삭제. + - `application-test.yml`을 MySQL 8.0 (`jdbc:mysql://localhost:3306/snowthing_test`) 및 `MySQLDialect`로 전환하여 로컬 Docker MySQL과 직통 연동. + - `.github/workflows/gradle.yml` CI 파이프라인에 MySQL 8.0 서비스 컨테이너(`services: mysql: image: mysql:8.0`)를 추가하여 프로덕션과 테스트 환경의 DB 방언 및 동작 100% 일치 보장. + 4. **검증 결과**: + - `application.yml` 로컬 DB 패스워드 기본값 설정 및 `bootRun` 실제 서버 기동 검증: HTTP 200 OK 응답 정상 수신 확인. + - `frontend` Next.js 16 프로덕션 빌드(`npm run build`) **100% SUCCESS** 통과. + - `spotlessApply` 및 `spotlessCheck` 서식 교정 100% 통과. + - `PostRepositoryCustomTest` 카테고리 중복 가드 및 `CommentCreateTest` 동시성 테스트 DB 클린업 보강. + - 실제 MySQL 8.0 환경 기반 **백엔드 전체 122개 단위/통합 테스트(`gradle test --rerun`) 100% BUILD SUCCESSFUL (32s)** 완전 통과. + + +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: PK(`comment_id`) 기반 논리적 시퀀스 단일 커서 전환 및 인덱스 최적화 (2026-09-04)**: + 1. **시계열 오차 해소 및 쿼리 단순화**: + - 기존 `(created_at, comment_id)` 복합 시계열 커서의 클락 스큐(Clock Skew) 및 트랜잭션 지연에 따른 누락(Phantom Skip) 위험을 해소하기 위해, 단조 증가하는 `comment_id` 단일 커서(`AND c.comment_id > :cursorId`) 및 단일 정렬(`ORDER BY c.comment_id ASC`)로 전환. + 2. **DB 중복 쿼리 제거 (RTT 50% 절감)**: + - 커서의 시각(`created_at`)을 얻기 위해 매 페이징마다 날아가던 선행 `findRootCursor` / `findReplyCursor` SELECT 쿼리를 제거하고, 경량 존재 검증(`existsRootCursor`, `existsReplyCursor`) 및 `cursorId` 직통 전달로 최적화. + 3. **인덱스 및 DDL 다이어트**: + - `Comment.java` 및 `database/ddl.sql`의 복합 인덱스에서 불필요한 `created_at` 컬럼을 제거하여 `idx_comment_post_parent_id(post_id, parent_id, comment_id)` 및 `idx_comment_parent_deleted_id(parent_id, is_deleted, comment_id)`로 B-Tree 인덱스 용량 절감 및 Clustered Index 정렬 일치. + 4. **검증 결과**: + - Spotless 서식 교정(`spotlessApply`) 및 댓글 도메인 전체 단위/통합 테스트(`gradle test --tests com.ikae.snowthing.domain.comment.*`) **100% BUILD SUCCESSFUL (20s)** 통과. + +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: `CommentResponse` 작성자명 상수화 및 축약 IP 표기 적용 (2026-09-04)**: + 1. **작성자명 1줄 상수화 및 Plain String 제거**: + - `CommentResponse.java` 내부에 `private static final String ANONYMOUS_NAME = "ㅇㅇ";` 상수를 선언하여 하드코딩된 리터럴 완전 제거 및 리뷰어 피드백 수용. + 2. **익명 축약 IP 포맷팅 (`ㅇㅇ(xxx.xxx)`) 및 일반 회원 정보 보호**: + - 익명 댓글인 경우 4옥텟 전체 또는 긴 마스킹 문자열 대신 앞 2개 옥텟만 취하여 `ㅇㅇ(xxx.xxx)` 형태로 간결하게 노출. IP 누락 시 `ㅇㅇ` 반환. + - 비익명 일반 회원 댓글의 경우 IP 노출을 원천 차단하고 닉네임을 반환하며, 회원 객체 누락/탈퇴 시에도 `"알 수 없음"` 대신 기본 상수(`ㅇㅇ`)를 반환하도록 Early Return 패턴으로 로직 평탄화. + 3. **단위 테스트 검증**: + - `CommentResponseTest.java` 신설하여 5대 시나리오(마스킹 IP, 원시 IP, IP 누락, 회원 정상 닉네임, 회원 null fallback) 100% 검증 통과. + - Spotless 서식 교정(`spotlessApply`) 및 댓글 도메인 전체 테스트(`gradle test --tests com.ikae.snowthing.domain.comment.*`) **100% BUILD SUCCESSFUL (16s)** 통과. + +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영 및 대댓글 인덱스/설정 최적화 (2026-09-02)**: + 1. **대댓글 복합 인덱스(idx_comment_parent_deleted_created) 최적화**: + - `database/ddl.sql` 및 `Comment.java` `@Index` 명세를 `(parent_id, created_at, comment_id)` ➔ `(parent_id, is_deleted, created_at, comment_id)`로 변경. + - 대댓글 100개 상한 검증(`countActiveReplies`) 및 대댓글 조회 시 살아있는 행으로 B-Tree Seek 직행 및 커버링 인덱스(`Using index`) 실측 달성. + 2. **DB Username 환경변수 동기화 (Configuration Parity)**: + - `backend/src/main/resources/application.yml`의 `datasource.username`을 `docker-compose.yml`과 일치하도록 `${SNOWTHING_DB_USERNAME:snowuser}`로 수정. + 3. **DataInitializer & 테스트 정합성 보강**: + - `DataInitializer.java` 내 닉네임 유니크 제약조건 중복 가드 추가. + - `CommentServiceTest.java` 내 활성 자식 노드가 있는 삭제 부모 placeholder 정책 반영 및 `@AfterEach` teardown 클린업 추가. + 4. **검증 결과**: + - `spotlessCheck` 및 백엔드 전체 단위/통합 테스트(`gradle test`) **100% BUILD SUCCESSFUL (23s)** 통과. + +- **Sprint 03 댓글 도메인 공식 API 명세서(comment_api_spec.md) 작성 (2026-09-01)**: + 1. **5대 CRUD 엔드포인트 계약 명세화**: `docs/conception/sprint03/comment_api_spec.md`에 댓글 작성(`POST`), 루트 댓글 Batch+Top-5 프리뷰 조회(`GET`), 대댓글 분리 페이징 조회(`GET`), 댓글 수정(`PUT`), Soft Delete 삭제(`DELETE`)의 Request/Response DTO, Header, 에러 코드 매핑을 100% 명세화. + +- **댓글 도메인 계층 모델 및 조회 아키텍처 공식 ADR-001 작성 및 확정 (2026-08-29)**: + 1. **실측 데이터 기반 아키텍처 의사결정**: 3개 독립 워크트리 브랜치에서 측정한 실측 벤치마크 지표(후보 1: 210KB 폭증 vs 후보 2: 핫스팟 103KB 비대화 vs 후보 3: 5.55KB 완벽 통제)를 근거로, **[후보 3: Adjacency List 기반 하이브리드 프리뷰(루트 20개 + 대댓글 5개) 및 대댓글 분리 페이징]을 최종 채택**. + 2. **ADR-001 9개 핵심 섹션 완결**: `docs/study/sprint03/comment/ADR-001-comment-hierarchy-and-retrieval-architecture.md`에 문제정의, 요구사항, 후보군, Spike 실측 매트릭스, 기각 근거, 기술 부채, 재검토 트리거 등 표준 아키텍처 의사결정 기록 공식 문서화. + - **댓글 조회 아키텍처 3대 후보 Spike 실험 공통 기반 및 측정 하네스 구축 (2026-08-29)**: 1. **실험 가이드 및 템플릿 작성**: `docs/study/sprint03/comment/spike_experiment_guide.md` (실험 목적, 2대 시나리오, 5대 측정 지표 정의) 및 `docs/study/sprint03/comment/spike_result_template.md` (표준 결과 보고서 템플릿) 문서화. 2. **공통 테스트 픽스처 및 하네스 개발**: `CommentSpikeDataInitializer.java` (분산 1,000건 & 핫스팟 500건 자동 주입기) 및 `CommentSpikeBenchmarkHarness.java` (실행 시간, JSON 직렬화 페이로드 바이트 크기, 쿼리 수 측정 러너) 구축. @@ -685,3 +808,61 @@ 3. `build` job에 `needs: spotless`를 추가하여 Spotless 검사 실패 시 빌드/테스트가 실행되지 않도록 Fast-Fail 흐름 유지. 4. 기존 깨진 한글 주석은 제거하고 ASCII 기반의 간결한 workflow로 정리. 5. 검증 결과: `git diff --check` 통과. 실제 GitHub Actions job 표시 여부는 push 후 PR checks 화면에서 확인 필요. + +- **Sprint 03 댓글 생성(Create) 기능 구현 및 검증 완료 (2026-09-01)**: + 1. 댓글 생성 경로에서 빌더 대신 `Comment.create()` 정적 팩토리를 사용하고, 대댓글에 작성한 답글의 부모를 최상위 루트로 평탄화하는 `rootParent()` 도메인 메서드를 추가. + 2. 루트별 활성 대댓글 수를 최대 100개로 제한하고, 초과 시 `COMMENT_004` (`COMMENT_REPLY_LIMIT_EXCEEDED`, 400 Bad Request) 예외를 반환하도록 구현. + 3. 동일 루트의 동시 생성 요청이 제한 검증을 함께 통과하지 않도록 루트 댓글에 비관적 쓰기 잠금을 적용하고, 삭제된 대댓글은 활성 개수 집계에서 제외. + 4. 댓글 저장과 `post.comment_count + 1` 벌크 갱신을 동일 트랜잭션에서 처리하여 성공·실패 경계를 동기화. + 5. 타 작업과의 충돌 방지를 위해 신규 `CommentCreateTest.java`에만 성공·실패·동시성 테스트 총 16건을 작성. H2를 사용하지 않고 로컬 MySQL 8.0.46의 테스트 전용 `snowthing_test` 스키마에서 `./gradlew.bat test --tests "*CommentCreateTest*"` 실행 결과 16건 전체 통과, `spotlessCheck` 통과, 종료 후 테스트 테이블 0개 확인. + 6. 실 MySQL 동시성 테스트에서 `REPEATABLE READ` 스냅샷 때문에 루트 잠금만으로는 99개 경계의 두 요청이 모두 통과하는 결함을 발견. 부모 최초 조회와 활성 대댓글 집계를 잠금 기반 현재 읽기로 변경하여 두 요청 중 1건만 성공하고 최종 100개가 유지됨을 검증. + 7. 남은 이슈: 비관적 잠금은 같은 루트에 대댓글 생성이 집중되면 해당 루트의 쓰기 요청을 직렬화하므로, 운영 환경에서는 잠금 대기 시간과 타임아웃 지표를 관찰해야 함. 프로젝트의 H2 테스트 의존성은 다른 기존 테스트가 사용하므로 제거하지 않았으며 `CommentCreateTest`에서는 MySQL 드라이버와 dialect를 강제해 H2를 사용하지 않음. + +- **Sprint 03 댓글 목록 및 대댓글 분리 조회(Read) 구현 완료 (2026-09-01)**: + 1. `comment` 테이블과 `Comment` 엔티티에 `(post_id, parent_id, created_at, comment_id)`, `(parent_id, created_at, comment_id)` 복합 인덱스를 추가. + 2. `CommentRepositoryCustom`/`CommentRepositoryImpl`을 추가하고 루트 댓글 커서 페이징, MySQL 8.0 `ROW_NUMBER() OVER (PARTITION BY parent_id)` 기반 부모별 Top-5 프리뷰, 대댓글 분리 커서 페이징을 구현. + 3. API 명세의 Long 타입 `commentId` 커서를 유지하면서 기준 행의 `created_at`을 복원해 `(created_at ASC, comment_id ASC)` 복합 정렬과 커서 조건이 일치하도록 처리. + 4. `CommentResponse`, `PostCommentListResponse`, `CommentReplyListResponse`를 조회 명세에 맞춰 구성하고 모든 응답 컬렉션에 `List.copyOf()` 방어적 복사를 적용. + 5. 삭제 루트에 활성 대댓글이 있으면 placeholder와 프리뷰를 노출하고, 루트와 하위 대댓글이 모두 삭제되면 목록에서 은닉하도록 정책 반영. `replyCount`는 활성 대댓글만 집계. + 6. `CommentReadTest.java` 신규 파일에 성공/실패 10개 시나리오를 작성: 루트 커서 경계, 동일 시각 PK 타이브레이커, Top-5/분리 조회, 삭제 은닉, DTO 불변성, 게시글 없음, 크기 범위, 다른 범위의 커서, 대댓글 ID의 루트 오용 검증. + 7. 검증 결과: `./gradlew.bat test --tests "*CommentReadTest*"` 10개 테스트 통과, `compileJava` 및 `spotlessApply` 통과. + 8. 확인 이슈: 기존 `CommentServiceTest`의 자식 없는 삭제 루트 노출 기대값은 Sprint 3의 고아 노드 은닉 정책과 충돌함. 기존 테스트 파일 수정 금지 조건에 따라 변경하지 않음. `CommentCreateTest` 동시성 테스트는 결과 중 정상 성공을 `null`로 표현하면서 `List.of(null, ...)`을 호출해 테스트 코드 자체에서 `NullPointerException`이 발생하며, Read 구현과 무관한 기존 이슈로 확인됨. + 9. 운영 확인 필요: Top-5 쿼리는 MySQL 8.0 윈도우 함수에 의존하므로 배포 전 실제 MySQL에서 `EXPLAIN ANALYZE`로 두 복합 인덱스 사용 여부와 filesort/읽은 행 수를 재검증해야 함. + 10. 공개 저장소 보안 점검에서 테스트, Spring 설정, Docker Compose에 하드코딩된 MySQL 비밀번호를 발견해 모두 환경변수 참조로 교체하고 `.env.example`에는 실제 값이 아닌 placeholder만 제공. `CommentCreateTest`는 `SNOWTHING_TEST_DB_URL`이 없으면 H2를 사용하고, 외부 MySQL을 선택한 경우 username/password 환경변수를 필수 검증하도록 변경. + 11. 실제 MySQL 8.0에서 Top-5 프리뷰 쿼리 실행 시 `row_number` 별칭이 함수명과 충돌해 500이 발생하는 문제를 확인하고, 윈도우 순번 별칭을 `rn`으로 변경해 MySQL 문법 호환성을 확보. + 12. MySQL 클라이언트 문자셋 오류로 한글이 손상되어 있던 Spike 전용 게시글 998/999와 댓글 2,000건을 `--default-character-set=utf8mb4`로 제한 재시드. 재검증 결과 게시글별 1,000건, API 루트 20개/Top-5 프리뷰, UTF-8 한글 응답 바이트를 확인. + 13. 로컬 재기동 시 `DataInitializer`가 이메일 존재 여부만 확인한 뒤 이미 사용 중인 닉네임을 삽입해 유니크 제약으로 실패하는 별도 이슈 발견. 데이터 삭제 없이 확인하기 위해 현재 서버는 `local,test` 프로필로 초기화기만 제외해 실행 중이며, 초기화기 멱등성 보완은 별도 작업 필요. + 14. 최초 Spike 재시드에서 `INSERT IGNORE`가 기존의 손상된 작성자 닉네임과 카테고리명을 유지하는 문제를 확인. 시드 SQL을 `ON DUPLICATE KEY UPDATE` 방식으로 보완하여 `스파이크테스터`, `자유게시판` 값도 UTF-8로 복구하도록 수정. + +- **Sprint 03 댓글 C-R 프론트엔드 2단계 UI 개편 완료 (2026-09-01)**: + 1. 백엔드의 루트 댓글 20개 커서 조회, 루트별 대댓글 Top-5 프리뷰, 대댓글 분리 커서 조회 계약에 맞춰 프론트엔드 댓글 구조를 재귀형 무한 계층에서 루트/대댓글 2단계 구조로 변경하기로 확정. + 2. 작업 범위는 댓글 작성(Create)과 조회(Read)이며, 댓글 수정·삭제 UI 개편은 제외. 기존 삭제 기능은 회귀 방지를 위해 유지. + 3. `frontend/app/lib/api.ts`에 루트 댓글과 대댓글의 `cursor`/`size` URL 빌더를 추가하고, `null` 여부로 커서 포함을 판별하도록 구현. + 4. `frontend/app/posts/[publicId]/page.tsx`의 DTO를 백엔드 응답 계약에 맞추고, 루트 댓글 20개 누적 조회와 루트별 대댓글 Top-5/20개 누적 조회 상태를 분리. + 5. 재귀형 `CommentRow`를 루트 `CommentRow`와 비재귀 `ReplyRow`로 분리해 3단계 이상 렌더링을 차단. 대댓글의 답글 버튼도 루트 작성창을 열고 원작성자 멘션 가이드만 표시하며, 서버에는 루트 ID를 `parentId`로 전달. + 6. 댓글·대댓글 응답 병합 시 `commentId` 중복을 방어하고, 삭제된 루트 placeholder 아래의 대댓글과 답글 작성 기능은 유지. + 7. 검증 결과: 변경 파일 대상 ESLint 오류 0건(기존 `` 최적화 경고 1건), `npm run build` 및 TypeScript 검사 통과. + 8. 확인 이슈: 전체 `npm run lint`는 이번 변경과 무관한 기존 `ToastEditor.tsx`, `ToastViewer.tsx`, 게시글 작성·목록 페이지의 오류 6건 때문에 실패. 브라우저 수동 검증은 백엔드와 테스트 데이터가 실행된 환경에서 추가 확인 필요. +- **Sprint 03 comment CR review fix: preview reply limit state synchronization (2026-09-04)**: + - After creating a reply, `previewReplies` is capped at five items with `slice(0, 5)`. + - `hasMoreReplies` is recalculated from the updated `replyCount`, so the load-more state is enabled immediately when the sixth reply is created. + - `npx eslint 'app/posts/[publicId]/page.tsx'` passed with no errors; one pre-existing `@next/next/no-img-element` warning remains. + +- **Sprint 03 comment CR review fix: prevent duplicate reply submissions (2026-09-04)**: + 1. Reused the existing `submittingComment` request state for reply submissions instead of introducing a separate state store or changing the API contract. + 2. Added an early-return guard to `handleCreateComment()` and disabled the reply submit button while a comment request is in progress. + 3. No backend API, database schema, or external dependency changes were required. + 4. Validation: targeted ESLint passed with no errors and one pre-existing `@next/next/no-img-element` warning; `npm run build` passed. +- **Sprint 03 댓글 생성 CR 리뷰 반영: 동시성 테스트 MySQL 엔진 강제 (2026-09-04)**: + - `CommentCreateTest`에 `test` 프로필을 명시하고 `SNOWTHING_TEST_DB_URL` 누락 시 H2 fallback 대신 `CustomAuthException(INVALID_INPUT)`으로 즉시 실패하도록 변경했습니다. + - GitHub Actions에 MySQL 8.0 테스트 DB URL·계정 환경변수를 명시해 `SELECT FOR UPDATE` 검증이 운영과 동일한 InnoDB에서 수행되도록 했습니다. + +- **Sprint 03 댓글 생성 CR 리뷰 반영: 익명 비밀번호 경계값 검증 완료 (2026-09-04)**: + - 상태: DONE + - 명세의 4~20자 조건과 달리 `CommentCreateRequest.anonymousPassword`에 길이 검증이 없음을 확인했습니다. + - `@Size(min = 4, max = 20)`로 API 입력 경계에서 검증하고, MockMvc로 3·4·20·21자 경계값을 확인할 계획입니다. + - 서비스 직접 호출 테스트는 `@Valid`를 실행하지 않으므로 기존 정상 저장·암호화 검증 역할만 유지합니다. + - `CommentCreateRequest.anonymousPassword`에 `@Size(min = 4, max = 20)`를 적용했습니다. `null`은 허용하므로 비밀번호가 필요 없는 회원 요청 계약은 유지됩니다. + - MockMvc 경계 테스트에서 3·21자는 `400 Bad Request`와 `COMMON_001`, 4·20자는 `201 Created`를 검증했습니다. + - `CommentControllerTest`와 `spotlessCheck`는 통과했습니다. + - `CommentCreateTest` 16건은 `SNOWTHING_TEST_DB_URL` 미설정 시 실행을 차단하는 기존 MySQL 강제 설정 때문에 Spring Context 생성 전에 실패했습니다. 경계값 변경으로 인한 테스트 assertion 실패는 아닙니다. diff --git a/frontend/app/lib/api.ts b/frontend/app/lib/api.ts index c377af4..19f8284 100644 --- a/frontend/app/lib/api.ts +++ b/frontend/app/lib/api.ts @@ -22,9 +22,16 @@ export const API_ENDPOINTS = { update: (publicId: string) => `${API_V1_URL}/posts/${publicId}`, delete: (publicId: string) => `${API_V1_URL}/posts/${publicId}`, reactions: (publicId: string) => `${API_V1_URL}/posts/${publicId}/reactions`, - comments: (publicId: string) => `${API_V1_URL}/posts/${publicId}/comments`, + comments: (publicId: string, cursor?: number | null, size = 20) => + `${API_V1_URL}/posts/${publicId}/comments?${ + cursor != null ? `cursor=${cursor}&size=${size}` : `size=${size}` + }`, }, comments: { + replies: (commentId: number | string, cursor?: number | null, size = 20) => + `${API_V1_URL}/comments/${commentId}/replies?${ + cursor != null ? `cursor=${cursor}&size=${size}` : `size=${size}` + }`, delete: (commentId: number | string) => `${API_V1_URL}/comments/${commentId}`, }, } as const; diff --git a/frontend/app/posts/[publicId]/page.tsx b/frontend/app/posts/[publicId]/page.tsx index 878086d..c461656 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -40,17 +40,37 @@ interface PostDetail { interface CommentItem { commentId: number; parentId: number | null; - writerName: string; + writer: WriterInfo | null; + isAnonymous: boolean; + writerIp: string; content: string; isDeleted: boolean; + replyCount: number; + previewReplies: CommentItem[]; + hasMoreReplies: boolean; createdAt: string; - children: CommentItem[]; } interface CommentListResponse { publicId: string; totalCommentCount: number; comments: CommentItem[]; + nextCursor: number | null; + hasNext: boolean; +} + +interface CommentReplyListResponse { + rootCommentId: number; + totalReplyCount: number; + replies: CommentItem[]; + nextCursor: number | null; + hasNext: boolean; +} + +interface ReplyPagingState { + nextCursor: number | null; + hasNext: boolean; + loading: boolean; } export default function PostDetailPage({ params }: { params: Promise<{ publicId: string }> }) { @@ -59,6 +79,10 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const [post, setPost] = useState(null); const [comments, setComments] = useState([]); const [totalCommentCount, setTotalCommentCount] = useState(0); + const [commentNextCursor, setCommentNextCursor] = useState(null); + const [hasNextComments, setHasNextComments] = useState(false); + const [isLoadingMoreComments, setIsLoadingMoreComments] = useState(false); + const [replyPagingByRootId, setReplyPagingByRootId] = useState>({}); const [loading, setLoading] = useState(true); const [errorMsg, setErrorMsg] = useState(""); const [reactionMsg, setReactionMsg] = useState(""); @@ -119,19 +143,97 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; - const fetchComments = useCallback(async () => { + const fetchComments = useCallback(async (cursor: number | null = null, append = false) => { try { - const res = await fetch(API_ENDPOINTS.posts.comments(publicId), { credentials: "include" }); + const res = await fetch(API_ENDPOINTS.posts.comments(publicId, cursor), { credentials: "include" }); if (res.ok) { const data: CommentListResponse = await res.json(); - setComments(data.comments || []); + setComments((current) => { + if (!append) return data.comments || []; + const merged = [...current, ...(data.comments || [])]; + return merged.filter( + (comment, index) => merged.findIndex((candidate) => candidate.commentId === comment.commentId) === index, + ); + }); setTotalCommentCount(data.totalCommentCount || 0); + setCommentNextCursor(data.nextCursor ?? null); + setHasNextComments(Boolean(data.hasNext)); } } catch (error) { console.error("댓글 로드 실패:", error); } }, [publicId]); + const handleLoadMoreComments = async () => { + if (isLoadingMoreComments || !hasNextComments || commentNextCursor == null) return; + + setIsLoadingMoreComments(true); + try { + await fetchComments(commentNextCursor, true); + } finally { + setIsLoadingMoreComments(false); + } + }; + + const handleLoadMoreReplies = async (rootCommentId: number) => { + const root = comments.find((comment) => comment.commentId === rootCommentId); + if (!root) return; + + const paging = replyPagingByRootId[rootCommentId]; + if (paging?.loading) return; + + const cursor = paging?.nextCursor ?? root.previewReplies.at(-1)?.commentId ?? null; + setReplyPagingByRootId((current) => ({ + ...current, + [rootCommentId]: { + nextCursor: cursor, + hasNext: paging?.hasNext ?? root.hasMoreReplies, + loading: true, + }, + })); + + try { + const res = await fetch(API_ENDPOINTS.comments.replies(rootCommentId, cursor), { + credentials: "include", + }); + if (!res.ok) throw new Error("답글을 불러오지 못했습니다."); + + const data: CommentReplyListResponse = await res.json(); + setComments((current) => + current.map((comment) => { + if (comment.commentId !== rootCommentId) return comment; + const merged = [...comment.previewReplies, ...(data.replies || [])]; + return { + ...comment, + replyCount: data.totalReplyCount, + previewReplies: merged.filter( + (reply, index) => merged.findIndex((candidate) => candidate.commentId === reply.commentId) === index, + ), + hasMoreReplies: data.hasNext, + }; + }), + ); + setReplyPagingByRootId((current) => ({ + ...current, + [rootCommentId]: { + nextCursor: data.nextCursor ?? null, + hasNext: data.hasNext, + loading: false, + }, + })); + } catch (error) { + console.error("답글 로드 실패:", error); + setReplyPagingByRootId((current) => ({ + ...current, + [rootCommentId]: { + nextCursor: current[rootCommentId]?.nextCursor ?? cursor, + hasNext: current[rootCommentId]?.hasNext ?? root.hasMoreReplies, + loading: false, + }, + })); + } + }; + useEffect(() => { const timer = window.setTimeout(() => { void (async () => { @@ -200,6 +302,8 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const isAnonymousPost = Boolean(post?.isAnonymous || post?.categoryCode === "ANONYMOUS"); const handleCreateComment = async (parentId: number | null) => { + if (submittingComment) return; + const text = parentId ? replyText : newCommentText; if (!text.trim()) { alert("댓글 내용을 입력해주세요."); @@ -244,15 +348,34 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: }); if (res.ok) { + const createdComment: CommentItem = await res.json(); if (parentId) { setReplyText(""); setReplyAnonPassword(""); setActiveReplyParentId(null); + setComments((current) => + current.map((comment) => { + if (comment.commentId !== parentId) return comment; + return { + ...comment, + replyCount: comment.replyCount + 1, + previewReplies: comment.hasMoreReplies + ? comment.previewReplies + : [...comment.previewReplies, createdComment], + }; + }), + ); + setTotalCommentCount((current) => current + 1); } else { setNewCommentText(""); setCommentAnonPassword(""); + if (hasNextComments) { + await fetchComments(); + } else { + setComments((current) => [...current, createdComment]); + setTotalCommentCount((current) => current + 1); + } } - await fetchComments(); setPost((current) => (current ? { ...current, commentCount: current.commentCount + 1 } : current)); return; } @@ -450,11 +573,25 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: replyAnonPassword={replyAnonPassword} setReplyAnonPassword={setReplyAnonPassword} handleCreateComment={handleCreateComment} + submittingComment={submittingComment} handleDeleteComment={handleDeleteComment} + handleLoadMoreReplies={handleLoadMoreReplies} + isLoadingReplies={Boolean(replyPagingByRootId[comment.commentId]?.loading)} /> )) )} + + {hasNextComments && ( + + )} @@ -474,7 +611,6 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: function CommentRow({ item, - depth = 0, isAnonymousPost, currentUserPublicId, activeReplyParentId, @@ -484,10 +620,12 @@ function CommentRow({ replyAnonPassword, setReplyAnonPassword, handleCreateComment, + submittingComment, handleDeleteComment, + handleLoadMoreReplies, + isLoadingReplies, }: { item: CommentItem; - depth?: number; isAnonymousPost: boolean; currentUserPublicId: string | null; activeReplyParentId: number | null; @@ -497,25 +635,58 @@ function CommentRow({ replyAnonPassword: string; setReplyAnonPassword: (value: string) => void; handleCreateComment: (parentId: number | null) => Promise; + submittingComment: boolean; handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; + handleLoadMoreReplies: (rootCommentId: number) => Promise; + isLoadingReplies: boolean; }) { + const toggleReplyEditor = () => { + setActiveReplyParentId(activeReplyParentId === item.commentId ? null : item.commentId); + }; + return ( -
0 ? "ml-5 border-l-2 border-black pl-5" : ""}`}> +
- {item.writerName} + {getWriterName(item)} {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}

{item.content}

- {!item.isDeleted && ( -
- - + {!item.isDeleted && ( + + )} +
+ + {item.previewReplies.length > 0 && ( +
+ {item.previewReplies.map((reply) => ( + + ))} +
+ )} + + {item.hasMoreReplies && ( +
+
)} @@ -551,35 +722,54 @@ function CommentRow({ )}
)} -
)} + + ); +} - {item.children?.length > 0 && ( -
- {item.children.map((child) => ( - - ))} +function ReplyRow({ + item, + onReply, + handleDeleteComment, +}: { + item: CommentItem; + onReply: () => void; + handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; +}) { + return ( +
+
+ {getWriterName(item)} + + {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + +
+

{item.content}

+ {!item.isDeleted && ( +
+ +
)}
); } + +function getWriterName(comment: CommentItem) { + if (comment.isAnonymous) return `익명 (${comment.writerIp})`; + return comment.writer?.nickname || "알 수 없음"; +}