From 0e54658c80f8156b0d5424411d244cffea7da446 Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 1 Sep 2026 15:38:57 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs:=20Sprint=2003=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B3=B5=EC=8B=9D=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EB=AC=B8=EC=84=9C(ADR-001,=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=EC=84=9C)=20=EB=B0=8F=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=ED=94=BD=EC=8A=A4=EC=B2=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spike/CommentSpikeBenchmarkHarness.java | 17 +- database/spike_seed_comments.sql | 2 +- ...nt-hierarchy-and-retrieval-architecture.md | 151 ++++++++++++++++++ docs/conception/sprint03/comment_policy.md | 56 +++++++ docs/project/work.md | 4 + 5 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md create mode 100644 docs/conception/sprint03/comment_policy.md 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/database/spike_seed_comments.sql b/database/spike_seed_comments.sql index c0123be..b6f5957 100644 --- a/database/spike_seed_comments.sql +++ b/database/spike_seed_comments.sql @@ -10,7 +10,7 @@ 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`) +INSERT IGNORE INTO `member` (`member_id`, `public_id`, `email`, `password`, `nickname`, `role`, `status`, `created_at`, `updated_at`) VALUES (1, 'member-spike-001', 'spike@snowthing.com', '$2a$10$dummyHashValueForSpikeTestingOnly1234567890', '스파이크테스터', 'ROLE_USER', 'ACTIVE', NOW(), NOW()); -- 2. 테스트용 게시글 2개 생성 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_policy.md b/docs/conception/sprint03/comment_policy.md new file mode 100644 index 0000000..f712d27 --- /dev/null +++ b/docs/conception/sprint03/comment_policy.md @@ -0,0 +1,56 @@ +# 📜 Snowthing 댓글/대댓글 도메인 공식 제품 규칙 명세서 (Comment Domain Policy) + +본 문서는 Snowthing 커뮤니티의 댓글 및 대댓글 도메인의 계층, 화면 응답, 삭제 및 카운트, 정렬 및 권한 정책을 정의한 공식 기술 명세서입니다. + +--- + +## 1. 계층 규칙 (Hierarchy Rules) +- **2-Depth 고정 구조**: 댓글(Root)과 대댓글(Child)로만 이루어진 2단계 계층 구조를 채택합니다. +- **평탄화(Flattening) 정책**: 대댓글에 다시 답글을 작성하는 경우, 부모 대댓글의 ID가 아닌 **최상위 Root 댓글의 `comment_id`를 `parent_id`로 자동 지정**하여 2단계를 초과하는 계층 생성을 물리적으로 방지합니다. + +--- + +## 2. 화면 및 응답 규칙 (UI & Response Rules) +- **초기 로딩 크기**: 게시글 상세 진입 시 루트 댓글은 **1페이지당 20개** 기준으로 조회합니다. +- **대댓글 노출 및 접기**: + - 각 루트 댓글 하위의 대댓글은 **기본 5개**까지 펼쳐서 노출합니다. + - 5개를 초과하는 대댓글은 **"답글 더보기(N개)"** UI로 접힘 처리하여 사용자가 클릭 시 추가 렌더링합니다. +- **최대 대댓글 제한**: 루트 댓글 1개당 작성 가능한 대댓글 수는 **최대 100개**로 제한합니다 (100개 도달 시 400 Bad Request 에러 반환). + +--- + +## 3. 삭제 및 카운트 규칙 (Deletion & Count Rules) +- **삭제된 루트 + 대댓글 존재 시**: + - 루트 댓글 본문은 `"삭제된 댓글입니다."` placeholder로 대체 노출 (`is_deleted = true`). + - 하위 대댓글들은 정상적으로 노출을 유지합니다. +- **삭제된 루트 댓글에 신규 대댓글 작성**: **허용**. (대화 맥락 유지를 위해 삭제된 부모 밑에도 신규 답글 작성 가능) +- **삭제된 대댓글 노출**: 대댓글 삭제 시에도 `"삭제된 댓글입니다."` placeholder로 대체 노출 (`is_deleted = true`). +- **부모 + 자식 모두 삭제된 노드(고아 노드)**: + - 루트 댓글이 삭제되고, 그 하위의 모든 대댓글도 삭제된 경우 **화면(클라이언트 응답 목록)에서 완전히 숨김(은닉)** 처리합니다. +- **`post.commentCount` (게시글 총 댓글 수)**: + - **"삭제된 댓글입니다"를 제외한 실제 살아있는 활성 댓글/대댓글(`is_deleted = false`)의 총합**만 카운트합니다. + - Soft Delete 실행 시 즉시 `comment_count - 1` 벌크 차감. +- **`replyCount` (대댓글 수)**: + - 각 루트 댓글 DTO에 포함되는 `replyCount`는 **삭제된 대댓글을 제외한 실제 활성 대댓글 수**만 집계합니다. + +--- + +## 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..48dac25 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,7 @@ +- **댓글 도메인 계층 모델 및 조회 아키텍처 공식 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 직렬화 페이로드 바이트 크기, 쿼리 수 측정 러너) 구축. From 052784ea473e46cfcb30da4abc02c628fc61b9b7 Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 1 Sep 2026 15:42:56 +0900 Subject: [PATCH 02/10] =?UTF-8?q?docs:=20Sprint=2003=20=EA=B3=B5=EC=8B=9D?= =?UTF-8?q?=20=EB=8C=93=EA=B8=80=20API=20=EB=AA=85=EC=84=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20C(=EC=83=9D=EC=84=B1)=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/comment/entity/Comment.java | 15 + .../comment/repository/CommentRepository.java | 10 + .../comment/service/CommentService.java | 39 ++- .../snowthing/global/error/ErrorCode.java | 2 + .../comment/service/CommentCreateTest.java | 150 ++++++++++ docs/conception/sprint03/comment_api_spec.md | 279 ++++++++++++++++++ docs/project/work.md | 11 + 7 files changed, 495 insertions(+), 11 deletions(-) create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java create mode 100644 docs/conception/sprint03/comment_api_spec.md 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..6e8f0ad 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 @@ -76,6 +76,21 @@ public Comment( this.isDeleted = false; } + public static Comment create( + Post post, + Member member, + Comment parent, + String content, + String writerIp, + boolean isAnonymous, + String anonymousPassword) { + return new Comment(post, member, parent, content, writerIp, isAnonymous, anonymousPassword); + } + + public Comment rootParent() { + return parent != null ? parent : this; + } + public void softDelete() { this.isDeleted = true; this.deletedAt = LocalDateTime.now(); diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java index 1b2d566..a2a7949 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,8 +1,12 @@ 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; @@ -10,6 +14,12 @@ public interface CommentRepository extends JpaRepository { + @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); 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..33e6d6c 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 @@ -28,6 +28,8 @@ @Transactional(readOnly = true) public class CommentService { + private static final long MAX_REPLY_COUNT = 100L; + private final CommentRepository commentRepository; private final PostRepository postRepository; private final MemberRepository memberRepository; @@ -84,7 +86,7 @@ public CommentResponse createComment( Comment parent = null; if (request.parentId() != null) { - parent = + Comment requestedParent = commentRepository .findById(request.parentId()) .orElseThrow( @@ -93,21 +95,36 @@ public CommentResponse createComment( ErrorCode .PARENT_COMMENT_NOT_FOUND)); - if (!parent.getPost().getId().equals(post.getId())) { + if (!requestedParent.getPost().getId().equals(post.getId())) { throw new CustomAuthException(ErrorCode.INVALID_COMMENT_PARENT); } + + Long rootCommentId = requestedParent.rootParent().getId(); + parent = + commentRepository + .findByIdForUpdate(rootCommentId) + .orElseThrow( + () -> + new CustomAuthException( + ErrorCode + .PARENT_COMMENT_NOT_FOUND)); + + long activeReplyCount = + commentRepository.countByParentIdAndIsDeletedFalse(rootCommentId); + 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()); diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index 6926451..1e78ef7 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -24,6 +24,8 @@ public enum ErrorCode { COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "존재하지 않거나 이미 삭제된 댓글입니다."), PARENT_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_002", "존재하지 않는 부모 댓글입니다."), INVALID_COMMENT_PARENT(HttpStatus.BAD_REQUEST, "COMMENT_003", "동일한 게시글의 댓글에만 대댓글을 달 수 있습니다."), + COMMENT_REPLY_LIMIT_EXCEEDED( + HttpStatus.BAD_REQUEST, "COMMENT_004", "루트 댓글 1개당 작성 가능한 대댓글 수는 최대 100개입니다."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다."), INVALID_PAGE_SIZE(HttpStatus.BAD_REQUEST, "COMMON_002", "페이지 크기는 1 이상 100 이하이어야 합니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); diff --git a/backend/src/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..f3d91d1 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java @@ -0,0 +1,150 @@ +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 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.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 CommentCreateTest { + + @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() { + categoryRepository + .findByCode("FREE") + .orElseGet(() -> categoryRepository.save(new PostCategory("자유게시판", "FREE"))); + + Member member = + memberRepository.save( + new Member( + null, + "comment-create@example.com", + passwordEncoder.encode("Password123!"), + "댓글작성자", + 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 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); + } + + @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); + } + + 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/docs/conception/sprint03/comment_api_spec.md b/docs/conception/sprint03/comment_api_spec.md new file mode 100644 index 0000000..41dbb80 --- /dev/null +++ b/docs/conception/sprint03/comment_api_spec.md @@ -0,0 +1,279 @@ +# 📋 Snowthing 댓글 도메인 공식 API 명세서 (Comment API Specification) + +- **문서 번호**: `SPEC-API-SPRINT03-COMMENT` +- **상태**: `Accepted` +- **적용 스프린트**: Sprint 03 (댓글 및 계층형 대댓글 도메인) +- **기반 정책 문서**: `docs/conception/sprint03/comment_policy.md`, `docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md` + +--- + +## 1. API 엔드포인트 요약 + +| 기능 | HTTP Method | Endpoint | 인증 (Auth) | 비고 | +| :--- | :---: | :--- | :---: | :--- | +| **1. 댓글/대댓글 작성** | `POST` | `/api/v1/posts/{publicId}/comments` | 일반회원/익명 | 2단계 평탄화, 루트당 100개 상한 | +| **2. 게시글 댓글 목록 조회** | `GET` | `/api/v1/posts/{publicId}/comments` | 불필요 (Public) | 루트 20개 Batch + 대댓글 Top-5 프리뷰 | +| **3. 대댓글 목록 분리 조회** | `GET` | `/api/v1/comments/{commentId}/replies` | 불필요 (Public) | 5개 초과 대댓글 20개 커서 페이징 | +| **4. 댓글 수정** | `PUT` | `/api/v1/comments/{commentId}` | 작성자 세션/비번 | 비회원 익명 비밀번호 검증 | +| **5. 댓글 삭제** | `DELETE` | `/api/v1/comments/{commentId}` | 작성자 세션/비번/관리자 | Soft Delete, 고아 노드 은닉 정책 | + +--- + +## 2. 세부 API 명세 + +--- + +### 1. 댓글 및 대댓글 작성 (Create Comment / Reply) + +게시글에 루트 댓글을 작성하거나, 특정 댓글 하위에 대댓글을 작성합니다. + +- **HTTP Method**: `POST` +- **URI**: `/api/v1/posts/{publicId}/comments` +- **인증 요구사항**: + - 일반 회원: 로그인 세션 쿠키 필수 + - 로그인 익명: 로그인 세션 쿠키 필수, `isAnonymous = true` + - 비로그인 익명: 로그인 불필요, `isAnonymous = true`, `anonymousPassword` (4자리 이상) 필수 + +#### Request Headers +```http +Content-Type: application/json +X-XSRF-TOKEN: {csrf_token} +``` + +#### Request Body +```json +{ + "content": "이 스키장 설질 오늘 정말 좋네요!", + "parentId": null, + "isAnonymous": false, + "anonymousPassword": null +} +``` + +| 필드명 | 타입 | 필수 여부 | 설명 | +| :--- | :---: | :---: | :--- | +| `content` | String | **필수** | 댓글 본문 (1자 이상 1,000자 이하) | +| `parentId` | Long | 선택 | 부모 댓글 ID. `null`이면 루트 댓글, 대댓글 작성 시 대상 댓글 ID 전달 (대댓글에 답글 시 서버에서 최상위 루트 ID로 자동 평탄화) | +| `isAnonymous` | Boolean | **필수** | 익명 작성 여부 (`true` / `false`) | +| `anonymousPassword` | String | 조건부 필수 | 비로그인 익명 작성 시 필수 (4자 이상 20자 이하) | + +#### Response (201 Created) +```json +{ + "commentId": 105, + "postId": 998, + "parentId": null, + "writer": { + "publicId": "member-pub-1234", + "nickname": "파우더매니아", + "profileImageUrl": "https://cdn.snowthing.com/profiles/1234.jpg" + }, + "isAnonymous": false, + "writerIp": "127.0.0.1", + "content": "이 스키장 설질 오늘 정말 좋네요!", + "replyCount": 0, + "createdAt": "2026-09-01T15:30:00" +} +``` + +#### 주요 예외 응답 +- `400 Bad Request` (`COMMENT_004`): 루트 댓글의 활성 대댓글 수가 이미 100개에 도달한 경우 +- `400 Bad Request` (`COMMON_001`): 본문이 비어있거나 비로그인 익명 비밀번호가 누락된 경우 +- `404 Not Found` (`POST_001`): 존재하지 않거나 삭제된 게시글인 경우 +- `404 Not Found` (`COMMENT_002`): 지정한 `parentId` 부모 댓글이 존재하지 않는 경우 + +--- + +### 2. 게시글 댓글 목록 조회 (Read Post Comments - Root Batch + Top-5 Preview) + +게시글 상세 화면에서 루트 댓글 20개와 각 루트 댓글 하위의 대댓글 상위 5개를 일괄 조회합니다. + +- **HTTP Method**: `GET` +- **URI**: `/api/v1/posts/{publicId}/comments` +- **인증 요구사항**: 없음 (Public) + +#### Request Query Parameters +| 파라미터명 | 타입 | 기본값 | 설명 | +| :--- | :---: | :---: | :--- | +| `cursor` | Long | `null` | 커서 페이징용 마지막 루트 댓글 ID (`commentId`). 첫 페이지 조회 시 생략 | +| `size` | Integer | `20` | 조회할 루트 댓글 수 (기본 20개, 최대 50개) | + +#### Response (200 OK) +```json +{ + "publicId": "post-pub-5678", + "totalCommentCount": 42, + "comments": [ + { + "commentId": 101, + "parentId": null, + "writer": { + "publicId": "member-pub-1234", + "nickname": "파우더매니아", + "profileImageUrl": "https://cdn.snowthing.com/profiles/1234.jpg" + }, + "isAnonymous": false, + "writerIp": "211.234.***.***", + "content": "하이원 아테나 슬로프 오픈했나요?", + "isDeleted": false, + "replyCount": 8, + "previewReplies": [ + { + "commentId": 102, + "parentId": 101, + "writer": { + "publicId": "member-pub-8888", + "nickname": "설질감별사", + "profileImageUrl": null + }, + "isAnonymous": false, + "writerIp": "175.120.***.***", + "content": "네 오늘 오전 9시에 오픈했습니다!", + "isDeleted": false, + "createdAt": "2026-09-01T15:32:00" + } + ], + "hasMoreReplies": true, + "createdAt": "2026-09-01T15:30:00" + }, + { + "commentId": 103, + "parentId": null, + "writer": null, + "isAnonymous": true, + "writerIp": "121.160.***.***", + "content": "삭제된 댓글입니다.", + "isDeleted": true, + "replyCount": 1, + "previewReplies": [ + { + "commentId": 104, + "parentId": 103, + "writer": { + "publicId": "member-pub-9999", + "nickname": "스노우보더", + "profileImageUrl": null + }, + "isAnonymous": false, + "writerIp": "220.70.***.***", + "content": "삭제된 질문이지만 답변 남깁니다. 야간개장은 18시부터입니다.", + "isDeleted": false, + "createdAt": "2026-09-01T15:35:00" + } + ], + "hasMoreReplies": false, + "createdAt": "2026-09-01T15:31:00" + } + ], + "nextCursor": 103, + "hasNext": true +} +``` + +--- + +### 3. 대댓글 목록 분리 페이징 조회 (Read Separated Replies) + +특정 루트 댓글 하위에 5개를 초과하는 대댓글이 있을 때, 사용자가 "답글 더보기"를 클릭하여 20개 단위로 추가 조회합니다. + +- **HTTP Method**: `GET` +- **URI**: `/api/v1/comments/{commentId}/replies` +- **인증 요구사항**: 없음 (Public) + +#### Request Query Parameters +| 파라미터명 | 타입 | 기본값 | 설명 | +| :--- | :---: | :---: | :--- | +| `cursor` | Long | `null` | 커서 페이징용 마지막 대댓글 ID (`commentId`). 첫 더보기 호출 시 5번째 프리뷰 대댓글의 ID를 전달 | +| `size` | Integer | `20` | 조회할 대댓글 수 (기본 20개, 최대 50개) | + +#### Response (200 OK) +```json +{ + "rootCommentId": 101, + "totalReplyCount": 8, + "replies": [ + { + "commentId": 106, + "parentId": 101, + "writer": { + "publicId": "member-pub-7777", + "nickname": "카빙장인", + "profileImageUrl": null + }, + "isAnonymous": false, + "writerIp": "112.180.***.***", + "content": "빅토리아 슬로프는 다음 주 오픈 예정이랍니다.", + "isDeleted": false, + "createdAt": "2026-09-01T15:40:00" + } + ], + "nextCursor": 106, + "hasNext": false +} +``` + +--- + +### 4. 댓글 수정 (Update Comment) + +본인이 작성한 댓글의 본문을 수정합니다. + +- **HTTP Method**: `PUT` +- **URI**: `/api/v1/comments/{commentId}` +- **인증 요구사항**: 로그인 회원(본인 세션 일치) 또는 비로그인 익명(`anonymousPassword` 일치) + +#### Request Body +```json +{ + "content": "수정된 댓글 본문 내용입니다.", + "anonymousPassword": "mypassword123" +} +``` + +#### Response (200 OK) +```json +{ + "commentId": 105, + "content": "수정된 댓글 본문 내용입니다.", + "updatedAt": "2026-09-01T15:45:00" +} +``` + +--- + +### 5. 댓글 삭제 (Delete Comment - Soft Delete) + +댓글을 삭제 처리합니다 (`is_deleted = true`). + +- **HTTP Method**: `DELETE` +- **URI**: `/api/v1/comments/{commentId}` +- **인증 요구사항**: 로그인 작성자 본인, 최고 관리자(`ROLE_ADMIN`), 또는 비로그인 익명 비밀번호 일치 + +#### Request Body +```json +{ + "anonymousPassword": "mypassword123" +} +``` + +#### Response (200 OK) +```json +{ + "message": "댓글이 삭제되었습니다." +} +``` + +--- + +## 3. 공통 에러 코드 매핑 + +| HTTP Status | ErrorCode | 에러 메시지 | +| :--- | :--- | :--- | +| `400 Bad Request` | `COMMENT_004` | 루트 댓글 1개당 작성 가능한 대댓글 수는 최대 100개입니다. | +| `400 Bad Request` | `COMMENT_003` | 동일한 게시글의 댓글에만 대댓글을 달 수 있습니다. | +| `400 Bad Request` | `COMMON_001` | 잘못된 입력값입니다. (글자수 제한 위반, 비밀번호 누락 등) | +| `403 Forbidden` | `AUTH_002` | 해당 작업을 수행할 권한이 없습니다. | +| `403 Forbidden` | `POST_004` | 비회원 익명 비밀번호가 일치하지 않습니다. | +| `404 Not Found` | `COMMENT_001` | 존재하지 않거나 이미 삭제된 댓글입니다. | +| `404 Not Found` | `COMMENT_002` | 존재하지 않는 부모 댓글입니다. | +| `404 Not Found` | `POST_001` | 존재하지 않거나 삭제된 게시글입니다. | diff --git a/docs/project/work.md b/docs/project/work.md index 48dac25..d08d065 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,6 @@ +- **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 실측 매트릭스, 기각 근거, 기술 부채, 재검토 트리거 등 표준 아키텍처 의사결정 기록 공식 문서화. @@ -689,3 +692,11 @@ 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`에만 생성 테스트 4건을 작성했으며, `./gradlew.bat test --tests "*CommentCreateTest*"` 및 `./gradlew.bat spotlessCheck` 통과. + 6. 남은 이슈: 애플리케이션의 비관적 잠금은 같은 루트에 대댓글 생성이 집중되면 해당 루트의 쓰기 요청을 직렬화하므로, 운영 환경에서는 잠금 대기 시간과 타임아웃 지표를 관찰해야 함. From 33fef4d87e06172099e70b7121835ca36c8ccb9f Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 1 Sep 2026 16:31:59 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(comment):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EB=B0=8F=20MySQL=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/repository/CommentRepository.java | 4 + .../comment/service/CommentService.java | 4 +- .../comment/service/CommentCreateTest.java | 274 +++++++++++++++++- 3 files changed, 278 insertions(+), 4 deletions(-) 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 a2a7949..981ab2c 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 @@ -20,6 +20,10 @@ public interface CommentRepository extends JpaRepository { long countByParentIdAndIsDeletedFalse(Long parentId); + @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); + @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); 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 33e6d6c..767ce22 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 @@ -88,7 +88,7 @@ public CommentResponse createComment( if (request.parentId() != null) { Comment requestedParent = commentRepository - .findById(request.parentId()) + .findByIdForUpdate(request.parentId()) .orElseThrow( () -> new CustomAuthException( @@ -110,7 +110,7 @@ public CommentResponse createComment( .PARENT_COMMENT_NOT_FOUND)); long activeReplyCount = - commentRepository.countByParentIdAndIsDeletedFalse(rootCommentId); + commentRepository.findActiveReplyIdsForUpdate(rootCommentId).size(); if (activeReplyCount >= MAX_REPLY_COUNT) { throw new CustomAuthException(ErrorCode.COMMENT_REPLY_LIMIT_EXCEEDED); } 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 index f3d91d1..18b0527 100644 --- 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 @@ -3,7 +3,14 @@ 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; @@ -13,6 +20,9 @@ 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.Propagation; import org.springframework.transaction.annotation.Transactional; import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; @@ -27,6 +37,7 @@ 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; @@ -38,6 +49,35 @@ @Transactional class CommentCreateTest { + @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; @@ -52,6 +92,7 @@ class CommentCreateTest { @BeforeEach void setUp() { + String fixtureId = UUID.randomUUID().toString(); categoryRepository .findByCode("FREE") .orElseGet(() -> categoryRepository.save(new PostCategory("자유게시판", "FREE"))); @@ -60,9 +101,9 @@ void setUp() { memberRepository.save( new Member( null, - "comment-create@example.com", + "comment-create-" + fixtureId + "@example.com", passwordEncoder.encode("Password123!"), - "댓글작성자", + "댓글작성자-" + fixtureId, null, null, null, @@ -80,6 +121,62 @@ void setUp() { "127.0.0.1"); } + @Test + @DisplayName("로그인 회원이 루트 댓글을 생성하면 부모 없이 저장되고 댓글 수가 증가한다") + void createRootCommentAsMember() { + CommentResponse response = createComment(null, "회원 루트 댓글"); + + entityManager.flush(); + entityManager.clear(); + Comment savedComment = commentRepository.findById(response.commentId()).orElseThrow(); + Post savedPost = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(savedComment.getParent()).isNull(); + assertThat(savedComment.getContent()).isEqualTo("회원 루트 댓글"); + assertThat(savedPost.getCommentCount()).isEqualTo(1); + } + + @Test + @DisplayName("로그인 회원은 작성자를 숨긴 익명 댓글을 생성할 수 있다") + void createAnonymousCommentAsMember() { + CommentResponse response = + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "로그인 익명 댓글", true, null), + userDetails, + "127.0.0.1"); + + Comment savedComment = commentRepository.findById(response.commentId()).orElseThrow(); + assertThat(savedComment.isAnonymous()).isTrue(); + assertThat(savedComment.getMember()).isNotNull(); + assertThat(savedComment.getAnonymousPassword()).isNull(); + } + + @Test + @DisplayName("비로그인 사용자는 비밀번호를 제공하면 익명 댓글을 생성할 수 있다") + void createAnonymousCommentAsGuest() { + CommentResponse response = + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "비로그인 익명 댓글", true, "1234"), + null, + "127.0.0.1"); + + Comment savedComment = commentRepository.findById(response.commentId()).orElseThrow(); + assertThat(savedComment.getMember()).isNull(); + assertThat(passwordEncoder.matches("1234", savedComment.getAnonymousPassword())).isTrue(); + } + + @Test + @DisplayName("삭제된 루트 댓글에도 새 대댓글을 생성할 수 있다") + void createReplyUnderDeletedRootComment() { + CommentResponse root = createComment(null, "삭제할 루트 댓글"); + commentService.deleteComment(root.commentId(), null, userDetails); + + CommentResponse reply = createComment(root.commentId(), "삭제된 루트의 새 대댓글"); + + assertThat(reply.parentId()).isEqualTo(root.commentId()); + } + @Test @DisplayName("대댓글에 답글을 작성해도 최상위 루트 댓글 아래로 평탄화한다") void flattenReplyToRootComment() { @@ -108,6 +205,15 @@ void rejectReplyWhenActiveReplyCountReachesLimit() { .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 @@ -140,6 +246,170 @@ void increasePostCommentCountWithCommentCreation() { 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(), From bfbb5e60d88c1e0adb8c550a78c55bd4009eb0fc Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 1 Sep 2026 16:32:52 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(comment):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EB=B0=8F=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 14 +- .../comment/dto/CommentReplyListResponse.java | 14 + .../domain/comment/dto/CommentResponse.java | 75 ++++- .../comment/dto/PostCommentListResponse.java | 15 +- .../domain/comment/entity/Comment.java | 11 +- .../comment/repository/CommentRepository.java | 6 +- .../repository/CommentRepositoryCustom.java | 25 ++ .../repository/CommentRepositoryImpl.java | 226 +++++++++++++ .../comment/service/CommentService.java | 83 +++-- .../domain/comment/CommentReadTest.java | 303 ++++++++++++++++++ frontend/app/lib/api.ts | 9 +- frontend/app/posts/[publicId]/page.tsx | 268 ++++++++++++++-- frontend/next-env.d.ts | 2 +- 13 files changed, 962 insertions(+), 89 deletions(-) create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentReplyListResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryCustom.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java index c8af434..142cd66 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java @@ -39,11 +39,21 @@ public ResponseEntity createComment( @GetMapping("/posts/{publicId}/comments") public ResponseEntity getCommentsByPost( - @PathVariable String publicId) { - PostCommentListResponse response = commentService.getCommentsByPost(publicId); + @PathVariable String publicId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size) { + PostCommentListResponse response = commentService.getCommentsByPost(publicId, cursor, size); return ResponseEntity.ok(response); } + @GetMapping("/comments/{commentId}/replies") + public ResponseEntity getCommentReplies( + @PathVariable Long commentId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(commentService.getCommentReplies(commentId, cursor, size)); + } + @DeleteMapping("/comments/{commentId}") public ResponseEntity> deleteComment( @PathVariable Long commentId, diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/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..bbb0ce5 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,80 @@ package com.ikae.snowthing.domain.comment.dto; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.global.util.WriterDisplayFormatter; public record CommentResponse( Long commentId, + Long postId, Long parentId, - String writerName, + WriterResponse writer, + boolean isAnonymous, + String writerIp, String content, boolean isDeleted, - LocalDateTime createdAt, - List children) { - public static CommentResponse from(Comment comment) { - String writerName = - WriterDisplayFormatter.format( - comment.isAnonymous(), comment.getMember(), comment.getWriterIp()); + long replyCount, + List previewReplies, + boolean hasMoreReplies, + LocalDateTime createdAt) { + + public CommentResponse { + previewReplies = previewReplies == null ? List.of() : List.copyOf(previewReplies); + } - String displayContent = comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(); - Long parentIdValue = comment.getParent() != null ? comment.getParent().getId() : null; + public record WriterResponse(String publicId, String nickname, String profileImageUrl) {} + public static CommentResponse from(Comment comment) { + Member member = comment.getMember(); + WriterResponse writer = + !comment.isAnonymous() && member != null + ? new WriterResponse( + member.getPublicId(), + member.getNickname(), + member.getProfileImageUrl()) + : null; return new CommentResponse( comment.getId(), - parentIdValue, - writerName, - displayContent, + comment.getPost().getId(), + comment.getParent() == null ? null : comment.getParent().getId(), + writer, + comment.isAnonymous(), + WriterDisplayFormatter.maskIp(comment.getWriterIp()), + comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(), comment.isDeleted(), - comment.getCreatedAt(), - new ArrayList<>()); + 0, + List.of(), + false, + comment.getCreatedAt()); + } + + public CommentResponse withPreviewReplies(List replies) { + return new CommentResponse( + commentId, + postId, + parentId, + writer, + isAnonymous, + writerIp, + content, + isDeleted, + replyCount, + replies, + hasMoreReplies, + createdAt); + } + + public 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/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 6e8f0ad..e82b92e 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java @@ -16,7 +16,16 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "comment") +@Table( + name = "comment", + indexes = { + @Index( + name = "idx_comment_post_parent_created", + columnList = "post_id,parent_id,created_at,comment_id"), + @Index( + name = "idx_comment_parent_created", + columnList = "parent_id,created_at,comment_id") + }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?") 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 981ab2c..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 @@ -12,7 +12,7 @@ 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") @@ -23,8 +23,4 @@ public interface CommentRepository extends JpaRepository { @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); - - @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); } 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..0116c5c --- /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 countActiveReplies(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..5b3ebf8 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java @@ -0,0 +1,226 @@ +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 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)) + """ + + 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 countActiveReplies(Long rootCommentId) { + Long count = + jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) FROM comment + WHERE parent_id = :rootCommentId AND is_deleted = false + """, + new MapSqlParameterSource("rootCommentId", rootCommentId), + Long.class); + return count == null ? 0 : count; + } + + 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"), + 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 767ce22..58c1c24 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; @@ -29,6 +30,8 @@ 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; @@ -134,6 +137,11 @@ public CommentResponse createComment( } public PostCommentListResponse getCommentsByPost(String postPublicId) { + return getCommentsByPost(postPublicId, null, DEFAULT_READ_SIZE); + } + + public PostCommentListResponse getCommentsByPost(String postPublicId, Long cursor, int size) { + validateReadSize(size); Post post = postRepository .findByPublicId(postPublicId) @@ -143,30 +151,65 @@ 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()))) + .toList(); + Long nextCursor = hasNext && !comments.isEmpty() ? comments.getLast().commentId() : null; + return new PostCommentListResponse( + postPublicId, post.getCommentCount(), comments, nextCursor, hasNext); + } - Map map = new LinkedHashMap<>(); - for (Comment comment : comments) { - map.put(comment.getId(), CommentResponse.from(comment)); + public CommentReplyListResponse getCommentReplies(Long commentId, Long cursor, int size) { + 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 = List.copyOf(hasNext ? fetched.subList(0, size) : fetched); + Long nextCursor = hasNext && !replies.isEmpty() ? replies.getLast().commentId() : null; + return new CommentReplyListResponse( + commentId, + commentRepository.countActiveReplies(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.INVALID_INPUT); } - - return PostCommentListResponse.builder() - .publicId(postPublicId) - .totalCommentCount(post.getCommentCount()) - .comments(rootComments) - .build(); } @Transactional 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..79e7f83 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java @@ -0,0 +1,303 @@ +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 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 PostResponse post; + + @BeforeEach + void setUp() { + categoryRepository + .findByCode("FREE") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder().name("자유게시판").code("FREE").build())); + Member member = + memberRepository.save( + Member.builder() + .email("comment-read@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글조회보더") + .profileImageUrl("https://example.com/profile.jpg") + .role(Role.ROLE_USER) + .build()); + userDetails = new CustomUserDetails(member); + post = createPost("댓글 조회 게시글"); + } + + @Nested + @DisplayName("성공 시나리오") + class SuccessCases { + + @Test + @DisplayName("루트 댓글을 중복 없이 커서 페이징하고 마지막 페이지를 판별한다") + void rootCursorPaging() { + CommentResponse first = createRoot("루트 1"); + CommentResponse second = createRoot("루트 2"); + CommentResponse third = createRoot("루트 3"); + + PostCommentListResponse firstPage = + commentService.getCommentsByPost(post.publicId(), null, 2); + PostCommentListResponse secondPage = + commentService.getCommentsByPost(post.publicId(), firstPage.nextCursor(), 2); + + assertThat(firstPage.comments()) + .extracting(CommentResponse::commentId) + .containsExactly(first.commentId(), second.commentId()); + assertThat(firstPage.hasNext()).isTrue(); + assertThat(firstPage.nextCursor()).isEqualTo(second.commentId()); + assertThat(secondPage.comments()) + .extracting(CommentResponse::commentId) + .containsExactly(third.commentId()); + assertThat(secondPage.hasNext()).isFalse(); + assertThat(secondPage.nextCursor()).isNull(); + } + + @Test + @DisplayName("동일 생성 시각에는 commentId 오름차순으로 결정론적 정렬한다") + void sameCreatedAtUsesIdTieBreaker() { + CommentResponse first = createRoot("동시각 1"); + CommentResponse second = createRoot("동시각 2"); + LocalDateTime sameTime = LocalDateTime.of(2026, 9, 1, 12, 0); + jdbcTemplate.update( + "UPDATE comment SET created_at = :createdAt WHERE comment_id IN (:ids)", + new MapSqlParameterSource("createdAt", sameTime) + .addValue( + "ids", + java.util.List.of(first.commentId(), second.commentId()))); + + PostCommentListResponse response = + commentService.getCommentsByPost(post.publicId(), null, 20); + + assertThat(response.comments()) + .extracting(CommentResponse::commentId) + .containsExactly(first.commentId(), second.commentId()); + } + + @Test + @DisplayName("루트별 대댓글은 5개만 프리뷰하고 이후 항목을 분리 API로 조회한다") + void topFivePreviewAndSeparatedReplies() throws Exception { + CommentResponse root = createRoot("프리뷰 루트"); + for (int i = 1; i <= 7; i++) { + createReply(root.commentId(), "대댓글 " + i); + } + + PostCommentListResponse comments = + commentService.getCommentsByPost(post.publicId(), null, 20); + CommentResponse rootResponse = comments.comments().getFirst(); + Long fifthReplyId = rootResponse.previewReplies().getLast().commentId(); + CommentReplyListResponse remainder = + commentService.getCommentReplies(root.commentId(), fifthReplyId, 20); + + assertThat(rootResponse.replyCount()).isEqualTo(7); + assertThat(rootResponse.previewReplies()).hasSize(5); + assertThat(rootResponse.hasMoreReplies()).isTrue(); + assertThat(remainder.replies()) + .extracting(CommentResponse::content) + .containsExactly("대댓글 6", "대댓글 7"); + assertThat(remainder.totalReplyCount()).isEqualTo(7); + assertThat(remainder.hasNext()).isFalse(); + + mockMvc.perform( + get("/api/v1/comments/{commentId}/replies", root.commentId()) + .param("cursor", fifthReplyId.toString()) + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rootCommentId").value(root.commentId())) + .andExpect(jsonPath("$.replies.length()").value(2)); + } + + @Test + @DisplayName("삭제 루트는 활성 대댓글이 있으면 placeholder로 남고 모두 삭제되면 은닉한다") + void deletionVisibilityPolicy() { + CommentResponse root = createRoot("삭제될 루트"); + CommentResponse reply = createReply(root.commentId(), "남아 있는 대댓글"); + commentService.deleteComment(root.commentId(), null, userDetails); + + PostCommentListResponse withActiveReply = + commentService.getCommentsByPost(post.publicId(), null, 20); + assertThat(withActiveReply.comments()).hasSize(1); + assertThat(withActiveReply.comments().getFirst().content()).isEqualTo("삭제된 댓글입니다."); + assertThat(withActiveReply.comments().getFirst().replyCount()).isEqualTo(1); + + commentService.deleteComment(reply.commentId(), null, userDetails); + PostCommentListResponse allDeleted = + commentService.getCommentsByPost(post.publicId(), null, 20); + assertThat(allDeleted.comments()).isEmpty(); + } + + @Test + @DisplayName("조회 응답의 루트 및 프리뷰 컬렉션은 변경할 수 없다") + void responseCollectionsAreImmutable() { + CommentResponse root = createRoot("불변 루트"); + createReply(root.commentId(), "불변 대댓글"); + PostCommentListResponse response = + commentService.getCommentsByPost(post.publicId(), null, 20); + + assertThatThrownBy(() -> response.comments().clear()) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> response.comments().getFirst().previewReplies().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } + } + + @Nested + @DisplayName("실패 시나리오") + class FailureCases { + + @Test + @DisplayName("존재하지 않는 게시글 댓글 조회는 POST_NOT_FOUND를 반환한다") + void postNotFound() { + assertErrorCode( + () -> commentService.getCommentsByPost("missing-public-id", null, 20), + ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("페이지 크기가 허용 범위를 벗어나면 INVALID_INPUT을 반환한다") + void invalidPageSize() throws Exception { + assertErrorCode( + () -> commentService.getCommentsByPost(post.publicId(), null, 0), + ErrorCode.INVALID_INPUT); + assertErrorCode( + () -> commentService.getCommentsByPost(post.publicId(), null, 51), + ErrorCode.INVALID_INPUT); + + mockMvc.perform( + get("/api/v1/posts/{publicId}/comments", post.publicId()) + .param("size", "51")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())); + } + + @Test + @DisplayName("다른 게시글의 루트 커서를 사용하면 COMMENT_NOT_FOUND를 반환한다") + void cursorFromDifferentPost() { + PostResponse anotherPost = createPost("다른 게시글"); + CommentResponse foreignCursor = + commentService.createComment( + anotherPost.publicId(), + request("다른 루트", null), + userDetails, + "127.0.0.1"); + + assertErrorCode( + () -> + commentService.getCommentsByPost( + post.publicId(), foreignCursor.commentId(), 20), + ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("대댓글 ID를 루트 분리 조회 대상으로 사용하면 COMMENT_NOT_FOUND를 반환한다") + void replyCannotBeUsedAsRoot() { + CommentResponse root = createRoot("루트"); + CommentResponse child = createReply(root.commentId(), "대댓글"); + + assertErrorCode( + () -> commentService.getCommentReplies(child.commentId(), null, 20), + ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("다른 루트의 대댓글 커서를 사용하면 COMMENT_NOT_FOUND를 반환한다") + void cursorFromDifferentRoot() { + CommentResponse firstRoot = createRoot("첫 루트"); + CommentResponse secondRoot = createRoot("둘째 루트"); + CommentResponse foreignReply = createReply(secondRoot.commentId(), "다른 루트 대댓글"); + + assertErrorCode( + () -> + commentService.getCommentReplies( + firstRoot.commentId(), foreignReply.commentId(), 20), + ErrorCode.COMMENT_NOT_FOUND); + } + } + + private PostResponse createPost(String title) { + return postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title(title) + .content("본문") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + } + + private CommentResponse createRoot(String content) { + return commentService.createComment( + post.publicId(), request(content, null), userDetails, "211.234.10.20"); + } + + private CommentResponse createReply(Long rootId, String content) { + return commentService.createComment( + post.publicId(), request(content, rootId), userDetails, "175.120.10.20"); + } + + private CommentCreateRequest request(String content, Long parentId) { + return CommentCreateRequest.builder() + .parentId(parentId) + .content(content) + .isAnonymous(false) + .build(); + } + + private void assertErrorCode(Runnable action, ErrorCode errorCode) { + assertThatThrownBy(action::run) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(errorCode); + } +} diff --git a/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..3fecf1a 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -40,17 +40,37 @@ interface PostDetail { interface CommentItem { commentId: number; parentId: number | null; - writerName: string; + writer: WriterInfo | null; + isAnonymous: boolean; + writerIp: string; content: string; isDeleted: boolean; + replyCount: number; + previewReplies: CommentItem[]; + hasMoreReplies: boolean; createdAt: string; - children: CommentItem[]; } interface CommentListResponse { publicId: string; totalCommentCount: number; comments: CommentItem[]; + nextCursor: number | null; + hasNext: boolean; +} + +interface CommentReplyListResponse { + rootCommentId: number; + totalReplyCount: number; + replies: CommentItem[]; + nextCursor: number | null; + hasNext: boolean; +} + +interface ReplyPagingState { + nextCursor: number | null; + hasNext: boolean; + loading: boolean; } export default function PostDetailPage({ params }: { params: Promise<{ publicId: string }> }) { @@ -59,6 +79,10 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const [post, setPost] = useState(null); const [comments, setComments] = useState([]); const [totalCommentCount, setTotalCommentCount] = useState(0); + const [commentNextCursor, setCommentNextCursor] = useState(null); + const [hasNextComments, setHasNextComments] = useState(false); + const [isLoadingMoreComments, setIsLoadingMoreComments] = useState(false); + const [replyPagingByRootId, setReplyPagingByRootId] = useState>({}); const [loading, setLoading] = useState(true); const [errorMsg, setErrorMsg] = useState(""); const [reactionMsg, setReactionMsg] = useState(""); @@ -66,6 +90,7 @@ 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 [currentUserPublicId, setCurrentUserPublicId] = useState(null); @@ -119,19 +144,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 +347,35 @@ 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; + return { + ...comment, + replyCount: comment.replyCount + 1, + previewReplies: comment.hasMoreReplies + ? comment.previewReplies + : [...comment.previewReplies, createdComment], + }; + }), + ); + setTotalCommentCount((current) => current + 1); } else { setNewCommentText(""); setCommentAnonPassword(""); + if (hasNextComments) { + await fetchComments(); + } else { + setComments((current) => [...current, createdComment]); + setTotalCommentCount((current) => current + 1); + } } - await fetchComments(); setPost((current) => (current ? { ...current, commentCount: current.commentCount + 1 } : current)); return; } @@ -445,16 +568,31 @@ 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} handleDeleteComment={handleDeleteComment} + handleLoadMoreReplies={handleLoadMoreReplies} + isLoadingReplies={Boolean(replyPagingByRootId[comment.commentId]?.loading)} /> )) )} + + {hasNextComments && ( + + )} @@ -474,53 +612,98 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: function CommentRow({ item, - depth = 0, isAnonymousPost, currentUserPublicId, activeReplyParentId, setActiveReplyParentId, + replyMentionName, + setReplyMentionName, replyText, setReplyText, replyAnonPassword, setReplyAnonPassword, handleCreateComment, 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; + handleLoadMoreReplies: (rootCommentId: number) => Promise; + isLoadingReplies: boolean; }) { + 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}

- {!item.isDeleted && ( -
- - + {!item.isDeleted && ( + + )} +
+ + {item.previewReplies.length > 0 && ( +
+ {item.previewReplies.map((reply) => ( + openReplyEditor(reply)} + handleDeleteComment={handleDeleteComment} + /> + ))} +
+ )} + + {item.hasMoreReplies && ( +
+
)} {activeReplyParentId === item.commentId && (
+ {replyMentionName && ( +

@{replyMentionName} 님에게 답글

+ )}