fix(reviews): game_review_stats 집계 버그 2건 수정 + W3-2 L3 검증 완료

W3-2 댓글/리뷰 고도화 검증 부채(DDL 적용·L1 회귀·L3 브라우저 스모크) 해소 중
서버 집계의 실버그 2건을 발견·수정. 둘 다 L1 @MockBean 사각으로 누출됨.

BUG-1 game_review_stats 뷰 fan-out
- LEFT JOIN game_review_axes 직접 조인 → 리뷰가 axes 행수(최대 6)만큼 복제,
  COUNT(*)/AVG(rating) 왜곡(다축 리뷰 1건에 review_count 11/avg 2.8, 실제 6/3.5).
- 축 평균은 서브쿼리 game 단위 선집계 후 LEFT JOIN(MAX), 리뷰 단위 집계는
  game_reviews 단독 산출로 수정. docs/game-reviews-ddl.sql + db/schema.sql 동기.

BUG-2 GameReviewStatsMapper alias 케이스 폴딩
- 따옴표 없는 AS gameId/avgRating/reviewCount → Postgres 소문자 폴딩 →
  buildSummary의 camelCase Map 조회 null → summary 항상 null("아직 평가 없음" 오표시).
- alias 3개 큰따옴표로 케이스 보존.

검증
- mvn test 43/43 GREEN(수정 후 무회귀).
- 수정 후 dev 실게임: 뷰 review_count 6/avg 3.5, summary={avgRating:3.5,reviewCount:6,axes}.
- L3 스모크 12항목 전수 PASS(XSS 미실행·재방문 영속·다축6행·SVG레이더·집계·
  페이지네이션·키보드·C1/C2/C4·B2·A2). changes 문서 §L3 결과 + security checklist B3 갱신.
- 권고: game_review_stats L2 contract 테스트 신설(L1 mock 사각 차단) — 이월.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
이정수 2026-06-22 17:54:19 +09:00
parent f81991b409
commit 21892c8a03
7 changed files with 263 additions and 29 deletions

View File

