diff --git a/README.md b/README.md index 2489ab9..f0b30a2 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ sequenceDiagram Server->>Session: request.changeSessionId() 호출! (세션 식별자 교체) Session-->>Server: 신규 32자리 JSESSIONID 발급 (기존 스키장/성향 검색 필터 세션 데이터 유지) - Server-->>Client: Set-Cookie: JSESSIONID=A1B2...; Path=/; HttpOnly; SameSite=Lax + Server-->>Client: 신규 세션 쿠키 발급 (JSESSIONID, HttpOnly, SameSite=Lax) Client-->>User: 로그인 성공 (메인 프로필 대시보드 전환) Note over User, Server: 4. 인증된 API 요청 (프로필 조회/수정) @@ -69,12 +69,12 @@ sequenceDiagram Client->>Server: POST /api/auth/logout Server->>Server: 1) ThreadLocal.clearContext() 청소 Server->>Session: 2) session.invalidate() 톰캣 세션 파기 - Server-->>Client: 3) Set-Cookie: JSESSIONID=; Max-Age=0 (쿠키 즉시 만료) + Server-->>Client: 3) JSESSIONID 쿠키 만료 응답 (Max-Age=0) ``` --- -### 핵심 아키텍처 고민 및 기술적 의사결정 +### 아키텍처 고민 및 기술적 의사결정 #### 1. 공통 엔티티와 JPA Auditing (`@EnableJpaAuditing`) 도입 @@ -111,7 +111,7 @@ sequenceDiagram --- -## 4. 게시판(Post) 도메인 설계 & 핵심 기술적 의사결정 (Board Architecture & Decisions) +## 4. 게시판(Post) 도메인 설계 & 기술적 의사결정 (Board Architecture & Decisions) 게시판은 Snowthing에서 가장 자주 읽히는 도메인이다. 그래서 단순 CRUD로만 만들지 않고, 목록 조회 비용, 익명 글 권한, 삭제 정책, 이미지 첨부 상태까지 같이 맞춰서 설계했다. @@ -268,13 +268,187 @@ Page findByCategoryCodeWithMemberAndCategory(@Param("categoryCode") String - `INVALID_PAGE_LIMIT (400)`: offset page 제한을 넘긴 요청 ### 11) CSRF + 게시글 생성, 수정, 삭제 같은 CUD 요청은 CSRF 공격 표적이 되기 쉽다. Spring Security의 `CookieCsrfTokenRepository.withHttpOnlyFalse()`를 적용했다. 이 방식은 Double Submit Cookie 패턴으로 동작한다. 백엔드가 `XSRF-TOKEN` 쿠키를 발급하면, 프론트엔드가 자원 변경 요청(POST, PUT, DELETE)을 보낼 때 쿠키 값을 읽어 `X-XSRF-TOKEN` HTTP 헤더에 담아서 보낸다. 서버의 `CsrfFilter`는 쿠키의 토큰 값과 헤더의 토큰 값이 일치하는지 비교하여 검증한다. 외부 해킹 사이트는 동일 출처 정책(SOP) 제약으로 인해 사용자의 `XSRF-TOKEN` 쿠키를 자바스크립트로 읽을 수 없어 `X-XSRF-TOKEN` 헤더를 생성하지 못하므로 위조된 요청은 403 Forbidden으로 차단된다. + +--- + +## 5. 댓글(Comment) 도메인 설계 & 기술적 의사결정 + +댓글은 게시글 상세 화면에서 가장 자주 읽히는 데이터다. 그래서 단순히 `post_id`로 전체 댓글을 가져오는 방식 대신, 루트 댓글과 대댓글을 나누고 초기 응답 크기를 제한하는 구조로 설계했다. + +자세한 후보 비교와 실행계획은 [ADR-001 댓글 아키텍처](docs/conception/sprint03/ADR-001-댓글아키텍처.md), [댓글 API 명세](docs/conception/sprint03/comment_api_spec.md), [기술부채 해결 기록](docs/conception/sprint03/기술부채%20해결_4.md)에 정리했다. + +### 1) 댓글 도메인 구조 + +댓글 엔티티는 `Comment` 하나로 둔다. 별도의 대댓글 `Reply` 엔티티를 만들지 않고, 하나의 `comment` 테이블에서 `parent_id`로 루트 댓글과 대댓글을 표현한다. + +- 루트 댓글: `parent_id = null` +- 대댓글: `parent_id = 루트 댓글 ID` +- 대댓글의 대댓글: 서버에서 최상위 루트 댓글 ID로 평탄화 + +무한 계층을 허용하지 않은 이유는 화면과 쿼리 비용 때문이다. 댓글 깊이가 3단계 이상으로 늘어나면 모바일 화면에서 들여쓰기와 접힘 처리가 복잡해지고, DB 조회도 재귀 구조나 별도 계층 테이블을 고민해야 한다. + +현재의 프로젝트에서는 댓글과 대댓글 2단계면 대화 흐름을 표현하기에 충분하다고 판단했다. + +### 2) 게시글과 댓글의 관계 + +게시글과 댓글은 `Post 1 : N Comment` 관계. 댓글은 반드시 하나의 게시글에 속하고, 게시글은 여러 댓글을 가질 수 있다. + +```text +Post + └─ Comment(parent_id = null) + └─ Comment(parent_id = root_comment_id) +``` + +`post.comment_count`는 매번 댓글 테이블을 `COUNT(*)` 하지 않기 위한 역정규화 필드. + +댓글 생성과 삭제 시 같은 트랜잭션에서 증감시켜 목록 화면에서 댓글 수를 빠르게 보여준다. + +이 선택은 읽기 성능을 얻는 대신, 댓글 저장/삭제 실패와 카운트 갱신 실패의 경계를 반드시 같은 트랜잭션 안에 묶어야 하는 트레이드오프가 있다. + +### 3) 댓글 상태와 유형 + +댓글 상태는 크게 정상 댓글과 Soft Delete 댓글로 나뉜다. + +- 정상 댓글: 목록과 상세 화면에 그대로 노출된다. +- 삭제된 댓글: DB row는 남기고 `is_deleted = true`, `deleted_at`을 기록한다. + +작성 유형은 세 가지다. + +- 로그인 일반 댓글: 회원 ID를 남기고 닉네임 표시 +- 로그인 익명 댓글: 회원 ID는 서버에 남기되 화면에서는 익명 표시 +- 비로그인 익명 댓글: 작성 IP와 익명 비밀번호 해시로 삭제 권한을 검증한다. + +삭제된 루트 댓글은 활성 대댓글 유무에 따라 다르게 처리한다. + +```text +삭제된 루트댓글 + 활성 대댓글 없음 -> 목록에서 숨김 +삭제된 루트댓글 + 활성 대댓글 있음 -> 루트 댓글은 "삭제된 댓글입니다."로 표시하고 활성 대댓글은 그대로 표시 +``` + +### 4) 댓글 조회 페이지네이션 방식 + +댓글 조회는 cursor pagination을 사용한다. + +```http +GET /api/v1/posts/{publicId}/comments?cursor={commentId}&size=20 +GET /api/v1/comments/{commentId}/replies?cursor={commentId}&size=20 +``` + +게시글 댓글 목록은 루트 댓글 20개를 먼저 조회하고, 각 루트 댓글의 대댓글은 5개까지만 같이 보여준다. 대댓글이 5개를 넘으면 사용자가 더보기를 눌렀을 때 대댓글 전용 API로 20개씩 추가 조회한다. + +정렬 기준은 루트 댓글과 대댓글 모두 같다. + +```sql +ORDER BY created_at ASC, comment_id ASC +``` + + +### 5) 조회 아키텍처 후보 비교 + +댓글 조회 구조는 같은 데이터셋과 같은 정책으로 후보 1, 2, 3을 Spike 실험한 뒤 결정했다. + +| 후보 | 방식 | 장점 | 단점 및 트레이드오프 | 판단 | +| :--- | :--- | :--- | :--- | :--- | +| 후보 1 | 전체 댓글을 한 번에 조회하고 메모리에서 트리 조립 | 쿼리 1회로 끝나 구현이 단순함 | 댓글 수가 늘수록 응답 크기와 메모리 사용량이 같이 증가함 | 기각 | +| 후보 2 | 루트 댓글 20개 조회 후 해당 루트의 대댓글 전체를 Batch 조회 | 루트 댓글 수를 제한하고 N+1을 피할 수 있음 | 특정 루트에 대댓글이 몰리면 초기 응답이 다시 커짐 | 기각 | +| 후보 3 | 루트 댓글 20개 + 루트별 대댓글 5개 프리뷰 + 대댓글 분리 API | 초기 응답 크기를 제한하고 핫스팟 댓글에도 대응 가능 | 대댓글 전용 API와 부모별 Top-N 쿼리가 필요함 | 채택 | + +실측 결과도 후보 3이 가장 안정적이었다. + +| 시나리오 | 후보 1 | 후보 2 | 후보 3 | +| :--- | :---: | :---: | :---: | +| 분산 데이터(Post 998) 응답 크기 | 210.44 KB | 39.87 KB | 22.03 KB | +| 핫스팟 데이터(Post 999) 응답 크기 | 205.84 KB | 103.70 KB | 5.55 KB | +| 핫스팟 데이터 읽은 행 수 | 1,000행 | 520행 | 25행 | + +후보 3은 API가 하나 늘어나지만 댓글 조회 시 대댓글 500개를 한 번에 읽어오는 상황을 피할 수 있었다. + +커뮤니티 서비스에서는 댓글이 많은 글도 빠르게 보여줘야 한다고 생각해서, 초기 응답 크기를 제한하는 방식을 생각했다. + +### 6) 선택한 방식의 기술부채 + +해당 방식을 선택하면서 다음 기술부채가 남았다. + +1. 부모별 Top-5 조회를 위한 MySQL 8.0 `ROW_NUMBER() OVER (PARTITION BY parent_id)`. +2. 게시글 댓글 조회 API 외에 대댓글 전용 페이징 API의 별도 관리. +3. `ORDER BY created_at ASC, comment_id ASC` 정렬을 안정적으로 처리하기 위한 복합 인덱스. +4. MySQL 실행계획에서 윈도우 함수 처리로 `Using temporary`, `Using filesort`가 일부 남을 수 있다. + + +### 7) 기술부채 개선 내용 + +부모별 Top-5 프리뷰는 MySQL 8.0 윈도우 함수로 구현했다. + +```sql +ROW_NUMBER() OVER ( + PARTITION BY c.parent_id + ORDER BY c.created_at ASC, c.comment_id ASC +) AS rn +``` + +대댓글 전용 API는 `GET /api/v1/comments/{commentId}/replies`로 분리했고, `size + 1`개를 조회해 `hasNext`를 판단한다. + +읽기 성능을 위해 복합 인덱스도 보강했다. + +```text +(post_id, parent_id, created_at, comment_id) +(parent_id, is_deleted, created_at, comment_id) +``` + +두 번째 인덱스에서 `is_deleted`는 `parent_id` 다음에 둔다. 특정 루트의 대댓글 범위를 먼저 좁힌 뒤, 활성 댓글만 필터링하고, 그 안에서 생성 시각과 PK 순서로 읽기 위한 구조다. + +```sql +WHERE parent_id = ? + AND is_deleted = false +ORDER BY created_at ASC, comment_id ASC +``` + +### 8) 개선 후 결과 + +| post_id | 데이터셋 | 전체 댓글 | 루트 댓글 | 대댓글 | +| :---: | :--- | :---: | :---: | :---: | +| 998 | 분산 데이터 | 1,000개 | 100개 | 900개 | +| 999 | 핫스팟 데이터 | 1,000개 | 500개 | 500개 | + +실행계획에서는 복합 인덱스가 사용되는 것을 확인했는데, `ROW_NUMBER()` 기반 Top-5 쿼리와 삭제 루트 노출 정책이 포함된 쿼리에서는 `Using temporary`, `Using filesort`가 남는다. + +목적은 DB 내부 정렬 비용을 완전히 없애는 것이 아니라, 초기 응답 크기와 서버 메모리 사용량을 제한하는 것. + + +### 9) 테스트 및 검증 결과 +개선 후의 테스트 결과 + +```bash +./gradlew.bat test --tests "*CommentReadTest*" +``` + +| 항목 | 결과 | +| :--- | :--- | +| 테스트 수 | 10 | +| 실패 | 0 | +| 에러 | 0 | +| 스킵 | 0 | + +댓글 도메인 전체 테스트는 42건 중 1건이 실패하고 1건이 스킵됐다. + +```bash +./gradlew.bat test --tests "*Comment*" +``` + +실패한 테스트는 후보 3 구조나 현재 조회 구현 문제가 아니다. + +기존 `CommentServiceTest` 일부가 "삭제된 루트 댓글은 활성 대댓글이 없어도 목록에 남는다"는 예전 정책을 기대하고 있어서 현재 정책과 충돌한다. + +현재 정책은 활성 대댓글이 없는 삭제 루트를 숨기는 방식이다. + --- -## 5. 프로젝트 물리 디렉토리 구조 (Project Structure) +## 6. 프로젝트 물리 디렉토리 구조 (Project Structure) ``` snowthing/ (프로젝트 최상위 루트) @@ -296,7 +470,7 @@ snowthing/ (프로젝트 최상위 루트) --- -## 6. 실행 및 테스트 (Build & Run) +## 7. 실행 및 테스트 (Build & Run) ### Backend (Spring Boot) ```bash diff --git a/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 index 4a3d83f..067c49f 100644 --- 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 @@ -2,11 +2,14 @@ import java.util.List; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; + public record CommentReplyListResponse( - Long rootCommentId, + @JsonSerialize(using = ToStringSerializer.class) Long rootCommentId, long totalReplyCount, List replies, - Long nextCursor, + @JsonSerialize(using = ToStringSerializer.class) 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 3470ed0..7d40f3c 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 @@ -4,14 +4,16 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; 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, + @JsonSerialize(using = ToStringSerializer.class) Long commentId, + @JsonSerialize(using = ToStringSerializer.class) Long postId, + @JsonSerialize(using = ToStringSerializer.class) Long parentId, WriterResponse writer, boolean isAnonymous, String writerIp, @@ -23,6 +25,8 @@ public record CommentResponse( @JsonIgnore String ownerPublicId, boolean canEdit, boolean requiresPassword, + boolean canDelete, + boolean requiresDeletePassword, LocalDateTime createdAt) { private static final String ANONYMOUS_NAME = "ㅇㅇ"; @@ -57,6 +61,8 @@ public static CommentResponse from(Comment comment) { member == null ? null : member.getPublicId(), false, false, + false, + false, comment.getCreatedAt()); } @@ -76,20 +82,13 @@ public CommentResponse withPreviewReplies(List replies) { ownerPublicId, canEdit, requiresPassword, + canDelete, + requiresDeletePassword, createdAt); } - public CommentResponse withViewerPermissions(String viewerPublicId) { - boolean editable = - !isDeleted - && (ownerPublicId == null - ? isAnonymous - : ownerPublicId.equals(viewerPublicId)); - boolean passwordRequired = editable && ownerPublicId == null && isAnonymous; - List visibleReplies = - previewReplies.stream() - .map(reply -> reply.withViewerPermissions(viewerPublicId)) - .toList(); + public CommentResponse withReplyInfo( + long replyCount, boolean hasMoreReplies, List replies) { return new CommentResponse( commentId, postId, @@ -100,16 +99,31 @@ public CommentResponse withViewerPermissions(String viewerPublicId) { content, isDeleted, replyCount, - visibleReplies, + replies, hasMoreReplies, ownerPublicId, - editable, - passwordRequired, + canEdit, + requiresPassword, + canDelete, + requiresDeletePassword, createdAt); } - public CommentResponse withReplyInfo( - long replyCount, boolean hasMoreReplies, List replies) { + public CommentResponse withViewerPermissions(String viewerPublicId, boolean admin) { + boolean guestAnonymous = ownerPublicId == null && isAnonymous; + boolean owner = ownerPublicId != null && ownerPublicId.equals(viewerPublicId); + + boolean editable = !isDeleted && (owner || guestAnonymous); + boolean editPasswordRequired = editable && guestAnonymous; + + boolean deletable = !isDeleted && (admin || owner || guestAnonymous); + boolean deletePasswordRequired = deletable && !admin && guestAnonymous; + + List visibleReplies = + previewReplies.stream() + .map(reply -> reply.withViewerPermissions(viewerPublicId, admin)) + .toList(); + return new CommentResponse( commentId, postId, @@ -120,14 +134,20 @@ public CommentResponse withReplyInfo( content, isDeleted, replyCount, - replies, + visibleReplies, hasMoreReplies, ownerPublicId, - canEdit, - requiresPassword, + editable, + editPasswordRequired, + deletable, + deletePasswordRequired, createdAt); } + public CommentResponse withViewerPermissions(String viewerPublicId) { + return withViewerPermissions(viewerPublicId, false); + } + public List children() { return previewReplies; } 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 5597c3f..251d97b 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,11 +2,14 @@ import java.util.List; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; + public record PostCommentListResponse( String publicId, int totalCommentCount, List comments, - Long nextCursor, + @JsonSerialize(using = ToStringSerializer.class) Long nextCursor, boolean hasNext) { public PostCommentListResponse { comments = comments == null ? List.of() : List.copyOf(comments); 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 7b7c16b..b7be8f6 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,5 +1,6 @@ package com.ikae.snowthing.domain.comment.repository; +import java.time.LocalDateTime; import java.util.Optional; import jakarta.persistence.LockModeType; @@ -17,6 +18,15 @@ public interface CommentRepository extends JpaRepository, Comment @Query("SELECT c FROM Comment c WHERE c.id = :commentId") Optional findByIdForUpdate(@Param("commentId") Long commentId); + @org.springframework.data.jpa.repository.Modifying( + flushAutomatically = true, + clearAutomatically = true) + @Query( + "UPDATE Comment c SET c.isDeleted = true, c.deletedAt = :deletedAt " + + "WHERE c.id = :commentId AND c.isDeleted = false") + int softDeleteIfActive( + @Param("commentId") Long commentId, @Param("deletedAt") LocalDateTime deletedAt); + long countByParentIdAndIsDeletedFalse(Long parentId); @Lock(LockModeType.PESSIMISTIC_READ) 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 index 08b2007..b3b88da 100644 --- 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 @@ -69,10 +69,6 @@ public List findRootComments(Long postId, Long cursorId, int fe 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.comment_id ASC LIMIT :fetchSize"; @@ -227,6 +223,8 @@ private CommentResponse mapResponse(ResultSet rs, int rowNum) throws SQLExceptio memberPublicId, false, false, + false, + false, rs.getObject("created_at", LocalDateTime.class)); } 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 ee7d1ab..936f9da 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 @@ -1,5 +1,6 @@ package com.ikae.snowthing.domain.comment.service; +import java.time.LocalDateTime; import java.util.*; import org.springframework.security.crypto.password.PasswordEncoder; @@ -126,7 +127,8 @@ public PostCommentListResponse getCommentsByPost( comment.withViewerPermissions( userDetails == null ? null - : userDetails.getPublicId())) + : userDetails.getPublicId(), + isAdmin(userDetails))) .toList(); Long nextCursor = hasNext && !comments.isEmpty() ? comments.getLast().commentId() : null; return new PostCommentListResponse( @@ -156,6 +158,7 @@ public CommentReplyListResponse getCommentReplies( if (root.getParent() != null) { throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); } + if (cursor != null && !commentRepository.existsReplyCursor(commentId, cursor)) { throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); } @@ -169,7 +172,8 @@ public CommentReplyListResponse getCommentReplies( reply.withViewerPermissions( userDetails == null ? null - : userDetails.getPublicId())) + : userDetails.getPublicId(), + isAdmin(userDetails))) .toList(); Long nextCursor = hasNext && !replies.isEmpty() ? replies.getLast().commentId() : null; return new CommentReplyListResponse( @@ -243,17 +247,12 @@ public void deleteComment( validateDeletePermission(comment, anonymousPassword, userDetails); - comment.softDelete(); - postRepository.decreaseCommentCount(comment.getPost().getId()); + commentRepository.softDeleteIfActive(commentId, LocalDateTime.now()); } private void validateDeletePermission( Comment comment, String anonymousPassword, CustomUserDetails userDetails) { - boolean isAdmin = - userDetails != null - && userDetails.getAuthorities().stream() - .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); - if (isAdmin) { + if (isAdmin(userDetails)) { return; } @@ -283,4 +282,10 @@ private boolean isWriter(Comment comment, CustomUserDetails userDetails) { private boolean hasAnonymousPassword(String anonymousPassword) { return anonymousPassword != null && !anonymousPassword.isBlank(); } + + private boolean isAdmin(CustomUserDetails userDetails) { + return userDetails != null + && userDetails.getAuthorities().stream() + .anyMatch(authority -> authority.getAuthority().equals("ROLE_ADMIN")); + } } 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 index 4040545..83afa97 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java @@ -245,7 +245,9 @@ void deletionVisibilityPolicy() { commentService.deleteComment(reply.commentId(), null, userDetails); PostCommentListResponse allDeleted = commentService.getCommentsByPost(post.publicId(), null, 20); - assertThat(allDeleted.comments()).isEmpty(); + assertThat(allDeleted.comments()).hasSize(1); + assertThat(allDeleted.comments().getFirst().isDeleted()).isTrue(); + assertThat(allDeleted.comments().getFirst().replyCount()).isEqualTo(1); } @Test diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java index 76fcbfe..676265f 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java @@ -175,6 +175,25 @@ void getComments_success() throws Exception { .andExpect(jsonPath("$.totalCommentCount").value(1)); } + @Test + @DisplayName("GET /api/v1/posts/{publicId}/comments - 로그인 익명 작성자는 비밀번호 없이 삭제 가능") + void getComments_exposesDeletePermissionForAnonymousMemberOwner() throws Exception { + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder().content("로그인 익명 댓글").isAnonymous(true).build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform( + get("/api/v1/posts/{publicId}/comments", post.publicId()) + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.comments[0].writer").doesNotExist()) + .andExpect(jsonPath("$.comments[0].ownerPublicId").doesNotExist()) + .andExpect(jsonPath("$.comments[0].canDelete").value(true)) + .andExpect(jsonPath("$.comments[0].requiresDeletePassword").value(false)); + } + @Test @DisplayName("DELETE /api/v1/comments/{commentId} - 댓글 Soft Delete 삭제 200 OK") void deleteComment_success() throws Exception { diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java index ef9f43f..cf5ff1c 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java @@ -124,6 +124,8 @@ private CommentResponse createResponse( null, false, false, + false, + false, LocalDateTime.now()); } } diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java index 7d37d81..32b413f 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java @@ -424,12 +424,46 @@ void allowOnlyOneConcurrentReplyAtLimitBoundary() throws Exception { } } + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 루트의 서로 다른 대댓글에 동시에 답글을 작성해도 데드락 없이 저장된다") + void createConcurrentRepliesThroughDifferentChildrenWithoutDeadlock() throws Exception { + CommentResponse root = createComment(null, "동시성 루트 댓글"); + CommentResponse firstChild = createComment(root.commentId(), "첫 번째 대댓글"); + CommentResponse secondChild = createComment(root.commentId(), "두 번째 대댓글"); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + List> results = + List.of( + executor.submit( + () -> + createConcurrentReply( + firstChild.commentId(), ready, start)), + executor.submit( + () -> + createConcurrentReply( + secondChild.commentId(), ready, start))); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + assertThat(results.get(0).get(30, TimeUnit.SECONDS)).isNull(); + assertThat(results.get(1).get(30, TimeUnit.SECONDS)).isNull(); + assertThat(commentRepository.countByParentIdAndIsDeletedFalse(root.commentId())) + .isEqualTo(4); + } finally { + executor.shutdownNow(); + } + } + private ErrorCode createConcurrentReply( - Long rootCommentId, CountDownLatch ready, CountDownLatch start) throws Exception { + Long parentCommentId, CountDownLatch ready, CountDownLatch start) throws Exception { ready.countDown(); assertThat(start.await(10, TimeUnit.SECONDS)).isTrue(); try { - createComment(rootCommentId, "동시 대댓글"); + createComment(parentCommentId, "동시 대댓글"); return null; } catch (CustomAuthException exception) { return exception.getErrorCode(); 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..6ba11bc --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentDeleteTest.java @@ -0,0 +1,409 @@ +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.CommentReplyListResponse; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.PostCommentListResponse; +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(1); + } + + @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(1); + } + + @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(2); + } + + @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(3); + } + + @Test + @DisplayName("댓글 삭제 UI 권한은 작성 주체와 현재 요청자에 따라 계산한다") + void deletePermissionMetadataMatchesOwnershipPolicy() { + CommentResponse memberComment = createMemberComment("회원 댓글"); + CommentResponse memberAnonymousComment = + createMemberAnonymousComment(null, "로그인 익명 댓글"); + CommentResponse guestAnonymousComment = + createGuestAnonymousComment("비회원 익명 댓글", "anonPass1234"); + + PostCommentListResponse writerView = + commentService.getCommentsByPost( + postResponse.publicId(), null, 20, writerDetails); + assertDeletePermission(writerView, memberComment.commentId(), true, false); + assertDeletePermission(writerView, memberAnonymousComment.commentId(), true, false); + assertDeletePermission(writerView, guestAnonymousComment.commentId(), true, true); + + PostCommentListResponse otherView = + commentService.getCommentsByPost( + postResponse.publicId(), null, 20, otherDetails); + assertDeletePermission(otherView, memberComment.commentId(), false, false); + assertDeletePermission(otherView, memberAnonymousComment.commentId(), false, false); + assertDeletePermission(otherView, guestAnonymousComment.commentId(), true, true); + + PostCommentListResponse adminView = + commentService.getCommentsByPost( + postResponse.publicId(), null, 20, adminDetails); + assertDeletePermission(adminView, memberComment.commentId(), true, false); + assertDeletePermission(adminView, memberAnonymousComment.commentId(), true, false); + assertDeletePermission(adminView, guestAnonymousComment.commentId(), true, false); + } + + @Test + @DisplayName("대댓글 분리 조회에도 로그인 익명 작성자의 삭제 권한을 적용한다") + void separatedReplyDeletePermissionMatchesOwnershipPolicy() { + CommentResponse root = createMemberComment("권한 확인 루트"); + CommentResponse memberAnonymousReply = + createMemberAnonymousComment(root.commentId(), "로그인 익명 대댓글"); + + CommentReplyListResponse writerView = + commentService.getCommentReplies(root.commentId(), null, 20, writerDetails); + CommentReplyListResponse otherView = + commentService.getCommentReplies(root.commentId(), null, 20, otherDetails); + + assertThat(findReply(writerView, memberAnonymousReply.commentId()).canDelete()) + .isTrue(); + assertThat( + findReply(writerView, memberAnonymousReply.commentId()) + .requiresDeletePassword()) + .isFalse(); + assertThat(findReply(otherView, memberAnonymousReply.commentId()).canDelete()) + .isFalse(); + } + } + + @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 createMemberAnonymousComment(Long parentId, String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(parentId, content, true, null), + writerDetails, + "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"); + } + + private void assertDeletePermission( + PostCommentListResponse response, + Long commentId, + boolean canDelete, + boolean requiresPassword) { + CommentResponse comment = + response.comments().stream() + .filter(item -> item.commentId().equals(commentId)) + .findFirst() + .orElseThrow(); + assertThat(comment.canDelete()).isEqualTo(canDelete); + assertThat(comment.requiresDeletePassword()).isEqualTo(requiresPassword); + } + + private CommentResponse findReply(CommentReplyListResponse response, Long commentId) { + return response.replies().stream() + .filter(comment -> comment.commentId().equals(commentId)) + .findFirst() + .orElseThrow(); + } +} diff --git a/docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md "b/docs/conception/sprint03/ADR-001-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" similarity index 100% rename from docs/conception/sprint03/ADR-001-comment-hierarchy-and-retrieval-architecture.md rename to "docs/conception/sprint03/ADR-001-\353\214\223\352\270\200\354\225\204\355\202\244\355\205\215\354\262\230.md" diff --git a/docs/conception/sprint03/comment_api_spec.md b/docs/conception/sprint03/comment_api_spec.md index 96576cd..b0954e6 100644 --- a/docs/conception/sprint03/comment_api_spec.md +++ b/docs/conception/sprint03/comment_api_spec.md @@ -53,7 +53,7 @@ X-XSRF-TOKEN: {csrf_token} | 필드명 | 타입 | 필수 여부 | 설명 | | :--- | :---: | :---: | :--- | | `content` | String | **필수** | 댓글 본문 (1자 이상 1,000자 이하) | -| `parentId` | Long | 선택 | 부모 댓글 ID. `null`이면 루트 댓글, 대댓글 작성 시 대상 댓글 ID 전달 (대댓글에 답글 시 서버에서 최상위 루트 ID로 자동 평탄화) | +| `parentId` | String | 선택 | 부모 댓글 ID. `null`이면 루트 댓글, 대댓글 작성 시 대상 댓글 ID 전달 (대댓글에 답글 시 서버에서 최상위 루트 ID로 자동 평탄화) | | `isAnonymous` | Boolean | **필수** | 익명 작성 여부 (`true` / `false`) | | `anonymousPassword` | String | 조건부 필수 | 비로그인 익명 작성 시 필수 (4자 이상 20자 이하) | @@ -88,6 +88,8 @@ X-XSRF-TOKEN: {csrf_token} 게시글 상세 화면에서 루트 댓글 20개와 각 루트 댓글 하위의 대댓글 상위 5개를 일괄 조회합니다. +삭제된 루트 댓글과 대댓글도 `"삭제된 댓글입니다."` placeholder로 노출하며, `replyCount`와 대댓글 목록은 삭제된 항목을 포함한 전체 수를 기준으로 합니다. + - **HTTP Method**: `GET` - **URI**: `/api/v1/posts/{publicId}/comments` - **인증 요구사항**: 없음 (Public) @@ -95,7 +97,7 @@ X-XSRF-TOKEN: {csrf_token} #### Request Query Parameters | 파라미터명 | 타입 | 기본값 | 설명 | | :--- | :---: | :---: | :--- | -| `cursor` | Long | `null` | 커서 페이징용 마지막 루트 댓글 ID (`commentId`). 첫 페이지 조회 시 생략 | +| `cursor` | String | `null` | 커서 페이징용 마지막 루트 댓글 ID (`commentId`). 첫 페이지 조회 시 생략 | | `size` | Integer | `20` | 조회할 루트 댓글 수 (기본 20개, 최대 50개) | #### Response (200 OK) @@ -118,6 +120,8 @@ X-XSRF-TOKEN: {csrf_token} "isDeleted": false, "canEdit": true, "requiresPassword": false, + "canDelete": true, + "requiresDeletePassword": false, "replyCount": 8, "previewReplies": [ { @@ -134,6 +138,8 @@ X-XSRF-TOKEN: {csrf_token} "isDeleted": false, "canEdit": false, "requiresPassword": false, + "canDelete": false, + "requiresDeletePassword": false, "createdAt": "2026-09-01T15:32:00" } ], @@ -150,6 +156,8 @@ X-XSRF-TOKEN: {csrf_token} "isDeleted": true, "canEdit": false, "requiresPassword": false, + "canDelete": false, + "requiresDeletePassword": false, "replyCount": 1, "previewReplies": [ { @@ -166,6 +174,8 @@ X-XSRF-TOKEN: {csrf_token} "isDeleted": false, "canEdit": false, "requiresPassword": false, + "canDelete": false, + "requiresDeletePassword": false, "createdAt": "2026-09-01T15:35:00" } ], @@ -180,6 +190,8 @@ X-XSRF-TOKEN: {csrf_token} - `canEdit`: 현재 요청 사용자가 해당 댓글을 수정할 수 있는지 나타냅니다. 로그인 익명 댓글도 작성자 세션이 일치할 때만 `true`입니다. - `requiresPassword`: 수정 시 익명 비밀번호가 필요한지 나타냅니다. 비회원 익명 댓글에만 `true`이며, 작성 회원의 식별자는 익명 응답에 노출하지 않습니다. +- `canDelete`: 현재 요청자가 해당 댓글을 삭제할 수 있는지 나타냅니다. 로그인 익명 댓글도 작성자 세션이 일치하거나 관리자인 경우에만 `true`입니다. +- `requiresDeletePassword`: 삭제 시 익명 비밀번호가 필요한지 나타냅니다. 비회원 익명 댓글에만 `true`이며, 로그인 익명 작성자의 회원 식별자는 응답에 노출하지 않습니다. --- @@ -194,7 +206,7 @@ X-XSRF-TOKEN: {csrf_token} #### Request Query Parameters | 파라미터명 | 타입 | 기본값 | 설명 | | :--- | :---: | :---: | :--- | -| `cursor` | Long | `null` | 커서 페이징용 마지막 대댓글 ID (`commentId`). 첫 더보기 호출 시 5번째 프리뷰 대댓글의 ID를 전달 | +| `cursor` | String | `null` | 커서 페이징용 마지막 대댓글 ID (`commentId`). 첫 더보기 호출 시 5번째 프리뷰 대댓글의 ID를 전달 | | `size` | Integer | `20` | 조회할 대댓글 수 (기본 20개, 최대 50개) | #### Response (200 OK) diff --git a/docs/conception/sprint03/comment_policy.md b/docs/conception/sprint03/comment_policy.md index 87d7a2d..3ab1396 100644 --- a/docs/conception/sprint03/comment_policy.md +++ b/docs/conception/sprint03/comment_policy.md @@ -26,10 +26,10 @@ - **삭제된 루트 댓글에 신규 대댓글 작성**: **허용**. (대화 맥락 유지를 위해 삭제된 부모 밑에도 신규 답글 작성 가능) - **삭제된 대댓글 노출**: 대댓글 삭제 시에도 `"삭제된 댓글입니다."` placeholder로 대체 노출 (`is_deleted = true`). - **부모 + 자식 모두 삭제된 노드(고아 노드)**: - - 루트 댓글이 삭제되고, 그 하위의 모든 대댓글도 삭제된 경우 **화면(클라이언트 응답 목록)에서 완전히 숨김(은닉)** 처리합니다. + - 루트 댓글과 대댓글이 모두 삭제된 경우에도 각 댓글을 tombstone placeholder로 계속 노출합니다. - **`post.commentCount` (게시글 총 댓글 수)**: - - **"삭제된 댓글입니다"를 제외한 실제 살아있는 활성 댓글/대댓글(`is_deleted = false`)의 총합**만 카운트합니다. - - Soft Delete 실행 시 즉시 `comment_count - 1` 벌크 차감. + - 삭제된 댓글을 포함한 게시글의 전체 댓글/대댓글 수를 카운트합니다. + - Soft Delete 시 카운트를 차감하지 않고, 댓글 생성 시에만 증가시킵니다. - **`replyCount` (대댓글 수)**: - 각 루트 댓글 DTO 및 화면에 노출되는 `replyCount`는 화면 렌더링 노드 일원화 정책에 따라 **삭제된 대댓글 placeholder를 포함한 전체 대댓글 수(`totalCount`)**를 집계합니다. - 이를 통해 화면 상단의 대댓글 수 뱃지(`replyCount`), 상위 5개 미리보기(`previewReplies`), 페이징 더보기 플래그(`hasMoreReplies = totalCount > 5`)의 기준을 100% 일치시켜 UI 인지 부조화를 방지합니다. 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 002a71e..4e67586 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,42 @@ +- **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 다중 PR 통합: PR #14 베이스 병합 및 PR #15 역병합 충돌 해결 (2026-09-06)**: 1. **PR #14 (`feature/sprint03-comment-cr`) 머지 완결**: - 베이스 브랜치(`feature/sprint03-comment`)로 PR #14 병합 완료 (`MERGED`). @@ -895,6 +934,36 @@ 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건 확인. + ## Sprint 03 댓글 수정 프론트엔드 UI - 상태: DONE @@ -923,6 +992,100 @@ ### 검증 - 변경 파일 대상 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 로컬 상태 파일이므로 커밋 대상에서 제외. + +- **댓글 동시 삭제 카운트 중복 차감 방지 (2026-09-06)**: + - 삭제를 `is_deleted = false` 조건부 UPDATE로 원자화했습니다. + - 영향 행이 1인 최초 삭제 요청만 `post.commentCount`를 차감하며, 동시 요청에서 영향 행이 0이면 카운트를 차감하지 않습니다. + +- **Sprint 03 로그인 익명 댓글 삭제 UI 권한 정합성 보강 (2026-09-06)**: + - 댓글 조회 응답에 요청자 기준 `canDelete`, `requiresDeletePassword`를 추가했습니다. 로그인 익명 작성자의 내부 식별자는 `ownerPublicId`로만 판정하고 `@JsonIgnore`로 외부 응답에서 제외했습니다. + - 일반 회원 본인, 로그인 익명 본인, 비회원 익명, 관리자, 타 회원, 삭제 댓글을 삭제 권한 매트릭스와 동일하게 판정하며 루트 프리뷰와 분리 대댓글에도 적용했습니다. + - 프런트엔드의 `writer` 기반 `isOwnerAnonMember` 추정을 제거하고, 삭제 버튼과 비밀번호 입력 여부를 각각 서버의 `canDelete`, `requiresDeletePassword`로 결정하도록 변경했습니다. + - `CommentDeleteTest`에 작성자별 권한 매트릭스와 분리 대댓글 권한 테스트를, `CommentControllerTest`에 로그인 익명 작성자의 응답 권한 및 내부 식별자 비노출 테스트를 추가했습니다. + - 검증 결과: `CommentDeleteTest`, `CommentReadTest`, 백엔드 `spotlessCheck`, 변경 프런트 파일 ESLint, `npm run build`가 통과했습니다. 기존 게시글 이미지의 `` 관련 ESLint 경고 1건은 남아 있습니다. +- **댓글 삭제 후 대댓글 페이징 상태 초기화 (2026-09-06)**: + - 삭제 성공 후 루트 댓글을 재조회할 때 기존 `replyPagingByRootId`도 함께 초기화해 이전 커서·더보기 상태가 새 미리보기와 섞이지 않도록 수정했습니다. + +- **댓글 조회 실패 상태 및 재시도 UI 보강 (2026-09-06)**: + - 최초 댓글 조회의 로딩·성공 빈 목록·실패 상태를 분리해 서버 오류가 빈 목록처럼 표시되지 않도록 수정했습니다. + - 최초 조회 실패 시 오류 메시지와 재시도 버튼을 표시하고, 재시도 중에는 중복 요청을 막는 로딩 상태를 적용했습니다. + - 댓글 더보기 실패 시 이미 조회한 댓글을 유지하면서 오류 메시지와 더보기 재시도 버튼을 표시하도록 구현했습니다. + - HTTP 비정상 응답도 실패로 처리하며 서버 오류 메시지가 없으면 기본 안내 문구를 사용하도록 보강했습니다. + - 검증 결과: 변경 파일 ESLint는 오류 없이 통과했으며 기존 `` 최적화 경고 1건만 남았습니다. `npm run build`도 통과했습니다. + +- **대댓글 생성 락 순서 및 조회 비용 개선 (2026-09-06)**: + - 요청된 부모 댓글은 일반 조회로 식별하고, 최상위 루트 댓글 한 행에만 비관적 쓰기 락을 획득하도록 락 지점을 단일화했습니다. + - 루트의 활성 대댓글 ID 전체를 잠그고 애플리케이션에서 개수를 세던 `findActiveReplyIdsForUpdate()`를 제거하고, 루트 락을 보유한 트랜잭션 안에서 `countActiveReplies()`를 실행하도록 변경했습니다. + - 같은 루트의 서로 다른 대댓글을 부모로 지정한 두 동시 요청이 모두 데드락 없이 저장되는 회귀 테스트를 추가했습니다. + - 기존 99개 상한 경계 동시성 테스트를 포함한 `CommentCreateTest`와 백엔드 `spotlessCheck`가 통과했습니다. +- **댓글 식별자·커서 프론트 정밀도 보호 (2026-09-06)**: + - 댓글 응답의 `Long` 식별자와 커서를 JSON 문자열로 직렬화하고 프론트 상태를 `string`으로 변경했습니다. + - JavaScript `number`의 `MAX_SAFE_INTEGER` 초과 시 댓글 수정·삭제 및 커서가 손상되는 문제를 방지했습니다. +- **댓글 삭제 tombstone 및 카운트 정책 단순화 (2026-09-06)**: + - 삭제된 루트와 대댓글을 모두 tombstone으로 계속 노출하고, 루트가 모든 자식과 함께 삭제되어도 목록에서 숨기지 않도록 정책을 변경했습니다. + - `post.commentCount`, `replyCount`, `totalReplyCount`, 프리뷰·대댓글 목록을 삭제 포함 전체 댓글 기준으로 통일했습니다. + - 대댓글 100개 작성 제한 검증은 삭제 댓글이 한도를 영구 점유하지 않도록 활성 대댓글 수 기준을 유지했습니다. + - 삭제 시 게시글 댓글 수를 차감하지 않도록 서비스와 테스트를 갱신했습니다. + +- **댓글 대댓글 조회 게시글 공개 상태 검증 보강 (2026-09-06)**: + - `getCommentReplies`가 루트 댓글 여부만 확인하던 문제를 수정하고, 루트 댓글의 게시글을 조회해 삭제 여부와 `PostStatus.NORMAL`을 공통 검증하도록 변경했다. + - 게시글 댓글 목록과 대댓글 조회가 동일한 `POST_NOT_FOUND` 정책을 사용하도록 `validatePostVisibility`를 적용했다. + - 삭제·차단 게시글의 대댓글 조회가 `POST_NOT_FOUND`로 차단되는 통합 테스트를 `CommentReadTest`에 추가했다. + - 검증: `spotlessApply` 및 `CommentReadTest` 성공. 테스트 DB 환경변수 미설정 상태에서는 실제 MySQL 테스트 실행이 보류됨. +======= - **Sprint 03 테스트 환경 MySQL 단일화 (2026-09-06)**: - H2 의존성·datasource·dialect를 제거하고 모든 Spring Boot 테스트 설정을 MySQL 8.0/InnoDB로 통일했습니다. - `CommentCreateTest`와 `CommentUpdateTest`는 `SNOWTHING_TEST_DB_URL` 누락 시 fallback 없이 즉시 실패하며, `.env.example`에 프로세스 환경변수 전달 방법을 명시했습니다. @@ -1006,3 +1169,4 @@ - MockMvc 경계 테스트에서 3·21자는 `400 Bad Request`와 `COMMON_001`, 4·20자는 `201 Created`를 검증했습니다. - `CommentControllerTest`와 `spotlessCheck`는 통과했습니다. - `CommentCreateTest` 16건은 `SNOWTHING_TEST_DB_URL` 미설정 시 실행을 차단하는 기존 MySQL 강제 설정 때문에 Spring Context 생성 전에 실패했습니다. 경계값 변경으로 인한 테스트 assertion 실패는 아닙니다. +>>>>>>> origin/feature/sprint03-comment diff --git a/docs/specs/comment_policy.md b/docs/specs/comment_policy.md deleted file mode 100644 index f712d27..0000000 --- a/docs/specs/comment_policy.md +++ /dev/null @@ -1,56 +0,0 @@ -# 📜 Snowthing 댓글/대댓글 도메인 공식 제품 규칙 명세서 (Comment Domain Policy) - -본 문서는 Snowthing 커뮤니티의 댓글 및 대댓글 도메인의 계층, 화면 응답, 삭제 및 카운트, 정렬 및 권한 정책을 정의한 공식 기술 명세서입니다. - ---- - -## 1. 계층 규칙 (Hierarchy Rules) -- **2-Depth 고정 구조**: 댓글(Root)과 대댓글(Child)로만 이루어진 2단계 계층 구조를 채택합니다. -- **평탄화(Flattening) 정책**: 대댓글에 다시 답글을 작성하는 경우, 부모 대댓글의 ID가 아닌 **최상위 Root 댓글의 `comment_id`를 `parent_id`로 자동 지정**하여 2단계를 초과하는 계층 생성을 물리적으로 방지합니다. - ---- - -## 2. 화면 및 응답 규칙 (UI & Response Rules) -- **초기 로딩 크기**: 게시글 상세 진입 시 루트 댓글은 **1페이지당 20개** 기준으로 조회합니다. -- **대댓글 노출 및 접기**: - - 각 루트 댓글 하위의 대댓글은 **기본 5개**까지 펼쳐서 노출합니다. - - 5개를 초과하는 대댓글은 **"답글 더보기(N개)"** UI로 접힘 처리하여 사용자가 클릭 시 추가 렌더링합니다. -- **최대 대댓글 제한**: 루트 댓글 1개당 작성 가능한 대댓글 수는 **최대 100개**로 제한합니다 (100개 도달 시 400 Bad Request 에러 반환). - ---- - -## 3. 삭제 및 카운트 규칙 (Deletion & Count Rules) -- **삭제된 루트 + 대댓글 존재 시**: - - 루트 댓글 본문은 `"삭제된 댓글입니다."` placeholder로 대체 노출 (`is_deleted = true`). - - 하위 대댓글들은 정상적으로 노출을 유지합니다. -- **삭제된 루트 댓글에 신규 대댓글 작성**: **허용**. (대화 맥락 유지를 위해 삭제된 부모 밑에도 신규 답글 작성 가능) -- **삭제된 대댓글 노출**: 대댓글 삭제 시에도 `"삭제된 댓글입니다."` placeholder로 대체 노출 (`is_deleted = true`). -- **부모 + 자식 모두 삭제된 노드(고아 노드)**: - - 루트 댓글이 삭제되고, 그 하위의 모든 대댓글도 삭제된 경우 **화면(클라이언트 응답 목록)에서 완전히 숨김(은닉)** 처리합니다. -- **`post.commentCount` (게시글 총 댓글 수)**: - - **"삭제된 댓글입니다"를 제외한 실제 살아있는 활성 댓글/대댓글(`is_deleted = false`)의 총합**만 카운트합니다. - - Soft Delete 실행 시 즉시 `comment_count - 1` 벌크 차감. -- **`replyCount` (대댓글 수)**: - - 각 루트 댓글 DTO에 포함되는 `replyCount`는 **삭제된 대댓글을 제외한 실제 활성 대댓글 수**만 집계합니다. - ---- - -## 4. 정렬 및 권한 규칙 (Ordering & Permission Rules) -- **루트 댓글 정렬**: **등록순 / 오래된 순 (`ORDER BY created_at ASC, comment_id ASC`)** -- **대댓글 정렬**: **등록순 / 오래된 순 (`ORDER BY created_at ASC, comment_id ASC`)** -- **결정론적 순서 고정**: 동일 생성 시각 발생 시 PK 타이브레이커(`comment_id ASC`)를 필수 적용하여 순서 뒤바뀜을 원천 방지합니다. - -### 🔐 4대 사용자 권한 매트릭스 -| 구분 | 작성(Create) 규칙 | 삭제(Delete) 규칙 | -| :--- | :--- | :--- | -| **1. 일반 회원** | 로그인 필수, 본인 닉네임/프로필 노출 | **비밀번호 불필요**, 본인 로그인 세션으로 즉시 삭제 | -| **2. 로그인 익명** | 로그인 필수, 화면에는 `익명 (IP)` 노출 | **비밀번호 불필요**, 본인 로그인 세션 일치 시 즉시 삭제 | -| **3. 비로그인 익명** | 로그인 불필요, `anonymousPassword` (4자리 이상) 필수 | **비밀번호 필수**, Request Body JSON 비밀번호 일치 시 삭제 | -| **4. 최고 관리자 (`ROLE_ADMIN`)** | 관리자 권한으로 작성 | **비밀번호 불필요**, 어떤 댓글이든 즉시 강제 삭제 | - ---- - -## 5. 향후 확장 정책 (Future Expansion) -- **베스트 댓글(Best Comments) 상단 고정**: - - 댓글 추천 기능 도입 시, **추천수 상위 3개 댓글을 목록 최상단에 뱃지와 함께 고정(Pinning)** 노출합니다. - - 베스트 댓글은 본문만 우선 노출하며, "답글 읽기" 클릭 시 대댓글을 조회할 수 있도록 구성합니다. diff --git a/docs/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 19f8284..41accdb 100644 --- a/frontend/app/lib/api.ts +++ b/frontend/app/lib/api.ts @@ -22,16 +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, cursor?: number | null, size = 20) => + comments: (publicId: string, cursor?: string | 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) => + replies: (commentId: string, cursor?: string | 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}`, + delete: (commentId: 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 cd465aa..7fd1194 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -38,8 +38,8 @@ interface PostDetail { } interface CommentItem { - commentId: number; - parentId: number | null; + commentId: string; + parentId: string | null; writer: WriterInfo | null; isAnonymous: boolean; writerIp: string; @@ -50,6 +50,8 @@ interface CommentItem { hasMoreReplies: boolean; canEdit: boolean; requiresPassword: boolean; + canDelete: boolean; + requiresDeletePassword: boolean; createdAt: string; } @@ -57,26 +59,26 @@ interface CommentListResponse { publicId: string; totalCommentCount: number; comments: CommentItem[]; - nextCursor: number | null; + nextCursor: string | null; hasNext: boolean; } interface CommentReplyListResponse { - rootCommentId: number; + rootCommentId: string; totalReplyCount: number; replies: CommentItem[]; - nextCursor: number | null; + nextCursor: string | null; hasNext: boolean; } interface ReplyPagingState { - nextCursor: number | null; + nextCursor: string | null; hasNext: boolean; loading: boolean; } interface CommentUpdateResponse { - commentId: number; + commentId: string; content: string; updatedAt: string; } @@ -87,24 +89,31 @@ 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 [commentNextCursor, setCommentNextCursor] = useState(null); const [hasNextComments, setHasNextComments] = useState(false); + const [isLoadingComments, setIsLoadingComments] = useState(true); + const [commentLoadError, setCommentLoadError] = useState(""); + const [loadMoreCommentError, setLoadMoreCommentError] = useState(""); const [isLoadingMoreComments, setIsLoadingMoreComments] = useState(false); - const [replyPagingByRootId, setReplyPagingByRootId] = useState>({}); + const [replyPagingByRootId, setReplyPagingByRootId] = useState>({}); const [loading, setLoading] = useState(true); const [errorMsg, setErrorMsg] = useState(""); const [reactionMsg, setReactionMsg] = useState(""); const [newCommentText, setNewCommentText] = useState(""); const [commentAnonPassword, setCommentAnonPassword] = useState(""); const [submittingComment, setSubmittingComment] = useState(false); - const [activeReplyParentId, setActiveReplyParentId] = useState(null); + const [activeReplyParentId, setActiveReplyParentId] = useState(null); const [replyText, setReplyText] = useState(""); const [replyAnonPassword, setReplyAnonPassword] = useState(""); - const [activeEditCommentId, setActiveEditCommentId] = useState(null); + const [activeEditCommentId, setActiveEditCommentId] = useState(null); const [editCommentText, setEditCommentText] = useState(""); const [editCommentPassword, setEditCommentPassword] = useState(""); const [editCommentError, setEditCommentError] = useState(""); const [submittingEditComment, setSubmittingEditComment] = useState(false); + const [activeDeleteCommentId, setActiveDeleteCommentId] = useState(null); + const [deleteCommentPassword, setDeleteCommentPassword] = useState(""); + const [deleteCommentError, setDeleteCommentError] = useState(""); + const [submittingDeleteComment, setSubmittingDeleteComment] = useState(false); const [currentUserPublicId, setCurrentUserPublicId] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -156,24 +165,43 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; - const fetchComments = useCallback(async (cursor: number | null = null, append = false) => { + const fetchComments = useCallback(async (cursor: string | null = null, append = false) => { + if (append) { + setLoadMoreCommentError(""); + } else { + setIsLoadingComments(true); + setCommentLoadError(""); + } + try { const res = await fetch(API_ENDPOINTS.posts.comments(publicId, cursor), { credentials: "include" }); - if (res.ok) { - const data: CommentListResponse = await res.json(); - 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)); + + if (!res.ok) { + const errorData = await res.json().catch(() => null); + throw new Error(errorData?.message || "댓글을 불러오지 못했습니다."); } + + const data: CommentListResponse = await res.json(); + 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); + const message = error instanceof Error ? error.message : "서버 통신 중 오류가 발생했습니다."; + if (append) { + setLoadMoreCommentError(message); + } else { + setCommentLoadError(message); + } + } finally { + if (!append) setIsLoadingComments(false); } }, [publicId]); @@ -188,7 +216,7 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; - const handleLoadMoreReplies = async (rootCommentId: number) => { + const handleLoadMoreReplies = async (rootCommentId: string) => { const root = comments.find((comment) => comment.commentId === rootCommentId); if (!root) return; @@ -314,9 +342,8 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const isAnonymousPost = Boolean(post?.isAnonymous || post?.categoryCode === "ANONYMOUS"); - const handleCreateComment = async (parentId: number | null) => { + const handleCreateComment = async (parentId: string | null) => { if (submittingComment) return; - const text = parentId ? replyText : newCommentText; if (!text.trim()) { alert("댓글 내용을 입력해주세요."); @@ -470,32 +497,46 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; - const handleDeleteComment = async (comment: CommentItem) => { - let anonymousPassword = ""; - if (comment.requiresPassword) { - const input = prompt("익명 댓글 삭제 비밀번호를 입력하세요."); - if (!input) return; - anonymousPassword = input; - } else if (!confirm("댓글을 삭제하시겠습니까?")) { + const handleStartDeleteComment = (commentId: string) => { + setActiveDeleteCommentId(commentId); + setDeleteCommentPassword(""); + setDeleteCommentError(""); + }; + + const handleCancelDeleteComment = () => { + setActiveDeleteCommentId(null); + setDeleteCommentPassword(""); + setDeleteCommentError(""); + }; + + const handleConfirmDeleteComment = async (comment: CommentItem) => { + if (comment.requiresDeletePassword && !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: anonymousPassword || null }), + body: JSON.stringify({ anonymousPassword: deleteCommentPassword.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("서버 통신 중 오류가 발생했습니다."); + handleCancelDeleteComment(); + setReplyPagingByRootId({}); + 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); } }; @@ -636,7 +677,22 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId:
- {comments.length === 0 ? ( + {isLoadingComments ? ( +

+ 댓글을 불러오는 중입니다. +

+ ) : commentLoadError ? ( +
+

{commentLoadError}

+ +
+ ) : comments.length === 0 ? (

첫 댓글을 작성해보세요.

) : ( comments.map((comment) => ( @@ -662,8 +718,15 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: handleStartEditComment={handleStartEditComment} handleCancelEditComment={handleCancelEditComment} handleUpdateComment={handleUpdateComment} + activeDeleteCommentId={activeDeleteCommentId} + deleteCommentPassword={deleteCommentPassword} + setDeleteCommentPassword={setDeleteCommentPassword} + deleteCommentError={deleteCommentError} + submittingDeleteComment={submittingDeleteComment} + handleStartDeleteComment={handleStartDeleteComment} + handleCancelDeleteComment={handleCancelDeleteComment} + handleConfirmDeleteComment={handleConfirmDeleteComment} submittingComment={submittingComment} - handleDeleteComment={handleDeleteComment} handleLoadMoreReplies={handleLoadMoreReplies} isLoadingReplies={Boolean(replyPagingByRootId[comment.commentId]?.loading)} /> @@ -671,15 +734,26 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: )}
- {hasNextComments && ( - + {!isLoadingComments && !commentLoadError && hasNextComments && ( +
+ {loadMoreCommentError && ( +

+ {loadMoreCommentError} +

+ )} + +
)}
@@ -720,22 +794,29 @@ function CommentRow({ handleStartEditComment, handleCancelEditComment, handleUpdateComment, - handleDeleteComment, + activeDeleteCommentId, + deleteCommentPassword, + setDeleteCommentPassword, + deleteCommentError, + submittingDeleteComment, + handleStartDeleteComment, + handleCancelDeleteComment, + handleConfirmDeleteComment, handleLoadMoreReplies, isLoadingReplies, }: { item: CommentItem; isAnonymousPost: boolean; currentUserPublicId: string | null; - activeReplyParentId: number | null; - setActiveReplyParentId: (id: number | null) => void; + activeReplyParentId: string | null; + setActiveReplyParentId: (id: string | null) => void; replyText: string; setReplyText: (text: string) => void; replyAnonPassword: string; setReplyAnonPassword: (value: string) => void; - handleCreateComment: (parentId: number | null) => Promise; + handleCreateComment: (parentId: string | null) => Promise; submittingComment: boolean; - activeEditCommentId: number | null; + activeEditCommentId: string | null; editCommentText: string; setEditCommentText: (text: string) => void; editCommentPassword: string; @@ -745,11 +826,19 @@ function CommentRow({ handleStartEditComment: (comment: CommentItem) => void; handleCancelEditComment: () => void; handleUpdateComment: (comment: CommentItem) => Promise; - handleDeleteComment: (comment: CommentItem) => Promise; - handleLoadMoreReplies: (rootCommentId: number) => Promise; + activeDeleteCommentId: string | null; + deleteCommentPassword: string; + setDeleteCommentPassword: (password: string) => void; + deleteCommentError: string; + submittingDeleteComment: boolean; + handleStartDeleteComment: (commentId: string) => void; + handleCancelDeleteComment: () => void; + handleConfirmDeleteComment: (comment: CommentItem) => Promise; + handleLoadMoreReplies: (rootCommentId: string) => Promise; isLoadingReplies: boolean; }) { const canEdit = canEditComment(item); + const canDelete = canDeleteComment(item); const isEditing = activeEditCommentId === item.commentId; const toggleReplyEditor = () => { setActiveReplyParentId(activeReplyParentId === item.commentId ? null : item.commentId); @@ -760,7 +849,22 @@ function CommentRow({
{getWriterName(item)} - {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
+ {formatCommentDate(item.createdAt)} + {canDelete && ( + handleStartDeleteComment(item.commentId)} + onClose={handleCancelDeleteComment} + password={deleteCommentPassword} + setPassword={setDeleteCommentPassword} + error={activeDeleteCommentId === item.commentId ? deleteCommentError : ""} + submitting={submittingDeleteComment} + onConfirm={() => void handleConfirmDeleteComment(item)} + /> + )} +
{isEditing ? ( )} - {!item.isDeleted && ( - - )}
)} @@ -813,7 +912,14 @@ function CommentRow({ handleStartEditComment={handleStartEditComment} handleCancelEditComment={handleCancelEditComment} handleUpdateComment={handleUpdateComment} - handleDeleteComment={handleDeleteComment} + activeDeleteCommentId={activeDeleteCommentId} + deleteCommentPassword={deleteCommentPassword} + setDeleteCommentPassword={setDeleteCommentPassword} + deleteCommentError={deleteCommentError} + submittingDeleteComment={submittingDeleteComment} + handleStartDeleteComment={handleStartDeleteComment} + handleCancelDeleteComment={handleCancelDeleteComment} + handleConfirmDeleteComment={handleConfirmDeleteComment} /> ))}
@@ -892,7 +998,14 @@ function ReplyRow({ handleStartEditComment, handleCancelEditComment, handleUpdateComment, - handleDeleteComment, + activeDeleteCommentId, + deleteCommentPassword, + setDeleteCommentPassword, + deleteCommentError, + submittingDeleteComment, + handleStartDeleteComment, + handleCancelDeleteComment, + handleConfirmDeleteComment, }: { item: CommentItem; onReply: () => void; @@ -906,17 +1019,40 @@ function ReplyRow({ handleStartEditComment: (comment: CommentItem) => void; handleCancelEditComment: () => void; handleUpdateComment: (comment: CommentItem) => Promise; - handleDeleteComment: (comment: CommentItem) => Promise; + activeDeleteCommentId: string | null; + deleteCommentPassword: string; + setDeleteCommentPassword: (password: string) => void; + deleteCommentError: string; + submittingDeleteComment: boolean; + handleStartDeleteComment: (commentId: string) => void; + handleCancelDeleteComment: () => void; + handleConfirmDeleteComment: (comment: CommentItem) => Promise; }) { const canEdit = canEditComment(item); + const canDelete = canDeleteComment(item); return (
{getWriterName(item)} - - {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - +
+ + {formatCommentDate(item.createdAt)} + + {canDelete && ( + handleStartDeleteComment(item.commentId)} + onClose={handleCancelDeleteComment} + password={deleteCommentPassword} + setPassword={setDeleteCommentPassword} + error={activeDeleteCommentId === item.commentId ? deleteCommentError : ""} + submitting={submittingDeleteComment} + onConfirm={() => void handleConfirmDeleteComment(item)} + /> + )} +
{isEditing ? ( )} -
)}
@@ -1032,7 +1165,7 @@ function canEditComment(comment: CommentItem) { return comment.canEdit; } -function updateCommentContent(comments: CommentItem[], commentId: number, content: string) { +function updateCommentContent(comments: CommentItem[], commentId: string, content: string) { return comments.map((comment) => { if (comment.commentId === commentId) return { ...comment, content }; return { @@ -1043,3 +1176,113 @@ function updateCommentContent(comments: CommentItem[], commentId: number, conten }; }); } + +function CommentDeleteInline({ + comment, + isActive, + onOpen, + onClose, + password, + setPassword, + error, + submitting, + onConfirm, +}: { + comment: CommentItem; + isActive: boolean; + onOpen: () => void; + onClose: () => void; + password: string; + setPassword: (val: string) => void; + error: string; + submitting: boolean; + onConfirm: () => void; +}) { + return ( +
+ + + {isActive && ( + <> + {/* 외부 클릭 시 닫히도록 투명 백드롭 오버레이 */} +
+ + {/* 시간 및 아이콘 바로 아래에 완벽하게 플로팅되는 팝오버 (레이아웃 밀림 0) */} +
+ {comment.requiresDeletePassword ? ( + setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onConfirm(); + } else if (e.key === "Escape") { + onClose(); + } + }} + autoFocus + className="h-6 w-28 bg-white px-1.5 text-xs text-black outline-none border-r border-black placeholder:text-gray-400" + /> + ) : ( + + 삭제할까요? + + )} + + + {error && ( +
+ {error} +
+ )} +
+ + )} +
+ ); +} + +function formatCommentDate(dateString: string): string { + try { + const d = new Date(dateString); + if (isNaN(d.getTime())) return dateString; + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const hh = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); + const ss = String(d.getSeconds()).padStart(2, "0"); + return `${mm}.${dd} ${hh}:${min}:${ss}`; + } catch { + return dateString; + } +} + +function canDeleteComment(comment: CommentItem): boolean { + return comment.canDelete; +}