---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-24T12:30:00+09:00
workstream: W2-5-인기투표
concerns: []
concerns_checked: true
workers_spawned: 5
planned_workers: 5
actual_workers: 5
self_verification:
  checklist_passed: true
  unused_diagnostics: 0
  test_compile: BUILD SUCCESS
references:
  design: .atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md
  frozen_schema: docs/jam-eval-ddl.sql  # §3 jam_votes (W2-3 동결, 본 워크 미수정)
---

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

## 변경 목록

| 파일 | worker | 변경 | 결과 요약 |
|---|---|---|---|
| `src/main/java/com/pandoli365/bibimbap/mapper/JamVotesMapper.java` | w-001 | 신규 | @Mapper 6메서드(castVote/updateVote/deleteVote/findVotedGameId/countByJam/listCountsByJam). `#{}` only, `${}` 0건. listCountsByJam alias 큰따옴표 인용. |
| `src/main/java/com/pandoli365/bibimbap/controller/JamVoteController.java` | w-002 | 신규 | @Controller 4핸들러(POST vote / DELETE cancelVote / GET mine / GET results). 게이트 6단, 토글, 노출 게이트. |
| `src/main/webapp/WEB-INF/views/jam-detail.jsp` | w-003 | 수정 | 인기투표 섹션 + 결과 영역(종료 후만 렌더) + 내 표 + JS(textContent/CSRF 헤더) 추가. 기존 섹션 무변경. |
| `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | w-004 | 수정 | JamVotesMapper @MockBean 등록(contextLoads 보존). import 1 + 필드 1. |
| `src/test/java/com/pandoli365/bibimbap/controller/JamVoteControllerTest.java` | w-005 | 신규 | plain Mockito 24테스트. VP-1~8/VP-11/AC-T1/AC-T3 전수. |

신규 DDL 0건(W2-3 jam_votes 동결 소비). schema.sql / docs/*-ddl.sql 무수정.

## Bash 단계 (advisor 직접)
- `./mvnw -o clean test-compile -Dmaven.compiler.showWarnings=true -Dmaven.compiler.compilerArgument=-Xlint:all` (JAVA_HOME=openjdk@21) → **BUILD SUCCESS (EXIT 0)**. 신규 4 Java + 1 test modify 전부 컴파일 통과. 테스트 실행 안 함(test-compile 라이프사이클 — verification 영역).
- **unused 진단 게이트(별도)**: 전체 100 WARNING 중 W2-5 신규 파일(JamVotesMapper/JamVoteController/JamVoteControllerTest) 매치 **0건**. unused/this-escape/unchecked/dead-param 0. 전체 100 은 기존 코드베이스 경고(@MockBean deprecation 등, W2-5 경로 매치 0 — W2-4 보고서와 동일 기존 경고군).
- 집합 전수 AC grep 게이트(상세 아래 §집합 전수 체크): AC-T1 핸들러 2 + CSRF 2 + 게이트 2 / AC-T2 `${` 0 / AC-T4 jam_votes DDL 변경문 0 — 전부 PASS.

## §33 인용 alias 적용 결과 (인기집계 매퍼)
- `JamVotesMapper.listCountsByJam` 은 `List<Map<String,Object>>` 반환(집계 GROUP BY COUNT) → **Map resultType 이라 alias 큰따옴표 인용 적용**:
  `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`
  (선례: `JamScoreStatsMapper` 가 동일하게 `AS "gameId"` 큰따옴표 — Postgres lowercase 폴딩 회피로 Map 키 `voteCount` 보존 → 컨트롤러 `((Number) row.get("voteCount"))` 정상.)
- 단일 스칼라 반환 `findVotedGameId`(Long), `countByJam`(long) 은 alias 불요 — 미적용(정상).
- 그 외 POJO 직접매핑 매퍼 없음(JamVotesMapper 는 Map 1 + 스칼라 2 + 쓰기 3).

## 1인1표 / 평가기간 / 종료후공개 게이트 구현 위치
- **1인1표(P2/G2/AC-2)**: DB `ux_jam_votes_jam_voter` UNIQUE(jam_id, voter_user_id)(W2-3 동결) + 컨트롤러 토글(`JamVoteController.vote` line 77~87): findVotedGameId → null=castVote(INSERT) / 동일 game=멱등 no-op / 다른 game=updateVote(game_id 교체). 표 추가 INSERT 안 함 → UNIQUE 충돌 없음.
- **평가기간 게이트(P4/G4/AC-4)**: `JamVoteController.vote` line 68, `cancelVote` line 120 — `!JamEvalWindow.isOpen(jam, OffsetDateTime.now())` → 422 "투표 기간이 아닙니다.". W2-4 산출 `JamEvalWindow.isOpen`(EVAL + now∈[evalStart,evalEnd]) **재사용**(설계 §게이트연동 4 + JamScoringController 선례 동형). 상태변경 핸들러 2건 전부 보유.
- **미로그인 차단(P5/G5/AC-5)**: vote line 58, cancelVote line 110, myVote line 141 — sessionUserId(session) null → 401. (results 는 공개 — session 미수신.)
- **CSRF(AC-8)**: vote line 54, cancelVote line 106 — `!CsrfTokens.isValid(request)` → 403 errorBody. 매퍼 접근 전(게이트 1).
- **종료후공개(P6/G6/AC-6 밴드왜건 회피)**: `JamVoteController.results` line 170~188 — `publiclyVisible = "CLOSED".equals(status) || (evalEndAt != null && now > evalEndAt)`. true=listCountsByJam(출품작별 count)+total / false=countByJam(총합만)+`results:null, open:true`(표심 은닉). JSP(jam-detail.jsp line 403/447) 동일 게이트 — 진행 중 출품작별 결과 영역 미렌더, 안내문만. **컨트롤러+JSP 2지점 모두 게이트 보유(AC-T3)**.
- **본인 표 상시 노출(P6/AC-11)**: myVote line 151 — findVotedGameId(진행 중에도 본인 노출). 토글 응답에도 votedGameId 포함(집계는 미포함).

## 설계와의 차이 (근거 기록)

### 차이1 — listCountsByJam alias 큰따옴표 인용 (설계 concern 3 과의 의도적 deviation)
- 설계 concern 3 / §W2-6계약 / §시그니처 주석은 "집계 VIEW 아님 → 큰따옴표 alias 불요(`COUNT(*) AS voteCount`)" 라 명시했다.
- 그러나 본 구현은 `AS "voteCount"` 큰따옴표 인용을 적용했다.
- **근거**: 케이스폴딩 위험의 기준은 "VIEW 여부"가 아니라 "**Map resultType 여부**"다. `listCountsByJam` 은 `List<Map<String,Object>>` 반환 → MyBatis 가 컬럼-프로퍼티 자동 대소문자 매칭을 하지 않고 DB 가 반환한 컬럼 라벨 문자열을 Map 키로 그대로 쓴다. Postgres 는 비인용 alias 를 lowercase 로 폴딩(`voteCount`→`votecount`)하므로 컨트롤러 `row.get("voteCount")` 가 null 이 되어 total 합산이 0 이 되는 결함이 발생한다. POJO 직접매핑(JamScoresMapper.listByJudge 등)은 MyBatis 케이스 무시 매칭으로 안전하지만 Map 은 그렇지 않다.
- 프로젝트 동일 선례 `JamScoreStatsMapper`(Map resultType)도 `AS "gameId"` 큰따옴표 인용 → 본 구현이 그 선례 및 누적 교훈 §33(인용 alias)과 일치. 작업 지시 ★1("인기투표 집계 매퍼가 Map/집계면 인용")이 명시적으로 이 경로를 지시 — 작업 지시 > 설계 concern.
- 설계 정본의 SQL 의미(컬럼/집계/정렬)는 불변. alias 인용은 L2 DB-방언 계약 안전 강화이며 API 응답 키(gameId/voteCount)도 설계 §외부계약과 동일. **기능/계약 변경 0, 무결성 강화만.**

### 차이2 (없음) — concern 1 dead method/parameter 재확인 결과
- 설계 concern 1 은 castVote/updateVote/findVotedGameId 분리가 ON CONFLICT upsert 채택 시 dead 가능성을 경고했다. 본 구현은 **분기 전략(findVotedGameId 사전조회)을 채택**(upsert 아님)했고, 6개 매퍼 메서드 전부 컨트롤러에서 실사용 확인:
  - castVote(vote 신규), updateVote(vote 변경), deleteVote(cancelVote), findVotedGameId(vote 토글 분기 + myVote), countByJam(results 진행 중 총합), listCountsByJam(results 종료 후).
  - dead method 0, dead parameter 0(컴파일 lint 경고 0 으로 교차 확인).
- 따라서 concern 1 은 "구현이 분기 전략 채택 + 전메서드 실사용" 으로 해소 — 시그니처 inflate 없음. (settled, 잔여 concern 아님.)

### 자기표 허용(P8) / 표 변경 허용(P7)
- 설계 정본대로 구현. 자기 출품작 투표 422 분기 미추가(P8 허용), 표 변경 updateVote 경로 구현(P7). 설계 일치.

## Verification 을 위한 힌트

### acceptance criteria — 설계 §검증 포인트 verbatim echo
> 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 아님 → 큰따옴표 미사용 확인).
  - ※ 구현 정정: 본 구현은 Map resultType 이라 **큰따옴표 인용 적용**(§설계와의 차이 차이1). 검증 시 매퍼 alias 가 `AS "gameId"`/`AS "voteCount"` 큰따옴표임을 확인 — Map 키 케이스폴딩 회피.
- **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 — 설계 §집합 전수 체크 AC verbatim echo + 구현 충족 결과
> 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 게이트의 핵심 가드**.
  - **구현 충족**: @PostMapping("/jams/{slug}/vote")(line 44) + @DeleteMapping("/jams/{slug}/vote")(line 97) = 2건. `CsrfTokens.isValid` 호출 2회(line 54, 106), `JamEvalWindow.isOpen` 호출 2회(line 68, 120) — 두 핸들러 각 진입부 보유. GET 2건은 게이트 비대상. PASS.
- **AC-T2 투표 매퍼 `${` 0건** — JamVotesMapper(1파일)에 `${` 매치 0: `grep -c '\${' JamVotesMapper.java` == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
  - **구현 충족**: `grep -c '\${' JamVotesMapper.java` == **0**. (컨트롤러도 0.) PASS.
- **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.
  - **구현 충족**: 컨트롤러 results line 170~172(`publiclyVisible = "CLOSED".equals(status) || (evalEndAt != null && now>evalEndAt)`) + JSP line 403/447(`resultsPublic = "CLOSED".equals(status) || evalEnded`; resultsPublic 일 때만 `#vote-results` 렌더, 아니면 "평가 종료 후 공개" 안내). JS 측 2차 게이트(line 520/583 `resultsPublic` + line 589 `data.open` 분기). 2지점(+JS) 모두 게이트 보유. PASS.
- **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) 위반(투표가 스키마 손대기) 즉시 검출.
  - **구현 충족**: W2-5 산출물에 jam_votes ALTER/CREATE TABLE/CREATE INDEX **0건**(grep 무출력). 매퍼는 INSERT/UPDATE/DELETE/SELECT 만. docs/jam-eval-ddl.sql / schema.sql 무수정. PASS.

### 이번 변경으로 영향받는 테스트 파일
- `src/test/java/com/pandoli365/bibimbap/controller/JamVoteControllerTest.java` (신규, 24테스트)
- `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` (contextLoads — JamVotesMapper @MockBean 등록 후 PASS 필요, VP-7)
- full `./mvnw -o test` 권장(§30): 신규 컨트롤러+매퍼 의존 contextLoads + 단위 24 + dev DB contract(L2: listCountsByJam GROUP BY + alias 케이스폴딩) 실측.

## grounding 불일치 / concerns 처리

### grounding 실측 결과 (불일치 0)
- W2-3 jam_votes 스키마: docs/jam-eval-ddl.sql §3 line 87~117 직접 확인 — id/jam_id/game_id/voter_user_id/created_at + ux_jam_votes_jam_voter UNIQUE + idx_jam_votes_jam_game. 매퍼 컬럼명 일치.
- W2-4 JamEvalWindow.isOpen(JamData, OffsetDateTime): jam/JamEvalWindow.java 직접 확인 — EVAL + now∈[evalStart,evalEnd] 경계포함. 평가기간 게이트 재사용 정합.
- W2-1 JamController: getBySlug/sessionUserId(line 244~260)/response(line 270~275)/CsrfTokens 패턴/`/jams/{slug}/entries`·`/teams` 매핑 직접 확인 — JamVoteController `/vote*` 경로 충돌 0.
- JamEntriesMapper.exists(jamId, gameId): 직접 확인(line 65) — 활성 출품작 검증 정합.
- JamData getter: getStatus/getEvalStartAt/getEvalEndAt(OffsetDateTime)/getId/getSlug — JamsMapper alias(eval_end_at AS evalEndAt) + JamScoringController 사용처로 교차 확인. JSP getEvalEndAt() OffsetDateTime 반환 정합(worker 의존경고 해소).
- CsrfTokens.isValid/errorBody/HEADER_NAME/SESSION_ATTRIBUTE/getOrCreate: security/CsrfTokens.java 직접 확인.
- game_likes 무참조: JamVotesMapper/JamVoteController 에 game_likes 참조 0 — 설계 P3/G3(별개) 준수.
- BibimbapApplicationTests @MockBean: 직접 확인(JamScoresMapper 등 기등록 패턴) — JamVotesMapper 추가 정합.

### concerns 처리 (설계 5개 concern)
- concern 1 (시그니처 inflate / dead method): **해소** — 분기 전략 채택, 6메서드 전부 실사용, lint 경고 0. (§설계와의 차이 차이2.)
- concern 2 (@MockBean §30): **충족** — BibimbapApplicationTests 에 JamVotesMapper @MockBean 등록(w-004), contextLoads test-compile 통과. VP-7 가드.
- concern 3 (집계 alias 큰따옴표 불요 주장): **정정 적용** — Map resultType 이라 큰따옴표 인용 채택(§설계와의 차이 차이1 + ★1). 기능 무변경.
- concern 4 (결과 노출 게이트 컨트롤러/JSP 분기): **구현** — 컨트롤러 results + JSP resultsPublic 2지점 게이트(AC-T3 PASS).
- concern 5 (자기표 허용): **설계대로** — 자기 출품작 422 분기 미추가(P8 허용).
- 신규 concern: **없음(빈 리스트)**. 설계 5 concern 전부 구현 단계에서 해소/정정/충족.

### 기존 파일 수정 + 근거
- jam-detail.jsp(W2-1 산출) 수정 — 설계 §파일영향맵 "수정" 명시 대상. 투표 섹션+JS 추가만, 기존 hero/운영안내/출품작/팀/출품폼/팀폼 무변경(회귀 0). 근거: 설계 G1(읽기=잼 상세 JSP 재사용, 투표 폼/결과 영역 추가).
- BibimbapApplicationTests 수정 — 설계 §파일영향맵 "수정(검증)" + concern 2 §30 의무. JamVotesMapper @MockBean 1개 추가만.

### 미해결
- 없음. 설계 오픈질문 0, concern 5개 전부 처리, 집합 전수 AC 4개 PASS, test-compile BUILD SUCCESS, unused 진단 0.
- (참고 — verification 영역, 차단 아님) JSP 결과 영역은 게임명 대신 "게임 ID N" 텍스트 표기(/vote/results 응답에 gameName 미포함, W2-6 소비 형태 안정화 전 Map 유지 — 설계 §시그니처 주석과 정합). 막대 그래프 대신 텍스트 count(설계 "1차는 종료 후 count 단순 노출" §비목표와 정합). 후속 개선 후보이며 AC 위반 아님.