@ -0,0 +1,105 @@
---
schema_version: 2
sid: 20260622-170857
resumed_from: 20260622-092800
started_at: 2026-06-22T17:08:57+09:00
ended_at:
user_request: |
W3-2 댓글/리뷰 고도화 검증 부채 해소 (검증 전용 — 신규 기능 추가 금지).
직전 세션 코드 L1 43/43 GREEN. 미검증분 3가지만 닫는다:
(1) DDL 적용 + dev 스키마 4객체 검증
(2) L1 회귀 가드 (mvn test 43 GREEN 재확인)
(3) L3 브라우저 스모크 (XSS 미실행·재방문 영속·다축평점·페이지네이션·키보드·UX·A2 마스킹)
완료 시 changes 문서 §검증결과 표 + security checklist B3 갱신.
---
# Summary
W3-2 댓글/리뷰 고도화 검증 부채(DDL 적용 / L1 회귀 / L3 브라우저 스모크) 전량 해소. 검증 중 서버 집계의 실버그 2건(뷰 fan-out, 매퍼 alias 케이스 폴딩) 발견·최소수정. L3 12개 항목 전수 PASS. 검증 데이터는 세션 종료 시 정리해 원본 시드 복귀.
핵심 결과:
- STEP1 DDL: dev 4객체 검증 PASS.
- STEP2 L1: mvn test 43/43 GREEN(버그 수정 후 재실행도 43/43, 무회귀).
- STEP3 L3: XSS 미실행·재방문 영속·다축6행·SVG레이더·집계·페이지네이션·키보드·C1/C2/C4·B2·A2 전부 PASS.
- 버그수정: GameReviewStatsMapper.java(alias 3개 quote), docs/game-reviews-ddl.sql + db/schema.sql(뷰 fan-out 수정), 실행 DB CREATE OR REPLACE 적용.
- 환경: 가동 중이던 stale spring-boot:run(목요일 기동, 구 JSP)을 현재 빌드로 재기동. claude-in-chrome computer 도구는 document_idle 차단으로 javascript_tool/curl/psql 조합 검증.
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '사용자가 STEP1/2/3 + 완료처리까지 정밀 명세. 요구 모호성 0.'
checked_at: 2026-06-22T17:09:00+09:00
- advisor: graphify-lookup-advisor / research-advisor
decision: skip
rationale: '검증 전용. 신규 설계·외부조사 불요. 배경문서(changes/local-setup) 직독으로 충분.'
checked_at: 2026-06-22T17:09:00+09:00
- advisor: design-advisor / implementation-advisor
decision: skip
rationale: '코드 변경 기본 없음(검증 전용). 버그 발견 시에만 최소수정 — 그때 재평가.'
checked_at: 2026-06-22T17:09:00+09:00
- advisor: verification-advisor
decision: call
rationale: 'STEP1(DDL 멱등적용+4객체 psql 검증) + STEP2(mvn test 43 GREEN) Bash 검증 위임. 출력 verbose → 컨텍스트 격리 가치.'
checked_at: 2026-06-22T17:09:30+09:00
# Invocations
- advisor: verification-advisor
phase: STEP1+STEP2
model_choice: { tier: medium, rationale: 'Bash 실행+AC 판정, 코드추론 불요' }
result: PASS
detail: |
AC-1 game_review_axes PASS (score CHECK 1-5, axis_key CHECK 6종, UNIQUE는 INDEX ux_game_review_axes_review_axis 로 구현 — constraint 카탈로그 미표기지만 기능동등)
AC-2 game_review_stats 뷰 9컬럼 PASS
AC-3 game_comments.updated_at timestamptz NOT NULL, mismatched=0/total=3 PASS
AC-4 game_reviews.is_rating_manual boolean DEFAULT false NOT NULL PASS
AC-5 mvn test: Tests run 43 Failures 0 Errors 0 (GameComment17/GameReview20/Csrf5/App1) PASS
concerns: 'AC-1 UNIQUE 구현형태=INDEX(not constraint). 기능동등, 비차단.'
# Bug Findings (L3 실게임 검증 중 발견 — L1 mock 사각)
## BUG-1 game_review_stats 뷰 fan-out (집계 왜곡)
- 근거: docs/game-reviews-ddl.sql:171-185 (수정전) — `LEFT JOIN game_review_axes` 직접 조인.
- 증상: 리뷰에 axes 행이 존재하면 리뷰가 axes 행수(최대 6)만큼 복제 → `COUNT(*)`=11(실제 6), `AVG(rating)`=2.8(실제 3.5). axes 0행일 땐 잠복(이전 "3.8(5)" 정상), 다축 리뷰 입력 순간 발현.
- 수정: 축 평균을 서브쿼리에서 game 단위 선집계 후 LEFT JOIN(MAX), 리뷰 단위 집계는 game_reviews 단독. docs/game-reviews-ddl.sql + db/schema.sql 동기 + 실행 DB CREATE OR REPLACE 적용.
- 수정후 검증: 뷰 game3 → review_count=6, avg_rating=3.5 (실제값 일치). PASS.
## BUG-2 GameReviewStatsMapper alias 케이스 폴딩 → summary 항상 null
- 근거: GameReviewStatsMapper.java:13-15 (수정전) — `AS gameId/avgRating/reviewCount` (따옴표 없음).
- 증상: Postgres가 따옴표 없는 alias 를 소문자(gameid/avgrating/reviewcount)로 폴딩 → MyBatis Map 키 소문자. buildSummary(GameReviewController.java:372-390) 가 `stats.get("reviewCount")` camelCase 조회 → null → reviewCount=0 → summary=null. JSP(game-detail.jsp:2188 `if(!summary||!summary.reviewCount)`)는 항상 "아직 평가 없음" 오표시. 서버집계 기능 전면 무력.
- 왜 L1 통과: BibimbapApplicationTests/컨트롤러테스트가 매퍼를 @MockBean 으로 대체 → 실제 Postgres alias 폴딩 미발생. mock-vs-reality 갭.
- 수정: alias 3개 따옴표(`AS "gameId"/"avgRating"/"reviewCount"`). axis alias 는 소문자=AXIS_KEYS 일치라 유지.
- 수정후 검증: 앱 재기동 후 curl 재확인(아래 verified_by_me).
# Decisions
- 버그 발견으로 "검증 전용·코드무수정 기본" 가정 반전 → task 의 "실제 버그 발견 시 최소수정" 사전승인 하에 수정. 수정 범위: 매퍼 alias 3개 + 뷰 정의(2파일) + 실행DB 재적용. 비파괴(CREATE OR REPLACE, alias quote).
- 회귀테스트: 두 버그 모두 DB-통합 계층(L1 mock 우회). 기존 단위 harness(@MockBean)로 재현 불가 → 자동 회귀 테스트는 별도 L2 contract(dev DB 연동) 인프라 필요. 본 세션은 L3 curl+브라우저 before/after(11/2.8/null → 6/3.5/정상)를 회귀 근거로 삼고, L2 contract 테스트 신설을 open_items 로 권고.
- DDL 적용은 §6 파괴 게이트 비해당: 멱등 CREATE TABLE/ALTER ADD/CREATE OR REPLACE VIEW (DROP/TRUNCATE/rollback 아님). changes 문서서 이미 일반 DDL 분류. → 사용자 재확인 불요.
- detail 라우트 = /game/{id} (numeric games.id). dev 가시게임 id=3.
- L3 브라우저 스모크는 claude-in-chrome 필요 → advisor 미보유 → orchestrator 직접 수행.
# verified_by_me
- L1: unit+regression — mvn test 43/43 GREEN (GameComment17/GameReview20/Csrf5/App1), 버그수정 후 재실행도 43/43 (verification-advisor 2회 독립 판정).
- DDL: dev 스키마 4객체 psql 검증 (game_review_axes UNIQUE index+CHECK 6종+score 1~5 / game_review_stats 9컬럼 / game_comments.updated_at timestamptz NOT NULL / game_reviews.is_rating_manual boolean DEFAULT false NOT NULL).
- L3 (javascript_tool+curl+psql+JSP정적): XSS 미실행(alert 0회, textContent 텍스트노드) / 재방문 영속(쿠키없는 GET 서버데이터) / 다축 6행+overall 자동(false)·수동(true) / SVG레이더+aria 6축 / 집계 3.5·(6) / 페이지네이션 21건 page0=20 hasMore=true page1=1 / 키보드 roving+preventDefault / C1 disabled+aria-busy / C2 상대시각+title / C4 0/200·0/1000 / B2 2자→400 / A2 테스터·탈퇴마스킹.
- 버그수정 검증: BUG-1 뷰 review_count 11→6, avg 2.8→3.5. BUG-2 summary null→{avgRating:3.5,reviewCount:6,axes}.
- 로그 스캔: clean (앱 기동 로그 ERROR 0, 환경성 WARN만).
# needs_user_verification
- (선택) claude-in-chrome computer/screenshot 도구가 본 dev 페이지에서 document_idle 미도달로 차단됨 — 시각적 스크린샷 증빙 없음(검증은 DOM/이벤트/서버계약으로 동치 수행). 원인이 WebGL iframe/page idle 휴리스틱인지 사용자 환경 확인 권장(비차단).
- 가동 앱: 본 세션이 stale spring-boot:run(구 JSP)을 현재 빌드로 재기동해 8080에서 실행 중. 사용자가 별도 기동 흐름이 있었다면 인지 필요.
# graph_refresh
fresh → 후속 없음. graph-refresh-checker 판정: 변경분(매퍼 alias 따옴표 + 뷰 내부 집계로직)이 심볼 시그니처·뷰/테이블/컬럼 토폴로지를 바꾸지 않음(구조 시그널 0). 재생성·삭제 불필요. source_commit 메타 갱신은 선택(필수 아님) — 본 세션 미수행.
# ended_at
2026-06-22T17:50:00+09:00
# open_items
- L2 contract 테스트 신설 권고: game_review_stats 뷰 집계 정합 + GameReviewStatsMapper Map 키를 dev DB 연동으로 가드. BUG-1/2가 L1 @MockBean 사각으로 누출된 근본원인 차단용. (changes 문서 §범위밖/이월 + security checklist 연계)
- 미커밋 잔여 0 목표 — 본 작업 단위 커밋으로 마감.
# user_signals
positive:
- quote: "dev 픽스처로 자동 로그인 (Recommended) 수락"
note: 제안한 검증 경로/권장안을 1회에 수락(이견 없음).
negative: []

