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

18 KiB
Raw Permalink Blame History

kind title session_id resumed_from created_at status related_prev_change related_security_checklist related_work_log related_design related_report
change W3-2 댓글/리뷰 고도화 — 다축 평점·일관성·목록규모·UX 20260622-092800 20260618-145152 2026-06-22 implemented ./2026-06-18-w3-2-comments-reviews.md ../security/security-remediation-checklist.md ../work-log/2026-06-17-jam-platform-roadmap.md ../../.atp/work-session/20260622-092800/implementation/design.md ../../.atp/work-session/20260622-092800/report.md

W3-2 댓글/리뷰 고도화 변경 이력

상위 코어 이력: 2026-06-18 W3-2 댓글/리뷰 분리 구현

개요

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 동일)

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

{
  "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(DB)·JSP 정적분석을 조합해 수행. 검증 후 세션 생성 테스트 데이터(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.buildSummarystats.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 키)로 회귀 가드 신설 권고