bibimbap/docs/changes/2026-06-22-w3-2-comments-re...

324 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
kind: change
title: "W3-2 댓글/리뷰 고도화 — 다축 평점·일관성·목록규모·UX"
session_id: 20260622-092800
resumed_from: 20260618-145152
created_at: 2026-06-22
status: implemented
related_prev_change: "./2026-06-18-w3-2-comments-reviews.md"
related_security_checklist: "../security/security-remediation-checklist.md"
related_work_log: "../work-log/2026-06-17-jam-platform-roadmap.md"
related_design: "../../.atp/work-session/20260622-092800/implementation/design.md"
related_report: "../../.atp/work-session/20260622-092800/report.md"
---
# W3-2 댓글/리뷰 고도화 변경 이력
상위 코어 이력: [2026-06-18 W3-2 댓글/리뷰 분리 구현](./2026-06-18-w3-2-comments-reviews.md)
## 개요
2026-06-18 코어 구현(댓글/리뷰 서버 영속화·CRUD) 위에 4개 축의 고도화를 얹었다.
| 고도화 축 | 주요 내용 |
|---|---|
| **A 일관성** | commentView 단일 통일(A1) + 작성자명 하이브리드 마스킹 QG-2 해결(A2) + edited/updatedAt(A3) |
| **B 목록규모** | 페이지네이션 더보기 20건(B1) + 리뷰 본문 최소 10자(B2) + TextNormalizer 제어문자 정규화(B3) |
| **다축 평점** | game_review_axes 6축(리뷰당 6행) + overall 자동평균/수동덮어쓰기(is_rating_manual) + game_review_stats 집계뷰 |
| **C UX** | submit 잠금(C1) + 상대시각(C2) + 글자수 카운터(C4) + 완전소멸 유지(C5) + radiogroup 완전 패턴 roving tabindex(C6) + 육각형 SVG 레이더 |
프론트엔드 구현(game-detail.jsp 시각 컴포넌트)은 frontend-design 스킬 패스로 처리됐다.
---
## C3 재분류 — 직전 코어 이월 항목 해소 (중요)
### 배경
직전 코어 이력(`2026-06-18-w3-2-comments-reviews.md`) 이월 항목 1번:
> "W2-3 평점 집계 계약 (SELECT AVG/COUNT) — **W2-6 시상 후속**."
이 이월 표기는 game_review_stats 집계뷰가 W2-3 동결 묶음에 속한다는 **오분류**에 근거했다. 본 세션에서 정정 확인 후 해소했다.
### 정정 근거
`docs/work-log/2026-06-17-jam-platform-roadmap.md` 3개 지점:
- `:63` — "W2-3 범위 = 잼 평가만. 댓글/리뷰 스키마 자체는 W3에서 설계(동결 묶음 아님)."
- `:201` — "잼 평가(심사/투표/시상) 스키마만 동결. 댓글/리뷰 스키마는 W3-2에서 별도 설계."
- `:202` — "댓글/리뷰 분리 → W3-2 일반기능으로 재분류: 잼 평가 동결묶음에서 분리."
### 결론
`game_review_stats` = `game_reviews`(W3-2 테이블) 위 **읽기전용 집계뷰** 신설 = **W3-2 일반 DDL**. W2-3 잼 평가(심사/투표/시상) 스키마 동결과 완전히 무관하다. `§6 파괴적 게이트 / 사용자 재확인 경로` 불필요 — 본 세션에서 일반 CREATE VIEW 로 처리 완료.
### 부수 무효화
아래 두 보수 주석이 본 변경으로 무효화됐다(구현 완료 시 갱신 대상):
| 위치 | 기존 보수 주석 | 상태 |
|---|---|---|
| `docs/game-reviews-ddl.sql:63` | "집계 컬럼/뷰는 신설하지 않음 (W2-3 동결 보호)" | 본 DDL 블록 추가로 **무효화** |
| `db/schema.sql:136-137` | "집계 컬럼/뷰는 W2-3 동결 — 신설 금지" | 동기화 DDL 블록으로 **무효화** |
> **ADR 권고**: W2-3 동결 경계 재확인(잼 평가 한정, 댓글/리뷰 무관)은 미래 세션에서 `adr/ADR-0001` 로 정식화할 가치가 있다. 현재는 직전 오분류를 정정하는 수준이므로 `changes/` 내 섹션으로 기록하되, W2-3 관련 설계 분기가 재발하면 ADR 발행을 권고한다.
---
## DDL 변경
### 신규 테이블: game_review_axes
리뷰당 정확히 6행. `UNIQUE(review_id, axis_key)`로 중복 방지.
| 컬럼 | 타입 | 비고 |
|---|---|---|
| `id` | bigint PK | `game_review_axes_id_seq` |
| `review_id` | bigint NOT NULL | FK → `game_reviews(id)` |
| `axis_key` | varchar(20) NOT NULL | CHECK 6종 (아래 참조) |
| `score` | smallint NOT NULL | CHECK(1~5) |
`axis_key` 6종 (육각형 축 인덱스 0~5 순서 고정):
`immersion`(몰입성) / `creativity`(창의성) / `controls`(조작성) / `completeness`(완성도) / `sound`(사운드) / `visual`(비주얼)
### game_comments.updated_at (멱등 ALTER)
`timestamptz DEFAULT now() NOT NULL` 추가. `edited = updated_at > created_at` (리뷰 대칭). ALTER 직후 기존 댓글 `updated_at = created_at` 정렬 UPDATE 1회 적용(오표시 방지, 멱등).
### game_reviews.is_rating_manual (멱등 ALTER)
`boolean DEFAULT false NOT NULL` 추가. `true` = 유저 직접 선택 overall, `false` = 6축 자동평균.
### game_review_stats (VIEW 신규)
`game_reviews(is_delete IS NOT TRUE)` LEFT JOIN `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) 폐기 공급원.
### 적용 대상 파일
- `docs/game-reviews-ddl.sql` — 멱등 블록 append(+ :63 보수주석 갱신)
- `db/schema.sql` — 동일 정의 동기화(+ :136-137 주석 갱신)
**적용 상태**: PASS — dev 스키마 적용·검증 완료(20260622-170857, `db/apply-local-ddl.sh`). game_review_axes(UNIQUE index ux_game_review_axes_review_axis + score CHECK 1~5 + axis_key CHECK 6종) / game_review_stats(9컬럼) / game_comments.updated_at(timestamptz NOT NULL, 기존행 = created_at) / game_reviews.is_rating_manual(boolean DEFAULT false NOT NULL) 전수 확인.
---
## 런타임 동작 변화
### A1 — commentView 단일 통일
| 항목 | 이전 | 이후 |
|---|---|---|
| POST /comments 응답 | flat(commentId/gameId/authorName/userId/content) | commentView 전체(+createdAt/edited/updatedAt) |
| PUT /comments/{id} 응답 | 부분(commentId/content) | commentView 전체 |
| commentView 키 수 | 가변 | 고정 8키 |
### A2 — 하이브리드 작성자명 + 탈퇴자 마스킹 (QG-2 해결)
`GameCommentsMapper` LEFT JOIN `users` + CASE 3분기:
1. `u.id IS NULL` → 스냅샷 닉네임(레거시 user_id NULL 레코드 역호환)
2. `u.is_delete``"(탈퇴한 사용자)"` 마스킹
3. else → `u.display_name`
QG-2(레거시 user_id NULL 레코드 `authorName` 빈값) 자동 해결.
### A3 — 댓글 edited/updatedAt
댓글 수정 시 `updated_at = now()` 갱신. `edited = (updated_at > created_at)`. nickname 덮어쓰기 없음.
### B1 — 페이지네이션 (limit+1 hasMore 방식)
| 항목 | 이전 | 이후 |
|---|---|---|
| 목록 반환 형태 | `{ status, items[] }` | `{ status, items[], hasMore }` |
| 요청 파라미터 | 없음 | `?page=<int≥0>&sort=<enum>` |
| 더보기 방식 | 전체 반환 | 20건 고정, `limit+1` 조회 후 hasMore 판정 |
sort enum:
| 대상 | 허용값 | 기본값 |
|---|---|---|
| 댓글 | `oldest`, `newest` | `oldest` |
| 리뷰 | `newest`, `rating_desc`, `rating_asc` | `newest` |
미허용 값 → 기본값 fallback (400 미반환). ORDER BY는 `@SelectProvider` 컴파일타임 상수 분기 — `${}` 동적치환 0건. `offset`/`limit`은 `#{}` 바인딩.
### B2 — 리뷰 본문 최소 10자
`TextNormalizer.normalize()` 후 trim 10자 미만 → 400. 댓글은 현행 유지(상한 200자만).
### B3 — TextNormalizer 제어문자 정규화
신규 `com.pandoli365.bibimbap.util.TextNormalizer.normalize(String raw)`:
- C0/C1 제어문자(U+0000~U+001F, U+007F~U+009F) 중 `\t`(U+0009)·`\n`·`\r` 제외 후 제거
- 외곽 `strip()`. 내부 공백/줄바꿈 보존.
- 댓글 content / 리뷰 body 검증 **전** 적용.
### 다축 평점 — overall 자동/수동
| 입력 | overall 결정 | is_rating_manual |
|---|---|---|
| overall 미전송/빈값 | `rating = Math.round(6축 평균)` (서버 계산, HALF_UP) | `false` |
| overall 직접 전송 | `rating = 전송값(1~5 검증)` | `true` |
axes 재저장 전략(editReview): `deleteReviewAxes(reviewId)``addReviewAxes(reviewId, 6행)` (TX 원자성).
---
## 프론트엔드 (frontend-design 스킬 패스)
frontend-design 스킬로 처리된 `game-detail.jsp` 변경 요약. 기존 앰버/크림 디자인 언어 확장, 외부 의존성 추가 없음.
| 컴포넌트 | 내용 |
|---|---|
| 육각형 SVG 레이더 | 인라인 SVG 자체 구현. 요약(viewBox 200×200, R=80) + 개별 리뷰 카드 컴팩트(viewBox 120×120, R=44). 방사 그라디언트·글로우·등장 모션. `role="img" aria-label` 6축 점수 텍스트 대체(a11y 필수). |
| 6축 radiogroup (C6) | `buildStarRadioGroup(axisKey, labelText)` 공용 함수. roving tabindex(선택 radio `tabindex="0"`, 나머지 `-1`). 키보드: ←→↑↓ 이동+즉시선택, Home=1/End=5, Space/Enter 확정, `preventDefault()` 스크롤 방지. |
| C1 submit 잠금 | 제출 중 버튼 disabled + 로딩 표시, 응답 후 해제. 연타 차단. |
| C2 상대시각 | "n분 전" 표시 + title 절대시각. |
| C4 글자수 카운터 | 댓글 n/200, 리뷰 n/1000 + 최소 10자 안내 실시간 표시. |
| C5 완전소멸 | 삭제 항목 DOM 제거 유지. |
| 서버집계 표시 | game_review_stats 평균 소수 1자리, review_count=0 → "아직 평가 없음". 클라 평균계산(updateSummary 구버전) 제거. |
---
## 신규/변경 파일
### 신규 (4개)
| 파일 | 설명 |
|---|---|
| `src/.../data/ReviewAxisRow.java` | 축 평점 행 POJO (reviewId/axisKey/score) |
| `src/.../mapper/GameReviewAxesMapper.java` | axes add/deleteAll/listByReviewIds |
| `src/.../mapper/GameReviewStatsMapper.java` | game_review_stats 1행 조회 |
| `src/.../util/TextNormalizer.java` | B3 제어문자 정규화 static util |
### 변경 (13개)
| 파일 | 변경 내용 |
|---|---|
| `docs/game-reviews-ddl.sql` | 고도화 DDL 블록 append(:63 보수주석 갱신) |
| `db/schema.sql` | 동기화(:136-137 동결주석 C3 갱신) |
| `src/.../data/GameCommentData.java` | updatedAt + edited 필드 추가 |
| `src/.../data/GameReviewData.java` | ratingManual + axes(Map) 필드 추가 |
| `src/.../mapper/GameCommentsMapper.java` | A2 LEFT JOIN+CASE, A3 updated_at, B1 offset/limit/sort |
| `src/.../mapper/GameReviewsMapper.java` | B1 offset/limit/sort, is_rating_manual, @SelectProvider |
| `src/.../controller/api/GameCommentController.java` | A1 재조회·commentView, B1 page/sort, B3 normalize |
| `src/.../controller/api/GameReviewController.java` | 다축 검증/저장, overall 자동·수동, B2 10자, B3, B1, summary |
| `src/.../controller/api/GameController.java` | gameDetail SSR 신규 매퍼 주입 (영향맵 누락→보정 완료) |
| `src/main/webapp/WEB-INF/views/game-detail.jsp` | 육각형 SVG, C1~C6, 페이지네이션·sort UI, 서버집계, 6축 입력 |
| `src/test/.../GameCommentControllerTest.java` | A1·A2·B3 신규 + 기존 12건 유지 |
| `src/test/.../GameReviewControllerTest.java` | 다축·B2·sort 신규 + 기존 13건 유지 |
| `src/test/.../BibimbapApplicationTests.java` | 신규 매퍼(GameReviewAxesMapper·GameReviewStatsMapper) @MockBean 추가 |
---
## API 계약 변화
### commentView 스키마 (A1 통일 — POST·PUT·list 동일)
```json
{
"commentId": 100,
"gameId": 1,
"authorName": "표시명 또는 (탈퇴한 사용자) 또는 스냅샷닉",
"userId": 7,
"content": "...",
"createdAt": "ISO-8601",
"edited": false,
"updatedAt": "ISO-8601"
}
```
### reviewView 스키마 (axes 추가)
기존 reviewView(reviewId/gameId/authorName/userId/rating/body/edited/createdAt/updatedAt) + 신규:
```json
{
"ratingManual": false,
"axes": { "immersion":4,"creativity":5,"controls":3,"completeness":4,"sound":2,"visual":5 }
}
```
### GET list 응답 형태
- 댓글: `{ status, comments:[commentView...], hasMore }`
- 리뷰: `{ status, reviews:[reviewView...], hasMore, summary }`
- `summary` = `{ avgRating, reviewCount, axes:{immersion..visual} }` (review_count=0 이면 `null`)
### 요청 파라미터 신규
`?page=<int≥0, default 0>&sort=<enum, default 토글기본값>`
---
## 검증 결과 요약
| 레이어 | 결과 | 비고 |
|---|---|---|
| L1 단위 테스트 (43건) | GREEN — 43/43 Failures 0 | 기존 31건 무회귀. FIX LOOP 1회(테스트 계약 미갱신 8건, 컨트롤러 무버그 — body<10자 정당거부). verification-advisor 2차 독립 재판정 확정. |
| AC-4 sort 5분기 | PASS | `@SelectProvider` 분기 5건 전수, ORDER BY 상수 |
| AC-5 `${}` 0건 | PASS | 신규·변경 매퍼 전수(주석 3건 제외) |
| AC-6 axis_key 6키 정합 | PASS | DDL CHECK· FILTER· enum·JSP 라벨 4곳 일치 |
| L2 contract-DB | skip | 원격 DB 미기동 |
| L3 브라우저 스모크 | **PASS (+버그 2건 발견·수정)** | dev 실게임(game id=3) 대상, 세션 20260622-170857. 아래 §L3 결과 참조 |
| DDL 적용 | **PASS** | game_review_axes/stats/updated_at/is_rating_manual 4객체 dev 스키마 검증 완료(20260622-170857) |
FIX LOOP 상세: `report.md#Invocations` implementation-advisor(FIX LOOP §2.6) 근본원인=테스트 계약 미갱신(컨트롤러 무버그). body<10자 7곳10자+ 교체, 행위 assertion 보존, 계약 무약화. main 코드 무수정.
---
## L3 브라우저 스모크 결과 (세션 20260622-170857)
dev 실게임(game id=3, 더미) + dev 픽스처 계정(`tester@bibimbap.local`, `db/seed-dev.sql`) 검증. 브라우저 확장의 computer/screenshot 도구가 해당 페이지에서 `document_idle` 미도달로 차단되어, 검증은 `javascript_tool`(DOM/이벤트 동기 검사)·`curl`(서버 계약psql(DBJSP 정적분석을 조합해 수행. 검증 세션 생성 테스트 데이터(tester=user3)는 전량 삭제해 원본 시드(댓글 3·리뷰 5) 복귀.
| 항목 | 결과 | 근거 |
|---|---|---|
| **B3 XSS 미실행** | PASS | 댓글 `<img src=x onerror=alert(1)>`/`<script>alert(2)</script>` + 리뷰 본문 저장 → 재렌더 시 isOnlyTextNode=true, img/script 엘리먼트 미생성, alert/confirm/prompt 0회. game-detail.jsp:1840(댓글)/:2136(리뷰) textContent. |
| **재방문 영속** | PASS | 작성 후 새로고침 유지 + 쿠키 없는 GET이 서버 데이터 반환(localStorage 비의존). |
| **다축 6행 + overall 자동/수동** | PASS | axes(4,5,3,4,2,5) rating 미전송 → overall=4(avg 3.83 HALF_UP), is_rating_manual=false, game_review_axes 6행. PUT rating=2 → rating=2, is_rating_manual=true, edited=true. |
| **육각형 SVG 레이더 + aria** | PASS | summary radar svg role="img" aria-label="6축 평가: 몰입성 4, 창의성 5, 조작성 3, 완성도 4, 사운드 2, 비주얼 5". 개별 카드 레이더 동일 aria. axes 0행 리뷰는 "6축 평가 데이터 없음". |
| **서버 집계 표시** | PASS(수정 후) | summary={avgRating:3.5, reviewCount:6, axes{...}}, UI "3.5"·"(6)" 소수1자리. review_count=0 → buildSummary null → "아직 평가 없음" 코드경로. |
| **페이지네이션 더보기 20+hasMore** | PASS | 댓글 21건: page0 count=20 hasMore=true, page1 count=1 hasMore=false. |
| **6축 radiogroup 키보드(C6)** | PASS | roving tabindex [0,-1,-1,-1,-1] → ArrowRight 즉시선택+tabindex 이동, Home=1/End=5, Arrow preventDefault=true(스크롤차단), aria-label "몰입성 N점". |
| **C1 submit 잠금** | PASS | 제출 클릭 직후 btn.disabled=true, aria-busy=true. |
| **C2 상대시각** | PASS | "5일 전"/"6일 전"/"7일 전", >7일 절대날짜, `<time datetime=ISO title=절대시각>`. |
| **C4 글자수 카운터** | PASS | 댓글 0/200, 리뷰 0/1000(4자→"4/1000"). |
| **B2 리뷰 10자 미만 400** | PASS | 본문 2자 + 정상 6축 → HTTP 400 "평가는 최소 10자 이상". |
| **A2 작성자명 마스킹** | PASS | 로그인 댓글 authorName="테스터"(display_name). 탈퇴(users.is_delete=true) → "(탈퇴한 사용자)". 레거시는 시드 nickname(스냅샷) 경로. |
## L3 중 발견·수정한 버그 (2건)
L1(43건)이 매퍼를 `@MockBean`으로 대체해 실제 Postgres 동작을 우회 → 아래 2건은 L3 실게임에서만 표면화됨.
### BUG-1 `game_review_stats` 뷰 fan-out (집계 왜곡)
- 위치: `docs/game-reviews-ddl.sql` / `db/schema.sql` 뷰 정의(수정 전).
- 증상: `LEFT JOIN game_review_axes` 직접 조인 → 리뷰가 axes 행수(최대 6)만큼 복제. 다축 리뷰 1건 존재 시 game3 `review_count`=11(실제 6), `avg_rating`=2.8(실제 3.5). axes 0행일 땐 잠복.
- 수정: 축 평균을 서브쿼리에서 game 단위 선집계 후 `LEFT JOIN ... MAX(...)`, 리뷰 단위 집계(`COUNT(*)`/`AVG(rating)`)는 `game_reviews` 단독 산출. 양 파일 동기 + 실행 DB `CREATE OR REPLACE VIEW` 적용. 검증: game3 → review_count=6, avg_rating=3.5(실제 일치).
### BUG-2 `GameReviewStatsMapper` alias 케이스 폴딩 → summary 항상 null
- 위치: `src/.../mapper/GameReviewStatsMapper.java:13-15`(수정 전, 따옴표 없는 `AS gameId/avgRating/reviewCount`).
- 증상: Postgres가 따옴표 없는 alias를 소문자로 폴딩(gameid/avgrating/reviewcount) → MyBatis Map 키 소문자. `GameReviewController.buildSummary``stats.get("reviewCount")`(camelCase) = null → reviewCount=0 → summary=null → JSP(game-detail.jsp:2188) 항상 "아직 평가 없음". 서버 집계 기능 전면 무력.
- 수정: alias 3개 따옴표(`AS "gameId"/"avgRating"/"reviewCount"`). axis alias는 소문자=AXIS_KEYS 일치라 유지. 검증: summary={avgRating:3.5, reviewCount:6, axes{...}}.
- 회귀 가드: 두 버그 모두 DB-통합 계층이라 기존 단위 harness(@MockBean)로 재현 불가. 자동 회귀는 별도 L2 contract(dev DB 연동) 인프라 필요 — 이월 권고. 본 세션은 mvn test 43/43 GREEN(무회귀) + L3 before/after(11/2.8/null → 6/3.5/정상)를 근거로 한다.
## 범위 밖 / 이월
| 항목 | 사유 |
|---|---|
| 신고·숨김 | W1 운영자 role 선행 필요 |
| 리뷰 이력 테이블 | in-row 마커(updated_at)만. 열람 권한 정책 확정 시 별도 |
| 좋아요 서버화 | 별도 관리 (여전히 localStorage) |
| GET /reviews/mine | applyReviewGate 정밀화 — 본인 리뷰 첫 페이지 밖 폼 노출 가능. 서버 409 최종 차단(비차단). 차기 세션 후보 |
| 댓글 더보기 정렬 정합 | 낙관 삽입 2페이지+ 정렬 어긋남 가능. 리뷰는 page0 재조회로 정합, 비차단. 차기 세션 후보 |
| 기존 리뷰 axes 백필 | 기존 game_reviews 행은 axes 0행 — stats 뷰 6축 평균 NULL. 백필 필요 시 별도 |
| game_review_stats L2 contract 테스트 | BUG-1(뷰 fan-out)·BUG-2(매퍼 alias 케이스)가 L1 `@MockBean` 사각으로 누출됨. dev DB 연동 contract 테스트(뷰 집계 정합 + 매퍼 Map 키)로 회귀 가드 신설 권고 |