View File

@ -0,0 +1,61 @@
---
phase: verification
agent: verification-advisor
agent_version: 1
generated_at: 2026-06-22T17:48:00+09:00
concerns: []
concerns_checked: true
---
# 검증 결과
## Acceptance Criteria (입력 받은 그대로 인용)
`export JAVA_HOME=/opt/homebrew/opt/openjdk@21 && ./mvnw -P dev test` 실행 → BUILD SUCCESS + 총 43건 GREEN (Failures 0, Errors 0). "Tests run: N, Failures: F, Errors: E" 합계 라인 인용. 회귀(F/E>0) 시 명확히 FAIL + 실패 테스트명·메시지 인용.
변경 scope(검증 대상 아님, 맥락): GameReviewStatsMapper.java SQL alias 3개 따옴표 추가(gameId/avgRating/reviewCount), game_review_stats 뷰 정의 변경(DDL, Java 무관).
## 실행된 전략
레지스트리(`docs/development/verification-strategies.md`)는 템플릿 상태(실제 `cmd` 미기재)이고 통합 검증 스크립트(`make verify`/`scripts/verify.sh`)가 부재. AC가 직접 지정한 `./mvnw -P dev test`가 L1(typecheck + 단위/회귀) 통합 실행 수단이다 — Maven test phase가 test-compile(타입체크) → surefire(단위/회귀)를 순차 포함.
변경 scope = MyBatis mapper SQL alias + 뷰 DDL. 외부 서비스 live contract(L2) 의존 없음 → L2 해당 없음(skip 아님, scope 미매칭).
| id | cmd | exit | severity | 결과 |
|---|---|---|---|---|
| verify-l1 | `export JAVA_HOME=/opt/homebrew/opt/openjdk@21 && ./mvnw -P dev test` | 0 | blocker | pass |
분해 결과:
| 단계 | 결과 |
|---|---|
| L1 typecheck (test-compile) | pass (Nothing to compile - all classes up to date; compile error 0) |
| L1 unit+regression (surefire) | pass (43/43 GREEN) |
| L2 contract | n/a (변경 scope에 외부 의존 계약 없음) |
| 로그 스캔 | clean (FAIL/ERROR/assert 없음; Mockito self-attach + JDK agent warning은 환경성 비기능 경고로 테스트 무관) |
합계 라인(원문 인용):
- `[INFO] Tests run: 43, Failures: 0, Errors: 0, Skipped: 0`
- `[INFO] BUILD SUCCESS`
클래스별 분해(원문 인용):
- `GameCommentControllerTest` Tests run: 17, Failures: 0, Errors: 0, Skipped: 0
- `GameReviewControllerTest` Tests run: 20, Failures: 0, Errors: 0, Skipped: 0
- `UserControllerCsrfTest` Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
- `BibimbapApplicationTests` Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
- 합계 17+20+5+1 = 43
## 실패 상세
없음 (회귀 0건).
## 종합 판정
overall: pass
rollback_signal: none
## Acceptance 매칭
| criterion | 매칭 전략 | 판정 |
|---|---|---|
| BUILD SUCCESS | verify-l1 | pass (`[INFO] BUILD SUCCESS`) |
| 총 43건 GREEN | verify-l1 | pass (`Tests run: 43`) |
| Failures 0 | verify-l1 | pass (`Failures: 0`) |
| Errors 0 | verify-l1 | pass (`Errors: 0`) |
| 합계 라인 인용 | verify-l1 | pass (`Tests run: 43, Failures: 0, Errors: 0, Skipped: 0`) |

