---
kind: change
title: "W2 게임잼 워크스트림 구현 (W2-1~6)"
session_id: 20260624-100749
resumed_from: 20260623-104307
created_at: 2026-06-24
status: implemented
related_design: "../../.atp/work-session/20260623-104307/implementation/"
related_impl: "../../.atp/work-session/20260624-100749/implementation/"
related_verification: "../../.atp/work-session/20260624-100749/verification.md"
related_report: "../../.atp/work-session/20260624-100749/report.md"
related_design_summary: "../work-log/2026-06-23-w2-w4-full-design-summary.md"
related_security_checklist: "../security/security-remediation-checklist.md"
---

# W2 게임잼 워크스트림 구현 변경 이력 (W2-1~6)

상위 설계 인덱스: [W2+W3(잔여)+W4 풀설계 통합 요약](../work-log/2026-06-23-w2-w4-full-design-summary.md) §설계 인덱스 W2-1~6.
설계 본문: `.atp/work-session/20260623-104307/implementation/W2-*.md` · 구현 보고: `.atp/work-session/20260624-100749/implementation/W2-*-impl.md` · 검증: `.atp/work-session/20260624-100749/verification.md`.

## 개요

W1(거버넌스/RBAC) 위에 게임잼 플랫폼 전체를 6개 서브 워크스트림으로 구현했다. 잼 엔티티/라이프사이클부터 심사위원 역할·동결 평가 스키마·심사 평가·인기 투표·시상 집계까지 잼 1회차의 전 수명주기를 커버한다. 평가 스키마(W2-3)는 ⚠️클러스터 권위로 단일 동결 후 하류(W2-4/5/6)가 소비하는 구조다.

| 서브 | 커밋 | 핵심 |
|---|---|---|
| W2-1 잼 엔티티/라이프사이클 | `ccf1e42` | jams/jam_teams/jam_team_members/jam_entries/jam_status_log + JamController/JamAdminController + InterceptorConfig exclude + GAME_JAM_MANAGE enforcement 연결 |
| W2-2 심사위원 역할 | `71b6b6f` | jam_judges(잼 스코프) + JamRoleGate(isJudge) + 자기출품 충돌 isOwnEntry |
| W2-3 잼 평가 동결 스키마 ⚠️권위 | `2d5601b` | jam_criteria/jam_scores/jam_votes/jam_awards + jam_score_stats VIEW. 평가단위 = (jam_id, game_id) 활성 자연키 |
| W2-4 심사위원 평가 | `09ed6bc` | 3중게이트(isJudge·평가기간·자기출품) + criterion UPSERT + 가중집계 |
| W2-5 인기투표 | `5ed06d9` | jam_votes 1인1표 UNIQUE + 평가기간 게이트 + 종료후 공개(밴드왜건 회피) |
| W2-6 시상 집계 | `a74bf74` | 3트랙(JUDGE/USER_RATING/POPULAR) 개별수상 + 가중 GRAND + CLOSED 확정 멱등 |

커밋 범위: `ccf1e42`(W2-1) → `71b6b6f`(W2-2) → `2d5601b`(W2-3) → `09ed6bc`(W2-4) → `5ed06d9`(W2-5) → `a74bf74`(W2-6). 모두 `feat/v2` 브랜치.

---

## W2-1 — 잼 엔티티 / 라이프사이클 (`ccf1e42`)

### DDL (신규 5테이블)

| 테이블 | 역할 | 핵심 제약 |
|---|---|---|
| `jams` | 잼 회차 본체 + 상태/일정 | `jams_status_check` IN(RECRUIT, DEV, EVAL, CLOSED), slug 활성 UNIQUE(`ux_jams_slug_active`), keyset 인덱스 `idx_jams_visible_keyset` |
| `jam_teams` | 팀 출품 컨테이너 | jam 스코프 |
| `jam_team_members` | 팀 멤버 | |
| `jam_entries` | 출품 조인테이블(다회차·정규화) | 개인/팀 XOR(`jam_entries_entrant_xor_check` + `jam_entries_entrant_type_check`), 활성 UNIQUE(`ux_jam_entries_jam_game_active`, partial WHERE is_delete IS NOT TRUE) |
| `jam_status_log` | 상태 전이 감사 | `jam_status_log_to_status_check` IN 4값(jams 동기) |

