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-인기투표 |
|
true |
|
|
설계: 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_votesUNIQUE(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_votescount(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 동결 — 변경 금지)
-- 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_idseek 를 지원(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-1GET /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/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 §인터셉터 직접 확인). 게이트는 컨트롤러 진입부 앱계층만.
컨트롤러 진입부 게이트 순서 (모든 상태변경 액션 공통)
CsrfTokens.isValid(request)거짓 → 403 +CsrfTokens.errorBody()(mapper 접근 전 — verification §CSRF-before-mapper 패턴).userId = sessionUserId(session)(RecruitController.java:155 선례 —session.getAttribute("userId")) null → 401. (PermissionGate.isAuthenticated(session) 동등 — 본 설계는 RecruitController 의sessionUserId헬퍼 패턴 재사용 권장: 투표 컨트롤러가 PermissionGate 의존 없이 세션 userId 만으로 충족 → 의존 최소화. PermissionGate.isAuthenticated 호출도 동등 허용.)jam = jamsMapper.getBySlug(slug)(W2-1 제공) null → 404.- 평가기간 게이트(P4/F6):
jam.status == 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]거짓 → 422. - 출품작 존재:
jamEntriesMapper.exists(jam.id, gameId)(W2-1 제공, POST 만 — DELETE 는 gameId 불요) 거짓 → 404. - 통과 후 토글 본문.
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분리는 "사전조회 후 분기" 전략이다. 구현이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 명시) | 기각 |
롤아웃 / 마이그레이션
순서
- 스키마: 신규 DDL 0 — W2-3
docs/jam-eval-ddl.sql(jam_votes) 이 이미 적용돼 있어야 함(선행). 본 설계 추가 DDL/schema.sql 수정 없음. - 선행 의존: W2-3 동결(jam_votes) + W2-1(jams/jam_entries/JamsMapper/JamEntriesMapper/jam-detail.jsp) 코드 배포 선행. 본 설계는 그 위에 매퍼/컨트롤러/JSP 영역만 추가.
- 코드 배포: V-MAPPER → V-CONTROLLER → V-VIEW. BibimbapApplicationTests @MockBean(JamVotesMapper) 동반(contextLoads).
- 권한 시드 불요: 투표는 로그인 개방 — 권한 키/시드 없음.
역호환
- 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 로 이관.