diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f255ead --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# 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 + +# Required credentials for CommentCreateTest and CommentUpdateTest against the MySQL test schema. +# Export these process environment variables before running either test; Gradle does not load .env automatically: +SNOWTHING_TEST_DB_URL=jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul +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, + @AuthenticationPrincipal CustomUserDetails userDetails) { + PostCommentListResponse response = + commentService.getCommentsByPost(publicId, cursor, size, userDetails); + return ResponseEntity.ok(response); + } + + @GetMapping("/comments/{commentId}/replies") + public ResponseEntity getCommentReplies( + @PathVariable Long commentId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok( + commentService.getCommentReplies(commentId, cursor, size, userDetails)); + } + + @PutMapping("/comments/{commentId}") + public ResponseEntity updateComment( + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + CommentUpdateResponse response = + commentService.updateComment(commentId, request, userDetails); return ResponseEntity.ok(response); } 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..492e72c 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,119 @@ package com.ikae.snowthing.domain.comment.dto; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; +import com.fasterxml.jackson.annotation.JsonIgnore; 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, + @JsonIgnore String ownerPublicId, + boolean canEdit, + boolean requiresPassword, + LocalDateTime createdAt) { + + 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, + member == null ? null : member.getPublicId(), + false, + false, + comment.getCreatedAt()); + } + + public CommentResponse withPreviewReplies(List replies) { + return new CommentResponse( + commentId, + postId, + parentId, + writer, + isAnonymous, + writerIp, + content, + isDeleted, + replyCount, + replies, + hasMoreReplies, + ownerPublicId, + canEdit, + requiresPassword, + createdAt); + } + + public CommentResponse withViewerPermissions(String viewerPublicId) { + boolean editable = + !isDeleted + && (ownerPublicId == null + ? isAnonymous + : ownerPublicId.equals(viewerPublicId)); + boolean passwordRequired = editable && ownerPublicId == null && isAnonymous; + List visibleReplies = + previewReplies.stream() + .map(reply -> reply.withViewerPermissions(viewerPublicId)) + .toList(); + return new CommentResponse( + commentId, + postId, + parentId, + writer, + isAnonymous, + writerIp, + content, + isDeleted, + replyCount, + visibleReplies, + hasMoreReplies, + ownerPublicId, + editable, + passwordRequired, + createdAt); + } + + public List children() { + return previewReplies; + } + + public String writerName() { + if (isAnonymous) { + return "익명 (" + writerIp + ")"; + } + return writer == null ? "알 수 없음" : writer.nickname(); } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..cc9575b --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java @@ -0,0 +1,10 @@ +package com.ikae.snowthing.domain.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequest( + @NotBlank(message = "댓글 내용은 필수 입력값입니다.") + @Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.") + String content, + String anonymousPassword) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java new file mode 100644 index 0000000..3313352 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java @@ -0,0 +1,5 @@ +package com.ikae.snowthing.domain.comment.dto; + +import java.time.LocalDateTime; + +public record CommentUpdateResponse(Long commentId, String content, LocalDateTime updatedAt) {} 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..2b87de7 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 @@ -9,6 +9,8 @@ import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.post.entity.Post; import com.ikae.snowthing.global.common.BaseTimeEntity; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; import lombok.AccessLevel; import lombok.Builder; @@ -16,12 +18,23 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "comment") +@Table( + name = "comment", + indexes = { + @Index( + name = "idx_comment_post_parent_created", + columnList = "post_id,parent_id,created_at,comment_id"), + @Index( + name = "idx_comment_parent_deleted_created", + columnList = "parent_id,is_deleted,created_at,comment_id") + }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?") public class Comment extends BaseTimeEntity { + private static final int MAX_CONTENT_LENGTH = 1000; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "comment_id") @@ -69,15 +82,41 @@ public Comment( this.post = post; this.member = member; this.parent = parent; - this.content = content; + this.content = validateContent(content); this.writerIp = writerIp; this.isAnonymous = isAnonymous; this.anonymousPassword = anonymousPassword; 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() { + return parent != null ? parent : this; + } + public void softDelete() { this.isDeleted = true; this.deletedAt = LocalDateTime.now(); } + + public void updateContent(String newContent) { + this.content = validateContent(newContent); + } + + private static String validateContent(String content) { + if (content == null || content.isBlank() || content.length() > MAX_CONTENT_LENGTH) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + return content; + } } 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..43bac96 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,26 @@ 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") + 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..e75a00a --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryCustom.java @@ -0,0 +1,25 @@ +package com.ikae.snowthing.domain.comment.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.ikae.snowthing.domain.comment.dto.CommentResponse; + +public interface CommentRepositoryCustom { + + record CursorPosition(LocalDateTime createdAt, Long commentId) {} + + Optional findRootCursor(Long postId, Long cursorId); + + Optional findReplyCursor(Long rootCommentId, Long cursorId); + + List findRootComments(Long postId, CursorPosition cursor, int fetchSize); + + Map> findTopReplyPreviews(List rootCommentIds); + + List findReplies(Long rootCommentId, CursorPosition cursor, int fetchSize); + + 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..49a06fc --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java @@ -0,0 +1,228 @@ +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 java.util.Optional; + +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 Optional findRootCursor(Long postId, Long cursorId) { + return findCursor( + """ + SELECT created_at, comment_id + FROM comment + WHERE post_id = :scopeId AND parent_id IS NULL AND comment_id = :cursorId + """, + postId, + cursorId); + } + + @Override + public Optional findReplyCursor(Long rootCommentId, Long cursorId) { + return findCursor( + """ + SELECT created_at, comment_id + FROM comment + WHERE parent_id = :scopeId AND comment_id = :cursorId + """, + rootCommentId, + cursorId); + } + + @Override + public List findRootComments( + Long postId, CursorPosition cursor, int fetchSize) { + String cursorCondition = + cursor == null + ? "" + : """ + AND (c.created_at > :cursorCreatedAt + OR (c.created_at = :cursorCreatedAt AND c.comment_id > :cursorId)) + """; + String sql = + "SELECT " + + SELECT_RESPONSE_COLUMNS + + """ + , (SELECT COUNT(*) FROM comment all_reply + WHERE all_reply.parent_id = c.comment_id) AS reply_count, + CASE WHEN (SELECT COUNT(*) FROM comment all_reply + WHERE all_reply.parent_id = c.comment_id) > 5 + THEN true ELSE false END 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.created_at ASC, c.comment_id ASC LIMIT :fetchSize"; + + MapSqlParameterSource params = + new MapSqlParameterSource("postId", postId).addValue("fetchSize", fetchSize); + addCursorParameters(params, cursor); + return jdbcTemplate.query(sql, params, this::mapResponse); + } + + @Override + public Map> findTopReplyPreviews(List rootCommentIds) { + if (rootCommentIds.isEmpty()) { + return Map.of(); + } + String sql = + """ + SELECT ranked.* + FROM ( + SELECT 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, + 0 AS reply_count, false AS has_more_replies, + ROW_NUMBER() OVER ( + PARTITION BY c.parent_id + ORDER BY c.created_at ASC, c.comment_id ASC + ) AS rn + FROM comment c + LEFT JOIN member m ON m.member_id = c.member_id + WHERE c.parent_id IN (:rootCommentIds) + ) ranked + WHERE ranked.rn <= 5 + ORDER BY ranked.parent_id ASC, ranked.created_at ASC, ranked.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, CursorPosition cursor, int fetchSize) { + String cursorCondition = + cursor == null + ? "" + : """ + AND (c.created_at > :cursorCreatedAt + OR (c.created_at = :cursorCreatedAt 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.created_at ASC, c.comment_id ASC LIMIT :fetchSize"; + MapSqlParameterSource params = + new MapSqlParameterSource("rootCommentId", rootCommentId) + .addValue("fetchSize", fetchSize); + addCursorParameters(params, cursor); + return jdbcTemplate.query(sql, params, this::mapResponse); + } + + @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 Optional findCursor(String sql, Long scopeId, Long cursorId) { + List positions = + jdbcTemplate.query( + sql, + new MapSqlParameterSource("scopeId", scopeId) + .addValue("cursorId", cursorId), + (rs, rowNum) -> + new CursorPosition( + rs.getObject("created_at", LocalDateTime.class), + rs.getLong("comment_id"))); + return positions.stream().findFirst(); + } + + private void addCursorParameters(MapSqlParameterSource params, CursorPosition cursorPosition) { + if (cursorPosition != null) { + params.addValue("cursorCreatedAt", cursorPosition.createdAt()); + params.addValue("cursorId", cursorPosition.commentId()); + } + } + + 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"), + memberPublicId, + false, + false, + 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..3b53a0f 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; 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; @@ -45,14 +50,18 @@ public CommentResponse createComment( if (request.isAnonymous()) { if (userDetails != null) { - member = memberRepository.findByPublicId(userDetails.getPublicId()).orElse(null); - } - if (member == null - && (request.anonymousPassword() == null - || request.anonymousPassword().isBlank())) { - throw new CustomAuthException(ErrorCode.INVALID_INPUT); - } - if (request.anonymousPassword() != null && !request.anonymousPassword().isBlank()) { + member = + memberRepository + .findByPublicId(userDetails.getPublicId()) + .orElseThrow( + () -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); + if (hasAnonymousPassword(request.anonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + } else { + if (!hasAnonymousPassword(request.anonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } encodedPassword = passwordEncoder.encode(request.anonymousPassword()); } } else { @@ -84,39 +93,69 @@ public CommentResponse createComment( Comment parent = null; if (request.parentId() != null) { - parent = + Comment requestedParent = commentRepository - .findById(request.parentId()) + .findByIdForUpdate(request.parentId()) .orElseThrow( () -> new CustomAuthException( 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()); - return CommentResponse.from(savedComment); + return CommentResponse.from(savedComment) + .withViewerPermissions( + userDetails == null ? null : userDetails.getPublicId()); }); } + @Transactional(readOnly = true) public PostCommentListResponse getCommentsByPost(String postPublicId) { + return getCommentsByPost(postPublicId, null, DEFAULT_READ_SIZE); + } + + @Transactional(readOnly = true) + public PostCommentListResponse getCommentsByPost(String postPublicId, Long cursor, int size) { + return getCommentsByPost(postPublicId, cursor, size, null); + } + + @Transactional(readOnly = true) + public PostCommentListResponse getCommentsByPost( + String postPublicId, Long cursor, int size, CustomUserDetails userDetails) { + validateReadSize(size); Post post = postRepository .findByPublicId(postPublicId) @@ -126,30 +165,124 @@ public PostCommentListResponse getCommentsByPost(String postPublicId) { throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); } - List comments = commentRepository.findByPostIdWithMember(post.getId()); + CommentRepositoryCustom.CursorPosition cursorPosition = + cursor == null + ? null + : commentRepository + .findRootCursor(post.getId(), cursor) + .orElseThrow( + () -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + List fetched = + commentRepository.findRootComments(post.getId(), cursorPosition, size + 1); + boolean hasNext = fetched.size() > size; + List roots = new ArrayList<>(hasNext ? fetched.subList(0, size) : fetched); + Map> previews = + commentRepository.findTopReplyPreviews( + roots.stream().map(CommentResponse::commentId).toList()); + List comments = + roots.stream() + .map( + root -> + root.withPreviewReplies( + previews.getOrDefault(root.commentId(), List.of()))) + .map( + comment -> + comment.withViewerPermissions( + userDetails == null + ? null + : userDetails.getPublicId())) + .toList(); + Long nextCursor = hasNext && !comments.isEmpty() ? comments.getLast().commentId() : null; + return new PostCommentListResponse( + postPublicId, post.getCommentCount(), comments, nextCursor, hasNext); + } + + @Transactional(readOnly = true) + public CommentReplyListResponse getCommentReplies(Long commentId, Long cursor, int size) { + return getCommentReplies(commentId, cursor, size, null); + } - Map map = new LinkedHashMap<>(); - for (Comment comment : comments) { - map.put(comment.getId(), CommentResponse.from(comment)); + @Transactional(readOnly = true) + public CommentReplyListResponse getCommentReplies( + Long commentId, Long cursor, int size, CustomUserDetails userDetails) { + validateReadSize(size); + Comment root = + commentRepository + .findById(commentId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + if (root.getParent() != null) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); } + CommentRepositoryCustom.CursorPosition cursorPosition = + cursor == null + ? null + : commentRepository + .findReplyCursor(commentId, cursor) + .orElseThrow( + () -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + List fetched = + commentRepository.findReplies(commentId, cursorPosition, size + 1); + boolean hasNext = fetched.size() > size; + List replies = + (hasNext ? fetched.subList(0, size) : fetched) + .stream() + .map( + reply -> + reply.withViewerPermissions( + userDetails == null + ? null + : userDetails.getPublicId())) + .toList(); + 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 validateReadSize(int size) { + if (size < 1 || size > MAX_READ_SIZE) { + throw new CustomAuthException(ErrorCode.COMMENT_INVALID_PAGE_SIZE); + } + } + + @Transactional + public CommentUpdateResponse updateComment( + Long commentId, CommentUpdateRequest request, CustomUserDetails userDetails) { + Comment comment = + commentRepository + .findById(commentId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + + if (comment.isDeleted()) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); + } + + validateUpdatePermission(comment, request.anonymousPassword(), userDetails); + + comment.updateContent(request.content()); + commentRepository.flush(); + + return new CommentUpdateResponse( + comment.getId(), comment.getContent(), comment.getUpdatedAt()); + } + + private void validateUpdatePermission( + Comment comment, String anonymousPassword, CustomUserDetails userDetails) { + if (comment.getMember() != null) { + if (isWriter(comment, userDetails)) { + return; } + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + if (!comment.isAnonymous()) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); } - return PostCommentListResponse.builder() - .publicId(postPublicId) - .totalCommentCount(post.getCommentCount()) - .comments(rootComments) - .build(); + if (!hasAnonymousPassword(anonymousPassword) + || comment.getAnonymousPassword() == null + || !passwordEncoder.matches(anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + } } @Transactional @@ -180,31 +313,30 @@ private void validateDeletePermission( return; } - if (comment.isAnonymous()) { - if (userDetails != null - && comment.getMember() != null - && comment.getMember().getPublicId().equals(userDetails.getPublicId())) { + if (comment.getMember() != null) { + if (isWriter(comment, userDetails)) { return; } - - if (anonymousPassword == null - || !passwordEncoder.matches( - anonymousPassword, comment.getAnonymousPassword())) { - throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); - } - return; + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); } - if (userDetails == null) { + if (!comment.isAnonymous()) { throw new CustomAuthException(ErrorCode.ACCESS_DENIED); } - boolean isWriter = - comment.getMember() != null - && comment.getMember().getPublicId().equals(userDetails.getPublicId()); - - if (!isWriter) { - throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + if (!hasAnonymousPassword(anonymousPassword) + || comment.getAnonymousPassword() == null + || !passwordEncoder.matches(anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); } } + + private boolean isWriter(Comment comment, CustomUserDetails userDetails) { + return userDetails != null + && comment.getMember().getPublicId().equals(userDetails.getPublicId()); + } + + private boolean hasAnonymousPassword(String anonymousPassword) { + return anonymousPassword != null && !anonymousPassword.isBlank(); + } } 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..ea35367 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 @@ -22,7 +22,7 @@ import lombok.RequiredArgsConstructor; @Component -@Profile("!test") +@Profile("local") @RequiredArgsConstructor public class DataInitializer implements CommandLineRunner { @@ -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..08c4e97 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,8 +24,12 @@ 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 이하이어야 합니다."), + COMMENT_INVALID_PAGE_SIZE( + HttpStatus.BAD_REQUEST, "COMMENT_005", "댓글 페이지 크기는 1 이상 50 이하이어야 합니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); private final HttpStatus status; diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java index 5c93c9d..758f935 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -59,6 +60,14 @@ public ResponseEntity handleValidationException( : ErrorCode.INVALID_INPUT.getMessage())); } + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException e) { + log.warn("요청 본문을 읽을 수 없습니다: {}", e.getMessage()); + return ResponseEntity.status(ErrorCode.INVALID_INPUT.getStatus()) + .body(ErrorResponse.from(ErrorCode.INVALID_INPUT)); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneralException(Exception e) { log.error("서버 내부 미처리 예외 발생: ", e); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 18702da..bf88d7e 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} 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..ff7e52e --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java @@ -0,0 +1,421 @@ +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.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.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +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 MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private NamedParameterJdbcTemplate jdbcTemplate; + @Autowired private MockMvc mockMvc; + + private CustomUserDetails userDetails; + private CustomUserDetails otherUserDetails; + 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); + Member otherMember = + memberRepository.save( + Member.builder() + .email("comment-read-other@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글조회다른사용자") + .role(Role.ROLE_USER) + .build()); + otherUserDetails = new CustomUserDetails(otherMember); + 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)) + .andExpect(jsonPath("$.replies[0].canEdit").value(false)) + .andExpect(jsonPath("$.replies[0].requiresPassword").value(false)) + .andExpect(jsonPath("$.replies[0].ownerPublicId").doesNotExist()); + } + + @Test + @DisplayName("삭제된 대댓글도 placeholder로 노출하며 대댓글 수와 더보기 기준을 일치시킨다") + void deletedRepliesKeepResponseCountsConsistent() { + CommentResponse root = createRoot("삭제 대댓글 집계 루트"); + List replies = + java.util.stream.IntStream.rangeClosed(1, 7) + .mapToObj(index -> createReply(root.commentId(), "대댓글 " + index)) + .toList(); + 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 rootResponse = response.comments().getFirst(); + + assertThat(rootResponse.replyCount()).isEqualTo(7); + assertThat(rootResponse.previewReplies()).hasSize(5); + assertThat(rootResponse.previewReplies().getFirst().isDeleted()).isTrue(); + assertThat(rootResponse.hasMoreReplies()).isTrue(); + + CommentReplyListResponse repliesResponse = + commentService.getCommentReplies(root.commentId(), null, 20); + assertThat(repliesResponse.totalReplyCount()).isEqualTo(7); + assertThat(repliesResponse.replies()).hasSize(7); + } + + @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); + } + + @Test + @DisplayName("댓글 수정 UI 권한은 서버의 작성 주체별 권한 정책과 일치한다") + void editPermissionMetadataMatchesOwnershipPolicy() { + CommentResponse memberComment = createRoot("회원 댓글"); + CommentResponse memberAnonymousComment = + commentService.createComment( + post.publicId(), + new CommentCreateRequest(null, "로그인 익명 댓글", true, null), + userDetails, + "127.0.0.1"); + CommentResponse guestAnonymousComment = + commentService.createComment( + post.publicId(), + new CommentCreateRequest(null, "비회원 익명 댓글", true, "password1234"), + null, + "127.0.0.1"); + + PostCommentListResponse ownerView = + commentService.getCommentsByPost(post.publicId(), null, 20, userDetails); + assertThat(findComment(ownerView, memberComment.commentId()).canEdit()).isTrue(); + assertThat(findComment(ownerView, memberComment.commentId()).requiresPassword()) + .isFalse(); + assertThat(findComment(ownerView, memberAnonymousComment.commentId()).canEdit()) + .isTrue(); + assertThat( + findComment(ownerView, memberAnonymousComment.commentId()) + .requiresPassword()) + .isFalse(); + assertThat(findComment(ownerView, guestAnonymousComment.commentId()).canEdit()) + .isTrue(); + assertThat(findComment(ownerView, guestAnonymousComment.commentId()).requiresPassword()) + .isTrue(); + + PostCommentListResponse otherView = + commentService.getCommentsByPost(post.publicId(), null, 20, otherUserDetails); + assertThat(findComment(otherView, memberComment.commentId()).canEdit()).isFalse(); + assertThat(findComment(otherView, memberAnonymousComment.commentId()).canEdit()) + .isFalse(); + assertThat(findComment(otherView, guestAnonymousComment.commentId()).canEdit()) + .isTrue(); + } + + @Test + @DisplayName("대댓글 분리 조회에도 동일한 수정 UI 권한을 적용한다") + void separatedReplyPermissionMetadataMatchesOwnershipPolicy() { + CommentResponse root = createRoot("권한 확인 루트"); + CommentResponse memberAnonymousReply = + commentService.createComment( + post.publicId(), + new CommentCreateRequest(root.commentId(), "로그인 익명 대댓글", true, null), + userDetails, + "127.0.0.1"); + + CommentReplyListResponse ownerView = + commentService.getCommentReplies(root.commentId(), null, 20, userDetails); + CommentReplyListResponse otherView = + commentService.getCommentReplies(root.commentId(), null, 20, otherUserDetails); + + assertThat(findReply(ownerView, memberAnonymousReply.commentId()).canEdit()).isTrue(); + assertThat(findReply(otherView, memberAnonymousReply.commentId()).canEdit()).isFalse(); + } + } + + @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("댓글 페이지 크기가 허용 범위를 벗어나면 COMMENT_INVALID_PAGE_SIZE를 반환한다") + void invalidPageSize() throws Exception { + assertErrorCode( + () -> commentService.getCommentsByPost(post.publicId(), null, 0), + ErrorCode.COMMENT_INVALID_PAGE_SIZE); + assertErrorCode( + () -> commentService.getCommentsByPost(post.publicId(), null, 51), + ErrorCode.COMMENT_INVALID_PAGE_SIZE); + + mockMvc.perform( + get("/api/v1/posts/{publicId}/comments", post.publicId()) + .param("size", "51")) + .andExpect(status().isBadRequest()) + .andExpect( + jsonPath("$.code") + .value(ErrorCode.COMMENT_INVALID_PAGE_SIZE.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); + } + } + + 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); + } + + private CommentResponse findComment(PostCommentListResponse response, Long commentId) { + return response.comments().stream() + .filter(comment -> comment.commentId().equals(commentId)) + .findFirst() + .orElseThrow(); + } + + private CommentResponse findReply(CommentReplyListResponse response, Long commentId) { + return response.replies().stream() + .filter(comment -> comment.commentId().equals(commentId)) + .findFirst() + .orElseThrow(); + } +} 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..6bfe110 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.assertj.core.api.Assertions.assertThat; 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.*; @@ -21,6 +22,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.CommentUpdateRequest; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; import com.ikae.snowthing.domain.comment.service.CommentService; import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.member.entity.Role; @@ -49,6 +53,8 @@ class CommentControllerTest { @Autowired private CommentService commentService; + @Autowired private CommentRepository commentRepository; + @Autowired private PasswordEncoder passwordEncoder; private Member member; @@ -140,4 +146,125 @@ void deleteComment_success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").exists()); } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - 작성자 인증과 CSRF 토큰으로 수정하면 200 OK") + void updateComment_success() throws Exception { + CommentResponse comment = createMemberComment("수정 전 댓글"); + CommentUpdateRequest request = new CommentUpdateRequest("수정 후 댓글", null); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.commentId").value(comment.commentId())) + .andExpect(jsonPath("$.content").value("수정 후 댓글")) + .andExpect(jsonPath("$.updatedAt").exists()); + + Comment updated = commentRepository.findById(comment.commentId()).orElseThrow(); + assertThat(updated.getContent()).isEqualTo("수정 후 댓글"); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - 다른 회원이면 AUTH_002와 403을 반환한다") + void updateComment_forbiddenForOtherMember() throws Exception { + CommentResponse comment = createMemberComment("작성자 댓글"); + Member otherMember = + memberRepository.save( + Member.builder() + .email("comment-update-other@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글수정타인") + .role(Role.ROLE_USER) + .build()); + CustomUserDetails otherUserDetails = new CustomUserDetails(otherMember); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .with(user(otherUserDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + new CommentUpdateRequest("타인의 수정", null)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AUTH_002")); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - 공백 본문은 COMMON_001과 400을 반환한다") + void updateComment_rejectsInvalidRequestBody() throws Exception { + CommentResponse comment = createMemberComment("수정 전 댓글"); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\" \",\"anonymousPassword\":null}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_001")); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - JSON 역직렬화 실패 시 400을 반환한다") + void updateComment_rejectsMalformedJson() throws Exception { + CommentResponse comment = createMemberComment("수정 전 댓글"); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":{")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - CSRF 토큰이 없으면 403을 반환한다") + void updateComment_rejectsRequestWithoutCsrfToken() throws Exception { + CommentResponse comment = createMemberComment("수정 전 댓글"); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + new CommentUpdateRequest("수정 시도", null)))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - 비회원 익명 댓글은 비밀번호로 수정하면 200 OK") + void updateGuestAnonymousComment_success() throws Exception { + CommentResponse comment = + commentService.createComment( + post.publicId(), + new CommentCreateRequest(null, "비회원 익명 댓글", true, "password1234"), + null, + "127.0.0.1"); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + new CommentUpdateRequest( + "비회원 수정 댓글", "password1234")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("비회원 수정 댓글")); + } + + private CommentResponse createMemberComment(String content) { + return commentService.createComment( + post.publicId(), + new CommentCreateRequest(null, content, false, null), + userDetails, + "127.0.0.1"); + } } 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..7d9c16c --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java @@ -0,0 +1,438 @@ +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; + + 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 rejectAnonymousPasswordFromMember() { + assertThatThrownBy( + () -> + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest( + null, "로그인 익명 댓글", true, "password1234"), + userDetails, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @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(); + } + } + + 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/service/CommentUpdateTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.java new file mode 100644 index 0000000..0d18ad4 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.java @@ -0,0 +1,423 @@ +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.List; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +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.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.Transactional; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.CommentUpdateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentUpdateResponse; +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.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +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 CommentUpdateTest { + + @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 MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private EntityManager entityManager; + + private CustomUserDetails writerDetails; + private CustomUserDetails otherDetails; + private PostResponse postResponse; + + @BeforeEach + void setUp() { + String fixtureId = UUID.randomUUID().toString().substring(0, 8); + categoryRepository + .findByCode("FREE") + .orElseGet(() -> categoryRepository.save(new PostCategory("자유게시판", "FREE"))); + + Member writer = + memberRepository.save( + new Member( + null, + "update-writer-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "수정작성자-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + writerDetails = new CustomUserDetails(writer); + + Member other = + memberRepository.save( + new Member( + null, + "update-other-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "타인회원-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + otherDetails = new CustomUserDetails(other); + + postResponse = + postService.createPost( + new PostCreateRequest( + "FREE", "수정 테스트 게시글", "게시글 본문", false, null, List.of()), + writerDetails, + "127.0.0.1"); + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCase { + + @Test + @DisplayName("[성공 1] 일반 회원 본인 댓글 수정 성공") + void updateOwnCommentAsMember() { + CommentResponse created = createMemberComment("수정 전 내용"); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 후 내용", null), + writerDetails); + + assertThat(updated.commentId()).isEqualTo(created.commentId()); + assertThat(updated.content()).isEqualTo("수정 후 내용"); + assertThat(updated.updatedAt()).isNotNull(); + + entityManager.flush(); + entityManager.clear(); + Comment savedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(savedComment.getContent()).isEqualTo("수정 후 내용"); + } + + @Test + @DisplayName("[성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 수정 성공") + void updateAnonymousCommentWithCorrectPassword() { + CommentResponse created = createGuestAnonymousComment("익명 수정 전", "mypass1234"); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("익명 수정 후", "mypass1234"), + null); + + assertThat(updated.content()).isEqualTo("익명 수정 후"); + + entityManager.flush(); + entityManager.clear(); + Comment savedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(savedComment.getContent()).isEqualTo("익명 수정 후"); + } + + @Test + @DisplayName("로그인 익명 댓글은 작성자 세션으로 수정할 수 있다") + void updateMemberAnonymousCommentByOwnerSession() { + CommentResponse created = createMemberAnonymousComment("로그인 익명 댓글"); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("작성자 수정", null), + writerDetails); + + assertThat(updated.content()).isEqualTo("작성자 수정"); + } + + @Test + @DisplayName("비회원 익명 댓글은 올바른 비밀번호로 삭제할 수 있다") + void deleteGuestAnonymousCommentWithCorrectPassword() { + CommentResponse created = createGuestAnonymousComment("비회원 익명 댓글", "password1234"); + + commentService.deleteComment(created.commentId(), "password1234", null); + + assertThat(commentRepository.findById(created.commentId()).orElseThrow().isDeleted()) + .isTrue(); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCase { + + @Test + @DisplayName("[실패 1] 로그인 회원이 타인의 댓글 수정 시도 시 ACCESS_DENIED (403)") + void rejectUpdateByOtherMember() { + CommentResponse created = createMemberComment("원본 댓글"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("타인이 수정", null), + otherDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("로그인 익명 댓글은 다른 사용자가 비밀번호를 보내도 수정할 수 없다") + void rejectPasswordFallbackForMemberAnonymousUpdate() { + CommentResponse created = createMemberAnonymousComment("로그인 익명 댓글"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 시도", "password1234"), + otherDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("로그인 익명 댓글은 비회원이 비밀번호를 보내도 삭제할 수 없다") + void rejectPasswordFallbackForMemberAnonymousDelete() { + CommentResponse created = createMemberAnonymousComment("로그인 익명 댓글"); + + assertThatThrownBy( + () -> + commentService.deleteComment( + created.commentId(), "password1234", null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + + assertThat(commentRepository.findById(created.commentId()).orElseThrow().isDeleted()) + .isFalse(); + } + + @Test + @DisplayName("[실패 2] 비회원 익명 댓글에 잘못된 비밀번호 입력 시 INVALID_ANON_PASSWORD (403)") + void rejectUpdateWithWrongPassword() { + CommentResponse created = createGuestAnonymousComment("익명 원본", "correct1234"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 시도", "wrong9999"), + null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ANON_PASSWORD); + } + + @Test + @DisplayName("[실패 3] 이미 Soft Delete된 댓글 수정 시도 시 COMMENT_NOT_FOUND (404)") + void rejectUpdateOnDeletedComment() { + CommentResponse created = createMemberComment("삭제할 댓글"); + commentService.deleteComment(created.commentId(), null, writerDetails); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("삭제 후 수정", null), + writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("[실패 4] 존재하지 않는 댓글 ID로 수정 시도 시 COMMENT_NOT_FOUND (404)") + void rejectUpdateOnNonExistentComment() { + assertThatThrownBy( + () -> + commentService.updateComment( + Long.MAX_VALUE, + new CommentUpdateRequest("수정 시도", null), + writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("[실패 5] 비회원 익명 댓글에 비밀번호 누락 시 INVALID_ANON_PASSWORD (403)") + void rejectUpdateWithNullPassword() { + CommentResponse created = createGuestAnonymousComment("익명 원본", "pass1234"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("비밀번호 없이 수정", null), + null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ANON_PASSWORD); + } + } + + @Test + @DisplayName("수정 응답의 updatedAt은 수정 전 값보다 이후이다") + void returnUpdatedAtAfterFlush() { + CommentResponse created = createMemberComment("수정 전 본문"); + Comment beforeUpdate = commentRepository.findById(created.commentId()).orElseThrow(); + java.time.LocalDateTime previousUpdatedAt = beforeUpdate.getUpdatedAt(); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 후 본문", null), + writerDetails); + + assertThat(updated.updatedAt()).isAfter(previousUpdatedAt); + } + + @Nested + @DisplayName("엔티티 본문 불변식") + class EntityContentInvariant { + + @Test + @DisplayName("생성 시에도 잘못된 본문을 거부한다") + void rejectInvalidContentOnCreation() { + assertThatThrownBy( + () -> Comment.create(null, null, null, null, "127.0.0.1", true, null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + assertThatThrownBy( + () -> Comment.create(null, null, null, " ", "127.0.0.1", true, null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + assertThatThrownBy( + () -> + Comment.create( + null, + null, + null, + "a".repeat(1001), + "127.0.0.1", + true, + null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("null 본문을 거부하고 기존 본문을 유지한다") + void rejectNullContent() { + assertInvalidEntityContent(null); + } + + @Test + @DisplayName("공백 본문을 거부하고 기존 본문을 유지한다") + void rejectBlankContent() { + assertInvalidEntityContent(" "); + } + + @Test + @DisplayName("1,000자를 초과한 본문을 거부하고 기존 본문을 유지한다") + void rejectOversizedContent() { + assertInvalidEntityContent("a".repeat(1001)); + } + } + + private void assertInvalidEntityContent(String invalidContent) { + CommentResponse created = createMemberComment("기존 본문"); + Comment comment = commentRepository.findById(created.commentId()).orElseThrow(); + + assertThatThrownBy(() -> comment.updateContent(invalidContent)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + assertThat(comment.getContent()).isEqualTo("기존 본문"); + } + + private CommentResponse createMemberComment(String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, false, null), + writerDetails, + "127.0.0.1"); + } + + private CommentResponse createGuestAnonymousComment(String content, String password) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, true, password), + null, + "127.0.0.1"); + } + + private CommentResponse createMemberAnonymousComment(String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, true, null), + writerDetails, + "127.0.0.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/resources/application-test.yml b/backend/src/test/resources/application-test.yml index b12aed3..14af407 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: ${SNOWTHING_TEST_DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${SNOWTHING_TEST_DB_USERNAME} + password: ${SNOWTHING_TEST_DB_PASSWORD} jpa: hibernate: @@ -17,4 +17,4 @@ spring: show-sql: false properties: hibernate: - dialect: org.hibernate.dialect.H2Dialect + dialect: org.hibernate.dialect.MySQLDialect diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 27938e4..5caa041 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -5,10 +5,10 @@ spring: - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration - 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: ${SNOWTHING_TEST_DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${SNOWTHING_TEST_DB_USERNAME} + password: ${SNOWTHING_TEST_DB_PASSWORD} jpa: hibernate: diff --git a/database/ddl.sql b/database/ddl.sql index ee0891d..ec2a007 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_created` (`post_id`, `parent_id`, `created_at`, `comment_id`), + INDEX `idx_comment_parent_deleted_created` (`parent_id`, `is_deleted`, `created_at`, `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..1c9eb07 100644 --- a/database/spike_seed_comments.sql +++ b/database/spike_seed_comments.sql @@ -8,19 +8,26 @@ USE `snowthing`; 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()); +-- 1. 테스트용 기본 카테고리 및 회원 확인/생성 (기존 1번 레코드 덮어쓰기 방지: 자연키 기반 안전 시딩) +INSERT INTO `post_category` (`name`, `code`) VALUES ('자유게시판', 'FREE') +ON DUPLICATE KEY UPDATE `name` = '자유게시판'; + +INSERT INTO `member` (`public_id`, `email`, `password`, `nickname`, `role`, `status`, `created_at`, `updated_at`) +VALUES ('member-spike-001', 'spike@snowthing.com', '$2a$10$dummyHashValueForSpikeTestingOnly1234567890', '스파이크테스터', 'ROLE_USER', 'ACTIVE', NOW(), NOW()) +ON DUPLICATE KEY UPDATE `nickname` = '스파이크테스터'; + +-- 스파이크 전용 레코드의 실제 PK 식별자 조회 +SET @spike_category_id = (SELECT `category_id` FROM `post_category` WHERE `code` = 'FREE' LIMIT 1); +SET @spike_member_id = (SELECT `member_id` FROM `member` WHERE `public_id` = 'member-spike-001' LIMIT 1); -- 2. 테스트용 게시글 2개 생성 -- Post 998: 시나리오 A (분산 1,000건용) INSERT INTO `post` (`post_id`, `public_id`, `member_id`, `category_id`, `title`, `content`, `writer_ip`, `is_anonymous`, `comment_count`, `created_at`, `updated_at`) -VALUES (998, 'post-spike-distributed-998', 1, 1, 'Spike [시나리오 A] 분산 1,000건 테스트 글', '내용', '127.0.0.1', FALSE, 1000, NOW(), NOW()); +VALUES (998, 'post-spike-distributed-998', @spike_member_id, @spike_category_id, 'Spike [시나리오 A] 분산 1,000건 테스트 글', '내용', '127.0.0.1', FALSE, 1000, NOW(), NOW()); -- Post 999: 시나리오 B (집중 핫스팟 1,000건용) INSERT INTO `post` (`post_id`, `public_id`, `member_id`, `category_id`, `title`, `content`, `writer_ip`, `is_anonymous`, `comment_count`, `created_at`, `updated_at`) -VALUES (999, 'post-spike-hotspot-999', 1, 1, 'Spike [시나리오 B] 핫스팟 500건 집중 테스트 글', '내용', '127.0.0.1', FALSE, 1000, NOW(), NOW()); +VALUES (999, 'post-spike-hotspot-999', @spike_member_id, @spike_category_id, 'Spike [시나리오 B] 핫스팟 500건 집중 테스트 글', '내용', '127.0.0.1', FALSE, 1000, NOW(), NOW()); -- ============================================================================== -- [시나리오 A] Post 998 : 루트 댓글 100개 + 각 루트당 대댓글 9개 = 총 1,000개 @@ -36,7 +43,7 @@ BEGIN -- 1. 루트 댓글 100개 생성 WHILE root_idx <= 100 DO INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) - VALUES (998, 1, NULL, CONCAT('루트 댓글 #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); + VALUES (998, @spike_member_id, NULL, CONCAT('루트 댓글 #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); SET current_root_id = LAST_INSERT_ID(); @@ -44,7 +51,7 @@ BEGIN SET reply_idx = 1; WHILE reply_idx <= 9 DO INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) - VALUES (998, 1, current_root_id, CONCAT('대댓글 #', reply_idx, ' (부모:', current_root_id, ')'), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (root_idx * 10 + reply_idx) SECOND, NOW()); + VALUES (998, @spike_member_id, current_root_id, CONCAT('대댓글 #', reply_idx, ' (부모:', current_root_id, ')'), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (root_idx * 10 + reply_idx) SECOND, NOW()); SET reply_idx = reply_idx + 1; END WHILE; @@ -71,7 +78,7 @@ BEGIN -- 1. 루트 댓글 500개 생성 WHILE root_idx <= 500 DO INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) - VALUES (999, 1, NULL, CONCAT('루트 댓글 #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); + VALUES (999, @spike_member_id, NULL, CONCAT('루트 댓글 #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); IF root_idx = 1 THEN SET hotspot_root_id = LAST_INSERT_ID(); @@ -83,7 +90,7 @@ BEGIN -- 2. 1번 루트 댓글에 대댓글 500개 집중 생성 WHILE reply_idx <= 500 DO INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) - VALUES (999, 1, hotspot_root_id, CONCAT('핫스팟 대댓글 #', reply_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (500 + reply_idx) SECOND, NOW()); + VALUES (999, @spike_member_id, hotspot_root_id, CONCAT('핫스팟 대댓글 #', reply_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (500 + reply_idx) SECOND, NOW()); SET reply_idx = reply_idx + 1; END WHILE; END$$ 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..96576cd --- /dev/null +++ b/docs/conception/sprint03/comment_api_spec.md @@ -0,0 +1,293 @@ +# 📋 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, + "canEdit": true, + "requiresPassword": 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, + "canEdit": false, + "requiresPassword": 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, + "canEdit": false, + "requiresPassword": false, + "replyCount": 1, + "previewReplies": [ + { + "commentId": 104, + "parentId": 103, + "writer": { + "publicId": "member-pub-9999", + "nickname": "스노우보더", + "profileImageUrl": null + }, + "isAnonymous": false, + "writerIp": "220.70.***.***", + "content": "삭제된 질문이지만 답변 남깁니다. 야간개장은 18시부터입니다.", + "isDeleted": false, + "canEdit": false, + "requiresPassword": false, + "createdAt": "2026-09-01T15:35:00" + } + ], + "hasMoreReplies": false, + "createdAt": "2026-09-01T15:31:00" + } + ], + "nextCursor": 103, + "hasNext": true +} +``` + +- `canEdit`: 현재 요청 사용자가 해당 댓글을 수정할 수 있는지 나타냅니다. 로그인 익명 댓글도 작성자 세션이 일치할 때만 `true`입니다. +- `requiresPassword`: 수정 시 익명 비밀번호가 필요한지 나타냅니다. 비회원 익명 댓글에만 `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, + "canEdit": false, + "requiresPassword": 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_005` | 댓글 페이지 크기는 1 이상 50 이하이어야 합니다. | +| `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..b0f9368 --- /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` (대댓글 수)**: + - 삭제된 대댓글도 placeholder로 노출하는 정책과 응답 수치의 일관성을 위해, 각 루트 댓글 DTO의 `replyCount`와 분리 조회의 `totalReplyCount`는 **삭제된 대댓글을 포함한 전체 대댓글 수**를 집계합니다. + - 게시글의 `post.commentCount`는 기존처럼 실제 활성 댓글·대댓글만 집계합니다. + +--- + +## 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..eabd0b2 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,51 @@ +- **Sprint 03 PR #15 코드리뷰 피드백 반영 및 시드/설정/인덱스 안전화 완결 (2026-09-02)**: + 1. **스파이크 시드(`database/spike_seed_comments.sql`) 소유권 기반 안전 시딩 적용**: + - `post_category`, `member`의 고정 PK(1) 강제 삽입을 제거하고 자연키(`code = 'FREE'`, `public_id = 'member-spike-001'`) 기반 생성 및 변수(`@spike_member_id`) 바인딩으로 변경하여 기존 로컬 1번 회원 데이터 덮어쓰기 방지. + 2. **DB Username 환경변수 동기화 (Configuration Parity)**: + - `backend/src/main/resources/application.yml`의 `datasource.username`을 `docker-compose.yml`과 일치하도록 `${SNOWTHING_DB_USERNAME:snowuser}`로 수정. + 3. **.env.example 테스트 환경변수 가이드 보강**: + - `CommentCreateTest` 및 `CommentUpdateTest` 두 테스트 모두 실제 MySQL 연동을 지원함을 명시하고 `SNOWTHING_TEST_DB_URL` 표준 예시값 추가. + 4. **인덱스 및 테스트/초기화 무결성 동기화**: + - `ddl.sql` 및 `Comment.java` 대댓글 복합 인덱스(`idx_comment_parent_deleted_created`) 동기화. + - `DataInitializer.java` 닉네임 유니크 제약조건 중복 가드 추가. + - `CommentServiceTest.java` 플레이스홀더 도메인 규칙 및 테스트 간 DB 격리 클린업(`@AfterEach`) 보강. + 5. **검증 결과**: + - `spotlessApply` 서식 교정 완료. + - MySQL 스파이크 시드 스크립트 실행 실측 성공 (Post 998: 1,000건, Post 999: 1,000건 생성 확인). + - 백엔드 전체 단위/통합 테스트(`gradle test`) **125개 전수 통과 (BUILD SUCCESSFUL in 27s)**. + +- **Sprint 03 댓글 수정(PUT /api/v1/comments/{commentId}) 기능 및 테스트 전담 개발 완결 (2026-09-01)**: + 1. **작업명**: 댓글 수정(Update) 기능 구현 및 권한/유효성 검증 테스트 + 2. **현재 상태**: 완료 + 3. **완료된 항목**: + - `CommentUpdateRequest.java` DTO 신설 (`content` @NotBlank/@Size(max=1000), `anonymousPassword` 선택). + - `CommentUpdateResponse.java` DTO 신설 (`commentId`, `content`, `updatedAt`). + - `Comment.java` 엔티티 내 본문 갱신용 `updateContent(String newContent)` 더티 체킹 메서드 추가. + - `CommentService.java` 내 `updateComment` 및 `validateUpdatePermission` 구현 (수정 권한은 오직 작성자 본인만 가능하도록 관리자 우회 제외). + - `CommentController.java` 내 `PUT /api/v1/comments/{commentId}` 엔드포인트 연동. + - `CommentUpdateTest.java` 단위/통합 테스트 7건 작성 (성공 2건 + 실패 5건). + 4. **남은 항목**: 없음 (Update 전담 완료) + 5. **발견된 이슈 및 사용자 결정**: + - 이슈: 삭제(DELETE)와 달리 수정(PUT) 작업 시 관리자(`ROLE_ADMIN`)의 타인 댓글 본문 수정 허용 여부 정책 확인 필요. + - 사용자 결정: 수정은 오직 작성자 본인만 가능하도록 확정 (`validateUpdatePermission`에 관리자 우회 로직 배제). + 6. **검증 결과**: + - `spotlessApply` 서식 포맷팅 완료. + - `gradle test --tests "*CommentUpdateTest*"` 총 7개 테스트 케이스 100% PASS (BUILD SUCCESSFUL in 18s). + - [성공 1] 일반 회원 본인 댓글 수정 성공 + - [성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 수정 성공 + - [실패 1] 로그인 회원이 타인 댓글 수정 시도 시 ACCESS_DENIED (403) + - [실패 2] 비회원 익명 댓글에 잘못된 비밀번호 입력 시 INVALID_ANON_PASSWORD (403) + - [실패 3] 이미 Soft Delete된 댓글 수정 시도 시 COMMENT_NOT_FOUND (404) + - [실패 4] 존재하지 않는 댓글 ID로 수정 시도 시 COMMENT_NOT_FOUND (404) + - [실패 5] 비회원 익명 댓글에 비밀번호 누락 시 INVALID_ANON_PASSWORD (403) + +- **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 +733,117 @@ 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 댓글 수정 프론트엔드 UI + +- 상태: DONE +- 시작일: 2026-09-01 + +### 계획 +- 루트 댓글과 대댓글에 한 번에 하나만 열리는 인라인 수정 폼을 적용한다. +- 일반 회원 댓글은 `writer.publicId`가 현재 사용자와 같을 때 수정 버튼을 노출한다. +- 익명 댓글은 소유권 응답 필드가 없어 버튼 노출 후 세션 또는 비밀번호를 서버에서 최종 검증하는 방안 A를 적용한다. +- 삭제 UI 변경은 `feature/sprint03-comment-d`로 분리하고 이 브랜치에는 포함하지 않는다. + +### 완료 +- 공백, 1,000자 제한, 변경 없음, 비로그인 익명 비밀번호를 검증하는 인라인 수정 폼 구현. +- PUT 성공 시 루트 또는 대댓글의 해당 `commentId` 본문만 불변 업데이트하도록 구현. +- 답글 작성 폼과 수정 폼이 동시에 열리지 않도록 수정 시작 시 답글 상태 초기화. + +### 남은 작업 +- 백엔드 Update API와 실제 브라우저 통합 검증. + +### 이슈 +- 익명 댓글 응답에 `canEdit`, `requiresPassword`가 없어 프론트만으로 정확한 소유권 버튼 노출은 불가능하다. + +### 결정 필요 +- 방안 A 적용을 사용자 승인받았으며 서버를 최종 권한 검증 주체로 사용한다. + +### 검증 +- 변경 파일 대상 ESLint 오류 0건. 기존 게시글 이미지 `` 최적화 경고 1건만 확인. +- `npm run build` 성공 및 TypeScript 오류 0건 확인. +- **Sprint 03 테스트 환경 MySQL 단일화 (2026-09-06)**: + - H2 의존성·datasource·dialect를 제거하고 모든 Spring Boot 테스트 설정을 MySQL 8.0/InnoDB로 통일했습니다. + - `CommentCreateTest`와 `CommentUpdateTest`는 `SNOWTHING_TEST_DB_URL` 누락 시 fallback 없이 즉시 실패하며, `.env.example`에 프로세스 환경변수 전달 방법을 명시했습니다. + +- **Sprint 03 댓글 수정 감사 시각 및 본문 불변식 보강 (2026-09-06)**: + - 상태: DONE + - `CommentService.updateComment()`가 본문 변경 후 repository를 flush한 다음 응답을 생성하도록 변경하여 `@LastModifiedDate`가 갱신된 `updatedAt`을 반환하게 했습니다. + - `Comment` 생성자와 `updateContent()`가 공통 본문 검증을 사용하도록 변경하여 null, 공백, 1,000자 초과 값을 `INVALID_INPUT`으로 즉시 거부합니다. + - `CommentUpdateTest`에 수정 전보다 이후인 응답 `updatedAt`, 생성·수정 엔티티 불변식, 실패 후 기존 본문 보존 검증을 추가했습니다. + - 검증: `compileTestJava` 통과. `spotlessCheck`는 기존 수정 파일 `CommentReadTest.java`의 혼합 줄바꿈 위반 때문에 전체 완료되지 않았으며, 이번 변경 파일의 포맷 지적은 해소했습니다. + - 테스트 실행: 로컬 `snowthing-mysql` 컨테이너에 `snowthing_test` 스키마를 준비하고 자격정보를 해당 Gradle 프로세스에만 주입하여 `./gradlew.bat test --tests "*CommentUpdateTest*"`를 실행했습니다. 총 12건 모두 통과했습니다. +- **Sprint 03 댓글 페이지 크기 전용 오류 코드 추가 (2026-09-06)**: + - 댓글 조회의 잘못된 `size` 요청에 `COMMENT_005`를 사용하도록 변경했습니다. + - 게시글 API의 공용 `INVALID_PAGE_SIZE(COMMON_002)` 계약은 변경하지 않았습니다. + - `CommentReadTest`에 서비스·HTTP 응답 오류 코드 검증을 반영했습니다. +- **DataInitializer 운영 실행 방지 (2026-09-06)**: + - 샘플 회원·고정 관리자·마스터 데이터 초기화기를 `@Profile("local")`로 제한했습니다. + - `docker`, `prod`, `test` 프로필에서는 초기화기가 로드되지 않아 운영 환경에서 고정 관리자 계정이 자동 생성되지 않습니다. + - 운영 관리자 계정은 별도 운영 생성·시크릿 주입 절차로 관리해야 합니다. + +- **Sprint 03 로그인 익명 댓글 소유권 정책 보강 (2026-09-06)**: + - 로그인 사용자가 익명 댓글을 생성할 때 `anonymousPassword`를 함께 보내면 `INVALID_INPUT`으로 거부하고, 회원 식별자가 없는 비회원 익명 댓글에만 비밀번호 해시를 저장하도록 변경했습니다. + - 수정·삭제 권한 판단을 `isAnonymous` 단독 기준에서 `member_id` 존재 여부 기준으로 변경했습니다. 회원이 작성한 익명 댓글은 작성자 세션으로만 수정·삭제할 수 있고, 비회원 익명 댓글만 비밀번호 검증 경로를 사용합니다. 관리자의 삭제 권한은 기존 정책대로 유지했습니다. + - 성공 테스트로 로그인 익명 작성자의 세션 수정과 비회원 익명 댓글의 비밀번호 삭제를 검증하고, 실패 테스트로 로그인 사용자의 비밀번호 동시 제출 거부 및 타 사용자·비회원의 비밀번호 우회 수정·삭제 차단을 검증했습니다. + - 검증 결과: `spotlessCheck` 통과, 로컬 MySQL 8.0의 `snowthing_test` 스키마에서 `CommentCreateTest`와 `CommentUpdateTest` 총 33건 통과했습니다. + - 확인 이슈: 테스트 종료 시 Hibernate `create-drop` 정리 과정에서 외래 키 제거 실패 로그가 출력되지만 Gradle 테스트 결과는 성공입니다. 테스트 컨텍스트가 둘 이상 생성되며 동일 스키마 정리를 시도하는 기존 테스트 환경 문제로, 이번 권한 정책 변경의 실패는 아닙니다. + +- **Sprint 03 댓글 수정 버튼 권한 응답 정합성 보강 (2026-09-06)**: + - 댓글 조회 응답에 현재 요청자 기준 `canEdit`, `requiresPassword`를 추가했습니다. 익명 댓글의 실제 회원 식별자는 `ownerPublicId` 내부 필드로만 판정하고 `@JsonIgnore`로 응답에서 제외했습니다. + - 공개 조회 컨트롤러가 선택적 인증 주체를 서비스에 전달하도록 변경했으며, 서비스는 일반 회원·로그인 익명·비회원 익명·삭제 댓글의 수정 가능 여부를 서버 권한 매트릭스와 동일하게 계산합니다. + - 프런트엔드는 `isAnonymous`나 로그인 여부를 자체 추정하지 않고 서버의 `canEdit`, `requiresPassword`를 사용해 수정 버튼과 비밀번호 입력을 표시합니다. + - `CommentReadTest`에 작성자/타 사용자별 루트 댓글 권한, 분리 대댓글 권한, 내부 소유자 식별자 비노출 검증을 추가했습니다. + - 검증 결과: `CommentReadTest`, `CommentCreateTest`, `CommentUpdateTest` 통과, 백엔드 `spotlessCheck` 통과, 프런트엔드 `npm run build` 통과했습니다. + - 전체 `npm run lint`는 이번 변경 파일 외의 기존 오류 6건(`ToastEditor.tsx`, `ToastViewer.tsx`, 게시글 작성·목록 페이지) 때문에 실패했습니다. 이번 변경 파일은 별도 ESLint 검사로 신규 오류가 없음을 확인합니다. +- **댓글 생성 트랜잭션 경계 리뷰 이슈 기록 (2026-09-06)**: + - `TransactionTemplate`의 기본 전파가 `REQUIRED`라 `CommentService.createComment`의 기존 트랜잭션에 참여하는 구조임을 확인했습니다. + +- **Sprint 03 댓글 수정 MockMvc 통합 테스트 보강 (2026-09-06)**: + - `CommentControllerTest`에 `PUT /api/v1/comments/{commentId}`의 정상 회원 수정, 타 회원 권한 거부, 공백 본문 검증, 잘못된 JSON 역직렬화, CSRF 누락, 비회원 익명 비밀번호 수정 시나리오를 추가했습니다. + - 정상 요청은 `@AuthenticationPrincipal` 주입과 JSON 응답뿐 아니라 실제 댓글 본문이 DB에 반영됐는지도 확인합니다. + - 테스트 과정에서 `HttpMessageNotReadableException`이 공통 예외 처리에 누락되어 잘못된 JSON이 `500 SERVER_001`로 반환되는 문제를 발견했습니다. `GlobalExceptionHandler`에서 이를 `400 COMMON_001`로 변환하도록 보강했습니다. + - 검증 결과: `CommentControllerTest` 9건과 `CommentUpdateTest` 16건, 총 25건 통과 및 `spotlessCheck` 통과했습니다. + - `NOT_SUPPORTED` 또는 `REQUIRES_NEW`로 단순 변경하면 `CommentCreateTest`·`CommentUpdateTest`의 미커밋 픽스처를 새 트랜잭션에서 읽지 못해 테스트가 실패합니다. + - 안전한 해결에는 생성 전용 트랜잭션 Bean 분리와 테스트 픽스처의 별도 커밋 경계 조정이 함께 필요합니다. 현재는 동작을 깨뜨리는 부분 수정 대신 후속 작업으로 남겼습니다. +- **댓글 익명 사용자 유형별 삭제 비밀번호 분기 수정 (2026-09-06)**: + - 삭제 핸들러가 `isAnonymous`가 아닌 서버 응답의 `requiresPassword`를 기준으로 동작하도록 변경했습니다. + - 로그인 익명 댓글은 비밀번호 없이 로그인 세션으로 삭제를 요청하고, 비회원 익명 댓글만 비밀번호를 요구합니다. +- **댓글 생성 후 대댓글 미리보기·더보기 상태 동기화 (2026-09-06)**: + - 대댓글 생성 직후 미리보기를 최대 5개로 제한했습니다. + - 증가된 `replyCount`를 기준으로 `hasMoreReplies`를 재계산해 6번째 대댓글부터 더보기 상태가 활성화됩니다. 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..5a2fea6 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -40,17 +40,45 @@ 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; + canEdit: boolean; + requiresPassword: 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; +} + +interface CommentUpdateResponse { + commentId: number; + content: string; + updatedAt: string; } export default function PostDetailPage({ params }: { params: Promise<{ publicId: string }> }) { @@ -59,6 +87,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(""); @@ -66,8 +98,14 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const [commentAnonPassword, setCommentAnonPassword] = useState(""); const [submittingComment, setSubmittingComment] = useState(false); const [activeReplyParentId, setActiveReplyParentId] = useState(null); + const [replyMentionName, setReplyMentionName] = useState(null); const [replyText, setReplyText] = useState(""); const [replyAnonPassword, setReplyAnonPassword] = useState(""); + const [activeEditCommentId, setActiveEditCommentId] = useState(null); + const [editCommentText, setEditCommentText] = useState(""); + const [editCommentPassword, setEditCommentPassword] = useState(""); + const [editCommentError, setEditCommentError] = useState(""); + const [submittingEditComment, setSubmittingEditComment] = useState(false); const [currentUserPublicId, setCurrentUserPublicId] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -119,19 +157,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 () => { @@ -244,15 +360,38 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: }); if (res.ok) { + const createdComment: CommentItem = await res.json(); if (parentId) { setReplyText(""); setReplyAnonPassword(""); setActiveReplyParentId(null); + setReplyMentionName(null); + setComments((current) => + current.map((comment) => { + if (comment.commentId !== parentId) return comment; + const nextReplyCount = comment.replyCount + 1; + const nextPreviewReplies = comment.hasMoreReplies + ? comment.previewReplies + : [...comment.previewReplies, createdComment].slice(0, 5); + return { + ...comment, + replyCount: nextReplyCount, + previewReplies: nextPreviewReplies, + hasMoreReplies: comment.hasMoreReplies || nextReplyCount > 5, + }; + }), + ); + 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; } @@ -266,9 +405,75 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; - const handleDeleteComment = async (commentId: number, isAnonymousWriter: boolean) => { + const handleStartEditComment = (comment: CommentItem) => { + setActiveReplyParentId(null); + setReplyMentionName(null); + setActiveEditCommentId(comment.commentId); + setEditCommentText(comment.content); + setEditCommentPassword(""); + setEditCommentError(""); + }; + + const handleCancelEditComment = () => { + if (submittingEditComment) return; + setActiveEditCommentId(null); + setEditCommentText(""); + setEditCommentPassword(""); + setEditCommentError(""); + }; + + const handleUpdateComment = async (comment: CommentItem) => { + const content = editCommentText.trim(); + const requiresPassword = comment.requiresPassword; + if (!content) { + setEditCommentError("댓글 내용을 입력해주세요."); + return; + } + if (content.length > 1000) { + setEditCommentError("댓글은 1,000자 이하로 입력해주세요."); + return; + } + if (requiresPassword && !editCommentPassword.trim()) { + setEditCommentError("익명 댓글 비밀번호를 입력해주세요."); + return; + } + if (content === comment.content) { + setEditCommentError("변경된 내용이 없습니다."); + return; + } + + setSubmittingEditComment(true); + setEditCommentError(""); + try { + const res = await csrfFetch(API_ENDPOINTS.comments.delete(comment.commentId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content, + anonymousPassword: editCommentPassword.trim() || null, + }), + }); + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "댓글 수정에 실패했습니다."); + } + + const updated: CommentUpdateResponse = await res.json(); + setComments((current) => updateCommentContent(current, updated.commentId, updated.content)); + setActiveEditCommentId(null); + setEditCommentText(""); + setEditCommentPassword(""); + setEditCommentError(""); + } catch (error) { + setEditCommentError(error instanceof Error ? error.message : "서버 통신 중 오류가 발생했습니다."); + } finally { + setSubmittingEditComment(false); + } + }; + + const handleDeleteComment = async (comment: CommentItem) => { let anonymousPassword = ""; - if (isAnonymousWriter) { + if (comment.requiresPassword) { const input = prompt("익명 댓글 삭제 비밀번호를 입력하세요."); if (!input) return; anonymousPassword = input; @@ -277,12 +482,10 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } try { - const res = await csrfFetch(API_ENDPOINTS.comments.delete(commentId), { + const res = await csrfFetch(API_ENDPOINTS.comments.delete(comment.commentId), { method: "DELETE", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - anonymousPassword: anonymousPassword || null, - }), + body: JSON.stringify({ anonymousPassword: anonymousPassword || null }), }); if (res.ok) { await fetchComments(); @@ -445,16 +648,41 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: currentUserPublicId={currentUserPublicId} activeReplyParentId={activeReplyParentId} setActiveReplyParentId={setActiveReplyParentId} + replyMentionName={replyMentionName} + setReplyMentionName={setReplyMentionName} replyText={replyText} setReplyText={setReplyText} replyAnonPassword={replyAnonPassword} setReplyAnonPassword={setReplyAnonPassword} handleCreateComment={handleCreateComment} + activeEditCommentId={activeEditCommentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} handleDeleteComment={handleDeleteComment} + handleLoadMoreReplies={handleLoadMoreReplies} + isLoadingReplies={Boolean(replyPagingByRootId[comment.commentId]?.loading)} /> )) )} + + {hasNextComments && ( + + )} @@ -474,53 +702,151 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: function CommentRow({ item, - depth = 0, isAnonymousPost, currentUserPublicId, activeReplyParentId, setActiveReplyParentId, + replyMentionName, + setReplyMentionName, replyText, setReplyText, replyAnonPassword, setReplyAnonPassword, handleCreateComment, + activeEditCommentId, + editCommentText, + setEditCommentText, + editCommentPassword, + setEditCommentPassword, + editCommentError, + submittingEditComment, + handleStartEditComment, + handleCancelEditComment, + handleUpdateComment, handleDeleteComment, + handleLoadMoreReplies, + isLoadingReplies, }: { item: CommentItem; - depth?: number; isAnonymousPost: boolean; currentUserPublicId: string | null; activeReplyParentId: number | null; setActiveReplyParentId: (id: number | null) => void; + replyMentionName: string | null; + setReplyMentionName: (name: string | null) => void; replyText: string; setReplyText: (text: string) => void; replyAnonPassword: string; setReplyAnonPassword: (value: string) => void; handleCreateComment: (parentId: number | null) => Promise; - handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; + activeEditCommentId: number | null; + editCommentText: string; + setEditCommentText: (text: string) => void; + editCommentPassword: string; + setEditCommentPassword: (password: string) => void; + editCommentError: string; + submittingEditComment: boolean; + handleStartEditComment: (comment: CommentItem) => void; + handleCancelEditComment: () => void; + handleUpdateComment: (comment: CommentItem) => Promise; + handleDeleteComment: (comment: CommentItem) => Promise; + handleLoadMoreReplies: (rootCommentId: number) => Promise; + isLoadingReplies: boolean; }) { + const canEdit = canEditComment(item); + const isEditing = activeEditCommentId === item.commentId; + const openReplyEditor = (target: CommentItem) => { + if (activeReplyParentId === item.commentId && replyMentionName === getWriterName(target)) { + setActiveReplyParentId(null); + setReplyMentionName(null); + return; + } + setActiveReplyParentId(item.commentId); + setReplyMentionName(getWriterName(target)); + }; + 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}

+ {isEditing ? ( + void handleUpdateComment(item)} + /> + ) : ( + <> +

{item.content}

+
+ + {canEdit && ( + + )} + {!item.isDeleted && ( + + )} +
+ + )} - {!item.isDeleted && ( + {item.previewReplies.length > 0 && ( +
+ {item.previewReplies.map((reply) => ( + openReplyEditor(reply)} + isEditing={activeEditCommentId === reply.commentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} + handleDeleteComment={handleDeleteComment} + /> + ))} +
+ )} + + {item.hasMoreReplies && (
- -
)} {activeReplyParentId === item.commentId && (
+ {replyMentionName && ( +

@{replyMentionName} 님에게 답글

+ )}