---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-23T16:30:00+09:00
workstream: W2-5-인기투표
concerns:
  - "JamVotesMapper 시그니처는 최소 인자로 명세했다. 본 설계는 변경=DELETE→INSERT 가 아니라 '기존 표 갱신' 정석으로 updateVote(jamId, voterUserId, gameId) 단일경로를 채택했으나, 구현이 ON CONFLICT (jam_id, voter_user_id) DO UPDATE 한 문장으로 castVote 를 멱등 upsert 화하면 hasVoted 사전조회·updateVote 분리가 불요해질 수 있다(dead method/parameter 방지, 프로토콜 §11.2). 구현 1보에서 인자/메서드 전부 실사용 재확인."
  - "신규 JamVotesMapper + (신규일 경우) JamVoteController 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper(JamVotesMapper) @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException."
  - "투표 집계 조회 SQL(countByGame/listCountsByJam)은 단순 COUNT 라 일반 매퍼 snake→camel 직접 alias(COUNT(*) AS voteCount) 표준 — 집계 VIEW 아님(W2-3 은 jam_votes 에 VIEW 를 두지 않고 COUNT 계약, 본 설계 §집계 준수). 큰따옴표 alias 불요. dev DB contract(L2) 로 GROUP BY count 정합 실측 권장."
  - "결과 노출 게이트(평가기간 중 표심 은닉 vs 종료 후 count 공개)는 컨트롤러/JSP 분기다 — DB 가 강제하지 않는다. 노출 시점 판정은 jam.status/eval_end_at 런타임 비교(W2-1 JamLifecycle 또는 인라인). 구현이 이 분기를 누락하면 밴드왜건 회피 요구(결과 은닉) 위반 → AC-T3 전수 가드로 검출. 노출 분기 위치는 구현 점검 항목."
  - "자기 출품작 투표 가부는 W2-3 동결에서 'W2-5 정책' 으로 위임됐다. 본 설계는 정석(자기표 허용 — 잼 인기투표는 자기 작품 응원 통상 허용, 1인1표 UNIQUE 가 다중표를 막으므로 자기표가 결과를 왜곡하지 않음)으로 확정한다. 운영 정책상 금지가 필요하면 컨트롤러 422 분기 추가 — 구현 점검 항목(스키마 영향 없음)."
concerns_checked: true
self_verification:
  checklist_passed: true
references:
  requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
  research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
  adrs:
    - .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
    - .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
    - .atp/work-session/20260622-180054/implementation/W1-design.md
    - docs/work-log/2026-06-17-jam-platform-roadmap.md
    - docs/development/verification-strategies.md
    - docs/jam-eval-ddl.sql
---

# 설계: W2-5 — 인기투표 (잼당 1인 1표 / 평가기간 게이트 / 표심 은닉→종료 후 공개)

> ⚠️ **상류 동결 소비 워크스트림**. 본 설계는 W2-3(`.atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md`)이 동결한 `jam_votes` 스키마(테이블·1인1표 UNIQUE·FK)를 **재정의하지 않고 그대로 소비**한다. 신규 DDL 테이블 0건. 본 설계 범위 = 투표 API + 1인1표 토글 로직 + 평가기간 게이트 + 결과 노출 시점 + 신규 매퍼(`JamVotesMapper`) 1개.

## 목표 / 비목표

### 목표 (FR/NFR 추적 — 골자 W2-5)
- **G1 투표 API**: 출품작 투표(POST) / 취소(DELETE). `POST /jams/{slug}/vote`(body: gameId) + `DELETE /jams/{slug}/vote`. RecruitController 패턴(읽기 JSP 뷰, 쓰기 ResponseEntity JSON status/message).
- **G2 잼당 1인 1표(최애 1작품)**: `jam_votes` UNIQUE(jam_id, voter_user_id)(W2-3 동결 `ux_jam_votes_jam_voter`)를 권위로 1인 1표 보장. 표 변경 = 기존 표 갱신(같은 voter 행의 game_id UPDATE), 취소 = DELETE.
- **G3 game_likes 와 별개**: `jam_votes.voter_user_id`(bigint, 로그인 user_id) 기반. game_likes(user_key varchar·1인1표 비권위·grounding R-D) 무참조.
- **G4 평가기간 게이트**: 투표/취소는 `jam.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at]` 일 때만 허용(W2-3 §평가기간 게이트 계약 F6). 미충족 시 422.
- **G5 미로그인 차단**: 1인1표 식별자 = 세션 `userId`. 미로그인 401(밴드왜건/중복표 방지의 식별 기반).
- **G6 결과 노출 시점**: **평가기간 중 표심 은닉(밴드왜건 회피)** → 평가 종료 후(`status='CLOSED'` 또는 `now() > eval_end_at`) 공개(출품작별 count). 본인 투표 여부(votedGameId)는 평가기간 중에도 본인에게는 노출(중복 투표 UX).
- **G7 W2-6 POPULAR 트랙 소스 제공**: 시상 인기 트랙은 `jam_votes` count(W2-3 §집계 노출 계약 — VIEW 없이 COUNT). 본 설계 매퍼가 잼·출품작별 집계 조회를 제공.
- **NFR**: 상태변경 CSRF 전수(`CsrfTokens.isValid` → 403 + `errorBody()`), `#{}` 바인딩(`${}` 금지), 1인1표 UNIQUE DB 강제, 비파괴(신규 DDL 0 — 동결 소비).

