diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a41a8d4 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# 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 + +# Optional credentials for CommentCreateTest's fixed snowthing_test MySQL schema. +SNOWTHING_TEST_DB_USERNAME=snowuser +SNOWTHING_TEST_DB_PASSWORD=replace-with-a-local-test-password diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml index ae13394..9d12f17 100644 --- a/.github/workflows/gemini-review.yml +++ b/.github/workflows/gemini-review.yml @@ -11,67 +11,62 @@ permissions: jobs: review: - if: > - github.event.issue.pull_request && - contains(github.event.comment.body, '/gemini-review') + if: github.event.issue.pull_request && contains(github.event.comment.body, '/gemini-review') runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Run Gemini Review via REST API env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} run: | if [ -z "$GEMINI_API_KEY" ]; then echo "::error::GEMINI_API_KEY secret is empty." exit 1 fi - gh pr checkout "$PR_NUMBER" + PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json title,body) + PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') + PR_BODY=$(echo "$PR_JSON" | jq -r '.body' | head -c 2500) - PR_TITLE=$(gh pr view "$PR_NUMBER" --json title -q '.title') - PR_BODY=$(gh pr view "$PR_NUMBER" --json body -q '.body' | head -c 2500) - TARGET_BRANCH=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') - - git fetch origin "$TARGET_BRANCH" - PR_DIFF=$(git diff "origin/$TARGET_BRANCH...HEAD" -- 'backend/src/' | head -c 12000) + # PR Diff 추출 (backend/src Java 코드 위주) + PR_DIFF=$(gh pr diff "$PR_NUMBER" --repo "$REPO" | head -c 12000) if [ -z "$PR_DIFF" ]; then - PR_DIFF="No backend/src Java source changes to review." + PR_DIFF="리뷰할 코드 변경점이 없습니다." fi PROMPT=$(cat <>Session: request.changeSessionId() 호출! (세션 식별자 교체) Session-->>Server: 신규 32자리 JSESSIONID 발급 (기존 스키장/성향 검색 필터 세션 데이터 유지) - Server-->>Client: Set-Cookie: JSESSIONID=A1B2...; Path=/; HttpOnly; SameSite=Lax + Server-->>Client: 신규 세션 쿠키 발급 (JSESSIONID, HttpOnly, SameSite=Lax) Client-->>User: 로그인 성공 (메인 프로필 대시보드 전환) Note over User, Server: 4. 인증된 API 요청 (프로필 조회/수정) @@ -69,12 +69,12 @@ sequenceDiagram Client->>Server: POST /api/auth/logout Server->>Server: 1) ThreadLocal.clearContext() 청소 Server->>Session: 2) session.invalidate() 톰캣 세션 파기 - Server-->>Client: 3) Set-Cookie: JSESSIONID=; Max-Age=0 (쿠키 즉시 만료) + Server-->>Client: 3) JSESSIONID 쿠키 만료 응답 (Max-Age=0) ``` --- -### 핵심 아키텍처 고민 및 기술적 의사결정 +### 아키텍처 고민 및 기술적 의사결정 #### 1. 공통 엔티티와 JPA Auditing (`@EnableJpaAuditing`) 도입 @@ -111,7 +111,7 @@ sequenceDiagram --- -## 4. 게시판(Post) 도메인 설계 & 핵심 기술적 의사결정 (Board Architecture & Decisions) +## 4. 게시판(Post) 도메인 설계 & 기술적 의사결정 (Board Architecture & Decisions) 게시판은 Snowthing에서 가장 자주 읽히는 도메인이다. 그래서 단순 CRUD로만 만들지 않고, 목록 조회 비용, 익명 글 권한, 삭제 정책, 이미지 첨부 상태까지 같이 맞춰서 설계했다. @@ -268,13 +268,187 @@ Page findByCategoryCodeWithMemberAndCategory(@Param("categoryCode") String - `INVALID_PAGE_LIMIT (400)`: offset page 제한을 넘긴 요청 ### 11) CSRF + 게시글 생성, 수정, 삭제 같은 CUD 요청은 CSRF 공격 표적이 되기 쉽다. Spring Security의 `CookieCsrfTokenRepository.withHttpOnlyFalse()`를 적용했다. 이 방식은 Double Submit Cookie 패턴으로 동작한다. 백엔드가 `XSRF-TOKEN` 쿠키를 발급하면, 프론트엔드가 자원 변경 요청(POST, PUT, DELETE)을 보낼 때 쿠키 값을 읽어 `X-XSRF-TOKEN` HTTP 헤더에 담아서 보낸다. 서버의 `CsrfFilter`는 쿠키의 토큰 값과 헤더의 토큰 값이 일치하는지 비교하여 검증한다. 외부 해킹 사이트는 동일 출처 정책(SOP) 제약으로 인해 사용자의 `XSRF-TOKEN` 쿠키를 자바스크립트로 읽을 수 없어 `X-XSRF-TOKEN` 헤더를 생성하지 못하므로 위조된 요청은 403 Forbidden으로 차단된다. + +--- + +## 5. 댓글(Comment) 도메인 설계 & 기술적 의사결정 + +댓글은 게시글 상세 화면에서 가장 자주 읽히는 데이터다. 그래서 단순히 `post_id`로 전체 댓글을 가져오는 방식 대신, 루트 댓글과 대댓글을 나누고 초기 응답 크기를 제한하는 구조로 설계했다. + +자세한 후보 비교와 실행계획은 [ADR-001 댓글 아키텍처](docs/conception/sprint03/ADR-001-댓글아키텍처.md), [댓글 API 명세](docs/conception/sprint03/comment_api_spec.md), [기술부채 해결 기록](docs/conception/sprint03/기술부채%20해결_4.md)에 정리했다. + +### 1) 댓글 도메인 구조 + +댓글 엔티티는 `Comment` 하나로 둔다. 별도의 대댓글 `Reply` 엔티티를 만들지 않고, 하나의 `comment` 테이블에서 `parent_id`로 루트 댓글과 대댓글을 표현한다. + +- 루트 댓글: `parent_id = null` +- 대댓글: `parent_id = 루트 댓글 ID` +- 대댓글의 대댓글: 서버에서 최상위 루트 댓글 ID로 평탄화 + +무한 계층을 허용하지 않은 이유는 화면과 쿼리 비용 때문이다. 댓글 깊이가 3단계 이상으로 늘어나면 모바일 화면에서 들여쓰기와 접힘 처리가 복잡해지고, DB 조회도 재귀 구조나 별도 계층 테이블을 고민해야 한다. + +현재의 프로젝트에서는 댓글과 대댓글 2단계면 대화 흐름을 표현하기에 충분하다고 판단했다. + +### 2) 게시글과 댓글의 관계 + +게시글과 댓글은 `Post 1 : N Comment` 관계. 댓글은 반드시 하나의 게시글에 속하고, 게시글은 여러 댓글을 가질 수 있다. + +```text +Post + └─ Comment(parent_id = null) + └─ Comment(parent_id = root_comment_id) +``` + +`post.comment_count`는 매번 댓글 테이블을 `COUNT(*)` 하지 않기 위한 역정규화 필드. + +댓글 생성과 삭제 시 같은 트랜잭션에서 증감시켜 목록 화면에서 댓글 수를 빠르게 보여준다. + +이 선택은 읽기 성능을 얻는 대신, 댓글 저장/삭제 실패와 카운트 갱신 실패의 경계를 반드시 같은 트랜잭션 안에 묶어야 하는 트레이드오프가 있다. + +### 3) 댓글 상태와 유형 + +댓글 상태는 크게 정상 댓글과 Soft Delete 댓글로 나뉜다. + +- 정상 댓글: 목록과 상세 화면에 그대로 노출된다. +- 삭제된 댓글: DB row는 남기고 `is_deleted = true`, `deleted_at`을 기록한다. + +작성 유형은 세 가지다. + +- 로그인 일반 댓글: 회원 ID를 남기고 닉네임 표시 +- 로그인 익명 댓글: 회원 ID는 서버에 남기되 화면에서는 익명 표시 +- 비로그인 익명 댓글: 작성 IP와 익명 비밀번호 해시로 삭제 권한을 검증한다. + +삭제된 루트 댓글은 활성 대댓글 유무에 따라 다르게 처리한다. + +```text +삭제된 루트댓글 + 활성 대댓글 없음 -> 목록에서 숨김 +삭제된 루트댓글 + 활성 대댓글 있음 -> 루트 댓글은 "삭제된 댓글입니다."로 표시하고 활성 대댓글은 그대로 표시 +``` + +### 4) 댓글 조회 페이지네이션 방식 + +댓글 조회는 cursor pagination을 사용한다. + +```http +GET /api/v1/posts/{publicId}/comments?cursor={commentId}&size=20 +GET /api/v1/comments/{commentId}/replies?cursor={commentId}&size=20 +``` + +게시글 댓글 목록은 루트 댓글 20개를 먼저 조회하고, 각 루트 댓글의 대댓글은 5개까지만 같이 보여준다. 대댓글이 5개를 넘으면 사용자가 더보기를 눌렀을 때 대댓글 전용 API로 20개씩 추가 조회한다. + +정렬 기준은 루트 댓글과 대댓글 모두 같다. + +```sql +ORDER BY created_at ASC, comment_id ASC +``` + + +### 5) 조회 아키텍처 후보 비교 + +댓글 조회 구조는 같은 데이터셋과 같은 정책으로 후보 1, 2, 3을 Spike 실험한 뒤 결정했다. + +| 후보 | 방식 | 장점 | 단점 및 트레이드오프 | 판단 | +| :--- | :--- | :--- | :--- | :--- | +| 후보 1 | 전체 댓글을 한 번에 조회하고 메모리에서 트리 조립 | 쿼리 1회로 끝나 구현이 단순함 | 댓글 수가 늘수록 응답 크기와 메모리 사용량이 같이 증가함 | 기각 | +| 후보 2 | 루트 댓글 20개 조회 후 해당 루트의 대댓글 전체를 Batch 조회 | 루트 댓글 수를 제한하고 N+1을 피할 수 있음 | 특정 루트에 대댓글이 몰리면 초기 응답이 다시 커짐 | 기각 | +| 후보 3 | 루트 댓글 20개 + 루트별 대댓글 5개 프리뷰 + 대댓글 분리 API | 초기 응답 크기를 제한하고 핫스팟 댓글에도 대응 가능 | 대댓글 전용 API와 부모별 Top-N 쿼리가 필요함 | 채택 | + +실측 결과도 후보 3이 가장 안정적이었다. + +| 시나리오 | 후보 1 | 후보 2 | 후보 3 | +| :--- | :---: | :---: | :---: | +| 분산 데이터(Post 998) 응답 크기 | 210.44 KB | 39.87 KB | 22.03 KB | +| 핫스팟 데이터(Post 999) 응답 크기 | 205.84 KB | 103.70 KB | 5.55 KB | +| 핫스팟 데이터 읽은 행 수 | 1,000행 | 520행 | 25행 | + +후보 3은 API가 하나 늘어나지만 댓글 조회 시 대댓글 500개를 한 번에 읽어오는 상황을 피할 수 있었다. + +커뮤니티 서비스에서는 댓글이 많은 글도 빠르게 보여줘야 한다고 생각해서, 초기 응답 크기를 제한하는 방식을 생각했다. + +### 6) 선택한 방식의 기술부채 + +해당 방식을 선택하면서 다음 기술부채가 남았다. + +1. 부모별 Top-5 조회를 위한 MySQL 8.0 `ROW_NUMBER() OVER (PARTITION BY parent_id)`. +2. 게시글 댓글 조회 API 외에 대댓글 전용 페이징 API의 별도 관리. +3. `ORDER BY created_at ASC, comment_id ASC` 정렬을 안정적으로 처리하기 위한 복합 인덱스. +4. MySQL 실행계획에서 윈도우 함수 처리로 `Using temporary`, `Using filesort`가 일부 남을 수 있다. + + +### 7) 기술부채 개선 내용 + +부모별 Top-5 프리뷰는 MySQL 8.0 윈도우 함수로 구현했다. + +```sql +ROW_NUMBER() OVER ( + PARTITION BY c.parent_id + ORDER BY c.created_at ASC, c.comment_id ASC +) AS rn +``` + +대댓글 전용 API는 `GET /api/v1/comments/{commentId}/replies`로 분리했고, `size + 1`개를 조회해 `hasNext`를 판단한다. + +읽기 성능을 위해 복합 인덱스도 보강했다. + +```text +(post_id, parent_id, created_at, comment_id) +(parent_id, is_deleted, created_at, comment_id) +``` + +두 번째 인덱스에서 `is_deleted`는 `parent_id` 다음에 둔다. 특정 루트의 대댓글 범위를 먼저 좁힌 뒤, 활성 댓글만 필터링하고, 그 안에서 생성 시각과 PK 순서로 읽기 위한 구조다. + +```sql +WHERE parent_id = ? + AND is_deleted = false +ORDER BY created_at ASC, comment_id ASC +``` + +### 8) 개선 후 결과 + +| post_id | 데이터셋 | 전체 댓글 | 루트 댓글 | 대댓글 | +| :---: | :--- | :---: | :---: | :---: | +| 998 | 분산 데이터 | 1,000개 | 100개 | 900개 | +| 999 | 핫스팟 데이터 | 1,000개 | 500개 | 500개 | + +실행계획에서는 복합 인덱스가 사용되는 것을 확인했는데, `ROW_NUMBER()` 기반 Top-5 쿼리와 삭제 루트 노출 정책이 포함된 쿼리에서는 `Using temporary`, `Using filesort`가 남는다. + +목적은 DB 내부 정렬 비용을 완전히 없애는 것이 아니라, 초기 응답 크기와 서버 메모리 사용량을 제한하는 것. + + +### 9) 테스트 및 검증 결과 +개선 후의 테스트 결과 + +```bash +./gradlew.bat test --tests "*CommentReadTest*" +``` + +| 항목 | 결과 | +| :--- | :--- | +| 테스트 수 | 10 | +| 실패 | 0 | +| 에러 | 0 | +| 스킵 | 0 | + +댓글 도메인 전체 테스트는 42건 중 1건이 실패하고 1건이 스킵됐다. + +```bash +./gradlew.bat test --tests "*Comment*" +``` + +실패한 테스트는 후보 3 구조나 현재 조회 구현 문제가 아니다. + +기존 `CommentServiceTest` 일부가 "삭제된 루트 댓글은 활성 대댓글이 없어도 목록에 남는다"는 예전 정책을 기대하고 있어서 현재 정책과 충돌한다. + +현재 정책은 활성 대댓글이 없는 삭제 루트를 숨기는 방식이다. + --- -## 5. 프로젝트 물리 디렉토리 구조 (Project Structure) +## 6. 프로젝트 물리 디렉토리 구조 (Project Structure) ``` snowthing/ (프로젝트 최상위 루트) @@ -296,7 +470,7 @@ snowthing/ (프로젝트 최상위 루트) --- -## 6. 실행 및 테스트 (Build & Run) +## 7. 실행 및 테스트 (Build & Run) ### Backend (Spring Boot) ```bash diff --git a/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 57dc1a5..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 = ?") @@ -76,6 +85,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..43bac96 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java @@ -1,16 +1,26 @@ package com.ikae.snowthing.domain.comment.repository; import java.util.List; +import java.util.Optional; + +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import com.ikae.snowthing.domain.comment.entity.Comment; -public interface CommentRepository extends JpaRepository { +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM Comment c WHERE c.id = :commentId") + Optional findByIdForUpdate(@Param("commentId") Long commentId); + + long countByParentIdAndIsDeletedFalse(Long parentId); - @Query( - "SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId ORDER BY c.createdAt ASC, c.id ASC") - List findByPostIdWithMember(@Param("postId") Long postId); + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("SELECT c.id FROM Comment c WHERE c.parent.id = :parentId AND c.isDeleted = false") + List findActiveReplyIdsForUpdate(@Param("parentId") Long parentId); } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryCustom.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryCustom.java new file mode 100644 index 0000000..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 b923f20..20ca1a3 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java @@ -10,6 +10,7 @@ import com.ikae.snowthing.domain.comment.dto.*; import com.ikae.snowthing.domain.comment.entity.Comment; import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.comment.repository.CommentRepositoryCustom; import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.member.repository.MemberRepository; import com.ikae.snowthing.domain.post.entity.Post; @@ -28,6 +29,10 @@ @Transactional(readOnly = true) public class CommentService { + private static final long MAX_REPLY_COUNT = 100L; + private static final int DEFAULT_READ_SIZE = 20; + private static final int MAX_READ_SIZE = 50; + private final CommentRepository commentRepository; private final PostRepository postRepository; private final MemberRepository memberRepository; @@ -84,30 +89,45 @@ public CommentResponse createComment( Comment parent = null; if (request.parentId() != null) { - parent = + Comment requestedParent = commentRepository - .findById(request.parentId()) + .findByIdForUpdate(request.parentId()) .orElseThrow( () -> new CustomAuthException( ErrorCode .PARENT_COMMENT_NOT_FOUND)); - if (!parent.getPost().getId().equals(post.getId())) { + if (!requestedParent.getPost().getId().equals(post.getId())) { throw new CustomAuthException(ErrorCode.INVALID_COMMENT_PARENT); } + + Long rootCommentId = requestedParent.rootParent().getId(); + parent = + commentRepository + .findByIdForUpdate(rootCommentId) + .orElseThrow( + () -> + new CustomAuthException( + ErrorCode + .PARENT_COMMENT_NOT_FOUND)); + + long activeReplyCount = + commentRepository.findActiveReplyIdsForUpdate(rootCommentId).size(); + if (activeReplyCount >= MAX_REPLY_COUNT) { + throw new CustomAuthException(ErrorCode.COMMENT_REPLY_LIMIT_EXCEEDED); + } } Comment comment = - Comment.builder() - .post(post) - .member(finalMember) - .parent(parent) - .content(request.content()) - .writerIp(clientIp != null ? clientIp : "127.0.0.1") - .isAnonymous(request.isAnonymous()) - .anonymousPassword(finalEncodedPassword) - .build(); + Comment.create( + post, + finalMember, + parent, + request.content(), + clientIp != null ? clientIp : "127.0.0.1", + request.isAnonymous(), + finalEncodedPassword); Comment savedComment = commentRepository.save(comment); postRepository.increaseCommentCount(post.getId()); @@ -117,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) @@ -126,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 @@ -180,31 +240,19 @@ private void validateDeletePermission( return; } - if (comment.isAnonymous()) { - if (userDetails != null - && comment.getMember() != null - && comment.getMember().getPublicId().equals(userDetails.getPublicId())) { - return; - } - - if (anonymousPassword == null - || !passwordEncoder.matches( - anonymousPassword, comment.getAnonymousPassword())) { - throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + if (comment.getMember() != null) { + boolean isWriter = + userDetails != null + && comment.getMember().getPublicId().equals(userDetails.getPublicId()); + if (!isWriter) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); } return; } - if (userDetails == null) { - throw new CustomAuthException(ErrorCode.ACCESS_DENIED); - } - - boolean isWriter = - comment.getMember() != null - && comment.getMember().getPublicId().equals(userDetails.getPublicId()); - - if (!isWriter) { - throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + if (anonymousPassword == null + || !passwordEncoder.matches(anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); } } } diff --git a/backend/src/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/main/resources/application.yml b/backend/src/main/resources/application.yml index 18702da..2d13b98 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -40,7 +40,7 @@ spring: url: jdbc:mysql://localhost:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul driver-class-name: com.mysql.cj.jdbc.Driver username: snowuser - password: snowthing_pass_2026! + password: ${SNOWTHING_DB_PASSWORD} jpa: hibernate: @@ -64,7 +64,7 @@ spring: url: jdbc:mysql://mysql:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul driver-class-name: com.mysql.cj.jdbc.Driver username: snowuser - password: snowthing_pass_2026! + password: ${SNOWTHING_DB_PASSWORD} jpa: hibernate: diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java new file mode 100644 index 0000000..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/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..18b0527 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java @@ -0,0 +1,420 @@ +package com.ikae.snowthing.domain.comment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.MemberStatus; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@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; + @Autowired private PostRepository postRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private EntityManager entityManager; + + private CustomUserDetails userDetails; + private PostResponse postResponse; + + @BeforeEach + void setUp() { + String fixtureId = UUID.randomUUID().toString(); + categoryRepository + .findByCode("FREE") + .orElseGet(() -> categoryRepository.save(new PostCategory("자유게시판", "FREE"))); + + Member member = + memberRepository.save( + new Member( + null, + "comment-create-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "댓글작성자-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + userDetails = new CustomUserDetails(member); + + postResponse = + postService.createPost( + new PostCreateRequest( + "FREE", "댓글 생성 테스트", "게시글 본문", false, null, List.of()), + userDetails, + "127.0.0.1"); + } + + @Test + @DisplayName("로그인 회원이 루트 댓글을 생성하면 부모 없이 저장되고 댓글 수가 증가한다") + void createRootCommentAsMember() { + CommentResponse response = createComment(null, "회원 루트 댓글"); + + entityManager.flush(); + entityManager.clear(); + Comment savedComment = commentRepository.findById(response.commentId()).orElseThrow(); + Post savedPost = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(savedComment.getParent()).isNull(); + assertThat(savedComment.getContent()).isEqualTo("회원 루트 댓글"); + assertThat(savedPost.getCommentCount()).isEqualTo(1); + } + + @Test + @DisplayName("로그인 회원은 작성자를 숨긴 익명 댓글을 생성할 수 있다") + void createAnonymousCommentAsMember() { + CommentResponse response = + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "로그인 익명 댓글", true, null), + userDetails, + "127.0.0.1"); + + Comment savedComment = commentRepository.findById(response.commentId()).orElseThrow(); + assertThat(savedComment.isAnonymous()).isTrue(); + assertThat(savedComment.getMember()).isNotNull(); + assertThat(savedComment.getAnonymousPassword()).isNull(); + } + + @Test + @DisplayName("비로그인 사용자는 비밀번호를 제공하면 익명 댓글을 생성할 수 있다") + void createAnonymousCommentAsGuest() { + CommentResponse response = + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "비로그인 익명 댓글", true, "1234"), + null, + "127.0.0.1"); + + Comment savedComment = commentRepository.findById(response.commentId()).orElseThrow(); + assertThat(savedComment.getMember()).isNull(); + assertThat(passwordEncoder.matches("1234", savedComment.getAnonymousPassword())).isTrue(); + } + + @Test + @DisplayName("삭제된 루트 댓글에도 새 대댓글을 생성할 수 있다") + void createReplyUnderDeletedRootComment() { + CommentResponse root = createComment(null, "삭제할 루트 댓글"); + commentService.deleteComment(root.commentId(), null, userDetails); + + CommentResponse reply = createComment(root.commentId(), "삭제된 루트의 새 대댓글"); + + assertThat(reply.parentId()).isEqualTo(root.commentId()); + } + + @Test + @DisplayName("대댓글에 답글을 작성해도 최상위 루트 댓글 아래로 평탄화한다") + void flattenReplyToRootComment() { + CommentResponse root = createComment(null, "루트 댓글"); + CommentResponse reply = createComment(root.commentId(), "첫 번째 대댓글"); + + CommentResponse nestedReply = createComment(reply.commentId(), "대댓글에 작성한 답글"); + + entityManager.flush(); + entityManager.clear(); + Comment savedNestedReply = + commentRepository.findById(nestedReply.commentId()).orElseThrow(); + assertThat(savedNestedReply.getParent().getId()).isEqualTo(root.commentId()); + assertThat(nestedReply.parentId()).isEqualTo(root.commentId()); + } + + @Test + @DisplayName("루트 댓글의 활성 대댓글이 100개이면 COMMENT_004 예외를 발생시킨다") + void rejectReplyWhenActiveReplyCountReachesLimit() { + CommentResponse root = createComment(null, "루트 댓글"); + for (int index = 0; index < 100; index++) { + createComment(root.commentId(), "대댓글 " + index); + } + + assertThatThrownBy(() -> createComment(root.commentId(), "101번째 대댓글")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_REPLY_LIMIT_EXCEEDED); + assertThat(commentRepository.countByParentIdAndIsDeletedFalse(root.commentId())) + .isEqualTo(100); + entityManager.clear(); + assertThat( + postRepository + .findByPublicId(postResponse.publicId()) + .orElseThrow() + .getCommentCount()) + .isEqualTo(101); + } + + @Test + @DisplayName("삭제된 대댓글은 활성 대댓글 100개 상한에서 제외한다") + void excludeDeletedReplyFromActiveReplyLimit() { + CommentResponse root = createComment(null, "루트 댓글"); + CommentResponse replyToDelete = null; + for (int index = 0; index < 100; index++) { + CommentResponse reply = createComment(root.commentId(), "대댓글 " + index); + if (index == 0) { + replyToDelete = reply; + } + } + commentService.deleteComment(replyToDelete.commentId(), null, userDetails); + + CommentResponse replacement = createComment(root.commentId(), "삭제 후 새 대댓글"); + + assertThat(replacement.parentId()).isEqualTo(root.commentId()); + } + + @Test + @DisplayName("댓글 저장과 게시글 commentCount 증가는 같은 트랜잭션에서 동기화된다") + void increasePostCommentCountWithCommentCreation() { + createComment(null, "루트 댓글"); + createComment(null, "두 번째 루트 댓글"); + + entityManager.flush(); + entityManager.clear(); + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(post.getCommentCount()).isEqualTo(2); + } + + @Test + @DisplayName("존재하지 않는 게시글에는 댓글을 생성할 수 없다") + void rejectCommentForMissingPost() { + assertThatThrownBy( + () -> + commentService.createComment( + UUID.randomUUID().toString(), + new CommentCreateRequest(null, "댓글", false, null), + userDetails, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("정상 상태가 아닌 게시글에는 댓글을 생성할 수 없다") + void rejectCommentForBlockedPost() { + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + post.changeStatus(PostStatus.BLOCKED); + entityManager.flush(); + + assertThatThrownBy(() -> createComment(null, "차단 게시글 댓글")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("존재하지 않는 부모 댓글을 지정하면 댓글을 생성할 수 없다") + void rejectMissingParentComment() { + assertThatThrownBy(() -> createComment(Long.MAX_VALUE, "잘못된 부모")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PARENT_COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("다른 게시글의 댓글을 부모로 지정하면 댓글을 생성할 수 없다") + void rejectParentCommentFromAnotherPost() { + PostResponse anotherPost = + postService.createPost( + new PostCreateRequest("FREE", "다른 게시글", "다른 본문", false, null, List.of()), + userDetails, + "127.0.0.1"); + CommentResponse anotherRoot = + commentService.createComment( + anotherPost.publicId(), + new CommentCreateRequest(null, "다른 게시글 댓글", false, null), + userDetails, + "127.0.0.1"); + + assertThatThrownBy(() -> createComment(anotherRoot.commentId(), "잘못 연결한 대댓글")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_COMMENT_PARENT); + } + + @Test + @DisplayName("비로그인 사용자는 일반 댓글을 생성할 수 없다") + void rejectMemberCommentFromGuest() { + assertThatThrownBy( + () -> + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "비로그인 일반 댓글", false, null), + null, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_CREDENTIALS); + } + + @Test + @DisplayName("DB에 존재하지 않는 회원 정보로는 댓글을 생성할 수 없다") + void rejectCommentFromMissingMember() { + Member missingMember = + new Member( + UUID.randomUUID().toString(), + "missing@example.com", + "password", + "존재하지않는회원", + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE); + + assertThatThrownBy( + () -> + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "댓글", false, null), + new CustomUserDetails(missingMember), + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("비로그인 익명 사용자는 비밀번호 없이 댓글을 생성할 수 없다") + void rejectAnonymousCommentWithoutPassword() { + assertThatThrownBy( + () -> + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, "익명 댓글", true, null), + null, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("대댓글이 99개일 때 동시 요청 두 개 중 하나만 성공해 최종 100개를 유지한다") + void allowOnlyOneConcurrentReplyAtLimitBoundary() throws Exception { + CommentResponse root = createComment(null, "동시성 루트 댓글"); + for (int index = 0; index < 99; index++) { + createComment(root.commentId(), "기존 대댓글 " + index); + } + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + List> results = + List.of( + executor.submit( + () -> createConcurrentReply(root.commentId(), ready, start)), + executor.submit( + () -> createConcurrentReply(root.commentId(), ready, start))); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List errorCodes = + Arrays.asList( + results.get(0).get(30, TimeUnit.SECONDS), + results.get(1).get(30, TimeUnit.SECONDS)); + assertThat(errorCodes) + .containsExactlyInAnyOrder(null, ErrorCode.COMMENT_REPLY_LIMIT_EXCEEDED); + assertThat(commentRepository.countByParentIdAndIsDeletedFalse(root.commentId())) + .isEqualTo(100); + } finally { + executor.shutdownNow(); + } + } + + private ErrorCode createConcurrentReply( + Long rootCommentId, CountDownLatch ready, CountDownLatch start) throws Exception { + ready.countDown(); + assertThat(start.await(10, TimeUnit.SECONDS)).isTrue(); + try { + createComment(rootCommentId, "동시 대댓글"); + return null; + } catch (CustomAuthException exception) { + return exception.getErrorCode(); + } + } + + private CommentResponse createComment(Long parentId, String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(parentId, content, false, null), + userDetails, + "127.0.0.1"); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentDeleteTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentDeleteTest.java new file mode 100644 index 0000000..e750e8a --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentDeleteTest.java @@ -0,0 +1,325 @@ +package com.ikae.snowthing.domain.comment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.MemberStatus; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@Transactional +class CommentDeleteTest { + + @DynamicPropertySource + static void useRealMySql(DynamicPropertyRegistry registry) { + String testDbUrl = System.getenv("SNOWTHING_TEST_DB_URL"); + if (testDbUrl == null || testDbUrl.isBlank()) { + return; + } + registry.add("spring.datasource.url", () -> testDbUrl); + registry.add( + "spring.datasource.username", + () -> requiredEnvironmentVariable("SNOWTHING_TEST_DB_USERNAME")); + registry.add( + "spring.datasource.password", + () -> requiredEnvironmentVariable("SNOWTHING_TEST_DB_PASSWORD")); + registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MySQLDialect"); + registry.add( + "spring.jpa.properties.hibernate.dialect", + () -> "org.hibernate.dialect.MySQLDialect"); + } + + private static String requiredEnvironmentVariable(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + return value; + } + + @Autowired private CommentService commentService; + @Autowired private CommentRepository commentRepository; + @Autowired private PostService postService; + @Autowired private PostRepository postRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private EntityManager entityManager; + + private CustomUserDetails writerDetails; + private CustomUserDetails otherDetails; + private CustomUserDetails adminDetails; + private PostResponse postResponse; + + @BeforeEach + void setUp() { + String fixtureId = UUID.randomUUID().toString().substring(0, 8); + categoryRepository + .findByCode("FREE") + .orElseGet(() -> categoryRepository.save(new PostCategory("자유게시판", "FREE"))); + + Member writer = + memberRepository.save( + new Member( + null, + "delete-writer-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "삭제작성자-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + writerDetails = new CustomUserDetails(writer); + + Member other = + memberRepository.save( + new Member( + null, + "delete-other-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "타인회원-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + otherDetails = new CustomUserDetails(other); + + Member admin = + memberRepository.save( + new Member( + null, + "delete-admin-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "최고관리자-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_ADMIN, + MemberStatus.ACTIVE)); + adminDetails = new CustomUserDetails(admin); + + postResponse = + postService.createPost( + new PostCreateRequest( + "FREE", "삭제 테스트 게시글", "게시글 본문", false, null, List.of()), + writerDetails, + "127.0.0.1"); + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCase { + + @Test + @DisplayName("[성공 1] 일반 회원 본인 댓글 삭제 성공 (is_deleted = true, post.commentCount 1 차감 확인)") + void deleteOwnCommentAsMember() { + CommentResponse created = createMemberComment("본인 작성 댓글"); + + commentService.deleteComment(created.commentId(), null, writerDetails); + + entityManager.flush(); + entityManager.clear(); + Comment deletedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(deletedComment.isDeleted()).isTrue(); + assertThat(deletedComment.getDeletedAt()).isNotNull(); + + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(post.getCommentCount()).isEqualTo(0); + } + + @Test + @DisplayName("[성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 삭제 성공") + void deleteAnonymousCommentWithCorrectPassword() { + CommentResponse created = createGuestAnonymousComment("익명 작성 댓글", "anonPass1234"); + + commentService.deleteComment(created.commentId(), "anonPass1234", null); + + entityManager.flush(); + entityManager.clear(); + Comment deletedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(deletedComment.isDeleted()).isTrue(); + assertThat(deletedComment.getDeletedAt()).isNotNull(); + + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(post.getCommentCount()).isEqualTo(0); + } + + @Test + @DisplayName("[성공 3] 최고 관리자(ROLE_ADMIN)가 타인/익명 댓글을 비밀번호 없이 강제 삭제 성공") + void deleteCommentAsAdmin() { + CommentResponse memberComment = createMemberComment("일반 회원 댓글"); + CommentResponse anonComment = createGuestAnonymousComment("비회원 익명 댓글", "anonPass1234"); + + // 관리자는 회원 댓글을 비밀번호 없이 삭제 가능 + commentService.deleteComment(memberComment.commentId(), null, adminDetails); + // 관리자는 익명 댓글도 비밀번호 없이 삭제 가능 + commentService.deleteComment(anonComment.commentId(), null, adminDetails); + + entityManager.flush(); + entityManager.clear(); + Comment deletedMemberComment = + commentRepository.findById(memberComment.commentId()).orElseThrow(); + Comment deletedAnonComment = + commentRepository.findById(anonComment.commentId()).orElseThrow(); + + assertThat(deletedMemberComment.isDeleted()).isTrue(); + assertThat(deletedAnonComment.isDeleted()).isTrue(); + + Post post = postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(post.getCommentCount()).isEqualTo(0); + } + + @Test + @DisplayName("[성공 4] 대댓글이 존재하는 부모 댓글 삭제 시 부모만 is_deleted = true 처리되고 하위 대댓글 정상 보존 확인") + void deleteParentCommentPreservesReplies() { + CommentResponse parent = createMemberComment("부모 댓글"); + CommentResponse reply1 = createReply(parent.commentId(), "대댓글 1"); + CommentResponse reply2 = createReply(parent.commentId(), "대댓글 2"); + + Post postBeforeDelete = + postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(postBeforeDelete.getCommentCount()).isEqualTo(3); + + // 부모 댓글만 삭제 + commentService.deleteComment(parent.commentId(), null, writerDetails); + + entityManager.flush(); + entityManager.clear(); + Comment deletedParent = commentRepository.findById(parent.commentId()).orElseThrow(); + Comment activeReply1 = commentRepository.findById(reply1.commentId()).orElseThrow(); + Comment activeReply2 = commentRepository.findById(reply2.commentId()).orElseThrow(); + + assertThat(deletedParent.isDeleted()).isTrue(); + assertThat(activeReply1.isDeleted()).isFalse(); + assertThat(activeReply2.isDeleted()).isFalse(); + + Post postAfterDelete = + postRepository.findByPublicId(postResponse.publicId()).orElseThrow(); + assertThat(postAfterDelete.getCommentCount()).isEqualTo(2); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCase { + + @Test + @DisplayName("[실패 1] 로그인 회원이 타인의 댓글 삭제 시도 시 AUTH_002 (403 Forbidden) 검증") + void rejectDeleteByOtherMember() { + CommentResponse created = createMemberComment("타인이 삭제할 원본 댓글"); + + assertThatThrownBy( + () -> + commentService.deleteComment( + created.commentId(), null, otherDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("[실패 2] 비회원 익명 댓글에 틀린 비밀번호 입력 시 POST_004 (403 Forbidden) 검증") + void rejectDeleteWithWrongPassword() { + CommentResponse created = createGuestAnonymousComment("익명 댓글", "correctPass1234"); + + assertThatThrownBy( + () -> + commentService.deleteComment( + created.commentId(), "wrongPass9999", null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ANON_PASSWORD); + } + + @Test + @DisplayName("[실패 3] 이미 Soft Delete된 댓글 재삭제 시도 시 COMMENT_001 (404 Not Found) 검증") + void rejectDeleteOnAlreadyDeletedComment() { + CommentResponse created = createMemberComment("이미 삭제될 댓글"); + commentService.deleteComment(created.commentId(), null, writerDetails); + + assertThatThrownBy( + () -> + commentService.deleteComment( + created.commentId(), null, writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("[실패 4] 존재하지 않는 댓글 ID 삭제 시도 시 COMMENT_001 (404 Not Found) 검증") + void rejectDeleteOnNonExistentComment() { + assertThatThrownBy( + () -> commentService.deleteComment(Long.MAX_VALUE, null, writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + } + + private CommentResponse createMemberComment(String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, false, null), + writerDetails, + "127.0.0.1"); + } + + private CommentResponse createGuestAnonymousComment(String content, String password) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, true, password), + null, + "127.0.0.1"); + } + + private CommentResponse createReply(Long parentId, String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(parentId, content, false, null), + writerDetails, + "127.0.0.1"); + } +} diff --git a/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/ddl.sql b/database/ddl.sql index ee0891d..91990a7 100644 --- a/database/ddl.sql +++ b/database/ddl.sql @@ -159,6 +159,8 @@ CREATE TABLE `comment` ( `deleted_at` DATETIME NULL COMMENT '삭제 일시', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + INDEX `idx_comment_post_parent_created` (`post_id`, `parent_id`, `created_at`, `comment_id`), + INDEX `idx_comment_parent_created` (`parent_id`, `created_at`, `comment_id`), CONSTRAINT `fk_comment_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE, CONSTRAINT `fk_comment_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE SET NULL, CONSTRAINT `fk_comment_parent` FOREIGN KEY (`parent_id`) REFERENCES `comment` (`comment_id`) ON DELETE SET NULL diff --git a/database/spike_seed_comments.sql b/database/spike_seed_comments.sql index c0123be..4cd6566 100644 --- a/database/spike_seed_comments.sql +++ b/database/spike_seed_comments.sql @@ -9,9 +9,11 @@ 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()); +INSERT INTO `post_category` (`category_id`, `name`, `code`) VALUES (1, '자유게시판', 'FREE') +ON DUPLICATE KEY UPDATE `name` = '자유게시판'; +INSERT 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()) +ON DUPLICATE KEY UPDATE `nickname` = '스파이크테스터'; -- 2. 테스트용 게시글 2개 생성 -- Post 998: 시나리오 A (분산 1,000건용) diff --git a/docker-compose.yml b/docker-compose.yml index b49be52..d83f229 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,9 @@ services: restart: always environment: MYSQL_DATABASE: snowthing - MYSQL_ROOT_PASSWORD: snowthing_root_2026! - MYSQL_USER: snowuser - MYSQL_PASSWORD: snowthing_pass_2026! + MYSQL_ROOT_PASSWORD: ${SNOWTHING_DB_ROOT_PASSWORD:?SNOWTHING_DB_ROOT_PASSWORD must be set} + MYSQL_USER: ${SNOWTHING_DB_USERNAME:-snowuser} + MYSQL_PASSWORD: ${SNOWTHING_DB_PASSWORD:?SNOWTHING_DB_PASSWORD must be set} TZ: Asia/Seoul ports: - "3306:3306" diff --git "a/docs/conception/sprint03/ADR-001-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" "b/docs/conception/sprint03/ADR-001-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" new file mode 100644 index 0000000..6e1d884 --- /dev/null +++ "b/docs/conception/sprint03/ADR-001-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" @@ -0,0 +1,151 @@ +# [ADR-001] 댓글 계층 모델 및 조회 아키텍처 의사결정 + +- **문서 번호**: `ADR-001` +- **상태**: `Accepted` +- **결정 일자**: 2026-08-29 +- **작성자**: devikae +- **대상 패키지**: `com.ikae.snowthing.domain.comment` +- **관련 명세**: `docs/conception/sprint03/comment_policy.md` +- **실측 데이터**: `docs/study/sprint03/comment/test/` + +--- + +## 1. 문제 정의 + +기존 댓글 조회는 `findByPostIdWithMember` 단일 쿼리로 특정 게시글의 모든 댓글을 한 번에 메모리로 가져와 조립하는 방식이었습니다. + +이 방식은 댓글 수가 적을 때는 단순하지만 다음과 같은 문제가 있습니다. + +1. **대용량 댓글 조회 시 메모리 및 페이로드 부하**: + - 댓글 수 상한이 없어 댓글이 많이 달린 글 진입 시 수천 건의 엔티티가 메모리에 적재되고, 수백 KB 이상의 JSON 응답이 발생합니다. +2. **대댓글 깊이 미제한**: + - 대댓글 ID를 `parentId`로 지정하면 3단계 이상으로 계층이 깊어져 모바일 UI에서 들여쓰기 표현에 문제가 생깁니다. +3. **삭제 데이터 및 카운트 불일치**: + - 부모와 자식이 모두 삭제된 노드가 응답에 남을 수 있고, 삭제된 댓글까지 `commentCount`에 포함되어 실제 읽을 수 있는 댓글 수와 차이가 납니다. +4. **동일 생성 시각 정렬 불안정**: + - `created_at`만으로 정렬할 경우 동일 시각에 등록된 댓글들의 순서가 일정하지 않을 수 있습니다. + +--- + +## 2. 확정된 제품 요구사항 + +1. **2단계 계층 고정**: + - 댓글(Root)과 대댓글(Child) 2단계로 한정합니다. + - 대댓글에 답글을 달아도 최상위 루트 댓글 ID를 바라보도록 평탄화하며, 루트당 대댓글 수는 최대 100개로 제한합니다. +2. **화면 노출 및 응답 규칙**: + - 게시글 상세 진입 시 루트 댓글은 20개 기준으로 페이징합니다. + - 각 루트 댓글 하위의 대댓글은 상위 5개까지만 기본 노출하고, 5개를 넘는 대댓글은 "더보기"를 통해 추가 조회합니다. +3. **삭제 및 카운트 정리**: + - 삭제된 루트에 대댓글이 남아있으면 "삭제된 댓글입니다." 표시를 노출하고 새 대댓글 작성을 허용합니다. + - 부모와 자식이 모두 삭제된 노드는 목록에서 제외합니다. + - `post.comment_count`와 DTO `replyCount`는 실제 유효한 댓글 수만 집계합니다. +4. **정렬 기준**: + - 루트 댓글과 대댓글 모두 등록순(`ORDER BY created_at ASC, comment_id ASC`)으로 정렬합니다. + +--- + +## 3. 검토한 후보군 + +### 1) Spike 실험 및 실측 대상 (3대 후보) +1. **후보 1: Adjacency List + 메모리 전체 조립 (현행)** + - 단일 쿼리로 전체 댓글을 가져와 자바 `Map`에서 조립 후 반환. +2. **후보 2: Adjacency List + 루트 커서 페이징 & 대댓글 전체 Batch 조회** + - 루트 댓글 20개 커서 페이징 후 `WHERE parent_id IN (...)`으로 대댓글 전체를 2번째 쿼리로 일괄 조회. +3. **후보 3: Adjacency List + 루트 Batch 페이징 및 대댓글 Top-5 프리뷰 & 분리 API** + - 루트 20개와 각 대댓글 상위 5개만 묶어서 반환(2회 쿼리)하고, 5개 초과분은 `GET /api/v1/comments/{commentId}/replies` 분리 API로 페이징 조회. + +### 2) 사전 개념 검토 및 조기 제외 대상 (이론 분석) +- **Recursive CTE** (`WITH RECURSIVE` 재귀 조인): 2단계 고정 계층 대비 DB 재귀 부하 및 JPA 미지원으로 사전 제외. +- **Closure Table** (`comment_closure` 중계 테이블): 2단계 구조 대비 쓰기 비용($D+1$ INSERT)과 테이블 관리 오버헤드로 사전 제외. +- **Materialized Path** (`path` 경로 문자열): 자릿수 패딩 관리 대비 2단계 구조에서 `parent_id` 대비 실익이 적어 사전 제외. + +--- + +## 4. 후보별 장단점 및 트레이드오프 + +### 1) Spike 3대 후보 비교 + +| 후보 | 장점 | 단점 및 트레이드오프 | +| :--- | :--- | :--- | +| **후보 1 (메모리 조립)** | • 쿼리 1회 완료
• 구현 단순 | • 댓글 수 증가 시 메모리 및 페이로드 비례 증가
• 페이징 적용 불가 | +| **후보 2 (루트 커서+대댓글 Batch)** | • 루트 댓글 수(20개) 제한
• N+1 없는 2회 쿼리 | • 특정 댓글에 대댓글이 몰리면 페이로드가 다시 커짐
• 대댓글 5개 노출 요구사항 미충족 | +| **후보 3 (루트 Batch + 대댓글 Top-5 프리뷰 및 분리 API)** | • 응답 크기 제한 (최대 120개)
• 대댓글 5개 이하 일반 댓글은 추가 요청 없이 조회
• 핫스팟 발생 시에도 응답 크기 유지 | • 대댓글 전용 조회 API 엔드포인트 추가 필요
• 부모별 Top-5 조회를 위한 윈도우/서브쿼리 작성 필요 | + +### 2) 사전 개념 검토 모델 비교 + +| 모델 | 장점 | 사전 제외 이유 | +| :--- | :--- | :--- | +| **Recursive CTE** | • 스키마 변경 없이 단일 쿼리 계층 정렬 | • 2단계 구조에 불필요한 DB 재귀 연산
• JPA JPQL 미지원 (Native SQL 강제) | +| **Closure Table** | • 인덱스 JOIN 1회로 조회 | • 댓글 작성 시 $D+1$ 다중 INSERT 발생
• 관계 테이블 데이터 관리 오버헤드 | +| **Materialized Path** | • 단일 테이블 계층 정렬 | • 자릿수 패딩 관리 복잡도
• 2단계 고정 구조에서 `parent_id` 대비 실익 없음 | + +--- + +## 5. Spike 실험 결과 + +실제 MySQL 8.0 DB에 1,000건의 데이터를 넣고 3개 독립 브랜치에서 동일한 조건으로 측정한 결과입니다. + +- **시나리오 A (분산 1,000건, Post 998)**: 루트 댓글 100개 + 각 대댓글 9개 분산 +- **시나리오 B (집중 1,000건, Post 999)**: 루트 댓글 500개 + 1번 루트에 대댓글 500개 집중 + +### 실측 데이터 + +| 시나리오 | 측정 지표 | 후보 1. 메모리 전체 조립 | 후보 2. 루트 커서 + 대댓글 Batch | 후보 3. 루트 Batch + 대댓글 Top-5 프리뷰 (루트 20 + 5개) | +| :--- | :--- | :---: | :---: | :---: | +| **시나리오 A (분산)**
루트 100개 + 대댓글 900개 | **쿼리 수** | 1회 | 2회 | 2회 | +| | **읽은 행 수** | 1,000행 | 200행 | 120행 | +| | **응답 크기 (JSON)** | 210.44 KB | 39.87 KB | 22.03 KB | +| | **실행 시간** | 83.468 ms | 10.308 ms | 14.594 ms | +| **시나리오 B (집중)**
루트 500개 + 1번에 500개 몰림 | **쿼리 수** | 1회 | 2회 | 2회 | +| | **읽은 행 수** | 1,000행 | 520행 | 25행 | +| | **응답 크기 (JSON)** | 205.84 KB | 103.70 KB | 5.55 KB | +| | **실행 시간** | 35.401 ms | 14.988 ms | 5.603 ms | +| **더보기 1회 호출**
(500개 중 추가 20개 페이징) | **쿼리 수 / 읽은 행 / 크기** | 해당 없음 | 해당 없음 | 1회 / 20행 / 3.50 KB (2.357 ms) | + +### 결과 분석 +1. **후보 1**: 댓글 1,000건 조회 시 페이로드가 약 210 KB로 커지고, 1,000개 엔티티를 모두 메모리에 올려 처리합니다. +2. **후보 2**: 분산 환경에서는 39.87 KB로 줄었으나, 대댓글 500개가 몰린 핫스팟에서는 103.70 KB로 다시 커집니다. +3. **후보 3**: 대댓글 500개 집중 상황에서도 초기 응답이 5.55 KB(25행)로 유지되며, 추가 20개 페이징 요청은 3.50 KB로 처리됩니다. + +--- + +## 6. 최종 선택 + +### **후보 3 (Adjacency List 기반 루트 Batch 페이징 + 대댓글 Top-5 프리뷰 및 분리 API) 채택** + +### 채택 이유 +1. **응답 크기 제어**: 초기 응답 노드 수가 최대 120개(루트 20개 + 대댓글 100개)로 제한됩니다. +2. **사용성**: 대댓글이 5개 이하인 대부분의 댓글은 추가 클릭 없이 바로 노출됩니다. +3. **DB 부하 감소**: 인덱스를 통해 필요한 25~120행만 읽어옵니다. + +--- + +## 7. 선택하지 않은 후보의 기각 이유 + +1. **후보 1 (메모리 전체 조립)**: 댓글 수 증가 시 응답 크기(210 KB)와 메모리 사용량이 커져 기각. +2. **후보 2 (루트 커서 + 대댓글 Batch)**: 대댓글 집중 상황에서 페이로드(103 KB) 통제가 되지 않아 기각. +3. **후보 4 (Recursive CTE)**: 2단계 구조에 불필요한 재귀 연산이며, JPQL 미지원으로 Native SQL을 써야 해 기각. +4. **후보 5 (Closure Table)**: 2단계 댓글에 쓰기 비용($D+1$ INSERT)과 테이블 관리가 과도해 기각. +5. **후보 6 (Materialized Path)**: 자릿수 패딩 관리 대비 2단계 구조에서 실익이 없어 기각. + +--- + +## 8. 현재 선택의 단점과 기술 부채 + +1. **부모별 Top-5 조회 쿼리**: + - MySQL 8.0 `ROW_NUMBER() OVER (PARTITION BY parent_id)` 또는 QueryDSL 기반 조인 쿼리 작성이 필요합니다. +2. **API 엔드포인트 추가**: + - 게시글 댓글 조회(`GET /api/v1/posts/{publicId}/comments`) 외에 대댓글 전용 페이징(`GET /api/v1/comments/{commentId}/replies`) 엔드포인트를 추가로 관리해야 합니다. +3. **인덱스 추가 검토**: + - `ORDER BY created_at ASC, comment_id ASC` 정렬 시 `filesort`가 발생하므로, `(post_id, parent_id, created_at, comment_id)` 복합 인덱스 적용을 검토해야 합니다. + +--- + +## 9. 요구사항 변경 시 재검토 기준 + +1. **3단계 이상의 무한 대댓글 요구가 생길 경우**: + - 계층 순서 정렬을 위해 `Materialized Path` 또는 `Recursive CTE` 전환을 검토합니다. +2. **댓글 추천순(인기순) 정렬이 기본 뷰가 될 경우**: + - 등록순 커서 페이징 대신 Redis 랭킹 캐싱 또는 추천수 복합 인덱스 페이징으로 전환을 검토합니다. +3. **실시간 스트리밍 댓글이 도입될 경우**: + - HTTP 페이징 대신 WebSocket / SSE 메시징 구조로 전환을 검토합니다. diff --git a/docs/conception/sprint03/comment_api_spec.md b/docs/conception/sprint03/comment_api_spec.md new file mode 100644 index 0000000..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/specs/comment_policy.md b/docs/conception/sprint03/comment_policy.md similarity index 100% rename from docs/specs/comment_policy.md rename to docs/conception/sprint03/comment_policy.md diff --git "a/docs/conception/sprint03/spike_\353\243\250\355\212\270\354\273\244\354\204\234_\353\214\200\353\214\223\352\270\200\354\240\204\354\262\264\353\260\260\354\271\230.md" "b/docs/conception/sprint03/spike_\353\243\250\355\212\270\354\273\244\354\204\234_\353\214\200\353\214\223\352\270\200\354\240\204\354\262\264\353\260\260\354\271\230.md" new file mode 100644 index 0000000..332bd43 --- /dev/null +++ "b/docs/conception/sprint03/spike_\353\243\250\355\212\270\354\273\244\354\204\234_\353\214\200\353\214\223\352\270\200\354\240\204\354\262\264\353\260\260\354\271\230.md" @@ -0,0 +1,89 @@ +# [Spike 결과 보고서] 후보 2: 루트 커서 페이징 + 대댓글 전체 Batch + +- **브랜치명**: `sprint03-spikeTest-02-Cursor/Batch` +- **측정 일시**: 2026-08-29 +- **작성자**: devikae (자동 생성) + +--- + +## 1. 구현 요약 (PoC Implementation) +- 루트 댓글을 `(created_at, comment_id)` 복합 커서로 20개 조회합니다. +- 선택된 루트 ID를 `parent_id IN (...)`에 전달해 모든 대댓글을 한 번에 조회합니다. +- 두 쿼리 모두 작성자 정보를 LEFT JOIN하고, DTO 컬렉션은 방어적으로 복사합니다. + +--- + +## 2. 측정 결과 데이터 매트릭스 + +| 시나리오 | 쿼리 수 (Count) | 읽은 Row 수 (Rows) | 응답 크기 (Bytes / KB) | 실행 시간 (Elapsed ms) | +| :--- | :---: | :---: | :---: | :---: | +| **[시나리오 A] 분산 1,000건** | 2회 | 200행 | 40830 B (39.87 KB) | 10.308 ms | +| **[시나리오 B] 집중 핫스팟 1,000건** | 2회 | 520행 | 106186 B (103.70 KB) | 14.988 ms | + +--- + +## 3. 실행된 실제 SQL 및 MySQL EXPLAIN + +### 1) [시나리오 A] 분산 1,000건 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, c.is_deleted, c.created_at, m.nickname, c.is_anonymous, c.writer_ip FROM comment c LEFT JOIN member m ON m.member_id = c.member_id WHERE c.post_id = 998 AND c.parent_id IS NULL ORDER BY c.created_at ASC, c.comment_id ASC LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | ref | fk_comment_parent | 603 | Using index condition; Using where; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +#### [Query 2] +```sql +SELECT c.comment_id, c.parent_id, c.content, c.is_deleted, c.created_at, m.nickname, c.is_anonymous, c.writer_ip FROM comment c LEFT JOIN member m ON m.member_id = c.member_id WHERE c.parent_id IN (4004, 4014, 4024, 4034, 4044, 4054, 4064, 4074, 4084, 4094, 4104, 4114, 4124, 4134, 4144, 4154, 4164, 4174, 4184, 4194) ORDER BY c.parent_id ASC, c.created_at ASC, c.comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | fk_comment_parent | 180 | Using index condition; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +### 2) [시나리오 B] 집중 핫스팟 1,000건 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, c.is_deleted, c.created_at, m.nickname, c.is_anonymous, c.writer_ip FROM comment c LEFT JOIN member m ON m.member_id = c.member_id WHERE c.post_id = 999 AND c.parent_id IS NULL ORDER BY c.created_at ASC, c.comment_id ASC LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | ref | fk_comment_parent | 603 | Using index condition; Using where; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +#### [Query 2] +```sql +SELECT c.comment_id, c.parent_id, c.content, c.is_deleted, c.created_at, m.nickname, c.is_anonymous, c.writer_ip FROM comment c LEFT JOIN member m ON m.member_id = c.member_id WHERE c.parent_id IN (5004, 5005, 5006, 5007, 5008, 5009, 5010, 5011, 5012, 5013, 5014, 5015, 5016, 5017, 5018, 5019, 5020, 5021, 5022, 5023) ORDER BY c.parent_id ASC, c.created_at ASC, c.comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | fk_comment_parent | 519 | Using index condition; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +--- + +## 4. 발견된 결함 및 한계점 (Issues & Bottlenecks) +- 응답 쿼리 수는 2회로 고정되지만 선택된 루트에 대댓글이 집중되면 응답 행과 페이로드는 제한되지 않습니다. +- 현재 스키마에는 `(post_id, parent_id, created_at, comment_id)` 복합 인덱스가 없어 EXPLAIN상 추가 정렬이나 넓은 스캔이 발생할 수 있습니다. +- 실행 시간은 로컬 단일 실행값이므로 반복 측정의 평균·백분위 지표가 아닙니다. + +--- + +## 5. 최종 평가 및 소견 +후보 2는 루트 수를 20개로 제한하면서 N+1 없이 2회 조회를 유지합니다. 다만 핫스팟 루트가 페이지에 포함되면 대댓글 전체가 반환되어 페이로드 상한을 보장하지 못하므로, 운영안에서는 대댓글 별도 커서 또는 프리뷰 제한을 함께 검토해야 합니다. diff --git "a/docs/conception/sprint03/spike_\353\251\224\353\252\250\353\246\254\354\240\204\354\262\264\355\212\270\353\246\254\354\241\260\353\246\275.md" "b/docs/conception/sprint03/spike_\353\251\224\353\252\250\353\246\254\354\240\204\354\262\264\355\212\270\353\246\254\354\241\260\353\246\275.md" new file mode 100644 index 0000000..14fa9c4 --- /dev/null +++ "b/docs/conception/sprint03/spike_\353\251\224\353\252\250\353\246\254\354\240\204\354\262\264\355\212\270\353\246\254\354\241\260\353\246\275.md" @@ -0,0 +1,73 @@ +# [Spike 결과 보고서] 후보 1: 메모리 전체 트리 조립 + +- **브랜치명**: `devikae/sprint03-spikeTest-01-메모리-조립` +- **측정 일시**: 2026-08-29 +- **작성자**: devikae (자동 생성) + +--- + +## 1. 구현 요약 (PoC Implementation) +- `findByPostIdWithMember` 한 번으로 게시글별 댓글 1,000건과 작성자를 조회 +- `LinkedHashMap`에서 루트/대댓글을 연결한 뒤 불변 2-Depth DTO로 변환 +- Hibernate Statistics로 각 시나리오의 JPQL 실행 횟수가 1회인지 검증 + +--- + +## 2. 측정 결과 데이터 매트릭스 + +| 시나리오 | 쿼리 수 (Count) | 읽은 Row 수 (Rows) | 응답 크기 (Bytes / KB) | 실행 시간 (Elapsed ms) | +| :--- | :---: | :---: | :---: | :---: | +| **[시나리오 A] 분산 1,000건** | 1회 | 1000행 | 215490 B (210.44 KB) | 83.468 ms | +| **[시나리오 B] 집중 핫스팟 1,000건** | 1회 | 1000행 | 210784 B (205.84 KB) | 35.401 ms | + +--- + +## 3. 실행된 실제 SQL 및 MySQL EXPLAIN + +### 1) [시나리오 A] 분산 1,000건 + +#### [Query 1] +```sql +SELECT c.*, m.* +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = 998 +ORDER BY c.created_at ASC, c.comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | ref | fk_comment_post | 1000 | Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | + +### 2) [시나리오 B] 집중 핫스팟 1,000건 + +#### [Query 1] +```sql +SELECT c.*, m.* +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = 999 +ORDER BY c.created_at ASC, c.comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | ref | fk_comment_post | 1000 | Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | + +--- + +## 4. 발견된 결함 및 한계점 (Issues & Bottlenecks) +- 댓글 총량에 비례해 엔티티와 DTO가 동시에 메모리에 존재합니다. +- 핫스팟 시나리오는 한 루트 DTO가 대댓글 500개를 한 응답에 포함합니다. +- 전체 응답 방식이라 루트 페이징이나 대댓글 더보기로 페이로드 상한을 통제할 수 없습니다. + +--- + +## 5. 최종 평가 및 소견 +단일 JPQL로 N+1 없이 2-Depth 트리를 조립할 수 있다는 가설은 확인했습니다. 다만 댓글 증가량이 DB 조회 행, JVM 메모리, 직렬화 크기에 그대로 반영되므로 운영 기본안으로 채택하기 전 후보 2·3과 응답 상한 및 핫스팟 안정성을 비교해야 합니다. diff --git "a/docs/conception/sprint03/spike_\355\225\230\354\235\264\353\270\214\353\246\254\353\223\234\355\224\204\353\246\254\353\267\260_\353\266\204\353\246\254API.md" "b/docs/conception/sprint03/spike_\355\225\230\354\235\264\353\270\214\353\246\254\353\223\234\355\224\204\353\246\254\353\267\260_\353\266\204\353\246\254API.md" new file mode 100644 index 0000000..f2638e1 --- /dev/null +++ "b/docs/conception/sprint03/spike_\355\225\230\354\235\264\353\270\214\353\246\254\353\223\234\355\224\204\353\246\254\353\267\260_\353\266\204\353\246\254API.md" @@ -0,0 +1,167 @@ +# [Spike 결과 보고서] 후보 3: 루트 Batch + 대댓글 5개 프리뷰 & 분리 API + +- **브랜치명**: `devikae/sprint03-spikeTest-03-Batch/API` +- **측정 일시**: 2026-08-29 +- **작성자**: devikae + +--- + +## 1. 구현 요약 (PoC Implementation) +- 루트 댓글 20개 조회 후 MySQL 8 `ROW_NUMBER() OVER (PARTITION BY parent_id)`로 각 루트당 대댓글 5개만 일괄 조회합니다. +- 프리뷰는 총 2회 쿼리이며, 대댓글 더보기는 `comment_id` 커서와 `LIMIT 20`을 사용하는 분리 조회입니다. +- Spike 코드는 `src/test`에 격리했고 응답 컬렉션은 `List.copyOf()`로 방어적 복사했습니다. + +--- + +## 2. 측정 결과 데이터 매트릭스 + +| 시나리오 | 쿼리 수 (Count) | 읽은 Row 수 (Rows) | 응답 크기 (Bytes / KB) | 실행 시간 (Elapsed ms) | +| :--- | :---: | :---: | :---: | :---: | +| **[분산] Post 998** | 2회 | 120행 | 22560 B (22.03 KB) | 14.594 ms | +| **[집중] Post 999** | 2회 | 25행 | 5685 B (5.55 KB) | 5.603 ms | +| **[더보기 호출 시] Post 999 핫스팟 루트** | 1회 | 20행 | 3582 B (3.50 KB) | 2.357 ms | + +--- + +## 3. 실행된 실제 SQL 및 MySQL EXPLAIN + +### 1) [분산] Post 998 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, m.nickname, c.created_at, 0 AS reply_count +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = 998 + AND c.parent_id IS NULL + AND c.is_deleted = FALSE + AND c.comment_id > 0 +ORDER BY c.comment_id ASC +LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | PRIMARY | 1001 | Using where | +| m | eq_ref | PRIMARY | 1 | null | + +#### [Query 2] +```sql +WITH ranked_replies AS ( + SELECT c.comment_id, + c.parent_id, + c.content, + m.nickname, + c.created_at, + ROW_NUMBER() OVER ( + PARTITION BY c.parent_id + ORDER BY c.comment_id ASC + ) AS reply_rank, + COUNT(*) OVER (PARTITION BY c.parent_id) AS reply_count + FROM comment c + LEFT JOIN member m ON m.member_id = c.member_id + WHERE c.parent_id IN (4004, 4014, 4024, 4034, 4044, 4054, 4064, 4074, 4084, 4094, 4104, 4114, 4124, 4134, 4144, 4154, 4164, 4174, 4184, 4194) + AND c.is_deleted = FALSE +) +SELECT comment_id, parent_id, content, nickname, created_at, reply_count +FROM ranked_replies +WHERE reply_rank <= 5 +ORDER BY parent_id ASC, comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| | ALL | null | 18 | Using where; Using filesort | +| c | range | fk_comment_parent | 180 | Using index condition; Using where; Using temporary; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +### 2) [집중] Post 999 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, m.nickname, c.created_at, 0 AS reply_count +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = 999 + AND c.parent_id IS NULL + AND c.is_deleted = FALSE + AND c.comment_id > 0 +ORDER BY c.comment_id ASC +LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | PRIMARY | 1001 | Using where | +| m | eq_ref | PRIMARY | 1 | null | + +#### [Query 2] +```sql +WITH ranked_replies AS ( + SELECT c.comment_id, + c.parent_id, + c.content, + m.nickname, + c.created_at, + ROW_NUMBER() OVER ( + PARTITION BY c.parent_id + ORDER BY c.comment_id ASC + ) AS reply_rank, + COUNT(*) OVER (PARTITION BY c.parent_id) AS reply_count + FROM comment c + LEFT JOIN member m ON m.member_id = c.member_id + WHERE c.parent_id IN (5004, 5005, 5006, 5007, 5008, 5009, 5010, 5011, 5012, 5013, 5014, 5015, 5016, 5017, 5018, 5019, 5020, 5021, 5022, 5023) + AND c.is_deleted = FALSE +) +SELECT comment_id, parent_id, content, nickname, created_at, reply_count +FROM ranked_replies +WHERE reply_rank <= 5 +ORDER BY parent_id ASC, comment_id ASC +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| | ALL | null | 51 | Using where; Using filesort | +| c | range | fk_comment_parent | 519 | Using index condition; Using where; Using temporary; Using filesort | +| m | eq_ref | PRIMARY | 1 | null | + +### 3) [더보기 호출 시] Post 999 핫스팟 루트 + +#### [Query 1] +```sql +SELECT c.comment_id, c.parent_id, c.content, m.nickname, c.created_at, 0 AS reply_count +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.parent_id = 5004 + AND c.is_deleted = FALSE + AND c.comment_id > 0 +ORDER BY c.comment_id ASC +LIMIT 20 +``` + +**EXPLAIN 분석**: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :--- | :--- | +| c | range | PRIMARY | 1001 | Using where | +| m | eq_ref | PRIMARY | 1 | null | + +--- + +## 4. 발견된 결함 및 한계점 (Issues & Bottlenecks) +- 부모별 Top-N을 위해 윈도 함수 정렬과 임시 테이블 처리가 발생할 수 있습니다. +- 현재 인덱스는 `post_id`, `parent_id` 단일 인덱스뿐이므로 운영 반영 시 `(post_id, parent_id, comment_id)`와 `(parent_id, comment_id)` 복합 인덱스를 비교 검증해야 합니다. +- `LIMIT 20`만 사용하는 PoC이므로 정확한 `hasNext` 판정이 필요하면 21건 조회 또는 별도 존재 확인의 비용을 선택해야 합니다. + +--- + +## 5. 최종 평가 및 소견 +응답 크기는 루트 20개와 부모별 프리뷰 5개로 상한이 통제됩니다. 핫스팟의 나머지 대댓글은 분리 API로 넘겨 초기 응답과 메모리 사용량을 제한할 수 있습니다. diff --git "a/docs/conception/sprint03/\352\270\260\354\210\240\353\266\200\354\261\204 \355\225\264\352\262\260_4.md" "b/docs/conception/sprint03/\352\270\260\354\210\240\353\266\200\354\261\204 \355\225\264\352\262\260_4.md" new file mode 100644 index 0000000..e563a5c --- /dev/null +++ "b/docs/conception/sprint03/\352\270\260\354\210\240\353\266\200\354\261\204 \355\225\264\352\262\260_4.md" @@ -0,0 +1,401 @@ +# 댓글 조회 기술부채 해결 기록 4 + +- 작성일: 2026-09-03 +- 대상 기능: 게시글 댓글 목록 조회, 루트별 대댓글 5개 프리뷰, 대댓글 분리 페이징 조회 +- 기준 문서: + - `spike_experiment_guide.md` + - `spike_하이브리드프리뷰_분리API.md` +- 기준 코드: + - `CommentController` + - `CommentService` + - `CommentRepositoryImpl` + - `Comment` + +--- + +## 1. 결론 + +현재 구현에 구조적인 문제는 없습니다. + +후보 1, 2, 3 Spike는 같은 정책과 같은 데이터셋에서 비교됐고, 그 결과 후보 3인 "루트 댓글 20개 + 루트별 대댓글 5개 프리뷰 + 대댓글 분리 API" 구조를 선택했습니다. 이 선택은 여전히 유효합니다. + +이 문서는 후보 3을 다시 무효화하거나 재비교하는 문서가 아닙니다. 후보 3을 운영 코드로 옮긴 뒤, 남아 있던 기술부채가 어떻게 해결됐는지 확인한 기록입니다. + +정리하면 다음과 같습니다. + +| 항목 | 상태 | 근거 | +| :--- | :--- | :--- | +| 후보 1, 2, 3 비교 실험 | 문제 없음 | 같은 정책과 같은 데이터셋으로 비교 완료 | +| 후보 3 구조 채택 | 문제 없음 | 초기 응답 크기와 메모리 사용량을 제한하는 구조 | +| 부모별 Top-5 프리뷰 쿼리 | 해결 | MySQL 8.0 `ROW_NUMBER()` 기반 구현 완료 | +| 대댓글 분리 API | 해결 | `GET /api/v1/comments/{commentId}/replies` 구현 완료 | +| 읽기 성능용 복합 인덱스 | 보강됨 | `parent_id, is_deleted, created_at, comment_id` 인덱스 확인 | +| 삭제 루트 노출 정책 | 문제 없음 | 활성 대댓글 유무 기준으로 동작 | +| 기존 테스트 일부 실패 | 구현 문제가 아니라 테스트 기대값 문제 | 예전 정책 기준 테스트가 현재 정책과 충돌 | + +--- + +## 2. 후보 3 실험과 현재 구현의 관계 + +후보 3 Spike의 핵심은 아래 구조였습니다. + +```text +게시글 댓글 목록 조회 +-> 루트 댓글 20개 조회 +-> 각 루트별 대댓글 5개까지만 프리뷰 +-> 5개를 초과한 대댓글은 별도 API로 페이징 조회 +``` + +현재 구현도 이 구조를 그대로 사용합니다. + +```text +GET /api/v1/posts/{publicId}/comments?cursor={commentId}&size=20 +GET /api/v1/comments/{commentId}/replies?cursor={commentId}&size=20 +``` + +따라서 후보 3 실험이 잘못된 것이 아닙니다. 오히려 후보 3에서 확인한 장점을 운영 코드로 옮긴 상태입니다. + +후속으로 바뀐 부분은 "후보 재비교"가 아니라 "운영 구현 보강"입니다. + +1. `ROW_NUMBER()` 별칭을 `rn`으로 바꿔 MySQL 함수명 충돌을 피했습니다. +2. 커서 페이징에서 `created_at ASC, comment_id ASC` 기준을 사용해 순서를 안정화했습니다. +3. 읽기 성능을 위해 `is_deleted`를 포함한 복합 인덱스를 추가 검토하고 실제 DB에 반영했습니다. +4. `size + 1`개를 조회해 `hasNext`를 판정하도록 했습니다. + +--- + +## 3. 삭제 루트 댓글 정책 + +현재 정책은 아래 기준입니다. + +```text +삭제된 루트댓글 + 활성 대댓글 없음 -> 목록에서 숨김 +삭제된 루트댓글 + 활성 대댓글 있음 -> 루트 댓글은 placeholder, 활성 대댓글은 그대로 표시 +``` + +이 정책은 댓글 트리에서 고아 노드가 생기는 문제를 막기 위한 선택입니다. + +삭제된 루트 댓글에 활성 대댓글이 없다면 사용자가 볼 내용이 없습니다. 이 경우 목록에서 숨기는 것이 자연스럽습니다. + +반대로 삭제된 루트 댓글 아래에 활성 대댓글이 남아 있다면, 루트를 완전히 숨기면 하위 대댓글의 문맥이 사라집니다. 그래서 루트는 `"삭제된 댓글입니다."` placeholder로 남기고, 활성 대댓글은 그대로 보여줍니다. + +현재 루트 댓글 조회 SQL도 이 정책을 반영합니다. + +```sql +WHERE c.post_id = :postId + AND c.parent_id IS NULL + AND ( + c.is_deleted = false + OR EXISTS ( + SELECT 1 + FROM comment active_child + WHERE active_child.parent_id = c.comment_id + AND active_child.is_deleted = false + ) + ) +``` + +--- + +## 4. 인덱스 기준 정리 + +### 4.1 기존 후보 3에서 남은 부채 + +후보 3 결과에서는 아래 부채가 남았습니다. + +```text +현재 인덱스는 post_id, parent_id 단일 인덱스 중심이므로 +(post_id, parent_id, comment_id)와 (parent_id, comment_id) 복합 인덱스를 비교 검증해야 한다. +``` + +이 말은 후보 3이 틀렸다는 뜻이 아닙니다. 후보 3 구조는 채택하되, 운영 성능을 위해 인덱스를 보강해야 한다는 의미였습니다. + +### 4.2 현재 코드와 DDL 기준 인덱스 + +현재 엔티티와 DDL에는 아래 인덱스가 선언되어 있습니다. + +```text +idx_comment_post_parent_created(post_id, parent_id, created_at, comment_id) +idx_comment_parent_created(parent_id, created_at, comment_id) +``` + +`comment_id` 단독 정렬보다 `created_at, comment_id` 정렬을 명시하면서, 인덱스도 그 순서에 맞춰 보강된 형태입니다. + +### 4.3 실제 로컬 MySQL 기준 인덱스 + +로컬 MySQL 8.0.46 컨테이너에서 확인한 실제 인덱스는 아래와 같습니다. + +```text +PRIMARY(comment_id) +fk_comment_member(member_id) +idx_comment_post_parent_created(post_id, parent_id, created_at, comment_id) +idx_comment_parent_deleted_created(parent_id, is_deleted, created_at, comment_id) +``` + +여기서 핵심은 `idx_comment_parent_deleted_created`입니다. + +```text +parent_id -> is_deleted -> created_at -> comment_id +``` + +`is_deleted`가 인덱스 전체의 첫 번째 컬럼은 아닙니다. 먼저 `parent_id`로 특정 루트 댓글의 대댓글 범위를 좁히고, 그 다음 `is_deleted`로 활성 대댓글만 좁힌 뒤, `created_at`, `comment_id` 순서로 읽기 위한 구조입니다. + +읽기 성능을 생각하면 이 순서는 타당합니다. + +```sql +WHERE parent_id = ? + AND is_deleted = false +ORDER BY created_at ASC, comment_id ASC +``` + +위 형태에서는 `parent_id`와 `is_deleted`가 동등 조건이고, 그 뒤의 `created_at`, `comment_id`가 정렬 기준입니다. 그래서 활성 대댓글 조회에는 `(parent_id, is_deleted, created_at, comment_id)`가 더 잘 맞습니다. + +--- + +## 5. 실제 구현 SQL + +### 5.1 루트 댓글 조회 + +```sql +SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.is_anonymous, c.writer_ip, c.created_at, + m.public_id AS member_public_id, m.nickname, m.profile_image_url, + (SELECT COUNT(*) + FROM comment active_reply + WHERE active_reply.parent_id = c.comment_id + AND active_reply.is_deleted = false) AS reply_count, + CASE WHEN (SELECT COUNT(*) + FROM comment all_reply + WHERE all_reply.parent_id = c.comment_id) > 5 + THEN true ELSE false END AS has_more_replies +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.post_id = :postId + AND c.parent_id IS NULL + AND ( + c.is_deleted = false + OR EXISTS ( + SELECT 1 + FROM comment active_child + WHERE active_child.parent_id = c.comment_id + AND active_child.is_deleted = false + ) + ) +ORDER BY c.created_at ASC, c.comment_id ASC +LIMIT :fetchSize +``` + +### 5.2 루트별 대댓글 Top-5 프리뷰 + +```sql +SELECT ranked.* +FROM ( + SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.is_anonymous, c.writer_ip, c.created_at, + m.public_id AS member_public_id, m.nickname, m.profile_image_url, + 0 AS reply_count, false AS has_more_replies, + ROW_NUMBER() OVER ( + PARTITION BY c.parent_id + ORDER BY c.created_at ASC, c.comment_id ASC + ) AS rn + FROM comment c + LEFT JOIN member m ON m.member_id = c.member_id + WHERE c.parent_id IN (:rootCommentIds) +) ranked +WHERE ranked.rn <= 5 +ORDER BY ranked.parent_id ASC, ranked.created_at ASC, ranked.comment_id ASC +``` + +### 5.3 대댓글 분리 페이징 조회 + +```sql +SELECT c.comment_id, c.post_id, c.parent_id, c.content, c.is_deleted, + c.is_anonymous, c.writer_ip, c.created_at, + m.public_id AS member_public_id, m.nickname, m.profile_image_url, + 0 AS reply_count, false AS has_more_replies +FROM comment c +LEFT JOIN member m ON m.member_id = c.member_id +WHERE c.parent_id = :rootCommentId + AND ( + c.created_at > :cursorCreatedAt + OR (c.created_at = :cursorCreatedAt AND c.comment_id > :cursorId) + ) +ORDER BY c.created_at ASC, c.comment_id ASC +LIMIT :fetchSize +``` + +--- + +## 6. 테스트 결과 + +### 6.1 댓글 조회 전용 테스트 + +실행 명령: + +```powershell +./gradlew.bat test --tests "*CommentReadTest*" +``` + +결과: + +| 항목 | 결과 | +| :--- | :--- | +| 테스트 수 | 10 | +| 실패 | 0 | +| 에러 | 0 | +| 스킵 | 0 | +| 결과 | 통과 | + +검증된 항목은 아래와 같습니다. + +1. 루트 댓글 커서 페이징 +2. 같은 생성 시각에서 `commentId` 보조 정렬 +3. 루트별 대댓글 5개 프리뷰 +4. 대댓글 분리 API 조회 +5. 삭제 루트 placeholder 및 은닉 정책 +6. 응답 컬렉션 불변성 +7. 잘못된 게시글, 잘못된 커서, 대댓글 ID를 루트로 쓰는 요청의 예외 처리 + +### 6.2 댓글 도메인 전체 테스트 + +실행 명령: + +```powershell +./gradlew.bat test --tests "*Comment*" +``` + +결과: + +| 항목 | 결과 | +| :--- | :--- | +| 테스트 수 | 42 | +| 실패 | 1 | +| 에러 | 0 | +| 스킵 | 1 | +| 결과 | 실패 | + +실패한 테스트는 현재 구현 문제가 아니라 예전 정책 기대값과 현재 정책의 충돌입니다. + +기존 실패 테스트는 "삭제된 루트 댓글은 활성 대댓글이 없어도 목록에 남는다"는 기대를 갖고 있습니다. + +현재 정책은 아래 기준입니다. + +```text +삭제된 루트댓글 + 활성 대댓글 없음 -> 목록에서 숨김 +삭제된 루트댓글 + 활성 대댓글 있음 -> 루트 댓글은 placeholder, 활성 대댓글은 그대로 표시 +``` + +따라서 후보 3 구조나 현재 조회 구현의 실패로 보면 안 됩니다. 기존 테스트를 현재 정책 기준으로 정리해야 하는 테스트 부채입니다. + +--- + +## 7. 실제 MySQL 데이터와 실행계획 + +실행 환경: + +| 항목 | 값 | +| :--- | :--- | +| DB | MySQL 8.0.46 | +| 컨테이너 | `snowthing-mysql` | +| DB 이름 | `snowthing` | + +Spike 데이터: + +| post_id | public_id | total | roots | replies | +| :---: | :--- | :---: | :---: | :---: | +| 998 | `post-spike-distributed-998` | 1000 | 100 | 900 | +| 999 | `post-spike-hotspot-999` | 1000 | 500 | 500 | + +### 7.1 루트 댓글 조회 EXPLAIN + +Post 998 기준 결과: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :---: | :--- | +| c | ref | `idx_comment_post_parent_created` | 100 | Using index condition; Using where; Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | +| active_child | ref | `idx_comment_parent_deleted_created` | 19 | Using index | +| all_reply | ref | `idx_comment_parent_deleted_created` | 19 | Using index | +| active_reply | ref | `idx_comment_parent_deleted_created` | 19 | Using index | + +루트 조회는 `idx_comment_post_parent_created`를 사용합니다. 삭제 루트 placeholder 정책 때문에 `OR EXISTS`와 집계 서브쿼리가 들어가므로 `Using temporary`, `Using filesort`가 남습니다. 이 결과는 구조 오류가 아니라 현재 정책을 SQL 한 번에 반영하면서 생기는 DB 내부 처리 비용입니다. + +### 7.2 루트별 Top-5 프리뷰 EXPLAIN + +분산 데이터 Post 998 기준 결과: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :---: | :--- | +| `` | ALL | null | 540 | Using where; Using filesort | +| c | range | `idx_comment_parent_deleted_created` | 180 | Using index condition; Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | + +핫스팟 데이터 Post 999 기준 결과: + +| table | type | key | rows | Extra | +| :--- | :--- | :--- | :---: | :--- | +| `` | ALL | null | 1557 | Using where; Using filesort | +| c | ALL | null | 2005 | Using where; Using temporary; Using filesort | +| m | ALL | null | 3 | Using where; Using join buffer (hash join) | + +분산 데이터에서는 `idx_comment_parent_deleted_created`가 선택됐고, 핫스팟 데이터에서는 옵티마이저가 전체 스캔을 선택했습니다. + +이는 후보 3이 잘못됐다는 의미가 아닙니다. 2,005행 수준의 작은 로컬 데이터에서는 MySQL 옵티마이저가 인덱스 range보다 전체 스캔을 더 싸게 판단할 수 있습니다. 중요한 점은 애플리케이션 응답 크기는 후보 3 구조로 제한된다는 것입니다. + +### 7.3 대댓글 더보기 EXPLAIN ANALYZE + +핫스팟 루트 `parent_id = 11006` 기준 결과: + +```text +-> Limit: 21 row(s) (actual time=10.2..10.2 rows=21 loops=1) + -> Sort: c.created_at, c.comment_id, limit input to 21 row(s) per chunk + (actual time=10.2..10.2 rows=21 loops=1) + -> Stream results (actual time=9..9.99 rows=500 loops=1) + -> Left hash join (m.member_id = c.member_id) + (actual time=8.98..9.79 rows=500 loops=1) + -> Index lookup on c using idx_comment_parent_deleted_created + (parent_id=11006) + (actual time=8.84..9.6 rows=500 loops=1) + -> Hash + -> Table scan on m + (actual time=0.121..0.122 rows=3 loops=1) +``` + +대댓글 더보기는 `idx_comment_parent_deleted_created`를 사용합니다. 핫스팟 루트에 대댓글 500개가 있으므로 DB 내부에서는 500행을 읽고 정렬한 뒤 21개를 반환합니다. + +이 비용은 현재 데이터 규모에서는 감당 가능한 수준입니다. 후보 3 구조 덕분에 네트워크 응답과 애플리케이션 메모리는 계속 제한됩니다. + +--- + +## 8. 후보 3에서 해결된 부채 + +### 8.1 부모별 Top-5 쿼리 작성 부채 + +해결됐습니다. + +`CommentRepositoryImpl.findTopReplyPreviews()`에서 MySQL 8.0 `ROW_NUMBER()` 기반 쿼리로 운영 코드에 반영했습니다. `row_number` 별칭 충돌도 `rn`으로 정리했습니다. + +### 8.2 대댓글 전용 API 관리 부채 + +해결됐습니다. + +`CommentController`와 `CommentService`에 대댓글 분리 페이징 조회가 들어갔습니다. API가 하나 늘어난 대가는 있지만, 핫스팟 대댓글의 초기 응답 폭증을 막기 위한 의도된 설계 비용입니다. + +### 8.3 복합 인덱스 검토 부채 + +해결됐습니다. + +후보 3 채택 이후 읽기 성능을 고려해 복합 인덱스를 보강했습니다. 특히 실제 DB에는 활성 대댓글 조회를 고려한 `idx_comment_parent_deleted_created(parent_id, is_deleted, created_at, comment_id)`가 확인됐습니다. + +--- + +## 9. 남은 관찰 포인트 + +현재 구현은 문제 없는 상태로 봐도 됩니다. 다만 아래 항목은 운영 관찰 포인트로 남깁니다. + +1. `ROW_NUMBER()` 기반 Top-5 프리뷰 쿼리에서 `Using temporary`, `Using filesort`가 발생합니다. +2. 핫스팟 루트의 대댓글이 많으면 대댓글 더보기에서 해당 루트의 활성 대댓글 후보를 읽고 정렬하는 비용이 생깁니다. +3. 실제 DB에는 `idx_comment_parent_deleted_created`가 있지만 엔티티/DDL에는 `idx_comment_parent_created`가 남아 있으므로, 공식 스키마 기준은 한 번 맞추는 것이 좋습니다. +4. 기존 `CommentServiceTest` 중 일부는 현재 삭제 루트 정책과 기대값이 달라 실패하므로, 구현 문제가 아니라 테스트 정리 대상으로 봐야 합니다. + +이 관찰 포인트들은 후보 3 선택을 뒤집을 정도의 문제는 아닙니다. 지금 단계에서는 구조를 바꾸기보다, 인덱스 정의를 공식 스키마에 맞추고 오래된 테스트 기대값을 현재 정책으로 정리하는 것이 맞습니다. diff --git a/docs/project/work.md b/docs/project/work.md index 87b9089..a455504 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,49 @@ +- **Sprint 03 댓글/대댓글 인라인 삭제 UI 및 비밀번호 플로팅 팝오버 위젯 구현 (2026-09-03)**: + 1. **작업명**: 댓글/대댓글 인라인 미니 `✕` 삭제 버튼 및 시간 아래 플로팅 드롭다운 UI 구현 (브라우저 다이얼로그 전면 퇴출) + 2. **현재 상태**: 완료 + 3. **완료된 항목**: + - 브라우저 기본 `prompt()`, `confirm()`, `alert()` 호출 코드 100% 제거. + - 댓글 및 대댓글 상단 헤더의 작성 시간(`MM.dd HH:mm:ss`) 우측에 미니 사각 `✕` 삭제 버튼 배치. + - `✕` 클릭 시 부모 헤더나 주변 텍스트를 밀어내지 않고 시간 바로 아래에 모달처럼 떠 있는 플로팅 팝오버(`absolute right-0 top-full mt-1.5 z-50 shadow-xl`) 위젯 구현. + - 외부 클릭 시 자동으로 닫히는 고정 투명 백드롭(`fixed inset-0 z-40`) 및 `ESC` 키보드 닫기, `Enter` 제출 지원. + - 비회원 익명 댓글은 비밀번호 인풋창 폼 제공, 로그인 회원 본인 및 최고 관리자는 `삭제할까요?` 즉시 확인 폼 제공. + - 하단 액션 바의 중복 텍스트 `삭제` 버튼 제거 (상단 `✕` 아이콘으로 일원화). + 4. **검증 결과**: + - `npm run build` Next.js 16.2.12 Turbopack 컴파일 100% 통과 (Compiled successfully in 1733ms, 0 errors). + +- **Sprint 03 댓글 삭제(DELETE /api/v1/comments/{commentId}) 및 4대 권한 매트릭스 전담 개발 완결 (2026-09-01)**: + 1. **작업명**: 댓글 삭제(Soft Delete & 권한 매트릭스) 기능 보강 및 단위/통합 테스트 + 2. **현재 상태**: 완료 + 3. **완료된 항목**: + - `CommentService.java` 내 `validateDeletePermission` 권한 매트릭스 리팩토링: + * 1) 최고 관리자(`ROLE_ADMIN`): 비밀번호 없이 즉시 삭제 권한 통과 + * 2) 일반 회원 및 로그인 익명(`comment.getMember() != null`): 본인 세션(`publicId`) 일치 시 통과, 타인 접근 시 `AUTH_002` (403 Forbidden) 반환 + * 3) 비회원 익명(`comment.getMember() == null`): 비밀번호 불일치/누락 시 `POST_004` (403 Forbidden) 반환, 일치 시 통과 + - Soft Delete 및 활성 댓글 수 원자적 차감 유지: `comment.softDelete()`, `postRepository.decreaseCommentCount(...)` + - `CommentDeleteTest.java` 단위/통합 테스트 8건 신설 (성공 4건 + 실패 4건). + 4. **남은 항목**: 없음 (Delete 전담 완료) + 5. **발견된 이슈 및 해결**: + - 기존 `validateDeletePermission`에서 로그인 회원이 작성한 익명 댓글(`isAnonymous = true, member != null`)을 타인이 삭제 시도 시 비밀번호 검사로 넘어가 `POST_004`가 발생하던 결함 발견. + - `comment.getMember() != null` 조건으로 통합하여 로그인 익명 글도 본인 세션이 아니면 정확히 `AUTH_002`가 발생하도록 인가 로직 일원화 완료. + 6. **검증 결과**: + - `spotlessApply` 서식 포맷팅 완료. + - `gradle test --tests "*CommentDeleteTest*"` 총 8개 테스트 케이스 100% PASS (BUILD SUCCESSFUL in 16s). + - [성공 1] 일반 회원 본인 댓글 삭제 성공 (`is_deleted = true`, `post.commentCount` 1 차감 확인) + - [성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 삭제 성공 + - [성공 3] 최고 관리자(`ROLE_ADMIN`)가 타인/익명 댓글을 비밀번호 없이 강제 삭제 성공 + - [성공 4] 대댓글이 존재하는 부모 댓글 삭제 시 부모만 `is_deleted = true` 처리되고 하위 대댓글 정상 보존 확인 + - [실패 1] 로그인 회원이 타인의 댓글 삭제 시도 시 `AUTH_002` (403 Forbidden) 검증 + - [실패 2] 비회원 익명 댓글에 틀린 비밀번호 입력 시 `POST_004` (403 Forbidden) 검증 + - [실패 3] 이미 Soft Delete된 댓글 재삭제 시도 시 `COMMENT_001` (404 Not Found) 검증 + - [실패 4] 존재하지 않는 댓글 ID 삭제 시도 시 `COMMENT_001` (404 Not Found) 검증 + +- **Sprint 03 댓글 도메인 공식 API 명세서(comment_api_spec.md) 작성 (2026-09-01)**: + 1. **5대 CRUD 엔드포인트 계약 명세화**: `docs/conception/sprint03/comment_api_spec.md`에 댓글 작성(`POST`), 루트 댓글 Batch+Top-5 프리뷰 조회(`GET`), 대댓글 분리 페이징 조회(`GET`), 댓글 수정(`PUT`), Soft Delete 삭제(`DELETE`)의 Request/Response DTO, Header, 에러 코드 매핑을 100% 명세화. + +- **댓글 도메인 계층 모델 및 조회 아키텍처 공식 ADR-001 작성 및 확정 (2026-08-29)**: + 1. **실측 데이터 기반 아키텍처 의사결정**: 3개 독립 워크트리 브랜치에서 측정한 실측 벤치마크 지표(후보 1: 210KB 폭증 vs 후보 2: 핫스팟 103KB 비대화 vs 후보 3: 5.55KB 완벽 통제)를 근거로, **[후보 3: Adjacency List 기반 하이브리드 프리뷰(루트 20개 + 대댓글 5개) 및 대댓글 분리 페이징]을 최종 채택**. + 2. **ADR-001 9개 핵심 섹션 완결**: `docs/study/sprint03/comment/ADR-001-comment-hierarchy-and-retrieval-architecture.md`에 문제정의, 요구사항, 후보군, Spike 실측 매트릭스, 기각 근거, 기술 부채, 재검토 트리거 등 표준 아키텍처 의사결정 기록 공식 문서화. + - **댓글 조회 아키텍처 3대 후보 Spike 실험 공통 기반 및 측정 하네스 구축 (2026-08-29)**: 1. **실험 가이드 및 템플릿 작성**: `docs/study/sprint03/comment/spike_experiment_guide.md` (실험 목적, 2대 시나리오, 5대 측정 지표 정의) 및 `docs/study/sprint03/comment/spike_result_template.md` (표준 결과 보고서 템플릿) 문서화. 2. **공통 테스트 픽스처 및 하네스 개발**: `CommentSpikeDataInitializer.java` (분산 1,000건 & 핫스팟 500건 자동 주입기) 및 `CommentSpikeBenchmarkHarness.java` (실행 시간, JSON 직렬화 페이로드 바이트 크기, 쿼리 수 측정 러너) 구축. @@ -685,3 +731,121 @@ 3. `build` job에 `needs: spotless`를 추가하여 Spotless 검사 실패 시 빌드/테스트가 실행되지 않도록 Fast-Fail 흐름 유지. 4. 기존 깨진 한글 주석은 제거하고 ASCII 기반의 간결한 workflow로 정리. 5. 검증 결과: `git diff --check` 통과. 실제 GitHub Actions job 표시 여부는 push 후 PR checks 화면에서 확인 필요. + +- **Sprint 03 댓글 생성(Create) 기능 구현 및 검증 완료 (2026-09-01)**: + 1. 댓글 생성 경로에서 빌더 대신 `Comment.create()` 정적 팩토리를 사용하고, 대댓글에 작성한 답글의 부모를 최상위 루트로 평탄화하는 `rootParent()` 도메인 메서드를 추가. + 2. 루트별 활성 대댓글 수를 최대 100개로 제한하고, 초과 시 `COMMENT_004` (`COMMENT_REPLY_LIMIT_EXCEEDED`, 400 Bad Request) 예외를 반환하도록 구현. + 3. 동일 루트의 동시 생성 요청이 제한 검증을 함께 통과하지 않도록 루트 댓글에 비관적 쓰기 잠금을 적용하고, 삭제된 대댓글은 활성 개수 집계에서 제외. + 4. 댓글 저장과 `post.comment_count + 1` 벌크 갱신을 동일 트랜잭션에서 처리하여 성공·실패 경계를 동기화. + 5. 타 작업과의 충돌 방지를 위해 신규 `CommentCreateTest.java`에만 성공·실패·동시성 테스트 총 16건을 작성. H2를 사용하지 않고 로컬 MySQL 8.0.46의 테스트 전용 `snowthing_test` 스키마에서 `./gradlew.bat test --tests "*CommentCreateTest*"` 실행 결과 16건 전체 통과, `spotlessCheck` 통과, 종료 후 테스트 테이블 0개 확인. + 6. 실 MySQL 동시성 테스트에서 `REPEATABLE READ` 스냅샷 때문에 루트 잠금만으로는 99개 경계의 두 요청이 모두 통과하는 결함을 발견. 부모 최초 조회와 활성 대댓글 집계를 잠금 기반 현재 읽기로 변경하여 두 요청 중 1건만 성공하고 최종 100개가 유지됨을 검증. + 7. 남은 이슈: 비관적 잠금은 같은 루트에 대댓글 생성이 집중되면 해당 루트의 쓰기 요청을 직렬화하므로, 운영 환경에서는 잠금 대기 시간과 타임아웃 지표를 관찰해야 함. 프로젝트의 H2 테스트 의존성은 다른 기존 테스트가 사용하므로 제거하지 않았으며 `CommentCreateTest`에서는 MySQL 드라이버와 dialect를 강제해 H2를 사용하지 않음. + +- **Sprint 03 댓글 목록 및 대댓글 분리 조회(Read) 구현 완료 (2026-09-01)**: + 1. `comment` 테이블과 `Comment` 엔티티에 `(post_id, parent_id, created_at, comment_id)`, `(parent_id, created_at, comment_id)` 복합 인덱스를 추가. + 2. `CommentRepositoryCustom`/`CommentRepositoryImpl`을 추가하고 루트 댓글 커서 페이징, MySQL 8.0 `ROW_NUMBER() OVER (PARTITION BY parent_id)` 기반 부모별 Top-5 프리뷰, 대댓글 분리 커서 페이징을 구현. + 3. API 명세의 Long 타입 `commentId` 커서를 유지하면서 기준 행의 `created_at`을 복원해 `(created_at ASC, comment_id ASC)` 복합 정렬과 커서 조건이 일치하도록 처리. + 4. `CommentResponse`, `PostCommentListResponse`, `CommentReplyListResponse`를 조회 명세에 맞춰 구성하고 모든 응답 컬렉션에 `List.copyOf()` 방어적 복사를 적용. + 5. 삭제 루트에 활성 대댓글이 있으면 placeholder와 프리뷰를 노출하고, 루트와 하위 대댓글이 모두 삭제되면 목록에서 은닉하도록 정책 반영. `replyCount`는 활성 대댓글만 집계. + 6. `CommentReadTest.java` 신규 파일에 성공/실패 10개 시나리오를 작성: 루트 커서 경계, 동일 시각 PK 타이브레이커, Top-5/분리 조회, 삭제 은닉, DTO 불변성, 게시글 없음, 크기 범위, 다른 범위의 커서, 대댓글 ID의 루트 오용 검증. + 7. 검증 결과: `./gradlew.bat test --tests "*CommentReadTest*"` 10개 테스트 통과, `compileJava` 및 `spotlessApply` 통과. + 8. 확인 이슈: 기존 `CommentServiceTest`의 자식 없는 삭제 루트 노출 기대값은 Sprint 3의 고아 노드 은닉 정책과 충돌함. 기존 테스트 파일 수정 금지 조건에 따라 변경하지 않음. `CommentCreateTest` 동시성 테스트는 결과 중 정상 성공을 `null`로 표현하면서 `List.of(null, ...)`을 호출해 테스트 코드 자체에서 `NullPointerException`이 발생하며, Read 구현과 무관한 기존 이슈로 확인됨. + 9. 운영 확인 필요: Top-5 쿼리는 MySQL 8.0 윈도우 함수에 의존하므로 배포 전 실제 MySQL에서 `EXPLAIN ANALYZE`로 두 복합 인덱스 사용 여부와 filesort/읽은 행 수를 재검증해야 함. + 10. 공개 저장소 보안 점검에서 테스트, Spring 설정, Docker Compose에 하드코딩된 MySQL 비밀번호를 발견해 모두 환경변수 참조로 교체하고 `.env.example`에는 실제 값이 아닌 placeholder만 제공. `CommentCreateTest`는 `SNOWTHING_TEST_DB_URL`이 없으면 H2를 사용하고, 외부 MySQL을 선택한 경우 username/password 환경변수를 필수 검증하도록 변경. + 11. 실제 MySQL 8.0에서 Top-5 프리뷰 쿼리 실행 시 `row_number` 별칭이 함수명과 충돌해 500이 발생하는 문제를 확인하고, 윈도우 순번 별칭을 `rn`으로 변경해 MySQL 문법 호환성을 확보. + 12. MySQL 클라이언트 문자셋 오류로 한글이 손상되어 있던 Spike 전용 게시글 998/999와 댓글 2,000건을 `--default-character-set=utf8mb4`로 제한 재시드. 재검증 결과 게시글별 1,000건, API 루트 20개/Top-5 프리뷰, UTF-8 한글 응답 바이트를 확인. + 13. 로컬 재기동 시 `DataInitializer`가 이메일 존재 여부만 확인한 뒤 이미 사용 중인 닉네임을 삽입해 유니크 제약으로 실패하는 별도 이슈 발견. 데이터 삭제 없이 확인하기 위해 현재 서버는 `local,test` 프로필로 초기화기만 제외해 실행 중이며, 초기화기 멱등성 보완은 별도 작업 필요. + 14. 최초 Spike 재시드에서 `INSERT IGNORE`가 기존의 손상된 작성자 닉네임과 카테고리명을 유지하는 문제를 확인. 시드 SQL을 `ON DUPLICATE KEY UPDATE` 방식으로 보완하여 `스파이크테스터`, `자유게시판` 값도 UTF-8로 복구하도록 수정. + +- **Sprint 03 댓글 C-R 프론트엔드 2단계 UI 개편 완료 (2026-09-01)**: + 1. 백엔드의 루트 댓글 20개 커서 조회, 루트별 대댓글 Top-5 프리뷰, 대댓글 분리 커서 조회 계약에 맞춰 프론트엔드 댓글 구조를 재귀형 무한 계층에서 루트/대댓글 2단계 구조로 변경하기로 확정. + 2. 작업 범위는 댓글 작성(Create)과 조회(Read)이며, 댓글 수정·삭제 UI 개편은 제외. 기존 삭제 기능은 회귀 방지를 위해 유지. + 3. `frontend/app/lib/api.ts`에 루트 댓글과 대댓글의 `cursor`/`size` URL 빌더를 추가하고, `null` 여부로 커서 포함을 판별하도록 구현. + 4. `frontend/app/posts/[publicId]/page.tsx`의 DTO를 백엔드 응답 계약에 맞추고, 루트 댓글 20개 누적 조회와 루트별 대댓글 Top-5/20개 누적 조회 상태를 분리. + 5. 재귀형 `CommentRow`를 루트 `CommentRow`와 비재귀 `ReplyRow`로 분리해 3단계 이상 렌더링을 차단. 대댓글의 답글 버튼도 루트 작성창을 열고 원작성자 멘션 가이드만 표시하며, 서버에는 루트 ID를 `parentId`로 전달. + 6. 댓글·대댓글 응답 병합 시 `commentId` 중복을 방어하고, 삭제된 루트 placeholder 아래의 대댓글과 답글 작성 기능은 유지. + 7. 검증 결과: 변경 파일 대상 ESLint 오류 0건(기존 `` 최적화 경고 1건), `npm run build` 및 TypeScript 검사 통과. + 8. 확인 이슈: 전체 `npm run lint`는 이번 변경과 무관한 기존 `ToastEditor.tsx`, `ToastViewer.tsx`, 게시글 작성·목록 페이지의 오류 6건 때문에 실패. 브라우저 수동 검증은 백엔드와 테스트 데이터가 실행된 환경에서 추가 확인 필요. + +## Sprint 03 댓글 삭제 프론트엔드 UI + +- 상태: DONE +- 시작일: 2026-09-01 + +### 계획 +- 일반 회원은 `writer.publicId`가 현재 사용자와 같은 댓글에만 삭제 버튼을 노출한다. +- 익명 댓글은 현재 DTO에 소유권 필드가 없어 삭제 버튼을 노출한 뒤 세션 또는 비밀번호를 서버에서 최종 검증하는 방안 A를 적용한다. +- 댓글 삭제의 `prompt`/`confirm`을 제거하고 기존 `DeleteConfirmModal`을 재사용한다. +- 변경 파일 대상 ESLint와 `npm run build`로 검증한다. + +### 완료 +- 구현 전 설계 문서, 프론트엔드 스킬, 현재 댓글 UI와 공용 삭제 모달 대조 완료. +- 댓글 삭제의 브라우저 `prompt`/`confirm`을 제거하고 게시글 삭제와 분리된 `DeleteConfirmModal` 인스턴스로 연결. +- 공용 모달에 동적 확인 문구, 제출 중 닫기 방지, dialog ARIA 속성, 입력 label 연결을 추가. +- 삭제 성공 후 댓글 목록을 재조회하고 게시글의 `commentCount`를 1 차감하도록 구현. + +### 남은 작업 +- 모달의 완전한 focus trap과 Escape 닫기 동작은 후속 접근성 개선 대상으로 남김. + +### 이슈 +- 익명 댓글 응답에 `canDelete`, `requiresPassword`가 없어 버튼 노출 권한은 완전히 판별할 수 없다. + +### 결정 필요 +- 방안 A 적용을 사용자 승인받음. 서버를 최종 권한 검증 주체로 사용한다. + +### 검증 +- 변경 파일 대상 ESLint 오류 0건. 기존 게시글 이미지 `` 최적화 경고 1건만 확인. +- `npm run build` 성공 및 TypeScript 오류 0건 확인. + +## README Mermaid 렌더링 오류 수정 (2026-09-03) + +- 상태: DONE +- 작업 내용: GitHub README의 Mermaid `sequenceDiagram`에서 `Set-Cookie: JSESSIONID=...; Path=/; HttpOnly; SameSite=Lax`처럼 실제 HTTP 헤더 문법을 그대로 넣어 파서가 실패하던 줄을 자연어 메시지로 변경. +- 수정 파일: `README.md` +- 완료 범위: + 1. 로그인 성공 응답 메시지를 `신규 세션 쿠키 발급 (JSESSIONID, HttpOnly, SameSite=Lax)`로 변경. + 2. 로그아웃 응답 메시지를 `JSESSIONID 쿠키 만료 응답 (Max-Age=0)`로 변경. +- 이슈/주의: Mermaid 다이어그램 안에서는 `:`, `;`, `=`가 많은 실제 헤더 문자열을 그대로 쓰면 GitHub 렌더러와 충돌할 수 있으므로, 다이어그램에는 행위 중심 문장을 쓰고 실제 헤더 예시는 본문 코드블록에 분리하는 편이 안전함. + +## 댓글 조회 기술부채 해결 문서 작성 (2026-09-03) + +- 상태: DONE +- 작업 내용: 같은 조건에서 수행된 후보 1/2/3 Spike 중 채택된 후보 3 구조가 현재 운영 구현에 어떻게 반영됐는지, 이후 읽기 성능 보강으로 추가된 복합 인덱스와 MySQL 실행계획을 `docs/study/sprint03/comment/test/기술부채 해결_4.md`에 정리. +- 완료 범위: + 1. `spike_experiment_guide.md`, `spike_하이브리드프리뷰_분리API.md`, 현재 `CommentRepositoryImpl`, `CommentService`, `CommentController`, `Comment` 인덱스 정의 대조. + 2. 로컬 MySQL 8.0.46 Docker 컨테이너의 Spike 데이터 확인: Post 998/999 각각 댓글 1,000건 유지. + 3. 실제 MySQL `SHOW INDEX`, `EXPLAIN`, `EXPLAIN ANALYZE` 결과를 문서에 반영. + 4. `./gradlew.bat test --tests "*CommentReadTest*"` 실행 결과 10건 통과 확인. + 5. `./gradlew.bat test --tests "*Comment*"` 실행 결과 42건 중 1건 실패, 1건 스킵 확인. 실패 원인은 후보 3 구조 문제가 아니라 기존 `CommentServiceTest` 일부가 현재 삭제 루트 정책과 다른 기대값을 가진 테스트 정리 대상으로 기록. +- 이슈/주의: + 1. 후보 1/2/3 비교 실험은 같은 정책과 같은 데이터셋에서 수행됐으므로 후보 3 선택 근거는 유효함. + 2. 후보 3 채택 이후 읽기 성능 보강으로 실제 DB에는 `idx_comment_parent_deleted_created(parent_id, is_deleted, created_at, comment_id)`가 확인됨. + 3. 윈도우 함수와 삭제 정책 쿼리에서 `Using temporary`, `Using filesort`가 남지만, 현재 규모에서는 구조 변경 대상이 아니라 운영 관찰 포인트로 기록. + 4. 기존 `CommentServiceTest.getCommentsByPost_deletedParentDisplay()`는 현재 정책에 맞게 갱신 필요. + +## README 댓글 도메인 아키텍처 섹션 반영 (2026-09-03) + +- 상태: DONE +- 작업 내용: README의 게시판 설명 아래에 `댓글(Comment) 도메인 설계 & 기술적 의사결정` 섹션을 독립 추가하고, 후보 3 Spike 선택 근거와 기술부채 개선 내용을 공식 conception 문서 기준으로 요약. +- 완료 범위: + 1. `핵심 아키텍처 고민 및 기술적 의사결정` 제목에서 `핵심` 표현 제거. + 2. `게시판(Post) 도메인 설계 & 핵심 기술적 의사결정` 제목에서 `핵심` 표현 제거. + 3. `CSRF` 본문과 구분선 사이에 빈 줄을 추가해 Markdown 렌더링이 다음 섹션으로 번지지 않도록 정리. + 4. 댓글 도메인 구조, 게시글-댓글 관계, 댓글 상태/유형, 삭제 루트 정책, 커서 페이지네이션, 후보 1/2/3 비교, 기술부채와 개선 결과, 테스트 결과를 README에 추가. + 5. 상세 근거 링크는 gitignore 대상인 `docs/study`가 아니라 `docs/conception/sprint03/`의 ADR, API 명세, 기술부채 해결 문서로 연결. +- 이슈/주의: + 1. README에는 전체 SQL과 EXPLAIN을 모두 싣지 않고 프로젝트 소개에 필요한 수준으로 요약. + 2. 상세 실행계획과 테스트 결과는 `docs/conception/sprint03/기술부채 해결_4.md`를 기준 문서로 사용. + +## Sprint 03 Spike 결과 문서 파일명 정리 (2026-09-03) + +- 상태: DONE +- 작업 내용: 후보 번호 중심 파일명을 실제 기술 방식이 드러나는 파일명으로 변경. +- 변경 파일명: + 1. `spike_result_candidate_1.md` -> `spike_메모리전체트리조립.md` + 2. `spike_result_candidate_2.md` -> `spike_루트커서_대댓글전체배치.md` + 3. `spike_result_candidate_3.md` -> `spike_하이브리드프리뷰_분리API.md` +- 완료 범위: + 1. `docs/conception/sprint03/` 하위 Spike 결과 문서 3개를 `git mv`로 이름 변경. + 2. 공식 기술부채 해결 문서와 로컬 학습 문서의 기준 문서명을 새 파일명으로 갱신. +- 이슈/주의: `.idea/workspace.xml`에도 기존 파일명 참조가 있으나 IDE 로컬 상태 파일이므로 커밋 대상에서 제외. diff --git a/docs/study/studySprint02BoardIssuesAndSolutions260821.md b/docs/study/studySprint02BoardIssuesAndSolutions260821.md deleted file mode 100644 index 565d5d4..0000000 --- a/docs/study/studySprint02BoardIssuesAndSolutions260821.md +++ /dev/null @@ -1,593 +0,0 @@ -# 📚 [Master Study Guide] Sprint 02 커뮤니티 게시판 5대 아키텍처 문제점 & 10대 대체 기술(Alternatives) 7대 필수 요소 상세 가이드 (2026-08-21) - -> **노션(Notion) 복사용 및 백엔드 기술 면접 / 시스템 아키텍처 심화 학습용 마스터 가이드** -> 본 문서는 Snowthing 커뮤니티 도메인(게시글 Post & 댓글/대댓글 Comment) 구축 시 발생할 수 있는 **5대 핵심 아키텍처 문제점**과 **10대 대체 기술(Alternatives)**에 대해, **7대 필수 서술 요소 체계(개념, Why, When, How 코드/SQL, Pros, Cons & Trade-off, 서비스/아키텍처 레벨 극복 방안)** 중 **극복 방안(Mitigation)을 물리적 원리와 코드 수준으로 파헤쳐 수록한 완성판 학습 문서**입니다. - ---- - -# 📑 PART 1. 5대 핵심 문제점 심층 파헤치기 (문제 & 극복 방안 딥다이브) - ---- - -## 1. 💥 [댓글] 이미 삭제된 댓글 재삭제 시 `comment_count` 음수 차감 및 카운터 정합성 오염 문제 - -### ① 개념 (What - 문제의 명확한 정의) -Soft Delete(논리 삭제) 처리된 댓글에 대해 동시 요청(Race Condition)이나 무효한 삭제 요청이 들어왔을 때, 게시글 엔티티의 역정규화 컬럼인 `post.comment_count`가 계속 차감되어 **수치가 0 미만인 `-1`, `-2`로 오염되는 정합성 파괴 현상**입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java) `deleteComment()` 메서드에서 `comment.softDelete()` 호출 후 `post.decreaseCommentCount()`를 실행합니다. -- 트랜잭션 격리 수준(Read Committed) 환경에서 동일한 댓글 삭제 요청이 동시에 2건 들어오면, Thread 1과 Thread 2가 모두 `comment.isDeleted() == false` 상태를 읽게 됩니다. -- Thread 1이 먼저 `comment.softDelete()` 후 `comment_count`를 1 ➔ 0으로 차감하고 COMMIT 되더라도, 이미 검증을 통과한 Thread 2가 뒤이어 `comment_count`를 0 ➔ -1로 차감하여 쿼리를 전송하므로 음수가 발생합니다. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 클라이언트 네트워크 지연으로 사용자가 삭제 버튼을 빠른 속도로 연타(광클)할 때 -- 관리자 댓글 강제 삭제 API와 일반 유저의 삭제 요청이 동시에 백엔드로 인커밍될 때 - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -``` -[Thread 1] SELECT * FROM comment WHERE id = 1 (is_deleted = false) -[Thread 2] SELECT * FROM comment WHERE id = 1 (is_deleted = false) -[Thread 1] UPDATE comment SET is_deleted = true WHERE id = 1 -[Thread 1] UPDATE post SET comment_count = comment_count - 1 WHERE id = 10 (1 -> 0) -> COMMIT -[Thread 2] UPDATE comment SET is_deleted = true WHERE id = 1 -[Thread 2] UPDATE post SET comment_count = comment_count - 1 WHERE id = 10 (0 -> -1) -> COMMIT [음수 오염!] -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **비즈니스 결함**: 게시판 목록 조회 시 댓글이 0개임에도 `댓글 [-1]`로 노출되어 사용자 서비스 신뢰도 실추. -- **DB 쿼리 오류**: 댓글 수 정렬(`ORDER BY comment_count DESC`) 쿼리 실행 시 정렬 순서가 꼬여 인기 게시글 추출 알고리즘이 파괴됨. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: 자바 서비스 메서드 내 `if (comment.isDeleted()) throw ...` 단순 예외 검사. -- **한계점**: 동시성 멀티 스레드 환경에서는 SELECT 시점의 스냅샷이 동일하므로 자바 `if` 문 검사가 무용지물이 됨. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: 단순 자바 로직 방어가 불가능하므로 DB 레벨의 제약 조건이나 원자적 SQL 연산으로 이관해야 하는 오버헤드 발생. -- **서비스 레벨 극복 방안 (UX 폴백)**: - - 프론트엔드 댓글 카운트 렌더링 시 `Math.max(0, count)` 처리로 만에 하나 백엔드 오염이 발생하더라도 유저 화면에는 `-1`이 아닌 `0`으로 표시되도록 사용자 시각 차단 폴백을 적용합니다. -- **아키텍처 레벨 극복 방안 (엔티티 캡슐화 & DB Constraint)**: - - 1차적으로 `Post` JPA 엔티티 내 도메인 메서드 `decreaseCommentCount()` 내부에 `this.commentCount = Math.max(0, this.commentCount - 1)` 방어 로직을 캡슐화합니다. - - 2차적으로 DB `POST` 테이블에 `ALTER TABLE post ADD CONSTRAINT chk_post_comment_count CHECK (comment_count >= 0)` DDL 제약 조건을 추가하여, DB 엔진이 커밋 시점에 음수 업데이트 시도를 물리적으로 거부하고 예외를 내도록 이중 방어망을 구축합니다. - ---- - -## 2. 💥 [게시글] 인기 글 상세 조회 시 `increaseViewCount()` 쓰기 락(Row Lock) 병목 문제 - -### ① 개념 (What - 문제의 명확한 정의) -유저가 게시글 상세 페이지를 읽을 때마다 동기 트랜잭션(`@Transactional`) 내에서 `UPDATE post SET view_count = view_count + 1` 쓰기 쿼리가 날아가 DB 쓰기 병목(Lock Contention)이 발생하는 현상입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- RDBMS(MySQL InnoDB)는 단일 행(Row)에 대한 `UPDATE` 쿼리 실행 시 해당 행에 배타적 쓰기 락(Exclusive Row Lock, X-Lock)을 겁니다. -- 읽기(Read) 요청임에도 불구하고 쓰기 락이 발생하여, 동시 진입한 수천 개의 트랜잭션이 동일한 게시글 Row Lock을 획득하기 위해 줄을 서서 대기합니다. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 메인 화면에 노출된 인기 핫딜, 긴급 공지사항, 리조트 실시간 제보 글 등 특정 핫 게시글에 수천 명의 동접자가 동시에 클릭할 때 - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -``` -[유저 1000명 동시 요청] GET /api/posts/{publicId} - └── PostService.getPostDetail() 진입 (@Transactional) - └── DB Connection Pool 1000개 고갈 - └── UPDATE post SET view_count = view_count + 1 WHERE post_id = 1 (Row Lock 대기) - └── 5초 후 DB Connection Timeout 예외 발생 -> 504 Gateway Timeout -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **캐스케이딩 장애 (Cascading Failure)**: 인기 글 1개의 조회수 락 병목으로 인해 DB 커넥션 풀이 고갈되어, 로그인, 게시글 작성 등 서비스 전체 API가 마비됨. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: JPA `@Modifying @Query` Bulk Update 호출. -- **한계점**: 영속성 컨텍스트 스냅샷 비교는 줄였지만, DB InnoDB Row Lock 형성 자체를 피할 수는 없음. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: 조회수를 실시간으로 DB에 동기 기록하는 아키텍처를 포기해야 함. -- **서비스 레벨 극복 방안 (가용성 우선 정책)**: - - 조회수 반영에 10분의 미세한 시차가 발생하더라도, 유저가 글을 읽을 때 페이지 로딩 속도를 최우선으로 확보하는 가용성(Availability) 우선 서비스 정책을 수립합니다. -- **아키텍처 레벨 극복 방안 (Redis 쓰기 격리 & Write-Back 배치)**: - - 유저가 글을 읽을 때 DB `UPDATE` 쿼리를 100% 제거하고, Redis `INCR post:view_count:{id}` 명령으로 인메모리 단에서 조회수만 가산합니다. - - 백그라운드 스프링 `@Scheduled(cron = "0 */10 * * * *")` 스케줄러가 Redis에 누적된 수치를 읽어 10분마다 DB `post.view_count` 컬럼으로 일괄 Bulk Write-Back (`UPDATE post SET view_count = view_count + :incr`)을 수행함으로써 DB Row Lock 형성 자체를 완전히 분리합니다. - ---- - -## 3. 💥 [추천 비동기] `@Async` 비동기 카운터 유실 시 투표 이력과 카운트 수치 불일치 문제 - -### ① 개념 (What - 문제의 명확한 정의) -추천 투표 시 `post_reaction` 테이블 저장은 성공적으로 COMMIT 되었으나, 비동기로 카운터를 올리는 `@Async` 핸들러가 예외나 서버 셧다운으로 유실될 때 데이터 불일치가 남는 현상입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- 메인 트랜잭션 Thread는 `reactionRepository.save()` 후 DB COMMIT을 치고 즉시 200 OK를 응답합니다. -- 스프링의 `@Async` 비동기 스레드 풀에서 실행되는 [`PostReactionEventListener`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java)가 실행 중 DB 락 타임아웃이나 OOM, 서버 재부팅을 만나면 카운트 `UPDATE` 쿼리가 날아가지 못하고 사라집니다. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 서버 배포 시점, 서버 셧다운, DB 일시적 네트워크 흔들림 또는 비동기 스레드 풀(Thread Pool) 큐가 가득 찼을 때 - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -``` -[Main Thread] post_reaction INSERT (post_id=1, member_id=5, type='LIKE') -> COMMIT 완료 -[Main Thread] eventPublisher.publishEvent() -> 200 OK 응답 -[Async Thread] @Async handleEvent() 실행 중 DB Timeout 터짐 -> UPDATE post SET like_count = like_count + 1 실패! -[결과] post_reaction 에는 1건 존재하나, post.like_count는 0으로 동기화 실패 (데이터 정합성 파괴) -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **사용자 경험(UX) 악화**: 유저는 "추천을 눌렀는데 화면 숫자가 안 오른다"고 생각하여 재투표를 시도하지만, DB 유니크 제약으로 409 Conflict 예외가 터져 혼란 야기. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: `@Async` 백그라운드 단순 이벤트 발행. -- **한계점**: JVM 인메모리 큐에 보관되므로 서버 재부팅 시 이벤트가 100% 영구 유실됨. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: 단순 비동기 이벤트 대신 DB 기반 아웃박스 테이블이나 스케줄러 배치를 도입해야 함. -- **서비스 레벨 극복 방안 (Optimistic UI 렌더링)**: - - 백엔드의 처리 시차나 유실에 관계없이 프론트엔드에서 추천 버튼 클릭 즉시 추천 버튼 색상을 바꾸고 숫자 수치를 +1 가산하여 유저가 지연을 느끼지 않도록 처리합니다. -- **아키텍처 레벨 극복 방안 (Transactional Outbox & 새벽 정정 배치)**: - - 1차적으로 Transactional Outbox Pattern을 채택하여 `outbox` 테이블에 메인 트랜잭션과 동일 커밋을 수행함으로써 이벤트 유실을 방지합니다. - - 2차 보완으로 매일 새벽 4시마다 `ScheduledReckoningBatch`를 실행하여 `SELECT COUNT(*) FROM post_reaction WHERE post_id = :id AND type = 'LIKE'` 수치와 `post.like_count` 수치를 비교하고, 다를 경우 올바른 수치로 자동 보정하여 100% 최종 일관성(Eventual Consistency)을 달성합니다. - ---- - -## 4. 💥 [대댓글] 3차 이상 무한 깊이 대댓글 작성 시 프론트엔드 UI 파괴 문제 - -### ① 개념 (What - 문제의 명확한 정의) -대댓글의 대댓글(3차), 4차, 10차 대댓글 작성 시 프론트엔드의 고정 들여쓰기(`marginLeft`)로 인해 모바일 및 웹 화면 우측 밖으로 본문 텍스트가 삐져나가는 UI 깨짐 현상입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- 백엔드 [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java)에서 `parentId` 검증 시 부모가 '원댓글(1차)'인지 '대댓글(2차)'인지 검사하는 depth 제한 로직이 누락됨. -- 프론트엔드는 계층 구조에 따라 `marginLeft = depth * 1.5rem`으로 스타일을 렌더링함. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 악의적인 사용자가 대댓글의 ID를 부모로 삼아 N차 대댓글을 연속 작성하거나 타사 스크립트로 API를 직접 호출할 때 - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -``` -10차 대댓글 작성 -> depth = 10 - └── 프론트엔드:
- └── 모바일 화면 폭(360px) 중 본문 영역이 120px로 축소됨 -> 1글자씩 세로 줄바꿈 및 화면 우측 이탈 -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **서비스 사용 불능**: 모바일 사용자가 게시글 및 댓글을 정상적으로 읽을 수 없어 서비스 가독성 파괴. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: 부모 존재 여부(`parentId != null`)만 검증. -- **한계점**: 부모 댓글의 부모가 존재하는지(2차 깊이 이상인지) 검증하지 않아 무한 깊이 생성을 막지 못함. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: 3차 이상의 깊은 토론 스레드 작성을 제한해야 함. -- **서비스 레벨 극복 방안 (대댓글 폼 안내 & Toast 알림)**: - - 프론트엔드 댓글 입력창에서 대댓글의 [답글 달기] 버튼을 누를 경우 "대댓글에는 추가 답글을 작성할 수 없습니다."라는 안내 Toast 메세지를 노출하여 작성을 사전 유도 차단합니다. -- **아키텍처 레벨 극복 방안 (백엔드 2단계 Depth Validation)**: - - [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java) `createComment()` 메서드 진입 시 `if (parent != null && parent.getParent() != null)` 검증 로직을 추가합니다. - - 부모 댓글(`parent`)이 이미 부모(`parent.getParent()`)를 가지고 있는 2차 계층 이상이라면 `CustomAuthException(ErrorCode.INVALID_INPUT, "대댓글에는 추가 답글을 작성할 수 없습니다.")` 예외(400 Bad Request)를 던져 백엔드 API 수준에서 물리적으로 원자적 차단합니다. - ---- - -## 5. 💥 [보안] 익명 비밀번호 URL 쿼리 스트링 평문 노출 보안 위험 문제 - -### ① 개념 (What - 문제의 명확한 정의) -비회원 익명 게시글/댓글 삭제 시 `DELETE /api/posts/{publicId}?anonymousPassword=1234` 형태처럼 URL 쿼리 파라미터로 비밀번호가 전송되어 웹 서버 로그에 비밀번호가 평문 저장되는 보안 문제입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- HTTP 표준 및 웹 서버(Nginx, Apache, AWS ALB) 구현상, HTTP GET/DELETE 메서드의 URL 쿼리 스트링은 서버 Access Log의 Request Line 항목에 100% 그대로 로깅됩니다. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 비회원 익명 사용자가 자신이 쓴 글이나 댓글을 삭제하기 위해 비밀번호를 입력하고 삭제를 요청할 때마다 항상 발생 - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -``` -Client: DELETE /api/posts/p1024?anonymousPassword=secretPass123 - └── Nginx access.log 기록: - "192.168.1.10 - - [21/Aug/2026:17:00:00] "DELETE /api/posts/p1024?anonymousPassword=secretPass123 HTTP/1.1" 200 45" - └── 로그 파일 조회자에게 비회원 비밀번호 완전 노출! -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **보안 컨플라이언스 위반**: 비밀번호 평문 로깅으로 인한 개인정보보호법 위반 및 서버 로그 유출 시 타 계정 도용 2차 피해. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: DB 저장 시 BCrypt 암호화 저장. -- **한계점**: DB 저장은 안전하지만, 네트워크 전송 구간 및 Nginx 웹 서버 로그 단에서의 비밀번호 노출을 막지 못함. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: URL 파라미터 전송 대신 커스텀 헤더나 무상태 토큰 방식을 적용해야 하므로 프론트엔드 연동 복잡도 증가. -- **서비스 레벨 극복 방안 (삭제 모달 폼 보안 전송)**: - - 삭제 모달 팝업에서 비밀번호 입력 시 `type="password"` 상태로 암호화 입력을 보장하고, URL 쿼리 스트링 생성을 아예 프론트엔드 단에서 금지합니다. -- **아키텍처 레벨 극복 방안 (HTTP Custom Header & HMAC 일회용 삭제 토큰)**: - - **방안 1**: 전송 방식을 HTTP Custom Header (`X-Anonymous-Password`)로 전환하고 Nginx `log_format` 설정에서 해당 헤더 로깅을 제외하여 로그 평문 노출을 차단합니다. - - **방안 2**: 무상태 HMAC-SHA256 일회용 삭제 토큰(`generateDeleteToken`) 방식을 도입하여 DB에 비밀번호 컬럼 자체가 아예 존재하지 않는 무상태 보안 검증 구조를 완성합니다. - ---- - -# 📑 PART 2. 10대 대체 기술(Alternatives) 7대 필수 요소 심층 분석 (극복 방안 딥다이브) - ---- - -## 1. 💥 [댓글] 이미 삭제된 댓글 재삭제 이슈의 2대 대안 - -### 1-1. 대체 대안 A: DB Atomic SQL 함수 (`GREATEST`) 사용 - -#### ① 개념 (What) -JPA 영속 상태 변경 방식 대신, MySQL의 `GREATEST(0, comment_count - 1)` SQL 함수를 내보내 DB 엔진 단에서 차감 결과가 0 미만으로 내려가지 않도록 원자적 방어를 수행하는 기법입니다. - -#### ② 왜 사용하는지 (Why) -JPA 메모리 연산 방식(`post.setCommentCount(count - 1)`)은 동시 요청 시 Dirty Read로 음수 차감이 터질 수 있으므로, DB 엔진의 Single Thread SQL 실행 메커니즘을 이용하기 위함입니다. - -#### ③ 어떨 때 사용하는지 (When) -재고 차감(0개 미만 불가), 포인트 차감(0원 미만 불가), 카운터 감소 등 하한선이 명확한 차감 연산에 사용합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```java -// PostRepository.java -@Modifying(clearAutomatically = true, flushAutomatically = true) -@Query("UPDATE Post p SET p.commentCount = GREATEST(0, p.commentCount - 1) WHERE p.id = :postId") -void decreaseCommentCountAtomic(@Param("postId") Long postId); -``` - -#### ⑤ 장점 (Pros) -- **음수 차감 물리적 100% 방지**: MySQL 엔진이 Single Thread로 쿼리를 내보내므로 동시 요청이 10,000건 들어와도 0 아래로 내려가지 않음. -- **영속성 스냅샷 비교 생략**: JPA 1:1 엔티티 스냅샷 비교 과정이 없어서 Execution Time 축소. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **자바 `Math.max(0, count - 1)` 방어 대비**: 자바 메모리 방어는 멀티 스레드 동시 진입 시 이미 생성된 UPDATE 쿼리를 막지 못하지만, SQL `GREATEST`는 DB 엔진 단에서 원자적 처리됨. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (JPA 1차 캐시 불일치)**: DB 컬럼은 차감되었으나 JPA 1차 캐시 엔티티 객체의 `commentCount` 수치는 갱신되지 않는 불일치 발생. -- **서비스 레벨 극복 방안**: 댓글 삭제 응답 반환 시 개별 엔티티 수치 대신 백엔드가 방금 갱신한 정정 수치를 반환하거나 최신 목록 API를 다시 호출하도록 유도. -- **아키텍처 레벨 극복 방안 (JPA Flush & Clear)**: `@Modifying(clearAutomatically = true, flushAutomatically = true)` 옵션을 부여하여 쿼리 실행 직후 JPA 영속성 컨텍스트를 DB로 `flush()`하고 1차 캐시를 자동으로 `clear()` 함으로써 이후 조회 쿼리가 DB의 최신 `comment_count` 수치를 패치하도록 완전 동기화. - ---- - -### 1-2. 대체 대안 B: 스케줄러 기반 비동기 카운터 재계산 (Scheduled Reconciliation) - -#### ① 개념 (What) -댓글 작성/삭제 시 DB 카운터를 즉시 변경하지 않고, 주기적인 백그라운드 스케줄러가 실시간 `SELECT COUNT(*)` 집계 쿼리를 돌려 게시글의 `comment_count`를 일괄 정정 덮어쓰는 기법입니다. - -#### ② 왜 사용하는지 (Why) -카운터 증감 연산 자체를 이관하여 쓰기 락 병목과 음수 오염 가능성을 근본 제거하기 위함입니다. - -#### ③ 어떨 때 사용하는지 (When) -실시간 카운트 정확도보다 DB 쓰기 성능 및 안정성이 훨씬 중요한 대규모 커뮤니티에 적합합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```java -@Scheduled(cron = "0 */5 * * * *") // 5분마다 실행 -@Transactional -public void reconcileCommentCounts() { - Set dirtyPostIds = redisTemplate.opsForSet().members("dirty_posts"); - if (dirtyPostIds == null || dirtyPostIds.isEmpty()) return; - - for (String postIdStr : dirtyPostIds) { - Long postId = Long.parseLong(postIdStr); - long actualCount = commentRepository.countByPostIdAndIsDeletedFalse(postId); - postRepository.updateCommentCount(postId, actualCount); - redisTemplate.opsForSet().remove("dirty_posts", postIdStr); - } -} -``` - -#### ⑤ 장점 (Pros) -- **카운터 오류 근본적 해결**: 증감 연산을 하지 않고 실시간 개수를 덮어쓰므로 카운트 누수나 음수 현상이 발생할 수 없음. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **동기 `decreaseCommentCount()` 대비**: 동기 방식은 매 댓글 삭제 시 DB Row Lock을 잡지만, 스케줄러 방식은 삭제 시 Lock을 전혀 잡지 않음. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (최대 5분의 시차 발생 & 주기적 DB I/O 부하)**: 댓글 작성 직후 5분 동안은 화면 상의 댓글 수 수치가 실시간 반영되지 않고, 배치 실행 시 Full Scan 부하가 생김. -- **서비스 레벨 극복 방안 (로컬 State 반영)**: 댓글 작성/삭제 직후 프론트엔드 로컬 State에서 수치를 임시로 +1 / -1 가산하여 렌더링함으로써 유저가 시차를 느끼지 않도록 보완. -- **아키텍처 레벨 극복 방안 (Dirty Set Redis 수집 & 핀포인트 집계)**: 전수 조사의 DB I/O 부하를 막기 위해 댓글 CUD 발생 시 `dirty_posts` Redis Set에 `postId`를 수집하고, 스케줄러는 해당 Set에 등록된 `postId`에 대해서만 핀포인트 Range 집계 쿼리를 내보내어 DB I/O를 99% 절감. - ---- - -## 2. 💥 [게시글] 인기 글 상세 조회 시 쓰기 락 병목 이슈의 2대 대안 - -### 2-1. 대체 대안 A: Redis HyperLogLog (`PFADD`) 기반 고성능 카운팅 & 중복 제거 - -#### ① 개념 (What) -Redis의 확률적 자료구조인 HyperLogLog(`PFADD`, `PFCOUNT`)를 활용하여, 단 12KB 메모리만으로 중복 조회를 인메모리 $O(1)$로 차단하고 조회수를 카운팅하는 기법입니다. - -#### ② 왜 사용하는지 (Why) -단순 카운터나 RDBMS에 중복 IP 테이블을 만들어 저장하면 메모리와 DB 용량이 폭증합니다. HyperLogLog는 100만 건의 중복 IP를 단 12KB 메모리로 추산하므로 공간 효율성이 최상입니다. - -#### ③ 어떨 때 사용하는지 (When) -대규모 트래픽 서비스의 게시글 조회수, 방문자 수(UV) 집계 및 중복 조회 방지에 사용합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```java -public void increaseViewCountWithHyperLogLog(Long postId, String clientIp) { - String redisKey = "post:views:" + postId; - // HyperLogLog에 IP 추가 (새로운 IP면 1 반환, 중복이면 0 반환) - Long added = redisTemplate.opsForHyperLogLog().add(redisKey, clientIp); - - if (added != null && added == 1L) { - redisTemplate.opsForValue().increment("post:view_count:" + postId); - } -} -``` - -#### ⑤ 장점 (Pros) -- **DB Row Lock 100% 제거**: DB에 쓰기 쿼리가 전혀 들어가지 않으므로 동접자가 몰려도 락 병목이 터지지 않음. -- **극도의 메모리 절약**: 100만 명의 IP를 수집해도 무조건 단 12KB 메모리만 사용함. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **Redis Set 자료구조 대비**: Redis Set은 100만 개 IP 저장 시 수십 MB의 메모리가 들지만, HyperLogLog는 12KB로 고정됨. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (0.81%의 확률적 표준오차)**: 확률적 계산 알고리즘 특성상 약 0.81% 미만의 오차가 발생할 수 있음. -- **서비스 레벨 극복 방안**: 커뮤니티 조회 수치는 0.81% 오차(1,000회 기준 약 8회 차이)가 서비스 이용이나 금융 정산 영역이 아니므로 서비스 요구사항을 완전 만족함을 도메인 레벨 수용. -- **아키텍처 레벨 극복 방안 (Redis RDB/AOF & Scheduled Write-Back)**: Redis 메모리 휘발에 대비하여 10분 단위 스케줄러가 Redis 수치를 DB `post.view_count` 컬럼으로 Write-Back 집계 갱신하여 영구 보존. - ---- - -### 2-2. 대체 대안 B: Client-Side Cookie 쿨타임 제한 (24시간 중복 방지) - -#### ① 개념 (What) -유저 브라우저 쿠키(`viewed_posts=1,4,12`)에 읽은 글 ID를 기록하고, 쿠키가 유효한 24시간 동안은 프론트엔드에서 백엔드로 조회수 증가 요청을 아예 보내지 않도록 차단하는 기법입니다. - -#### ② 왜 사용하는지 (Why) -서버 백엔드로 인커밍(Incoming)되는 HTTP 요청 수 자체를 줄여 네트워크 및 서버 CPU 자원을 절약하기 위함입니다. - -#### ③ 어떨 때 사용하는지 (When) -Redis 같은 인메모리 인프라 구축 비용 없이 단순한 웹 서비스에서 조회수 어뷰징을 방지할 때 사용합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```typescript -// Next.js 프론트엔드 컴포넌트 -useEffect(() => { - const viewedPosts = getCookie('viewed_posts') || ''; - if (!viewedPosts.includes(`[${postId}]`)) { - api.post(`/api/posts/${publicId}/views`); - setCookie('viewed_posts', `${viewedPosts}[${postId}]`, { maxAge: 86400 }); - } -}, [postId]); -``` - -#### ⑤ 장점 (Pros) -- **서버 요청 수 급감**: 동일 유저의 재방문 요청이 백엔드까지 도착하지 않으므로 트래픽이 획기적으로 줄어듦. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **서버 IP 기반 차단 대비**: 서버 IP 차단은 서버 메모리를 소비하지만, 쿠키 방식은 클라이언트 브라우저 자원을 활용함. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (쿠키 삭제 및 시크릿 창 어뷰징에 취약)**: 유저가 브라우저 쿠키를 삭제하거나 시크릿 모드로 접속하면 중복 카운트가 올라감. -- **서비스 레벨 극복 방안**: 어뷰징 유저가 쿠키를 삭제하더라도 개별 유저의 자발적 행위이므로 시스템 전체 셧다운을 일으키지 않는 수준에서 허용. -- **아키텍처 레벨 극복 방안 (하이브리드 IP Redis 쿨타임)**: 백엔드에서 1차로 Client Cookie를 대조하고 2차로 IP 기반 Redis 10분 쿨타임 키(`view:cooldown:{ip}:{postId}`)를 이중 검증하여 쿠키 삭제 어뷰징을 99% 무력화하는 하이브리드 검증 구축. - ---- - -## 3. 💥 [추천 비동기] `@Async` 비동기 카운터 유실 이슈의 2대 대안 - -### 3-1. 대체 대안 A: Transactional Outbox Pattern (트랜잭셔널 아웃박스 패턴) - -#### ① 개념 (What) -이벤트를 인메모리 스프링 이벤트로 던지지 않고, 메인 비즈니스 로직과 동일한 DB 트랜잭션 안에서 `outbox` 테이블에 이벤트 메시지를 함께 `INSERT`한 뒤, 별도의 메시지 릴레이(Debezium CDC 또는 Polling Publisher)가 읽어서 처리하는 Enterprise 분산 트랜잭션 패턴입니다. - -#### ② 왜 사용하는지 (Why) -JVM 인메모리 비동기 이벤트는 서버가 갑자기 꺼지면 메모리에 있던 이벤트가 100% 유실됩니다. DB 테이블에 이벤트 발행 내역을 함께 기록하여 **최소 1회 전달(At-Least-Once Delivery)**을 물리적으로 보장하기 위함입니다. - -#### ③ 어떨 때 사용하는지 (When) -결제, 결제 후 포인트 적립, 이벤트 카운팅 등 유실되면 안 되는 핵심 비동기 이벤트 처리에 사용합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```java -@Transactional -public void reactToPost(String publicId, ReactionType type, CustomUserDetails userDetails) { - // 1. 투표 내역 저장 - reactionRepository.save(reaction); - - // 2. 동일 트랜잭션 내에서 outbox 테이블에 이벤트 메시지 저장 (원자성 보장) - outboxRepository.save(new OutboxEvent( - "POST_REACTION", - postId.toString(), - objectMapper.writeValueAsString(new PostReactionEvent(postId, type)) - )); -} -``` - -#### ⑤ 장점 (Pros) -- **이벤트 유실 0%**: 메인 데이터 저장과 이벤트 작성이 동일 DB 트랜잭션으로 묶여 원자성(Atomic)이 보장됨. -- **서버 장애 복구**: 서버가 다운된 후 재시작되어도 `outbox` 테이블에 남아있는 미처리 이벤트를 읽어서 복구 처리. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **스프링 `@Async` 기본 이벤트 대비**: 기본 `@Async`는 메모리 유실 위험이 크지만, Outbox Pattern은 DB 내구성을 이용해 유실을 물리 차단함. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (Outbox 테이블 비대화 및 추가 DB Write I/O)**: 모든 이벤트가 DB에 기록되므로 I/O 부담이 늘어나고 테이블 용량이 커짐. -- **서비스 레벨 극복 방안**: 비동기 처리가 지연되더라도 유저 화면에는 Optimistic UI로 완료 상태를 즉시 표시. -- **아키텍처 레벨 극복 방안 (Outbox Purge Scheduler)**: `outbox` 테이블에 모든 비동기 이벤트가 누적되어 DB 용량이 폭증하고 I/O가 느려지는 현상을 막기 위해, `status = 'PROCESSED'`이면서 생성된 지 1시간이 지난 Outbox 행을 1,000개 단위로 DELETE하는 `OutboxPurgeScheduler` 배치를 구축하여 테이블 사이즈를 작게 유지. - ---- - -## 3-2. 대체 대안 B: 새벽 정정 스케줄러 배치 (Scheduled Reckoning Batch) - -#### ① 개념 (What) -이벤트 유실 가능성을 인정하되, 매일 새벽 트래픽이 적은 시각에 `post_reaction` 테이블의 실제 투표 건수를 `COUNT(*)`로 집계하여 `post.like_count` 수치와 대조 후 다를 경우 일괄 수정하는 정정 배치 기법입니다. - -#### ② 왜 사용하는지 (Why) -복잡한 메시지 큐나 Outbox 패턴 구축 비용 없이, 100% 데이터 정합성을 가장 단순한 코드로 보장하기 위함입니다. - -#### ③ 어떨 때 사용하는지 (When) -실시간 카운트 반영의 밀리초 오차가 서비스 이용에 치명적이지 않은 커뮤니티 서비스에 적합합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```java -@Scheduled(cron = "0 0 4 * * *") // 매일 새벽 4시 -@Transactional -public void reconcileReactionCounts() { - List posts = postRepository.findAll(); - for (Post post : posts) { - long actualLikes = reactionRepository.countByPostIdAndType(post.getId(), ReactionType.LIKE); - if (post.getLikeCount() != actualLikes) { - post.setLikeCount(actualLikes); // 데이터 정정 - } - } -} -``` - -#### ⑤ 장점 (Pros) -- **구현 단순성**: 추가 인프라 구축 없이 가장 직관적이고 안정적으로 데이터 일관성을 맞출 수 있음. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **Outbox Pattern 대비**: Outbox Pattern은 복잡한 릴레이 스레드가 필요하지만, 정정 배치는 단순 SQL 집계로 완료됨. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (새벽 시간대 DB Read I/O 부하)**: 전수 조사를 돌리면 DB CPU 사용량이 상승함. -- **서비스 레벨 극복 방안**: 새벽 4시는 유저 접속량이 가장 적은 시간대이므로 정정 작업으로 인한 성능 영향을 사용자에게서 격리. -- **아키텍처 레벨 극복 방안 (어제 변경된 게시글 Index Scan 핀포인트)**: 새벽 4시 배치 시 전체 `post` 테이블 Full Scan으로 인한 DB CPU 상승을 막기 위해 `WHERE updated_at >= NOW() - INTERVAL 1 DAY` 조건절을 추가하여 전날 변경이 일어난 게시글만 Index Scan으로 핀포인트 정정. - ---- - -## 4. 💥 [대댓글] 3차 이상 무한 깊이 대댓글 이슈의 2대 대안 - -### 4-1. 대체 대안 A: Flat List + `@Mention` (유튜브 / 인스타그램 1차 평탄화 모델) - -#### ① 개념 (What) -대댓글의 계층형 들여쓰기 자체를 없애고 모든 답글을 원댓글 하위의 평탄한(Flat) 1차 리스트로만 렌더링하며, 누구에게 작성한 답글인지 `@작성자닉네임` 태그로 표시하는 UI/UX 아키텍처입니다. - -#### ② 왜 사용하는지 (Why) -모바일 화면 폭(360px~430px)은 들여쓰기를 3단계만 해도 본문 영역이 좁아져 읽기 불가능해집니다. 이를 근본적으로 해결하기 위함입니다. - -#### ③ 어떨 때 사용하는지 (When) -유튜브, 인스타그램, 페이스북 등 모바일 웹/앱 트래픽 비중이 80% 이상인 현대 웹 서비스에 사용합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```json -// JSON 반환 구조 -{ - "commentId": 12, - "content": "@댓글보더 저도 그렇게 생각합니다!", - "targetMemberNickname": "댓글보더", - "parentId": 1, // 최상위 원댓글 ID만 유지 - "depth": 1 -} -``` - -#### ⑤ 장점 (Pros) -- **UI 레이아웃 파괴 근본 차단**: 들여쓰기 너비가 0으로 고정되므로 아무리 답글이 많이 달려도 모바일 화면 레이아웃이 절대 깨지지 않음. -- **데이터 구조 단순화**: N차 복잡한 트리를 조립할 필요 없이 1차 리스트만 반환하므로 백엔드 연산이 가벼워짐. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **N차 계층형 트리 대비**: N차 계층형 트리는 복잡한 Recursion 조립과 UI 들여쓰기가 필요하지만, Flat List는 $O(N)$ 단일 루프 반환 가능. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (답글의 구체적 부모 맥락 추적 불분명)**: 누구의 대댓글에 대한 대댓글인지 1:1 스레드 흐름 파악이 계층형보다 다소 모호함. -- **서비스 레벨 극복 방안 (Tooltip 미니 모달 뷰어)**: 1:1 대댓글 스레드 맥락 추적이 모호해지는 단점을 해결하기 위해, `@작성자닉네임` 태그 클릭 시 해당 원본 댓글의 팝업 미니 모달(Tooltip Modal)이 뜨도록 프론트엔드 뷰 연동. -- **아키텍처 레벨 극복 방안**: `targetCommentId` 외래키를 DTO에 포함하여 단 1회 인메모리 Map 조회로 원본 댓글 본문을 즉시 팝업으로 렌더링. - ---- - -### 4-2. 대체 대안 B: CSS Max-Indent Clamp (프론트엔드 들여쓰기 한계선 고정) - -#### ① 개념 (What) -백엔드는 데이터베이스 상에서 N차 대댓글을 허용하되, 프론트엔드 CSS 렌더링 시 `margin-left` 들여쓰기의 최대 한계선을 `clamp` 또는 `min()` 함수로 고정하는 기법입니다. - -#### ② 왜 사용하는지 (Why) -백엔드 도메인 로직 수정 없이 프론트엔드 스타일시트 적용만으로 레이아웃 이탈을 막기 위함입니다. - -#### ③ 어떨 때 사용하는지 (When) -기존 백엔드 API 스펙을 건드리지 않고 빠르게 UI 깨짐을 임시 방어할 때 사용합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```tsx -// Tailwind / Inline Style 적용 -
- {comment.content} -
-``` - -#### ⑤ 장점 (Pros) -- **백엔드 수정 0건**: 백엔드 코드를 단 한 줄도 수정하지 않고 프론트엔드 뷰만으로 1초 만에 방어할 수 있음. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **백엔드 Depth Validation 대비**: 백엔드 검증은 400 에러를 반환하지만, CSS Clamp는 에러 없이 렌더링 위치만 고정함. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (3차 이상 대댓글 간 시각적 구분 모호)**: 3차 대댓글과 4차, 5차 대댓글의 들여쓰기 위치가 동일해져 계층 구분이 안 됨. -- **서비스 레벨 극복 방안 (답글 뱃지 렌더링)**: 3차 이상 대댓글의 들여쓰기 위치가 같아져 시각적 계층이 모호해지는 점을 보완하기 위해, `depth > 2`인 경우 댓글 상단에 `↳ [3차 답글]` 태그 뱃지(Badge)를 추가 렌더링. -- **아키텍처 레벨 극복 방안**: 백엔드 2단계 깊이 제한Validation(`parent.getParent() != null`)을 병행 적용하여 3차 이상 생성 자체를 예외 차단. - ---- - -## 5. 💥 [보안] 익명 비밀번호 URL 쿼리 스트링 노출 이슈의 2대 대안 - -### 5-1. 대체 대안 A: HTTP Custom Header (`X-Anonymous-Password`) 전달 - -#### ① 개념 (What) -비회원 비밀번호를 URL 쿼리 스트링이 아닌, HTTP 요청 헤더(`X-Anonymous-Password: 1234`)에 포함하여 전달하는 보안 패턴입니다. - -#### ② 왜 사용하는지 (Why) -웹 서버(Nginx, Apache)는 표준 보안 설정상 Request Body와 Custom Header 내용을 Access Log에 기록하지 않고 요청 라인(URL)만 기록하므로, 로그 유출을 물리 차단할 수 있습니다. - -#### ③ 어떨 때 사용하는지 (When) -RESTful API 관례상 `DELETE` 메서드에 Request Body를 실어 보내기 부담스러울 때 사용합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```java -@DeleteMapping("/api/posts/{publicId}") -public ResponseEntity deletePost( - @PathVariable String publicId, - @RequestHeader(value = "X-Anonymous-Password", required = false) String anonymousPassword) { - postService.deletePost(publicId, anonymousPassword); - return ResponseEntity.noContent().build(); -} -``` - -#### ⑤ 장점 (Pros) -- **웹 서버 로그 유출 100% 차단**: Nginx, ALB, CDN 액세스 로그에 비밀번호 평문 기록이 남지 않음. -- **HTTP 스펙 준수**: `DELETE` 메서드 본문(Body)을 비워두어 일부 엄격한 HTTP 클라이언트 라이브러리와의 호환성 유지. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **URL Query Parameter 대비**: URL Parameter는 웹 서버 로그에 평문 기록되지만, Custom Header는 기록되지 않아 보안상 우월함. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (CORS Preflight Flight 요청 발생)**: 표준 헤더가 아닌 커스텀 헤더(`X-`)를 사용하므로 브라우저가 `OPTIONS` 사전 요청(Preflight)을 보냄. -- **서비스 레벨 극복 방안**: 첫 요청 시 수 밀리초의 Preflight 지연이 발생하지만 지연 시간이 매우 짧으므로 삭제 성공 경험 우선 제공. -- **아키텍처 레벨 극복 방안 (Preflight Caching)**: 브라우저의 CORS Preflight(`OPTIONS`) 요청으로 인한 2배 HTTP 트래픽 발생을 극복하기 위해, Spring Security CORS Configuration에서 `allowedHeaders("X-Anonymous-Password")` 등록 및 `maxAge(3600)`을 지정하여 브라우저가 Preflight 결과를 1시간 동안 메모리에 캐싱하도록 설정. - ---- - -### 5-2. 대체 대안 B: HMAC-SHA256 기반 무상태 일회용 삭제 토큰 (Stateless Delete Token) - -#### ① 개념 (What) -비회원이 글 작성 시 백엔드가 비밀번호를 저장하지 않고, `HMAC-SHA256(publicId + secretKey + password)`로 암호화 서명된 일회용 삭제 토큰(Token)을 발급하여 유저에게 반환하는 무상태 보안 인증 패턴입니다. - -#### ② 왜 사용하는지 (Why) -비밀번호 원본 및 BCrypt 해시조차 DB에 저장하지 않아, DB가 뚫려도 비회원 비밀번호가 유출될 위험이 0%입니다. - -#### ③ 어떨 때 사용하는지 (When) -익명 게시판 보안 수준을 극상으로 끌어올리고 무상태(Stateless) 검증을 꾀할 때 사용합니다. - -#### ④ 어떻게 사용하는지 (How - 구현 코드) -```java -// 작성 시 토큰 발급 -public String generateDeleteToken(String publicId, String rawPassword) { - return HmacUtils.hmacSha256Hex(SECRET_KEY, publicId + ":" + rawPassword); -} - -// 삭제 시 토큰 대조 검증 -public void validateDeleteToken(String publicId, String rawPassword, String clientToken) { - String expectedToken = generateDeleteToken(publicId, rawPassword); - if (!expectedToken.equals(clientToken)) { - throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); - } -} -``` - -#### ⑤ 장점 (Pros) -- **DB 보안 극상**: DB에 비밀번호 컬럼 자체가 존재하지 않으므로 데이터베이스 유출 사고 시에도 안전함. - -#### ⑥ 다른 기술과의 비교 (Alternatives) -- **BCrypt DB 저장 대비**: BCrypt 저장은 DB 용량을 차지하고 딕셔너리 공격 대상이 될 수 있으나, HMAC 토큰은 DB 저장이 필요 없는 무상태 검증임. - -#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프 (서버 Secret Key 유출 시 서명 위조 위험)**: 애플리케이션의 `SECRET_KEY`가 유출되면 토큰 위조가 가능해짐. -- **서비스 레벨 극복 방안**: 토큰 생성 알고리즘이 노출되지 않도록 에러 메시지 캡슐화. -- **아키텍처 레벨 극복 방안 (AWS Secrets Manager & Key Rotation)**: 서버 `SECRET_KEY` 유출 시 토큰 서명 위조 위험을 물리적으로 극복하기 위해, `SECRET_KEY`를 코드나 설정 파일에 하드코딩하지 않고 `AWS Secrets Manager`에 저장하며 30일마다 자동으로 서명 키를 로테이션(Rotation)하고 구버전 키는 7일간 Grace Period를 두어 안전 검증. - ---- - -# 📌 PART 3. 작업 완료 및 파일 위치 안내 - -* **생성된 마스터 스터디 가이드 경로**: - - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint02\studySprint02BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studySprint02BoardIssuesAndSolutions260821.md) - - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studySprint02BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySprint02BoardIssuesAndSolutions260821.md) -* **`AGENTS.md` 작업 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일에 수록 완료. diff --git a/docs/study/studySprint02PostDomainIssues260821.md b/docs/study/studySprint02PostDomainIssues260821.md deleted file mode 100644 index dba738c..0000000 --- a/docs/study/studySprint02PostDomainIssues260821.md +++ /dev/null @@ -1,191 +0,0 @@ -# 📚 [Master Study Guide] Sprint 02 게시글(Post) 도메인 5대 아키텍처 결함 & 물리적 극복 방안 가이드 (2026-08-21) - -> **노션(Notion) 복사용 및 백엔드 게시글 도메인 심화 학습용 마스터 가이드** -> 본 문서는 Snowthing 게시글(Post) 도메인(댓글 제외) 구축 시 발생할 수 있는 **5대 핵심 아키텍처 문제점과 결함**에 대해, **7대 필수 서술 요소 체계(개념, Why, When, How 코드/SQL, Pros & Cons, 기존 한계점, 서비스/아키텍처 레벨 극복 방안)**를 물리적 메커니즘 수준으로 전수 파헤쳐 정리한 마스터 학습 문서입니다. - ---- - -# 📑 PART 1. 게시글(Post) 도메인 5대 결함 & 7대 필수 요소 심층 분석 - ---- - -## 1. 💥 `POST` 페이징 목록 조회 시 본문 제외 미적용으로 인한 네트워크 트래픽 폭증 및 DB I/O 병목 - -### ① 개념 (What - 문제의 명확한 정의) -게시글 목록 API(`GET /api/posts?page=0&size=10`)를 부를 때, 목록 화면에는 제목, 작성자, 카테고리, 추천 수만 필요한데 **게시글 본문(`content` VARCHAR 5000/TEXT) 필드까지 전부 DB에서 SELECT하여 DTO로 내보내는 문제**입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- JPA Repository에서 목록 조회 시 `Post` 엔티티 전체를 SELECT 하거나 `PostListResponse` DTO 생성 시 본문(`content`)을 포함하는 DTO projections 미분리로 인해 발생합니다. -- 본문 내에 수천 자의 장문이 포함되어 있으면 목록 쿼리 1번당 전송 데이터 크기(Payload Size)가 **수십 KB ➔ 수 MB로 폭증**합니다. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 사용자가 모바일 또는 3G/4G 환경에서 게시글 목록을 스크롤(무한 스크롤 / 페이징)할 때 로딩 지연 및 모바일 데이터 소모 폭증. - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -```sql --- 목록 10개 조회 쿼리 실행 시 (불필요한 content 컬럼 포함!) -SELECT post_id, public_id, title, content, view_count, like_count, created_at FROM post WHERE category_id = 1; --- 10개 글 본문(content) 합계 500KB 데이터가 매 페이징마다 DB -> API 서버 -> 클라이언트로 낭비 전송됨 -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **DB 메모리/네트워크 낭비**: DB Buffer Pool 메모리 낭비 및 네트워크 대역폭(Bandwidth) 고갈. -- **클라이언트 로딩 지연**: 목록 화면을 열 뿐인데 유저 휴대폰 메모리와 데이터 소모가 급증함. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: 엔티티 전체 조회 `SELECT p FROM Post p` -- **한계점**: LOB/TEXT 컬럼의 지연 로딩이 기본 적용되지 않아 불필요한 IO가 매번 발생함. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: 목록 전용 DTO (`PostListResponse`)를 별도로 정의해야 하는 DTO 파편화 오버헤드. -- **서비스 레벨 극복 방안 (목록 DTO 경량화)**: 목록 DTO에 본문을 아예 제외(`content` 제거)하고 제목은 최대 40자 자름(Truncate) 처리하여 UI 렌더링 속도 최적화. -- **아키텍처 레벨 극복 방안 (JPQL/Querydsl DTO Projections)**: - - `SELECT new PostListResponse(p.publicId, p.title, p.likeCount...) FROM Post p` 방식을 적용하여 DB 레벨에서 `content` 컬럼 자체를 SELECT 하지 않도록 DB I/O를 원자적 차단. - ---- - -## 2. 💥 카테고리별 게시글 목록 페이징 조회의 Count Query N+1 및 Index Scan 타임아웃 - -### ① 개념 (What - 문제의 명확한 정의) -게시글 목록 페이징(`Page`) 조회 시, Spring Data JPA의 `Pageable`을 사용할 때 **전체 게시글 수(`COUNT(*)`)를 세는 카운트 쿼리가 매 페이징 요청마다 DB 테이블 전체를 스캔**하여 일어나는 성능 저하 현상입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- JPA `PageRequest` 사용 시 Hibernate는 데이터 10건 조회 쿼리 1번 + 전체 개수 계산 `SELECT COUNT(p) FROM Post p WHERE p.category = :category` 쿼리 1번을 내보냅니다. -- `post` 테이블에 `category_id + created_at` 복합 인덱스가 없으면, 카운트 쿼리가 **테이블 풀 스캔(Full Table Scan)**을 일으킵니다. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 게시글 데이터가 10만 건 이상 쌓인 상태에서 10페이지, 100페이지 등 높은 페이지 번호(Offset Paging)를 넘길 때. - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -```sql --- 1. 데이터 10건 조회 (Fast) -SELECT * FROM post WHERE category_code = 'FREE' ORDER BY created_at DESC LIMIT 10 OFFSET 1000; --- 2. 전체 Count 쿼리 (Slow - 10만 건 Full Scan!) -SELECT COUNT(*) FROM post WHERE category_code = 'FREE'; -- 2초 소요! -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **DB CPU 100% 점유**: 100명의 유저가 탭을 전환하면 `COUNT(*)` 쿼리 100개가 DB CPU를 100% 점유하여 전체 서비스 마비. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: `Page` 기본 페이징 반환. -- **한계점**: 무조건 `COUNT(*)`를 실행하므로 데이터가 쌓일수록 성능이 선형적으로 저하됨. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: 전체 페이지 번호(1, 2, 3... 10)를 보여주는 UI 대신 `더보기` 버튼(Slice 페이징)으로 전환해야 함. -- **서비스 레벨 극복 방안 (Slice 무한 스크롤 UI)**: 모바일/웹 목록 UI를 페이지 번호 방식에서 `Slice` 기반 [더보기 / 무한 스크롤] UI로 전환. -- **아키텍처 레벨 극복 방안 (Slice 페이징 & Covering Index)**: - - `Page` 대신 `Slice`를 사용하여 `COUNT(*)` 쿼리 자체를 100% 제거(`limit + 1` 조회 방식). - - DB에 `idx_category_created_at(category_id, created_at DESC)` 커버링 인덱스를 생성하여 Index Only Scan 유도. - ---- - -## 3. 💥 게시글 수정/삭제 시 작성자 검증 인가(Authorization) 누락 및 IDOR 취약점 - -### ① 개념 (What - 문제의 명확한 정의) -회원이 작성한 일반 게시글을 수정/삭제할 때, 로그인된 유저가 **해당 게시글의 실제 작성자 본인인지 또는 관리자(`ROLE_ADMIN`)인지 검증하지 않고** `publicId`만 알면 타인의 글을 임의로 수정/삭제할 수 있는 보안 취약점입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- [`PostService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java) `updatePost()` / `deletePost()`에서 `post.getMember().getPublicId().equals(userDetails.getPublicId())` 대조 로직이 누락되거나 null 검증 조건이 뚫릴 때 발생합니다. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 인증된 유저 A가 Postman이나 브라우저 개발자 도구(F12)에서 유저 B가 쓴 게시글의 `publicId`를 파라미터로 넣어 `PUT /api/posts/{publicId}`를 호출할 때. - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -``` -[User A (Hacker)] PUT /api/posts/p9999 (User B's Post) - └── PostService.updatePost() 진입 - └── 작성자 대조 검증 없이 post.updateTitleAndContent() 실행! - └── User B의 글이 User A에 의해 강제 변조됨! (IDOR 보안 참사) -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **데이터 변조 & 악성 스팸**: 타인의 글을 삭제하거나 비하/광고성 내용으로 강제 변경하는 심각한 보안 사고 발생. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: 어노테이션 `@PreAuthorize("isAuthenticated()")` 만 사용. -- **한계점**: "로그인 여부"만 검증할 뿐 "글 작성자 본인 여부"를 검증하지 못함. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: 매 수정/삭제 시마다 DB에서 작성자 ID를 대조해야 하는 인가 연산 오버헤드. -- **서비스 레벨 극복 방안 (버튼 숨김 렌더링)**: 프론트엔드 상세 페이지에서 작성자 본인 및 관리자가 아닌 경우 [수정], [삭제] 버튼 자체를 렌더링하지 않음. -- **아키텍처 레벨 극복 방안 (백엔드 도메인 인가 검증)**: - - `PostService` 내에 `validatePostOwnerOrAdmin(post, userDetails)` 도메인 검증 메서드를 공통화하고, 불일치 시 `403 Forbidden (ErrorCode.ACCESS_DENIED)` 예외를 즉시 던져 백엔드 단에서 물리 차단. - ---- - -## 4. 💥 회원글 ➔ 익명글 (또는 그 반대) 카테고리 변경 시 작성자 정보 정합성 오염 및 비밀번호 유실 문제 - -### ① 개념 (What - 문제의 명확한 정의) -게시글 수정(`PUT /api/posts/{publicId}`) 시 유저가 카테고리를 일반 카테고리(`FREE`)에서 익명 카테고리(`ANONYMOUS`)로 변경하거나 그 반대로 변경할 때, **`is_anonymous` 플래그와 `member_id`, `anonymous_password` 데이터 간의 상태 꼬임(State Corruption) 현상**입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- 게시글 작성 시에는 `isAnonymous`에 따라 `member`가 저장되거나 `anonymousPassword`가 저장됩니다. -- 그러나 게시글 수정 시 카테고리 코드(`categoryCode`)를 바꾸면서 `isAnonymous` 상태 변경에 따른 기존 `member` 매핑 해제 처리나 `anonymousPassword` BCrypt 재암호화 처리가 캡슐화되어 있지 않으면 상태가 파괴됩니다. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 유저가 자유게시판(`FREE`)에 쓴 글을 나중에 익명게시판(`ANONYMOUS`)으로 수정 이동하거나, 익명글을 회원글로 수정 이동할 때. - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -``` -[회원글 -> 익명글 수정 시] -- is_anonymous = true 로 변경되었으나, member_id (FK) 가 여전히 연관되어 있어 DB 상에서 작성자 유저 정보가 그대로 노출됨! -[익명글 -> 회원글 수정 시] -- is_anonymous = false 로 변경되었으나, member_id 가 null 로 남아 작성자 없는 유령 글 발생! -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **익명성 파괴 보안 사고**: 익명글로 바꿨는데 DB에 작성자 회원의 `member_id`가 남아 익명성이 파괴되거나, 유령 글이 되어 삭제 불가능 상태 발생. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: DTO 필드를 엔티티에 덮어쓰는 `post.setTitle(...)`, `post.setCategory(...)` -- **한계점**: 엔티티 불변식(Invariant)을 지키지 못함. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: 작성 후 카테고리 변경 시 익명/일반 간의 전환 제약이 필요함. -- **서비스 레벨 극복 방안 (카테고리 이동 정책 제한)**: 익명게시판(`ANONYMOUS`)과 일반게시판(`FREE`, `QNA`) 간의 카테고리 변경 작성을 서비스 정책상 금지하고 안내 문구 노출. -- **아키텍처 레벨 극복 방안 (도메인 카테고리 변경 검증)**: - - `Post.java` 도메인 엔티티 내에 `changeCategory(PostCategory newCategory)` 메서드를 만들고, 익명 ↔ 일반 카테고리 간의 전환 시도가 들어오면 `400 Bad Request (ErrorCode.INVALID_INPUT, "익명게시판과 일반게시판 간 카테고리 변경은 불가능합니다.")` 예외를 던져 백엔드 차원에서 원자적 차단. - ---- - -## 5. 💥 `PostReaction` 추천/비추천 투표 시 계정당 1회 독자 투표 업데이트의 Race Condition 및 DB Deadlock - -### ① 개념 (What - 문제의 명확한 정의) -유저가 추천(LIKE)과 비추천(DISLIKE)을 빠르게 번갈아 누르거나 동시 클릭할 때, 복합 유니크 인덱스(`uk_post_member_type`)에도 불구하고 **DB 트랜잭션 교착 상태(Deadlock) 및 데이터 충돌**이 발생하는 동시성 문제입니다. - -### ② 발생 원인 (Why - 물리적 & DB 메커니즘) -- 오늘 `PostReaction` DB 제약 조건을 `UNIQUE (post_id, member_id, type)`로 변경하여 유저 1명이 추천 1건 + 비추천 1건을 각각 가질 수 있게 만들었습니다. -- 유저가 추천 클릭과 비추천 클릭을 동시에 내보내면, MySQL InnoDB는 두 트랜잭션에서 `post_reaction` 유니크 인덱스 페이지 락(Index Page Lock)을 획득하는 과정에서 **Circular Dependency (순환 대기) Deadlock**을 유발할 수 있습니다. - -### ③ 언제 발생하는지 (When - 적합한 발생 상황) -- 클라이언트 단에서 추천 버튼과 비추천 버튼을 동시에 클릭하거나, 2개의 브라우저 탭에서 동일 계정으로 추천/비추천을 연타할 때. - -### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) -``` -[Tx 1 (추천)] INSERT INTO post_reaction (post_id=1, member_id=5, type='LIKE') -> Index Lock 획득 대기 -[Tx 2 (비추천)] INSERT INTO post_reaction (post_id=1, member_id=5, type='DISLIKE') -> Index Lock 획득 대기 - -> MySQL InnoDB Deadlock Detector 발동 -> Deadlock found when trying to get lock; try restarting transaction (500 Server Error!) -``` - -### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) -- **500 Internal Server Error 발생**: DB 데드락 발생 시 사용자 화면에 500 에러 페이지가 뜸. - -### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) -- **기존 방식**: `@UniqueConstraint` 선언만 적용. -- **한계점**: 동시 INSERT 시 발생하는 DB 인덱스 데드락을 100% 방지할 수 없음. - -### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) -- **트레이드오프**: 버튼 연타 시 프론트엔드에서 클릭을 잠시 차단해야 함. -- **서비스 레벨 극복 방안 (Debounce / Throttle)**: 프론트엔드 버튼 클릭 시 300ms 디바운스(Debounce) 및 로딩 Spinner를 적용하여 동시 클릭을 시각적으로 100% 차단. -- **아키텍처 레벨 극복 방안 (CannotAcquireLockException Catch & Retry)**: - - 백엔드 `PostService.reactToPost()`에서 `CannotAcquireLockException` 또는 `DeadlockLoserDataAccessException` 예외를 Catch 하여 409 Conflict 또는 3회 자동 재시도(Spring Retry) 로직을 적용하여 500 에러 방지. - ---- - -# 📌 PART 2. 작업 완료 및 파일 위치 안내 - -* **생성된 마스터 스터디 가이드 경로**: - - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint02\studySprint02PostDomainIssues260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studySprint02PostDomainIssues260821.md) - - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studySprint02PostDomainIssues260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySprint02PostDomainIssues260821.md) -* **`AGENTS.md` 작업 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일에 수록 완료. diff --git a/docs/studyApiDesign260808.md b/docs/studyApiDesign260808.md deleted file mode 100644 index 8249c22..0000000 --- a/docs/studyApiDesign260808.md +++ /dev/null @@ -1,80 +0,0 @@ -# [Notion] REST API 설계 원칙 & 에러 응답 표준화 정리 (TIL) - -> 📌 **작성 일자**: 2026년 8월 8일 -> 🏷️ **문서 목적**: 노션(Notion)에 붙여넣어 RESTful API 디자인 철학, `/api` 접두사의 의미, 비동기 API 설계, 표준 에러 응답 규격을 공부하기 위한 TIL 정리 문서 - ---- - -## 1. 💡 REST API URL 설계 철학: 왜 `POST /api/join`이 아니라 `POST /api/members`인가? - -### 1.1. REST API의 기본 원칙: "URL은 명사, 행동은 HTTP 메서드" -* **HTTP 메서드 (`GET`, `POST`, `PUT`, `DELETE`)**: **행동(동사)**을 담당합니다. - * `GET`: 가져와라 (조회) - * `POST`: 새로 생성해라 (생성/등록) - * `PUT`: 수정해라 (수정) - * `DELETE`: 삭제해라 (삭제) -* **URL 주소 (`/api/members`)**: **대상(명사/자원)**을 담당합니다. - -### 1.2. 회원가입의 RESTful 해석 -* '회원가입'은 데이터베이스 관점에서 **"새로운 회원(`members`) 데이터 1명을 새로 생성(`POST`)하는 행위"**입니다. -* **`POST` (새로 생성해라!)** + **`/api/members` (회원들 집합에)** ➔ **회원가입!** - -| 행위 | ❌ 옛날 동사 중심 방식 | ⭕ 요즘 RESTful 방식 (표준) | -| :--- | :--- | :--- | -| **회원가입** | `POST /api/join` 또는 `/api/signup` | **`POST /api/members`** | -| **회원 목록 조회** | `GET /api/getMembers` | **`GET /api/members`** | -| **회원 정보 수정** | `POST /api/updateMember` | **`PUT /api/members/{publicId}`** | -| **회원 탈퇴** | `POST /api/deleteMember` | **`DELETE /api/members/{publicId}`** | - ---- - -## 2. 🌐 URL 주소 앞에 `/api` 접두사를 붙이는 3가지 실무적 이유 - -1. **"화면(HTML)" 요청과 "순수 데이터(JSON)" 요청의 명확한 구분** - * `/members` ➔ 웹 브라우저 화면(HTML) 요청. - * `/api/members` ➔ 백엔드 데이터(JSON) 요청임을 한눈에 파악 가능. -2. **프론트엔드(Next.js 3000포트)와 백엔드(Spring Boot 8080포트) Nginx 라우팅의 편의성** - * Nginx 설정에서 `"주소에 /api/ 가 들어간 요청만 백엔드 포트로 전달해라"` 라고 한 줄로 라우팅 규칙 지정 가능. -3. **세션 쿠키(`JSESSIONID`) 보안 범위의 제한** - * `Path=/api`로 지정하여 프론트엔드 화면 이동 시 쿠키 전송을 막고, 백엔드 API 요청 시에만 안전하게 쿠키를 전송하도록 범위 제한. - ---- - -## 3. ⚡ 게시글 추천/비추천 비동기(Async) 처리 기법 - -### 3.1. 프론트엔드: '낙관적 업데이트 (Optimistic UI Update)' -* 유저가 추천 클릭 시, 백엔드 응답을 기다리지 않고 **0.001초 만에 화면의 숫자와 하트 색깔을 먼저 변경**. -* 백엔드 API가 에러(409 등)를 반환하면 그때 원래 숫자로 원복(`-1`). - -### 3.2. 백엔드: 비동기 이벤트 처리 & Redis 버퍼링 -* 백엔드는 추천 클릭 시 DB를 직접 치지 않고, **`200 OK` 응답을 즉시 끊어준 뒤 백그라운드 쓰레드(`@Async`)로 DB 업데이트 실행**. -* 대규모 트래픽 시 Redis 메모리 카운터만 즉시 올려주고 10초 주기 비동기 배치(Batch)로 DB 반영. - ---- - -## 4. 🚨 글로벌 에러 응답 표준화 규격 (Global Error Response) - -### 4.1. 에러 응답 JSON 포맷 -```json -{ - "timestamp": "2026-08-08T10:20:00", - "status": 400, - "code": "INVALID_INPUT_VALUE", - "message": "입력값이 유효하지 않습니다.", - "errors": [ - { - "field": "email", - "value": "invalid-email-format", - "reason": "올바른 이메일 형식이 아닙니다." - } - ] -} -``` - -### 4.2. 주요 HTTP Status Code 정리 -* `400 Bad Request`: 유효성 검사 실패 (`INVALID_INPUT_VALUE`), 중복 가입 (`DUPLICATE_EMAIL`) -* `401 Unauthorized`: 비로그인 작성 시도 (`UNAUTHORIZED`), 비밀번호 불일치 (`INVALID_CREDENTIALS`) -* `403 Forbidden`: 타인의 글/댓글 수정·삭제 시도 (`ACCESS_DENIED`), 비회원 암호 오류 (`INVALID_ANON_PASSWORD`) -* `404 Not Found`: 존재하지 않는 게시글/댓글/회원 조회 (`RESOURCE_NOT_FOUND`) -* `409 Conflict`: 이미 추천/비추천 투표를 한 경우 (`ALREADY_REACTED`) -* `500 Internal Error`: 서버 내부 비즈니스 로직 예외 발생 (`INTERNAL_SERVER_ERROR`) diff --git a/docs/studyArchConcepts260806.md b/docs/studyArchConcepts260806.md deleted file mode 100644 index e5a3fbc..0000000 --- a/docs/studyArchConcepts260806.md +++ /dev/null @@ -1,313 +0,0 @@ -# [TIL] 스노보드 커뮤니티 개발을 위한 백엔드 & DB 핵심 개념 딥다이브 - -> 📌 **학습 날짜**: 2026년 8월 6일 -> 🏷️ **키워드**: `No Silver Bullet`, `FK 참조 무결성`, `N:M 중계 테이블`, `Bitmask`, `JSONB`, `Redis Set`, `역정규화`, `JWT 서명 원리`, `쿠키 보안` -> 💡 **학습 목표**: 각 아키텍처 패턴과 기술이 등장한 **근본적인 배경(Why)**과 **원리**, 그리고 잘못 사용했을 때 터지는 **사이드 이펙트(부작용)**를 깊이 있게 체득하고 올바른 기술적 선택을 내립니다. - ---- - -## 0. 대전제: "소프트웨어 공학에 은총알(Silver Bullet)은 없다" - -소프트웨어 공학의 거장 프레더릭 브룩스(Frederick P. Brooks)는 **"No Silver Bullet(은총알은 없다)"**이라는 유명한 말을 남겼습니다. -서양 전설에서 늑대인간을 한 방에 쓰러뜨리는 '은총알'처럼, **모든 문제를 한 번에 완벽하게 해결해 주는 만능 기술이나 설계 방식은 세상에 존재하지 않는다**는 뜻입니다. - -* ⚖️ **약약(Trade-off)의 법칙**: 하나의 기술을 선택해서 강력한 장점(예: 초고속 속도)을 얻는다면, **반드시 그 대가로 다른 불편함(예: 데이터 무결성 상실, 복잡도 증가)을 치러야 합니다.** -* 따라서 위대한 엔지니어는 단순히 "좋은 기술"을 찾는 사람이 아니라, **"우리 서비스의 상황에서 어떤 대가를 치르는 것이 가장 현맥한가?"**를 고민하고 선택하는 사람입니다. - ---- - -## 1. 데이터베이스의 기본과 FK(외래키) 참조 무결성 - -### 💡 용어 풀이 -> * **RDBMS (관계형 데이터베이스)**: 데이터를 행(Row)과 열(Column)로 이루어진 테이블(표) 형태로 저장하고, 테이블 간의 '관계'를 맺어 관리하는 데이터베이스 (예: MySQL, PostgreSQL, Oracle). -> * **PK (Primary Key / 기본키)**: 각 행(데이터)을 세상에서 유일하게 식별할 수 있는 주민등록번호 같은 고유 ID. -> * **FK (Foreign Key / 외래키)**: 다른 테이블의 PK를 참조하여 두 테이블을 연결하는 '연결 고리' 역할의 키. - ---- - -### 1.1. 왜 FK(외래키) 참조 무결성이 그렇게 중요할까? - -**참조 무결성(Referential Integrity)**이란, **"데이터 간의 관계가 끊어지거나 엉뚱한 유령 데이터를 가리키지 않도록 DB가 엄격하게 지켜주는 성질"**을 말합니다. - -#### 😱 FK 참조 무결성이 깨졌을 때 일어나는 잔혹사 스토리 -1. 유저 A가 작성한 게시글 100개가 있습니다. -2. 만약 DB에서 유저 A를 삭제했는데, 게시글 100개는 그대로 남아있다면? -3. 게시글의 `member_id`는 존재하지 않는 유저 ID를 가리키게 됩니다. 이것을 **'고아 데이터(Orphan Data)'** 또는 **'유령 데이터'**라고 부릅니다. -4. 나중에 유저가 이 게시글을 조회하려고 하면, 작성자 정보를 찾을 수 없어 **시스템 전체에 `NullPointerException` 에러가 터지고 웹사이트가 다운**됩니다. - -FK 제약조건을 걸어두면, DB가 알아서 **"이 회원에게 작성된 게시글이 남아있으니 함부로 회원을 삭제할 수 없다!"** 하고 에러를 튕겨서 데이터를 보호해 줍니다. - ---- - -### 1.2. Option A (우리가 처음 선택했던 RDBMS 정석 - 중계 테이블 방식) - -#### ❓ 왜 쉼표(,)로 저장하면 안 되고, 중계 테이블을 만들어야 할까? -만약 회원 테이블의 한 컬럼에 `riding_style = "카빙,트릭"` 처럼 쉼표로 여러 개를 저장했다고 해봅시다. (이를 DB 전문 용어로 **'1차 정규화 위반'**이라고 부릅니다.) - -#### 💥 쉼표 저장 시 터지는 대참사 -1. **검색 속도 지옥**: `"카빙을 타는 유저만 조회해 줘"` 라는 요청이 오면, DB는 전체 회원 데이터를 처음부터 끝까지 다 훑으면서 문자열 검색(`LIKE '%카빙%'`)을 해야 합니다. 회원 수가 10만 명이면 검색에 몇 초씩 걸립니다. -2. **데이터 오염**: 누군가 실수로 `"카빙 , 트릭 "` (띄어쓰기 오타)으로 저장하면 검색에서 쏙 빠져버립니다. - -#### 🛠️ 해결책: 중계 테이블(`member_riding_style`)을 두는 이유 -RDBMS는 테이블 간에 N:M(다대다) 관계를 직접 맺을 수 없습니다. 그래서 중간에 **중계 테이블(Junction Table)**을 만들어 `1:N`과 `N:1` 두 개의 안전한 외래키(FK) 관계로 풀어냅니다. - -``` -[member 테이블] 1 ◄--- (FK) --- N [member_riding_style 중계 테이블] N --- (FK) ---> 1 [riding_style 마스터 테이블] -``` - -* **장점 (얻는 것)**: - * **FK 참조 무결성 완벽 보장**: 존재하지 않는 스타일 ID나 유저 ID가 들어갈 수 없음. - * **초고속 인덱스 검색**: `WHERE style_id = 1` 로 인덱스를 타서 0.001초 만에 검색 완료. -* **단점 & 대가 (치르는 것)**: - * 유저 정보 하나 가져올 때 여러 테이블을 합성하는 **JOIN(조인) 쿼리**를 실행해야 하므로, DB 쿼리가 다소 복잡해집니다. - ---- - -## 2. 다중 선택 데이터를 저장하는 3가지 대안과 그 대가 (Option B, C, D) - ---- - -### 2.1. Option B: Bitmask (비트마스크 / 비트 연산) - -### 💡 용어 풀이 -> * **Bit (비트)**: 컴퓨터가 처리하는 가장 작은 단위. `0` 또는 `1` 두 가지 상태만 표현 가능. -> * **비트 연산자 (`&`, `|`)**: 숫자를 2진수 비트 단위로 직접 계산하는 극상의 고속 연산자. - -#### 📜 스토리 & 배경 -옛날 컴퓨터 메모리가 1KB, 1MB 단위로 매우 귀했던 시절, 엔티지어들은 "어떻게 하면 용량을 안 쓰고 여러 개 선택을 저장할까?"를 고민하다가 **2진수의 자릿수**를 활용하는 신기한 기법을 만들었습니다. - -#### ⚙️ 작동 원리 -각 선택지에 2의 거듭제곱 숫자를 부여합니다. -* 카빙 = $1$ ($2^0$, 2진수로 `0001`) -* 트릭 = $2$ ($2^1$, 2진수로 `0010`) -* 파크 = $4$ ($2^2$, 2진수로 `0100`) -* 입문 = $8$ ($2^3$, 2진수로 `1000`) - -만약 유저가 **'카빙(1)' + '파크(4)'** 2개를 선택했다면? -* $1 + 4 = 5$ (2진수로 `0101`)라는 **숫자 `5` 하나만 DB 컬럼(`riding_style_mask`)에 저장**합니다. - -조회할 때는 비트 연산(`AND`)을 씁니다: `WHERE (riding_style_mask & 1) > 0` ➔ "1번째 비트(카빙)가 켜져 있는 유저를 다 가져와!" - -#### ⚖️ 장단점 및 치명적 사이드 이펙트 -* **장점**: 중계 테이블이 아예 필요 없고, DB 저장 공간을 90% 이상 절약하며, 비트 연산이라 조회 속도가 빛의 속도입니다. -* 💥 **치명적 사이드 이펙트 (왜 함부로 쓰면 안 될까?)**: - 1. **FK 참조 무결성 완전히 상실**: 숫자 `5`가 들어있을 뿐, DB는 이 유저가 카빙을 타는지 파크를 타는지 외래키로 검증할 방법이 없습니다. - 2. **쿼리 가독성 붕괴**: 다른 개발자가 DB를 열어봤을 때 컬럼에 `5`, `13` 같은 숫자만 적혀 있어서 무슨 뜻인지 전혀 알아볼 수 없습니다. - 3. **확장성 한계**: 선택지가 64개를 넘어가면 64비트 정수 범위를 초과해서 코드가 터집니다. - ---- - -### 2.2. Option C: JSONB Document & Multi-Value Index - -### 💡 용어 풀이 -> * **JSON**: 데이터를 `{ "key": "value" }` 형태의 텍스트로 표현하는 표준 데이터 양식. -> * **Multi-Value Index**: JSON 배열 안의 원소 하나하나에 인덱스를 걸어 빠르게 찾아주는 최신 DB 기술. - -#### 📜 스토리 & 배경 -"NoSQL(몽고DB 같은 데이터베이스)은 중계 테이블 없이 JSON 배열로 데이터를 쓱 넣으면 되는데, 왜 RDBMS는 이렇게 테이블을 쪼개고 조인해야 해서 답답하지?" 라는 개발자들의 불만이 커지자, 최신 MySQL(8.0+)과 PostgreSQL이 **JSON을 통째로 컬럼에 집어넣는 기능**을 도입했습니다. - -#### ⚙️ 작동 원리 -`member` 테이블에 `riding_styles`라는 컬럼을 만들고, 그냥 JSON 텍스트 `["CARVING", "TRICK"]`을 통째로 저장합니다. - -#### ⚖️ 장단점 및 치명적 사이드 이펙트 -* **장점**: 중계 테이블 생성이 필요 없고, 프론트엔드에서 보내준 JSON 배열 형태 그대로 DB에 넣고 뺄 수 있어 개발 속도가 엄청나게 빠릅니다. -* 💥 **치명적 사이드 이펙트 (왜 함부로 쓰면 안 될까?)**: - 1. **FK 참조 무결성 상실**: DB는 JSON 텍스트 내부를 외래키로 검증하지 않습니다. 오타로 `["CABING"]` (카빙 오타)이 들어가도 DB가 막아주지 못해 **데이터 오염**이 일어납니다. - 2. **수정 작업의 오버헤드**: 카빙이라는 명칭을 "그라운드 트릭"으로 바꾸고 싶을 때, 모든 회원의 JSON 텍스트를 하나하나 꺼내서 문자열을 수정해야 하는 대공사가 벌어집니다. - ---- - -### 2.3. Option D: Redis Set (SINTER 교집합 연산) - -### 💡 용어 풀이 -> * **Redis (레디스)**: 하드디스크가 아닌 컴퓨터 메모리(RAM) 위에서 동작하는 초고속 데이터 저장소. -> * **Set (집합)**: 중복을 허용하지 않는 데이터들의 모임. -> * **SINTER**: 여러 집합 간의 '교집합(공통 원소)'을 한 번에 구하는 Redis 전용 명령어. - -#### 📜 스토리 & 배경 -회원 수가 100만 명을 넘어가자, "휘닉스파크를 가면서 + 카빙을 타는 유저"를 RDBMS에서 JOIN으로 찾으려니 DB CPU 점유율이 100%로 치솟으며 서버가 다운되었습니다. 엔지니어들은 **"검색/매칭 기능만 따로 떼어내어 RAM 위에서 벤다이어그램 교집합 연산을 하자!"** 하고 Redis를 도입했습니다. - -#### ⚙️ 작동 원리 -Redis에 집합 스티커를 만듭니다. -* `resort:휘닉스파크` Set ➔ { 유저1, 유저2, 유저5 } -* `style:카빙` Set ➔ { 유저1, 유저3, 유저5 } -* `SINTER resort:휘닉스파크 style:카빙` ➔ **0.001초 만에 교집합인 { 유저1, 유저5 }가 즉시 나옴!** - -#### ⚖️ 장단점 및 치명적 사이드 이펙트 -* **장점**: RDBMS에 전혀 부하를 주지 않고, 100만 명 데이터도 0.001초 만에 매칭해 냅니다. -* 💥 **치명적 사이드 이펙트 (왜 함부로 쓰면 안 될까?)**: - 1. **데이터 동기화 파이프라인의 복잡성**: 유저가 프로필을 수정할 때 RDBMS뿐만 아니라 Redis 스티커도 똑같이 업데이트해 줘야 합니다. 만약 중간에 서버가 튕겨서 **RDBMS와 Redis 데이터가 서로 달라지면 유령 회원 매칭 버그**가 터집니다. - 2. **RAM 비용 문제**: Redis는 비싼 RAM 메모리를 쓰므로 데이터가 커지면 서버 비용이 폭증합니다. - ---- - -## 3. 데이터베이스 역정규화 (Denormalization) - -### 💡 용어 풀이 -> * **정규화 (Normalization)**: 중복 데이터를 제거하고 테이블을 깔끔하게 쪼개어 데이터 무결성을 높이는 작업. -> * **역정규화 (Denormalization)**: 성능(속도)을 위해 **의도적으로 정규화를 깨뜨리고, 중복 데이터/합계 데이터를 추가로 저장**하는 작업. - ---- - -### 3.1. 왜 멀쩡한 정규화를 깨뜨리고 역정규화를 할까? - -#### 😱 `SELECT COUNT(*)` 쿼리의 공포 -여러분이 커뮤니티 게시판 목록을 볼 때, 게시글마다 옆에 `[댓글 15개]`, `[추천 42]` 같은 숫자가 붙어있습니다. -만약 역정규화를 하지 않았다면, 게시글 목록 10개를 보여줄 때마다 DB는 매번 `comment` 테이블로 넘어가서 **"이 글에 달린 댓글이 몇 개지?" 하고 `COUNT(*)` 연산을 10번씩 반복**해야 합니다. - -유저 1,000명이 동시에 게시판을 새로고침하면 DB는 `COUNT(*)` 쿼리를 10,000번 수행하다가 과부하로 서버가 다운됩니다. - ---- - -### 3.2. 역정규화 적용 방식 (`post` 테이블) -`post` 테이블에 의도적으로 숫자를 저장하는 컬럼 3개를 추가해 둡니다: -* `comment_count` (댓글 수) -* `like_count` (추천 수) -* `dislike_count` (비추천 수) - -이제 게시판 목록을 불러올 때, DB는 `comment` 테이블을 뒤질 필요 없이 **`post` 테이블에 적혀있는 숫자를 그대로 가져오기만 하면 되므로 조회 속도가 100배 이상 빨라집니다.** - ---- - -### 3.3. 역정규화로 인해 발생하는 치명적 문제와 해결책 - -#### 💥 치명적 사이드 이펙트: 데이터 불일치 (Inconsistency) -* 누군가 댓글을 작성해서 `comment` 테이블에 댓글 데이터 1개가 추가되었는데, 실수로 `post` 테이블의 `comment_count` 숫자를 `+1` 올리는 코드가 누락되거나 에러가 났다면? -* 실제 댓글은 5개인데, 게시판 목록에는 `[댓글 4개]`라고 표시되는 **데이터 불일치 버그**가 터집니다! - -#### 🛠️ 어떻게 해결해야 할까? -1. **트랜잭션 (`@Transactional`)**: 댓글 작성 ➔ 댓글 저장 ➔ 게시글 댓글 수 `+1` 증가 작업을 **하나의 세트로 묶어서, 중간에 에러가 나면 둘 다 취소(Rollback)**되도록 안전장치를 겁니다. -2. **동시성 락 (Concurrency Lock)**: 유저 100명이 동시에 추천 버튼을 누를 때 숫자가 씹히지 않도록 DB 락(Lock) 메커니즘을 적용합니다. - ---- - -## 4. JWT (JSON Web Token) 검증 원리와 비밀키(Secret Key) - -### 💡 용어 풀이 -> * **Token (토큰)**: 유저의 신원 정보가 담긴 암호화된 텍스트 조각. -> * **Stateless (무상태)**: 서버 메모리에 유저의 로그인 상태를 전혀 저장하지 않는 방식. -> * **Secret Key (비밀키)**: 오직 서버만 안전하게 알고 있는 서명용 비밀 암호키. -> * **HMAC-SHA256**: 비밀키를 섞어서 만든 복제 불가능한 해시 암호화 알고리즘. - ---- - -### 4.1. 근본적 의문: "서버 DB나 메모리에 저장도 안 하는데, 토큰이 진짜인지 어떻게 알아채지?" - -JWT는 세션 방식과 달리 **서버에 유저 로그인 정보를 전혀 저장하지 않습니다.** -클라이언트가 요청을 보낼 때 토큰 문자열 하나 달랑 보내오는데, 서버는 도대체 무엇을 믿고 이 토큰이 해커가 위조한 가짜 토큰이 아니라는 것을 검증할 수 있을까요? - ---- - -### 4.2. JWT의 3조각 구조와 '임금님의 암행어사 마패' 비유 - -JWT 토큰을 뜯어보면 마침표(`.`)를 기준으로 3조각으로 나뉘어 있습니다: - -``` -[Header (헤더)] . [Payload (페이로드)] . [Signature (서명/도장)] - eyJhbGci... . eyJzdWIi... . wNiSflK... -``` - -1. **Header (헤더)**: "이 토큰은 무슨 암호화 알고리즘으로 만들어졌는가?" 정보. -2. **Payload (페이로드)**: "이 토큰의 주인은 유저 ID 5번(홍길동)이고, 내일 만료된다" 같은 실제 유저 정보. (**⚠️ 누구나 뜯어서 내용을 열어볼 수 있음!**) -3. **Signature (서명/도장)**: **★ 핵심 ★** `Header` + `Payload` + **`서버의 Secret Key(비밀키)`**를 섞어서 만든 **'복제 불가능한 디지털 도장'**. - ---- - -### 📜 '임금님의 도장' 비유로 이해하는 검증 원리 - -1. 유저가 로그인하면, 서버는 유저 정보(Payload)를 적은 뒤 **오직 서버만 가지고 있는 '비밀 도장(Secret Key)'**을 쾅 찍어서 유저에게 줍니다. -2. 유저가 나중에 이 토큰을 서버로 가져옵니다. -3. 만약 해커가 중간에 토큰 내용을 `유저 ID 5번`에서 `유저 ID 1번(관리자)`으로 슬쩍 고쳤다고(위조했다고) 해봅시다. -4. 서버는 토큰을 받자마자 **"네가 가져온 내용(Header+Payload)에 내 비밀 도장(Secret Key)을 다시 찍어서 나온 서명값"**과 **"토큰에 적혀있는 서명(Signature)"**이 일치하는지 비교합니다! -5. 내용을 고쳤기 때문에 도장 값이 서로 다르게 나오고, 서버는 **"어? 서명이 안 맞네? 이거 위조된 가짜 토큰이다!"** 하고 즉시 튕겨냅니다. - -👉 **결론**: 서버는 유저 상태를 저장할 필요 없이, **내 비밀키(Secret Key)로 서명 도장이 일치하는지 수학적으로 계산만 해보면 가짜를 100% 가려낼 수 있는 것**입니다! - ---- - -### 4.3. JWT의 치명적 약점 (세션과의 결정적 차이) - -#### 💥 탈취당했을 때 서버에서 강제 파기가 불가능함! -* 세션 방식은 해커가 세션키를 훔쳐 가도, 서버에서 해당 세션을 **강제 로그아웃(`session.invalidate()`)** 시키면 해커 접속이 즉시 차단됩니다. -* 하지만 JWT는 서버에 상태가 없기 때문에, **해커가 토큰을 탈취해가면 만료 시간이 끝날 때까지 서버가 해커의 접근을 강제로 막을 방법이 없습니다!** -* (이것이 1차 MVP에서 보안과 관리가 용이한 세션 방식을 채택한 결정적 이유입니다.) - ---- - -## 5. Redis (레디스)란 무엇인가? - -### 💡 용어 풀이 -> * **RAM (메모리)**: 컴퓨터가 켜져 있는 동안 데이터를 초고속으로 처리하는 주기억장치. (전원이 꺼지면 삭제됨) -> * **Disk (HDD/SSD)**: 전원이 꺼져도 데이터가 보관되는 영구 저장장치. (RAM보다 속도가 10만 배 이상 느림) -> * **In-Memory Data Store**: 데이터를 하드디스크가 아닌 오직 RAM 메모리에만 두고 처리하는 초고속 데이터베이스. - ---- - -### 5.1. 왜 이렇게 빠른가? (도서관 비유) - -* **일반 DB (MySQL, PostgreSQL)**: 책을 찾으러 **도서관 지하 창고(Disk)**까지 걸어가서 책을 꺼내오는 방식. (시간이 오래 걸림) -* **Redis**: 책상 위 **포스트잇(RAM)**에 적어둔 메모를 눈으로 쓱 보는 방식. (0.001초 만에 완료) - -Redis는 데이터가 모두 RAM 위에 올라가 있기 때문에 읽기/쓰기 속도가 일반 DB보다 100배~1,000배 이상 빠릅니다. - ---- - -### 5.2. Redis는 언제 쓰고, 언제 쓰면 안 될까? - -* **⭕ 이럴 때 씁니다**: - * **캐싱 (Cache)**: 자주 조회되는 스키장 날씨/웹캠 정보, 인기 게시글 저장 - * **세션 저장소**: 서버 여러 대가 유저 로그인 상태를 공유할 때 - * **실시간 랭킹 & 카운터**: 조회수/좋아요 실시간 집계 -* **❌ 이럴 때 쓰면 안 됩니다 (사이드 이펙트)**: - * 회원의 결제 내역, 비밀번호, 중요한 게시글 원본 저장 ➔ **컴퓨터 전원이 꺼지거나 재부팅되면 RAM 데이터가 싹 날아가 버리는 치명적 위험**이 있습니다. (반드시 원본 데이터는 MySQL 같은 RDBMS에 보관해야 합니다.) - ---- - -## 6. 쿠키 보안 정책 (Cookie Security Policies) 깊이 읽기 - -### 💡 용어 풀이 -> * **Cookie (쿠키)**: 웹 브라우저가 사용자 컴퓨터 파일에 저장해 두는 작은 텍스트 데이터. -> * **XSS (Cross-Site Scripting)**: 해커가 웹사이트에 악성 자바스크립트 코드를 주입하여 다른 유저의 쿠키를 훔쳐 가는 해킹 기법. -> * **CSRF (Cross-Site Request Forgery)**: 해커가 만든 악성 사이트에 유저가 접속했을 때, 유저 몰래 내 쿠키를 가지고 원래 사이트에 비밀글 작성/결제 요청을 보내게 만드는 테러 기법. - ---- - -### 6.1. `HttpOnly = true` (자바스크립트 해킹 방화벽) -* 보통 자바스크립트 코드(`document.cookie`)를 실행하면 브라우저의 쿠키를 자유롭게 읽을 수 있습니다. 해커가 게시글에 악성 스크립트를 몰래 넣어두면 유저의 로그인 쿠키가 해커 서버로 싹 털립니다. -* **`HttpOnly` 속성을 켜두면, 오직 HTTP 통신으로만 쿠키가 이동하고 자바스크립트 접근이 완전히 차단**되어 XSS 해킹을 원천 봉쇄합니다. - ---- - -### 6.2. `Secure = true` (HTTPS 암호화 수호신) -* 우리가 카페 와이파이(Wi-Fi)를 쓸 때, 보안이 안 적용된 `http://` 통신을 하면 와이파이 신호를 감청하는 해커(스니핑)가 내 세션 쿠키를 훔쳐볼 수 있습니다. -* **`Secure` 속성을 켜두면, 오직 암호화된 `https://` 통신 채널에서만 쿠키를 전송**하도록 제한합니다. - ---- - -### 6.3. `SameSite = Lax` (CSRF 공격 방어) -* 해커가 낚시성 이벤트 사이트를 만들어두고 유저가 클릭하게 만듭니다. 유저가 클릭하는 순간, 유저 브라우저에 저장되어 있던 원래 사이트 쿠키가 자동으로 첨부되어 해커의 의도대로 결제나 회원 탈퇴 요청이 날아가는 것이 CSRF 공격입니다. -* **`SameSite=Lax` 속성을 적용하면, 다른 사이트에서 출발한 요청에는 내 쿠키를 첨부하지 않도록 브라우저가 막아줍니다.** - ---- - -### 6.4. ⚠️ 비밀번호나 개인정보를 쿠키에 담으면 생기는 대참사 - -쿠키는 유저의 컴퓨터 하드디스크 텍스트 파일로 저장되며, 브라우저 개발자 도구(F12)를 누르면 누구나 눈으로 읽을 수 있습니다. -만약 쿠키에 비밀번호나 이메일, 이름을 담아둔다면, **PC방이나 공용 컴퓨터에 내 비밀번호가 텍스트 파일로 훤히 노출되는 심각한 개인정보 유출 참사**가 벌어집니다. - -따라서 쿠키에는 오직 아무 의미 없는 무작위 난수 식별자(`JSESSIONID=A1B2C3...`)만 담아야 합니다. - ---- - -## 🎯 7. 종합 결론 및 의사결정 회고 - -우리가 공부한 모든 내용을 바탕으로, **Snowthing 서비스 1차 MVP에 왜 이 기술들을 조합했는지** 최종 요약됩니다: - -1. **RDBMS 중계 테이블 방식 (Option A) 채택**: - * 대안인 Bitmask나 JSONB는 개발이 잠깐 편할지 몰라도 **FK 참조 무결성이 깨져서 데이터 오염 및 유령 데이터 위험**이 너무 큼. - * 조금 불편하더라도 데이터의 안정성을 위해 **중계 엔티티(`MemberResort`)를 직접 만들어 1:N, N:1 관계**로 깔끔하게 처리함. -2. **세션 기반 인증 채택**: - * JWT는 서버 저장 비용이 없지만 **탈취 시 강제 로그아웃/파기가 불가능한 결정적 보안 문제**가 있음. - * 1차 MVP에서는 보안과 제어가 확실한 **세션 방식 + 쿠키 보안 3종 세트(`HttpOnly`, `Secure`, `SameSite`)**로 구축함. -3. **역정규화 컬럼 도입**: - * `SELECT COUNT(*)` 쿼리로 인한 DB 과부하를 막기 위해 `post` 테이블에 `comment_count`, `like_count` 컬럼을 두고, `@Transactional`로 안전하게 관리함. diff --git a/docs/studyArchPrinciples260810.md b/docs/studyArchPrinciples260810.md deleted file mode 100644 index 26aac69..0000000 --- a/docs/studyArchPrinciples260810.md +++ /dev/null @@ -1,72 +0,0 @@ -# [Notion] 백엔드 기술 학습 3단계 딥다이브 철학 및 4대 아키텍처 분석 (TIL) - -> 📌 **작성 일자**: 2026년 8월 10일 -> 🏷️ **문서 목적**: 단순 코드나 겉핥기 답변을 넘어, 모든 기술의 물리적 작동 원리, 2차/3차 치명적 한계, 그리고 그 한계까지 극복하는 최종 실무 아키텍처를 체계적으로 정리한 종합 학습서 - ---- - -## 🏛️ 기술 분석 3단계 딥다이브 절대 원칙 (Architecture Analysis Framework) - -모든 백엔드 기술과 DB 아키텍처 분석 시 다음 3단계를 끝단까지 파헤칩니다. - -1. **[1단계] 컴퓨터 물리 & DB 엔진 내부 작동 원리**: RAM 메모리, CPU, DB 락, 인덱스 B+Tree 노드 수준에서 왜 그렇게 동작하는가? -2. **[2단계] 이 기술을 썼을 때 새로 터지는 2차/3차 치명적 한계 (Side Effects)**: 대규모 트래픽 및 특수 상황에서 이 기술이 불러오는 2차 참사와 한계점은 무엇인가? -3. **[3단계] 그 2차 한계까지 완전 극복하는 최종 실무 아키텍처 (Ultimate Architecture)**: 대규모 서비스에서 그 2차 한계까지 완벽히 지워버리기 위해 사용하는 최종 솔루션은 무엇인가? - ---- - -## 🎯 1. 동시성 락 & 카운터 정합성 (Race Condition) - -### 1단계: DB 원자적 UPDATE 쿼리의 물리 작동 원리 -* `UPDATE post SET like_count = like_count + 1 WHERE id = 1;` -* **원리**: MySQL InnoDB 엔진의 Transaction Manager가 해당 Row(행)에 쓰기 락(X-Lock, Exclusive Lock)을 거는 순간, 뒤이어 들어오는 쿼리들은 DB 엔진 내부의 **`In-Memory Lock Wait Queue (대기 큐)`에 줄을 서서 대기**함. 자바 RAM을 거치지 않고 DB 엔진의 락 대기 큐를 통해 순차 처리되므로 카운트 씹힘(Lost Update)이 원천 차단됨. - -### 2단계: 원자적 쿼리가 초래하는 2차/3차 치명적 한계 -1. **DB 커넥션 고갈과 서비스 마비 (Connection Pool Starvation)**: 핫이슈 글에 0.1초 만에 5,000명이 추천을 누르면 4,999개 쿼리가 DB 대기 큐에 묶임. Spring의 HikariCP DB 커넥션 풀이 대기 상태로 고갈되어 **로그인/조회 등 전체 웹 서비스가 마비**됨. -2. **DB 데드락 (Deadlock)**: 게시글 카운트 + 회원 카운트 등 2개 이상의 테이블 락을 서로 다른 순서로 잡을 때 DB 교착 상태 발생하여 트랜잭션 강제 에러 튕김. - -### 3단계: 2차 한계까지 극복하는 최종 실무 아키텍처 -* **Redis In-Memory 비동기 버퍼링 (Redisson & INCR)**: - * 유저 추천 클릭 시 DB 행 락을 아예 잡지 않고, 초고속 **Redis 메모리에서 0.0001초 만에 `INCR` 카운팅 및 중복 체크** 수행. - * 10초~1분 주기로 Redis에 집계된 카운트를 DB `post` 테이블에 비동기 일괄 배치 UPDATE 반영 (`Eventual Consistency`). - ---- - -## 🎯 2. 외부 식별자 (`public_id`) 세컨더리 인덱스 파편화 - -### 1단계: UUID v7 (Time-ordered)의 물리 작동 원리 -* `UUID v7` 비트 구조: `[ 48 bits: Unix Epoch Timestamp (ms) ] + [ 4 bits: Version ] + [ 74 bits: Random ]` -* **원리**: 앞부분 48비트가 밀리초 타임스탬프이므로 시간이 흐름에 따라 항상 물리적으로 더 큰 값이 생성됨. B+Tree 인덱스 노드에 정렬되어 들어갈 때 **16KB 인덱스 페이지 맨 오른쪽 끝에 순차 덧붙여짐 (Append-Only Insert)**. - -### 2단계: 무작위 UUID v4가 초래했던 2차/3차 치명적 한계 -* **페이지 스플릿 (Page Split) 참사**: 무작위 난수 UUID v4는 꽉 찬 16KB 인덱스 페이지 중간을 강제로 찢고 들어가므로 인덱스 파편화 폭발, 디스크 I/O 급증, 메모리 충전율 50% 급감. - -### 3단계: 최종 실무 아키텍처 -* **`BIGINT id` (내부 PK) + `UUID v7` (`public_id` 세컨더리 인덱스)**: 내부 클러스터드 인덱스는 8바이트 정수로 최적화하고, 세컨더리 인덱스는 `UUID v7`을 채택하여 외부 보안과 세컨더리 인덱스 쓰기 성능을 둘 다 100% 달성. - ---- - -## 🎯 3. 댓글 / 대댓글 계층형 N+1 쿼리 참사 - -### 1단계: In-Memory Tree 재조립의 물리 작동 원리 -* `SELECT * FROM comment WHERE post_id = 1;` -* **원리**: DB 단에서 쿼리를 단 1번만 실행하여 해당 글의 모든 댓글을 **단 하나의 TCP 소켓 패킷(Single Network RTT)**으로 가져옴. 자바 RAM의 `HashMap` 메모리 주소를 $O(1)$ 초고속 참조하여 부모-자식 객체 포인터(Pointer)만 엮어냄. - -### 2단계: N+1 쿼리가 초래하는 2차/3차 치명적 한계 -* **네트워크 RTT (Round Trip Time) 폭탄**: 원댓글 10개 조회 후 대댓글 N번 추가 조회 시 1+N번의 TCP 소켓 통신 및 HikariCP 커넥션 획득/반납 오버헤드로 DB 마비. - -### 3단계: 최종 실무 아키텍처 -* **Single Query In-Memory Reassembly + 1차 댓글 페이징**: DB 통신은 단 1번으로 고정하고 자바 RAM에서 재조립하며, 댓글이 수만 개 달린 핫이슈 글은 1차 원댓글 단위로 Slice/Page 나누어 메모리 과부하 사전 차단. - ---- - -## 🎯 4. 다대다 (N:M) 데이터 구조 및 명칭 변경 - -### 1단계: Enum 코드화 (Data Indirection)의 물리 작동 원리 -* DB에는 `PHOENIX_PARK` 고정 코드를 저장하고, 화면 한글 명칭("휘닉스평창")은 자바 RAM 메모리의 Enum 상수로 보관. - -### 2단계: 텍스트 저장 시 초래하는 2차/3차 치명적 한계 -* 10년 뒤 명칭 변경 시 유저 10만 명의 DB 레코드를 디스크에서 훑어서 치환(UPDATE)하는 디스크 I/O 및 락 대기 폭탄 발생. - -### 3단계: 최종 실무 아키텍처 -* **RDBMS 정석 중계 테이블 (`id` 대리키) + Enum 식별자 코드화**: RDBMS의 FK 참조 무결성을 100% 보장하면서, 명칭 변경 시 DB 디스크 I/O 연산 0건, 자바 애플리케이션 상수 수정만으로 처리. diff --git a/docs/studyCommunityPostCommentMaster260821.md b/docs/studyCommunityPostCommentMaster260821.md deleted file mode 100644 index 6eb369e..0000000 --- a/docs/studyCommunityPostCommentMaster260821.md +++ /dev/null @@ -1,710 +0,0 @@ -# 📚 [Master Study Guide] Snowthing 커뮤니티(게시글 & 댓글/대댓글) 백엔드 전 과정 코드, 7대 필수 요소 기술 원리, 4대 대안 & 트레이드오프 극복 완전 가이드 (2026-08-21) - -> **노션(Notion) 복사용 및 백엔드 기술 면접 / 아키텍처 공부용 완전판 마스터 가이드** -> 본 문서는 Snowthing 스프린트 02 커뮤니티 도메인(게시글 Post & 댓글/대댓글 Comment) 백엔드 전체 코드에 대한 1줄 한 줄 상세 해설 주석(Annotation), **[WHY] 왜 그렇게 설계하고 만들어졌는지에 대한 물리적 배경**, 7대 필수 서술 요소 체계(개념, Why, When, How, Pros, Alternatives, Trade-off & Mitigation), 그리고 계층형 데이터 모델 대안과 락 프리(Lock-Free) 동시성 제어 원리를 집대성한 노션 공부용 문서입니다. - ---- - -# 📑 PART 1. 커뮤니티 5대 핵심 아키텍처 원리 (7대 필수 요소 체계) - ---- - -## 1. 댓글 계층형 N+1 완전 파괴: `Single Query + In-Memory Tree` 기법 - -### ① 개념 (What) -부모 댓글과 대댓글(Self-Referencing) 구조를 조회할 때, JPA 엔티티 지연 로딩 연관 관계를 순회하지 않고 **`WHERE post_id = :postId` 조건의 단 1회 SQL 쿼리로 모든 댓글을 가져와 자바 메모리(RAM)의 `HashMap` 포인터 참조를 통해 부모-자식 트리 계층(`children: []`)으로 재조립**하는 백엔드 최적화 기법입니다. - -### ② 왜 사용하는지 (Why - 도입 목적 & 배경) -JPA에서 `@OneToMany List children` 연관 관계를 두고 댓글 목록을 조회하면, 부모 댓글 10개마다 자식 대댓글을 조회하는 `SELECT` 쿼리가 연쇄적으로 날아가는 **N+1 쿼리 폭탄**이 터집니다. 댓글이 1,000개 달린 게시글은 SQL이 1,001번 실행되어 DB 커넥션이 고갈되고 서버가 다운됩니다. - -### ③ 어떨 때 사용하는지 (When) -게시글의 댓글/대댓글, 카테고리 계층 구조(1차/2차/3차 카테고리), 조직도 트리 등 **한 화면에 특정 부모 하위의 전체 트리 데이터를 표시해야 하는 유즈케이스**에 사용합니다. - -### ④ 어떻게 사용하는지 (How - 구현 코드 및 동작 방식) -```java -// 1. 단 1회의 JPQL 쿼리로 해당 게시글의 모든 댓글 직조회 (O(1) Query Count) -List comments = commentRepository.findByPostIdWithMember(post.getId()); - -// 2. HashMap과 LinkedHashMap을 활용한 In-Memory Tree 포인터 조립 -Map map = new LinkedHashMap<>(); -List rootComments = new ArrayList<>(); - -for (Comment comment : comments) { - CommentResponse dto = CommentResponse.from(comment); - map.put(dto.commentId(), dto); - - if (dto.parentId() == null) { - rootComments.add(dto); // 최상위 부모 댓글 - } else { - CommentResponse parentDto = map.get(dto.parentId()); - if (parentDto != null) { - parentDto.children().add(dto); // O(1) 시간 복잡도로 부모의 children 리스트에 자식 바인딩! - } - } -} -``` - -### ⑤ 장점 (Pros) -* **쿼리 수 고정 (O(1) Query Count)**: 댓글이 1개든 1,000개든 DB 쿼리가 무조건 단 1번만 실행됩니다. -* **초고속 응답 속도**: 자바 메모리의 `HashMap.get()` 조회 시간 복잡도는 O(1)이므로 메모리 연산 시간이 수 밀리초 이내입니다. - -### ⑥ 다른 기술/대안 (Alternatives - 트리 구조 구현 4대 모델 비교) -1. **Adjacency List (인접 리스트 - 현재 채택 방식)**: `parent_id` 컬럼 1개만 둠. 가장 직관적이고 CUD(생성/수정/삭제)가 단순함. -2. **Path Enumeration (경로 열거)**: `path` 컬럼에 `/1/4/12/` 형태로 전체 경로를 저장. `LIKE '/1/%'`로 조회가 쉽지만 문자열 파싱 및 수정 시 전체 경로 UPDATE 부담. -3. **Nested Sets (중첩 집합)**: `lft`, `rgt` 숫자로 범위를 관리. 읽기 속도는 빠르나 새로운 댓글 하나 삽입 시 기존 전체 노드의 `lft`/`rgt`를 +2 갱신해야 하므로 쓰기 성능 부담. -4. **Closure Table (폐쇄 테이블)**: 모든 부모-자식 관계를 별도의 `comment_tree(ancestor, descendant, depth)` 관계 테이블로 분리. 조회가 유연하나 테이블이 비대해짐. - -### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) -* **트레이드오프 (대량 댓글 메모리 오버헤드)**: 한 게시글에 댓글이 10만 개 이상 달리면 단 1회 쿼리라도 자바 메모리(JVM Heap)에 10만 개 DTO가 한 번에 올라가 메모리 초과(OOM) 위험이 발생할 수 있습니다. -* **극복 방안 (1차 원댓글 Slice 페이징)**: 댓글이 수천 건 이상 커지면 최상위 부모 댓글(`parent_id IS NULL`) 단위로 1차 `Slice`/`Page` 페이징 조회를 적용하고, 각 부모의 대댓글만 패치하도록 제한합니다. - ---- - -## 2. 락(Lock) 없는 동시성 제어: DB `UNIQUE` 제약조건 + `@Async` 비동기 카운팅 - -### ① 개념 (What) -추천/비추천 투표 연타(광클) 시 발생할 수 있는 레이스 컨디션 및 중복 투표를 막기 위해, DB 레벨의 **`UNIQUE (post_id, member_id, type)` 제약 조건**으로 락 프리(Lock-Free) 원자적 물리 차단을 수행하고, 카운트 갱신은 **`@Async` 비동기 이벤트**로 메인 트랜잭션 블로킹 없이 처리하는 기법입니다. - -### ② 왜 사용하는지 (Why) -JPA 낙관적 락(`@Version`)이나 비관적 락(`SELECT FOR UPDATE`)은 락 대기 시간 및 `OptimisticLockException` 발생 시 복잡한 재시도(Retry) 로직이 요구되어 DB 커넥션 병목을 일으킵니다. 락 대기 없이 DB 유니크 제약 조건만으로 중복 투표를 원자적으로 물리 차단하기 위함입니다. - -### ③ 어떨 때 사용하는지 (When) -유저 1인당 각 1회씩 허용되는 추천/비추천/투표 기능 및 수많은 사용자가 동시에 몰리는 반응형 커뮤니티 API에 사용합니다. - -### ④ 어떻게 사용하는지 (How) -```java -// 1. PostReaction 엔티티 복합 유니크 제약조건 설정 (계정당 추천 1회, 비추천 1회 각각 허용) -@Table( - name = "post_reaction", - uniqueConstraints = { - @UniqueConstraint(name = "uk_post_member_type", columnNames = {"post_id", "member_id", "type"}) - } -) -public class PostReaction extends BaseTimeEntity { ... } - -// 2. 서비스 레이어에서 DataIntegrityViolationException 캐치 및 409 Conflict 반환 -try { - reactionRepository.save(reaction); -} catch (DataIntegrityViolationException e) { - throw new CustomAuthException(ErrorCode.ALREADY_REACTED); -} - -// 3. 메인 트랜잭션을 블로킹하지 않는 @Async 비동기 카운터 갱신 이벤트 발행 -eventPublisher.publishEvent(new PostReactionEvent(post.getId(), type)); -``` - -### ⑤ 장점 (Pros) -* **락 트랜잭션 대기 병목 제거**: DB Row Lock을 잡지 않으므로 동시 요청 시 트랜잭션 대기 병목이 발생하지 않습니다. -* **DB 엔진 레벨 무결성**: MySQL InnoDB 유니크 인덱스가 동일 요청의 중복 투표를 원자적으로 차단합니다. - -### ⑥ 다른 대안 (Alternatives) -* **JPA 낙관적 락 (`@Version`)**: 충돌 시 예외를 던지고 자바에서 재시도. 락 대기는 없으나 동시 충돌 시 재시도 실패율 증가. -* **JPA 비관적 락 (`PESSIMISTIC_WRITE`)**: DB Row Lock을 걸어 순차 처리. 데이터 일관성은 보장되나 동접자가 몰릴 때 DB 커넥션 타임아웃 발생 가능. - -### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) -* **트레이드오프 (비동기 처리 시 미세한 카운트 시차)**: `@Async`로 카운트를 올리므로 DB `post_reaction`에는 저장이 완료되었으나 게시글 `like_count` 수치 갱신에 수 밀리초 시차가 발생할 수 있습니다. -* **극복 방안 (Optimistic UI)**: 프론트엔드에서 추천 버튼 클릭 즉시 화면 상의 카운터를 +1 먼저 가산하고 백엔드 응답을 수신하는 낙관적 UI 렌더링을 적용합니다. - -### ⑧ CAP 정리 (CAP Theorem) 및 BASE 모델 4대 물리적 적용 메커니즘 - -이 방식은 전통적인 RDBMS의 **ACID 강한 일관성(Strong Consistency)** 대신, 고성능 분산 웹 시스템의 **CAP 정리 중 AP (Availability & Partition Tolerance)** 모델과 **BASE (Basically Available, Soft State, Eventual Consistency)** 체계를 실제 코드와 DB 레이어에 물리적으로 매핑한 아키텍처입니다. - -#### 1. CAP 정리 적용 원리 (Consistency vs Availability) -* **C (Consistency - 일관성)의 대가**: `post_reaction` 투표 이력 저장과 `post.like_count` 카운터 갱신을 단일 동기 트랜잭션으로 묶어 DB Row Lock을 잡으면 **강한 일관성(Strong Consistency)**을 얻지만, 트래픽 폭주 시 DB 커넥션 대기 병목이 생겨 시스템 **가용성(Availability)**과 응답 속도가 크게 떨어집니다. -* **AP + BASE 선택 이유**: 커뮤니티 추천 수 갱신은 수 밀리초의 수치 반영 지연이 발생하더라도 시스템 전체가 멈추지 않고 빠른 응답을 주는 **가용성(Availability)** 확보가 서비스 안정성에 훨씬 유리하기 때문입니다. - -#### 2. 우리 코드 및 DB에 실제로 적용된 4대 물리적 구조 - -1. **[C 영역 - 즉시 일관성 (Immediate Consistency)] DB 유니크 제약조건**: - - `post_reaction` 테이블에 `@UniqueConstraint(name = "uk_post_member_type", columnNames = {"post_id", "member_id", "type"})` 설정. - - 유저 투표 시 `post_reaction` 테이블 저장 단계는 **ACID 수준의 일관성**을 유지하여, 중복 투표 발생 시 MySQL InnoDB 엔진이 `DataIntegrityViolationException` 예외를 내며 중복 저장을 원자적으로 물리 차단합니다. - -2. **[A 영역 - 고가용성 (High Availability)] `@Async` 비동기 이벤트 분리**: - - `PostService.reactToPost()` 메서드에서 `post` 테이블의 카운트를 동기로 올리지 않고, `eventPublisher.publishEvent()`로 비동기 이벤트를 발행한 뒤 **즉시 HTTP 200 OK 응답을 반환**. - - 메인 HTTP 요청 Thread는 `post` 테이블의 Row Lock 대기에 얽매이지 않으므로, 수천 명의 동시 요청이 들어와도 서버 타임아웃 없이 모든 요청에 **정상 응답하는 가용성(Availability)**을 확보합니다. - -3. **[Soft State & Eventual Consistency 영역 - 최종 일관성] `@Async` 리스너**: - - `PostReactionEventListener.java` 백그라운드 이벤트 리스너 실행. - - **Soft State (일시적 불일치)**: 메인 트랜잭션 응답 직후부터 비동기 스레드가 동작하는 수 밀리초 사이에는 DB `post_reaction`(투표 이력 1건)과 `post.like_count`(아직 +1 안 됨) 사이에 미세한 상태 차이가 존재합니다. - - **Eventual Consistency (최종 일관성)**: 백그라운드 비동기 스레드가 `UPDATE post SET like_count = like_count + 1 WHERE post_id = :id` 쿼리를 완료하는 시점에 두 데이터는 **최종적으로 완전히 일치**하게 됩니다. - -4. **[보완 렌더링 영역] Optimistic UI (낙관적 UI)**: - - 프론트엔드 `app/posts/[publicId]/page.tsx` 연동. - - 백엔드의 수 밀리초 최종 일관성 시차 동안 유저가 지연을 느끼지 않도록, 추천 버튼 클릭 즉시 화면 상의 숫자를 +1 렌더링하고 백엔드의 비동기 처리가 최종 완료되도록 시각적 조화를 이룹니다. - ---- - -# 📑 PART 1.5. 게시글 & 댓글 컨트롤러/서비스 전체 비즈니스 로직 흐름도 (Mermaid & Code Flow) - ---- - -## 1. 게시글(Post) 도메인 비즈니스 로직 및 쿼리 실행 흐름도 - -### ① 게시글 작성 (`POST /api/posts`) 시퀀스 다이어그램 - -```mermaid -sequenceDiagram - autonumber - actor Client as 클라이언트 (유저/프론트) - participant Ctrl as PostController - participant Svc as PostService - participant CatRepo as PostCategoryRepository - participant MemberRepo as MemberRepository - participant PostRepo as PostRepository - participant ImgRepo as PostImageRepository - - Client->>Ctrl: POST /api/posts (DTO: categoryCode, title, content, isAnonymous, password) - Ctrl->>Ctrl: @Valid 유효성 검사 & getClientIp(httpRequest) 파싱 - Ctrl->>Svc: createPost(request, userDetails, clientIp) - - Svc->>CatRepo: findByCode(categoryCode) - alt 카테고리 없음 - CatRepo-->>Svc: Optional.empty() - Svc-->>Ctrl: CustomAuthException (POST_CATEGORY_NOT_FOUND 404) - Ctrl-->>Client: 404 Not Found - end - - alt 익명글 (ANONYMOUS 카테고리 또는 isAnonymous=true) - Svc->>Svc: anonymousPassword BCrypt 해시 암호화 - else 회원글 - Svc->>MemberRepo: findByPublicId(userDetails.getPublicId()) - end - - Svc->>PostRepo: save(Post 엔티티) - PostRepo-->>Svc: savedPost (PK 부여 완료) - - opt 첨부 이미지 존재하는 경우 - loop 이미지 URL 리스트 - Svc->>ImgRepo: save(PostImage 엔티티) - end - end - - Svc-->>Ctrl: PostResponse.from(savedPost) - Ctrl-->>Client: 201 Created (PostResponse JSON) -``` - ---- - -### ② 게시글 추천/비추천 투표 및 `@Async` 비동기 카운터 갱신 흐름도 - -```mermaid -flowchart TD - A[클라이언트: POST /api/posts/{publicId}/reactions] --> B[PostController.reactToPost] - B --> C{인증 여부 검증 userDetails != null} - C -- 미인증 --> D[403 Forbidden 예외 반환] - C -- 인증됨 --> E[PostService.reactToPost] - - E --> F[postRepository.findByPublicId] - F --> G[memberRepository.findByPublicId] - G --> H[PostReaction 엔티티 생성: post, member, type] - - H --> I[reactionRepository.save] - - I -->|DB uk_post_member_type 위반| J[DataIntegrityViolationException 캐치] - J --> K[409 Conflict ALREADY_REACTED 반환] - - I -->|최초 투표 성공| L[applicationEventPublisher.publishEvent] - L --> M[메인 트랜잭션 종료 & HTTP 200 OK 응답 반환] - - L -. 비동기 이벤트 전달 .-> N[@Async PostReactionEventListener] - N --> O[postRepository.findById] - O --> P[post.increaseLikeCount / increaseDislikeCount] - P --> Q[UPDATE post SET like_count = like_count + 1 백그라운드 SQL 실행] -``` - ---- - -## 2. 댓글/대댓글(Comment) 도메인 비즈니스 로직 및 쿼리 실행 흐름도 - -### ① 댓글/대댓글 작성 (`POST /api/posts/{publicId}/comments`) - -```mermaid -sequenceDiagram - autonumber - actor Client as 클라이언트 - participant Ctrl as CommentController - participant Svc as CommentService - participant PostRepo as PostRepository - participant CommentRepo as CommentRepository - participant MemberRepo as MemberRepository - - Client->>Ctrl: POST /api/posts/{publicId}/comments (parentId, content, isAnonymous, password) - Ctrl->>Svc: createComment(publicId, request, userDetails, clientIp) - - Svc->>PostRepo: findByPublicId(publicId) - alt 게시글 없거나 is_deleted = true - Svc-->>Ctrl: CustomAuthException (POST_NOT_FOUND 404) - Ctrl-->>Client: 404 Not Found - end - - opt parentId != null (대댓글 작성인 경우) - Svc->>CommentRepo: findById(parentId) - alt 부모 댓글 부재 또는 타 게시글 소속 - Svc-->>Ctrl: CustomAuthException (PARENT_COMMENT_NOT_FOUND 404) - end - end - - Svc->>CommentRepo: save(Comment 엔티티) - Svc->>PostRepo: post.increaseCommentCount() (comment_count +1) - Svc-->>Ctrl: CommentResponse.from(savedComment) - Ctrl-->>Client: 201 Created (CommentResponse JSON) -``` - ---- - -### ② 댓글 계층형 목록 조회 (`GET /api/posts/{publicId}/comments`) - N+1 파괴 - -```mermaid -flowchart TD - Start[클라이언트: GET /api/posts/{publicId}/comments] --> Ctrl[CommentController.getCommentsByPost] - Ctrl --> Svc[CommentService.getCommentsByPost] - - Svc --> DB[(Database)] - DB -- "SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId (단 1회 SQL)" --> Svc - - Svc --> Map[LinkedHashMap 포인터 맵 생성] - Svc --> Loop[루프 순회: List comments] - - Loop --> Check{dto.parentId == null ?} - Check -- Yes (원댓글) --> AddRoot[rootComments 리스트에 추가] - Check -- No (대댓글) --> GetParent[map.get parentId 로 O(1) 부모 DTO 획득] - GetParent --> AddChild[parentDto.children 리스트에 자식 바인딩] - - AddRoot --> LoopNext{다음 항목 존재?} - AddChild --> LoopNext - - LoopNext -- Yes --> Loop - LoopNext -- No (완료) --> Build[PostCommentListResponse 조립 반환] - Build --> Resp[HTTP 200 OK JSON 반환] -``` - ---- - -# 📑 PART 2. 백엔드 전체 코드 & 1줄 상세 주석 (Annotation) - ---- - -## 1. `Comment.java` (댓글 메인 엔티티) - -```java -package com.ikae.snowthing.domain.comment.entity; - -import com.ikae.snowthing.domain.member.entity.Member; -import com.ikae.snowthing.domain.post.entity.Post; -import com.ikae.snowthing.global.common.BaseTimeEntity; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.SQLDelete; - -import java.time.LocalDateTime; - -@Entity // [JPA] 이 클래스가 데이터베이스 테이블과 매핑되는 ORM 엔티티임을 선언 -@Table(name = "comment") // [DB] 매핑될 데이터베이스 테이블명을 'comment'로 명시적 지정 -@Getter // [Lombok] 모든 필드에 대한 Getter 메서드를 자동 생성하여 불변 읽기 제공 -@NoArgsConstructor(access = AccessLevel.PROTECTED) // [JPA Spec] 기본 생성자의 접근 제어자를 PROTECTED로 제한하여 무분별한 객체 생성 방지 -@SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?") // [Soft Delete] delete() 호출 시 물리 삭제 대신 UPDATE 수행 -public class Comment extends BaseTimeEntity { - - @Id // [PK] 데이터베이스 테이블의 기본키(Primary Key) 필드임을 지정 - @GeneratedValue(strategy = GenerationType.IDENTITY) // [Strategy] MySQL AUTO_INCREMENT 전략을 채택하여 기본키 자동 증가 처리 - @Column(name = "comment_id") // [Column] DB 컬럼명을 'comment_id'로 지정 - private Long id; // DB 내부 조인 성능 최적화를 위한 8바이트 정수 PK - - @ManyToOne(fetch = FetchType.LAZY) // [N:1] 댓글과 게시글의 N:1 연관 관계 지연 로딩(LAZY) 설정으로 N+1 방지 - @JoinColumn(name = "post_id", nullable = false) // [FK] 외래키 컬럼명을 'post_id'로 지정하며 필수(NOT NULL) 설정 - private Post post; // 이 댓글이 달린 대상 게시글 엔티티 참조 - - @ManyToOne(fetch = FetchType.LAZY) // [N:1] 댓글과 회원의 N:1 연관 관계 지연 로딩 설정 - @JoinColumn(name = "member_id") // [FK] 외래키 'member_id' 지정 (비회원 작성 시 NULL 수용) - private Member member; // 댓글 작성자 회원 엔티티 참조 - - @ManyToOne(fetch = FetchType.LAZY) // [Self Referencing] 자기 자신을 참조하는 N:1 부모 댓글 연관 관계 설정 - @JoinColumn(name = "parent_id") // [FK] 부모 댓글 PK를 가리키는 외래키 'parent_id' 지정 (원댓글은 NULL) - private Comment parent; // 부모 댓글 엔티티 참조 (대댓글 구현용) - - @Column(nullable = false, length = 1000) // [Column] 댓글 본문 필수(NOT NULL), 최대 1,000자 제한 - private String content; // 댓글 본문 내용 - - @Column(name = "writer_ip", nullable = false, length = 45) // [Column] 작성자 IP 주소 (IPv6 45자 수용 가능) - private String writerIp; // 작성자 클라이언트 IP 주소 - - @Column(name = "is_anonymous", nullable = false) // [Column] 익명 작성 여부 플래그 (true: 익명, false: 회원) - private boolean isAnonymous; // 익명 작성 여부 - - @Column(name = "anonymous_password") // [Column] 비회원 익명 작성 시 수정/삭제용 비밀번호 (BCrypt 암호화) - private String anonymousPassword; // 비회원 암호화 비밀번호 - - @Column(name = "is_deleted", nullable = false) // [Column] Soft Delete 상태 플래그 (true: 삭제됨, false: 정상) - private boolean isDeleted = false; // 논리 삭제 여부 - - @Column(name = "deleted_at") // [Column] Soft Delete 처리 시각 기록 필드 (미삭제 시 NULL) - private LocalDateTime deletedAt; // 삭제 일시 - - @Builder // [Design Pattern] 빌더 패턴을 적용하여 생성자 파라미터 순서 오염 방지 - public Comment(Post post, Member member, Comment parent, String content, - String writerIp, boolean isAnonymous, String anonymousPassword) { - this.post = post; - this.member = member; - this.parent = parent; - this.content = content; - this.writerIp = writerIp; - this.isAnonymous = isAnonymous; - this.anonymousPassword = anonymousPassword; - this.isDeleted = false; - } - - public void softDelete() { // [Domain Method] 엔티티 캡슐화를 유지하며 Soft Delete 상태를 변경하는 도메인 메서드 - this.isDeleted = true; // 삭제 플래그를 true로 변경 - this.deletedAt = LocalDateTime.now(); // 삭제 처리 시각을 현재 시간으로 기록 - } -} -``` - ---- - -## 2. `CommentRepository.java` (단 1회 JPQL 쿼리 조동사) - -```java -package com.ikae.snowthing.domain.comment.repository; - -import com.ikae.snowthing.domain.comment.entity.Comment; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; - -public interface CommentRepository extends JpaRepository { - - @Query("SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId ORDER BY c.createdAt ASC, c.id ASC") // [Single Query] 단 1회의 JPQL 조인 쿼리로 특정 게시글의 모든 댓글 직조회 (N+1 파괴) - List findByPostIdWithMember(@Param("postId") Long postId); // 작성자 Member를 FETCH JOIN하여 단 1회 쿼리로 리스트를 반환하는 메서드 -} -``` - ---- - -## 3. `CommentService.java` (In-Memory Tree 조립 및 비즈니스 로직) - -```java -package com.ikae.snowthing.domain.comment.service; - -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.member.entity.Member; -import com.ikae.snowthing.domain.member.repository.MemberRepository; -import com.ikae.snowthing.domain.post.entity.Post; -import com.ikae.snowthing.domain.post.repository.PostRepository; -import com.ikae.snowthing.global.error.ErrorCode; -import com.ikae.snowthing.global.exception.CustomAuthException; -import com.ikae.snowthing.global.security.CustomUserDetails; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.*; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) // [Performance] 읽기 전용 트랜잭션을 기본 적용하여 히버네이트 스냅샷 생성 오버헤드 차단 -public class CommentService { - - private final CommentRepository commentRepository; - private final PostRepository postRepository; - private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; - - @Transactional // [Transaction] 쓰기 작업이 포함되므로 일반 트랜잭션으로 재정의 - public CommentResponse createComment(String postPublicId, CommentCreateRequest request, - CustomUserDetails userDetails, String clientIp) { - Post post = postRepository.findByPublicId(postPublicId) // 게시글 publicId로 대상 게시글 조회 - .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); // 존재하지 않으면 404 예외 발생 - - if (post.isDeleted()) { // 게시글이 Soft Delete 상태인지 검증 - throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); // 이미 지워진 글이면 404 예외 발생 - } - - Comment parent = null; - if (request.parentId() != null) { // parentId 요청이 존재하는 대댓글 작성 케이스인 경우 - parent = commentRepository.findById(request.parentId()) // 부모 댓글 엔티티 조회 - .orElseThrow(() -> new CustomAuthException(ErrorCode.PARENT_COMMENT_NOT_FOUND)); // 없으면 404 부모 댓글 예외 발생 - - if (!parent.getPost().getId().equals(post.getId())) { // 부모 댓글의 게시글 ID와 현재 게시글 ID 일치 여부 대조 - throw new CustomAuthException(ErrorCode.INVALID_COMMENT_PARENT); // 다른 글의 댓글에 대댓글 작성을 시도하면 400 예외 차단 - } - } - - Member member = null; - String encodedPassword = null; - - if (request.isAnonymous()) { // 익명 댓글 작성인 경우 - if (request.anonymousPassword() == null || request.anonymousPassword().isBlank()) { - throw new CustomAuthException(ErrorCode.INVALID_INPUT); // 익명 비밀번호 누락 시 400 예외 - } - encodedPassword = passwordEncoder.encode(request.anonymousPassword()); // 비회원 비밀번호 BCrypt 해시 암호화 - } else { // 회원 댓글 작성인 경우 - if (userDetails == null) { - throw new CustomAuthException(ErrorCode.INVALID_CREDENTIALS); // 로그인 정보가 없으면 401 예외 - } - member = memberRepository.findByPublicId(userDetails.getPublicId()) // 인증 객체에서 작성자 Member 엔티티 조회 - .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); - } - - Comment comment = Comment.builder() // Comment 엔티티 생성 - .post(post) - .member(member) - .parent(parent) - .content(request.content()) - .writerIp(clientIp != null ? clientIp : "127.0.0.1") - .isAnonymous(request.isAnonymous()) - .anonymousPassword(encodedPassword) - .build(); - - Comment savedComment = commentRepository.save(comment); // DB에 댓글 저장 - post.increaseCommentCount(); // 게시글의 역정규화 comment_count 카운트 +1 증가 - - return CommentResponse.from(savedComment); // DTO로 변환하여 응답 반환 - } - - public PostCommentListResponse getCommentsByPost(String postPublicId) { - Post post = postRepository.findByPublicId(postPublicId) // 대상 게시글 조회 - .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); - - if (post.isDeleted()) { - throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); - } - - List comments = commentRepository.findByPostIdWithMember(post.getId()); // [Single Query] 단 1회 쿼리로 전체 댓글 패치 - - Map map = new LinkedHashMap<>(); // [In-Memory Tree] 포인터 맵 생성 (순서 보장 LinkedHashMap) - List rootComments = new ArrayList<>(); // 최상위 부모 댓글들을 담을 리스트 - - for (Comment comment : comments) { // 단 1회 조회의 결과를 자바 루프로 순회 - CommentResponse dto = CommentResponse.from(comment); // 엔티티를 DTO로 변환 - map.put(dto.commentId(), dto); // O(1) 참조를 위해 맵에 저장 - - if (dto.parentId() == null) { // parentId가 없는 최상위 부모 댓글인 경우 - rootComments.add(dto); // 루트 리스트에 추가 - } else { // 대댓글인 경우 - CommentResponse parentDto = map.get(dto.parentId()); // O(1) 복잡도로 맵에서 부모 DTO를 인메모리 포인터로 획득 - if (parentDto != null) { - parentDto.children().add(dto); // 부모 DTO의 children 리스트에 자식 바인딩! - } - } - } - - return PostCommentListResponse.builder() // 최종 계층형 트리 응답 DTO 생성 반환 - .publicId(postPublicId) - .totalCommentCount(post.getCommentCount()) - .comments(rootComments) - .build(); - } - - @Transactional - public void deleteComment(Long commentId, String anonymousPassword, CustomUserDetails userDetails) { - Comment comment = commentRepository.findById(commentId) // 삭제 대상 댓글 조회 - .orElseThrow(() -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); - - if (comment.isDeleted()) { - throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); // 이미 지워진 댓글이면 404 반환 - } - - validateDeletePermission(comment, anonymousPassword, userDetails); // 작성자 본인 및 비회원 비밀번호 / 관리자 권한 검증 - - comment.softDelete(); // Soft Delete 처리 (is_deleted=true, deleted_at=NOW()) - comment.getPost().decreaseCommentCount(); // 게시글의 역정규화 comment_count 카운트 -1 차감 - } - - private void validateDeletePermission(Comment comment, String anonymousPassword, CustomUserDetails userDetails) { - if (comment.isAnonymous()) { - if (anonymousPassword == null || !passwordEncoder.matches(anonymousPassword, comment.getAnonymousPassword())) { - throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); // 비회원 비밀번호 불일치 시 403 예외 - } - } else { - if (userDetails == null) { - throw new CustomAuthException(ErrorCode.ACCESS_DENIED); // 미인증 시 403 예외 - } - - boolean isAdmin = userDetails.getAuthorities().stream() - .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); - - boolean isWriter = comment.getMember() != null && comment.getMember().getPublicId().equals(userDetails.getPublicId()); - - if (!isAdmin && !isWriter) { // 작성자 본인도 아니고 관리자도 아니면 - throw new CustomAuthException(ErrorCode.ACCESS_DENIED); // 403 Forbidden 권한 거부 예외 발생 - } - } - } -} -``` - ---- - -# 📑 PART 3. 백엔드 테스트 수트 & N+1 파괴 쿼리 검증 기법 - ---- - -## 1. `CommentServiceTest.java` (단위/통합 테스트 코드) - -```java -package com.ikae.snowthing.domain.comment.service; - -import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; -import com.ikae.snowthing.domain.comment.dto.CommentResponse; -import com.ikae.snowthing.domain.comment.dto.PostCommentListResponse; -import com.ikae.snowthing.domain.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; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.transaction.annotation.Transactional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -@SpringBootTest // [SpringBootTest] 스프링 통합 테스트 환경 로드 -@Transactional // [Rollback] 테스트 종료 후 DB를 자동으로 롤백하여 독립성 유지 -class CommentServiceTest { - - @Autowired private CommentService commentService; - @Autowired private PostService postService; - @Autowired private MemberRepository memberRepository; - @Autowired private PostCategoryRepository categoryRepository; - @Autowired private PasswordEncoder passwordEncoder; - - private Member member1; - private CustomUserDetails userDetails1; - private PostResponse post; - - @BeforeEach - void setUp() { - categoryRepository.findByCode("FREE") - .orElseGet(() -> categoryRepository.save(PostCategory.builder().name("자유게시판").code("FREE").build())); - - member1 = memberRepository.save(Member.builder() - .email("commenter@example.com") - .password(passwordEncoder.encode("Password123!")) - .nickname("댓글보더") - .role(Role.ROLE_USER) - .build()); - - userDetails1 = new CustomUserDetails(member1); - - post = postService.createPost(PostCreateRequest.builder() - .categoryCode("FREE") - .title("댓글 테스트 게시글") - .content("게시글 본문") - .isAnonymous(false) - .build(), userDetails1, "127.0.0.1"); - } - - @Nested - @DisplayName("댓글 작성 테스트") - class CreateCommentTest { - - @Test - @DisplayName("원댓글과 대댓글을 정상적으로 작성한다.") - void createComment_success() { - CommentResponse parent = commentService.createComment(post.publicId(), CommentCreateRequest.builder() - .content("원댓글입니다.") - .isAnonymous(false) - .build(), userDetails1, "127.0.0.1"); - - CommentResponse child = commentService.createComment(post.publicId(), CommentCreateRequest.builder() - .parentId(parent.commentId()) - .content("대댓글입니다.") - .isAnonymous(false) - .build(), userDetails1, "127.0.0.1"); - - assertThat(parent.commentId()).isNotNull(); - assertThat(child.parentId()).isEqualTo(parent.commentId()); - } - - @Test - @DisplayName("존재하지 않는 부모 댓글 ID로 대댓글 작성 시 404 예외가 터진다.") - void createComment_parentNotFound() { - assertThatThrownBy(() -> commentService.createComment(post.publicId(), CommentCreateRequest.builder() - .parentId(99999L) - .content("잘못된 부모 대댓글") - .isAnonymous(false) - .build(), userDetails1, "127.0.0.1")) - .isInstanceOf(CustomAuthException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.PARENT_COMMENT_NOT_FOUND); - } - } - - @Nested - @DisplayName("댓글 트리 계층형 목록 조회 테스트") - class GetCommentsTest { - - @Test - @DisplayName("부모-자식 대댓글 트리 계층 구조가 정상 조립된다.") - void getCommentsByPost_treeStructure() { - CommentResponse parent1 = commentService.createComment(post.publicId(), CommentCreateRequest.builder() - .content("부모 댓글 1") - .isAnonymous(false) - .build(), userDetails1, "127.0.0.1"); - - commentService.createComment(post.publicId(), CommentCreateRequest.builder() - .parentId(parent1.commentId()) - .content("자식 대댓글 1-1") - .isAnonymous(false) - .build(), userDetails1, "127.0.0.1"); - - PostCommentListResponse response = commentService.getCommentsByPost(post.publicId()); - - assertThat(response.totalCommentCount()).isEqualTo(2); - assertThat(response.comments()).hasSize(1); - assertThat(response.comments().get(0).children()).hasSize(1); - assertThat(response.comments().get(0).children().get(0).content()).isEqualTo("자식 대댓글 1-1"); - } - - @Test - @DisplayName("삭제된 부모 댓글은 본문이 '삭제된 댓글입니다.'로 표시된다.") - void getCommentsByPost_deletedParentDisplay() { - CommentResponse parent1 = commentService.createComment(post.publicId(), CommentCreateRequest.builder() - .content("지워질 부모 댓글") - .isAnonymous(false) - .build(), userDetails1, "127.0.0.1"); - - commentService.deleteComment(parent1.commentId(), null, userDetails1); - - PostCommentListResponse response = commentService.getCommentsByPost(post.publicId()); - - assertThat(response.comments().get(0).isDeleted()).isTrue(); - assertThat(response.comments().get(0).content()).isEqualTo("삭제된 댓글입니다."); - } - } -} -``` - ---- - -# 📌 PART 4. 작업 결과 및 검증 완료 요약 - -* **생성된 마스터 스터디 파일 경로**: - - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\studyCommunityPostCommentMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/studyCommunityPostCommentMaster260821.md) - - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studyCommunityPostCommentMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studyCommunityPostCommentMaster260821.md) - - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint02\studyCommunityPostCommentMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studyCommunityPostCommentMaster260821.md) -* **`.\gradlew.bat test` 실행 결과**: **BUILD SUCCESSFUL in 18s (모든 단위/통합 테스트 100% PASS)** -* **작업 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일에 수록 완료. diff --git a/docs/studyDomainErd260807.md b/docs/studyDomainErd260807.md deleted file mode 100644 index dd457ba..0000000 --- a/docs/studyDomainErd260807.md +++ /dev/null @@ -1,107 +0,0 @@ -# [Notion] Snowthing 도메인 모델 & ERD 종합 설계서 - -> 📌 **작성 일자**: 2026년 8월 7일 -> 🏷️ **문서 목적**: 노션(Notion)에 기록하여 핵심 도메인, ERD 스키마, DB 제약조건, 회원-게시글 관계를 한눈에 파악하기 위한 종합 가이드 - ---- - -## 1. 📌 핵심 도메인 목록 (Core Domains) - -Snowthing 플랫폼의 비즈니스 영역을 4개 도메인 경계(Bounded Context)로 분리하고, 1차 MVP 대상을 정립합니다. - -``` -┌────────────────────────────────────────────────────────────────────────┐ -│ Snowthing Platform │ -├───────────────────┬───────────────────┬────────────────┬───────────────┤ -│ 1. 회원/프로필 │ 2. 게시판/커뮤니티 │ 3. 리조트/현장 │ 4. 소셜/매칭 │ -│ (Member) │ (Community) │ (Resort) │ (Social) │ -└───────────────────┴───────────────────┴────────────────┴───────────────┘ -``` - -### 1.1. 회원 / 프로필 도메인 (`Member & Profile Domain`) [1차 MVP Core] -* **도메인 역할**: 유저 신원 인증 및 보더 라이딩 정체성(보더명함) 관리. -* **주요 객체**: - * `Member` (회원): 이메일, 비밀번호, 닉네임, 전역 권한(`ROLE_USER`), 계정 상태(`ACTIVE`). - * `Profile` (프로필): 프로필 이미지 URL, 자기소개(`bio`), 주 출발/거주 지역(`departure_region`). - * `BaseResort` (선호 스키장): 주 베이스 및 서브 베이스 스키장 다중 선택. - * `RidingStyle` (라이딩 성향): 카빙, 트릭, 파크, 입문, 관광 등 성향 다중 선택. - -### 1.2. 게시판 / 커뮤니티 도메인 (`Board & Community Domain`) [1차 MVP Core] -* **도메인 역할**: 유저 간 자유로운 정보 교류, 질의응답, 노하우 공유 및 댓글/추천 소통. -* **주요 객체**: - * `PostCategory` (카테고리): 자유, 익명, 질문, 장비VS, 맛집. - * `Post` (게시글): 제목, 본문, 조회수, 역정규화 카운트(댓글/추천/비추천), Soft Delete. - * `PostImage` (첨부 이미지): 1:N 이미지 업로드. - * `PostReaction` (게시글 반응): 추천(`LIKE`) / 비추천(`DISLIKE`) (1인 1회 제한). - * `Comment` (댓글/대댓글): 원댓글 및 계층형 대댓글 (`parentId`), Soft Delete. - -### 1.3. 리조트 / 현장 정보 도메인 (`Resort & Field Domain`) [2차 로드맵] -* **도메인 역할**: 전국 스키장 실시간 웹캠 및 AI 리프트 혼잡도 정보 통합 제공. - -### 1.4. 소셜 / 동행 매칭 도메인 (`Social & Matching Domain`) [3차 로드맵] -* **도메인 역할**: 1/N 카풀 비용 정산 및 1:1 같이타요 동행 매칭. - ---- - -## 2. 📊 ERD 초안 (Database ERD Draft) - -### 2.1. Mermaid ERD 다이어그램 -```mermaid -erDiagram - CREW ||--o{ MEMBER : "소속됨 (1:N)" - - MEMBER ||--o{ MEMBER_RESORT : "가지고 있음" - RESORT ||--o{ MEMBER_RESORT : "속해 있음" - - MEMBER ||--o{ MEMBER_RIDING_STYLE : "가지고 있음" - RIDING_STYLE ||--o{ MEMBER_RIDING_STYLE : "속해 있음" - - MEMBER ||--o{ POST : "작성함 (1:N)" - POST_CATEGORY ||--o{ POST : "포함함" - - POST ||--o{ POST_IMAGE : "첨부함 (1:N)" - POST ||--o{ POST_REACTION : "받음" - MEMBER ||--o{ POST_REACTION : "투표함" - - POST ||--o{ COMMENT : "달림 (1:N)" - MEMBER ||--o{ COMMENT : "작성함 (1:N)" - COMMENT ||--o{ COMMENT : "대댓글 (Self Reference)" -``` - -### 2.2. 엔티티 간 정규화 타협 및 관계 요약 (총 11개 테이블) -* **`member` 1 : N `post`**: 회원이 여러 게시글 작성. (비회원 작성 시 `member_id NULLABLE`) -* **`member` 1 : N `comment`**: 회원이 여러 댓글 작성. (비회원 작성 시 `member_id NULLABLE`) -* **`post` 1 : N `comment`**: 게시글 1개에 여러 댓글 작성. -* **`member` N : M `resort`**: `member_resort` 중계 테이블로 1:N, N:1 분리. -* **`member` N : M `riding_style`**: `member_riding_style` 중계 테이블로 1:N, N:1 분리. - ---- - -## 3. 🔒 주요 DB 제약조건 (Database Constraints & Rules) - -| 구분 | 제약조건 / 정책 | 설정 이유 및 방어 목적 | -| :--- | :--- | :--- | -| **PK 보안 전략** | DB 내부 `BIGINT id` + 외부 노출 `public_id (UUID)` | DB 조인 성능(8바이트 정수)과 외부 URL 크롤링/ID 추측 해킹 테러 100% 차단 | -| **중계 테이블 PK** | 단일 대리키 `id` (PK) + `UNIQUE (member_id, target_id)` | JPA 복합키(`@EmbeddedId`) 개발 지옥 탈출 및 중복 등록 방지 | -| **추천/비추천 제약** | `UNIQUE (post_id, member_id)` | 유저 1명당 게시글별 1회만 투표 중복 제한 | -| **소프트 삭제 (Soft Delete)** | `is_deleted = true` | 댓글 삭제 시 대댓글 흐름 유지 ("삭제된 댓글입니다" 표시) | -| **역정규화 컬럼** | `post.comment_count`, `like_count`, `dislike_count` | 게시글 목록 조회 시 매번 `SELECT COUNT(*)`로 인한 DB 과부하 차단 | -| **OAuth2 소셜 대비** | `member.password NULLABLE` | 구글/카카오 소셜 가입 시 비밀번호 부재로 인한 DB 에러 방지 | - ---- - -## 4. 🤝 게시글과 회원 관계 검토 (Post & Member Relationship) - -### 4.1. 회원-게시글 관계의 확장 (비회원 & 익명 작성 지원) -일반 커뮤니티는 `member_id`가 필수(NOT NULL)이지만, Snowthing은 **비회원 작성 및 100% 익명성 보장**을 위해 다음과 같이 수용합니다. - -1. **`member_id` (BIGINT, NULLABLE)** - * **로그인 유저 작성 시**: 세션의 `member_id` 저장 (내 프로필/작성글 관리 가능). - * **비회원 작성 시**: `member_id = NULL` 로 저장. -2. **`anonymous_password` (VARCHAR 255, NULLABLE)** - * 비회원이 글/댓글을 쓴 경우, 수정/삭제 시 본인 인증을 위해 입력한 익명 비밀번호를 BCrypt 해시로 저장. -3. **`writer_ip` (VARCHAR 45, NOT NULL)** - * 로그인 여부와 관계없이 악성 어그로, 비매너, 법적 추적을 위해 작성자 IP 필수 보관. - -### 4.2. 익명성 표기 규칙 -* `is_anonymous = true` 인 경우 닉네임을 완전 무시하고, **무조건 시스템 지정 텍스트 `익명 (123.456.***.***)`** 처럼 IP 마스킹 형태로 화면에 전원 일괄 표시됩니다. diff --git a/docs/studyPkStrategy260807.md b/docs/studyPkStrategy260807.md deleted file mode 100644 index 8e3ebfc..0000000 --- a/docs/studyPkStrategy260807.md +++ /dev/null @@ -1,133 +0,0 @@ -# [TIL] DB PK(기본키) 설계 전략 및 3가지 대참사 방지 딥다이브 - -> 📌 **학습 날짜**: 2026년 8월 7일 -> 🏷️ **키워드**: `Primary Key`, `AUTO_INCREMENT`, `UUID`, `TSID`, `대리키 (Surrogate Key)`, `복합키 (Composite Key)`, `보안 크롤링`, `JPA @EmbeddedId` -> 💡 **학습 목표**: DB PK(기본키)를 부실하게 설계했을 때 나중에 터지는 3가지 대참사를 이해하고, 실무에서 사용하는 4가지 PK 전략의 장단점과 대가를 익혀 서비스에 맞는 최적의 PK를 스스로 선택할 수 있도록 돕습니다. - ---- - -## 0. 대전제: "PK(기본키) 설계 하나가 서비스 전체의 생사를 가른다" - -데이터베이스의 **PK(Primary Key / 기본키)**는 단순한 순번 표기가 아닙니다. 데이터베이스 인덱싱(B-Tree)의 기준점이며, 애플리케이션 프레임워크(JPA/Hibernate) 연동의 핵심축이자, 외부 시스템과 소통하는 **식별 통로**입니다. - -초기에 PK 전략을 깊이 고민하지 않고 단순히 `1, 2, 3...` 증가하는 숫자만 써두면, 서비스가 성장했을 때 **보안 테러, 개발 코드 꼬임, DB 분산 불가능**이라는 3대 대참사를 맞이하게 됩니다. - ---- - -## 1. PK 설계를 대충 했을 때 나중에 터지는 3가지 대참사 - -### 💥 대참사 1. 보안 테러: "ID 추측을 통한 데이터 싹쓸이 (크롤링 & 권한 탈취)" - -#### 😱 무슨 일이 일어날까? -만약 여러분이 작성한 게시글 조회/삭제 API URL이 다음과 같다고 해봅시다: -```http -GET /api/posts/105 -DELETE /api/posts/105 -``` - -* **ID 추측 공격 (ID Enumeration Attack)**: 숫자가 1씩 규칙적으로 증가하는 것을 눈치채고, 악의적인 해커나 크롤링 스크립트가 `104`, `103`, `102`... 순서대로 숫자만 올려가며 전체 데이터를 1초 만에 싹 긁어갑니다. -* **비즈니스 기밀 유출**: 회원가입을 했는데 내 회원 ID가 `500`번인 것을 보고 경쟁사가 **"아, 이 서비스 총 가입자가 500명밖에 안 되네?"** 하고 회사 비즈니스 규모를 훤히 파악하게 됩니다. -* **권한 검증 허점 클릭**: 권한 검증 로직에 헛점이 생기면 숫자를 바꿔가며 남의 글이나 회원 정보를 무단 삭제/수정하는 대참사가 벌어집니다. - ---- - -### 💥 대참사 2. JPA 개발 지옥: "중계 테이블 복합 PK (`member_id` + `resort_id`)의 비극" - -#### 💡 용어 풀이 -> * **복합키 (Composite Key)**: 2개 이상의 컬럼을 합쳐서 하나의 PK로 사용하는 방식. (예: `member_id` + `resort_id`) -> * **대리키 (Surrogate Key)**: 비즈니스 의미가 없는 인공적인 고유 ID (예: `id` BIGINT AUTO_INCREMENT)를 새로 만들어서 PK로 쓰는 방식. - -#### 😱 무슨 일이 일어날까? -회원과 리조트의 N:M 중계 테이블(`member_resort`)에 `member_id`와 `resort_id` 두 개를 묶어서 복합 PK로 지정했을 때, 자바 JPA(Hibernate) 개발 시 다음과 같은 지옥이 펼쳐집니다: - -1. **지저분한 복합키 클래스 파편화**: JPA에서 복합키를 쓰려면 `@EmbeddedId` 또는 `@IdClass`라는 클래스를 별도로 만들어 `Serializable` 구현, `equals()`, `hashCode()` 메서드를 일일이 오버라이딩해야 합니다. -2. **개발 생산성 폭망**: 중계 데이터를 조회나 수정할 때마다 단일 숫자 ID 대신 복합키 객체를 매번 생성해서 넘겨야 하므로 **코드가 엄청나게 길어지고 오작동 에러가 속출**합니다. - ---- - -### 💥 대참사 3. DB 확장 불가능: "서버 여러 대(분산 DB)로 늘릴 때 PK 충돌" - -#### 😱 무슨 일이 일어날까? -서비스가 대박이 나서 DB 서버 1대로 감당이 안 되어 DB를 2대(A 서버, B 서버)로 분할(Sharding)하는 상황을 맞이했습니다. - -* A 서버의 DB도 `post_id = 1`을 생성하고, B 서버의 DB도 `post_id = 1`을 생성합니다. -* 두 DB의 데이터를 통합하거나 조인할 때 **PK 충돌 참사**가 터져서 데이터가 엉망진창으로 뒤죽박죽 섞이고 복구가 불가능해집니다. - ---- - -## 2. 실무에서 사용하는 4가지 PK 전략 및 장단점 비교 - -### 🛡️ 전략 A: AUTO_INCREMENT (내부 PK) + `public_id` UUID (외부 노출용) [실무 정석 ⭐] - -#### ⚙️ 작동 원리 -* **DB 내부 (PK)**: DB 조인 성능 및 저장 공간 최적화를 위해 **8바이트 숫자 `id` (BIGINT AUTO_INCREMENT)**를 PK로 사용합니다. -* **외부 노출 (Public ID)**: 외부 API나 URL에는 `posts/a3b2c1d4-5e6f-4a...` 같은 **36자리 UUID 문자열 (`public_id`)**을 따로 만들어서 보여줍니다. - -#### ⚖️ 장단점 & 대가 (Trade-off) -* **장점 (얻는 것)**: - * DB 내부 조인(JOIN) 및 B-Tree 인덱스 성능이 8바이트 정수라 **빛의 속도로 빠름.** - * 외부에 노출되는 ID는 추측 불가능한 UUID이므로 **해킹/크롤링/비즈니스 노출 위험 100% 차단.** -* **단점 & 대가 (치르는 것)**: - * `member` 및 `post` 테이블에 `public_id` 컬럼을 하나 더 만들어야 함. - * 외부 요청이 왔을 때 `public_id`로 DB를 조회하는 쿼리가 한 번 더 들어감. - ---- - -### 🛡️ 전략 B: 36자리 UUID (Universally Unique Identifier) 문자열 PK - -#### ⚙️ 작동 원리 -* `d3b07384-d113-46e4-a719-d640b728040d` 같은 36자리 무작위 난수 문자열을 DB PK로 아예 직접 사용합니다. - -#### ⚖️ 장단점 & 대가 (Trade-off) -* **장점 (얻는 것)**: - * 세상에서 유일한 고유값이므로 ID 추측 불가능. - * DB가 여러 대(분산 DB)여도 PK 충돌 위험 0%. -* 💥 **치명적 단점 (치르는 대가)**: - * **DB 성능 저하 (인덱스 파편화)**: UUID는 무작위 문자열이라 DB B-Tree 인덱스에 저장될 때 순서 없이 무작위 위치로 쑤셔 지므로, DB 저장 속도가 둔화되고 메모리를 4배 이상 더 먹습니다. - ---- - -### 🛡️ 전략 C: TSID (Time-Sorted Unique Identifier) / ULID [최신 트렌드 🚀] - -#### ⚙️ 작동 원리 -* **"현재 시간(Timestamp)" + "무작위 난수"**를 조합하여 만드는 64비트 정수(BIGINT) 형태의 고유 ID. - -#### ⚖️ 장단점 & 대가 (Trade-off) -* **장점 (얻는 것)**: - * 숫자가 1씩 증가하지 않아 **해커가 절대 추측 불가능**. - * 생성된 시간 순서대로 정렬(Sortable)되므로 DB 인덱스 성능이 BIGINT 정수처럼 **극상으로 빠름**. - * 분산 DB 환경에서도 PK 충돌 제로. -* **단점 & 대가 (치르는 것)**: - * 자바 라이브러리(TSID Creator 등)를 프로젝트에 별도로 추가하여 ID 생성 로직을 적용해야 함. - ---- - -### 🛡️ 전략 D: [중계 테이블 전용] 단일 대리키(`id`) + `UNIQUE KEY` 조합 - -#### ⚙️ 작동 원리 -* 중계 테이블(`member_resort`, `member_riding_style`)에 `member_id` + `resort_id` 복합 PK를 쓰지 않습니다! -* 대신 **`id` (BIGINT AUTO_INCREMENT)** 단일 대리키를 PK로 새로 만들어주고, `(member_id, resort_id)`에는 **`UNIQUE KEY` 제약조건**을 겁니다. - -#### ⚖️ 장단점 & 대가 (Trade-off) -* **장점 (얻는 것)**: - * JPA 복합키 지옥(`@EmbeddedId`)에서 완전히 벗어나 **자바 코드가 10배 깔끔해짐**. - * 중복 저장 방지 제약조건(`UNIQUE`)은 DB 레벨에서 완벽하게 유지됨. -* **단점 & 대가 (치르는 것)**: - * 중계 테이블에 `id`라는 8바이트 컬럼이 하나 더 생겨 용량을 아주 미세하게 더 먹음. - ---- - -## 🎯 3. 최종 요약 및 비교표 - -| PK 설계 전략 | 보안성 | DB 조회/인덱스 성능 | JPA 개발 편의성 | 분산 DB 확장성 | -| :--- | :--- | :--- | :--- | :--- | -| **1. 순수 AUTO_INCREMENT** | ❌ 취약 (추측 가능) | 🟢 극상 | 🟢 나쁨 (복합키 시) | ❌ 충돌 발생 | -| **2. AUTO_INCREMENT + Public ID(UUID)** | 🟢 완벽 | 🟢 극상 | 🟢 양호 | 🟡 보통 | -| **3. 순수 UUID 문자열 PK** | 🟢 완벽 | 🔴 느림 (인덱스 파편화) | 🟢 양호 | 🟢 완벽 | -| **4. TSID (시간정렬 정수)** | 🟢 완벽 | 🟢 극상 | 🟢 최고 | 🟢 완벽 | -| **5. 중계 테이블 단일 대리키(`id`)** | - | 🟢 극상 | 🟢 최고 (JPA 지옥 해방) | - | - ---- - -이제 각 PK 방식이 가진 **장점과 치명적 대가(Trade-off)**를 비교하실 수 있습니다! -이 학습 문서를 읽어보시고, 우리 Snowthing 서비스에 어떤 PK 방식을 적용하는 것이 가장 좋을지 천천히 고민해 보세요! 😊 diff --git a/docs/studySessionAuth260806.md b/docs/studySessionAuth260806.md deleted file mode 100644 index 66c4615..0000000 --- a/docs/studySessionAuth260806.md +++ /dev/null @@ -1,67 +0,0 @@ -# [TIL] 세션 기반 인증/인가 정책 및 쿠키 보안 설계 - -> 📌 **학습 날짜**: 2026년 8월 6일 -> 🏷️ **키워드**: `Session Authentication`, `Cookie Security`, `JSESSIONID`, `Session Fixation`, `Redis`, `Technical Debt` -> 💡 **한 줄 요약**: 1차 MVP 서비스에 적용할 세션 인증 및 쿠키 보안 정책을 정립하고, 기술적 트레이드오프와 향후 Redis 분산 세션 확장 방향을 정리합니다. - ---- - -## 1. 세션 인증 정책 (Session Authentication Policy) - -| 정책 항목 | 결정 사항 | 상세 설명 및 설계 배경 | -| :--- | :--- | :--- | -| **로그인 시 세션 저장 정보** | `memberId`, `role` | 최소 식별 정보만 메모리에 보관. 비밀번호, 이메일, 프로필 전체 저장 지양 (메모리 절약 및 보안) | -| **세션 만료 시간** | `30분` (Idle Timeout) | 비활동 시간 30분 기준 세션 만료. 보안성과 유저 이용 편의성의 최적 타협점 | -| **로그아웃 처리** | `session.invalidate()` | 백엔드 세션 객체 즉시 파기 및 클라이언트 쿠키 만료 (`Max-Age=0`) 처리 | -| **동시 로그인 정책** | 동시 접속 허용 | 1차 MVP에서는 동일 계정 다중 기기 접속 허용 (향후 중복 로그인 제어 고려) | -| **세션 ID 재발급 시점** | 로그인 성공 시 | 로그인 성공 시점에 `changeSessionId()`를 호출하여 **세션 고정 공격(Session Fixation)** 방어 | -| **인증 필요 API 구분** | 비인증 / 인증 API 분리 | **비인증**: 목록/상세 조회, 회원가입, 로그인
**인증 필요**: 게시글/댓글 작성·수정·삭제, 프로필 조회 | -| **권한 검증 방식** | 작성자(소유자) 검증 | 수정/삭제 요청 시 `세션 memberId == DB 작성자 memberId` 비교 검증 후 처리 | - ---- - -## 2. 쿠키 보안 정책 (Cookie Security Policy) - -세션 식별자(`JSESSIONID`)를 전달하는 쿠키에 엄격한 보안 속성을 적용하여 주요 Web Vulnerability를 방어합니다. - -``` -Set-Cookie: JSESSIONID=A1B2C3D4E5F6...; Path=/; HttpOnly; Secure; SameSite=Lax -``` - -* 🛡️ **HttpOnly (`true`)** - * 자바스크립트(`document.cookie`)를 통한 세션 쿠키 접근을 원천 차단합니다. - * **방어 목적**: XSS(Cross-Site Scripting) 공격으로 인한 세션 키 탈취 방지. -* 🔒 **Secure (`true`)** - * 암호화된 `HTTPS` 통신 채널에서만 쿠키가 전송되도록 제한합니다. (로컬 개발 환경에서는 조건부 적용) - * **방어 목적**: 네트워크 패킷 스니핑을 통한 세션 키 유출 방지. -* 🌐 **SameSite (`Lax`)** - * 타 사이트에서 발생하는 서드파티 요청 시 쿠키 전송을 제한하되, 일반적인 서프(링크 클릭) 이동 시에는 쿠키를 전송합니다. - * **방어 목적**: CSRF(Cross-Site Request Forgery) 공격 방어. -* 📍 **Path (`/`)** - * 웹 애플리케이션의 모든 API 경로(`/api/...`)에서 쿠키가 정상적으로 전송되도록 전역 설정합니다. -* ⏳ **Max-Age 및 세션 쿠키 정책** - * 별도의 Max-Age를 지정하지 않는 **세션 쿠키(Session Cookie)** 방식을 기본으로 채택합니다. - * 브라우저가 종료되면 클라이언트 단에서 세션 쿠키가 자동 파기됩니다. -* ⚠️ **개인정보 쿠키 저장 절대 금지** - * 쿠키에는 오직 서버 세션을 가리키는 무작위 난수 식별자(`JSESSIONID`)만 저장합니다. - * 비밀번호, 이메일, 닉네임, 개인정보 등은 **절대로 쿠키에 담지 않습니다.** - ---- - -## 3. 기술적 깊이 및 배경 (Deep Dive & Trade-offs) - -### 3.1. JWT 대신 세션(Session) 방식을 선택한 이유 -* **세션 제어의 용이성**: JWT는 Stateless 특성상 이미 발급된 토큰을 즉시 강제 만료(블랙리스트 관리 없이)시키기 어렵습니다. 반면 세션은 보안 이슈나 로그아웃 시 서버에서 `session.invalidate()`로 즉시 파기할 수 있습니다. -* **보안성**: 토큰을 로컬스토리지에 저장할 경우 XSS에 노출되기 쉽고, 쿠키에 담더라도 JWT 크기(헤더+페이로드+서명)로 인한 네트워크 오버헤드가 발생합니다. 세션 방식은 오직 난수 형태의 키만 쿠키로 전송하므로 유출 리스크가 적습니다. - -### 3.2. 세션 저장 정보의 최소화 기준 -* 세션 메모리(RAM)는 서버의 한정된 자원입니다. 세션 객체에 유저 전체 프로필이나 대용량 객체를 담으면 유저 수가 늘어날 때 **OutOfMemoryError(OOM)**가 발생할 수 있습니다. -* 따라서 오직 PK값인 `memberId`와 권한 정보 `role`만 담고, 필요한 유저 상세 정보는 DB 조회를 통해 가져오도록 최소화 기준을 수립했습니다. - -### 3.3. 다중 서버 환경에서의 개선 방향 (Scale-out 대비) -* **현 상태의 한계**: 현재 구현은 단일 톰캣 서버의 **인메모리(In-Memory) 세션**을 사용합니다. -* **개선 방향**: 서버가 여러 대(Scale-out)로 증설되면, 유저 요청이 다른 서버로 로드밸런싱될 때 세션이 유실되는 문제가 발생합니다. 이를 위해 향후 **Spring Session + Redis** 기반의 **중앙 집중식 분산 세션 저장소**를 도입하여 상태를 공유하도록 개선할 예정입니다. - -### 3.4. 의도적으로 남긴 기술 부채 (Technical Debt) -* 1차 MVP 단계에서는 백엔드 개발 속도 및 도메인 핵심 검증을 위해 **단일 서버 톰캣 메모리 세션**을 채택했습니다. -* 세션 유실 및 Scale-out 한계라는 기술 부채가 존재함을 인지하고 있으며, 2차 확장 시 **Redis 분산 세션 연동**으로 매끄럽게 전환될 수 있도록 스프링 세션 Abstraction Layer를 고려하여 설계했습니다. diff --git a/docs/studySessionFlow260810.md b/docs/studySessionFlow260810.md deleted file mode 100644 index c828dfc..0000000 --- a/docs/studySessionFlow260810.md +++ /dev/null @@ -1,83 +0,0 @@ -# [Notion] 세션 인증 순서도 (Session Authentication Flowchart) - -> 📌 **작성 일자**: 2026년 8월 10일 (최종 수정: 2026년 8월 12일) -> 🏷️ **문서 목적**: 마크다운(Markdown) 및 노션(Notion) 환경에서 100% 정상 visual 다이어그램으로 렌더링되는 **Spring Session Redis** 기반 세션 인증 순서도 - ---- - -## 📊 1. 세션 인증 전체 플로우차트 (Flowchart TD) - -Below is the Mermaid flowchart rendered directly in GitHub Markdown and Notion. - -```mermaid -flowchart TD - A["클라이언트 API 요청 /api/..."] --> B{"1. 비인증 허용 API인가? (PermitAll)"} - - %% 비인증 허용 경로 - B -- Yes --> C["비인증 로직 즉시 실행 (회원가입/목록조회/비회원글작성)"] - C --> END1["200 OK / 201 Created 응답"] - - %% 인증 필요 경로 - B -- No --> D{"2. JSESSIONID 쿠키가 헤더에 존재하는가?"} - - %% 쿠키 없음 - D -- No --> E1["401 Unauthorized 에러 (로그인이 필요합니다)"] - - %% 쿠키 존재 - D -- Yes --> E2{"3. Spring Session Redis 저장소에 유효한 세션이 존재하는가?"} - - %% 세션 만료/파기됨 - E2 -- No --> F1["401 Unauthorized 에러 (세션이 만료되었습니다)"] - F1 --> F2["Set-Cookie: JSESSIONID=; Max-Age=0 (쿠키 클리어)"] - - %% 세션 유효함 - E2 -- Yes --> G{"4. 요청 종류 구분"} - - %% 경로 1: 로그인 요청 처리 (Session Fixation 방어) - G -- 로그인 요청 --> H1["Security: changeSessionId (기존 세션 파기 & 신규 세션ID 재발급)"] - H1 --> H2["Spring Session Redis에 memberId & role 0.0001초 저장"] - H2 --> H3["Set-Cookie: JSESSIONID=new_id (HttpOnly, Secure, SameSite=Lax)"] - H3 --> END2["200 OK 로그인 성공"] - - %% 경로 2: 일반 인증 API 요청 (프로필/글작성 등) - G -- 일반 인증 API --> I1["Spring Session Redis에서 memberId & role 0.0001초 추출"] - I1 --> I2["SecurityContextHolder에 인증 객체 등록"] - I2 --> I3["소유자 권한 검증 & 비즈니스 로직 실행"] - I3 --> END3["200 OK 응답 데이터 반환"] - - %% 경로 3: 로그아웃 요청 - G -- 로그아웃 요청 --> J1["Redis 세션 완전 파기 (spring:session 키 삭제)"] - J1 --> J2["Set-Cookie: JSESSIONID=; Max-Age=0 (브라우저 쿠키 즉시 만료)"] - J2 --> END4["200 OK 로그아웃 성공"] - - %% 스타일링 - style A fill:#333333,stroke:#ffffff,color:#ffffff - style B fill:#1f618d,stroke:#ffffff,color:#ffffff - style D fill:#1f618d,stroke:#ffffff,color:#ffffff - style E2 fill:#1f618d,stroke:#ffffff,color:#ffffff - style G fill:#28b463,stroke:#ffffff,color:#ffffff - style E1 fill:#922b21,stroke:#ffffff,color:#ffffff - style F1 fill:#922b21,stroke:#ffffff,color:#ffffff - style H1 fill:#d4ac0d,stroke:#ffffff,color:#000000 - style J1 fill:#d4ac0d,stroke:#ffffff,color:#000000 -``` - ---- - -## 🔍 2. 플로우차트 단계별 조건 분기 설명 - -### 1단계: API 접근 권한 판별 (`PermitAll` vs `Authenticated`) -* `/api/members` (회원가입), `/api/auth/login` (로그인), `/api/posts` (목록/상세 조회) 같은 **비인증 공개 API는 쿠키 검증을 스킵하고 즉시 실행**됩니다. - -### 2단계 & 3단계: 2중 세션 쿠키 검증 (`JSESSIONID` + Spring Session Redis) -* **1차 검증 (브라우저 쿠키)**: 요청 헤더에 `JSESSIONID` 쿠키가 아예 없으면 즉시 `401 Unauthorized`를 반환합니다. -* **2차 검증 (Spring Session Redis RAM)**: 쿠키가 있더라도 Redis 세션 저장소(`spring:session:sessions:...`)에서 만료되거나 삭제되었으면 `401 Unauthorized` 반환과 함께 브라우저 쿠키를 만료(`Max-Age=0`)시킵니다. - -### 4단계: 요청 종류별 세션 락 & 처리 메커니즘 -1. **로그인 시 (Session Fixation 방어)**: - * 기존 임시 세션 ID를 파기하고 신규 세션 ID를 발급하는 `request.changeSessionId()`를 호출하여 세션 탈취 공격을 방어합니다. - * `Set-Cookie: JSESSIONID=...; Path=/api; HttpOnly; Secure; SameSite=Lax` 쿠키를 내려줍니다. -2. **일반 인증 API 요청 시**: - * Redis에서 0.0001초 만에 `memberId`를 추출하여 `SecurityContextHolder`에 등록 후 비즈니스 로직을 수행합니다. -3. **로그아웃 시**: - * Redis 세션 키를 삭제하여 서버 세션을 완전 파기하고, 쿠키 만료 헤더를 내려줍니다. diff --git a/docs/studySystemArch260810.md b/docs/studySystemArch260810.md deleted file mode 100644 index 0fa58bd..0000000 --- a/docs/studySystemArch260810.md +++ /dev/null @@ -1,87 +0,0 @@ -# [Notion] Snowthing 전체 시스템 아키텍처 구성도 (TIL) - -> 📌 **작성 일자**: 2026년 8월 10일 -> 🏷️ **문서 목적**: 노션(Notion) 및 마크다운에 복사하여 전체 시스템 구성도, 계층별 역할, 데이터 흐름을 한눈에 파악하고 공부하기 위한 종합 가이드 - ---- - -## 📊 1. 시스템 구성도 다이어그램 (Mermaid System Architecture) - -Below is the system architecture diagram rendered directly in GitHub Markdown and Notion. - -```mermaid -flowchart TB - %% 클라이언트 레이어 - subgraph Client_Layer [👤 Client / Browser Layer] - Browser[🌐 Web Browser / Mobile Client] - end - - %% 프론트엔드 레이어 - subgraph Frontend_Layer [💻 Frontend Layer] - NextJS[⚡ Next.js 14+ Application
App Router / React Query / Optimistic UI] - end - - %% 네트워크 & 프록시 레이어 - subgraph Proxy_Layer [🛡️ Network & Proxy Layer] - Nginx[🔒 Nginx Reverse Proxy
SSL/HTTPS Termination
Route /api/* -> Spring Boot] - end - - %% 백엔드 애플리케이션 레이어 - subgraph Backend_Layer [⚙️ Backend Application Layer - Spring Boot 3.2+ / Java 21] - SecFilter[🔒 Spring Security Filter
JSESSIONID / HttpOnly / changeSessionId] - - - subgraph Core_App [App Core Services] - MemberSvc[👤 Member Service
Profile & Crew Management] - PostSvc[📝 Post Service
Single Query + In-Memory Tree] - CommentSvc[💬 Comment Service
Single Query + Flat List] - ReactionSvc[👍 Reaction Service
Async Event Publisher] - end - - BatchJob[⏰ Scheduled Batch Sync Job
10s Write-Behind DB Flush] - end - - %% 인메모리 캐시 & 분산 락 레이어 - subgraph Cache_Layer [🚀 In-Memory Cache & Buffer Layer] - Redis[(🧠 Redis 7.x In-Memory
SADD Voters / INCR Like Counter
AOF Persistence)] - end - - %% 릴레이셔널 데이터베이스 레이어 - subgraph Database_Layer [🗄️ Relational Database Layer] - MySQL[(🐬 MySQL 8.0 Container
InnoDB Engine / 11 Tables
BIGINT id + UUID v7 Secondary Index)] - end - - %% 데이터 흐름 연결 - Browser <-->|HTTP/HTTPS Page Request| NextJS - Browser <-->|HTTPS API Request / Cookies| Nginx - NextJS <-->|Server Component Fetch| Nginx - - Nginx <-->|Forward /api/*| SecFilter - SecFilter <-->|Session Check / Context| Core_App - - ReactionSvc -->|0.0001s In-Memory INCR & SADD| Redis - BatchJob <-->|Read Counter & Flush| Redis - - Core_App <-->|JPA / JPQL / Single Query| MySQL - BatchJob -->|Write-Behind Batch UPDATE| MySQL - - %% 스타일링 - style Browser fill:#333,stroke:#fff,color:#fff - style NextJS fill:#000,stroke:#61dafb,color:#61dafb - style Nginx fill:#009639,stroke:#fff,color:#fff - style SecFilter fill:#d4ac0d,stroke:#fff,color:#000 - style Core_App fill:#2e4053,stroke:#fff,color:#fff - style Redis fill:#dc382d,stroke:#fff,color:#fff - style MySQL fill:#00758f,stroke:#fff,color:#fff - style BatchJob fill:#8e44ad,stroke:#fff,color:#fff -``` - ---- - -## 🔍 2. 5대 계층 구조 및 핵심 기술 요약 - -1. **`Client & Frontend`**: Next.js 14+, React Query의 **낙관적 UI 업데이트**를 통해 추천 0.001초 미친 반응성 제공. -2. **`Proxy & Network`**: Nginx를 통해 `/api/*` 라우팅 분리 및 **6대 쿠키 보안 속성** 적용. -3. **`Backend Core`**: Spring Boot 3.x, **`Single Query + In-Memory Tree`**로 N+1 문제 완파, 10초 주기 **`Write-Behind` 배치 스케줄러** 구동. -4. **`In-Memory Cache`**: Redis 7.x `INCR`/`SADD`로 DB 락 병목 제거, AOF로 데이터 유실 방지. -5. **`Relational Database`**: MySQL 8.0 (11개 테이블), `BIGINT id` + **`UUID v7` 세컨더리 인덱스** 적용. diff --git a/docs/study_sprint01_session_concurrency_jpa_260817.md b/docs/study_sprint01_session_concurrency_jpa_260817.md deleted file mode 100644 index 65fc06c..0000000 --- a/docs/study_sprint01_session_concurrency_jpa_260817.md +++ /dev/null @@ -1,539 +0,0 @@ -# 📚 [Snowthing Study Report] Sprint 1 세션 인증, 동시성 락 실증, JPA & DB 제약조건 통합 공부 가이드 - -> **본 문서는 노션(Notion)에 그대로 복사하여 학습할 수 있도록 작성된 종합 공부용 문서입니다.** -> **파일명**: `docs/study_sprint01_session_concurrency_jpa_260817.md` -> **작성일**: 2026년 8월 17일 -> **주요 키워드**: `Spring Session`, `changeSessionId`, `BCrypt`, `N:M 중계 테이블`, `CountDownLatch 동시성 락 실증`, `JPA JOIN FETCH`, `DB UNIQUE 제약조건`, `@Modifying(clearAutomatically = true)` - ---- - -# 📑 **목차 (Table of Contents)** - -1. [Part 1: 핵심 기술 선택의 이유, 비교군, Trade-Off & 극복 방안](#part-1-핵심-기술-선택의-이유-비교군-trade-off--극복-방안) - - 1.1 인증 방식: Spring Session (세션) vs JWT (JSON Web Token) - - 1.2 세션 고정 방어: `changeSessionId()` vs `newSession()` vs `none()` - - 1.3 비밀번호 암호화: `BCrypt` vs `Argon2` vs `PBKDF2` vs `SHA-256` - - 1.4 N:M 관계 매핑: 대리키 `id` PK 중계 엔티티 vs 복합키(`@EmbeddedId`) vs 단일 JSON 저장 - - 1.5 N+1 성능 극복: `JOIN FETCH` vs `FetchType.EAGER` vs `@BatchSize` -2. [Part 2: 동시성 제어(Concurrency Lock) & 비정상 파라미터 실증 테스트 레포트](#part-2-동시성-제어concurrency-lock--비정상-파라미터-실증-테스트-레포트) - - 2.1 왜 Mockito 가 아닌 실제 DB + 멀티스레드로 동시성을 테스트했는가? - - 2.2 `CountDownLatch` + `ExecutorService` 10개 멀티스레드 동시 가입 물리적 원리 - - 2.3 Race Condition 락 검증 결과 분석 (Java 차단 실패 ➔ DB UNIQUE 인덱스 차단 성공) - - 2.4 경계값 & 비정상 파라미터 2중 검증 테스트 결과 -3. [Part 3: 완성 코드 & JPA 옵션 - DB 제약조건 연동 종합 해설서](#part-3-완성-코드--jpa-옵션---db-제약조건-연동-종합-해설서) - - 3.1 엔티티 & N:M 중계 구조 (`Member.java`, `MemberResort.java`, `Resort.java`) 주석 해설 - - 3.2 JPA 주요 어노테이션 & 옵션 상세 파헤치기 - - 3.3 DB 제약조건(`PK`, `UNIQUE`, `FK`)과 JPA 연관관계의 물리적 연동 원리 - - 3.4 전체 실증 테스트 수트 통합 모음 (25개 전체 통합/단위 테스트 수트) - ---- - -# 🧠 **[Part 1] 핵심 기술 선택의 이유, 비교군, Trade-Off & 극복 방안** - -## 1.1 인증 방식: Spring Session (세션) vs JWT (JSON Web Token) - -```text - [Spring Session (서버 중앙 제어)] [JWT (Stateless 무상태)] - - 세션 데이터: 서버 RAM/Redis 보관 - 토큰 데이터: 클라이언트 브라우저 보관 - - 보안성: 무작위 식별자(JSESSIONID) - 보안성: 서명된 Claim 데이터 포함 - - 강제 로그아웃: 가능 (서버 세션 삭제) - 강제 로그아웃: 불가능 (만료 시까지 유효) -``` - -### 🎯 선택: Spring Session (세션 기반 인증) -* **비교군**: JWT (JSON Web Token) -* **선택 이유**: - * 커뮤니티 플랫폼 특성상 특정 악성 유저 발생 시 **관리자가 즉시 계정을 정지시키고 접속 세션을 강제 파기(Session Invalidation)**할 수 있는 **서버 중앙 통제권**이 필수적임. - * JWT는 클라이언트가 토큰을 보관하므로, 서버에서 특정 유저를 즉시 차단(Blacklist)하기 어렵고 별도의 Redis Blacklist 저장소를 둬야 하는 아키텍처 복잡성이 생김. -* **장점**: - * 클라이언트 브라우저에는 민감 정보(PII)가 들어있지 않은 무작위 세션 키(`JSESSIONID`)만 쿠키로 전달됨. - * 세션 상태를 서버가 100% 제어하므로 즉시 로그아웃 및 강제 세션 파기가 가능함. -* **치러야 하는 대가 (Trade-off)**: - * 서버 메모리(RAM) 사용량 증가 및 서버를 여러 대 증설(Scale-Out)할 때 세션 불일치(Session Discrepancy) 문제 발생. -* **아키텍처 레벨 극복 방안**: - 1. `server.servlet.session.timeout=30m` 30분 세션 타임아웃을 설정하여 30분간 활동이 없는 세션은 톰캣 백그라운드 스레드가 GC로 메모리를 자동 정리하도록 구성. - 2. 서버 확장 시 DB/서버 RAM이 아닌 인메모리 세션 서버인 **Spring Session Redis** 로 전환 가능하도록 `@EnableRedisHttpSession` 아키텍처 확장 레이어를 설계함. - ---- - -## 1.2 세션 고정 방어: `changeSessionId()` vs `newSession()` vs `none()` - -### 🎯 선택: `request.changeSessionId()` -* **비교군**: `newSession()`, `none()` (세션 유지) -* **선택 이유**: - * **세션 고정 공격 (Session Fixation Attack)**: 해커가 미리 자신이 발급받은 세션 ID를 피해자 유저의 쿠키에 심어두고, 피해자가 해당 세션으로 로그인하면 해커가 피해자의 계정 권한을 그대로 훔쳐 쓰는 공격. -* **동작 원리 및 비교**: - * `none()`: 로그인 후에도 세션 ID를 바꾸지 않음 ➔ 세션 고정 공격에 노출. - * `newSession()`: 기존 세션의 모든 속성을 삭제하고 아예 새 세션을 만듦 ➔ 로그인 전 유저가 설정해 둔 스키장/라이딩 성향 검색 필터 세션 데이터까지 모두 소실됨. - * **`changeSessionId()` (선택)**: 세션 객체 내부의 속성 데이터(로그인 전 선택한 스키장/성향 검색 필터 상태 등)는 그대로 유지하면서, **외부에 노출된 `JSESSIONID` 세션 식별자만 암호학적 난수로 교체**함. -* **Trade-off & 극복**: - * 기존 세션 Map 에서 식별자 키를 갱신하는 메모리 참조 교체 연산 비용 발생 ➔ 톰캣 및 Spring Security 6 세션 매니저의 인메모리 HashMap 키 교체 처리로 연산 오버헤드를 극복함. - ---- - -## 1.3 비밀번호 암호화: `BCryptPasswordEncoder` vs `Argon2` vs `PBKDF2` vs `SHA-256` - -### 🎯 선택: `BCryptPasswordEncoder` -* **비교군**: `SHA-256` (단순 해시), `PBKDF2`, `Argon2` -* **선택 이유**: - * `SHA-256` 같은 단순 단방향 해시는 GPU 병렬 연산을 이용한 **레인보우 테이블(Rainbow Table) 공격**으로 빠르게 복호화가 시도될 수 있음. - * BCrypt는 비밀번호 암호화 시 **솔트(Salt)**를 매번 무작위로 생성하여 저장하며, **Work Factor (Key Stretching Cost)**를 적용하여 무차별 대입(Brute-Force) 연산 속도를 물리적으로 지연시킴. -* **Argon2 / PBKDF2 대비 장점**: - * Argon2 가 최신 표준이나 추가 외부 라이브러리 연동이 필요함. Spring Security 의 표준 검증 모듈인 BCrypt가 유지보수성 측면에서 검증됨. -* **Trade-off & 극복**: - * BCrypt 해싱 연산은 의도적으로 CPU 연산 리소스를 소모함 (1건당 수십ms 소요). - * 회원가입/로그인 시점에만 한정되어 발생하므로 전체 서비스 응답 성능에 영향을 주지 않음. - ---- - -## 1.4 N:M 관계 매핑: 대리키 `BIGINT id` PK 중계 엔티티 vs 복합키(`@EmbeddedId`) vs 단일 JSON 저장 - -### 🎯 선택: 대리키 `BIGINT id` PK + 복합 UNIQUE 제약조건 중계 엔티티 (`MemberResort`, `MemberRidingStyle`) -* **비교군**: - 1. 회원 테이블 내 문자열/JSON 컬럼에 `[1, 2, 3]` 형태로 저장 - 2. `@EmbeddedId` (member_id + resort_id) 복합키 식별 관계 중계 엔티티 -* **선택 이유 & 물리적 비교**: - * **JSON 저장 방식의 한계**: 데이터베이스 정규화 1NF(원자성) 위반. 특정 스키장을 이용하는 회원 목록 검색(`WHERE resort_id = 1`) 시 Full Table Scan 이 발생하여 성능 저하. - * **복합키(`@EmbeddedId`)의 한계**: JPA 복합키 클래스(`MemberResortId`)를 별도로 작성해야 하며, `EqualsAndHashCode` 재정의 필수 및 부모 엔티티 조인 시 식별자 객체 생성 비용 발생. - * **대리키 `id` PK 방식 (선택)**: 8바이트 정수 `id` AUTO_INCREMENT 에 독립 PK를 두고, `UNIQUE (member_id, resort_id)` 복합 유니크 제약을 걸어 정규화 3NF 준수 + JPA 엔티티 조작 편의성을 확보함. - ---- - -## 1.5 N+1 성능 극복: `JOIN FETCH` vs `FetchType.EAGER` vs `@BatchSize` - -### 🎯 선택: JPQL `JOIN FETCH` 쿼리 -* **비교군**: `@ManyToOne(fetch = FetchType.EAGER)` (즉시 로딩), `@BatchSize` -* **선택 이유**: - * `FetchType.EAGER` (즉시 로딩) 적용 시, 다른 API 조회 시에도 원치 않는 조인이 무조건 발생하여 메모리 낭비 및 예측 불가능한 N+1 쿼리가 발생함. - * 엔티티 연관관계는 `FetchType.LAZY` (지연 로딩)로 차단해두고, **N:M 목록 조회가 필요한 프로필 API 레포지토리 메서드에만 `JOIN FETCH` JPQL 을 지정**하여 1번의 SQL INNER JOIN 쿼리로 조회함. - ---- - -# 🔬 **[Part 2] 동시성 제어(Concurrency Lock) & 비정상 파라미터 실증 테스트 레포트** - -## 2.1 왜 Mockito 가 아닌 실제 DB + 멀티스레드로 동시성을 테스트했는가? - -> ⚠️ **실무 원리**: -> Mockito 는 객체의 동작을 가짜(Mock)로 흉내 내는 단위 테스트 도구입니다. **동시성 락(Concurrency Lock)과 Race Condition(경합 상태)은 실제 데이터베이스의 트랜잭션 격리 수준(Isolation Level), 커넥션 풀, DB UNIQUE 인덱스 락 동작에서만 발생**합니다. -> 따라서 Mock 객체로는 동시성 락을 테스트할 수 없으며, **실제 Spring Context + 물리 H2 DB + 멀티스레드 환경(`ExecutorService`)** 으로 테스트를 진행했습니다. - ---- - -## 2.2 `CountDownLatch` + `ExecutorService` 10개 멀티스레드 동시 가입 물리적 원리 - -```text -[10개 멀티스레드 준비] ──► countDownLatch.await() 대기 ──► (startLatch 신호) ──► 스레드 동시 요청 시작! - │ - ┌──────────────────────────────────────────────────────────────────────────────────┘ - ▼ -[스레드 1] ──► existsByEmail() = false ──► [DB Insert 시도] ──► 성공! (1건) -[스레드 2] ──► existsByEmail() = false ──► [DB Insert 시도] ──► UNIQUE KEY 위반 예외! (DataIntegrityViolationException) -... -[스레드 10]──► existsByEmail() = false ──► [DB Insert 시도] ──► UNIQUE KEY 위반 예외! (DataIntegrityViolationException) -``` - -* **`ExecutorService`**: 10개의 OS 스레드 풀을 생성합니다. -* **`CountDownLatch readyLatch = new CountDownLatch(10)`**: 10개 스레드가 모두 준비를 마칠 때까지 대기시킵니다. -* **`CountDownLatch startLatch = new CountDownLatch(1)`**: `startLatch.countDown()` 이 호출되는 순간 10개의 스레드가 동시에 `MemberService.signUp()` 을 호출합니다. - ---- - -## 2.3 Race Condition 락 검증 결과 분석 - -### 🧪 실증 결과 요약 -* **동시 요청 수**: 10개 스레드 동시 동일 이메일(`concurrent@snowthing.com`) 회원가입 시도 -* **Java 코어 검사 (`existsByEmail`) 결과**: 10개 스레드가 동시 실행되면서 race condition 으로 인해 **10개 스레드 모두 Java 검사를 `false` 로 통과해 버림** (어플리케이션 단 차단 실패). -* **DB UNIQUE 인덱스 + `saveAndFlush()` 방어선 결과**: - * DB Engine 수준에서 `email UNIQUE INDEX` 락이 발생. - * **정확히 1개의 스레드만 DB 저장 성공!** - * **나머지 9개의 스레드는 DB `DataIntegrityViolationException` 발생 ➔ `MemberService` 캐치에서 예외 차단!** -* **DB 최종 상태**: **DB에 저장된 동일 이메일 회원 레코드는 정확히 1건.** - ---- - -## 2.4 경계값 & 비정상 파라미터 2중 검증 테스트 결과 - -| 검증 항목 | 입력 파라미터 예시 | 백엔드/프론트엔드 반응 | 결과 | -| :--- | :--- | :--- | :---: | -| **비정상 이메일 TLD** | `test@naver.co` | 정규식 `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$` 에 의해 **400 Bad Request** 차단 | **성공** | -| **대문자 미포함 비밀번호** | `password123!` | 복잡도 정규식 대문자`[A-Z]` 미달 ➔ **400 Bad Request** 차단 | **성공** | -| **특수문자 미포함 비밀번호** | `Password1234` | 복잡도 정규식 특수문자 미달 ➔ **400 Bad Request** 차단 | **성공** | -| **8자 미만 비밀번호** | `Pass1!` | 최소 8자 미달 ➔ **400 Bad Request** 차단 | **성공** | -| **닉네임 경계값 (1자)** | `홍` | 2자~10자 미달 ➔ **400 Bad Request** 차단 | **성공** | -| **닉네임 경계값 (11자)** | `휘닉스파크카빙왕짱1` | 10자 초과 ➔ **400 Bad Request** 차단 | **성공** | - ---- - -# 💻 **[Part 3] 완성 코드 & JPA 옵션 - DB 제약조건 연동 종합 해설서** - -## 3.1 엔티티 & N:M 중계 구조 (`Member.java`, `MemberResort.java`, `Resort.java`) - -```java -// ========================================== -// 1. Member.java (회원 엔티티) -// ========================================== -package com.ikae.snowthing.domain.member.entity; - -import com.ikae.snowthing.global.common.BaseTimeEntity; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -@Entity // [JPA] 이 클래스가 데이터베이스 테이블과 1대1 매핑되는 ORM 엔티티임을 선언 -@Table( - name = "member", // [DB] 실제 RDBMS 데이터베이스의 테이블명을 'member' 로 지정 - uniqueConstraints = { - // [DB 제약조건] 이메일, 닉네임, public_id 에 각각 단일 UNIQUE 인덱스 생성 - @UniqueConstraint(name = "uk_member_email", columnNames = {"email"}), - @UniqueConstraint(name = "uk_member_nickname", columnNames = {"nickname"}), - @UniqueConstraint(name = "uk_member_public_id", columnNames = {"public_id"}) - } -) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) // [JPA/Lombok] 기본 생성자를 protected 로 설정하여 외부 무분별한 new 객체 생성 차단 -public class Member extends BaseTimeEntity { - - @Id // [DB/JPA] 이 필드가 기본키(Primary Key, PK)임을 지정 - @GeneratedValue(strategy = GenerationType.IDENTITY) // [DB] MySQL/H2 의 AUTO_INCREMENT 대리키 채번 전략 사용 (DB에 PK 생성을 위임) - @Column(name = "member_id") // [DB] 실제 DB 컬럼명을 member_id 로 매핑 - private Long id; - - @Column(name = "public_id", nullable = false, unique = true, length = 36) - private String publicId; // 외부 URL/API 노출용 UUID v7 - - @Column(name = "email", nullable = false, unique = true, length = 100) - private String email; // 회원 로그인 이메일 계정 - - @Column(name = "password", length = 255) // BCrypt 60자 해시 문자열 저장용 - private String password; - - @Column(name = "nickname", nullable = false, unique = true, length = 50) - private String nickname; // 유저 활동 닉네임 - - @Column(name = "profile_image_url", length = 500) - private String profileImageUrl; - - @Column(name = "bio", length = 255) - private String bio; - - @Column(name = "departure_region", length = 100) - private String departureRegion; - - @Enumerated(EnumType.STRING) // [JPA] Enum 상수의 '이름 문자열(ROLE_USER)' 자체를 DB에 저장 - @Column(name = "role", nullable = false, length = 20) - private Role role; - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 20) - private MemberStatus status; - - @PrePersist // [JPA Callback] 엔티티가 DB에 INSERT 되기 직전에 실행되는 영속성 라이프사이클 콜백 함수 - public void prePersist() { - if (this.publicId == null) { - this.publicId = UUID.randomUUID().toString(); - } - if (this.role == null) { - this.role = Role.ROLE_USER; - } - if (this.status == null) { - this.status = MemberStatus.ACTIVE; - } - } - - @Builder - public Member(String publicId, String email, String password, String nickname, - String profileImageUrl, String bio, String departureRegion, - Long crewId, String crewRole, Role role, MemberStatus status) { - this.publicId = publicId; - this.email = email; - this.password = password; - this.nickname = nickname; - this.profileImageUrl = profileImageUrl; - this.bio = bio; - this.departureRegion = departureRegion; - this.role = role != null ? role : Role.ROLE_USER; - this.status = status != null ? status : MemberStatus.ACTIVE; - } - - // 프로필 정보 수정 비즈니스 메서드 (Dirty Checking 활용) - public void updateProfile(String nickname, String bio, String departureRegion, String profileImageUrl) { - this.nickname = nickname; - this.bio = bio; - this.departureRegion = departureRegion; - this.profileImageUrl = profileImageUrl; - } -} -``` - -```java -// ========================================== -// 2. MemberResort.java (회원-스키장 N:M 중계 엔티티) -// ========================================== -package com.ikae.snowthing.domain.member.entity; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table( - name = "member_resort", - uniqueConstraints = { - // [DB 제약조건] 동일 회원이 동일 스키장을 중복 선택하지 못하도록 (member_id + resort_id) 복합 UNIQUE 인덱스 부여 - @UniqueConstraint(name = "uk_member_resort", columnNames = {"member_id", "resort_id"}) - } -) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class MemberResort { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) // 대리키 id PK - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) // [JPA 옵션] 지연 로딩 적용. MemberResort 만 조회 시 Member 엔티티는 프록시로 유지 - @JoinColumn(name = "member_id", nullable = false) // [DB FK] member 테이블의 member_id 를 참조하는 외래키 생성 - private Member member; - - @ManyToOne(fetch = FetchType.LAZY) // [JPA 옵션] 지연 로딩 적용 - @JoinColumn(name = "resort_id", nullable = false) // [DB FK] resort 테이블의 resort_id 를 참조하는 외래키 생성 - private Resort resort; - - @Builder - public MemberResort(Member member, Resort resort) { - this.member = member; - this.resort = resort; - } -} -``` - ---- - -## 3.2 JPA 주요 어노테이션 & 옵션 상세 파헤치기 - -1. **`GenerationType.IDENTITY`**: - * **물리적 동작**: DB의 `AUTO_INCREMENT` 기능에 PK 생성을 위임합니다. - * **특징**: JPA 영속성 컨텍스트(1차 캐시)에 엔티티를 등록하려면 식별자(PK)가 필요하므로, `em.persist()` 나 `save()` 호출 시 **트랜잭션 커밋 전이라도 DB에 즉시 SQL INSERT 가 실행**되어 PK를 채번해옵니다. -2. **`EnumType.STRING`**: - * **필수 이유**: 디폴트값인 `EnumType.ORDINAL` 은 Enum 의 순서 숫자(`0, 1, 2`)를 DB에 저장합니다. 추후 Enum 에 새로운 값을 추가하거나 순서를 변경하면 기존 DB의 숫자가 엉키게 됩니다. 따라서 반드시 `STRING` 을 명시해야 합니다. -3. **`FetchType.LAZY` vs `JOIN FETCH`**: - * `FetchType.LAZY` 는 객체 참조 시점까지 DB 조회를 미루는 지연 로딩입니다. - * JPQL `JOIN FETCH` 는 `LAZY` 로 설정된 연관 엔티티를 **SQL 1번의 JOIN 구문으로 영속성 컨텍스트에 로딩**하여 N+1 쿼리를 방지합니다. -4. **`@Modifying(clearAutomatically = true, flushAutomatically = true)`**: - * JPQL 로 Bulk DELETE 쿼리를 실행할 때, JPA 영속성 컨텍스트의 쓰기 지연 버퍼(Write-Behind Buffer)로 인해 DELETE 보다 신규 INSERT 가 먼저 실행되는 순서 꼬임 현상을 방지합니다. `flushAutomatically = true` 가 쓰기 버퍼를 먼저 DB로 보내고, `clearAutomatically = true` 가 1차 캐시를 비워 DB와 메모리 격차를 완전히 차단합니다. -5. **`saveAndFlush()`**: - * 일반 `save()` 는 트랜잭션 커밋 시점까지 SQL 구문을 쓰기 지연 버퍼(Write-Behind Buffer)에 보관합니다. - * `saveAndFlush()` 는 **호출 즉시 DB로 SQL INSERT 를 전송(`flush`)** 하여, DB 수준의 UNIQUE KEY 제약조건 위반 예외(`DataIntegrityViolationException`)를 트랜잭션 블록 내에서 감지할 수 있게 해줍니다. - ---- - -## 3.3 DB 제약조건(`PK`, `UNIQUE`, `FK`)과 JPA 연관관계의 물리적 연동 원리 - -```text - [member 테이블] [member_resort 테이블] [resort 테이블] -┌─────────────────┐ ┌───────────────────────┐ ┌──────────────────┐ -│ PK: member_id │◄─── FK ────│ FK: member_id │ │ PK: resort_id │ -│ UNIQUE: email │ │ FK: resort_id ────────┼──── FK ───►│ UNIQUE: name │ -│ UNIQUE: nickname│ │ UNIQUE(member, resort)│ └──────────────────┘ -└─────────────────┘ └───────────────────────┘ -``` - -1. **`PRIMARY KEY (PK)`**: - * DB 테이블 내 각 행(Row)을 유일하게 식별하는 물리적 클러스터드 인덱스(Clustered Index). JPA의 `@Id` 와 1대1 대응. -2. **`UNIQUE KEY (유니크 인덱스)`**: - * 특정 컬럼(또는 컬럼 조합)의 값이 중복되는 것을 DB 엔진 수준에서 거부. - * **동시성 락 방어선의 핵심**: 애플리케이션 Java 코드(`existsByEmail`)가 Race Condition 으로 뚫리더라도, DB 유니크 인덱스가 물리적 락(Lock)을 걸어 중복 INSERT 를 차단함. -3. **`FOREIGN KEY (FK)`**: - * 참조 무결성 제약조건. `member_resort` 의 `member_id` 에 존재하지 않는 회원의 ID 가 들어오려고 하면 DB 엔진이 쿼리를 거부함. JPA 의 `@JoinColumn` 과 매핑됨. - ---- - -## 3.4 전체 실증 테스트 수트 통합 모음 (25개 전체 통과) - -### ① 내 프로필 수정 & N:M 중계 갱신 통합 테스트 ([MemberProfileUpdateIntegrationTest.java](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberProfileUpdateIntegrationTest.java)) - -```java -package com.ikae.snowthing.domain.member.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ikae.snowthing.domain.auth.dto.MemberLoginRequest; -import com.ikae.snowthing.domain.member.dto.MemberProfileUpdateRequest; -import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; -import com.ikae.snowthing.domain.member.entity.Resort; -import com.ikae.snowthing.domain.member.entity.RidingStyle; -import com.ikae.snowthing.domain.member.repository.MemberRepository; -import com.ikae.snowthing.domain.member.repository.MemberResortRepository; -import com.ikae.snowthing.domain.member.repository.MemberRidingStyleRepository; -import com.ikae.snowthing.domain.member.repository.ResortRepository; -import com.ikae.snowthing.domain.member.repository.RidingStyleRepository; -import com.ikae.snowthing.domain.member.service.MemberService; -import org.junit.jupiter.api.AfterEach; -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.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockHttpSession; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; - -import java.util.List; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -class MemberProfileUpdateIntegrationTest { - - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - @Autowired private MemberService memberService; - @Autowired private MemberRepository memberRepository; - @Autowired private MemberResortRepository memberResortRepository; - @Autowired private MemberRidingStyleRepository memberRidingStyleRepository; - @Autowired private ResortRepository resortRepository; - @Autowired private RidingStyleRepository ridingStyleRepository; - - private Long resortId1; - private Long resortId2; - private Long styleId1; - private Long styleId2; - - @BeforeEach - void setUp() { - cleanUp(); - - Resort r1 = resortRepository.findByName("휘닉스파크").orElseGet(() -> resortRepository.save(Resort.builder().name("휘닉스파크").regionName("강원 평창").build())); - Resort r2 = resortRepository.findByName("하이원리조트").orElseGet(() -> resortRepository.save(Resort.builder().name("하이원리조트").regionName("강원 정선").build())); - resortId1 = r1.getId(); - resortId2 = r2.getId(); - - RidingStyle s1 = ridingStyleRepository.findByStyleName("올라운드").orElseGet(() -> ridingStyleRepository.save(RidingStyle.builder().styleName("올라운드").description("올라운드").build())); - RidingStyle s2 = ridingStyleRepository.findByStyleName("그라운드 트릭").orElseGet(() -> ridingStyleRepository.save(RidingStyle.builder().styleName("그라운드 트릭").description("그라운드 트릭").build())); - styleId1 = s1.getId(); - styleId2 = s2.getId(); - - MemberSignUpRequest signUpRequest = MemberSignUpRequest.builder() - .email("profileupdate@snowthing.com") - .password("Password123!") - .nickname("수정전닉네임") - .bio("수정전소개") - .departureRegion("서울") - .resortIds(List.of(resortId1)) - .ridingStyleIds(List.of(styleId1)) - .build(); - memberService.signUp(signUpRequest); - } - - @AfterEach - void tearDown() { - cleanUp(); - } - - private void cleanUp() { - memberResortRepository.deleteAll(); - memberRidingStyleRepository.deleteAll(); - memberRepository.deleteAll(); - } - - @Test - @DisplayName("[프로필 수정 통합 테스트] 로그인한 유저가 PUT /api/members/me 로 닉네임과 N:M 스키장/성향을 변경 시 DB 중계 데이터가 갱신되고 조회가 반영되어야 한다") - void updateMyProfile_Success_UpdatesProfileAndMiddleTables() throws Exception { - MemberLoginRequest loginRequest = MemberLoginRequest.builder() - .email("profileupdate@snowthing.com") - .password("Password123!") - .build(); - - MvcResult loginResult = mockMvc.perform(post("/api/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(loginRequest))) - .andExpect(status().isOk()) - .andReturn(); - - MockHttpSession session = (MockHttpSession) loginResult.getRequest().getSession(false); - - MemberProfileUpdateRequest updateRequest = MemberProfileUpdateRequest.builder() - .nickname("수정후닉네임") - .bio("수정후소개입니다") - .departureRegion("경기 이천") - .resortIds(List.of(resortId1, resortId2)) - .ridingStyleIds(List.of(styleId1, styleId2)) - .build(); - - mockMvc.perform(put("/api/members/me") - .session(session) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(updateRequest))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.nickname").value("수정후닉네임")) - .andExpect(jsonPath("$.resortNames.length()").value(2)) - .andExpect(jsonPath("$.ridingStyleNames.length()").value(2)); - - mockMvc.perform(get("/api/members/me").session(session)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.nickname").value("수정후닉네임")) - .andExpect(jsonPath("$.resortNames[0]").value("휘닉스파크")) - .andExpect(jsonPath("$.resortNames[1]").value("하이원리조트")); - } -} -``` - -### ② 마스터 데이터 API 통합 테스트 ([MasterDataControllerTest.java](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MasterDataControllerTest.java)) - -```java -package com.ikae.snowthing.domain.member.controller; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.web.servlet.MockMvc; - -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; - -@SpringBootTest -@AutoConfigureMockMvc -class MasterDataControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Test - @DisplayName("[마스터 데이터 API 통합 테스트] GET /api/resorts 호출 시 6대 스키장 마스터 목록이 비회원에게도 반환되어야 한다") - void getResorts_Returns6Resorts_PermitAll() throws Exception { - mockMvc.perform(get("/api/resorts")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(6)) - .andExpect(jsonPath("$[0].name").value("휘닉스파크")) - .andExpect(jsonPath("$[1].name").value("하이원리조트")); - } - - @Test - @DisplayName("[마스터 데이터 API 통합 테스트] GET /api/riding-styles 호출 시 올라운드를 포함한 6대 라이딩 성향 마스터 목록이 반환되어야 한다") - void getRidingStyles_Returns6Styles_PermitAll() throws Exception { - mockMvc.perform(get("/api/riding-styles")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(6)) - .andExpect(jsonPath("$[0].styleName").value("올라운드")); - } -} -``` - ---- - -### 📝 **결론** -통합 테스트 수트까지 **총 25개 테스트 수트 100% 그린(Green) 통과**를 완벽하게 정돈하고 학습 문서에도 추가해 두었습니다. diff --git a/frontend/app/components/DeleteConfirmModal.tsx b/frontend/app/components/DeleteConfirmModal.tsx index 628e348..5447944 100644 --- a/frontend/app/components/DeleteConfirmModal.tsx +++ b/frontend/app/components/DeleteConfirmModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; interface DeleteConfirmModalProps { isOpen: boolean; @@ -9,6 +9,8 @@ interface DeleteConfirmModalProps { title?: string; description?: string; requirePassword?: boolean; + confirmLabel?: string; + submittingLabel?: string; } export function DeleteConfirmModal({ @@ -18,7 +20,11 @@ export function DeleteConfirmModal({ title = "게시글 삭제 확인", description = "이 게시글을 삭제하시겠습니까?", requirePassword = true, + confirmLabel = "삭제", + submittingLabel = "삭제 중...", }: DeleteConfirmModalProps) { + const titleId = useId(); + const passwordId = useId(); const [password, setPassword] = useState(""); const [errorMsg, setErrorMsg] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -49,6 +55,7 @@ export function DeleteConfirmModal({ }; const handleClose = () => { + if (submitting) return; setPassword(""); setErrorMsg(""); onClose(); @@ -56,11 +63,19 @@ export function DeleteConfirmModal({ return (
-
+
-

{title}

+

{title}

diff --git a/frontend/app/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..f4b2637 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -40,17 +40,43 @@ 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; +} + +interface CommentUpdateResponse { + commentId: number; + content: string; + updatedAt: string; } export default function PostDetailPage({ params }: { params: Promise<{ publicId: string }> }) { @@ -59,6 +85,10 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const [post, setPost] = useState(null); const [comments, setComments] = useState([]); const [totalCommentCount, setTotalCommentCount] = useState(0); + const [commentNextCursor, setCommentNextCursor] = useState(null); + const [hasNextComments, setHasNextComments] = useState(false); + const [isLoadingMoreComments, setIsLoadingMoreComments] = useState(false); + const [replyPagingByRootId, setReplyPagingByRootId] = useState>({}); const [loading, setLoading] = useState(true); const [errorMsg, setErrorMsg] = useState(""); const [reactionMsg, setReactionMsg] = useState(""); @@ -66,8 +96,18 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const [commentAnonPassword, setCommentAnonPassword] = useState(""); const [submittingComment, setSubmittingComment] = useState(false); const [activeReplyParentId, setActiveReplyParentId] = useState(null); + const [replyMentionName, setReplyMentionName] = useState(null); const [replyText, setReplyText] = useState(""); const [replyAnonPassword, setReplyAnonPassword] = useState(""); + const [activeEditCommentId, setActiveEditCommentId] = useState(null); + const [editCommentText, setEditCommentText] = useState(""); + const [editCommentPassword, setEditCommentPassword] = useState(""); + const [editCommentError, setEditCommentError] = useState(""); + const [submittingEditComment, setSubmittingEditComment] = useState(false); + const [activeDeleteCommentId, setActiveDeleteCommentId] = useState(null); + const [deleteCommentPassword, setDeleteCommentPassword] = useState(""); + const [deleteCommentError, setDeleteCommentError] = useState(""); + const [submittingDeleteComment, setSubmittingDeleteComment] = useState(false); const [currentUserPublicId, setCurrentUserPublicId] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -119,19 +159,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 +362,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; } @@ -266,34 +404,115 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; - const handleDeleteComment = async (commentId: number, isAnonymousWriter: boolean) => { - let anonymousPassword = ""; - if (isAnonymousWriter) { - const input = prompt("익명 댓글 삭제 비밀번호를 입력하세요."); - if (!input) return; - anonymousPassword = input; - } else if (!confirm("댓글을 삭제하시겠습니까?")) { + const handleStartEditComment = (comment: CommentItem) => { + setActiveReplyParentId(null); + setReplyMentionName(null); + setActiveEditCommentId(comment.commentId); + setEditCommentText(comment.content); + setEditCommentPassword(""); + setEditCommentError(""); + }; + + const handleCancelEditComment = () => { + if (submittingEditComment) return; + setActiveEditCommentId(null); + setEditCommentText(""); + setEditCommentPassword(""); + setEditCommentError(""); + }; + + const handleUpdateComment = async (comment: CommentItem) => { + const content = editCommentText.trim(); + const requiresPassword = comment.isAnonymous && !currentUserPublicId; + if (!content) { + setEditCommentError("댓글 내용을 입력해주세요."); + return; + } + if (content.length > 1000) { + setEditCommentError("댓글은 1,000자 이하로 입력해주세요."); + return; + } + if (requiresPassword && !editCommentPassword.trim()) { + setEditCommentError("익명 댓글 비밀번호를 입력해주세요."); + return; + } + if (content === comment.content) { + setEditCommentError("변경된 내용이 없습니다."); return; } + setSubmittingEditComment(true); + setEditCommentError(""); try { - const res = await csrfFetch(API_ENDPOINTS.comments.delete(commentId), { - method: "DELETE", + const res = await csrfFetch(API_ENDPOINTS.comments.delete(comment.commentId), { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - anonymousPassword: anonymousPassword || null, + content, + anonymousPassword: editCommentPassword.trim() || null, }), }); - if (res.ok) { - await fetchComments(); - setPost((current) => (current ? { ...current, commentCount: Math.max(0, current.commentCount - 1) } : current)); - return; + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "댓글 수정에 실패했습니다."); } - const errorData = await res.json(); - alert(`삭제 실패: ${errorData.message || "요청을 처리하지 못했습니다."}`); - } catch { - alert("서버 통신 중 오류가 발생했습니다."); + const updated: CommentUpdateResponse = await res.json(); + setComments((current) => updateCommentContent(current, updated.commentId, updated.content)); + setActiveEditCommentId(null); + setEditCommentText(""); + setEditCommentPassword(""); + setEditCommentError(""); + } catch (error) { + setEditCommentError(error instanceof Error ? error.message : "서버 통신 중 오류가 발생했습니다."); + } finally { + setSubmittingEditComment(false); + } + }; + + const handleStartDeleteComment = (commentId: number) => { + setActiveDeleteCommentId(commentId); + setDeleteCommentPassword(""); + setDeleteCommentError(""); + }; + + const handleCancelDeleteComment = () => { + setActiveDeleteCommentId(null); + setDeleteCommentPassword(""); + setDeleteCommentError(""); + }; + + const handleConfirmDeleteComment = async (comment: CommentItem) => { + const isOwnerMember = !comment.isAnonymous && currentUserPublicId && comment.writer?.publicId === currentUserPublicId; + const isOwnerAnonMember = comment.isAnonymous && currentUserPublicId && comment.writer?.publicId === currentUserPublicId; + const requiresPassword = !isAdmin && !isOwnerMember && !isOwnerAnonMember; + + if (requiresPassword && !deleteCommentPassword.trim()) { + setDeleteCommentError("비밀번호를 입력해주세요."); + return; + } + + setSubmittingDeleteComment(true); + setDeleteCommentError(""); + try { + const res = await csrfFetch(API_ENDPOINTS.comments.delete(comment.commentId), { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ anonymousPassword: deleteCommentPassword.trim() || null }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "댓글 삭제에 실패했습니다."); + } + + handleCancelDeleteComment(); + await fetchComments(); + setPost((current) => (current ? { ...current, commentCount: Math.max(0, current.commentCount - 1) } : current)); + } catch (error) { + setDeleteCommentError(error instanceof Error ? error.message : "서버 통신 중 오류가 발생했습니다."); + } finally { + setSubmittingDeleteComment(false); } }; @@ -445,16 +664,49 @@ 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} + activeEditCommentId={activeEditCommentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} + activeDeleteCommentId={activeDeleteCommentId} + deleteCommentPassword={deleteCommentPassword} + setDeleteCommentPassword={setDeleteCommentPassword} + deleteCommentError={deleteCommentError} + submittingDeleteComment={submittingDeleteComment} + handleStartDeleteComment={handleStartDeleteComment} + handleCancelDeleteComment={handleCancelDeleteComment} + handleConfirmDeleteComment={handleConfirmDeleteComment} + isAdmin={isAdmin} + handleLoadMoreReplies={handleLoadMoreReplies} + isLoadingReplies={Boolean(replyPagingByRootId[comment.commentId]?.loading)} /> )) )}
+ + {hasNextComments && ( + + )}
@@ -474,53 +726,189 @@ 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, + activeEditCommentId, + editCommentText, + setEditCommentText, + editCommentPassword, + setEditCommentPassword, + editCommentError, + submittingEditComment, + handleStartEditComment, + handleCancelEditComment, + handleUpdateComment, + activeDeleteCommentId, + deleteCommentPassword, + setDeleteCommentPassword, + deleteCommentError, + submittingDeleteComment, + handleStartDeleteComment, + handleCancelDeleteComment, + handleConfirmDeleteComment, + isAdmin, + handleLoadMoreReplies, + isLoadingReplies, }: { item: CommentItem; - depth?: number; isAnonymousPost: boolean; currentUserPublicId: string | null; activeReplyParentId: number | null; setActiveReplyParentId: (id: number | null) => void; + replyMentionName: string | null; + setReplyMentionName: (name: string | null) => void; replyText: string; setReplyText: (text: string) => void; replyAnonPassword: string; setReplyAnonPassword: (value: string) => void; handleCreateComment: (parentId: number | null) => Promise; - handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; + activeEditCommentId: number | null; + editCommentText: string; + setEditCommentText: (text: string) => void; + editCommentPassword: string; + setEditCommentPassword: (password: string) => void; + editCommentError: string; + submittingEditComment: boolean; + handleStartEditComment: (comment: CommentItem) => void; + handleCancelEditComment: () => void; + handleUpdateComment: (comment: CommentItem) => Promise; + activeDeleteCommentId: number | null; + deleteCommentPassword: string; + setDeleteCommentPassword: (password: string) => void; + deleteCommentError: string; + submittingDeleteComment: boolean; + handleStartDeleteComment: (commentId: number) => void; + handleCancelDeleteComment: () => void; + handleConfirmDeleteComment: (comment: CommentItem) => Promise; + isAdmin: boolean; + handleLoadMoreReplies: (rootCommentId: number) => Promise; + isLoadingReplies: boolean; }) { + const canEdit = canEditComment(item, currentUserPublicId); + const canDelete = canDeleteComment(item, currentUserPublicId, isAdmin); + const isEditing = activeEditCommentId === item.commentId; + const openReplyEditor = (target: CommentItem) => { + if (activeReplyParentId === item.commentId && replyMentionName === getWriterName(target)) { + setActiveReplyParentId(null); + setReplyMentionName(null); + return; + } + setActiveReplyParentId(item.commentId); + setReplyMentionName(getWriterName(target)); + }; + return ( -
0 ? "ml-5 border-l-2 border-black pl-5" : ""}`}> +
- {item.writerName} - {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + {getWriterName(item)} +
+ {formatCommentDate(item.createdAt)} + {canDelete && ( + handleStartDeleteComment(item.commentId)} + onClose={handleCancelDeleteComment} + password={deleteCommentPassword} + setPassword={setDeleteCommentPassword} + error={activeDeleteCommentId === item.commentId ? deleteCommentError : ""} + submitting={submittingDeleteComment} + onConfirm={() => void handleConfirmDeleteComment(item)} + /> + )} +
-

{item.content}

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

{item.content}

+
+ + {canEdit && ( + + )} +
+ + )} - {!item.isDeleted && ( + {item.previewReplies.length > 0 && ( +
+ {item.previewReplies.map((reply) => ( + openReplyEditor(reply)} + isEditing={activeEditCommentId === reply.commentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} + currentUserPublicId={currentUserPublicId} + activeDeleteCommentId={activeDeleteCommentId} + deleteCommentPassword={deleteCommentPassword} + setDeleteCommentPassword={setDeleteCommentPassword} + deleteCommentError={deleteCommentError} + submittingDeleteComment={submittingDeleteComment} + handleStartDeleteComment={handleStartDeleteComment} + handleCancelDeleteComment={handleCancelDeleteComment} + handleConfirmDeleteComment={handleConfirmDeleteComment} + isAdmin={isAdmin} + /> + ))} +
+ )} + + {item.hasMoreReplies && (
- -
)} {activeReplyParentId === item.commentId && (
+ {replyMentionName && ( +

@{replyMentionName} 님에게 답글

+ )}