bibimbap/.atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md

33 KiB

phase agent agent_version generated_at workstream concerns concerns_checked self_verification references
design design-advisor 1 2026-06-23T16:30:00+09:00 W2-5-인기투표
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 분기 추가 — 구현 점검 항목(스키마 영향 없음).
true
checklist_passed
true
requirements research adrs
docs/work-log/2026-06-23-w2-w4-feature-skeletons.md .atp/work-session/20260623-104307/research/W2-W4-grounding.md
.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.sqljam_votes소비만 한다. 아래는 소비하는 동결 스키마의 참조 사본(W2-3 §데이터모델3 권위 — 본 설계가 정의/변경하지 않음. 재게시는 매퍼 SQL 작성 grounding 용).

소비 대상: jam_votes (W2-3 동결 — 변경 금지)

-- 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_gameSELECT 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}`
집계 조회(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/resultsjam.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 시상 산정은 본 설계 매퍼의 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 방지)

// 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 분리는 "사전조회 후 분기" 전략이다. 구현이 castVoteINSERT ... 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 로 이관.