### 비목표 (스코프 밖)
- **jam_votes 스키마 정의** — **W2-3 동결 소유**. 본 설계는 컬럼/UNIQUE/FK 를 재정의하지 않고 소비만(신규 DDL 파일 0).
- **시상 산정 알고리즘·POPULAR 트랙 rank 부여** — **W2-6 소유**. 본 설계는 count 집계 조회 매퍼만 제공(트랙 산정은 W2-6).
- **심사 점수 입력(jam_scores)·유저평점 트랙** — W2-4/W2-6 소유. 본 설계 무관.
- **잼 엔티티·상태·출품(jam_entries) 본체** — **W2-1 소유**. 본 설계는 `JamsMapper.getBySlug`·`JamEntriesMapper.exists`(활성 출품작 검증)를 호출 소비만.
- **투표 결과 시각화 차트·실시간 갱신** — 1차는 종료 후 count 단순 노출. 실시간 폴링·차트는 후속.

---

## 개요

bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. W2-3 이 `jam_votes`(id/jam_id/game_id/voter_user_id/created_at + FK jams·games·users + `ux_jam_votes_jam_voter` UNIQUE(jam_id, voter_user_id) + `idx_jam_votes_jam_game`)를 동결했고(W2-3 §데이터모델3, 직접 확인), W2-1 이 `jams`(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작) + `JamsMapper.getBySlug`/`JamEntriesMapper.exists`(W2-1 §파일영향맵·시그니처)를 제공한다. W1 이 `PermissionGate.isAuthenticated(session)`(PermissionGate.java:47, 직접 확인) + `CsrfTokens.isValid(request)`(CsrfTokens.java:35, 직접 확인) + 세션 `userId` attr(RecruitController.java:159 `session.getAttribute("userId")`, 직접 확인)을 제공한다.

본 설계는 **신규 테이블 0**으로 `jam_votes` 를 소비하는 **투표 토글 API + JamVotesMapper 1개**를 추가한다. 가장 까다로운 두 결정을 다음과 같이 확정한다.

- **난제1 (1인1표 토글 — 변경 vs 삭제후삽입)**: 잼당 1인 1표(최애 1작품)이므로 "다른 작품으로 표를 바꾸는" 행위는 **기존 voter 행의 game_id 를 UPDATE**(`updateVote`)로 처리한다(DELETE→INSERT 2문장 대신 1문장 — id/created_at 보존, 경합 윈도 최소, UNIQUE 충돌 없음). 사전 `hasVoted` 조회로 미투표→`castVote`(INSERT) / 기투표→`updateVote`(같은 game 재투표는 no-op 멱등) 분기. 취소(`DELETE /vote`)는 `deleteVote`(voter 행 삭제). UNIQUE(jam_id, voter_user_id)가 다중표를 DB 차원에서 차단하므로 동시요청 경합에서도 1인1표가 깨지지 않는다(INSERT 경합 시 한쪽 UNIQUE 위반 → 컨트롤러가 updateVote 재시도 또는 409 친절 처리, §시퀀스 S1 주석).
- **난제2 (결과 노출 시점 — 밴드왜건 회피)**: **평가기간 중에는 집계 count 를 일반 사용자에게 노출하지 않는다**(밴드왜건/표 쏠림 회피 — 골자 W2-5 Q3·확정 결정). 노출 게이트 = `jam.status='CLOSED' OR now() > jam.eval_end_at`. 이 판정은 **컨트롤러/JSP 런타임 분기**(DB 가 강제하지 않음 — 시각 비교는 런타임). 단 (a) 본인의 투표 여부/대상(`votedGameId`)은 평가기간 중에도 본인에게 노출(중복투표·취소 UX 필수), (b) 잼 관리자(GAME_JAM_MANAGE)는 운영 목적상 평가기간 중에도 집계 열람 가능(선택 — 본 설계는 공개 화면 은닉만 강제, 관리자 열람은 W2-6/관리 화면 소관으로 비강제). 결과 = 평가 종료 전 공개 화면은 "총 투표 수/내 투표만", 종료 후 "출품작별 득표 count" 공개.

---

## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 소비)

