From 9270b1c33b47cfc81b3334e08be75de992f8362d Mon Sep 17 00:00:00 2001 From: ikae Date: Sat, 29 Aug 2026 06:25:54 +0900 Subject: [PATCH 01/26] =?UTF-8?q?docs:=20=EB=8C=93=EA=B8=80/=EB=8C=80?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=AA=85=EC=84=B8=EC=84=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(comment=5Fpolicy.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/comment_policy.md | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/specs/comment_policy.md diff --git a/docs/specs/comment_policy.md b/docs/specs/comment_policy.md new file mode 100644 index 0000000..f712d27 --- /dev/null +++ b/docs/specs/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)** ๋…ธ์ถœํ•ฉ๋‹ˆ๋‹ค. + - ๋ฒ ์ŠคํŠธ ๋Œ“๊ธ€์€ ๋ณธ๋ฌธ๋งŒ ์šฐ์„  ๋…ธ์ถœํ•˜๋ฉฐ, "๋‹ต๊ธ€ ์ฝ๊ธฐ" ํด๋ฆญ ์‹œ ๋Œ€๋Œ“๊ธ€์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค. From 003bc17707c06abc20086b40b0edfcdf1a7c0824 Mon Sep 17 00:00:00 2001 From: ikae Date: Sat, 29 Aug 2026 13:04:57 +0900 Subject: [PATCH 02/26] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20Spike=20?= =?UTF-8?q?=EC=8B=A4=ED=97=98=20=EA=B3=B5=ED=86=B5=20=ED=94=BD=EC=8A=A4?= =?UTF-8?q?=EC=B2=98,=20=ED=95=98=EB=84=A4=EC=8A=A4=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EC=84=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spike/CommentSpikeBenchmarkHarness.java | 45 +++++++ .../spike/CommentSpikeDataInitializer.java | 116 ++++++++++++++++++ docs/project/work.md | 5 + 3 files changed, 166 insertions(+) create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeBenchmarkHarness.java create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeDataInitializer.java 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 new file mode 100644 index 0000000..29dc712 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeBenchmarkHarness.java @@ -0,0 +1,45 @@ +package com.ikae.snowthing.domain.comment.spike; + +import java.util.function.Supplier; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +/** Spike ์‹คํ—˜์šฉ ์„ฑ๋Šฅ ๋ฐ ํŽ˜์ด๋กœ๋“œ ํฌ๊ธฐ ์ธก์ • ์œ ํ‹ธ๋ฆฌํ‹ฐ */ +@Slf4j +public class CommentSpikeBenchmarkHarness { + + private static final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + public record BenchmarkResult( + T result, long elapsedNanos, double elapsedMs, int payloadBytes, double payloadKb) {} + + /** ์‹คํ–‰ ์‹œ๊ฐ„ ๋ฐ ์‘๋‹ต JSON ๋ฐ”์ดํŠธ ํฌ๊ธฐ๋ฅผ ์ผ๊ด€๋˜๊ฒŒ ์ธก์ •ํ•ฉ๋‹ˆ๋‹ค. */ + public static BenchmarkResult measure(String testName, Supplier supplier) { + long start = System.nanoTime(); + T result = supplier.get(); + long end = System.nanoTime(); + + long elapsedNanos = end - start; + double elapsedMs = elapsedNanos / 1_000_000.0; + + int payloadBytes = 0; + try { + byte[] bytes = objectMapper.writeValueAsBytes(result); + payloadBytes = bytes.length; + } catch (Exception e) { + log.error("[Benchmark] ์ง๋ ฌํ™” ์‹คํŒจ", e); + } + + double payloadKb = payloadBytes / 1024.0; + + log.info("=========================================================="); + log.info("[Spike Benchmark: {}]", testName); + log.info(" - ์‹คํ–‰ ์‹œ๊ฐ„: {} ms ({} ns)", String.format("%.3f", elapsedMs), elapsedNanos); + log.info(" - ์ง๋ ฌํ™” ํฌ๊ธฐ: {} Bytes ({} KB)", payloadBytes, String.format("%.2f", payloadKb)); + log.info("=========================================================="); + + return new BenchmarkResult<>(result, elapsedNanos, elapsedMs, payloadBytes, payloadKb); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeDataInitializer.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeDataInitializer.java new file mode 100644 index 0000000..76d2321 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeDataInitializer.java @@ -0,0 +1,116 @@ +package com.ikae.snowthing.domain.comment.spike; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.EntityManager; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.post.entity.Post; + +import lombok.RequiredArgsConstructor; + +/** Spike ์‹คํ—˜์šฉ ๋Œ€์šฉ๋Ÿ‰ 1,000๊ฑด ๋Œ“๊ธ€ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ๊ธฐ */ +@Component +@RequiredArgsConstructor +public class CommentSpikeDataInitializer { + + private final EntityManager em; + + /** [์‹œ๋‚˜๋ฆฌ์˜ค A: ๋ถ„์‚ฐ 1,000๊ฑด] - ๋ฃจํŠธ ๋Œ“๊ธ€ 100๊ฐœ - ๊ฐ ๋ฃจํŠธ๋‹น ๋Œ€๋Œ“๊ธ€ 9๊ฐœ์”ฉ (100 * 9 = 900๊ฐœ) - ์ด 1,000๊ฐœ */ + @Transactional + public void setupDistributedScenario(Post post, Member member) { + List roots = new ArrayList<>(); + + // 1. ๋ฃจํŠธ ๋Œ“๊ธ€ 100๊ฐœ ์ƒ์„ฑ + for (int i = 1; i <= 100; i++) { + Comment root = + Comment.builder() + .post(post) + .member(member) + .parent(null) + .content("๋ฃจํŠธ ๋Œ“๊ธ€ #" + i) + .writerIp("127.0.0.1") + .isAnonymous(false) + .build(); + em.persist(root); + roots.add(root); + + if (i % 50 == 0) { + em.flush(); + } + } + em.flush(); + + // 2. ๊ฐ ๋ฃจํŠธ๋‹น ๋Œ€๋Œ“๊ธ€ 9๊ฐœ์”ฉ ์ƒ์„ฑ (์ด 900๊ฐœ) + int replySeq = 1; + for (Comment root : roots) { + for (int r = 1; r <= 9; r++) { + Comment reply = + Comment.builder() + .post(post) + .member(member) + .parent(root) + .content("๋Œ€๋Œ“๊ธ€ #" + (replySeq++) + " (๋ถ€๋ชจ:" + root.getId() + ")") + .writerIp("127.0.0.1") + .isAnonymous(false) + .build(); + em.persist(reply); + } + } + em.flush(); + em.clear(); + } + + /** [์‹œ๋‚˜๋ฆฌ์˜ค B: ์ง‘์ค‘ ํ•ซ์ŠคํŒŸ 1,000๊ฑด] - ๋ฃจํŠธ ๋Œ“๊ธ€ 500๊ฐœ - 1๋ฒˆ ๋ฃจํŠธ ๋Œ“๊ธ€ 1๊ฐœ์— ๋Œ€๋Œ“๊ธ€ 500๊ฐœ ๋ชฐ๋ฆผ - ์ด 1,000๊ฐœ */ + @Transactional + public void setupHotspotScenario(Post post, Member member) { + Comment hotspotRoot = null; + + // 1. ๋ฃจํŠธ ๋Œ“๊ธ€ 500๊ฐœ ์ƒ์„ฑ + for (int i = 1; i <= 500; i++) { + Comment root = + Comment.builder() + .post(post) + .member(member) + .parent(null) + .content("๋ฃจํŠธ ๋Œ“๊ธ€ #" + i) + .writerIp("127.0.0.1") + .isAnonymous(false) + .build(); + em.persist(root); + if (i == 1) { + hotspotRoot = root; + } + + if (i % 50 == 0) { + em.flush(); + } + } + em.flush(); + + // 2. 1๋ฒˆ ๋ฃจํŠธ ๋Œ“๊ธ€์— ๋Œ€๋Œ“๊ธ€ 500๊ฐœ ์ง‘์ค‘ ์ƒ์„ฑ + for (int r = 1; r <= 500; r++) { + Comment reply = + Comment.builder() + .post(post) + .member(member) + .parent(hotspotRoot) + .content("ํ•ซ์ŠคํŒŸ ๋Œ€๋Œ“๊ธ€ #" + r) + .writerIp("127.0.0.1") + .isAnonymous(false) + .build(); + em.persist(reply); + + if (r % 50 == 0) { + em.flush(); + } + } + em.flush(); + em.clear(); + } +} diff --git a/docs/project/work.md b/docs/project/work.md index 0efe4b1..87b9089 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,8 @@ +- **๋Œ“๊ธ€ ์กฐํšŒ ์•„ํ‚คํ…์ฒ˜ 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 ์ง๋ ฌํ™” ํŽ˜์ด๋กœ๋“œ ๋ฐ”์ดํŠธ ํฌ๊ธฐ, ์ฟผ๋ฆฌ ์ˆ˜ ์ธก์ • ๋Ÿฌ๋„ˆ) ๊ตฌ์ถ•. + 3. **๋‹จ์œ„/ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ๊ฒ€์ฆ**: Spotless ํฌ๋งทํŒ…(`spotlessApply`) ๋ฐ ์ „์ฒด 90๊ฐœ ๋ฐฑ์—”๋“œ ๋‹จ์œ„/ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ(`gradle test`) **100% BUILD SUCCESSFUL** ํ†ต๊ณผ. + - **๊ฐ€์ƒ ์Šค๋ ˆ๋“œ(Virtual Thread) ์กฐ๊ธฐ ์ตœ์ ํ™” ์ œ๊ฑฐ ๋ฐ ํ‘œ์ค€ ํ”Œ๋žซํผ ์Šค๋ ˆ๋“œ ํ’€ ์ „ํ™˜ (2026-08-28)**: 1. **๊ฐ€์ƒ ์Šค๋ ˆ๋“œ ์ œ๊ฑฐ ๋ฐ ํ‘œ์ค€ํ™” (YAGNI)**: BCrypt ์•”ํ˜ธํ™” ์—ฐ์‚ฐ ๋ณ‘๋ชฉ, HikariCP ์ปค๋„ฅ์…˜ ํ’€ ๊ณ ๊ฐˆ ์œ„ํ—˜, Thread Pinning ๋“ฑ ์กฐ๊ธฐ ์ตœ์ ํ™”๋กœ ์ธํ•œ ์ž ์žฌ์  ๊ฒฐํ•จ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด, `application.yml`์—์„œ `spring.threads.virtual.enabled: true`๋ฅผ ์™„์ „ ์ œ๊ฑฐํ•˜๊ณ  `spring.jpa.open-in-view: false`๋ฅผ ๋ช…์‹œํ•˜์—ฌ DB ์ปค๋„ฅ์…˜ ์ ์œ  ์ตœ์†Œํ™”. 2. **ํ‘œ์ค€ `ThreadPoolTaskExecutor` ์ ์šฉ**: `AsyncConfig.java`์—์„œ ๊ฐ€์ƒ ์Šค๋ ˆ๋“œ ๋Œ€์‹  ์˜ˆ์ธก ๊ฐ€๋Šฅํ•˜๊ณ  ๊ฒ€์ฆ๋œ ๊ณ ์ • ํ”Œ๋žซํผ ์Šค๋ ˆ๋“œ ํ’€(Core 8, Max 16, Queue 100, Prefix `async-worker-`)๋กœ ์ „ํ™˜. From 5953481fd8d529d9188f771201f5d4fc88930a03 Mon Sep 17 00:00:00 2001 From: ikae Date: Sat, 29 Aug 2026 13:05:08 +0900 Subject: [PATCH 03/26] =?UTF-8?q?docs:=20Spike=20=EC=8B=A4=ED=97=98=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=B0=EA=B3=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/spike_experiment_guide.md | 73 +++++++++++++++++++ .../sprint03/comment/spike_result_template.md | 52 +++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 docs/study/sprint03/comment/spike_experiment_guide.md create mode 100644 docs/study/sprint03/comment/spike_result_template.md diff --git a/docs/study/sprint03/comment/spike_experiment_guide.md b/docs/study/sprint03/comment/spike_experiment_guide.md new file mode 100644 index 0000000..7d2015d --- /dev/null +++ b/docs/study/sprint03/comment/spike_experiment_guide.md @@ -0,0 +1,73 @@ +# ๐Ÿงช ๋Œ“๊ธ€ ์กฐํšŒ ์•„ํ‚คํ…์ฒ˜ Spike ์‹คํ—˜ ๋งˆ์Šคํ„ฐ ๊ฐ€์ด๋“œ + +๋ณธ ๋ฌธ์„œ๋Š” Snowthing ๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€ ๋„๋ฉ”์ธ์˜ ์ตœ์  ์กฐํšŒ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ฒฐ์ •ํ•˜๊ธฐ ์œ„ํ•ด ์ง„ํ–‰ํ•˜๋Š” **3๋Œ€ ํ›„๋ณด ๋™์‹œ Spike ์‹คํ—˜(Spike Benchmark)**์˜ ์„ค๊ณ„, ๊ฐ€์„ค, ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์…‹์—…, ์ธก์ • ๋ฐฉ๋ฒ•๋ก  ๋ฐ ์‹คํ–‰ ์ ˆ์ฐจ๋ฅผ ์ •๋ฆฌํ•œ ๋งˆ์Šคํ„ฐ ๊ฐ€์ด๋“œ์ž…๋‹ˆ๋‹ค. + +--- + +## 1. ์‹คํ—˜ ๋ชฉ์  ๋ฐ ๋ฐฐ๊ฒฝ + +### 1) ์™œ ์ด ์‹คํ—˜์„ ํ•˜๋Š”๊ฐ€? +- ํ˜„์žฌ์˜ "๋‹จ์ผ ์ฟผ๋ฆฌ ์ „์ฒด ๋ฉ”๋ชจ๋ฆฌ ์กฐ๋ฆฝ" ๋ฐฉ์‹์€ ๋Œ“๊ธ€ ์ˆ˜๊ฐ€ ์ ์„ ๋•Œ๋Š” ๋ฌธ์ œ๊ฐ€ ์—†์œผ๋‚˜, ์ธ๊ธฐ ๊ฒŒ์‹œ๊ธ€์ด๋‚˜ ํ•ซ์ŠคํŒŸ ์Šค๋ ˆ๋“œ์—์„œ **์‘๋‹ต ํŽ˜์ด๋กœ๋“œ ํญ์ฆ๊ณผ ์„œ๋ฒ„ OOM(Out of Memory) ์œ„ํ—˜**์„ ์•ˆ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. +- "๋ฃจํŠธ ์ปค์„œ ํŽ˜์ด์ง• + ๋Œ€๋Œ“๊ธ€ Batch" ๋ฐ "ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ํ”„๋ฆฌ๋ทฐ(๋ฃจํŠธ Batch + ๋Œ€๋Œ“๊ธ€ ๋ถ„๋ฆฌ API)"์™€์˜ **์‹ค์ œ ์ฟผ๋ฆฌ ์ˆ˜, ์ฝ์€ ํ–‰ ์ˆ˜, ์ง๋ ฌํ™” ๋ฐ”์ดํŠธ ํฌ๊ธฐ, ์‹คํ–‰ ์‹œ๊ฐ„, ์‹คํ–‰ ๊ณ„ํš(EXPLAIN)**์„ ์ˆ˜์น˜๋กœ ์ง์ ‘ ์ธก์ •ํ•˜์—ฌ ๊ฐ€์žฅ ๊ท ํ˜• ์žกํžŒ ์•„ํ‚คํ…์ฒ˜๋ฅผ ์„ ํƒํ•˜๊ธฐ ์œ„ํ•จ์ž…๋‹ˆ๋‹ค. + +### 2) Spike ์‹คํ—˜ ๊ทœ์น™ (Timeboxing & Fairness) +- **Timebox**: ํ›„๋ณด 1๊ฐœ๋‹น ์ตœ๋Œ€ 3~4์‹œ๊ฐ„(๋ฐ˜๋‚˜์ ˆ) ์ด๋‚ด๋กœ ์ตœ์†Œ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์ฝ”๋“œ(PoC)๋งŒ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. +- **๊ณต์ •์„ฑ ๋ณด์žฅ (Fairness)**: 3๊ฐœ ๋ธŒ๋žœ์น˜ ๋ชจ๋‘ **๋™์ผํ•œ ๋ฐ์ดํ„ฐ์…‹(๋ถ„์‚ฐ 1,000๊ฑด / ์ง‘์ค‘ 500๊ฑด)**๊ณผ **๋™์ผํ•œ ์ธก์ • ๋„๊ตฌ(`CommentSpikeBenchmarkHarness`)**๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +- **๊ฒฉ๋ฆฌ ์‹คํ–‰**: Git Worktree๋ฅผ ํ†ตํ•ด 3๊ฐœ์˜ ๋…๋ฆฝ๋œ ๋ธŒ๋žœ์น˜(`spike/candidate-1-in-memory`, `spike/candidate-2-root-cursor-batch`, `spike/candidate-3-hybrid-preview`)์—์„œ ๋ณ‘๋ ฌ๋กœ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +--- + +## 2. ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ์…‹ ๊ตฌ์„ฑ (์ด 1,000๊ฑด Fixture) + +`CommentSpikeDataInitializer`๋ฅผ ํ†ตํ•ด ๋‹ค์Œ 2๊ฐ€์ง€ ๊ทน๋‹จ์  ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ DB์— ์ฃผ์ž…ํ•˜์—ฌ ์ธก์ •ํ•ฉ๋‹ˆ๋‹ค: + +### ๐Ÿ“ฆ ์‹œ๋‚˜๋ฆฌ์˜ค A: [๋ถ„์‚ฐ ๋ฐ์ดํ„ฐ์…‹] ์ผ๋ฐ˜ ์ปค๋ฎค๋‹ˆํ‹ฐ ๋ถ„ํฌ +- **๊ฒŒ์‹œ๊ธ€**: 1๊ฐœ (`post_id = 999`) +- **๋ฃจํŠธ ๋Œ“๊ธ€**: 100๊ฐœ (`parent_id IS NULL`) +- **๋Œ€๋Œ“๊ธ€**: 900๊ฐœ (100๊ฐœ ๋ฃจํŠธ ๋Œ“๊ธ€์— ๊ฐ๊ฐ 9๊ฐœ์”ฉ ๊ท ๋“ฑ ๋ถ„์‚ฐ) +- **์ด ๋Œ“๊ธ€ ์ˆ˜**: 1,000๊ฐœ +- **๊ฒ€์ฆ ๋ชฉ์ **: ์ผ์ƒ์ ์ธ ๊ฒŒ์‹œ๊ธ€ ํ™˜๊ฒฝ์—์„œ ๋ฃจํŠธ ํŽ˜์ด์ง•๊ณผ ๋Œ€๋Œ“๊ธ€ Batch ์กฐํšŒ์˜ ํŽ˜์ด๋กœ๋“œ ๋ฐ ์ฟผ๋ฆฌ ํšจ์œจ ๊ฒ€์ฆ. + +### ๐Ÿ“ฆ ์‹œ๋‚˜๋ฆฌ์˜ค B: [์ง‘์ค‘ ํ•ซ์ŠคํŒŸ ๋ฐ์ดํ„ฐ์…‹] ๋…ผ์Ÿ๊ธ€/์ธ๊ธฐ ๋Œ“๊ธ€ ์ ๋ฆผ +- **๊ฒŒ์‹œ๊ธ€**: 1๊ฐœ (`post_id = 999`) +- **๋ฃจํŠธ ๋Œ“๊ธ€**: 500๊ฐœ (`parent_id IS NULL`) +- **๋Œ€๋Œ“๊ธ€**: 500๊ฐœ (**์˜ค์ง `1๋ฒˆ ๋ฃจํŠธ ๋Œ“๊ธ€` 1๊ฐœ์— ๋Œ€๋Œ“๊ธ€ 500๊ฐœ๊ฐ€ ์ „๋ถ€ ๋ชฐ๋ ค์žˆ๋Š” ๊ทน๋‹จ์  ํ•ซ์ŠคํŒŸ**) +- **์ด ๋Œ“๊ธ€ ์ˆ˜**: 1,000๊ฐœ +- **๊ฒ€์ฆ ๋ชฉ์ **: ํŠน์ • ๋Œ“๊ธ€์— ๋Œ€๋Œ“๊ธ€์ด ์ˆ˜๋ฐฑ ๊ฐœ ๋ชฐ๋ ธ์„ ๋•Œ, ๊ฐ ์•„ํ‚คํ…์ฒ˜๊ฐ€ ์‘๋‹ต ํฌ๊ธฐ์™€ ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ํ†ต์ œํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ํ•œ๊ณ„ ๊ฒ€์ฆ. + +--- + +## 3. ์‹คํ—˜ ๋Œ€์ƒ 3๋Œ€ ํ›„๋ณด๊ตฐ + +``` +1. [ํ›„๋ณด 1 : spike/candidate-1-in-memory] + - findByPostIdWithMember ๋‹จ์ผ ์ฟผ๋ฆฌ๋กœ 1,000๊ฑด ์ „์ฒด ๋กœ๋”ฉ -> Java Map 2-Depth ์กฐ๋ฆฝ -> DTO ๋ฐ˜ํ™˜ + +2. [ํ›„๋ณด 2 : spike/candidate-2-root-cursor-batch] + - ๋ฃจํŠธ ๋Œ“๊ธ€ 20๊ฐœ ์ปค์„œ ํŽ˜์ด์ง• (1st Query) + - WHERE parent_id IN (20๊ฐœ IDs) ๋Œ€๋Œ“๊ธ€ ์ „์ฒด ์ผ๊ด„ ์กฐํšŒ (2nd Query) -> DTO ๋ฐ˜ํ™˜ + +3. [ํ›„๋ณด 3 : spike/candidate-3-hybrid-preview] + - ๋ฃจํŠธ ๋Œ“๊ธ€ 20๊ฐœ ์กฐํšŒ + ๊ฐ ๋ฃจํŠธ๋‹น ๋Œ€๋Œ“๊ธ€ ์ƒ์œ„ 5๊ฐœ๋งŒ Batch ์กฐํšŒ (์ด 2ํšŒ ์ฟผ๋ฆฌ, ์‘๋‹ต ํฌ๊ธฐ ์ตœ๋Œ€ 120๊ฐœ ๊ณ ์ •) + - 5๊ฐœ ์ดˆ๊ณผ ๋Œ€๋Œ“๊ธ€ "๋”๋ณด๊ธฐ" ํด๋ฆญ ์‹œ GET /api/v1/comments/{commentId}/replies?cursor=... ๋ถ„๋ฆฌ ํŽ˜์ด์ง• (3rd Query) +``` + +--- + +## 4. ์ธก์ • ๋ฐฉ๋ฒ•๋ก  ๋ฐ 5๋Œ€ ํ•ต์‹ฌ ์ง€ํ‘œ + +`CommentSpikeBenchmarkHarness`๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ์•„๋ž˜ ์ง€ํ‘œ๋ฅผ ์ฝ˜์†” ๋กœ๊ทธ๋กœ ์ถ”์ถœํ•˜๊ณ  ๋ณด๊ณ ์„œ์— ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค: + +1. **์‹คํ–‰ SQL ์ฟผ๋ฆฌ ์ˆ˜ (Query Count)**: 1ํšŒ API ํ˜ธ์ถœ ์‹œ DB๋กœ ์ „์†ก๋œ ์‹ค์ œ ์ฟผ๋ฆฌ ํšŸ์ˆ˜. +2. **์ฝ์€ Row ์ˆ˜ (Fetched Rows)**: DB๊ฐ€ ๋ฉ”๋ชจ๋ฆฌ๋กœ ๋ฐ˜ํ™˜ํ•œ ์‹ค์ œ ์—”ํ‹ฐํ‹ฐ/ํ–‰ ๊ฐœ์ˆ˜. +3. **์‘๋‹ต ์ง๋ ฌํ™” ํŽ˜์ด๋กœ๋“œ ํฌ๊ธฐ (JSON Payload Bytes)**: `ObjectMapper.writeValueAsBytes`๋กœ ์ธก์ •ํ•œ ์‹ค์ œ ๋„คํŠธ์›Œํฌ ์ „์†ก ํฌ๊ธฐ. +4. **์‹คํ–‰ ์‹œ๊ฐ„ (Elapsed Time)**: Service ๋ฉ”์„œ๋“œ ์ง„์ž…๋ถ€ํ„ฐ DTO ๋ฐ˜ํ™˜๊นŒ์ง€์˜ ์†Œ์š” ์‹œ๊ฐ„ ($ms$). +5. **MySQL EXPLAIN ์‹คํ–‰ ๊ณ„ํš**: ์ธ๋ฑ์Šค Scan ์œ ํ˜• (`ref`, `range`, `ALL` ๋“ฑ) ํ™•์ธ. + +--- + +## 5. ๊ฒฐ๊ณผ ์ž‘์„ฑ ๋ฐ ์ทจํ•ฉ ์ ˆ์ฐจ + +1. ๊ฐ ์›ŒํฌํŠธ๋ฆฌ ํ„ฐ๋ฏธ๋„์—์„œ ๊ตฌํ˜„ ๋ฐ ํ…Œ์ŠคํŠธ ์‹คํ–‰ +2. ์ธก์ •๋œ ์ˆ˜์น˜๋ฅผ `docs/study/sprint03/comment/spike_result_candidate_{1|2|3}.md`๋กœ ์ž‘์„ฑ ๋ฐ ์ปค๋ฐ‹ +3. 3๊ฐœ ๋ธŒ๋žœ์น˜ ์‹คํ—˜์ด ์™„๋ฃŒ๋˜๋ฉด ๋ฉ”์ธ ๋ธŒ๋žœ์น˜์—์„œ ์ตœ์ข… ADR-001 5์žฅ(Spike ๊ฒฐ๊ณผ)์— ํ†ตํ•ฉ ๋ฐ˜์˜ diff --git a/docs/study/sprint03/comment/spike_result_template.md b/docs/study/sprint03/comment/spike_result_template.md new file mode 100644 index 0000000..c467f41 --- /dev/null +++ b/docs/study/sprint03/comment/spike_result_template.md @@ -0,0 +1,52 @@ +# [Spike ๊ฒฐ๊ณผ ๋ณด๊ณ ์„œ] ํ›„๋ณด {๋ฒˆํ˜ธ}: {ํ›„๋ณด๋ช…} + +- **๋ธŒ๋žœ์น˜๋ช…**: `spike/candidate-{๋ฒˆํ˜ธ}-{์ด๋ฆ„}` +- **์ธก์ • ์ผ์‹œ**: 2026-08-29 +- **์ž‘์„ฑ์ž**: devikae + +--- + +## 1. ๊ตฌํ˜„ ์š”์•ฝ (PoC Implementation) +- **์กฐํšŒ ๋ฐฉ์‹ ๋ฐ ์ฟผ๋ฆฌ ์ „๋žต**: + - (์˜ˆ: ๋‹จ์ผ ์ฟผ๋ฆฌ ์ „์ฒด ๋กœ๋”ฉ / ๋ฃจํŠธ ์ปค์„œ ํŽ˜์ด์ง• + ๋Œ€๋Œ“๊ธ€ IN์ ˆ Batch / ๋ฃจํŠธ 20๊ฐœ + 5๊ฐœ ํ”„๋ฆฌ๋ทฐ Batch + ๋ถ„๋ฆฌ API) +- **์ฃผ์š” ๋ณ€๊ฒฝ/์ž‘์„ฑ ํด๋ž˜์Šค**: + - `CommentSpikeService.java` + - `CommentSpikeRepository.java` + +--- + +## 2. ์ธก์ • ๊ฒฐ๊ณผ ๋ฐ์ดํ„ฐ ๋งคํŠธ๋ฆญ์Šค + +| ์‹œ๋‚˜๋ฆฌ์˜ค | ์ฟผ๋ฆฌ ์ˆ˜ (Count) | ์ฝ์€ Row ์ˆ˜ (Rows) | ์‘๋‹ต ํฌ๊ธฐ (Bytes / KB) | ์‹คํ–‰ ์‹œ๊ฐ„ (Elapsed ms) | +| :--- | :---: | :---: | :---: | :---: | +| **[์‹œ๋‚˜๋ฆฌ์˜ค A] ๋ถ„์‚ฐ 1,000๊ฑด**
(๋ฃจํŠธ 100๊ฐœ + ๋Œ€๋Œ“๊ธ€ 900๊ฐœ) | ํšŒ | ํ–‰ | KB | ms | +| **[์‹œ๋‚˜๋ฆฌ์˜ค B] ์ง‘์ค‘ 500๊ฑด ํ•ซ์ŠคํŒŸ**
(๋ฃจํŠธ 500๊ฐœ + 1๋ฒˆ์— ๋Œ€๋Œ“๊ธ€ 500๊ฐœ) | ํšŒ | ํ–‰ | KB | ms | +| **[๋”๋ณด๊ธฐ ํ˜ธ์ถœ ์‹œ]** (ํ•ด๋‹น ์‹œ) | ํšŒ | ํ–‰ | KB | ms | + +--- + +## 3. ์‹คํ–‰๋œ ์‹ค์ œ SQL ๋ฐ MySQL EXPLAIN + +### 1) ์‹คํ–‰ ์ฟผ๋ฆฌ 1 (๋ฃจํŠธ ๋˜๋Š” ์ „์ฒด) +```sql +-- ์‹คํ–‰๋œ ์‹ค์ œ ์ฟผ๋ฆฌ +``` +- **EXPLAIN ๋ถ„์„**: `type = ref`, `key = idx_...`, `rows = ...` + +### 2) ์‹คํ–‰ ์ฟผ๋ฆฌ 2 (๋Œ€๋Œ“๊ธ€ Batch - ํ•ด๋‹น ์‹œ) +```sql +-- ์‹คํ–‰๋œ ์‹ค์ œ ์ฟผ๋ฆฌ +``` +- **EXPLAIN ๋ถ„์„**: `type = range / ref`, `key = idx_...`, `rows = ...` + +--- + +## 4. ๋ฐœ๊ฒฌ๋œ ๊ฒฐํ•จ ๋ฐ ํ•œ๊ณ„์  (Issues & Bottlenecks) +- **๋ฉ”๋ชจ๋ฆฌ/ํŽ˜์ด๋กœ๋“œ ๊ด€๋ จ**: +- **ํŽ˜์ด์ง• ๋ฐ ํ•ซ์ŠคํŒŸ ๊ด€๋ จ**: +- **์ฝ”๋“œ ๋ณต์žก๋„ ๊ด€๋ จ**: + +--- + +## 5. ์ตœ์ข… ํ‰๊ฐ€ ๋ฐ ์†Œ๊ฒฌ +- (ํ•ด๋‹น ํ›„๋ณด์˜ ์žฅ๋‹จ์  ๋ฐ ADR ๋ฐ˜์˜ ์˜๊ฒฌ ์ž‘์„ฑ) From 54749ff56300e49bda5e8ae2a5b86693fbdaf811 Mon Sep 17 00:00:00 2001 From: ikae Date: Sat, 29 Aug 2026 13:16:51 +0900 Subject: [PATCH 04/26] =?UTF-8?q?feat:=20Spike=20=ED=95=98=EB=84=A4?= =?UTF-8?q?=EC=8A=A4=20EXPLAIN=20=EB=B0=8F=20=EB=A7=88=ED=81=AC=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EC=97=85?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C,=20DB=20Seeder=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spike/CommentSpikeBenchmarkHarness.java | 198 +++++++++++++++++- .../spike/CommentSpikeDataSeederTest.java | 115 ++++++++++ database/spike_seed_comments.sql | 101 +++++++++ .../{ => test}/spike_experiment_guide.md | 21 +- .../{ => test}/spike_result_template.md | 0 5 files changed, 420 insertions(+), 15 deletions(-) create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeDataSeederTest.java create mode 100644 database/spike_seed_comments.sql rename docs/study/sprint03/comment/{ => test}/spike_experiment_guide.md (70%) rename docs/study/sprint03/comment/{ => test}/spike_result_template.md (100%) 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 29dc712..f0e6500 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 @@ -1,22 +1,57 @@ package com.ikae.snowthing.domain.comment.spike; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import java.util.function.Supplier; +import jakarta.persistence.EntityManager; + import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; -/** Spike ์‹คํ—˜์šฉ ์„ฑ๋Šฅ ๋ฐ ํŽ˜์ด๋กœ๋“œ ํฌ๊ธฐ ์ธก์ • ์œ ํ‹ธ๋ฆฌํ‹ฐ */ +/** Spike ์‹คํ—˜์šฉ ์„ฑ๋Šฅ, ํŽ˜์ด๋กœ๋“œ ํฌ๊ธฐ, SQL ๋กœ๊น… ๋ฐ EXPLAIN ๋ถ„์„ ์ž๋™ํ™” ํ•˜๋„ค์Šค */ @Slf4j public class CommentSpikeBenchmarkHarness { private static final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); - public record BenchmarkResult( - T result, long elapsedNanos, double elapsedMs, int payloadBytes, double payloadKb) {} + public record ExplainRow( + String id, + String selectType, + String table, + String type, + String possibleKeys, + String key, + String keyLen, + String ref, + String rows, + String filtered, + String extra) {} + + public record ScenarioMetric( + String scenarioName, + int queryCount, + int fetchedRows, + double elapsedMs, + int payloadBytes, + double payloadKb, + List executedSqls, + List> explains) {} + + /** ์‹คํ–‰ ์‹œ๊ฐ„ ๋ฐ ์‘๋‹ต JSON ์ง๋ ฌํ™” ๋ฐ”์ดํŠธ ํฌ๊ธฐ๋ฅผ ์ธก์ •ํ•ฉ๋‹ˆ๋‹ค. */ + public static ScenarioMetric measureScenario( + String scenarioName, + int queryCount, + int fetchedRows, + Supplier supplier, + List executedSqls, + EntityManager em) { - /** ์‹คํ–‰ ์‹œ๊ฐ„ ๋ฐ ์‘๋‹ต JSON ๋ฐ”์ดํŠธ ํฌ๊ธฐ๋ฅผ ์ผ๊ด€๋˜๊ฒŒ ์ธก์ •ํ•ฉ๋‹ˆ๋‹ค. */ - public static BenchmarkResult measure(String testName, Supplier supplier) { long start = System.nanoTime(); T result = supplier.get(); long end = System.nanoTime(); @@ -31,15 +66,160 @@ public static BenchmarkResult measure(String testName, Supplier suppli } catch (Exception e) { log.error("[Benchmark] ์ง๋ ฌํ™” ์‹คํŒจ", e); } - double payloadKb = payloadBytes / 1024.0; + // EXPLAIN ์‹คํ–‰ + List> explains = new ArrayList<>(); + if (em != null && executedSqls != null) { + for (String sql : executedSqls) { + try { + @SuppressWarnings("unchecked") + List rows = em.createNativeQuery("EXPLAIN " + sql).getResultList(); + List explainList = new ArrayList<>(); + for (Object[] r : rows) { + 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]))); + } + explains.add(explainList); + } catch (Exception e) { + log.warn("[EXPLAIN] ์‹คํ–‰ ๋ถˆ๊ฐ€ ๋˜๋Š” ๋ฌด์‹œ: {}", e.getMessage()); + } + } + } + log.info("=========================================================="); - log.info("[Spike Benchmark: {}]", testName); - log.info(" - ์‹คํ–‰ ์‹œ๊ฐ„: {} ms ({} ns)", String.format("%.3f", elapsedMs), elapsedNanos); + log.info("[Spike Benchmark: {}]", scenarioName); + log.info(" - ์ฟผ๋ฆฌ ์ˆ˜: {} ํšŒ | ์ฝ์€ ํ–‰: {} ํ–‰", queryCount, fetchedRows); + log.info(" - ์‹คํ–‰ ์‹œ๊ฐ„: {} ms", String.format("%.3f", elapsedMs)); log.info(" - ์ง๋ ฌํ™” ํฌ๊ธฐ: {} Bytes ({} KB)", payloadBytes, String.format("%.2f", payloadKb)); log.info("=========================================================="); - return new BenchmarkResult<>(result, elapsedNanos, elapsedMs, payloadBytes, payloadKb); + return new ScenarioMetric( + scenarioName, + queryCount, + fetchedRows, + elapsedMs, + payloadBytes, + payloadKb, + executedSqls, + explains); + } + + /** + * ์ธก์ •๋œ ๋ชจ๋“  ์‹œ๋‚˜๋ฆฌ์˜ค ๊ฒฐ๊ณผ๋ฅผ docs/study/sprint03/comment/test/spike_result_candidate_{candidateNumber}.md + * ๋งˆํฌ๋‹ค์šด ํŒŒ์ผ๋กœ ์ž๋™ ์ƒ์„ฑ/์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + */ + public static void generateAndSaveReport( + int candidateNumber, + String candidateName, + String branchName, + String implementationSummary, + List metrics, + String issuesAndBottlenecks, + String evaluation) { + + StringBuilder sb = new StringBuilder(); + sb.append("# [Spike ๊ฒฐ๊ณผ ๋ณด๊ณ ์„œ] ํ›„๋ณด ") + .append(candidateNumber) + .append(": ") + .append(candidateName) + .append("\n\n"); + sb.append("- **๋ธŒ๋žœ์น˜๋ช…**: `").append(branchName).append("`\n"); + sb.append("- **์ธก์ • ์ผ์‹œ**: ").append(LocalDate.now()).append("\n"); + sb.append("- **์ž‘์„ฑ์ž**: devikae (์ž๋™ ์ƒ์„ฑ)\n\n"); + sb.append("---\n\n"); + + sb.append("## 1. ๊ตฌํ˜„ ์š”์•ฝ (PoC Implementation)\n"); + sb.append(implementationSummary).append("\n\n"); + sb.append("---\n\n"); + + sb.append("## 2. ์ธก์ • ๊ฒฐ๊ณผ ๋ฐ์ดํ„ฐ ๋งคํŠธ๋ฆญ์Šค\n\n"); + sb.append( + "| ์‹œ๋‚˜๋ฆฌ์˜ค | ์ฟผ๋ฆฌ ์ˆ˜ (Count) | ์ฝ์€ Row ์ˆ˜ (Rows) | ์‘๋‹ต ํฌ๊ธฐ (Bytes / KB) | ์‹คํ–‰ ์‹œ๊ฐ„ (Elapsed ms) |\n"); + sb.append("| :--- | :---: | :---: | :---: | :---: |\n"); + + for (ScenarioMetric m : metrics) { + sb.append("| **") + .append(m.scenarioName()) + .append("** | ") + .append(m.queryCount()) + .append("ํšŒ | ") + .append(m.fetchedRows()) + .append("ํ–‰ | ") + .append(m.payloadBytes()) + .append(" B (") + .append(String.format("%.2f", m.payloadKb())) + .append(" KB) | ") + .append(String.format("%.3f", m.elapsedMs())) + .append(" ms |\n"); + } + sb.append("\n---\n\n"); + + sb.append("## 3. ์‹คํ–‰๋œ ์‹ค์ œ SQL ๋ฐ MySQL EXPLAIN\n\n"); + int sIdx = 1; + for (ScenarioMetric m : metrics) { + sb.append("### ").append(sIdx++).append(") ").append(m.scenarioName()).append("\n\n"); + if (m.executedSqls() != null && !m.executedSqls().isEmpty()) { + for (int q = 0; q < m.executedSqls().size(); q++) { + sb.append("#### [Query ").append(q + 1).append("]\n"); + sb.append("```sql\n").append(m.executedSqls().get(q)).append("\n```\n\n"); + + if (m.explains() != null && m.explains().size() > q) { + List exList = m.explains().get(q); + sb.append("**EXPLAIN ๋ถ„์„**:\n\n"); + sb.append("| table | type | key | rows | Extra |\n"); + sb.append("| :--- | :--- | :--- | :--- | :--- |\n"); + for (ExplainRow ex : exList) { + sb.append("| ") + .append(ex.table()) + .append(" | ") + .append(ex.type()) + .append(" | ") + .append(ex.key()) + .append(" | ") + .append(ex.rows()) + .append(" | ") + .append(ex.extra()) + .append(" |\n"); + } + sb.append("\n"); + } + } + } + } + sb.append("---\n\n"); + + sb.append("## 4. ๋ฐœ๊ฒฌ๋œ ๊ฒฐํ•จ ๋ฐ ํ•œ๊ณ„์  (Issues & Bottlenecks)\n"); + sb.append(issuesAndBottlenecks).append("\n\n"); + sb.append("---\n\n"); + + sb.append("## 5. ์ตœ์ข… ํ‰๊ฐ€ ๋ฐ ์†Œ๊ฒฌ\n"); + sb.append(evaluation).append("\n"); + + try { + Path targetDir = Paths.get("..", "docs", "study", "sprint03", "comment", "test"); + if (!Files.exists(targetDir)) { + targetDir = Paths.get("docs", "study", "sprint03", "comment", "test"); + } + Files.createDirectories(targetDir); + + Path targetFile = + targetDir.resolve("spike_result_candidate_" + candidateNumber + ".md"); + Files.writeString(targetFile, sb.toString()); + log.info("[Spike Report Generated] -> {}", targetFile.toAbsolutePath()); + } catch (Exception e) { + log.error("[Spike Report] ํŒŒ์ผ ์ €์žฅ ์‹คํŒจ", e); + } } } diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeDataSeederTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeDataSeederTest.java new file mode 100644 index 0000000..6fcb2f9 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/spike/CommentSpikeDataSeederTest.java @@ -0,0 +1,115 @@ +package com.ikae.snowthing.domain.comment.spike; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.Rollback; +import org.springframework.transaction.annotation.Transactional; + +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.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 lombok.extern.slf4j.Slf4j; + +/** ์‹ค์ œ ๋กœ์ปฌ MySQL DB์— Spike์šฉ 1,000๊ฑด ๋ฐ์ดํ„ฐ๋ฅผ ์ง์ ‘ ์ƒ์„ฑํ•˜๊ณ  ์˜์†ํ™”(Commit)ํ•˜๋Š” Seeder ๋Ÿฌ๋„ˆ */ +@Slf4j +@Disabled("์ „์ฒด ๋นŒ๋“œ ์‹œ ์ž๋™ ์‹คํ–‰ ๋ฐฉ์ง€ (Spike ๋ฐ์ดํ„ฐ ์ฃผ์ž… ์‹œ์—๋งŒ ์ˆ˜๋™ ์‹คํ–‰)") +@SpringBootTest +public class CommentSpikeDataSeederTest { + + @Autowired private MemberRepository memberRepository; + + @Autowired private PostCategoryRepository postCategoryRepository; + + @Autowired private PostRepository postRepository; + + @Autowired private CommentSpikeDataInitializer dataInitializer; + + @Test + @DisplayName("์‹ค์ œ DB์— Spike ์‹œ๋‚˜๋ฆฌ์˜ค A(๋ถ„์‚ฐ 1,000๊ฑด) & ์‹œ๋‚˜๋ฆฌ์˜ค B(ํ•ซ์ŠคํŒŸ 1,000๊ฑด) ๋ฐ์ดํ„ฐ ์ƒ์„ฑ") + @Transactional + @Rollback(false) // ์‹ค์ œ DB์— ์˜๊ตฌ ์ปค๋ฐ‹ + void seedAllSpikeData() { + // 1. ๊ธฐ๋ณธ ํšŒ์› ์ƒ์„ฑ + Member member = + memberRepository + .findByEmail("spike@snowthing.com") + .orElseGet( + () -> + memberRepository.save( + Member.builder() + .email("spike@snowthing.com") + .password( + "$2a$10$dummyHashValueForSpikeTestingOnly1234567890") + .nickname("์ŠคํŒŒ์ดํฌํ…Œ์Šคํ„ฐ") + .role(Role.ROLE_USER) + .status(MemberStatus.ACTIVE) + .build())); + + // 2. ๊ธฐ๋ณธ ์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ + PostCategory category = + postCategoryRepository + .findByCode("FREE") + .orElseGet( + () -> + postCategoryRepository.save( + PostCategory.builder() + .name("์ž์œ ๊ฒŒ์‹œํŒ") + .code("FREE") + .build())); + + // 3. Post 998 (๋ถ„์‚ฐ 1,000๊ฑด์šฉ ๊ฒŒ์‹œ๊ธ€) + Post postDistributed = + postRepository + .findByPublicId("post-spike-distributed-998") + .orElseGet( + () -> + postRepository.save( + Post.builder() + .member(member) + .category(category) + .title("Spike [์‹œ๋‚˜๋ฆฌ์˜ค A] ๋ถ„์‚ฐ 1,000๊ฑด ํ…Œ์ŠคํŠธ ๊ธ€") + .content("๋‚ด์šฉ") + .writerIp("127.0.0.1") + .isAnonymous(false) + .build())); + + // 4. Post 999 (์ง‘์ค‘ 1,000๊ฑด์šฉ ๊ฒŒ์‹œ๊ธ€) + Post postHotspot = + postRepository + .findByPublicId("post-spike-hotspot-999") + .orElseGet( + () -> + postRepository.save( + Post.builder() + .member(member) + .category(category) + .title("Spike [์‹œ๋‚˜๋ฆฌ์˜ค B] ํ•ซ์ŠคํŒŸ 500๊ฑด ์ง‘์ค‘ ํ…Œ์ŠคํŠธ ๊ธ€") + .content("๋‚ด์šฉ") + .writerIp("127.0.0.1") + .isAnonymous(false) + .build())); + + log.info("========== Spike ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ์‹œ์ž‘ =========="); + dataInitializer.setupDistributedScenario(postDistributed, member); + log.info( + "[์™„๋ฃŒ] ์‹œ๋‚˜๋ฆฌ์˜ค A (Post ID: {}, PublicId: {}) - ๋ถ„์‚ฐ 1,000๊ฑด ์ƒ์„ฑ ์™„๋ฃŒ", + postDistributed.getId(), + postDistributed.getPublicId()); + + dataInitializer.setupHotspotScenario(postHotspot, member); + log.info( + "[์™„๋ฃŒ] ์‹œ๋‚˜๋ฆฌ์˜ค B (Post ID: {}, PublicId: {}) - ํ•ซ์ŠคํŒŸ 1,000๊ฑด ์ƒ์„ฑ ์™„๋ฃŒ", + postHotspot.getId(), + postHotspot.getPublicId()); + log.info("========== Spike ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ์ข…๋ฃŒ =========="); + } +} diff --git a/database/spike_seed_comments.sql b/database/spike_seed_comments.sql new file mode 100644 index 0000000..c0123be --- /dev/null +++ b/database/spike_seed_comments.sql @@ -0,0 +1,101 @@ +-- ============================================================================== +-- [Spike ์‹คํ—˜์šฉ ๋Œ€์šฉ๋Ÿ‰ 1,000๊ฑด ๋Œ“๊ธ€ ๋ฐ์ดํ„ฐ ์‹œ๋“œ ์Šคํฌ๋ฆฝํŠธ (MySQL 8.0 ์ „์šฉ)] +-- ============================================================================== + +USE `snowthing`; + +-- 0. ๊ธฐ์กด Spike ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” +DELETE FROM `comment` WHERE `post_id` IN (998, 999); +DELETE FROM `post` WHERE `post_id` IN (998, 999); + +-- 1. ํ…Œ์ŠคํŠธ์šฉ ๊ธฐ๋ณธ ์นดํ…Œ๊ณ ๋ฆฌ ๋ฐ ํšŒ์› ํ™•์ธ/์ƒ์„ฑ +INSERT IGNORE INTO `post_category` (`category_id`, `name`, `code`) VALUES (1, '์ž์œ ๊ฒŒ์‹œํŒ', 'FREE'); +INSERT IGNORE INTO `member` (`member_id`, `public_id`, `email`, `password_hash`, `nickname`, `role`, `status`, `created_at`, `updated_at`) +VALUES (1, 'member-spike-001', 'spike@snowthing.com', '$2a$10$dummyHashValueForSpikeTestingOnly1234567890', '์ŠคํŒŒ์ดํฌํ…Œ์Šคํ„ฐ', 'ROLE_USER', 'ACTIVE', NOW(), NOW()); + +-- 2. ํ…Œ์ŠคํŠธ์šฉ ๊ฒŒ์‹œ๊ธ€ 2๊ฐœ ์ƒ์„ฑ +-- Post 998: ์‹œ๋‚˜๋ฆฌ์˜ค A (๋ถ„์‚ฐ 1,000๊ฑด์šฉ) +INSERT INTO `post` (`post_id`, `public_id`, `member_id`, `category_id`, `title`, `content`, `writer_ip`, `is_anonymous`, `comment_count`, `created_at`, `updated_at`) +VALUES (998, 'post-spike-distributed-998', 1, 1, 'Spike [์‹œ๋‚˜๋ฆฌ์˜ค A] ๋ถ„์‚ฐ 1,000๊ฑด ํ…Œ์ŠคํŠธ ๊ธ€', '๋‚ด์šฉ', '127.0.0.1', FALSE, 1000, NOW(), NOW()); + +-- Post 999: ์‹œ๋‚˜๋ฆฌ์˜ค B (์ง‘์ค‘ ํ•ซ์ŠคํŒŸ 1,000๊ฑด์šฉ) +INSERT INTO `post` (`post_id`, `public_id`, `member_id`, `category_id`, `title`, `content`, `writer_ip`, `is_anonymous`, `comment_count`, `created_at`, `updated_at`) +VALUES (999, 'post-spike-hotspot-999', 1, 1, 'Spike [์‹œ๋‚˜๋ฆฌ์˜ค B] ํ•ซ์ŠคํŒŸ 500๊ฑด ์ง‘์ค‘ ํ…Œ์ŠคํŠธ ๊ธ€', '๋‚ด์šฉ', '127.0.0.1', FALSE, 1000, NOW(), NOW()); + +-- ============================================================================== +-- [์‹œ๋‚˜๋ฆฌ์˜ค A] Post 998 : ๋ฃจํŠธ ๋Œ“๊ธ€ 100๊ฐœ + ๊ฐ ๋ฃจํŠธ๋‹น ๋Œ€๋Œ“๊ธ€ 9๊ฐœ = ์ด 1,000๊ฐœ +-- ============================================================================== +DROP PROCEDURE IF EXISTS InsertDistributedComments; +DELIMITER $$ +CREATE PROCEDURE InsertDistributedComments() +BEGIN + DECLARE root_idx INT DEFAULT 1; + DECLARE reply_idx INT DEFAULT 1; + DECLARE current_root_id BIGINT; + + -- 1. ๋ฃจํŠธ ๋Œ“๊ธ€ 100๊ฐœ ์ƒ์„ฑ + WHILE root_idx <= 100 DO + INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) + VALUES (998, 1, NULL, CONCAT('๋ฃจํŠธ ๋Œ“๊ธ€ #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); + + SET current_root_id = LAST_INSERT_ID(); + + -- 2. ๊ฐ ๋ฃจํŠธ๋‹น ๋Œ€๋Œ“๊ธ€ 9๊ฐœ์”ฉ ์ƒ์„ฑ (์ด 900๊ฐœ) + SET reply_idx = 1; + WHILE reply_idx <= 9 DO + INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) + VALUES (998, 1, current_root_id, CONCAT('๋Œ€๋Œ“๊ธ€ #', reply_idx, ' (๋ถ€๋ชจ:', current_root_id, ')'), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (root_idx * 10 + reply_idx) SECOND, NOW()); + SET reply_idx = reply_idx + 1; + END WHILE; + + SET root_idx = root_idx + 1; + END WHILE; +END$$ +DELIMITER ; + +CALL InsertDistributedComments(); +DROP PROCEDURE IF EXISTS InsertDistributedComments; + + +-- ============================================================================== +-- [์‹œ๋‚˜๋ฆฌ์˜ค B] Post 999 : ๋ฃจํŠธ ๋Œ“๊ธ€ 500๊ฐœ + 1๋ฒˆ ๋ฃจํŠธ์— ๋Œ€๋Œ“๊ธ€ 500๊ฐœ ์ง‘์ค‘ = ์ด 1,000๊ฐœ +-- ============================================================================== +DROP PROCEDURE IF EXISTS InsertHotspotComments; +DELIMITER $$ +CREATE PROCEDURE InsertHotspotComments() +BEGIN + DECLARE root_idx INT DEFAULT 1; + DECLARE reply_idx INT DEFAULT 1; + DECLARE hotspot_root_id BIGINT; + + -- 1. ๋ฃจํŠธ ๋Œ“๊ธ€ 500๊ฐœ ์ƒ์„ฑ + WHILE root_idx <= 500 DO + INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) + VALUES (999, 1, NULL, CONCAT('๋ฃจํŠธ ๋Œ“๊ธ€ #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); + + IF root_idx = 1 THEN + SET hotspot_root_id = LAST_INSERT_ID(); + END IF; + + SET root_idx = root_idx + 1; + END WHILE; + + -- 2. 1๋ฒˆ ๋ฃจํŠธ ๋Œ“๊ธ€์— ๋Œ€๋Œ“๊ธ€ 500๊ฐœ ์ง‘์ค‘ ์ƒ์„ฑ + WHILE reply_idx <= 500 DO + INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) + VALUES (999, 1, hotspot_root_id, CONCAT('ํ•ซ์ŠคํŒŸ ๋Œ€๋Œ“๊ธ€ #', reply_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (500 + reply_idx) SECOND, NOW()); + SET reply_idx = reply_idx + 1; + END WHILE; +END$$ +DELIMITER ; + +CALL InsertHotspotComments(); +DROP PROCEDURE IF EXISTS InsertHotspotComments; + +-- ์ตœ์ข… ์ƒ์„ฑ ๊ฑด์ˆ˜ ํ™•์ธ +SELECT `post_id`, COUNT(*) AS total_comments, + SUM(CASE WHEN `parent_id` IS NULL THEN 1 ELSE 0 END) AS root_count, + SUM(CASE WHEN `parent_id` IS NOT NULL THEN 1 ELSE 0 END) AS reply_count +FROM `comment` +WHERE `post_id` IN (998, 999) +GROUP BY `post_id`; diff --git a/docs/study/sprint03/comment/spike_experiment_guide.md b/docs/study/sprint03/comment/test/spike_experiment_guide.md similarity index 70% rename from docs/study/sprint03/comment/spike_experiment_guide.md rename to docs/study/sprint03/comment/test/spike_experiment_guide.md index 7d2015d..4617516 100644 --- a/docs/study/sprint03/comment/spike_experiment_guide.md +++ b/docs/study/sprint03/comment/test/spike_experiment_guide.md @@ -10,16 +10,25 @@ - ํ˜„์žฌ์˜ "๋‹จ์ผ ์ฟผ๋ฆฌ ์ „์ฒด ๋ฉ”๋ชจ๋ฆฌ ์กฐ๋ฆฝ" ๋ฐฉ์‹์€ ๋Œ“๊ธ€ ์ˆ˜๊ฐ€ ์ ์„ ๋•Œ๋Š” ๋ฌธ์ œ๊ฐ€ ์—†์œผ๋‚˜, ์ธ๊ธฐ ๊ฒŒ์‹œ๊ธ€์ด๋‚˜ ํ•ซ์ŠคํŒŸ ์Šค๋ ˆ๋“œ์—์„œ **์‘๋‹ต ํŽ˜์ด๋กœ๋“œ ํญ์ฆ๊ณผ ์„œ๋ฒ„ OOM(Out of Memory) ์œ„ํ—˜**์„ ์•ˆ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. - "๋ฃจํŠธ ์ปค์„œ ํŽ˜์ด์ง• + ๋Œ€๋Œ“๊ธ€ Batch" ๋ฐ "ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ํ”„๋ฆฌ๋ทฐ(๋ฃจํŠธ Batch + ๋Œ€๋Œ“๊ธ€ ๋ถ„๋ฆฌ API)"์™€์˜ **์‹ค์ œ ์ฟผ๋ฆฌ ์ˆ˜, ์ฝ์€ ํ–‰ ์ˆ˜, ์ง๋ ฌํ™” ๋ฐ”์ดํŠธ ํฌ๊ธฐ, ์‹คํ–‰ ์‹œ๊ฐ„, ์‹คํ–‰ ๊ณ„ํš(EXPLAIN)**์„ ์ˆ˜์น˜๋กœ ์ง์ ‘ ์ธก์ •ํ•˜์—ฌ ๊ฐ€์žฅ ๊ท ํ˜• ์žกํžŒ ์•„ํ‚คํ…์ฒ˜๋ฅผ ์„ ํƒํ•˜๊ธฐ ์œ„ํ•จ์ž…๋‹ˆ๋‹ค. -### 2) Spike ์‹คํ—˜ ๊ทœ์น™ (Timeboxing & Fairness) -- **Timebox**: ํ›„๋ณด 1๊ฐœ๋‹น ์ตœ๋Œ€ 3~4์‹œ๊ฐ„(๋ฐ˜๋‚˜์ ˆ) ์ด๋‚ด๋กœ ์ตœ์†Œ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์ฝ”๋“œ(PoC)๋งŒ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. -- **๊ณต์ •์„ฑ ๋ณด์žฅ (Fairness)**: 3๊ฐœ ๋ธŒ๋žœ์น˜ ๋ชจ๋‘ **๋™์ผํ•œ ๋ฐ์ดํ„ฐ์…‹(๋ถ„์‚ฐ 1,000๊ฑด / ์ง‘์ค‘ 500๊ฑด)**๊ณผ **๋™์ผํ•œ ์ธก์ • ๋„๊ตฌ(`CommentSpikeBenchmarkHarness`)**๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. -- **๊ฒฉ๋ฆฌ ์‹คํ–‰**: Git Worktree๋ฅผ ํ†ตํ•ด 3๊ฐœ์˜ ๋…๋ฆฝ๋œ ๋ธŒ๋žœ์น˜(`spike/candidate-1-in-memory`, `spike/candidate-2-root-cursor-batch`, `spike/candidate-3-hybrid-preview`)์—์„œ ๋ณ‘๋ ฌ๋กœ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค. +### 2) Spike 6๋Œ€ ์‹คํ—˜ ๊ทœ์น™ (Engineering Rules) +1. **์‹คํ—˜๊ตฐ ์„ ํƒ ์™„๋ฃŒ**: [1. ๋ฉ”๋ชจ๋ฆฌ ํŠธ๋ฆฌ ์กฐ๋ฆฝ], [2. ๋ฃจํŠธ ์ปค์„œ + ๋Œ€๋Œ“๊ธ€ Batch], [3. ๋ฃจํŠธ Batch + ๋Œ€๋Œ“๊ธ€ API (ํ•˜์ด๋ธŒ๋ฆฌ๋“œ)] 3๊ฐ€์ง€ ํ›„๋ณด๋ฅผ ์„ ์ •ํ•จ. +2. **๋ฐ˜๋‚˜์ ˆ ํƒ€์ž„๋ฐ•์Šค(Timebox)**: ํ›„๋ณด 1๊ฐœ๋‹น ์ตœ๋Œ€ 3~4์‹œ๊ฐ„(๋ฐ˜๋‚˜์ ˆ) ์ด๋‚ด๋กœ ์ตœ์†Œ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ PoC ์ฝ”๋“œ๋งŒ ์ž‘์„ฑํ•˜์—ฌ ๋น ๋ฅด๊ฒŒ ์ธก์ •ํ•จ. +3. **๋ฏธ์™„์„ฑ PoC ๊ตฌํ˜„**: ์˜ˆ์™ธ ์ฒ˜๋ฆฌ, ํ”„๋ก ํŠธ ์—ฐ๋™ ๋“ฑ ๋ถˆํ•„์š”ํ•œ ์ฝ”๋“œ๋ฅผ ๋ฐฐ์ œํ•˜๊ณ  ์˜ค์ง ์ฟผ๋ฆฌ์™€ DTO ๋งคํ•‘ ๊ธฐ๋Šฅ๋งŒ ๊ฒ€์ฆํ•จ. +4. **๋™์ผ ๋ฐ์ดํ„ฐ ๋ฐ ๋™์ผ ์ธก์ • ๊ธฐ์ค€**: ์‹ค์ œ MySQL DB์— ์ฃผ์ž…๋œ ๋™์ผํ•œ 1,000๊ฑด ๋ฐ์ดํ„ฐ์…‹๊ณผ ๋™์ผํ•œ ์ธก์ • ํ•˜๋„ค์Šค(`CommentSpikeBenchmarkHarness`)๋ฅผ ์‚ฌ์šฉํ•จ. +5. **5๋Œ€ ํ•„์ˆ˜ ์ง€ํ‘œ ๊ธฐ๋ก**: ์‹คํ–‰ SQL, ์ฟผ๋ฆฌ ์ˆ˜, ์ฝ์€ Row ์ˆ˜, ์‘๋‹ต ์ง๋ ฌํ™” ๋ฐ”์ดํŠธ ํฌ๊ธฐ, MySQL `EXPLAIN` ์‹คํ–‰ ๊ณ„ํš์„ ๋ฐ˜๋“œ์‹œ ๋‚จ๊น€. +6. **์‹คํ—˜ ์ฝ”๋“œ ๊ฒฉ๋ฆฌ ๋ฐ ์ •๋ฆฌ**: ์‹คํ—˜ ์ฝ”๋“œ๋Š” 3๊ฐœ ๋…๋ฆฝ ์›ŒํฌํŠธ๋ฆฌ ๋ธŒ๋žœ์น˜์— ์œ ์ง€ํ•˜๋ฉฐ, ์ตœ์ข… ์„ ํƒ์•ˆ๋งŒ ๋ฉ”์ธ PR์— ๋ณ‘ํ•ฉํ•˜๊ณ  ๋‚˜๋จธ์ง€๋Š” ํ๊ธฐํ•จ. --- -## 2. ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ์…‹ ๊ตฌ์„ฑ (์ด 1,000๊ฑด Fixture) +## 2. ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ์…‹ ๊ตฌ์„ฑ ๋ฐ ์‹ค์ œ DB ์ฃผ์ž… ๋ฐฉ๋ฒ• -`CommentSpikeDataInitializer`๋ฅผ ํ†ตํ•ด ๋‹ค์Œ 2๊ฐ€์ง€ ๊ทน๋‹จ์  ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ DB์— ์ฃผ์ž…ํ•˜์—ฌ ์ธก์ •ํ•ฉ๋‹ˆ๋‹ค: +์‹ค์ œ MySQL 8.0 DB(`snowthing`)์— 1,000๊ฑด์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์ ์žฌํ•˜์—ฌ ์‹ค์ œ ์ฟผ๋ฆฌ ์‹คํ–‰ ๊ณ„ํš๊ณผ DB I/O๋ฅผ ์ธก์ •ํ•ฉ๋‹ˆ๋‹ค. + +### 1) DB ์ฃผ์ž… ๋ฐฉ๋ฒ• (2๊ฐ€์ง€ ์ค‘ ํƒ 1) +- **๋ฐฉ๋ฒ• 1 (SQL ์ง์ ‘ ์‹คํ–‰)**: `database/spike_seed_comments.sql`์„ DBeaver, DataGrip ๋˜๋Š” MySQL CLI์—์„œ ์ง์ ‘ ์‹คํ–‰. +- **๋ฐฉ๋ฒ• 2 (ํ…Œ์ŠคํŠธ ๋Ÿฌ๋„ˆ ์‹คํ–‰)**: `./gradlew.bat test --tests *CommentSpikeDataSeederTest*` ์‹คํ–‰. + +### 2) 2๋Œ€ ๋ฐ์ดํ„ฐ ์‹œ๋‚˜๋ฆฌ์˜ค ์ƒ์„ธ ### ๐Ÿ“ฆ ์‹œ๋‚˜๋ฆฌ์˜ค A: [๋ถ„์‚ฐ ๋ฐ์ดํ„ฐ์…‹] ์ผ๋ฐ˜ ์ปค๋ฎค๋‹ˆํ‹ฐ ๋ถ„ํฌ - **๊ฒŒ์‹œ๊ธ€**: 1๊ฐœ (`post_id = 999`) diff --git a/docs/study/sprint03/comment/spike_result_template.md b/docs/study/sprint03/comment/test/spike_result_template.md similarity index 100% rename from docs/study/sprint03/comment/spike_result_template.md rename to docs/study/sprint03/comment/test/spike_result_template.md From 5ad065d040899c0e84170a6089590ddb083d4f01 Mon Sep 17 00:00:00 2001 From: ikae Date: Sat, 29 Aug 2026 13:21:45 +0900 Subject: [PATCH 05/26] =?UTF-8?q?docs:=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=82=B4=20post=5Fid(998/999)=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=20=ED=8C=8C=EC=9D=BC=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/study/sprint03/comment/test/spike_experiment_guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/study/sprint03/comment/test/spike_experiment_guide.md b/docs/study/sprint03/comment/test/spike_experiment_guide.md index 4617516..caf3798 100644 --- a/docs/study/sprint03/comment/test/spike_experiment_guide.md +++ b/docs/study/sprint03/comment/test/spike_experiment_guide.md @@ -31,14 +31,14 @@ ### 2) 2๋Œ€ ๋ฐ์ดํ„ฐ ์‹œ๋‚˜๋ฆฌ์˜ค ์ƒ์„ธ ### ๐Ÿ“ฆ ์‹œ๋‚˜๋ฆฌ์˜ค A: [๋ถ„์‚ฐ ๋ฐ์ดํ„ฐ์…‹] ์ผ๋ฐ˜ ์ปค๋ฎค๋‹ˆํ‹ฐ ๋ถ„ํฌ -- **๊ฒŒ์‹œ๊ธ€**: 1๊ฐœ (`post_id = 999`) +- **๊ฒŒ์‹œ๊ธ€**: 1๊ฐœ (`post_id = 998`, `public_id = post-spike-distributed-998`) - **๋ฃจํŠธ ๋Œ“๊ธ€**: 100๊ฐœ (`parent_id IS NULL`) - **๋Œ€๋Œ“๊ธ€**: 900๊ฐœ (100๊ฐœ ๋ฃจํŠธ ๋Œ“๊ธ€์— ๊ฐ๊ฐ 9๊ฐœ์”ฉ ๊ท ๋“ฑ ๋ถ„์‚ฐ) - **์ด ๋Œ“๊ธ€ ์ˆ˜**: 1,000๊ฐœ - **๊ฒ€์ฆ ๋ชฉ์ **: ์ผ์ƒ์ ์ธ ๊ฒŒ์‹œ๊ธ€ ํ™˜๊ฒฝ์—์„œ ๋ฃจํŠธ ํŽ˜์ด์ง•๊ณผ ๋Œ€๋Œ“๊ธ€ Batch ์กฐํšŒ์˜ ํŽ˜์ด๋กœ๋“œ ๋ฐ ์ฟผ๋ฆฌ ํšจ์œจ ๊ฒ€์ฆ. ### ๐Ÿ“ฆ ์‹œ๋‚˜๋ฆฌ์˜ค B: [์ง‘์ค‘ ํ•ซ์ŠคํŒŸ ๋ฐ์ดํ„ฐ์…‹] ๋…ผ์Ÿ๊ธ€/์ธ๊ธฐ ๋Œ“๊ธ€ ์ ๋ฆผ -- **๊ฒŒ์‹œ๊ธ€**: 1๊ฐœ (`post_id = 999`) +- **๊ฒŒ์‹œ๊ธ€**: 1๊ฐœ (`post_id = 999`, `public_id = post-spike-hotspot-999`) - **๋ฃจํŠธ ๋Œ“๊ธ€**: 500๊ฐœ (`parent_id IS NULL`) - **๋Œ€๋Œ“๊ธ€**: 500๊ฐœ (**์˜ค์ง `1๋ฒˆ ๋ฃจํŠธ ๋Œ“๊ธ€` 1๊ฐœ์— ๋Œ€๋Œ“๊ธ€ 500๊ฐœ๊ฐ€ ์ „๋ถ€ ๋ชฐ๋ ค์žˆ๋Š” ๊ทน๋‹จ์  ํ•ซ์ŠคํŒŸ**) - **์ด ๋Œ“๊ธ€ ์ˆ˜**: 1,000๊ฐœ @@ -78,5 +78,5 @@ ## 5. ๊ฒฐ๊ณผ ์ž‘์„ฑ ๋ฐ ์ทจํ•ฉ ์ ˆ์ฐจ 1. ๊ฐ ์›ŒํฌํŠธ๋ฆฌ ํ„ฐ๋ฏธ๋„์—์„œ ๊ตฌํ˜„ ๋ฐ ํ…Œ์ŠคํŠธ ์‹คํ–‰ -2. ์ธก์ •๋œ ์ˆ˜์น˜๋ฅผ `docs/study/sprint03/comment/spike_result_candidate_{1|2|3}.md`๋กœ ์ž‘์„ฑ ๋ฐ ์ปค๋ฐ‹ +2. ์ธก์ •๋œ ์ˆ˜์น˜๋ฅผ `docs/study/sprint03/comment/test/spike_result_candidate_{1|2|3}.md`๋กœ ์ž‘์„ฑ ๋ฐ ์ปค๋ฐ‹ 3. 3๊ฐœ ๋ธŒ๋žœ์น˜ ์‹คํ—˜์ด ์™„๋ฃŒ๋˜๋ฉด ๋ฉ”์ธ ๋ธŒ๋žœ์น˜์—์„œ ์ตœ์ข… ADR-001 5์žฅ(Spike ๊ฒฐ๊ณผ)์— ํ†ตํ•ฉ ๋ฐ˜์˜ From 0e54658c80f8156b0d5424411d244cffea7da446 Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 1 Sep 2026 15:38:57 +0900 Subject: [PATCH 06/26] =?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 07/26] =?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 08/26] =?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 09/26] =?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} ๋‹˜์—๊ฒŒ ๋‹ต๊ธ€

+ )}