From 87e27e27d73bd4633c66ef14e0b1009fb1369cbd Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 1 Sep 2026 20:45:01 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C(Soft=20Delete)=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EB=B0=9C?= =?UTF-8?q?=20=EB=B0=8F=20=EB=8B=A8=EC=9C=84/=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/service/CommentService.java | 30 +- .../comment/service/CommentDeleteTest.java | 325 ++++++++++++++++++ docs/project/work.md | 61 ++++ 3 files changed, 395 insertions(+), 21 deletions(-) create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentDeleteTest.java 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 58c1c24..20ca1a3 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 @@ -240,31 +240,19 @@ private void validateDeletePermission( return; } - if (comment.isAnonymous()) { - if (userDetails != null - && comment.getMember() != null - && comment.getMember().getPublicId().equals(userDetails.getPublicId())) { - return; - } - - if (anonymousPassword == null - || !passwordEncoder.matches( - anonymousPassword, comment.getAnonymousPassword())) { - throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + if (comment.getMember() != null) { + boolean isWriter = + userDetails != null + && comment.getMember().getPublicId().equals(userDetails.getPublicId()); + if (!isWriter) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); } return; } - if (userDetails == null) { - 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 (anonymousPassword == null + || !passwordEncoder.matches(anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); } } } diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentDeleteTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentDeleteTest.java new file mode 100644 index 0000000..e750e8a --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentDeleteTest.java @@ -0,0 +1,325 @@ +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.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.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.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 +@Transactional +class CommentDeleteTest { + + @DynamicPropertySource + static void useRealMySql(DynamicPropertyRegistry registry) { + String testDbUrl = System.getenv("SNOWTHING_TEST_DB_URL"); + if (testDbUrl == null || testDbUrl.isBlank()) { + return; + } + 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 writerDetails; + private CustomUserDetails otherDetails; + private CustomUserDetails adminDetails; + 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, + "delete-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, + "delete-other-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "타인회원-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + otherDetails = new CustomUserDetails(other); + + Member admin = + memberRepository.save( + new Member( + null, + "delete-admin-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "최고관리자-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_ADMIN, + MemberStatus.ACTIVE)); + adminDetails = new CustomUserDetails(admin); + + postResponse = + postService.createPost( + new PostCreateRequest( + "FREE", "삭제 테스트 게시글", "게시글 본문", false, null, List.of()), + writerDetails, + "127.0.0.1"); + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCase { + + @Test + @DisplayName("[성공 1] 일반 회원 본인 댓글 삭제 성공 (is_deleted = true, post.commentCount 1 차감 확인)") + void deleteOwnCommentAsMember() { + CommentResponse created = createMemberComment("본인 작성 댓글"); + + commentService.deleteComment(created.commentId(), null, writerDetails); + + entityManager.flush(); + entityManager.clear(); + Comment deletedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(deletedComment.isDeleted()).isTrue(); + assertThat(deletedComment.getDeletedAt()).isNotNull(); + + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(post.getCommentCount()).isEqualTo(0); + } + + @Test + @DisplayName("[성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 삭제 성공") + void deleteAnonymousCommentWithCorrectPassword() { + CommentResponse created = createGuestAnonymousComment("익명 작성 댓글", "anonPass1234"); + + commentService.deleteComment(created.commentId(), "anonPass1234", null); + + entityManager.flush(); + entityManager.clear(); + Comment deletedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(deletedComment.isDeleted()).isTrue(); + assertThat(deletedComment.getDeletedAt()).isNotNull(); + + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(post.getCommentCount()).isEqualTo(0); + } + + @Test + @DisplayName("[성공 3] 최고 관리자(ROLE_ADMIN)가 타인/익명 댓글을 비밀번호 없이 강제 삭제 성공") + void deleteCommentAsAdmin() { + CommentResponse memberComment = createMemberComment("일반 회원 댓글"); + CommentResponse anonComment = createGuestAnonymousComment("비회원 익명 댓글", "anonPass1234"); + + // 관리자는 회원 댓글을 비밀번호 없이 삭제 가능 + commentService.deleteComment(memberComment.commentId(), null, adminDetails); + // 관리자는 익명 댓글도 비밀번호 없이 삭제 가능 + commentService.deleteComment(anonComment.commentId(), null, adminDetails); + + entityManager.flush(); + entityManager.clear(); + Comment deletedMemberComment = + commentRepository.findById(memberComment.commentId()).orElseThrow(); + Comment deletedAnonComment = + commentRepository.findById(anonComment.commentId()).orElseThrow(); + + assertThat(deletedMemberComment.isDeleted()).isTrue(); + assertThat(deletedAnonComment.isDeleted()).isTrue(); + + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(post.getCommentCount()).isEqualTo(0); + } + + @Test + @DisplayName("[성공 4] 대댓글이 존재하는 부모 댓글 삭제 시 부모만 is_deleted = true 처리되고 하위 대댓글 정상 보존 확인") + void deleteParentCommentPreservesReplies() { + CommentResponse parent = createMemberComment("부모 댓글"); + CommentResponse reply1 = createReply(parent.commentId(), "대댓글 1"); + CommentResponse reply2 = createReply(parent.commentId(), "대댓글 2"); + + Post postBeforeDelete = + postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(postBeforeDelete.getCommentCount()).isEqualTo(3); + + // 부모 댓글만 삭제 + commentService.deleteComment(parent.commentId(), null, writerDetails); + + entityManager.flush(); + entityManager.clear(); + Comment deletedParent = commentRepository.findById(parent.commentId()).orElseThrow(); + Comment activeReply1 = commentRepository.findById(reply1.commentId()).orElseThrow(); + Comment activeReply2 = commentRepository.findById(reply2.commentId()).orElseThrow(); + + assertThat(deletedParent.isDeleted()).isTrue(); + assertThat(activeReply1.isDeleted()).isFalse(); + assertThat(activeReply2.isDeleted()).isFalse(); + + Post postAfterDelete = + postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(postAfterDelete.getCommentCount()).isEqualTo(2); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCase { + + @Test + @DisplayName("[실패 1] 로그인 회원이 타인의 댓글 삭제 시도 시 AUTH_002 (403 Forbidden) 검증") + void rejectDeleteByOtherMember() { + CommentResponse created = createMemberComment("타인이 삭제할 원본 댓글"); + + assertThatThrownBy( + () -> + commentService.deleteComment( + created.commentId(), null, otherDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("[실패 2] 비회원 익명 댓글에 틀린 비밀번호 입력 시 POST_004 (403 Forbidden) 검증") + void rejectDeleteWithWrongPassword() { + CommentResponse created = createGuestAnonymousComment("익명 댓글", "correctPass1234"); + + assertThatThrownBy( + () -> + commentService.deleteComment( + created.commentId(), "wrongPass9999", null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ANON_PASSWORD); + } + + @Test + @DisplayName("[실패 3] 이미 Soft Delete된 댓글 재삭제 시도 시 COMMENT_001 (404 Not Found) 검증") + void rejectDeleteOnAlreadyDeletedComment() { + CommentResponse created = createMemberComment("이미 삭제될 댓글"); + commentService.deleteComment(created.commentId(), null, writerDetails); + + assertThatThrownBy( + () -> + commentService.deleteComment( + created.commentId(), null, writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("[실패 4] 존재하지 않는 댓글 ID 삭제 시도 시 COMMENT_001 (404 Not Found) 검증") + void rejectDeleteOnNonExistentComment() { + assertThatThrownBy( + () -> commentService.deleteComment(Long.MAX_VALUE, null, writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + } + + 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 createReply(Long parentId, String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(parentId, content, false, null), + writerDetails, + "127.0.0.1"); + } +} diff --git a/docs/project/work.md b/docs/project/work.md index c481049..111100f 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,29 @@ +- **Sprint 03 댓글 삭제(DELETE /api/v1/comments/{commentId}) 및 4대 권한 매트릭스 전담 개발 완결 (2026-09-01)**: + 1. **작업명**: 댓글 삭제(Soft Delete & 권한 매트릭스) 기능 보강 및 단위/통합 테스트 + 2. **현재 상태**: 완료 + 3. **완료된 항목**: + - `CommentService.java` 내 `validateDeletePermission` 권한 매트릭스 리팩토링: + * 1) 최고 관리자(`ROLE_ADMIN`): 비밀번호 없이 즉시 삭제 권한 통과 + * 2) 일반 회원 및 로그인 익명(`comment.getMember() != null`): 본인 세션(`publicId`) 일치 시 통과, 타인 접근 시 `AUTH_002` (403 Forbidden) 반환 + * 3) 비회원 익명(`comment.getMember() == null`): 비밀번호 불일치/누락 시 `POST_004` (403 Forbidden) 반환, 일치 시 통과 + - Soft Delete 및 활성 댓글 수 원자적 차감 유지: `comment.softDelete()`, `postRepository.decreaseCommentCount(...)` + - `CommentDeleteTest.java` 단위/통합 테스트 8건 신설 (성공 4건 + 실패 4건). + 4. **남은 항목**: 없음 (Delete 전담 완료) + 5. **발견된 이슈 및 해결**: + - 기존 `validateDeletePermission`에서 로그인 회원이 작성한 익명 댓글(`isAnonymous = true, member != null`)을 타인이 삭제 시도 시 비밀번호 검사로 넘어가 `POST_004`가 발생하던 결함 발견. + - `comment.getMember() != null` 조건으로 통합하여 로그인 익명 글도 본인 세션이 아니면 정확히 `AUTH_002`가 발생하도록 인가 로직 일원화 완료. + 6. **검증 결과**: + - `spotlessApply` 서식 포맷팅 완료. + - `gradle test --tests "*CommentDeleteTest*"` 총 8개 테스트 케이스 100% PASS (BUILD SUCCESSFUL in 16s). + - [성공 1] 일반 회원 본인 댓글 삭제 성공 (`is_deleted = true`, `post.commentCount` 1 차감 확인) + - [성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 삭제 성공 + - [성공 3] 최고 관리자(`ROLE_ADMIN`)가 타인/익명 댓글을 비밀번호 없이 강제 삭제 성공 + - [성공 4] 대댓글이 존재하는 부모 댓글 삭제 시 부모만 `is_deleted = true` 처리되고 하위 대댓글 정상 보존 확인 + - [실패 1] 로그인 회원이 타인의 댓글 삭제 시도 시 `AUTH_002` (403 Forbidden) 검증 + - [실패 2] 비회원 익명 댓글에 틀린 비밀번호 입력 시 `POST_004` (403 Forbidden) 검증 + - [실패 3] 이미 Soft Delete된 댓글 재삭제 시도 시 `COMMENT_001` (404 Not Found) 검증 + - [실패 4] 존재하지 않는 댓글 ID 삭제 시도 시 `COMMENT_001` (404 Not Found) 검증 + - **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% 명세화. @@ -727,3 +753,38 @@ 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`가 현재 사용자와 같은 댓글에만 수정·삭제 버튼을 노출한다. +- 익명 댓글은 현재 DTO에 소유권 필드가 없어 버튼을 노출한 뒤 세션 또는 비밀번호를 서버에서 최종 검증하는 방안 A를 적용한다. +- 댓글 삭제의 `prompt`/`confirm`을 제거하고 기존 `DeleteConfirmModal`을 재사용한다. +- 변경 파일 대상 ESLint와 `npm run build`로 검증한다. + +### 완료 +- 구현 전 설계 문서, 프론트엔드 스킬, 현재 댓글 UI와 공용 삭제 모달 대조 완료. +- 루트 댓글과 대댓글에 공통 인라인 수정 폼을 적용하고 공백, 1,000자 제한, 변경 없음, 익명 비밀번호를 검증하도록 구현. +- 수정 성공 시 전체 목록 재조회 없이 해당 `commentId`의 본문만 불변 업데이트하도록 구현. +- 댓글 삭제의 브라우저 `prompt`/`confirm`을 제거하고 게시글 삭제와 분리된 `DeleteConfirmModal` 인스턴스로 연결. +- 공용 모달에 동적 확인 문구, 제출 중 닫기 방지, dialog ARIA 속성, 입력 label 연결을 추가. +- 삭제 성공 후 댓글 목록을 재조회하고 게시글의 `commentCount`를 1 차감하도록 구현. + +### 남은 작업 +- 백엔드 PUT 구현 완료 후 실제 수정·권한 실패 응답을 브라우저에서 통합 검증. +- 모달의 완전한 focus trap과 Escape 닫기 동작은 후속 접근성 개선 대상으로 남김. + +### 이슈 +- 현재 프론트가 참조하는 백엔드 브랜치에는 PUT 엔드포인트가 아직 없으며 백엔드에서 병행 구현 중이다. +- 익명 댓글 응답에 `canEdit`, `canDelete`, `requiresPassword`가 없어 버튼 노출 권한은 완전히 판별할 수 없다. + +### 결정 필요 +- 방안 A 적용을 사용자 승인받음. 서버를 최종 권한 검증 주체로 사용한다. + +### 검증 +- 변경 파일 대상 ESLint 오류 0건. 기존 게시글 이미지 `` 최적화 경고 1건만 확인. +- `npm run build` 성공 및 TypeScript 오류 0건 확인. From 3992f72a76527dee6ecef86526f59b23edce36cb Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 1 Sep 2026 20:49:32 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(comment):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=AA=A8=EB=8B=AC=20UI=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 13 +-- .../app/components/DeleteConfirmModal.tsx | 29 +++-- frontend/app/posts/[publicId]/page.tsx | 101 +++++++++++------- 3 files changed, 92 insertions(+), 51 deletions(-) diff --git a/docs/project/work.md b/docs/project/work.md index 111100f..de80a0f 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -754,33 +754,28 @@ 7. 검증 결과: 변경 파일 대상 ESLint 오류 0건(기존 `` 최적화 경고 1건), `npm run build` 및 TypeScript 검사 통과. 8. 확인 이슈: 전체 `npm run lint`는 이번 변경과 무관한 기존 `ToastEditor.tsx`, `ToastViewer.tsx`, 게시글 작성·목록 페이지의 오류 6건 때문에 실패. 브라우저 수동 검증은 백엔드와 테스트 데이터가 실행된 환경에서 추가 확인 필요. -## Sprint 03 댓글 수정·삭제 프론트엔드 UI +## Sprint 03 댓글 삭제 프론트엔드 UI - 상태: DONE - 시작일: 2026-09-01 ### 계획 -- 댓글과 대댓글에 한 번에 하나만 열리는 인라인 수정 폼을 적용한다. -- 일반 회원은 `writer.publicId`가 현재 사용자와 같은 댓글에만 수정·삭제 버튼을 노출한다. -- 익명 댓글은 현재 DTO에 소유권 필드가 없어 버튼을 노출한 뒤 세션 또는 비밀번호를 서버에서 최종 검증하는 방안 A를 적용한다. +- 일반 회원은 `writer.publicId`가 현재 사용자와 같은 댓글에만 삭제 버튼을 노출한다. +- 익명 댓글은 현재 DTO에 소유권 필드가 없어 삭제 버튼을 노출한 뒤 세션 또는 비밀번호를 서버에서 최종 검증하는 방안 A를 적용한다. - 댓글 삭제의 `prompt`/`confirm`을 제거하고 기존 `DeleteConfirmModal`을 재사용한다. - 변경 파일 대상 ESLint와 `npm run build`로 검증한다. ### 완료 - 구현 전 설계 문서, 프론트엔드 스킬, 현재 댓글 UI와 공용 삭제 모달 대조 완료. -- 루트 댓글과 대댓글에 공통 인라인 수정 폼을 적용하고 공백, 1,000자 제한, 변경 없음, 익명 비밀번호를 검증하도록 구현. -- 수정 성공 시 전체 목록 재조회 없이 해당 `commentId`의 본문만 불변 업데이트하도록 구현. - 댓글 삭제의 브라우저 `prompt`/`confirm`을 제거하고 게시글 삭제와 분리된 `DeleteConfirmModal` 인스턴스로 연결. - 공용 모달에 동적 확인 문구, 제출 중 닫기 방지, dialog ARIA 속성, 입력 label 연결을 추가. - 삭제 성공 후 댓글 목록을 재조회하고 게시글의 `commentCount`를 1 차감하도록 구현. ### 남은 작업 -- 백엔드 PUT 구현 완료 후 실제 수정·권한 실패 응답을 브라우저에서 통합 검증. - 모달의 완전한 focus trap과 Escape 닫기 동작은 후속 접근성 개선 대상으로 남김. ### 이슈 -- 현재 프론트가 참조하는 백엔드 브랜치에는 PUT 엔드포인트가 아직 없으며 백엔드에서 병행 구현 중이다. -- 익명 댓글 응답에 `canEdit`, `canDelete`, `requiresPassword`가 없어 버튼 노출 권한은 완전히 판별할 수 없다. +- 익명 댓글 응답에 `canDelete`, `requiresPassword`가 없어 버튼 노출 권한은 완전히 판별할 수 없다. ### 결정 필요 - 방안 A 적용을 사용자 승인받음. 서버를 최종 권한 검증 주체로 사용한다. diff --git a/frontend/app/components/DeleteConfirmModal.tsx b/frontend/app/components/DeleteConfirmModal.tsx index 628e348..5447944 100644 --- a/frontend/app/components/DeleteConfirmModal.tsx +++ b/frontend/app/components/DeleteConfirmModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; interface DeleteConfirmModalProps { isOpen: boolean; @@ -9,6 +9,8 @@ interface DeleteConfirmModalProps { title?: string; description?: string; requirePassword?: boolean; + confirmLabel?: string; + submittingLabel?: string; } export function DeleteConfirmModal({ @@ -18,7 +20,11 @@ export function DeleteConfirmModal({ title = "게시글 삭제 확인", description = "이 게시글을 삭제하시겠습니까?", requirePassword = true, + confirmLabel = "삭제", + submittingLabel = "삭제 중...", }: DeleteConfirmModalProps) { + const titleId = useId(); + const passwordId = useId(); const [password, setPassword] = useState(""); const [errorMsg, setErrorMsg] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -49,6 +55,7 @@ export function DeleteConfirmModal({ }; const handleClose = () => { + if (submitting) return; setPassword(""); setErrorMsg(""); onClose(); @@ -56,11 +63,19 @@ export function DeleteConfirmModal({ return (
-
+
-

{title}

+

{title}

diff --git a/frontend/app/posts/[publicId]/page.tsx b/frontend/app/posts/[publicId]/page.tsx index 3fecf1a..df689d2 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -73,6 +73,11 @@ interface ReplyPagingState { loading: boolean; } +interface CommentDeleteTarget { + commentId: number; + requiresPassword: boolean; +} + export default function PostDetailPage({ params }: { params: Promise<{ publicId: string }> }) { const router = useRouter(); const { publicId } = use(params); @@ -93,6 +98,7 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const [replyMentionName, setReplyMentionName] = useState(null); const [replyText, setReplyText] = useState(""); const [replyAnonPassword, setReplyAnonPassword] = useState(""); + const [commentDeleteTarget, setCommentDeleteTarget] = useState(null); const [currentUserPublicId, setCurrentUserPublicId] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -389,35 +395,29 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; - const handleDeleteComment = async (commentId: number, isAnonymousWriter: boolean) => { - let anonymousPassword = ""; - if (isAnonymousWriter) { - const input = prompt("익명 댓글 삭제 비밀번호를 입력하세요."); - if (!input) return; - anonymousPassword = input; - } else if (!confirm("댓글을 삭제하시겠습니까?")) { - return; - } + const handleOpenCommentDeleteModal = (comment: CommentItem) => { + setCommentDeleteTarget({ + commentId: comment.commentId, + requiresPassword: comment.isAnonymous && !currentUserPublicId, + }); + }; - try { - const res = await csrfFetch(API_ENDPOINTS.comments.delete(commentId), { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - anonymousPassword: anonymousPassword || null, - }), - }); - if (res.ok) { - await fetchComments(); - setPost((current) => (current ? { ...current, commentCount: Math.max(0, current.commentCount - 1) } : current)); - return; - } + const handleConfirmCommentDelete = async (password: string) => { + if (!commentDeleteTarget) return; + const res = await csrfFetch(API_ENDPOINTS.comments.delete(commentDeleteTarget.commentId), { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ anonymousPassword: password || null }), + }); + if (!res.ok) { const errorData = await res.json(); - alert(`삭제 실패: ${errorData.message || "요청을 처리하지 못했습니다."}`); - } catch { - alert("서버 통신 중 오류가 발생했습니다."); + throw new Error(errorData.message || "댓글 삭제에 실패했습니다."); } + + setCommentDeleteTarget(null); + await fetchComments(); + setPost((current) => (current ? { ...current, commentCount: Math.max(0, current.commentCount - 1) } : current)); }; if (loading) { @@ -575,7 +575,8 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: replyAnonPassword={replyAnonPassword} setReplyAnonPassword={setReplyAnonPassword} handleCreateComment={handleCreateComment} - handleDeleteComment={handleDeleteComment} + handleOpenCommentDeleteModal={handleOpenCommentDeleteModal} + isAdmin={isAdmin} handleLoadMoreReplies={handleLoadMoreReplies} isLoadingReplies={Boolean(replyPagingByRootId[comment.commentId]?.loading)} /> @@ -605,6 +606,16 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: description={deleteModalConfig.description} requirePassword={deleteModalConfig.requirePassword} /> + setCommentDeleteTarget(null)} + onConfirm={handleConfirmCommentDelete} + title="댓글 삭제 확인" + description="정말 삭제하시겠습니까?" + requirePassword={commentDeleteTarget?.requiresPassword ?? false} + confirmLabel="댓글 삭제" + submittingLabel="삭제 중..." + />
); @@ -623,7 +634,8 @@ function CommentRow({ replyAnonPassword, setReplyAnonPassword, handleCreateComment, - handleDeleteComment, + handleOpenCommentDeleteModal, + isAdmin, handleLoadMoreReplies, isLoadingReplies, }: { @@ -639,10 +651,12 @@ function CommentRow({ replyAnonPassword: string; setReplyAnonPassword: (value: string) => void; handleCreateComment: (parentId: number | null) => Promise; - handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; + handleOpenCommentDeleteModal: (comment: CommentItem) => void; + isAdmin: boolean; handleLoadMoreReplies: (rootCommentId: number) => Promise; isLoadingReplies: boolean; }) { + const canDelete = canDeleteComment(item, currentUserPublicId, isAdmin); const openReplyEditor = (target: CommentItem) => { if (activeReplyParentId === item.commentId && replyMentionName === getWriterName(target)) { setActiveReplyParentId(null); @@ -666,8 +680,8 @@ function CommentRow({ - {!item.isDeleted && ( - )} @@ -680,7 +694,9 @@ function CommentRow({ key={reply.commentId} item={reply} onReply={() => openReplyEditor(reply)} - handleDeleteComment={handleDeleteComment} + currentUserPublicId={currentUserPublicId} + isAdmin={isAdmin} + handleOpenCommentDeleteModal={handleOpenCommentDeleteModal} /> ))}
@@ -748,12 +764,17 @@ function CommentRow({ function ReplyRow({ item, onReply, - handleDeleteComment, + currentUserPublicId, + isAdmin, + handleOpenCommentDeleteModal, }: { item: CommentItem; onReply: () => void; - handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; + currentUserPublicId: string | null; + isAdmin: boolean; + handleOpenCommentDeleteModal: (comment: CommentItem) => void; }) { + const canDelete = canDeleteComment(item, currentUserPublicId, isAdmin); return (
@@ -768,9 +789,11 @@ function ReplyRow({ - + {canDelete && ( + + )}
)}
@@ -781,3 +804,9 @@ function getWriterName(comment: CommentItem) { if (comment.isAnonymous) return `익명 (${comment.writerIp})`; return comment.writer?.nickname || "알 수 없음"; } + +function canDeleteComment(comment: CommentItem, currentUserPublicId: string | null, isAdmin: boolean) { + if (comment.isDeleted) return false; + if (isAdmin || comment.isAnonymous) return true; + return Boolean(currentUserPublicId && comment.writer?.publicId === currentUserPublicId); +} From 0e48e4cdcfa0640cbd22e4a141b77b8751139d7e Mon Sep 17 00:00:00 2001 From: ikae Date: Thu, 3 Sep 2026 17:36:22 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs(comment):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=B0=8F=20README=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 186 +++++++- ...04\355\202\244\355\205\215\354\262\230.md" | 0 ...04\354\262\264\353\260\260\354\271\230.md" | 89 ++++ ...70\353\246\254\354\241\260\353\246\275.md" | 73 ++++ ...53\267\260_\353\266\204\353\246\254API.md" | 167 ++++++++ ...354\261\204 \355\225\264\352\262\260_4.md" | 401 ++++++++++++++++++ docs/project/work.md | 66 +++ 7 files changed, 976 insertions(+), 6 deletions(-) rename docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md => "docs/conception/sprint03/ADR-001-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" (100%) create mode 100644 "docs/conception/sprint03/spike_\353\243\250\355\212\270\354\273\244\354\204\234_\353\214\200\353\214\223\352\270\200\354\240\204\354\262\264\353\260\260\354\271\230.md" create mode 100644 "docs/conception/sprint03/spike_\353\251\224\353\252\250\353\246\254\354\240\204\354\262\264\355\212\270\353\246\254\354\241\260\353\246\275.md" create mode 100644 "docs/conception/sprint03/spike_\355\225\230\354\235\264\353\270\214\353\246\254\353\223\234\355\224\204\353\246\254\353\267\260_\353\266\204\353\246\254API.md" create mode 100644 "docs/conception/sprint03/\352\270\260\354\210\240\353\266\200\354\261\204 \355\225\264\352\262\260_4.md" diff --git a/README.md b/README.md index 19ebdc9..96e0cb6 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ sequenceDiagram Server->>Session: request.changeSessionId() 호출! (세션 식별자 교체) Session-->>Server: 신규 32자리 JSESSIONID 발급 (기존 스키장/성향 검색 필터 세션 데이터 유지) - Server-->>Client: Set-Cookie: JSESSIONID=A1B2...; Path=/; HttpOnly; SameSite=Lax + Server-->>Client: 신규 세션 쿠키 발급 (JSESSIONID, HttpOnly, SameSite=Lax) Client-->>User: 로그인 성공 (메인 프로필 대시보드 전환) Note over User, Server: 4. 인증된 API 요청 (프로필 조회/수정) @@ -69,12 +69,12 @@ sequenceDiagram Client->>Server: POST /api/auth/logout Server->>Server: 1) ThreadLocal.clearContext() 청소 Server->>Session: 2) session.invalidate() 톰캣 세션 파기 - Server-->>Client: 3) Set-Cookie: JSESSIONID=; Max-Age=0 (쿠키 즉시 만료) + Server-->>Client: 3) JSESSIONID 쿠키 만료 응답 (Max-Age=0) ``` --- -### 핵심 아키텍처 고민 및 기술적 의사결정 +### 아키텍처 고민 및 기술적 의사결정 #### 1. 공통 엔티티와 JPA Auditing (`@EnableJpaAuditing`) 도입 @@ -111,7 +111,7 @@ sequenceDiagram --- -## 4. 게시판(Post) 도메인 설계 & 핵심 기술적 의사결정 (Board Architecture & Decisions) +## 4. 게시판(Post) 도메인 설계 & 기술적 의사결정 (Board Architecture & Decisions) 게시판은 Snowthing에서 가장 자주 읽히는 도메인이다. 그래서 단순 CRUD로만 만들지 않고, 목록 조회 비용, 익명 글 권한, 삭제 정책, 이미지 첨부 상태까지 같이 맞춰서 설계했다. @@ -268,13 +268,187 @@ Page findByCategoryCodeWithMemberAndCategory(@Param("categoryCode") String - `INVALID_PAGE_LIMIT (400)`: offset page 제한을 넘긴 요청 ### 11) CSRF + 게시글 생성, 수정, 삭제 같은 CUD 요청은 CSRF 공격 표적이 되기 쉽다. Spring Security의 `CookieCsrfTokenRepository.withHttpOnlyFalse()`를 적용했다. 이 방식은 Double Submit Cookie 패턴으로 동작한다. 백엔드가 `XSRF-TOKEN` 쿠키를 발급하면, 프론트엔드가 자원 변경 요청(POST, PUT, DELETE)을 보낼 때 쿠키 값을 읽어 `X-XSRF-TOKEN` HTTP 헤더에 담아서 보낸다. 서버의 `CsrfFilter`는 쿠키의 토큰 값과 헤더의 토큰 값이 일치하는지 비교하여 검증한다. 외부 해킹 사이트는 동일 출처 정책(SOP) 제약으로 인해 사용자의 `XSRF-TOKEN` 쿠키를 자바스크립트로 읽을 수 없어 `X-XSRF-TOKEN` 헤더를 생성하지 못하므로 위조된 요청은 403 Forbidden으로 차단된다. + +--- + +## 5. 댓글(Comment) 도메인 설계 & 기술적 의사결정 + +댓글은 게시글 상세 화면에서 가장 자주 읽히는 데이터다. 그래서 단순히 `post_id`로 전체 댓글을 가져오는 방식 대신, 루트 댓글과 대댓글을 나누고 초기 응답 크기를 제한하는 구조로 설계했다. + +자세한 후보 비교와 실행계획은 [ADR-001 댓글 아키텍처](docs/conception/sprint03/ADR-001-댓글아키텍처.md), [댓글 API 명세](docs/conception/sprint03/comment_api_spec.md), [기술부채 해결 기록](docs/conception/sprint03/기술부채%20해결_4.md)에 정리했다. + +### 1) 댓글 도메인 구조 + +댓글 엔티티는 `Comment` 하나로 둔다. 별도의 대댓글 `Reply` 엔티티를 만들지 않고, 하나의 `comment` 테이블에서 `parent_id`로 루트 댓글과 대댓글을 표현한다. + +- 루트 댓글: `parent_id = null` +- 대댓글: `parent_id = 루트 댓글 ID` +- 대댓글의 대댓글: 서버에서 최상위 루트 댓글 ID로 평탄화 + +무한 계층을 허용하지 않은 이유는 화면과 쿼리 비용 때문이다. 댓글 깊이가 3단계 이상으로 늘어나면 모바일 화면에서 들여쓰기와 접힘 처리가 복잡해지고, DB 조회도 재귀 구조나 별도 계층 테이블을 고민해야 한다. + +현재의 프로젝트에서는 댓글과 대댓글 2단계면 대화 흐름을 표현하기에 충분하다고 판단했다. + +### 2) 게시글과 댓글의 관계 + +게시글과 댓글은 `Post 1 : N Comment` 관계. 댓글은 반드시 하나의 게시글에 속하고, 게시글은 여러 댓글을 가질 수 있다. + +```text +Post + └─ Comment(parent_id = null) + └─ Comment(parent_id = root_comment_id) +``` + +`post.comment_count`는 매번 댓글 테이블을 `COUNT(*)` 하지 않기 위한 역정규화 필드. + +댓글 생성과 삭제 시 같은 트랜잭션에서 증감시켜 목록 화면에서 댓글 수를 빠르게 보여준다. + +이 선택은 읽기 성능을 얻는 대신, 댓글 저장/삭제 실패와 카운트 갱신 실패의 경계를 반드시 같은 트랜잭션 안에 묶어야 하는 트레이드오프가 있다. + +### 3) 댓글 상태와 유형 + +댓글 상태는 크게 정상 댓글과 Soft Delete 댓글로 나뉜다. + +- 정상 댓글: 목록과 상세 화면에 그대로 노출된다. +- 삭제된 댓글: DB row는 남기고 `is_deleted = true`, `deleted_at`을 기록한다. + +작성 유형은 세 가지다. + +- 로그인 일반 댓글: 회원 ID를 남기고 닉네임 표시 +- 로그인 익명 댓글: 회원 ID는 서버에 남기되 화면에서는 익명 표시 +- 비로그인 익명 댓글: 작성 IP와 익명 비밀번호 해시로 삭제 권한을 검증한다. + +삭제된 루트 댓글은 활성 대댓글 유무에 따라 다르게 처리한다. + +```text +삭제된 루트댓글 + 활성 대댓글 없음 -> 목록에서 숨김 +삭제된 루트댓글 + 활성 대댓글 있음 -> 루트 댓글은 "삭제된 댓글입니다."로 표시하고 활성 대댓글은 그대로 표시 +``` + +### 4) 댓글 조회 페이지네이션 방식 + +댓글 조회는 cursor pagination을 사용한다. + +```http +GET /api/v1/posts/{publicId}/comments?cursor={commentId}&size=20 +GET /api/v1/comments/{commentId}/replies?cursor={commentId}&size=20 +``` + +게시글 댓글 목록은 루트 댓글 20개를 먼저 조회하고, 각 루트 댓글의 대댓글은 5개까지만 같이 보여준다. 대댓글이 5개를 넘으면 사용자가 더보기를 눌렀을 때 대댓글 전용 API로 20개씩 추가 조회한다. + +정렬 기준은 루트 댓글과 대댓글 모두 같다. + +```sql +ORDER BY created_at ASC, comment_id ASC +``` + + +### 5) 조회 아키텍처 후보 비교 + +댓글 조회 구조는 같은 데이터셋과 같은 정책으로 후보 1, 2, 3을 Spike 실험한 뒤 결정했다. + +| 후보 | 방식 | 장점 | 단점 및 트레이드오프 | 판단 | +| :--- | :--- | :--- | :--- | :--- | +| 후보 1 | 전체 댓글을 한 번에 조회하고 메모리에서 트리 조립 | 쿼리 1회로 끝나 구현이 단순함 | 댓글 수가 늘수록 응답 크기와 메모리 사용량이 같이 증가함 | 기각 | +| 후보 2 | 루트 댓글 20개 조회 후 해당 루트의 대댓글 전체를 Batch 조회 | 루트 댓글 수를 제한하고 N+1을 피할 수 있음 | 특정 루트에 대댓글이 몰리면 초기 응답이 다시 커짐 | 기각 | +| 후보 3 | 루트 댓글 20개 + 루트별 대댓글 5개 프리뷰 + 대댓글 분리 API | 초기 응답 크기를 제한하고 핫스팟 댓글에도 대응 가능 | 대댓글 전용 API와 부모별 Top-N 쿼리가 필요함 | 채택 | + +실측 결과도 후보 3이 가장 안정적이었다. + +| 시나리오 | 후보 1 | 후보 2 | 후보 3 | +| :--- | :---: | :---: | :---: | +| 분산 데이터(Post 998) 응답 크기 | 210.44 KB | 39.87 KB | 22.03 KB | +| 핫스팟 데이터(Post 999) 응답 크기 | 205.84 KB | 103.70 KB | 5.55 KB | +| 핫스팟 데이터 읽은 행 수 | 1,000행 | 520행 | 25행 | + +후보 3은 API가 하나 늘어나지만 댓글 조회 시 대댓글 500개를 한 번에 읽어오는 상황을 피할 수 있었다. + +커뮤니티 서비스에서는 댓글이 많은 글도 빠르게 보여줘야 한다고 생각해서, 초기 응답 크기를 제한하는 방식을 생각했다. + +### 6) 선택한 방식의 기술부채 + +해당 방식을 선택하면서 다음 기술부채가 남았다. + +1. 부모별 Top-5 조회를 위한 MySQL 8.0 `ROW_NUMBER() OVER (PARTITION BY parent_id)`. +2. 게시글 댓글 조회 API 외에 대댓글 전용 페이징 API의 별도 관리. +3. `ORDER BY created_at ASC, comment_id ASC` 정렬을 안정적으로 처리하기 위한 복합 인덱스. +4. MySQL 실행계획에서 윈도우 함수 처리로 `Using temporary`, `Using filesort`가 일부 남을 수 있다. + + +### 7) 기술부채 개선 내용 + +부모별 Top-5 프리뷰는 MySQL 8.0 윈도우 함수로 구현했다. + +```sql +ROW_NUMBER() OVER ( + PARTITION BY c.parent_id + ORDER BY c.created_at ASC, c.comment_id ASC +) AS rn +``` + +대댓글 전용 API는 `GET /api/v1/comments/{commentId}/replies`로 분리했고, `size + 1`개를 조회해 `hasNext`를 판단한다. + +읽기 성능을 위해 복합 인덱스도 보강했다. + +```text +(post_id, parent_id, created_at, comment_id) +(parent_id, is_deleted, created_at, comment_id) +``` + +두 번째 인덱스에서 `is_deleted`는 `parent_id` 다음에 둔다. 특정 루트의 대댓글 범위를 먼저 좁힌 뒤, 활성 댓글만 필터링하고, 그 안에서 생성 시각과 PK 순서로 읽기 위한 구조다. + +```sql +WHERE parent_id = ? + AND is_deleted = false +ORDER BY created_at ASC, comment_id ASC +``` + +### 8) 개선 후 결과 + +| post_id | 데이터셋 | 전체 댓글 | 루트 댓글 | 대댓글 | +| :---: | :--- | :---: | :---: | :---: | +| 998 | 분산 데이터 | 1,000개 | 100개 | 900개 | +| 999 | 핫스팟 데이터 | 1,000개 | 500개 | 500개 | + +실행계획에서는 복합 인덱스가 사용되는 것을 확인했는데, `ROW_NUMBER()` 기반 Top-5 쿼리와 삭제 루트 노출 정책이 포함된 쿼리에서는 `Using temporary`, `Using filesort`가 남는다. + +목적은 DB 내부 정렬 비용을 완전히 없애는 것이 아니라, 초기 응답 크기와 서버 메모리 사용량을 제한하는 것. + + +### 9) 테스트 및 검증 결과 +개선 후의 테스트 결과 + +```bash +./gradlew.bat test --tests "*CommentReadTest*" +``` + +| 항목 | 결과 | +| :--- | :--- | +| 테스트 수 | 10 | +| 실패 | 0 | +| 에러 | 0 | +| 스킵 | 0 | + +댓글 도메인 전체 테스트는 42건 중 1건이 실패하고 1건이 스킵됐다. + +```bash +./gradlew.bat test --tests "*Comment*" +``` + +실패한 테스트는 후보 3 구조나 현재 조회 구현 문제가 아니다. + +기존 `CommentServiceTest` 일부가 "삭제된 루트 댓글은 활성 대댓글이 없어도 목록에 남는다"는 예전 정책을 기대하고 있어서 현재 정책과 충돌한다. + +현재 정책은 활성 대댓글이 없는 삭제 루트를 숨기는 방식이다. + --- -## 5. 프로젝트 물리 디렉토리 구조 (Project Structure) +## 6. 프로젝트 물리 디렉토리 구조 (Project Structure) ``` snowthing/ (프로젝트 최상위 루트) @@ -296,7 +470,7 @@ snowthing/ (프로젝트 최상위 루트) --- -## 6. 실행 및 테스트 (Build & Run) +## 7. 실행 및 테스트 (Build & Run) ### Backend (Spring Boot) ```bash diff --git a/docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md "b/docs/conception/sprint03/ADR-001-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" similarity index 100% rename from docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md rename to "docs/conception/sprint03/ADR-001-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" diff --git "a/docs/conception/sprint03/spike_\353\243\250\355\212\270\354\273\244\354\204\234_\353\214\200\353\214\223\352\270\200\354\240\204\354\262\264\353\260\260\354\271\230.md" "b/docs/conception/sprint03/spike_\353\243\250\355\212\270\354\273\244\354\204\234_\353\214\200\353\214\223\352\270\200\354\240\204\354\262\264\353\260\260\354\271\230.md" new file mode 100644 index 0000000..332bd43 --- /dev/null +++ "b/docs/conception/sprint03/spike_\353\243\250\355\212\270\354\273\244\354\204\234_\353\214\200\353\214\223\352\270\200\354\240\204\354\262\264\353\260\260\354\271\230.md" @@ -0,0 +1,89 @@ +# [Spike 결과 보고서] 후보 2: 루트 커서 페이징 + 대댓글 전체 Batch + +- **브랜치명**: `sprint03-spikeTest-02-Cursor/Batch` +- **측정 일시**: 2026-08-29 +- **작성자**: devikae (자동 생성) + +--- + +## 1. 구현 요약 (PoC Implementation) +- 루트 댓글을 `(created_at, comment_id)` 복합 커서로 20개 조회합니다. +- 선택된 루트 ID를 `parent_id IN (...)`에 전달해 모든 대댓글을 한 번에 조회합니다. +- 두 쿼리 모두 작성자 정보를 LEFT JOIN하고, DTO 컬렉션은 방어적으로 복사합니다. + +--- + +## 2. 측정 결과 데이터 매트릭스 + +| 시나리오 | 쿼리 수 (Count) | 읽은 Row 수 (Rows) | 응답 크기 (Bytes / KB) | 실행 시간 (Elapsed ms) | +| :--- | :---: | :---: | :---: | :---: | +| **[시나리오 A] 분산 1,000건** | 2회 | 200행 | 40830 B (39.87 KB) | 10.308 ms | +| **[시나리오 B] 집중 핫스팟 1,000건** | 2회 | 520행 | 106186 B (103.70 KB) | 14.988 ms | + +--- + +## 3. 실행된 실제 SQL 및 MySQL EXPLAIN + +### 1) [시나리오 A] 분산 1,000건 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, c.is_deleted, c.created_at, m.nickname, c.is_anonymous, c.writer_ip FROM comment c LEFT JOIN member m ON m.member_id = c.member_id WHERE c.post_id = 998 AND c.parent_id IS NULL ORDER BY c.created_at ASC, c.comment_id ASC LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | ref | fk_comment_parent | 603 | Using index condition; Using where; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +#### [Query 2] +```sql +SELECT c.comment_id, c.parent_id, c.content, c.is_deleted, c.created_at, m.nickname, c.is_anonymous, c.writer_ip FROM comment c LEFT JOIN member m ON m.member_id = c.member_id WHERE c.parent_id IN (4004, 4014, 4024, 4034, 4044, 4054, 4064, 4074, 4084, 4094, 4104, 4114, 4124, 4134, 4144, 4154, 4164, 4174, 4184, 4194) ORDER BY c.parent_id ASC, c.created_at ASC, c.comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | fk_comment_parent | 180 | Using index condition; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +### 2) [시나리오 B] 집중 핫스팟 1,000건 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, c.is_deleted, c.created_at, m.nickname, c.is_anonymous, c.writer_ip FROM comment c LEFT JOIN member m ON m.member_id = c.member_id WHERE c.post_id = 999 AND c.parent_id IS NULL ORDER BY c.created_at ASC, c.comment_id ASC LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | ref | fk_comment_parent | 603 | Using index condition; Using where; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +#### [Query 2] +```sql +SELECT c.comment_id, c.parent_id, c.content, c.is_deleted, c.created_at, m.nickname, c.is_anonymous, c.writer_ip FROM comment c LEFT JOIN member m ON m.member_id = c.member_id WHERE c.parent_id IN (5004, 5005, 5006, 5007, 5008, 5009, 5010, 5011, 5012, 5013, 5014, 5015, 5016, 5017, 5018, 5019, 5020, 5021, 5022, 5023) ORDER BY c.parent_id ASC, c.created_at ASC, c.comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | fk_comment_parent | 519 | Using index condition; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +--- + +## 4. 발견된 결함 및 한계점 (Issues & Bottlenecks) +- 응답 쿼리 수는 2회로 고정되지만 선택된 루트에 대댓글이 집중되면 응답 행과 페이로드는 제한되지 않습니다. +- 현재 스키마에는 `(post_id, parent_id, created_at, comment_id)` 복합 인덱스가 없어 EXPLAIN상 추가 정렬이나 넓은 스캔이 발생할 수 있습니다. +- 실행 시간은 로컬 단일 실행값이므로 반복 측정의 평균·백분위 지표가 아닙니다. + +--- + +## 5. 최종 평가 및 소견 +후보 2는 루트 수를 20개로 제한하면서 N+1 없이 2회 조회를 유지합니다. 다만 핫스팟 루트가 페이지에 포함되면 대댓글 전체가 반환되어 페이로드 상한을 보장하지 못하므로, 운영안에서는 대댓글 별도 커서 또는 프리뷰 제한을 함께 검토해야 합니다. diff --git "a/docs/conception/sprint03/spike_\353\251\224\353\252\250\353\246\254\354\240\204\354\262\264\355\212\270\353\246\254\354\241\260\353\246\275.md" "b/docs/conception/sprint03/spike_\353\251\224\353\252\250\353\246\254\354\240\204\354\262\264\355\212\270\353\246\254\354\241\260\353\246\275.md" new file mode 100644 index 0000000..14fa9c4 --- /dev/null +++ "b/docs/conception/sprint03/spike_\353\251\224\353\252\250\353\246\254\354\240\204\354\262\264\355\212\270\353\246\254\354\241\260\353\246\275.md" @@ -0,0 +1,73 @@ +# [Spike 결과 보고서] 후보 1: 메모리 전체 트리 조립 + +- **브랜치명**: `devikae/sprint03-spikeTest-01-메모리-조립` +- **측정 일시**: 2026-08-29 +- **작성자**: devikae (자동 생성) + +--- + +## 1. 구현 요약 (PoC Implementation) +- `findByPostIdWithMember` 한 번으로 게시글별 댓글 1,000건과 작성자를 조회 +- `LinkedHashMap`에서 루트/대댓글을 연결한 뒤 불변 2-Depth DTO로 변환 +- Hibernate Statistics로 각 시나리오의 JPQL 실행 횟수가 1회인지 검증 + +--- + +## 2. 측정 결과 데이터 매트릭스 + +| 시나리오 | 쿼리 수 (Count) | 읽은 Row 수 (Rows) | 응답 크기 (Bytes / KB) | 실행 시간 (Elapsed ms) | +| :--- | :---: | :---: | :---: | :---: | +| **[시나리오 A] 분산 1,000건** | 1회 | 1000행 | 215490 B (210.44 KB) | 83.468 ms | +| **[시나리오 B] 집중 핫스팟 1,000건** | 1회 | 1000행 | 210784 B (205.84 KB) | 35.401 ms | + +--- + +## 3. 실행된 실제 SQL 및 MySQL EXPLAIN + +### 1) [시나리오 A] 분산 1,000건 + +#### [Query 1] +```sql +SELECT c.*, m.* +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = 998 +ORDER BY c.created_at ASC, c.comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | ref | fk_comment_post | 1000 | Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | + +### 2) [시나리오 B] 집중 핫스팟 1,000건 + +#### [Query 1] +```sql +SELECT c.*, m.* +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = 999 +ORDER BY c.created_at ASC, c.comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | ref | fk_comment_post | 1000 | Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | + +--- + +## 4. 발견된 결함 및 한계점 (Issues & Bottlenecks) +- 댓글 총량에 비례해 엔티티와 DTO가 동시에 메모리에 존재합니다. +- 핫스팟 시나리오는 한 루트 DTO가 대댓글 500개를 한 응답에 포함합니다. +- 전체 응답 방식이라 루트 페이징이나 대댓글 더보기로 페이로드 상한을 통제할 수 없습니다. + +--- + +## 5. 최종 평가 및 소견 +단일 JPQL로 N+1 없이 2-Depth 트리를 조립할 수 있다는 가설은 확인했습니다. 다만 댓글 증가량이 DB 조회 행, JVM 메모리, 직렬화 크기에 그대로 반영되므로 운영 기본안으로 채택하기 전 후보 2·3과 응답 상한 및 핫스팟 안정성을 비교해야 합니다. diff --git "a/docs/conception/sprint03/spike_\355\225\230\354\235\264\353\270\214\353\246\254\353\223\234\355\224\204\353\246\254\353\267\260_\353\266\204\353\246\254API.md" "b/docs/conception/sprint03/spike_\355\225\230\354\235\264\353\270\214\353\246\254\353\223\234\355\224\204\353\246\254\353\267\260_\353\266\204\353\246\254API.md" new file mode 100644 index 0000000..edca094 --- /dev/null +++ "b/docs/conception/sprint03/spike_\355\225\230\354\235\264\353\270\214\353\246\254\353\223\234\355\224\204\353\246\254\353\267\260_\353\266\204\353\246\254API.md" @@ -0,0 +1,167 @@ +# [Spike 결과 보고서] 후보 3: 루트 Batch + 대댓글 5개 프리뷰 & 분리 API + +- **브랜치명**: `devikae/sprint03-spikeTest-03-Batch/API` +- **측정 일시**: 2026-08-29 +- **작성자**: devikae (자동 생성) + +--- + +## 1. 구현 요약 (PoC Implementation) +- 루트 댓글 20개 조회 후 MySQL 8 `ROW_NUMBER() OVER (PARTITION BY parent_id)`로 각 루트당 대댓글 5개만 일괄 조회합니다. +- 프리뷰는 총 2회 쿼리이며, 대댓글 더보기는 `comment_id` 커서와 `LIMIT 20`을 사용하는 분리 조회입니다. +- Spike 코드는 `src/test`에 격리했고 응답 컬렉션은 `List.copyOf()`로 방어적 복사했습니다. + +--- + +## 2. 측정 결과 데이터 매트릭스 + +| 시나리오 | 쿼리 수 (Count) | 읽은 Row 수 (Rows) | 응답 크기 (Bytes / KB) | 실행 시간 (Elapsed ms) | +| :--- | :---: | :---: | :---: | :---: | +| **[분산] Post 998** | 2회 | 120행 | 22560 B (22.03 KB) | 14.594 ms | +| **[집중] Post 999** | 2회 | 25행 | 5685 B (5.55 KB) | 5.603 ms | +| **[더보기 호출 시] Post 999 핫스팟 루트** | 1회 | 20행 | 3582 B (3.50 KB) | 2.357 ms | + +--- + +## 3. 실행된 실제 SQL 및 MySQL EXPLAIN + +### 1) [분산] Post 998 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, m.nickname, c.created_at, 0 AS reply_count +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = 998 + AND c.parent_id IS NULL + AND c.is_deleted = FALSE + AND c.comment_id > 0 +ORDER BY c.comment_id ASC +LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | PRIMARY | 1001 | Using where | +| m | eq_ref | PRIMARY | 1 | null | + +#### [Query 2] +```sql +WITH ranked_replies AS ( + SELECT c.comment_id, + c.parent_id, + c.content, + m.nickname, + c.created_at, + ROW_NUMBER() OVER ( + PARTITION BY c.parent_id + ORDER BY c.comment_id ASC + ) AS reply_rank, + COUNT(*) OVER (PARTITION BY c.parent_id) AS reply_count + FROM comment c + LEFT JOIN member m ON m.member_id = c.member_id + WHERE c.parent_id IN (4004, 4014, 4024, 4034, 4044, 4054, 4064, 4074, 4084, 4094, 4104, 4114, 4124, 4134, 4144, 4154, 4164, 4174, 4184, 4194) + AND c.is_deleted = FALSE +) +SELECT comment_id, parent_id, content, nickname, created_at, reply_count +FROM ranked_replies +WHERE reply_rank <= 5 +ORDER BY parent_id ASC, comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| | ALL | null | 18 | Using where; Using filesort | +| c | range | fk_comment_parent | 180 | Using index condition; Using where; Using temporary; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +### 2) [집중] Post 999 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, m.nickname, c.created_at, 0 AS reply_count +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = 999 + AND c.parent_id IS NULL + AND c.is_deleted = FALSE + AND c.comment_id > 0 +ORDER BY c.comment_id ASC +LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | PRIMARY | 1001 | Using where | +| m | eq_ref | PRIMARY | 1 | null | + +#### [Query 2] +```sql +WITH ranked_replies AS ( + SELECT c.comment_id, + c.parent_id, + c.content, + m.nickname, + c.created_at, + ROW_NUMBER() OVER ( + PARTITION BY c.parent_id + ORDER BY c.comment_id ASC + ) AS reply_rank, + COUNT(*) OVER (PARTITION BY c.parent_id) AS reply_count + FROM comment c + LEFT JOIN member m ON m.member_id = c.member_id + WHERE c.parent_id IN (5004, 5005, 5006, 5007, 5008, 5009, 5010, 5011, 5012, 5013, 5014, 5015, 5016, 5017, 5018, 5019, 5020, 5021, 5022, 5023) + AND c.is_deleted = FALSE +) +SELECT comment_id, parent_id, content, nickname, created_at, reply_count +FROM ranked_replies +WHERE reply_rank <= 5 +ORDER BY parent_id ASC, comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| | ALL | null | 51 | Using where; Using filesort | +| c | range | fk_comment_parent | 519 | Using index condition; Using where; Using temporary; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +### 3) [더보기 호출 시] Post 999 핫스팟 루트 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, m.nickname, c.created_at, 0 AS reply_count +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.parent_id = 5004 + AND c.is_deleted = FALSE + AND c.comment_id > 0 +ORDER BY c.comment_id ASC +LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | PRIMARY | 1001 | Using where | +| m | eq_ref | PRIMARY | 1 | null | + +--- + +## 4. 발견된 결함 및 한계점 (Issues & Bottlenecks) +- 부모별 Top-N을 위해 윈도 함수 정렬과 임시 테이블 처리가 발생할 수 있습니다. +- 현재 인덱스는 `post_id`, `parent_id` 단일 인덱스뿐이므로 운영 반영 시 `(post_id, parent_id, comment_id)`와 `(parent_id, comment_id)` 복합 인덱스를 비교 검증해야 합니다. +- `LIMIT 20`만 사용하는 PoC이므로 정확한 `hasNext` 판정이 필요하면 21건 조회 또는 별도 존재 확인의 비용을 선택해야 합니다. + +--- + +## 5. 최종 평가 및 소견 +응답 크기는 루트 20개와 부모별 프리뷰 5개로 상한이 통제됩니다. 핫스팟의 나머지 대댓글은 분리 API로 넘겨 초기 응답과 메모리 사용량을 제한할 수 있습니다. diff --git "a/docs/conception/sprint03/\352\270\260\354\210\240\353\266\200\354\261\204 \355\225\264\352\262\260_4.md" "b/docs/conception/sprint03/\352\270\260\354\210\240\353\266\200\354\261\204 \355\225\264\352\262\260_4.md" new file mode 100644 index 0000000..e563a5c --- /dev/null +++ "b/docs/conception/sprint03/\352\270\260\354\210\240\353\266\200\354\261\204 \355\225\264\352\262\260_4.md" @@ -0,0 +1,401 @@ +# 댓글 조회 기술부채 해결 기록 4 + +- 작성일: 2026-09-03 +- 대상 기능: 게시글 댓글 목록 조회, 루트별 대댓글 5개 프리뷰, 대댓글 분리 페이징 조회 +- 기준 문서: + - `spike_experiment_guide.md` + - `spike_하이브리드프리뷰_분리API.md` +- 기준 코드: + - `CommentController` + - `CommentService` + - `CommentRepositoryImpl` + - `Comment` + +--- + +## 1. 결론 + +현재 구현에 구조적인 문제는 없습니다. + +후보 1, 2, 3 Spike는 같은 정책과 같은 데이터셋에서 비교됐고, 그 결과 후보 3인 "루트 댓글 20개 + 루트별 대댓글 5개 프리뷰 + 대댓글 분리 API" 구조를 선택했습니다. 이 선택은 여전히 유효합니다. + +이 문서는 후보 3을 다시 무효화하거나 재비교하는 문서가 아닙니다. 후보 3을 운영 코드로 옮긴 뒤, 남아 있던 기술부채가 어떻게 해결됐는지 확인한 기록입니다. + +정리하면 다음과 같습니다. + +| 항목 | 상태 | 근거 | +| :--- | :--- | :--- | +| 후보 1, 2, 3 비교 실험 | 문제 없음 | 같은 정책과 같은 데이터셋으로 비교 완료 | +| 후보 3 구조 채택 | 문제 없음 | 초기 응답 크기와 메모리 사용량을 제한하는 구조 | +| 부모별 Top-5 프리뷰 쿼리 | 해결 | MySQL 8.0 `ROW_NUMBER()` 기반 구현 완료 | +| 대댓글 분리 API | 해결 | `GET /api/v1/comments/{commentId}/replies` 구현 완료 | +| 읽기 성능용 복합 인덱스 | 보강됨 | `parent_id, is_deleted, created_at, comment_id` 인덱스 확인 | +| 삭제 루트 노출 정책 | 문제 없음 | 활성 대댓글 유무 기준으로 동작 | +| 기존 테스트 일부 실패 | 구현 문제가 아니라 테스트 기대값 문제 | 예전 정책 기준 테스트가 현재 정책과 충돌 | + +--- + +## 2. 후보 3 실험과 현재 구현의 관계 + +후보 3 Spike의 핵심은 아래 구조였습니다. + +```text +게시글 댓글 목록 조회 +-> 루트 댓글 20개 조회 +-> 각 루트별 대댓글 5개까지만 프리뷰 +-> 5개를 초과한 대댓글은 별도 API로 페이징 조회 +``` + +현재 구현도 이 구조를 그대로 사용합니다. + +```text +GET /api/v1/posts/{publicId}/comments?cursor={commentId}&size=20 +GET /api/v1/comments/{commentId}/replies?cursor={commentId}&size=20 +``` + +따라서 후보 3 실험이 잘못된 것이 아닙니다. 오히려 후보 3에서 확인한 장점을 운영 코드로 옮긴 상태입니다. + +후속으로 바뀐 부분은 "후보 재비교"가 아니라 "운영 구현 보강"입니다. + +1. `ROW_NUMBER()` 별칭을 `rn`으로 바꿔 MySQL 함수명 충돌을 피했습니다. +2. 커서 페이징에서 `created_at ASC, comment_id ASC` 기준을 사용해 순서를 안정화했습니다. +3. 읽기 성능을 위해 `is_deleted`를 포함한 복합 인덱스를 추가 검토하고 실제 DB에 반영했습니다. +4. `size + 1`개를 조회해 `hasNext`를 판정하도록 했습니다. + +--- + +## 3. 삭제 루트 댓글 정책 + +현재 정책은 아래 기준입니다. + +```text +삭제된 루트댓글 + 활성 대댓글 없음 -> 목록에서 숨김 +삭제된 루트댓글 + 활성 대댓글 있음 -> 루트 댓글은 placeholder, 활성 대댓글은 그대로 표시 +``` + +이 정책은 댓글 트리에서 고아 노드가 생기는 문제를 막기 위한 선택입니다. + +삭제된 루트 댓글에 활성 대댓글이 없다면 사용자가 볼 내용이 없습니다. 이 경우 목록에서 숨기는 것이 자연스럽습니다. + +반대로 삭제된 루트 댓글 아래에 활성 대댓글이 남아 있다면, 루트를 완전히 숨기면 하위 대댓글의 문맥이 사라집니다. 그래서 루트는 `"삭제된 댓글입니다."` placeholder로 남기고, 활성 대댓글은 그대로 보여줍니다. + +현재 루트 댓글 조회 SQL도 이 정책을 반영합니다. + +```sql +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 + ) + ) +``` + +--- + +## 4. 인덱스 기준 정리 + +### 4.1 기존 후보 3에서 남은 부채 + +후보 3 결과에서는 아래 부채가 남았습니다. + +```text +현재 인덱스는 post_id, parent_id 단일 인덱스 중심이므로 +(post_id, parent_id, comment_id)와 (parent_id, comment_id) 복합 인덱스를 비교 검증해야 한다. +``` + +이 말은 후보 3이 틀렸다는 뜻이 아닙니다. 후보 3 구조는 채택하되, 운영 성능을 위해 인덱스를 보강해야 한다는 의미였습니다. + +### 4.2 현재 코드와 DDL 기준 인덱스 + +현재 엔티티와 DDL에는 아래 인덱스가 선언되어 있습니다. + +```text +idx_comment_post_parent_created(post_id, parent_id, created_at, comment_id) +idx_comment_parent_created(parent_id, created_at, comment_id) +``` + +`comment_id` 단독 정렬보다 `created_at, comment_id` 정렬을 명시하면서, 인덱스도 그 순서에 맞춰 보강된 형태입니다. + +### 4.3 실제 로컬 MySQL 기준 인덱스 + +로컬 MySQL 8.0.46 컨테이너에서 확인한 실제 인덱스는 아래와 같습니다. + +```text +PRIMARY(comment_id) +fk_comment_member(member_id) +idx_comment_post_parent_created(post_id, parent_id, created_at, comment_id) +idx_comment_parent_deleted_created(parent_id, is_deleted, created_at, comment_id) +``` + +여기서 핵심은 `idx_comment_parent_deleted_created`입니다. + +```text +parent_id -> is_deleted -> created_at -> comment_id +``` + +`is_deleted`가 인덱스 전체의 첫 번째 컬럼은 아닙니다. 먼저 `parent_id`로 특정 루트 댓글의 대댓글 범위를 좁히고, 그 다음 `is_deleted`로 활성 대댓글만 좁힌 뒤, `created_at`, `comment_id` 순서로 읽기 위한 구조입니다. + +읽기 성능을 생각하면 이 순서는 타당합니다. + +```sql +WHERE parent_id = ? + AND is_deleted = false +ORDER BY created_at ASC, comment_id ASC +``` + +위 형태에서는 `parent_id`와 `is_deleted`가 동등 조건이고, 그 뒤의 `created_at`, `comment_id`가 정렬 기준입니다. 그래서 활성 대댓글 조회에는 `(parent_id, is_deleted, created_at, comment_id)`가 더 잘 맞습니다. + +--- + +## 5. 실제 구현 SQL + +### 5.1 루트 댓글 조회 + +```sql +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, + (SELECT COUNT(*) + FROM comment active_reply + WHERE active_reply.parent_id = c.comment_id + AND active_reply.is_deleted = false) 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 + ) + ) +ORDER BY c.created_at ASC, c.comment_id ASC +LIMIT :fetchSize +``` + +### 5.2 루트별 대댓글 Top-5 프리뷰 + +```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 +``` + +### 5.3 대댓글 분리 페이징 조회 + +```sql +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 +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.parent_id = :rootCommentId + AND ( + c.created_at > :cursorCreatedAt + OR (c.created_at = :cursorCreatedAt AND c.comment_id > :cursorId) + ) +ORDER BY c.created_at ASC, c.comment_id ASC +LIMIT :fetchSize +``` + +--- + +## 6. 테스트 결과 + +### 6.1 댓글 조회 전용 테스트 + +실행 명령: + +```powershell +./gradlew.bat test --tests "*CommentReadTest*" +``` + +결과: + +| 항목 | 결과 | +| :--- | :--- | +| 테스트 수 | 10 | +| 실패 | 0 | +| 에러 | 0 | +| 스킵 | 0 | +| 결과 | 통과 | + +검증된 항목은 아래와 같습니다. + +1. 루트 댓글 커서 페이징 +2. 같은 생성 시각에서 `commentId` 보조 정렬 +3. 루트별 대댓글 5개 프리뷰 +4. 대댓글 분리 API 조회 +5. 삭제 루트 placeholder 및 은닉 정책 +6. 응답 컬렉션 불변성 +7. 잘못된 게시글, 잘못된 커서, 대댓글 ID를 루트로 쓰는 요청의 예외 처리 + +### 6.2 댓글 도메인 전체 테스트 + +실행 명령: + +```powershell +./gradlew.bat test --tests "*Comment*" +``` + +결과: + +| 항목 | 결과 | +| :--- | :--- | +| 테스트 수 | 42 | +| 실패 | 1 | +| 에러 | 0 | +| 스킵 | 1 | +| 결과 | 실패 | + +실패한 테스트는 현재 구현 문제가 아니라 예전 정책 기대값과 현재 정책의 충돌입니다. + +기존 실패 테스트는 "삭제된 루트 댓글은 활성 대댓글이 없어도 목록에 남는다"는 기대를 갖고 있습니다. + +현재 정책은 아래 기준입니다. + +```text +삭제된 루트댓글 + 활성 대댓글 없음 -> 목록에서 숨김 +삭제된 루트댓글 + 활성 대댓글 있음 -> 루트 댓글은 placeholder, 활성 대댓글은 그대로 표시 +``` + +따라서 후보 3 구조나 현재 조회 구현의 실패로 보면 안 됩니다. 기존 테스트를 현재 정책 기준으로 정리해야 하는 테스트 부채입니다. + +--- + +## 7. 실제 MySQL 데이터와 실행계획 + +실행 환경: + +| 항목 | 값 | +| :--- | :--- | +| DB | MySQL 8.0.46 | +| 컨테이너 | `snowthing-mysql` | +| DB 이름 | `snowthing` | + +Spike 데이터: + +| post_id | public_id | total | roots | replies | +| :---: | :--- | :---: | :---: | :---: | +| 998 | `post-spike-distributed-998` | 1000 | 100 | 900 | +| 999 | `post-spike-hotspot-999` | 1000 | 500 | 500 | + +### 7.1 루트 댓글 조회 EXPLAIN + +Post 998 기준 결과: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :---: | :--- | +| c | ref | `idx_comment_post_parent_created` | 100 | Using index condition; Using where; Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | +| active_child | ref | `idx_comment_parent_deleted_created` | 19 | Using index | +| all_reply | ref | `idx_comment_parent_deleted_created` | 19 | Using index | +| active_reply | ref | `idx_comment_parent_deleted_created` | 19 | Using index | + +루트 조회는 `idx_comment_post_parent_created`를 사용합니다. 삭제 루트 placeholder 정책 때문에 `OR EXISTS`와 집계 서브쿼리가 들어가므로 `Using temporary`, `Using filesort`가 남습니다. 이 결과는 구조 오류가 아니라 현재 정책을 SQL 한 번에 반영하면서 생기는 DB 내부 처리 비용입니다. + +### 7.2 루트별 Top-5 프리뷰 EXPLAIN + +분산 데이터 Post 998 기준 결과: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :---: | :--- | +| `` | ALL | null | 540 | Using where; Using filesort | +| c | range | `idx_comment_parent_deleted_created` | 180 | Using index condition; Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | + +핫스팟 데이터 Post 999 기준 결과: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :---: | :--- | +| `` | ALL | null | 1557 | Using where; Using filesort | +| c | ALL | null | 2005 | Using where; Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | + +분산 데이터에서는 `idx_comment_parent_deleted_created`가 선택됐고, 핫스팟 데이터에서는 옵티마이저가 전체 스캔을 선택했습니다. + +이는 후보 3이 잘못됐다는 의미가 아닙니다. 2,005행 수준의 작은 로컬 데이터에서는 MySQL 옵티마이저가 인덱스 range보다 전체 스캔을 더 싸게 판단할 수 있습니다. 중요한 점은 애플리케이션 응답 크기는 후보 3 구조로 제한된다는 것입니다. + +### 7.3 대댓글 더보기 EXPLAIN ANALYZE + +핫스팟 루트 `parent_id = 11006` 기준 결과: + +```text +-> Limit: 21 row(s) (actual time=10.2..10.2 rows=21 loops=1) + -> Sort: c.created_at, c.comment_id, limit input to 21 row(s) per chunk + (actual time=10.2..10.2 rows=21 loops=1) + -> Stream results (actual time=9..9.99 rows=500 loops=1) + -> Left hash join (m.member_id = c.member_id) + (actual time=8.98..9.79 rows=500 loops=1) + -> Index lookup on c using idx_comment_parent_deleted_created + (parent_id=11006) + (actual time=8.84..9.6 rows=500 loops=1) + -> Hash + -> Table scan on m + (actual time=0.121..0.122 rows=3 loops=1) +``` + +대댓글 더보기는 `idx_comment_parent_deleted_created`를 사용합니다. 핫스팟 루트에 대댓글 500개가 있으므로 DB 내부에서는 500행을 읽고 정렬한 뒤 21개를 반환합니다. + +이 비용은 현재 데이터 규모에서는 감당 가능한 수준입니다. 후보 3 구조 덕분에 네트워크 응답과 애플리케이션 메모리는 계속 제한됩니다. + +--- + +## 8. 후보 3에서 해결된 부채 + +### 8.1 부모별 Top-5 쿼리 작성 부채 + +해결됐습니다. + +`CommentRepositoryImpl.findTopReplyPreviews()`에서 MySQL 8.0 `ROW_NUMBER()` 기반 쿼리로 운영 코드에 반영했습니다. `row_number` 별칭 충돌도 `rn`으로 정리했습니다. + +### 8.2 대댓글 전용 API 관리 부채 + +해결됐습니다. + +`CommentController`와 `CommentService`에 대댓글 분리 페이징 조회가 들어갔습니다. API가 하나 늘어난 대가는 있지만, 핫스팟 대댓글의 초기 응답 폭증을 막기 위한 의도된 설계 비용입니다. + +### 8.3 복합 인덱스 검토 부채 + +해결됐습니다. + +후보 3 채택 이후 읽기 성능을 고려해 복합 인덱스를 보강했습니다. 특히 실제 DB에는 활성 대댓글 조회를 고려한 `idx_comment_parent_deleted_created(parent_id, is_deleted, created_at, comment_id)`가 확인됐습니다. + +--- + +## 9. 남은 관찰 포인트 + +현재 구현은 문제 없는 상태로 봐도 됩니다. 다만 아래 항목은 운영 관찰 포인트로 남깁니다. + +1. `ROW_NUMBER()` 기반 Top-5 프리뷰 쿼리에서 `Using temporary`, `Using filesort`가 발생합니다. +2. 핫스팟 루트의 대댓글이 많으면 대댓글 더보기에서 해당 루트의 활성 대댓글 후보를 읽고 정렬하는 비용이 생깁니다. +3. 실제 DB에는 `idx_comment_parent_deleted_created`가 있지만 엔티티/DDL에는 `idx_comment_parent_created`가 남아 있으므로, 공식 스키마 기준은 한 번 맞추는 것이 좋습니다. +4. 기존 `CommentServiceTest` 중 일부는 현재 삭제 루트 정책과 기대값이 달라 실패하므로, 구현 문제가 아니라 테스트 정리 대상으로 봐야 합니다. + +이 관찰 포인트들은 후보 3 선택을 뒤집을 정도의 문제는 아닙니다. 지금 단계에서는 구조를 바꾸기보다, 인덱스 정의를 공식 스키마에 맞추고 오래된 테스트 기대값을 현재 정책으로 정리하는 것이 맞습니다. diff --git a/docs/project/work.md b/docs/project/work.md index de80a0f..a455504 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,16 @@ +- **Sprint 03 댓글/대댓글 인라인 삭제 UI 및 비밀번호 플로팅 팝오버 위젯 구현 (2026-09-03)**: + 1. **작업명**: 댓글/대댓글 인라인 미니 `✕` 삭제 버튼 및 시간 아래 플로팅 드롭다운 UI 구현 (브라우저 다이얼로그 전면 퇴출) + 2. **현재 상태**: 완료 + 3. **완료된 항목**: + - 브라우저 기본 `prompt()`, `confirm()`, `alert()` 호출 코드 100% 제거. + - 댓글 및 대댓글 상단 헤더의 작성 시간(`MM.dd HH:mm:ss`) 우측에 미니 사각 `✕` 삭제 버튼 배치. + - `✕` 클릭 시 부모 헤더나 주변 텍스트를 밀어내지 않고 시간 바로 아래에 모달처럼 떠 있는 플로팅 팝오버(`absolute right-0 top-full mt-1.5 z-50 shadow-xl`) 위젯 구현. + - 외부 클릭 시 자동으로 닫히는 고정 투명 백드롭(`fixed inset-0 z-40`) 및 `ESC` 키보드 닫기, `Enter` 제출 지원. + - 비회원 익명 댓글은 비밀번호 인풋창 폼 제공, 로그인 회원 본인 및 최고 관리자는 `삭제할까요?` 즉시 확인 폼 제공. + - 하단 액션 바의 중복 텍스트 `삭제` 버튼 제거 (상단 `✕` 아이콘으로 일원화). + 4. **검증 결과**: + - `npm run build` Next.js 16.2.12 Turbopack 컴파일 100% 통과 (Compiled successfully in 1733ms, 0 errors). + - **Sprint 03 댓글 삭제(DELETE /api/v1/comments/{commentId}) 및 4대 권한 매트릭스 전담 개발 완결 (2026-09-01)**: 1. **작업명**: 댓글 삭제(Soft Delete & 권한 매트릭스) 기능 보강 및 단위/통합 테스트 2. **현재 상태**: 완료 @@ -783,3 +796,56 @@ ### 검증 - 변경 파일 대상 ESLint 오류 0건. 기존 게시글 이미지 `` 최적화 경고 1건만 확인. - `npm run build` 성공 및 TypeScript 오류 0건 확인. + +## README Mermaid 렌더링 오류 수정 (2026-09-03) + +- 상태: DONE +- 작업 내용: GitHub README의 Mermaid `sequenceDiagram`에서 `Set-Cookie: JSESSIONID=...; Path=/; HttpOnly; SameSite=Lax`처럼 실제 HTTP 헤더 문법을 그대로 넣어 파서가 실패하던 줄을 자연어 메시지로 변경. +- 수정 파일: `README.md` +- 완료 범위: + 1. 로그인 성공 응답 메시지를 `신규 세션 쿠키 발급 (JSESSIONID, HttpOnly, SameSite=Lax)`로 변경. + 2. 로그아웃 응답 메시지를 `JSESSIONID 쿠키 만료 응답 (Max-Age=0)`로 변경. +- 이슈/주의: Mermaid 다이어그램 안에서는 `:`, `;`, `=`가 많은 실제 헤더 문자열을 그대로 쓰면 GitHub 렌더러와 충돌할 수 있으므로, 다이어그램에는 행위 중심 문장을 쓰고 실제 헤더 예시는 본문 코드블록에 분리하는 편이 안전함. + +## 댓글 조회 기술부채 해결 문서 작성 (2026-09-03) + +- 상태: DONE +- 작업 내용: 같은 조건에서 수행된 후보 1/2/3 Spike 중 채택된 후보 3 구조가 현재 운영 구현에 어떻게 반영됐는지, 이후 읽기 성능 보강으로 추가된 복합 인덱스와 MySQL 실행계획을 `docs/study/sprint03/comment/test/기술부채 해결_4.md`에 정리. +- 완료 범위: + 1. `spike_experiment_guide.md`, `spike_하이브리드프리뷰_분리API.md`, 현재 `CommentRepositoryImpl`, `CommentService`, `CommentController`, `Comment` 인덱스 정의 대조. + 2. 로컬 MySQL 8.0.46 Docker 컨테이너의 Spike 데이터 확인: Post 998/999 각각 댓글 1,000건 유지. + 3. 실제 MySQL `SHOW INDEX`, `EXPLAIN`, `EXPLAIN ANALYZE` 결과를 문서에 반영. + 4. `./gradlew.bat test --tests "*CommentReadTest*"` 실행 결과 10건 통과 확인. + 5. `./gradlew.bat test --tests "*Comment*"` 실행 결과 42건 중 1건 실패, 1건 스킵 확인. 실패 원인은 후보 3 구조 문제가 아니라 기존 `CommentServiceTest` 일부가 현재 삭제 루트 정책과 다른 기대값을 가진 테스트 정리 대상으로 기록. +- 이슈/주의: + 1. 후보 1/2/3 비교 실험은 같은 정책과 같은 데이터셋에서 수행됐으므로 후보 3 선택 근거는 유효함. + 2. 후보 3 채택 이후 읽기 성능 보강으로 실제 DB에는 `idx_comment_parent_deleted_created(parent_id, is_deleted, created_at, comment_id)`가 확인됨. + 3. 윈도우 함수와 삭제 정책 쿼리에서 `Using temporary`, `Using filesort`가 남지만, 현재 규모에서는 구조 변경 대상이 아니라 운영 관찰 포인트로 기록. + 4. 기존 `CommentServiceTest.getCommentsByPost_deletedParentDisplay()`는 현재 정책에 맞게 갱신 필요. + +## README 댓글 도메인 아키텍처 섹션 반영 (2026-09-03) + +- 상태: DONE +- 작업 내용: README의 게시판 설명 아래에 `댓글(Comment) 도메인 설계 & 기술적 의사결정` 섹션을 독립 추가하고, 후보 3 Spike 선택 근거와 기술부채 개선 내용을 공식 conception 문서 기준으로 요약. +- 완료 범위: + 1. `핵심 아키텍처 고민 및 기술적 의사결정` 제목에서 `핵심` 표현 제거. + 2. `게시판(Post) 도메인 설계 & 핵심 기술적 의사결정` 제목에서 `핵심` 표현 제거. + 3. `CSRF` 본문과 구분선 사이에 빈 줄을 추가해 Markdown 렌더링이 다음 섹션으로 번지지 않도록 정리. + 4. 댓글 도메인 구조, 게시글-댓글 관계, 댓글 상태/유형, 삭제 루트 정책, 커서 페이지네이션, 후보 1/2/3 비교, 기술부채와 개선 결과, 테스트 결과를 README에 추가. + 5. 상세 근거 링크는 gitignore 대상인 `docs/study`가 아니라 `docs/conception/sprint03/`의 ADR, API 명세, 기술부채 해결 문서로 연결. +- 이슈/주의: + 1. README에는 전체 SQL과 EXPLAIN을 모두 싣지 않고 프로젝트 소개에 필요한 수준으로 요약. + 2. 상세 실행계획과 테스트 결과는 `docs/conception/sprint03/기술부채 해결_4.md`를 기준 문서로 사용. + +## Sprint 03 Spike 결과 문서 파일명 정리 (2026-09-03) + +- 상태: DONE +- 작업 내용: 후보 번호 중심 파일명을 실제 기술 방식이 드러나는 파일명으로 변경. +- 변경 파일명: + 1. `spike_result_candidate_1.md` -> `spike_메모리전체트리조립.md` + 2. `spike_result_candidate_2.md` -> `spike_루트커서_대댓글전체배치.md` + 3. `spike_result_candidate_3.md` -> `spike_하이브리드프리뷰_분리API.md` +- 완료 범위: + 1. `docs/conception/sprint03/` 하위 Spike 결과 문서 3개를 `git mv`로 이름 변경. + 2. 공식 기술부채 해결 문서와 로컬 학습 문서의 기준 문서명을 새 파일명으로 갱신. +- 이슈/주의: `.idea/workspace.xml`에도 기존 파일명 참조가 있으나 IDE 로컬 상태 파일이므로 커밋 대상에서 제외. From 0fb576cdabd64eabbe4ba65cb4f79090fd98cfbc Mon Sep 17 00:00:00 2001 From: ikae Date: Thu, 3 Sep 2026 17:47:17 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(comment):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=82=AD=EC=A0=9C=20UI=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=9C=EA=B0=84=20=EC=95=84=EB=9E=98=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=ED=8C=85=20=ED=8C=9D=EC=98=A4=EB=B2=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/posts/[publicId]/page.tsx | 580 ++++++++++++++++++++++--- 1 file changed, 520 insertions(+), 60 deletions(-) diff --git a/frontend/app/posts/[publicId]/page.tsx b/frontend/app/posts/[publicId]/page.tsx index df689d2..f4b2637 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -73,9 +73,10 @@ interface ReplyPagingState { loading: boolean; } -interface CommentDeleteTarget { +interface CommentUpdateResponse { commentId: number; - requiresPassword: boolean; + content: string; + updatedAt: string; } export default function PostDetailPage({ params }: { params: Promise<{ publicId: string }> }) { @@ -98,7 +99,15 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const [replyMentionName, setReplyMentionName] = useState(null); const [replyText, setReplyText] = useState(""); const [replyAnonPassword, setReplyAnonPassword] = useState(""); - const [commentDeleteTarget, setCommentDeleteTarget] = useState(null); + const [activeEditCommentId, setActiveEditCommentId] = useState(null); + const [editCommentText, setEditCommentText] = useState(""); + const [editCommentPassword, setEditCommentPassword] = useState(""); + const [editCommentError, setEditCommentError] = useState(""); + const [submittingEditComment, setSubmittingEditComment] = useState(false); + const [activeDeleteCommentId, setActiveDeleteCommentId] = useState(null); + const [deleteCommentPassword, setDeleteCommentPassword] = useState(""); + const [deleteCommentError, setDeleteCommentError] = useState(""); + const [submittingDeleteComment, setSubmittingDeleteComment] = useState(false); const [currentUserPublicId, setCurrentUserPublicId] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -395,29 +404,116 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; - const handleOpenCommentDeleteModal = (comment: CommentItem) => { - setCommentDeleteTarget({ - commentId: comment.commentId, - requiresPassword: comment.isAnonymous && !currentUserPublicId, - }); + const handleStartEditComment = (comment: CommentItem) => { + setActiveReplyParentId(null); + setReplyMentionName(null); + setActiveEditCommentId(comment.commentId); + setEditCommentText(comment.content); + setEditCommentPassword(""); + setEditCommentError(""); }; - const handleConfirmCommentDelete = async (password: string) => { - if (!commentDeleteTarget) return; + const handleCancelEditComment = () => { + if (submittingEditComment) return; + setActiveEditCommentId(null); + setEditCommentText(""); + setEditCommentPassword(""); + setEditCommentError(""); + }; - const res = await csrfFetch(API_ENDPOINTS.comments.delete(commentDeleteTarget.commentId), { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ anonymousPassword: password || null }), - }); - if (!res.ok) { - const errorData = await res.json(); - throw new Error(errorData.message || "댓글 삭제에 실패했습니다."); + const handleUpdateComment = async (comment: CommentItem) => { + const content = editCommentText.trim(); + const requiresPassword = comment.isAnonymous && !currentUserPublicId; + 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 handleStartDeleteComment = (commentId: number) => { + setActiveDeleteCommentId(commentId); + setDeleteCommentPassword(""); + setDeleteCommentError(""); + }; - setCommentDeleteTarget(null); - await fetchComments(); - setPost((current) => (current ? { ...current, commentCount: Math.max(0, current.commentCount - 1) } : current)); + const handleCancelDeleteComment = () => { + setActiveDeleteCommentId(null); + setDeleteCommentPassword(""); + setDeleteCommentError(""); + }; + + const handleConfirmDeleteComment = async (comment: CommentItem) => { + const isOwnerMember = !comment.isAnonymous && currentUserPublicId && comment.writer?.publicId === currentUserPublicId; + const isOwnerAnonMember = comment.isAnonymous && currentUserPublicId && comment.writer?.publicId === currentUserPublicId; + const requiresPassword = !isAdmin && !isOwnerMember && !isOwnerAnonMember; + + if (requiresPassword && !deleteCommentPassword.trim()) { + setDeleteCommentError("비밀번호를 입력해주세요."); + return; + } + + setSubmittingDeleteComment(true); + setDeleteCommentError(""); + try { + const res = await csrfFetch(API_ENDPOINTS.comments.delete(comment.commentId), { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ anonymousPassword: deleteCommentPassword.trim() || null }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "댓글 삭제에 실패했습니다."); + } + + handleCancelDeleteComment(); + await fetchComments(); + setPost((current) => (current ? { ...current, commentCount: Math.max(0, current.commentCount - 1) } : current)); + } catch (error) { + setDeleteCommentError(error instanceof Error ? error.message : "서버 통신 중 오류가 발생했습니다."); + } finally { + setSubmittingDeleteComment(false); + } }; if (loading) { @@ -575,7 +671,24 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: replyAnonPassword={replyAnonPassword} setReplyAnonPassword={setReplyAnonPassword} handleCreateComment={handleCreateComment} - handleOpenCommentDeleteModal={handleOpenCommentDeleteModal} + activeEditCommentId={activeEditCommentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} + activeDeleteCommentId={activeDeleteCommentId} + deleteCommentPassword={deleteCommentPassword} + setDeleteCommentPassword={setDeleteCommentPassword} + deleteCommentError={deleteCommentError} + submittingDeleteComment={submittingDeleteComment} + handleStartDeleteComment={handleStartDeleteComment} + handleCancelDeleteComment={handleCancelDeleteComment} + handleConfirmDeleteComment={handleConfirmDeleteComment} isAdmin={isAdmin} handleLoadMoreReplies={handleLoadMoreReplies} isLoadingReplies={Boolean(replyPagingByRootId[comment.commentId]?.loading)} @@ -606,16 +719,6 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: description={deleteModalConfig.description} requirePassword={deleteModalConfig.requirePassword} /> - setCommentDeleteTarget(null)} - onConfirm={handleConfirmCommentDelete} - title="댓글 삭제 확인" - description="정말 삭제하시겠습니까?" - requirePassword={commentDeleteTarget?.requiresPassword ?? false} - confirmLabel="댓글 삭제" - submittingLabel="삭제 중..." - />
); @@ -634,7 +737,24 @@ function CommentRow({ replyAnonPassword, setReplyAnonPassword, handleCreateComment, - handleOpenCommentDeleteModal, + activeEditCommentId, + editCommentText, + setEditCommentText, + editCommentPassword, + setEditCommentPassword, + editCommentError, + submittingEditComment, + handleStartEditComment, + handleCancelEditComment, + handleUpdateComment, + activeDeleteCommentId, + deleteCommentPassword, + setDeleteCommentPassword, + deleteCommentError, + submittingDeleteComment, + handleStartDeleteComment, + handleCancelDeleteComment, + handleConfirmDeleteComment, isAdmin, handleLoadMoreReplies, isLoadingReplies, @@ -651,12 +771,31 @@ function CommentRow({ replyAnonPassword: string; setReplyAnonPassword: (value: string) => void; handleCreateComment: (parentId: number | null) => Promise; - handleOpenCommentDeleteModal: (comment: CommentItem) => void; + 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; + activeDeleteCommentId: number | null; + deleteCommentPassword: string; + setDeleteCommentPassword: (password: string) => void; + deleteCommentError: string; + submittingDeleteComment: boolean; + handleStartDeleteComment: (commentId: number) => void; + handleCancelDeleteComment: () => void; + handleConfirmDeleteComment: (comment: CommentItem) => Promise; isAdmin: boolean; handleLoadMoreReplies: (rootCommentId: number) => Promise; isLoadingReplies: boolean; }) { + const canEdit = canEditComment(item, currentUserPublicId); const canDelete = canDeleteComment(item, currentUserPublicId, isAdmin); + const isEditing = activeEditCommentId === item.commentId; const openReplyEditor = (target: CommentItem) => { if (activeReplyParentId === item.commentId && replyMentionName === getWriterName(target)) { setActiveReplyParentId(null); @@ -672,20 +811,53 @@ function CommentRow({
{getWriterName(item)} - {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} -
-

{item.content}

- -
- - {canDelete && ( - - )} +
+ {formatCommentDate(item.createdAt)} + {canDelete && ( + handleStartDeleteComment(item.commentId)} + onClose={handleCancelDeleteComment} + password={deleteCommentPassword} + setPassword={setDeleteCommentPassword} + error={activeDeleteCommentId === item.commentId ? deleteCommentError : ""} + submitting={submittingDeleteComment} + onConfirm={() => void handleConfirmDeleteComment(item)} + /> + )} +
+ {isEditing ? ( + void handleUpdateComment(item)} + /> + ) : ( + <> +

{item.content}

+
+ + {canEdit && ( + + )} +
+ + )} {item.previewReplies.length > 0 && (
@@ -694,9 +866,26 @@ function CommentRow({ key={reply.commentId} item={reply} onReply={() => openReplyEditor(reply)} + isEditing={activeEditCommentId === reply.commentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} currentUserPublicId={currentUserPublicId} + activeDeleteCommentId={activeDeleteCommentId} + deleteCommentPassword={deleteCommentPassword} + setDeleteCommentPassword={setDeleteCommentPassword} + deleteCommentError={deleteCommentError} + submittingDeleteComment={submittingDeleteComment} + handleStartDeleteComment={handleStartDeleteComment} + handleCancelDeleteComment={handleCancelDeleteComment} + handleConfirmDeleteComment={handleConfirmDeleteComment} isAdmin={isAdmin} - handleOpenCommentDeleteModal={handleOpenCommentDeleteModal} /> ))}
@@ -765,33 +954,101 @@ function ReplyRow({ item, onReply, currentUserPublicId, + isEditing, + editCommentText, + setEditCommentText, + editCommentPassword, + setEditCommentPassword, + editCommentError, + submittingEditComment, + handleStartEditComment, + handleCancelEditComment, + handleUpdateComment, + activeDeleteCommentId, + deleteCommentPassword, + setDeleteCommentPassword, + deleteCommentError, + submittingDeleteComment, + handleStartDeleteComment, + handleCancelDeleteComment, + handleConfirmDeleteComment, isAdmin, - handleOpenCommentDeleteModal, }: { item: CommentItem; onReply: () => void; currentUserPublicId: string | null; + isEditing: boolean; + 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; + activeDeleteCommentId: number | null; + deleteCommentPassword: string; + setDeleteCommentPassword: (password: string) => void; + deleteCommentError: string; + submittingDeleteComment: boolean; + handleStartDeleteComment: (commentId: number) => void; + handleCancelDeleteComment: () => void; + handleConfirmDeleteComment: (comment: CommentItem) => Promise; isAdmin: boolean; - handleOpenCommentDeleteModal: (comment: CommentItem) => void; }) { + const canEdit = canEditComment(item, currentUserPublicId); const canDelete = canDeleteComment(item, currentUserPublicId, isAdmin); + return (
{getWriterName(item)} - - {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - +
+ + {formatCommentDate(item.createdAt)} + + {canDelete && ( + handleStartDeleteComment(item.commentId)} + onClose={handleCancelDeleteComment} + password={deleteCommentPassword} + setPassword={setDeleteCommentPassword} + error={activeDeleteCommentId === item.commentId ? deleteCommentError : ""} + submitting={submittingDeleteComment} + onConfirm={() => void handleConfirmDeleteComment(item)} + /> + )} +
-

{item.content}

- {!item.isDeleted && ( + {isEditing ? ( + void handleUpdateComment(item)} + /> + ) : ( +

{item.content}

+ )} + {!item.isDeleted && !isEditing && (
- {canDelete && ( - )}
@@ -800,13 +1057,216 @@ function ReplyRow({ ); } +function CommentEditForm({ + comment, + content, + setContent, + password, + setPassword, + error, + submitting, + requiresPassword, + onCancel, + onSubmit, +}: { + comment: CommentItem; + content: string; + setContent: (content: string) => void; + password: string; + setPassword: (password: string) => void; + error: string; + submitting: boolean; + requiresPassword: boolean; + onCancel: () => void; + onSubmit: () => void; +}) { + const contentId = `comment-edit-content-${comment.commentId}`; + const passwordId = `comment-edit-password-${comment.commentId}`; + + return ( +
+ +