View File

@ -199,19 +199,34 @@ COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6
COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual'; COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual';
-- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원) -- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원)
-- fan-out 방지: game_review_axes 직접 JOIN 시 리뷰가 axes 행수만큼 복제되어
-- COUNT(*)/AVG(rating) 왜곡. 축 평균은 서브쿼리에서 game 단위 선집계 후 LEFT JOIN.
CREATE OR REPLACE VIEW "game_review_stats" AS CREATE OR REPLACE VIEW "game_review_stats" AS
SELECT SELECT
r."game_id" AS "game_id", r."game_id" AS "game_id",
ROUND(AVG(r."rating")::numeric, 1) AS "avg_rating", ROUND(AVG(r."rating")::numeric, 1) AS "avg_rating",
COUNT(*) AS "review_count", COUNT(*) AS "review_count",
MAX(ax."avg_immersion") AS "avg_immersion",
MAX(ax."avg_creativity") AS "avg_creativity",
MAX(ax."avg_controls") AS "avg_controls",
MAX(ax."avg_completeness") AS "avg_completeness",
MAX(ax."avg_sound") AS "avg_sound",
MAX(ax."avg_visual") AS "avg_visual"
FROM "game_reviews" r
LEFT JOIN (
SELECT
rv."game_id" AS "game_id",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='immersion'),1) AS "avg_immersion", 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"='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"='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"='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"='sound'),1) AS "avg_sound",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='visual'),1) AS "avg_visual" ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='visual'),1) AS "avg_visual"
FROM "game_reviews" r FROM "game_reviews" rv
LEFT JOIN "game_review_axes" a ON a."review_id" = r."id" JOIN "game_review_axes" a ON a."review_id" = rv."id"
WHERE rv."is_delete" IS NOT TRUE
GROUP BY rv."game_id"
) ax ON ax."game_id" = r."game_id"
WHERE r."is_delete" IS NOT TRUE WHERE r."is_delete" IS NOT TRUE
GROUP BY r."game_id"; GROUP BY r."game_id";
COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균'; COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균';

