--- phase: design agent: design-advisor agent_version: 1 generated_at: 2026-06-18T12:10:00+09:00 concerns: - "동결영역(W2-3 평점 집계 계약) 비침범 확인: game_reviews 는 rating 컬럼(평점 공급원)까지만 정의. 집계 컬럼/뷰/트리거를 만들지 않음. 후속 집계가 SELECT AVG(rating)/COUNT 로 읽을 수 있도록 rating(NOT NULL, 1~5 CHECK)·is_delete·game_id 인덱스만 보장. 집계 컬럼 신설은 명시적으로 비목표." - "동결영역(schema.sql 비권위 타입) 비신뢰 확인: game_reviews 신규 DDL 의 인접 타입(users.id, games.id)을 schema.sql 비권위 복원본에서 복사하지 않고 recruit_posts 권위 스타일(bigint nextval, timestamptz, partial index, ON DELETE CASCADE 미사용)을 기준으로 작성. game_comments user_id 추가도 동일 권위 스타일." - "시그니처 inflate 재확인 필요: GameReviewsMapper.getGameReview / GameCommentsMapper.listGameComments / isOperator(role) 의 모든 인자가 구현에서 실제 사용되는지 구현 단계 unused 진단 게이트(프로토콜 §11.2)에서 재확인. 본 설계는 최소 인자 원칙으로 명세했으나 헬퍼(isOperator)는 inflate 유혹 영역." - "운영자 role 부여 경로 부재(W1 연결): ROLE_ADMIN 상수는 정의하나 실제 ADMIN 부여자가 코드에 없음(USER 만 발급). isOperator 분기는 단위테스트로만 검증 가능. 운영 환경에서 ADMIN 사용자 생성 경로는 W1 작업 범위 — 본 설계 범위 밖." concerns_checked: true references: requirements: null research: /Users/wemadeplay/workspace/stz/bibimbap/.atp/work-session/20260618-104034/research/code-coupling.md adrs: - /Users/wemadeplay/workspace/stz/bibimbap/docs/recruit-posts-ddl.sql - /Users/wemadeplay/workspace/stz/bibimbap/docs/security-hardening-ddl.sql - /Users/wemadeplay/workspace/stz/bibimbap/docs/security/security-remediation-checklist.md --- # 설계: W3-2 게임 댓글/리뷰 분리 (서버 영속화 + 리뷰 신설) ## 목표 / 비목표 ### 목표 - (G1) 게임 상세 댓글을 **localStorage → 서버 영속화**로 전환. 작성자 = 로그인 사용자(user_id 귀속). content 200자 제한. - (G2) 게임당 1회 **리뷰(별점 1~5 + 서술 평가)** 도메인 신설. 수정 시 "수정됨" 마커 + 이력 보존. - (G3) 댓글/리뷰 수정·삭제 권한 = `작성자 본인 OR 운영자급 role`. 운영자 분기는 단위테스트로 검증 가능. - (G4) 게임 soft-delete 시 game_reviews 동반 정리(cascade) 추가. - (G5) 보안 체크리스트 line 101~117(댓글/리뷰 해당분) 동시 충족: CSRF 게이트 / 길이 제한 / 작성자·운영자 삭제 / 서버 escape·textContent / 서버 응답 기준 렌더 / 새로고침·브라우저변경 영속. - (G6) DDL 3종 산출(schema.sql 블록, docs 권위 파일, idempotent ALTER). ### 비목표 - 좋아요(localStorage) 변경 — **범위 밖, 미변경**. - 평점 집계 컬럼/뷰/트리거 신설 — **범위 밖**(W2-3 동결 계약 보호). rating 공급원까지만. - 다축(육각형) 평점 — **후속**. DDL 은 단일 rating 컬럼으로 확정하되 향후 축 테이블 분리 여지를 주석으로만 남김. - 기존 localStorage 댓글 마이그레이션 — **비마이그레이션 확정**. - UI/JSP/CSS 비주얼 구현 — **별도 `/frontend-design` 위임**. 본 설계는 API 계약 + escape 규약 + textContent 규약 + data 형태만 명시. - 운영자(ADMIN) 사용자 부여 경로 — W1 연결, 범위 밖(상수만 정의). --- ## 개요 프로젝트는 **controller→mapper 직결**(서비스 계층 없음), **커스텀 HttpSession 인증 + 커스텀 CsrfTokens**(Spring Security 부재), **MyBatis annotation + 명시 시퀀스 PostgreSQL**, **soft-delete(is_delete) 규약**이다(research 종합). W3-2 는 이 패턴을 그대로 따라 ① 기존 orphan 인 `GameCommentsMapper`/`game_comments` 를 서버 API 로 연결(user_id 컬럼 nullable 추가)하고, ② 신규 `game_reviews` 도메인(테이블·POJO·Mapper·컨트롤러)을 recruit_posts 권위 스타일로 추가한다. 표준 상태변경 시퀀스는 RecruitController.createRecruitPost(`RecruitController.java:49-143`)를 골격으로, 작성자/운영자 권한 분기는 GameController(`GameController.java:183-185`, `:239-241`)를 합성한다. 신규 운영자 분기(`isOperator`)는 session attr `role`(`UserController.java:508`)을 `ROLE_ADMIN` 상수와 비교한다. --- ## 플로우 ### F1. 댓글 작성 (POST /game/{id}/comments) 1. 진입: `@Transactional` 2. `CsrfTokens.isValid(request)` 실패 → 403 `CsrfTokens.errorBody()` 3. `sessionUserId(session)` null → 401 `{status:401,message:"로그인이 필요합니다."}` 4. 게임 존재 확인 `gamesMapper.getGame(id)` null → 404 5. content `trimToNull` → null 또는 `length()>200` → 400 `"덧글은 200자 이내로 입력해 주세요."` 6. POJO 세팅(gameId, userId, content; nickname = session displayName 스냅샷) → `gameCommentsMapper.addGameComment` 7. 생성 id null → 500 8. 종단: 200 JSON `{status:200, message, commentId, gameId, content, authorName, userId, createdAt}` (프론트가 textContent 로 즉시 append 가능한 data 형태) ### F2. 댓글 목록 조회 - **초기 진입은 model 주입**: `GameController.gameDetail(:103-129)` 의 DB 게임 분기(`:104-109`)에서 `addGameModel` 이후 `model.addAttribute("comments", gameCommentsMapper.listGameComments(id))` 추가. 정적 GameCatalog 폴백 분기(`:111-128`)는 DB 미존재 게임이므로 빈 리스트(`model.addAttribute("comments", List.of())`). - **작성·수정·삭제 이후 갱신은 fetch GET API**: `GET /game/{id}/comments` → JSON 배열. (완전 서버화 = 초기 model 주입 + mutation 이후 fetch 재조회 혼합. 명시 확정.) ### F3. 댓글 수정 (PUT /game/{id}/comments/{commentId}) 1~3. (F1 과 동일: Transactional → CSRF → 로그인) 4. `gameCommentsMapper.getGameComment(commentId)` null → 404. `gameId` 불일치 → 404(경로 위변조 방지). 5. **권한**: `userId.equals(comment.getUserId()) || isOperator(role)` 거짓 → 403 `"작성자만 수정할 수 있습니다."` (단, comment.userId == null 인 레거시 닉네임 댓글은 작성자 매칭 불가 → 운영자만 수정 가능) 6. content 200자 검증(F1-5 동일) 7. `gameCommentsMapper.editGameComment` (content 갱신, is_delete/deleted_at 비변경) 8. 200 JSON ### F4. 댓글 삭제 (DELETE /game/{id}/comments/{commentId}) 1~5. (F3 과 동일 권한 분기) 6. `gameCommentsMapper.softDeleteGameComment(commentId)` (is_delete=true, deleted_at=now()) 7. 200 JSON `{status:200, message}` ### F5. 리뷰 작성 (POST /game/{id}/reviews) 1~4. (Transactional → CSRF → 로그인 → 게임 존재) 5. rating: `Integer` 파싱 실패 또는 `<1 || >5` → 400 `"별점은 1~5 사이로 선택해 주세요."` 6. body(서술) `trimToEmpty` → `length()>1000` → 400 (recruit description 1200 대비 보수적 1000자) 7. **게임당 1회**: `gameReviewsMapper.getActiveReviewByGameAndUser(gameId, userId)` 존재 → 409 `"이미 이 게임에 리뷰를 작성하셨습니다."` (앱레벨 선검사 + DB partial UNIQUE 가 2차 방어) 8. `gameReviewsMapper.addGameReview` (DB UNIQUE 위반 시 DataIntegrityViolation → ApiExceptionControllerAdvice 가 처리하거나 사전 409 가 차단) 9. 생성 id null → 500 10. 200 JSON `{status:200, message, reviewId, gameId, rating, body, authorName, userId, edited:false, createdAt, updatedAt}` ### F6. 리뷰 수정 (PUT /game/{id}/reviews/{reviewId}) — "수정됨" + 이력 보존 1~3. (Transactional → CSRF → 로그인) 4. `gameReviewsMapper.getGameReview(reviewId)` null → 404. gameId 불일치 → 404. 5. 권한: `userId.equals(review.getUserId()) || isOperator(role)` 거짓 → 403. 6. rating/body 검증(F5-5,6 동일) 7. **이력 보존(in-row 채택, 근거는 데이터 모델 절)**: 수정 전 `updated_at = now()` 갱신. `created_at` 불변. → `edited` 판별식 `updated_at > created_at`. (별도 history 테이블 미채택 — 노출은 "수정됨" 마커만, 이력 열람권한 이월.) 8. `gameReviewsMapper.editGameReview` (rating, body, updated_at=now() 갱신) 9. 200 JSON (`edited:true`) ### F7. 리뷰 조회 - **목록**: `GET /game/{id}/reviews` → JSON 배열 (또는 게임 상세 model 주입 `reviews`). 댓글과 동일하게 초기 model 주입 + mutation 후 fetch 혼합. - **단건**: `GET /game/{id}/reviews/{reviewId}` → JSON 단건. ### F8. 리뷰 삭제 (DELETE /game/{id}/reviews/{reviewId}) 1~5. (F6 권한 분기 동일) 6. `gameReviewsMapper.softDeleteGameReview(reviewId)` (is_delete=true, deleted_at=now()) 7. 200 JSON ### F9. 게임 삭제 cascade (기존 deleteGame 확장) `GameController.deleteGame(:243-245)` 의 cascade 블록에 한 줄 추가: ``` gamesMapper.softDeleteGameComments(id); // 기존 gamesMapper.softDeleteGameReviews(id); // 신규 추가 (review = soft-delete 채택, comments 미러) gamesMapper.deleteGameLikes(id); // 기존 (좋아요 미변경) gamesMapper.softDeleteGame(id); // 기존 ``` --- ## 데이터 모델 ### D1. game_comments 변경 (비파괴, QG-2) - 추가 컬럼: `user_id bigint NULL REFERENCES "users"("id")` — 작성자 귀속. **NULL 허용**(기존 레코드/레거시 닉네임 댓글 보존, 파괴적 마이그레이션 없음). - 기존 컬럼(id/game_id/nickname/content/created_at/deleted_at/is_delete) 전부 유지. - **content 200자 제약 권위 = 앱레벨**(컨트롤러 검증). DB CHECK 미적용. 근거: ① 기존 컬럼 타입이 `text`(길이 무제한)이고 운영 DB 실제 타입이 비권위(schema.sql:8-11)라 DB CHECK 추가가 기존 레코드와 충돌 위험, ② recruit_posts 도 길이는 앱레벨 검증(`RecruitController.java:80-114`)으로만 처리하고 DB CHECK 는 enum 값(role/participation_type)에만 사용하는 선례. → 200자는 앱레벨 권위, DB 는 무제한 text 유지. - FK 는 `ON DELETE CASCADE` 미사용(앱레벨 cascade 규약). user soft-delete 시 댓글 user_id 는 dangling 가능하나 조회는 user JOIN 없이 nickname 스냅샷으로 표시(아래). #### 댓글 작성자 표시명 전략 - INSERT 시 `nickname` 에 **작성 시점 session displayName 스냅샷**을 저장(기존 nickname 컬럼 재사용). 동시에 `user_id` 저장. - 목록 조회 SELECT 는 users JOIN 없이 `nickname` 을 그대로 표시명으로 사용 → 레거시 NULL user_id 댓글(기존 '익명' 닉네임)도 동일 경로로 표시. JOIN 부재로 user soft-delete dangling 무영향. ### D2. game_reviews 신규 (recruit_posts 권위 스타일) | 컬럼 | 타입 | 제약 | 역할 | |---|---|---|---| | id | bigint | PK, DEFAULT nextval('game_reviews_id_seq') | 리뷰 고유 ID | | game_id | bigint | NOT NULL, FK→games(id) | 대상 게임 | | user_id | bigint | NOT NULL, FK→users(id) | 작성자 (리뷰는 댓글과 달리 로그인 필수 → NOT NULL) | | rating | smallint | NOT NULL, CHECK(rating BETWEEN 1 AND 5) | 별점 5점 단일 (향후 다축은 별도 game_review_axes 테이블 분리 — 주석으로만) | | body | text | NULL | 서술 평가 (앱레벨 1000자 제한, DB 무제한) | | created_at | timestamptz | DEFAULT now() NOT NULL | 작성 시각 | | updated_at | timestamptz | DEFAULT now() NOT NULL | 마지막 수정 시각 | | deleted_at | timestamptz | NULL | soft-delete 시각 | | is_delete | boolean | DEFAULT false NOT NULL | soft-delete 플래그 | - **게임당 1회 제약**: partial UNIQUE INDEX `CREATE UNIQUE INDEX ux_game_reviews_game_user_active ON game_reviews (game_id, user_id) WHERE is_delete IS NOT TRUE;` (security-hardening 의 active-unique 선례 `ux_user_auth_identities_..._active` 와 동형. soft-delete 된 리뷰는 제약 제외 → 삭제 후 재작성 허용.) - **"수정됨" 판별 = in-row `updated_at > created_at`** (별도 boolean/edit_count 컬럼 불요). 채택 근거: ① 기존 테이블 전부 created_at/updated_at 쌍 보유(games/recruit_posts), ② 추가 컬럼 0개로 inflate 회피, ③ 노출 요구가 "수정됨" 마커 단일이라 횟수 불필요. - **이력 보존 = in-row 채택(별도 history 테이블 미생성)**. 근거: 노출 요구는 "수정됨" 마커만이고 이력 **열람** 권한은 이월(범위 밖). history 테이블은 열람 UI·권한이 정해질 때(후속) 신설하는 편이 inflate 회피에 부합. 현재는 `updated_at` 으로 "수정 발생 사실"만 보존. (history 가 필요해지면 game_review_history 를 후속 추가 — 본 설계의 in-row 결정과 충돌하지 않음.) - FK `ON DELETE CASCADE` 미사용(앱레벨 cascade). 게임 삭제 시 `softDeleteGameReviews(gameId)` 로 정리(F9). - 인덱스: `CREATE INDEX idx_game_reviews_game ON game_reviews (game_id) WHERE is_delete = false;` (목록 조회 + 후속 집계 SELECT 의 game_id 필터용. **집계 컬럼/뷰는 만들지 않음** — W2-3 동결 보호.) ### D3. POJO (data/GameReviewData.java 신규) 필드: `Long id, Long gameId, Long userId, Integer rating, String body, OffsetDateTime createdAt, OffsetDateTime updatedAt, OffsetDateTime deletedAt, String authorName`(목록 JOIN alias, 비영속), `Boolean edited`(`updated_at > created_at` SELECT 계산 alias, 비영속). - GameCommentData 변경: `Long userId` 필드 추가(나머지 기존 유지). --- ## 외부 계약 (HTTP API) 응답은 전부 `ResponseEntity>` JSON `{status,message,...}`. 조회 GET 은 게임 상세 진입 시 model 주입 + 별도 fetch GET 은 JSON 배열. ### 댓글 (5 엔드포인트) | # | 메서드 | URL | 요청 | 성공 | 에러 | |---|---|---|---|---|---| | C1 | GET | /game/{id}/comments | — | 200 `{status,comments:[{commentId,gameId,authorName,userId,content,createdAt}]}` | 404(게임없음) | | C2 | POST | /game/{id}/comments | body: `content`(form param) | 200 `{status,commentId,gameId,authorName,userId,content,createdAt}` | 403 CSRF / 401 / 404 / 400(공백·201자+) | | C3 | PUT | /game/{id}/comments/{commentId} | `content` | 200 `{status,commentId,content}` | 403 CSRF / 401 / 404 / 403(권한) / 400 | | C4 | DELETE | /game/{id}/comments/{commentId} | — | 200 `{status,message}` | 403 CSRF / 401 / 404 / 403(권한) | (C2 의 200자 거부: `content.length() > 200` → 400. 201자 입력은 거부.) ### 리뷰 (6 엔드포인트) | # | 메서드 | URL | 요청 | 성공 | 에러 | |---|---|---|---|---|---| | R1 | GET | /game/{id}/reviews | — | 200 `{status,reviews:[{reviewId,gameId,authorName,userId,rating,body,edited,createdAt,updatedAt}]}` | 404 | | R2 | GET | /game/{id}/reviews/{reviewId} | — | 200 `{status,review:{...}}` | 404 | | R3 | POST | /game/{id}/reviews | `rating`(1~5), `body` | 200 `{status,reviewId,...,edited:false}` | 403 CSRF / 401 / 404 / 400(rating·body) / 409(중복) | | R4 | PUT | /game/{id}/reviews/{reviewId} | `rating`, `body` | 200 `{status,reviewId,...,edited:true}` | 403 CSRF / 401 / 404 / 403(권한) / 400 | | R5 | DELETE | /game/{id}/reviews/{reviewId} | — | 200 `{status,message}` | 403 CSRF / 401 / 404 / 403(권한) | > 엔드포인트 총합 = 댓글 4 + 리뷰 5 = **9 mutation/fetch 엔드포인트** (C1/R1/R2 GET 3, C2~C4 댓글 mutation 3, R3~R5 리뷰 mutation 3). AC-AGG-1 에서 전수 검증. ### 컨트롤러 배치 - 댓글: 신규 `controller/api/GameCommentController.java` (game_comments 도메인 전담; GameController 비대화 회피). - 리뷰: 신규 `controller/api/GameReviewController.java`. - 두 컨트롤러 모두 RecruitController/GameController 와 동일한 private 헬퍼(`sessionUserId`, `trimToNull`, `trimToEmpty`, `response`) 복붙 패턴 유지(프로젝트 기존 관행 — 3곳 복붙 선례). **새 추상화/서비스 계층 도입 금지**(범위 밖, inflate 회피). --- ## 권한 모델 ```java // 운영자 role 상수 (신규). UserController.ROLE_USER="USER"(:43) 와 동일 위치 관행. public static final String ROLE_ADMIN = "ADMIN"; // 운영자 role 값. 실제 부여자 없음(W1 연결). // isOperator: 인자는 role 문자열 1개만 — 최소 인자 원칙. (inflate 회피) private boolean isOperator(String role) { // role: session attr "role" 스냅샷 (UserController.java:508) return ROLE_ADMIN.equals(role); } // 권한 합성: 작성자 본인 OR 운영자 // authorUserId 는 댓글/리뷰의 user_id (댓글은 NULL 가능 → 레거시는 작성자 매칭 불가, 운영자만) private boolean canModify(Long currentUserId, Long authorUserId, String role) { // currentUserId: 현재 로그인 사용자 (sessionUserId) // authorUserId: 대상 글 작성자 user_id // role: 현재 사용자 role (운영자 분기용) return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role); } ``` - role 획득: `(String) session.getAttribute("role")` (로그인 시 `UserController.java:508` 저장). 비로그인은 4번 단계(sessionUserId null)에서 이미 401 차단되므로 role 은 로그인 사용자 한정. - 비로그인 → 401. 권한없음 → 403 `"작성자만 수정/삭제할 수 있습니다."`. - GameController:183-185(수정)/239-241(삭제) 작성자 패턴에 isOperator OR 분기를 합성한 것이 canModify. --- ## 게임 삭제 cascade - **GamesMapper 신규 메서드** (comments soft-delete 미러): ```java @Update(""" UPDATE game_reviews SET is_delete = true, deleted_at = COALESCE(deleted_at, now()) WHERE game_id = #{gameId} AND is_delete IS NOT TRUE """) int softDeleteGameReviews(@Param("gameId") long gameId); ``` - **GameController.deleteGame** cascade 블록(`:243-245`)에 `gamesMapper.softDeleteGameReviews(id);` 한 줄 추가(F9 순서). 좋아요는 미변경. --- ## 파일 영향 맵 | 변경 유형 | 경로 | 역할 | |---|---|---| | 변경 | `db/schema.sql` | dev 블록에 game_comments user_id 컬럼 추가 + game_reviews CREATE 블록 추가 | | 신규 | `docs/game-reviews-ddl.sql` | game_reviews 권위 DDL (recruit-posts-ddl.sql 선례) | | 신규 | `docs/game-reviews-ddl.sql` 동봉 또는 별도 `docs/game-comments-user-id-ddl.sql` | 기존 DB 적용용 idempotent ALTER (game_comments user_id + game_reviews) — security-hardening-ddl.sql DO $$ 패턴 | | 변경 | `src/main/java/.../data/GameCommentData.java` | `Long userId` 필드 + getter/setter 추가 | | 신규 | `src/main/java/.../data/GameReviewData.java` | 리뷰 POJO | | 변경 | `src/main/java/.../mapper/GameCommentsMapper.java` | listGameComments / editGameComment / softDeleteGameComment 추가, getGameComment/addGameComment SELECT·INSERT 에 user_id 반영 | | 신규 | `src/main/java/.../mapper/GameReviewsMapper.java` | 리뷰 CRUD 매퍼 | | 변경 | `src/main/java/.../mapper/GamesMapper.java` | softDeleteGameReviews 추가 | | 신규 | `src/main/java/.../controller/api/GameCommentController.java` | 댓글 API (C1~C4) | | 신규 | `src/main/java/.../controller/api/GameReviewController.java` | 리뷰 API (R1~R5), ROLE_ADMIN 상수·isOperator·canModify | | 변경 | `src/main/java/.../controller/api/GameController.java` | gameDetail(:107) model 에 comments/reviews 주입 + deleteGame(:243) cascade 한 줄 | | 변경(위임) | `src/main/webapp/WEB-INF/views/game-detail.jsp` | 댓글 localStorage JS(:807-1009 중 댓글부) → fetch 교체, 리뷰 위젯 추가 — **/frontend-design 위임** | | 신규(test) | `src/test/.../GameCommentControllerTest.java` | CSRF/200자/권한/운영자 분기 | | 신규(test) | `src/test/.../GameReviewControllerTest.java` | 게임당1회/rating/수정됨/운영자 분기 | ### DDL 3종 골격 **① `db/schema.sql` (dev 블록 내 game_comments 직후 추가)** ```sql -- game_comments user_id 컬럼 (W3-2: 작성자 귀속, nullable 비파괴) ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint REFERENCES "users"("id"); -- --------------------------------------------------------------------------- -- game_reviews (권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규) -- --------------------------------------------------------------------------- CREATE SEQUENCE IF NOT EXISTS "game_reviews_id_seq"; CREATE TABLE IF NOT EXISTS "game_reviews" ( "id" bigint DEFAULT nextval('game_reviews_id_seq'::regclass) NOT NULL, "game_id" bigint NOT NULL REFERENCES "games"("id"), "user_id" bigint NOT NULL REFERENCES "users"("id"), "rating" smallint NOT NULL, "body" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, "deleted_at" timestamp with time zone, "is_delete" boolean DEFAULT false NOT NULL, PRIMARY KEY ("id"), CONSTRAINT "game_reviews_rating_check" CHECK ("rating" BETWEEN 1 AND 5) ); ALTER SEQUENCE "game_reviews_id_seq" OWNED BY "game_reviews"."id"; CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_reviews_game_user_active" ON "game_reviews" ("game_id", "user_id") WHERE "is_delete" IS NOT TRUE; CREATE INDEX IF NOT EXISTS "idx_game_reviews_game" ON "game_reviews" ("game_id") WHERE "is_delete" = false; -- 향후 다축(육각형) 확장 시: rating 유지 + game_review_axes(review_id, axis, score) 별도 테이블 분리. 집계 컬럼/뷰는 W2-3 동결 — 신설 금지. ``` **② `docs/game-reviews-ddl.sql` (권위 파일)** — 위 game_reviews CREATE + 시퀀스 + UNIQUE/일반 인덱스 + FK/CHECK 를 DO $$ idempotent 블록(recruit-posts-ddl.sql 형식) + COMMENT ON COLUMN 전 컬럼. rating/body/edited 의미 주석. **③ `docs/game-reviews-ddl.sql` 하단 또는 동봉 ALTER 섹션 (기존 DB 적용용 idempotent)** ```sql -- game_comments user_id (멱등) ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='game_comments_user_id_fkey') THEN ALTER TABLE "game_comments" ADD CONSTRAINT "game_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id"); END IF; END $$; -- game_reviews: CREATE SEQUENCE/TABLE IF NOT EXISTS + DO $$ FK/CHECK + CREATE UNIQUE INDEX IF NOT EXISTS (위 ① 와 동일, 멱등 보장) ``` --- ## 신규 시그니처 (inflate 방지 — 각 인자 사용 목적 인라인) ### GameCommentsMapper (변경) ```java List listGameComments(long gameId); // gameId: 목록 필터 (is_delete IS NOT TRUE) int editGameComment(GameCommentData c); // c: id+content (content 만 갱신) int softDeleteGameComment(long id); // id: 대상 댓글 (is_delete=true, deleted_at=now()) // getGameComment: SELECT 에 user_id AS userId 추가. addGameComment: INSERT 에 user_id 추가. ``` - listGameComments SELECT: `id AS commentId? → id, game_id AS gameId, nickname AS authorName, user_id AS userId, content, created_at AS createdAt FROM game_comments WHERE game_id=#{gameId} AND is_delete IS NOT TRUE ORDER BY created_at ASC, id ASC`. (users JOIN 없음 — nickname 스냅샷 표시.) ### GameReviewsMapper (신규) ```java GameReviewData getGameReview(long id); // id: 단건/권한확인 List listGameReviews(long gameId); // gameId: 목록 필터 GameReviewData getActiveReviewByGameAndUser(@Param("gameId") long gameId, @Param("userId") long userId); // 게임당1회 선검사 int addGameReview(GameReviewData r); // r: gameId/userId/rating/body int editGameReview(GameReviewData r); // r: id/rating/body (updated_at=now() SET 문 명시) int softDeleteGameReview(long id); // id: is_delete=true ``` - list/get SELECT 의 edited alias: `(updated_at > created_at) AS edited`. authorName: users JOIN `u.display_name AS authorName`(리뷰는 user_id NOT NULL 이므로 JOIN 안전, `u.is_delete IS NOT TRUE` 필터 — soft-delete 사용자 리뷰는 목록서 누락 가능, 허용). ### isOperator / canModify - 위 권한 모델 절 참조. **inflate 재확인 대상**(concerns 마킹): isOperator(role) 단일 인자, canModify(currentUserId, authorUserId, role) 3인자 — 전부 본문에서 사용. 구현 시 unused 발생하면 즉시 제거. --- ## 보안 (체크리스트 line 101~117 댓글/리뷰 해당분 충족 매핑) | 체크리스트 line | 충족 방식 | |---|---| | 105 `GET /game/{id}/comments` 또는 모델 주입 결정 | F2: 초기 model 주입 + mutation 후 fetch GET(C1) 혼합 — 확정 | | 106 POST 댓글 CSRF/길이/작성자 | F1: CsrfTokens.isValid + 200자 앱레벨 + user_id 귀속 | | 107 댓글 삭제 작성자/관리자 | F4 canModify(작성자 OR isOperator) | | 108 서버 댓글도 escape/textContent | 서버 렌더 시 HtmlUtils.htmlEscape(JSP), 클라이언트 동적삽입 textContent (research 포인트9 규약 유지) | | 110 localStorage UI 서버 응답 기준 교체 | F1~F4 fetch + 초기 model 주입으로 localStorage 댓글부 제거(위임: frontend-design) | | 114 새로고침/브라우저변경 영속 | 서버 DB 영속(localStorage 비의존) | | 115 토큰 없는 변경 실패 | 전 mutation 첫 게이트 CsrfTokens.isValid → 403 | | 116 XSS payload 미실행 | escape/textContent (108) | | 117 게임 삭제 시 정리 | F9 softDeleteGameComments(기존) + softDeleteGameReviews(신규) | - 좋아요 관련 line(101~104)은 범위 밖 — 본 설계 미해결(좋아요 미변경 명시). --- ## 프론트엔드 경계 (/frontend-design 위임) 본 설계가 제공하는 위임 입력: - **소비 API 계약**: 위 댓글 C1~C4 / 리뷰 R1~R5 표(URL·메서드·요청 param·응답 JSON 형태). - **CSRF 규약**: 모든 mutation 요청에 `BibimbapCsrf.headers()` (theme-init.jsp:19-31) 또는 `_csrf` 파라미터. 메타 `csrf-token` 전역 제공됨. - **escape 규약**: 서버 렌더(JSP scriptlet `<%= HtmlUtils.htmlEscape(...) %>`); 클라이언트 동적 삽입은 `textContent`(innerHTML 금지, listEl 초기화 `innerHTML=''` 만 허용). - **필요 data 형태**: 댓글 `{commentId,authorName,userId,content,createdAt}`; 리뷰 `{reviewId,authorName,userId,rating,body,edited,createdAt,updatedAt}`. `edited:true` 일 때 "수정됨" 마커 표시. 별점 위젯은 rating 1~5 정수 송수신. - **소유자/권한 힌트**: 응답의 `userId` 와 로그인 사용자(`owner` 모델 또는 별도 노출) 비교로 수정·삭제 버튼 노출 결정(서버가 최종 권한 재검증). **위임 경계(본 설계 비포함)**: 실제 JSP 마크업, CSS, 별점 위젯 비주얼/인터랙션, "수정됨" 마커 위치·스타일. --- ## 대안 비교 | 결정 | 채택 | 대안 | 채택 근거 | |---|---|---|---| | 댓글/리뷰 분리 | 2 테이블 (game_comments 변경 + game_reviews 신규) | 단일 테이블 type 컬럼 | rating/edited 등 리뷰 전용 컬럼이 댓글에 dead → 사용자 plan gate 확정 | | content 200자 권위 | 앱레벨 검증 | DB CHECK | 기존 text 컬럼·비권위 운영타입·recruit 선례(길이는 앱레벨) | | "수정됨" 판별 | in-row updated_at>created_at | boolean edited / edit_count | 추가 컬럼 0개, 기존 created/updated 쌍 활용, 노출요구 마커 단일 | | 이력 보존 | in-row(updated_at) | 별도 game_review_history | 이력 열람 권한 이월(범위 밖) → 후속 신설이 inflate 회피 | | 게임당 1회 | partial UNIQUE WHERE is_delete IS NOT TRUE | 전체 UNIQUE | soft-delete 후 재작성 허용 + security-hardening active-unique 선례 | | 댓글 작성자 표시 | nickname 스냅샷(JOIN 없음) | users JOIN display_name | 레거시 NULL user_id·user soft-delete dangling 무영향 | | 리뷰 cascade | soft-delete | hard-delete | comments 미러(soft) 일관성 | --- ## 롤아웃 / 마이그레이션 1. **DDL 선적용**: 기존 DB → idempotent ALTER(③) 실행 (game_comments user_id 추가 + game_reviews 생성). 신규 환경은 schema.sql(①)로 자동. 둘 다 `IF NOT EXISTS`/`DO $$` 멱등. 2. **역호환**: game_comments user_id NULL 허용 → 기존 레코드 무손상. orphan 이던 GameCommentsMapper 가 컨트롤러 연결돼도 기존 SELECT(getGameComment) 컬럼 추가는 alias 만 늘어 호환. 3. **배포 순서**: DDL → 백엔드(매퍼/컨트롤러) → 프론트(/frontend-design). 프론트 미배포 상태에서도 백엔드 API 는 독립 동작(기존 localStorage UI 가 잠시 공존 가능, 비파괴). 4. **롤백**: 컨트롤러/매퍼 revert 시 game_reviews 테이블은 잔존(데이터 무손실). game_comments user_id 컬럼은 nullable 이라 revert 후에도 무해. 파괴적 DROP 불요. 5. **localStorage 댓글**: 비마이그레이션(확정). 사용자 브라우저 로컬 데이터는 방치(자연 소멸). --- ## 검증 포인트 (AC) > §4.3 전수 체크 + §4.7 self-audit(시점 안정성·표현 견고성) 적용. ### 기능 AC (원 요청 매핑) - **AC-1 (댓글 CRUD)**: 로그인 사용자가 C2 작성 → C1 목록에 등장, C3 수정 반영, C4 삭제 후 C1 에서 누락. 단위/통합 테스트 4건. (시점: 테스트 내 자족 — 안정.) - **AC-2 (비작성자 불가 + 운영자 예외)**: 사용자 A 작성 댓글을 사용자 B(role=USER)가 C3/C4 → 403. 사용자 C(role=ADMIN)는 C3/C4 → 200. 단위테스트로 isOperator 분기 검증. (운영자 부여자 없음 → 테스트에서 session role="ADMIN" 주입.) - **AC-3 (리뷰 게임당 1회)**: 동일 user+game R3 두 번째 → 409. soft-delete(R5) 후 R3 재작성 → 200 (partial UNIQUE 검증). - **AC-4 (수정 시 "수정됨" + 이력 보존)**: R3 직후 응답 `edited:false`(updated_at==created_at). R4 후 R1/R2 응답 `edited:true`(updated_at>created_at). created_at 불변 확인. - **AC-5 (댓글 201자 거부)**: C2/C3 에 201자 content → 400. 200자 경계 → 200 (off-by-one 경계 테스트). - **AC-6 (rating 범위)**: R3/R4 rating 0 또는 6 → 400. 1·5 경계 → 200. - **AC-7 (CSRF 없는 상태변경 실패)**: C2/C3/C4/R3/R4/R5 를 X-CSRF-Token·_csrf 없이 호출 → 전부 403. (표현: 헤더/파라미터 둘 다 부재 케이스. UserControllerCsrfTest 선례 형식.) - **AC-8 (XSS payload 미실행)**: content/body 에 `