bibimbap/.atp/work-session/20260618-104034/design.md

415 lines
33 KiB
Markdown

---
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<Map<String,Object>>` 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<GameCommentData> 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<GameReviewData> 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 에 `<script>` payload 저장 → JSP 서버 렌더는 `&lt;script&gt;` escape, 클라이언트 textContent 는 문자열 표시. 스크립트 미실행. (검증: 렌더 출력에 raw `<script>` 부재 — escape 확인.)
- **AC-9 (영속성)**: C2/R3 후 새 세션/브라우저로 C1/R1 조회 → 데이터 잔존(DB 영속, localStorage 비의존).
- **AC-10 (게임 삭제 cascade)**: deleteGame 후 해당 game_id 의 game_comments·game_reviews 전부 is_delete=true. softDeleteGameReviews 호출 검증.
### 집합 전수 AC (§4.3)
- **AC-AGG-1 (엔드포인트 전수 9건)**: 댓글 컨트롤러 매핑 4건(@GetMapping C1 + @PostMapping C2 + @PutMapping C3 + @DeleteMapping C4) + 리뷰 컨트롤러 매핑 5건(R1 R2 GET + R3 POST + R4 PUT + R5 DELETE) == 9.
- 검증(불변식, 시점 안정): `grep -cE '@(Get|Post|Put|Delete)Mapping' src/main/java/.../GameCommentController.java` == 4 **AND** `... GameReviewController.java` == 5. (두 파일 합 9. 컨트롤러 파일 한정 → 다른 매핑 오염 없음.)
- §4.7 시점: 두 컨트롤러는 본 작업 산출물이며 구현 완료 시점에 고정 — verification 시점에도 동일. 안정.
- §4.7 표현: 매핑 애너테이션은 Spring 고정 토큰(동의 표현 없음) → 리터럴 grep 견고. 단 메서드 수가 아니라 매핑 애너테이션 수를 센다(헬퍼 메서드 오카운트 방지).
- **AC-AGG-2 (DDL 3종 산출 전수)**: ① schema.sql 에 `game_reviews` CREATE TABLE 블록 존재(`grep -c 'CREATE TABLE IF NOT EXISTS "game_reviews"' db/schema.sql` >= 1) + game_comments user_id ADD COLUMN 존재. ② `docs/game-reviews-ddl.sql` 파일 존재(ls). ③ idempotent ALTER 섹션(②파일 내 `ADD COLUMN IF NOT EXISTS "user_id"` + `DO $$` 블록 존재). 3종 모두 충족.
- **AC-AGG-3 (CSRF 게이트 전수)**: 댓글/리뷰 mutation 6개 핸들러(C2 C3 C4 R3 R4 R5) 전부 진입부 `CsrfTokens.isValid(request)` 호출. `grep -c 'CsrfTokens.isValid' (두 컨트롤러)` == 6. (시점: 산출물 고정. 표현: 메서드명 고정 토큰.)
### §4.7 self-audit 1패스 결과
- AC-AGG-1/AGG-3: 카운트 대상이 **본 작업이 만드는 신규 파일**이라 verification 시점까지 값 불변(자기 트리 증가 대상 아님 — 20260616 AC-3 류 함정 회피). 고정 스칼라 허용. 표현은 Spring/메서드 고정 토큰이라 동의표현 누락 위험 없음.
- AC-8(XSS): 단일 리터럴 grep 의존 회피 — "raw `<script>` 부재 AND escape 출력 존재"의 의미 불변식 + 수동 렌더 확인 병행(escape 함수가 `&lt;`로 바꾸는지). 표현 견고성 확보.
- AC-3/AC-4: 테스트 내 자족 데이터로 시점 독립.
---
## 미해결 / 에스컬레이션
- 없음(전 결정 확정). 단 frontmatter `concerns` 4건은 **동결영역 비침범 확인 기록 + inflate 재확인 마킹 + W1 의존**으로, 차단 이슈가 아니라 구현/검증 시 점검 포인트. orchestrator 반환만 하고 요구사항 수정은 하지 않음.