| 결정 | 확정값 | 본 설계의 구체화 |
|---|---|---|
| P1 투표 API | POST/DELETE `/jams/{slug}/vote` (body gameId) | 로그인 필수 + 평가기간 게이트 + CSRF. 읽기=잼 상세 JSP(W2-1), 쓰기=JSON status/message |
| P2 1인1표 | `jam_votes` UNIQUE(jam_id, voter_user_id) | W2-3 동결 `ux_jam_votes_jam_voter` 권위. 변경=updateVote(game_id), 취소=deleteVote |
| P3 game_likes 별개 | voter_user_id bigint(로그인) | 신규 jam_votes 소비. game_likes(user_key varchar) 무참조 |
| P4 평가기간 게이트 | EVAL + now∈[eval_start,eval_end] | 미충족 422(W2-3 F6). 게이트 위치=컨트롤러 진입부 앱계층 |
| P5 미로그인 | 세션 userId 식별 | 미로그인 401(redirect 아님 — API). PermissionGate.isAuthenticated |
| P6 결과 노출 | 평가기간 중 은닉 → 종료 후 count 공개 | status='CLOSED' OR now()>eval_end_at 시 출품작별 count. 본인 votedGameId 는 상시 본인 노출 |
| P7 표 변경 정책 | 변경 허용(최애 교체) | hasVoted→updateVote(game_id). 같은 game 재투표=멱등 no-op |
| P8 자기표 | 허용 | 1인1표 UNIQUE 가 다중표 차단하므로 자기 작품 응원 허용(concern 5) |

---

## 데이터 모델 (신규 DDL 0 — W2-3 동결 jam_votes 소비)

> **신규 테이블/컬럼/VIEW 0건.** 본 설계는 W2-3 `docs/jam-eval-ddl.sql` 의 `jam_votes` 를 **소비만** 한다. 아래는 소비하는 동결 스키마의 **참조 사본**(W2-3 §데이터모델3 권위 — 본 설계가 정의/변경하지 않음. 재게시는 매퍼 SQL 작성 grounding 용).

### 소비 대상: `jam_votes` (W2-3 동결 — 변경 금지)
```sql
-- W2-3 docs/jam-eval-ddl.sql §3 (권위). 본 W2-5 는 읽기/쓰기 소비만, DDL 미수정.
CREATE TABLE IF NOT EXISTS "jam_votes" (
    "id"            bigint DEFAULT nextval('jam_votes_id_seq'::regclass) NOT NULL,
    "jam_id"        bigint NOT NULL,        -- 평가단위 1/2 (FK jams)
    "game_id"       bigint NOT NULL,        -- 투표 대상 출품작 (FK games)
    "voter_user_id" bigint NOT NULL,        -- 투표자 (FK users; 로그인 1인1표)
    "created_at"    timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
-- 잼당 1인 1표(최애 1개). 표 변경=UPDATE game_id, 취소=DELETE (W2-5 정책 = 본 설계 P7)
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_votes_jam_voter"
    ON "jam_votes" ("jam_id", "voter_user_id");
-- 투표 집계(출품작별 count) — W2-6 POPULAR 트랙 소스
CREATE INDEX IF NOT EXISTS "idx_jam_votes_jam_game"
    ON "jam_votes" ("jam_id", "game_id");
```

### 동결 스키마와 본 설계 로직의 정합 확인
- **1인1표(P2/G2)**: `ux_jam_votes_jam_voter` 가 (jam_id, voter_user_id) 다중행을 DB 차원에서 차단 → 한 유저가 한 잼에 2표 INSERT 불가. 표 변경은 INSERT 추가가 아니라 **기존 행 UPDATE** 라 UNIQUE 충돌 없음.
- **집계(P6/G7)**: `idx_jam_votes_jam_game` 가 `SELECT game_id, COUNT(*) ... GROUP BY game_id` seek 를 지원(W2-3 §집계 노출 계약 — VIEW 없이 COUNT 정석).
- **평가기간 게이트(P4)**: DB CHECK 로 기간 강제 안 함(W2-3 F6 — 시각 비교는 런타임). `created_at` 은 감사/노출 시점 판정 보조이나, 게이트 자체는 `jams.status`+`eval_*_at` 런타임 비교(앱계층).
- **변경 금지 확인**: 본 설계는 `jam_votes` 에 컬럼/제약/인덱스를 추가하지 않는다. `game_likes`/`game_reviews`/`jams`/`jam_entries` 도 무변경(소비만). → 신규 DDL 파일 0, schema.sql 수정 0.

---

## 외부 계약 (API)

> 공통: 모든 상태변경(POST/DELETE)은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java:35,51 직접 확인). 응답은 RecruitController 패턴 — `ResponseEntity<Map<String,Object>>`(status/message). 잼 상세 화면(투표 UI 부착)은 W2-1 `GET /jams/{slug}`(jam-detail JSP) 재사용 — 본 설계는 그 JSP 에 투표 폼/결과 영역 추가만(신규 페이지 컨트롤러 없음).

