Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9270b1c
docs: 댓글/대댓글 도메인 정책 명세서 추가 (comment_policy.md)
devikae Aug 28, 2026
003bc17
feat: 댓글 조회 아키텍처 Spike 실험 공통 픽스처, 하네스 및 가이드 문서 구축
devikae Aug 29, 2026
5953481
docs: Spike 실험 마스터 가이드 및 결과 템플릿 추가
devikae Aug 29, 2026
54749ff
feat: Spike 하네스 EXPLAIN 및 마크다운 자동 생성 업그레이드, DB Seeder 추가
devikae Aug 29, 2026
5ad065d
docs: 가이드 문서 내 post_id(998/999) 및 결과 파일 경로 오타 수정
devikae Aug 29, 2026
0e54658
docs: Sprint 03 댓글 도메인 공식 설계 문서(ADR-001, 정책 명세서) 및 공통 픽스처 추가
devikae Sep 1, 2026
052784e
docs: Sprint 03 공식 댓글 API 명세서 추가 및 C(생성) 기능 구현 커밋
devikae Sep 1, 2026
33fef4d
feat(comment): 댓글 생성 동시성 제어 및 MySQL 테스트 보강
devikae Sep 1, 2026
bfbb5e6
feat(comment): 댓글 조회 API 및 프론트엔드 연동
devikae Sep 1, 2026
a4490a9
chore(comment): 댓글 실행환경 및 데이터베이스 설정 반영
devikae Sep 1, 2026
4396c24
docs(comment): Sprint 03 댓글 C/R 작업 기록 반영
devikae Sep 1, 2026
834b313
ci: Gemini PR 코드 리뷰 수동 명령어(/gemini-review) 안정화
devikae Sep 1, 2026
b82aa89
feat: 댓글 수정(PUT /api/v1/comments/{id}) 기능 및 단위/통합 테스트 구현
devikae Sep 1, 2026
87e27e2
feat: 댓글 삭제(Soft Delete)기능 개발 및 단위/통합 테스트 구현
devikae Sep 1, 2026
3992f72
feat(comment): 댓글 삭제 모달 UI 연동
devikae Sep 1, 2026
3c624c6
feat(comment): 댓글 인라인 수정 UI 구현
devikae Sep 1, 2026
a930fc6
perf(comment): 대댓글 복합 인덱스 is_deleted 선행 최적화 및 DB 환경변수 동기화
devikae Sep 2, 2026
31bf804
fix(seed,config): 스파이크 시드 안전화, DB 유저명 동기화 및 테스트 환경변수 보강
devikae Sep 2, 2026
0e48e4c
docs(comment): 댓글 아키텍처 문서 및 README 정리
devikae Sep 3, 2026
0fb576c
feat(comment): 댓글 인라인 삭제 UI 및 시간 아래 플로팅 팝오버 적용
devikae Sep 3, 2026
391aedb
chore: next-env.d.ts 라우트 타입 동기화 및 구버전 문서 정리
devikae Sep 4, 2026
14ff543
refactor(comment): PR #14 코드리뷰 피드백 반영
devikae Sep 4, 2026
e763171
PR #15 피드백 반영, 댓글 기능 및 테스트 환경 통합
devikae Sep 6, 2026
b43f53c
PR #16 피드백 반영/ 댓글삭제 및 카운트 정책 정합성 보강
devikae Sep 6, 2026
46f49ec
refactor(comment): PR #15 코드리뷰 피드백 반영
devikae Sep 6, 2026
ccd8823
Merge pull request #14 from devikae/feature/sprint03-comment-cr
devikae Sep 6, 2026
2fb7ad4
chore(comment): feature/sprint03-comment 병합 및 충돌 해결
devikae Sep 6, 2026
7cf98a7
Merge pull request #15 from devikae/feature/sprint03-comment-u
devikae Sep 6, 2026
a9b974e
chore(comment): feature/sprint03-comment 병합 및 충돌 해결
devikae Sep 6, 2026
483c907
Merge pull request #16 from devikae/feature/sprint03-comment-d
devikae Sep 6, 2026
4ff2fa5
docs(work): Sprint 03 다중 PR 순차 머지 및 베이스 브랜치 통합 완료 기록
devikae Sep 6, 2026
33562c2
chore: sync origin/main into feature/sprint03-comment
devikae Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Copy this file to .env and replace the placeholders with local-only values.
# Never commit the generated .env file.
SNOWTHING_DB_USERNAME=snowuser
SNOWTHING_DB_PASSWORD=replace-with-a-local-password
SNOWTHING_DB_ROOT_PASSWORD=replace-with-a-different-root-password

