--- 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=&sort=` | | 더보기 방식 | 전체 반환 | 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=&sort=` --- ## 검증 결과 요약 | 레이어 | 결과 | 비고 | |---|---|---| | 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(DB)·JSP 정적분석을 조합해 수행. 검증 후 세션 생성 테스트 데이터(tester=user3)는 전량 삭제해 원본 시드(댓글 3·리뷰 5) 복귀. | 항목 | 결과 | 근거 | |---|---|---| | **B3 XSS 미실행** | PASS | 댓글 ``/`` + 리뷰 본문 저장 → 재렌더 시 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일 절대날짜, `