bibimbap/.atp/work-session/20260622-092800/implementation/design.md

537 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-22T00:00:00+09:00
concerns:
- "C3 재분류 확정: game_review_stats 는 W3-2 일반 DDL(읽기전용 집계뷰). W2-3 동결 묶음 아님 — 근거 jam-platform-roadmap.md:63,201,202. 단 game-reviews-ddl.sql:63 / db/schema.sql:136-137 의 '집계뷰 미신설' 보수 주석이 본 설계로 무효화되므로 구현 시 두 곳 주석 갱신 필수(미갱신 시 차기 세션 혼선)."
- "is_rating_manual 플래그 채택 — game_reviews 행 폭 증가(boolean 1컬럼). 채택 근거는 §5.3. 비채택 대안(매번 axes AVG 재계산)도 가능하나 '유저가 자동평균과 같은 값을 수동선택' 케이스를 구분 못 함 → 채택. 구현에서 이 컬럼이 실제 분기에 쓰이는지(수동/자동 표시 차별화 UI 없으면 dead) 재확인 필요."
- "GameReviewsMapper.editGameReview 의 axes 재저장은 delete-all + insert-6 채택(§5.2). upsert(ON CONFLICT) 대안 대비 단순하나, 6 DELETE+6 INSERT 트랜잭션. axes 매퍼 신규 분리 vs GameReviewsMapper 확장 — 신규 GameReviewAxesMapper 채택(§1, §5). 단일 책임."
- "SVG 육각형 좌표는 JS 런타임 계산 채택(§6). JSP 컴파일타임(JSTL) 대안은 개별 리뷰 N건 동적 렌더와 맞지 않음 — 기존 buildStarDisplay 패턴(JS DOM 생성)과 일관. 좌표 공식은 §6 에 고정."
- "axis_key DB 저장형 = 영문 snake(immersion/creativity/controls/completeness/sound/visual). 한글 라벨(몰입성 등)은 JSP 상수 매핑. enum 순서 = 육각형 각 축 인덱스(0~5)와 1:1 고정 — 순서 바뀌면 레이더 축 위치 변동. §2.1 / §6.3 에 순서 고정."
- "B2 최소 10자: 기존 createReview 는 빈 본문 허용(report Grounded Baseline:80). 본 변경으로 별점만 제출하던 기존 유저 플로우가 막힘 — UX 마찰. FINAL SPEC 확정 사항이므로 진행하되 프론트 안내문(C4 카운터 '최소 10자') 필수."
- "ESCALATION 없음 — requirements(report FINAL SPEC) vs research(roadmap) 충돌 0. C3 오분류는 task 가 이미 정정 지시 → 반영 완료."
references:
requirements: /Users/wemadeplay/workspace/stz/bibimbap/.atp/work-session/20260618-145152/report.md
research: /Users/wemadeplay/workspace/stz/bibimbap/docs/work-log/2026-06-17-jam-platform-roadmap.md
adrs:
- /Users/wemadeplay/workspace/stz/bibimbap/docs/changes/2026-06-18-w3-2-comments-reviews.md
- /Users/wemadeplay/workspace/stz/bibimbap/docs/game-reviews-ddl.sql
- /Users/wemadeplay/workspace/stz/bibimbap/db/schema.sql
---
# 설계: W3-2 댓글/리뷰 고도화 (확정 구현 설계도)
## 목표 / 비목표
### 목표 (FINAL SPEC 추적 가능)
- **A1** 댓글 응답 commentView 단일 통일 (POST·PUT·list 전부 동일 스키마, POST는 insert 후 재조회).
- **A2** 댓글 작성자명 하이브리드 (LEFT JOIN users + CASE 마스킹). QG-2(레거시 user_id NULL) 자동 해결.
- **A3** 댓글 edited+updatedAt (game_comments.updated_at 멱등 ALTER + 매퍼/모델 갱신).
- **B1** 페이지네이션 offset/limit 20건 "더보기" + sort 토글 (화이트리스트 enum→고정 SQL, `${}` 금지).
- **B2** 리뷰 본문 필수 최소 10자(trim 후). 댓글은 현행 유지.
- **B3** 본문 정규화 (제어문자 strip + 외곽 trim, 댓글·리뷰 공통 유틸).
- **다축 평점** game_review_axes 6축(1~5 전 축 필수) + overall 자동평균/수동덮어쓰기(is_rating_manual).
- **C3** game_review_stats 읽기전용 집계뷰 (avg_rating, review_count, 6축 평균). 클라 평균계산 폐기.
- **C1** submit 잠금+로딩 / **C2** 상대시각 / **C4** 글자수 카운터 / **C5** 완전소멸 유지 / **C6** radiogroup 완전 패턴.
- **육각형 레이더** 인라인 SVG 자체구현 (요약 + 개별 리뷰 카드), a11y 텍스트 대체.
### 비목표 (스코프 밖 — 명시)
- 신고·숨김 (W1 운영자 role 선행).
- 리뷰 이력 테이블 (in-row 마커만).
- 좋아요 서버화 (별도 관리).
- W2-3 잼 평가(심사/투표/시상) 스키마 — **본 설계 무관**(C3 정정으로 명확히 분리).
- 댓글 최소 글자수 (현행: 상한 200자만).
---
## C3 재분류 (직전 report 오분류 교정 — 설계 결정)
직전 report.md 는 C3(game_review_stats 평균별점 뷰)를 "W2-3 동결 해제·고위험·§6 게이트"로 표기했으나 **오분류**다.
- **근거** (`docs/work-log/2026-06-17-jam-platform-roadmap.md`):
- `:63` — "W2-3 범위 = 잼 평가만. 댓글/리뷰 스키마 자체는 W3에서 설계(동결 묶음 아님)."
- `:201` — "S4D 평가 통합설계 → W2-3 범위 축소: 잼 평가(심사/투표/시상) 스키마만 동결. 댓글/리뷰 스키마는 W3-2에서 별도 설계."
- `:202` — "S4a 댓글/리뷰 분리 → W3-2 일반기능으로 재분류: 잼 평가 동결묶음에서 분리."
- **결론**: `game_review_stats` = `game_reviews`(W3-2 테이블) 위 **읽기전용 집계뷰** 신설 = **일반 DDL**. §6 파괴적 게이트 없음. orchestrator 사용자 재확인 경로 불필요.
- **부수 조치**: `docs/game-reviews-ddl.sql:63` 주석("집계 컬럼/뷰는 신설하지 않음 (W2-3 동결 보호)") 및 `db/schema.sql:136-137`("집계 컬럼/뷰는 W2-3 동결 — 신설 금지") 은 본 설계로 **무효화** → 구현 시 갱신 대상(파일 영향 맵 참조).
---
## 개요
기존 W3-2 코어(댓글/리뷰 CRUD, 2026-06-18 커밋)를 보존하며 그 위에 일관성(A)·규모(B)·다축평점·UX(C)를 얹는다. 백엔드는 매퍼 시그니처 확장(offset/limit/sort, axes 저장) + 신규 axes 매퍼/DDL, 프론트는 game-detail.jsp 단일 파일의 댓글·리뷰 JS 블록 재작성(육각형 SVG, radiogroup, 페이지네이션, 서버집계 표시)이다. DDL은 game-reviews-ddl.sql 에 멱등 append + db/schema.sql 동기화한다. 전 변경은 31건 회귀 테스트를 깨지 않는 것을 1차 게이트로 한다.
---
## 플로우
### 리뷰 작성 (POST /game/{id}/reviews) — 다축 확장
```
진입: CSRF(403) → 로그인(401) → 게임존재(404)
→ rating 파싱(수동 overall, optional) + axes 6값 파싱(전 축 1~5 필수, 누락/범위외 400)
→ body 정규화(B3) + 최소 10자 검사(B2, 400)
→ 게임당 1회 검사(409)
→ overall 결정:
유저가 overall 직접선택 O → rating=선택값, is_rating_manual=true
유저가 overall 직접선택 X → rating=round(6축 평균), is_rating_manual=false
→ [TX] addGameReview(rating, body, is_rating_manual) → review.id 획득
→ addReviewAxes(review.id, 6행) ← axes 매퍼
→ getGameReview(review.id) 재조회 → reviewView(+axes) 반환
종단: 200 reviewView
```
### 리뷰 수정 (PUT) — axes 재저장
```
진입: CSRF → 로그인 → 리뷰존재+게임일치(404) → 권한(canModify, 403)
→ rating/axes/body 검증(작성과 동일)
→ [TX] editGameReview(rating, body, is_rating_manual, updated_at=now())
→ deleteReviewAxes(reviewId) ← 기존 6행 제거
→ addReviewAxes(reviewId, 6행) ← 신규 6행
→ getGameReview 재조회 → reviewView 반환
```
### 목록 조회 (GET /game/{id}/reviews?page&sort) — 페이지네이션
```
진입: 게임존재(404)
→ page(default 0)·sort(default newest, 화이트리스트 검증→고정 enum) 파싱
→ offset = page*20, limit=21 (hasMore 판정용 +1 조회)
→ listGameReviews(gameId, offset, limit, sortEnum) ← 21건 조회
→ 각 리뷰에 axes 6행 batch 조회·매핑 (listReviewAxesByReviewIds)
→ hasMore = (조회건수 > 20) → 21번째 잘라 20건 반환
→ game_review_stats 1행 조회(요약) → summary 포함
종단: { status, reviews:[reviewView...], hasMore, summary:{avgRating,reviewCount,axes{}} }
```
### 댓글 작성 (POST /game/{id}/comments) — A1 통일
```
진입: CSRF → 로그인 → 게임존재
→ content 정규화(B3) + 1~200자 검사
→ nickname 스냅샷 = sessionDisplayName (유지)
→ addGameComment → comment.id 획득
→ getGameComment(comment.id) 재조회(LEFT JOIN authorName/edited/updatedAt) ← A1: 재조회 패턴
→ commentView 반환
종단: 200 commentView (createReview 패턴 대칭)
```
---
## 데이터 모델
### 2.1 game_review_axes (신규 테이블)
| 컬럼 | 타입 | 제약 | 비고 |
|---|---|---|---|
| id | bigint | PK, seq | game_review_axes_id_seq |
| review_id | bigint | NOT NULL, FK→game_reviews(id) | |
| axis_key | varchar(20) | NOT NULL, CHECK(6종) | 영문 snake |
| score | smallint | NOT NULL, CHECK(1~5) | |
| | | UNIQUE(review_id, axis_key) | 리뷰당 축별 1행 |
| | | INDEX(review_id) | 조회 |
- **axis_key 6종 (순서 고정 — 육각형 축 인덱스 0~5)**:
`immersion`(몰입성), `creativity`(창의성), `controls`(조작성), `completeness`(완성도), `sound`(사운드), `visual`(비주얼).
- 리뷰당 정확히 6행 (전 축 필수). DB는 UNIQUE+CHECK로 보장, 앱이 6행 누락 없이 insert 책임.
### 2.2 game_comments.updated_at (멱등 ALTER)
- `timestamptz DEFAULT now() NOT NULL`. `edited = updated_at > created_at` (리뷰 대칭).
- 기존 행은 ALTER 시 default now() 적용 → created_at != updated_at 가능성 → 기존 행 보정: `UPDATE ... SET updated_at = created_at WHERE updated_at IS NULL` 불필요(NOT NULL default). 대신 ALTER 직후 `UPDATE game_comments SET updated_at = created_at WHERE updated_at > created_at` 1회로 기존 댓글이 "수정됨" 오표시되지 않게 정렬 (멱등 — 재실행 시 영향 없음, 신규 행은 INSERT가 created/updated 동시 now()).
### 2.3 game_reviews.is_rating_manual (멱등 ALTER)
- `boolean DEFAULT false NOT NULL`. true=유저 직접선택 overall, false=6축 자동평균.
### 2.4 game_review_stats (VIEW 신규)
- `game_reviews(is_delete IS NOT TRUE)``game_review_axes` 집계.
- 컬럼: `game_id, avg_rating numeric, review_count bigint, avg_immersion numeric, avg_creativity numeric, avg_controls numeric, avg_completeness numeric, avg_sound numeric, avg_visual numeric`.
- 클라 평균계산(updateSummary, JSP:1513-1522) 폐기 공급원.
---
## 외부 계약 (API)
### 3.1 commentView 스키마 (A1 통일 — POST·PUT·list 동일)
```json
{
"commentId": 100,
"gameId": 1,
"authorName": "표시명 또는 (탈퇴한 사용자) 또는 스냅샷닉",
"userId": 7, // nullable (레거시)
"content": "...",
"createdAt": "ISO-8601",
"edited": false, // updated_at > created_at
"updatedAt": "ISO-8601" // A3 신규
}
```
- 변화: POST 응답이 flat(commentId/gameId/authorName/userId/content) → commentView 전체. PUT 응답이 부분(commentId/content) → commentView 전체. createdAt/edited/updatedAt 신규 노출. `status`,`message`는 POST/PUT 응답에 commentView 와 병합(createReview 패턴 그대로).
### 3.2 reviewView 스키마 (axes 추가)
기존 reviewView(reviewId,gameId,authorName,userId,rating,body,edited,createdAt,updatedAt) + 신규:
```json
{
"...": "기존 필드 유지",
"ratingManual": false, // is_rating_manual
"axes": { "immersion":4,"creativity":5,"controls":3,"completeness":4,"sound":2,"visual":5 }
}
```
### 3.3 GET list 응답 형태 (페이지네이션)
- 댓글: `{ status, comments:[commentView...], hasMore }`
- 리뷰: `{ status, reviews:[reviewView...], hasMore, summary }`
- `summary` = game_review_stats 1행 → `{ avgRating, reviewCount, axes:{immersion..visual} }` (review_count=0 이면 summary=null).
- **요청 파라미터**: `?page=<int≥0, default 0>&sort=<enum, default 토글기본값>`.
- **hasMore 방식 채택**: total count 대신 `limit+1` 조회 후 초과 여부. 근거 = 추가 COUNT 쿼리 회피, "더보기" UI는 total 불요.
### 3.4 sort enum 값 정의
- 댓글: `oldest`(기본), `newest`.
- 리뷰: `newest`(기본), `rating_desc`, `rating_asc`.
- 미지정/미허용 값 → 기본값으로 fallback (400 던지지 않음 — UX 관대).
---
## sort 화이트리스트 매핑 표 (§4 — `${}` 금지 안전성)
매퍼는 **enum 분기 → 고정 SQL 절** 방식. 동적 `${}` SQL 치환 절대 사용 안 함. 컨트롤러가 String sort 를 화이트리스트 enum 으로 변환(미스매치=기본값) 후, 매퍼는 MyBatis `<choose>`(XML) 또는 **annotation @SelectProvider 의 if-분기로 고정 문자열 상수 선택**.
**채택 방식**: 현 코드가 annotation 매퍼(@Select 텍스트블록)이므로 일관성 위해 **컨트롤러에서 sort enum 결정 + 매퍼 메서드를 sort별로 분리하지 않고**, `@SelectProvider` 로 전환하여 ORDER BY 절만 상수 분기. (대안: 매퍼 메서드 N개 분리 — 중복 과다로 비채택.)
| sort enum | 대상 | ORDER BY 고정 SQL 절 (상수, 사용자 입력 미포함) |
|---|---|---|
| `oldest` (댓글) | comments | `ORDER BY created_at ASC, id ASC` |
| `newest` (댓글) | comments | `ORDER BY created_at DESC, id DESC` |
| `newest` (리뷰) | reviews | `ORDER BY r.created_at DESC, r.id DESC` |
| `rating_desc` (리뷰) | reviews | `ORDER BY r.rating DESC, r.created_at DESC, r.id DESC` |
| `rating_asc` (리뷰) | reviews | `ORDER BY r.rating ASC, r.created_at DESC, r.id DESC` |
- **안전성 근거**: ORDER BY 문자열은 5개 **컴파일타임 상수** 중 하나로만 결정. 사용자 입력 String 은 enum 매칭(`switch`/`Map`)에만 사용되고 SQL 텍스트에 절대 보간되지 않음 → SQL injection 면역. offset/limit 은 `#{}` 파라미터 바인딩 유지.
- **tiebreaker**: 별점순은 동점 시 created_at DESC, id DESC 로 결정적 정렬(페이지네이션 안정성 — offset 중복/누락 방지).
---
## 다축 시퀀스 (§5)
### 5.1 overall 자동/수동 계산 위치 = **서버**
- 클라(JS)는 6축 점수 + (선택적) overall 수동선택값만 전송.
- **서버가 권위 계산**: overall 미전송/빈값 → `rating = Math.round(평균(6축))`, `is_rating_manual=false`. overall 전송 → `rating=전송값(1~5 검증)`, `is_rating_manual=true`.
- 근거: 클라 계산은 변조 가능 + C3 서버집계 정합. round 정책 = 정수 반올림(HALF_UP, Java `Math.round` 는 .5 올림 — 일관).
### 5.2 axes 재저장 전략 = **delete-all + insert-6** (updateReview)
- `editGameReview` TX 내: `deleteReviewAxes(reviewId)``addReviewAxes(reviewId, List<6>)`.
- 근거: upsert(ON CONFLICT) 대비 매퍼 단순. 6행 고정이라 성능 동일 수준. UNIQUE(review_id,axis_key) 와 무관(전삭제 후 삽입).
### 5.3 트랜잭션 순서 (createReview)
```
@Transactional
1. addGameReview(rating, body, is_rating_manual) → useGeneratedKeys → review.id
2. addReviewAxes(review.id, [6 axis rows]) → 6행 batch insert
(실패 시 전체 롤백 — review+axes 원자성)
3. getGameReview(review.id) + listReviewAxes(review.id) → reviewView 조립
```
### 5.4 axes 매퍼 = **GameReviewAxesMapper 신규** (단일 책임)
신규 인터페이스. 시그니처(인자 사용목적 인라인 주석 — inflate 방지):
```java
@Mapper
public interface GameReviewAxesMapper {
// 1리뷰 6축 일괄 저장. List 1건당 review_id/axis_key/score 사용.
int addReviewAxes(@Param("reviewId") long reviewId, // FK 대상 리뷰
@Param("axes") List<ReviewAxisRow> axes); // 6행(axis_key+score)
// 수정 시 기존 축 전삭제(이후 add 재삽입).
int deleteReviewAxes(@Param("reviewId") long reviewId); // 대상 리뷰
// 목록 화면 N리뷰 축 batch 조회(N+1 회피).
List<ReviewAxisRow> listAxesByReviewIds(@Param("reviewIds") List<Long> reviewIds); // 페이지 리뷰 id들
}
```
- `ReviewAxisRow` = `{ Long reviewId; String axisKey; Integer score; }` (신규 POJO 또는 GameReviewData 내 정적 중첩). axes batch insert SQL은 `<foreach>` (XML) 또는 @InsertProvider. **`#{}` 바인딩 유지**.
- 최소 인자 원칙 적용: gameId 등 불필요 컨텍스트 미수신. 구현에서 확장 필요 시 추가.
---
## SVG 육각형 좌표 공식 (§6)
### 6.1 계산 위치 = **JS 런타임** (game-detail.jsp 인라인)
- 근거: 개별 리뷰 N건 동적 렌더(buildReviewItem) + 요약 1건. 기존 buildStarDisplay 가 JS DOM 생성 idiom → 일관. JSTL 컴파일타임은 동적 N건과 부적합.
### 6.2 viewBox / 기하 상수
- 요약(큰) 육각형: `viewBox="0 0 200 200"`, 중심 `cx=100, cy=100`, 반지름 `R=80`.
- 개별 리뷰 카드(컴팩트): `viewBox="0 0 120 120"`, `cx=60, cy=60`, `R=44`. (페이지당 20건 DOM 고려.)
- 함수는 cx/cy/R 파라미터화하여 공용 1함수로.
### 6.3 6 꼭지점(축 끝) 좌표 공식
- 축 i(0~5, §2.1 axis_key 순서와 1:1): 각도 `θ_i = -90° + 60°*i` (12시 방향 시작, 시계방향).
```
angle_rad = (-90 + 60*i) * Math.PI / 180
axisX_i = cx + R * Math.cos(angle_rad)
axisY_i = cy + R * Math.sin(angle_rad)
```
- 배경 그리드(외곽 육각형) = 위 6점 polygon. 단계 그리드 = R*0.25, R*0.5, R*0.75, R 4단계 동심 육각형(`scale = level/4` 곱).
### 6.4 score 폴리곤 점 공식
- 축 i 점수 `s_i`(1~5) → 반지름 비율 `ratio_i = s_i / 5`:
```
ptX_i = cx + R * (s_i/5) * Math.cos(angle_rad_i)
ptY_i = cy + R * (s_i/5) * Math.sin(angle_rad_i)
```
- `<polygon points="ptX_0,ptY_0 ptX_1,ptY_1 ... ptX_5,ptY_5" />` (fill 반투명 + stroke).
### 6.5 렌더 함수 시그니처 (inflate 방지 — 인자 사용목적 명시)
```js
// 6축 점수 배열을 육각형 SVG 엘리먼트로. 요약/카드 공용.
function buildHexRadar(scores /* [6] axis순 점수배열, score폴리곤 */,
cx /* 중심x, 좌표기준 */,
cy /* 중심y, 좌표기준 */,
R /* 반지름, 스케일 */) { ... return <svg> }
```
- 최소 인자. 라벨 텍스트/색상은 함수 내부 상수(AXIS_LABELS_KO) 참조 → 인자 미부풀림.
### 6.6 a11y (SVG 시각요소 텍스트 대체 — 필수)
- `<svg role="img" aria-label="6축 평가: 몰입성 4, 창의성 5, 조작성 3, 완성도 4, 사운드 2, 비주얼 5">`.
- 추가로 시각적 보조: 각 축 점수를 visually-hidden 표 또는 인접 `<dl>`(축명/점수) 동반. 폴리곤만으로 종료 금지(FINAL SPEC).
---
## a11y radiogroup 스펙 (§7 — C6)
현 별점위젯(JSP:1043-1049)은 `role=radiogroup`/`role=radio`/`aria-checked` 마크업은 있으나 **키보드 핸들러 없음**(click만, JSP:1553-1556). roving tabindex 도 없음. C6 = 완전 패턴 신규 추가.
### 7.1 roving tabindex
- radiogroup 내 단일 tabstop: 선택된 radio `tabindex="0"`, 나머지 `tabindex="-1"`. 미선택 시 첫 radio `tabindex="0"`.
- 화살표 이동 시 tabindex 이전(`-1`)→대상(`0`) + `.focus()`.
### 7.2 키 핸들러 (keydown)
| 키 | 동작 |
|---|---|
| `ArrowRight`, `ArrowUp` | 다음 별점(+1, max 5에서 정지 또는 wrap — **정지** 채택, LTR 별점 직관) |
| `ArrowLeft`, `ArrowDown` | 이전 별점(-1, min 1에서 정지) |
| `Home` | 1점 |
| `End` | 5점 |
| `Space`/`Enter` | 현재 focus radio 선택 확정 |
- 화살표는 이동+즉시선택(setRating) 동시 — radiogroup 표준. `preventDefault()` 로 스크롤 방지.
### 7.3 aria 속성
- 그룹 `role="radiogroup" aria-labelledby="<라벨id>"` (축별 라벨 id 6개).
-`role="radio" aria-checked="true|false"`, 선택만 `aria-checked=true`.
### 7.4 6위젯 공통화
- 별점위젯 생성을 JS 함수 `buildStarRadioGroup(axisKey, labelId)` 로 추출 → 6축 입력 위젯 생성에 재사용. 각 위젯 독립 selectedScore 상태(객체 `axisScores[axisKey]`). 기존 단일 overall 위젯도 동일 함수로 통일(overall은 별도 또는 자동표시).
- 함수 시그니처(인자 사용목적):
```js
// 1개 축 별점 radiogroup DOM 생성 + 키/클릭 핸들러 바인딩.
function buildStarRadioGroup(axisKey /* axisScores 키 + 상태귀속 */,
labelText /* 라벨/aria-label 텍스트 */) { ... }
```
---
## B3 정규화 유틸 위치 (§8)
- **현황**: 공용 `util` 패키지 없음(grep 확인). trimToNull/trimToEmpty 가 GameCommentController·GameReviewController·GameController 에 private 중복.
- **채택**: 신규 `com.pandoli365.bibimbap.util.TextNormalizer` (static util 클래스). 기존 private trim 메서드는 **이번 스코프에선 미통합**(회귀 리스크 — 댓글/리뷰 컨트롤러만 호출 추가). 통합 리팩터는 비목표.
- **시그니처** (인자 사용목적 인라인):
```java
public final class TextNormalizer {
private TextNormalizer() {}
// 저장 위생: 제어문자(탭\t·개행\n\r 제외) 제거 + 외곽 trim.
// 내부 연속공백/줄바꿈 보존. null→null.
public static String normalize(String raw /* 원문, 정규화 대상 */) { ... }
}
```
- 규칙: `\t`(U+0009), `\n`(U+000A), `\r`(U+000D) 외의 C0/C1 제어문자(U+0000~U+001F, U+007F~U+009F) 제거 → 외곽 `strip()`. 내부 공백 미축약.
- 적용: createComment/updateComment content, createReview/updateReview body 에 검증 **전** 호출(정규화 후 길이 검사).
---
## 파일 영향 맵 (§1 파일 소유권 + worker 분할)
worker 분할은 **동시수정 충돌 회피** 기준. game-detail.jsp 는 단일 파일이라 1 worker 전담.
| 변경 유형 | 경로 | 역할 | worker |
|---|---|---|---|
| 신규 | `db/.../docs/game-reviews-ddl.sql` (append) | axes 테이블+updated_at ALTER+is_rating_manual ALTER+stats VIEW 멱등 블록. :63 보수주석 갱신 | W-DDL |
| 변경 | `db/schema.sql` | 위 DDL 동기화(:136-137 주석 갱신) | W-DDL |
| 신규 | `src/.../util/TextNormalizer.java` | B3 정규화 static util | W-BE |
| 신규 | `src/.../mapper/GameReviewAxesMapper.java` | axes add/delete/listByReviewIds | W-BE |
| 신규 | `src/.../data/ReviewAxisRow.java` (또는 GameReviewData 중첩) | axes 행 POJO | W-BE |
| 변경 | `src/.../data/GameCommentData.java` | updatedAt + edited(비영속) 필드 추가 | W-BE |
| 변경 | `src/.../data/GameReviewData.java` | ratingManual + axes(Map/List 비영속) 필드 추가 | W-BE |
| 변경 | `src/.../mapper/GameCommentsMapper.java` | A2 LEFT JOIN+CASE, A3 updated_at/edited select, B1 offset/limit/sort, editGameComment updated_at | W-BE |
| 변경 | `src/.../mapper/GameReviewsMapper.java` | B1 offset/limit/sort, is_rating_manual select/insert/update, edit 시 axes 무관(별 매퍼) | W-BE |
| 신규 | `src/.../mapper/GameReviewStatsMapper.java` (또는 GameReviewsMapper 내 1메서드) | game_review_stats 1행 조회 | W-BE |
| 변경 | `src/.../controller/api/GameCommentController.java` | A1 재조회·commentView, B1 page/sort, B3 normalize | W-BE |
| 변경 | `src/.../controller/api/GameReviewController.java` | 다축 검증/저장, overall 자동·수동, B2 10자, B3, B1 page/sort, summary | W-BE |
| 변경 | `src/main/webapp/WEB-INF/views/game-detail.jsp` | 육각형 SVG, C1·C2·C4·C6, 페이지네이션·sort UI, 서버집계 표시, 6축 입력, A1 commentView 렌더 | W-FE |
| 변경 | `src/test/.../GameCommentControllerTest.java` | A1·A2·B3 신규 + 기존 12 유지 | W-TEST |
| 변경 | `src/test/.../GameReviewControllerTest.java` | 다축·B2·sort 신규 + 기존 13 유지 | W-TEST |
| (무변경 확인) | `src/test/.../BibimbapApplicationTests.java` | @MockBean 신규 매퍼(GameReviewAxesMapper 등) 추가 필요 시만 | W-TEST |
**충돌 회피**: W-BE 가 매퍼/컨트롤러/data/util 동시 소유(상호 의존 — 단일 worker 권장). W-FE 는 jsp 단독. W-TEST 는 BE 완료 후 진행(시그니처 의존). W-DDL 독립 선행 가능.
**BibimbapApplicationTests 주의**: 신규 `@Mapper` 빈(GameReviewAxesMapper, GameReviewStatsMapper)이 컨트롤러 생성자 주입되면 contextLoads 가 빈을 못 찾아 실패 → `@MockBean` 추가 필수. 이 추가는 31건 회귀의 ApplicationTests 1건이 깨지지 않도록 하는 **필수 조치**.
---
## DDL 전문 (§2 — game-reviews-ddl.sql append 블록)
아래를 `docs/game-reviews-ddl.sql` 끝에 append. 전부 IF NOT EXISTS / DO $$ 멱등. `db/schema.sql` 에도 동일 정의 동기화.
```sql
-- ===========================================================================
-- W3-2 고도화: 다축 평점(game_review_axes) + 댓글 updated_at + is_rating_manual
-- + game_review_stats 집계뷰
-- C3 재분류: game_review_stats 는 W3-2 일반 읽기전용 집계뷰. W2-3 잼 평가 동결과 무관
-- (roadmap.md:63,201,202). 아래 위 'idx_game_reviews_game' 주석의 "집계뷰 미신설"
-- 보수 표기는 본 블록으로 갱신됨.
-- ===========================================================================
-- 1) game_comments.updated_at (A3)
ALTER TABLE "game_comments"
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
-- 기존 댓글이 '수정됨' 오표시되지 않도록 정렬(멱등: 이미 정렬된 행엔 무영향)
UPDATE "game_comments" SET "updated_at" = "created_at" WHERE "updated_at" > "created_at";
COMMENT ON COLUMN "game_comments"."updated_at" IS '덧글 마지막 수정 시각. updated_at > created_at 이면 수정됨(리뷰 대칭)';
-- 2) game_reviews.is_rating_manual (overall 출처 구분)
ALTER TABLE "game_reviews"
ADD COLUMN IF NOT EXISTS "is_rating_manual" boolean DEFAULT false NOT NULL;
COMMENT ON COLUMN "game_reviews"."is_rating_manual" IS 'true=유저 직접선택 overall, false=6축 자동평균';
-- 3) game_review_axes (다축 평점, 리뷰당 6행)
CREATE SEQUENCE IF NOT EXISTS "game_review_axes_id_seq";
CREATE TABLE IF NOT EXISTS "game_review_axes" (
"id" bigint DEFAULT nextval('game_review_axes_id_seq'::regclass) NOT NULL,
"review_id" bigint NOT NULL,
"axis_key" character varying(20) NOT NULL,
"score" smallint NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_review_axes_id_seq" OWNED BY "game_review_axes"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_review_id_fkey') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_review_id_fkey"
FOREIGN KEY ("review_id") REFERENCES "game_reviews" ("id");
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_score_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_score_check" CHECK ("score" BETWEEN 1 AND 5);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_axis_key_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_axis_key_check"
CHECK ("axis_key" IN ('immersion','creativity','controls','completeness','sound','visual'));
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_review_axes_review_axis"
ON "game_review_axes" ("review_id", "axis_key");
CREATE INDEX IF NOT EXISTS "idx_game_review_axes_review"
ON "game_review_axes" ("review_id");
COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6행). axis_key 6종 각 1~5';
COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual';
-- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원)
CREATE OR REPLACE VIEW "game_review_stats" AS
SELECT
r."game_id" AS "game_id",
ROUND(AVG(r."rating")::numeric, 1) AS "avg_rating",
COUNT(*) AS "review_count",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='immersion'),1) AS "avg_immersion",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='creativity'),1) AS "avg_creativity",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='controls'),1) AS "avg_controls",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='completeness'),1) AS "avg_completeness",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='sound'),1) AS "avg_sound",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='visual'),1) AS "avg_visual"
FROM "game_reviews" r
LEFT JOIN "game_review_axes" a ON a."review_id" = r."id"
WHERE r."is_delete" IS NOT TRUE
GROUP BY r."game_id";
COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균';
```
### db/schema.sql 동기화 판단 = **동기화 필수**
- 근거: schema.sql 은 dev 컨테이너 부트스트랩(line 16-18, docker-entrypoint-initdb.d 자동실행). axes 테이블/뷰/컬럼 없으면 dev 환경 매퍼 실행 실패. game_reviews 가 schema.sql:117-135 에 이미 정의되어 있으므로 동일 위치에 위 4블록 append + `:136-137` 동결 주석을 C3 갱신 주석으로 교체.
---
## 대안 비교 (§ 선택적)
| 안 | 장점 | 단점 | 채택? |
|---|---|---|---|
| axes 매퍼 신규 vs GameReviewsMapper 확장 | 단일책임/리뷰매퍼 비대화 방지 | 클래스 1개 추가 | **신규 채택** |
| hasMore(limit+1) vs total count | COUNT 쿼리 회피 | total 미표시 | **hasMore 채택** |
| sort: @SelectProvider 분기 vs 메서드 N분리 | ORDER BY만 상수분기, 메서드 1개 | provider 클래스 추가 | **provider 채택** |
| overall 계산 서버 vs 클라 | 변조방지·C3정합 | round 1회 서버부담(무시가능) | **서버 채택** |
| axes 재저장 delete+insert vs upsert | 매퍼 단순 | 6 DEL+6 INS | **delete+insert 채택** |
| SVG 좌표 JS vs JSTL | 동적 N건 일관 | 클라 계산 | **JS 채택** |
| is_rating_manual 채택 vs 미채택 | 자동/수동 출처 구분 | 1컬럼 | **채택**(concerns 재확인) |
---
## 롤아웃 / 마이그레이션
1. **순서**: DDL 멱등 적용(dev: schema.sql 재부트 또는 game-reviews-ddl.sql 수동 실행) → BE 빌드/단위테스트 → FE → L3 스모크.
2. **역호환**:
- 기존 game_reviews 행은 axes 0행 상태 → game_review_stats 의 6축 평균 NULL(LEFT JOIN). 프론트는 NULL 축을 "데이터 없음"/0 처리. overall avg_rating 은 정상(rating 컬럼 기반).
- 기존 댓글: updated_at default now() + 정렬 UPDATE 로 edited=false 유지.
- is_rating_manual default false → 기존 리뷰는 자동평균 취급(축 없어도 rating 표시 영향 없음).
3. **롤백 경로**: 신규 객체(axes 테이블/stats 뷰/2 컬럼)는 비파괴(ADD/CREATE). 코드 롤백 시 DB 객체 잔존해도 무해(미참조). 뷰는 `DROP VIEW IF EXISTS game_review_stats`, 컬럼은 보존(비파괴) 권장.
4. **기존 리뷰 axes 백필**: 본 스코프 비대상(NULL 허용). 백필 필요 시 별도.
---
## 검증 포인트 (verification-advisor 점검 acceptance criteria)
### L1 단위 테스트 (회귀 + 신규)
- **AC-1**: 기존 31건 회귀 PASS — `./mvnw test` 결과 GameCommentControllerTest(12) + GameReviewControllerTest(13) + UserControllerCsrfTest(5) + BibimbapApplicationTests(1) 전부 GREEN. (DbUpdateQueryGeneratorTest 1건은 W3-2 무관, 영향 없음 확인.)
- **AC-2**: BibimbapApplicationTests 의 @MockBean 집합이 컨트롤러 생성자 의존 매퍼 전수를 커버 — 신규 매퍼(GameReviewAxesMapper, GameReviewStatsMapper 등) 추가 시 contextLoads PASS.
- **AC-3**: 신규 단위테스트 추가건 PASS (목록 §9).
### L1 안전성 (sort 화이트리스트 — 집합 전수)
- **AC-4**: sort enum 매핑 전수 5건 보존 — 매퍼/provider 의 ORDER BY 분기가 §4 표의 5개 enum(`oldest`,`newest`(댓글),`newest`,`rating_desc`,`rating_asc`(리뷰))을 전수 커버. 검증: `grep -c 'ORDER BY' <provider 또는 매퍼 소스>` >= 5 AND 각 분기 SQL에 사용자 입력 변수 보간(`${`) 0건 — `grep -c '\${' <매퍼소스>` == 0 (전 매퍼 통틀어 W3-2 변경분).
- **AC-5**: SQL injection 면역 — 전 신규/변경 매퍼에서 `${` 동적치환 0건 (`grep -rc '\${' src/main/java/.../mapper/Game*Mapper.java` 합 == 0). offset/limit/sort 전부 `#{}` 또는 enum 상수.
### L1 다축 (집합 전수 — 6축)
- **AC-6**: axis_key 집합 전수 6건 일치 — DDL CHECK·뷰 FILTER·앱 enum·JSP 라벨 4곳이 동일 6키(`immersion,creativity,controls,completeness,sound,visual`). 검증: `grep -c "'immersion'\|'creativity'\|'controls'\|'completeness'\|'sound'\|'visual'"` 패턴이 game-reviews-ddl.sql 의 axis_key CHECK 절에 6개 전수, stats VIEW FILTER 에 6개 전수.
- **AC-7**: createReview 가 6축 미만 입력 시 400 (전 축 필수). 단위테스트로 5축 입력 거부.
- **AC-8**: overall 자동평균 — 6축 [4,5,3,4,2,5] 입력·overall 미전송 시 rating=round(23/6=3.83)=4, is_rating_manual=false. overall=2 전송 시 rating=2, is_rating_manual=true.
### L1 기타 신규
- **AC-9**: B2 — 리뷰 본문 trim 후 9자 거부(400), 10자 통과. 댓글은 최소제한 없음(기존 유지).
- **AC-10**: B3 — TextNormalizer.normalize("ab\tc") == "ab\tc"(NUL 제거, 탭 보존), 외곽 trim, 내부 공백 보존.
- **AC-11**: A1 — POST/PUT 댓글 응답이 commentView 전체 키(commentId,gameId,authorName,userId,content,createdAt,edited,updatedAt) 포함.
- **AC-12**: A2 마스킹 — 매퍼 SQL CASE 3분기 존재(`u.id IS NULL`→nickname, `u.is_delete`→'(탈퇴한 사용자)', else→display_name). LEFT JOIN 확인.
- **AC-13**: A3 — editGameComment 가 updated_at=now() 설정, nickname 미덮어씀.
### L3 브라우저 스모크 체크리스트
- **AC-14**: 별점위젯 키보드 — Tab 으로 위젯 1회 진입(roving), ←→↑↓ 점수 이동, Home=1/End=5, 선택 시 aria-checked 토글. 6축 위젯 각각 독립 동작.
- **AC-15**: 육각형 레이더 — 요약 SVG(6축 평균) + 개별 리뷰 카드 컴팩트 SVG 렌더. `<svg role="img" aria-label>` 6축 점수 텍스트 대체 존재.
- **AC-16**: 페이지네이션 — 21건 이상 리뷰/댓글 시 "더보기" 노출, 클릭 시 다음 20건 append, hasMore=false 면 버튼 숨김. sort 토글 동작(댓글 oldest↔newest, 리뷰 newest/rating_desc/rating_asc).
- **AC-17**: 서버집계 표시 — 요약 평균 소수1자리, review_count=0 시 "아직 평가 없음". 클라 평균계산(updateSummary 구버전) 잔존 0 — `grep -c 'list.reduce' game-detail.jsp` 의 평균계산 블록 제거 확인.
- **AC-18**: C1 submit 잠금 — 제출 중 버튼 disabled, 응답 후 해제(연타 차단). C4 글자수 카운터 실시간(댓글 n/200, 리뷰 n/1000 + 최소10자 안내). C2 상대시각 "n분 전" + title 절대시각.
- **AC-19**: DDL 적용 — game_review_axes·game_review_stats·game_comments.updated_at·game_reviews.is_rating_manual 존재 (psql `\d` 또는 information_schema).
### AC 정식화 self-audit (시점·표현 — 프로토콜 §4.7)
1. **시점 안정성**: AC-1 의 31건은 현재 세션이 W-TEST 로 테스트를 **추가**하므로 verification 시점엔 31+α건이 됨. → 표현 교정: "기존 31건이 전부 PASS(삭제·실패 0)" 불변식으로 측정, 신규는 AC-3 별도. 자기 트리 고정카운트 함정 회피.
2. **표현 견고성**: AC-5/AC-17 의 `grep -c` 는 리터럴 의존 — `${` 는 SQL injection 신호로 안정적(동의표현 없음, 견고). AC-17 'list.reduce' 는 구현이 다른 메서드명 쓸 수 있어 fragile → "클라 평균계산 로직 부재"를 수동 코드리뷰로 보강(grep 은 보조). AC-6 axis_key 는 6키 고정 리터럴 — DDL/뷰/앱 동의표현 없이 정확히 이 6문자열만 유효(견고).
---
## verification 실행 명령 (L1)
- 빌드툴 = **Maven**(pom.xml 확인, build.gradle 없음). 래퍼 `./mvnw`.
- L1: `./mvnw -q test` (전체) 또는 `./mvnw -q test -Dtest=GameCommentControllerTest,GameReviewControllerTest,UserControllerCsrfTest,BibimbapApplicationTests` (회귀 31건 집중).
- 컴파일만: `./mvnw -q test-compile`.