### 401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 일치)
- **미인증**(세션 `userId` 없음): API **401** JSON `{status:401, message:"로그인이 필요합니다."}`. (투표는 API 이므로 redirect 아님.)
- **인증·미인가**: 본 투표 API 는 별도 권한 키(GAME_JAM_MANAGE 등) 불요 — **로그인 유저 누구나 투표 가능**(개방). 따라서 403(미인가)은 CSRF 실패 전용.
- **평가기간 외**(P4 게이트 위반): **422** JSON `{status:422, message:"투표 기간이 아닙니다."}`(인가는 됐으나 도메인 상태 위반 → 403 아님 422 — W2-3 F6 정책 일치).
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
- **출품작 없음/잼 없음**: 404.

### 투표 액션 (상태변경 API — 로그인 필수 + CSRF + 평가기간 게이트)
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|---|---|---|---|---|---|
| 투표/변경(G1/P1) | POST | `/jams/{slug}/vote` | gameId | `{status:200, message, votedGameId, changed:bool}` | 401(미인증), 403(CSRF), 404(잼/출품작 없음), 422(평가기간 외) |
| 취소(G1/P1) | DELETE | `/jams/{slug}/vote` | (없음 — voter 세션 식별) | `{status:200, message, votedGameId:null}` | 401, 403(CSRF), 404(잼 없음/미투표), 422(평가기간 외) |
| 내 투표 조회(P6 본인 노출) | GET | `/jams/{slug}/vote/mine` | (없음) | `{status:200, votedGameId:Long|null}` | 401, 404(잼 없음) |
| 집계 조회(P6/G7 — 종료 후만 공개) | GET | `/jams/{slug}/vote/results` | (없음) | 종료 후: `{status:200, results:[{gameId, voteCount}], total}` / 진행 중: `{status:200, open:true, total, results:null}` | 404(잼 없음) |

- **POST 토글 의미(P7)**: 같은 voter 가 (a) 미투표→투표(`changed:false`, 신규 INSERT), (b) 다른 game 으로 변경(`changed:true`, game_id UPDATE), (c) 같은 game 재요청(멱등 no-op, `changed:false`). 별도 grant/revoke 가 아닌 "현재 표 설정" 의미.
- **DELETE = 취소(P7)**: voter 의 잼 내 표 1행 삭제. 미투표 상태에서 DELETE 는 404(취소할 표 없음) 또는 멱등 200(정책 — 본 설계는 **404 미투표 명시**, 클라가 상태 동기화).
- **집계 노출 게이트(P6/G6 — 밴드왜건 회피 핵심)**: `GET /vote/results` 는 `jam.status='CLOSED' OR now()>jam.eval_end_at` 일 때만 `results` 배열(출품작별 count) 반환. 진행 중에는 `{open:true, results:null, total}`(총합만 — 표 쏠림 정보 미노출). JSP 도 동일 분기로 결과 영역 렌더.
- **자기 출품작 투표(P8)**: 허용. games.user_id == voter 여도 422 안 냄(concern 5 — 운영 금지 정책 시 컨트롤러 분기 추가, 스키마 무관).

### W2-6 소비 계약 (POPULAR 트랙 소스 — G7)
- W2-6 시상 산정은 본 설계 매퍼의 `listCountsByJam(jamId)`(또는 `countByGame`)를 호출해 출품작별 득표를 얻고 DESC rank 부여 → `jam_awards.award_track='POPULAR'`(W2-3 §시상 트랙 산정). 본 설계는 **count 조회만 제공**, rank/award insert 는 W2-6 소관.
- 집계 SQL(W2-6 참고): `SELECT game_id AS gameId, COUNT(*) AS voteCount FROM jam_votes WHERE jam_id = #{jamId} GROUP BY game_id ORDER BY COUNT(*) DESC, game_id ASC` — 일반 매퍼 snake→camel 직접 alias(집계 VIEW 아님 → 큰따옴표 불요, concern 3).

---

## 인터셉터 / 게이트 연동

> 본 투표 API 는 **관리자 게이트(GAME_JAM_MANAGE) 불요** — 로그인 유저 개방. 따라서 `/admin/**` RbacInterceptor 와 무관(투표 경로 `/jams/**` 는 인터셉터 미등록, InterceptorConfig.java:18 `/admin/**` 만 — W2-1 §인터셉터 직접 확인). 게이트는 **컨트롤러 진입부 앱계층**만.

### 컨트롤러 진입부 게이트 순서 (모든 상태변경 액션 공통)
1. `CsrfTokens.isValid(request)` 거짓 → 403 + `CsrfTokens.errorBody()` (mapper 접근 전 — verification §CSRF-before-mapper 패턴).
2. `userId = sessionUserId(session)` (RecruitController.java:155 선례 — `session.getAttribute("userId")`) null → 401. (PermissionGate.isAuthenticated(session) 동등 — 본 설계는 RecruitController 의 `sessionUserId` 헬퍼 패턴 재사용 권장: 투표 컨트롤러가 PermissionGate 의존 없이 세션 userId 만으로 충족 → 의존 최소화. PermissionGate.isAuthenticated 호출도 동등 허용.)
3. `jam = jamsMapper.getBySlug(slug)` (W2-1 제공) null → 404.
4. **평가기간 게이트(P4/F6)**: `jam.status == 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]` 거짓 → 422.
5. 출품작 존재: `jamEntriesMapper.exists(jam.id, gameId)` (W2-1 제공, POST 만 — DELETE 는 gameId 불요) 거짓 → 404.
6. 통과 후 토글 본문.