- 평가 단위 = `(jam_id, game_id)` 활성 자연키. `jam_entries` active-UNIQUE 가 1:1 보장. games 테이블 무변경(허브 회귀 0).
- DDL 권위: `docs/jam-ddl.sql`(멱등) + `db/schema.sql` 동기(jams/jam_teams/jam_team_members/jam_entries/jam_status_log).

### 런타임 동작

- `JamController` — 잼 조회·출품(개인/팀) 무결성(소유 409/비소유 422/비멤버 422/EVAL 422).
- `JamAdminController` — 잼 CRUD + 명시 status 전이 + 전이 감사 적재. 쓰기 5핸들러 전수 `requireJamManage` 게이트, 읽기 console 은 인라인 `gate.has(GAME_JAM_MANAGE)`.
- `InterceptorConfig` exclude 보정 — 정적 자산 경로 인터셉터 제외.
- `JamStatus` enum(RECRUIT/DEV/EVAL/CLOSED) = jams CHECK = jam_status_log CHECK 3곳 동기.

---

## W2-2 — 심사위원 역할 (`71b6b6f`)

### DDL (신규 1테이블)

- `jam_judges` — **별도 잼 스코프 테이블**(전역 user_permissions RBAC 무오염). FK 3개(jam/user/assigned_by), `ux_jam_judges_jam_user` UNIQUE(멱등 가드).
- `docs/jam-judge-ddl.sql` + `db/schema.sql`(line 566~594) 동기.

### 런타임 동작

- `JamRoleGate.isJudge(jamId, userId)` — 잼 스코프 심사위원 자격 판정(미지정/미인증 false).
- `JamRoleGate.isOwnEntry` — 자기출품 충돌 규칙: 개인출품 true / 팀멤버출품 true / 타인·타팀·비활성 false (OR-EXISTS, USER·TEAM 양경로).
- 심사위원 지정/해제는 GAME_JAM_MANAGE 게이트. 누구나(USER role 포함) 지정 가능(전역 role 검사 없음), 미존재 userId 404, 재지정 409.

---

## W2-3 — 잼 평가 동결 스키마 ⚠️클러스터 권위 (`2d5601b`)

W2-4/5/6 착수 전 필수 동결 묶음. Java 코드 0(스키마만), 신규 매퍼/컨트롤러/테스트 0(하류 소관).

### DDL (신규 4테이블 + 1 VIEW)

| 객체 | 역할 | 핵심 제약 |
|---|---|---|
| `jam_criteria` | 잼별 평가 기준(가중치) | `ux_jam_criteria_jam_key` UNIQUE, weight >= 0 CHECK |
| `jam_scores` | 심사위원 점수 | `ux_jam_scores_jam_game_judge_criterion`(1심사위원1기준1점) UNIQUE, score BETWEEN 1 AND 5 CHECK |
| `jam_votes` | 인기 투표 | `ux_jam_votes_jam_voter`(1인1표) UNIQUE |
| `jam_awards` | 시상 결과 | `ux_jam_awards_jam_track_game` UNIQUE, `jam_awards_track_check` IN(JUDGE, USER_RATING, POPULAR, GRAND), rank >= 1 CHECK |
| `jam_score_stats` (VIEW) | 가중집계 | per_criterion CTE 선집계 후 `jam_criteria LEFT JOIN` (fan-out 가드) |

- 평가단위 = `(jam_id, game_id)` 활성 자연키(orchestrator 동결 컬럼 + game_review_stats join 정합).
- 유저평점 트랙 = `game_review_stats.avg_rating` 단방향 소비(6축은 표시전용, 최소 review_count 임계 미달 제외·NULLS LAST).
- `jam_score_stats` VIEW 는 `game_review_stats` 선례 fan-out 버그를 회피(per_criterion 선집계 + `NULLIF(SUM(weight),0)` 0-division 가드). 손계산·미채점 제외·NULLIF·fan-out 불변 4축 L2 검증 통과.
- `docs/jam-eval-ddl.sql` + `db/schema.sql`(4테이블 line 608/642/685/721 + VIEW line 765) 동기. 기존 game_reviews/game_review_stats 무변경(ALTER 17건 전부 신규 jam_* 제약추가, DROP 0).

