Compare commits

...

5 Commits

Author SHA1 Message Date
이정수 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
52 changed files with 5802 additions and 116 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,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

@ -108,6 +108,34 @@ 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;
-- 향후 다축(육각형) 확장 시: rating 유지 + game_review_axes(review_id, axis, score) 별도 테이블 분리.
-- 집계 컬럼/뷰는 W2-3 동결 — 신설 금지.
-- ---------------------------------------------------------------------------
-- 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 시상 후속.
2. 운영자 role 부여 경로 — W1 RBAC/Interceptor 연결 시 활성.
3. 다축(육각형) 평점 — 현재 단일 rating, game_review_axes 분리 여지(주석).
4. 리뷰 이력 열람 권한 — 열람 정책 확정 시 별도 이력 테이블.
5. GameCatalog 정적 폴백 게임 대상 댓글/리뷰 — DB 실제 게임만 기능 동작(폴백은 404).

View File

@ -4,4 +4,4 @@
## 목록
_(아직 문서 없음)_
- [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. 좋아요는 범위밖.

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

@ -6,5 +6,6 @@
- [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용)
- [document-category-classification.md](./document-category-classification.md) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리)
- [agent-output-conventions.md](./agent-output-conventions.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 → 로그 스캔 순차 실행을 담당.

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

@ -0,0 +1,100 @@
-- 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 필터용. 집계 컬럼/뷰는 신설하지 않음 (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 — 레거시 닉네임 덧글 보존)';

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

@ -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,247 @@
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 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 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) {
if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
List<Map<String, Object>> comments = new ArrayList<>();
for (GameCommentData comment : gameCommentsMapper.listGameComments(id)) {
comments.add(commentView(comment));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("comments", comments);
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(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, "덧글 등록 결과를 확인하지 못했습니다.");
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "덧글이 등록되었습니다.");
body.put("commentId", comment.getId());
body.put("gameId", id);
body.put("authorName", authorName);
body.put("userId", userId);
body.put("content", normalizedContent);
return ResponseEntity.ok(body);
}
@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(content);
if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) {
return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요.");
}
comment.setContent(normalizedContent);
gameCommentsMapper.editGameComment(comment);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "덧글이 수정되었습니다.");
body.put("commentId", commentId);
body.put("content", normalizedContent);
return ResponseEntity.ok(body);
}
@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.getNickname());
view.put("userId", comment.getUserId());
view.put("content", comment.getContent());
view.put("createdAt", comment.getCreatedAt());
return view;
}
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));
model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id));
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,286 @@
package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameReviewData;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
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_MAX = 1000;
private static final String ROLE_ADMIN = "ADMIN";
private final GameReviewsMapper gameReviewsMapper;
private final GamesMapper gamesMapper;
public GameReviewController(GameReviewsMapper gameReviewsMapper, GamesMapper gamesMapper) {
this.gameReviewsMapper = gameReviewsMapper;
this.gamesMapper = gamesMapper;
}
@GetMapping("/game/{id}/reviews")
public ResponseEntity<Map<String, Object>> listReviews(@PathVariable("id") long id) {
if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
List<Map<String, Object>> reviews = new ArrayList<>();
for (GameReviewData review : gameReviewsMapper.listGameReviews(id)) {
reviews.add(reviewView(review));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("reviews", reviews);
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, "리뷰를 찾을 수 없습니다.");
}
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,
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, "게임을 찾을 수 없습니다.");
}
Integer parsedRating = parseRating(rating);
if (parsedRating == null) {
return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요.");
}
String normalizedBody = trimToEmpty(body);
if (normalizedBody.length() > BODY_MAX) {
return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요.");
}
if (gameReviewsMapper.getActiveReviewByGameAndUser(id, userId) != null) {
return response(HttpStatus.CONFLICT, "이미 이 게임에 리뷰를 작성하셨습니다.");
}
GameReviewData review = new GameReviewData();
review.setGameId(id);
review.setUserId(userId);
review.setRating(parsedRating);
review.setBody(normalizedBody);
gameReviewsMapper.addGameReview(review);
if (review.getId() == null) {
return response(HttpStatus.INTERNAL_SERVER_ERROR, "리뷰 등록 결과를 확인하지 못했습니다.");
}
GameReviewData created = gameReviewsMapper.getGameReview(review.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,
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, "작성자만 수정할 수 있습니다.");
}
Integer parsedRating = parseRating(rating);
if (parsedRating == null) {
return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요.");
}
String normalizedBody = trimToEmpty(body);
if (normalizedBody.length() > BODY_MAX) {
return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요.");
}
review.setRating(parsedRating);
review.setBody(normalizedBody);
gameReviewsMapper.editGameReview(review);
GameReviewData updated = gameReviewsMapper.getGameReview(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("edited", review.getEdited() != null && review.getEdited());
view.put("createdAt", review.getCreatedAt());
view.put("updatedAt", review.getUpdatedAt());
return view;
}
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 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