### epoch 전파 무관
- 투표는 권한 키 판정이 없으므로 W1 epoch(refreshIfStale) 연동 불요. 세션 userId 존재만 확인(로그인 세션 유효성은 기존 로그인 인프라가 보장).

---

## 시퀀스 (주요 플로우 의사코드)

### S1. 투표 / 변경 (POST — 1인1표 토글)
```
[로그인 유저] POST /jams/{slug}/vote (CSRF, gameId)
  → JamVoteController.vote
      → CsrfTokens.isValid(request)                          (아니면 403 errorBody)
      → userId = sessionUserId(session)                      (없으면 401)
      → jam = jamsMapper.getBySlug(slug)                     (없으면 404)
      → 평가기간 게이트(P4): jam.status=='EVAL' AND now∈[eval_start,eval_end]?  (아니면 422)
      → jamEntriesMapper.exists(jam.id, gameId)?             (아니면 404 출품작 아님)
      → current = jamVotesMapper.findVotedGameId(jam.id, userId)   # 현재 표(없으면 null)
          - current == null  → jamVotesMapper.castVote(jam.id, gameId, userId)   # INSERT, changed=false
          - current == gameId → no-op (멱등)                                     # changed=false
          - current != gameId → jamVotesMapper.updateVote(jam.id, userId, gameId) # game_id 교체, changed=true
      → 200 {votedGameId: gameId, changed}
   # 동시요청 경합(같은 voter 2 INSERT): UNIQUE(jam_id,voter_user_id) 위반 → 한쪽 DataIntegrityViolation
   #   → 컨트롤러 catch 후 updateVote 재시도 또는 409. 1인1표 불변(DB 강제).
   # 결과 count 는 응답에 미포함(P6 밴드왜건 회피 — 본인 표만 반환).
```

### S2. 취소 (DELETE)
```
[로그인 유저] DELETE /jams/{slug}/vote (CSRF)
  → JamVoteController.cancel
      → CsrfTokens.isValid                                   (아니면 403)
      → userId = sessionUserId                               (없으면 401)
      → jam = getBySlug(slug)                                (없으면 404)
      → 평가기간 게이트(P4)                                   (아니면 422)
      → affected = jamVotesMapper.deleteVote(jam.id, userId) # voter 행 삭제
      → affected == 0 → 404 (취소할 표 없음)
      → 200 {votedGameId: null}
```

### S3. 결과 노출 (GET /vote/results — 밴드왜건 회피 분기)
```
[공개] GET /jams/{slug}/vote/results
  → JamVoteController.results
      → jam = getBySlug(slug)                                (없으면 404)
      → 노출 게이트(P6): jam.status=='CLOSED' OR now() > jam.eval_end_at?
          - 종료 → results = jamVotesMapper.listCountsByJam(jam.id)  # [{gameId, voteCount}] DESC
                   total = sum(voteCount)
                   200 {results, total}
          - 진행 중 → total = jamVotesMapper.countByJam(jam.id)      # 총합만(표심 은닉)
                   200 {open:true, results:null, total}
   # JSP(jam-detail) 결과 영역도 동일 분기: 종료 후만 출품작별 막대, 진행 중엔 "투표 진행 중 · 총 N표".

[로그인 유저] GET /jams/{slug}/vote/mine
  → jam = getBySlug; userId = sessionUserId (없으면 401)
  → votedGameId = jamVotesMapper.findVotedGameId(jam.id, userId)
  → 200 {votedGameId}   # 본인 표는 진행 중에도 노출(중복투표 UX)
```

---

## 파일 영향 맵

> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
> **V-MAPPER**(JamVotesMapper) · **V-CONTROLLER**(JamVoteController + 401/403/422/CSRF/게이트 헬퍼) · **V-VIEW**(jam-detail.jsp 투표 폼·결과 영역 추가 — W2-1 산출 JSP 수정).
> 의존: W2-3 동결(jam_votes) + W2-1(jams/jam_entries/JamsMapper/JamEntriesMapper/jam-detail.jsp) 선행. V-MAPPER → V-CONTROLLER → V-VIEW.