---

## W2-4 — 심사위원 평가 (`09ed6bc`)

### 런타임 동작 (W2-3 스키마 소비, 신규 DDL 0)

- `submitScores` 진입부 3중게이트 + CSRF: CSRF누락 403 / 미인증 401 / 비-isJudge 403 / 잼없음 404 / 평가기간 외 422 / 출품작없음 404 / 자기출품 422 / 정상 200.
- criterion별 UPSERT(`INSERT ... ON CONFLICT`) — 동일 (심사위원, 기준, 평가단위) 재채점 시 update(중복행 0).
- criterion 화이트리스트(잼 소속 아닌 criterion 422) + score 범위 검증(범위 밖 422).
- 가중 집계는 `jam_score_stats` VIEW 소비. 신규 매퍼 SQL `${}` 0건, JamScoreStatsMapper camelCase alias 전부 `AS "..."` 인용(§33 케이스폴딩 회피).

---

## W2-5 — 인기투표 (`5ed06d9`)

### 런타임 동작 (W2-3 jam_votes 소비, 신규 DDL 0)

- `vote`(POST) / `cancelVote`(DELETE) 1인1표 토글 — `ux_jam_votes_jam_voter` UNIQUE(jam_id, voter_user_id) 강제. 재투표 update(중복행 0). game_likes(user_key varchar 비권위) 재활용 안 함, 로그인 user_id 기반.
- 평가기간 게이트(`JamEvalWindow.isOpen`) 2지점 — 기간 외 vote/cancelVote 422.
- 종료후 공개(밴드왜건 회피) — CLOSED 또는 eval_end 경과 시만 count 노출. `JamVoteController.results` + `jam-detail.jsp` 2지점 게이트. 진행 중에는 "평가 종료 후 공개" 안내.
- 미로그인 vote/cancelVote/mine 401, results 는 공개 200.
- `JamVotesMapper.listCountsByJam` alias `AS "gameId"/"voteCount"` 인용.

---

## W2-6 — 시상 집계 (W2 종점, `a74bf74`)

### 런타임 동작 (W2-3 jam_awards 소비)

- 3트랙 개별수상: JUDGE(weightedTotal NULL 제외) / USER_RATING(review_count >= 3 임계·NULLS LAST) / POPULAR(득표 > 0). 미달·NULL 트랙 제외.
- 가중 GRAND — 가용 트랙만 가중 정규화·결합.
- CLOSED 게이트: status != CLOSED 시 산정 422. 산정/확정 쓰기 CSRF 403 + GAME_JAM_MANAGE 게이트.
- 멱등 recompute: `deleteByJamTrack` → 재insert(`@Transactional`). 2회 실행 시 jam_awards 중복 0, 결과 동일.
- `JamAwardController` 공개 결과 노출. 집계 매퍼(JamReviewRatingMapper/JamVotesMapper/JamScoreStatsMapper) camelCase alias 전부 `AS "..."` 인용.

---

## 보안 (프로젝트 보안 원칙 추적)

W2 에서 신설된 인가/검증 게이트. 모두 단위 + L2 DB 계약 레벨로 검증 완료.