# Required credentials for CommentCreateTest and CommentUpdateTest against the MySQL test schema.
# Export these process environment variables before running either test; Gradle does not load .env automatically:
SNOWTHING_TEST_DB_URL=jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
SNOWTHING_TEST_DB_USERNAME=snowuser
SNOWTHING_TEST_DB_PASSWORD=replace-with-a-local-test-password
19 changes: 19 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ jobs:
runs-on: ubuntu-latest
needs: spotless

services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: snowthing_test
MYSQL_USER: snowuser
MYSQL_PASSWORD: snowthing_pass_2026!
MYSQL_ROOT_PASSWORD: snowthing_root_2026!
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5

env:
SNOWTHING_DB_USERNAME: snowuser
SNOWTHING_DB_PASSWORD: snowthing_pass_2026!
SNOWTHING_TEST_DB_URL: jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
SNOWTHING_TEST_DB_USERNAME: snowuser
SNOWTHING_TEST_DB_PASSWORD: snowthing_pass_2026!

defaults:
run:
working-directory: ./backend
Expand Down
188 changes: 181 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* **Build & Test**: Gradle, JUnit5, Mockito, MockMvc

### Database & Cache
* **RDBMS**: H2 (In-Memory Dev), MySQL 8.0 (Production)
* **RDBMS**: MySQL 8.0 (local, test, and production)
* **In-Memory Cache**: Spring Session Redis (Scale-out Ready)

---
Expand Down Expand Up @@ -55,7 +55,7 @@ sequenceDiagram
Server->>Session: request.changeSessionId() 호출! (세션 식별자 교체)
Session-->>Server: 신규 32자리 JSESSIONID 발급 (기존 스키장/성향 검색 필터 세션 데이터 유지)

Server-->>Client: Set-Cookie: JSESSIONID=A1B2...; Path=/; HttpOnly; SameSite=Lax
Server-->>Client: 신규 세션 쿠키 발급 (JSESSIONID, HttpOnly, SameSite=Lax)
Client-->>User: 로그인 성공 (메인 프로필 대시보드 전환)