| 변경 유형 | 경로 | 역할 | 소유 |
|---|---|---|---|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamVotesMapper.java` | `@Mapper` jam_votes 투표/취소/조회/집계(`#{}`, snake→camel 직접 alias) | V-MAPPER |
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamVoteController.java` | `/jams/{slug}/vote` POST/DELETE + /mine + /results. CSRF·평가기간 게이트·1인1표 토글 | V-CONTROLLER |
| 수정 | `src/main/webapp/WEB-INF/views/jam-detail.jsp` (W2-1 산출) | 출품작별 투표 버튼(CSRF hidden) + 결과 영역(종료 후 count / 진행 중 은닉) + 내 표 표시 | V-VIEW |
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 JamVotesMapper @MockBean 등록(contextLoads 보존, verification §30) | (검증) |
| 신규 | `src/test/.../JamVoteControllerTest.java` | 투표/변경/취소 + 401/403(CSRF)/422(평가기간)/404 + 1인1표 + 결과 은닉/공개 분기 | (검증) |

> SSR 호출지점 영향(verification §영향맵): 본 설계는 jam_votes(W2-3 동결) 소비 + jam-detail.jsp(W2-1) 수정만. games/game_likes/jams/jam_entries 무변경 → 기존 소비처 0 영향. jam-detail.jsp 는 W2-1 신규 산출이라 기존 화면 회귀 없음(투표 영역 추가만). JamVoteController 신규 컨트롤러 의존은 JamsMapper/JamEntriesMapper(W2-1 제공) + JamVotesMapper(신규) → contextLoads 시 신규 매퍼 @MockBean 필요(concern 2).

### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
```java
// JamVotesMapper (@Mapper, #{} only, snake→camel 직접 alias — 집계 VIEW 아님 → 큰따옴표 불요)
int castVote(long jamId,          // 평가단위 1/2 (UNIQUE 좌변)
             long gameId,         // 투표 대상 출품작
             long voterUserId)    // 투표자(세션 userId; 1인1표 식별)
int updateVote(long jamId,        // 대상 잼
               long voterUserId,  // 기존 표 보유자(WHERE 절 식별)
               long gameId)       // 교체할 출품작(SET game_id)
int deleteVote(long jamId,        // 대상 잼
               long voterUserId)  // 취소 대상 voter(취소=voter 행 삭제)
Long findVotedGameId(long jamId,      // 대상 잼
                     long voterUserId) // 본인 현재 표 조회(null=미투표; 토글 분기·/mine 소스)