| 게이트 | 적용 W | 내용 |
|---|---|---|
| GAME_JAM_MANAGE enforcement | W2-1/2/6 | W1 선언키의 첫 enforcement 연결(이전 소비처 0). 잼 CRUD·전이·심사위원 지정·시상 산정 쓰기 핸들러 전수 게이트. 무권한 SUBADMIN 403 / 미인증 401. |
| 잼 스코프 isJudge | W2-2/4 | 전역 RBAC 와 분리된 잼 회차 단위 심사위원 자격. 비-isJudge 평가 403. |
| 자기출품 충돌 isOwnEntry | W2-2/4 | 심사위원이 본인/본인팀 출품작 평가 시 422(개인·팀멤버·팀오너 차단). |
| 평가기간 게이트(JamEvalWindow) | W2-4/5 | EVAL 기간 밖 평가/투표 422. |
| 1인1표 UNIQUE | W2-5 | jam_votes UNIQUE(jam_id, voter_user_id) DB 레벨 강제. |
| CSRF 전수 | W2-1/2/4/5/6 | 모든 상태 변경 핸들러 CsrfTokens.isValid → 누락 시 403 + mapper 미호출. |
| MyBatis `${}` 0건 | 전 W | 신규 매퍼 전수 `#{}` 바인딩, 동적 치환 0. |
| §33 alias 케이스폴딩 | W2-4/5/6 | VIEW/집계 매퍼 camelCase alias 큰따옴표 인용(`AS "gameId"`) — Postgres 소문자 폴딩 회피. game_review_stats BUG-2 재발 0. |

---

## 검증 결과 요약

최종 `./mvnw -o test` (W2-6 종점) **190/190 GREEN** (Failures 0 / Errors 0 / Skipped 0). 회귀 0.

| 서브 | L1 누적 카운트 | L2 dev DB contract | 비고 |
|---|---|---|---|
| W2-1 | 97/97 GREEN | PASS(격리 throwaway DB) | 1차 컴파일 BLOCKER(`response()` 미정의 24건) → 보정 후 R2 PASS |
| W2-2 | 119/119 GREEN | PASS(a/b/c 실측) | JamRoleGateTest 8 + JamJudgeAdminControllerTest 14 |
| W2-3 | 119/119 GREEN(회귀 무변경) | PASS(DDL/제약거부/VIEW fan-out 가드/alias) | Java 0·신규테스트 0 정상 |
| W2-4 | 148/148 GREEN | PASS(UPSERT/alias 폴딩/가중집계) | JamScoringControllerTest 19 + JamEvalWindowTest 10 |
| W2-5 | 171/171 GREEN | PASS(UNIQUE 토글/§33 케이스폴딩) | JamVoteControllerTest 23 |
| W2-6 | **190/190 GREEN** | PASS 4/4(멱등/3트랙 손계산/alias/GRAND) | JamAwardServiceTest 7 + JamAwardAdminControllerTest 7 + JamAwardControllerTest 5 |

- **L2 비파괴 원칙**: 모든 L2 contract 는 격리 throwaway DB(생성 → DDL 적용 → 실측 → DROP)로 수행. 사용자 dev(`bibimbap`) DB 무접촉.
- 신규 5매퍼(W2-1) 등 전 신규 매퍼 `@MockBean` 등록 — contextLoads PASS(NoSuchBeanDefinitionException 0).

상세는 `.atp/work-session/20260624-100749/verification.md` 참조.

---

## 미적용 / 이월 (needs_user_verification)

| 항목 | 사유 |
|---|---|
| dev DB jam_* 테이블 마이그레이션 | L2 는 throwaway DB 로 통과했으나 사용자 dev(`bibimbap`) DB 에는 jam_* 테이블 미적용(파괴적 조작 회피). 실 dev 런타임(L3) 전 `db/apply-local-ddl.sh` 별도 수행 필요. |
| L3 런타임 인가 E2E | WAR 기동 후 권한 403·CLOSED 422·종료후 공개 렌더·전이 감사 적재는 자동 미수행. 사용자 스모크 권장. |
| W2-6 GRAND 가중계수 단위검증 | verification-advisor 역할상 구현파일 미열람 — L1 JamAwardServiceTest 7 GREEN 에 위임(L2-4 는 "가용 트랙만 정규화" 원칙만 검증). |
| 테스트 카운트 미세 차이(무해) | W2-2 JamJudgeAdminControllerTest 예측 16 vs 실측 14, W2-5 JamVoteControllerTest 예측 24 vs 실측 23 — 전부 GREEN·회귀 0, 통합/카운트 표현 차이만(기능 결함 아님). |