Note over User, Server: 4. 인증된 API 요청 (프로필 조회/수정)
Expand All @@ -69,12 +69,12 @@ sequenceDiagram
Client->>Server: POST /api/auth/logout
Server->>Server: 1) ThreadLocal.clearContext() 청소
Server->>Session: 2) session.invalidate() 톰캣 세션 파기
Server-->>Client: 3) Set-Cookie: JSESSIONID=; Max-Age=0 (쿠키 즉시 만료)
Server-->>Client: 3) JSESSIONID 쿠키 만료 응답 (Max-Age=0)
```

---

### 핵심 아키텍처 고민 및 기술적 의사결정
### 아키텍처 고민 및 기술적 의사결정

#### 1. 공통 엔티티와 JPA Auditing (`@EnableJpaAuditing`) 도입

Expand Down Expand Up @@ -111,7 +111,7 @@ sequenceDiagram

---

## 4. 게시판(Post) 도메인 설계 & 핵심 기술적 의사결정 (Board Architecture & Decisions)
## 4. 게시판(Post) 도메인 설계 & 기술적 의사결정 (Board Architecture & Decisions)

게시판은 Snowthing에서 가장 자주 읽히는 도메인이다. 그래서 단순 CRUD로만 만들지 않고, 목록 조회 비용, 익명 글 권한, 삭제 정책, 이미지 첨부 상태까지 같이 맞춰서 설계했다.

Expand Down Expand Up @@ -268,13 +268,187 @@ Page<Post> findByCategoryCodeWithMemberAndCategory(@Param("categoryCode") String
- `INVALID_PAGE_LIMIT (400)`: offset page 제한을 넘긴 요청

### 11) CSRF

게시글 생성, 수정, 삭제 같은 CUD 요청은 CSRF 공격 표적이 되기 쉽다.
Spring Security의 `CookieCsrfTokenRepository.withHttpOnlyFalse()`를 적용했다.
이 방식은 Double Submit Cookie 패턴으로 동작한다. 백엔드가 `XSRF-TOKEN` 쿠키를 발급하면, 프론트엔드가 자원 변경 요청(POST, PUT, DELETE)을 보낼 때 쿠키 값을 읽어 `X-XSRF-TOKEN` HTTP 헤더에 담아서 보낸다. 서버의 `CsrfFilter`는 쿠키의 토큰 값과 헤더의 토큰 값이 일치하는지 비교하여 검증한다.
외부 해킹 사이트는 동일 출처 정책(SOP) 제약으로 인해 사용자의 `XSRF-TOKEN` 쿠키를 자바스크립트로 읽을 수 없어 `X-XSRF-TOKEN` 헤더를 생성하지 못하므로 위조된 요청은 403 Forbidden으로 차단된다.

---

## 5. 댓글(Comment) 도메인 설계 & 기술적 의사결정

댓글은 게시글 상세 화면에서 가장 자주 읽히는 데이터다. 그래서 단순히 `post_id`로 전체 댓글을 가져오는 방식 대신, 루트 댓글과 대댓글을 나누고 초기 응답 크기를 제한하는 구조로 설계했다.

자세한 후보 비교와 실행계획은 [ADR-001 댓글 아키텍처](docs/conception/sprint03/ADR-001-댓글아키텍처.md), [댓글 API 명세](docs/conception/sprint03/comment_api_spec.md), [기술부채 해결 기록](docs/conception/sprint03/기술부채%20해결_4.md)에 정리했다.

### 1) 댓글 도메인 구조

댓글 엔티티는 `Comment` 하나로 둔다. 별도의 대댓글 `Reply` 엔티티를 만들지 않고, 하나의 `comment` 테이블에서 `parent_id`로 루트 댓글과 대댓글을 표현한다.

- 루트 댓글: `parent_id = null`
- 대댓글: `parent_id = 루트 댓글 ID`
- 대댓글의 대댓글: 서버에서 최상위 루트 댓글 ID로 평탄화

무한 계층을 허용하지 않은 이유는 화면과 쿼리 비용 때문이다. 댓글 깊이가 3단계 이상으로 늘어나면 모바일 화면에서 들여쓰기와 접힘 처리가 복잡해지고, DB 조회도 재귀 구조나 별도 계층 테이블을 고민해야 한다.

현재의 프로젝트에서는 댓글과 대댓글 2단계면 대화 흐름을 표현하기에 충분하다고 판단했다.

### 2) 게시글과 댓글의 관계

게시글과 댓글은 `Post 1 : N Comment` 관계. 댓글은 반드시 하나의 게시글에 속하고, 게시글은 여러 댓글을 가질 수 있다.

```text
Post
└─ Comment(parent_id = null)
└─ Comment(parent_id = root_comment_id)
```

`post.comment_count`는 매번 댓글 테이블을 `COUNT(*)` 하지 않기 위한 역정규화 필드.

댓글 생성과 삭제 시 같은 트랜잭션에서 증감시켜 목록 화면에서 댓글 수를 빠르게 보여준다.

이 선택은 읽기 성능을 얻는 대신, 댓글 저장/삭제 실패와 카운트 갱신 실패의 경계를 반드시 같은 트랜잭션 안에 묶어야 하는 트레이드오프가 있다.

### 3) 댓글 상태와 유형

댓글 상태는 크게 정상 댓글과 Soft Delete 댓글로 나뉜다.

- 정상 댓글: 목록과 상세 화면에 그대로 노출된다.
- 삭제된 댓글: DB row는 남기고 `is_deleted = true`, `deleted_at`을 기록한다.

작성 유형은 세 가지다.

- 로그인 일반 댓글: 회원 ID를 남기고 닉네임 표시
- 로그인 익명 댓글: 회원 ID는 서버에 남기되 화면에서는 익명 표시
- 비로그인 익명 댓글: 작성 IP와 익명 비밀번호 해시로 삭제 권한을 검증한다.

삭제된 루트 댓글은 활성 대댓글 유무에 따라 다르게 처리한다.

```text
삭제된 루트댓글 + 활성 대댓글 없음 -> 목록에서 숨김
삭제된 루트댓글 + 활성 대댓글 있음 -> 루트 댓글은 "삭제된 댓글입니다."로 표시하고 활성 대댓글은 그대로 표시
```

### 4) 댓글 조회 페이지네이션 방식

댓글 조회는 cursor pagination을 사용한다.

```http
GET /api/v1/posts/{publicId}/comments?cursor={commentId}&size=20
GET /api/v1/comments/{commentId}/replies?cursor={commentId}&size=20
```

게시글 댓글 목록은 루트 댓글 20개를 먼저 조회하고, 각 루트 댓글의 대댓글은 5개까지만 같이 보여준다. 대댓글이 5개를 넘으면 사용자가 더보기를 눌렀을 때 대댓글 전용 API로 20개씩 추가 조회한다.

정렬 기준은 루트 댓글과 대댓글 모두 같다.

```sql
ORDER BY created_at ASC, comment_id ASC
```


### 5) 조회 아키텍처 후보 비교

댓글 조회 구조는 같은 데이터셋과 같은 정책으로 후보 1, 2, 3을 Spike 실험한 뒤 결정했다.

| 후보 | 방식 | 장점 | 단점 및 트레이드오프 | 판단 |
| :--- | :--- | :--- | :--- | :--- |
| 후보 1 | 전체 댓글을 한 번에 조회하고 메모리에서 트리 조립 | 쿼리 1회로 끝나 구현이 단순함 | 댓글 수가 늘수록 응답 크기와 메모리 사용량이 같이 증가함 | 기각 |
| 후보 2 | 루트 댓글 20개 조회 후 해당 루트의 대댓글 전체를 Batch 조회 | 루트 댓글 수를 제한하고 N+1을 피할 수 있음 | 특정 루트에 대댓글이 몰리면 초기 응답이 다시 커짐 | 기각 |
| 후보 3 | 루트 댓글 20개 + 루트별 대댓글 5개 프리뷰 + 대댓글 분리 API | 초기 응답 크기를 제한하고 핫스팟 댓글에도 대응 가능 | 대댓글 전용 API와 부모별 Top-N 쿼리가 필요함 | 채택 |

실측 결과도 후보 3이 가장 안정적이었다.

| 시나리오 | 후보 1 | 후보 2 | 후보 3 |
| :--- | :---: | :---: | :---: |
| 분산 데이터(Post 998) 응답 크기 | 210.44 KB | 39.87 KB | 22.03 KB |
| 핫스팟 데이터(Post 999) 응답 크기 | 205.84 KB | 103.70 KB | 5.55 KB |
| 핫스팟 데이터 읽은 행 수 | 1,000행 | 520행 | 25행 |

후보 3은 API가 하나 늘어나지만 댓글 조회 시 대댓글 500개를 한 번에 읽어오는 상황을 피할 수 있었다.

커뮤니티 서비스에서는 댓글이 많은 글도 빠르게 보여줘야 한다고 생각해서, 초기 응답 크기를 제한하는 방식을 생각했다.

### 6) 선택한 방식의 기술부채

해당 방식을 선택하면서 다음 기술부채가 남았다.

1. 부모별 Top-5 조회를 위한 MySQL 8.0 `ROW_NUMBER() OVER (PARTITION BY parent_id)`.
2. 게시글 댓글 조회 API 외에 대댓글 전용 페이징 API의 별도 관리.
3. `ORDER BY created_at ASC, comment_id ASC` 정렬을 안정적으로 처리하기 위한 복합 인덱스.
4. MySQL 실행계획에서 윈도우 함수 처리로 `Using temporary`, `Using filesort`가 일부 남을 수 있다.


### 7) 기술부채 개선 내용

부모별 Top-5 프리뷰는 MySQL 8.0 윈도우 함수로 구현했다.

```sql
ROW_NUMBER() OVER (
PARTITION BY c.parent_id
ORDER BY c.created_at ASC, c.comment_id ASC
) AS rn
```

대댓글 전용 API는 `GET /api/v1/comments/{commentId}/replies`로 분리했고, `size + 1`개를 조회해 `hasNext`를 판단한다.

읽기 성능을 위해 복합 인덱스도 보강했다.

```text
(post_id, parent_id, created_at, comment_id)
(parent_id, is_deleted, created_at, comment_id)
```

두 번째 인덱스에서 `is_deleted`는 `parent_id` 다음에 둔다. 특정 루트의 대댓글 범위를 먼저 좁힌 뒤, 활성 댓글만 필터링하고, 그 안에서 생성 시각과 PK 순서로 읽기 위한 구조다.

```sql
WHERE parent_id = ?
AND is_deleted = false
ORDER BY created_at ASC, comment_id ASC
```

### 8) 개선 후 결과

| post_id | 데이터셋 | 전체 댓글 | 루트 댓글 | 대댓글 |
| :---: | :--- | :---: | :---: | :---: |
| 998 | 분산 데이터 | 1,000개 | 100개 | 900개 |
| 999 | 핫스팟 데이터 | 1,000개 | 500개 | 500개 |

실행계획에서는 복합 인덱스가 사용되는 것을 확인했는데, `ROW_NUMBER()` 기반 Top-5 쿼리와 삭제 루트 노출 정책이 포함된 쿼리에서는 `Using temporary`, `Using filesort`가 남는다.

목적은 DB 내부 정렬 비용을 완전히 없애는 것이 아니라, 초기 응답 크기와 서버 메모리 사용량을 제한하는 것.


### 9) 테스트 및 검증 결과
개선 후의 테스트 결과

```bash
./gradlew.bat test --tests "*CommentReadTest*"
```

| 항목 | 결과 |
| :--- | :--- |
| 테스트 수 | 10 |
| 실패 | 0 |
| 에러 | 0 |
| 스킵 | 0 |

댓글 도메인 전체 테스트는 42건 중 1건이 실패하고 1건이 스킵됐다.

```bash
./gradlew.bat test --tests "*Comment*"
```

실패한 테스트는 후보 3 구조나 현재 조회 구현 문제가 아니다.

기존 `CommentServiceTest` 일부가 "삭제된 루트 댓글은 활성 대댓글이 없어도 목록에 남는다"는 예전 정책을 기대하고 있어서 현재 정책과 충돌한다.

현재 정책은 활성 대댓글이 없는 삭제 루트를 숨기는 방식이다.

---

## 5. 프로젝트 물리 디렉토리 구조 (Project Structure)
## 6. 프로젝트 물리 디렉토리 구조 (Project Structure)

```
snowthing/ (프로젝트 최상위 루트)
Expand All @@ -296,7 +470,7 @@ snowthing/ (프로젝트 최상위 루트)

