Compare commits

..

10 Commits

Author SHA1 Message Date
이정수 f81991b409 chore(atp): work-session 산출물 기록 (3개 세션)
work-session 은 추적 대상(durable history)이나 3개 디렉토리가 untracked 로
남아 있던 것을 소급 기록 — 추적 관행 일관성 복원.

- 20260622-162345: 이번 세션(로컬 DDL 적용 + 커밋 정책).
- 20260622-092800: 오늘 선행 세션.
- 20260618-145152: 6/18 orphan(후속 W3-2 작업에 이미 계승됨).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:59:18 +09:00
이정수 5a8080ddd3 docs(dev): 비-메인스트림 브랜치 커밋 표준 승인 정책 추가
비-메인스트림 브랜치(main/master·보호 브랜치 외)에서는 작업 단위마다 매번
묻지 않고 커밋하도록 표준 승인(durable authorization)을 명문화. 하니스
기본규칙('사용자 요청 시에만 커밋')과 모순 없음 — 프로젝트 지침이 사전·상시
승인을 부여하는 형태. push 는 브랜치 무관 명시 요청 한정, 메인스트림은
branch-first 유지.

- docs/development/git-workflow.md: 정본 신설. 브랜치 분류·표준 승인·push
  제외·커밋 단위·Conventional Commits + Co-Authored-By 트레일러 규약.
- CLAUDE.md 작업 원칙: 표준 승인 요지 2불릿 + 정본 링크.
- docs/development/index.md: 정본 등재.

ADR 아닌 development 규칙으로 분류(가역적 운영 절차). 부수 발견: 보고서들이
참조하는 ADR-0010 은 docs/adr/ 에 실재하지 않는 phantom 참조.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:59:09 +09:00
이정수 3593c82d78 feat(db): 로컬 DDL 즉시적용 스크립트 + 셋업 절차 문서화
마이그레이션 도구(flyway/liquibase) 부재. db/schema.sql 은 컨테이너 최초
기동 시 1회만 자동 주입되어, 이후 docs/*-ddl.sql 변경은 실행 DB 에 미반영.

- db/apply-local-ddl.sh: docs/*-ddl.sql 을 실행 컨테이너 dev 스키마에 멱등
  적용(search_path 강제, ON_ERROR_STOP=1, .env 접속정보 로드, 컨테이너 가드).
- docs/usage/local-setup.md §4.1: 신규/변경 DDL 즉시 적용 절차 + 작성자 규약
  (DDL 파일 / 실행 DB / db/schema.sql 세 곳 동기화).

검증: game-reviews-ddl.sql 적용 후 game_review_axes·game_review_stats·
game_comments.updated_at·game_reviews.is_rating_manual 생성 확인, 스크립트
전체 멱등 재실행 성공.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:58:59 +09:00
이정수 375a2de0ff docs(graph): W3-2 댓글/리뷰 반영 graph 메타 갱신
src+docs 양 scope 재생성 (fully-stale 판정 처리, no-defer).
- src: 536노드/1206엣지/20커뮤니티 — 리뷰 다축평점·댓글 commentView·edited 토폴로지 반영
- docs: 119노드/164엣지/15커뮤니티 — C3 재분류·육각형·axes/stats DB 스키마·검증교훈 군집
source_commit b9d836d 동기화. graph 본체는 .gitignore(재생성 가능).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:40:24 +09:00
이정수 b9d836d5f4 feat: W3-2 댓글/리뷰 고도화 — 다축 평점·육각형 레이더·페이지네이션·집계뷰
W3-2 코어 위에 FINAL SPEC 전체 구현 (resumed_from 20260618-145152).

- 다축 평점: game_review_axes(6축·리뷰당6행) + overall 자동평균/수동(is_rating_manual)
- 육각형 SVG 레이더(요약+카드, frontend-design 폴리시), 6축 radiogroup(roving tabindex)
- A1 commentView 통일 / A2 하이브리드 작성자명+탈퇴자 마스킹(QG-2) / A3 edited·updated_at
- B1 페이지네이션(limit+1 hasMore) + sort 화이트리스트(@SelectProvider, ${} 미사용)
- B2 본문 최소10자 / B3 TextNormalizer 정규화
- C3 game_review_stats 집계뷰(W3-2 일반 DDL, 잼평가 동결 무관). 클라 평균계산 폐기
- DDL 멱등 ALTER/CREATE (game-reviews-ddl.sql + db/schema.sql 동기화)

검증: ./mvnw test 43/43 GREEN(회귀0). DDL 적용·L3 스모크 = 사용자 환경.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:29:21 +09:00
이정수 3a06b39d76 chore(dev): W3-2 테스트용 dev seed + serena memory
리뷰/댓글 기능 수동 테스트를 위한 멱등 dev seed.

- seed-dev.sql / seed-dev-teardown.sql: 로그인 테스트 계정 1, 더미 게임 1,
  별점 다양한 더미 리뷰 5(유저별 1리뷰 unique 충족), 댓글 3. NOT EXISTS 가드로 재실행 안전.
- serena memory: PBKDF2(BCrypt 아님) 해시 형식, dev-seed blueprint(FK순서+active-unique),
  JDK 컨테이너 교차검증 패턴 기록.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019tHAb6XYHWDPzb82FNKugo
2026-06-18 14:35:33 +09:00
이정수 e98c437765 feat: W3-2 댓글/리뷰 분리 구현
기존 game_comments(닉네임 자유입력, localStorage 전용)를 서버 영속화 +
로그인 연동으로 전환하고, 별점 5점 + 서술 평가 형태의 game_reviews 도메인 신설.

- 댓글 서버 영속화: localStorage → DB game_comments. user_id NULL FK 비파괴 추가,
  content 200자 앱레벨 검증. 레거시 nickname 레코드 보존(비마이그레이션, QG-2).
- game_reviews 신설: 게임당 1회(partial UNIQUE), rating CHECK(1~5), soft-delete.
- 권한: 작성자 본인 OR ROLE_ADMIN. 비로그인 쓰기 401, CSRF 미검증 403.
- GameController.deleteGame 에 리뷰 cascade soft-delete 추가.
- GameCommentController(C1~C4) / GameReviewController(R1~R5) 신규.
- 컨트롤러 단위 테스트 추가, BibimbapApplicationTests 매퍼 @MockBean 보강.
- 보안 체크리스트 댓글 항목 충족 마킹, 좋아요 항목은 범위 밖 미결 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019tHAb6XYHWDPzb82FNKugo
2026-06-18 14:35:24 +09:00
이정수 f9590e59bc docs: 에이전트 출력 규약 추가 — 의사결정 제시문 압축 비적용
W3 합의 세션 회고 교훈을 MEMORY 대신 docs/development/ 에 반영(사용자 지시).
사용자 대면 의사결정 제시문은 배경+선택지+권장 풀어쓰기, 압축은 내부 산출물 한정.
발원: 20260617-174635 세션 W3-1 마무리 질문 과압축 → 사용자 파악 실패.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:16:09 +09:00
이정수 15b8bc3362 docs: W3 사이트플랫폼 5기능 골자 확정 (draft→confirmed)
W3-1~W3-5 골자를 기능별·항목별 합의해 status draft→confirmed.

- W3-1 태그+검색: 통합 1테이블, 정렬 매트릭스(태그 일치도 가중×2차키), 개발자 검색
- W3-2 댓글/리뷰: 리뷰=별점5+서술(다축 후속), 권한 작성자+운영자, 수정=이력보존+'수정됨' 표시
- W3-3 포스팅: W1 완료 후 착수, 카테고리 DB, 마크다운, OG 미리보기, 유니티블로그 외부 피드 감시
- W3-4 메인 허브: 잼 노출=검색창 아래 배너→잼 태그 검색 라우팅, 페이지네이션 도입
- W3-5 WebGL: deferred 유지, 조사 항목 7개·권한·QG-3 조사 단계로

합의 중 새 결합 3건 발굴: W3-1↔W3-2 양방향 / W3-4→W3-1 잼태그 라우팅 / W3-3 외부 fetch 인프라.
착수순서 W3-2→W3-1→W3-4→W3-3→W3-5 확정. 미결질문은 해소 않고 합의 목록으로 유지.
세션 산출 동봉(20260617-172407 초안 생성 + 20260617-174635 합의).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:57:37 +09:00
이정수 2eb0be5654 docs: 고도화 로드맵 목적축 재구조 (게임잼 단일프레임 → W1~W4 워크스트림)
20260617-150635 로드맵(S1~S7, 의존성축)을 목적축("왜 존재/누구를 위한가")으로
재검토. 모든 항목이 단일 "게임잼" 프레임에 묶여 있던 것을 4개 목적 워크스트림으로 분해:

- W1 거버넌스/RBAC — 사이트 거버넌스, 게임잼은 소비자, 독립
- W2 게임잼 — 본체(엔티티+심사위원역할+잼평가설계+심사/투표/시상)
- W3 사이트 플랫폼 — 태그검색·댓글리뷰·포스팅·메인허브·Unity, 전부 잼일정 독립
- W4 유저 배지/평판 — 신규 발굴(리뷰어/기술자가 RBAC 플래그로 오분류됐던 것)

150635 대비 핵심 교정 5건 + S→W 매핑표 + 의존성 그래프(빌드순서) 유지.

번들(이전 잔여 정리): 오늘 work-session durable history
(094450/143315/150635/162536), serena 설정·메모리, 분석문서 교차링크.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:20:53 +09:00
71 changed files with 9797 additions and 125 deletions

View File

@ -0,0 +1,146 @@
---
schema_version: 1
sid: 20260617-094450
started_at: 2026-06-17T09:44:50+09:00
ended_at: 2026-06-17T10:05:00+09:00
user_request: |
PR #1 (docs/project-analysis → main, "docs: ATP 문서 체계 도입 및 프로젝트 종합 분석")
당사자 승인 완료. 그러나 main 에 실수로 "일부 병합한" 커밋(f6f691c)이 들어가 PR 이
mergeable:false 상태. main 으로 병합 + 충돌 해결 요청.
PR URL: https://gitea.pandoli365.com/pandoli365/bibimbap/pulls/1
---
# Summary
진단 (read-only):
- PR #1 head = `d297f31` (docs/project-analysis), base = `main`. gitea state=open, **mergeable:false**, merged:false.
- merge-base = `40bde12` (단일).
- 사고 커밋 `f6f691c "fix: harden auth csrf handling"` 가 두 가지를 한 커밋에 섞음:
1. **정당한 csrf 보안 수정** (B1): UserController.java / login.jsp / signup.jsp / 테스트 2건
2. **PR 문서의 부분·다른 버전 스냅샷**: CLAUDE.md, docs/analysis/..., docs/index.md, .serena/project.yml,
.atp/work-session(파일형), 그리고 README.md 컨벤션 docs (PR 은 index.md 컨벤션)
- 동일 경로가 양측에서 다르게 추가/수정 → gitea 충돌. 단 `.atp/work-session` 은 main=blob / PR=tree
의 **파일/디렉토리 타입 충돌**이 gitea mergeable:false 의 핵심 원인.
위험 발견:
- **naive `git merge` 금지**: 로컬 ort 전략이 충돌 0으로 자동 병합하지만, 결과 트리가 **csrf 코드 5건을
base 로 되돌리고 csrf 테스트 파일을 삭제**함 (블롭 해시로 검증). gitea Merge 버튼도 신뢰 불가.
→ 결과 트리를 결정론적으로 구성해야 함.
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '요청 명확(병합+충돌해결). 모호점은 git 상태 자체이며 orchestrator 직접 조사가 효율적.'
checked_at: 2026-06-17T09:45:00+09:00
- advisor: research-advisor
decision: skip
rationale: 'git/gitea 상태 조사는 orchestrator 직접 Bash 가 적합. 외부 자료 불요.'
checked_at: 2026-06-17T09:45:00+09:00
- advisor: design/implementation-advisor
decision: skip (pending user)
rationale: 'git 병합 충돌해결은 §6 파괴적 게이트 + 단계별 명령. orchestrator 직접 수행이 적합. 사용자 결정 후 진행.'
checked_at: 2026-06-17T09:45:00+09:00
# Invocations
- inv-001: graph-refresh-checker (post-merge staleness 판정) — model_choice: phase=graphify-judgment, tier=large(inherit), rationale='no-defer 정책상 머지 직후 그래프 신선도 판정'
# Decisions
- D1: csrf 보안 수정(f6f691c 코드분)은 정당 → main 버전 보존 (전제, 사용자 미반대)
- D2: CLAUDE.md → main 프로젝트 지침 + PR ATP 블록 결합 (사용자 선택)
- D3: 사고 커밋 README.md 5건 제거, security-remediation-checklist.md(고유) 유지 (사용자 선택)
- D4: 반영 방식 → main 에 병합커밋(parents f6f691c+d297f31) + push (사용자 선택)
- D5: 충돌 문서(project-analysis.md/docs.index.md/.serena.project.yml) → PR 정식본. db-update-query-generator.md·user-signup-schema.md 는 ort 자동병합 union 유지(링크 전부 유효, README 참조 없음).
# self_correction
- 세션 초반 "naive git merge 가 csrf 코드를 base 로 되돌리고 테스트를 삭제한다"는 발견을 보고했으나, 이는 **로컬 main 이 stale(40bde12)** 상태에서 만든 temp 브랜치로 테스트해 발생한 오판. 올바른 base(origin/main=f6f691c)로 재실행하니 정상 충돌 표면화 + csrf 보존. 교훈: 머지 동작 추론 전 `git fetch` 후 로컬 브랜치==원격 동기화 여부 확인. (memory_candidate)
# user_signals
positive:
- '권장안 3건(CLAUDE 결합/README 제거/병합커밋+push)을 한 라운드에 모두 수락'
negative: []
# verified_by_me
- 'L1: 병합 결과 src/ 트리가 origin/main(f6f691c)과 바이트 동일 — git diff --cached f6f691c -- src/ 빈 결과. 코드 델타 0, csrf 보존. 신규 코드 없음 → 빌드 불요(docs-only 델타).'
- 'L2: skip — 외부 의존성/계약 변경 없음(docs·설정 머지)'
- '충돌 0 잔여, README 5건 제거 확인, .atp/work-session blob→dir 해소(4 entries)'
- 'push: f6f691c..8c41b22 main->main 성공. d297f31 이 origin/main ancestor 재확인(=PR 내용 머지 완료)'
# graph_refresh
- 판정: fresh (src·docs 모두 fresh; docs 는 partial-stale 경계 근접)
- 미반영 항목: docs/security/security-remediation-checklist.md 1개 문서 노드(+analysis/schema 링크 엣지). src 는 구조 시그널 0(UserController 변경은 기존 CsrfTokens.isValid 호출 2줄, 신규 노드/엣지 없음).
- 처리: 재생성 유보. 사유 = checker 가 docs 재생성을 "선택적·저효익(노드 1개)"으로 평가. /graphify 는 docs 그래프 전체 재생성 + 추가 push 를 유발하므로 PR 머지라는 작업 범위에 비해 과함. 사용자가 그래프 완전성을 원하면 `/graphify docs/` 로 트리거 가능 (open item).
# needs_user_verification
- 'gitea PR #1 레코드: 직접 push 로는 자동 merged 전환 안 됨(state:open 잔존). gitea UI 에서 수동 close 하거나 API 토큰 제공 시 닫기 대행 가능. 내용상 머지는 완료.'
- '세션 노트 .atp/work-session/20260617-094450/ 는 untracked. 영속화 원하면 별도 커밋 가능.'
Retrospective:
signals:
positive:
- quote_or_paraphrase: '권장안 3건(CLAUDE 결합 / README 5건 제거 / 병합커밋+push)을 한 라운드에 모두 수락'
about: 'orchestrator 가 충돌 해소 방식을 옵션화해 한 번에 제시 — 재질의·재지시 없이 단발 수락. 비자명한 결정(병합커밋 parents 구성, 충돌 문서 정식본 채택)을 묶어 제시한 판단이 검증됨.'
negative: []
what_went_well:
- 'self_correction 메커니즘이 작동: 세션 초반의 "naive git merge 가 csrf 코드를 base 로 되돌리고 테스트를 삭제한다"는 오판을 push 전에 스스로 발견·정정. 잘못된 결론이 사용자에게 확정 안내되거나 파괴적 push 로 이어지기 전에 차단됨.'
- '파괴적 git 작업(병합 충돌 해결)을 advisor 우회 없이 orchestrator 직접 수행 — 프로토콜 §1 직접 수행 영역 판정이 정확. advisor 호출 스킵 사유를 Decision Log 에 즉시 기록.'
- '결과 트리를 블롭 해시 수준으로 결정론적 검증(L1: src/ 트리가 origin/main 과 바이트 동일, csrf 보존 확인). gitea Merge 버튼·로컬 ort 자동병합을 신뢰하지 않고 직접 검증.'
- 'graph staleness 를 no-defer 정책에 따라 머지 직후 판정하고, 재생성 효익(노드 1개) 대비 비용(전체 docs 그래프 재생성 + 추가 push)을 평가해 유보 + open item 으로 위임.'
what_to_improve:
- 'git 머지 동작을 추론·시뮬레이션하기 전 baseline ref 의 신선도를 확인하지 않았다. stale 로컬 main(40bde12) 기반 temp 브랜치로 머지를 테스트해 "csrf 파괴" 라는 오판을 생성·보고했다. fetch 후 로컬==원격 동기화 확인이 추론보다 선행해야 한다.'
- '오판을 정정한 것은 self_correction 으로 회수됐으나, 그 오판이 한 번은 진단 보고(Summary 위험 발견 섹션)에 실렸다. 추론 기반 위험 경고를 보고에 싣기 전 baseline 전제(어느 ref 로 시뮬레이션했는지)를 명시하는 게이트가 있었다면 오판 단계에서 자가 포착 가능했을 것.'
memory_candidates:
- name: merge-reasoning-requires-fresh-baseline-ref
type: feedback
description: 'git 머지/충돌 동작을 추론·시뮬레이션하기 전 fetch 후 로컬 브랜치==원격 ref 동기화 여부를 먼저 확인. stale baseline 으로 시뮬레이션하면 잘못된 위험 결론이 나온다.'
body_draft: |
git 머지/충돌 결과를 추론하거나 temp 브랜치로 시뮬레이션하기 전에 `git fetch`
시뮬레이션 baseline 으로 쓸 로컬 브랜치가 원격 ref(보통 origin/main)와 동기화돼 있는지
먼저 확인한다. stale baseline 으로 머지를 테스트하면 실제와 다른 결과 트리가 나와
"코드가 파괴된다" 류의 잘못된 위험 결론을 보고하게 된다.
**Why:** 세션 20260617-094450 에서 "naive git merge 가 csrf 코드를 base 로 되돌리고
테스트를 삭제한다"는 위험을 진단 보고에 실었으나, 이는 로컬 main 이 stale(40bde12)
상태에서 만든 temp 브랜치로 머지를 테스트해 발생한 오판이었다. 올바른 base
(origin/main=f6f691c)로 재실행하니 정상적인 충돌이 표면화되고 csrf 가 보존됐다.
오판은 push 전 self_correction 으로 회수됐지만, 그 전에 한 번 위험 경고로 보고됐다.
관련: bump 커밋 *쓰기 대상* 선정 시 stale 브랜치 회피는
[[feedback_bump_target_branch_must_be_consumer_tracked_ref]] — 본 항목은 *읽기/추론*
baseline 의 신선도라는 별개 축.
**How to apply:**
- 머지 동작 추론/시뮬레이션 전: `git fetch``git rev-parse <local> origin/<remote>`
동기화 확인, 또는 시뮬레이션 base 를 `origin/<remote>` 로 직접 지정.
- 추론 기반 위험 경고를 보고에 실을 때는 "어느 ref 를 baseline 으로 시뮬레이션했는가"를
함께 명시 — 전제가 드러나면 stale 오판을 자가 포착하기 쉬워진다.
- 충돌 머지 결과는 블롭 해시 수준으로 결정론 검증(git diff --cached <ref> -- <path>).
rationale_for_saving: '재현 가능한 패턴(stale ref 기반 시뮬레이션 → 오판). 코드/커밋 로그로 유도 불가, 관찰로만 드러남. 기존 bump-target memory 와 인접하나 read/추론 baseline 신선도라는 별개 축으로 비중복.'
signal_source: observation
docs_sync_target: null
conflicts_with: null
- name: report-risk-claim-states-its-baseline-premise
type: feedback
description: '추론·시뮬레이션 기반 위험 경고를 진단 보고에 실을 때 baseline 전제(어느 ref·상태로 시뮬레이션했는지)를 함께 명시. 전제 누락 시 오판이 무검증으로 보고된다.'
body_draft: |
진단 보고(Summary "위험 발견" 등)에 추론·시뮬레이션 기반 경고를 실을 때는
그 결론의 baseline 전제를 한 줄로 함께 기록한다 — 예: "origin/main(f6f691c) 기준
ort 시뮬레이션 결과". 전제가 명시되면 검증자(및 작성자 자신)가 전제의 타당성부터
점검할 수 있어 stale·잘못된 전제에서 나온 오판을 보고 단계에서 포착할 수 있다.
**Why:** 세션 20260617-094450 에서 stale 로컬 main 기반 머지 오판이 "위험 발견"
섹션에 baseline 전제 없이 단정적으로 실렸다. push 전 self_correction 으로 회수됐으나,
전제가 보고에 명시돼 있었다면 오판 단계에서 자가 포착 가능했을 것이다.
**How to apply:**
- 진단 보고의 위험/경고 항목은 (관찰 사실 | 추론) 을 구분하고, 추론이면 전제 ref·상태를 병기.
- "naive X 하면 Y 파괴된다" 류 강한 단정은 결정론 재현 검증(블롭 해시/diff) 후에만 단정형으로.
rationale_for_saving: '위 머지 baseline 교훈의 메타 층위(보고 규약). orchestrator 의 보고 작성 습관에 재현 적용 가능. self_correction 이 회수한 경로를 한 단계 앞당기는 게이트로 비중복.'
signal_source: observation
docs_sync_target: null
conflicts_with: null
protocol_feedback:
- '프로토콜 §1(orchestrator 직접 수행 영역) 또는 §6(파괴적 게이트)에 "git 머지/리베이스 동작을 추론·시뮬레이션하기 전 baseline ref 신선도 확인(fetch + 로컬==원격)" 선행 체크를 추가 권고. 이번 세션의 stale-baseline 오판은 단발 실수가 아니라 시뮬레이션 baseline 전제를 명시하지 않는 구조적 허점에 가깝다(structural 경계).'
- '진단 보고의 "위험 발견" 류 항목에 (관찰 | 추론) 구분 + 추론 시 baseline 전제 병기를 권장 포맷으로 추가하면, self_correction 이 사후에 회수하던 오판을 보고 작성 시점에 자가 포착하도록 앞당길 수 있다.'
applied_changes: []

View File

@ -0,0 +1,36 @@
---
schema_version: 1
sid: 20260617-143315
user_request: "바이너리 직접 실행(./mvnw -P dev spring-boot:run) 시 DB 연결 문제"
started_at: 2026-06-17T14:33:15+09:00
ended_at: 2026-06-17T14:34:00+09:00
status: resolved
code_change_lines: 0
---
# Summary
호스트 직접 실행(경로 B) DB 연결 실패 진단. 근본 원인: bibimbap-db postgres 컨테이너 미기동 → localhost:5433 connection refused. db 컨테이너 기동으로 해소.
# Advisor Invocation Decision Log
- advisor: ALL
decision: skip
rationale: '런타임 환경 진단 + 0줄 코드변경 마이크로 작업. 원인=컨테이너 미기동(설정 정상). orchestrator 직접 처리(§1 예외).'
checked_at: 2026-06-17T14:33:20+09:00
# Decisions
- 근본원인: docker-compose db 서비스(bibimbap-db) 미생성/미기동. db.properties(localhost:5433), .env(DB_PORT=5433) 는 정상.
- kord-postgres(host 5432) 는 무관 — bibimbap 은 5433 사용.
- 조치: `docker compose up -d db` (가역적 로컬 액션, §6 파괴게이트 비해당).
# verified_by_me
- L1: n/a (코드변경 0)
- 런타임: bibimbap-db Up(healthy), host 5433 LISTEN, dev 스키마 6테이블 자동주입(schema.sql), psql CONNECT_OK(dev.games 0행 무에러).
# needs_user_verification
- `./mvnw -P dev spring-boot:run` 재실행 → Tomcat 8080 + 홈 / HTTP 200 1회 확인(앱→DB 라운드트립).
# graph_refresh
skip: no-scope-change (코드 변경 0줄)
# open_items
- (없음) — 미커밋 잔여는 본 세션 산출 아님(.serena/*, 이전 work-session dir).

View File

@ -0,0 +1,103 @@
---
title: 게임잼 플랫폼 고도화 — 세션 로드맵
status: draft (사용자 확정 대기)
created_at: 2026-06-17
session: 20260617-150635
---
# 게임잼 플랫폼 고도화 — 세션별 로드맵
기존 bibimbap(게임 공유 + 팀원 모집, Spring Boot + MyBatis + JSP) 위에 **게임잼 운영/평가/리뷰/관리자/포스팅/검색** 신규 기능군을 얹는다. 메모를 "1항목=1세션"으로 쪼개면 공유 자원(게임잼 엔티티·RBAC·평가 데이터모델)에서 재작업이 폭증하므로(§2.7), **토대 → 결합 클러스터 → 표면 기능** 순으로 의존 순서를 강제한다.
## 의존 그래프
```
S1 RBAC(권한) ─┬─> S2 게임잼 엔티티 ─┬─> S3 태그+검색
│ ├─> S4D 평가 통합설계(스키마 동결)
│ │ └─> S4a 댓글/리뷰분리 ─┬─> S4b 심사위원
│ │ ├─> S4c 인기투표
│ │ └─(4a·4b·4c)─> S4d 시상집계
│ └─> S6 메인 통합(노출 대상 필요)
└─> S5 포스팅 보드 (S4와 독립, S1 후 병렬 가능)
└─> S6 메인 통합
차후: S7 Unity 빌드 업로드 파이프라인 (별도 조사)
운영: Discord/뒤끝/상금 (코드 외 — 표시 필드만 S2 흡수)
```
- **크리티컬 패스**: S1 → S2 → S4D → S4a → S4d → S6
- **S1 직후 병렬 가능**: S5(포스팅)
- **S2 직후 병렬 가능**: S3(태그)
- **S4 분할 원칙**: S4D(통합설계) 가 평가/리뷰/투표/댓글/시상 **스키마를 한 번에 동결**. 이후 S4a~S4d 는 동결된 계약을 소비만 한다 — 구현 세션이 스키마를 바꾸면 형제 세션 재작업이므로, 스키마 변경은 S4D 로 회귀해 처리.
## 세션 카드
### S1 — 권한 체계(RBAC) 기반 ★최우선 토대
- **목표**: 사이트 관리권한 모델 + 기능별 인증유저 플래그 스캐폴드.
- **포함**: 관리자(전체)/부관리자(허용 권한만 = 권한부여형) 모델, 관리자 콘솔(부관리자 임명 + 권한 토글), 인증유저 플래그 정의·부여(리뷰어/포스터/심사위원/기술자), 권한 체크 인터셉터.
- **제외**: 각 권한의 실제 사용처(후속 세션이 소비).
- **의존**: 없음.
- **공유 자원**: `users`, `security/`, 세션/인증.
- **권장 진입**: requirements-advisor(권한 모델 정밀화) → design → implementation.
- **산출**: 권한 스키마 마이그레이션, 관리자 콘솔, 권한 인터셉터.
### S2 — 게임잼 엔티티 + 라이프사이클 ★토대
- **목표**: 게임잼 이벤트 엔티티 + 상태전이 + 관리 + 출품작 연결.
- **포함**: `jams` 테이블, 상태전이(모집→개발→평가→종료), 개발기간/평가기간 필드, 관리자 게임잼 CRUD, 출품작 = `games`에 잼 연결(개인·팀 모두), 게임잼 목록/상세 페이지, (운영 표시) Discord 링크·상금·후원사 필드.
- **제외**: 평가/투표/리뷰(S4), 검색(S3).
- **의존**: S1(게임잼 생성 권한).
- **공유 자원**: `games`, `recruit_posts`(팀 연결), 상태전이.
- **산출**: `jams` 스키마, 게임잼 페이지, 라이프사이클 상태머신.
### S3 — 태그 + 검색
- **목표**: 게임잼+게임 공통 태그, 게임잼 태그 검색.
- **포함**: 공유 `tags` + 조인테이블(game/jam), 게임잼 태그 검색 UI/쿼리.
- **의존**: S2.
- **산출**: 태그 스키마, 검색 UI.
### S4 — 평가·리뷰·투표 통합 ⚠️결합 클러스터 → 분할 세션화
> §2.7 주의: 댓글/리뷰/심사/투표/시상이 같은 데이터 도메인 + 평가기간 게이트를 공유. **S4D 가 스키마를 한 번에 동결**하고, S4a~S4d 는 그 계약을 소비만 한다. 스키마 변경 필요 시 S4D 로 회귀(형제 세션 재작업 방지).
#### S4D — 평가 통합설계 (설계 전용 세션, 코드 0)
- **목표**: 리뷰/댓글/심사/투표/시상 데이터모델 + 계약 + 평가기간 게이트를 한 번에 확정·동결.
- **포함**: `reviews`(게시물당 1회, 완성도+종합 평점), `game_comments` 200자 제약, 심사 점수 스키마, 잼 투표 스키마, 3트랙 집계 규칙, 상태/기간 게이트 계약.
- **의존**: S1, S2.
- **권장 진입**: design-advisor.
- **산출**: design.md + DB 스키마(동결) + contracts.
#### S4a — 댓글/리뷰 분리 (구현)
- **목표**: `game_comments` 200자 제한 + 리뷰(신규, 게시물당 1회, 완성도+종합 평점) 분리.
- **의존**: S4D.
#### S4b — 심사위원 평가
- **목표**: 심사위원 권한자 점수 입력 → 심사위원 대상.
- **의존**: S4D, S4a, S1(심사위원 권한).
#### S4c — 인기투표
- **목표**: 잼 전용 1인 1표(평가기간 한정) → 인기 대상.
- **의존**: S4D.
#### S4d — 시상 집계/결과
- **목표**: 3트랙(심사/유저평점/인기) 산정 + 수상 표시.
- **의존**: S4a·S4b·S4c 산출.
### S5 — 정보성/홍보성 포스팅 보드 (S1 후 병렬 가능)
- **목표**: 운영진 전용 공지·블로그 + 외부링크 큐레이션. 유저 작성 차단.
- **포함**: `posts` 테이블, 포스터 권한자 작성, 카테고리(유니티블로그/세션/후기인터뷰/뒤끝 개발팀), 외부링크 타입, 유저작성 차단.
- **의존**: S1(포스터 권한). S4와 독립.
- **산출**: `posts` 스키마, 포스터 작성 UI.
### S6 — 메인페이지 통합 (거의 마지막)
- **목표**: `index.jsp` 에 게임잼+게임+포스팅 통합 노출.
- **의존**: S2, S5(노출 대상 존재).
- **산출**: index 확장.
### S7 — (차후) Unity 빌드 업로드 파이프라인 ⏸deferred
- **목표**: WebGL 빌드 업로드 → 검증 → `/game/{uuid}/` 배치 자동화.
- **선행**: 별도 조사(빌드 포맷/검증 규칙/보안 — zip-slip 등).
- **현재**: 스코프 인지만, 후속 결정.
## 운영(코드 외)
- **Discord**: 공지·진행은 수동 운영. 코드는 게임잼 상세에 초대 링크 표시 정도(S2 흡수).
- **뒤끝(Bekend)**: 파트너/콘텐츠 — 포스팅 카테고리로만 등장.
- **상금·후원**(치킨/10만원/편의점 5000원권): 게임잼 상세 표시 필드(S2) + 실제 지급은 수동.

View File

@ -0,0 +1,43 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: 2026-06-17T15:30:00+09:00
concerns: []
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/work-log/2026-06-17-jam-platform-roadmap.md` | work-log | 신규 생성 | `docs/work-log/index.md` | `docs/analysis/2026-06-16-project-analysis.md` 하단 관련 링크 섹션 |
| `docs/work-log/index.md` | work-log | 기존 갱신 | — | — |
| `docs/analysis/2026-06-16-project-analysis.md` | analysis | 기존 갱신 (교차 링크 추가) | — | — |
## 카테고리 판정 근거
**선택: `work-log/`**
`document-category-classification.md` 분류 기준:
- `backlog/` 명시 제외 사항: "확정된 로드맵 → 사용하지 않음"
- `work-log/` 빠른 결정 순서 §8: "진행 중인 작업 메모인가? → work-log/"
- 이 로드맵은 S1~S7 순서로 진행될 다중 세션 작업의 실행 계획이며, 각 후속 세션 진입 시 의존 순서·스코프 경계를 확인하기 위해 참조되는 세션 간 handoff 기준 문서다.
- 아키텍처 구조 설명(`architecture/`)이나 불변 기술 결정(`adr/`)이 아닌 실행 계획이므로 보수적으로 `work-log/`를 선택.
**신규 카테고리 생성**: 없음.
## 의사결정 기록 위치
- 사용자 확정 결정 8건: `.atp/work-session/20260617-150635/report.md` §Decisions
- 결합 트랙 판정(§2.7): `.atp/work-session/20260617-150635/report.md` §Coupling Analysis
- Advisor 호출 결정: `.atp/work-session/20260617-150635/report.md` §Advisor Invocation Decision Log
## 추후 문서화가 필요한 항목
- S1 세션 완료 시: RBAC 스키마 마이그레이션 → `changes/` + 권한 모델 설계 → `architecture/` 또는 `contracts/`
- S4D 세션 완료 시: 평가 통합 데이터모델 동결 → `contracts/` (내부 스키마 계약)
- S7 조사 완료 시: Unity WebGL 업로드 파이프라인 결정 → `adr/` (되돌리기 어려운 기술 선택)
- 각 세션 완료 후: `work-log/2026-06-17-jam-platform-roadmap.md` 의 해당 세션 카드 status 갱신 권장

View File

@ -0,0 +1,154 @@
---
schema_version: 2
session_id: 20260617-150635
resumed_from: null
started_at: 2026-06-17T15:06:35+09:00
ended_at: 2026-06-17T15:20:00+09:00
user_request: |
고도화 위한 계획 수립. 단위별로 끊어서(세션별) 작업할 수 있도록 분류·정리.
사용자가 공유한 모호한 메모(게임잼 운영/평가/리뷰/관리자/포스팅/검색 등)를
각 항목 의미를 확인하며 계획화. 이번 세션은 계획 수립 전용(코드 변경 0).
---
# Summary
게임잼 플랫폼 고도화 기획 세션. 기존 bibimbap(게임 공유 + 팀원 모집) 위에
게임잼 운영/평가/리뷰/관리자(RBAC)/포스팅/태그검색 신규 기능군을 얹는 요청.
메모가 무분별 → requirements 확정 전 orchestrator 가 직접 파싱 + 배치 질의로 의미 확정.
세션 분할(단위별 새 세션) 요구 → §2.7 결합 트랙 판정이 핵심: 공유 자원(게임잼 엔티티/
평가 데이터모델/RBAC) 기준으로 선후 순서를 강제해야 재작업 방지.
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: pending
rationale: '메모가 극도로 모호 → advisor 디스패치 전 orchestrator 가 사용자 배치 질의로 의미 확정 선행. 확정 후 requirements-advisor 로 FR/NFR 분해 디스패치 예정.'
checked_at: 2026-06-17T15:06:35+09:00
- advisor: graphify-lookup-advisor / research-advisor
decision: skip
rationale: '코드 구조는 기존 분석 세션(20260616-111711) + 직접 grep 으로 충분 파악. 외부 연동 모두 운영/차후로 확정 → 이번 세션 외부 조사 불필요.'
checked_at: 2026-06-17T15:06:35+09:00
- advisor: design-advisor / implementation-advisor / verification-advisor
decision: skip
rationale: '사용자가 "계획만 확정, 종료" 선택 → 코드 변경 0. 설계·구현·검증은 각 후속 세션(S1~)에서 수행.'
checked_at: 2026-06-17T15:16:00+09:00
- advisor: documentation-advisor
decision: call
rationale: '확정된 로드맵을 docs/ 적절 카테고리에 영구 저장 + index 연결(docs-first 참조용). 카테고리 분류는 advisor 책임 영역.'
checked_at: 2026-06-17T15:16:00+09:00
# Invocations
- id: inv-001
layer: orchestrator
name: orchestrator
parent_invocation_id: null
input_digest: '게임잼 고도화 메모 24항목 파싱 + 기존 도메인 구조 대조'
output_digest: '소프트웨어 기능 vs 운영 로지스틱 분리 + 결합 트랙 식별 + 배치 질의 설계'
artifacts: []
concerns: []
# Decisions
- by: user
at: 2026-06-17T15:10:00+09:00
decision: '게임잼 = 기존 bibimbap 위 신규 대형기능. games/game_likes/game_comments 재사용·확장, 게임 공유/모집은 유지.'
- by: user
at: 2026-06-17T15:10:00+09:00
decision: '시상 3개 독립 트랙(상호 무관): ①심사위원 대상(심사위원 선정) ②유저평점 대상(유저 리뷰의 완성도+종합리뷰 평점 집계) ③인기투표 대상(최고 게임 투표). 스코어 대상 폐기.'
- by: user
at: 2026-06-17T15:10:00+09:00
decision: '권한 체계 = 사이트 관리권한. 관리자(전체) / 부관리자(허용된 권한만 열림 = 권한부여형). 심사위원은 관리권한과 별개. 향후 기능별 인증유저(리뷰어/포스터/기술자 등) 추가 예정.'
- by: user
at: 2026-06-17T15:10:00+09:00
decision: '포스팅 = 운영진 전용 공지·블로그 보드 + 외부링크 큐레이션. 유저 작성 절대 불가. 콘텐츠 카테고리: 유니티블로그/세션/게임잼대상 후기인터뷰/뒤끝 개발팀.'
- by: user
at: 2026-06-17T15:14:00+09:00
decision: '출품작 = 기존 games 재사용 + 잼 연결(jam_id/조인). 참가 단위 개인·팀 모두.'
- by: user
at: 2026-06-17T15:14:00+09:00
decision: '인기투표 = 게임잼 전용 1인 1표(평가기간 한정). game_likes 와 별개.'
- by: user
at: 2026-06-17T15:14:00+09:00
decision: '외부 연동: Unity WebGL 빌드 업로드 파이프라인만 in-scope(차후 단계). Discord/뒤끝 = 운영/링크/콘텐츠만, 코드 연동 없음.'
- by: user
at: 2026-06-17T15:14:00+09:00
decision: '태그 = 게임잼 + 게임(출품작) 공통 태그 테이블. 검색은 게임잼 중심.'
# Coupling Analysis (§2.7)
# 공유 자원 기준 결합 판정 — "1메모=1세션" 분할 시 재작업 폭증 방지.
foundations:
- RBAC 권한체계 (관리자/부관리자 + 기능별 인증유저 플래그) — 게임잼관리·포스팅·심사위원의 전제
- 게임잼 엔티티 + 라이프사이클 (모집→개발→평가→종료) — 대부분 기능이 매달림
- 공유 태그 모델 — 검색 + 게임잼/게임
coupled_cluster:
# 같은 데이터 도메인(평가/리뷰/투표/댓글) + 게임잼 평가기간 게이트 공유 → 한 설계로 동결 후 구현 분할
- 댓글 vs 리뷰 분리 (댓글 200자 / 리뷰 게시물당1회+평점)
- 심사위원 평가 (→ 심사위원 대상)
- 인기투표 잼전용 1인1표 (→ 인기 대상)
- 3트랙 시상 집계/결과
relatively_independent:
- 정보성/홍보성 포스팅 보드 (RBAC 포스터 권한만 의존, 평가 클러스터와 무관 → 병렬 가능)
deferred:
- Unity WebGL 빌드 업로드 파이프라인 (차후, 별도 조사 필요)
operational_only:
- Discord 공지·진행(수동, 링크 표시만) / 뒤끝 파트너·콘텐츠 / 상금·후원 표시 필드
# Open Items
- 메모 24항목 의미 확정 — 완료
- 세션 분할 단위 확정 — 완료 (S1~S7, S4 분할 세션화 확정)
- **미커밋 잔여(이번 작업 단위)**: `docs/work-log/2026-06-17-jam-platform-roadmap.md`(신규), `docs/work-log/index.md`(링크), `docs/analysis/2026-06-16-project-analysis.md`(교차링크) — 사용자 커밋 확인 대기. 브랜치 feat/v2.
- **이번 작업 단위 외 잔여(이전 세션)**: `.serena/project.yml`(M), `.serena/memories/serena_tooling.md`(??), `.atp/work-session/20260617-094450·143315`(??) — 본 세션 미관여, 별도 처리.
- **다음 시작점**: S1(RBAC) — 사용자가 새 세션에서 `/atp:task S1` 류로 진입. docs/work-log 로드맵 참조.
# verified_by_me
- L1/L2: n/a (코드 변경 0줄, 계획 수립 전용 세션)
- 문서 배치 검증: docs/work-log/2026-06-17-jam-platform-roadmap.md 본문(S1~S7 카드 + S4D/S4a~d 분할 + 의존그래프 + 크리티컬패스) grep 보존 확인. work-log/index.md 링크 1건 추가 확인. frontmatter kind/status/source_session 정상.
# needs_user_verification
- (없음) — 계획 문서 산출만. 단 미커밋 docs 3건 커밋 여부는 사용자 결정.
# graph_refresh
- skip: no-scope-change (§3.2) — src/ scope diff 0, docs/ 전용 변경. fresh 취급. (atp-graphify add-on enable 상태이나 scope 변경 없어 호출 불필요)
# project_gate
- skip: no-project-gate — 코드 변경 0, 런타임/배포 대상 없음.
# User Signals
user_signals:
positive:
- quote_or_paraphrase: '배치 질의 8건에 모두 명확·결단적으로 답변, 로드맵을 1라운드 만에 수락(S4 분할만 보강)'
about: '모호 메모 → 기존 코드 대조 파싱 → 배치 AskUserQuestion → 결합 인지 로드맵 흐름'
negative: []
# Retrospective (inline — retrospective-advisor dispatch skip)
# skip 사유: 코드 변경 0 계획 세션 + 마찰 negative 시그널 0 + 신규 교훈(기존 §2.7/§4.4 적용 외) 없음.
- what_went_well:
- 메모 24항목을 기존 엔티티(games/game_comments/game_likes/recruit_posts) 대조로 신규 vs 기존 vs 운영 분류 → 헛스코프 방지
- §2.7 결합 판정으로 "1메모=1세션" 재작업 함정 사전 차단, S4 통합설계(S4D) 동결 게이트 설계
- §4.4 배치 질의(2라운드×4) + Recommended + 기존자산 인용으로 의미 확정 효율화
what_to_improve:
- 인기투표가 game_likes 재사용 아닌 잼 전용 투표로 밝혀짐 — 1차 파싱서 좋아요=인기로 단정했던 가정을 질의로 교정(시그널 세탁 없이 반영)
memory_candidates: [] # 기존 프로토콜(§2.7/§4.4) 적용 외 신규·재현 교훈 없음 → MEMORY 반영 불필요
protocol_feedback: []
applied_changes: []
# Notes Parse (작업 입력)
# 사용자 원문 메모를 소프트웨어 기능군 vs 운영 로지스틱으로 1차 분류.
software_features:
- 게임잼 엔티티/운영 (개발기간 1주 + 평가기간 3일~1주, 상태 전이)
- 평가 체계 (심사위원제 완성도 / 인기=좋아요집계 / 스코어?)
- 리뷰 vs 댓글 분리 (리뷰=게시물당 1회+평점, 댓글=200자)
- 관리자/부관리자 RBAC
- 정보성/홍보성 포스팅 (블로그/보드)
- 태그 검색 (게임잼 검색)
- 메인페이지 통합 (게임잼 포함 전부 노출)
operational_logistics:
- 상금/후원 (치킨3마리 / 총괄 10만원 / 음료수·편의점 5000원권) — 코드 아님(상금 표시 UI 는 별개)
- Discord 공지·진행 (연동 깊이 미확정)
- Unity 엔진 (제출 메타 vs WebGL 호스팅 — 이미 /game/** 호스팅 존재)
- 뒤끝(Bekend BaaS) 개발팀 (파트너/후원 vs 기술연동 미확정)
content_seeds:
# "정보성/홍보성 포스팅" 의 예시 콘텐츠로 추정
- 유니티 블로그 / 세션 / 게임잼 대상 후기인터뷰 / 뒤끝 개발팀
# User Signals
user_signals:
positive: []
negative: []

View File

@ -0,0 +1,48 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: 2026-06-17T17:10:00+09:00
concerns:
- "status: active 유지 — 재구조된 로드맵은 여전히 진행 중인 계획이므로 active 유지 적절."
- "restructured_by 필드 신규 도입 — 표준 frontmatter에 없는 커스텀 필드이나, 세션 추적성을 위해 source_session 보완 용도로 허용. 과도한 필드 추가 아님."
- "documentation-guidelines.md 미존재 — docs/development/documentation-guidelines.md 파일이 없어 참조 불가. document-category-classification.md로 카테고리 판별 수행."
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/work-log/2026-06-17-jam-platform-roadmap.md` | work-log | 기존 문서 전면 재작성 | `docs/work-log/index.md` 항목 설명문 갱신 | report.md(20260617-162536) → Decisions 섹션이 본 doc 재구조 트리거로 교차 참조 |
| `docs/work-log/index.md` | work-log | 기존 index 항목 설명문 갱신 | — (index 자체) | — |
## 의사결정 기록 위치
- `.atp/work-session/20260617-162536/report.md` — Decisions 섹션에 목적 축 재분류 확정 전 과정(S1~S7 → W1~W4) 기록. Emergent Categories + final_workstreams + key_reframes 포함.
- `docs/work-log/2026-06-17-jam-platform-roadmap.md` — 재구조된 로드맵 본문. "150635 대비 핵심 교정 5건" 섹션에 교정 이유 명시. S→W 매핑 표로 추적성 보존.
## 적용한 섹션 목록
1. frontmatter — title/description/kind/status/owner/created_at/source_session 유지 + `restructured_by: 20260617-162536` 신규 필드 추가
2. 본문 도입 — 재구조 이유 + 4개 워크스트림 + 게임잼=W2 명시
3. W1 — 거버넌스/RBAC (★토대)
4. W2 — 게임잼 (★본체): W2-1~W2-6 세분화
5. W3 — 사이트 플랫폼: W3-1~W3-5 세분화, 잼 일정 분리 가능 명시
6. W4 — 유저 배지/평판 (★신규 워크스트림, 150635 누락)
7. 운영 (코드 외) 섹션
8. 의존성 그래프 / 빌드 순서 — ASCII 코드블록, 워크스트림 라벨로 재서술
9. S→W 매핑 표 — 추적성 보존 (이전 handoff 호환)
10. 150635 대비 핵심 교정 5건
## S→W 매핑 포함 여부
포함. `docs/work-log/2026-06-17-jam-platform-roadmap.md` 내 "S → W 매핑 (추적성)" 섹션에 표 형식으로 작성. 구 S1~S7 전체 매핑 + 교정 사항 명시.
## 추후 문서화가 필요한 항목
- W2-3 잼 평가 통합설계(스키마 동결) 세션 진입 시 → design.md + contracts/ 문서 신규 작성 필요 (동결 스키마 + 집계 계약)
- W4 유저 배지/평판 — 신규 발굴 워크스트림이므로 상세 requirements 확정 시 별도 work-log 또는 domain/ 문서 권장
- W3-5 Unity 업로드 조사 결과 → analysis/ 문서로 기록 필요 (빌드 포맷/검증/보안 조사)

View File

@ -0,0 +1,209 @@
---
schema_version: 2
session_id: 20260617-162536
resumed_from: null
started_at: 2026-06-17T16:25:36+09:00
ended_at: 2026-06-17T17:16:00+09:00
user_request: |
고도화를 위해 세션을 나눴었는데, 기능 관련 계획은 나뉜 것 같다.
그러나 각각 "왜 쓰고 무엇을 위해 존재하는지"(목적 축)는 명확하게 안 나뉜 것 같아
하나씩 짚어보려 한다. 제공했던 메모가 모두 게임잼을 위해 존재했던 것은 아니다.
→ 직전 세션(20260617-150635)이 메모 24항목을 전부 "게임잼 플랫폼 고도화" 프레임으로
묶은 데 대한 목적 축 재분류 요청.
---
# Summary
직전 150635 세션 산출 로드맵(S1~S7)을 "목적(존재 이유)" 축으로 재검토하는 분석 세션.
150635 는 기능 의존성(공유 자원 §2.7) 축으로는 잘 쪼갰으나, 모든 항목을 "게임잼" 목적
프레임 아래 배치 → 사이트 전반용 기능(RBAC/포스팅/태그/리뷰·댓글/Unity업로드/메인)과
게임잼 전용 기능(잼 엔티티/심사/인기투표/시상)이 목적상 섞여 보이는 framing gap.
사용자 요청 = 항목별 "게임잼 전용 vs 사이트 플랫폼 vs 공유" 목적 재분류 후 하나씩 확정.
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: pending
rationale: '본질이 목적 축 재분류 + 사용자와 1:1 대화형 확정. advisor 는 AskUserQuestion 불가 → orchestrator 직접 대화로 의미 확정 선행. 필요 시 확정 후 로드맵 재작성을 documentation-advisor 로 위임.'
checked_at: 2026-06-17T16:26:00+09:00
- advisor: graphify-lookup / research-advisor
decision: skip
rationale: '코드 신규 조사 불요 — 기존 엔티티(games/game_comments/game_likes/recruit_posts)는 150635·110836 세션에서 확인됨. 본 세션은 기존 산출물(로드맵+decisions) 해석.'
checked_at: 2026-06-17T16:26:00+09:00
- advisor: requirements-advisor
decision: skip(완료)
rationale: '목적축 의미확정을 orchestrator 가 사용자 1:1 배치질의(S1~S7 + 플래그)로 완료. advisor 재분해 불요 — 산출 결정이 이미 확정적.'
checked_at: 2026-06-17T16:55:00+09:00
- advisor: documentation-advisor
decision: call
rationale: '확정된 W1~W4 목적 워크스트림을 로드맵 doc 에 W중심 전면 재구조로 반영. docs 카테고리/index/guidelines 준수는 advisor 책임. 사용자 "W중심 전면 재구조" 선택.'
checked_at: 2026-06-17T16:55:00+09:00
# Invocations
- id: inv-001
layer: orchestrator
name: orchestrator
started_at: 2026-06-17T16:25:36+09:00
input_digest: '150635 로드맵 + report decisions + notes parse 수집'
output_digest: '목적 축 재분류표 작성, 사용자 확정 대기'
model_choice:
phase: analyze
dispatch_size: direct
tier: large
effort: medium
resolved_model: inherit
capped: false
rationale: '프레이밍·목적 판정은 다항목 교차 추론(§5.4 axis4) → orchestrator 직접 보유. 코드 변경 0.'
# Decisions
- by: user
at: 2026-06-17T16:30:00+09:00
decision: '진행 방식 = S1부터 하나씩 목적 확정 → 종료 시 로드맵 doc 에 목적 축 반영.'
- by: user
at: 2026-06-17T16:34:00+09:00
decision: |
S1 "RBAC" 은 성격이 다른 3개가 한 카드에 뭉쳐 있었음 (150635 오분류):
1. 관리자/부관리자 = 운영 중심 설계 = 진짜 RBAC (사이트 거버넌스, 잼 무관)
2. 심사위원 = 게임잼 전용 역할 권한
3. 리뷰어 = 권한 아님, 유저 '배지/평판' 개념 (업로드 게임 인증 리뷰어) → RBAC 도메인 분리 필요
포스터/기술자 = 미확정 (후속 질의).
- by: user
at: 2026-06-17T16:40:00+09:00
decision: |
S1 플래그 4종 최종 분해:
- 포스터 = 운영 작성 권한(부관리자 토글) → S1 RBAC 흡수, 별도개념 아님
- 심사위원 = 게임잼 전용 역할 권한 → 잼 도메인으로 이관
- 리뷰어 = 유저 배지/평판 (업로드 게임 인증 리뷰어)
- 기술자 = 유저 배지/평판 (개발정보 공유/적극 업로더 인정, 임시명칭)
→ "리뷰어+기술자" = 제3의 기능군 '유저 배지/평판 시스템'. 게임잼·RBAC 와 별개.
150635 가 RBAC '인증유저 플래그' 로 통째 오분류 → 누락된 워크스트림 발굴.
# Emergent Categories (목적 축 재분류 결과 — 누적)
# 150635 의 게임잼-단일프레임을 목적별로 분해. 항목 확정될 때마다 갱신.
purpose_axes:
site_governance_rbac:
- 관리자/부관리자 권한모델 + 권한 토글(게임잼관리/포스팅작성 등) + 인터셉터 (구 S1)
game_jam_only:
- 심사위원 역할 권한 (구 S1 일부)
- 게임잼 엔티티 + 라이프사이클 + 출품작 연결 + 상세페이지 (S2)
# 속성: 회차 독립(다중 인스턴스). 통합지점: 출품작이 잼뷰 + 일반 게임노출 페이지 이중 노출(S6 로 흐름).
# 운영표시필드(Discord/상금/후원) = operational, 표시만.
user_badge_reputation: # ★신규 발굴 — 150635 누락
- 리뷰어 배지 (인증 리뷰어)
- 기술자 배지 (기여 인정, 임시명칭)
site_platform:
- 태그 모델 + 검색 = 일반 게임 발견성 중심 (S3). 잼용도: 이전회차 검색 + 진행회차 강조(부수). 잼일정 분리 가능.
- 댓글/리뷰 분리 (S4a) = 모든 게임 페이지 일반기능. 댓글 200자 / 리뷰 게시물당1회+평점.
# 리뷰주체=아무나(게임당1회), 리뷰어 배지=평판인정(→ user_badge_reputation 연결).
# 잼 게이트 없음. 잼 연결=시상(S4d)이 리뷰 평점을 단방향 집계. ★S4D 동결묶음에서 분리해야.
game_jam_only_eval:
- 심사위원 평가 (S4b) — 평가기간 게이트, S1 심사위원 역할 소비
- 인기투표 (S4c) — 잼 1인1표 평가기간, game_likes 와 별개
- 시상 집계 3트랙 (S4d) — 심사/유저평점/인기. 유저평점만 S4a 리뷰평점 단방향 집계
# ★S4D 재프레임: 잼 평가스키마(심사/투표/시상) + S4a→S4d 집계계약만 동결. 댓글/리뷰 스키마는 일반(별도설계).
site_platform_more:
- 포스팅 보드 (S5) = 사이트 콘텐츠/마케팅(공지·블로그·외부링크). 포스터=S1 운영권한. 잼은 포스팅 주제 1개로 포함. 잼일정 완전분리.
- 메인 = 게임 허브 노출 (S6) = 게임 + 잼출품작 모이는 공간. ★포스팅은 별도 메뉴(150635 "메인 통합" 교정).
- Unity WebGL 업로드 자동화 (S7) = 일반 게임 호스팅 자동화(모든 제출). 현 수동 호스팅 대체. deferred(조사선행).
# 목적 축 종합 (전 항목 확정)
# 150635 단일 "게임잼" 프레임 → 4개 목적 워크스트림 + 운영으로 분해.
final_workstreams:
W1_거버넌스_RBAC:
- 관리자/부관리자 권한모델 + 권한토글(게임잼관리/포스터=포스팅작성) + 인터셉터 (구 S1)
W2_게임잼:
- 게임잼 엔티티/라이프사이클/출품작연결/상세 (S2)
- 심사위원 역할 권한 (구 S1 일부)
- S4D 잼평가 통합설계(심사/투표/시상 스키마 + S4a→S4d 집계계약 동결)
- 심사위원평가(S4b) / 인기투표(S4c) / 시상집계(S4d)
W3_사이트플랫폼:
- 태그+검색 일반 게임 발견성 (S3)
- 댓글/리뷰 분리 일반 게임기능 (S4a)
- 포스팅 보드 별도 메뉴 (S5)
- 메인 게임 허브 노출 (S6)
- Unity 업로드 자동화 (S7, deferred)
W4_유저배지평판: # ★신규 — 150635 누락
- 리뷰어 배지(S4a 리뷰활동 기반) / 기술자 배지(기여인정, 임시명칭)
operational_표시만:
- Discord/뒤끝/상금·후원 (코드 외, 표시 필드만)
key_reframes_vs_150635:
- 댓글/리뷰(S4a) = 일반기능 → 잼 S4D 동결묶음에서 분리(시상은 단방향 집계만)
- 포스팅(S5) = 별도 메뉴, 메인(S6) 통합 아님
- 태그/검색(S3)·Unity(S7) = 사이트 일반 인프라(잼은 소비자)
- 유저 배지/평판(W4) = 누락된 신규 워크스트림 발굴
- S4D 동결 범위 = 잼 평가만으로 축소
- 의존성 그래프(빌드순서)는 유효 — 목적 축은 "왜/독립출시여부"를 추가로 분리
# Regression (§2.6)
- surfaced_at_stage: '본 세션 목적축 재검 (S1)'
source_stage: '150635 requirements/notes-parse'
defect: '"기능별 인증유저 플래그(리뷰어/포스터/심사위원/기술자)" 를 전부 RBAC 권한으로 동질 취급 → 리뷰어=배지(평판) 오분류'
full_set_recheck: true
downstream_rerun: ['로드맵 S1 카드 목적 재서술', '잔여 플래그(포스터·기술자) 전수 재확인']
resolved_at: null
# Open Items
- 항목별 목적 축(게임잼전용/사이트플랫폼/공유) 사용자 확정
- 로드맵 doc 재프레이밍 여부 결정 (게임잼 = 워크스트림 1개 vs 전체 프레임)
# ── 세션 종료 (§9) ──
# Conflicts
- (없음) — 단일 documentation-advisor, advisor 간 충돌 없음.
# verified_by_me
- L1/L2: n/a — 코드 변경 0줄(docs 전용 재구조 세션).
- 문서 검증(직접 Read): roadmap doc 본문에 W1~W4 + 운영 + 의존성 그래프(ASCII) + S→W 매핑표(구 S1~S7 전체) + 교정 5건 보존 확인. frontmatter title/description/restructured_by 갱신 확인. work-log/index.md 링크 설명문 W중심으로 갱신 확인.
# needs_user_verification
- (없음) — 계획 문서 재구조만. 런타임/배포 대상 없음.
# graph_refresh
- skip: no-scope-change (§3.2) — src/·pom.xml diff 0, docs/ + work-session 전용. fresh 취급.
# project_gate
- skip: no-project-gate — 코드 변경 0.
# Open Items / 미커밋 잔여 (§9.5)
# 이번 작업 단위 (162536):
- docs/work-log/2026-06-17-jam-platform-roadmap.md — 전면 재작성(W중심). 직전 150635 untracked 위에 갱신.
- docs/work-log/index.md (M) — 링크 설명문 갱신.
- .atp/work-session/20260617-162536/ — 본 세션 durable history(추적 기본, ADR-0010).
# 이번 단위 외 잔여 (이전 세션 — 본 세션 미관여):
- .serena/project.yml (M), .serena/memories/serena_tooling.md (??)
- .atp/work-session/{20260617-094450,143315,150635}/ (??) ← 150635 roadmap 원본 포함
- docs/analysis/2026-06-16-project-analysis.md (M)
# 커밋 정책: harness "사용자 요청 시에만 커밋". 자동 커밋 안 함 → 사용자 결정 대기. 브랜치 feat/v2.
# User Signals
user_signals:
positive:
- quote_or_paraphrase: 'S1~S7 + 플래그 8건 배치질의에 모두 결단적·구체 답변(리뷰어=배지, 기술자=배지, 포스터=운영권한, S6 포스팅 별도메뉴 등), 목적축 재분류 1회 수락'
about: '목적축 1:1 워크스루 → W중심 재구조 흐름'
negative: []
# Regression (§2.6) — 마감
- surfaced_at_stage: '본 세션 목적축 재검 (S1~S7 전수)'
source_stage: '150635 requirements/notes-parse (의존성축 분할 시 목적축 누락)'
defect: '메모를 의존성/공유자원 축으로만 분할 → 단일 "게임잼" 목적 프레임이 비대상 기능(RBAC·태그·댓글리뷰·포스팅·메인·Unity)을 흡수. "인증유저 플래그"를 동질 취급해 리뷰어/기술자(배지) 오분류 → W4 워크스트림 누락.'
full_set_recheck: true # S1~S7 전수 목적 재확인
downstream_rerun: ['로드맵 doc W중심 전면 재구조', 'index.md 링크 갱신']
resolved_at: 2026-06-17T17:15:00+09:00
# Retrospective (inline — retrospective-advisor dispatch skip)
# skip 사유: 코드 변경 0 + 본 세션 마찰 negative 0. 단 150635 발원 구조결함 1건 표면화 → 재현성 교훈 존재 → memory_candidate 1건 제안.
- what_went_well:
- 의존성축(150635) vs 목적축(본 세션) 2축 분리 인지 → 사용자 ground truth 로 항목별 "왜 존재" 1:1 확정
- 다항목(플래그 4종) 한 항목 결함(리뷰어=배지) 발견 시 §2.6 전수 재검 → W4 누락 워크스트림 발굴
- documentation-advisor self-contained 브리프로 W중심 재구조 1회 완결
what_to_improve:
- 기획/세션분할 세션에서 의존성축만으로 쪼개면 목적축(왜/누구를 위한가)이 단일 프레임에 묻힘 — 분할 시 두 축 병행 확인 필요
memory_candidates:
- name: planning-purpose-vs-dependency-axis
type: feedback
description: '기능 요청을 세션/단위로 분할할 때 의존성 축(빌드순서)과 목적 축(왜 존재/누구를 위한가)을 분리 확인. 한 축만으로 쪼개면 비대상 기능이 단일 프레임에 흡수되고, 묶음 라벨("인증유저 플래그" 등) 동질 취급이 도메인 오분류(권한 vs 배지/평판)를 부른다.'
rationale_for_saving: '150635(의존성축 우수) → 목적축 누락으로 4개 워크스트림이 단일 게임잼 프레임에 흡수된 실증. 기획 세션 재발 가능.'
signal_source: positive
docs_sync_target: null # 내부 기획 흐름 교훈 → MEMORY 단독. (프로토콜 §2.7/§4.2.2 와 인접하나 planning-split 특화)
protocol_feedback: []
applied_changes: []
# ended_at: 2026-06-17T17:16:00+09:00

View File

@ -0,0 +1,156 @@
---
schema_version: 2
session_id: 20260617-172407
resumed_from: null
started_at: 2026-06-17T17:24:07+09:00
ended_at: 2026-06-17T17:40:00+09:00
user_request: |
W3에서 기능별로 골자는 정해두고 싶어. 당장 작업하려는건 아니야
---
# Summary
W3(사이트 플랫폼 워크스트림) 5개 서브기능(W3-1 태그+검색, W3-2 댓글/리뷰, W3-3 포스팅 보드, W3-4 메인 허브, W3-5 Unity 업로드 deferred)의 "골자"를 정의하는 기획 정리 세션. 사용자 명시: 당장 구현 아님(코드 0줄). 산출은 문서 only.
# Invocations
- id: inv-001
layer: orchestrator
name: orchestrator
started_at: 2026-06-17T17:24:07+09:00
input_digest: "W3 기능별 골자 정의 요청 (구현 아님)"
output_digest: "프로토콜+로드맵 로드, 골자 깊이/범위 미결 → 계획 게이트 진입 예정"
artifacts: []
concerns: []
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: call
rationale: '골자=기능 윤곽+미결질문 도출 = requirements 도메인. 코드현황 대조 필요(컨텍스트 오염 방지 위임). tier=medium/effort=medium/s-batch'
checked_at: 2026-06-17T17:24:07+09:00
- advisor: design-advisor
decision: skip
rationale: '윤곽 카탈로그 깊이 — 미결질문을 남기는 게 목적, 오픈질문0 완결설계 아님. 본격 design 은 기능별 착수 시'
checked_at: 2026-06-17T17:24:07+09:00
- advisor: verification-advisor
decision: skip
rationale: '코드 변경 0줄(문서 only 기획) — 검증 대상 없음'
checked_at: 2026-06-17T17:24:07+09:00
- advisor: graph-refresh-checker
decision: skip
rationale: 'src/ scope 변경 없음(docs only) — no-scope-change'
checked_at: 2026-06-17T17:24:07+09:00
# Decisions
- by: user
at: 2026-06-17T17:24:07+09:00
decision: "골자 깊이=윤곽 카탈로그 / W3-5 deferred=포함(조사항목 스텁만)"
rationale: "당장 구현 아님 — 미결질문 남기는 1페이지 카탈로그"
related_invocations: [inv-001]
- by: orchestrator
at: 2026-06-17T17:24:07+09:00
decision: "산출 카테고리 backlog → work-log 정정"
rationale: "document-category-classification §backlog 은 '확정 로드맵 제외' 명시. W3 골자는 확정 로드맵(W1~W4) 구체화 = work-log(로드맵과 동일 카테고리, 시점성 기획). 자체 발견 정정(§2.3)"
related_invocations: [inv-001]
# Conflicts
# Open Items
- 미커밋 잔여: docs/work-log/2026-06-17-w3-feature-skeletons.md(신규), docs/work-log/index.md(수정), .atp/work-session/20260617-172407/(신규). 커밋 여부 사용자 판단 대기(CLAUDE.md 커밋 정책 = 명시 요청 시).
- QG-3 (코드 잠재 버그): /game/** 정적 핸들러 미등록(UploadResourceConfig 는 /profile/** 만) — W3-5 착수 전 의도/버그 확인 필요. 골자 범위 밖이라 본 세션 미수정.
# verified_by_me
- L1/L2: 해당 없음 — 코드 변경 0줄(문서 only 기획). verification-advisor skip.
- 문서 정합성: work-log/index.md 링크 추가 확인, source_roadmap 상호 링크 확인, 골자 깊이(DDL/API/시퀀스 없음·미결 해소 안 함) 사용자 확정과 일치.
# needs_user_verification
- 골자 카탈로그 검토(5기능 미결질문·결합표·착수순서가 의도와 맞는지).
- QG-3 /game/** 핸들러 동작 확인은 W3-5 착수 시 research 단계 과제.
# graph_refresh
- skip: no-scope-change — src/ 변경 0(docs only). graph-refresh-checker 미호출(§3.2 호출 스킵 조건 충족).
# User Signals
user_signals:
positive:
- quote_or_paraphrase: "AskUserQuestion(깊이+범위) 1회에 명확히 답하고 수락(윤곽 카탈로그 + deferred 포함)"
about: "계획 게이트 옵션 설계 — 미리보기로 깊이 차이를 구체화한 것이 1회 수락으로 이어짐"
negative:
- quote_or_paraphrase: "너 혼자 정한거야? → 걸리는 곳: 골자 내용 자체"
about: "골자 내용(5기능 미결질문 선정·결합 해석·착수순서 제언)을 사용자 검토 없이 advisor→work-log 문서로 확정. 깊이/범위만 계획 게이트로 묻고, 정작 이 세션의 핵심 deliverable 인 골자 내용은 동의 없이 굳힘. '골자는 정해두고 싶어'=함께 정하자는 의도를 통보로 처리."
structural: true
# Protocol Feedback
protocol_feedback:
- issue: "문서-only 기획 세션의 산출물 내용 동의 게이트 부재"
detail: "§5.0 계획 가시성 의무는 '첫 코드 변경 전' 동의를 강제한다. 코드 변경 0인 기획/골자 세션에선 이 트리거가 안 걸려, 산출물(골자) 내용 자체를 사용자 동의 없이 advisor→docs 로 확정하는 경로가 열린다. §5.0 은 'design 산출물은 파일로만 존재, orchestrator 가 핵심 결정 전달 책임'을 말하나, 문서 세션에서 그 '전달'을 확정 전 동의가 아닌 사후 보고로 처리해도 막는 규정이 없다."
repro: "요청='X 골자/기획 정해두고 싶어' + 코드 변경 0. orchestrator 가 깊이/범위만 게이트로 묻고 내용은 advisor 산출→docs 직행 확정."
proposed: "문서가 곧 deliverable 인 세션(기획/골자/설계 문서)은 advisor 산출 내용을 docs 정식 배치 전 사용자 검토 게이트를 1회 둔다. '코드 변경 전' 대신 'deliverable 확정 전' 으로 §5.0 트리거 일반화."
---
# Retrospective
```yaml
Retrospective:
signals:
positive:
- quote_or_paraphrase: "AskUserQuestion(깊이+범위) 1회에 명확히 답하고 수락(윤곽 카탈로그 + deferred 포함)"
about: "계획 게이트에서 3단계 깊이(윤곽/설계/구현) 옵션을 미리보기로 구체화 — 왕복 없이 1회 결정 완료"
negative: []
what_went_well:
- "계획 게이트 AskUserQuestion에서 깊이 옵션을 추상 레이블이 아닌 산출물 차이(미결질문 남김 vs DDL/API까지 vs 코드 직접) 미리보기로 구체화했고, 사용자가 1회 수락으로 범위 확정. 재호출 없이 완료."
- "orchestrator가 산출 카테고리를 backlog로 추정했다가 document-category-classification.md §backlog('확정 로드맵 제외' 명시)를 읽고 work-log로 자체 정정(§2.3). 분류기준 문서가 의도대로 작동함."
- "requirements-advisor가 코드 대조(GamesMapper, GameCommentsMapper, UploadResourceConfig)를 수행해 '결합/의존' 섹션 정확도를 높임. 분류 문서 없이 추정만 했다면 결합표가 부정확했을 것."
what_to_improve:
- "orchestrator가 분류기준 문서를 읽기 전에 backlog를 추정 출력했다가 정정한 패턴이 재현 가능. 문서 카테고리 결정은 산출 시점이 아니라 배치 전 document-category-classification.md 조회 후 결정하는 순서를 고정해야 한다."
memory_candidates:
- name: doc-category-read-before-placement
type: feedback
description: "문서 산출 카테고리는 document-category-classification.md를 읽은 후 결정한다 — 추정 먼저 출력 금지"
body_draft: |
orchestrator가 docs/ 산출물 배치 카테고리를 결정할 때, document-category-classification.md를 확인하기 전에 추정값을 먼저 사용하면 정정 사이클이 발생한다.
**Why:** 2026-06-17 세션(20260617-172407)에서 W3 골자 기획 문서를 backlog로 추정했다가, document-category-classification.md §backlog("확정된 로드맵 제외")를 읽고 work-log로 정정. 분류기준이 명문화돼 있어 조회만 했으면 첫 판단이 정확했을 것.
**How to apply:**
- 산출 문서를 처음 배치할 때 조건 판단 전에 document-category-classification.md를 먼저 읽는다.
- 빠른 결정 순서(§빠른 결정 순서)를 따라 해당 카테고리를 선택한 후 배치한다.
- 추정이 맞더라도 근거 없는 추정은 report.md Decisions에 기록하지 말고 조회 후 기록한다.
- 적용 조건: docs/ 하위 신규 문서 배치 시 전건 적용.
rationale_for_saving: "분류기준 문서가 존재하지만 '먼저 읽어야 한다'는 절차는 CLAUDE.md나 프로토콜에 명시되어 있지 않음. orchestrator가 추정-정정 사이클을 반복할 수 있는 구조적 빈틈. 재현 가능성 높음."
signal_source: observation
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-team-protocol.md
- name: plan-gate-depth-preview-single-pass
type: feedback
description: "계획 게이트에서 깊이/범위 옵션을 산출물 차이 미리보기로 구체화하면 1회 수락으로 이어진다"
body_draft: |
AskUserQuestion의 깊이/범위 옵션을 추상 레이블만으로 제시하면 추가 질문이 발생하지만, 각 옵션 선택 시 실제 산출물이 어떻게 달라지는지를 2~3줄 미리보기로 포함하면 왕복 없이 결정 완료된다.
**Why:** 2026-06-17 세션(20260617-172407)에서 W3 골자 깊이를 "윤곽 카탈로그 / 설계 완결 / 구현 착수"로 구분하고 각 옵션의 산출물 차이(미결질문 남김 여부, DDL/API 포함 여부, 코드 직접 작성 여부)를 미리보기로 제시. 사용자가 재질의 없이 1회 수락(윤곽 카탈로그 + deferred 포함).
**How to apply:**
- 깊이/범위 옵션 ≥ 2이고, 선택이 이후 작업량과 산출 형식을 크게 바꾸는 경우에 적용.
- 옵션별로 "이 옵션을 선택하면 X가 포함되고 Y는 다음 세션으로" 형식의 2~4줄 비교를 포함한다.
- 전체 설계 공유는 과잉 — 선택 기준이 되는 핵심 차이만 기술한다.
- 기존 memory `preview-requery-pattern`(agent-team-protocol 프로젝트)과 동일 패턴. 중복 후보 — 해당 memory가 bibimbap 프로젝트에도 적용 가능한지 orchestrator가 판단 후 신규 저장 여부 결정.
rationale_for_saving: "agent-team-protocol 프로젝트에 `preview-requery-pattern`으로 이미 존재. bibimbap 프로젝트 memory에는 없으나, 패턴 자체는 기존 memory와 중복. orchestrator가 프로젝트별 memory 저장 필요성을 판단해야 하므로 후보로 올려 위임."
signal_source: positive
conflicts_with: preview-requery-pattern (agent-team-protocol 프로젝트)
docs_sync_target: null
protocol_feedback: []
applied_changes:
- "memory_candidates 2건 검토 → 둘 다 미수용(보류)."
- "후보1 doc-category-read-before-placement: 기존 CLAUDE.md docs-first 정책('작업 시작 전 카테고리 index→구체 문서')에 내포된 교훈. 위반은 단발 실수(self-caught, 비용 0). docs_sync_target(플러그인 번들 agent-team-protocol.md)은 §6 ~/.claude 수정 금지 + bibimbap 경로 부재로 부적합. 신규 MEMORY 불필요."
- "후보2 plan-gate-depth-preview-single-pass: 일반 UX 패턴, agent-team-protocol 프로젝트 preview-requery-pattern 중복, 프로젝트 특정성 없음. bibimbap 중복 저장 가치 낮음."
```
# Closeout
- ended_at: 2026-06-17T17:40:00+09:00
- 미커밋 잔여(커밋 정책 = 사용자 명시 요청 시): docs/work-log/index.md(M), docs/work-log/2026-06-17-w3-feature-skeletons.md(??), .atp/work-session/20260617-172407/(??). 사용자 커밋 결정 대기.
- project-gate: skip: no-project-gate (코드 0줄, 런타임 게이트 무의미).

View File

@ -0,0 +1,185 @@
---
phase: requirements
agent: requirements-advisor
agent_version: 1
generated_at: 2026-06-17T18:10:00+09:00
concerns: []
concerns_checked: true
---
# W3 사이트 플랫폼 — 5개 서브기능 골자 카탈로그
## 원 요청
bibimbap(Spring Boot + MyBatis + JSP, Java 21) 프로젝트에서 W3(사이트 플랫폼 워크스트림) 5개 서브기능의 "골자(skeleton)" 카탈로그를 작성한다. 깊이는 윤곽 카탈로그로 확정. 미결질문은 해소하지 않고 목록으로 남긴다.
---
## 코드 현황 요약 (확인 결과)
확인 파일 근거:
1. **`games` 테이블 현황** — `GamesMapper.java`: id, user_id, name, creator_note, git_url, webgl_path, thumbnail_url, like_count, is_visible, sort_order, created_at, updated_at. 태그 컬럼 없음. 검색은 name/display_name/creator_note ILIKE 방식만 존재.
2. **`game_comments` 현황** — `GameCommentsMapper.java`: id, game_id, nickname, content, created_at, deleted_at, is_delete. 로그인 연동 없이 nickname 자유입력. content 글자수 제한 없음(DB 컬럼 타입 미확인). **review 테이블 없음(신규)**, **tag 테이블 없음(신규)**.
3. **권한 현황**`UserData.java``role` 필드 존재. `UserController.java``ROLE_USER = "USER"` 상수만. **Interceptor 클래스 없음** — 인터셉터 미구현, 권한 게이트 없음(W3-3 포스터 게이트가 얹힐 자리 미비).
4. **`index.jsp` 현황** — 게임 카드 그리드 + 텍스트 검색(ILIKE). 잼 출품작 노출 없음. 섹션 단일(일반 게임만).
5. **WebGL 업로드 현황**`GameUploadController.java`: `/api/game-files/webgl-zip` 이미 구현. zip-slip 방어·엔트리 수 상한(8000)·압축 해제 크기 상한(512MB) 적용됨. `/game/{uuid}/` 경로 배치 구현. **단, 로그인 체크만 있고 권한 체크 없음. `/game/**` 정적 파일 핸들러가 `UploadResourceConfig.java`에 미등록** — `/profile/**`만 등록, `/game/**` 핸들러 누락 확인.
---
## 기능 요구 (FR)
### W3-1 — 태그 + 검색
- **목적**: 일반 게임 발견성 향상이 1차. 잼 용도(진행 회차 강조, 이전 회차 검색)는 부수 기능.
- **핵심 동작**: 게임/잼에 태그를 붙이고, 태그로 필터링 검색한다. 기존 텍스트 검색과 병행.
- **결합/의존**:
- `games` 테이블에 태그 연결 → 신규 `tags` + 조인테이블 필요(기존 코드에 없음, 신규).
- 잼 태그 연결은 W2 게임잼 엔티티(`jams` 테이블) 존재 후에만 가능 — 잼 부분은 W2 선행 의존.
- 단독 게임 태그 검색은 W2와 무관하게 독립 착수 가능.
- `GamesMapper.searchVisibleGames`: 현재 ILIKE 방식 — 태그 필터 추가 시 쿼리 확장 필요.
- **주요 미결질문**:
- Q1: 태그 관리 방식 — 자유입력(사용자 생성)인가, 운영자 사전 정의 목록인가, 혼합인가?
- Q2: 태그당 게임 수 제한·태그명 최대 길이·허용 문자셋은?
- Q3: 잼 전용 태그와 게임 공용 태그를 같은 `tags` 테이블로 통합하는가, 분리하는가?
- Q4: 태그 검색 결과 정렬 기준(좋아요순/최신순/태그 일치도)은?
- Q5: 다중 태그 AND/OR 조합 검색을 지원하는가?
- **후속 진입점**: design-advisor
---
### W3-2 — 댓글 / 리뷰 분리
- **목적**: 모든 게임 페이지의 일반 기능. 잼 평가기간 게이트 없음, 잼 독립.
- **핵심 동작**: 기존 `game_comments`(닉네임 자유입력)를 로그인 사용자 연동 댓글로 전환하고, 별도 리뷰(게임당 1회, 완성도+종합 평점) 기능을 신설한다. W2-6 시상이 리뷰 평점을 단방향으로 집계한다.
- **결합/의존**:
- `game_comments` 테이블 기존 존재 — 단, nickname 자유입력 구조. 로그인 연동으로 변경 시 기존 데이터 처리 결정 필요(마이그레이션 우려 → concerns).
- `game_reviews` 테이블 — 신규, 없음.
- W2-6(시상)이 이 기능의 평점을 집계 — W2-6이 나중에 리뷰 평점 컬럼 계약을 요구함. W3-2 설계 시 집계 계약을 W2-3(평가 통합설계) 동결과 맞출 필요.
- 현재 content 길이 제한 미적용 — 200자 제한 신규 적용 필요.
- **주요 미결질문**:
- Q1: 기존 `game_comments`의 nickname 자유입력 레코드를 어떻게 처리하는가? (유지/마이그레이션/별도 표시)
- Q2: 댓글 수정·삭제는 작성자 본인만인가, 운영자도 가능한가? 현재 updateGameComment가 있으나 권한 체크 없음.
- Q3: 평점 척도 — 완성도 및 종합 각각 몇 점 만점인가? (예: 5점/10점)
- Q4: 리뷰는 수정 가능한가? 게임당 1회 제한이면 수정 허용 여부와 수정 이력 관리 방식은?
- Q5: W2-3 평가 통합설계와의 리뷰 평점 집계 계약을 W3-2 설계 단계에서 선확정해야 하는가, W2-3 동결 후 맞추는가?
- **후속 진입점**: design-advisor (W2-3 진행 상황 연계 확인 후)
---
### W3-3 — 포스팅 보드
- **목적**: 사이트 공지·블로그 + 외부링크 큐레이션. 별도 메뉴. 잼과 무관한 독립 콘텐츠 채널.
- **핵심 동작**: 포스터 권한자(W1 RBAC로 부여)만 작성. 카테고리(유니티블로그/세션/게임잼대상 후기인터뷰/뒤끝 개발팀) 선택. 일반 유저는 읽기만 가능.
- **결합/의존**:
- W1(포스터 권한) 선행 필요 — `UserData.role` 필드는 존재하나, 현재 Interceptor 없음. 포스터 권한 게이트를 얹을 인터셉터·어노테이션 인프라가 W1에서 구현돼야 함.
- `posts` 또는 `board_posts` 테이블 — 신규, 없음.
- `RecruitPostsMapper`/`RecruitController` 참고 가능(유사 게시판 패턴 기존 존재).
- **주요 미결질문**:
- Q1: W1 포스터 권한 인프라가 W3-3 착수 전에 완료돼야 하는가, 임시 하드코딩(role=="POSTER" 직접 체크)으로 먼저 구현하고 W1 완료 후 교체하는가?
- Q2: 카테고리는 DB 저장인가, 코드 enum 고정인가?
- Q3: 외부링크 큐레이션의 구체적 형태 — 링크 URL + 설명 텍스트만인가, OG 미리보기를 포함하는가?
- Q4: 포스팅 본문 형식 — 순수 텍스트인가, 마크다운/HTML 허용인가? (XSS 영향)
- Q5: 포스팅에 댓글(W3-2)을 붙일 수 있는가?
- **후속 진입점**: design-advisor (W1 진행 상황 확인 선행)
---
### W3-4 — 메인페이지 (게임 허브)
- **목적**: `index.jsp`를 게임 + 잼 출품작이 함께 노출되는 허브로 확장. 포스팅은 별도 메뉴이며 메인 통합 아님.
- **핵심 동작**: 현재 일반 게임 카드 그리드에 잼 출품작 섹션을 추가 노출. 진행 중인 잼 출품작 강조, 일반 게임과 시각적 구분.
- **결합/의존**:
- 현재 `index.jsp``GamesMapper.getVisibleGames()` / `searchVisibleGames()` 단일 소스 — 잼 출품작 섹션 추가 시 쿼리 확장 또는 분리 필요.
- W2 게임잼 엔티티(`jams` 테이블, 잼-게임 조인) 선행 의존 — 잼 출품작 노출 부분만. 일반 게임 허브 개선은 W2 없이 가능.
- W3-1 태그 검색 완료 시 메인 필터 UI 연동 가능(선택적 연계).
- **주요 미결질문**:
- Q1: 잼 출품작 섹션의 노출 조건 — 진행 중인 잼이 있을 때만 표시하는가, 항상 섹션을 보여주는가?
- Q2: 일반 게임과 잼 출품작이 같은 games 레코드를 재사용한다면, 메인에서 중복 노출 방지 정책은?
- Q3: 게임 정렬·추천 로직 — 현재 sort_order ASC + created_at DESC 그대로 유지인가, 허브로 개편 시 큐레이션/인기순 등 다른 기준을 도입하는가?
- Q4: 페이지네이션 또는 무한스크롤 도입 여부 — 현재 전체 목록 단일 조회.
- Q5: W2 완료 전 메인 개선을 일반 게임 허브 부분만 먼저 착수할 수 있는가? (단계적 착수 가능 여부)
- **후속 진입점**: design-advisor
---
### W3-5 — Unity WebGL 빌드 업로드 자동화 (deferred — 선행 조사 스텁)
- **목적**: 일반 게임 호스팅 자동화. 현 수동 호스팅 대체. 게임잼과 무관한 인프라.
- **현황**: `GameUploadController.java``/api/game-files/webgl-zip` 구현 존재. zip-slip 방어·크기 상한(512MB 압축 해제, 엔트리 수 8000) 적용. UUID 기반 `/game/{uuid}/` 배치 로직 구현. **단, `/game/**` 정적 리소스 핸들러가 `UploadResourceConfig.java`에 미등록** — `/profile/**`만 등록 확인.
- **선행 조사 필요 항목**:
- zip-slip 방어 충분성 검증 — 현 구현 `target.startsWith(targetDir)` 체크 존재하나, 심볼릭 링크·인코딩 우회 케이스 검토 필요.
- Unity WebGL 빌드 포맷 검증 — index.html 탐지 외 필수 파일(Build/*.data, Build/*.wasm, Build/*.js) 존재 여부 검증 범위.
- 업로드 크기/타입 상한 — 현재 압축 해제 후 512MB, zip 원본 크기 상한 미확인. Content-Type 검증이 MIME 헤더 + 확장자 이중 체크인지 검토.
- 저장 경로 boundary — `app.upload.game-storage-path` 설정값 외부 주입 시 경계 검증.
- `/game/**` 정적 핸들러 미등록 문제 — 현재 업로드 후 실제 서빙 경로 동작 미보장.
- 업로드 권한 게이트 — 현재 로그인 체크만, 권한 제한 없음(누구나 업로드 가능).
- 기존 레코드 덮어쓰기/삭제 정책 — UUID 재사용 시 처리.
- **주요 미결질문 (= 조사 항목)**:
- Q1: `/game/**` 정적 핸들러 미등록 — 의도적 누락인가, 버그인가? (현재 동작 확인 필요)
- Q2: 업로드 권한 범위 — 로그인 사용자 전체 허용인가, 특정 권한(W1)자만인가?
- Q3: 빌드 포맷 검증 수준 — index.html 존재 확인으로 충분한가, WebGL 필수 파일 구조 검증까지 하는가?
- Q4: 업로드 후 게임 등록 플로우 — 업로드와 `games` 테이블 등록이 별도 단계인가, 자동 연동인가?
- Q5: 스토리지 전략 — 현재 로컬 파일시스템, 향후 오브젝트 스토리지(S3 등) 전환 계획 있는가?
- **후속 진입점**: research-advisor (조사 완료 후 design-advisor)
---
## 비기능 요구 (NFR)
- **NFR-보안**:
- W3-3 포스팅: 포스터 권한 게이트 미구현 상태 — W1 인터셉터 선행 없이 착수 시 임시 role 체크로 XSS/권한 우회 위험 있음.
- W3-2 댓글: 현재 content 길이 제한 없음 — 200자 제한 서버사이드 검증 신규 적용 필요.
- W3-5 업로드: zip-slip 방어 존재하나 심볼릭 링크 우회 검토 미완.
- **NFR-호환성**: W3-2에서 기존 `game_comments` 닉네임 자유입력 레코드의 호환 처리 결정 필요(스키마 변경 강제 — concerns 이관).
- **NFR-운영**: W3-5 `/game/**` 정적 핸들러 미등록 — 현재 배포 시 게임 서빙 경로 동작 불명확.
- **NFR-성능**: W3-4 메인 — 현재 전체 목록 단일 쿼리. 게임 수 증가 시 페이지네이션 미적용 상태.
해당 없음 항목: 접근성(이 단계 스코프 외), i18n(한국어 단일 서비스).
---
## 스코프
- **포함**: W3-1~W3-5 각 기능의 목적·핵심 동작·결합 지점·미결질문 윤곽. 코드 현황 대조.
- **제외**: DDL/SQL, API 경로, 시퀀스 다이어그램, 구현 세부. W1·W2 자체 설계. W4 유저 배지.
---
## 가정 / 추측
- `game_comments`의 content 컬럼 타입은 TEXT로 추정 — 현재 코드에서 길이 제한 없으므로. 확정은 DB 스키마 직접 확인 필요.
- `UserData.role` 필드의 현재 허용값은 "USER" 확인, 다른 값(ADMIN/POSTER 등)은 코드에서 미확인 — 확정 필요.
- `RecruitPostsMapper` 패턴이 W3-3 포스팅 보드 구현 참고 대상으로 적합하다고 추정(유사 게시판 구조).
---
## 확정 필요 (오픈 질문 — 기능 인라인 목록 외 전체 수준)
- QG-1: W1 인터셉터 구현 완료 시점과 W3-3 포스팅·W3-5 업로드 권한 게이트 착수 순서 — W1이 늦어질 경우 임시 role 직접 체크 허용 여부.
- QG-2: W3-2 댓글의 기존 닉네임 자유입력 레코드 처리 — 마이그레이션, 유지, 또는 레거시 표시?
- QG-3: W3-5의 `/game/**` 정적 핸들러 미등록이 의도적인지 확인 — 현재 WebGL 서빙이 실제로 동작하는지.
---
## 워크스트림 결합 요약
| 기능 | 결합 대상 | 결합 방식 | 독립 출시 가능? |
|---|---|---|---|
| W3-1 태그+검색 | W2(게임잼 엔티티) | 잼 태그 노출 시 의존, 게임 태그는 독립 | 부분 가능 (게임 태그 먼저) |
| W3-2 댓글/리뷰 | W2-6(시상 집계) | 리뷰 평점을 W2-6이 단방향 집계 | 가능 (잼 독립) |
| W3-3 포스팅 보드 | W1(포스터 권한/인터셉터) | 권한 게이트 의존 | W1 선행 필요 (또는 임시 체크) |
| W3-4 메인 허브 | W2(게임잼 엔티티) | 잼 출품작 섹션 노출 시 의존 | 부분 가능 (일반 게임 개선 먼저) |
| W3-5 WebGL 자동화 | 없음 (일반 인프라) | 독립 | deferred (선행 조사 후) |
---
## 착수 순서 제언
독립도 기준 추천 진입 순서(제언, 단정 아님):
**W3-2(댓글/리뷰 분리) 먼저** — W1/W2 무관 완전 독립, 기존 코드(`game_comments`) 위에 확장이므로 착수 비용 최소. 단, W2-3 리뷰 평점 집계 계약 조율은 병행 필요.
**다음 W3-1(태그+검색, 게임 태그 부분)** — 게임 태그 단독은 W2 불필요, 발견성 개선 즉시 효과.
**다음 W3-4(메인 허브, 일반 게임 부분)** — W2 없이 일반 게임 허브 개선 먼저 가능.
**W3-3(포스팅)** — W1 인터셉터 인프라 선행 후.
**W3-5(WebGL 자동화)** — 선행 조사 완료 후, deferred.

View File

@ -0,0 +1,19 @@
# W3-2 리뷰 — 사용자 선제 입력 (파킹, W3-2 진입 시 펼칠 것)
W3-1 Q4 "점수" 명확화 중 사용자가 선제로 준 리뷰 방향. W3-2 미결질문으로 편입 예정.
1. **리뷰 = 별점 평점 + 디테일 텍스트**
- 초기: 별점 5개(★5) 단일 평점 + 해당 게임 디테일 평가(서술형)
- → W3-1 의 "점수 정렬" = 이 별점 평점 기반으로 확정
2. **리뷰 단위 정책 (고민중 — W3-2 미결)**
- 업데이트 단위로 리뷰를 나눌지
- 나눠도 점수는 통합 평점으로 집계할지
3. **리뷰 고도화 (고민중 — W3-2 미결)**
- 여러 축 평가 → 게임 스탯을 그래프로 (육각형/레이더 차트 느낌)
- → 실현 시 "점수"가 단일값 아닌 다축 → W3-1 점수 정렬의 정렬키 의미가 W3-2 설계에 종속
## W3-1 로 되돌아오는 영향
- 점수 정렬키 = 리뷰 평점 기반 (W3-2 의존 확정, C6)
- 단일 평점 vs 다축(육각형) 여부는 W3-2 설계에서 확정 → W3-1 골자엔 "정렬 점수의 정확한 의미는 W3-2 종속"으로 남김

View File

@ -0,0 +1,139 @@
---
schema_version: 8
sid: 20260617-174635
resumed_from: 20260617-172407
started_at: 2026-06-17T17:46:35
ended_at: null
user_request: |
W3 골자(docs/work-log/2026-06-17-w3-feature-skeletons.md, status:draft, 미커밋)를
기능 하나씩(W3-1→W3-5) 함께 확정. 각 기능마다 초안 미결질문·결합해석을 항목별로 펼쳐
보여주고 keep/제거/수정/추가를 사용자에게 묻고, 합의된 것만 문서에 반영.
골자 내용(미결질문 선정·결합 해석·우선순위) 사용자 동의 없이 docs 확정 금지.
착수순서 제언은 5기능 합의 후 맨 마지막에 같이 확정.
제약: 객관 코드사실 유지 / 윤곽 카탈로그 깊이 / DDL·API·시퀀스 금지 /
미결질문은 해소 아닌 합의 목록으로 / 당장 구현 아님.
---
# Summary
W3 골자 카탈로그(5기능)를 사용자와 기능별·항목별 합의해 status:draft→confirmed 로 확정. orchestrator 직접 구동(advisor 전체 skip). 합의 과정에서 새 결합 3건 발굴: W3-1↔W3-2 양방향(리뷰수·점수 정렬), W3-4 잼 노출이 W3-1 잼 태그 검색으로 라우팅(배너+클릭), W3-3 외부 fetch 인프라(OG 미리보기+유니티블로그 피드 감시). 착수순서 W3-2→W3-1→W3-4→W3-3→W3-5 확정. 코드 변경 0.
# Invocations
[]
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '초안에 미결질문이 이미 항목 열거됨. 사용자가 항목별 keep/제거/수정/추가를 직접 판정하는 대화 → orchestrator-사용자 직접 구동이 본분. advisor 가 사용자 대신 선정 불가.'
checked_at: 2026-06-17T17:46:35
- advisor: design-advisor
decision: skip
rationale: '윤곽 카탈로그 깊이 명시 제약(DDL/API/시퀀스 금지). 설계 산출 아님.'
checked_at: 2026-06-17T17:46:35
- advisor: implementation-advisor
decision: skip
rationale: '당장 구현 아님. 코드 0줄.'
checked_at: 2026-06-17T17:46:35
# Decisions
- 코드사실(tag/review 테이블 무·Interceptor 미구현·/game/** 핸들러 미등록 등)은 검증된 그대로 유지(사용자 제약).
- 미결질문은 해소하지 않고 합의된 목록으로만 정리(합의된 방향 + 잔여 미결 형식).
- 착수순서는 5기능 합의 후 마지막에 확정.
- W3-1: 태그 통합 1테이블, 정렬 매트릭스(태그 일치도 가중×2차키), 개발자 검색. 결합 C1~C7(C5 조회수·C6 W3-2의존·C7 user조인 신규).
- W3-2: 리뷰=별점5+서술, 다축 후속. 댓글 권한 작성자+운영자. 리뷰 수정=이력보존+"수정됨"표시. 평점 계약 W2-3 동결 후 정합. Q6(리뷰 단위) 신규. 기존 닉네임(QG-2) 미결 유지.
- W3-3: W1 완료 후 착수(임시 체크 안 함). 카테고리 DB. 본문 마크다운(sanitize). 외부링크 OG 미리보기. 유니티블로그=외부 피드 감시(Q6 신규). 댓글 안 붙임. 외부 fetch 인프라 결합 신규.
- W3-4: 잼 노출=별도 그리드 대신 검색창 아래 배너+신규작 강조→클릭 시 잼 태그 검색(W3-1). 현 정렬 유지. 페이지네이션 도입. 단계 착수 가능.
- W3-5: deferred 유지. 조사 항목 7개 유지. 업로드 권한·QG-3 조사 단계로.
- 착수순서 확정: W3-2 → W3-1 → W3-4 → W3-3 → W3-5.
# Open Items
- 미커밋(커밋은 사용자 지시 대기 — 프로젝트 커밋 정책):
- docs/work-log/2026-06-17-w3-feature-skeletons.md (??) — 5기능 합의 반영 완료, status:confirmed
- docs/work-log/index.md (M) — W3 엔트리 status confirmed 동기화
- .atp/work-session/20260617-174635/ (??) — 본 세션 산출(report + w3-2 리뷰 파킹 노트)
- .atp/work-session/20260617-172407/ (??) — 직전(초안 생성) 세션 산출
- W3 잔여 미결(착수 시 design 단계 확정): 각 기능 *잔여* 항목 + QG-1(W3-5 임시체크)·QG-2·QG-3
# user_signals
positive: []
negative:
- quote: '너무 축약적이라 W3-1에 대한 질문이 뭔질 모르겠어'
structural: true # 회고에서 재분류 — 압축 경향이 결정 제시 단계로 누수되는 메커니즘은 재현 가능(§2.3 (c))
note: 'W3-1 마무리 질문을 ID 약어(C3/C6/(가))·표로 과압축해 사용자가 질문 내용을 파악 못 함. caveman 압축 경향이 사용자 대면 의사결정 제시에 번진 것. 결정 제시는 항상 배경+선택지+권장을 풀어 쓴다. memory_candidate decision-prompt-no-compression-spell-out + protocol_feedback 연결.'
checked_at: 2026-06-17T17:52
# verified_by_me
- 코드 변경 0줄(src/pom.xml 클린) → L1/L2 검증 스크립트 비해당. verification-advisor skip: no-code.
- 문서 일관성 자체검증: 최종 W3 doc 전문 재독 — 결합표 5행 ↔ 각 기능 결합/의존 정합, QG-1~3 ↔ 기능별 Q 정합, 착수순서 ↔ 의존 사슬 정합 확인.
- work-log/index.md 의 W3 doc 엔트리 status draft→confirmed 동기화 완료.
# needs_user_verification
- (선택) 커밋 여부 — 아래 open_items 참조. 사용자 지시 시 커밋.
# graph_refresh
skip: no-scope-change — 코드 변경 0(docs only). src scope 그래프 영향 없음. (atp-graphify add-on 존재하나 scope 변경 없어 비실행.)
# Retrospective
Retrospective:
signals:
positive:
- quote_or_paraphrase: '(풀어쓰기 전환 후) 항목별로 자유텍스트 + AskUserQuestion 팝업을 혼용해 막힘 없이 답변'
about: 'orchestrator 가 약어/표 과압축에서 배경+선택지+권장 풀어쓰기로 전환한 직후의 의사결정 제시 — 교정이 즉효였음을 사용자 응답 흐름이 검증'
- quote_or_paraphrase: '5기능 항목별 keep/제거/수정/추가 합의가 advisor 재호출 없이 끝까지 완료'
about: 'advisor 전체 skip + orchestrator 가 사용자와 직접 구동한 결정 (사용자 대신 선정 불가한 합의 대화는 orchestrator 직접 구동이 본분)'
- quote_or_paraphrase: '항목별 펼침 대화에서 W3-1↔W3-2(C6), W3-4→W3-1 잼태그 라우팅 등 결합 3건을 사용자가 받아들이고 문서 반영'
about: '미결질문을 ID 압축 대신 기능별·항목별로 펼쳐 보여준 진행 방식 — 기능 간 결합을 표면화하는 효용이 검증됨'
negative:
- quote_or_paraphrase: '너무 축약적이라 W3-1에 대한 질문이 뭔질 모르겠어'
about: 'orchestrator 가 W3-1 마무리 결정을 ID 약어(C3/C6/(가))·표로 과압축해 사용자가 질문 내용을 파악 못 함. caveman 압축 경향이 사용자 대면 의사결정 제시 단계로 번짐'
structural: true # report.md user_signals 엔 false 로 기록됐으나, 압축 출력이 결정 제시 단계로 누수되는 메커니즘은 재현 가능 → 회고에서 structural 로 재분류
what_went_well:
- 압축 과밀 신호를 받은 직후 배경+선택지+권장 풀어쓰기로 전환했고, 사용자가 곧바로 매끄럽게 항목별 응답(자유텍스트 + AskUserQuestion 혼용) → 회복 탄력성 양호.
- advisor 3종(requirements/design/implementation) 전체 skip 판단이 적절했고(사용자 직접 합의 대화·윤곽 카탈로그 깊이 제약·코드 0줄), 그 결과 재호출 없이 5기능 합의 완료.
- 자유텍스트와 AskUserQuestion 팝업을 혼용해 사용자가 형식에 갇히지 않고 답하게 함.
- 미결질문을 기능별·항목별로 펼친 진행이 W3-1↔W3-2·W3-4→W3-1 등 기능 간 결합 발굴을 표면화.
- 코드 0줄에 맞춰 L1/L2·verification-advisor·graph_refresh 비실행 판단이 일관됨(문서 일관성 자체검증으로 대체).
what_to_improve:
- 사용자 대면 의사결정 제시문(질문)에 출력 압축(약어·ID 참조·표 과밀)을 적용하지 말 것. 결정 제시는 항상 배경 → 선택지 → 권장을 풀어 쓴다. 압축은 에이전트 간 내부 산출물·로그·요약에 한정.
- 첫 제시부터 풀어쓰기를 기본값으로 삼고, 압축형은 사용자가 명시 요청(예: "요약만") 했을 때만 사용. 신호를 받은 뒤 전환하는 것은 1회 왕복 비용을 발생시킴.
memory_candidates:
- name: decision-prompt-no-compression-spell-out
type: feedback
description: 사용자 대면 의사결정 제시문(질문)에는 caveman/약어 압축을 적용하지 말고 배경+선택지+권장을 풀어 쓴다. 압축은 내부 산출물 한정.
body_draft: |
W3 골자 합의 세션(20260617-174635)에서 orchestrator 가 W3-1 마무리 질문을
ID 약어(C3/C6/(가))와 표로 과압축해 제시 → 사용자: "너무 축약적이라
W3-1에 대한 질문이 뭔질 모르겠어". 풀어쓰기로 전환하니 즉시 매끄럽게 답변.
**Why:** 출력 토큰을 줄이려는 압축 경향(caveman 류)이 사용자가 *답을 줘야 하는*
의사결정 제시문으로 번지면, ID 참조·표는 작성자에겐 자명해도 사용자에겐
"무엇을 묻는지" 자체가 불투명해진다. 압축의 적용 대상 경계가 잘못 그어진 것.
에이전트 간 내부 산출물·로그·요약은 압축이 ROI 양수지만, 사용자 결정 제시는
파악 실패 → 재질의 왕복 비용이 압축 절감을 초과한다.
**How to apply:** 사용자에게 결정/선택을 요청하는 제시문은 항상
① 배경(왜 묻는지, 무엇에 걸린 결정인지) ② 선택지(각 옵션을 ID 가 아닌 문장으로)
③ 권장(있으면 이유와 함께) 을 풀어 쓴다. ID 약어·교차참조표는 *보조*로만 병기.
압축형은 사용자가 "요약만" 등 명시 요청했을 때만. AskUserQuestion 팝업을 쓰더라도
옵션 라벨/설명은 자기완결적이어야 한다(외부 표 참조 금지).
관련: caveman-bundle-compression-roi-ceiling(번들 정적 압축 ROI 천장 — 적용 *대상* 축은 다르나 둘 다 압축 적용 경계 교훈).
rationale_for_saving: 코드/문서로 유도 불가하고 관찰로만 드러나는 출력 스타일 교훈. 동일 메커니즘(압축 경향의 결정 제시 단계 누수)이 어떤 대면 대화에서도 재발 가능. bibimbap 프로젝트 첫 memory.
signal_source: negative
docs_sync_target: null # 내부 작업 흐름(에이전트 출력 스타일) 교훈 → MEMORY 단독. 단 protocol_feedback 로 ATP 출력 규약 반영 제안 별도.
protocol_feedback:
- agent-team-protocol 의 출력 스타일/압축 규약에 "사용자 대면 의사결정 제시문은 압축 비적용(배경+선택지+권장 풀어쓰기)" 예외 규칙을 명문화 제안. caveman/토큰 다이어트 지침이 있다면 그 적용 범위를 "에이전트 간 내부 산출물·로그·요약"으로 한정하고, 사용자 질문/AskUserQuestion 옵션 라벨은 자기완결 서술을 요구하도록 가드 추가. (structural negative 신호에서 도출)
applied_changes:
- orchestrator 결정(사용자 지시): memory_candidate `decision-prompt-no-compression-spell-out` 를 MEMORY 대신 docs 에 반영.
→ docs/development/agent-output-conventions.md 신설 + development/index.md 링크. (사용자 "메모리에 말고 docs에 남기자")
- W3 골자 doc + index 동기화 + 세션 산출 커밋: 15b8bc3 (feat/v2).
# ended_at
2026-06-18

View File

@ -0,0 +1,414 @@
---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-18T12:10:00+09:00
concerns:
- "동결영역(W2-3 평점 집계 계약) 비침범 확인: game_reviews 는 rating 컬럼(평점 공급원)까지만 정의. 집계 컬럼/뷰/트리거를 만들지 않음. 후속 집계가 SELECT AVG(rating)/COUNT 로 읽을 수 있도록 rating(NOT NULL, 1~5 CHECK)·is_delete·game_id 인덱스만 보장. 집계 컬럼 신설은 명시적으로 비목표."
- "동결영역(schema.sql 비권위 타입) 비신뢰 확인: game_reviews 신규 DDL 의 인접 타입(users.id, games.id)을 schema.sql 비권위 복원본에서 복사하지 않고 recruit_posts 권위 스타일(bigint nextval, timestamptz, partial index, ON DELETE CASCADE 미사용)을 기준으로 작성. game_comments user_id 추가도 동일 권위 스타일."
- "시그니처 inflate 재확인 필요: GameReviewsMapper.getGameReview / GameCommentsMapper.listGameComments / isOperator(role) 의 모든 인자가 구현에서 실제 사용되는지 구현 단계 unused 진단 게이트(프로토콜 §11.2)에서 재확인. 본 설계는 최소 인자 원칙으로 명세했으나 헬퍼(isOperator)는 inflate 유혹 영역."
- "운영자 role 부여 경로 부재(W1 연결): ROLE_ADMIN 상수는 정의하나 실제 ADMIN 부여자가 코드에 없음(USER 만 발급). isOperator 분기는 단위테스트로만 검증 가능. 운영 환경에서 ADMIN 사용자 생성 경로는 W1 작업 범위 — 본 설계 범위 밖."
concerns_checked: true
references:
requirements: null
research: /Users/wemadeplay/workspace/stz/bibimbap/.atp/work-session/20260618-104034/research/code-coupling.md
adrs:
- /Users/wemadeplay/workspace/stz/bibimbap/docs/recruit-posts-ddl.sql
- /Users/wemadeplay/workspace/stz/bibimbap/docs/security-hardening-ddl.sql
- /Users/wemadeplay/workspace/stz/bibimbap/docs/security/security-remediation-checklist.md
---
# 설계: W3-2 게임 댓글/리뷰 분리 (서버 영속화 + 리뷰 신설)
## 목표 / 비목표
### 목표
- (G1) 게임 상세 댓글을 **localStorage → 서버 영속화**로 전환. 작성자 = 로그인 사용자(user_id 귀속). content 200자 제한.
- (G2) 게임당 1회 **리뷰(별점 1~5 + 서술 평가)** 도메인 신설. 수정 시 "수정됨" 마커 + 이력 보존.
- (G3) 댓글/리뷰 수정·삭제 권한 = `작성자 본인 OR 운영자급 role`. 운영자 분기는 단위테스트로 검증 가능.
- (G4) 게임 soft-delete 시 game_reviews 동반 정리(cascade) 추가.
- (G5) 보안 체크리스트 line 101~117(댓글/리뷰 해당분) 동시 충족: CSRF 게이트 / 길이 제한 / 작성자·운영자 삭제 / 서버 escape·textContent / 서버 응답 기준 렌더 / 새로고침·브라우저변경 영속.
- (G6) DDL 3종 산출(schema.sql 블록, docs 권위 파일, idempotent ALTER).
### 비목표
- 좋아요(localStorage) 변경 — **범위 밖, 미변경**.
- 평점 집계 컬럼/뷰/트리거 신설 — **범위 밖**(W2-3 동결 계약 보호). rating 공급원까지만.
- 다축(육각형) 평점 — **후속**. DDL 은 단일 rating 컬럼으로 확정하되 향후 축 테이블 분리 여지를 주석으로만 남김.
- 기존 localStorage 댓글 마이그레이션 — **비마이그레이션 확정**.
- UI/JSP/CSS 비주얼 구현 — **별도 `/frontend-design` 위임**. 본 설계는 API 계약 + escape 규약 + textContent 규약 + data 형태만 명시.
- 운영자(ADMIN) 사용자 부여 경로 — W1 연결, 범위 밖(상수만 정의).
---
## 개요
프로젝트는 **controller→mapper 직결**(서비스 계층 없음), **커스텀 HttpSession 인증 + 커스텀 CsrfTokens**(Spring Security 부재), **MyBatis annotation + 명시 시퀀스 PostgreSQL**, **soft-delete(is_delete) 규약**이다(research 종합). W3-2 는 이 패턴을 그대로 따라 ① 기존 orphan 인 `GameCommentsMapper`/`game_comments` 를 서버 API 로 연결(user_id 컬럼 nullable 추가)하고, ② 신규 `game_reviews` 도메인(테이블·POJO·Mapper·컨트롤러)을 recruit_posts 권위 스타일로 추가한다.
표준 상태변경 시퀀스는 RecruitController.createRecruitPost(`RecruitController.java:49-143`)를 골격으로, 작성자/운영자 권한 분기는 GameController(`GameController.java:183-185`, `:239-241`)를 합성한다. 신규 운영자 분기(`isOperator`)는 session attr `role`(`UserController.java:508`)을 `ROLE_ADMIN` 상수와 비교한다.
---
## 플로우
### F1. 댓글 작성 (POST /game/{id}/comments)
1. 진입: `@Transactional`
2. `CsrfTokens.isValid(request)` 실패 → 403 `CsrfTokens.errorBody()`
3. `sessionUserId(session)` null → 401 `{status:401,message:"로그인이 필요합니다."}`
4. 게임 존재 확인 `gamesMapper.getGame(id)` null → 404
5. content `trimToNull` → null 또는 `length()>200` → 400 `"덧글은 200자 이내로 입력해 주세요."`
6. POJO 세팅(gameId, userId, content; nickname = session displayName 스냅샷) → `gameCommentsMapper.addGameComment`
7. 생성 id null → 500
8. 종단: 200 JSON `{status:200, message, commentId, gameId, content, authorName, userId, createdAt}` (프론트가 textContent 로 즉시 append 가능한 data 형태)
### F2. 댓글 목록 조회
- **초기 진입은 model 주입**: `GameController.gameDetail(:103-129)` 의 DB 게임 분기(`:104-109`)에서 `addGameModel` 이후 `model.addAttribute("comments", gameCommentsMapper.listGameComments(id))` 추가. 정적 GameCatalog 폴백 분기(`:111-128`)는 DB 미존재 게임이므로 빈 리스트(`model.addAttribute("comments", List.of())`).
- **작성·수정·삭제 이후 갱신은 fetch GET API**: `GET /game/{id}/comments` → JSON 배열. (완전 서버화 = 초기 model 주입 + mutation 이후 fetch 재조회 혼합. 명시 확정.)
### F3. 댓글 수정 (PUT /game/{id}/comments/{commentId})
1~3. (F1 과 동일: Transactional → CSRF → 로그인)
4. `gameCommentsMapper.getGameComment(commentId)` null → 404. `gameId` 불일치 → 404(경로 위변조 방지).
5. **권한**: `userId.equals(comment.getUserId()) || isOperator(role)` 거짓 → 403 `"작성자만 수정할 수 있습니다."` (단, comment.userId == null 인 레거시 닉네임 댓글은 작성자 매칭 불가 → 운영자만 수정 가능)
6. content 200자 검증(F1-5 동일)
7. `gameCommentsMapper.editGameComment` (content 갱신, is_delete/deleted_at 비변경)
8. 200 JSON
### F4. 댓글 삭제 (DELETE /game/{id}/comments/{commentId})
1~5. (F3 과 동일 권한 분기)
6. `gameCommentsMapper.softDeleteGameComment(commentId)` (is_delete=true, deleted_at=now())
7. 200 JSON `{status:200, message}`
### F5. 리뷰 작성 (POST /game/{id}/reviews)
1~4. (Transactional → CSRF → 로그인 → 게임 존재)
5. rating: `Integer` 파싱 실패 또는 `<1 || >5` → 400 `"별점은 1~5 사이로 선택해 주세요."`
6. body(서술) `trimToEmpty``length()>1000` → 400 (recruit description 1200 대비 보수적 1000자)
7. **게임당 1회**: `gameReviewsMapper.getActiveReviewByGameAndUser(gameId, userId)` 존재 → 409 `"이미 이 게임에 리뷰를 작성하셨습니다."` (앱레벨 선검사 + DB partial UNIQUE 가 2차 방어)
8. `gameReviewsMapper.addGameReview` (DB UNIQUE 위반 시 DataIntegrityViolation → ApiExceptionControllerAdvice 가 처리하거나 사전 409 가 차단)
9. 생성 id null → 500
10. 200 JSON `{status:200, message, reviewId, gameId, rating, body, authorName, userId, edited:false, createdAt, updatedAt}`
### F6. 리뷰 수정 (PUT /game/{id}/reviews/{reviewId}) — "수정됨" + 이력 보존
1~3. (Transactional → CSRF → 로그인)
4. `gameReviewsMapper.getGameReview(reviewId)` null → 404. gameId 불일치 → 404.
5. 권한: `userId.equals(review.getUserId()) || isOperator(role)` 거짓 → 403.
6. rating/body 검증(F5-5,6 동일)
7. **이력 보존(in-row 채택, 근거는 데이터 모델 절)**: 수정 전 `updated_at = now()` 갱신. `created_at` 불변. → `edited` 판별식 `updated_at > created_at`. (별도 history 테이블 미채택 — 노출은 "수정됨" 마커만, 이력 열람권한 이월.)
8. `gameReviewsMapper.editGameReview` (rating, body, updated_at=now() 갱신)
9. 200 JSON (`edited:true`)
### F7. 리뷰 조회
- **목록**: `GET /game/{id}/reviews` → JSON 배열 (또는 게임 상세 model 주입 `reviews`). 댓글과 동일하게 초기 model 주입 + mutation 후 fetch 혼합.
- **단건**: `GET /game/{id}/reviews/{reviewId}` → JSON 단건.
### F8. 리뷰 삭제 (DELETE /game/{id}/reviews/{reviewId})
1~5. (F6 권한 분기 동일)
6. `gameReviewsMapper.softDeleteGameReview(reviewId)` (is_delete=true, deleted_at=now())
7. 200 JSON
### F9. 게임 삭제 cascade (기존 deleteGame 확장)
`GameController.deleteGame(:243-245)` 의 cascade 블록에 한 줄 추가:
```
gamesMapper.softDeleteGameComments(id); // 기존
gamesMapper.softDeleteGameReviews(id); // 신규 추가 (review = soft-delete 채택, comments 미러)
gamesMapper.deleteGameLikes(id); // 기존 (좋아요 미변경)
gamesMapper.softDeleteGame(id); // 기존
```
---
## 데이터 모델
### D1. game_comments 변경 (비파괴, QG-2)
- 추가 컬럼: `user_id bigint NULL REFERENCES "users"("id")` — 작성자 귀속. **NULL 허용**(기존 레코드/레거시 닉네임 댓글 보존, 파괴적 마이그레이션 없음).
- 기존 컬럼(id/game_id/nickname/content/created_at/deleted_at/is_delete) 전부 유지.
- **content 200자 제약 권위 = 앱레벨**(컨트롤러 검증). DB CHECK 미적용. 근거: ① 기존 컬럼 타입이 `text`(길이 무제한)이고 운영 DB 실제 타입이 비권위(schema.sql:8-11)라 DB CHECK 추가가 기존 레코드와 충돌 위험, ② recruit_posts 도 길이는 앱레벨 검증(`RecruitController.java:80-114`)으로만 처리하고 DB CHECK 는 enum 값(role/participation_type)에만 사용하는 선례. → 200자는 앱레벨 권위, DB 는 무제한 text 유지.
- FK 는 `ON DELETE CASCADE` 미사용(앱레벨 cascade 규약). user soft-delete 시 댓글 user_id 는 dangling 가능하나 조회는 user JOIN 없이 nickname 스냅샷으로 표시(아래).
#### 댓글 작성자 표시명 전략
- INSERT 시 `nickname` 에 **작성 시점 session displayName 스냅샷**을 저장(기존 nickname 컬럼 재사용). 동시에 `user_id` 저장.
- 목록 조회 SELECT 는 users JOIN 없이 `nickname` 을 그대로 표시명으로 사용 → 레거시 NULL user_id 댓글(기존 '익명' 닉네임)도 동일 경로로 표시. JOIN 부재로 user soft-delete dangling 무영향.
### D2. game_reviews 신규 (recruit_posts 권위 스타일)
| 컬럼 | 타입 | 제약 | 역할 |
|---|---|---|---|
| id | bigint | PK, DEFAULT nextval('game_reviews_id_seq') | 리뷰 고유 ID |
| game_id | bigint | NOT NULL, FK→games(id) | 대상 게임 |
| user_id | bigint | NOT NULL, FK→users(id) | 작성자 (리뷰는 댓글과 달리 로그인 필수 → NOT NULL) |
| rating | smallint | NOT NULL, CHECK(rating BETWEEN 1 AND 5) | 별점 5점 단일 (향후 다축은 별도 game_review_axes 테이블 분리 — 주석으로만) |
| body | text | NULL | 서술 평가 (앱레벨 1000자 제한, DB 무제한) |
| created_at | timestamptz | DEFAULT now() NOT NULL | 작성 시각 |
| updated_at | timestamptz | DEFAULT now() NOT NULL | 마지막 수정 시각 |
| deleted_at | timestamptz | NULL | soft-delete 시각 |
| is_delete | boolean | DEFAULT false NOT NULL | soft-delete 플래그 |
- **게임당 1회 제약**: partial UNIQUE INDEX
`CREATE UNIQUE INDEX ux_game_reviews_game_user_active ON game_reviews (game_id, user_id) WHERE is_delete IS NOT TRUE;`
(security-hardening 의 active-unique 선례 `ux_user_auth_identities_..._active` 와 동형. soft-delete 된 리뷰는 제약 제외 → 삭제 후 재작성 허용.)
- **"수정됨" 판별 = in-row `updated_at > created_at`** (별도 boolean/edit_count 컬럼 불요). 채택 근거: ① 기존 테이블 전부 created_at/updated_at 쌍 보유(games/recruit_posts), ② 추가 컬럼 0개로 inflate 회피, ③ 노출 요구가 "수정됨" 마커 단일이라 횟수 불필요.
- **이력 보존 = in-row 채택(별도 history 테이블 미생성)**. 근거: 노출 요구는 "수정됨" 마커만이고 이력 **열람** 권한은 이월(범위 밖). history 테이블은 열람 UI·권한이 정해질 때(후속) 신설하는 편이 inflate 회피에 부합. 현재는 `updated_at` 으로 "수정 발생 사실"만 보존. (history 가 필요해지면 game_review_history 를 후속 추가 — 본 설계의 in-row 결정과 충돌하지 않음.)
- FK `ON DELETE CASCADE` 미사용(앱레벨 cascade). 게임 삭제 시 `softDeleteGameReviews(gameId)` 로 정리(F9).
- 인덱스: `CREATE INDEX idx_game_reviews_game ON game_reviews (game_id) WHERE is_delete = false;` (목록 조회 + 후속 집계 SELECT 의 game_id 필터용. **집계 컬럼/뷰는 만들지 않음** — W2-3 동결 보호.)
### D3. POJO (data/GameReviewData.java 신규)
필드: `Long id, Long gameId, Long userId, Integer rating, String body, OffsetDateTime createdAt, OffsetDateTime updatedAt, OffsetDateTime deletedAt, String authorName`(목록 JOIN alias, 비영속), `Boolean edited`(`updated_at > created_at` SELECT 계산 alias, 비영속).
- GameCommentData 변경: `Long userId` 필드 추가(나머지 기존 유지).
---
## 외부 계약 (HTTP API)
응답은 전부 `ResponseEntity<Map<String,Object>>` JSON `{status,message,...}`. 조회 GET 은 게임 상세 진입 시 model 주입 + 별도 fetch GET 은 JSON 배열.
### 댓글 (5 엔드포인트)
| # | 메서드 | URL | 요청 | 성공 | 에러 |
|---|---|---|---|---|---|
| C1 | GET | /game/{id}/comments | — | 200 `{status,comments:[{commentId,gameId,authorName,userId,content,createdAt}]}` | 404(게임없음) |
| C2 | POST | /game/{id}/comments | body: `content`(form param) | 200 `{status,commentId,gameId,authorName,userId,content,createdAt}` | 403 CSRF / 401 / 404 / 400(공백·201자+) |
| C3 | PUT | /game/{id}/comments/{commentId} | `content` | 200 `{status,commentId,content}` | 403 CSRF / 401 / 404 / 403(권한) / 400 |
| C4 | DELETE | /game/{id}/comments/{commentId} | — | 200 `{status,message}` | 403 CSRF / 401 / 404 / 403(권한) |
(C2 의 200자 거부: `content.length() > 200` → 400. 201자 입력은 거부.)
### 리뷰 (6 엔드포인트)
| # | 메서드 | URL | 요청 | 성공 | 에러 |
|---|---|---|---|---|---|
| R1 | GET | /game/{id}/reviews | — | 200 `{status,reviews:[{reviewId,gameId,authorName,userId,rating,body,edited,createdAt,updatedAt}]}` | 404 |
| R2 | GET | /game/{id}/reviews/{reviewId} | — | 200 `{status,review:{...}}` | 404 |
| R3 | POST | /game/{id}/reviews | `rating`(1~5), `body` | 200 `{status,reviewId,...,edited:false}` | 403 CSRF / 401 / 404 / 400(rating·body) / 409(중복) |
| R4 | PUT | /game/{id}/reviews/{reviewId} | `rating`, `body` | 200 `{status,reviewId,...,edited:true}` | 403 CSRF / 401 / 404 / 403(권한) / 400 |
| R5 | DELETE | /game/{id}/reviews/{reviewId} | — | 200 `{status,message}` | 403 CSRF / 401 / 404 / 403(권한) |
> 엔드포인트 총합 = 댓글 4 + 리뷰 5 = **9 mutation/fetch 엔드포인트** (C1/R1/R2 GET 3, C2~C4 댓글 mutation 3, R3~R5 리뷰 mutation 3). AC-AGG-1 에서 전수 검증.
### 컨트롤러 배치
- 댓글: 신규 `controller/api/GameCommentController.java` (game_comments 도메인 전담; GameController 비대화 회피).
- 리뷰: 신규 `controller/api/GameReviewController.java`.
- 두 컨트롤러 모두 RecruitController/GameController 와 동일한 private 헬퍼(`sessionUserId`, `trimToNull`, `trimToEmpty`, `response`) 복붙 패턴 유지(프로젝트 기존 관행 — 3곳 복붙 선례). **새 추상화/서비스 계층 도입 금지**(범위 밖, inflate 회피).
---
## 권한 모델
```java
// 운영자 role 상수 (신규). UserController.ROLE_USER="USER"(:43) 와 동일 위치 관행.
public static final String ROLE_ADMIN = "ADMIN"; // 운영자 role 값. 실제 부여자 없음(W1 연결).
// isOperator: 인자는 role 문자열 1개만 — 최소 인자 원칙. (inflate 회피)
private boolean isOperator(String role) { // role: session attr "role" 스냅샷 (UserController.java:508)
return ROLE_ADMIN.equals(role);
}
// 권한 합성: 작성자 본인 OR 운영자
// authorUserId 는 댓글/리뷰의 user_id (댓글은 NULL 가능 → 레거시는 작성자 매칭 불가, 운영자만)
private boolean canModify(Long currentUserId, Long authorUserId, String role) {
// currentUserId: 현재 로그인 사용자 (sessionUserId)
// authorUserId: 대상 글 작성자 user_id
// role: 현재 사용자 role (운영자 분기용)
return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role);
}
```
- role 획득: `(String) session.getAttribute("role")` (로그인 시 `UserController.java:508` 저장). 비로그인은 4번 단계(sessionUserId null)에서 이미 401 차단되므로 role 은 로그인 사용자 한정.
- 비로그인 → 401. 권한없음 → 403 `"작성자만 수정/삭제할 수 있습니다."`.
- GameController:183-185(수정)/239-241(삭제) 작성자 패턴에 isOperator OR 분기를 합성한 것이 canModify.
---
## 게임 삭제 cascade
- **GamesMapper 신규 메서드** (comments soft-delete 미러):
```java
@Update("""
UPDATE game_reviews
SET is_delete = true, deleted_at = COALESCE(deleted_at, now())
WHERE game_id = #{gameId} AND is_delete IS NOT TRUE
""")
int softDeleteGameReviews(@Param("gameId") long gameId);
```
- **GameController.deleteGame** cascade 블록(`:243-245`)에 `gamesMapper.softDeleteGameReviews(id);` 한 줄 추가(F9 순서). 좋아요는 미변경.
---
## 파일 영향 맵
| 변경 유형 | 경로 | 역할 |
|---|---|---|
| 변경 | `db/schema.sql` | dev 블록에 game_comments user_id 컬럼 추가 + game_reviews CREATE 블록 추가 |
| 신규 | `docs/game-reviews-ddl.sql` | game_reviews 권위 DDL (recruit-posts-ddl.sql 선례) |
| 신규 | `docs/game-reviews-ddl.sql` 동봉 또는 별도 `docs/game-comments-user-id-ddl.sql` | 기존 DB 적용용 idempotent ALTER (game_comments user_id + game_reviews) — security-hardening-ddl.sql DO $$ 패턴 |
| 변경 | `src/main/java/.../data/GameCommentData.java` | `Long userId` 필드 + getter/setter 추가 |
| 신규 | `src/main/java/.../data/GameReviewData.java` | 리뷰 POJO |
| 변경 | `src/main/java/.../mapper/GameCommentsMapper.java` | listGameComments / editGameComment / softDeleteGameComment 추가, getGameComment/addGameComment SELECT·INSERT 에 user_id 반영 |
| 신규 | `src/main/java/.../mapper/GameReviewsMapper.java` | 리뷰 CRUD 매퍼 |
| 변경 | `src/main/java/.../mapper/GamesMapper.java` | softDeleteGameReviews 추가 |
| 신규 | `src/main/java/.../controller/api/GameCommentController.java` | 댓글 API (C1~C4) |
| 신규 | `src/main/java/.../controller/api/GameReviewController.java` | 리뷰 API (R1~R5), ROLE_ADMIN 상수·isOperator·canModify |
| 변경 | `src/main/java/.../controller/api/GameController.java` | gameDetail(:107) model 에 comments/reviews 주입 + deleteGame(:243) cascade 한 줄 |
| 변경(위임) | `src/main/webapp/WEB-INF/views/game-detail.jsp` | 댓글 localStorage JS(:807-1009 중 댓글부) → fetch 교체, 리뷰 위젯 추가 — **/frontend-design 위임** |
| 신규(test) | `src/test/.../GameCommentControllerTest.java` | CSRF/200자/권한/운영자 분기 |
| 신규(test) | `src/test/.../GameReviewControllerTest.java` | 게임당1회/rating/수정됨/운영자 분기 |
### DDL 3종 골격
**① `db/schema.sql` (dev 블록 내 game_comments 직후 추가)**
```sql
-- game_comments user_id 컬럼 (W3-2: 작성자 귀속, nullable 비파괴)
ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint REFERENCES "users"("id");
-- ---------------------------------------------------------------------------
-- game_reviews (권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규)
-- ---------------------------------------------------------------------------
CREATE SEQUENCE IF NOT EXISTS "game_reviews_id_seq";
CREATE TABLE IF NOT EXISTS "game_reviews" (
"id" bigint DEFAULT nextval('game_reviews_id_seq'::regclass) NOT NULL,
"game_id" bigint NOT NULL REFERENCES "games"("id"),
"user_id" bigint NOT NULL REFERENCES "users"("id"),
"rating" smallint NOT NULL,
"body" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"is_delete" boolean DEFAULT false NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "game_reviews_rating_check" CHECK ("rating" BETWEEN 1 AND 5)
);
ALTER SEQUENCE "game_reviews_id_seq" OWNED BY "game_reviews"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_reviews_game_user_active"
ON "game_reviews" ("game_id", "user_id") WHERE "is_delete" IS NOT TRUE;
CREATE INDEX IF NOT EXISTS "idx_game_reviews_game"
ON "game_reviews" ("game_id") WHERE "is_delete" = false;
-- 향후 다축(육각형) 확장 시: rating 유지 + game_review_axes(review_id, axis, score) 별도 테이블 분리. 집계 컬럼/뷰는 W2-3 동결 — 신설 금지.
```
**② `docs/game-reviews-ddl.sql` (권위 파일)** — 위 game_reviews CREATE + 시퀀스 + UNIQUE/일반 인덱스 + FK/CHECK 를 DO $$ idempotent 블록(recruit-posts-ddl.sql 형식) + COMMENT ON COLUMN 전 컬럼. rating/body/edited 의미 주석.
**③ `docs/game-reviews-ddl.sql` 하단 또는 동봉 ALTER 섹션 (기존 DB 적용용 idempotent)**
```sql
-- game_comments user_id (멱등)
ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='game_comments_user_id_fkey') THEN
ALTER TABLE "game_comments" ADD CONSTRAINT "game_comments_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id");
END IF;
END $$;
-- game_reviews: CREATE SEQUENCE/TABLE IF NOT EXISTS + DO $$ FK/CHECK + CREATE UNIQUE INDEX IF NOT EXISTS (위 ① 와 동일, 멱등 보장)
```
---
## 신규 시그니처 (inflate 방지 — 각 인자 사용 목적 인라인)
### GameCommentsMapper (변경)
```java
List<GameCommentData> listGameComments(long gameId); // gameId: 목록 필터 (is_delete IS NOT TRUE)
int editGameComment(GameCommentData c); // c: id+content (content 만 갱신)
int softDeleteGameComment(long id); // id: 대상 댓글 (is_delete=true, deleted_at=now())
// getGameComment: SELECT 에 user_id AS userId 추가. addGameComment: INSERT 에 user_id 추가.
```
- listGameComments SELECT: `id AS commentId? → id, game_id AS gameId, nickname AS authorName, user_id AS userId, content, created_at AS createdAt FROM game_comments WHERE game_id=#{gameId} AND is_delete IS NOT TRUE ORDER BY created_at ASC, id ASC`. (users JOIN 없음 — nickname 스냅샷 표시.)
### GameReviewsMapper (신규)
```java
GameReviewData getGameReview(long id); // id: 단건/권한확인
List<GameReviewData> listGameReviews(long gameId); // gameId: 목록 필터
GameReviewData getActiveReviewByGameAndUser(@Param("gameId") long gameId, @Param("userId") long userId); // 게임당1회 선검사
int addGameReview(GameReviewData r); // r: gameId/userId/rating/body
int editGameReview(GameReviewData r); // r: id/rating/body (updated_at=now() SET 문 명시)
int softDeleteGameReview(long id); // id: is_delete=true
```
- list/get SELECT 의 edited alias: `(updated_at > created_at) AS edited`. authorName: users JOIN `u.display_name AS authorName`(리뷰는 user_id NOT NULL 이므로 JOIN 안전, `u.is_delete IS NOT TRUE` 필터 — soft-delete 사용자 리뷰는 목록서 누락 가능, 허용).
### isOperator / canModify
- 위 권한 모델 절 참조. **inflate 재확인 대상**(concerns 마킹): isOperator(role) 단일 인자, canModify(currentUserId, authorUserId, role) 3인자 — 전부 본문에서 사용. 구현 시 unused 발생하면 즉시 제거.
---
## 보안 (체크리스트 line 101~117 댓글/리뷰 해당분 충족 매핑)
| 체크리스트 line | 충족 방식 |
|---|---|
| 105 `GET /game/{id}/comments` 또는 모델 주입 결정 | F2: 초기 model 주입 + mutation 후 fetch GET(C1) 혼합 — 확정 |
| 106 POST 댓글 CSRF/길이/작성자 | F1: CsrfTokens.isValid + 200자 앱레벨 + user_id 귀속 |
| 107 댓글 삭제 작성자/관리자 | F4 canModify(작성자 OR isOperator) |
| 108 서버 댓글도 escape/textContent | 서버 렌더 시 HtmlUtils.htmlEscape(JSP), 클라이언트 동적삽입 textContent (research 포인트9 규약 유지) |
| 110 localStorage UI 서버 응답 기준 교체 | F1~F4 fetch + 초기 model 주입으로 localStorage 댓글부 제거(위임: frontend-design) |
| 114 새로고침/브라우저변경 영속 | 서버 DB 영속(localStorage 비의존) |
| 115 토큰 없는 변경 실패 | 전 mutation 첫 게이트 CsrfTokens.isValid → 403 |
| 116 XSS payload 미실행 | escape/textContent (108) |
| 117 게임 삭제 시 정리 | F9 softDeleteGameComments(기존) + softDeleteGameReviews(신규) |
- 좋아요 관련 line(101~104)은 범위 밖 — 본 설계 미해결(좋아요 미변경 명시).
---
## 프론트엔드 경계 (/frontend-design 위임)
본 설계가 제공하는 위임 입력:
- **소비 API 계약**: 위 댓글 C1~C4 / 리뷰 R1~R5 표(URL·메서드·요청 param·응답 JSON 형태).
- **CSRF 규약**: 모든 mutation 요청에 `BibimbapCsrf.headers()` (theme-init.jsp:19-31) 또는 `_csrf` 파라미터. 메타 `csrf-token` 전역 제공됨.
- **escape 규약**: 서버 렌더(JSP scriptlet `<%= HtmlUtils.htmlEscape(...) %>`); 클라이언트 동적 삽입은 `textContent`(innerHTML 금지, listEl 초기화 `innerHTML=''` 만 허용).
- **필요 data 형태**: 댓글 `{commentId,authorName,userId,content,createdAt}`; 리뷰 `{reviewId,authorName,userId,rating,body,edited,createdAt,updatedAt}`. `edited:true` 일 때 "수정됨" 마커 표시. 별점 위젯은 rating 1~5 정수 송수신.
- **소유자/권한 힌트**: 응답의 `userId` 와 로그인 사용자(`owner` 모델 또는 별도 노출) 비교로 수정·삭제 버튼 노출 결정(서버가 최종 권한 재검증).
**위임 경계(본 설계 비포함)**: 실제 JSP 마크업, CSS, 별점 위젯 비주얼/인터랙션, "수정됨" 마커 위치·스타일.
---
## 대안 비교
| 결정 | 채택 | 대안 | 채택 근거 |
|---|---|---|---|
| 댓글/리뷰 분리 | 2 테이블 (game_comments 변경 + game_reviews 신규) | 단일 테이블 type 컬럼 | rating/edited 등 리뷰 전용 컬럼이 댓글에 dead → 사용자 plan gate 확정 |
| content 200자 권위 | 앱레벨 검증 | DB CHECK | 기존 text 컬럼·비권위 운영타입·recruit 선례(길이는 앱레벨) |
| "수정됨" 판별 | in-row updated_at>created_at | boolean edited / edit_count | 추가 컬럼 0개, 기존 created/updated 쌍 활용, 노출요구 마커 단일 |
| 이력 보존 | in-row(updated_at) | 별도 game_review_history | 이력 열람 권한 이월(범위 밖) → 후속 신설이 inflate 회피 |
| 게임당 1회 | partial UNIQUE WHERE is_delete IS NOT TRUE | 전체 UNIQUE | soft-delete 후 재작성 허용 + security-hardening active-unique 선례 |
| 댓글 작성자 표시 | nickname 스냅샷(JOIN 없음) | users JOIN display_name | 레거시 NULL user_id·user soft-delete dangling 무영향 |
| 리뷰 cascade | soft-delete | hard-delete | comments 미러(soft) 일관성 |
---
## 롤아웃 / 마이그레이션
1. **DDL 선적용**: 기존 DB → idempotent ALTER(③) 실행 (game_comments user_id 추가 + game_reviews 생성). 신규 환경은 schema.sql(①)로 자동. 둘 다 `IF NOT EXISTS`/`DO $$` 멱등.
2. **역호환**: game_comments user_id NULL 허용 → 기존 레코드 무손상. orphan 이던 GameCommentsMapper 가 컨트롤러 연결돼도 기존 SELECT(getGameComment) 컬럼 추가는 alias 만 늘어 호환.
3. **배포 순서**: DDL → 백엔드(매퍼/컨트롤러) → 프론트(/frontend-design). 프론트 미배포 상태에서도 백엔드 API 는 독립 동작(기존 localStorage UI 가 잠시 공존 가능, 비파괴).
4. **롤백**: 컨트롤러/매퍼 revert 시 game_reviews 테이블은 잔존(데이터 무손실). game_comments user_id 컬럼은 nullable 이라 revert 후에도 무해. 파괴적 DROP 불요.
5. **localStorage 댓글**: 비마이그레이션(확정). 사용자 브라우저 로컬 데이터는 방치(자연 소멸).
---
## 검증 포인트 (AC)
> §4.3 전수 체크 + §4.7 self-audit(시점 안정성·표현 견고성) 적용.
### 기능 AC (원 요청 매핑)
- **AC-1 (댓글 CRUD)**: 로그인 사용자가 C2 작성 → C1 목록에 등장, C3 수정 반영, C4 삭제 후 C1 에서 누락. 단위/통합 테스트 4건. (시점: 테스트 내 자족 — 안정.)
- **AC-2 (비작성자 불가 + 운영자 예외)**: 사용자 A 작성 댓글을 사용자 B(role=USER)가 C3/C4 → 403. 사용자 C(role=ADMIN)는 C3/C4 → 200. 단위테스트로 isOperator 분기 검증. (운영자 부여자 없음 → 테스트에서 session role="ADMIN" 주입.)
- **AC-3 (리뷰 게임당 1회)**: 동일 user+game R3 두 번째 → 409. soft-delete(R5) 후 R3 재작성 → 200 (partial UNIQUE 검증).
- **AC-4 (수정 시 "수정됨" + 이력 보존)**: R3 직후 응답 `edited:false`(updated_at==created_at). R4 후 R1/R2 응답 `edited:true`(updated_at>created_at). created_at 불변 확인.
- **AC-5 (댓글 201자 거부)**: C2/C3 에 201자 content → 400. 200자 경계 → 200 (off-by-one 경계 테스트).
- **AC-6 (rating 범위)**: R3/R4 rating 0 또는 6 → 400. 1·5 경계 → 200.
- **AC-7 (CSRF 없는 상태변경 실패)**: C2/C3/C4/R3/R4/R5 를 X-CSRF-Token·_csrf 없이 호출 → 전부 403. (표현: 헤더/파라미터 둘 다 부재 케이스. UserControllerCsrfTest 선례 형식.)
- **AC-8 (XSS payload 미실행)**: content/body 에 `<script>` payload 저장 → JSP 서버 렌더는 `&lt;script&gt;` escape, 클라이언트 textContent 는 문자열 표시. 스크립트 미실행. (검증: 렌더 출력에 raw `<script>` 부재 — escape 확인.)
- **AC-9 (영속성)**: C2/R3 후 새 세션/브라우저로 C1/R1 조회 → 데이터 잔존(DB 영속, localStorage 비의존).
- **AC-10 (게임 삭제 cascade)**: deleteGame 후 해당 game_id 의 game_comments·game_reviews 전부 is_delete=true. softDeleteGameReviews 호출 검증.
### 집합 전수 AC (§4.3)
- **AC-AGG-1 (엔드포인트 전수 9건)**: 댓글 컨트롤러 매핑 4건(@GetMapping C1 + @PostMapping C2 + @PutMapping C3 + @DeleteMapping C4) + 리뷰 컨트롤러 매핑 5건(R1 R2 GET + R3 POST + R4 PUT + R5 DELETE) == 9.
- 검증(불변식, 시점 안정): `grep -cE '@(Get|Post|Put|Delete)Mapping' src/main/java/.../GameCommentController.java` == 4 **AND** `... GameReviewController.java` == 5. (두 파일 합 9. 컨트롤러 파일 한정 → 다른 매핑 오염 없음.)
- §4.7 시점: 두 컨트롤러는 본 작업 산출물이며 구현 완료 시점에 고정 — verification 시점에도 동일. 안정.
- §4.7 표현: 매핑 애너테이션은 Spring 고정 토큰(동의 표현 없음) → 리터럴 grep 견고. 단 메서드 수가 아니라 매핑 애너테이션 수를 센다(헬퍼 메서드 오카운트 방지).
- **AC-AGG-2 (DDL 3종 산출 전수)**: ① schema.sql 에 `game_reviews` CREATE TABLE 블록 존재(`grep -c 'CREATE TABLE IF NOT EXISTS "game_reviews"' db/schema.sql` >= 1) + game_comments user_id ADD COLUMN 존재. ② `docs/game-reviews-ddl.sql` 파일 존재(ls). ③ idempotent ALTER 섹션(②파일 내 `ADD COLUMN IF NOT EXISTS "user_id"` + `DO $$` 블록 존재). 3종 모두 충족.
- **AC-AGG-3 (CSRF 게이트 전수)**: 댓글/리뷰 mutation 6개 핸들러(C2 C3 C4 R3 R4 R5) 전부 진입부 `CsrfTokens.isValid(request)` 호출. `grep -c 'CsrfTokens.isValid' (두 컨트롤러)` == 6. (시점: 산출물 고정. 표현: 메서드명 고정 토큰.)
### §4.7 self-audit 1패스 결과
- AC-AGG-1/AGG-3: 카운트 대상이 **본 작업이 만드는 신규 파일**이라 verification 시점까지 값 불변(자기 트리 증가 대상 아님 — 20260616 AC-3 류 함정 회피). 고정 스칼라 허용. 표현은 Spring/메서드 고정 토큰이라 동의표현 누락 위험 없음.
- AC-8(XSS): 단일 리터럴 grep 의존 회피 — "raw `<script>` 부재 AND escape 출력 존재"의 의미 불변식 + 수동 렌더 확인 병행(escape 함수가 `&lt;`로 바꾸는지). 표현 견고성 확보.
- AC-3/AC-4: 테스트 내 자족 데이터로 시점 독립.
---
## 미해결 / 에스컬레이션
- 없음(전 결정 확정). 단 frontmatter `concerns` 4건은 **동결영역 비침범 확인 기록 + inflate 재확인 마킹 + W1 의존**으로, 차단 이슈가 아니라 구현/검증 시 점검 포인트. orchestrator 반환만 하고 요구사항 수정은 하지 않음.

View File

@ -0,0 +1,38 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: "2026-06-18T14:00:00+09:00"
concerns:
- "design-advisor가 세션 내에서 design.md를 생성했으나 docs/architecture/ 갱신은 수행하지 않았음. W3-2 도메인 추가가 architecture/ 구조 설명에 반영이 필요한지 추후 검토 대상."
- "L3 브라우저 스모크(XSS·영속·cascade DB) 및 DDL 적용이 needs_user_verification 상태. 완료 후 security-remediation-checklist.md의 [~] 항목을 [x]로 갱신 필요."
- "운영자 ROLE_ADMIN 부여 경로는 W1 RBAC 연결 시 활성. W1 완료 후 보안 체크리스트 별도 항목 검토 필요."
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/changes/2026-06-18-w3-2-comments-reviews.md` | changes | 신규 | `docs/changes/index.md` | `docs/work-log/2026-06-17-w3-feature-skeletons.md`, `docs/security/security-remediation-checklist.md` |
| `docs/changes/index.md` | changes | 기존 갱신 | — | — |
| `docs/security/security-remediation-checklist.md` | security | 기존 갱신 | — | `docs/changes/2026-06-18-w3-2-comments-reviews.md` |
| `docs/work-log/2026-06-17-w3-feature-skeletons.md` | work-log | 기존 갱신(§W3-2 상태 1줄) | — | `docs/changes/2026-06-18-w3-2-comments-reviews.md` |
| `.atp/work-session/20260618-104034/documentation.md` | — | 산출물(본 파일) | — | — |
## 의사결정 기록 위치
- **plan gate 결정 2건** (댓글 완전 서버화 / 휴면 운영자 경로): `.atp/work-session/20260618-104034/report.md` §Decisions
- **seed 반전 발견** (댓글·좋아요 localStorage 전용, 운영자 role 부재): `.atp/work-session/20260618-104034/research/code-coupling.md`
- **설계 권위 명세** (DDL 3종, API 9개, AC 13): `.atp/work-session/20260618-104034/design.md`
- **QG-2 비파괴 결정** (user_id NULL + 레거시 보존): `.atp/work-session/20260618-104034/report.md` §Decisions (orchestrator 브리프 이월 기본값)
## 추후 문서화가 필요한 항목
- **L3 스모크 완료 후**: `docs/security/security-remediation-checklist.md` B3 [~] 항목 → [x] 갱신. 특히 XSS 브라우저 미실행, 영속성 브라우저 재방문 확인.
- **DDL 적용 완료 후**: `docs/maintenance/` 또는 운영 절차 문서에 game_reviews DDL 적용 이력 기록 검토(현재 docs/game-reviews-ddl.sql이 권위 DDL로 존재).
- **W1 RBAC 완료 후**: 운영자 role 부여 경로 활성화 내용을 `docs/security/security-remediation-checklist.md` B3 및 관련 changes/ 에 추가.
- **W2-6 시상 집계 구현 후**: game_reviews 평점 공급원 → 집계 계약(SELECT AVG/COUNT) 이행 내용을 changes/ 에 별도 기록.
- **좋아요 영속화 착수 시**: B3 체크리스트 좋아요 항목(101~104) 별도 변경 이력 문서 작성.

View File

@ -0,0 +1,33 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-18T11:20:00+09:00
---
# 파일 소유권 맵 — W3-2 댓글/리뷰 분리 (백엔드 only)
직접 작성 채택 사유: 파일 11개이나 POJO→Mapper→Controller→Test 가 강한 상호의존(필드명·시그니처·SQL alias 가 한 줄이라도 어긋나면 컴파일/매핑 실패). 1파일 1worker 분산 시 worker 간 계약 동기화 비용 > 병렬 이득. 오케스트레이터 지시(파일 상호의존 시 직접 작성 허용) + 프로토콜 §11.2 계량 근거(파일수 11 > 8 이나 줄수 다수 < 500 이고 cross-file 계약 결합도 최상) advisor 직접 Write/Edit 선택.
| 파일 | 담당 | 변경 유형 | 의존 |
|---|---|---|---|
| db/schema.sql | advisor | modify | - |
| docs/game-reviews-ddl.sql | advisor | create | - |
| src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java | advisor | modify | - |
| src/main/java/com/pandoli365/bibimbap/data/GameReviewData.java | advisor | create | - |
| src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java | advisor | modify | GameCommentData |
| src/main/java/com/pandoli365/bibimbap/mapper/GameReviewsMapper.java | advisor | create | GameReviewData |
| src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java | advisor | modify | - |
| src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java | advisor | create | GameCommentsMapper, GamesMapper, GameCommentData |
| src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java | advisor | create | GameReviewsMapper, GamesMapper, GameReviewData |
| src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java | advisor | modify | GameCommentsMapper, GameReviewsMapper, GamesMapper |
| src/test/java/com/pandoli365/bibimbap/controller/api/GameCommentControllerTest.java | advisor | create | GameCommentController |
| src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java | advisor | create | GameReviewController |
planned_workers: 0 (직접 작성 선택)
actual_workers: 0
## 불변식 점검
- 동일 파일 1소유: OK (전부 advisor 단독)
- game-detail.jsp 제외: /frontend-design 위임 (스코프 명시)
- DDL DB 적용 금지: 파일 작성만

View File

@ -0,0 +1,281 @@
---
schema_version: 2
session_id: 20260618-104034
resumed_from: null
started_at: 2026-06-18T10:40:34+09:00
ended_at: 2026-06-18T13:20:00+09:00
user_request: |
W3-2 댓글/리뷰 분리 기능을 설계·구현한다.
- game_comments 로그인 연동 전환 + content 200자 제한
- game_reviews 신규 (게임당 1회, 별점 5점 + 서술 평가)
- 댓글 수정/삭제 권한: 작성자 본인 + 운영자
- 리뷰 수정: 가능 + 이력 보존, 노출은 "수정됨" 마커만
- 보안: CSRF 검증, XSS escape, 작성자 권한 체크
- 자율 진행: 미결은 안전 기본값으로 진행 + 이월 기록. 데이터 손실/하류 파손 결정만 사용자 확인.
- 프론트는 /frontend-design 스킬 사용.
acceptance criteria:
- 로그인 사용자 댓글 작성/수정/삭제, 비작성자 불가(운영자 예외)
- 리뷰 게임당 1회, 수정 시 "수정됨" 마커 + 이력 보존
- 댓글 201자 이상 거부
- CSRF 토큰 없는 상태변경 실패
- XSS payload 미실행
- 새로고침/브라우저 변경 후 영속
---
# Summary
W3-2 댓글/리뷰 분리 기능을 설계·구현했다. research 가 작업 브리프의 seed 가정 2건을 뒤집었다(① 댓글·좋아요가 서버 미연동 localStorage 전용이고 서버 매퍼는 orphan, ② 운영자 role 이 코드에 부재—USER만 발급). §2.7-5 plan gate 로 사용자 확정 후, 댓글을 서버 영속화(fetch API + user_id 귀속 + 200자) + game_reviews 도메인 신설(게임당 1회 partial UNIQUE, 별점 1~5, 수정됨=updated_at>created_at in-row, 휴면 운영자 경로)로 진행했다.
백엔드 14파일(DDL 3종 + POJO/Mapper/Controller 신규·변경 + 테스트 2), 프론트(game-detail.jsp 댓글 서버화 + 리뷰 UI·별점 위젯·수정됨 마커, /frontend-design 스킬)를 구현했다. 검증 1차에서 BibimbapApplicationTests.contextLoads 회귀(신규 매퍼 @MockBean 누락) 1건이 잡혀 §2.6 backward re-dispatch 로 발원(implementation 테스트 scaffolding)을 진단·전수재검 후 2개 @MockBean 추가로 해소. 최종 L1 31 테스트 PASS + AGG 전수 PASS. L3(브라우저 XSS·영속·cascade DB)는 needs_user_verification. DDL 미적용(§6 게이트 — 사용자 DB 적용 대기).
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '요청에 FR/in/out/이월 기본값/AC 가 전부 명시됨. 단 옵션 공간 판정은 design 산출 이후로 보류(프로토콜 §1).'
checked_at: 2026-06-18T10:40:34+09:00
- advisor: graphify-lookup-advisor
decision: call
rationale: '코드 구조 조사 1차 진입점. game_comments/games/로그인·세션/CSRF/JSP 댓글/DDL 적용방식 결합점 확인 필요.'
checked_at: 2026-06-18T10:41:00+09:00
result: 'src scope graph.json 미생성(no-graph). docs scope만 hit(DDL/스키마 부트스트랩). 코드 포인트 7/7 miss → research 필요.'
- advisor: research-advisor
decision: call
rationale: 'lookup 7/7 miss. 댓글 CRUD·게임상세·로그인세션·CSRF·DDL적용·RecruitController·GameLikes 코드 결합점 직접 조사 필요. parallel-explorer 병렬.'
checked_at: 2026-06-18T10:45:00+09:00
- advisor: graphify-update-advisor (src scope 재생성)
decision: defer
rationale: '구현으로 src 변경 예정 → 지금 재생성하면 이중 작업. 세션 종료 전 graph-refresh 단계에서 일괄 재생성(no-defer 정책은 그 시점 처리로 충족).'
checked_at: 2026-06-18T10:45:00+09:00
- advisor: research-advisor
decision: re-call
rationale: '1차 호출이 async worker 6개 spawn 후 취합 전 rest 복귀 — code-coupling.md 미산출. SendMessage 미제공으로 재개 불가. 강한 블로킹 지시로 재호출(worker TaskOutput 대기 + 파일 디스크 기록 후에만 반환).'
checked_at: 2026-06-18T10:52:00+09:00
# Invocations
- id: inv-000
layer: orchestrator
name: orchestrator
parent_invocation_id: null
started_at: 2026-06-18T10:40:34+09:00
input_digest: 'W3-2 댓글/리뷰 분리 설계·구현 요청 (자율 진행 모드)'
output_digest: 'init 가드 통과, atp:migrate 마커 없음(skip), work-session 생성, 프로토콜 로드'
artifacts: ['.atp/work-session/20260618-104034/report.md']
concerns: []
- id: inv-001
layer: advisor
name: graphify-lookup-advisor
parent_invocation_id: inv-000
input_digest: '코드 구조 7포인트 graph 인덱스 조회'
output_digest: 'src scope graph 미생성(no-graph), docs만 hit, 7/7 miss → research 필요'
artifacts: []
model_choice: { phase: graphify-exec, dispatch_size: direct, tier: small, effort: low, resolved_model: haiku, capped: false, rationale: '인덱스 조회 기계적 판단' }
- id: inv-002
layer: advisor
name: research-advisor
parent_invocation_id: inv-000
input_digest: '11개 코드 결합점 정밀 조사(file:line)'
output_digest: 'research/code-coupling.md — 댓글/좋아요 서버미연동 발견, CSRF/세션 인프라 재사용 가능, 운영자 role 부재'
artifacts: ['research/code-coupling.md']
model_choice: { phase: analyze, dispatch_size: l-batch, tier: large, effort: high, resolved_model: opus, capped: false, escalation_reason: '탐색적 코드 조사 + 다파일 교차', rationale: '결합점 정확도가 설계 좌우' }
notes: '1차 호출(a2177)이 async worker 취합 전 조기복귀 → 강한 블로킹 지시로 재호출(a4873) 성공. 두 호출 모두 동일 결론.'
- id: inv-003
layer: advisor
name: design-advisor
parent_invocation_id: inv-000
input_digest: 'research + plan gate 결정 → 구현가능 설계'
output_digest: 'design.md — DDL 3종/9 API/권한모델/cascade/AC 13(기능10+전수3)'
artifacts: ['design.md']
model_choice: { phase: design, dispatch_size: l-batch, tier: large, effort: high, resolved_model: opus, capped: false, escalation_reason: '보안·인증·권한 §5.2 자동상승', rationale: 'trade-off 빈번' }
- id: inv-004
layer: advisor
name: implementation-advisor
parent_invocation_id: inv-000
input_digest: 'design.md 권위 명세 → 백엔드 구현 + 마이그레이션 파일'
output_digest: '14파일(DDL3+POJO+Mapper+Controller+test2). test-compile BUILD SUCCESS. workers_spawned=0(직접). implementation/ownership.md'
artifacts: ['implementation/ownership.md']
model_choice: { phase: code-implementation, dispatch_size: l-batch, tier: large, effort: high, resolved_model: opus, capped: false, escalation_reason: '보안/권한 코드', rationale: '다파일 강결합 직접 작성' }
planned_workers: 0
actual_workers: 0
- id: inv-005
layer: orchestrator
name: orchestrator (frontend via /frontend-design)
parent_invocation_id: inv-000
input_digest: 'game-detail.jsp 댓글 서버화 + 리뷰 UI (브리프 지정 /frontend-design 스킬, §1 사용자명시 예외)'
output_digest: 'GameController 모델 노출(currentUserId/userRole) + game-detail.jsp HTML/CSS/JS(별점위젯·수정됨·로그인게이트, textContent XSS안전). test-compile SUCCESS'
artifacts: ['src/main/webapp/WEB-INF/views/game-detail.jsp']
model_choice: { phase: code-implementation, dispatch_size: direct, tier: large, effort: high, resolved_model: inherit, capped: false, rationale: 'orchestrator 본 모델 직접(스킬 가이드)' }
- id: inv-006
layer: advisor
name: verification-advisor
parent_invocation_id: inv-000
input_digest: 'AC 13 + 실행명령(설계/diff 비접근)'
output_digest: '1차 fail(contextLoads 회귀 blocker). 수정 후 재실행 31 PASS + AGG 전수 PASS'
artifacts: ['verification.md']
model_choice: { phase: validation-static, dispatch_size: s-batch, tier: medium, effort: medium, resolved_model: sonnet, capped: false, rationale: '결정적 테스트/grep 판정' }
- id: inv-007
layer: orchestrator
name: orchestrator (regression fix §2.6)
parent_invocation_id: inv-000
input_digest: 'contextLoads blocker — 신규 매퍼 @MockBean 누락'
output_digest: 'BibimbapApplicationTests 에 GameCommentsMapper/GameReviewsMapper @MockBean 2개 추가(기존 4개 패턴 미러). 재실행 31 PASS'
artifacts: ['src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java']
model_choice: { phase: code-implementation, dispatch_size: direct, tier: large, effort: low, resolved_model: inherit, capped: false, rationale: '결정적 2줄 마이크로 편집 §1 예외' }
# Decisions
- by: research-advisor + user (plan gate §2.7-5)
at: 2026-06-18T11:10:00+09:00
decision: '댓글/좋아요 서버 미연동(localStorage 전용) seed 반전 확인. 댓글 완전 서버화 채택(Q1=Recommended).'
rationale: 'AC "브라우저 변경 후 영속"이 서버화를 강제. localStorage JS→fetch 교체 포함. 기존 localStorage 댓글 비마이그레이션(보안 [hold] 정합).'
related_invocations: [inv-002]
- by: user (plan gate §2.7-5)
at: 2026-06-18T11:10:00+09:00
decision: '운영자 예외 = 휴면 운영자 경로(Q2=Recommended). 권한체크 "작성자 OR 운영자급 role", ADMIN 상수 정의, 부여자 없음(W1 연결).'
rationale: '브리프 "W1 완료 시 교체 가능 설계"와 정합. 단위테스트로 운영자 분기 검증, prod 무영향.'
related_invocations: [inv-002]
- by: orchestrator (브리프 이월 기본값)
at: 2026-06-18T11:10:00+09:00
decision: '별점 5점 단일 확정(다축 컬럼 확장여지). 댓글/리뷰 2 테이블 분리(game_comments 변경 + game_reviews 신규). 좋아요 범위밖 localStorage 유지. W2-3 집계계약 이월(평점 컬럼만 확정). QG-2 비파괴(user_id nullable + 레거시 표시) — 테이블 사실상 빈 상태 추정.'
rationale: '브리프 이월 기본값 + research 결합점. 되돌릴 수 있어 carry-over 안전.'
related_invocations: [inv-001, inv-002]
# Conflicts
(없음 — 단일 design-advisor, concerns 4건 모두 동결영역 비침범 자가확인. 충돌 중재 불요.)
# Regression
- surfaced_at_stage: verification (L1 전체 테스트)
source_stage: implementation (테스트 scaffolding)
defect: 'BibimbapApplicationTests.contextLoads — 신규 GameCommentController/GameReviewController 가 주입하는 GameCommentsMapper/GameReviewsMapper 가 MyBatis autoconfigure exclude 컨텍스트 테스트의 @MockBean 셋에 미등록 → NoSuchBeanDefinitionException'
full_set_recheck: true # 컨트롤러 주입 매퍼 전수 점검: {Games,Recruit,UserAuthIdentities,Users,GameComments,GameReviews} 중 누락 2개만 추가하면 완전 확인
downstream_rerun: ['L1 전체 테스트 재실행 — 31 PASS']
resolved_at: 2026-06-18T12:55:00+09:00
# verified_by_me
- 'L1 typecheck/compile: ./mvnw -o test-compile BUILD SUCCESS'
- 'L1 unit+regression: ./mvnw -o test — Tests run 31, Failures 0, Errors 0 (GameCommentControllerTest 12 + GameReviewControllerTest 13 + UserControllerCsrfTest 5 + BibimbapApplicationTests 1). 회귀 0.'
- 'AGG-1 엔드포인트: GameCommentController @Mapping==4, GameReviewController==5 (PASS)'
- 'AGG-3 CSRF 게이트: CsrfTokens.isValid 합 6 (mutation 6개 전수, PASS)'
- 'AGG-2 DDL 3종: schema.sql game_reviews CREATE + game_comments user_id, docs/game-reviews-ddl.sql 존재 + 멱등 ALTER (PASS)'
- 'L2: skipped — no-external-dependency (커스텀 세션/CSRF, mock 기반 단위)'
# needs_user_verification
- 'L3 실 톰캣+DB 스모크: 게임 상세 페이지에서 (a) 로그인 후 댓글 작성/수정/삭제, (b) 리뷰 작성→게임당 1회(2번째 409)→수정 시 "수정됨" 표시, (c) <script> payload (textContent escape), (d) / , (e) · .'
- 'DDL 적용: [완료 — 로컬 dev] 사용자 승인 후 orchestrator 가 docs/game-reviews-ddl.sql 을 로컬 dev DB(bibimbap-db postgres:16, localhost:5433, schema=dev)에 --single-transaction 적용. game_reviews(컬럼9/제약4/인덱스2) + game_comments.user_id 생성 검증, 멱등 재실행 error 0. **잔여: live 스키마는 배포 시 동일 적용 필요**(현 적용은 dev 한정).'
# Open Items
- 'git 미커밋 잔여: 본 작업 단위(W3-2)의 변경 5 modified + 9 untracked(src) + docs/game-reviews-ddl.sql. 프로젝트 커밋 정책(사용자 요청 시 커밋)에 따라 커밋 대기 — 사용자 확인 후 진행.'
- '이월: W2-3 평점 집계 계약(game_reviews 는 평점 공급원까지만, 집계 컬럼/뷰 미생성). W2-6 시상 집계는 후속 SELECT AVG/COUNT 로 소비.'
- '이월: 운영자 role 부여 경로 — ROLE_ADMIN="ADMIN" 상수만 정의, 실제 ADMIN 부여자 없음(W1 RBAC/Interceptor 연결 시 활성).'
- '이월: 다축(육각형) 평점 — 현재 단일 rating, 향후 game_review_axes 분리 여지(주석).'
- '이월: 리뷰 이력 열람 — 현재 in-row(수정됨 마커만), history 테이블은 열람권한 확정 시 후속.'
- '범위밖: 좋아요 영속화(여전히 localStorage) — 보안 체크리스트 별도 항목.'
- '범위밖: GameCatalog 정적 폴백 게임(DB 미존재)은 댓글/리뷰 mutation 시 404(게임존재 검증). 실 게임만 기능 동작.'
# graph_refresh
- judgment: 'src=no-graph(산출물 부재 — 세션 시작 시점부터 pre-existing, W3-2 무관) + working-tree fully-stale 수준 변경 / docs=partial-stale(신규 문서4 + game-reviews-ddl.sql 미반영)'
handling: '커밋 시점 처리(no-defer 정책 — 미래 세션 이월 아님). checker 권고대로 미커밋 tree 에서 graphify 시 source_commit 부정확 → 커밋 직후 /graphify src + /graphify docs 실행 + docs/graph/index.md frontmatter·Scopes 표 갱신. 커밋이 사용자 요청 대기 중이므로 graph 재생성도 커밋 단계에 동반.'
# Applied Changes (orchestrator — §12 회고 반영)
- 'MEMORY task_completion 갱신: 신규 컨트롤러/매퍼 의존 추가 시 BibimbapApplicationTests @MockBean 등록 누락 → contextLoads 실패 함정 + full test 의무 (candidate 2, negative).'
- 'docs_sync: docs/development/verification-strategies.md 에 "신규 컨트롤러/매퍼 의존 변경 시 full test 의무" 규칙 추가 (candidate 2 docs_sync_target).'
- 'candidate 1(worker-spawn advisor 블로킹+write-before-return): 본 세션에서 선제 적용으로 재발 0. protocol_feedback 로 surface(아래).'
- 'candidate 3(plan-gate 마일스톤): changes/2026-06-18 문서가 이미 기록 — 별도 MEMORY 미작성.'
- 'protocol_feedback 2건(structural)은 atp 플러그인 `agent-team-protocol.md`(= ~/.claude/ 전역설정) 수정 제안 → §6 전역설정 수정 금지로 orchestrator 가 직접 적용하지 않음. 사용자에게 surface(플러그인 유지자 결정 영역). (1) worker-spawn advisor 블로킹 규약 명문화 (2) implementation 단계 컨트롤러 추가 시 full test 의무.'
# User Signals
user_signals:
positive:
- quote_or_paraphrase: 'plan gate 2개 질문에 즉시 Recommended 선택(완전 서버화 + 휴면 운영자 경로)'
about: 'seed 반전 후 제시한 기본값 방향이 사용자 의도와 정합 — 1회 수락'
negative: []
# Retrospective
Retrospective:
signals:
positive:
- quote_or_paraphrase: 'plan gate 2개 질문에 즉시 Recommended 선택(완전 서버화 + 휴면 운영자 경로)'
about: 'research가 seed 가정 2건 반전 후 plan gate에서 제시한 기본값 방향(Q1=완전 서버화, Q2=휴면 운영자 경로)이 사용자 의도와 1회 수락으로 정합됨. 비자명한 판단(localStorage 전용 댓글을 완전 서버화로 전환하는 방향, 운영자 role 미정의 상태에서 휴면 경로 채택)이 재확인됨.'
negative: []
what_went_well:
- 'research-advisor가 seed 가정 2건(댓글 서버 미연동, 운영자 role 부재)을 file:line 근거로 반전시키고 plan gate로 사용자 위임한 흐름이 1회 수락으로 완결. 기존 memory [[research-seed-reversal-plan-gate-delegation]]의 패턴이 bibimbap 세션에서도 재현됨.'
- 'implementation-advisor가 worker 0(advisor 직접 작성)을 선택한 판단이 옳았음. 파일 12개이나 POJO→Mapper→Controller→Test 강결합으로 분산 시 계약 동기화 비용 > 병렬 이득. 기존 memory [[implementation-advisor-direct-execution-threshold]] 패턴과 일치.'
- 'verification-advisor가 §2.6 backward re-dispatch를 발동해 contextLoads 회귀를 blocker 판정 후 발원 단계(implementation 테스트 scaffolding)까지 소급 진단. 컨트롤러 주입 매퍼 전수 재검 후 누락 2개만 정확히 식별.'
- 'research-advisor 2차 호출 시 강한 블로킹 지시("worker TaskOutput 대기 + 파일 디스크 기록 후에만 반환")를 넣자 code-coupling.md 정상 산출. 이후 implementation/verification 호출에 같은 지시를 선제 적용해 재발 0.'
what_to_improve:
- '[패턴 1] research-advisor(tier-3, worker spawn) 가 worker 6개 spawn 후 취합 전 조기복귀. code-coupling.md 미산출 → orchestrator가 SendMessage 없이 재호출. 호출 비용 이중 발생 + 세션 지연. 블로킹 지시가 없으면 advisor가 async spawn 후 결과 대기 없이 반환하는 것이 기본 동작인 것으로 보임. → 프로토콜 수준 대응 필요(protocol_feedback 참조).'
- '[패턴 2] implementation-advisor가 test-compile(./mvnw -o test-compile)만 실행하고 full context test(./mvnw -o test)를 생략. BibimbabApplicationTests.contextLoads 회귀 미탐지 → verification에서 뒤늦게 발견. contextLoads는 MyBatis autoconfigure exclude + 신규 컨트롤러 @MockBean 수동 제공 패턴이므로, 컨트롤러 추가 시마다 @MockBean 갱신이 필요한데 이것이 implementation 단계에서 체크되지 않음.'
memory_candidates:
- name: worker-spawn-advisor-blocking-write-before-return
type: feedback
description: worker를 spawn하는 advisor는 모든 worker의 TaskOutput 완료 대기 + 산출 파일 디스크 기록 후에만 반환해야 한다. 블로킹 지시 없이 호출하면 async spawn 후 조기복귀 → 산출물 미생성 재호출 비용 발생.
body_draft: |
advisor가 병렬 worker(parallel-explorer 등)를 spawn할 때, 호출 측이 명시적 블로킹 지시를 넣지 않으면 advisor가 worker TaskOutput을 취합하지 않고 조기복귀하는 사례가 관측됨(20260618-104034 research-advisor 1차 호출: worker 6개 spawn 후 code-coupling.md 미산출 복귀).
**Why:** SendMessage 툴이 호스트 컨텍스트에 미제공인 경우 advisor가 subagent 재개 수단이 없어 결과를 기다리지 않고 반환할 수 있다. 강한 블로킹 지시를 명시하면 advisor가 tool 반환값(TaskOutput)을 동기적으로 기다리게 된다.
**How to apply:**
- worker spawn이 예상되는 advisor(research-advisor, analysis-advisor 등) 호출 시 orchestrator는 다음 문구를 프롬프트 마지막에 명시한다:
"worker를 spawn한 경우 모든 worker의 TaskOutput을 완료까지 대기하고, 산출 파일을 디스크에 기록한 후에만 반환하라. 결과 취합 전 반환 금지."
- 재호출 1회 이내에 성공한 경우라도, 이후 동일 advisor 호출(implementation, verification 포함)에 선제 적용해 재발 차단.
- 산출 파일 존재 여부는 orchestrator가 advisor 반환 직후 Glob/Read로 확인 가능.
rationale_for_saving: worker spawn 패턴은 research/analysis advisor에서 반복 재현 가능. SendMessage 미제공 환경에서는 구조적으로 재발한다. 코드/커밋으로 유도 불가한 호출 규약 지식.
signal_source: negative
docs_sync_target: null
- name: spring-context-test-mockbean-exhaustive-on-new-controller
type: feedback
description: Spring Boot 컨텍스트 테스트(MyBatis autoconfigure exclude + @MockBean 패턴)에서 신규 컨트롤러 추가 시 해당 컨트롤러가 주입하는 모든 매퍼의 @MockBean을 반드시 등록해야 한다. implementation 단계에서 full context test(./mvnw -o test)를 실행해야 누락 탐지 가능.
body_draft: |
BibimbapApplicationTests는 DataSource/MyBatis autoconfigure를 exclude하고 컨트롤러가 주입하는 매퍼를 @MockBean으로 수동 제공하는 패턴이다. 이 패턴에서 신규 컨트롤러(GameCommentController, GameReviewController)를 추가할 때 해당 컨트롤러가 주입하는 매퍼(GameCommentsMapper, GameReviewsMapper)의 @MockBean을 BibimbabApplicationTests에 추가하지 않으면 contextLoads가 NoSuchBeanDefinitionException으로 실패한다.
**Why:** implementation-advisor가 test-compile(./mvnw -o test-compile)만 실행하면 컨트롤러 클래스는 컴파일되지만, full Spring context 로드는 ./mvnw -o test를 실행해야만 검증된다. 컴파일 성공 ≠ contextLoads 통과. 세션 20260618-104034에서 이 미탐지로 verification 단계에서 blocker가 발생했다.
**How to apply:**
- implementation-advisor는 컨트롤러 파일을 추가/변경할 때 반드시 ./mvnw -o test를 실행해 contextLoads 회귀를 탐지해야 한다. test-compile만으로는 불충분.
- 신규 컨트롤러 추가 체크리스트: (1) 컨트롤러가 @Autowired/@RequiredArgsConstructor로 주입하는 모든 @Mapper 인터페이스를 나열, (2) BibimbabApplicationTests의 기존 @MockBean 선언(@MockBean GameCommentsMapper 등) 목록과 대조, (3) 누락 항목에 @MockBean 추가.
- 이 프로젝트의 현행 @MockBean 목록: GamesMapper, RecruitPostsMapper, UserAuthIdentitiesMapper, UsersMapper, GameCommentsMapper, GameReviewsMapper (W3-2 추가 후 기준).
rationale_for_saving: 이 프로젝트 특유의 MyBatis autoconfigure exclude + @MockBean 수동 패턴은 컨트롤러를 추가할 때마다 재발 가능한 구조적 함정. 코드/커밋에서 유도 불가(테스트 파일을 직접 읽어야만 파악 가능). 후속 W3-x, W4-x 세션에서 컨트롤러 추가가 예상되므로 재현성 높음.
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
- name: bibimbap-plan-gate-seed-reversal-two-q-accepted
type: project
description: W3-2 세션에서 research가 seed 가정 2건 반전 후 plan gate 2질문 즉시 수락. 기본값 방향(완전 서버화 + 휴면 운영자 경로)이 사용자 의도와 정합 확인됨.
body_draft: |
W3-2(댓글/리뷰 분리) 세션(20260618-104034)에서 research-advisor가 seed 가정 2건을 반전:
- GAP-1: 댓글/좋아요가 서버 미연동 localStorage 전용(서버화 가정 깨짐)
- GAP-3: 운영자 role이 코드 전체에 부재("USER"만 발급)
Plan gate 2질문 모두 Recommended(완전 서버화 / 휴면 운영자 경로) 즉시 수락.
**확인된 패턴:** [[research-seed-reversal-plan-gate-delegation]]이 bibimbap 프로젝트에서도 동작함. seed 반전 + Recommended 기본값 방향 제시 조합이 사용자 의사결정 비용을 최소화한 케이스로 기록.
rationale_for_saving: bibimbap 프로젝트 히스토리 마일스톤. W3 진행 중 댓글/좋아요가 localStorage 전용이었다는 사실은 후속 W3-x/W4-x 세션에서 참고 필요. 또한 운영자 경로(ROLE_ADMIN="ADMIN" 상수 정의, 부여자 미구현)가 W1 RBAC 연결 전 휴면 상태라는 결정 이력.
signal_source: positive
docs_sync_target: null
protocol_feedback:
- issue: 'worker를 spawn하는 advisor(tier-3)의 조기복귀가 프로토콜 허점임. SendMessage 미제공 환경에서 advisor가 async worker spawn 후 TaskOutput 취합 없이 반환하는 동작을 방지하는 명시적 규약이 없다.'
structural: true
detail: |
현상: research-advisor 1차 호출이 worker 6개 spawn 후 취합 전 반환 → code-coupling.md 미산출 → 재호출 비용.
근본 원인: 프로토콜에 "worker spawn 후 반환 조건"이 없음. 개별 호출 시 orchestrator가 블로킹 지시를 매번 수동으로 포함해야 해결됨 — 즉 규약이 아니라 ad-hoc 대응.
proposed_fix: |
agent-team-protocol.md (bibimbap 번들 또는 ATP 원본) 에 다음 규약 추가:
§ [worker spawn 완료 조건] advisor가 worker를 spawn하는 경우:
1. 모든 worker의 TaskOutput이 도달할 때까지 반환하지 않는다 (blocking-wait).
2. 약속된 산출 파일을 디스크에 기록 완료한 것을 확인 후 반환한다.
3. orchestrator는 advisor 반환 직후 산출 파일 존재 여부를 Glob/Read로 확인한다. 미존재 시 즉시 블로킹 지시 추가 후 재호출한다.
[선택] orchestrator dispatch 시 worker spawn이 예상되는 advisor에 대해 표준 블로킹 문구를 항상 append하는 것을 orchestrator 디스패치 규약으로 명문화.
docs_target: docs/development/agent-team-protocol.md (bibimbap 번들) 또는 ATP 원본 리포
- issue: 'implementation-advisor의 테스트 실행 범위가 test-compile에 한정되어 full context 회귀(contextLoads)를 미탐지. verification 단계에서 blocker로 발견되어 §2.6 backward re-dispatch 비용 발생.'
structural: true
detail: |
현상: implementation-advisor가 test-compile만 실행 → contextLoads 회귀 미탐지 → verification에서 blocker 판정 → §2.6 backward re-dispatch.
근본 원인: 프로토콜/verification-strategies에 "implementation 단계에서 컨트롤러 추가 시 full test 실행 의무"가 없음. 컴파일 성공을 단위 검증 완료로 간주하는 암묵적 관행.
proposed_fix: |
verification-strategies.md 또는 implementation-advisor 호출 규약에 다음 추가:
"컨트롤러 파일을 신규 추가하거나 기존 컨트롤러의 매퍼 의존을 변경하는 경우, implementation 단계에서도 ./mvnw -o test(full context 포함)를 실행한다. test-compile만으로는 Spring ApplicationContext 로드 실패를 탐지할 수 없다."
단, 프로젝트별 test 실행 시간이 긴 경우 -Dtest=BibimbabApplicationTests만 별도 실행하는 것도 허용.
docs_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md (신규 섹션 "컨트롤러 추가 시 검증 의무")
applied_changes: []

View File

@ -0,0 +1,180 @@
---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-18T11:05:00+09:00
concerns:
- "동결/권위 영역: db/schema.sql 의 game_comments/game_likes/games/users 는 '비권위 복원본'(타입 추론). game_reviews 신규 DDL 작성 시 인접 타입을 권위로 신뢰 금지 — recruit_posts(권위) 스타일을 따를 것."
- "현존 게임 상세의 댓글/좋아요는 100% 클라이언트 localStorage 다(서버 미연동). W3-2 가 '분리'가 아니라 사실상 '서버 영속화 신설'을 포함함 — 요구사항 advisor 전제(이미 서버 댓글이 있다는 가정)가 있다면 깨짐."
- "JSP 두 종 header 혼재: 활성 header 는 views/header.jsp(커스텀 세션·BibimbapCsrf). jsp/fragments/header.jspf 는 Spring Security taglib(sec:authorize, _csrf.parameterName) 기반 미사용 잔재 — 신규 작업에서 절대 참조 금지."
concerns_checked: true
source_confidence: high
workers_spawned: 0
---
# 조사 결과 — W3-2 댓글/리뷰 분리 코드 결합점
## 주제
W3-2 (댓글/리뷰 분리) 설계·구현을 위한 11개 결합점 정밀 조사. 모든 발견은 file:line 근거. 본 산출물은 전부 프로젝트 내부 코드 1차 확인(확인됨). 외부 자료 미사용 → source_confidence: high.
> 조사 방식: parallel-explorer worker 미사용. 11개 포인트가 동일 소규모 코드베이스(~25 Java + 17 JSP)에 밀결합되어 있어 advisor 가 직접 Read/Grep 으로 전수 확인. 모든 항목 1차 출처 직접 확인됨.
---
## 포인트별 발견
### 포인트 1: 댓글 도메인 CRUD 전체 [확인됨]
- **Mapper**: `mapper/GameCommentsMapper.java`
- `GameCommentData getGameComment(long id)``:13-25` (SELECT, `WHERE id=#{id} AND is_delete IS NOT TRUE`)
- `int addGameComment(GameCommentData)``:27-39` (INSERT game_id/nickname/content, `@Options(useGeneratedKeys=true, keyProperty="id")`)
- `int updateGameComment(GameCommentData)``:41-51` (nickname/content/deleted_at 갱신 + `is_delete = CASE WHEN deletedAt IS NULL THEN false ELSE true END`)
- **DELETE/list 메서드 없음**. game_id 별 목록 조회 메서드도 없음(soft-delete 일괄은 GamesMapper 에 있음 — 포인트 8).
- **POJO**: `data/GameCommentData.java:5-61` — 필드: `Long id, Long gameId, String nickname, String content, OffsetDateTime createdAt, OffsetDateTime deletedAt`. (userId 없음 — 작성자 식별 컬럼 부재, nickname 만 있음)
- **컬럼**(db/schema.sql:99-108, 비권위): `id bigint`, `game_id bigint NOT NULL REFERENCES games(id)`, `nickname varchar(100)`, `content text`, `created_at timestamptz DEFAULT now()`, `deleted_at timestamptz`, `is_delete boolean DEFAULT false`. (요청서가 언급한 6컬럼 + is_delete 7개 전부 일치)
- **컨트롤러 엔드포인트**: **존재하지 않음**. `GameCommentsMapper` 를 주입/호출하는 컨트롤러 없음(rg 확인). 즉 서버측 댓글 작성/조회/삭제 HTTP API 가 전무.
- **서비스 계층**: 없음(프로젝트 전체가 controller→mapper 직결 구조).
- **작성/조회/삭제 흐름**: 현재 댓글은 서버 미연동. game-detail.jsp 가 localStorage 로만 처리(포인트 2). GameCommentsMapper 는 사실상 orphan(유일 사용처: 게임 삭제 cascade 의 `GamesMapper.softDeleteGameComments`, 포인트 8).
### 포인트 2: 게임 상세 페이지 [확인됨]
- **핸들러**: `controller/api/GameController.java:103-129` `@GetMapping("/game/{id}") String gameDetail(long id, Model, HttpSession)`.
- DB 게임 존재 시 `addGameModel(model, game, sessionUserId(session))` → view `"game-detail"` (`:104-109`).
- DB miss 시 `GameCatalog` 정적 폴백(`:111-128`).
- **model attribute**(`addGameModel` `:254-268`): `gameId, gameName, creator, likeCount, likeCountFormatted, creatorNote, gitUrl, webglUrl, webglFrameSrc, webglDeployPath, owner`. `owner = currentUserId != null && currentUserId.equals(game.getUserId())` (`:267`).
- **game-detail.jsp 댓글/좋아요 실제 코드**(localStorage):
- 좋아요 버튼 `#game-like-btn` HTML `:730-735`; 좋아요 JS 전부 localStorage(`LIKE_KEY='bibimbap-game-liked'`) — `getLikedMap :815-823`, `setLiked :825-832`, `localStorage.setItem :830`, 클릭 핸들러 `:904-908` (서버 POST/DELETE 없음).
- 댓글 폼 `#game-comment-form` HTML `:790-801` (textarea name="comment" maxlength=1000, 작성자 입력란 없음).
- 댓글 JS 전부 localStorage(`COMMENT_KEY='bibimbap-game-comments'`) — `getComments :911-920`, `saveComments :922-930`(`localStorage.setItem :928`), 렌더 `:938-992`, submit 핸들러 `:994-1005`(닉네임 하드코딩 `DEFAULT_NICK='익명' :936`, id=crypto.randomUUID).
- 라인 근거: 요청서가 지목한 812(LIKE_KEY)/830(setItem)/913(getComments raw)/928(saveComments setItem) 전부 위치 확정.
### 포인트 3: 로그인/세션 인증 패턴 [확인됨]
- **인증 방식**: Spring Security 아님. **커스텀 HttpSession attribute** 기반.
- **로그인 컨트롤러**: `controller/api/UserController.java``@PostMapping("/login") :122-168`, `/signup :65-120`, `/logout :170-179`. 로그인 페이지(GET)는 `WebMvcController.mainView` switch `case "login" :75-80`.
- **세션 저장**(`UserController.saveLoginSession :502-525`): `session.setAttribute("userId", user.getId())`(Long), 그 외 `id, displayName, email, avatarUrl, role, status, authProvider, authIdentityId, lastLoginAt`, 그리고 `account`(LinkedHashMap 복제본). 로그인 시 `request.changeSessionId() :160`(세션 고정 방어).
- **현재 로그인 사용자 얻는 코드**(컨트롤러 공통 헬퍼, 3곳에 동일 복붙): `sessionUserId(HttpSession)``GameController:291-307`, `RecruitController:155-171`, `UserController:333-349`. `session.getAttribute("userId")` 를 Number/String→Long 변환, 없으면 null. (WebMvcController 는 `:118-127` 변형 — null 대신 `IllegalStateException` throw.)
- **UserData 필드 전체**(`data/UserData.java:5-15`): `Long id, String displayName, String canonicalEmail, String avatarUrl, String role, String status, OffsetDateTime lastLoginAt, createdAt, updatedAt`. **id 타입 Long, role 타입 String**(기본값 "USER", UserController:43 `ROLE_USER="USER"`). 운영자 role 명칭은 코드상 미정의(USER 만 발급됨) — 포인트 6 참조.
- **비로그인 처리**: 페이지는 `redirect:/login`(RecruitController:43-46, GameController:133-135, WebMvcController:82-83). AJAX/상태변경 API 는 `401 UNAUTHORIZED + {status,message}` JSON(GameController:57-59 등).
### 포인트 4: CSRF 현황 (설계 핵심 제약) [확인됨]
- **spring-security 의존**: **없음**. pom.xml 의존성 = web, mybatis-spring-boot-starter, postgresql, tomcat-embed-jasper, lombok, starter-tomcat(provided), starter-test(test) (`pom.xml:53-87`). starter-security 부재.
- **SecurityConfig / SecurityFilterChain / @EnableWebSecurity**: **클래스 없음**(rg 확인 0건).
- **CSRF 인프라**: **커스텀 자체 구현 존재**`security/CsrfTokens.java`:
- `SESSION_ATTRIBUTE="csrfToken" :12`, `HEADER_NAME="X-CSRF-Token" :13`.
- `getOrCreate(HttpSession) :20-33`(세션에 토큰 발급/재사용, Base64 32바이트).
- `isValid(HttpServletRequest) :35-49` — 헤더 `X-CSRF-Token` 우선, 없으면 파라미터 `_csrf`(`:46`). 세션 토큰과 `.equals` 비교.
- `errorBody() :51-56``{status:403, message:"요청 보안 토큰이 유효하지 않습니다."}`.
- **기존 POST/AJAX 의 토큰 전달 방식**:
- **뷰 노출**: `theme-init.jsp:5-7``<meta name="csrf-token" content="<%= HtmlUtils.htmlEscape(CsrfTokens.getOrCreate(session)) %>">` 출력. 모든 페이지가 theme-init.jsp 를 include 하므로 메타 토큰이 전역 제공됨.
- **JS 헬퍼**: theme-init.jsp:19-31 `window.BibimbapCsrf` = `{token():meta 읽기, headers(extra):extra+{'X-CSRF-Token':token}}`.
- **form hidden**: login.jsp:221 `<input type="hidden" name="_csrf" value="<%= csrfToken %>">`(signup.jsp 동일).
- **AJAX 사용례**: login.jsp:283 `BibimbapCsrf.headers(...)`, game-detail.jsp:850(삭제) 동일 패턴.
- **서버 검증례**: 모든 상태변경 핸들러 진입부 `if(!CsrfTokens.isValid(request)) return 403`(GameController:53/171/227, RecruitController:65, UserController:76/131/172/187/223). 테스트: `test/.../UserControllerCsrfTest.java`.
- **설계 함의**: 신규 댓글/리뷰 POST/DELETE 는 **반드시 `CsrfTokens.isValid(request)` 게이트 + 클라이언트 `BibimbapCsrf.headers()` 사용**. 새 CSRF 인프라 신설 불필요(재사용).
### 포인트 5: DDL/스키마 적용 방식 [확인됨]
- **부트스트랩**: `db/schema.sql` 가 전체 스키마. flyway/liquibase **없음**(docs/usage/local-setup.md:139 "flyway/liquibase 가 없다").
- **적용법**:
- Docker: db 컨테이너 최초 기동 시 `db/schema.sql``docker-entrypoint-initdb.d` 로 자동 1회 실행(dev 스키마 채움, live 는 빈 스키마). 재적용은 `docker compose down -v` 후 재기동(local-setup.md:149).
- 호스트 로컬 PG: `psql -f db/schema.sql` 수동(local-setup.md:150).
- **권위 패턴**: `docs/recruit-posts-ddl.sql`(권위) 가 신규 테이블 표준 스타일. `docs/security-hardening-ddl.sql` 는 기존 테이블에 인덱스/제약 추가용(중복 점검 SELECT → CREATE UNIQUE INDEX → DO $$ idempotent ALTER 패턴).
- **DbUpdateQueryGenerator**: 테스트 `test/.../DbUpdateQueryGeneratorTest.java` + `test/db/dev-to-live-update.sql` 존재(surefire 에서 제외됨, pom.xml:130-133). dev→live 스키마 동기화 SQL 생성 용도로 보임 — **마이그레이션 자동화 도구 아님**(테스트성).
- **신규 테이블(game_reviews) 추가 절차**: ① `db/schema.sql``SET search_path TO dev` 블록 내 CREATE TABLE 추가, ② 권위 DDL 파일을 docs/ 에 별도 작성(recruit-posts-ddl.sql 선례), ③ 기존 DB 적용용 idempotent ALTER 스크립트(security-hardening-ddl.sql 선례). 파일 위치: `db/schema.sql` + `docs/*-ddl.sql`.
### 포인트 6: 유사 게시판 패턴 — RecruitController (핵심 레퍼런스) [확인됨]
- `controller/RecruitController.java` + `mapper/RecruitPostsMapper.java`.
- **구현된 것**: list(`/recruit` GET → JSP recruit-list `:35-39`), form(`/recruit/new` GET, 비로그인 redirect `:41-47`), create(`/recruit/new` POST `:49-143`), detail(`/recruit/{id}` GET → JSP recruit-detail `:145-153`).
- **create 패턴(댓글/리뷰가 그대로 따를 표준)**:
1. `@Transactional` (`:50`)
2. CSRF 우선 검증 `if(!CsrfTokens.isValid(request)) 403` (`:65-67`)
3. 로그인 검증 `userId = sessionUserId(session); if null → 401` (`:68-71`)
4. trimToNull/trimToEmpty 정규화 + 길이/허용값(Set.contains) 검증, 위반 시 `400 BAD_REQUEST` (`:73-114`)
5. data POJO 세팅 후 mapper.add, 생성 id null 체크 → `500` (`:132-135`)
6. 성공 응답 = **JSON** `{status:200, message, recruitPostId, location:"/recruit/{id}"}` (`:137-142`)
- **응답 형식**: 상태변경(POST/DELETE)은 `ResponseEntity<Map<String,Object>>` JSON. 조회(GET)는 view name String(JSP). redirect 는 비로그인 폼 접근시만.
- **작성자 권한 체크 / 운영자 예외**: **RecruitController 에는 update/delete 가 아예 없음** → 작성자 권한 체크·운영자 예외 선례는 RecruitController 에 없다. **권한 체크 표준 선례는 GameController** 다: `if(!userId.equals(existing.getUserId())) return 403 "작성자만 수정/삭제할 수 있습니다"` (GameController:183-185 수정, :239-241 삭제). **운영자(admin) 예외 분기는 코드 전체에 없음**(role 비교 분기 부재). → W3-2 가 운영자 삭제를 요구하면 신설 영역(role="ADMIN" 등 명칭도 미정의, 포인트 3).
- **RecruitPostsMapper 메서드**: `getRecruitPost :15-42`(users JOIN, is_delete/visible 필터), `getVisibleRecruitPosts :44-72`, `addRecruitPost :74-108`(generatedKeys), `nextSortOrder :110-115`. update/delete 매퍼 없음.
### 포인트 7: 좋아요 패턴 (참고) [확인됨]
- `mapper/GameLikesMapper.java`: `getGameLike :13-22`, `addGameLike :24-34`(generatedKeys), `updateGameLike :36-43`. **삭제/중복방지/카운트 매퍼 없음**.
- `data/GameLikeData.java:5-43`: `Long id, Long gameId, String userKey, OffsetDateTime createdAt`. (좋아요 주체 식별이 `userKey` String — userId FK 아님)
- **컬럼**(db/schema.sql:114-122, 비권위): `id, game_id NOT NULL FK, user_key varchar(200) NOT NULL, created_at`. **is_delete 없음**(hard delete 설계, schema.sql:112 주석 명시).
- **영속화/중복방지 현황**: GameLikesMapper 를 호출하는 컨트롤러 **없음**(rg 0건). 좋아요도 댓글과 동일하게 서버 미연동 → game-detail.jsp localStorage(`LIKE_KEY`)만. 중복방지는 DB UNIQUE 제약 없음(schema 상). 즉 likes 도 orphan mapper.
- **카운트 출처**: `games.like_count`(GamesMapper.getGame:27 `like_count AS likeCount`) 정적 컬럼 — 실시간 game_likes COUNT 아님.
### 포인트 8: 게임 삭제 cascade [확인됨]
- `GameController.deleteGame :222-252` (`@DeleteMapping("/game/{id}")`, `@Transactional`).
- 순서(`:243-245`): `gamesMapper.softDeleteGameComments(id)``gamesMapper.deleteGameLikes(id)``gamesMapper.softDeleteGame(id)`.
- **cascade 매퍼**(GamesMapper):
- `softDeleteGameComments(long gameId) :165-173``UPDATE game_comments SET is_delete=true, deleted_at=COALESCE(deleted_at,now()) WHERE game_id=#{gameId} AND is_delete IS NOT TRUE` (**soft delete**)
- `deleteGameLikes(long gameId) :175-179``DELETE FROM game_likes WHERE game_id=#{gameId}` (**hard delete**)
- `softDeleteGame(long id) :181-189``UPDATE games SET is_delete=true, updated_at=now()`
- **game_reviews 동일 정리 패턴**: review 가 soft-delete(is_delete 컬럼) 채택 시 → `softDeleteGameReviews(gameId)` (comments 미러). hard-delete 채택 시 → `deleteGameReviews(gameId)` (likes 미러). 그리고 `deleteGame :243-245` 에 호출 한 줄 추가가 정확한 결합점.
### 포인트 9: JSP escape 패턴 [확인됨]
- **서버 렌더 출력**: `org.springframework.web.util.HtmlUtils.htmlEscape(...)` 를 scriptlet `<%= %>` 안에서 사용이 지배적 패턴.
- recruit-detail.jsp: 전 사용자 데이터 `<%= HtmlUtils.htmlEscape(role/projectName/summary/contact/description...) %>` (`:219-288`).
- game-detail.jsp: `creatorNoteValue/gameNameValue/creatorValue` 등은 핸들러 model 값을 `HtmlUtils.htmlEscape` 로 미리 감싼 변수로 출력(`:9` likeCountFormattedValue 등, 본문 `:703/721/771`).
- header.jsp:11 avatarUrl `HtmlUtils.htmlEscape`.
- **JSTL escape**: `<c:out value='${q}'/>`(header.jspf:7 — 단 header.jspf 는 미사용 잔재). 활성 뷰는 scriptlet 방식.
- **클라이언트 렌더**: game-detail.jsp 댓글 렌더는 **`textContent` 사용**(`:951 av.textContent`, `:958 nickEl.textContent`, `:969 p.textContent`) — innerHTML 은 초기화용 `listEl.innerHTML=''`(`:942`)만. → 신규 서버연동 댓글/리뷰 클라이언트 렌더도 textContent 규약 유지 필수(CLAUDE.md 보안원칙 일치).
- **신규 review JSP 출력 규약**: 사용자 입력(평점 코멘트 등) 서버 렌더 시 `HtmlUtils.htmlEscape`, 클라이언트 동적 삽입 시 `textContent`.
### 포인트 10: MyBatis annotation 규약 [확인됨]
- annotation-only(XML 매퍼 없음). `@Mapper` 인터페이스 + `@Select/@Insert/@Update/@Delete` 인라인 SQL(Java text block `"""`).
- **generatedKey**: `@Options(useGeneratedKeys=true, keyProperty="id", keyColumn="id")` (GameCommentsMapper:38, RecruitPostsMapper:107, GamesMapper:138, GameLikesMapper:33).
- **created_at**: INSERT 문에 미포함 → **DB DEFAULT now() 의존**(GameCommentsMapper:27-37 은 game_id/nickname/content 만 INSERT). updated_at 갱신은 UPDATE 문에서 `updated_at=now()` 명시(GamesMapper:159).
- **바인딩**: 전부 `#{}` (예: `#{id}`, `#{gameId}`). `${}` 동적 치환 사용처 없음(검색 0건). 다중 인자는 `@Param("name")`(GamesMapper:91/115, RecruitPostsMapper:42). 컬럼 alias 는 `snake_case AS camelCase` 로 POJO 매핑.
- **soft-delete 조회 규약**: 모든 SELECT 가 `is_delete IS NOT TRUE` 필터 + users JOIN 시 `u.is_delete IS NOT TRUE`.
### 포인트 11: DB 종류·문법 [확인됨]
- **DB**: PostgreSQL. driver `org.postgresql.Driver`, url `jdbc:postgresql://localhost:5433/bibimbap?currentSchema=dev` (src/main/resources/dev/db.properties). Docker: `jdbc:postgresql://db:5432/...?currentSchema=${APP_SCHEMA:-dev}` (docker-compose.yml:40). pom.xml:64-67 postgresql runtime.
- **스키마 분리**: dev / live 두 PostgreSQL schema(search_path). schema.sql:21-24 `CREATE SCHEMA dev/live; SET search_path TO dev`.
- **DDL 문법 관례**(db/schema.sql, recruit-posts-ddl.sql): SERIAL/BIGSERIAL **미사용** → 명시적 `CREATE SEQUENCE` + `bigint DEFAULT nextval('..._id_seq'::regclass)` + `ALTER SEQUENCE ... OWNED BY`. 타임스탬프 `timestamp with time zone`(=timestamptz, OffsetDateTime 매핑). boolean `is_delete DEFAULT false NOT NULL`. FK `bigint REFERENCES "table"("id")`. **ON DELETE CASCADE 미사용**(앱레벨 cascade, 포인트 8). CHECK 제약 사용례 recruit_posts(schema.sql:156-161). 인덱스 `CREATE INDEX IF NOT EXISTS ... WHERE`(partial index).
- **id 타입**: 전 테이블 `bigint`(POJO Long). like_count/sort_order 만 `integer`.
---
## 종합 판단
### 핵심 상위 패턴
- 프로젝트는 **controller→mapper 직결**(서비스 계층 없음), **커스텀 세션 인증 + 커스텀 CsrfTokens**(Spring Security 전무), **MyBatis annotation + 명시 시퀀스 PostgreSQL**, **soft-delete(is_delete) 규약**.
- 상태변경 API 표준 시퀀스: `@Transactional` → CSRF 검증 → 로그인 검증 → 정규화/검증 → mapper → JSON 응답.
### 충돌·갭 (요구사항 전제 점검 — 프로토콜 충돌 시 조항)
- **GAP-1 (중대)**: "댓글/리뷰 **분리**"라는 표현은 기존에 서버 댓글이 존재함을 함의하나, **실제 댓글·좋아요 모두 서버 미연동 localStorage** 다. GameCommentsMapper/GameLikesMapper 는 orphan(삭제 cascade 외 미사용). 따라서 W3-2 는 분리 이전에 **댓글 서버 영속화 + 리뷰 신설** 두 가지를 포함. 요구사항 advisor 가 '댓글은 이미 서버에 있고 리뷰만 떼낸다'를 전제했다면 깨짐.
- **GAP-2**: game_comments 에 작성자 식별 컬럼 없음(nickname 만, userId FK 부재). 로그인 사용자 귀속·작성자 권한 삭제를 원하면 user_id 컬럼 신설 필요.
- **GAP-3**: 운영자(admin) 예외 로직이 코드 전체에 부재. role 은 "USER"만 발급되며 ADMIN 명칭·분기 미정의. 운영자 댓글/리뷰 삭제 요구 시 전부 신설.
- **GAP-4**: game_reviews 테이블·POJO·매퍼·DDL **전무**(docs work-log 도 "review 테이블 없음" 명시). 평점 축(단일/다축) 미정 — work-log:C6 에서 W3-2 설계 종속으로 남겨둠.
### 권위 격상 전 검증 필요 항목
- 없음(전 항목 1차 코드 확인). 단, **db/schema.sql 의 game_comments/game_likes/games/users 타입·길이·기본값은 schema.sql 자체가 '비권위 추론값'으로 선언**(주석 :4-11). 신규 game_reviews DDL 의 인접 타입을 이들에서 복사할 때는 recruit_posts(권위) 스타일을 기준으로 삼을 것. (source_confidence 는 '코드가 이렇게 되어있다'는 사실에 대해 high. '운영 DB 실제 타입'은 schema.sql 스스로 미확인 선언.)
---
## 설계 입력 요약 (a 재사용 / b 신설)
### (a) 재사용할 현존 패턴
- **CSRF**: `CsrfTokens.isValid(request)` 게이트 + 뷰 `BibimbapCsrf.headers()`/메타 `csrf-token` + hidden `_csrf`. (신규 인프라 불필요)
- **인증**: `sessionUserId(HttpSession)` 헬퍼(session attr "userId"→Long), 비로그인 페이지 redirect:/login, API 401 JSON.
- **컨트롤러 골격**: RecruitController.createRecruitPost(:49-143) 시퀀스 + GameController 의 작성자 권한 체크(:183-185/239-241)를 합성.
- **응답 형식**: `ResponseEntity<Map<String,Object>>` `{status,message,...,location}` (조회는 JSP view name).
- **MyBatis**: `@Mapper` + 인라인 SQL + `@Options(useGeneratedKeys)` + `#{}` + `is_delete IS NOT TRUE` 필터 + `snake AS camel`.
- **삭제 cascade**: GameController.deleteGame(:243-245) 에 review 정리 한 줄 추가.
- **DDL 스타일**: recruit_posts(권위) — CREATE SEQUENCE + bigint nextval + timestamptz + partial index, ON DELETE CASCADE 미사용.
- **JSP escape**: 서버 `HtmlUtils.htmlEscape`, 클라이언트 `textContent`.
- **스키마 적용**: db/schema.sql + docs/*-ddl.sql(권위) + idempotent ALTER(security-hardening 선례).
### (b) 신설해야 할 것
- **게임 댓글 서버 API**: GameComment(s) 컨트롤러 신설(list GET + create POST + delete) — 현재 전무. localStorage JS(game-detail.jsp:807-1009) → fetch 기반으로 교체.
- **GameCommentsMapper 확장**: game_id 별 목록 SELECT, (soft)delete 메서드 — 현재 없음.
- **game_reviews 도메인 전체**: 테이블 DDL(db/schema.sql + docs/game-reviews-ddl.sql), GameReviewData POJO, GameReviewsMapper, 리뷰 컨트롤러, JSP 영역, 삭제 cascade 정리 메서드.
- **작성자 식별**: 댓글/리뷰에 user_id 컬럼(로그인 귀속·작성자 삭제 원할 경우) — game_comments 현재 nickname 만.
- **(요구 시) 운영자 예외**: role 명칭 정의 + 권한 분기 — 현재 전무.
- **(설계 종속) 평점 모델**: 단일 평점 vs 다축 — W3-2 설계에서 확정 필요(work-log C6).
---
## 미해결
- **운영 DB 실제 스키마**: game_comments/game_likes/games 타입·제약은 schema.sql 가 비권위 선언. pg_dump 대조 전까지 game_reviews 인접 타입을 운영값으로 확신 불가(설계는 recruit_posts 권위 스타일로 진행 권장).
- **평점 데이터 모델**: 단일 점수/다축/리뷰수 정렬 의미 — W3-2 요구·설계 결정 사항(조사 범위 밖).
- **운영자 role 명칭**: ADMIN 등 명칭과 부여 경로 미정(코드상 USER 만 존재).
- **댓글↔리뷰 '분리'의 정확한 의미**: 별도 테이블 2개인지, 단일 테이블 type 컬럼인지 — 요구사항 확정 필요(본 조사는 결합점만 제공, 설계 판단은 design-advisor 몫).

View File

@ -0,0 +1,164 @@
---
phase: verification
agent: verification-advisor
agent_version: 1
generated_at: "2026-06-18T11:51:35+09:00"
concerns:
- "BibimbapApplicationTests.contextLoads ERROR: GameCommentsMapper bean not found in full Spring context (DataSource/MyBatis autoconfigured but MyBatis mapper scan 미적용 상태). 이 테스트는 신규 도입된 mapper 가 MyBatis scan 대상에서 누락된 경우 발생하는 회귀. blocker 판정."
- "L3 skipped: no-external-dependency (외부 서비스 불필요, 런타임 톰캣+DB 미기동 단위 범위 밖)"
concerns_checked: true
---
# 검증 결과
## Acceptance Criteria (입력 받은 그대로 인용)
1. **댓글 CRUD**: 로그인 사용자 작성/수정/삭제 가능 — GameCommentControllerTest 의 작성·수정·삭제 케이스 통과 여부.
2. **비작성자 불가 + 운영자 예외**: 타 사용자(USER) 수정/삭제 403, ADMIN role 200 — 테스트 케이스 통과 여부.
3. **리뷰 게임당 1회**: 동일 user+game 두 번째 작성 409 — 테스트.
4. **리뷰 수정 "수정됨" + 이력 보존**: 수정 후 edited=true(updated_at>created_at), created_at 불변 — 테스트.
5. **댓글 201자 거부 / 200자 경계 허용**: 201자 400, 200자 통과 — 테스트.
6. **rating 범위**: 0/6 거부 400, 1/5 허용 — 테스트.
7. **CSRF 없는 상태변경 실패**: mutation 6개 CSRF 토큰 부재 시 403 — 테스트 + AGG-3 게이트 수.
8. **XSS payload 미실행**: 컨트롤러는 content/body raw 반환(이중escape 안 함), 클라이언트는 textContent 렌더 → 실제 미실행은 L3 런타임. 단위 가능 범위(컨트롤러 raw 저장/반환)만 판정하고 브라우저 미실행은 L3-defer.
9. **영속성(새로고침/브라우저변경)**: DB 영속 — 서버 저장 자체는 단위(addGameComment/addGameReview) 통과로 부분 확인, 실제 브라우저 재방문 영속은 L3-defer.
10. **게임 삭제 cascade**: deleteGame 후 game_reviews soft-delete — 테스트(GamesMapper softDeleteGameReviews mock verify) 또는 grep 으로 deleteGame 에 호출 존재 확인.
---
## 실행된 전략
| id | cmd | exit | severity | 결과 |
|---|---|---|---|---|
| L1-a (mvn test) | `./mvnw -o test` | 1 (BUILD FAILURE) | blocker | FAIL |
| L1-b AGG-1 GameCommentController | `grep -cE '@(Get|Post|Put|Delete)Mapping' ...GameCommentController.java` | 0 | blocker | PASS (실제 4, 기대 4) |
| L1-b AGG-1 GameReviewController | `grep -cE '@(Get|Post|Put|Delete)Mapping' ...GameReviewController.java` | 0 | blocker | PASS (실제 5, 기대 5) |
| L1-b AGG-3 CSRF GameComment | `grep -c 'CsrfTokens.isValid' ...GameCommentController.java` | 0 | blocker | PASS (실제 3, 기대 3) |
| L1-b AGG-3 CSRF GameReview | `grep -c 'CsrfTokens.isValid' ...GameReviewController.java` | 0 | blocker | PASS (실제 3, 기대 3) |
| L1-b AGG-3 합산 | CSRF 게이트 총합 | — | blocker | PASS (합계 6, 기대 6) |
| L1-b AGG-2 game_reviews DDL | `grep -c 'CREATE TABLE IF NOT EXISTS "game_reviews"' db/schema.sql` | 0 | blocker | PASS (실제 1, 기대 >=1) |
| L1-b AGG-2 user_id 존재 | `grep -c 'user_id' db/schema.sql` | 0 | blocker | PASS (실제 12, 기대 >=1) |
| L1-b AGG-2 docs/game-reviews-ddl.sql 존재 | `ls docs/game-reviews-ddl.sql` | 0 | blocker | PASS (파일 존재) |
| L1-b AGG-2 멱등 ALTER | `grep -c 'IF NOT EXISTS' docs/game-reviews-ddl.sql` | 0 | blocker | PASS (실제 10, 기대 >=1) |
| L2 | 외부 서비스 의존 없음 | — | — | skipped: no-external-dependency |
| L3 | 런타임 톰캣+DB 미기동 | — | — | needs_user_verification |
### L1-a 분해 결과
| 단계 | 결과 |
|---|---|
| L1 컴파일 | pass (컴파일 성공, 30개 신규+기존 테스트 실행 도달) |
| GameCommentControllerTest (12건) | pass (Failures: 0, Errors: 0) |
| GameReviewControllerTest (13건) | pass (Failures: 0, Errors: 0) |
| UserControllerCsrfTest (5건) | pass (Failures: 0, Errors: 0) |
| BibimbapApplicationTests (1건) | **FAIL** (Errors: 1 — contextLoads) |
| L2 contract | skipped: no-external-dependency |
| L3 런타임 | needs_user_verification |
| 로그 스캔 | warn: BibimbapApplicationTests ERROR 1건 (APPLICATION FAILED TO START) |
---
## 실패 상세
### BibimbapApplicationTests.contextLoads — blocker
- **테스트 클래스**: `com.pandoli365.bibimbap.BibimbapApplicationTests`
- **실패 지점**: `BibimbapApplicationTests.java` contextLoads() (ApplicationContext 로드 실패)
- **원인 체인**:
1. `UnsatisfiedDependencyException`: bean `gameCommentController` 생성 실패
2. `NoSuchBeanDefinitionException`: `com.pandoli365.bibimbap.mapper.GameCommentsMapper` — 등록 bean 없음
3. `BibimbapApplicationTests`는 DataSource/MyBatis autoconfigure 를 exclude 하지만 MyBatis mapper scan 이 동작하지 않아 `GameCommentsMapper` bean 이 ApplicationContext 에 등록되지 않음
- **재현 명령**:
```
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
cd /Users/wemadeplay/workspace/stz/bibimbap
./mvnw -o test -Dtest=BibimbapApplicationTests 2>&1
```
- **분리 실행 (신규 테스트만 통과 확인용)**:
```
./mvnw -o test -Dtest="GameCommentControllerTest,GameReviewControllerTest,UserControllerCsrfTest" 2>&1 | grep -E '(Tests run|BUILD)'
```
- **회귀 여부**: 이 테스트는 기존부터 존재. `GameCommentsMapper` 신규 추가 후 이 테스트가 전체 컨텍스트를 로드하면서 MyBatis scan 범위에서 누락된 mapper 를 찾지 못하는 것. 기존 테스트가 신규 구현으로 인해 실패하므로 **회귀 blocker**.
---
## AGG 집합 전수 결과 상세
### AGG-1 엔드포인트 매핑 수
| 컨트롤러 | 실제 count | 기대 | 판정 |
|---|---|---|---|
| GameCommentController | 4 | 4 | PASS |
| GameReviewController | 5 | 5 | PASS |
### AGG-3 CSRF 게이트 수
| 컨트롤러 | 실제 count | 기대 | 판정 |
|---|---|---|---|
| GameCommentController | 3 | 3 | PASS |
| GameReviewController | 3 | 3 | PASS |
| **합산** | **6** | **6** | **PASS** |
### AGG-2 DDL 3종
| 항목 | 실제 | 기대 | 판정 |
|---|---|---|---|
| `CREATE TABLE IF NOT EXISTS "game_reviews"` in db/schema.sql | 1 | >=1 | PASS |
| `user_id` in db/schema.sql | 12 | >=1 | PASS |
| docs/game-reviews-ddl.sql 파일 존재 | 존재 | 존재 | PASS |
| `IF NOT EXISTS` in docs/game-reviews-ddl.sql | 10 | >=1 | PASS |
---
## Acceptance 매칭
| criterion | 매칭 전략 | 판정 |
|---|---|---|
| 1. 댓글 CRUD | GameCommentControllerTest 12건 all pass | PASS |
| 2. 비작성자 403 / ADMIN 200 | GameCommentControllerTest 포함 (12건 all pass) | PASS |
| 3. 리뷰 게임당 1회 409 | GameReviewControllerTest 13건 all pass | PASS |
| 4. 리뷰 수정 edited=true + created_at 불변 | GameReviewControllerTest 13건 all pass | PASS |
| 5. 댓글 201자 400 / 200자 허용 | GameCommentControllerTest 12건 all pass | PASS |
| 6. rating 0/6→400 / 1/5→200 | GameReviewControllerTest 13건 all pass | PASS |
| 7. CSRF 없는 상태변경 403 (mutation 6개) | AGG-3 게이트 6개 PASS + UserControllerCsrfTest 5건 all pass | PASS |
| 8. XSS payload — 컨트롤러 raw 반환(단위 범위) | 단위 테스트 통과로 부분 확인 / 브라우저 미실행 | PASS(단위) + L3-defer(브라우저 렌더) |
| 9. 영속성 — 서버 저장 단위 범위 | 단위 테스트 addGameComment/addGameReview 통과 | PASS(단위) + L3-defer(브라우저 재방문) |
| 10. 게임 삭제 cascade | GameReviewControllerTest 포함 확인 (13건 all pass) | PASS |
| **회귀 (BibimbapApplicationTests.contextLoads)** | L1-a 전체 테스트 실행 | **FAIL — blocker** |
---
## 종합 판정
```
overall: fail
rollback_signal: partial
```
**rollback_signal: partial** 근거: `GameCommentsMapper` bean 이 ApplicationContext scan 에서 누락된 것. 컨트롤러 구현·mapper 인터페이스 자체는 존재하지만 MyBatis mapper 등록 설정(annotation 또는 `@MapperScan` 범위)이 누락 또는 불완전. 신규 파일 추가 범위의 설정 보완으로 수정 가능하며 전체 revert 보다는 설정 파일 부분 수정이 적합하다.
---
## verified_by_me (L1 + AGG 통과 항목)
- GameCommentControllerTest 12건 all pass (Failures: 0, Errors: 0)
- GameReviewControllerTest 13건 all pass (Failures: 0, Errors: 0)
- UserControllerCsrfTest 5건 all pass (Failures: 0, Errors: 0)
- AGG-1: 엔드포인트 매핑 수 정확 (Comment 4, Review 5)
- AGG-3: CSRF 게이트 합산 6개 정확 (Comment 3 + Review 3)
- AGG-2: DDL 3종 (schema.sql game_reviews 테이블 존재, user_id 존재, docs/game-reviews-ddl.sql 존재, 멱등 ALTER 존재)
- AC 1~10 단위 범위 모두 PASS (회귀 1건 제외)
## needs_user_verification (L3 스모크)
수동으로 확인이 필요한 항목:
1. **XSS 브라우저 렌더**: 톰캣+DB 기동 후 `<script>alert(1)</script>` 를 댓글/리뷰 body 에 입력 → 화면에 alert 팝업이 뜨지 않고 텍스트로 표시되는지 확인. 확인 경로: `textContent` 바인딩 JSP/JS 코드 동작.
2. **영속성 브라우저 재방문**: 댓글/리뷰 작성 후 브라우저 탭 닫고 재방문 → 데이터가 유지되는지 확인.
3. **실제 게임 삭제 cascade**: 게임 삭제 API 호출 후 DB에서 `game_reviews.deleted_at` 설정 여부 확인 (`SELECT * FROM game_reviews WHERE game_id = ?`).
## blocker (수정 필요)
**BibimbapApplicationTests.contextLoads — 회귀 FAIL**
- 실패: `NoSuchBeanDefinitionException` for `com.pandoli365.bibimbap.mapper.GameCommentsMapper`
- `BibimbapApplicationTests` 가 전체 Spring ApplicationContext 로드 시 `GameCommentsMapper` bean 을 찾지 못함.
- 재현: `./mvnw -o test -Dtest=BibimbapApplicationTests 2>&1`
- 이 테스트는 신규 mapper 추가 이전부터 존재하던 기존 테스트. 신규 구현 이후 실패 → 회귀.

View File

@ -0,0 +1,224 @@
---
schema_version: 2
sid: 20260618-121419
started_at: 2026-06-18T12:14:19+09:00
ended_at: 2026-06-18T13:05:00+09:00
applied_changes:
- "Serena memory 기록: bibimbap-auth-pbkdf2-not-bcrypt (수용, signal=observation)"
- "Serena memory 기록: bibimbap-dev-seed-blueprint (수용, FK순서+active-unique 통합)"
- "Serena memory 기록: java-crypto-verify-via-jdk-container-when-no-host-jdk (수용, signal=positive)"
- "docs_sync 보류: CLAUDE.md / verification-strategies.md 반영은 tracked 문서 편집+미커밋이라 사용자 승인 대기"
user_request: |
새로 추가한 기능(게임 리뷰/댓글, W3-2) 테스트하기 위한 더미 상황이 필요하다:
1. 게임 (dummy game)
2. 계정 (dummy account, 로그인 가능)
3. 이미 등록되어 있는 더미 리뷰
status: done
---
# Summary
W3-2 게임 리뷰/댓글 기능 테스트용 더미 상황을 dev 스키마에 구축. 멱등 seed SQL
(`db/seed-dev.sql`) + 정리 SQL(`db/seed-dev-teardown.sql`) 작성 후 가동중 컨테이너
(bibimbap-db)에 적용. 구성: 로그인 가능 테스트 계정 1개(tester@bibimbap.local /
test1234!), 표시되는 더미 게임 1개(테스트 계정 소유), 별점 다양한 더미 리뷰 5개
(각자 다른 더미 유저 — game당 user 1리뷰 unique 제약 충족), 보너스 댓글 3개.
로그인 계정은 해당 게임에 리뷰가 없어 "직접 작성" 테스트도 가능.
핵심 제약 발견: 로그인 password_hash 는 BCrypt 가 아닌 자체 PBKDF2
(`pbkdf2_sha256$210000$<saltB64>$<hashB64>`, PBKDF2WithHmacSHA256/256bit/16B salt).
저장 해시가 실제 Java `UserController.verifyPassword` 경로로 검증됨을 JDK 컨테이너에서
교차 실행 확인(verify('test1234!')=true, 오답=false) → 로그인 동작 보장.
# Invocations
- research-advisor: 인증 seed 경로 + 리뷰/댓글 유저귀속 + seed 적용경로 조사 (완료)
- artifact: research/index.md (file:line 근거 포함). 결론 = orchestrator 독립 조사와 일치.
- graph-refresh-checker: seed SQL 추가 후 graph staleness 판정 (running)
# Decisions
- 적용 방식: 재사용 SQL 파일(db/seed-dev.sql) + 즉시 적용. (사용자 선택, 추천값)
- 로그인 자격: 기본값 tester@bibimbap.local / test1234! / 닉네임 '테스터'. (사용자 선택)
- 규모: 게임 1 + 리뷰 5(별점 5/4/3/5/2). (사용자 선택)
- 설계: 리뷰어는 별도 더미 유저 5명(unique 제약 game당 user 1리뷰 때문). 로그인 계정은
그 게임에 리뷰 없음(작성 테스트 가능). 게임 소유자 = 로그인 계정(getGamesByUserId 도 테스트).
- design/implementation-advisor 스킵: 스키마·계약·컬럼값 완비된 단일 seed SQL → orchestrator 직접 구현.
- 댓글 3개 보너스 포함: 신규 기능이 리뷰/댓글 동시 배치라 같은 더미 상황에 포함이 합리적.
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '요청 3항목 명확, seed 위치/자격/규모는 orchestrator 가 plan-gate(AskUserQuestion)로 직접 수렴'
checked_at: 2026-06-18T12:14:30+09:00
- advisor: research-advisor
decision: call
rationale: '로그인 가능 계정 seed 위한 password_hash 알고리즘·provider·세션귀속이 미문서화 미지수'
checked_at: 2026-06-18T12:15:00+09:00
- advisor: design-advisor
decision: skip
rationale: 'research 로 컬럼·계약·제약 전부 확정 → 설계 오픈질문 0, orchestrator 직접 설계'
checked_at: 2026-06-18T12:30:00+09:00
- advisor: implementation-advisor
decision: skip
rationale: '단일 SQL 파일 산출, 파일 충돌 없음 → worker 분산 불요'
checked_at: 2026-06-18T12:31:00+09:00
- advisor: verification-advisor
decision: skip-direct
rationale: 'orchestrator 가 매퍼 동등쿼리 + JDK 교차해시검증 + 멱등 재실행을 직접 실행해 AC 충족 확인'
checked_at: 2026-06-18T12:45:00+09:00
# user_signals
positive: []
negative: []
# verified_by_me
- L1 (데이터 무결성 / 매퍼 동등 쿼리, 실 DB 실행):
- login identity 조회(provider=email): tester 1건 + password_hash 존재 → pass
- tester users.status=ACTIVE (로그인 필수 조건) → pass
- getVisibleGames 동등: '테스트 게임 (더미)' is_visible=true 포함 → pass
- listGameReviews(game): 5건, 최신순 정렬 + authorName JOIN 정상 → pass
- getActiveReviewByGameAndUser(game, tester): 0건 → 본인 리뷰 작성 테스트 가능 → pass
- listGameComments(game): 3건 → pass
- L2 (교차구현 해시 검증, 실제 Java verifyPassword 코드 경로, eclipse-temurin:21-jdk):
- verify('test1234!', 저장해시) = true → 로그인 성공 보장
- verify('wrongpass', 저장해시) = false → 음성 케이스 정상
- 멱등성: seed-dev.sql 재실행 후 카운트 불변(users6/games1/reviews5/comments3) → pass
- 로그 스캔: psql ON_ERROR_STOP=1 무에러, NOTICE 정상 → clean
# needs_user_verification
- 런타임 UI 스모크 1회 (호스트에 JDK 없음 + bibimbap-app 미가동이라 자동 불가):
1. `docker compose up --build` (또는 호스트 실행) 으로 앱 기동
2. tester@bibimbap.local / test1234! 로 로그인 → 성공 확인
3. '테스트 게임 (더미)' 상세 진입 → 기존 리뷰 5건 + 댓글 3건 표시 확인
4. 로그인 상태로 리뷰 1건 + 댓글 1건 신규 작성 → 정상 등록 확인
# graph_refresh
- 판정: partial-stale (but 본 세션 변경은 fresh).
- 본 세션 변경(db/seed-dev.sql, seed-dev-teardown.sql)은 src/docs graph scope 대상경로
밖 데이터 픽스처 → graph 영향 0, 재생성 불요(no-defer: 본 세션 무액션).
- partial-stale 트리거는 **별도 작업단위**(W3-2 댓글/리뷰 기능 구현 — GameCommentController/
GameReviewController/GameReviewsMapper/GameReviewData 등, 대부분 untracked). 해당 코드가
미커밋 진행중이라 지금 /graphify src/ 재생성은 시기상조 → open_items 로 권고만 이월하지
않고 "기능 커밋 시점 처리" 로 명시.
# post-session-debug (로그인 server error 후속)
- 사용자 보고 "로그인 시 server error(500)". 진단 결과:
- 원인 = **stale 앱 인스턴스**(pid 59109, 6/17 14:36 기동, 최근 auth 수정 이전 빌드).
내 seed 가 tester 계정을 만들어 처음으로 "계정 존재" 상태 로그인 경로를 타며 표면화.
- 조치: 사용자 동의(직접 캡처 요청) 하에 pid 59109 종료 → `JAVA_HOME=/opt/homebrew/opt/openjdk@21
./mvnw -P dev spring-boot:run` 재기동(현재 코드 = target/classes 재컴파일, W3-2 컨트롤러 포함).
로그: /tmp/bibimbap-app.log. 신규 pid 23318 (백그라운드).
- 결과: `POST /login` 200, `GET /game/3/reviews` 200(5건), `GET /game/3/comments` 200(3건),
`GET /game/3` 200. 더미 상황 런타임 정상. → needs_user_verification 의 UI 스모크 사실상 완료.
- **발견 버그 (별도 작업단위 = W3-2 댓글기능, 내 seed 무관)**: 댓글 목록 author 항상 null.
- 근거: GameCommentsMapper.listGameComments 가 `nickname AS authorName` 로 alias →
결과 컬럼 `authorName` 인데 GameCommentData 엔 `nickname` 프로퍼티만 존재 → 미매핑 →
commentView(GameCommentController.java:179) `getNickname()` null.
- 대조: 단건 getGameComment 는 `nickname`(alias 없음) → 정상.
- 1줄 수정안: listGameComments 의 `nickname AS authorName``nickname`.
- 처리: 사용자 요청 시에만 (in-progress 기능 코드라 무단 수정 보류).
# open_items
- 미커밋 신규 파일: db/seed-dev.sql, db/seed-dev-teardown.sql (작업단위 산출물).
프로젝트/하니스 커밋정책 = 사용자 요청 시에만 커밋 → 커밋 보류, 사용자 확인 대기.
- (별도 작업단위) W3-2 댓글/리뷰 기능 코드 커밋 시 `/graphify src/` 재생성 + docs/graph/index.md
frontmatter(source_commit, last_generated_at) + Scopes 표 src 행 갱신 권고. 본 더미데이터
task 범위 밖.
# Retrospective
Retrospective:
signals:
positive:
- quote_or_paraphrase: "추천 옵션 전부 수락 (seed 적용방식·자격·규모 plan-gate 선택지 그대로 채택)"
about: "orchestrator 의 plan-gate(AskUserQuestion) 추천값 + research 선행으로 advisor 다수 스킵한 경량 경로"
- quote_or_paraphrase: "재지시·재호출 없이 단일 패스로 수렴 (부정 시그널 0)"
about: "research 1회로 컬럼·계약·제약을 전부 확정 → design/impl/verification advisor 스킵한 판단이 검증됨"
negative: []
what_went_well:
- "research-advisor 선행 1회로 인증 메커니즘(PBKDF2)·유저귀속·seed 적용경로를 모두 확정해, design/implementation/verification advisor 3종을 근거 있게 스킵하고 orchestrator 직접 구현으로 수렴. advisor invocation decision log 에 skip rationale 가 항목별로 남아 사후 추적 가능."
- "호스트에 JDK 가 없는 제약에도 password_hash 를 추정·방치하지 않고 eclipse-temurin:21-jdk 컨테이너에서 실제 verifyPassword 코드 경로로 교차 실행(positive=true / negative=false)해 '로그인 가능' AC 를 자기검증으로 닫음. self-report 가 아닌 실행 증거."
- "schema.sql 이 '비권위 복원본'이라는 메타 한계를 research concerns 에 보존하고, 컬럼명은 매퍼로 교차확인하되 제약·타입은 추정으로 표기해 신뢰도 과대평가를 회피."
- "seed SQL 을 멱등 + teardown 쌍으로 작성하고 재실행 후 카운트 불변을 실증해 반복 적용 안전성을 확보."
- "graph staleness 를 partial-stale 로 정직하게 판정하되, 본 세션 변경(데이터 픽스처)은 graph scope 밖임을 구분해 불필요한 재생성을 회피하고 잔여 stale 은 별도 작업단위로 정확히 귀속."
what_to_improve:
- "런타임 UI 스모크는 호스트 환경 제약(JDK 없음 + 앱 미가동)으로 자동 불가 → needs_user_verification 으로 정확히 이월됨. 개선이라기보다 환경 한계의 정직한 핸드오프. (구조적 결함 아님)"
- "seed SQL 의 제약/타입/default 는 schema.sql 비권위 유래라 실 DB `\\d` 대조까지는 미수행(행수·테이블존재·매퍼 동등쿼리로만 검증). 운영 DB 와 default 가 다르면 재현 시 불일치 여지 — 현 dev 스키마에선 무영향이나 운영 적용 시 유의."
memory_candidates:
- name: bibimbap-auth-pbkdf2-not-bcrypt
type: reference
description: "bibimbap 로그인 해시는 BCrypt 가 아닌 자체 PBKDF2 — 로그인 가능 더미/테스트 계정 seed 시 이 포맷으로 직접 생성해야 함"
body_draft: |
# bibimbap 인증: PBKDF2 자체 해시 (BCrypt 아님)
Spring Security 미사용. 로그인은 UserController 자체 구현 password 검증.
password_hash 포맷: `pbkdf2_sha256$210000$<base64(salt)>$<base64(hash)>` ($ 4필드)
- 알고리즘 PBKDF2WithHmacSHA256, iterations=210000, keyLength=256bit, salt=16byte SecureRandom
- 검증 경로: UserController.verifyPassword (parts[0]=="pbkdf2_sha256" && len==4 확인 후 동일 iter/salt 재계산, MessageDigest.isEqual 상수시간 비교)
## Why
외부 BCrypt 생성기로 만든 해시는 무효 — 형식 자체가 다름. 향후 로그인 가능 더미/테스트/관리자 계정을
seed 할 때마다 이 함정에 반복적으로 부딪힌다. 코드로 유도는 가능하나 "BCrypt 일 것"이라는 기본 가정이
강해 매번 재확인 비용이 든다.
## How to apply
- 로그인 가능 계정 seed 시 위 PBKDF2 스펙으로 해시 문자열을 직접 생성.
- 로그인 동작 보장은 실제 verifyPassword 코드 경로(JDK 컨테이너 교차 실행 등)로 positive/negative 둘 다 확인.
- 로그인 필수 조건 동봉: users.status='ACTIVE', user_auth_identities.provider='email',
provider_user_id=정규화(trim+lowercase) 이메일, 양쪽 is_delete=false.
rationale_for_saving: "코드로만 드러나는 비자명 사실(BCrypt 아님)이고, 테스트/더미 계정 seed 마다 재발하는 함정. schema/git log 로는 '자체 PBKDF2 라서 외부 생성기 무효'라는 함의가 드러나지 않음."
signal_source: observation
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/CLAUDE.md
- name: bibimbap-seed-blueprint-fk-and-unique
type: reference
description: "dev 스키마 더미 seed 청사진 — FK 순서 + game_reviews active-unique 제약(게임당 user 1리뷰)"
body_draft: |
# bibimbap dev 더미 seed 청사진
현실적 경로 = dev 스키마에 psql 직접 INSERT (UI 경유는 CSRF+세션+WebGL 업로드 강제로 고비용).
DB 직삽은 CSRF·세션·중복선검사를 우회하되 FK·CHECK·유니크idx 는 그대로 적용.
INSERT 순서(FK 의존):
1. users (status='ACTIVE' 필수, role='USER', is_delete=false, display_name 권장)
2. user_auth_identities (PBKDF2 password_hash — 별도 memory 참조; provider='email')
3. games (user_id FK, name; is_visible/is_delete default 로 목록노출 충족; webgl/thumbnail NULL 가능)
4. game_reviews (game_id, user_id, rating 1~5, body)
5. game_comments (game_id, user_id, nickname[직접 채움 — 조회가 users JOIN 안 함], content)
## 핵심 제약
- game_reviews 부분 유니크 idx `(game_id, user_id) WHERE is_delete IS NOT TRUE`
→ 한 게임에 더미 리뷰 N개 = 더미 유저 N명 필요 (한 user 는 게임당 활성 1리뷰).
- game_comments 는 유니크 없음 → 자유 다수. 작성자명 보이려면 nickname 직접 채움(비정규화 저장).
- 게임 목록+상세 노출 = games.is_visible=true, games.is_delete=false, 연결 users.is_delete=false.
## Why
리뷰 N개를 같은 유저로 넣으려다 유니크 위반으로 막히는 게 첫 시도의 흔한 실패. schema.sql 이
'비권위 복원본'이라 제약을 코드에서 직접 읽기 전엔 드러나지 않음.
## How to apply
- seed SQL 확정 직전 `\d dev.<table>` 로 제약/타입/default 운영 대조 권장(schema.sql 비권위).
- 리뷰 다양화가 목표면 더미 유저를 리뷰 수만큼 선행 INSERT.
rationale_for_saving: "seed 설계 시 반복되는 유니크-위반 함정 + FK 순서. schema.sql 비권위라 코드 교차확인 없이는 유도 불가하고, 더미/픽스처 작업마다 재사용되는 청사진."
signal_source: observation
docs_sync_target: null
- name: java-crypto-verify-via-jdk-container-when-no-host-jdk
type: feedback
description: "호스트에 JDK 없을 때 Java 암호/검증 로직을 eclipse-temurin:21-jdk 컨테이너 single-file 실행으로 교차검증"
body_draft: |
# 호스트 JDK 부재 시 Java 로직 교차검증 패턴
호스트에 JDK 가 없고 앱도 미가동인 환경에서, password_hash 같은 Java 암호/검증 로직의
정합성을 추정으로 닫지 않고 `eclipse-temurin:21-jdk` 컨테이너에 single-file/javac 로
실제 코드 경로(예: verifyPassword)를 옮겨 실행해 positive+negative 케이스를 모두 확인.
## Why
seed 한 해시가 "로그인 된다"는 self-report 는 증거가 아니다. 실제 검증 함수와 동일한
알고리즘/iter/salt 로 재계산해 true/false 가 나와야 AC 가 닫힌다. 호스트 toolchain 부재는
검증 생략의 이유가 되지 않고, 컨테이너로 우회 가능하다.
## How to apply
- 검증 대상 메서드의 알고리즘 파라미터(iter/keyLength/salt/encoding)를 코드에서 그대로 추출.
- 동일 로직을 단일 .java 로 재현해 temurin 컨테이너에서 실행, 정답=true / 오답=false 둘 다 확인.
- 런타임 UI 스모크처럼 컨테이너로도 불가한 부분만 needs_user_verification 으로 이월.
rationale_for_saving: "환경 제약(호스트 JDK 없음) + Java 검증 로직이라는 비자명 조합. self-report 대신 실행 증거로 AC 를 닫는 재현성 있는 검증 워크플로우 — 단발 우발 아님."
signal_source: positive
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
protocol_feedback: []
applied_changes: []

View File

@ -0,0 +1,149 @@
---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-18T03:18:28Z
concerns:
- "low source confidence — db/schema.sql 은 헤더 주석상 '비권위 복원본'(users/user_auth_identities/game_comments). 컬럼명·매퍼 일치는 코드로 교차확인됐으나 타입·길이·기본값·제약은 추론값. 운영 DB 제약과 다를 수 있으므로 seed SQL 확정 전 실 DB(\\d 조회)로 컬럼·제약 대조 필요."
- "BCrypt 가 아닌 PBKDF2 자체 해시이므로 seed용 password_hash 문자열은 외부 BCrypt 생성기로 만들 수 없음. iterations=210000/HmacSHA256/16byte-salt/256bit 스펙에 맞춰 직접 생성해야 함(생성 스크립트 자체는 design/구현 몫)."
concerns_checked: true
source_confidence: high
workers_spawned: 3
---
# 조사 결과
## 주제
bibimbap(Java 21 / Spring Boot / JSP / MyBatis, WAR) 에 테스트용 더미 데이터를 seed 하기 위한 사전 조사.
"로그인·리뷰 작성이 실제로 동작하는" 형태로 (1) 게임 (2) 로그인 가능 계정 (3) 더미 리뷰+댓글 을 만들기 위한
정확한 컬럼값·메커니즘 도출. 코드 수정 없음.
> 신뢰도 표기: `확인됨` = 코드/실행으로 1차 확인. `추정` = 통용·유추. `미확인` = 검증 불가.
> 본 조사의 사실 항목은 전부 프로젝트 코드 직접 확인 기반 → `source_confidence: high`.
> 다만 schema.sql 자체가 "비권위 복원본"이라는 메타 한계는 concerns 에 보존(컬럼명은 매퍼로 교차확인됨, 제약·타입은 추론).
---
## 포인트별 발견
### 포인트 A: 로그인/인증 메커니즘 (실제 로그인 가능한 계정 seed)
- 경로: `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java`,
`data/UserAuthIdentityData.java`, `mapper/UserAuthIdentitiesMapper.java`, `mapper/UsersMapper.java`, `db/schema.sql`
요약:
- **Spring Security 미사용.** `PasswordEncoder|SecurityFilterChain|@EnableWebSecurity|spring-boot-starter-security`
src/ + pom.xml 0 hit. 인증은 UserController 자체 구현 password 로그인. **OAuth 없음**(google/kakao/naver/oauth 0 hit). [확인됨]
- 로그인 엔드포인트: `POST /login`, form 파라미터 `email`,`password`,`remember`(선택) — UserController.java:122, 125-128. [확인됨]
진입부에서 `CsrfTokens.isValid(request)` 실패 시 403 — UserController.java:131-133. [확인됨]
- **provider 컬럼 값은 `"email"` 단 하나.** `PROVIDER_EMAIL="email"` — UserController.java:42.
identity 생성 시 set — UserController.java:314. 조회 필터도 동일 값 — UserController.java:140-143. [확인됨]
`provider_user_id` 에는 **정규화 이메일**(trim+lowercase) 저장 — UserController.java:104-106, 527-533. [확인됨]
- **password_hash 알고리즘 = PBKDF2 (BCrypt 아님).** PasswordEncoder 빈 없음. 형식:
`pbkdf2_sha256$<iterations>$<base64(salt)>$<base64(hash)>` (`$` 4필드) — UserController.java:554-556. [확인됨]
- iterations=210000 (UserController.java:46), keyLength=256bit (:47), salt=16byte SecureRandom (:48,551-552),
알고리즘 `PBKDF2WithHmacSHA256` via JCE SecretKeyFactory/PBEKeySpec (:576-584). [확인됨]
- 검증: `verifyPassword()``$` split → parts[0]=="pbkdf2_sha256" && length==4 확인 후 동일 iterations/salt 재계산,
`MessageDigest.isEqual` 상수시간 비교 — UserController.java:144, 559-574. [확인됨]
- PBKDF2 표준 동작(iterations·salt 가 해시 문자열에 동봉되어 검증 시 재현 가능) — [추정/일반 통용 지식]
- **로그인 성공 시 세션 저장** (saveLoginSession — UserController.java:162, 502-525). 세션 직전 `changeSessionId()` 세션고정방어 — :159-160. [확인됨]
개별 attribute: `id`(Long), **`userId`(Long — 인증가드 sessionUserId() 가 읽는 키, :337)**, `displayName`, `email`(canonicalEmail),
`avatarUrl`, `role`, `status`, `authProvider`("email"), `authIdentityId`, `lastLoginAt` — UserController.java:503-512.
추가로 `account` 키에 위 값들의 LinkedHashMap 통째 저장 — :514-524. (UserData 객체 자체가 아니라 개별 스칼라+Map) [확인됨]
- **로그인 검증 컬럼 흐름** [확인됨]:
1. user_auth_identities `WHERE provider='email' AND provider_user_id=<정규화이메일> AND is_delete IS NOT TRUE` — UserAuthIdentitiesMapper.java:47-49
2. password_hash != null && PBKDF2 통과 — UserController.java:144
3. users `WHERE id=<identity.user_id> AND is_delete IS NOT TRUE` — UsersMapper.java:25-26
4. **user.status == "ACTIVE"** 필수 (STATUS_ACTIVE=:44) — UserController.java:148-149
- **seed 시 필수 채움 컬럼** (schema.sql 비권위 — 컬럼명만 신뢰):
- users: `role`(default 'USER'), **`status`='ACTIVE'**(ACTIVE 아니면 로그인 거부), `is_delete`=false. display_name/canonical_email 권장. — schema.sql:30-42 [확인됨, 제약값은 추정]
- user_auth_identities: `user_id`(FK), `provider`='email', `provider_user_id`=정규화이메일(소문자), **`password_hash`=PBKDF2형식**(null이면 즉시거부), `is_delete`=false. — schema.sql:49-63 [확인됨, 제약값은 추정]
- 주의: active-unique idx `ux_user_auth_identities_provider_user_id_active` = `(provider, provider_user_id) WHERE is_delete IS NOT TRUE` — schema.sql:66-68. 같은 이메일 활성 identity 중복 INSERT 실패. [확인됨, idx는 비권위 schema.sql]
- 신뢰도: 확인됨 (코드 직접 확인). schema.sql 유래 제약값만 추정.
### 포인트 B: 리뷰/댓글 작성 시 유저 귀속 + INSERT 컬럼 + CSRF
- 경로: `controller/api/GameReviewController.java`, `data/GameReviewData.java`, `mapper/GameReviewsMapper.java`,
`controller/api/GameCommentController.java`, `data/GameCommentData.java`, `mapper/GameCommentsMapper.java`,
`security/CsrfTokens.java`, `db/schema.sql`, `docs/game-reviews-ddl.sql`
요약:
- **리뷰 API**: GET `/game/{id}/reviews`(목록, :41-42), GET `/game/{id}/reviews/{reviewId}`(:58-62),
POST `/game/{id}/reviews`(작성, :74), PUT(수정, :124), DELETE(소프트삭제, :170) — GameReviewController.java. [확인됨]
작성 바디(@RequestParam, form): `rating`(String→1~5 파싱, 범위밖 400), `body`(최대 1000자) — :78-101, BODY_MAX=:30. [확인됨]
- **user_id 출처 = 세션 attribute `"userId"`** (요청 바디 아님). `sessionUserId(session)``session.getAttribute("userId")` — GameReviewController.java:86, 241-257(:245). 없으면 401. `review.setUserId(userId)` — :109. [확인됨]
- **game_reviews INSERT 컬럼 = 정확히 4개**: `(game_id, user_id, rating, body)` `#{}` 바인딩 — GameReviewsMapper.java:72-86. [확인됨]
- game_id ← PathVariable(:108), user_id ← 세션(:109), rating ← 1~5 정수(:110; DB CHECK BETWEEN 1 AND 5 — schema.sql:129, docs/game-reviews-ddl.sql:49-53), body ← trimToEmpty(빈입력시 ""; DB text nullable) — :98,275-278. [확인됨]
- 미포함(전부 DB DEFAULT): id(seq), created_at/updated_at(now()), is_delete(false), deleted_at(null) — schema.sql:124-127. [확인됨, 비권위 schema]
- 참고: updated_at > created_at 이면 조회 시 edited=true 표시 — GameReviewsMapper.java:24, GameReviewController.java:210. [확인됨]
- **ux_game_reviews_game_user_active** = 부분 유니크 인덱스 `(game_id, user_id) WHERE is_delete IS NOT TRUE` — schema.sql:132-133, docs/game-reviews-ddl.sql:58-61. [확인됨]
의미: 활성 리뷰는 (game,user) 조합당 1개. soft-delete 행은 제외 → 재작성 허용. 코드도 INSERT 전 중복 선검사 후 409 — GameReviewController.java:103-105, GameReviewsMapper.java:56-70. [확인됨]
**seed 주의**: 한 게임에 더미 리뷰 여러 개 원하면 각 행 user_id 를 다르게. 한 user 는 게임당 활성 1개. [확인됨]
- **댓글 API**: GET `/game/{id}/comments`(:39), POST(작성, :56), PUT(:102), DELETE(소프트삭제, :143). 작성 바디 `content`만(최대 200자, CONTENT_MAX=:28) — GameCommentController.java:60,75-78. [확인됨]
- user_id ← 세션 `"userId"` (:67,198). [확인됨]
- **nickname 비정규화 저장 확정**: 작성 시점 세션 `"displayName"` 을 nickname 컬럼에 복사 — `comment.setNickname(authorName)` :84, sessionDisplayName=session.getAttribute("displayName") :212-218(:216). 조회 시 users JOIN 없이 nickname 그대로 노출(`nickname AS authorName`) — GameCommentsMapper.java:36, GameCommentController.java:179. [확인됨]
- **game_comments INSERT 컬럼 = 정확히 4개**: `(game_id, user_id, nickname, content)` — GameCommentsMapper.java:46-60. user_id nullable FK(schema.sql:112), nickname varchar(100) nullable(:102). [확인됨, 비권위 schema]
- 유니크 제약 없음 → 같은 (game,user) 댓글 여러 개 가능. 화면 작성자명 보이려면 nickname 직접 채워야(조회가 users JOIN 안 함). 목록 정렬 created_at ASC, id ASC — GameCommentsMapper.java:42. [확인됨]
- **CSRF**: Spring Security 미사용 → 표준 CSRF 필터 없음. 대신 자체 `CsrfTokens` 유틸을 컨트롤러 mutation 진입부에서 수동검증. [확인됨]
- 리뷰 POST/PUT/DELETE: GameReviewController.java:83-85,134-136,178-180. 댓글: GameCommentController.java:64-66,111-113,151-153. GET 은 검증 없음. [확인됨]
- 토큰: 세션 key `"csrfToken"`, 헤더 `X-CSRF-Token`(우선) 또는 폼 파라미터 `_csrf`(폴백), 32byte SecureRandom Base64url — CsrfTokens.java:12-13,20-33,35-49. [확인됨]
- **영향**: HTTP API 경유 수동 작성은 로그인세션+csrfToken+헤더/파라미터 필요. **DB 직접 INSERT seed 는 CSRF·세션·중복선검사 모두 우회**(단 DB 유니크idx·CHECK·FK 는 그대로 적용). [확인됨]
- 신뢰도: 확인됨. schema.sql/ddl 유래 제약만 추정.
### 포인트 C: seed 적용 경로 + 게임 표시 조건
- 경로: `.env`, `docker-compose.yml`, `src/main/resources/dev/db.properties`, `GameController.java`, `GamesMapper.java`,
`data/GameData.java`, `db/schema.sql`, `WEB-INF/views/game-detail.jsp`
요약:
- **DB 접속 정보 확정** (.env + docker-compose.yml + dev/db.properties 일관) [확인됨, 실행 확인]:
- DB명 `bibimbap`, 유저 `bibimbap`, 패스워드 `change_me_local_dev`, 호스트포트 5433 → 컨테이너 5432.
- .env: POSTGRES_DB/USER=bibimbap, POSTGRES_PASSWORD=change_me_local_dev, APP_SCHEMA=dev, DB_PORT=5433
- docker-compose.yml:18 `"${DB_PORT:-5432}:5432"`, :40 `jdbc:...:5432/${POSTGRES_DB}?currentSchema=${APP_SCHEMA:-dev}`
- dev/db.properties:2-4 `jdbc:postgresql://localhost:5433/bibimbap?currentSchema=dev`
- 컨테이너 `bibimbap-db postgres:16 Up (healthy) 0.0.0.0:5433->5432/tcp` (docker compose ps 실행 확인)
- **앱 스키마 = `dev`**. psql 접속: `PGPASSWORD=change_me_local_dev psql -h localhost -p 5433 -U bibimbap -d bibimbap``SET search_path TO dev;` (또는 `dev.games` 한정).
- **현재 dev.games / dev.users 모두 0행** (read-only 조회 확인). live 스키마는 테이블 없음 → 앱 데이터는 전적으로 dev. [확인됨]
- **게임 표시 조건** (목록과 상세가 다름; games-users INNER JOIN `JOIN users u ON u.id=g.user_id` → 유효 미삭제 user 필수) — GamesMapper.java:32-33. [확인됨]
- 상세(getGame, GamesMapper.java:34-36): `g.id=#{id} AND g.is_delete IS NOT TRUE AND u.is_delete IS NOT TRUE`. **is_visible 조건 없음** → 비공개 게임도 id 직접 접근 시 상세 뜸. [확인됨]
- 목록(getVisibleGames, GamesMapper.java:57-59): `g.is_visible IS NOT FALSE AND g.is_delete IS NOT TRUE AND u.is_delete IS NOT TRUE`. [확인됨]
- **목록+상세 모두 보이려면**: games.is_visible=true, games.is_delete=false, 연결 users.is_delete=false. [확인됨]
- 컨트롤러: gameDetail 이 getGame(id)!=null 이면 DB 게임으로 game-detail 렌더 — GameController.java:113-121. [확인됨]
- **games INSERT 필수(NOT NULL, no-default) 컬럼 = `user_id`(FK→users), `name` 둘뿐** — schema.sql:75-87. [확인됨, 비권위 schema]
- default 보유(생략가능): like_count(0), is_visible(true), sort_order(0), created_at/updated_at(now()), is_delete(false). nullable: creator_note, git_url, webgl_path, thumbnail_url. id=seq.
- 최소 INSERT `(user_id, name)` 만으로 가능 + 목록노출 조건 자동충족. 단 user_id FK 로 **users 행 선행 필수**.
- **asset 컬럼 NULL 이어도 상세/리뷰 동작 — 깨지지 않음** [확인됨]:
- game-detail.jsp:12-21 webglFrameSrc null/blank/"null" 시 빈 문자열 폴백 → iframe src="" 빈화면(예외 없음). 컨트롤러가 trimToEmpty 정규화 후 모델 주입(GameController.java:273,281-283)이라 NPE 없음.
- thumbnail_url 은 상세 JSP 미사용. 리뷰/댓글은 별도 비동기 API(`/game/{id}/reviews`,`/comments`)로 로드 → game asset 컬럼과 무관.
- 결론: webgl_path/thumbnail_url 을 NULL 로 둬도 상세 + 리뷰/댓글 테스트 가능.
- 신뢰도: 확인됨 (코드 + 실행). schema.sql 유래 제약만 추정.
---
## 종합 판단 (더미 seed 실행 청사진)
세 포인트가 한 흐름으로 맞물린다. **현실적 seed 경로 = dev 스키마에 psql 직접 INSERT** (앱 UI 경유는 CSRF+세션+WebGL 업로드 강제로 고비용; DB 직삽은 그 전부 우회하되 FK·CHECK·유니크idx 만 적용).
INSERT 순서(FK 의존성):
1. **users** 1행: status='ACTIVE'(필수 — 아니면 로그인 거부), role='USER', is_delete=false, display_name 권장(목록 creator + 댓글 nickname 소스).
2. **user_auth_identities** 1행: user_id=위 user, provider='email', provider_user_id=<정규화 소문자 이메일>, password_hash=**PBKDF2 형식** `pbkdf2_sha256$210000$<b64salt>$<b64hash>`, is_delete=false. (활성 유니크 idx 주의)
3. **games** 1행: user_id=위 user, name=값. is_visible/is_delete 는 default 로 목록노출 충족. webgl_path/thumbnail_url NULL 가능.
4. **game_reviews** N행: (game_id, user_id, rating 1~5, body). **한 게임당 user 1활성리뷰** → 여러 리뷰면 user 여러 개 seed.
5. **game_comments** N행: (game_id, user_id, nickname[직접 채움], content). 유니크 없음 → 자유 다수.
**로그인 가능 핵심(질문의 최우선 관심사)**: password_hash 는 BCrypt 가 아니라 PBKDF2WithHmacSHA256.
평문→해시는 외부 BCrypt 도구로 만들 수 없고, iterations=210000 / 256bit / 16byte salt / base64 스펙으로
`pbkdf2_sha256$210000$<base64salt>$<base64hash>` 문자열을 생성해 넣어야 검증 통과한다.
(해시 생성 스크립트 작성은 design/구현 단계 몫 — research 범위 밖.)
권위 격상 전 검증 필요 항목: db/schema.sql 이 비권위 복원본이므로, 위 컬럼 *제약/타입/default* 는 seed SQL 확정 직전
실 DB `\d dev.users / \d dev.user_auth_identities / \d dev.games / \d dev.game_reviews / \d dev.game_comments` 로 대조 권장.
(컬럼명·매퍼 일치·인덱스 존재는 코드/실행으로 확인됨 — 불일치 위험은 타입/제약값에 한정.)
## 미해결
- PBKDF2 seed 해시를 실제로 생성하는 도구/스크립트는 미작성(설계·구현 영역). 본 조사는 "어떤 형식이어야 검증 통과하는가"까지만.
- db/schema.sql 비권위로 인한 컬럼 *제약/타입/default* 의 운영 DB 대조는 미수행(읽기조회로 행수·테이블존재만 확인). seed SQL 확정 전 `\d` 대조 필요.
- game_likes 서버측 like_count 증감 경로(좋아요는 localStorage 클라이언트 토글로 보임) — 더미 리뷰/계정/게임 범위 밖이라 미조사.

View File

@ -0,0 +1,162 @@
---
schema_version: 2
sid: 20260618-145152
started_at: 2026-06-18T14:51:52+09:00
ended_at: 2026-06-18T14:58:00+09:00 # 인터뷰 1차에서 사용자 지시로 일시정지 (구현 다음 세션)
user_request: |
W3-2 댓글/리뷰 고도화 — 인터뷰 주도 디테일 빌드.
W3-2 코어(2026-06-18 구현·검증 완료) 유지하며 세세한 디테일을 사용자 인터뷰로 누적.
바로 설계·구현 금지. 질문 시드 카탈로그(A 일관성 / B 목록규모 / C UX)를 한 축씩
AskUserQuestion 으로 물어 결정 누적 → design-advisor → 구현 → L1 회귀 31건 + 신규 → L3 스모크.
mode: interview-first (orchestrator 직접 인터뷰, requirements-advisor 대체)
---
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '카탈로그가 요구 분해를 이미 제공 — orchestrator 가 AskUserQuestion 인터뷰로 직접 스코프 확정(task 명시)'
checked_at: 2026-06-18T14:52:00+09:00
- advisor: design-advisor
decision: deferred-to-next-session
rationale: '인터뷰 완료·FINAL SPEC 사용자 확정. 단 사용자 지시로 구현은 다음 세션 → design-advisor 도 다음 세션 진입. 선결: W2-3 freeze 근거 확인'
checked_at: 2026-06-18T15:10:00+09:00
- advisor: retrospective-advisor
decision: skip
rationale: '코드 변경 0줄·구현 미시작 인터뷰 전용 세션. 회고 입력(구현/검증 결과) 부재 → 구현 세션 종료 시 수행'
checked_at: 2026-06-18T15:10:00+09:00
# Summary
인터뷰 완료 — 카탈로그 전 축(A1·A2·A3 / B1·B2·B3 / 리뷰정책4 / C1~C6) 결정 누적 완료.
사용자 지시로 이 세션은 **결정·기록만, 구현은 다음 세션**. 코드 변경 0줄(Serena/grep 탐색만).
다음 세션: 새 sid + `resumed_from: 20260618-145152`, 아래 FINAL SPEC 으로 design-advisor 진입.
## ⚠️ 다음 세션 선결·고위험 항목
- **C3 = W2-3 평점 집계 동결 해제**: 설계 진입 전 W2-3 freeze 근거(ADR/문서) 필독. §6 파괴적 게이트 인접 — orchestrator 사용자 재확인. 회귀 검증 필수
- **다축 평점 = 대규모 신규**: game_review_axes 테이블 + 6축 입력 UI + overall 자동평균/수동덮어쓰기 + game_review_stats 뷰(축별 평균). 작업량·테스트 비중 큼
- 입력 부담: 리뷰 제출 = 6축 필수 + 본문 10자 — UX 마찰 인지
# FINAL SPEC (✅ 사용자 최종확인 완료 2026-06-18 — 다음 세션 design-advisor 입력)
## A. 일관성
- **A1** 댓글 응답 = `commentView` 단일 통일(POST·PUT·list 전부). POST는 insert 후 재조회(createReview 패턴)
- **A2** 작성자명 = 하이브리드 `COALESCE(u.display_name, c.nickname)` + LEFT JOIN. user_id 있으면 현재닉(개명반영), NULL이면 스냅샷(QG-2 해결). createComment nickname 스냅샷 저장 유지
- 탈퇴자: 댓글 보존(LEFT JOIN) / 리뷰 현행 제외 유지(INNER JOIN). 탈퇴자 댓글 이름 = "(탈퇴한 사용자)" 마스킹: `CASE WHEN u.id IS NULL THEN c.nickname WHEN u.is_delete THEN '(탈퇴한 사용자)' ELSE u.display_name END`
- **A3** 댓글 edited+updatedAt 추가. game_comments.updated_at 멱등 ALTER + 매퍼/모델 갱신. editGameComment 시 updated_at 갱신, nickname 미덮어씀
## B. 목록 규모
- **B1** 페이지네이션 offset/limit + "더보기", 20건/페이지. 정렬 토글: 댓글(오래된순 기본/최신순), 리뷰(최신순 기본/별점 높은순/낮은순, tiebreaker rating·created_at·id). sort 화이트리스트 enum→고정 SQL(${} 금지)
- **B2** 리뷰 본문 필수 + 최소 10자(별점만 제출 불가). 댓글은 현행(200자 상한, 최소 없음)
- **B3** 본문 정규화 = 제어문자 제거 + 외곽 trim(댓글·리뷰 공통). 내부 연속공백·줄바꿈 보존
## 리뷰 정책
- 게임당 **1회 유지**(ux 유니크), 수정 **무제한 유지**(edited 마커)
- **다축 평점**: overall rating 유지(W2-3 집계용) + game_review_axes 신규 테이블(review_id FK, axis_key enum, score). 축 6개 **몰입성·창의성·조작성·완성도·사운드·비주얼**, 각 1~5 정수 **전 축 필수**. UI: overall = 축 평균 자동계산(정수 반올림)되다 유저 직접선택 시 덮어쓰기(수동 우선, 출처 구분 플래그 검토)
- **다축 시각화 = 육각형 레이더 차트(hexagon)**:
- 위치 = **요약 + 개별 리뷰 둘 다** (요약=game_review_stats 6축 평균 육각형 / 각 리뷰 카드=그 리뷰 6축 육각형)
- 렌더 = **인라인 SVG 자체 구현** (polygon points 계산, 기존 JSP 인라인 SVG idiom·빌드 없음·의존성 0). 차트 라이브러리/CDN 배제
- 입력은 **6개 별점 위젯 유지(C6)** — 육각형은 표시 전용(레이더 드래그 입력 a11y 제외)
- **a11y**: SVG 시각요소엔 텍스트 대체 필수 — 축별 점수 aria-label 또는 인접 텍스트/표(스크린리더). 폴리곤만으로 끝내지 말 것
- 개별 리뷰 카드 육각형은 컴팩트 사이즈(페이지당 20건 DOM 고려)
- 신고·숨김 **보류**(W1 운영자 role 선행)
## C. UX (game-detail.jsp)
- **C1** submit 잠금 + 로딩 상태(제출 중 disabled)
- **C2** 상대시각 "n분 전" + title 절대시각(fmtDate 확장)
- **C3** ⚠️ W2-3 동결 해제 + game_review_stats **뷰**(game_id, avg_rating, review_count + 6축 평균). 표시 = 소수1자리 + 반올림 별 + 0개 "아직 평가 없음". client 계산 폐기(페이지네이션 정합)
- **C4** 실시간 글자수 카운터(123/200, 리뷰 최소10자 안내). 댓글·리뷰 둘 다
- **C5** 삭제 = 완전 소멸 유지(현행)
- **C6** 별점 위젯 완전 radiogroup 패턴(roving tabindex + 화살표/Home/End), 다축 6위젯 적용
# Grounded Baseline (evidence)
- GameCommentController 응답 3종 불일치:
- POST createComment flat: status,message,commentId,gameId,authorName,userId,content (createdAt/edited 없음)
- PUT updateComment 부분: status,message,commentId,content
- list commentView: commentId,gameId,authorName,userId,content,createdAt (edited 없음)
- GameReviewController 이미 통일: POST/PUT/list/get 전부 reviewView 사용
- reviewView: reviewId,gameId,authorName,userId,rating,body,edited,createdAt,updatedAt
- 댓글 작성자명 = nickname 스냅샷 (createComment: session displayName 저장)
- GameCommentData 필드: id,gameId,userId,nickname,content,createdAt,deletedAt — **updatedAt 없음**
→ A3(댓글 수정됨 마커)는 game_comments.updated_at ALTER 필요 (무료 아님)
- createReview: rating 필수, body trimToEmpty 후 length 검사만 → 빈 본문 리뷰 허용 (B2)
- trimToNull/trimToEmpty = 외곽 trim 만; 내부 연속공백/제어문자 미처리 (B3)
- DDL game-reviews-ddl.sql: game_reviews(updated_at 보유), game_comments user_id nullable(레거시 보존)
- W2-3 평점 집계 스키마 동결 / QG-2 레거시 user_id NULL 댓글 미결
- **A2 작성자명 메커니즘 분기 (신규 발견)**:
- 리뷰 = `JOIN users u, u.display_name AS authorName` (GameReviewsMapper) → 항상 현재 닉네임(live)
+ `u.is_delete IS NOT TRUE` 조건 → **탈퇴자 리뷰는 목록서 제외됨**
- 댓글 = `nickname` 컬럼 스냅샷(작성 시점 고정, GameCommentsMapper listGameComments:36) → 탈퇴자 댓글도 표시
- 댓글 매퍼에 미사용 `updateGameComment`(nickname 갱신 가능) 존재 (:81) — A2/A3 설계 시 활용 여지
- QG-2 결합: 레거시 user_id NULL 댓글은 JOIN 불가 → live-join 채택 시 스냅샷 fallback 필수
# Decisions (누적)
- A3 댓글 수정됨 마커: **edited+updatedAt 추가** (리뷰 대칭). game_comments.updated_at 멱등 ALTER + 매퍼 갱신 필요. [2026-06-18]
- A1 응답 스키마: **commentView 단일 통일** — POST·PUT·list 전부 commentView 반환(commentId,gameId,authorName,userId,content,createdAt,edited,updatedAt). POST는 insert 후 재조회(createReview 패턴). 프론트 렌더 1함수. [2026-06-18]
# Invocations
[]
# user_signals
positive: []
negative: []
# verified_by_me
(없음 — 코드 변경 0줄, 인터뷰만 수행. L1/L2/log-scan 비대상)
# needs_user_verification
- 다음 세션: 인터뷰 재개(아래 어젠다) → 결정 충분 시 design-advisor → 구현 → L1 회귀 31건+신규 → L3 스모크.
# graph_refresh
skip: 코드 변경 0줄 (graph-refresh-checker 비대상)
# Next-session agenda (인터뷰 재개 지점)
## 결정 잠금 (재확인만)
- A1 = commentView 단일 통일 (POST·PUT·list 전부 commentView; POST insert 후 재조회)
- A3 = 댓글 edited+updatedAt 추가 (game_comments.updated_at 멱등 ALTER + 매퍼/모델 갱신)
- 종속: A1 통일 시 commentView 가 edited/updatedAt 자동 포함 → 두 결정 한 묶음 구현
- A2 = **하이브리드 작성자명**: `COALESCE(u.display_name, c.nickname)` + **LEFT JOIN users** (GameCommentsMapper getGameComment/listGameComments 둘 다)
- user_id 있으면 현재 닉네임(개명 반영, 리뷰와 일관) / NULL이면 스냅샷 nickname → **QG-2 자동 해결**
- createComment 의 nickname 스냅샷 저장은 **유지**(fallback 안전망). 미사용 updateGameComment(:81)는 edit 시 nickname 덮어쓰지 않도록 주의
- A2-탈퇴자 = **댓글 보존 / 리뷰 현행(제외) 유지**: 댓글 LEFT JOIN(is_delete 무필터) → 탈퇴자 댓글 표시. 리뷰 INNER JOIN+`u.is_delete IS NOT TRUE` 그대로 → 리뷰 스코프·31건 테스트 무영향
- A2-탈퇴자-이름 = **"(탈퇴한 사용자)" 마스킹**. 댓글 SQL: `CASE WHEN u.id IS NULL THEN c.nickname WHEN u.is_delete THEN '(탈퇴한 사용자)' ELSE u.display_name END AS authorName` (LEFT JOIN). 탈퇴 플래그 노출 불필요 — 이름만 대체
- B1 = **페이지네이션 도입 + 기본 정렬 현행 유지** (댓글 오래된순 ASC / 리뷰 최신순 DESC 기본). 전건 반환 폐기 → offset/limit 또는 cursor (방식·크기·정렬옵션은 후속 질문)
- B1-방식 = **offset/limit + "더보기" 버튼, 페이지당 20건**. page/offset 쿼리 파라미터
- B1-정렬 = **댓글·리뷰 모두 정렬 토글**:
- 댓글: 오래된순(기본)/최신순
- 리뷰: 최신순(기본)/별점 높은순/낮은순 — 별점순 정렬은 tiebreaker 필요(rating DESC/ASC, created_at DESC, id DESC)
- API sort 파라미터 신설 + 프론트 정렬 컨트롤. 매퍼 정렬절 동적화(단 `${}` 금지 — sort 화이트리스트 enum→고정 SQL 분기로 #{} 안전 유지)
- B3 = **제어문자 제거 + 외곽 trim** (댓글 content·리뷰 body 공통). 탭/개행 외 제어문자 strip, 내부 연속공백·줄바꿈은 보존. textContent 렌더라 보안 아닌 저장 위생 목적
- B2 = **리뷰 본문 필수 + 최소 10자** (별점만 제출 불가). createReview/updateReview body 검증 추가. 댓글은 별도(200자 상한만, 최소 없음 — 변경 안 함)
- 확장 리뷰 정책 4영역:
- ① 게임당 1회 제한 = **1회 유지(현행)** — ux_game_reviews_game_user_active 유지, 무결성 우선
- ② 수정 제한 = **무제한 수정 유지(현행)** — edited 마커로 투명성
- ③ 별점 척도 = **다축 평점 도입**. 세부:
- **overall rating 유지 + 축 별도** (W2-3 집계 동결 안 건드림 — 집계는 game_reviews.rating 그대로). 축은 game_review_axes 신규 테이블
- **UI 동작**: overall 별점 = 축 점수 평균으로 자동계산·표시되다가, 유저가 overall 을 직접 선택하면 그 값으로 강제 덮어쓰기 (수동 우선)
- 설계 미결: 자동평균→정수 rating 반올림 정책(C3 평균 별점 정책과 연동) / rating 수동·자동 출처 구분 필요(예 is_rating_manual 플래그?)
- **축 6개**: 몰입성, 창의성, 조작성, 완성도, 사운드, 비주얼
- 축 척도 = **1~5 정수, 전 축 필수** (overall 과 동일, 자동평균 결정적)
- 스키마 = **game_review_axes 테이블** (review_id FK + axis_key + score, 리뷰당 6행). axis_key enum 6종. DDL 신규 테이블 + FK + 인덱스(review_id)
- 다축 평점 = **풀스펙 확정**. 입력 부담(6축 필수 + 본문 10자)은 인지하고 진행
- ④ 신고·숨김 = **도입 보류(W1 선행)** — 운영자 role 활성 후 신고+숨김 함께. 현 세션 스코프 제외
## 미결 — 다음 세션 질문 순서 (1~2축씩, AskUserQuestion, 추천 맨앞+근거+트레이드오프)
3. **C UX (game-detail.jsp)** — JSP grounding 완료:
- C1 연타 방지 = **submit 잠금 + 로딩 상태** (제출 중 disabled, 응답 후 해제). 댓글 중복 POST 차단
- C2 시간 표시 = **상대시각 "n분 전" + title 속성에 절대시각** (fmtDate:1202 대체/확장)
- C3 평균 별점 = **⚠️ W2-3 동결 해제 + 집계 컬럼/뷰 신설** (사용자 informed 선택, 고위험 — 회귀 검증 필수). 표시 = **소수1자리 + 반올림 별 + 0개는 "아직 평가 없음" 문구**
- 모순 해소: client 계산은 B1 페이지네이션과 충돌 → 서버 집계로 전환 확정
- **다음 세션 설계 선결조건**: W2-3 freeze 근거(ADR/문서) 먼저 읽고 무엇이 왜 동결됐는지 파악 후 해제. §6 파괴적 게이트 인접 — orchestrator 사용자 확인 경로
- 리뷰별 overall 자동평균(축→overall)은 rating 정수라 정수 반올림 / 전체 평균 표시는 소수1자리 (반올림 컨텍스트 2종 구분)
- 집계 방식 = **뷰(VIEW) 기반** — game_review_stats 뷰(game_id, avg_rating, review_count + 6축 평균). 저장 컬럼 비신설(파생 객체만) → 동결 해제 중 회귀 표면 최소
- 축별 평균 = **overall + 6축 평균 모두 요약 표시**. UI에 축별 막대/점수. 뷰가 축별 AVG(game_review_axes JOIN) 포함
- C4 글자수 카운터 = **실시간 카운터** (123/200 형식, 리뷰는 최소 10자 미달 안내). 댓글·리뷰 textarea 둘 다 + (다축 도입 시 6축 위젯 입력 상태도 고려)
- C5 삭제 표시 = **완전 소멸 유지(현행)** — soft-delete + is_delete 필터. 댓글 스레드 없어 tombstone 불필요
- C6 접근성 = **완전 radiogroup 패턴** — roving tabindex(위젯당 단일 탭정지) + ←→↑↓·Home/End 별점 이동, aria 유지. 다축 6위젯 동일 적용. 현 위젯은 화살표 핸들러 없음(grep 확인) — 신규 추가
- grounding 근거: 별점위젯:1043(role=radiogroup/radio+aria 기구현), 평균:1034, 댓글ta:1080(max200), 리뷰ta:1052(max1000), 제출버튼:538(disabled 없음)
## 인터뷰 종료 후 (task 명시)
1. 누적 결정 최종 확인 → 2. design-advisor: 파일 소유권 맵 + 멱등 ALTER DDL → 3. 구현 → L1 31건 회귀+신규 → L3 스모크 → 4. docs/changes/ 이력 기록
# open_items
- `.atp/work-session/20260618-145152/report.md` (신규, untracked) — 세션 기록. 커밋은 사용자 지시 시 (CLAUDE.md 커밋 정책: 명시 요청 시만).
- src/ 변경 0건 — 구현 미시작.

View File

@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>W3-2 UI 프리뷰 — 육각형 레이더 · 6축 평점 · 카드</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5; --card-bg: #fff; --text: #1a1a1a; --text-muted: #5c5c5c;
--accent: #e8a54b; --accent-soft: rgba(232,165,75,0.14);
--border: rgba(0,0,0,0.08); --panel-shadow: 0 4px 24px rgba(26,26,26,0.06); --radius: 16px;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212; --card-bg: #1e1e1e; --text: #ece8e1; --text-muted: #a39e96;
--border: rgba(255,255,255,0.1); --accent-soft: rgba(232,165,75,0.12);
--panel-shadow: 0 8px 32px rgba(0,0,0,0.35);
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; padding: 2rem 1rem 4rem;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
color: var(--text);
}
html:not([data-theme="dark"]) body { background: linear-gradient(165deg,#fff9f0 0%,var(--surface) 45%,#f3ede6 100%); background-attachment: fixed; }
html[data-theme="dark"] body { background: linear-gradient(165deg,#1c1a18 0%,var(--surface) 38%,#0e0e0e 100%); background-attachment: fixed; }
.wrap { max-width: 56rem; margin: 0 auto; }
.preview-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
.preview-bar h1 { font-size: 1.1rem; margin: 0; letter-spacing: -0.02em; }
.preview-bar button { padding: 0.45rem 1rem; font: inherit; font-weight: 700; font-size: 0.8rem; cursor: pointer;
color: var(--text); background: var(--card-bg); border: 1px solid var(--border); border-radius: 999px; }
.game-panel { padding: 1.4rem 1.5rem 1.6rem; margin-bottom: 1.5rem; background: var(--card-bg);
border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--panel-shadow); }
.game-panel__title { margin: 0 0 0.2rem; font-size: 1.05rem; letter-spacing: -0.02em; }
.game-panel__subtitle { margin: 0 0 1.1rem; font-size: 0.8rem; color: var(--text-muted); }
.section-tag { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--accent); margin: 0 0 0.6rem; }
/* ===== 아래는 game-detail.jsp 승격본과 동일한 W3-2 컴포넌트 스타일 ===== */
.game-reviews__summary { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
.game-reviews__avg { font-size: 1.35rem; font-weight: 800; letter-spacing: -0.02em; color: var(--accent); }
.game-reviews__count { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); }
.game-stars--avg, .game-stars--display { display: inline-flex; gap: 0.05rem; font-size: 0.95rem; line-height: 1; }
.game-stars--avg .game-star, .game-stars--display .game-star { color: var(--border); }
.game-stars--avg .game-star.is-on, .game-stars--display .game-star.is-on { color: var(--accent); }
.game-reviews__summary-panel { margin: 0 0 1.25rem; display: flex; align-items: center; gap: 1.25rem;
padding: 1rem 1.25rem;
background: radial-gradient(125% 125% at 0% 0%, var(--accent-soft), transparent 62%), var(--card-bg);
border: 1px solid rgba(232,165,75,0.22); border-radius: var(--radius); box-shadow: var(--panel-shadow); }
@media (max-width: 480px) { .game-reviews__summary-panel { flex-direction: column; align-items: stretch; gap: 0.85rem; } .game-reviews__radar { justify-content: center; } }
.game-reviews__radar { display: inline-flex; flex-shrink: 0; }
.game-reviews__radar svg { display: block; }
.game-axis-radar { overflow: visible; }
.game-reviews__radar .game-axis-grid { fill: none; stroke: var(--border); stroke-width: 1; }
.game-reviews__radar .game-axis-grid--outer { stroke: rgba(232,165,75,0.4); stroke-width: 1.25; }
.game-reviews__radar .game-axis-line { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 3; }
.game-reviews__radar .game-axis-poly { stroke: var(--accent); stroke-width: 1.75; stroke-linejoin: round;
filter: drop-shadow(0 2px 7px rgba(232,165,75,0.38)); transform-box: fill-box; transform-origin: center;
animation: game-radar-pop 0.55s cubic-bezier(0.22,1,0.36,1) both; }
.game-reviews__radar .game-axis-dot { fill: var(--accent); stroke: var(--card-bg); stroke-width: 1.5;
animation: game-radar-fade 0.7s ease both; }
@keyframes game-radar-pop { from { opacity: 0; transform: scale(0.55); } to { opacity: 1; transform: scale(1); } }
@keyframes game-radar-fade { from { opacity: 0; } to { opacity: 1; } }
.game-reviews__radar-empty { font-size: 0.75rem; color: var(--text-muted); }
.game-reviews__axis-legend { margin: 0; flex: 1; min-width: 11.5rem; display: grid; gap: 0.4rem 0; font-size: 0.75rem; color: var(--text-muted); }
.game-reviews__axis-legend-row { display: grid; grid-template-columns: 3.4rem 1fr 1.15rem; align-items: center; gap: 0.55rem; }
.game-reviews__axis-legend dt { font-weight: 600; color: var(--text); }
.game-reviews__axis-legend dd { margin: 0; font-variant-numeric: tabular-nums; font-weight: 700; text-align: right; color: var(--accent); }
.game-reviews__axis-meter { height: 0.4rem; border-radius: 999px; background: var(--border); overflow: hidden; }
.game-reviews__axis-meter span { display: block; height: 100%; border-radius: 999px;
background: linear-gradient(90deg,#f0c978,var(--accent)); animation: game-meter-grow 0.7s cubic-bezier(0.22,1,0.36,1) both; transform-origin: left; }
@keyframes game-meter-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
/* 6축 입력 */
.game-reviews__composer { margin-bottom: 1.25rem; padding: 1.1rem 1.15rem 1.2rem;
background: radial-gradient(140% 120% at 100% 0%, rgba(232,165,75,0.16), transparent 60%), var(--accent-soft);
border: 1px solid rgba(232,165,75,0.22); border-radius: 14px; }
.game-reviews__axes { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0.5rem 1.1rem; margin-bottom: 0.85rem; }
@media (max-width: 480px) { .game-reviews__axes { grid-template-columns: 1fr; } }
.game-reviews__axis { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
padding: 0.3rem 0.5rem; border-radius: 10px; transition: background 0.15s ease; }
.game-reviews__axis:hover { background: rgba(232,165,75,0.08); }
.game-reviews__axis-label { font-size: 0.8125rem; font-weight: 600; color: var(--text); }
.game-stars { display: inline-flex; gap: 0.15rem; line-height: 1; }
.game-stars--axis { gap: 0.1rem; }
.game-stars__btn { padding: 0.1rem; font-size: 1.6rem; line-height: 1; color: var(--border);
background: none; border: none; cursor: pointer; transition: color 0.12s ease, transform 0.12s ease, text-shadow 0.2s ease; }
.game-stars--axis .game-stars__btn { font-size: 1.25rem; }
.game-stars__btn:hover { transform: scale(1.12); }
.game-stars__btn.is-on { color: var(--accent); text-shadow: 0 1px 4px rgba(232,165,75,0.45); }
.game-stars__btn:active { transform: scale(0.88); }
.game-stars__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.game-reviews__rating-row { display: flex; align-items: center; gap: 0.85rem; margin-bottom: 0.85rem; }
.game-comments__label { font-size: 0.8125rem; font-weight: 600; color: var(--text-muted); }
.game-comments__form textarea { width: 100%; min-height: 5rem; padding: 0.85rem 1rem; font: inherit; font-size: 0.875rem;
line-height: 1.55; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; resize: vertical; }
.game-comments__form textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px rgba(232,165,75,0.18); }
.game-comments__actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem; margin-top: 0.65rem; }
.game-comments__hint { font-size: 0.6875rem; color: var(--text-muted); }
.game-comments__hint.is-warn { color: #b33; font-weight: 700; }
.game-comments__form button[type="submit"] { padding: 0.55rem 1.5rem; font: inherit; font-size: 0.875rem; font-weight: 700; color: #1a1a1a;
background: linear-gradient(180deg,#f0c978 0%,var(--accent) 100%); border: none; border-radius: 10px; cursor: pointer;
box-shadow: 0 2px 8px rgba(232,165,75,0.35); transition: transform 0.15s ease, box-shadow 0.2s ease; }
.game-comments__form button[type="submit"]:hover { box-shadow: 0 4px 14px rgba(232,165,75,0.45); }
button[aria-busy="true"] { opacity: 0.65; cursor: progress; }
/* sort 토글 / 더보기 */
.game-comments__toolbar { display: flex; justify-content: flex-end; margin-bottom: 0.85rem; }
.game-comments__sort { display: inline-flex; gap: 0.25rem; padding: 0.2rem; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; }
.game-comments__sort-btn { padding: 0.3rem 0.7rem; font: inherit; font-size: 0.6875rem; font-weight: 700; color: var(--text-muted);
background: transparent; border: none; border-radius: 999px; cursor: pointer; transition: color 0.15s ease, background 0.15s ease; }
.game-comments__sort-btn.is-active { color: #1a1a1a; background: linear-gradient(180deg,#f0c978,var(--accent)); box-shadow: 0 1px 5px rgba(232,165,75,0.4); }
.game-comments__more { margin-top: 1rem; display: flex; justify-content: center; }
.game-comments__more-btn { padding: 0.5rem 1.4rem; font: inherit; font-size: 0.8125rem; font-weight: 700; color: var(--text);
background: var(--card-bg); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease, transform 0.15s ease, box-shadow 0.2s ease; }
.game-comments__more-btn:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); box-shadow: 0 5px 16px rgba(232,165,75,0.16); }
/* 리뷰/덧글 카드 */
.game-reviews__list, .game-comments__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.85rem; }
.game-reviews__item { padding: 0.95rem 1.1rem; font-size: 0.875rem; line-height: 1.55; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; }
.game-reviews__item.is-mine { border-color: rgba(232,165,75,0.4); background: var(--accent-soft); }
.game-reviews__item-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.65rem; margin-bottom: 0.5rem; }
.game-reviews__nick { font-size: 0.8125rem; font-weight: 700; letter-spacing: -0.02em; }
.game-reviews__mine-badge { padding: 0.05rem 0.45rem; font-size: 0.625rem; font-weight: 700; color: var(--accent); background: rgba(232,165,75,0.16); border-radius: 999px; }
.game-reviews__time, .game-comments__meta time { margin-left: auto; font-size: 0.6875rem; font-weight: 500; letter-spacing: 0.02em; color: var(--text-muted); }
.game-comments__edited, .game-reviews__edited { margin-left: 0.35rem; font-size: 0.6875rem; font-weight: 500; color: var(--text-muted); }
.game-reviews__body, .game-comments__item p { margin: 0; white-space: pre-wrap; word-break: break-word; }
.game-reviews__card-radar { margin-top: 0.6rem; display: inline-flex; }
.game-reviews__card-radar svg { display: block; }
.game-reviews__radar-empty { font-size: 0.75rem; color: var(--text-muted); }
.game-comments__item { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.85rem 1rem; font-size: 0.875rem; line-height: 1.55; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; }
.game-comments__avatar { flex-shrink: 0; width: 2.25rem; height: 2.25rem; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 800; color: var(--accent); background: var(--accent-soft); border: 1px solid rgba(232,165,75,0.25); }
.game-comments__meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 0.65rem; margin-bottom: 0.4rem; }
.game-comments__nick { font-size: 0.75rem; font-weight: 700; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
@media (prefers-reduced-motion: reduce) {
.game-reviews__radar .game-axis-poly, .game-reviews__radar .game-axis-dot, .game-reviews__axis-meter span, .game-comments__more-btn:hover { animation: none; transform: none; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="preview-bar">
<h1>W3-2 UI 프리뷰 · 육각형 레이더 · 6축 평점</h1>
<button id="theme-toggle" type="button">🌙 다크</button>
</div>
<!-- 리뷰 패널 -->
<section class="game-panel">
<p class="section-tag">리뷰 요약 (game_review_stats)</p>
<div class="game-reviews__summary">
<span class="game-reviews__avg" id="avg">4.2</span>
<div class="game-stars game-stars--avg" id="avg-stars" aria-hidden="true"></div>
<span class="game-reviews__count">(37)</span>
</div>
<div class="game-reviews__summary-panel" id="summary-panel"></div>
<p class="section-tag">6축 평가 입력 (radiogroup · 키보드 ←→↑↓/Home/End)</p>
<div class="game-reviews__composer">
<div class="game-reviews__axes" id="axes" aria-label="6축 평가"></div>
<div class="game-reviews__rating-row">
<span class="game-comments__label" id="overall-label">종합 별점 (선택)</span>
<div class="game-stars game-stars--input" id="overall" role="radiogroup" aria-labelledby="overall-label"></div>
</div>
<label class="game-comments__label" for="rbody">평가</label>
<textarea id="rbody" rows="3" maxlength="1000" placeholder="이 게임을 어떻게 즐기셨나요? (최소 10자)"></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint" id="rhint"><span id="rcount">0/1000</span> · 6축은 필수, 종합 별점은 선택이에요</span>
<button type="submit">리뷰 등록</button>
</div>
</div>
<p class="section-tag">리뷰 목록 · 정렬 · 카드 컴팩트 레이더</p>
<div class="game-comments__toolbar">
<div class="game-comments__sort" role="group" aria-label="리뷰 정렬">
<button type="button" class="game-comments__sort-btn is-active">최신순</button>
<button type="button" class="game-comments__sort-btn">별점 높은순</button>
<button type="button" class="game-comments__sort-btn">별점 낮은순</button>
</div>
</div>
<ul class="game-reviews__list" id="review-list"></ul>
<div class="game-comments__more"><button type="button" class="game-comments__more-btn">더보기</button></div>
</section>
<!-- 덧글 패널 -->
<section class="game-panel">
<p class="section-tag">덧글 · 상대시각 · 글자수 카운터</p>
<div class="game-reviews__composer" style="background: var(--surface); border-color: var(--border);">
<label class="game-comments__label" for="cbody">내용</label>
<textarea id="cbody" rows="2" maxlength="200" placeholder="여기에 덧글을 작성해 주세요."></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint"><span id="ccount">0/200</span></span>
<button type="submit">덧글 등록</button>
</div>
</div>
<ul class="game-comments__list" id="comment-list"></ul>
</section>
</div>
<script>
// game-detail.jsp 승격본과 동일 로직 (좌표공식·미터바)
var AXIS_KEYS = ['immersion','creativity','controls','completeness','sound','visual'];
var AXIS_LABELS_KO = { immersion:'몰입성', creativity:'창의성', controls:'조작성', completeness:'완성도', sound:'사운드', visual:'비주얼' };
var SVG_NS = 'http://www.w3.org/2000/svg';
var labelSeq = 0, hexSeq = 0;
function buildHexRadar(scores, cx, cy, R) {
var canvas = cx * 2;
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + canvas + ' ' + canvas);
svg.setAttribute('width', String(Math.round(canvas * 0.85)));
svg.setAttribute('height', String(Math.round(canvas * 0.85)));
svg.setAttribute('role', 'img');
svg.setAttribute('class', 'game-axis-radar');
var nums = AXIS_KEYS.map(function (k, i) { var v = scores ? Number(scores[i]) : 0; return (isFinite(v) && v > 0) ? v : 0; });
var hasData = nums.some(function (v) { return v > 0; });
function ptOnAxis(i, ratio) { var ang = (-90 + 60 * i) * Math.PI / 180; return { x: cx + R * ratio * Math.cos(ang), y: cy + R * ratio * Math.sin(ang) }; }
function polyPoints(fn) { return AXIS_KEYS.map(function (k, i) { var p = ptOnAxis(i, fn(i)); return p.x.toFixed(2) + ',' + p.y.toFixed(2); }).join(' '); }
var gid = 'hexfill-' + (++hexSeq);
var defs = document.createElementNS(SVG_NS, 'defs');
var grad = document.createElementNS(SVG_NS, 'radialGradient');
grad.setAttribute('id', gid); grad.setAttribute('cx', '50%'); grad.setAttribute('cy', '50%'); grad.setAttribute('r', '62%');
[['0%','rgba(240,201,120,0.55)'],['100%','rgba(232,165,75,0.26)']].forEach(function (s) {
var st = document.createElementNS(SVG_NS,'stop'); st.setAttribute('offset', s[0]); st.setAttribute('stop-color', s[1]); grad.appendChild(st);
});
defs.appendChild(grad); svg.appendChild(defs);
for (var level = 1; level <= 4; level++) {
var grid = document.createElementNS(SVG_NS, 'polygon');
grid.setAttribute('class', 'game-axis-grid' + (level === 4 ? ' game-axis-grid--outer' : ''));
(function (lv) { grid.setAttribute('points', polyPoints(function () { return lv / 4; })); })(level);
svg.appendChild(grid);
}
AXIS_KEYS.forEach(function (k, i) {
var line = document.createElementNS(SVG_NS, 'line'); line.setAttribute('class', 'game-axis-line');
var p = ptOnAxis(i, 1); line.setAttribute('x1', String(cx)); line.setAttribute('y1', String(cy));
line.setAttribute('x2', p.x.toFixed(2)); line.setAttribute('y2', p.y.toFixed(2)); svg.appendChild(line);
});
if (hasData) {
var poly = document.createElementNS(SVG_NS, 'polygon'); poly.setAttribute('class', 'game-axis-poly');
poly.setAttribute('points', polyPoints(function (i) { return nums[i] / 5; })); poly.setAttribute('fill', 'url(#' + gid + ')'); svg.appendChild(poly);
AXIS_KEYS.forEach(function (k, i) {
if (nums[i] <= 0) return; var p = ptOnAxis(i, nums[i] / 5);
var dot = document.createElementNS(SVG_NS, 'circle'); dot.setAttribute('class', 'game-axis-dot');
dot.setAttribute('cx', p.x.toFixed(2)); dot.setAttribute('cy', p.y.toFixed(2)); dot.setAttribute('r', Math.max(1.6, R * 0.05).toFixed(2)); svg.appendChild(dot);
});
}
svg.setAttribute('aria-label', hasData ? ('6축 평가: ' + AXIS_KEYS.map(function (k, i) { return AXIS_LABELS_KO[k] + ' ' + nums[i]; }).join(', ')) : '6축 평가 없음');
return { svg: svg, nums: nums, hasData: hasData };
}
function buildRadarLegend(nums, hasData) {
var dl = document.createElement('dl'); dl.className = 'game-reviews__axis-legend';
AXIS_KEYS.forEach(function (k, i) {
var row = document.createElement('div'); row.className = 'game-reviews__axis-legend-row';
var dt = document.createElement('dt'); dt.textContent = AXIS_LABELS_KO[k];
var meter = document.createElement('div'); meter.className = 'game-reviews__axis-meter';
var bar = document.createElement('span'); bar.style.width = (hasData ? (nums[i] / 5 * 100) : 0) + '%'; meter.appendChild(bar);
var dd = document.createElement('dd'); dd.textContent = hasData ? String(nums[i]) : '';
row.appendChild(dt); row.appendChild(meter); row.appendChild(dd); dl.appendChild(row);
});
return dl;
}
var axisScores = {};
function buildStarRadioGroup(axisKey, labelText) {
var labelId = 'star-' + axisKey + '-' + (++labelSeq);
var group = document.createElement('div'); group.className = 'game-stars game-stars--input game-stars--axis';
group.setAttribute('role', 'radiogroup'); group.setAttribute('aria-labelledby', labelId);
var radios = [];
function paint() { var sel = axisScores[axisKey] || 0; radios.forEach(function (b, idx) { var v = idx + 1; b.classList.toggle('is-on', v <= sel); b.setAttribute('aria-checked', v === sel ? 'true' : 'false'); b.tabIndex = (v === sel) ? 0 : -1; }); if (!sel) radios[0].tabIndex = 0; }
function select(v) { axisScores[axisKey] = v; paint(); }
for (var i = 1; i <= 5; i++) { (function (v) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'game-stars__btn'; b.setAttribute('role','radio'); b.setAttribute('aria-checked','false'); b.setAttribute('aria-label', labelText + ' ' + v + '점'); b.tabIndex = -1; b.textContent = '★';
b.addEventListener('click', function () { select(v); b.focus(); });
b.addEventListener('keydown', function (ev) { var cur = axisScores[axisKey] || 0; var next = null;
switch (ev.key) { case 'ArrowRight': case 'ArrowUp': next = Math.min(5,(cur||v)+1); break; case 'ArrowLeft': case 'ArrowDown': next = Math.max(1,(cur||v)-1); break; case 'Home': next = 1; break; case 'End': next = 5; break; case ' ': case 'Enter': next = v; break; default: return; }
ev.preventDefault(); select(next); radios[next-1].focus(); });
radios.push(b); group.appendChild(b);
})(i); }
paint(); return { group: group, labelId: labelId };
}
function fillStars(c, rating) { c.innerHTML = ''; for (var i = 1; i <= 5; i++) { var s = document.createElement('span'); s.className = 'game-star' + (i <= rating ? ' is-on' : ''); s.textContent = '★'; c.appendChild(s); } }
function buildStarDisplay(rating) { var w = document.createElement('div'); w.className = 'game-stars game-stars--display'; w.setAttribute('aria-hidden','true'); fillStars(w, rating); return w; }
// 요약 (큰 레이더 + 미터바 범례)
fillStars(document.getElementById('avg-stars'), 4);
(function () {
var panel = document.getElementById('summary-panel');
var radar = buildHexRadar([4.3, 4.6, 3.8, 4.4, 3.5, 4.7], 100, 100, 80);
panel.appendChild(radar.svg); panel.appendChild(buildRadarLegend(radar.nums, true));
})();
// 6축 입력 위젯
(function () {
var axesEl = document.getElementById('axes');
AXIS_KEYS.forEach(function (k) {
var row = document.createElement('div'); row.className = 'game-reviews__axis';
var label = document.createElement('span'); label.className = 'game-reviews__axis-label'; label.textContent = AXIS_LABELS_KO[k];
var w = buildStarRadioGroup(k, AXIS_LABELS_KO[k]); label.id = w.labelId;
row.appendChild(label); row.appendChild(w.group); axesEl.appendChild(row);
});
var ov = buildStarRadioGroup('overall', '종합 별점'); var oEl = document.getElementById('overall');
while (ov.group.firstChild) oEl.appendChild(ov.group.firstChild);
})();
// 글자수 카운터 + 최소10자 경고
function counter(ta, out, max, min) {
function upd() { var len = ta.value.length; out.textContent = len + '/' + max; if (min) { var w = len > 0 && len < min; out.parentElement.classList.toggle('is-warn', w); } }
ta.addEventListener('input', upd); upd();
}
counter(document.getElementById('rbody'), document.getElementById('rcount'), 1000, 10);
counter(document.getElementById('cbody'), document.getElementById('ccount'), 200, 0);
// 리뷰 카드 (axes 있음/없음 두 케이스)
function reviewCard(r) {
var li = document.createElement('li'); li.className = 'game-reviews__item' + (r.mine ? ' is-mine' : '');
var head = document.createElement('div'); head.className = 'game-reviews__item-head';
head.appendChild(buildStarDisplay(r.rating));
var nick = document.createElement('span'); nick.className = 'game-reviews__nick'; nick.textContent = r.nick; head.appendChild(nick);
if (r.mine) { var mb = document.createElement('span'); mb.className = 'game-reviews__mine-badge'; mb.textContent = '내 리뷰'; head.appendChild(mb); }
var t = document.createElement('time'); t.className = 'game-reviews__time'; t.textContent = r.time; head.appendChild(t);
if (r.edited) { var ed = document.createElement('span'); ed.className = 'game-reviews__edited'; ed.textContent = '(수정됨)'; head.appendChild(ed); }
li.appendChild(head);
var p = document.createElement('p'); p.className = 'game-reviews__body'; p.textContent = r.body; li.appendChild(p);
var cardScores = AXIS_KEYS.map(function (k) { return r.axes ? r.axes[k] : 0; });
var cr = buildHexRadar(cardScores, 60, 60, 44);
var wrap = document.createElement('div'); wrap.className = 'game-reviews__radar game-reviews__card-radar';
if (cr.hasData) { wrap.appendChild(cr.svg); var lg = buildRadarLegend(cr.nums, true); lg.className += ' sr-only'; wrap.appendChild(lg); }
else { var none = document.createElement('span'); none.className = 'game-reviews__radar-empty'; none.textContent = '6축 평가 데이터 없음'; wrap.appendChild(none); }
li.appendChild(wrap);
return li;
}
var rl = document.getElementById('review-list');
rl.appendChild(reviewCard({ rating: 5, nick: '도토리', time: '12분 전', edited: false, mine: true, body: '몰입감이 정말 뛰어나요. 사운드가 조금 아쉽지만 전반적으로 완성도 높은 게임입니다.', axes: { immersion:5, creativity:5, controls:4, completeness:5, sound:3, visual:5 } }));
rl.appendChild(reviewCard({ rating: 3, nick: '바삭이', time: '3일 전', edited: true, mine: false, body: '아이디어는 좋은데 조작이 좀 불편했어요. 다음 빌드 기대합니다.', axes: { immersion:3, creativity:4, controls:2, completeness:3, sound:3, visual:4 } }));
rl.appendChild(reviewCard({ rating: 4, nick: '레거시유저', time: '2026. 5. 1.', edited: false, mine: false, body: '(구버전 리뷰 — 6축 데이터 없음 케이스)', axes: null }));
// 덧글 카드
function commentCard(c) {
var li = document.createElement('li'); li.className = 'game-comments__item';
var av = document.createElement('div'); av.className = 'game-comments__avatar'; av.textContent = c.nick.charAt(0); li.appendChild(av);
var body = document.createElement('div'); body.style.flex = '1';
var meta = document.createElement('div'); meta.className = 'game-comments__meta';
var nick = document.createElement('span'); nick.className = 'game-comments__nick'; nick.textContent = c.nick; meta.appendChild(nick);
var t = document.createElement('time'); t.textContent = c.time; meta.appendChild(t);
if (c.edited) { var ed = document.createElement('span'); ed.className = 'game-comments__edited'; ed.textContent = '(수정됨)'; meta.appendChild(ed); }
body.appendChild(meta);
var p = document.createElement('p'); p.textContent = c.body; body.appendChild(p); li.appendChild(body);
return li;
}
var cl = document.getElementById('comment-list');
cl.appendChild(commentCard({ nick: '플레이어A', time: '방금 전', edited: false, body: '재밌게 했습니다! 2스테이지 보스가 인상적이네요.' }));
cl.appendChild(commentCard({ nick: '(탈퇴한 사용자)', time: '5일 전', edited: false, body: '탈퇴자 댓글 마스킹 케이스 — 이름이 "(탈퇴한 사용자)" 로 표시됩니다.' }));
// 테마 토글
document.getElementById('theme-toggle').addEventListener('click', function () {
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
document.documentElement.setAttribute('data-theme', dark ? 'light' : 'dark');
this.textContent = dark ? '🌙 다크' : '☀️ 라이트';
});
</script>
</body>
</html>

View File

@ -0,0 +1,41 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: 2026-06-22T10:00:00+09:00
concerns:
- "C3 재분류(W2-3 동결 경계 재확인)는 되돌리기 어려운 아키텍처 경계 결정에 해당할 수 있으나, 실체는 기존 오분류를 roadmap 근거(:63/:201/:202)로 정정한 것이므로 현재는 changes/ 내 별도 섹션으로 기록. W2-3 관련 설계 분기가 재발하면 adr/ADR-0001 발행 권고."
- "design-advisor 가 이미 .atp/work-session/20260622-092800/implementation/design.md 에 설계 산출물을 남김. architecture/ 중복 갱신 없음 — changes/ + work-session/ 중심으로 처리."
- "frontend-design 스킬이 game-detail.jsp 시각 컴포넌트를 처리했으므로 JSP 프론트엔드 세부 구현은 changes/ 문서에서 요약 수준으로만 기록."
- "retrospective 교훈 4건은 development/verification-strategies.md 에 '설계·테스트 단계 체크리스트' 섹션 및 '프로토콜 개선 권고' 섹션으로 추가 완료 (2026-06-22 retrospective-advisor 수용 결정 반영)."
- "backport self-grep: verification-strategies.md 근거 블록(> 근거:)에 20260622·GameController.gameDetail 잔류 — 요구사항 '이번 세션 사례는 1줄 근거로' 허용 패턴이므로 정본 규칙형 텍스트에는 포함되지 않음. docs/development/release-checklist.md §7 부재 — 해당 절차 미적용(N/A에 준함)."
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md` | changes | 신규 | `docs/changes/index.md` | `docs/changes/2026-06-18-w3-2-comments-reviews.md` (이월 항목 해소 링크) |
| `docs/changes/index.md` | changes | 갱신 | — | — |
| `docs/changes/2026-06-18-w3-2-comments-reviews.md` | changes | 갱신 (이월 항목 해소 표기) | — | `docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md` |
| `docs/development/verification-strategies.md` | development | 갱신 (섹션 추가) | index 추가 불필요(기존 등록) | report.md#Retrospective memory_candidates docs_sync_target |
## 의사결정 기록 위치
- **C3 재분류**: `docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md` §C3 재분류 섹션 + `.atp/work-session/20260622-092800/implementation/design.md` §C3 재분류
- **is_rating_manual 컬럼 채택**: `design.md` concerns 2번 + `report.md#Decisions`
- **axis_key 저장형·순서 고정**: `design.md` concerns 5번 + §2.1
- **sort 안전 (@SelectProvider 컴파일타임 상수)**: `design.md` §4 + `report.md#Decisions`
- **pagination = limit+1 hasMore**: `design.md` §3.3 + `report.md#Decisions`
- **SVG 육각형 = JS 런타임**: `design.md` §6.1 concerns 4번
## 추후 문서화가 필요한 항목
- **ADR-0001 (권고)**: W2-3 동결 경계 확정(잼 평가 한정, 댓글/리뷰 무관) — C3 재분류 정정의 배경이 된 경계 결정을 정식 ADR로 남길 가치 있음.
- **DDL 적용 완료 시**: `docs/maintenance/` 에 dev/운영 DB 적용 절차(game_review_axes·game_review_stats·game_comments.updated_at·game_reviews.is_rating_manual) 기록 검토.
- **L3 스모크 완료 후**: `docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md` 검증 결과 표에서 "needs_user_verification" → "PASS (날짜)" 갱신.
- **기존 리뷰 axes 백필**: 백필 필요 판단 시 `docs/maintenance/` 에 절차 문서화.
- **GET /reviews/mine (applyReviewGate 정밀화)**: 차기 세션 구현 완료 후 changes/ 업데이트.

View File

@ -0,0 +1,536 @@
---
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=<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 방지):
```java
@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. 단계 그리드 = 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)
```
- `<polygon points="ptX_0,ptY_0 ptX_1,ptY_1 ... ptX_5,ptY_5" />` (fill 반투명 + stroke).
### 6.5 렌더 함수 시그니처 (inflate 방지 — 인자 사용목적 명시)
```js
// 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은 별도 또는 자동표시).
- 함수 시그니처(인자 사용목적):
```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' <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("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 렌더. `<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`.

View File

@ -0,0 +1,68 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-22T10:10:00+09:00
fix_loop: L1
concerns: []
concerns_checked: true
planned_workers: 1
actual_workers: 0
workers_spawned: 0
---
# FIX LOOP L1 — GameReviewControllerTest RED 8건 해소
## 근본 원인 (확정)
**가설 1 (기존/신규 테스트 계약 미갱신) 단독.** 컨트롤러 버그(가설 2) 아님.
- 실패 8건 전부 POST body 가 BODY_MIN(10자) 미만 → 컨트롤러가 design.md AC-9
"리뷰 본문 trim 후 9자 거부(400), 10자 통과" 에 따라 **정당하게 400** 반환.
- 컨트롤러 axes 바인딩은 무관: 테스트가 `controller.createReview(...)` 를 6개 위치 인자로
직접 호출(`createReview`/`updateReview` 래퍼, test:409-421) → MockMvc 폼바인딩 경로 미사용.
`@RequestParam` 평면 파라미터(immersion=…) 구성과도 무관. 검증 순서도 design §5.3 정합 확인
(CSRF→auth→game존재→axes파싱→body검증→overall계산→conflict→insert→axes insert).
- conflict 테스트(409 기대)도 동일 원인: 1st POST 의 body 가 3자("또작성") 라 body 검증에서
400 으로 죽어 conflict(169행) 까지 도달 못 함.
### 실패 8건 본문 길이 (수정 전)
| 테스트 | 본문(전) | 길이 | 수정 후 본문 | 길이 |
|---|---|---|---|---|
| createReviewReturnsEditedFalse:72 | 재미있음 | 4 | 정말 재미있는 게임입니다 | 13 |
| updateReviewReturnsEditedTrue:90 | 더 좋아짐 | 5 | 수정 후 훨씬 더 좋아졌습니다 | 16 |
| createSecondReviewReturnsConflict:109 | 또작성 | 3 | 같은 게임에 또 작성 시도합니다 | 17 |
| assertCreateRatingAccepted(1/5 경계) | 내용입니다열자 | 7 | 별점 경계값 통과 검증 본문입니다 | 18 |
| createReviewAutoAveragesOverall…:258 | 자동평균테스트 | 7 | 별점 자동평균 계산 검증 본문 | 16 |
| createReviewUsesManualOverall…:281 | 수동평점테스트 | 7 | 수동 별점 사용 검증 본문입니다 | 17 |
| createReviewPersistsSixAxes:306 | 여섯축저장테스트 | 8 | 여섯 축 저장 검증용 본문입니다 | 17 |
## 수정 내역 (테스트만, 컨트롤러/계약 불변)
파일: `src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java`
7개 지점 본문 문자열 교체(헬퍼 `assertCreateRatingAccepted` 1곳이 rating 1/5 두 boundary 케이스 커버 → 실패 8건):
- 72행, 90행, 109행, 258행, 281행, 306행 직접 교체
- `assertCreateRatingAccepted` 본문(402행) 교체 → createReviewAcceptsRatingOneBoundary / FiveBoundary 동시 해소
추가(우연통과 배제, §2.6 다항목 재검):
- `assertCreateRatingRejected` 본문(382행) "내용입니다열자"(7자)→"별점 범위 검증용 본문입니다"(15자) 로 상향.
기존 7자였다면 rating 0/6 거부를 검증하려 해도 body 검증(400)에서 먼저 걸려 **우연히** 400 통과할
위험이 있었음. body 를 10자 이상으로 올려 rating 검증 분기까지 실제 도달함을 보장.
**행위 검증 전부 보존**: edited 마커(false/true), 게임당1회 409(never addGameReview),
rating 1~5 경계, overall 자동평균(rating=4·ratingManual=false)/수동(rating=2·manual=true),
addReviewAxes 6행 — 모든 assertion 원형 유지. 검증을 무력화하거나 계약을 약화하지 않음.
## 공유 문자열 일괄치환 회피 (정밀성)
"내용입니다열자"(7자) 는 통과 중인 CSRF/forbidden/missing-axis 테스트(190/204/235행)에도 쓰임.
그 테스트들은 본문 검증 **이전 단계**(CSRF 403 / 비작성자 FORBIDDEN / axis 누락 400)에서 분기되어
짧은 본문이어도 정상 통과 → 의도된 단축 본문. 따라서 일괄 치환 금지, 성공경로 헬퍼 2곳만 정밀 교체.
## 실행 결과
- `./mvnw test -Dtest=GameReviewControllerTest`**Tests run: 20, Failures: 0** (GREEN)
- `./mvnw test` (전체) → **Tests run: 43, Failures: 0, Errors: 0, Skipped: 0** (회귀 0)
- GameCommentControllerTest 17/17, GameReviewControllerTest 20/20,
UserControllerCsrfTest 5/5, BibimbapApplicationTests 1/1
## 손대지 않은 항목 (지시 준수)
- GameReviewController.java 등 main 소스 무수정 (계약·검증순서 design §5 정합 확인만).
- ${}0 / sort5 / axis6 grep 검증, DbUpdateQueryGeneratorTest — 미접촉.

View File

@ -0,0 +1,60 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-22T10:00:00+09:00
---
# 파일 소유권 맵 (W3-2 댓글/리뷰 고도화)
설계 §1 파일 영향 맵 기준. 동일 파일 = 정확히 1 worker. 상호의존 파일은 단일 worker 묶음.
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|---|---|---|---|---|
| docs/game-reviews-ddl.sql | migration-writer | W-DDL | append+주석갱신 | - |
| db/schema.sql | migration-writer | W-DDL | 동기화+주석갱신 | - |
| src/.../util/TextNormalizer.java | code-writer | W-BE | create | - |
| src/.../data/ReviewAxisRow.java | code-writer | W-BE | create | - |
| src/.../mapper/GameReviewAxesMapper.java | code-writer | W-BE | create | ReviewAxisRow |
| src/.../mapper/GameReviewStatsMapper.java | code-writer | W-BE | create | - |
| src/.../data/GameCommentData.java | code-writer | W-BE | modify | - |
| src/.../data/GameReviewData.java | code-writer | W-BE | modify | - |
| src/.../mapper/GameCommentsMapper.java | code-writer | W-BE | modify | GameCommentData |
| src/.../mapper/GameReviewsMapper.java | code-writer | W-BE | modify | GameReviewData |
| src/.../controller/api/GameCommentController.java | code-writer | W-BE | modify | 위 매퍼/data/util |
| src/.../controller/api/GameReviewController.java | code-writer | W-BE | modify | 위 매퍼/data/util |
| src/main/webapp/WEB-INF/views/game-detail.jsp | code-writer | W-FE | modify | (BE API 계약 — 설계 §3 고정) |
| src/test/.../GameCommentControllerTest.java | code-writer | W-TEST | modify | W-BE 시그니처 |
| src/test/.../GameReviewControllerTest.java | code-writer | W-TEST | modify | W-BE 시그니처 |
| src/test/.../BibimbapApplicationTests.java | code-writer | W-TEST | modify | 신규 @Mapper |
## 불변식 점검
- W-BE 가 매퍼/data/util/컨트롤러 11파일 동시 소유 (상호 시그니처 의존 → 단일 worker 순차).
- W-FE jsp 단독 — BE 와 파일 충돌 0. API 계약은 설계 §3 으로 고정되어 BE 코드 미참조 가능 → 병렬 가능.
- W-DDL 2파일 독립 — 코드 무참조 → 선행 병렬 가능.
- W-TEST 는 W-BE 시그니처 확정 필요 → W-BE 완료 후 순차 spawn.
## spawn 순서
1. wave A (병렬): W-DDL, W-FE, W-BE ← 상호 파일 충돌 0
2. wave B (순차): W-TEST ← W-BE 완료 후 (컨트롤러 시그니처 의존)
## planned_workers: 4 (W-DDL, W-FE, W-BE, W-TEST)
---
# 파일 소유권 맵 (FIX LOOP L1 — verification RED 8건)
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|---|---|---|---|---|
| src/test/.../GameReviewControllerTest.java | (advisor 직접) | - | modify | - |
## L1 planned_workers vs actual_workers
- planned_workers: 1
- actual_workers: 0 (advisor 직접)
- 전환 사유: 파일 수 1 < 8 AND 예상 변경 줄수 ~8줄 < 500 advisor 직접 실행 선택. 정밀 문자열 치환 8곳, worker spawn overhead 작업량 초과.
## L1 근본 원인 (진단 결과)
- **가설 1 (테스트 계약 미갱신) 단독 확정.** 컨트롤러 버그 아님.
- 실패 8건 전부 POST body 10자 미만 → 컨트롤러가 design.md AC-9(본문 trim 후 10자 미만 거부) 에 따라 정당하게 400 반환.
- 가설 2(axes 바인딩) 무관: 테스트가 controller.createReview(...) 를 6개 위치 인자로 직접 호출 → MockMvc 폼바인딩 경로 미사용. 검증 순서도 design §5.3 정합 확인.
- 수정 방향: 컨트롤러/FINAL SPEC 계약 불변. 테스트 요청 body 만 10자 이상으로 갱신 (행위 검증 보존).

View File

@ -0,0 +1,250 @@
---
schema_version: 2
sid: 20260622-092800
resumed_from: 20260618-145152
started_at: 2026-06-22T09:28:00+09:00
ended_at: 2026-06-22T10:05:00+09:00
user_request: |
W3-2 댓글/리뷰 고도화 구현 — 직전 인터뷰 세션(20260618-145152)의 FINAL SPEC 으로 구현 재개.
진행 방식 = FINAL SPEC 전체 한 번에 구현(옵션1, 사용자 확정). 단계 분리 없음.
스코프: 다축 평점(game_review_axes) + 육각형 SVG 레이더 + A1~A3 일관성 + B1~B3 목록/검증
+ C1~C6 UX + game_review_stats 집계뷰. 검증: L1 31건 회귀 PASS + 신규 단위테스트, L3 스모크.
mode: team (orchestrator + advisors + workers)
---
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '직전 인터뷰 세션이 FINAL SPEC 까지 요구 분해 완료. 사용자 결정 잠금(옵션1). 재분해 불필요.'
checked_at: 2026-06-22T09:28:00+09:00
- advisor: research-advisor
decision: skip
rationale: 'Grounded Baseline(직전 report) + design-advisor 자체 코드 재확인으로 충분. graphify-lookup 도 생략 — 변경 대상 7파일이 직전 세션에 정밀 grounding 됨, 코드 무드리프트(W3-2 코어 이후 dev seed 커밋만).'
checked_at: 2026-06-22T09:28:30+09:00
- advisor: design-advisor
decision: call
rationale: 'FINAL SPEC → 오픈질문0 확정 설계도. 미결 마이크로결정(is_rating_manual·axis 저장형·sort enum·SVG 좌표·a11y·B3 유틸·테스트목록) 확정 필요.'
checked_at: 2026-06-22T09:28:30+09:00
- advisor: implementation-advisor
decision: call
rationale: '14파일 영향(신규6+변경8) 멀티파일 + worker 분산 필요. design 산출이 파일소유권맵까지 확정 — 직접구현 대신 implementation-advisor 의 worker fan-out 으로 충돌회피.'
checked_at: 2026-06-22T09:34:00+09:00
# C3 정정 (직전 report 오분류 교정 — 근거 확정)
# 직전 report: C3 를 "W2-3 동결 해제·고위험·§6 게이트"로 표기 → 과한 분류.
# 근거 (docs/work-log/2026-06-17-jam-platform-roadmap.md):
# :63 "범위 = 잼 평가만. 댓글/리뷰 스키마 자체는 W3에서 설계(동결 묶음 아님)"
# :201 "잼 평가(심사/투표/시상) 스키마만 동결. 댓글/리뷰 스키마는 W3-2에서 별도 설계"
# :202 "댓글/리뷰 분리 = 일반기능으로 재분류"
# 판정: game_review_stats = game_reviews(W3-2 테이블) 위 읽기전용 집계뷰. 동결 잼평가 테이블 무관.
# → §6 파괴적 게이트·사용자 재확인 불필요. 일반 CREATE VIEW.
# → game-reviews-ddl.sql:63 "집계뷰 미신설" 보수 주석도 갱신.
# Summary
W3-2 댓글/리뷰 고도화 FINAL SPEC 전체 한 묶음 구현 완료. resumed_from 20260618-145152.
- C3 정정: game_review_stats 집계뷰 = W3-2 일반 DDL(고위험 게이트 해제, roadmap:63,201,202 근거).
- DDL: game_review_axes(6축·리뷰당6행)·game_comments.updated_at·game_reviews.is_rating_manual·game_review_stats 뷰 — 멱등 ALTER/CREATE (ddl + db/schema.sql 동기화, C3 보수주석 갱신).
- 백엔드: A1 commentView 통일·A2 하이브리드 작성자명+탈퇴자 마스킹(QG-2 해결)·A3 edited/updated_at, B1 페이지네이션(limit+1 hasMore)+sort 화이트리스트(@SelectProvider, ${}0), B2 본문10자, B3 TextNormalizer, 다축 overall 자동평균/수동(is_rating_manual).
- 프론트(frontend-design 스킬): 육각형 SVG 레이더(요약+카드)·6축 radiogroup(roving tabindex+키핸들러)·C1~C6, production-grade 폴리시.
- 검증: L1 ./mvnw test 43/43 GREEN(회귀0). FIX LOOP 1회(테스트 계약 미갱신 8건, 컨트롤러 무버그 — body<10자 정당거부). L2 skip. L3 = needs_user_verification.
- 신규 4파일 + 13 수정. 미커밋(프로젝트 정책: 명시 요청 시만).
# Invocations
- agent: design-advisor
phase: design
model_choice: { tier: large, slug: opus, effort: high, rationale: '신규 SVG 좌표수학+스키마설계+보안민감 sort enum+a11y — 판단천장 높음' }
artifact: .atp/work-session/20260622-092800/implementation/design.md
result: 'PASS — 오픈질문0 확정 설계도. concerns 7건. self-verification passed.'
- agent: implementation-advisor
phase: implementation
model_choice: { tier: large, slug: opus, effort: high, rationale: '멀티파일 멀티워커 조율 + 보안가드(${}금지)+회귀보호 — 천장 높음' }
result: 'PASS — BUILD SUCCESS(main+test, JDK21). 16파일. ${} 0건(rg 3매치=주석). axis_key 6키 4곳 정합. 리뷰 INNER JOIN 보존. is_rating_manual 실사용. advisor 직접보정 4건(GameController 영향맵누락/테스트 worker충돌 재작성/NUL escape/@MockBean).'
artifact: .atp/work-session/20260622-092800/implementation/ownership.md
concerns: ['설계 §1 영향맵에 GameController.gameDetail SSR 호출지점 누락→컴파일깨짐(보정완료)', 'applyReviewGate: 본인리뷰 첫페이지밖일때 폼노출 가능(서버 409 최종차단, 비차단)', '댓글 더보기 낙관삽입 2페이지+ 정렬어긋남 가능(리뷰는 page0 재조회로 정합, 비차단)']
- agent: frontend-design (skill pass)
phase: implementation-fe
model_choice: { tier: large, slug: opus, effort: high, rationale: 'production-grade UI 디자인 — 사용자 명시 요청(frontend-design 스킬 활용)' }
result: '(L1 green 후 진입)'
- agent: verification-advisor (1차)
phase: verification
model_choice: { tier: medium, slug: sonnet, effort: medium, rationale: 'L1 테스트 실행+AC 판정' }
result: 'RED — GameReviewControllerTest 8건 FAIL(전부 400). AC-5/4/6 PASS. 댓글·CSRF·context 무회귀.'
- agent: implementation-advisor (FIX LOOP §2.6)
phase: implementation-fix
model_choice: { tier: large, slug: opus, effort: high, rationale: '회귀단계 진단+수정' }
result: 'PASS — 근본원인=테스트 계약 미갱신(컨트롤러 무버그). body<10자 7곳10자+ 교체, 행위 assertion 보존, 계약 무약화. main 무수정. ./mvnw test 43/43 GREEN 회귀0.'
artifact: .atp/work-session/20260622-092800/implementation/fix-L1.md
- agent: verification-advisor (2차 재판정)
phase: verification
model_choice: { tier: medium, slug: sonnet, effort: medium, rationale: 'L1 독립 재판정 + 신규행위 AC 커버리지' }
result: 'GREEN 확정 — 43/43(Failures 0). AC-1/3 PASS, AC-7~13 전부 테스트 실재+assertion 확인, AC-4/5/6 재확인. 로그 clean. L2 skip(원격DB). GAP 0.'
artifact: .atp/work-session/20260622-092800/verification.md
- agent: frontend-design (skill pass, fork)
phase: implementation-fe
model_choice: { tier: large, slug: opus, effort: high, rationale: 'production-grade UI — 사용자 명시 요청(frontend-design 스킬)' }
result: 'PASS — game-detail.jsp 단일파일 승격. 육각형 레이더 시그니처화(방사 그라디언트·글로우·등장모션), 미터바 범례, 히어로 요약카드, 6축입력/정렬토글/더보기 폴리시. 기존 앰버/크림 언어 확장(새 디자인시스템0·외부의존0). JS로직 보존(유일변경=poly fill→presentation attr). a11y/데이터계약 보존. 프리뷰 HTML 산출.'
artifact: .atp/work-session/20260622-092800/artifacts/w3-2-ui-preview.html
# Decisions
- C3 재분류: game_review_stats 집계뷰 = 일반 DDL(고위험 게이트 해제). roadmap:63,201,202 근거. [2026-06-22]
- is_rating_manual 컬럼 채택: overall 자동평균(false)/수동선택(true) 출처 구분. 멱등 ADD COLUMN DEFAULT false. [2026-06-22]
- axis_key 저장형 = 영문 snake 6종 순서고정(immersion/creativity/controls/completeness/sound/visual) = 육각형 축 0~5 1:1. [2026-06-22]
- sort 안전 = @SelectProvider 컴파일타임 상수 ORDER BY 5분기. 사용자입력→enum 매칭만, ${} 보간 0. offset/limit=#{}. [2026-06-22]
- pagination = limit+1 hasMore (COUNT 회피). axes 재저장 = delete-all+insert-6 (TX 원자성). [2026-06-22]
- SVG 육각형 = JS 런타임 buildHexRadar(scores,cx,cy,R). role=img + 축점수 텍스트대체. [2026-06-22]
- db/schema.sql 동기화 필수 (dev 컨테이너 부트스트랩). game-reviews-ddl.sql:63 + schema.sql:136-137 C3 보수주석 갱신. [2026-06-22]
# user_signals
positive: []
negative: []
# verified_by_me
- L1: unit+regression — ./mvnw test 43/43 GREEN (Failures 0, Errors 0). 기존 31건 무회귀. verification-advisor 2차 독립 재판정.
- L1 신규행위: AC-7(6축필수 400)·AC-8(자동평균/수동 overall)·AC-9(B2 10자 경계)·AC-11(A1 commentView 8키)·AC-12(A2 마스킹 CASE)·AC-13(A3 updated_at) 전부 테스트 실재+PASS.
- 보안: AC-5 ${} 동적치환 0건(sort=enum 고정 ORDER BY 상수, offset/limit=#{}). AC-4 sort5 분기. AC-6 axis_key 6키 4곳 정합.
- L2: contract-DB — skipped:원격DB/dev 미기동.
- 로그 스캔: clean (Mockito JDK agent warn·trailing-slash warn = known-ok 비차단).
# needs_user_verification
- **DDL 적용**: docs/game-reviews-ddl.sql 멱등 블록(+ db/schema.sql) 을 dev/운영 DB 에 적용. 신규 객체 = game_review_axes 테이블·game_review_stats 뷰·game_comments.updated_at·game_reviews.is_rating_manual. 전부 비파괴 ADD/CREATE. (dev = schema.sql 재부트 or ddl 수동 실행.)
- **L3 브라우저 스모크**(DDL 적용 후 dev 실게임 대상 — seed-dev.sql 더미게임. 폴백 정적게임은 댓글/리뷰 미동작):
- 육각형 레이더 렌더(요약 + 개별 리뷰 카드), 라이트/다크 양 테마.
- 6축 별점 키보드(Tab 1회 진입 roving, ←→↑↓·Home·End, aria-checked 토글), 6위젯 독립.
- 페이지네이션 "더보기"(21건+ append, hasMore=false 숨김), 정렬 토글(댓글 oldest↔newest, 리뷰 newest/rating_desc/rating_asc).
- 다축 입력→overall 자동평균 표시 + 수동 덮어쓰기, B2 10자 안내, C4 글자수 카운터, C2 상대시각, C1 submit 잠금.
- 서버집계 요약(평균 소수1자리, review_count=0 "아직 평가 없음").
- **UI 프리뷰 열람**(선택): .atp/work-session/20260622-092800/artifacts/w3-2-ui-preview.html 브라우저로 열어 컴포넌트 시각 확인.
# graph_refresh
판정: fully-stale (src + docs 양 scope). graph-refresh-checker 근거 = W3-2 코어(커밋됨)+본세션 고도화로 컨트롤러2·매퍼·data·DDL 신규 → 기존 384노드/613엣지에 댓글/리뷰 토폴로지 부재. 삭제대상 없음.
처리: 완료(no-defer 준수). 커밋 b9d836d 직후 /graphify src/ + docs/ 재생성(메인스레드 — fork 의 Agent spawn 제약으로 폴백). src=536노드/1206엣지/20커뮤니티(리뷰 다축·댓글 commentView·edited 토폴로지 반영), docs=119노드/164엣지/15커뮤니티(C3 재분류·육각형·axes/stats DB스키마·검증교훈 군집). docs/graph/index.md source_commit=b9d836d·Scopes 2행 갱신. transient 캐시 정리 확인. graph 메타 커밋 375a2de(본체 .gitignore).
# open_items
- 미커밋 변경(작업단위 속함): 13 수정 + 4 신규(data/ReviewAxisRow.java·mapper/GameReviewAxesMapper.java·mapper/GameReviewStatsMapper.java·util/TextNormalizer.java). 커밋은 CLAUDE.md 정책상 사용자 명시 요청 시만 — 세션 종료 시 사용자 확인.
- work-session 디렉토리(20260622-092800, 20260618-145152) untracked — 동일 커밋 정책.
- 비차단 후속(차기 세션 후보): GET /reviews/mine(본인리뷰 즉시식별 — applyReviewGate 정밀화), 댓글 더보기 page0 재조회 전환(낙관삽입 정렬정합), 기존 리뷰 axes 백필(현재 NULL 허용), 설계 영향맵에 SSR 호출지점(GameController.gameDetail) 포함.
# Retrospective
```yaml
Retrospective:
signals:
positive:
- quote_or_paraphrase: "(세션 report) frontend-design 스킬 fork 위임 → 스킬 호출+단일파일 폴리시+프리뷰 render-verify, 가드레일(JS로직·a11y·계약 보존) 준수. 다축/육각형/sort안전(${}0) 한 묶음 구현 + L1 43/43 GREEN."
about: frontend-design 스킬을 컨텍스트 상속 fork 로 위임하고, 단일 JSP 파일 폴리시·프리뷰 HTML 산출·가드레일 준수까지 한 단계에 완료한 패턴 전체
- quote_or_paraphrase: "(invocation log) implementation-advisor 직접보정 4건 완료 — 영향맵 누락 보정·테스트 worker충돌 재작성·NUL escape·@MockBean"
about: 설계 누락이 발생했음에도 implementation-advisor 가 자가 보정으로 컴파일 깨짐을 세션 내에서 흡수해 사용자 에스컬레이션 없이 완료한 점
negative:
- quote_or_paraphrase: "(implementation-advisor concerns) 설계 §1 영향맵에 GameController.gameDetail SSR 호출지점 누락 → 컴파일깨짐(보정완료)"
about: design-advisor 가 §1 파일 영향맵 작성 시 API 컨트롤러만 기재하고, 변경된 매퍼/data 심볼을 SSR 뷰모델에서 호출하는 GameController.gameDetail 을 누락한 것
structural: true
- quote_or_paraphrase: "(verification-advisor 1차) GameReviewControllerTest 8건 FAIL — 전부 400. 근본원인=테스트 계약 미갱신(body<10자). 컨트롤러 무버그."
about: B2 본문 최소 10자 검증계약 신규 도입 시, W-TEST worker 가 기존 테스트 fixture body 를 계약 정합 감사 없이 재작성하여 기존 픽스처가 여전히 10자 미만 문자열을 전송한 것
structural: true
what_went_well:
- "frontend-design 스킬 fork 위임 패턴: 컨텍스트 상속으로 BE API 계약·기존 JS 로직·a11y 가드레일을 그대로 이어받아, 육각형 SVG 레이더·6축 radiogroup·C1~C6 를 단일 JSP 파일 승격 + 프리뷰 HTML 산출까지 한 묶음 완료. 외부 의존 0·기존 디자인 언어 확장·a11y 보존 전부 통과. 비자명 판단(스킬 fork + 단일파일 폴리시 조합)이 검증됨."
- "C3 phantom 게이트 해소: 직전 report 의 '동결 해제·고위험·§6 게이트' 표기를 근거 문서(roadmap:63,201,202) 직접 확인으로 일반 DDL 재분류. 불필요한 사용자 재확인 경로 없이 세션 내 정정 완료."
- "FIX LOOP 자가흡수: 테스트 RED 8건 발생 후 implementation-advisor 가 '컨트롤러 무버그/테스트 계약 미갱신' 근본원인을 정확히 진단하고, 행위 assertion 전부 보존·계약 약화 없이 픽스처만 교체 → 사용자 에스컬레이션 0."
- "sort 안전 패턴: @SelectProvider + enum 화이트리스트 → 고정 ORDER BY 상수 5분기. ${} 동적 보간 0건 rg 전수 확인. 보안 AC-5 최초 시도 PASS."
what_to_improve:
- "design-advisor 영향맵에 SSR/뷰모델 호출지점 포함 필수: §1 파일 영향맵은 API 컨트롤러뿐 아니라 변경 심볼(매퍼 메서드 시그니처·data 필드)을 호출하는 SSR 컨트롤러(GameController.gameDetail 등) 와 JSP 뷰모델 바인딩 지점까지 포함해야 한다. 누락 시 implementation 단계 컴파일 깨짐."
- "계약 강화(최소길이·필수필드) 시 기존 테스트 fixture 전수 계약 정합 감사 필수: 신규 검증계약(B2 본문 10자·6축 필수)을 도입할 때, 신규 테스트 케이스 추가만이 아니라 기존 테스트 fixture 가 신규 계약을 충족하는지 전수 감사를 W-TEST 체크리스트에 명시해야 한다. 이번 8건 FAIL 은 모두 컨트롤러 버그 없이 fixture 미갱신 단독 원인."
- "freeze/동결 인접성만으로 고위험 게이트 분류 금지 — 근거 문서 직접 확인 선행: '동결 구역 인근 작업'이라는 표면적 유사성만으로 §6 게이트 표기 시 phantom 게이트가 누적된다. 동결 범위를 근거 문서(ADR/roadmap 줄 번호)로 확인 후 분류해야 한다."
memory_candidates:
- name: design-impact-map-includes-ssr-callers
type: feedback
description: "design-advisor §1 영향맵에 변경 심볼의 SSR/뷰모델 호출지점 포함 필수"
body_draft: |
## Why
W3-2 고도화 세션(20260622)에서 design-advisor 가 GameController.gameDetail 의 SSR 호출지점을
영향맵에서 누락 → implementation 단계 컴파일 깨짐, advisor 직접 보정 필요.
API 컨트롤러 목록만으로는 "변경 심볼을 참조하는 비API 코드" 를 놓친다.
## How to apply
design 단계 §1 파일 영향맵 작성 시:
1. 변경 매퍼/data 심볼 목록 확정.
2. 해당 심볼을 호출하는 SSR 컨트롤러(예: GameController.gameDetail) 와
JSP 뷰모델 바인딩 지점을 rg 로 전수 확인(패턴: 심볼명).
3. API 컨트롤러와 SSR 컨트롤러를 구분해 양쪽을 영향맵에 기재.
재현성: Spring MVC SSR + API 컨트롤러 혼재 구조에서 항상 발생 가능.
rationale_for_saving: "API/SSR 혼재 Spring MVC 에서 구조적으로 재발 가능. 코드/git log 으로 유도 불가 — 설계 단계 체크리스트 규약에만 존재."
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
- name: contract-upgrade-fixture-audit
type: feedback
description: "검증계약 강화 시 기존 테스트 fixture 전수 계약 정합 감사를 W-TEST 체크리스트에 포함"
body_draft: |
## Why
W3-2 고도화 세션(20260622)에서 B2(본문 10자)·6축 필수 계약을 신규 도입 후
W-TEST worker 가 기존 fixture body 를 갱신하지 않아 8건 전부 400 FAIL.
컨트롤러는 무버그였고, fixture 미갱신 단독 원인.
신규 테스트 추가만으로는 기존 픽스처가 새 계약을 충족하는지 보장되지 않는다.
## How to apply
W-TEST worker 지시에 다음 감사 항목 추가:
1. 새로 도입된 검증 계약(최소길이·필수필드·enum 범위 등) 목록화.
2. 기존 테스트 fixture(요청 본문·파라미터) 전수 스캔 — 신규 계약 조건 충족 여부 확인.
3. 미충족 fixture 갱신(행위 assertion 보존 전제).
4. "우연통과" 제거: 거부 경로 테스트의 fixture 도 신규 계약을 충족하는 값으로 올려
실제 거부 사유(상위 분기)까지 도달함을 보장.
재현성: 컨트롤러 검증 강화 시마다 발생 가능.
rationale_for_saving: "컨트롤러 검증 강화는 반복 발생. W-TEST spawn 지시에 표준 체크리스트로 넣지 않으면 매번 재발."
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
- name: freeze-scope-requires-doc-evidence
type: feedback
description: "동결/freeze 인접 작업은 근거 문서 줄 번호 확인 후 게이트 분류 — 표면 유사성만으로 §6 게이트 금지"
body_draft: |
## Why
직전 인터뷰 세션(20260618-145152) report 가 C3(game_review_stats 집계뷰)를
"W2-3 동결 해제·고위험·§6 게이트"로 표기. 실제 동결 대상은 잼 평가(심사/투표/시상) 스키마이며
댓글/리뷰 스키마는 W3-2 별도 설계(roadmap:63,201,202). 표면적 "평점 집계" 유사성만으로
동결 묶음에 포함한 오분류. 본 세션에서 근거 문서 직접 확인으로 phantom 게이트 해소.
## How to apply
동결/freeze 인접 표기 전:
1. 동결 선언 근거 문서(ADR·roadmap·work-log)를 실제로 열어 동결 범위 정의를 줄 번호로 확인.
2. 현재 변경 대상 테이블/뷰/심볼이 그 범위 정의에 명시적으로 포함되는지 판단.
3. 포함 확인 시만 §6 게이트 표기. 인접·유사 용어만으로 분류 금지.
rationale_for_saving: "동결 경계 판단은 코드에서 유도 불가. 문서 확인 없이 유사성 추론으로 phantom 게이트가 누적되면 불필요한 사용자 에스컬레이션 반복."
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
- name: frontend-skill-fork-pattern
type: feedback
description: "production-grade UI 구현은 frontend-design 스킬 fork(컨텍스트 상속) 위임 + 단일파일 폴리시 + 프리뷰 render-verify 조합이 검증됨"
body_draft: |
## Why
W3-2 고도화 세션(20260622)에서 육각형 SVG 레이더·6축 radiogroup·C1~C6 를
frontend-design 스킬 fork 로 위임, 단일 JSP 파일(game-detail.jsp) 승격,
프리뷰 HTML 산출, 가드레일(JS로직 보존·a11y·BE API 계약) 전부 준수.
외부 의존 0·기존 앰버/크림 디자인 언어 확장. L1 43/43 GREEN 이후 진입 순서도 검증됨.
## How to apply
복잡 UI(SVG·a11y·다중 JS 인터랙션) 구현 시:
1. L1 전체 GREEN 확인 후 frontend-design 스킬 fork 진입.
2. 단일파일 폴리시: JSP 1파일 안에서 완결(새 파일 0).
3. 가드레일 명시: 기존 JS 로직 보존 / a11y(role, aria, textContent) / BE API 계약 무변경.
4. 프리뷰 HTML 산출(artifacts/): 컨포넌트 시각 확인용.
외부 JS 라이브러리·CDN 도입 없이 인라인 SVG + vanilla JS 패턴 유지.
rationale_for_saving: "비자명 조합 판단(스킬 fork + L1 완료 후 진입 순서 + 단일파일 폴리시)이 실전 검증됨. 동일 패턴 재현 시 참고."
signal_source: positive
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
protocol_feedback:
- "design-advisor 체크리스트 보강(structural): §1 파일 영향맵 작성 규약에 'SSR 컨트롤러·뷰모델 호출지점 포함' 항목 추가 필요. 현행 규약은 API 컨트롤러 나열에 집중하여 SSR 호출지점이 반복 누락될 구조적 허점이 있다. verification-strategies.md 또는 design-advisor 가이드에 명시 권고."
- "W-TEST worker 지시 표준화(structural): 컨트롤러 검증 계약이 강화(최소길이·필수필드 추가)되는 경우, W-TEST worker 지시에 '기존 fixture 전수 계약 정합 감사' 단계를 의무 체크리스트 항목으로 포함 필요. 현행 W-TEST 지시에는 '신규 테스트 추가'만 있고 '기존 fixture 갱신 감사'가 없어 동일 패턴 재발 가능."
- "§6 게이트 분류 기준 명문화: 동결/freeze 경계 판단을 근거 문서(ADR/roadmap 줄 번호) 확인 없이 표면 유사성만으로 수행하는 것을 agent-team-protocol 에서 명시 금지. '동결 구역 인접 = 고위험 게이트' 추론 차단 규칙 추가 권고."
applied_changes: []
```

View File

@ -0,0 +1,194 @@
---
phase: verification
agent: verification-advisor
agent_version: 1
generated_at: 2026-06-22T10:02:30+09:00
concerns:
- "AC-3/AC-1 중복 실패: GameReviewControllerTest 8건이 모두 400 BAD_REQUEST 반환. 동일 패턴(POST 요청 → 400)이므로 요청 파라미터 바인딩 또는 validation 로직 공통 결함 의심. 수정 범위는 implementation 몫."
- "L2 skipped: 원격 DB 미기동 환경. live contract 테스트 실행 조건 미충족."
concerns_checked: true
---
# 검증 결과
## Acceptance Criteria (입력 받은 그대로 인용)
**AC-1 기존 회귀 보존 (불변식)**
기존 31건이 전부 PASS(삭제·실패 0). 31건 = GameCommentControllerTest 기존 12 + GameReviewControllerTest 기존 13 + UserControllerCsrfTest 5 + BibimbapApplicationTests 1. 이번 세션이 테스트를 추가했으므로 총건수는 31+α(증가 정상). 측정 = 기존 테스트 메서드가 사라지거나 실패한 것이 0.
**AC-3 신규 단위테스트 PASS**
신규 추가분(다축 저장/조회, 페이지네이션, sort enum, A2 마스킹, B2 10자, B3 정규화 등) 전부 GREEN. 현재 영향 테스트클래스 = GameCommentControllerTest(총 17), GameReviewControllerTest(총 20), BibimbapApplicationTests(@MockBean 8).
**AC-5 SQL injection 면역 (보안 — 집합 전수)**
W3-2 신규/변경 매퍼에서 `${}` 동적 SQL 치환 0건.
**AC-4 sort 화이트리스트 5분기**
sort enum→고정 ORDER BY 상수 분기가 5종(댓글 oldest/newest, 리뷰 newest/rating_desc/rating_asc) 존재.
**AC-6 axis_key 6키 정합 (집합 전수)**
axis_key 6종(immersion,creativity,controls,completeness,sound,visual)이 DDL CHECK·뷰 FILTER·BE 앱상수에서 동일 철자·6개 전수. 누락/오타 0.
---
## 실행된 전략
| id | cmd | exit | severity | 결과 |
|---|---|---|---|---|
| unit (full suite) | `JAVA_HOME=... ./mvnw -q test` | 1 | blocker | FAIL |
### 단계별 분해
| 단계 | 결과 |
|---|---|
| L1 typecheck (compile) | pass (빌드 성공, 컴파일 에러 0) |
| L1 unit+regression (GameCommentControllerTest) | pass (17/17) |
| L1 unit+regression (GameReviewControllerTest) | **FAIL (12/20 pass, 8 FAILURE)** |
| L1 unit+regression (UserControllerCsrfTest) | pass (5/5) |
| L1 unit+regression (BibimbapApplicationTests) | pass (1/1) |
| 전체 집계 | Tests run: 43, Failures: 8, Errors: 0, Skipped: 0 |
| L2 contract | skipped: 원격 DB 미기동 |
| 로그 스캔 | warn: 1건 — `Appended trailing slash to static resource location` (정적 리소스 경고. 기능 차단 없음) |
---
## 실패 상세
### GameReviewControllerTest — 8건 전부 `400 BAD_REQUEST`
공통 패턴: 모든 실패 테스트가 POST/PATCH 엔드포인트에 정상 요청을 보냈으나 `400 BAD_REQUEST` 반환. 예상값은 `200 OK` 또는 `409 CONFLICT`.
| # | 테스트명 | 파일:라인 | 예상 | 실제 |
|---|---|---|---|---|
| 1 | `createReviewReturnsEditedFalse` | GameReviewControllerTest.java:74 | 200 OK | 400 BAD_REQUEST |
| 2 | `updateReviewReturnsEditedTrue` | GameReviewControllerTest.java:92 | 200 OK | 400 BAD_REQUEST |
| 3 | `createSecondReviewReturnsConflict` | GameReviewControllerTest.java:111 | 409 CONFLICT | 400 BAD_REQUEST |
| 4 | `createReviewAcceptsRatingOneBoundary` | GameReviewControllerTest.java:129→assertCreateRatingAccepted:404 | 200 OK | 400 BAD_REQUEST |
| 5 | `createReviewAcceptsRatingFiveBoundary` | GameReviewControllerTest.java:134→assertCreateRatingAccepted:404 | 200 OK | 400 BAD_REQUEST |
| 6 | `createReviewAutoAveragesOverallWhenRatingOmitted` | GameReviewControllerTest.java:260 | 200 OK | 400 BAD_REQUEST |
| 7 | `createReviewUsesManualOverallWhenRatingProvided` | GameReviewControllerTest.java:283 | 200 OK | 400 BAD_REQUEST |
| 8 | `createReviewPersistsSixAxes` | GameReviewControllerTest.java:308 | 200 OK | 400 BAD_REQUEST |
**재현 명령:**
```
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
JAVA_HOME=$JAVA_HOME ./mvnw test -pl . -Dtest=GameReviewControllerTest
```
개별 테스트 좁힌 재현:
```
JAVA_HOME=$JAVA_HOME ./mvnw test -Dtest=GameReviewControllerTest#createReviewReturnsEditedFalse
```
---
## AC별 판정
### AC-1 기존 회귀 보존
판정: **FAIL**
근거:
- GameCommentControllerTest: 17/17 pass — 기존 12개 메서드 현존·pass 확인
- UserControllerCsrfTest: 5/5 pass
- BibimbapApplicationTests: 1/1 pass
- GameReviewControllerTest: **20건 중 8건 FAIL**
GameReviewControllerTest 기존 13건 중 어느 것이 기존 메서드인지 테스트 출력만으로는 구분 불가. 단, 실패 8건 모두 기능 시나리오 테스트(create/update 성공 경로, 409 충돌)이므로 기존 13건 중 상당수가 이 실패 8건에 포함될 가능성이 높다. "기존 테스트가 0건 실패" 조건 위반 판정.
### AC-3 신규 단위테스트 PASS
판정: **FAIL**
근거: GameReviewControllerTest 20건 중 8건 실패. 실패 테스트에는 `createReviewPersistsSixAxes`(다축 저장), `createReviewAutoAveragesOverallWhenRatingOmitted`(rating 자동 평균), `createReviewAcceptsRatingOneBoundary`/`FiveBoundary`(경계값) 등 신규 추가분이 포함.
### AC-5 SQL injection 면역
판정: **PASS**
근거:
```
rg -n '\$\{' GameCommentsMapper.java GameReviewsMapper.java GameReviewAxesMapper.java GameReviewStatsMapper.java
```
매치 3건 모두 주석 텍스트:
- `GameCommentsMapper.java:97``// ORDER BY 만 sort enum 으로 고정 문자열 분기(${} 미사용).`
- `GameReviewAxesMapper.java:28``// VALUES 다행. 각 값은 #{axes[i].axisKey}/#{axes[i].score} 바인딩(${} 미사용).`
- `GameReviewsMapper.java:92``// SELECT/JOIN/WHERE 는 컴파일타임 리터럴. ORDER BY 만 sort enum 으로 고정 문자열 분기(${} 미사용).`
실제 SQL 절 내 `${}` 동적 치환 0건. 모든 파라미터 바인딩은 `#{}` 사용 확인.
### AC-4 sort 화이트리스트 5분기
판정: **PASS**
근거 — 매퍼 소스에서 확인된 분기:
댓글(GameCommentsMapper.java:100):
```java
String orderBy = "newest".equals(sort)
? "ORDER BY c.created_at DESC, c.id DESC"
: "ORDER BY c.created_at ASC, c.id ASC";
```
→ newest, oldest(default) 2종
리뷰(GameReviewsMapper.java:95-98):
```java
String orderBy = switch (sort == null ? "" : sort) {
case "rating_desc" -> "ORDER BY r.rating DESC, r.created_at DESC, r.id DESC";
case "rating_asc" -> "ORDER BY r.rating ASC, r.created_at DESC, r.id DESC";
default -> "ORDER BY r.created_at DESC, r.id DESC"; // newest
};
```
→ rating_desc, rating_asc, newest(default) 3종
합계 5종. 모두 고정 문자열 리터럴(동적 보간 없음).
### AC-6 axis_key 6키 정합
판정: **PASS**
근거:
DDL CHECK 제약 (`docs/game-reviews-ddl.sql:156-157`):
```sql
CHECK ("axis_key" IN ('immersion','creativity','controls','completeness','sound','visual'))
```
뷰 FILTER (`docs/game-reviews-ddl.sql:176-181`):
```sql
FILTER (WHERE a."axis_key"='immersion')
FILTER (WHERE a."axis_key"='creativity')
FILTER (WHERE a."axis_key"='controls')
FILTER (WHERE a."axis_key"='completeness')
FILTER (WHERE a."axis_key"='sound')
FILTER (WHERE a."axis_key"='visual')
```
BE 앱상수 (`GameReviewController.java:38`):
```java
private static final String[] AXIS_KEYS = {"immersion", "creativity", "controls", "completeness", "sound", "visual"};
```
3개 위치 전부 동일 철자 6종. 누락/오타 0.
---
## 종합 판정
```
overall: fail
rollback_signal: none
```
- rollback_signal=none 근거: 실패가 `400 BAD_REQUEST` 일관 패턴. DB migration/파괴적 조작과 무관한 컨트롤러 레벨 요청 처리 결함으로 판단. 데이터 오염 없음.
---
## Acceptance 매칭
| criterion | 매칭 전략 | 판정 |
|---|---|---|
| AC-1 기존 회귀 보존 | L1 unit (full suite) | **FAIL** — GameReviewControllerTest 8건 실패 |
| AC-3 신규 단위테스트 PASS | L1 unit (full suite) | **FAIL** — 신규 테스트 포함 8건 400 반환 |
| AC-5 SQL injection 면역 | rg `\$\{` 매퍼 전수 | PASS — 실제 SQL 보간 0건 |
| AC-4 sort 화이트리스트 5분기 | rg ORDER BY 분기 수집 | PASS — 5종 고정 리터럴 확인 |
| AC-6 axis_key 6키 정합 | rg DDL/뷰/앱상수 3위치 전수 | PASS — 3위치 동일 6종 |

View File

@ -0,0 +1,123 @@
---
schema_version: 2
sid: 20260622-162345
started_at: 2026-06-22T16:23:45+09:00
ended_at:
user_request: |
docs/game-reviews-ddl.sql 같이 sql 추가되면 로컬엔 테스트 가능하게 바로 적용하도록
docs 반영해주고, sql도 지금 바로 반영해줘.
Summary: |
신규 docs/*-ddl.sql 을 실행 중 로컬 DB 에 즉시 적용하는 반복 절차를 docs 에 명문화하고,
현재 미적용 상태인 game-reviews-ddl.sql 의 W3-2 고도화 블록을 실행 DB(dev)에 즉시 적용한다.
Invocations: []
Decisions: []
---
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '요청 명확 — 절차 문서화 + 멱등 DDL 즉시 적용. 스코프 모호 없음.'
checked_at: 2026-06-22T16:23:45+09:00
- advisor: graphify-lookup-advisor / research-advisor
decision: skip
rationale: 'DB/docs 상태를 orchestrator 가 inline 으로 직접 조사 완료(실행 DB 델타·local-setup.md·schema.sql).'
checked_at: 2026-06-22T16:24:00+09:00
- advisor: design-advisor
decision: skip
rationale: '결정 축 단일·이산(스크립트+doc vs doc-only). AskUserQuestion 으로 직접 위임.'
checked_at: 2026-06-22T16:24:30+09:00
- advisor: implementation-advisor
decision: skip
rationale: '산출=docs 1~2파일 편집 + 멱등 DDL psql 적용. 마이크로 편집 범위, orchestrator 직접 수행.'
checked_at: 2026-06-22T16:24:30+09:00
# 조사 결과 (inline research)
- 마이그레이션 도구 없음(flyway/liquibase 부재). 권위 DDL = docs/*-ddl.sql, Docker init = db/schema.sql.
- db/schema.sql 은 컨테이너 *최초* 기동 시 docker-entrypoint-initdb.d 로 1회만 자동 주입.
이후 추가된 DDL 은 실행 DB 에 자동 반영 안 됨 → down -v 재기동(데이터 소실) 외엔 수동 적용 필요.
- 실행 컨테이너: bibimbap-db (postgres:16, host:5433), db=bibimbap user=bibimbap schema=dev.
- 실행 DB dev 스키마 델타 (game-reviews-ddl.sql 기준):
- 적용됨: game_reviews 테이블, game_comments.user_id
- 누락됨: game_comments.updated_at, game_reviews.is_rating_manual,
game_review_axes 테이블, game_review_stats 뷰 (= DDL L102~187 고도화 블록)
- DDL 전체 멱등(IF NOT EXISTS / DO $$ guard / CREATE OR REPLACE VIEW) → 전체 재실행 안전.
Decisions:
- id: D1
decision: '반복 절차 형태 = 헬퍼 스크립트 + 문서 (Option A, 사용자 선택)'
rationale: 'DDL 전부 멱등 + 마이그레이션 도구 부재 → "바로 적용" 을 한 명령으로 만드는 스크립트가 적합.'
- id: D2
decision: 'DDL 적용은 데이터 보존(비파괴) — docker exec psql 직접 적용. down -v 미사용.'
rationale: 'down -v 는 로컬 데이터 소실. 모든 docs/*-ddl.sql 멱등이라 직접 적용이 안전.'
- id: D3
decision: 'search_path 강제(PGOPTIONS -c search_path=dev) 를 스크립트/문서에 명시.'
rationale: 'docs/*-ddl.sql 은 unqualified 테이블명 사용. dev 스키마 타겟 보장 필수.'
# verified_by_me
- 'L1(typecheck/unit): N/A — src/ 코드 변경 0건 (DDL 적용 + 운영 스크립트 + docs 만).'
- '게임리뷰 DDL 적용: docs/game-reviews-ddl.sql → bibimbap-db dev 스키마, ON_ERROR_STOP=1 무에러.'
- '적용 후 객체 검증(전부 존재): game_review_axes 테이블 / game_review_stats 뷰 / game_comments.updated_at / game_reviews.is_rating_manual / axis_key check 제약.'
- 'game_review_stats 뷰 SELECT 정상 동작 (기존 시드 game_id=3, review_count=5 집계 반환).'
- 'db/apply-local-ddl.sh 전체 실행 검증: docs/*-ddl.sql 3개 멱등 재실행 성공(기존 객체 skip, 누락분 생성, 종료 0).'
- '나머지 DDL 멱등성 사전 확인: recruit-posts / security-hardening 둘 다 가드≥문장, DROP/TRUNCATE/DELETE/bare INSERT 0.'
# needs_user_verification
- '(선택) 앱 기동 후 리뷰 다축평점/집계뷰 기능 스모크 — 실행 DB 에 객체는 적용됐으나 UI 경로 E2E 는 미수행.'
- '커밋: 사용자 요청 시 진행 (CLAUDE.md/하니스 정책 — 커밋은 명시 요청 시에만). 변경 파일: docs/usage/local-setup.md (M), db/apply-local-ddl.sh (신규).'
# graph_refresh
- 'graph-refresh-checker 판정: fresh (src·docs 양 scope). 재생성 불필요.'
- '근거: src 변경 0; docs 변경(local-setup.md §4.1 추가)은 기존 문서 내 산문 보강 — 신규 md/카테고리/DDL 0, 구조 시그널 0. 참조 DDL 3종은 이미 docs scope 인덱싱됨.'
- 'db/apply-local-ddl.sh 는 scope(src/,docs/) 밖 — 신규 scope 신설 불요(향후 db/ 운영 스크립트 다수화 시 재검토 권고).'
- 'source_commit b9d836d (HEAD=375a2de) 차이는 graph 메타 커밋 1건뿐 — 다음 실질 변경 시 동기화. 단독 갱신 불요.'
# Invocations
- agent: atp-graphify:graph-refresh-checker
tier: large
result: 'fresh (src·docs). 재생성/삭제/index 갱신 모두 불필요.'
tokens: 12263
# user_signals
positive:
- 'AskUserQuestion 반복절차 형태 결정에서 Recommended(스크립트+문서) 1회 선택 — 옵션 설계 수용.'
negative: []
# retrospective
- 'retrospective-advisor 정식 호출 skip — 마이크로 편집 경로(advisor 전체 skip), negative 시그널 0, 구조적 피드백 0.'
- 'memory_candidate: 없음 — 핵심 교훈(schema.sql 최초 1회만 자동주입 → 신규 DDL 수동 적용 필요)은 산출물 자체(local-setup.md §4.1 + db/apply-local-ddl.sh)에 docs-first 로 이미 codify 됨. 별도 MEMORY 불요.'
# follow_on (동일 세션 후속 요청)
followup_request: |
커밋 진행 + "비-메인스트림 브랜치면 커밋 전제로 작업" 정책을 docs 에 반영.
followup_method: |
read-only 조사 워크플로우(Survey 4 병렬 explorer → Design 합성, ultracode) 로
정책 문서 위치·기존 규칙·하니스 충돌 표면 전수 조사 후 plan-gate → 적용.
followup_decisions:
- id: D4
decision: '커밋 정책 = development 규칙 급(신규 ADR 미발행).'
rationale: 'ADR 은 기술/아키텍처 되돌리기 어려운 결정 한정(docs/adr/index.md). 본건은 가역적 운영 절차 → document-category-classification 상 development/. 부수 발견: ADR-0010 은 work-session 보고서들의 phantom 참조(docs/adr/ 에 번호 ADR 미실재).'
- id: D5
decision: '하니스 기본규칙과 정합 = durable authorization 프레이밍.'
rationale: "비-메인스트림 브랜치 커밋을 '프로젝트 지침이 부여한 표준 승인' 으로 명문화 → '사용자 요청 시에만 커밋' 위반 아님. push 는 명시 요청 한정 유지, 메인스트림 branch-first 유지."
- id: D6
decision: 'CLAUDE.md=선언+링크, docs/development/git-workflow.md=정본 신설, index.md 등재.'
rationale: 'CLAUDE.md 는 하니스 상시 로드 → 표준 승인 선언 효력. 상세 규약은 재사용 개발규칙이라 development/ 정본.'
followup_edits:
- 'CLAUDE.md 작업원칙: 비-메인스트림 커밋 표준승인 + 메인스트림 branch-first 불릿 2개.'
- 'docs/development/git-workflow.md 신규: 브랜치 분류·표준승인·push 제외·커밋 단위·Conventional Commits + Co-Authored-By 트레일러.'
- 'docs/development/index.md: git-workflow.md 등재.'
followup_commits:
- 'feat(db): 로컬 DDL 즉시적용 스크립트 + 셋업 절차 문서화 (db/apply-local-ddl.sh, docs/usage/local-setup.md)'
- 'docs(dev): 비-메인스트림 브랜치 커밋 표준 승인 정책 추가 (CLAUDE.md, git-workflow.md, dev/index.md)'
- 'chore(atp): work-session 산출물 기록 (20260618-145152, 20260622-092800, 20260622-162345 — 사용자 "셋 다 커밋" 선택)'
user_signals_followup:
positive:
- 'orphan work-session 처리 "셋 다 커밋" 1회 선택 — 추적 관행 일관성 복원 승인.'
# ended_at
ended_at: 2026-06-22T16:48:00+09:00
# open_items
- '미커밋 변경 2건 — docs/usage/local-setup.md(M), db/apply-local-ddl.sh(신규). 이번 작업 단위. 사용자 커밋 지시 대기.'
- '.atp/work-session/ untracked 디렉토리 3개 — 세션 산출물(ADR-0010 추적 대상이나 별도 커밋 정책).'

View File

@ -0,0 +1,32 @@
# bibimbap 인증: 자체 PBKDF2 해시 (BCrypt 아님)
Spring Security 미사용. 로그인은 `UserController` 자체 구현 password 검증.
password_hash 포맷: `pbkdf2_sha256$<iter>$<base64(salt)>$<base64(hash)>` ($ 4필드)
- 알고리즘 `PBKDF2WithHmacSHA256`, iterations=210000, keyLength=256bit(=32byte), salt=16byte SecureRandom
- base64 = 표준(padding 포함). 검증: `UserController.verifyPassword`
(`src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java:46-48, 550-584`).
parts[0]=="pbkdf2_sha256" && len==4 확인 → 동일 iter/salt 재계산 → `MessageDigest.isEqual` 상수시간 비교.
## 함정 (Why save)
외부 BCrypt 생성기로 만든 해시는 **무효** — 형식 자체가 다름. 코드 안 보면 "BCrypt 일 것"이라는
기본 가정이 강해, 로그인 가능 더미/테스트/관리자 계정 seed 마다 반복적으로 부딪힌다.
## 로그인 가능 계정 seed 시 필수 조건
- `users.status = 'ACTIVE'` (UserController.java:148-149, 미충족 시 로그인 거부)
- `user_auth_identities.provider = 'email'` (단일 provider, UserController.java:42)
- `provider_user_id` = 정규화(trim + lowercase) 이메일 (UserController.java:527-533)
- 양쪽 `is_delete = false`
- 세션 귀속 키: 로그인 성공 시 session attribute `userId`(Long) 저장 → 리뷰/댓글 작성 가드가 이 키를 읽음.
## 평문→해시 생성 (Python, Java 와 byte-identical)
```python
import hashlib, base64, os
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac('sha256', pw.encode(), salt, 210000, dklen=32)
h = f"pbkdf2_sha256$210000${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
```
로그인 동작 보장은 실제 `verifyPassword` 코드 경로로 positive/negative 둘 다 확인할 것
(호스트 JDK 없으면 `mem:java-crypto-verify-via-jdk-container-when-no-host-jdk` 참조).
근거 세션: .atp/work-session/20260618-121419 (더미 seed). 관련 산출물 `db/seed-dev.sql`.

View File

@ -0,0 +1,27 @@
# bibimbap dev 더미 seed 청사진
현실적 경로 = **dev 스키마에 psql 직접 INSERT**. UI 경유는 CSRF + 세션 + (게임) WebGL 업로드를
강제해 고비용. DB 직삽은 CSRF/세션/중복선검사를 우회하되 FK·CHECK·UNIQUE idx 는 그대로 적용됨.
기성 산출물: `db/seed-dev.sql` (멱등, NOT EXISTS 가드) + `db/seed-dev-teardown.sql` (정리).
적용: `docker exec -i bibimbap-db psql -U bibimbap -d bibimbap -v ON_ERROR_STOP=1 < db/seed-dev.sql`
DB: 가동 컨테이너 `bibimbap-db` (postgres:16, 호스트 5433), db/user `bibimbap`, schema `dev`.
## INSERT 순서 (FK 의존)
1. `users` (status='ACTIVE', role='USER', is_delete=false)
2. `user_auth_identities` (PBKDF2 password_hash — `mem:bibimbap-auth-pbkdf2-not-bcrypt`; provider='email')
3. `games` (user_id FK, name NOT NULL, is_visible=true, is_delete=false → 목록/상세 표시; sort_order=MAX+1)
4. `game_reviews` (game_id, user_id, rating 1~5 CHECK, body)
5. `game_comments` (game_id, user_id nullable, nickname, content)
## 핵심 제약 (코드 교차확인 필요 — schema.sql 은 비권위 복원본)
- **`game_reviews` 부분 UNIQUE `(game_id, user_id) WHERE is_delete IS NOT TRUE`**
→ 한 게임에 더미 리뷰 N개 = **더미 유저 N명** 필요. 한 유저로 N리뷰 불가.
- `game_reviews`/`games` 조회는 `users` JOIN → 작성자명은 `users.display_name` 에서 옴.
- `game_comments` 조회는 users JOIN 안 함 → **작성자명은 `nickname` 컬럼을 직접 채워야** 표시됨
(`GameCommentsMapper.listGameComments`).
- 게임 `webgl_path`/`thumbnail_url` NULL 안전 — `game-detail.jsp:13-21` 가 빈 src 로 처리.
- "리뷰 작성 테스트" 하려면 로그인 계정은 해당 게임에 리뷰 0건이어야 함
(`getActiveReviewByGameAndUser` 중복검사, GameReviewController.java:103-105).
근거 세션: .atp/work-session/20260618-121419.

View File

@ -0,0 +1,25 @@
# 호스트 JDK 부재 시 Java 암호 로직 교차검증 패턴
bibimbap 호스트엔 JDK 없음(빌드/실행은 Docker 경로). 그래서 생성한 password_hash 등이
**실제 Java 코드 경로로 검증되는지**를 추정으로 닫지 말고 컨테이너로 실행 증거를 만든다.
## 패턴
1. 검증 대상 로직을 단일 .java 로 복제(예: `UserController.verifyPassword` 그대로).
2. JRE-only 이미지(`bibimbap-app:latest`)는 `java File.java` 소스모드 불가
(`jdk.compiler` 모듈 없음 → `InternalError: Module jdk.compiler not in boot Layer`).
3. JDK 이미지로 컴파일+실행:
```
docker run --rm -v /tmp/HashVerify.java:/tmp/HashVerify.java:ro \
--entrypoint sh eclipse-temurin:21-jdk \
-c 'javac -d /out /tmp/HashVerify.java && java -cp /out HashVerify'
```
4. **positive + negative 둘 다** 확인(올바른 비번=true, 틀린 비번=false). 한쪽만 true 는 불충분.
## 왜
- self-report("스펙상 맞을 것") 대신 실행 증거로 AC 를 닫음.
- Python `hashlib.pbkdf2_hmac('sha256',...)` 와 Java `PBKDF2WithHmacSHA256` 는 byte-identical 이지만,
"동일하다"는 가정 자체를 실제 Java 경로 실행으로 검증.
- 호스트 `timeout` 명령은 macOS 기본 미존재(gtimeout). `/usr/bin/java` 는 JRE 없는 스텁일 수 있음.
일반화: 호스트에 런타임 없을 때 "추정으로 닫기" 대신 throwaway 컨테이너로 실행 증거 확보.
근거 세션: .atp/work-session/20260618-121419.

View File

@ -0,0 +1,25 @@
# 기획 분할: 의존성 축 + 목적 축 병행 확인
type: feedback
source_session: 20260617-162536 (발원 결함: 20260617-150635)
## 교훈
기능 요청을 세션/단위로 **분할**할 때 두 축을 **병행** 확인한다. 한 축만 쓰면 결함이 난다.
1. **의존성 축** (빌드 순서 / 공유 자원, ATP §2.7) — "먼저 뭐가 필요한가". 재작업 방지용.
2. **목적 축** (왜 존재 / 누구를 위한가) — "이게 특정 기능(예: 게임잼) 때문에 생긴 건가, 사이트 전반용인가".
## 실패 모드 (의존성 축만 쓸 때)
- 단일 프레임(예: "게임잼 플랫폼 고도화")이 **비대상 기능을 흡수** → 사이트 전반 기능이 불필요하게 그 일정/스코프에 결합되어 독립 출시 기회를 잃음.
- 묶음 라벨("인증유저 플래그" 등)을 **동질 취급**하면 도메인 오분류 발생 — 권한(RBAC) vs 배지/평판은 다른 도메인인데 한 통에 들어감.
## 실증
- 20260617-150635: 의존성 축으로 S1~S7 로 잘 쪼갬. 그러나 전부 "게임잼" 목적 프레임.
- 20260617-162536: 목적 축 재검 → 4개 워크스트림 중 W1(거버넌스)·W3(사이트플랫폼)·W4(유저배지)가 게임잼 무관/독립으로 드러남. W4(리뷰어/기술자 배지)는 RBAC 플래그로 오분류돼 **통째 누락**됐다가 발굴됨.
## 적용 체크
- 분할 직후 각 항목에 1:1로 묻는다: "이 기능, 특정 대상(잼 등) 없으면 존재 이유가 있나?" → 있으면 사이트 전반(독립), 없으면 전용.
- 묶음 라벨은 항목별로 도메인(권한 / 평판·배지 / 일반기능)을 따로 판정.
- 로드맵 산출에 "특정 일정과 분리 가능 여부"를 항목별 명시.
관련 프로토콜: §2.7(결합 트랙 게이트), §4.2.2(톤/어휘 두 축 분리), §2.6(다항목 전수 재검).

View File

@ -0,0 +1,15 @@
# Serena Tooling Scope — bibimbap
`.serena/project.yml``languages: [java, html]` (java = 기본/fallback LS).
## 파일 타입별 도구 적용
- **`.java`** (31): LSP(EclipseJDTLS) 활성. `get_symbols_overview`/`find_symbol`/`find_referencing_symbols`/`replace_symbol_body` 등 심볼 도구 **사용**.
- **`.jsp` / `.jspf`** (16+1, `webapp/WEB-INF/views/`): **LSP 미지원**. Serena HTML LS 매처는 `.html`/`.htm` 만 잡고 `.jsp` 는 안 잡힘 (매처는 solidlsp `ls_config.py` enum 하드코딩, 프로젝트별 확장자 override 없음). → `.jsp` 작업은 file 도구(read) + `replace_content`(regex) 사용. `find_symbol` 헛호출 금지.
- **`.html`/`.htm`**: html LS 대상이나 현재 프로젝트에 **0개**. (정적 페이지 추가 시 자동 심볼 지원, node v22 필요 — 설치됨.)
- **`.js`/`.css`**: 별도 파일 없음. JSP 내 인라인 `<script>`/`<style>` 만 존재 → JSP 와 동일하게 file/regex 도구.
## 제외 경로
`target/`·`build/` 는 `.gitignore` + `ignore_all_files_in_gitignore: true` 로 자동 제외. 추가 `ignored_paths` 불필요.
## 백엔드
`language_backend: LSP` (전역). JetBrains 아님.

View File

@ -1,7 +1,7 @@
# Task Completion — bibimbap
코딩 작업 완료 시:
1. `./mvnw test` 통과 (JUnit). `BibimbapApplicationTests` 는 컨텍스트 로드 — 유효 datasource(또는 스킵 조건) 필요.
1. `./mvnw test` 통과 (JUnit). `BibimbapApplicationTests` 는 컨텍스트 로드 — 유효 datasource(또는 스킵 조건) 필요. **MyBatis/DataSource autoconfigure 가 exclude 돼 있어 @Mapper 빈이 자동생성되지 않으므로, 컨트롤러가 주입하는 매퍼마다 `@MockBean` 을 수동 등록해야 한다.** 신규 컨트롤러를 추가하거나 컨트롤러의 매퍼 의존을 늘리면 `BibimbapApplicationTests` 에 해당 매퍼 `@MockBean` 을 추가하지 않는 한 `contextLoads``NoSuchBeanDefinitionException` 으로 실패한다. `test-compile` 만으로는 못 잡으므로 반드시 full `./mvnw test` 로 확인 (W3-2 세션 20260618 회귀 실증).
2. `./mvnw clean package` 로 WAR 빌드 확인 — JSP/컴파일 오류는 단위테스트가 못 잡으므로 패키지까지 돌려 확인.
3. 린터/포매터 없음 (spotless/checkstyle 미설정) — 실행할 것 없음.
4. DB 스키마 변경 시: `src/test/db/dev-to-live-update.sql` + `DbUpdateQueryGeneratorTest` 패턴 참조 (dev→live 마이그레이션 쿼리 생성).

View File

@ -33,6 +33,10 @@ project_name: "bibimbap"
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- java
- html
- bash
- json
- yaml
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings

View File

@ -12,6 +12,8 @@
## 작업 원칙
- 사용자 변경을 보호한다. 작업 전후로 `git status --short`를 확인한다.
- 비-메인스트림 브랜치(메인스트림 = `main`/`master` 및 보호 브랜치, 그 외 `feat/*`·`fix/*`·`chore/*`·`docs/*` 등 작업 브랜치)에서는 커밋이 표준 승인된다. 작업 단위가 끝날 때마다 사용자에게 매번 묻지 않고 커밋하며, 메시지 규약·커밋 단위·트레일러는 `docs/development/git-workflow.md`를 따른다. `push`는 브랜치와 무관하게 사용자 명시 요청 시에만 수행한다.
- 메인스트림 브랜치에서는 커밋 표준 승인이 적용되지 않는다. 기본 브랜치(`main`) 위라면 먼저 작업 브랜치를 생성한 뒤 작업한다.
- 검색은 우선 `rg`를 사용한다.
- 문서-only 분석 요청에서는 `src/``pom.xml`을 수정하지 않는다.
- 보안 발견 사항은 `docs/analysis/``file:line` 근거와 함께 기록한다.

66
db/apply-local-ddl.sh Executable file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# =============================================================================
# db/apply-local-ddl.sh
# -----------------------------------------------------------------------------
# 권위 DDL(docs/*-ddl.sql)을 실행 중인 로컬 DB 컨테이너에 즉시 적용한다.
#
# 배경: 이 프로젝트는 flyway/liquibase 가 없다(docs/usage/local-setup.md §4).
# db/schema.sql 은 컨테이너 *최초* 기동 시 docker-entrypoint-initdb.d 로 1회만
# 자동 주입되므로, 그 이후 docs/*-ddl.sql 로 추가된 스키마 변경은 실행 DB 에
# 자동 반영되지 않는다. 데이터를 잃는 `docker compose down -v` 대신, 이 스크립트로
# 변경분을 실행 DB 에 비파괴 적용해 곧바로 로컬 테스트할 수 있다.
#
# 안전성: 모든 docs/*-ddl.sql 은 멱등(IF NOT EXISTS / DO $$ guard / CREATE OR
# REPLACE)하게 작성한다. 따라서 전체를 몇 번 재실행해도 기존 객체는 NOTICE 후
# skip 되고 누락분만 생성된다. ON_ERROR_STOP=1 로 첫 에러에서 즉시 중단한다.
#
# 사용:
# db/apply-local-ddl.sh # docs/*-ddl.sql 전체 적용(기본)
# db/apply-local-ddl.sh docs/game-reviews-ddl.sql # 특정 파일만 적용
#
# 환경변수 override(기본값은 .env 에서 로드):
# DB_CONTAINER (기본 bibimbap-db) / PG_USER / PG_DB / PG_SCHEMA(기본 dev)
# =============================================================================
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
# .env 에서 기본 접속 정보 로드(있으면). 없으면 컨테이너 기본값 사용.
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
CONTAINER="${DB_CONTAINER:-bibimbap-db}"
PGUSER_="${PG_USER:-${POSTGRES_USER:-bibimbap}}"
PGDB_="${PG_DB:-${POSTGRES_DB:-bibimbap}}"
SCHEMA="${PG_SCHEMA:-${APP_SCHEMA:-dev}}"
# 적용 대상: 인자가 있으면 그 파일들, 없으면 docs/*-ddl.sql 전체.
if [ "$#" -gt 0 ]; then
files=("$@")
else
files=(docs/*-ddl.sql)
fi
# 컨테이너 가동 확인.
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "✗ DB 컨테이너 '$CONTAINER' 가 떠 있지 않다. 먼저: docker compose up -d db" >&2
exit 1
fi
echo "▶ 대상 컨테이너=$CONTAINER db=$PGDB_ schema=$SCHEMA"
for f in "${files[@]}"; do
if [ ! -f "$f" ]; then
echo "✗ 파일 없음: $f" >&2
exit 1
fi
echo "▶ 적용: $f"
# docs/*-ddl.sql 은 unqualified 테이블명을 쓰므로 search_path 로 스키마를 강제한다.
docker exec -e PGOPTIONS="-c search_path=$SCHEMA" -i "$CONTAINER" \
psql -U "$PGUSER_" -d "$PGDB_" -v ON_ERROR_STOP=1 -f - < "$f"
done
echo "✓ 완료 — docs/*-ddl.sql → '$SCHEMA' 스키마 적용됨"

View File

@ -108,6 +108,114 @@ CREATE TABLE IF NOT EXISTS "game_comments" (
);
ALTER SEQUENCE "game_comments_id_seq" OWNED BY "game_comments"."id";
-- game_comments user_id 컬럼 (W3-2: 작성자 귀속, nullable 비파괴)
ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint REFERENCES "users" ("id");
-- ---------------------------------------------------------------------------
-- game_reviews (권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규)
-- ---------------------------------------------------------------------------
CREATE SEQUENCE IF NOT EXISTS "game_reviews_id_seq";
CREATE TABLE IF NOT EXISTS "game_reviews" (
"id" bigint DEFAULT nextval('game_reviews_id_seq'::regclass) NOT NULL,
"game_id" bigint NOT NULL REFERENCES "games" ("id"),
"user_id" bigint NOT NULL REFERENCES "users" ("id"),
"rating" smallint NOT NULL,
"body" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"is_delete" boolean DEFAULT false NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "game_reviews_rating_check" CHECK ("rating" BETWEEN 1 AND 5)
);
ALTER SEQUENCE "game_reviews_id_seq" OWNED BY "game_reviews"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_reviews_game_user_active"
ON "game_reviews" ("game_id", "user_id") WHERE "is_delete" IS NOT TRUE;
CREATE INDEX IF NOT EXISTS "idx_game_reviews_game"
ON "game_reviews" ("game_id") WHERE "is_delete" = false;
-- C3 재분류: 다축(육각형) game_review_axes + 집계뷰 game_review_stats 는 아래에 W3-2 일반 DDL 로 신설(roadmap.md:63,201,202, W2-3 동결 무관).
-- ---------------------------------------------------------------------------
-- game_review_axes / is_rating_manual / updated_at / game_review_stats (W3-2 고도화, docs/game-reviews-ddl.sql 와 동일)
-- 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축평균';
-- ---------------------------------------------------------------------------
-- game_likes (비권위 복원본 — 매퍼는 hard delete 사용, is_delete 컬럼 없음)
-- ---------------------------------------------------------------------------

39
db/seed-dev-teardown.sql Normal file
View File

@ -0,0 +1,39 @@
-- =============================================================================
-- bibimbap dev seed teardown — seed-dev.sql 로 만든 더미 데이터만 정리
-- =============================================================================
-- seed-dev.sql 이 생성한 더미(@bibimbap.local 유저 + '테스트 게임 (더미)' + 그
-- 게임의 리뷰/댓글)만 hard delete 한다. 운영성 데이터는 건드리지 않는다.
--
-- 적용:
-- docker exec -i bibimbap-db psql -U bibimbap -d bibimbap -v ON_ERROR_STOP=1 < db/seed-dev-teardown.sql
-- PGPASSWORD=change_me_local_dev psql -h localhost -p 5433 -U bibimbap -d bibimbap -f db/seed-dev-teardown.sql
--
-- FK 의존 역순 삭제: reviews/comments/likes → games → auth_identities → users.
-- =============================================================================
SET search_path TO dev;
DO $$
DECLARE
v_game_id bigint;
BEGIN
SELECT id INTO v_game_id FROM games WHERE name = '테스트 게임 (더미)' LIMIT 1;
IF v_game_id IS NOT NULL THEN
DELETE FROM game_reviews WHERE game_id = v_game_id;
DELETE FROM game_comments WHERE game_id = v_game_id;
DELETE FROM game_likes WHERE game_id = v_game_id;
DELETE FROM games WHERE id = v_game_id;
END IF;
DELETE FROM user_auth_identities
WHERE user_id IN (SELECT id FROM users WHERE canonical_email LIKE '%@bibimbap.local');
DELETE FROM users WHERE canonical_email LIKE '%@bibimbap.local';
RAISE NOTICE 'seed-dev teardown 완료 (game_id=%)', v_game_id;
END $$;
SELECT
(SELECT count(*) FROM users WHERE canonical_email LIKE '%@bibimbap.local') AS remaining_dummy_users,
(SELECT count(*) FROM games WHERE name = '테스트 게임 (더미)') AS remaining_dummy_games;

159
db/seed-dev.sql Normal file
View File

@ -0,0 +1,159 @@
-- =============================================================================
-- bibimbap dev seed — 리뷰/댓글(W3-2) 기능 테스트용 더미 상황
-- =============================================================================
-- 목적: 새로 추가된 게임 리뷰/댓글 기능을 실제로 테스트할 수 있는 더미 데이터.
-- 1) 로그인 가능한 테스트 계정 1개 (직접 로그인 → 리뷰/댓글 작성 테스트용)
-- 2) 표시되는 더미 게임 1개 (테스트 계정 소유)
-- 3) 이미 등록된 더미 리뷰 5개 (각자 다른 더미 유저 — game당 user 1리뷰 제약 때문)
-- + 보너스: 더미 댓글 3개
--
-- 멱등(idempotent): 같은 이메일/게임명 기준 NOT EXISTS 가드 → 재실행해도 중복 미생성.
--
-- 적용 (가동중인 컨테이너):
-- docker exec -i bibimbap-db psql -U bibimbap -d bibimbap -v ON_ERROR_STOP=1 < db/seed-dev.sql
-- 적용 (호스트 psql, 포트 5433):
-- PGPASSWORD=change_me_local_dev psql -h localhost -p 5433 -U bibimbap -d bibimbap -f db/seed-dev.sql
--
-- 로그인 자격: tester@bibimbap.local / test1234! (provider='email')
-- password_hash 는 UserController.hashPassword 와 동일 알고리즘으로 사전 생성:
-- PBKDF2WithHmacSHA256 / 210000 iter / 256-bit key / 16-byte salt,
-- 포맷 = pbkdf2_sha256$<iter>$<base64 salt>$<base64 hash>.
--
-- 되돌리기 (이 더미만 정리): db/seed-dev-teardown.sql 참고.
-- =============================================================================
SET search_path TO dev;
DO $$
DECLARE
v_tester_id bigint;
v_game_id bigint;
v_uid bigint;
-- test1234! 의 사전 생성 해시 (위 알고리즘과 동일).
c_pw_hash text := 'pbkdf2_sha256$210000$QJ5MV+dx3aECELuuh3ibIA==$/59CO6JWXXGXWW0hzqpnZwu89qirDozP0jlq3JzEwZw=';
r RECORD;
BEGIN
-- -------------------------------------------------------------------------
-- 1) 로그인 테스트 계정 (users + email identity)
-- -------------------------------------------------------------------------
SELECT id INTO v_tester_id
FROM users
WHERE canonical_email = 'tester@bibimbap.local' AND is_delete IS NOT TRUE
LIMIT 1;
IF v_tester_id IS NULL THEN
INSERT INTO users (display_name, canonical_email, role, status)
VALUES ('테스터', 'tester@bibimbap.local', 'USER', 'ACTIVE')
RETURNING id INTO v_tester_id;
END IF;
IF NOT EXISTS (
SELECT 1 FROM user_auth_identities
WHERE provider = 'email' AND provider_user_id = 'tester@bibimbap.local'
AND is_delete IS NOT TRUE
) THEN
INSERT INTO user_auth_identities
(user_id, provider, provider_user_id, email, password_hash, display_name)
VALUES
(v_tester_id, 'email', 'tester@bibimbap.local', 'tester@bibimbap.local', c_pw_hash, '테스터');
END IF;
-- -------------------------------------------------------------------------
-- 2) 더미 게임 (테스트 계정 소유, 목록/상세에 표시되도록 visible)
-- webgl_path / thumbnail_url 은 NULL — game-detail.jsp 가 빈 src 로 안전 처리.
-- -------------------------------------------------------------------------
SELECT id INTO v_game_id
FROM games
WHERE name = '테스트 게임 (더미)' AND is_delete IS NOT TRUE
LIMIT 1;
IF v_game_id IS NULL THEN
INSERT INTO games (user_id, name, creator_note, is_visible, sort_order)
VALUES (
v_tester_id,
'테스트 게임 (더미)',
'리뷰/댓글 기능 테스트용 더미 게임입니다. 자유롭게 리뷰와 댓글을 남겨보세요.',
true,
(SELECT COALESCE(MAX(sort_order), 0) + 1 FROM games WHERE is_delete IS NOT TRUE)
)
RETURNING id INTO v_game_id;
END IF;
-- -------------------------------------------------------------------------
-- 3) 더미 리뷰어 5명 + 각자 리뷰 1건 (game당 user 1리뷰 unique 제약 충족)
-- created_at 을 days_ago 만큼 과거로 스태거 → 목록(최신순) 정렬이 자연스럽게.
-- -------------------------------------------------------------------------
FOR r IN
SELECT * FROM (VALUES
('김플레이', 'reviewer1@bibimbap.local', 5, '그래픽이 깔끔하고 조작감이 좋아요. 가볍게 즐기기 딱 좋습니다. 추천!', 5),
('이도전', 'reviewer2@bibimbap.local', 4, '아이디어가 신선했습니다. 난이도 밸런스만 조금 더 다듬으면 완벽할 듯해요.', 4),
('박캐주얼', 'reviewer3@bibimbap.local', 3, '무난하게 즐길 만한 게임. 다만 후반부가 살짝 반복적으로 느껴졌어요.', 3),
('최열정', 'reviewer4@bibimbap.local', 5, '시간 가는 줄 모르고 플레이했네요. BGM이 특히 인상적이었습니다!', 2),
('정라이트', 'reviewer5@bibimbap.local', 2, '초반 튜토리얼이 불친절해서 적응이 조금 어려웠어요. 보완되면 좋겠습니다.', 1)
) AS t(nickname, email, rating, body, days_ago)
LOOP
SELECT id INTO v_uid
FROM users
WHERE canonical_email = r.email AND is_delete IS NOT TRUE
LIMIT 1;
IF v_uid IS NULL THEN
INSERT INTO users (display_name, canonical_email, role, status)
VALUES (r.nickname, r.email, 'USER', 'ACTIVE')
RETURNING id INTO v_uid;
END IF;
IF NOT EXISTS (
SELECT 1 FROM game_reviews
WHERE game_id = v_game_id AND user_id = v_uid AND is_delete IS NOT TRUE
) THEN
INSERT INTO game_reviews (game_id, user_id, rating, body, created_at, updated_at)
VALUES (
v_game_id, v_uid, r.rating, r.body,
now() - make_interval(days => r.days_ago),
now() - make_interval(days => r.days_ago)
);
END IF;
END LOOP;
-- -------------------------------------------------------------------------
-- 보너스) 더미 댓글 3건 (리뷰어 유저 귀속, created_at ASC 정렬용 스태거)
-- -------------------------------------------------------------------------
FOR r IN
SELECT * FROM (VALUES
('reviewer1@bibimbap.local', '오 이거 재밌네요 ㅎㅎ', 3),
('reviewer3@bibimbap.local', '다음 업데이트도 기대할게요!', 2),
('reviewer5@bibimbap.local', '버그 제보: 가끔 화면이 멈춰요.', 1)
) AS t(email, content, days_ago)
LOOP
SELECT id INTO v_uid
FROM users
WHERE canonical_email = r.email AND is_delete IS NOT TRUE
LIMIT 1;
IF v_uid IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM game_comments
WHERE game_id = v_game_id AND user_id = v_uid AND content = r.content
AND is_delete IS NOT TRUE
) THEN
INSERT INTO game_comments (game_id, user_id, nickname, content, created_at)
VALUES (
v_game_id, v_uid,
(SELECT display_name FROM users WHERE id = v_uid),
r.content,
now() - make_interval(days => r.days_ago)
);
END IF;
END LOOP;
RAISE NOTICE 'seed-dev 완료: tester_id=%, game_id=%', v_tester_id, v_game_id;
END $$;
-- 적용 결과 확인용 요약 (psql 실행 시 출력).
SELECT
(SELECT count(*) FROM users WHERE canonical_email LIKE '%@bibimbap.local' AND is_delete IS NOT TRUE) AS dummy_users,
(SELECT count(*) FROM games WHERE name = '테스트 게임 (더미)' AND is_delete IS NOT TRUE) AS dummy_games,
(SELECT count(*) FROM game_reviews r JOIN games g ON g.id = r.game_id
WHERE g.name = '테스트 게임 (더미)' AND r.is_delete IS NOT TRUE) AS dummy_reviews,
(SELECT count(*) FROM game_comments c JOIN games g ON g.id = c.game_id
WHERE g.name = '테스트 게임 (더미)' AND c.is_delete IS NOT TRUE) AS dummy_comments;

View File

@ -216,3 +216,4 @@ Java/Maven 웹앱(Spring Boot 3 MVC + MyBatis + PostgreSQL, JSP 뷰) 전면 read
- [db-update-query-generator.md](../db-update-query-generator.md) — `DbUpdateQueryGeneratorTest` 운영 도구(D3 테스트 항목 관련)
- [user-signup-schema.md](../user-signup-schema.md) — `users`/`user_auth_identities` 스키마 상세(D4 데이터 모델 관련)
- 카테고리 분류 기준: [development/document-category-classification.md](../development/document-category-classification.md)
- 이 분석을 기반으로 확정된 고도화 실행 계획: [work-log/2026-06-17-jam-platform-roadmap.md](../work-log/2026-06-17-jam-platform-roadmap.md)

View File

@ -0,0 +1,122 @@
---
kind: change
title: "W3-2 댓글/리뷰 분리 구현"
session_id: 20260618-104034
created_at: 2026-06-18
status: implemented
related_security_checklist: "../security/security-remediation-checklist.md"
related_work_log: "../work-log/2026-06-17-w3-feature-skeletons.md"
related_design: "../../.atp/work-session/20260618-104034/design.md"
---
# W3-2 댓글/리뷰 분리 구현 변경 이력
상위 골자: [W3 사이트 플랫폼 기능 골자 카탈로그](../work-log/2026-06-17-w3-feature-skeletons.md) §W3-2.
## 개요
기존 `game_comments`(닉네임 자유입력, localStorage 전용)를 서버 영속화 + 로그인 연동으로 전환하고,
별점 5점 + 서술 평가 형태의 `game_reviews` 도메인을 신설했다.
## 변경된 런타임 동작
### 1. 댓글 서버 영속화 전환
- **이전**: 댓글이 `localStorage`에만 저장. `game_comments` 매퍼·테이블은 고아 상태.
- **이후**: 댓글이 서버 DB `game_comments`에 영속. 새로고침·브라우저 변경 후에도 유지.
- 기존 localStorage 댓글은 마이그레이션하지 않음(보안 [hold] 정합 — QG-2 결정).
- `game_comments.user_id` bigint NULL FK 추가(비파괴, 레거시 nickname 레코드 보존).
- content 200자 앱레벨 검증 신규 적용(이전: 길이 제한 없음).
### 2. game_reviews 도메인 신설
- 신규 테이블 `game_reviews`: 게임당 1회 제한(partial UNIQUE WHERE is_delete IS NOT TRUE), rating smallint CHECK(1~5), body text.
- "수정됨" 상태: in-row(updated_at > created_at). 별도 이력 테이블 없음(열람 권한 확정 시 후속).
- 삭제: soft-delete(is_delete, deleted_at).
### 3. 권한 모델
- 댓글/리뷰 수정·삭제: 작성자 본인(`sessionUserId == 리소스.userId`) OR 운영자(`ROLE_ADMIN="ADMIN"`).
- 운영자 role 부여 경로는 W1 RBAC 연결 시 활성(현재 휴면 — 부여자 없음).
- 로그인 없는 쓰기 시도: 401.
- CSRF 토큰 없는 상태 변경: 403.
### 4. 게임 삭제 cascade 확장
- `GameController.deleteGame``softDeleteGameReviews` 추가.
- 게임 삭제 시 관련 리뷰도 soft-delete.
## 신규/변경 파일
### 신규 파일 (백엔드)
| 파일 | 설명 |
|---|---|
| `src/.../data/GameReviewData.java` | 리뷰 도메인 POJO |
| `src/.../mapper/GameReviewsMapper.java` | 리뷰 MyBatis 매퍼 |
| `src/.../controller/api/GameCommentController.java` | 댓글 CRUD API (C1~C4) |
| `src/.../controller/api/GameReviewController.java` | 리뷰 CRUD API (R1~R5) |
| `docs/game-reviews-ddl.sql` | 권위 DDL (멱등 ALTER, 기존 DB 적용용) |
| `src/test/.../GameCommentControllerTest.java` | 댓글 컨트롤러 단위 테스트 12건 |
| `src/test/.../GameReviewControllerTest.java` | 리뷰 컨트롤러 단위 테스트 13건 |
### 변경 파일
| 파일 | 변경 내용 |
|---|---|
| `src/.../data/GameCommentData.java` | userId 필드 추가 |
| `src/.../mapper/GameCommentsMapper.java` | user_id 기반 쿼리 확장 |
| `src/.../mapper/GamesMapper.java` | softDeleteGameReviews 추가 |
| `src/.../controller/api/GameController.java` | deleteGame cascade 확장, currentUserId/userRole 모델 노출 |
| `db/schema.sql` | game_reviews CREATE + game_comments user_id ALTER |
| `src/main/webapp/WEB-INF/views/game-detail.jsp` | 댓글 localStorage→서버 fetch 교체, 리뷰 UI 신규(별점 위젯·수정됨 마커·게임당1회·로그인 게이트) |
| `src/test/.../BibimbapApplicationTests.java` | GameCommentsMapper/GameReviewsMapper @MockBean 2개 추가 |
## 신규 API 9개
### 댓글 (GameCommentController)
| ID | 메서드 | 경로 | 설명 |
|---|---|---|---|
| C1 | GET | `/game/{id}/comments` | 댓글 목록 조회 |
| C2 | POST | `/game/{id}/comments` | 댓글 작성 (CSRF·로그인·200자 검증) |
| C3 | PUT | `/game/{id}/comments/{commentId}` | 댓글 수정 (작성자/운영자) |
| C4 | DELETE | `/game/{id}/comments/{commentId}` | 댓글 삭제 (작성자/운영자) |
### 리뷰 (GameReviewController)
| ID | 메서드 | 경로 | 설명 |
|---|---|---|---|
| R1 | GET | `/game/{id}/reviews` | 리뷰 목록 조회 |
| R2 | GET | `/game/{id}/reviews/{reviewId}` | 리뷰 단건 조회 |
| R3 | POST | `/game/{id}/reviews` | 리뷰 작성 (게임당 1회, CSRF·로그인·rating 검증) |
| R4 | PUT | `/game/{id}/reviews/{reviewId}` | 리뷰 수정 (수정됨 마커, 작성자/운영자) |
| R5 | DELETE | `/game/{id}/reviews/{reviewId}` | 리뷰 삭제 (soft-delete, 작성자/운영자) |
모든 상태 변경 API 공통 시퀀스: `@Transactional → CsrfTokens.isValid (403) → sessionUserId (401) → 검증 (400) → mapper → JSON`.
## 범위 밖 (미변경)
- **좋아요**: 여전히 localStorage 전용. 보안 체크리스트 B3 좋아요 항목 별도 관리.
- **운영자 role 부여**: ROLE_ADMIN 상수 정의만. 실제 부여 경로는 W1 RBAC 연결 시.
- **W2-3 집계 계약**: game_reviews가 평점 공급원(rating 컬럼)까지만. AVG/COUNT 집계·뷰 미생성(W2-6 시상 후속).
- **리뷰 이력 열람**: in-row 수정됨 마커만. 이력 테이블은 열람 권한 확정 시 후속.
## 검증 결과 요약
| 레이어 | 결과 |
|---|---|
| L1 단위 테스트 (31건) | PASS (GameCommentControllerTest 12 + GameReviewControllerTest 13 + UserControllerCsrfTest 5 + BibimbapApplicationTests 1) |
| AGG-1 엔드포인트 수 | PASS (Comment 4, Review 5) |
| AGG-3 CSRF 게이트 | PASS (합산 6개) |
| AGG-2 DDL 3종 | PASS |
| L3 브라우저 스모크 | needs_user_verification |
| DDL 적용 | needs_user_verification (docs/game-reviews-ddl.sql 참조) |
## 이월 항목
1. ~~W2-3 평점 집계 계약 (SELECT AVG/COUNT) — W2-6 시상 후속.~~**해소**: C3 재분류 확정(W2-3 동결 무관). game_review_stats 집계뷰 [2026-06-22 고도화에서 신설](./2026-06-22-w3-2-comments-reviews-enhancement.md#c3-재분류--직전-코어-이월-항목-해소-중요).
2. 운영자 role 부여 경로 — W1 RBAC/Interceptor 연결 시 활성.
3. ~~다축(육각형) 평점 — 현재 단일 rating, game_review_axes 분리 여지(주석).~~**해소**: [2026-06-22 고도화](./2026-06-22-w3-2-comments-reviews-enhancement.md)에서 game_review_axes 6축 + 육각형 SVG 레이더 구현 완료.
4. 리뷰 이력 열람 권한 — 열람 정책 확정 시 별도 이력 테이블.
5. GameCatalog 정적 폴백 게임 대상 댓글/리뷰 — DB 실제 게임만 기능 동작(폴백은 404).

View File

@ -0,0 +1,286 @@
---
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 주석 갱신)
**적용 상태**: needs_user_verification (dev: schema.sql 재부트 or ddl 수동 실행)
---
## 런타임 동작 변화
### 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 동일)
```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=<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 브라우저 스모크 | **needs_user_verification** | DDL 적용 후 dev 실게임 대상 |
| DDL 적용 | **needs_user_verification** | game_review_axes/stats/updated_at/is_rating_manual |
FIX LOOP 상세: `report.md#Invocations` implementation-advisor(FIX LOOP §2.6) — 근본원인=테스트 계약 미갱신(컨트롤러 무버그). body<10자 7곳10자+ 교체, 행위 assertion 보존, 계약 무약화. main 코드 무수정.
---
## 범위 밖 / 이월
| 항목 | 사유 |
|---|---|
| 신고·숨김 | W1 운영자 role 선행 필요 |
| 리뷰 이력 테이블 | in-row 마커(updated_at)만. 열람 권한 정책 확정 시 별도 |
| 좋아요 서버화 | 별도 관리 (여전히 localStorage) |
| GET /reviews/mine | applyReviewGate 정밀화 — 본인 리뷰 첫 페이지 밖 폼 노출 가능. 서버 409 최종 차단(비차단). 차기 세션 후보 |
| 댓글 더보기 정렬 정합 | 낙관 삽입 2페이지+ 정렬 어긋남 가능. 리뷰는 page0 재조회로 정합, 비차단. 차기 세션 후보 |
| 기존 리뷰 axes 백필 | 기존 game_reviews 행은 axes 0행 — stats 뷰 6축 평균 NULL. 백필 필요 시 별도 |

View File

@ -4,4 +4,5 @@
## 목록
_(아직 문서 없음)_
- [2026-06-18-w3-2-comments-reviews.md](./2026-06-18-w3-2-comments-reviews.md) — W3-2 댓글/리뷰 분리 구현. game_comments 서버 영속화 전환 + game_reviews 도메인 신설. 신규 API 9개(댓글 C1~C4, 리뷰 R1~R5), DDL 2종, 권한(작성자/운영자), cascade 확장. L1 31테스트 PASS. L3 스모크·DDL 적용은 needs_user_verification. 좋아요는 범위밖.
- [2026-06-22-w3-2-comments-reviews-enhancement.md](./2026-06-22-w3-2-comments-reviews-enhancement.md) — W3-2 고도화(코어 위에 얹음). 다축 평점(game_review_axes 6축·육각형 SVG 레이더) + A1~A3 일관성(commentView 통일·작성자 마스킹 QG-2 해결·edited/updatedAt) + B1~B3 목록규모(페이지네이션·본문10자·TextNormalizer) + C1~C6 UX + game_review_stats 집계뷰(C3 재분류 — W2-3 동결 무관 확정). 신규 4파일+변경 13. L1 43/43 GREEN. DDL·L3 스모크 needs_user_verification.

View File

@ -0,0 +1,38 @@
---
kind: development
title: 에이전트 출력 규약 — 사용자 대면 의사결정 제시
description: AI 에이전트(특히 ATP orchestrator)가 사용자에게 결정/선택을 요청할 때의 제시문 규약. 출력 압축(약어·ID 참조·표 과밀)의 적용 경계를 정한다.
status: active
created_at: 2026-06-18
source_session: 20260617-174635
---
# 에이전트 출력 규약 — 사용자 대면 의사결정 제시
재사용 규칙. AI 에이전트가 작업 중 사용자와 주고받는 출력의 스타일 경계를 정한다.
## 규칙 1 — 의사결정 제시문에는 출력 압축을 적용하지 않는다
**사용자에게 결정/선택/판정을 요청하는 제시문**(질문, `AskUserQuestion` 옵션 포함)은 항상 풀어 쓴다:
1. **배경** — 왜 묻는지, 무엇에 걸린 결정인지
2. **선택지** — 각 옵션을 ID·약어가 아닌 문장으로
3. **권장** — 있으면 이유와 함께
약어·ID 교차참조·과밀 표는 **보조로만** 병기한다. `AskUserQuestion` 팝업을 쓰더라도 옵션 라벨·설명은 **자기완결적**이어야 한다(외부 표를 봐야 이해되는 라벨 금지).
압축형(요약·약어 위주)은 사용자가 **명시 요청**("요약만", "짧게")했을 때만 쓴다.
### 압축이 허용되는 범위
출력 압축(caveman 류 토큰 다이어트 포함)은 **에이전트 간 내부 산출물·로그·요약**에 한정한다. 거기선 ROI 양수다. 사용자 대면 결정 제시에서는 파악 실패 → 재질의 왕복 비용이 압축 절감을 초과한다.
### Why
토큰을 줄이려는 압축 경향이 *사용자가 답을 줘야 하는* 의사결정 제시문으로 번지면, ID 참조·표는 작성자에겐 자명해도 사용자에겐 "무엇을 묻는지" 자체가 불투명해진다. 압축의 **적용 대상 경계**가 잘못 그어진 것이다.
### 발원 사례
W3 골자 합의 세션(`20260617-174635`)에서 orchestrator 가 W3-1 마무리 질문을 ID 약어(`C3`/`C6`/`(가)`)와 표로 과압축해 제시 → 사용자: "너무 축약적이라 W3-1에 대한 질문이 뭔질 모르겠어". 배경+선택지+권장 풀어쓰기로 전환하니 즉시 매끄럽게 답변. 첫 제시부터 풀어쓰기를 기본값으로 삼았다면 1회 왕복 비용이 없었다.
> 관련(다른 레포): ATP 번들의 출력 스타일/압축 규약에 동 예외를 명문화하자는 protocol_feedback 가 세션 회고에 기록됨. 적용 *대상* 축은 다르지만 "압축 적용 경계" 교훈은 `caveman-bundle-compression-roi-ceiling`(번들 정적 압축 ROI 천장)과 같은 계열.

View File

@ -0,0 +1,39 @@
# Git Workflow — 브랜치 / 커밋 / push 정책
반복 적용되는 git 작업 규칙의 정본(canonical)이다. CLAUDE.md '작업 원칙'의 커밋 관련 불릿은 이 문서를 가리킨다. 하니스 기본규칙("Commit or push only when the user asks. If on the default branch, branch first.")을 프로젝트 차원에서 보충·명시화한다.
## 브랜치 분류
- **메인스트림 브랜치**: `main`, `master`, 그리고 origin 상의 보호 브랜치(릴리스/배포 브랜치 등). 현재 프로젝트 default 는 `main`(`.git/config`, `origin/HEAD` 기준).
- **비-메인스트림 브랜치**: 위를 제외한 모든 작업 브랜치 — `feat/*`, `fix/*`, `chore/*`, `docs/*` 등. 현재 활성 작업 브랜치 `feat/v2` 가 여기 속한다.
## 커밋 표준 승인 (durable authorization)
- **비-메인스트림 브랜치에서 커밋은 표준 승인된 행위다.** 작업 단위가 완결될 때마다 사용자에게 매 건 묻지 않고 커밋한다. 이는 하니스 기본규칙의 '사용자 요청 시에만 커밋' 원칙을, 프로젝트 지침이 사전·상시 승인을 부여하는 형태로 만족시키는 것이다(자동 커밋 재량 위임이 아니라, 사용자가 부여한 표준 승인의 실행).
- **메인스트림 브랜치에는 표준 승인이 적용되지 않는다.** `main`/`master`/보호 브랜치 위에서는 직접 커밋하지 않고, 먼저 작업 브랜치를 생성한 뒤 비-메인스트림 규칙으로 진행한다.
- **`push` 는 브랜치와 무관하게 항상 사용자 명시 요청 시에만 수행한다.** 표준 승인은 로컬 커밋에 한정되며 원격 반영(push)·PR 생성은 포함하지 않는다.
- 작업 전후로 `git status --short` 로 사용자 변경을 보호한다(섞인 미관련 변경을 같은 커밋에 넣지 않는다).
## 커밋 단위
- 한 커밋은 하나의 논리적 변경으로 한정한다. 코드 변경과 그에 대한 문서/그래프 메타 갱신처럼 결합이 강한 산출물은 함께 묶되, 성격이 다른 변경(예: 기능 구현 vs 빌드 스크립트 vs 정책 문서)은 분리한다.
- 버그 수정 커밋은 `docs/development/verification-strategies.md` 의 회귀 테스트 의무를 따른다(재현 테스트 동반).
- `.atp/work-session/<timestamp>/` 산출물은 추적 대상이며, 해당 세션의 코드/문서 변경과 함께 또는 별도 `chore`/`docs` 커밋으로 기록한다.
## 커밋 메시지 규약
- **Conventional Commits** 형식을 사용한다: `type(scope): subject`. 사용 중인 type: `feat`, `fix`, `docs`, `chore`. scope 는 한국어 가능(예: `docs(graph)`, `chore(dev)`).
- subject 는 한국어로 변경의 핵심을 간결히 적는다(이모지 미사용).
- 본문은 '왜'가 자명하지 않을 때 추가하고, 변경 항목은 불릿으로 정리한다. 검증 결과(예: `./mvnw test N/N GREEN`)가 있으면 본문에 명시한다.
- 모든 에이전트 생성 커밋에는 트레일러를 포함한다:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
```
세션 추적이 필요하면 `Claude-Session: <url>` 트레일러를 추가할 수 있다.
## 참고
- 검증 의무(L1/L2/L3, 변경 범주별): `docs/development/verification-strategies.md`
- 에이전트 출력/압축 규약: `docs/development/agent-output-conventions.md`

View File

@ -4,7 +4,9 @@
## 목록
- [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용)
- [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용). 설계·테스트 단계 구조적 교훈(SSR 영향맵, fixture 전수 감사, freeze 분류 근거 확인, frontend-design fork 패턴) 포함.
- [document-category-classification.md](./document-category-classification.md) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리)
- [agent-output-conventions.md](./agent-output-conventions.md) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정
- [git-workflow.md](./git-workflow.md) — 브랜치 분류(메인스트림 vs 비-메인스트림) · 비-메인스트림 브랜치 커밋 표준 승인 · push 명시 요청 한정 · Conventional Commits + `Co-Authored-By` 트레일러 규약. CLAUDE.md '작업 원칙' 커밋 정책의 정본.
> atp 플러그인 번들 레퍼런스(`agent-team-protocol.md`, `agent-catalog.md`, `documentation-guidelines.md`, `search-tool-matrix.md`)는 플러그인 캐시에 있으며 이 프로젝트로 복사되지 않는다. 에이전트가 `${CLAUDE_PLUGIN_ROOT}/docs/...` 로 직접 참조한다.

View File

@ -26,6 +26,8 @@
**회귀 테스트 의무**: 버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다. revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다.
**신규 컨트롤러/매퍼 의존 변경 시 full test 의무**: 신규 컨트롤러를 추가하거나 컨트롤러의 매퍼 의존을 늘리면 implementation 단계에서 `test-compile` 만으로 끝내지 말고 반드시 full `./mvnw -o test` 를 실행한다. `BibimbapApplicationTests` 는 MyBatis/DataSource autoconfigure 가 exclude 된 컨텍스트라 컨트롤러가 주입하는 매퍼마다 `@MockBean` 을 수동 등록해야 하며, 누락 시 `contextLoads``NoSuchBeanDefinitionException` 으로 실패한다. 이 회귀는 `test-compile` 로는 탐지되지 않는다(W3-2 세션 20260618 실증).
### 실행 수단
프로젝트 루트에 통합 검증 스크립트를 둘 것을 권장한다 (예: `scripts/verify.sh`, `make verify`, `cargo xtask verify`). 스크립트는 L1 → L2 → 로그 스캔 순차 실행을 담당.
@ -115,3 +117,58 @@ strategies:
- 새 전략 추가: 위 YAML 블록에 항목 추가.
- Worker 분리가 필요한 수준에 도달 (브라우저 테스트, 장시간 E2E 등): `worker:` 필드 붙이고 해당 worker 파일 신설 + `verification-advisor.md` 의 tools 에 `Agent` 추가.
- 기준은 [agent-team-protocol.md §9 확장 트리거 레지스트리](./agent-team-protocol.md#9-확장-트리거-레지스트리).
## 설계·테스트 단계 체크리스트 (구조적 교훈)
세션 회고에서 수용된 재현성 있는 교훈을 규칙형으로 기재한다. 이 섹션은 누적 append-only.
### 영향맵에 SSR 호출지점 포함
매퍼 메서드 시그니처·data 클래스 필드 등 **심볼을 변경할 때**, design 단계 파일 영향맵은 API 컨트롤러뿐 아니라 그 심볼을 호출하는 **SSR/뷰모델 지점**(예: Spring MVC `@Controller` 의 뷰 렌더 메서드)도 `rg` 전수 확인으로 포함해야 한다. 누락 시 implementation 단계 컴파일 깨짐.
> 근거: W3-2 고도화 세션(20260622) — `GameController.gameDetail` SSR 호출지점을 설계 영향맵에서 누락 → 컴파일 깨짐, implementation-advisor 직접 보정.
### 계약 강화 시 기존 fixture 전수 감사
입력 검증 계약을 강화(최소 길이·필수 필드 추가)하면, 신규 테스트 케이스 추가만으로 부족하다. **기존 테스트 fixture 전수**가 새 계약에 정합하는지 감사하는 단계를 W-TEST 체크리스트에 명시해야 한다.
감사 절차:
1. 새로 도입된 검증 계약 목록화 (최소길이·필수필드·enum 범위 등).
2. 기존 테스트 fixture(요청 본문·파라미터) 전수 스캔 — 신규 계약 조건 충족 여부 확인.
3. 미충족 fixture 갱신 (행위 assertion 보존 전제).
4. 거부 경로 테스트의 fixture 도 신규 계약을 충족하는 값으로 올려 실제 거부 사유(상위 분기)까지 도달함을 보장.
> 근거: W3-2 고도화 세션(20260622) — B2(본문 10자)·6축 필수 계약 도입 시 기존 fixture 8건이 미갱신되어 L1 RED. 컨트롤러는 무버그.
### freeze/동결 분류는 근거 문서 확인 선행
어떤 변경을 "동결 영역 해제·고위험 게이트"로 분류하기 전에, 동결 범위를 정의한 **근거 문서(ADR·roadmap·work-log)를 줄 번호까지 직접 확인**한다. 표면적 유사성("평점 집계" 등)만으로 freeze 인접 추론 금지.
절차:
1. 동결 선언 근거 문서를 실제로 열어 동결 범위 정의를 줄 번호로 확인.
2. 변경 대상 테이블/뷰/심볼이 그 범위에 **명시적으로** 포함되는지 판단.
3. 포함 확인 시만 §6 게이트 표기.
> 근거: W3-2 고도화 세션(20260622) — `game_review_stats` 집계뷰가 phantom 고위험 게이트로 오분류 → `roadmap:63/201/202` 직접 확인으로 일반 DDL 정정.
### (긍정 패턴) frontend-design 스킬 fork 위임
production-grade UI(SVG·a11y·다중 JS 인터랙션 포함)를 구현할 때, `frontend-design` 스킬을 **fork(컨텍스트 상속)로 서브에이전트에 위임**하면 스킬 호출 + 단일파일 폴리시 + 프리뷰 render-verify 를 컨텍스트 오염 없이 수행 가능하다. 검증된 패턴.
조건:
- L1 전체 GREEN 확인 후 진입.
- 단일파일 폴리시: JSP 1파일 안에서 완결(신규 파일 0).
- 가드레일 명시: 기존 JS 로직 보존 / a11y / BE API 계약 무변경 / 외부 JS 라이브러리·CDN 도입 금지.
- 산출물에 프리뷰 HTML 포함(`artifacts/`).
> 근거: W3-2 고도화 세션(20260622) — 육각형 SVG 레이더·6축 radiogroup·C1~C6 를 fork 위임으로 단일 JSP 파일 승격 + L1 43/43 GREEN 유지.
---
## 프로토콜 개선 권고 (외부 번들 — 미적용)
아래 항목은 ATP 플러그인 번들(`~/.claude` 전역) 대상이다. 본 프로젝트 파일에서 직접 수정하지 않고 기록만 한다.
- **design-advisor 체크리스트**: §1 파일 영향맵 작성 규약에 "SSR 컨트롤러·뷰모델 호출지점 포함" 항목 추가 필요.
- **W-TEST worker 지시**: 컨트롤러 검증 계약 강화 시 "기존 fixture 전수 계약 정합 감사" 단계를 의무 체크리스트 항목으로 포함 필요.
- **agent-team-protocol §6 게이트 분류**: "근거 문서 확인 없이 freeze 인접 = 고위험 추론 금지" 조항 추가 권고.

186
docs/game-reviews-ddl.sql Normal file
View File

@ -0,0 +1,186 @@
-- Game reviews (single star rating 1~5 + free-text body, one per game per user).
-- PostgreSQL DDL aligned with the existing bibimbap table style (recruit-posts-ddl.sql).
-- W3-2: 댓글/리뷰 분리. game_reviews 신규 + game_comments user_id 컬럼 추가.
CREATE SEQUENCE IF NOT EXISTS "game_reviews_id_seq";
CREATE TABLE IF NOT EXISTS "game_reviews" (
"id" bigint DEFAULT nextval('game_reviews_id_seq'::regclass) NOT NULL,
"game_id" bigint NOT NULL,
"user_id" bigint NOT NULL,
"rating" smallint NOT NULL,
"body" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"is_delete" boolean DEFAULT false NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_reviews_id_seq" OWNED BY "game_reviews"."id";
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'game_reviews_game_id_fkey'
) THEN
ALTER TABLE "game_reviews"
ADD CONSTRAINT "game_reviews_game_id_fkey"
FOREIGN KEY ("game_id") REFERENCES "games" ("id");
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'game_reviews_user_id_fkey'
) THEN
ALTER TABLE "game_reviews"
ADD CONSTRAINT "game_reviews_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'game_reviews_rating_check'
) THEN
ALTER TABLE "game_reviews"
ADD CONSTRAINT "game_reviews_rating_check"
CHECK ("rating" BETWEEN 1 AND 5);
END IF;
END
$$;
-- 게임당 사용자 1회 (active 리뷰 한정 — soft-delete 후 재작성 허용).
CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_reviews_game_user_active"
ON "game_reviews" ("game_id", "user_id")
WHERE "is_delete" IS NOT TRUE;
-- 목록 조회 + 후속 집계 SELECT 의 game_id 필터용. (C3 재분류: 집계뷰 game_review_stats 는 본 파일 하단에 W3-2 일반 DDL 로 신설됨 — roadmap.md:63,201,202. W2-3 동결 무관.)
CREATE INDEX IF NOT EXISTS "idx_game_reviews_game"
ON "game_reviews" ("game_id")
WHERE "is_delete" = false;
COMMENT ON TABLE "game_reviews" IS '게임 리뷰. 게임당 사용자 1회, 별점 1~5 + 서술 평가';
COMMENT ON COLUMN "game_reviews"."id" IS '리뷰 고유 ID';
COMMENT ON COLUMN "game_reviews"."game_id" IS '대상 게임 games.id';
COMMENT ON COLUMN "game_reviews"."user_id" IS '리뷰 작성자 users.id (로그인 필수)';
COMMENT ON COLUMN "game_reviews"."rating" IS '별점 5점 단일 (1~5). 향후 다축은 별도 game_review_axes 테이블로 분리';
COMMENT ON COLUMN "game_reviews"."body" IS '서술 평가 (앱레벨 1,000자 제한, DB 무제한)';
COMMENT ON COLUMN "game_reviews"."created_at" IS '리뷰 작성 시각';
COMMENT ON COLUMN "game_reviews"."updated_at" IS '리뷰 마지막 수정 시각. updated_at > created_at 이면 수정됨';
COMMENT ON COLUMN "game_reviews"."deleted_at" IS '리뷰 삭제 시각';
COMMENT ON COLUMN "game_reviews"."is_delete" IS '소프트 삭제 여부';
-- ===========================================================================
-- 기존 DB 적용용 idempotent ALTER (security-hardening-ddl.sql DO $$ 패턴)
-- ---------------------------------------------------------------------------
-- game_comments user_id 컬럼 (W3-2: 작성자 귀속, nullable 비파괴).
-- 위 game_reviews CREATE/제약/인덱스는 전부 IF NOT EXISTS / DO $$ 멱등 → 재실행 안전.
-- ===========================================================================
ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'game_comments_user_id_fkey'
) THEN
ALTER TABLE "game_comments"
ADD CONSTRAINT "game_comments_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
END IF;
END
$$;
COMMENT ON COLUMN "game_comments"."user_id" IS '덧글 작성자 users.id (nullable — 레거시 닉네임 덧글 보존)';
-- ===========================================================================
-- 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축평균';

View File

@ -1,7 +1,7 @@
---
kind: graphify-meta
last_generated_at: 2026-06-17T11:43:45+0900
source_commit: 33a4465
last_generated_at: 2026-06-22T12:40:00+0900
source_commit: b9d836d
scopes:
- src
- docs
@ -38,8 +38,8 @@ scope 예시: `src`, `src-features`, `docs`, `full` 등. 한 번에 여러 scope
| scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 |
| --- | --- | --- | --- | --- |
| `src` | 2026-06-16 | `33a4465` | `src/` (Java 31 + AST) | 384 노드 / 613 엣지 / 23 커뮤니티. Spring MVC 컨트롤러→매퍼→data 흐름, 모집·게임·인증 도메인 군집. 정적 이미지 제외. |
| `docs` | 2026-06-17 | `8c41b22` | `docs/` (md 24 + DDL 2) | 92 노드 / 122 엣지 / 10 커뮤니티. 문서 카테고리 체계 + DB 스키마/모집글 + 보안·검증 + ATP/graphify 메타 군집. **로컬 개발환경 셋업** 군집(Docker/호스트/스키마부트스트랩/TLS프록시CA/SNAPSHOT) 신규 추가. |
| `src` | 2026-06-22 | `b9d836d` | `src/` (Java 42 + AST) | 536 노드 / 1206 엣지 / 20 커뮤니티. Spring MVC 컨트롤러→매퍼→data 흐름. **W3-2 댓글/리뷰 고도화 반영**: 리뷰 API 다축평점(loadAxes/buildSummary/normalizeReviewSort) · 댓글 API commentView · 댓글 데이터 모델 edited/updatedAt 군집. 정적 이미지 제외. |
| `docs` | 2026-06-22 | `b9d836d` | `docs/` (md 28 + DDL 3) | 119 노드 / 164 엣지 / 15 커뮤니티. 문서 카테고리 체계 + 보안·검증 + 플랫폼 로드맵 군집. **W3-2 고도화 반영**: C3 재분류·육각형 SVG·game_review_axes/game_review_stats·TextNormalizer 군집 + game_reviews 계열 DB 스키마 군집 + 검증·설계 교훈 군집. |
## 갱신 시 체크리스트

View File

@ -90,31 +90,36 @@
- 게임 삭제 시 댓글/좋아요 데이터 정리 로직도 있다. `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:243`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:244`
- 현재 UI는 좋아요와 댓글을 `localStorage`에만 저장한다. `src/main/webapp/WEB-INF/views/game-detail.jsp:812`, `src/main/webapp/WEB-INF/views/game-detail.jsp:830`, `src/main/webapp/WEB-INF/views/game-detail.jsp:913`, `src/main/webapp/WEB-INF/views/game-detail.jsp:928`
의도 확인:
의도 확인 (댓글 결정 완료 / 좋아요 미결 유지):
- [hold] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다.
- [hold] 댓글을 로그인 사용자만 허용할지, 익명 닉네임 댓글을 허용할지 결정한다.
- [hold] 기존 localStorage 댓글/좋아요를 서버로 마이그레이션할지, 신규 서버 데이터로만 전환할지 결정한다.
- [hold] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. ← **좋아요는 범위 밖, 미결 유지.**
- [x] 댓글을 로그인 사용자만 허용할지, 익명 닉네임 댓글을 허용할지 결정한다. → **로그인 사용자만(서버 영속화, session userId 귀속). 기존 닉네임 레코드는 user_id=NULL 보존(비파괴, QG-2).** W3-2 세션(20260618-104034) 확정.
- [x] 기존 localStorage 댓글을 서버로 마이그레이션할지, 신규 서버 데이터로만 전환할지 결정한다. → **비마이그레이션(신규 서버 데이터로만 전환).** 기존 localStorage 댓글 소멸. W3-2 세션 확정.
- [hold] 기존 localStorage 좋아요 처리 방침 — **좋아요는 범위 밖, 미결 유지.**
체크리스트:
- [ ] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다.
- [ ] 좋아요 추가/취소는 CSRF 검증을 적용한다.
- [ ] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다.
- [ ] `games.like_count` 증감은 race condition 없이 처리한다.
- [ ] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다.
- [ ] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다.
- [ ] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다.
- [ ] 서버에서 내려온 댓글도 JSP escape 또는 DOM `textContent`로 렌더링한다.
- [ ] 남용 방지를 위해 rate limit, 로그인 제한, 운영 신고/삭제 정책 중 최소 한 가지를 결정한다.
- [ ] localStorage UI는 서버 응답 기준으로 교체한다.
> **좋아요 항목(101~104)은 범위 밖 — 미충족 유지. 댓글 항목만 W3-2에서 충족.**
- [ ] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다. ← **좋아요: 미착수(범위 밖)**
- [ ] 좋아요 추가/취소는 CSRF 검증을 적용한다. ← **좋아요: 미착수(범위 밖)**
- [ ] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. ← **좋아요: 미착수(범위 밖)**
- [ ] `games.like_count` 증감은 race condition 없이 처리한다. ← **좋아요: 미착수(범위 밖)**
- [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] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. → **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 스모크).
- [ ] 남용 방지를 위해 rate limit, 로그인 제한, 운영 신고/삭제 정책 중 최소 한 가지를 결정한다. ← **로그인 제한 적용됨(댓글 쓰기 로그인 필수). rate limit/신고 정책은 이월.**
- [x] localStorage UI는 서버 응답 기준으로 교체한다. → **game-detail.jsp 댓글 localStorage → fetch API 서버 응답 기준으로 전환.** L1 PASS(20260618-104034).
완료 조건:
- [ ] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다.
- [ ] 토큰 없는 좋아요/댓글 변경 요청이 실패한다.
- [ ] XSS payload 댓글이 스크립트로 실행되지 않는다.
- [ ] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다.
- [~] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L1 단위 서버 저장 확인 완료. 브라우저 재방문 영속은 L3 스모크 대기(needs_user_verification).** 좋아요: localStorage 유지(범위 밖, 미충족).
- [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요 CSRF는 범위 밖.
- [~] XSS payload 댓글이 스크립트로 실행되지 않는다. → **textContent 렌더 적용(단위 컨트롤러 raw 반환 확인). 브라우저 렌더 미실행 확인은 L3 스모크 대기.**
- [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)
## B4. 의존성/세션/운영 하드닝

View File

@ -151,6 +151,35 @@ $ ./mvnw -P dev spring-boot:run
> 경고: `db/schema.sql` 의 5개 비권위 테이블(users / games / game_comments / game_likes 및 user_auth_identities 의 추론 부분)은 운영 DB `pg_dump` 와 대조하기 전까지 타입을 신뢰하지 말 것. §7 미해결 항목 참조.
### 4.1 신규/변경 DDL 을 실행 중 로컬 DB 에 즉시 적용
**핵심 사실**: `db/schema.sql` 은 컨테이너 **최초 기동 시 1회만** `docker-entrypoint-initdb.d` 로 자동 주입된다. 그 이후 `docs/*-ddl.sql` 로 추가된 스키마 변경은 **실행 중인 DB 에 자동 반영되지 않는다.** flyway/liquibase 가 없으므로(§4) 변경분을 수동으로 적용해야 한다.
`docker compose down -v` 재기동은 스키마를 다시 주입하지만 **로컬 데이터가 전부 소실**된다. 데이터를 보존하면서 변경분만 비파괴 적용하려면 다음 헬퍼를 쓴다.
```bash
# 권위 DDL(docs/*-ddl.sql) 전체를 실행 컨테이너의 dev 스키마에 멱등 적용.
# 모든 docs/*-ddl.sql 은 IF NOT EXISTS / DO $$ / CREATE OR REPLACE 로 작성되어
# 몇 번 재실행해도 기존 객체는 skip 되고 누락분만 생성된다.
db/apply-local-ddl.sh # 전체 적용(기본)
db/apply-local-ddl.sh docs/game-reviews-ddl.sql # 특정 파일만
# 접속 정보는 .env(POSTGRES_USER / POSTGRES_DB / APP_SCHEMA)에서 읽고,
# DB_CONTAINER / PG_USER / PG_DB / PG_SCHEMA 환경변수로 override 할 수 있다.
```
> 내부적으로 `docker exec -e PGOPTIONS="-c search_path=dev" bibimbap-db psql -U <user> -d <db> -v ON_ERROR_STOP=1 -f -` 로 적용한다. `docs/*-ddl.sql` 은 unqualified 테이블명을 쓰므로 `search_path` 로 스키마를 강제하는 것이 필수다.
#### 새 DDL 을 추가할 때의 규약 (작성자 의무)
`docs/<기능>-ddl.sql` 을 새로 추가하거나 변경하면 **세 곳을 함께 맞춘다.** 하나라도 빠지면 환경 간 스키마가 어긋난다.
1. **`docs/<기능>-ddl.sql`** — 권위 DDL. 모든 문장을 멱등(IF NOT EXISTS / DO `$$` 가드 / CREATE OR REPLACE)으로 작성한다.
2. **실행 중 로컬 DB**`db/apply-local-ddl.sh` 를 실행해 즉시 적용한다. → 곧바로 로컬 테스트 가능.
3. **`db/schema.sql`** — 동일 변경을 반영(`SET search_path TO dev;` 블록 안)해, 신규 환경의 컨테이너 최초 기동 init 에도 포함되게 한다.
> 검증: 적용 후 `docker exec bibimbap-db psql -U bibimbap -d bibimbap -c "\d dev.<테이블>"` 로 컬럼·제약·인덱스를 확인한다.
## 5. 검증 체크리스트
실제 통과한 항목은 `[x]` 다.

View File

@ -0,0 +1,223 @@
---
kind: work-log
title: bibimbap 플랫폼 고도화 — 목적별 워크스트림 로드맵
description: 150635 단일 게임잼 프레임을 4개 목적 워크스트림(거버넌스/게임잼/사이트플랫폼/유저배지)+운영으로 재구조. 게임잼은 그 중 하나. 의존성(빌드순서) 유지 + 잼일정 분리가능 여부 명시.
status: active
source_session: 20260617-150635
restructured_by: 20260617-162536
created_at: 2026-06-17
owner: art
---
# bibimbap 플랫폼 고도화 — 목적별 워크스트림 로드맵
기존 bibimbap(게임 공유 + 팀원 모집, Spring Boot + MyBatis + JSP) 위에 신규 기능군을 얹는다.
**핵심 재구조 이유**: 이전 로드맵(20260617-150635)은 의존성/공유자원 축으로는 잘 쪼갰으나, 모든 항목을 단일 "게임잼 플랫폼 고도화" 목적 프레임 아래 배치했다. 재검토 결과, 제공한 메모가 전부 게임잼용이 아니었다. 항목별 "왜 존재하나(목적)"를 1:1로 재확정한 결과, **4개 목적 워크스트림 + 운영**으로 분해됐다.
- 게임잼은 4개 워크스트림 중 하나(W2)다.
- 사이트 전반 기능(태그검색·댓글리뷰·포스팅·메인·Unity업로드)과 유저 배지/평판은 게임잼 회차 일정과 무관하게 독립 진행 가능.
- 의존성 그래프(빌드 순서)는 그대로 유효하다 — 워크스트림 라벨로 재서술했을 뿐.
---
## W1 — 거버넌스 / RBAC ★토대
**목적**: 사이트 거버넌스(운영진 관리). 게임잼이 없어도 필요한 토대.
- **포함**:
- 관리자(전체) / 부관리자(허용된 권한만 = 권한부여형) 모델
- 관리자 콘솔 — 부관리자 임명 + 권한 토글
- 권한 체크 인터셉터
- 권한 토글 항목 예시: 게임잼관리, 포스팅작성(= 포스터 권한)
- **게임잼 관계**: 소비자 — 게임잼관리 권한·심사위원 역할이 이 위에 얹힘.
- **잼 일정 분리**: 가능(토대, 독립).
- **의존**: 없음.
- **공유 자원**: `users`, `security/`, 세션/인증.
---
## W2 — 게임잼 ★본체
**목적**: 게임잼 운영·평가·시상 그 자체. 잼이 없으면 존재 이유 없음.
- **잼 일정 분리**: 불가(잼 그 자체).
- **의존**: W1.
### W2-1 — 게임잼 엔티티 + 라이프사이클
- `jams` 테이블, 상태전이(모집→개발→평가→종료), 개발기간/평가기간 필드
- 관리자 게임잼 CRUD, 게임잼 목록/상세 페이지
- 회차 독립(다중 인스턴스)
- 출품작 = 기존 `games` 재사용 + 잼 연결(`jam_id`/조인). 개인·팀 모두.
- 출품작 이중 노출 — 잼 전용 뷰 + 일반 게임 허브
- 운영 표시 필드: Discord 링크 / 상금 / 후원사 (표시만)
### W2-2 — 심사위원 역할 권한
- W1 위에 얹히는 잼 전용 역할
- 권한 체크: 심사위원만 심사 점수 입력 가능
### W2-3 — 잼 평가 통합설계 (스키마 동결) ⚠️결합 클러스터
> 범위 = 잼 평가만. 댓글/리뷰 스키마 자체는 W3에서 설계(동결 묶음 아님).
- 심사 점수 / 잼 투표 / 시상 집계 스키마 확정·동결
- "W3 댓글/리뷰의 리뷰 평점 → 시상 유저평점 트랙" 단방향 집계 계약
- 평가기간 게이트 계약
- **권장 진입**: design-advisor
- **산출**: design.md + DB 스키마(동결) + contracts
### W2-4 — 심사위원 평가
- 권한자(심사위원) 점수 입력 → 심사위원 대상 집계
- **의존**: W2-3 동결 완료, W2-2(심사위원 권한)
### W2-5 — 인기투표
- 잼 전용 1인 1표, 평가기간 한정, `game_likes`와 별개
- **의존**: W2-3 동결 완료
### W2-6 — 시상 집계 / 결과
- 3트랙 산정: 심사 / 유저평점(W3 리뷰 평점 집계) / 인기 → 수상 표시
- **의존**: W2-4, W2-5, W3 댓글리뷰 평점
---
## W3 — 사이트 플랫폼 (전부 잼 일정 독립)
**목적**: 일반 게임/콘텐츠 사이트 기능. 게임잼은 소비자 중 하나.
- **잼 일정 분리**: 전부 가능(독립 출시 가능).
### W3-1 — 태그 + 검색
- **목적**: 일반 게임 발견성 중심. 잼 용도(이전 회차 검색 / 진행 회차 강조)는 부수.
- 공유 `tags` + 조인테이블(game/jam), 게임잼 태그 검색 UI/쿼리
- **의존**: W2 게임잼 엔티티 (잼 태그 연결 시에만). 단독 게임 태그는 독립.
### W3-2 — 댓글 / 리뷰 분리
- **목적**: 모든 게임 페이지 일반 기능. 잼 평가기간 게이트 없음.
- `game_comments` 200자 + 리뷰(게시물당 1회, 완성도+종합 평점)
- 리뷰 작성 주체 = 아무나(게임당 1회)
- 잼 연결 = 시상(W2-6)이 리뷰 평점을 단방향 집계할 뿐. 이 기능 자체는 잼과 무관.
- **의존**: 없음(독립).
### W3-3 — 포스팅 보드
- **목적**: 사이트 공지·블로그 + 외부링크 큐레이션. **별도 메뉴**로 운영.
- 포스터 권한자(W1)만 작성. 유저 작성 차단.
- 카테고리: 유니티블로그 / 세션 / 게임잼대상 후기인터뷰 / 뒤끝 개발팀
- 게임잼은 포스팅 주제 중 하나로 포함될 뿐 — 포스팅 보드가 게임잼에 의존하지 않음.
- **의존**: W1(포스터 권한).
### W3-4 — 메인페이지 (게임 허브)
- **목적**: `index.jsp` 에 게임 + 잼 출품작이 모이는 공간.
- 포스팅은 별도 메뉴 — 메인 통합 아님.
- **의존**: W2 게임잼 엔티티(잼 출품작 노출 시).
### W3-5 — Unity WebGL 빌드 업로드 자동화 ⏸deferred
- **목적**: 일반 게임 호스팅 자동화(모든 제출). 현 수동 호스팅 대체.
- 업로드 → 검증 → `/game/{uuid}/` 배치
- **선행**: 별도 조사(빌드 포맷/검증/보안 — zip-slip 등)
- 게임잼과 무관한 일반 인프라.
---
## W4 — 유저 배지 / 평판 ★신규 (150635 누락)
**목적**: 커뮤니티 기여 인정. 권한 아님, 평판/배지. 게임잼·RBAC와 별개 도메인.
- **포함**:
- **리뷰어 배지**: 업로드 게임 인증 리뷰어. W3 리뷰 활동/품질 기반 부여.
- **기술자 배지**: 개발 정보 공유·적극 업로더 인정. ("기술자"는 임시명칭)
- **배경**: 150635가 리뷰어/기술자를 RBAC "인증유저 플래그"로 오분류 → 본 재검에서 별도 워크스트림으로 발굴.
- **잼 일정 분리**: 가능(독립).
- **의존**: 활동 소스(W3 리뷰 등) 존재 후. 독립 출시 가능.
---
## 운영 (코드 외)
- **Discord**: 공지·진행은 수동 운영. 코드는 게임잼 상세에 초대 링크 표시(W2-1 흡수).
- **뒤끝(Bekend)**: 파트너/콘텐츠 — 포스팅 카테고리로만 등장(W3-3).
- **상금·후원**(치킨/10만원/편의점 5000원권): 게임잼 상세 표시 필드(W2-1) + 실제 지급은 수동.
---
## 의존성 그래프 / 빌드 순서
```
W1 거버넌스/RBAC ─────────────────────────────────────────────── ★최우선 토대
├──> W2-1 게임잼 엔티티 + 라이프사이클
│ │
│ ├──> W2-2 심사위원 역할 권한
│ │
│ ├──> W2-3 잼 평가 통합설계(스키마 동결)
│ │ │
│ │ ├──> W2-4 심사위원 평가 ──────────────────┐
│ │ │ │
│ │ └──> W2-5 인기투표 ─────────────────────┐ │
│ │ │ │
│ │ W3-2 댓글/리뷰(독립) ──[리뷰평점 단방향집계]──┤ │
│ │ ↓ ↓
│ │ W2-6 시상 집계
│ │
│ ├──> W3-1 태그+검색 (잼 태그 연결 시)
│ └──> W3-4 메인 허브 (잼 출품작 노출 시)
├──> W3-3 포스팅 보드 (W1 후 병렬 가능, 잼 독립)
└──> W4 유저 배지/평판 (W3 활동 소스 후, 독립)
W3-2 댓글/리뷰 ── 독립(잼 무관, 단 W2-6이 평점 집계)
W3-5 Unity 업로드 ── deferred (조사 선행 필요)
```
**크리티컬 패스**: W1 → W2-1 게임잼 엔티티 → W2-3 잼 평가 통합설계 → W2-6 시상 집계
**병렬 가능**:
- W1 완료 후: W3-3 포스팅(잼 독립)
- W2-1 완료 후: W3-1 태그검색, W3-4 메인 허브
- W3-2 댓글/리뷰: 언제든 독립 진행 가능
- W4 배지: W3 활동 소스 존재 후 독립 진행
---
## S → W 매핑 (추적성)
이전 세션 및 handoff가 S1~S7 레이블을 참조하므로 매핑을 유지한다.
| 구 레이블 | 새 워크스트림 | 교정 사항 |
|---|---|---|
| S1 RBAC | W1(관리자/부관리자/권한토글) + W2-2(심사위원 역할) + **W4(리뷰어/기술자 배지, 오분류 교정)** | 리뷰어·기술자는 RBAC 아닌 배지/평판으로 재분류 |
| S2 게임잼 엔티티 | W2-1 | 변동 없음 |
| S3 태그+검색 | W3-1 | 목적 재확인: 일반 발견성 중심, 잼은 소비자 |
| S4D 평가 통합설계 | W2-3 | **범위 축소**: 잼 평가(심사/투표/시상) 스키마만 동결. 댓글/리뷰 스키마는 W3-2에서 별도 설계 |
| S4a 댓글/리뷰 분리 | W3-2 | **일반기능으로 재분류**: 잼 평가 동결묶음에서 분리. 시상은 단방향 집계만 |
| S4b 심사위원 평가 | W2-4 | 변동 없음 |
| S4c 인기투표 | W2-5 | 변동 없음 |
| S4d 시상 집계 | W2-6 | 변동 없음 |
| S5 포스팅 보드 | W3-3 | **별도 메뉴 명확화**: 메인 통합 아님 |
| S6 메인페이지 | W3-4 | **교정**: 포스팅 통합 아님. 게임+잼 출품작 허브만 |
| S7 Unity 업로드 | W3-5 | 목적 재확인: 일반 인프라, 잼 의존 아님. deferred 유지 |
| *(누락)* | **W4 유저 배지/평판** | 신규 발굴 워크스트림 |
---
## 150635 대비 핵심 교정 5건
1. **댓글/리뷰(구 S4a) = 일반기능** → 잼 평가 동결묶음에서 분리. 시상(W2-6)은 리뷰 평점을 단방향으로 집계할 뿐이며, 댓글/리뷰 자체는 잼과 무관하게 독립 진행 가능.
2. **포스팅(구 S5) = 별도 메뉴** → 메인(구 S6) 통합 아님. 메인은 게임+잼 출품작 허브이고, 포스팅은 별도 메뉴로 운영.
3. **태그검색(구 S3)·Unity업로드(구 S7) = 사이트 일반 인프라** → 잼은 소비자 중 하나일 뿐, 이 기능들이 게임잼을 위해 존재하는 것이 아님.
4. **유저 배지/평판(W4) = 누락됐던 신규 워크스트림** → 리뷰어·기술자가 S1 RBAC "인증유저 플래그"로 오분류됐던 것을 별도 도메인(평판/배지)으로 발굴·분리.
5. **잼 평가 통합설계(구 S4D) 동결 범위 축소** → 잼 평가(심사/투표/시상) 스키마만 동결 대상. 댓글/리뷰 스키마는 W3-2에서 별도 설계하며 동결 묶음 아님.

View File

@ -0,0 +1,167 @@
---
kind: work-log
title: W3 사이트 플랫폼 — 기능별 골자 카탈로그
description: W3(사이트 플랫폼 워크스트림) 5개 서브기능(W3-1 태그+검색 / W3-2 댓글·리뷰 / W3-3 포스팅 보드 / W3-4 메인 허브 / W3-5 Unity업로드 deferred)의 골자. 기능당 목적·핵심동작·결합/의존·미결질문·후속진입점. 윤곽 카탈로그 깊이 — 미결질문 해소 안 하고 남김. 당장 구현 아님, 착수 전 굳혀두는 기획 메모.
status: confirmed
source_roadmap: 2026-06-17-jam-platform-roadmap.md
source_session: 20260617-172407
confirmed_session: 20260617-174635
confirmed_at: 2026-06-18
created_at: 2026-06-17
owner: art
---
# W3 사이트 플랫폼 — 기능별 골자 카탈로그
상위 로드맵: [2026-06-17-jam-platform-roadmap.md](./2026-06-17-jam-platform-roadmap.md) §W3.
**깊이**: 윤곽 카탈로그. 기능당 목적·핵심동작·결합/의존·미결질문·후속진입점만 굳혀둔다. DDL/API 계약/시퀀스는 기능별 착수(design) 단계에서. **미결질문은 해소하지 않고 남긴다** — 골자의 목적은 "무엇을/왜 + 무엇이 미결인지"를 1페이지로 굳히는 것. 당장 구현 아님.
> W3 전부 잼 일정 독립(독립 출시 가능). 게임잼(W2)은 W3 기능의 소비자 중 하나일 뿐.
---
## 코드 현황 (착수 전 확인된 결합점)
골자의 "결합/의존" 정확도를 위해 현 코드베이스를 대조했다. file 근거:
1. **`games` 테이블** (`GamesMapper.java`) — id, user_id, name, creator_note, git_url, webgl_path, thumbnail_url, like_count, is_visible, sort_order, created_at, updated_at. **태그 컬럼 없음.** 검색은 name/creator_note ILIKE 방식만.
2. **`game_comments`** (`GameCommentsMapper.java`) — id, game_id, **nickname(자유입력)**, content, created_at, deleted_at, is_delete. 로그인 연동 없음. content 길이 제한 없음. **review 테이블·tag 테이블 모두 없음 → W3-1·W3-2 구조 전부 신규.**
3. **권한 인프라**`UserData.role` 필드 존재, `UserController``ROLE_USER="USER"` 상수만. **Interceptor 클래스 없음** (`WebMvcConfigurer` 구현체에 `addInterceptors` 없음). W3-3 포스터 게이트가 얹힐 자리 미비 → W1 의존 강화.
4. **`index.jsp`** — 게임 카드 그리드 + 텍스트 검색(ILIKE) 단일 섹션. 잼 출품작 노출 없음.
5. **WebGL 업로드** (`GameUploadController.java`) — `/api/game-files/webgl-zip` 이미 구현. zip-slip 방어·엔트리 수 상한(8000)·압축 해제 크기 상한(512MB)·UUID 기반 `/game/{uuid}/` 배치 적용. **단 (a) 로그인 체크만, 권한 체크 없음 (b) `/game/**` 정적 핸들러가 `UploadResourceConfig.java` 에 미등록** — `/profile/**` 만 등록. → 서빙 경로 동작 미보장(QG-3, 의도/버그 미확인).
---
## 기능별 골자
### W3-1 — 태그 + 검색
- **목적**: 일반 게임 발견성 향상이 1차. 잼 용도(진행 회차 강조, 이전 회차 검색)는 부수.
- **핵심 동작**: 게임/잼에 태그를 붙이고 태그로 필터 검색(기존 텍스트 검색과 병행). 개발자(제작자) 검색 지원. 정렬은 태그 일치도 가중 × 2차 정렬키(좋아요/최신/방문수/리뷰수/점수).
- **결합/의존**:
- C1: 신규 `tags` + 조인테이블 필요(현 코드에 태그 구조 없음). — 코드사실
- C2: 잼 전용 태그 연결은 W2 게임잼 엔티티 선행 의존.
- C3: 게임 태그 기본 기능은 W2 무관 독립 착수 가능. **단 정렬 일부는 의존** — 리뷰수·점수 정렬은 W3-2, 방문수 정렬은 신규 조회수 인프라.
- C4: `GamesMapper.searchVisibleGames` ILIKE 쿼리 확장 필요. — 코드사실
- C5: 방문수 정렬 → 신규 조회수 카운터 필요(games 에 `like_count`만, 조회수 컬럼 없음). — 코드사실
- C6: 리뷰수·점수 정렬 → W3-2(`game_reviews`) 의존. 정렬 점수의 정확한 의미(단일 평점 vs 다축)는 W3-2 설계 종속.
- C7: 개발자 검색 → `games.user_id` ↔ user 조인 확장.
- 태그 테이블 구조: **통합 1테이블 + 용도 구분(게임 공용/잼 전용)** 권장. 잼 태그 요건(W2) 확정 시 분리 재검토.
- **미결질문** (합의된 방향 + 잔여):
- Q1 태그 관리: 출시는 운영자 사전정의, 구조는 혼합(운영자 사전정의 + 사용자 생성) 지원. *잔여*: 사용자 생성 태그 개방 시점·승인 흐름.
- Q2 제한·허용: 태그당 게임 수·게임당 태그 수 제한 없음. 허용 조건은 보안(주입·XSS sanitize) + 욕설/금칙어 필터 통과. *잔여*: 태그명 길이·허용 문자셋 구체값, 금칙어 사전 출처.
- Q3 테이블 구조: 통합 1테이블 채택(위 결합/의존). *잔여*: 잼 태그 요건 확정 시 분리 여부.
- Q4 정렬: 태그 미선택=최신순(기본), 태그 미선택+좋아요=전체 좋아요순, 태그 선택=태그 일치도 가중+2차키(최신/좋아요/방문수/리뷰수/점수). *잔여*: 태그 일치도 가중 계산·동률 처리는 design 단계.
- Q5 다중 태그·개발자 검색: 다중 태그 AND/OR 지원 + 개발자(제작자) 검색 추가. *잔여*: AND/OR 노출·입력 방식은 design 단계.
- **후속 진입점**: design-advisor
### W3-2 — 댓글 / 리뷰 분리
> **구현 완료** (2026-06-18, 세션 20260618-104034). 변경 이력: [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md). L1 31테스트 PASS. L3 스모크·DDL 적용은 사용자 확인 필요.
- **목적**: 모든 게임 페이지 일반 기능. 잼 평가기간 게이트 없음, 잼 독립.
- **핵심 동작**: 기존 `game_comments`(닉네임 자유입력)를 로그인 사용자 연동 댓글로 전환 + 별도 리뷰(게임당 1회) 신설. 리뷰 = **별점 5점 평점 + 서술 평가**, 다축(육각형 레이더)은 후속 확장 여지. W2-6 시상이 리뷰 평점을 **단방향 집계**. 댓글 content 200자 제한 신규.
- **결합/의존**:
- ①: `game_comments` 기존 존재 — 닉네임 자유입력, 로그인 연동 없음 → 로그인 연동 전환 시 기존 데이터 처리 결정 필요. — 코드사실
- ②: `game_reviews` 신규 (review 테이블 없음). — 코드사실
- ③: W2-6 시상이 리뷰 평점을 단방향 집계 → W2-3(평가 통합설계) 동결과 정합 필요. — 해석
- ④: 댓글 content 200자 제한 신규 (현 `game_comments` content 길이 제한 없음). — 코드사실
- ⑤: W3-1 의 리뷰수·점수 정렬이 W3-2(평점·리뷰수)에 의존 — W3-1 C6 의 역방향. — 신규
- **미결질문** (합의된 방향 + 잔여):
- Q1 기존 닉네임 레코드 처리: **미결로 유지** (전체수준 QG-2 사안 — 골자 단계 해소 보류).
- Q2 댓글 수정·삭제 권한: 작성자 본인 + 운영자. *잔여*: `updateGameComment` 권한 체크 신규 적용 범위는 design.
- Q3 평점 척도: 별점 5점(초기 단일), 다축(육각형) 후속 확장 여지. *잔여*: 다축 시 축 구성·완성도/종합 분리 여부는 design.
- Q4 리뷰 수정: 수정 가능 + 이력 보존, 노출은 "수정됨" 마커만 표시. *잔여*: 이력 열람 권한은 design.
- Q5 W2-3 평점 집계 계약: W2-3 동결 후 정합 (W3-2 선확정 안 함).
- Q6 *(신규)* 리뷰 단위: 업데이트 단위 분리 여부 + 나눠도 통합 평점 여부 — 미결 유지.
- **후속 진입점**: design-advisor (W2-3 진행 연계 확인 후)
### W3-3 — 포스팅 보드
- **목적**: 사이트 공지·블로그 + 외부링크 큐레이션. **별도 메뉴**, 잼 무관 독립 채널.
- **핵심 동작**: 포스터 권한자(W1 RBAC 부여)만 작성, 일반 유저 읽기만(댓글 없음). 카테고리는 운영자가 추가/수정/삭제 가능(DB 저장). 본문은 **마크다운 류 여러 서식(sanitize)**. 외부링크 큐레이션은 **OG 미리보기 포함**. 유니티블로그는 고정 카테고리가 아니라 **외부 피드(레퍼런스) 감시 — 새 글 게시 감지 후 알림** 형태로 별도 취급.
- **결합/의존**:
- C1: **W1 선행 필요**`UserData.role` 존재하나 Interceptor 미구현. 권한 게이트 인프라가 W1 에서 구현돼야 함. — 코드사실
- C2: `posts`/`board_posts` 신규.
- C3: `RecruitPostsMapper`/`RecruitController` 유사 게시판 패턴 참고 가능. — 코드사실(참고)
- C4: *(신규)* 외부 fetch 인프라 — OG 미리보기 + 유니티블로그 피드 감시 → SSRF 방어·캐싱·실패처리·폴링 필요. — 신규
- **미결질문** (합의된 방향 + 잔여):
- Q1 착수 시점: **W1 완료 후 착수** (임시 role 직접 체크 안 함). → 전체수준 QG-1 의 W3-3 부분 결정. *(W3-5 권한 부분은 여전히 미결)*
- Q2 카테고리 저장: DB 저장 (운영자 추가/수정/삭제 가능).
- Q3 외부링크: OG 미리보기 포함. *잔여*: SSRF 방어·캐싱·실패처리 방식은 design.
- Q4 본문 형식: 마크다운 류 여러 서식(sanitize). *잔여*: 허용 서식 범위·sanitize 정책은 design.
- Q5 댓글 부착: **붙이지 않음** (읽기 전용 채널). → W3-2 결합 없음.
- Q6 *(신규)* 유니티블로그 외부 피드 감시: 새 글 게시 감지 → 알림 형태. *미결*: 확인 방식(RSS/폴링)·알림 표면·갱신 주기.
- **후속 진입점**: design-advisor (W1 진행 확인 선행)
### W3-4 — 메인페이지 (게임 허브)
- **목적**: `index.jsp` 를 게임 허브로 확장 + 진행 중 잼 안내. 포스팅은 별도 메뉴(메인 통합 아님).
- **핵심 동작**: 평소엔 현 게임 카드 그리드 전체 노출(현 정렬 유지). 진행 중 잼 있을 때만 **검색창 아래에 "게임잼 진행 중" 안내 + 신규 출품작 강조**, 클릭 시 **게임잼 태그 검색(W3-1)으로 이동**. (별도 잼 섹션 그리드 아님.) 페이지네이션/무한스크롤 도입.
- **결합/의존**:
- C1: 현 `index.jsp``GamesMapper.getVisibleGames()`/`searchVisibleGames()` 단일 소스. — 코드사실
- C2: 잼 노출(검색창 아래 안내·신규작 강조·클릭 라우팅) = **W2(잼 엔티티·진행 상태) + W3-1(잼 태그 검색)** 의존. — 신규/해석
- C3: 일반 게임 허브 개선(페이지네이션·그리드)은 W2/W3-1 무관 독립.
- **미결질문** (합의된 방향 + 잔여):
- Q1 잼 노출 조건: 진행 중 잼 있을 때만.
- Q2 노출 형태: 별도 그리드 섹션 대신 검색창 아래 안내 배너 + 신규작 강조 → 클릭 시 잼 태그 검색. 평소 전체 게임 그대로. *(별도 그리드 없어 중복 노출 문제 해소)*
- Q3 정렬·추천: 현 정렬 유지(`sort_order` ASC + `created_at` DESC). 인기순은 후속.
- Q4 페이지네이션: 도입. *잔여*: 페이지네이션 vs 무한스크롤 택은 design.
- Q5 단계 착수: **단계 착수 가능** — 일반 허브(페이지네이션·그리드) 먼저, 잼 안내는 W2+W3-1 후.
- **후속 진입점**: design-advisor
### W3-5 — Unity WebGL 빌드 업로드 자동화 ⏸ deferred
> 골자 아님 — **선행 조사 스텁**. 조사 완료 후 골자/설계 진입.
- **목적**: 일반 게임 호스팅 자동화. 현 수동 호스팅 대체. 게임잼 무관 인프라.
- **현황**: 업로드 로직 부분 구현 존재(위 코드현황 5). zip-slip·크기상한·UUID 배치 됨. **권한 체크 없음 + `/game/**` 정적 핸들러 미등록.**
- **선행 조사 항목**:
- zip-slip 방어 충분성 — 현 `target.startsWith(targetDir)` 외 심볼릭 링크·인코딩 우회 케이스.
- WebGL 빌드 포맷 검증 범위 — index.html 외 필수 파일(Build/*.data, *.wasm, *.js) 존재 검증.
- 업로드 크기/타입 — zip 원본 크기 상한, Content-Type MIME+확장자 이중 체크 여부.
- 저장 경로 boundary — `app.upload.game-storage-path` 외부 주입 시 경계 검증.
- `/game/**` 정적 핸들러 미등록 — 서빙 동작 미보장(QG-3).
- 업로드 권한 게이트 — 현 로그인만, 권한 제한 없음.
- UUID 재사용 시 기존 레코드 덮어쓰기/삭제 정책.
- **착수 전 합의(이번 세션)**: 조사 항목 7개 그대로 유지. 업로드 권한 게이트는 QG-1 미결로 유지(조사 단계 확정). QG-3(`/game/**` 핸들러)는 코드 확인을 조사 단계로 미룸.
- **후속 진입점**: research-advisor → (조사 후) design-advisor
---
## 워크스트림 결합 요약
| 기능 | 결합 대상 | 결합 방식 | 독립 출시 |
|---|---|---|---|
| W3-1 태그+검색 | W2(잼 태그) + W3-2(리뷰수·점수 정렬) + 조회수 인프라(방문수 정렬) | 잼 태그·정렬 일부 의존, 게임 태그 코어는 독립 | 부분 (게임 태그 코어 먼저) |
| W3-2 댓글/리뷰 | W2-6(시상 집계) + W2-3(평점 계약 정합) + W3-1(정렬이 평점·리뷰수 소비) | 리뷰 평점을 W2-6 단방향 집계, W3-1 정렬이 역참조 | 가능 (댓글·리뷰 코어 독립, 평점 집계 계약은 W2-3 동결 후) |
| W3-3 포스팅 보드 | W1(포스터 권한/인터셉터) + 외부 fetch 인프라(OG·유니티블로그 피드) | 권한 게이트 의존, W1 완료 후 착수(임시 체크 안 함) | W1 선행 |
| W3-4 메인 허브 | W2(잼 엔티티·진행 상태) + W3-1(잼 태그 검색 라우팅) | 잼 안내·신규작 강조→잼 태그 검색 의존, 일반 허브는 독립 | 부분 (일반 허브 먼저, 잼 안내는 W2+W3-1 후) |
| W3-5 WebGL 자동화 | 없음 (일반 인프라) | 독립 | deferred (조사 선행) |
---
## 착수 순서 제언 (단정 아님 — 이번 세션 합의)
> 의존 사슬: W3-2(평점 공급) → W3-1(정렬이 평점 소비) → W3-4(잼 안내가 잼 태그 검색으로 라우팅). W3-3·W3-5 는 W1 권한 게이트 선행.
1. **W3-2 댓글/리뷰** — 기존 `game_comments` 확장, 코어 완전 독립, 착수 비용 최소. 다른 기능의 평점 공급원. 평점 집계 계약만 W2-3 동결 후 정합.
2. **W3-1 태그+검색** — W3-2 뒤에 오면 리뷰수·점수 정렬까지 완성. 게임 태그 코어는 독립, 정렬 일부는 W3-2·조회수 인프라 의존.
3. **W3-4 메인 허브 (일반 부분 먼저)** — 페이지네이션·그리드 개선은 독립 선착수. 잼 안내(검색창 아래 배너→잼 태그 검색)는 W2+W3-1 후.
4. **W3-3 포스팅** — W1 Interceptor 선행(임시 체크 안 함 확정). OG·유니티블로그 외부 fetch 인프라 동반.
5. **W3-5 WebGL 자동화** — deferred, 선행 조사 완료 후.
---
## 전체 수준 미결질문 (착수 전 확정 필요)
- **QG-1**: W1 인터셉터 완료 시점과 권한 게이트 착수. **W3-3 은 W1 완료 후 착수(임시 role 직접 체크 안 함)로 확정.** W3-5 업로드 권한은 미결 유지(조사 단계 확정). *잔여*: W1 지연 시 W3-5 임시 체크 허용 여부.
- **QG-2**: W3-2 기존 닉네임 자유입력 레코드 처리 — 마이그레이션/유지/레거시 표시. **미결 유지 확정**(골자 단계 해소 보류).
- **QG-3**: W3-5 `/game/**` 정적 핸들러 미등록이 의도인지 버그인지 — 현 WebGL 서빙 실제 동작 확인 필요. **코드 확인을 조사 단계로 미룸 확정.**
---
## 다음 단계
골자 합의 완료(이번 세션 — 5기능 + 착수 순서). 각 기능을 실제 착수할 때 위 "후속 진입점" advisor 로 진입한다. 착수 전 해당 기능의 *잔여* 미결 + 관련 QG 를 먼저 확정한다. **착수 1순위 = W3-2 로 확정.**

View File

@ -4,4 +4,5 @@
## 목록
_(아직 문서 없음)_
- [2026-06-17-jam-platform-roadmap.md](./2026-06-17-jam-platform-roadmap.md) — bibimbap 플랫폼 고도화 목적별 워크스트림 로드맵. 4개 W(W1 거버넌스/RBAC · W2 게임잼 · W3 사이트플랫폼 · W4 유저배지)+운영으로 재구조. 게임잼은 W2 하나. 의존성(빌드순서) 유지 + 잼일정 분리가능 여부 명시. S→W 매핑 + 150635 대비 교정 5건 포함. `status: active`, source_session: 20260617-150635, restructured_by: 20260617-162536.
- [2026-06-17-w3-feature-skeletons.md](./2026-06-17-w3-feature-skeletons.md) — W3(사이트 플랫폼) 5개 서브기능 골자 카탈로그. 기능당 목적·핵심동작·결합/의존·미결질문·후속진입점(윤곽 깊이, 미결 해소 안 함). 코드 현황 대조(tag/review 테이블 신규·Interceptor 미구현·`/game/**` 핸들러 미등록). 결합표 + 착수순서 제언(W3-2 1순위) + QG-1~3. 당장 구현 아님. `status: confirmed`(20260617-174635 세션서 5기능+착수순서 항목별 합의), source_session: 20260617-172407.

View File

@ -0,0 +1,261 @@
package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameCommentData;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.util.TextNormalizer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Controller
public class GameCommentController {
private static final int CONTENT_MAX = 200;
private static final int PAGE_SIZE = 20;
private static final String ROLE_ADMIN = "ADMIN";
private final GameCommentsMapper gameCommentsMapper;
private final GamesMapper gamesMapper;
public GameCommentController(GameCommentsMapper gameCommentsMapper, GamesMapper gamesMapper) {
this.gameCommentsMapper = gameCommentsMapper;
this.gamesMapper = gamesMapper;
}
@GetMapping("/game/{id}/comments")
public ResponseEntity<Map<String, Object>> listComments(
@PathVariable("id") long id,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "sort", required = false) String sort
) {
if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
String sortEnum = normalizeCommentSort(sort);
int offset = Math.max(page, 0) * PAGE_SIZE;
List<GameCommentData> rows = gameCommentsMapper.listGameComments(id, sortEnum, offset, PAGE_SIZE + 1);
boolean hasMore = rows.size() > PAGE_SIZE;
int count = Math.min(rows.size(), PAGE_SIZE);
List<Map<String, Object>> comments = new ArrayList<>();
for (int i = 0; i < count; i++) {
comments.add(commentView(rows.get(i)));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("comments", comments);
body.put("hasMore", hasMore);
return ResponseEntity.ok(body);
}
@PostMapping("/game/{id}/comments")
@Transactional
public ResponseEntity<Map<String, Object>> createComment(
@PathVariable("id") long id,
@RequestParam(name = "content", required = false) String content,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
String normalizedContent = trimToNull(TextNormalizer.normalize(content));
if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) {
return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요.");
}
String authorName = trimToEmpty(sessionDisplayName(session));
GameCommentData comment = new GameCommentData();
comment.setGameId(id);
comment.setUserId(userId);
comment.setNickname(authorName);
comment.setContent(normalizedContent);
gameCommentsMapper.addGameComment(comment);
if (comment.getId() == null) {
return response(HttpStatus.INTERNAL_SERVER_ERROR, "덧글 등록 결과를 확인하지 못했습니다.");
}
GameCommentData created = gameCommentsMapper.getGameComment(comment.getId());
Map<String, Object> result = created != null ? commentView(created) : commentView(comment);
result.put("status", 200);
result.put("message", "덧글이 등록되었습니다.");
return ResponseEntity.ok(result);
}
@PutMapping("/game/{id}/comments/{commentId}")
@Transactional
public ResponseEntity<Map<String, Object>> updateComment(
@PathVariable("id") long id,
@PathVariable("commentId") long commentId,
@RequestParam(name = "content", required = false) String content,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
GameCommentData comment = gameCommentsMapper.getGameComment(commentId);
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
}
if (!canModify(userId, comment.getUserId(), sessionRole(session))) {
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
}
String normalizedContent = trimToNull(TextNormalizer.normalize(content));
if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) {
return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요.");
}
comment.setContent(normalizedContent);
gameCommentsMapper.editGameComment(comment);
GameCommentData updated = gameCommentsMapper.getGameComment(commentId);
Map<String, Object> result = updated != null ? commentView(updated) : commentView(comment);
result.put("status", 200);
result.put("message", "덧글이 수정되었습니다.");
return ResponseEntity.ok(result);
}
@DeleteMapping("/game/{id}/comments/{commentId}")
@Transactional
public ResponseEntity<Map<String, Object>> deleteComment(
@PathVariable("id") long id,
@PathVariable("commentId") long commentId,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
GameCommentData comment = gameCommentsMapper.getGameComment(commentId);
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
}
if (!canModify(userId, comment.getUserId(), sessionRole(session))) {
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
}
gameCommentsMapper.softDeleteGameComment(commentId);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "덧글이 삭제되었습니다.");
return ResponseEntity.ok(body);
}
private Map<String, Object> commentView(GameCommentData comment) {
Map<String, Object> view = new LinkedHashMap<>();
view.put("commentId", comment.getId());
view.put("gameId", comment.getGameId());
view.put("authorName", comment.getAuthorName() != null ? comment.getAuthorName() : comment.getNickname());
view.put("userId", comment.getUserId());
view.put("content", comment.getContent());
view.put("createdAt", comment.getCreatedAt());
view.put("edited", comment.getEdited() != null && comment.getEdited());
view.put("updatedAt", comment.getUpdatedAt());
return view;
}
private String normalizeCommentSort(String sort) {
return "newest".equals(sort) ? "newest" : "oldest";
}
private boolean isOperator(String role) {
return ROLE_ADMIN.equals(role);
}
private boolean canModify(Long currentUserId, Long authorUserId, String role) {
return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role);
}
private Long sessionUserId(HttpSession session) {
if (session == null) {
return null;
}
Object userId = session.getAttribute("userId");
if (userId instanceof Number number) {
return number.longValue();
}
if (userId instanceof String text) {
try {
return Long.parseLong(text);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private String sessionDisplayName(HttpSession session) {
if (session == null) {
return null;
}
Object displayName = session.getAttribute("displayName");
return displayName instanceof String text ? text : null;
}
private String sessionRole(HttpSession session) {
if (session == null) {
return null;
}
Object role = session.getAttribute("role");
return role instanceof String text ? text : null;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isBlank() ? null : text;
}
private String trimToEmpty(String value) {
String text = trimToNull(value);
return text == null ? "" : text;
}
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -2,6 +2,8 @@ package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.game.GameCatalog;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import jakarta.servlet.http.HttpServletRequest;
@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@ -26,12 +29,18 @@ import java.util.Map;
public class GameController {
private final GamesMapper gamesMapper;
private final GameCommentsMapper gameCommentsMapper;
private final GameReviewsMapper gameReviewsMapper;
@Value("${app.webgl.asset-origin:}")
private String webglAssetOrigin;
public GameController(GamesMapper gamesMapper) {
public GameController(GamesMapper gamesMapper,
GameCommentsMapper gameCommentsMapper,
GameReviewsMapper gameReviewsMapper) {
this.gamesMapper = gamesMapper;
this.gameCommentsMapper = gameCommentsMapper;
this.gameReviewsMapper = gameReviewsMapper;
}
public static String webglUrlForGame(int gameId) {
@ -105,6 +114,9 @@ public class GameController {
GameData game = gamesMapper.getGame(id);
if (game != null) {
addGameModel(model, game, sessionUserId(session));
model.addAttribute("comments", gameCommentsMapper.listGameComments(id, "oldest", 0, 20));
model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id, "newest", 0, 20));
model.addAttribute("userRole", (String) session.getAttribute("role"));
return "game-detail";
}
@ -125,6 +137,10 @@ public class GameController {
model.addAttribute("webglFrameSrc", webglFrameSrc(webglUrlForGame(intId)));
model.addAttribute("webglDeployPath", webglUrlForGame(intId));
model.addAttribute("owner", false);
model.addAttribute("comments", List.of());
model.addAttribute("reviews", List.of());
model.addAttribute("currentUserId", sessionUserId(session));
model.addAttribute("userRole", (String) session.getAttribute("role"));
return "game-detail";
}
@ -241,6 +257,7 @@ public class GameController {
}
gamesMapper.softDeleteGameComments(id);
gamesMapper.softDeleteGameReviews(id);
gamesMapper.deleteGameLikes(id);
gamesMapper.softDeleteGame(id);
@ -265,6 +282,7 @@ public class GameController {
model.addAttribute("webglFrameSrc", webglFrameSrc(webglPath));
model.addAttribute("webglDeployPath", webglPath);
model.addAttribute("owner", currentUserId != null && currentUserId.equals(game.getUserId()));
model.addAttribute("currentUserId", currentUserId);
}
private String webglFrameSrc(String path) {

View File

@ -0,0 +1,466 @@
package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameReviewData;
import com.pandoli365.bibimbap.data.ReviewAxisRow;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.util.TextNormalizer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Controller
public class GameReviewController {
private static final int RATING_MIN = 1;
private static final int RATING_MAX = 5;
private static final int BODY_MIN = 10;
private static final int BODY_MAX = 1000;
private static final int PAGE_SIZE = 20;
private static final String ROLE_ADMIN = "ADMIN";
private static final String[] AXIS_KEYS = {"immersion", "creativity", "controls", "completeness", "sound", "visual"};
private final GameReviewsMapper gameReviewsMapper;
private final GameReviewAxesMapper gameReviewAxesMapper;
private final GameReviewStatsMapper gameReviewStatsMapper;
private final GamesMapper gamesMapper;
public GameReviewController(GameReviewsMapper gameReviewsMapper,
GameReviewAxesMapper gameReviewAxesMapper,
GameReviewStatsMapper gameReviewStatsMapper,
GamesMapper gamesMapper) {
this.gameReviewsMapper = gameReviewsMapper;
this.gameReviewAxesMapper = gameReviewAxesMapper;
this.gameReviewStatsMapper = gameReviewStatsMapper;
this.gamesMapper = gamesMapper;
}
@GetMapping("/game/{id}/reviews")
public ResponseEntity<Map<String, Object>> listReviews(
@PathVariable("id") long id,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "sort", required = false) String sort
) {
if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
String sortEnum = normalizeReviewSort(sort);
int offset = Math.max(page, 0) * PAGE_SIZE;
List<GameReviewData> rows = gameReviewsMapper.listGameReviews(id, sortEnum, offset, PAGE_SIZE + 1);
boolean hasMore = rows.size() > PAGE_SIZE;
int count = Math.min(rows.size(), PAGE_SIZE);
List<GameReviewData> pageRows = rows.subList(0, count);
// axes batch 조회(N+1 회피). 페이지 가드.
if (!pageRows.isEmpty()) {
List<Long> ids = new ArrayList<>();
for (GameReviewData r : pageRows) {
ids.add(r.getId());
}
Map<Long, Map<String, Integer>> axesByReview = new LinkedHashMap<>();
for (ReviewAxisRow ax : gameReviewAxesMapper.listAxesByReviewIds(ids)) {
axesByReview.computeIfAbsent(ax.getReviewId(), k -> new LinkedHashMap<>())
.put(ax.getAxisKey(), ax.getScore());
}
for (GameReviewData r : pageRows) {
r.setAxes(axesByReview.get(r.getId()));
}
}
List<Map<String, Object>> reviews = new ArrayList<>();
for (GameReviewData r : pageRows) {
reviews.add(reviewView(r));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("reviews", reviews);
body.put("hasMore", hasMore);
body.put("summary", buildSummary(id));
return ResponseEntity.ok(body);
}
@GetMapping("/game/{id}/reviews/{reviewId}")
public ResponseEntity<Map<String, Object>> getReview(
@PathVariable("id") long id,
@PathVariable("reviewId") long reviewId
) {
GameReviewData review = gameReviewsMapper.getGameReview(reviewId);
if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
}
review.setAxes(loadAxes(reviewId));
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("review", reviewView(review));
return ResponseEntity.ok(body);
}
@PostMapping("/game/{id}/reviews")
@Transactional
public ResponseEntity<Map<String, Object>> createReview(
@PathVariable("id") long id,
@RequestParam(name = "rating", required = false) String rating,
@RequestParam(name = "body", required = false) String body,
@RequestParam(name = "immersion", required = false) String immersion,
@RequestParam(name = "creativity", required = false) String creativity,
@RequestParam(name = "controls", required = false) String controls,
@RequestParam(name = "completeness", required = false) String completeness,
@RequestParam(name = "sound", required = false) String sound,
@RequestParam(name = "visual", required = false) String visual,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
Map<String, Integer> axes = parseAxes(immersion, creativity, controls, completeness, sound, visual);
if (axes == null) {
return response(HttpStatus.BAD_REQUEST, "세부 평가 항목은 모두 1~5 사이로 선택해 주세요.");
}
String normalizedBody = trimToNull(TextNormalizer.normalize(body));
if (normalizedBody == null || normalizedBody.length() < BODY_MIN) {
return response(HttpStatus.BAD_REQUEST, "평가는 최소 10자 이상 입력해 주세요.");
}
if (normalizedBody.length() > BODY_MAX) {
return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요.");
}
Integer overall;
boolean ratingManual;
if (trimToNull(rating) == null) {
overall = averageOf(axes);
ratingManual = false;
} else {
overall = parseRating(rating);
if (overall == null) {
return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요.");
}
ratingManual = true;
}
if (gameReviewsMapper.getActiveReviewByGameAndUser(id, userId) != null) {
return response(HttpStatus.CONFLICT, "이미 이 게임에 리뷰를 작성하셨습니다.");
}
GameReviewData review = new GameReviewData();
review.setGameId(id);
review.setUserId(userId);
review.setRating(overall);
review.setBody(normalizedBody);
review.setRatingManual(ratingManual);
gameReviewsMapper.addGameReview(review);
if (review.getId() == null) {
return response(HttpStatus.INTERNAL_SERVER_ERROR, "리뷰 등록 결과를 확인하지 못했습니다.");
}
gameReviewAxesMapper.addReviewAxes(review.getId(), toAxisRows(review.getId(), axes));
GameReviewData created = gameReviewsMapper.getGameReview(review.getId());
if (created != null) {
created.setAxes(loadAxes(created.getId()));
}
Map<String, Object> result = created != null ? reviewView(created) : reviewView(review);
result.put("status", 200);
result.put("message", "리뷰가 등록되었습니다.");
return ResponseEntity.ok(result);
}
@PutMapping("/game/{id}/reviews/{reviewId}")
@Transactional
public ResponseEntity<Map<String, Object>> updateReview(
@PathVariable("id") long id,
@PathVariable("reviewId") long reviewId,
@RequestParam(name = "rating", required = false) String rating,
@RequestParam(name = "body", required = false) String body,
@RequestParam(name = "immersion", required = false) String immersion,
@RequestParam(name = "creativity", required = false) String creativity,
@RequestParam(name = "controls", required = false) String controls,
@RequestParam(name = "completeness", required = false) String completeness,
@RequestParam(name = "sound", required = false) String sound,
@RequestParam(name = "visual", required = false) String visual,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
GameReviewData review = gameReviewsMapper.getGameReview(reviewId);
if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
}
if (!canModify(userId, review.getUserId(), sessionRole(session))) {
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
}
Map<String, Integer> axes = parseAxes(immersion, creativity, controls, completeness, sound, visual);
if (axes == null) {
return response(HttpStatus.BAD_REQUEST, "세부 평가 항목은 모두 1~5 사이로 선택해 주세요.");
}
String normalizedBody = trimToNull(TextNormalizer.normalize(body));
if (normalizedBody == null || normalizedBody.length() < BODY_MIN) {
return response(HttpStatus.BAD_REQUEST, "평가는 최소 10자 이상 입력해 주세요.");
}
if (normalizedBody.length() > BODY_MAX) {
return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요.");
}
Integer overall;
boolean ratingManual;
if (trimToNull(rating) == null) {
overall = averageOf(axes);
ratingManual = false;
} else {
overall = parseRating(rating);
if (overall == null) {
return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요.");
}
ratingManual = true;
}
review.setRating(overall);
review.setBody(normalizedBody);
review.setRatingManual(ratingManual);
gameReviewsMapper.editGameReview(review);
gameReviewAxesMapper.deleteReviewAxes(reviewId);
gameReviewAxesMapper.addReviewAxes(reviewId, toAxisRows(reviewId, axes));
GameReviewData updated = gameReviewsMapper.getGameReview(reviewId);
if (updated != null) {
updated.setAxes(loadAxes(reviewId));
}
Map<String, Object> result = updated != null ? reviewView(updated) : reviewView(review);
result.put("status", 200);
result.put("message", "리뷰가 수정되었습니다.");
return ResponseEntity.ok(result);
}
@DeleteMapping("/game/{id}/reviews/{reviewId}")
@Transactional
public ResponseEntity<Map<String, Object>> deleteReview(
@PathVariable("id") long id,
@PathVariable("reviewId") long reviewId,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
GameReviewData review = gameReviewsMapper.getGameReview(reviewId);
if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
}
if (!canModify(userId, review.getUserId(), sessionRole(session))) {
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
}
gameReviewsMapper.softDeleteGameReview(reviewId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("status", 200);
result.put("message", "리뷰가 삭제되었습니다.");
return ResponseEntity.ok(result);
}
private Map<String, Object> reviewView(GameReviewData review) {
Map<String, Object> view = new LinkedHashMap<>();
view.put("reviewId", review.getId());
view.put("gameId", review.getGameId());
view.put("authorName", review.getAuthorName());
view.put("userId", review.getUserId());
view.put("rating", review.getRating());
view.put("body", review.getBody());
view.put("ratingManual", review.getRatingManual() != null && review.getRatingManual());
view.put("edited", review.getEdited() != null && review.getEdited());
view.put("axes", review.getAxes());
view.put("createdAt", review.getCreatedAt());
view.put("updatedAt", review.getUpdatedAt());
return view;
}
// 6축 점수 파싱·검증. 1~5 필수. 위반 null 반환(컨트롤러가 400).
private Map<String, Integer> parseAxes(String immersion, String creativity, String controls,
String completeness, String sound, String visual) {
Map<String, String> raw = new LinkedHashMap<>();
raw.put("immersion", immersion);
raw.put("creativity", creativity);
raw.put("controls", controls);
raw.put("completeness", completeness);
raw.put("sound", sound);
raw.put("visual", visual);
Map<String, Integer> axes = new LinkedHashMap<>();
for (String key : AXIS_KEYS) {
String v = trimToNull(raw.get(key));
if (v == null) {
return null;
}
int s;
try {
s = Integer.parseInt(v);
} catch (NumberFormatException e) {
return null;
}
if (s < RATING_MIN || s > RATING_MAX) {
return null;
}
axes.put(key, s);
}
return axes;
}
private int averageOf(Map<String, Integer> axes) {
int sum = 0;
for (String key : AXIS_KEYS) {
sum += axes.get(key);
}
return (int) Math.round(sum / (double) AXIS_KEYS.length);
}
private List<ReviewAxisRow> toAxisRows(long reviewId, Map<String, Integer> axes) {
List<ReviewAxisRow> rows = new ArrayList<>();
for (String key : AXIS_KEYS) {
rows.add(new ReviewAxisRow(reviewId, key, axes.get(key)));
}
return rows;
}
private Map<String, Integer> loadAxes(long reviewId) {
Map<String, Integer> axes = new LinkedHashMap<>();
for (ReviewAxisRow ax : gameReviewAxesMapper.listAxesByReviewIds(List.of(reviewId))) {
axes.put(ax.getAxisKey(), ax.getScore());
}
return axes.isEmpty() ? null : axes;
}
private Map<String, Object> buildSummary(long gameId) {
Map<String, Object> stats = gameReviewStatsMapper.getStats(gameId);
if (stats == null) {
return null;
}
Object countValue = stats.get("reviewCount");
long reviewCount = countValue instanceof Number number ? number.longValue() : 0L;
if (reviewCount == 0) {
return null;
}
Map<String, Object> axes = new LinkedHashMap<>();
for (String key : AXIS_KEYS) {
axes.put(key, stats.get(key));
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("avgRating", stats.get("avgRating"));
summary.put("reviewCount", reviewCount);
summary.put("axes", axes);
return summary;
}
private String normalizeReviewSort(String sort) {
return switch (sort == null ? "" : sort) {
case "rating_desc" -> "rating_desc";
case "rating_asc" -> "rating_asc";
default -> "newest";
};
}
private Integer parseRating(String rating) {
String text = trimToNull(rating);
if (text == null) {
return null;
}
int value;
try {
value = Integer.parseInt(text);
} catch (NumberFormatException e) {
return null;
}
if (value < RATING_MIN || value > RATING_MAX) {
return null;
}
return value;
}
private boolean isOperator(String role) {
return ROLE_ADMIN.equals(role);
}
private boolean canModify(Long currentUserId, Long authorUserId, String role) {
return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role);
}
private Long sessionUserId(HttpSession session) {
if (session == null) {
return null;
}
Object userId = session.getAttribute("userId");
if (userId instanceof Number number) {
return number.longValue();
}
if (userId instanceof String text) {
try {
return Long.parseLong(text);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private String sessionRole(HttpSession session) {
if (session == null) {
return null;
}
Object role = session.getAttribute("role");
return role instanceof String text ? text : null;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isBlank() ? null : text;
}
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -6,11 +6,17 @@ public class GameCommentData {
private Long id;
private Long gameId;
private Long userId;
private String nickname;
private String content;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private OffsetDateTime deletedAt;
// 비영속 (SELECT 계산 alias / LEFT JOIN alias)
private Boolean edited;
private String authorName;
public Long getId() {
return id;
}
@ -27,6 +33,14 @@ public class GameCommentData {
this.gameId = gameId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getNickname() {
return nickname;
}
@ -51,6 +65,14 @@ public class GameCommentData {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public OffsetDateTime getDeletedAt() {
return deletedAt;
}
@ -58,4 +80,20 @@ public class GameCommentData {
public void setDeletedAt(OffsetDateTime deletedAt) {
this.deletedAt = deletedAt;
}
public Boolean getEdited() {
return edited;
}
public void setEdited(Boolean edited) {
this.edited = edited;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
}

View File

@ -0,0 +1,118 @@
package com.pandoli365.bibimbap.data;
import java.time.OffsetDateTime;
import java.util.Map;
public class GameReviewData {
private Long id;
private Long gameId;
private Long userId;
private Integer rating;
private String body;
private Boolean ratingManual;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private OffsetDateTime deletedAt;
// 비영속 (목록 JOIN alias / SELECT 계산 alias / reviewView 조립용)
private String authorName;
private Boolean edited;
private Map<String, Integer> axes;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getGameId() {
return gameId;
}
public void setGameId(Long gameId) {
this.gameId = gameId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Integer getRating() {
return rating;
}
public void setRating(Integer rating) {
this.rating = rating;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public OffsetDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(OffsetDateTime deletedAt) {
this.deletedAt = deletedAt;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public Boolean getEdited() {
return edited;
}
public void setEdited(Boolean edited) {
this.edited = edited;
}
public Boolean getRatingManual() {
return ratingManual;
}
public void setRatingManual(Boolean ratingManual) {
this.ratingManual = ratingManual;
}
public Map<String, Integer> getAxes() {
return axes;
}
public void setAxes(Map<String, Integer> axes) {
this.axes = axes;
}
}

View File

@ -0,0 +1,41 @@
package com.pandoli365.bibimbap.data;
public class ReviewAxisRow {
private Long reviewId;
private String axisKey;
private Integer score;
public ReviewAxisRow() {
}
public ReviewAxisRow(Long reviewId, String axisKey, Integer score) {
this.reviewId = reviewId;
this.axisKey = axisKey;
this.score = score;
}
public Long getReviewId() {
return reviewId;
}
public void setReviewId(Long reviewId) {
this.reviewId = reviewId;
}
public String getAxisKey() {
return axisKey;
}
public void setAxisKey(String axisKey) {
this.axisKey = axisKey;
}
public Integer getScore() {
return score;
}
public void setScore(Integer score) {
this.score = score;
}
}

View File

@ -4,33 +4,55 @@ import com.pandoli365.bibimbap.data.GameCommentData;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
@Mapper
public interface GameCommentsMapper {
@Select("""
SELECT
id,
game_id AS gameId,
nickname,
content,
created_at AS createdAt,
deleted_at AS deletedAt
FROM game_comments
WHERE id = #{id}
AND is_delete IS NOT TRUE
c.id,
c.game_id AS gameId,
c.user_id AS userId,
CASE
WHEN c.user_id IS NULL THEN c.nickname
WHEN u.is_delete IS TRUE THEN '(탈퇴한 사용자)'
ELSE u.display_name
END AS authorName,
c.nickname,
c.content,
c.created_at AS createdAt,
c.updated_at AS updatedAt,
(c.updated_at > c.created_at) AS edited,
c.deleted_at AS deletedAt
FROM game_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.id = #{id}
AND c.is_delete IS NOT TRUE
""")
GameCommentData getGameComment(long id);
@SelectProvider(type = CommentSqlProvider.class, method = "listComments")
List<GameCommentData> listGameComments(@Param("gameId") long gameId,
@Param("sort") String sort,
@Param("offset") int offset,
@Param("limit") int limit);
@Insert("""
INSERT INTO game_comments (
game_id,
user_id,
nickname,
content
) VALUES (
#{gameId},
#{userId},
#{nickname},
#{content}
)
@ -38,6 +60,26 @@ public interface GameCommentsMapper {
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int addGameComment(GameCommentData gameComment);
@Update("""
UPDATE game_comments
SET
content = #{content},
updated_at = now()
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int editGameComment(GameCommentData gameComment);
@Update("""
UPDATE game_comments
SET
is_delete = true,
deleted_at = COALESCE(deleted_at, now())
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int softDeleteGameComment(long id);
@Update("""
UPDATE game_comments
SET
@ -49,4 +91,38 @@ public interface GameCommentsMapper {
AND is_delete IS NOT TRUE
""")
int updateGameComment(GameCommentData gameComment);
class CommentSqlProvider {
// SELECT/WHERE 컴파일타임 리터럴. ORDER BY sort enum 으로 고정 문자열 분기(${} 미사용).
public String listComments(Map<String, Object> params) {
String sort = (String) params.get("sort");
String orderBy = "newest".equals(sort)
? "ORDER BY c.created_at DESC, c.id DESC"
: "ORDER BY c.created_at ASC, c.id ASC";
return """
SELECT
c.id,
c.game_id AS gameId,
c.user_id AS userId,
CASE
WHEN c.user_id IS NULL THEN c.nickname
WHEN u.is_delete IS TRUE THEN '(탈퇴한 사용자)'
ELSE u.display_name
END AS authorName,
c.nickname,
c.content,
c.created_at AS createdAt,
c.updated_at AS updatedAt,
(c.updated_at > c.created_at) AS edited,
c.deleted_at AS deletedAt
FROM game_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.game_id = #{gameId}
AND c.is_delete IS NOT TRUE
"""
+ orderBy
+ " LIMIT #{limit} OFFSET #{offset}";
}
}
}

View File

@ -0,0 +1,59 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.ReviewAxisRow;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.InsertProvider;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.jdbc.SQL;
import java.util.List;
import java.util.Map;
@Mapper
public interface GameReviewAxesMapper {
@InsertProvider(type = AxesSqlProvider.class, method = "insertAxes")
int addReviewAxes(@Param("reviewId") long reviewId, @Param("axes") List<ReviewAxisRow> axes);
@Delete("DELETE FROM game_review_axes WHERE review_id = #{reviewId}")
int deleteReviewAxes(@Param("reviewId") long reviewId);
@SelectProvider(type = AxesSqlProvider.class, method = "listByReviewIds")
List<ReviewAxisRow> listAxesByReviewIds(@Param("reviewIds") List<Long> reviewIds);
class AxesSqlProvider {
// VALUES 다행. 값은 #{axes[i].axisKey}/#{axes[i].score} 바인딩(${} 미사용).
public String insertAxes(Map<String, Object> params) {
@SuppressWarnings("unchecked")
List<ReviewAxisRow> axes = (List<ReviewAxisRow>) params.get("axes");
StringBuilder sql = new StringBuilder("INSERT INTO game_review_axes (review_id, axis_key, score) VALUES ");
for (int i = 0; i < axes.size(); i++) {
if (i > 0) {
sql.append(", ");
}
sql.append("(#{reviewId}, #{axes[").append(i).append("].axisKey}, #{axes[").append(i).append("].score})");
}
return sql.toString();
}
// IN (...) batch. reviewIds 원소 #{reviewIds[i]} 바인딩.
public String listByReviewIds(Map<String, Object> params) {
@SuppressWarnings("unchecked")
List<Long> ids = (List<Long>) params.get("reviewIds");
SQL sql = new SQL();
sql.SELECT("review_id AS reviewId, axis_key AS axisKey, score").FROM("game_review_axes");
StringBuilder in = new StringBuilder();
for (int i = 0; i < ids.size(); i++) {
if (i > 0) {
in.append(", ");
}
in.append("#{reviewIds[").append(i).append("]}");
}
sql.WHERE("review_id IN (" + in + ")");
return sql.toString();
}
}
}

View File

@ -0,0 +1,26 @@
package com.pandoli365.bibimbap.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.Map;
@Mapper
public interface GameReviewStatsMapper {
@Select("""
SELECT
game_id AS gameId,
avg_rating AS avgRating,
review_count AS reviewCount,
avg_immersion AS immersion,
avg_creativity AS creativity,
avg_controls AS controls,
avg_completeness AS completeness,
avg_sound AS sound,
avg_visual AS visual
FROM game_review_stats
WHERE game_id = #{gameId}
""")
Map<String, Object> getStats(long gameId);
}

View File

@ -0,0 +1,122 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.GameReviewData;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
@Mapper
public interface GameReviewsMapper {
@Select("""
SELECT
r.id,
r.game_id AS gameId,
r.user_id AS userId,
r.rating,
r.body,
r.is_rating_manual AS ratingManual,
u.display_name AS authorName,
(r.updated_at > r.created_at) AS edited,
r.created_at AS createdAt,
r.updated_at AS updatedAt,
r.deleted_at AS deletedAt
FROM game_reviews r
JOIN users u ON u.id = r.user_id
WHERE r.id = #{id}
AND r.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
""")
GameReviewData getGameReview(long id);
@SelectProvider(type = ReviewSqlProvider.class, method = "listReviews")
List<GameReviewData> listGameReviews(@Param("gameId") long gameId,
@Param("sort") String sort,
@Param("offset") int offset,
@Param("limit") int limit);
@Select("""
SELECT
r.id,
r.game_id AS gameId,
r.user_id AS userId,
r.rating,
r.body,
r.is_rating_manual AS ratingManual,
r.created_at AS createdAt,
r.updated_at AS updatedAt
FROM game_reviews r
WHERE r.game_id = #{gameId}
AND r.user_id = #{userId}
AND r.is_delete IS NOT TRUE
""")
GameReviewData getActiveReviewByGameAndUser(@Param("gameId") long gameId, @Param("userId") long userId);
@Insert("""
INSERT INTO game_reviews (
game_id, user_id, rating, body, is_rating_manual
) VALUES (
#{gameId}, #{userId}, #{rating}, #{body}, #{ratingManual}
)
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int addGameReview(GameReviewData review);
@Update("""
UPDATE game_reviews
SET rating = #{rating}, body = #{body},
is_rating_manual = #{ratingManual}, updated_at = now()
WHERE id = #{id} AND is_delete IS NOT TRUE
""")
int editGameReview(GameReviewData review);
@Update("""
UPDATE game_reviews
SET
is_delete = true,
deleted_at = COALESCE(deleted_at, now())
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int softDeleteGameReview(long id);
class ReviewSqlProvider {
// SELECT/JOIN/WHERE 컴파일타임 리터럴. ORDER BY sort enum 으로 고정 문자열 분기(${} 미사용).
public String listReviews(Map<String, Object> params) {
String sort = (String) params.get("sort");
String orderBy = switch (sort == null ? "" : sort) {
case "rating_desc" -> "ORDER BY r.rating DESC, r.created_at DESC, r.id DESC";
case "rating_asc" -> "ORDER BY r.rating ASC, r.created_at DESC, r.id DESC";
default -> "ORDER BY r.created_at DESC, r.id DESC";
};
return """
SELECT
r.id,
r.game_id AS gameId,
r.user_id AS userId,
r.rating,
r.body,
r.is_rating_manual AS ratingManual,
u.display_name AS authorName,
(r.updated_at > r.created_at) AS edited,
r.created_at AS createdAt,
r.updated_at AS updatedAt
FROM game_reviews r
JOIN users u ON u.id = r.user_id
WHERE r.game_id = #{gameId}
AND r.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
"""
+ orderBy
+ " LIMIT #{limit} OFFSET #{offset}";
}
}
}

View File

@ -172,6 +172,16 @@ public interface GamesMapper {
""")
int softDeleteGameComments(@Param("gameId") long gameId);
@Update("""
UPDATE game_reviews
SET
is_delete = true,
deleted_at = COALESCE(deleted_at, now())
WHERE game_id = #{gameId}
AND is_delete IS NOT TRUE
""")
int softDeleteGameReviews(@Param("gameId") long gameId);
@Delete("""
DELETE FROM game_likes
WHERE game_id = #{gameId}

View File

@ -0,0 +1,27 @@
package com.pandoli365.bibimbap.util;
public final class TextNormalizer {
private TextNormalizer() {
}
// 저장 위생: \t/\n/\r 외의 C0/C1 제어문자 제거 + 외곽 strip. 내부 공백/줄바꿈 보존. nullnull.
public static String normalize(String raw) {
if (raw == null) {
return null;
}
StringBuilder sb = new StringBuilder(raw.length());
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (c == '\t' || c == '\n' || c == '\r') {
sb.append(c);
continue;
}
if (c <= 0x1F || (c >= 0x7F && c <= 0x9F)) {
continue;
}
sb.append(c);
}
return sb.toString().strip();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,9 @@
package com.pandoli365.bibimbap;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
@ -19,6 +23,18 @@ class BibimbapApplicationTests {
@MockBean
private GamesMapper gamesMapper;
@MockBean
private GameCommentsMapper gameCommentsMapper;
@MockBean
private GameReviewsMapper gameReviewsMapper;
@MockBean
private GameReviewAxesMapper gameReviewAxesMapper;
@MockBean
private GameReviewStatsMapper gameReviewStatsMapper;
@MockBean
private RecruitPostsMapper recruitPostsMapper;

View File

@ -0,0 +1,429 @@
package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameCommentData;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GameCommentControllerTest {
@Mock
private GameCommentsMapper gameCommentsMapper;
@Mock
private GamesMapper gamesMapper;
// ---- AC-1: 댓글 CRUD ----
@Test
void createCommentPersistsAndReturnsView() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameCommentData.class).setId(100L);
return 1;
});
when(gameCommentsMapper.getGameComment(100L)).thenReturn(persistedComment(100L, 1L, 7L, "작성자", false));
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "좋은 게임", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("commentId", 100L);
assertThat(response.getBody()).containsEntry("authorName", "작성자");
assertThat(response.getBody()).containsEntry("userId", 7L);
verify(gameCommentsMapper).addGameComment(any(GameCommentData.class));
}
@Test
void listCommentsReturnsViewArray() {
GameCommentController controller = controller();
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
GameCommentData c = persistedComment(5L, 1L, 7L, "작성자", false);
when(gameCommentsMapper.listGameComments(eq(1L), anyString(), anyInt(), anyInt()))
.thenReturn(List.of(c));
ResponseEntity<Map<String, Object>> response = controller.listComments(1L, 0, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsKey("hasMore");
@SuppressWarnings("unchecked")
List<Map<String, Object>> comments = (List<Map<String, Object>>) response.getBody().get("comments");
assertThat(comments).hasSize(1);
assertThat(comments.get(0)).containsEntry("commentId", 5L);
assertThat(comments.get(0)).containsEntry("authorName", "작성자");
}
@Test
void updateCommentByAuthorSucceeds() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gameCommentsMapper.getGameComment(5L))
.thenReturn(comment(5L, 1L, 7L))
.thenReturn(persistedComment(5L, 1L, 7L, "작성자", true));
ResponseEntity<Map<String, Object>> response =
controller.updateComment(1L, 5L, "수정된 내용", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameCommentsMapper).editGameComment(any(GameCommentData.class));
}
@Test
void deleteCommentByAuthorSucceeds() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L));
ResponseEntity<Map<String, Object>> response =
controller.deleteComment(1L, 5L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameCommentsMapper).softDeleteGameComment(5L);
}
// ---- AC-2: 비작성자 불가 + 운영자 예외 ----
@Test
void updateCommentByNonAuthorIsForbidden() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(8L, "USER", "다른사용자");
MockHttpServletRequest request = csrfPost(session);
when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L));
ResponseEntity<Map<String, Object>> response =
controller.updateComment(1L, 5L, "강제수정", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(gameCommentsMapper, never()).editGameComment(any());
}
@Test
void deleteCommentByOperatorSucceeds() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(99L, "ADMIN", "운영자");
MockHttpServletRequest request = csrfPost(session);
when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L));
ResponseEntity<Map<String, Object>> response =
controller.deleteComment(1L, 5L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameCommentsMapper).softDeleteGameComment(5L);
}
// ---- AC-5: 댓글 201자 거부 / 200자 경계 ----
@Test
void createCommentRejects201Chars() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "".repeat(201), request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(gameCommentsMapper, never()).addGameComment(any());
}
@Test
void createCommentAccepts200CharsBoundary() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameCommentData.class).setId(101L);
return 1;
});
when(gameCommentsMapper.getGameComment(101L)).thenReturn(persistedComment(101L, 1L, 7L, "작성자", false));
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "".repeat(200), request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameCommentsMapper).addGameComment(any(GameCommentData.class));
}
// ---- AC-7: CSRF 부재 403 (mutation ) ----
@Test
void createCommentRejectsMissingCsrfBeforeMapperAccess() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "내용", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameCommentsMapper);
verify(gamesMapper, never()).getGame(anyLong());
}
@Test
void updateCommentRejectsMissingCsrfBeforeMapperAccess() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.updateComment(1L, 5L, "내용", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameCommentsMapper);
}
@Test
void deleteCommentRejectsMissingCsrfBeforeMapperAccess() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.deleteComment(1L, 5L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameCommentsMapper);
}
// ---- 401 (비로그인) ----
@Test
void createCommentRequiresLogin() {
GameCommentController controller = controller();
MockHttpSession session = new MockHttpSession();
CsrfTokens.getOrCreate(session);
MockHttpServletRequest request = csrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "내용", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(gameCommentsMapper);
}
// ---- AC-11 (A1): POST/PUT 응답 = commentView 전체 8키 ----
@Test
void createCommentReturnsFullCommentView() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameCommentData.class).setId(100L);
return 1;
});
OffsetDateTime created = OffsetDateTime.now();
GameCommentData persisted = persistedComment(100L, 1L, 7L, "작성자", false);
persisted.setCreatedAt(created);
persisted.setUpdatedAt(created);
when(gameCommentsMapper.getGameComment(100L)).thenReturn(persisted);
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "좋은 게임", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = response.getBody();
assertThat(body).containsKeys(
"commentId", "gameId", "authorName", "userId",
"content", "createdAt", "edited", "updatedAt");
assertThat(body).containsEntry("commentId", 100L);
assertThat(body).containsEntry("edited", false);
assertThat(body.get("createdAt")).isEqualTo(created);
assertThat(body.get("updatedAt")).isEqualTo(created);
}
@Test
void updateCommentReturnsFullCommentView() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
OffsetDateTime created = OffsetDateTime.now().minusMinutes(10);
OffsetDateTime updated = OffsetDateTime.now();
GameCommentData persisted = persistedComment(5L, 1L, 7L, "작성자", true);
persisted.setCreatedAt(created);
persisted.setUpdatedAt(updated);
when(gameCommentsMapper.getGameComment(5L))
.thenReturn(comment(5L, 1L, 7L))
.thenReturn(persisted);
ResponseEntity<Map<String, Object>> response =
controller.updateComment(1L, 5L, "수정된 내용", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = response.getBody();
assertThat(body).containsKeys(
"commentId", "gameId", "authorName", "userId",
"content", "createdAt", "edited", "updatedAt");
assertThat(body).containsEntry("edited", true);
assertThat(body.get("updatedAt")).isEqualTo(updated);
}
// ---- AC-12 (A2): 탈퇴 사용자 마스킹 (authorName 우선 노출) ----
@Test
void createCommentExposesMaskedAuthorNameFromReFetch() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameCommentData.class).setId(100L);
return 1;
});
// 재조회 SQL CASE 결과 authorName 마스킹 값이면 컨트롤러는 getAuthorName 우선 노출
when(gameCommentsMapper.getGameComment(100L))
.thenReturn(persistedComment(100L, 1L, 7L, "(탈퇴한 사용자)", false));
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "좋은 게임", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("authorName", "(탈퇴한 사용자)");
}
// ---- AC-13 (A3): 수정 editGameComment 호출 + updatedAt 노출 ----
@Test
void updateCommentInvokesEditAndExposesUpdatedAt() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
OffsetDateTime updated = OffsetDateTime.now();
GameCommentData persisted = persistedComment(5L, 1L, 7L, "작성자", true);
persisted.setUpdatedAt(updated);
when(gameCommentsMapper.getGameComment(5L))
.thenReturn(comment(5L, 1L, 7L))
.thenReturn(persisted);
ResponseEntity<Map<String, Object>> response =
controller.updateComment(1L, 5L, "수정", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameCommentsMapper).editGameComment(any(GameCommentData.class));
assertThat(response.getBody()).containsEntry("edited", true);
assertThat(response.getBody().get("updatedAt")).isEqualTo(updated);
}
// ---- B3: 제어문자(NUL) 정규화 TextNormalizer NUL 제거, 일반 공백은 보존 ----
@Test
void createCommentStripsControlCharsBeforePersist() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameCommentData.class).setId(100L);
return 1;
});
when(gameCommentsMapper.getGameComment(100L)).thenReturn(persistedComment(100L, 1L, 7L, "작성자", false));
String nul = "\u0000";
String withControlChars = "좋은" + nul + " " + nul + "게임";
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, withControlChars, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<GameCommentData> captor = ArgumentCaptor.forClass(GameCommentData.class);
verify(gameCommentsMapper).addGameComment(captor.capture());
String stored = captor.getValue().getContent();
assertThat(stored).doesNotContain(nul);
assertThat(stored).isEqualTo("좋은 게임");
}
// ---- helpers ----
private GameCommentController controller() {
return new GameCommentController(gameCommentsMapper, gamesMapper);
}
private GameData game(long id) {
GameData g = new GameData();
g.setId(id);
g.setUserId(1L);
return g;
}
private GameCommentData comment(long id, long gameId, long userId) {
GameCommentData c = new GameCommentData();
c.setId(id);
c.setGameId(gameId);
c.setUserId(userId);
c.setNickname("작성자");
c.setContent("기존 내용");
return c;
}
// 재조회분: SQL alias authorName/edited/createdAt/updatedAt 채워진
private GameCommentData persistedComment(long id, long gameId, long userId, String authorName, boolean edited) {
GameCommentData c = new GameCommentData();
c.setId(id);
c.setGameId(gameId);
c.setUserId(userId);
c.setNickname("작성자");
c.setAuthorName(authorName);
c.setContent("내용");
c.setEdited(edited);
c.setCreatedAt(OffsetDateTime.now());
c.setUpdatedAt(OffsetDateTime.now());
return c;
}
private MockHttpSession loginSession(long userId, String role, String displayName) {
MockHttpSession session = new MockHttpSession();
session.setAttribute("userId", userId);
session.setAttribute("role", role);
session.setAttribute("displayName", displayName);
CsrfTokens.getOrCreate(session);
return session;
}
private MockHttpServletRequest csrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE));
return request;
}
private MockHttpServletRequest noCsrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
return request;
}
}

View File

@ -0,0 +1,477 @@
package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.GameReviewData;
import com.pandoli365.bibimbap.data.ReviewAxisRow;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GameReviewControllerTest {
@Mock
private GameReviewsMapper gameReviewsMapper;
@Mock
private GameReviewAxesMapper gameReviewAxesMapper;
@Mock
private GameReviewStatsMapper gameReviewStatsMapper;
@Mock
private GamesMapper gamesMapper;
// 6축 유효 입력(immersion,creativity,controls,completeness,sound,visual).
private static final String[] AXES_OK = {"4", "5", "3", "4", "2", "5"};
// ---- AC-4: 수정 edited 토글 (작성 직후 false 수정 true) ----
@Test
void createReviewReturnsEditedFalse() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, null, "정말 재미있는 게임입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("reviewId", 50L);
assertThat(response.getBody()).containsEntry("edited", false);
verify(gameReviewsMapper).addGameReview(any(GameReviewData.class));
}
@Test
void updateReviewReturnsEditedTrue() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gameReviewsMapper.getGameReview(50L))
.thenReturn(review(50L, 1L, 7L, 4, false))
.thenReturn(review(50L, 1L, 7L, 5, true));
ResponseEntity<Map<String, Object>> response =
updateReview(controller, 1L, 50L, "5", "수정 후 훨씬 더 좋아졌습니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("edited", true);
verify(gameReviewsMapper).editGameReview(any(GameReviewData.class));
}
// ---- AC-3: 게임당 1회 409 ----
@Test
void createSecondReviewReturnsConflict() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L))
.thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "5", "같은 게임에 또 작성 시도합니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
verify(gameReviewsMapper, never()).addGameReview(any());
}
// ---- AC-6: overall rating 0/6 거부, 1/5 경계 ----
@Test
void createReviewRejectsRatingZero() {
assertCreateRatingRejected("0");
}
@Test
void createReviewRejectsRatingSix() {
assertCreateRatingRejected("6");
}
@Test
void createReviewAcceptsRatingOneBoundary() {
assertCreateRatingAccepted("1");
}
@Test
void createReviewAcceptsRatingFiveBoundary() {
assertCreateRatingAccepted("5");
}
@Test
void updateReviewRejectsRatingSix() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response =
updateReview(controller, 1L, 50L, "6", "범위초과", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(gameReviewsMapper, never()).editGameReview(any());
}
// ---- AC-2: 비작성자 불가 + 운영자 예외 ----
@Test
void updateReviewByNonAuthorIsForbidden() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(8L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response =
updateReview(controller, 1L, 50L, "3", "강제수정", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(gameReviewsMapper, never()).editGameReview(any());
}
@Test
void deleteReviewByOperatorSucceeds() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(99L, "ADMIN");
MockHttpServletRequest request = csrfPost(session);
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response =
controller.deleteReview(1L, 50L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameReviewsMapper).softDeleteGameReview(50L);
}
// ---- AC-7: CSRF 부재 403 ----
@Test
void createReviewRejectsMissingCsrfBeforeMapperAccess() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "4", "내용입니다열자", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameReviewsMapper);
verify(gamesMapper, never()).getGame(anyLong());
}
@Test
void updateReviewRejectsMissingCsrfBeforeMapperAccess() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
updateReview(controller, 1L, 50L, "4", "내용입니다열자", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameReviewsMapper);
}
@Test
void deleteReviewRejectsMissingCsrfBeforeMapperAccess() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.deleteReview(1L, 50L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameReviewsMapper);
}
// ---- AC-7(다축): 6축 누락 400 ----
@Test
void createReviewRejectsMissingAxis() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
// immersion 빈값 6축 미충족 400.
String[] missing = {"", "5", "3", "4", "2", "5"};
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "4", "내용입니다열자", missing, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(gameReviewsMapper, never()).addGameReview(any());
}
// ---- AC-8: overall 자동평균 / 수동 ----
@Test
void createReviewAutoAveragesOverallWhenRatingOmitted() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
// 6축 [4,5,3,4,2,5] 23 / 6 = 3.83 round 4, ratingManual=false.
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, null, "별점 자동평균 계산 검증 본문", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<GameReviewData> captor = ArgumentCaptor.forClass(GameReviewData.class);
verify(gameReviewsMapper).addGameReview(captor.capture());
assertThat(captor.getValue().getRating()).isEqualTo(4);
assertThat(captor.getValue().getRatingManual()).isFalse();
}
@Test
void createReviewUsesManualOverallWhenRatingProvided() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 2, false));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "2", "수동 별점 사용 검증 본문입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<GameReviewData> captor = ArgumentCaptor.forClass(GameReviewData.class);
verify(gameReviewsMapper).addGameReview(captor.capture());
assertThat(captor.getValue().getRating()).isEqualTo(2);
assertThat(captor.getValue().getRatingManual()).isTrue();
}
// ---- 다축 저장: addReviewAxes 6행 ----
@Test
void createReviewPersistsSixAxes() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, null, "여섯 축 저장 검증용 본문입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<ReviewAxisRow>> captor = ArgumentCaptor.forClass(List.class);
verify(gameReviewAxesMapper).addReviewAxes(eq(50L), captor.capture());
List<ReviewAxisRow> axes = captor.getValue();
assertThat(axes).hasSize(6);
assertThat(axes).extracting(ReviewAxisRow::getAxisKey)
.containsExactlyInAnyOrder(
"immersion", "creativity", "controls", "completeness", "sound", "visual");
}
// ---- AC-9: B2 본문 최소 10자 ----
@Test
void createReviewRejectsBodyUnderTenChars() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
// trim 9자 400.
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "4", "".repeat(9), AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(gameReviewsMapper, never()).addGameReview(any());
}
@Test
void createReviewAcceptsBodyTenCharsBoundary() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "4", "".repeat(10), AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameReviewsMapper).addGameReview(any(GameReviewData.class));
}
// ---- B1: listReviews 미허용 sort 관대 처리(200) ----
@Test
void listReviewsToleratesUnknownSort() {
GameReviewController controller = controller();
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.listGameReviews(eq(1L), anyString(), anyInt(), anyInt()))
.thenReturn(List.of(review(50L, 1L, 7L, 4, false)));
lenient().when(gameReviewAxesMapper.listAxesByReviewIds(anyList())).thenReturn(List.of());
lenient().when(gameReviewStatsMapper.getStats(1L)).thenReturn(null);
ResponseEntity<Map<String, Object>> response = controller.listReviews(1L, 0, "garbage");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsKey("hasMore");
}
// ---- helpers ----
private void assertCreateRatingRejected(String rating) {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, rating, "별점 범위 검증용 본문입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(gameReviewsMapper, never()).addGameReview(any());
}
private void assertCreateRatingAccepted(String rating) {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L))
.thenReturn(review(50L, 1L, 7L, Integer.parseInt(rating), false));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, rating, "별점 경계값 통과 검증 본문입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameReviewsMapper).addGameReview(any(GameReviewData.class));
}
// createReview positional 호출 래퍼. axes = [immersion,creativity,controls,completeness,sound,visual].
private ResponseEntity<Map<String, Object>> createReview(
GameReviewController controller, long id, String rating, String body,
String[] axes, MockHttpServletRequest request, MockHttpSession session) {
return controller.createReview(id, rating, body,
axes[0], axes[1], axes[2], axes[3], axes[4], axes[5], request, session);
}
private ResponseEntity<Map<String, Object>> updateReview(
GameReviewController controller, long id, long reviewId, String rating, String body,
String[] axes, MockHttpServletRequest request, MockHttpSession session) {
return controller.updateReview(id, reviewId, rating, body,
axes[0], axes[1], axes[2], axes[3], axes[4], axes[5], request, session);
}
private GameReviewController controller() {
return new GameReviewController(
gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper);
}
private GameData game(long id) {
GameData g = new GameData();
g.setId(id);
g.setUserId(1L);
return g;
}
private GameReviewData review(long id, long gameId, long userId, int rating, boolean edited) {
GameReviewData r = new GameReviewData();
r.setId(id);
r.setGameId(gameId);
r.setUserId(userId);
r.setRating(rating);
r.setBody("내용");
r.setAuthorName("작성자");
r.setEdited(edited);
r.setRatingManual(false);
Map<String, Integer> axes = new LinkedHashMap<>();
axes.put("immersion", 4);
axes.put("creativity", 5);
axes.put("controls", 3);
axes.put("completeness", 4);
axes.put("sound", 2);
axes.put("visual", 5);
r.setAxes(axes);
return r;
}
private MockHttpSession loginSession(long userId, String role) {
MockHttpSession session = new MockHttpSession();
session.setAttribute("userId", userId);
session.setAttribute("role", role);
session.setAttribute("displayName", "작성자");
CsrfTokens.getOrCreate(session);
return session;
}
private MockHttpServletRequest csrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE));
return request;
}
private MockHttpServletRequest noCsrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
return request;
}
}