@ -6,6 +6,7 @@ public class GameCommentData {
private Long id;
private Long gameId;
private Long userId;
private String nickname;
private String content;
private OffsetDateTime createdAt;
@ -27,6 +28,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;
}

View File

@ -0,0 +1,99 @@
package com.pandoli365.bibimbap.data;
import java.time.OffsetDateTime;
public class GameReviewData {
private Long id;
private Long gameId;
private Long userId;
private Integer rating;
private String body;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private OffsetDateTime deletedAt;
// 비영속 (목록 JOIN alias / SELECT 계산 alias)
private String authorName;
private Boolean edited;
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;
}
}

View File

@ -4,9 +4,12 @@ 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.Update;
import java.util.List;
@Mapper
public interface GameCommentsMapper {
@ -14,6 +17,7 @@ public interface GameCommentsMapper {
SELECT
id,
game_id AS gameId,
user_id AS userId,
nickname,
content,
created_at AS createdAt,
@ -24,13 +28,30 @@ public interface GameCommentsMapper {
""")
GameCommentData getGameComment(long id);
@Select("""
SELECT
id,
game_id AS gameId,
user_id AS userId,
nickname AS authorName,
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
""")
List<GameCommentData> listGameComments(@Param("gameId") long gameId);
@Insert("""
INSERT INTO game_comments (
game_id,
user_id,
nickname,
content
) VALUES (
#{gameId},
#{userId},
#{nickname},
#{content}
)
@ -38,6 +59,25 @@ public interface GameCommentsMapper {
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int addGameComment(GameCommentData gameComment);
@Update("""
UPDATE game_comments
SET
content = #{content}
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

View File

@ -0,0 +1,108 @@
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.Update;
import java.util.List;
@Mapper
public interface GameReviewsMapper {
@Select("""
SELECT
r.id,
r.game_id AS gameId,
r.user_id AS userId,
r.rating,
r.body,
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);
@Select("""
SELECT
r.id,
r.game_id AS gameId,
r.user_id AS userId,
r.rating,
r.body,
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
ORDER BY r.created_at DESC, r.id DESC
""")
List<GameReviewData> listGameReviews(@Param("gameId") long gameId);
@Select("""
SELECT
r.id,
r.game_id AS gameId,
r.user_id AS userId,
r.rating,
r.body,
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
) VALUES (
#{gameId},
#{userId},
#{rating},
#{body}
)
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int addGameReview(GameReviewData review);
@Update("""
UPDATE game_reviews
SET
rating = #{rating},
body = #{body},
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);
}

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

@ -680,6 +680,250 @@
.game-comments__hint {
text-align: center;
}
.game-reviews__form-buttons {
width: 100%;
flex-direction: column-reverse;
}
.game-reviews__cancel,
.game-reviews__form-buttons #game-review-submit {
width: 100%;
}
}
/* ── 로그인 게이트 (덧글/리뷰 공통) ───────────────────── */
.game-comments__login-gate {
margin: 0 0 0.25rem;
padding: 1.1rem 1rem;
font-size: 0.875rem;
text-align: center;
color: var(--text-muted);
background: var(--surface);
border: 1px dashed var(--border);
border-radius: 12px;
}
.game-comments__login-gate a {
font-weight: 700;
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid rgba(232, 165, 75, 0.4);
}
.game-comments__login-gate a:hover {
border-bottom-color: var(--accent);
}
/* ── 별점 위젯 ───────────────────────────────────────── */
.game-stars {
display: inline-flex;
gap: 0.15rem;
line-height: 1;
}
.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;
-webkit-tap-highlight-color: transparent;
}
.game-stars__btn:hover {
transform: scale(1.12);
}
.game-stars__btn.is-on {
color: var(--accent);
}
.game-stars--input:focus-within .game-stars__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
/* 읽기 전용 별 표시 (리뷰 카드/요약) */
.game-stars--display,
.game-stars--avg {
gap: 0.05rem;
font-size: 0.95rem;
}
.game-stars--display .game-star,
.game-stars--avg .game-star {
color: var(--border);
}
.game-stars--display .game-star.is-on,
.game-stars--avg .game-star.is-on {
color: var(--accent);
}
/* ── 리뷰 작성/수정 폼 ───────────────────────────────── */
.game-reviews__composer {
margin-bottom: 1.25rem;
padding: 1.1rem 1.15rem 1.2rem;
background: var(--accent-soft);
border: 1px solid rgba(232, 165, 75, 0.22);
border-radius: 14px;
}
.game-reviews__rating-row {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 0.85rem;
}
.game-reviews__rating-row .game-comments__label {
margin-bottom: 0;
}
.game-reviews__form-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.game-reviews__cancel {
padding: 0.55rem 1.1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
}
.game-reviews__cancel:hover {
color: var(--text);
border-color: var(--accent);
}
/* ── 리뷰 요약 (평균 별점) ───────────────────────────── */
.game-reviews__summary {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.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-reviews__list {
margin: 0;
padding: 0;
list-style: none;
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;
transition: border-color 0.2s ease;
}
.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;
color: var(--text);
}
.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__edited {
font-size: 0.6875rem;
font-weight: 500;
color: var(--text-muted);
}
.game-reviews__time {
margin-left: auto;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--text-muted);
}
.game-reviews__body {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
}
.game-reviews__footer {
margin-top: 0.6rem;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.game-reviews__action {
padding: 0.25rem 0.6rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.game-reviews__action--edit:hover {
color: var(--accent);
border-color: rgba(232, 165, 75, 0.3);
background: var(--accent-soft);
}
.game-reviews__action--delete:hover {
color: #b33;
border-color: rgba(180, 50, 50, 0.25);
background: rgba(180, 50, 50, 0.06);
}
/* 덧글 수정 폼 (인라인) */
.game-comments__edit-form {
margin-top: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.game-comments__edit-form textarea {
width: 100%;
box-sizing: border-box;
min-height: 4.5rem;
padding: 0.65rem 0.85rem;
font-size: 0.875rem;
font-family: inherit;
line-height: 1.55;
color: var(--text);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
resize: vertical;
}
.game-comments__edit-form textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.18);
}
</style>
</head>
@ -777,6 +1021,49 @@
</div>
</section>
<section class="game-panel game-panel--reviews" aria-labelledby="reviews-heading">
<div class="game-panel__head">
<span class="game-panel__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l2.9 6.2 6.8.9-5 4.7 1.3 6.7L12 17.8 5.9 21.2 7.2 14.5l-5-4.7 6.8-.9z"/></svg>
</span>
<div>
<h2 id="reviews-heading" class="game-panel__title">리뷰</h2>
<p class="game-panel__subtitle">별점과 함께 플레이 평가를 남겨 주세요. 게임당 한 번 작성할 수 있어요.</p>
</div>
<div class="game-reviews__summary" id="game-reviews-summary" hidden>
<span class="game-reviews__avg" id="game-reviews-avg"></span>
<div class="game-stars game-stars--avg" id="game-reviews-avg-stars" aria-hidden="true"></div>
<span class="game-reviews__count" id="game-reviews-count"></span>
</div>
</div>
<div class="game-reviews__composer" id="game-review-composer" hidden>
<form id="game-review-form" class="game-comments__form" novalidate>
<div class="game-reviews__rating-row">
<span class="game-comments__label" id="game-review-stars-label">별점</span>
<div class="game-stars game-stars--input" id="game-review-stars" role="radiogroup" aria-labelledby="game-review-stars-label">
<button type="button" class="game-stars__btn" data-value="1" role="radio" aria-checked="false" aria-label="별 1점">★</button>
<button type="button" class="game-stars__btn" data-value="2" role="radio" aria-checked="false" aria-label="별 2점">★</button>
<button type="button" class="game-stars__btn" data-value="3" role="radio" aria-checked="false" aria-label="별 3점">★</button>
<button type="button" class="game-stars__btn" data-value="4" role="radio" aria-checked="false" aria-label="별 4점">★</button>
<button type="button" class="game-stars__btn" data-value="5" role="radio" aria-checked="false" aria-label="별 5점">★</button>
</div>
</div>
<label class="game-comments__label" for="game-review-input">평가</label>
<textarea id="game-review-input" name="body" rows="4" maxlength="1000" placeholder="이 게임을 어떻게 즐기셨나요?"></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint" id="game-review-hint">별점은 필수예요 · 최대 1,000자</span>
<div class="game-reviews__form-buttons">
<button type="button" class="game-reviews__cancel" id="game-review-cancel" hidden>취소</button>
<button type="submit" id="game-review-submit">리뷰 등록</button>
</div>
</div>
</form>
</div>
<p class="game-comments__login-gate" id="game-review-login-gate" hidden>리뷰를 남기려면 <a href="<%= ctx %>/login">로그인</a>이 필요해요.</p>
<p id="game-reviews-empty" class="game-comments__empty" hidden>아직 리뷰가 없습니다.<br>첫 번째 리뷰를 남겨 보세요.</p>
<ul id="game-review-list" class="game-reviews__list" aria-labelledby="reviews-heading"></ul>
</section>
<section class="game-panel game-panel--comments" aria-labelledby="comments-heading">
<div class="game-panel__head">
<span class="game-panel__icon" aria-hidden="true">
@ -787,16 +1074,17 @@
<p class="game-panel__subtitle">플레이 소감이나 버그 제보를 남겨 주세요.</p>
</div>
</div>
<div class="game-comments__composer">
<div class="game-comments__composer" id="game-comment-composer" hidden>
<form id="game-comment-form" class="game-comments__form" novalidate>
<label class="game-comments__label" for="game-comment-input">내용</label>
<textarea id="game-comment-input" name="comment" rows="4" maxlength="1000" placeholder="여기에 덧글을 작성해 주세요." required></textarea>
<textarea id="game-comment-input" name="comment" rows="4" maxlength="200" placeholder="여기에 덧글을 작성해 주세요." required></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint">최대 1,000자</span>
<span class="game-comments__hint">최대 200자</span>
<button type="submit">덧글 등록</button>
</div>
</form>
</div>
<p class="game-comments__login-gate" id="game-comment-login-gate" hidden>덧글을 남기려면 <a href="<%= ctx %>/login">로그인</a>이 필요해요.</p>
<p id="game-comments-empty" class="game-comments__empty" hidden>아직 덧글이 없습니다.<br>첫 번째 덧글을 남겨 보세요.</p>
<ul id="game-comment-list" class="game-comments__list" aria-labelledby="comments-heading"></ul>
</section>
@ -810,7 +1098,8 @@
var gameId = ${gameId};
var baseLikes = ${likeCount};
var LIKE_KEY = 'bibimbap-game-liked';
var COMMENT_KEY = 'bibimbap-game-comments';
var viewerId = ${empty currentUserId ? 'null' : currentUserId};
var viewerRole = '${empty userRole ? "" : userRole}';
function getLikedMap() {
try {
@ -908,103 +1197,382 @@
});
syncLike();
function getComments() {
try {
var raw = localStorage.getItem(COMMENT_KEY);
var o = raw ? JSON.parse(raw) : {};
var list = o[gid];
return Array.isArray(list) ? list : [];
} catch (e) {
return [];
// ===== 공통 헬퍼 (서버 연동 덧글/리뷰) =====
function fmtDate(iso) {
try { return iso ? new Date(iso).toLocaleString('ko-KR') : ''; }
catch (e) { return ''; }
}
function notifyError(title, message) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({ title: title, message: message, confirmText: '확인' });
} else { alert(message); }
}
function confirmAction(title, message, okText, onOk) {
if (window.BibimbapModal && typeof window.BibimbapModal.confirm === 'function') {
window.BibimbapModal.confirm({ title: title, message: message, confirmText: okText, cancelText: '취소', onConfirm: onOk });
} else if (confirm(message)) { onOk(); }
}
function baseHeaders(extra) {
var h = { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
if (extra) { Object.keys(extra).forEach(function (k) { h[k] = extra[k]; }); }
return window.BibimbapCsrf ? window.BibimbapCsrf.headers(h) : h;
}
function formHeaders() {
return baseHeaders({ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' });
}
function encodeForm(obj) {
return Object.keys(obj).map(function (k) {
return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k] == null ? '' : obj[k]);
}).join('&');
}
function api(url, opts) {
return fetch(url, opts).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) { throw new Error((data && data.message) || ('요청을 처리하지 못했습니다. (' + res.status + ')')); }
return data;
});
});
}
// 본인(작성자) 또는 운영자(ADMIN)만 수정/삭제 버튼 노출 — 서버가 최종 권한 재검증
function canActOn(authorUserId) {
if (viewerId == null) return false;
if (authorUserId != null && Number(authorUserId) === Number(viewerId)) return true;
return viewerRole === 'ADMIN';
}
// ===== 덧글 (서버 연동) =====
var cListEl = document.getElementById('game-comment-list');
var cEmptyEl = document.getElementById('game-comments-empty');
var cForm = document.getElementById('game-comment-form');
var cInput = document.getElementById('game-comment-input');
var cComposer = document.getElementById('game-comment-composer');
var cLoginGate = document.getElementById('game-comment-login-gate');
var commentsUrl = ctx + '/game/' + encodeURIComponent(gid) + '/comments';
function openCommentEdit(li, body, p, c) {
if (li.querySelector('.game-comments__edit-form')) return;
var ef = document.createElement('form');
ef.className = 'game-comments__edit-form';
var ta = document.createElement('textarea');
ta.maxLength = 200;
ta.value = c.content || '';
var actions = document.createElement('div');
actions.className = 'game-comments__actions';
var hint = document.createElement('span');
hint.className = 'game-comments__hint';
hint.textContent = '최대 200자';
var btnWrap = document.createElement('div');
btnWrap.className = 'game-reviews__form-buttons';
var cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'game-reviews__cancel';
cancel.textContent = '취소';
var save = document.createElement('button');
save.type = 'submit';
save.textContent = '저장';
btnWrap.appendChild(cancel);
btnWrap.appendChild(save);
actions.appendChild(hint);
actions.appendChild(btnWrap);
ef.appendChild(ta);
ef.appendChild(actions);
p.hidden = true;
body.appendChild(ef);
ta.focus();
cancel.addEventListener('click', function () { ef.remove(); p.hidden = false; });
ef.addEventListener('submit', function (ev) {
ev.preventDefault();
var text = (ta.value || '').trim();
if (!text) { notifyError('입력 필요', '덧글 내용을 입력해 주세요.'); return; }
if (text.length > 200) { notifyError('입력 초과', '덧글은 200자 이내로 입력해 주세요.'); return; }
api(commentsUrl + '/' + encodeURIComponent(c.commentId), {
method: 'PUT', headers: formHeaders(), body: encodeForm({ content: text })
}).then(loadComments).catch(function (err) { notifyError('수정 실패', err.message); });
});
}
function buildCommentItem(c) {
var li = document.createElement('li');
li.className = 'game-comments__item';
var nick = (c.authorName && String(c.authorName).trim()) ? String(c.authorName).trim() : '익명';
var av = document.createElement('div');
av.className = 'game-comments__avatar';
av.setAttribute('aria-hidden', 'true');
av.textContent = nick.charAt(0).toUpperCase();
var body = document.createElement('div');
body.className = 'game-comments__body';
var meta = document.createElement('div');
meta.className = 'game-comments__meta';
var nickEl = document.createElement('span');
nickEl.className = 'game-comments__nick';
nickEl.textContent = nick;
var t = document.createElement('time');
t.dateTime = c.createdAt || '';
t.textContent = fmtDate(c.createdAt);
meta.appendChild(nickEl);
meta.appendChild(t);
var p = document.createElement('p');
p.textContent = c.content || '';
body.appendChild(meta);
body.appendChild(p);
if (canActOn(c.userId)) {
var foot = document.createElement('div');
foot.className = 'game-comments__footer';
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'game-reviews__action game-reviews__action--edit';
editBtn.textContent = '수정';
editBtn.addEventListener('click', function () { openCommentEdit(li, body, p, c); });
var del = document.createElement('button');
del.type = 'button';
del.className = 'game-reviews__action game-reviews__action--delete';
del.textContent = '삭제';
del.setAttribute('aria-label', '이 덧글 삭제');
del.addEventListener('click', function () {
confirmAction('덧글 삭제', '이 덧글을 삭제할까요?', '삭제', function () {
api(commentsUrl + '/' + encodeURIComponent(c.commentId), { method: 'DELETE', headers: baseHeaders() })
.then(loadComments).catch(function (err) { notifyError('삭제 실패', err.message); });
});
});
foot.appendChild(editBtn);
foot.appendChild(del);
body.appendChild(foot);
}
li.appendChild(av);
li.appendChild(body);
return li;
}
function renderComments(list) {
cListEl.innerHTML = '';
cEmptyEl.hidden = list.length > 0;
list.forEach(function (c) { cListEl.appendChild(buildCommentItem(c)); });
}
function loadComments() {
return api(commentsUrl, { headers: baseHeaders() })
.then(function (data) { renderComments(Array.isArray(data.comments) ? data.comments : []); })
.catch(function () { cEmptyEl.hidden = false; });
}
if (cForm && cInput) {
if (viewerId == null) {
if (cComposer) cComposer.hidden = true;
if (cLoginGate) cLoginGate.hidden = false;
} else {
if (cComposer) cComposer.hidden = false;
if (cLoginGate) cLoginGate.hidden = true;
cForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var text = (cInput.value || '').trim();
if (!text) return;
if (text.length > 200) { notifyError('입력 초과', '덧글은 200자 이내로 입력해 주세요.'); return; }
api(commentsUrl, { method: 'POST', headers: formHeaders(), body: encodeForm({ content: text }) })
.then(function () { cInput.value = ''; return loadComments(); })
.catch(function (err) { notifyError('등록 실패', err.message); });
});
}
}
loadComments();
// ===== 리뷰 (서버 연동) =====
var rListEl = document.getElementById('game-review-list');
var rEmptyEl = document.getElementById('game-reviews-empty');
var rComposer = document.getElementById('game-review-composer');
var rLoginGate = document.getElementById('game-review-login-gate');
var rForm = document.getElementById('game-review-form');
var rInput = document.getElementById('game-review-input');
var rStarsEl = document.getElementById('game-review-stars');
var rSubmit = document.getElementById('game-review-submit');
var rCancel = document.getElementById('game-review-cancel');
var rSummary = document.getElementById('game-reviews-summary');
var rAvg = document.getElementById('game-reviews-avg');
var rAvgStars = document.getElementById('game-reviews-avg-stars');
var rCount = document.getElementById('game-reviews-count');
var reviewsUrl = ctx + '/game/' + encodeURIComponent(gid) + '/reviews';
var selectedRating = 0;
var editingReviewId = null; // null = 작성 모드, 값 = 수정 모드
function fillStars(container, rating) {
container.innerHTML = '';
for (var i = 1; i <= 5; i++) {
var s = document.createElement('span');
s.className = 'game-star' + (i <= rating ? ' is-on' : '');
s.textContent = '★';
container.appendChild(s);
}
}
function buildStarDisplay(rating) {
var wrap = document.createElement('div');
wrap.className = 'game-stars game-stars--display';
wrap.setAttribute('aria-hidden', 'true');
fillStars(wrap, rating);
return wrap;
}
function paintStarsInput() {
if (!rStarsEl) return;
Array.prototype.forEach.call(rStarsEl.querySelectorAll('.game-stars__btn'), function (b) {
var v = parseInt(b.getAttribute('data-value'), 10);
b.classList.toggle('is-on', v <= selectedRating);
b.setAttribute('aria-checked', v === selectedRating ? 'true' : 'false');
});
}
function setRating(v) { selectedRating = v; paintStarsInput(); }
function startReviewEdit(r) {
editingReviewId = r.reviewId;
setRating(Number(r.rating) || 0);
if (rInput) rInput.value = r.body || '';
if (rSubmit) rSubmit.textContent = '리뷰 수정';
if (rCancel) rCancel.hidden = false;
if (rComposer) {
rComposer.hidden = false;
if (rComposer.scrollIntoView) rComposer.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (rInput) rInput.focus();
}
function showComposerWrite() {
editingReviewId = null;
setRating(0);
if (rInput) rInput.value = '';
if (rSubmit) rSubmit.textContent = '리뷰 등록';
if (rCancel) rCancel.hidden = true;
if (rComposer) rComposer.hidden = false;
}
function buildReviewItem(r) {
var mine = viewerId != null && r.userId != null && Number(r.userId) === Number(viewerId);
var li = document.createElement('li');
li.className = 'game-reviews__item' + (mine ? ' is-mine' : '');
var head = document.createElement('div');
head.className = 'game-reviews__item-head';
head.appendChild(buildStarDisplay(Number(r.rating) || 0));
var nick = (r.authorName && String(r.authorName).trim()) ? String(r.authorName).trim() : '익명';
var nickEl = document.createElement('span');
nickEl.className = 'game-reviews__nick';
nickEl.textContent = nick;
head.appendChild(nickEl);
if (mine) {
var mb = document.createElement('span');
mb.className = 'game-reviews__mine-badge';
mb.textContent = '내 리뷰';
head.appendChild(mb);
}
if (r.edited) {
var ed = document.createElement('span');
ed.className = 'game-reviews__edited';
ed.textContent = '· 수정됨';
head.appendChild(ed);
}
var t = document.createElement('span');
t.className = 'game-reviews__time';
t.textContent = fmtDate(r.updatedAt || r.createdAt);
head.appendChild(t);
li.appendChild(head);
if (r.body && String(r.body).trim()) {
var p = document.createElement('p');
p.className = 'game-reviews__body';
p.textContent = r.body;
li.appendChild(p);
}
if (canActOn(r.userId)) {
var foot = document.createElement('div');
foot.className = 'game-reviews__footer';
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'game-reviews__action game-reviews__action--edit';
editBtn.textContent = '수정';
editBtn.addEventListener('click', function () { startReviewEdit(r); });
var del = document.createElement('button');
del.type = 'button';
del.className = 'game-reviews__action game-reviews__action--delete';
del.textContent = '삭제';
del.addEventListener('click', function () {
confirmAction('리뷰 삭제', '이 리뷰를 삭제할까요?', '삭제', function () {
api(reviewsUrl + '/' + encodeURIComponent(r.reviewId), { method: 'DELETE', headers: baseHeaders() })
.then(loadReviews).catch(function (err) { notifyError('삭제 실패', err.message); });
});
});
foot.appendChild(editBtn);
foot.appendChild(del);
li.appendChild(foot);
}
return li;
}
function updateSummary(list) {
if (!rSummary) return;
if (!list.length) { rSummary.hidden = true; return; }
var sum = list.reduce(function (acc, r) { return acc + (Number(r.rating) || 0); }, 0);
var avg = sum / list.length;
rSummary.hidden = false;
if (rAvg) rAvg.textContent = avg.toFixed(1);
if (rCount) rCount.textContent = '(' + list.length + ')';
if (rAvgStars) fillStars(rAvgStars, Math.round(avg));
}
function applyReviewGate(list) {
if (viewerId == null) {
if (rComposer) rComposer.hidden = true;
if (rLoginGate) rLoginGate.hidden = false;
return;
}
if (rLoginGate) rLoginGate.hidden = true;
var mineExists = list.some(function (r) { return r.userId != null && Number(r.userId) === Number(viewerId); });
if (mineExists) {
if (rComposer) rComposer.hidden = true; // 이미 작성 — 수정은 본인 카드의 '수정' 버튼으로
} else {
showComposerWrite(); // 미작성 — 작성 폼 노출
}
}
function saveComments(list) {
try {
var raw = localStorage.getItem(COMMENT_KEY);
var o = raw ? JSON.parse(raw) : {};
if (typeof o !== 'object' || o === null) o = {};
o[gid] = list;
localStorage.setItem(COMMENT_KEY, JSON.stringify(o));
} catch (err) {}
function renderReviews(list) {
rListEl.innerHTML = '';
rEmptyEl.hidden = list.length > 0;
list.forEach(function (r) { rListEl.appendChild(buildReviewItem(r)); });
updateSummary(list);
applyReviewGate(list);
}
function loadReviews() {
editingReviewId = null;
return api(reviewsUrl, { headers: baseHeaders() })
.then(function (data) { renderReviews(Array.isArray(data.reviews) ? data.reviews : []); })
.catch(function () { rEmptyEl.hidden = false; });
}
var listEl = document.getElementById('game-comment-list');
var emptyEl = document.getElementById('game-comments-empty');
var form = document.getElementById('game-comment-form');
var input = document.getElementById('game-comment-input');
var DEFAULT_NICK = '익명';
function renderComments() {
var items = getComments().slice().sort(function (a, b) {
return (b.at || '').localeCompare(a.at || '');
});
listEl.innerHTML = '';
emptyEl.hidden = items.length > 0;
items.forEach(function (c) {
var li = document.createElement('li');
li.className = 'game-comments__item';
var av = document.createElement('div');
var nick = (c.nickname && String(c.nickname).trim()) ? String(c.nickname).trim() : DEFAULT_NICK;
av.className = 'game-comments__avatar';
av.setAttribute('aria-hidden', 'true');
av.textContent = nick.charAt(0).toUpperCase();
var body = document.createElement('div');
body.className = 'game-comments__body';
var meta = document.createElement('div');
meta.className = 'game-comments__meta';
var nickEl = document.createElement('span');
nickEl.className = 'game-comments__nick';
nickEl.textContent = nick;
var t = document.createElement('time');
t.dateTime = c.at || '';
try {
t.textContent = c.at ? new Date(c.at).toLocaleString('ko-KR') : '';
} catch (e) {
t.textContent = '';
}
meta.appendChild(nickEl);
meta.appendChild(t);
var p = document.createElement('p');
p.textContent = c.text || '';
body.appendChild(meta);
body.appendChild(p);
if (c.id) {
var foot = document.createElement('div');
foot.className = 'game-comments__footer';
var del = document.createElement('button');
del.type = 'button';
del.className = 'game-comments__delete';
del.textContent = '삭제';
del.setAttribute('aria-label', '이 덧글 삭제');
del.addEventListener('click', function () {
var next = getComments().filter(function (x) { return x.id !== c.id; });
saveComments(next);
renderComments();
});
foot.appendChild(del);
body.appendChild(foot);
}
li.appendChild(av);
li.appendChild(body);
listEl.appendChild(li);
if (rStarsEl) {
Array.prototype.forEach.call(rStarsEl.querySelectorAll('.game-stars__btn'), function (b) {
b.addEventListener('click', function () { setRating(parseInt(b.getAttribute('data-value'), 10)); });
});
}
form.addEventListener('submit', function (ev) {
ev.preventDefault();
var text = (input.value || '').trim();
if (!text) return;
var id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) + '-' + Math.random();
var entry = { id: id, text: text, at: new Date().toISOString(), nickname: DEFAULT_NICK };
var list = getComments();
list.push(entry);
saveComments(list);
input.value = '';
renderComments();
});
renderComments();
if (rCancel) {
rCancel.addEventListener('click', function () { loadReviews(); });
}
if (rForm) {
rForm.addEventListener('submit', function (ev) {
ev.preventDefault();
if (viewerId == null) { notifyError('로그인 필요', '리뷰를 남기려면 로그인이 필요합니다.'); return; }
if (!(selectedRating >= 1 && selectedRating <= 5)) { notifyError('별점 필요', '별점을 선택해 주세요.'); return; }
var bodyText = ((rInput && rInput.value) || '').trim();
if (bodyText.length > 1000) { notifyError('입력 초과', '평가는 1,000자 이내로 입력해 주세요.'); return; }
var isEdit = editingReviewId != null;
var url = isEdit ? (reviewsUrl + '/' + encodeURIComponent(editingReviewId)) : reviewsUrl;
api(url, { method: isEdit ? 'PUT' : 'POST', headers: formHeaders(), body: encodeForm({ rating: selectedRating, body: bodyText }) })
.then(loadReviews)
.catch(function (err) { notifyError(isEdit ? '수정 실패' : '등록 실패', err.message); });
});
}
loadReviews();
})();
</script>
</body>

View File

@ -1,5 +1,7 @@
package com.pandoli365.bibimbap;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
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 +21,12 @@ class BibimbapApplicationTests {
@MockBean
private GamesMapper gamesMapper;
@MockBean
private GameCommentsMapper gameCommentsMapper;
@MockBean
private GameReviewsMapper gameReviewsMapper;
@MockBean
private RecruitPostsMapper recruitPostsMapper;

View File

@ -0,0 +1,276 @@
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.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.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
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;
});
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 = new GameCommentData();
c.setId(5L);
c.setGameId(1L);
c.setUserId(7L);
c.setNickname("작성자");
c.setContent("내용");
when(gameCommentsMapper.listGameComments(1L)).thenReturn(List.of(c));
ResponseEntity<Map<String, Object>> response = controller.listComments(1L);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@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));
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;
});
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);
}
// ---- 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;
}
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,282 @@
package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.GameReviewData;
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.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.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
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 GamesMapper gamesMapper;
// ---- 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 =
controller.createReview(1L, "4", "재미있음", 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 =
controller.updateReview(1L, 50L, "5", "더 좋아짐", 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 =
controller.createReview(1L, "5", "또작성", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
verify(gameReviewsMapper, never()).addGameReview(any());
}
// ---- AC-6: 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 =
controller.updateReview(1L, 50L, "6", "범위초과", 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 =
controller.updateReview(1L, 50L, "3", "강제수정", 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 =
controller.createReview(1L, "4", "내용", 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 =
controller.updateReview(1L, 50L, "4", "내용", 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);
}
// ---- 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 =
controller.createReview(1L, rating, "내용", 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 =
controller.createReview(1L, rating, "내용", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameReviewsMapper).addGameReview(any(GameReviewData.class));
}
private GameReviewController controller() {
return new GameReviewController(gameReviewsMapper, 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);
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;
}
}