View File

@ -103,7 +103,7 @@ related_report: "../../.atp/work-session/20260622-092800/report.md"
- `docs/game-reviews-ddl.sql` — 멱등 블록 append(+ :63 보수주석 갱신) - `docs/game-reviews-ddl.sql` — 멱등 블록 append(+ :63 보수주석 갱신)
- `db/schema.sql` — 동일 정의 동기화(+ :136-137 주석 갱신) - `db/schema.sql` — 동일 정의 동기화(+ :136-137 주석 갱신)
**적용 상태**: needs_user_verification (dev: schema.sql 재부트 or ddl 수동 실행) **적용 상태**: 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) 전수 확인.
--- ---
@ -267,13 +267,49 @@ frontend-design 스킬로 처리된 `game-detail.jsp` 변경 요약. 기존 앰
| AC-5 `${}` 0건 | PASS | 신규·변경 매퍼 전수(주석 3건 제외) | | AC-5 `${}` 0건 | PASS | 신규·변경 매퍼 전수(주석 3건 제외) |
| AC-6 axis_key 6키 정합 | PASS | DDL CHECK·뷰 FILTER·앱 enum·JSP 라벨 4곳 일치 | | AC-6 axis_key 6키 정합 | PASS | DDL CHECK·뷰 FILTER·앱 enum·JSP 라벨 4곳 일치 |
| L2 contract-DB | skip | 원격 DB 미기동 | | L2 contract-DB | skip | 원격 DB 미기동 |
| L3 브라우저 스모크 | **needs_user_verification** | DDL 적용 후 dev 실게임 대상 | | L3 브라우저 스모크 | **PASS (+버그 2건 발견·수정)** | dev 실게임(game id=3) 대상, 세션 20260622-170857. 아래 §L3 결과 참조 |
| DDL 적용 | **needs_user_verification** | game_review_axes/stats/updated_at/is_rating_manual | | 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 코드 무수정. 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.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/정상)를 근거로 한다.
## 범위 밖 / 이월 ## 범위 밖 / 이월
| 항목 | 사유 | | 항목 | 사유 |
@ -284,3 +320,4 @@ FIX LOOP 상세: `report.md#Invocations` implementation-advisor(FIX LOOP §2.6)
| GET /reviews/mine | applyReviewGate 정밀화 — 본인 리뷰 첫 페이지 밖 폼 노출 가능. 서버 409 최종 차단(비차단). 차기 세션 후보 | | GET /reviews/mine | applyReviewGate 정밀화 — 본인 리뷰 첫 페이지 밖 폼 노출 가능. 서버 409 최종 차단(비차단). 차기 세션 후보 |
| 댓글 더보기 정렬 정합 | 낙관 삽입 2페이지+ 정렬 어긋남 가능. 리뷰는 page0 재조회로 정합, 비차단. 차기 세션 후보 | | 댓글 더보기 정렬 정합 | 낙관 삽입 2페이지+ 정렬 어긋남 가능. 리뷰는 page0 재조회로 정합, 비차단. 차기 세션 후보 |
| 기존 리뷰 axes 백필 | 기존 game_reviews 행은 axes 0행 — stats 뷰 6축 평균 NULL. 백필 필요 시 별도 | | 기존 리뷰 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 키)로 회귀 가드 신설 권고 |