long countByJam(long jamId)           // 진행 중 총합(표심 은닉 시 총 투표 수만)
List<Map<String,Object>> listCountsByJam(long jamId)  // 종료 후 출품작별 count(W2-6 POPULAR 소스)
```
> ⚠️ inflate 마킹(concern 1): `castVote`/`updateVote`/`findVotedGameId` 분리는 "사전조회 후 분기" 전략이다. 구현이 `castVote` 를 `INSERT ... ON CONFLICT (jam_id, voter_user_id) DO UPDATE SET game_id = EXCLUDED.game_id` 단일 멱등 upsert 로 구현하면 `findVotedGameId` 사전조회와 `updateVote` 가 토글 경로에서 불요해질 수 있다(단 changed 플래그·/mine 응답에는 findVotedGameId 가 여전히 필요). `countByJam` 은 진행 중 총합 전용 — 종료 후 화면이 listCountsByJam 으로 total 을 합산하면 countByJam 이 dead 가 될 수 있으니 구현에서 실사용 재확인(최소 인자/메서드 원칙). `listCountsByJam` 반환은 W2-6 소비 형태 확정 전 Map 로 두되, 안정화 시 VoteCountData POJO 로 좁힘 검토.

---

## 대안 비교

| 주제 | 안 | 장점 | 단점 | 채택 |
|---|---|---|---|---|
| 표 변경 처리 | (A) updateVote(기존 행 game_id UPDATE) | 1문장, id/created_at 보존, UNIQUE 충돌 없음, 경합 윈도 최소 | 사전 hasVoted 분기 | **채택(P7)** |
| | (B) DELETE→INSERT | 단순 일관 | 2문장 경합 윈도, created_at 리셋, 트랜잭션 필요 | 기각 |
| | (C) ON CONFLICT DO UPDATE upsert | 단일 멱등문 | findVotedGameId 분리(changed/mine) 여전 필요, 구현 시 채택 가능(concern 1) | 구현 재량(미배제) |
| 투표 단위 | (A) 잼당 1표(최애 1작품) | 골자 W2-5 확정, 1인1표 UNIQUE 정합, 밴드왜건 완화 | 여러 작품 응원 불가 | **채택(G2)** |
| | (B) 출품작당 1표(여러 작품 가능) | 폭넓은 응원 | jam_votes UNIQUE(jam_id,voter) 와 충돌(스키마 동결 위반) | 기각(동결 위반) |
| 결과 노출 | (A) 평가기간 중 은닉 → 종료 후 count | 밴드왜건 회피(확정 결정), 표심 쏠림 방지 | 진행 중 결과 궁금증 미충족 | **채택(P6)** |
| | (B) 실시간 공개 | 즉시성·재미 | 밴드왜건(인기작 쏠림) — 확정 결정 위반 | 기각 |
| 식별자 | (A) 세션 userId(bigint) | 로그인 1인1표 정석, voter_user_id FK 정합 | 미로그인 투표 불가 | **채택(P5)** |
| | (B) game_likes user_key varchar 재활용 | 재사용 | 비권위·1인1표 비보장(R-D)·varchar | 기각(별개 동결) |
| 게이트 위치 | (A) 컨트롤러 진입부 앱계층 | W2-3 F6 계약 일치, 시각 비교 런타임 | 컨트롤러 분기 | **채택(P4)** |
| | (B) DB CHECK 로 기간 강제 | DB 보장 | now() CHECK 불가·동결 위반(W2-3 F6 명시) | 기각 |

---

## 롤아웃 / 마이그레이션

### 순서
1. **스키마**: 신규 DDL 0 — W2-3 `docs/jam-eval-ddl.sql`(jam_votes) 이 이미 적용돼 있어야 함(선행). 본 설계 추가 DDL/schema.sql 수정 없음.
2. **선행 의존**: W2-3 동결(jam_votes) + W2-1(jams/jam_entries/JamsMapper/JamEntriesMapper/jam-detail.jsp) 코드 배포 선행. 본 설계는 그 위에 매퍼/컨트롤러/JSP 영역만 추가.
3. **코드 배포**: V-MAPPER → V-CONTROLLER → V-VIEW. BibimbapApplicationTests @MockBean(JamVotesMapper) 동반(contextLoads).
4. **권한 시드 불요**: 투표는 로그인 개방 — 권한 키/시드 없음.

### 역호환
- jam_votes(W2-3)/jams/jam_entries(W2-1)/games/game_likes/game_reviews **전부 무변경**. 기존 동작 0 영향.
- jam-detail.jsp 는 W2-1 신규 산출 — 투표 영역 추가만(기존 출품작 표시 회귀 0).

### 롤백
- 코드 롤백: JamVoteController/JamVotesMapper 제거 + jam-detail.jsp 투표 영역 제거 → 투표 기능 미노출. jam_votes 테이블은 추가 전용(W2-3 소유)이라 잔존 무해(데이터 남아도 무참조).
- 스키마 롤백: 본 설계 신규 DDL 0 → 롤백 대상 없음(jam_votes drop 은 W2-3 maintenance 소관).

---

## AC 매핑

| AC | 요구(골자 W2-5 + 확정 결정) | 만족 설계 요소 | 비고 |
|---|---|---|---|
| AC-1 | 투표 API POST/DELETE `/jams/{slug}/vote`(gameId) | JamVoteController vote/cancel, RecruitController 패턴 | P1, §외부계약 |
| AC-2 | 잼당 1인 1표(최애 1작품) | jam_votes ux_jam_votes_jam_voter(W2-3) + updateVote 변경 경로 | P2/P7, S1 |
| AC-3 | game_likes 와 별개 | jam_votes.voter_user_id bigint 소비, game_likes 무참조 | P3, §대안 |
| AC-4 | 평가기간 게이트(EVAL + window) | 컨트롤러 진입부 jam.status=='EVAL' AND now∈[eval_start,eval_end] → 422 | P4, S1/S2 |
| AC-5 | 미로그인 차단 | sessionUserId null → 401(API) | P5, §게이트 |
| AC-6 | ★평가기간 중 표심 은닉 → 종료 후 공개 | /vote/results 노출 게이트(CLOSED OR now>eval_end) — 진행 중 results:null | P6/G6, S3 |
| AC-7 | 표 변경/취소 허용 | POST 변경(updateVote, changed:true) + DELETE 취소(deleteVote) | P7, S1/S2 |
| AC-8 | 상태변경 전수 CSRF | POST/DELETE 전부 CsrfTokens.isValid 선검증 → 403 errorBody | §게이트 공통 |
| AC-9 | 투표 SQL `${}` 0 | JamVotesMapper `#{}` only | §시그니처 |
| AC-10 | W2-6 POPULAR 트랙 소스 제공 | listCountsByJam(jamId) 출품작별 count DESC | G7, §W2-6계약 |
| AC-11 | 본인 투표 여부 상시 노출 | /vote/mine findVotedGameId(진행 중에도 본인 노출) | P6, S3 |

---

## 검증 포인트 (verification-advisor 점검 대상)

> L레벨 매핑(verification-strategies): 투표 토글·1인1표·평가기간 게이트·미로그인 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·집계 GROUP BY = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30, @MockBean).

