537 lines
35 KiB
Markdown
537 lines
35 KiB
Markdown
---
|
||
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("a |