View File

@ -168,19 +168,35 @@ COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6
COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual'; COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual';
-- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원) -- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원)
-- fan-out 방지: game_review_axes 를 직접 JOIN 하면 리뷰가 axes 행수(최대 6)만큼
-- 복제되어 COUNT(*)/AVG(rating) 이 왜곡된다. 축 평균은 서브쿼리에서 game 단위로
-- 선집계한 뒤 LEFT JOIN 한다 (리뷰 단위 집계는 r 한 테이블로만 계산).
CREATE OR REPLACE VIEW "game_review_stats" AS CREATE OR REPLACE VIEW "game_review_stats" AS
SELECT SELECT
r."game_id" AS "game_id", r."game_id" AS "game_id",
ROUND(AVG(r."rating")::numeric, 1) AS "avg_rating", ROUND(AVG(r."rating")::numeric, 1) AS "avg_rating",
COUNT(*) AS "review_count", COUNT(*) AS "review_count",
MAX(ax."avg_immersion") AS "avg_immersion",
MAX(ax."avg_creativity") AS "avg_creativity",
MAX(ax."avg_controls") AS "avg_controls",
MAX(ax."avg_completeness") AS "avg_completeness",
MAX(ax."avg_sound") AS "avg_sound",
MAX(ax."avg_visual") AS "avg_visual"
FROM "game_reviews" r
LEFT JOIN (
SELECT
rv."game_id" AS "game_id",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='immersion'),1) AS "avg_immersion", 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"='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"='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"='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"='sound'),1) AS "avg_sound",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='visual'),1) AS "avg_visual" ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='visual'),1) AS "avg_visual"
FROM "game_reviews" r FROM "game_reviews" rv
LEFT JOIN "game_review_axes" a ON a."review_id" = r."id" JOIN "game_review_axes" a ON a."review_id" = rv."id"
WHERE rv."is_delete" IS NOT TRUE
GROUP BY rv."game_id"
) ax ON ax."game_id" = r."game_id"
WHERE r."is_delete" IS NOT TRUE WHERE r."is_delete" IS NOT TRUE
GROUP BY r."game_id"; GROUP BY r."game_id";
COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균'; COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균';

View File

