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

35 KiB
Raw Permalink Blame History

phase agent agent_version generated_at concerns references
design design-advisor 1 2026-06-22T00:00:00+09:00
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 가 이미 정정 지시 → 반영 완료.
requirements research adrs
/Users/wemadeplay/workspace/stz/bibimbap/.atp/work-session/20260618-145152/report.md /Users/wemadeplay/workspace/stz/bibimbap/docs/work-log/2026-06-17-jam-platform-roadmap.md
/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 동일)

{
  "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) + 신규:

{
  "...": "기존 필드 유지",
  "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 방지):

@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. 단계 그리드 = R0.25, R0.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 방지 — 인자 사용목적 명시)

// 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은 별도 또는 자동표시).
  • 함수 시그니처(인자 사용목적):
    // 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 메서드는 이번 스코프에선 미통합(회귀 리스크 — 댓글/리뷰 컨트롤러만 호출 추가). 통합 리팩터는 비목표.
  • 시그니처 (인자 사용목적 인라인):
    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 에도 동일 정의 동기화.

-- ===========================================================================
-- 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<>b\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.