---

## 6. 실행 및 테스트 (Build & Run)
## 7. 실행 및 테스트 (Build & Run)

### Backend (Spring Boot)
```bash
Expand Down
1 change: 0 additions & 1 deletion backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ dependencies {
// JPA & DB
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'
testRuntimeOnly 'com.h2database:h2'

// Security & Session Redis
implementation 'org.springframework.boot:spring-boot-starter-security'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,32 @@ public ResponseEntity<CommentResponse> createComment(

@GetMapping("/posts/{publicId}/comments")
public ResponseEntity<PostCommentListResponse> getCommentsByPost(
@PathVariable String publicId) {
PostCommentListResponse response = commentService.getCommentsByPost(publicId);
@PathVariable String publicId,
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal CustomUserDetails userDetails) {
PostCommentListResponse response =
commentService.getCommentsByPost(publicId, cursor, size, userDetails);
return ResponseEntity.ok(response);
}

@GetMapping("/comments/{commentId}/replies")
public ResponseEntity<CommentReplyListResponse> getCommentReplies(
@PathVariable Long commentId,
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal CustomUserDetails userDetails) {
return ResponseEntity.ok(
commentService.getCommentReplies(commentId, cursor, size, userDetails));
}

@PutMapping("/comments/{commentId}")
public ResponseEntity<CommentUpdateResponse> updateComment(
@PathVariable Long commentId,
@Valid @RequestBody CommentUpdateRequest request,
@AuthenticationPrincipal CustomUserDetails userDetails) {
CommentUpdateResponse response =
commentService.updateComment(commentId, request, userDetails);
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ public record CommentCreateRequest(
@Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.")
String content,
boolean isAnonymous,
String anonymousPassword) {}
@Size(min = 4, max = 20, message = "익명 비밀번호는 4자 이상 20자 이하로 입력해야 합니다.")
String anonymousPassword) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.ikae.snowthing.domain.comment.dto;

import java.util.List;

import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;

public record CommentReplyListResponse(
@JsonSerialize(using = ToStringSerializer.class) Long rootCommentId,
long totalReplyCount,
List<CommentResponse> replies,
@JsonSerialize(using = ToStringSerializer.class) Long nextCursor,
boolean hasNext) {
public CommentReplyListResponse {
replies = replies == null ? List.of() : List.copyOf(replies);
}
}
Loading
Loading