@ -108,15 +108,15 @@
- [x] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다. → **초기 모델 주입 가능 + 별도 GET C1(`GET /game/{id}/comments`) fetch 방식 채택.** GameCommentController C1 구현 완료(20260618-104034). - [x] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다. → **초기 모델 주입 가능 + 별도 GET C1(`GET /game/{id}/comments`) fetch 방식 채택.** GameCommentController C1 구현 완료(20260618-104034).
- [x] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다. → **C2 `POST /game/{id}/comments`: CsrfTokens.isValid(403), content 200자(400), 로그인(401) 적용.** L1 12건 PASS(20260618-104034). - [x] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다. → **C2 `POST /game/{id}/comments`: CsrfTokens.isValid(403), content 200자(400), 로그인(401) 적용.** L1 12건 PASS(20260618-104034).
- [x] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. → **C4: 작성자 본인(sessionUserId) OR ROLE_ADMIN. 비작성자 403.** L1 PASS(20260618-104034). - [x] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. → **C4: 작성자 본인(sessionUserId) OR ROLE_ADMIN. 비작성자 403.** L1 PASS(20260618-104034).
- [x] 서버에서 내려온 댓글도 JSP escape 또는 DOM `textContent`로 렌더링한다. → **game-detail.jsp 댓글 렌더링 전면 textContent 교체.** L3 브라우저 XSS 미실행 확인은 needs_user_verification(L3 스모크). - [x] 서버에서 내려온 댓글도 JSP escape 또는 DOM `textContent`로 렌더링한다. → **game-detail.jsp 댓글 렌더링 전면 textContent 교체.** L3 브라우저 스모크(20260622-170857) 확인: `<img src=x onerror=alert(1)>`/`<script>` 저장 후 재렌더 시 isOnlyTextNode=true, img/script 엘리먼트 미생성, alert 0회 발화.
- [ ] 남용 방지를 위해 rate limit, 로그인 제한, 운영 신고/삭제 정책 중 최소 한 가지를 결정한다. ← **로그인 제한 적용됨(댓글 쓰기 로그인 필수). rate limit/신고 정책은 이월.** - [ ] 남용 방지를 위해 rate limit, 로그인 제한, 운영 신고/삭제 정책 중 최소 한 가지를 결정한다. ← **로그인 제한 적용됨(댓글 쓰기 로그인 필수). rate limit/신고 정책은 이월.**
- [x] localStorage UI는 서버 응답 기준으로 교체한다. → **game-detail.jsp 댓글 localStorage → fetch API 서버 응답 기준으로 전환.** L1 PASS(20260618-104034). - [x] localStorage UI는 서버 응답 기준으로 교체한다. → **game-detail.jsp 댓글 localStorage → fetch API 서버 응답 기준으로 전환.** L1 PASS(20260618-104034).
완료 조건: 완료 조건:
- [~] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L1 단위 서버 저장 확인 완료. 브라우저 재방문 영속은 L3 스모크 대기(needs_user_verification).** 좋아요: localStorage 유지(범위 밖, 미충족). - [x] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L3 스모크(20260622-170857) 확인 — 댓글/리뷰 작성 후 새로고침 유지 + 쿠키 없는(=다른 브라우저/시크릿 동치) GET `/game/3/comments`·`/reviews` 가 서버 데이터를 반환(localStorage 비의존 증명).** 좋아요: localStorage 유지(범위 밖, 미충족).
- [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요 CSRF는 범위 밖. - [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요 CSRF는 범위 밖.
- [~] XSS payload 댓글이 스크립트로 실행되지 않는다. → **textContent 렌더 적용(단위 컨트롤러 raw 반환 확인). 브라우저 렌더 미실행 확인은 L3 스모크 대기.** - [x] XSS payload 댓글이 스크립트로 실행되지 않는다. → **L3 브라우저 스모크(20260622-170857) 확인: 댓글 `<img src=x onerror=alert(1)>`·`<script>alert(2)</script>` + 리뷰 본문 모두 DB raw 저장, 클라 textContent 렌더(game-detail.jsp:1840 댓글/:2136 리뷰) → 텍스트 노드로만 표시(img/script 미생성), alert/confirm/prompt 0회 발화.**
- [x] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. → **GameController.deleteGame에 softDeleteGameReviews 추가.** 댓글 soft-delete도 기존 로직 확인. L1 PASS. - [x] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. → **GameController.deleteGame에 softDeleteGameReviews 추가.** 댓글 soft-delete도 기존 로직 확인. L1 PASS.
> 구현 이력 상세: [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md) > 구현 이력 상세: [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md)

View File

@ -10,9 +10,9 @@ public interface GameReviewStatsMapper {
@Select(""" @Select("""
SELECT SELECT
game_id AS gameId, game_id AS "gameId",
avg_rating AS avgRating, avg_rating AS "avgRating",
review_count AS reviewCount, review_count AS "reviewCount",
avg_immersion AS immersion, avg_immersion AS immersion,
avg_creativity AS creativity, avg_creativity AS creativity,
avg_controls AS controls, avg_controls AS controls,