--- 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=&sort=`. - **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 ``(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 axes); // 6행(axis_key+score) // 수정 시 기존 축 전삭제(이후 add 재삽입). int deleteReviewAxes(@Param("reviewId") long reviewId); // 대상 리뷰 // 목록 화면 N리뷰 축 batch 조회(N+1 회피). List listAxesByReviewIds(@Param("reviewIds") List reviewIds); // 페이지 리뷰 id들 } ``` - `ReviewAxisRow` = `{ Long reviewId; String axisKey; Integer score; }` (신규 POJO 또는 GameReviewData 내 정적 중첩). axes batch insert SQL은 `` (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) ``` - `` (fill 반투명 + stroke). ### 6.5 렌더 함수 시그니처 (inflate 방지 — 인자 사용목적 명시) ```js // 6축 점수 배열을 육각형 SVG 엘리먼트로. 요약/카드 공용. function buildHexRadar(scores /* [6] axis순 점수배열, score폴리곤 */, cx /* 중심x, 좌표기준 */, cy /* 중심y, 좌표기준 */, R /* 반지름, 스케일 */) { ... return } ``` - 최소 인자. 라벨 텍스트/색상은 함수 내부 상수(AXIS_LABELS_KO) 참조 → 인자 미부풀림. ### 6.6 a11y (SVG 시각요소 텍스트 대체 — 필수) - ``. - 추가로 시각적 보조: 각 축 점수를 visually-hidden 표 또는 인접 `
`(축명/점수) 동반. 폴리곤만으로 종료 금지(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' ` >= 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 렌더. `` 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`.