### 시나리오 검증
- **VP-1 (AC-2 1인1표, L1+L2)**: 같은 voter 가 같은 잼에 2회 castVote → 2번째 UNIQUE(jam_id,voter_user_id) 위반(DB 강제, L2 dev contract 실측). 다른 game 으로 POST → updateVote 로 행 1개 유지(changed:true), 표 수 불변.
- **VP-2 (AC-4 평가기간 게이트, L1+L3)**: jam.status!='EVAL'(RECRUIT/DEV/CLOSED) 또는 now∉[eval_start,eval_end] 시 POST/DELETE → 422 + mapper 미호출. EVAL+window 내만 200.
- **VP-3 (AC-5 미로그인, L1)**: 세션 userId 없음 → POST/DELETE/mine 401. results 는 미로그인도 200(공개 조회).
- **VP-4 (AC-6 밴드왜건 은닉, L1)**: 진행 중(EVAL) /vote/results → `results:null, open:true`(출품작별 count 미노출). 종료 후(CLOSED 또는 now>eval_end) → results 배열 노출. JSP 동일 분기 렌더 확인.
- **VP-5 (AC-8 CSRF, L1)**: POST/DELETE CSRF 누락 → 403 + mapper 미호출(`deleteCommentRejectsMissingCsrfBeforeMapperAccess` 패턴 준용 — verification §CSRF-before-mapper).
- **VP-6 (DB-방언 계약, L2)**: JamVotesMapper 반환 키(votedGameId/voteCount/gameId)가 컨트롤러/JSP 조회 키와 정합. listCountsByJam GROUP BY count 가 샘플 데이터와 일치(snake→camel 직접 alias, 집계 VIEW 아님 → 큰따옴표 미사용 확인).
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 JamVotesMapper @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException.
- **VP-8 (AC-7 변경/취소, L1)**: 투표→다른 game POST(changed:true, 표 수 1 유지)→DELETE(votedGameId:null, 행 0)→재투표(changed:false) 시퀀스 정합.

### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
> self-audit(시점): 아래 카운트는 **본 W2-5 가 신규 생성하는 정적 산출물**(컨트롤러 상태변경 핸들러·매퍼 메서드)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. jam_votes 동결 스키마 카운트는 W2-3 verification 소관(본 설계는 소비만 — 중복 검증 회피).
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 상태변경 핸들러 집합/게이트 호출 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴 정당).

- **AC-T1 투표 상태변경 핸들러 전수 2건 CSRF + 평가기간 게이트** — JamVoteController 의 상태변경 핸들러(POST vote / DELETE cancel) 전수 2건이 ① `CsrfTokens.isValid` 선검증 ② 평가기간 게이트(jam.status=='EVAL' AND now∈window) 둘 다 보유. 검증: 상태변경 핸들러(@PostMapping/@DeleteMapping) 열거 == 2 AND 각 진입부에 CSRF + 게이트 존재(수동 판정 — @PostMapping/@DeleteMapping 핸들러 열거 후 각 본문 확인, 리터럴 grep 단독 의존 회피). GET(/mine, /results)은 상태변경 아님 → 게이트 비대상(읽기). 핸들러 추가 시 게이트 누락 = 평가기간 우회 보안결함 → FAIL. **이 전수 AC 가 P4 게이트의 핵심 가드**.
- **AC-T2 투표 매퍼 `${` 0건** — JamVotesMapper(1파일)에 `${` 매치 0: `grep -c '\${' JamVotesMapper.java` == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
- **AC-T3 결과 노출 게이트 불변식(밴드왜건 회피)** — `/vote/results` 핸들러와 jam-detail.jsp 결과 영역 **둘 다** 노출 게이트(`status=='CLOSED' OR now()>eval_end_at`)를 보유: 진행 중 출품작별 count 비노출(results:null/JSP 막대 미렌더). 검증: 컨트롤러 results 분기 + JSP 조건 렌더 전수 2지점 모두 게이트 존재(수동 판정 — 시각 비교는 런타임이라 리터럴 grep 부적합, 의미 불변식 점검). 1지점이라도 무조건 count 노출 시 밴드왜건 회피(P6/G6) 위반 → FAIL.
- **AC-T4 jam_votes 무변경 불변식(동결 소비)** — 본 W2-5 산출물에 `jam_votes` 의 DDL 변경문(ALTER TABLE/CREATE INDEX/CREATE TABLE) 0건: 본 설계는 신규 docs/*-ddl.sql 파일을 만들지 않고 schema.sql 도 수정하지 않음. 검증: 본 워크스트림 diff 에 `jam_votes` 대상 ALTER/CREATE 0건(읽기/쓰기 매퍼 SQL 의 INSERT/UPDATE/DELETE/SELECT 는 무방, DDL 변경문만 0). 동결 소비 계약(W2-3) 위반(투표가 스키마 손대기) 즉시 검출.

---

## 잔여 오픈 질문
없음(0). 확정 결정 P1~P8 전제 고정. 두 난제(1인1표 토글 변경경로·결과 노출 시점 밴드왜건 회피)는 본 설계가 구체 메커니즘으로 확정. 자기표 허용(P8)·표 변경 허용(P7)도 정석으로 확정. 구현 점검 항목(매퍼 upsert vs 분기 시그니처 inflate·full-test @MockBean·집계 alias·결과 노출 분기 위치·자기표 운영 금지 정책)은 오픈 질문이 아니라 `concerns` 로 이관.
