bibimbap/.atp/work-session/20260623-104307/implementation/W2-6-award-aggregation-desi...

386 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-23T16:30:00+09:00
workstream: W2-6-시상 집계/결과
concerns:
- "JamAwardService / JamAwardsMapper 의 신규 메서드 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 산정 결과 행 빌더 buildAward(...) 와 정규화 헬퍼 normalizeRankScore(...)."
- "신규 컨트롤러(JamAwardController/JamAwardAdminController)+신규 매퍼(JamAwardsMapper)+소비 매퍼(JamScoreStatsMapper/JamVotesMapper/GameReviewStatsMapper 재사용) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException."
- "유저평점 트랙 소비 SQL 은 game_review_stats(집계 VIEW) 를 읽으므로 매퍼 alias 큰따옴표(AS \"avgRating\"/\"reviewCount\") 필수(케이스 폴딩, verification-strategies §33). jam_score_stats 도 집계 VIEW → weightedTotal 등 큰따옴표. jam_votes count·jam_awards CRUD 는 일반 매퍼 → snake→camel 직접 alias(a.score_value AS scoreValue). dev DB contract(L2) 로 3트랙 join·NULL·동점·재산정 멱등 실측 권장."
- "최소 리뷰수 임계 N=3 은 W2-3 동결 계약(F5)의 상수다. 본 설계는 GRAND 정규화·가중치 기본값(트랙 균등 1/3)도 산정 상수로 둔다 — 잼별 가변(jams 컬럼 또는 jam_award_config)이 필요하면 확장. 현 설계는 상수(W2-6 산정 로직에 위치, 구현 점검 항목)."
- "산정 트리거 시점 = jams.status='CLOSED' OR now()>eval_end_at(W2-3 F6). DB CHECK 로 강제하지 않고 앱계층 게이트(시각 비교 런타임). 재산정 멱등은 deleteByJamTrack 후 재INSERT 를 단일 트랜잭션으로 — 부분 실패 시 트랙 비는 상태 회피. @Transactional 경계는 구현 점검 항목."
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-6 — 시상 집계 / 결과 (3트랙 산정 + GRAND 종합 + 잼 결과 페이지/상세 수상 표시) ★크리티컬 패스 종점
> ⚠️ **W2-3 동결 소비 워크스트림**. 본 설계는 `jam_awards` 스키마(W2-3 동결, docs/jam-eval-ddl.sql §4)와 3트랙 소비 계약(G4/F5 단방향 유저평점·G6/F7 집계 노출)을 **재정의하지 않고 그대로 소비**한다. 신규 DDL 테이블 0(jam_awards 는 W2-3 이 이미 생성). 본 W2-6 은 **산정 로직 + 매퍼 + 컨트롤러 + 결과 표시 JSP** 만 신규.
## 목표 / 비목표
### 목표 (FR/NFR 추적 — 골자 W2-6)
- **G1 3트랙 산정** (FR-W2-6 핵심): JUDGE / USER_RATING / POPULAR 각 트랙 독립 랭킹 산정 → `jam_awards` 트랙별 행(rank) 기록.
- JUDGE = `jam_score_stats.weighted_total` DESC (W2-4 산출, W2-3 F7 동결 VIEW).
- USER_RATING = `game_review_stats.avg_rating`(overall) DESC NULLS LAST, `review_count >= 3` 임계 미달 제외 (W2-3 G4/F5 단방향 계약).
- POPULAR = `jam_votes` count DESC (W2-5 산출, W2-3 F7 동결 count 계약).
- **G2 GRAND 종합** (FR-W2-6 종합상): 3트랙 정규화 점수 가중합으로 종합 랭킹 산정 → `jam_awards` award_track='GRAND' 행. 정규화·가중·동점 규칙 본 설계가 확정.
- **G3 NULL/미달 처리** (W2-3 F5 계약 준수): 리뷰 0/axes 0행/심사 미완 출품작은 해당 트랙에서 **제외**(부당 0점 회피). GRAND 는 가용 트랙만 정규화 가중.
- **G4 확정 시점 + 멱등** (W2-3 F6): `jams.status='CLOSED'` 또는 `now() > eval_end_at` 일 때만 산정 허용. 재산정 멱등(트랙별 DELETE→INSERT 트랜잭션).
- **G5 결과 표시**: 잼 상세(`/jams/{slug}`)에 수상 요약 + 별도 결과 페이지(`/jams/{slug}/results`) 트랙별 + 종합 전체 노출.
- **G6 산정 트리거 권한** (W1 인프라 위): 관리자 산정 액션 = `GAME_JAM_MANAGE` 게이트(W2-1 D4-A 게이트 헬퍼 선례). 임시 role 직접체크 금지.
- **NFR**: 상태변경 CSRF 전수(산정 트리거), `#{}` 바인딩(`${}` 금지), 집계 VIEW 매퍼 alias 큰따옴표(case-folding), 단방향 유저평점(리뷰 도메인 write 0), 비파괴(신규 DDL 0 — jam_awards 소비만).
### 비목표 (스코프 밖)
- **jam_awards 스키마 정의** — **W2-3 동결 소유**(docs/jam-eval-ddl.sql §4). 본 설계는 컬럼/CHECK/UNIQUE 재정의 안 함, 소비만.
- **심사 점수 입력·집계 VIEW** — **W2-4 소유**(jam_score_stats VIEW 는 W2-3 동결, W2-4 가 점수 입력). 본 설계는 weighted_total 을 읽기만.
- **인기투표 토글·집계** — **W2-5 소유**(jam_votes). 본 설계는 count 를 읽기만.
- **리뷰 도메인 변경** — W3-2(구현완료). 본 설계는 `game_review_stats` VIEW 를 **SELECT 만**(W2-3 G4 단방향, game_reviews/axes 무변경).
- **자동 산정 스케줄러 본체** — 1차는 관리자 수동 트리거(`POST /admin/jams/{jamId}/awards/compute`). eval_end_at 경과 자동 산정은 W2-1 의 JamLifecycle 자동전이 훅(후속)과 동일하게 훅만 — 본 설계는 수동 산정 enforcement 만 구현 범위.
- **잼별 가변 가중치 UI/설정 테이블** — 1차는 산정 상수(트랙 균등 1/3 + 임계 N=3). 잼별 가변은 후속(concern 4).
---
## 개요
bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 시상 관련 산출물(jam_awards 소비 매퍼·산정 로직·결과 JSP)은 전무하다. W2-3 동결이 `jam_awards`(award_track CHECK 4값 JUDGE/USER_RATING/POPULAR/GRAND + rank + score_value + computed_at, `ux_jam_awards_jam_track_game` UNIQUE — docs/jam-eval-ddl.sql §4 직접 인용) + 3트랙 소비 계약을 이미 동결했고, W2-1 이 `jams`(status CHECK + slug + eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작 모집단)를 제공한다.
본 설계는 **3트랙 + GRAND 산정 로직(JamAwardService) + jam_awards CRUD 매퍼 + 관리자 산정 트리거 + 공개 결과 표시(상세 수상 요약 + 결과 페이지)** 를 신규 도입한다. 산정은 출품작 모집단(`jam_entries` 활성행) 위에서 3트랙 소스를 각각 정렬·랭크 → jam_awards 트랙별 행 기록 → GRAND 는 트랙 정규화 가중합으로 종합 랭크. 신규 DDL 테이블 0(jam_awards 소비).
확정된 정석 결정(전제 — orchestrator 확정 + W2-3 동결 정합):
- **3트랙 = 독립 산정** : 각 트랙은 자기 소스로 독립 랭크. 트랙 간 결합 없음(JUDGE 미완이어도 POPULAR 산정 가능).
- **GRAND = 트랙별 순위점수(rank-score) 정규화 가중합** : 트랙별 스케일 상이(weighted_total numeric / avg_rating 1~5 / vote count 정수) → **순위점수 정규화**(min-max 가 아닌 rank 기반)로 스케일 통일. 가중치 기본 균등(1/3). 가용 트랙만 가중.
- **NULL/미달 = 트랙 제외** (부당 0점 회피, W2-3 F5).
- **확정 시점 = CLOSED/eval종료 후, 멱등** (W2-3 F6).
가장 까다로운 세 난제 확정:
- **난제1 (GRAND 정규화 — 트랙별 스케일 통일)**: 3트랙은 스케일이 전혀 다르다(JUDGE weighted_total numeric 1~5 가중평균 / USER_RATING avg_rating numeric 1~5 / POPULAR vote count 정수 0~N). raw 점수 단순 합산은 vote count 가 압도(스케일 폭주). min-max 정규화는 트랙 내 분포에 민감(이상치·단일 출품작 시 0/1 양극단). 본 설계는 **순위점수(rank-score) 정규화**를 채택: 각 트랙에서 출품작을 정렬해 순위를 매기고, 순위를 `rankScore = (참여작수 - rank + 1) / 참여작수` (1위=1.0, 최하위=1/N) 로 [1/N, 1] 정규화한다. 스케일·이상치 무관, 트랙 간 동일 의미(상대 순위). GRAND = `Σ(rankScore_track × weight_track) / Σ(weight_track 가용)`. **가용 트랙만** 분자·분모에 포함(트랙 결측 출품작은 그 트랙 0 아님 — 가중 평균이 가용 트랙으로 정규화됨, F5 부당 0점 회피 정합). 동점은 같은 GRAND 점수 → 같은 rank(아래 동점 규칙).
- **난제2 (NULL/미달 트랙 제외의 산정 위치)**: W2-3 F5 는 "USER_RATING 트랙에서 review_count<3 제외" 동결했다. 설계는 **각 트랙 모집단을 트랙별로 좁힌다**: JUDGE = jam_score_stats 행이 있는 출품작(채점 1건 이상), USER_RATING = game_review_stats.review_count>=3, POPULAR = jam_votes count>=1(0표는 트랙 비포함이 아니라 동률 최하위 — 투표 트랙은 0표도 출품작이면 count 0 으로 랭크 가능하나, 정석=득표 0 출품작은 POPULAR 수상권 밖이므로 **rank 부여하되 score_value=0**, GRAND 의 POPULAR rankScore 계산 모집단에는 득표 있는 작품만). 트랙별 모집단을 명시(§시퀀스 S3). GRAND 모집단 = 3트랙 중 **최소 1트랙 이상 가용**한 출품작.
- **난제3 (동점 처리)**: 각 트랙 raw 점수 동점(예: weighted_total 동일, vote count 동일) → **같은 rank 부여**(standard competition ranking: 1,2,2,4). jam_awards UNIQUE 는 `(jam_id, award_track, game_id)` 이므로 같은 rank 동률 다행 허용(W2-3 동결 주석: "동률은 같은 rank 허용 위해 (jam_id, award_track, game_id) UNIQUE"). tie-break 표시 순서는 game_id ASC(결정적·재산정 안정). GRAND 동점도 동일 규칙.
---
## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 정합)
| 결정 | 확정값 | 본 설계의 구체화 |
|---|---|---|
| A1 트랙 산정 | 3트랙 독립 랭크 | JUDGE=jam_score_stats.weighted_total / USER_RATING=avg_rating(F5) / POPULAR=jam_votes count. 트랙 간 결합 없음 |
| A2 GRAND 정규화 | 순위점수(rank-score) 가중합 | rankScore=(N-rank+1)/N ∈[1/N,1]. GRAND=Σ(rankScore×weight)/Σ(weight 가용). 가용 트랙만 |
| A3 가중치 | 기본 균등 1/3(상수) | 3트랙 동일 weight. 잼별 가변은 후속(concern 4) |
| A4 NULL/미달 | 트랙 제외(부당 0점 회피) | JUDGE=채점 1건+ / USER_RATING=review_count>=3 / POPULAR rankScore 모집단=득표>0. GRAND=가용 트랙만 |
| A5 동점 | standard competition ranking(같은 rank) | jam_awards (jam_id,track,game_id) UNIQUE 가 동률 다행 허용(W2-3 동결). tie-break 표시=game_id ASC |
| A6 확정 시점 | CLOSED/eval종료 후, 멱등 | jam.status='CLOSED' OR now()>eval_end_at. 재산정=deleteByJamTrack→INSERT(트랜잭션) |
| A7 트리거 권한 | GAME_JAM_MANAGE 게이트 | /admin/jams/{jamId}/awards/compute. W2-1 D4-A 게이트 헬퍼 재사용(인터셉터 exclude 범위 내) |
| A8 표시 | 상세 요약 + 결과 페이지 | /jams/{slug} 수상 배지 + /jams/{slug}/results 트랙별+종합 전체 |
---
## 데이터 모델 (DDL — 신규 0건. W2-3 동결 jam_awards 소비)
> **신규 테이블/VIEW 0**. `jam_awards`(W2-3 동결, docs/jam-eval-ddl.sql §4)를 그대로 소비한다. 본 설계는 DDL 을 **재정의하지 않는다**(W2-3 소유). 아래는 소비 계약 확인용 동결 스키마 재인용(읽기 전용 — 변경 금지).
### 소비 대상: `jam_awards` (W2-3 동결 — 재정의 금지, 인용만)
```sql
-- docs/jam-eval-ddl.sql §4 (W2-3 동결. 본 W2-6 은 이 스키마를 INSERT/SELECT/DELETE 소비)
-- jam_awards(id, jam_id, game_id, award_track, rank, score_value numeric(10,4), computed_at)
-- award_track CHECK IN ('JUDGE','USER_RATING','POPULAR','GRAND')
-- rank CHECK >= 1
-- UNIQUE ux_jam_awards_jam_track_game (jam_id, award_track, game_id) ← 동률 다행 허용
-- INDEX idx_jam_awards_jam_track (jam_id, award_track, rank) ← 결과 조회 정렬
```
### score_value 컬럼 의미 확정 (트랙별 — W2-3 동결 컬럼의 본 설계 채움 규약)
> W2-3 동결은 `score_value numeric(10,4)` 를 "트랙별 의미 다름; NULL 허용"으로만 정의했다. 본 W2-6 이 트랙별 채움 의미를 확정한다(스키마 변경 0 — 같은 컬럼의 값 규약만).
| award_track | score_value 채움값 | 의미 |
|---|---|---|
| JUDGE | jam_score_stats.weighted_total (numeric, 가중종합 1~5) | 심사 가중 종합점수 |
| USER_RATING | game_review_stats.avg_rating (numeric, overall 1~5) | 유저 평균 별점(overall) |
| POPULAR | jam_votes count (정수를 numeric 로) | 득표수 |
| GRAND | GRAND 종합점수 (Σ(rankScore×weight)/Σweight, [0,1] 정규화) | 트랙 정규화 가중합 |
- **결정 근거**: score_value 에 트랙별 **raw 점수**(JUDGE/USER_RATING/POPULAR)와 GRAND 종합점수를 저장하면 결과 페이지가 jam_awards 단독 조회로 "점수와 함께" 표시 가능(소스 VIEW 재join 불요). rank 는 정렬·동점, score_value 는 표시·재현 근거. 둘 다 보존이 정석(rank 만으로는 동점 근거·점수 표시 불가).
### 신규 DDL 0 확인 (G3 비파괴)
- `jam_awards`/`jam_score_stats`/`jam_votes`/`game_review_stats`/`jam_entries`/`jams` **전부 무변경**(전부 선행 워크스트림 소유). 본 W2-6 은 jam_awards 에 INSERT/DELETE/SELECT, 나머지 4개는 SELECT 만. → DDL 파일 신규 0, schema.sql 변경 0.
---
## 외부 계약 (API)
> 공통: 산정 트리거(상태변경)는 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java 확인). 결과 조회는 공개(인증 불필요). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<Map<String,Object>>`(status/message). 관리자 산정 API 는 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 통과 후 본문(2-arg has — PermissionGate.java:22 직접 확인, request 인자 없음).
### 401 vs 403 정책 (W1-design / W2-1 / W2-3 과 일치)
- **미인증**(세션 `userId` 없음): API 401 JSON `{status:401, message:"로그인이 필요합니다."}`. 결과 페이지는 공개(미인증도 열람).
- **인증·미인가**(GAME_JAM_MANAGE 없음): 403 JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지).
- **산정 미개방**(F6 게이트 위반 — status≠CLOSED AND now()≤eval_end_at): **422** JSON `{status:422, message:"시상 산정 가능 상태가 아닙니다."}`(인가는 됐으나 도메인 상태 위반 → 403 아님 422, W2-3 F6 422 정책 일치).
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
### 공개 결과 (뷰 — 인증 불필요)
| method | path | 권한 | 응답 |
|---|---|---|---|
| GET | `/jams/{slug}/results` | 공개 | `jam-results` JSP. 트랙별(JUDGE/USER_RATING/POPULAR) 랭킹 + GRAND 종합. 미산정 잼이면 "결과 준비 중" 안내(빈 jam_awards) |
| GET | `/jams/{slug}` | 공개 | (W2-1 소유 — 본 설계는 수상 요약 모델 주입만 추가) `jam-detail` JSP 에 GRAND 상위 + 트랙 대상 배지 표시. **JamController.detail 의 모델에 awardsSummary 추가**(W2-1 컨트롤러 협의 수정 — concerns crossRef) |
### 관리자 산정 트리거 (상태변경 API — CSRF + GAME_JAM_MANAGE 게이트)
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|---|---|---|---|---|---|
| 시상 산정/재산정 | POST | `/admin/jams/{jamId}/awards/compute` | (path jamId) | `{status:200, message, jamId, awardCounts:{JUDGE:n, USER_RATING:n, POPULAR:n, GRAND:n}}` | 401(미인증), 403(CSRF/권한), 404(잼 없음), 422(산정 미개방 — CLOSED 아님+eval 진행중) |
- **단일 산정 엔드포인트 채택 근거**: 산정은 멱등(재실행=전체 재산정)이므로 최초 산정/재산정을 같은 엔드포인트로. 4트랙을 한 트랜잭션에 산정해 트랙 간 부분 산정(일부 트랙만 갱신된 비정합 상태) 회피. `awardCounts` 로 트랙별 수상 행수 반환.
- **경로 소속**: `/admin/jams/{jamId}/awards/compute``/admin/jams/**` 하위 → W2-1 D4-A 의 InterceptorConfig `.excludePathPatterns("/admin/jams/**")` 범위에 포함되어 인터셉터 ADMIN 게이트를 우회하고 컨트롤러 게이트 헬퍼(GAME_JAM_MANAGE)로 판정(SUBADMIN+키 통과 — W2-1 선례 그대로, 본 설계 인터셉터 추가 작업 0).
- **부작용**: jam_awards 4트랙 전체 deleteByJamTrack → 재INSERT(단일 트랜잭션). game_reviews/jam_scores/jam_votes write 0(읽기만 — 단방향).
### 결과 조회 계약
- `JamAwardsMapper.listByJam(jamId)` → 트랙별·rank 정렬(idx_jam_awards_jam_track). 컨트롤러가 트랙별로 그룹핑해 JSP 모델 주입.
- 결과 행에 게임 표시정보(이름/썸네일/출품자) 조인 필요 → `listByJamWithGame(jamId)`(JOIN games + jam_entries 표시필드). game_review_stats 등 소스 VIEW 재조회 불요(score_value 가 표시점수 보존).
---
## 인터셉터 / 게이트 연동 (A7 — W2-1 D4-A 재사용, 본 설계 추가 0)
### 게이트 경로 (W2-1 확정 그대로 소비)
- `/admin/jams/{jamId}/awards/compute``/admin/jams/**` 하위 → **W2-1 이 이미 InterceptorConfig 에 `.excludePathPatterns("/admin/jams/**")` 추가함**(W2-1 D4-A). 본 W2-6 은 InterceptorConfig 를 **추가 수정하지 않는다**(W2-1 exclude 가 본 경로를 이미 커버). 인터셉터 우회 후 컨트롤러 게이트 헬퍼가 판정.
- **JamAwardAdminController 진입부 게이트 헬퍼**(W2-1 requireJamManage 패턴 동형):
1. `gate.isAuthenticated(session)` 거짓 → 401(API).
2. `gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` 거짓 → 403.
3. 통과 후 본문.
- **이유**: 잼 산정은 "ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE" 라 권한 키 판정 필요 → `gate.has`(ADMIN 암묵전권 + SUBADMIN 키보유, PermissionGate.java:30-36 직접 확인) 정확히 적합. 커스텀 어노테이션/AOP 는 W1/W2-1 에서 오버엔지니어링으로 기각된 선례 → 동일하게 게이트 헬퍼.
- **중복 0**: 단일 산정 액션이므로 헬퍼는 1회 호출. W2-1 의 requireJamManage 와 동형 private 헬퍼(컨트롤러 내) 또는 W2-1 헬퍼가 공용 컴포넌트면 재사용(구현 시 W2-1 소유자와 협의 — concerns crossRef).
### epoch 전파 연동 (W1 결정4 — 추가 작업 0)
- `gate.has` 내부 `refreshIfStale`(PermissionGate.java:86)가 요청당 epoch 대조 → ADMIN 이 GAME_JAM_MANAGE 부여/회수하면 대상 다음 요청에서 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 0).
---
## 시퀀스 (주요 플로우 의사코드)
### S1. 관리자 시상 산정/재산정 (전체 트랜잭션)
```
[SUBADMIN(+GAME_JAM_MANAGE) 세션] POST /admin/jams/42/awards/compute (CSRF)
→ InterceptorConfig: /admin/jams/** exclude(W2-1) → 인터셉터 미개입
→ JamAwardAdminController.computeAwards(42)
→ requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)? (아니면 401/403)
→ CsrfTokens.isValid(request) (아니면 403 errorBody)
→ jam = jamsMapper.getById(42) (없으면 404)
→ 산정 게이트(F6): jam.status=='CLOSED' OR now() > jam.evalEndAt? (아니면 422)
→ jamAwardService.recompute(jam.id): # @Transactional — 4트랙 원자적
for track in [JUDGE, USER_RATING, POPULAR, GRAND]:
jamAwardsMapper.deleteByJamTrack(jam.id, track) # 멱등 재산정
→ S2/S3 트랙별 산정 → jamAwardsMapper.insert(award) 다건
→ 200 {jamId:42, awardCounts:{JUDGE:n,...}}
# game_reviews/jam_scores/jam_votes write 0 (단방향 읽기).
```
### S2. 3트랙 독립 산정 (JamAwardService 내부 — 각 트랙 모집단·정렬·랭크)
```
trackJudge(jamId):
rows = jamScoreStatsMapper.listStatsByJam(jamId) # [{gameId, weightedTotal, ...}]
# W2-3 F7 동결 VIEW. weightedTotal NULL(채점 0) 행은 모집단 제외(A4)
rows = rows.filter(r -> r.weightedTotal != null)
rank = standardCompetitionRank(rows, key=weightedTotal DESC, tiebreak=gameId ASC)
for r: jamAwardsMapper.insert(award(jamId, r.gameId, 'JUDGE', rank[r], r.weightedTotal))
trackUserRating(jamId):
rows = gameReviewStatsConsumerMapper.listAvgByJam(jamId)
# jam_entries e LEFT JOIN game_review_stats st (st."avgRating"/"reviewCount")
# WHERE e.jam_id=#{jamId} AND e.is_delete<>true AND st.review_count >= 3 (F5 임계)
# ORDER BY st.avg_rating DESC NULLS LAST, e.game_id ASC (F5 정렬)
rank = standardCompetitionRank(rows, key=avgRating DESC, tiebreak=gameId ASC)
for r: jamAwardsMapper.insert(award(jamId, r.gameId, 'USER_RATING', rank[r], r.avgRating))
trackPopular(jamId):
rows = jamVotesMapper.listCountsByJam(jamId) # [{gameId, voteCount}] GROUP BY game_id
# 득표 0 출품작은 GROUP BY 에 안 나옴 → POPULAR rankScore 모집단=득표>0(A4)
rank = standardCompetitionRank(rows, key=voteCount DESC, tiebreak=gameId ASC)
for r: jamAwardsMapper.insert(award(jamId, r.gameId, 'POPULAR', rank[r], r.voteCount))
```
- **standardCompetitionRank**(공통 헬퍼): 정렬 후 동점이면 같은 rank, 다음 순위는 건너뜀(1,2,2,4). tiebreak=gameId ASC 로 결정적(재산정 안정). 이 헬퍼는 jam_awards rank 부여와 GRAND rankScore 산정 양쪽 공유(중복 0).
### S3. GRAND 종합 산정 (순위점수 정규화 가중합 — 난제1)
```
trackGrand(jamId, weights={JUDGE:1/3, USER_RATING:1/3, POPULAR:1/3}):
# 각 트랙의 "수상권 모집단" 위에서 rankScore 계산
judgeScore = rankScoreMap(trackJudge 모집단, key=weightedTotal DESC) # gameId -> (N-rank+1)/N
ratingScore = rankScoreMap(trackUserRating 모집단, key=avgRating DESC)
popularScore = rankScoreMap(trackPopular 모집단, key=voteCount DESC) # 득표>0 만
grandPop = union(모든 트랙 모집단 gameId) # 최소 1트랙 가용 출품작
for gameId in grandPop:
avail = [(judgeScore, JUDGE), (ratingScore, USER_RATING), (popularScore, POPULAR)]
.filter(트랙 모집단에 gameId 존재) # 가용 트랙만(A4 부당 0점 회피)
grand = Σ(score[gameId] × weights[track]) / Σ(weights[track] for 가용) # [1/N..1] 정규화
rank = standardCompetitionRank(grandPop, key=grand DESC, tiebreak=gameId ASC)
for g: jamAwardsMapper.insert(award(jamId, g, 'GRAND', rank[g], grand[g]))
```
- **정규화 논증(난제1)**: rankScore = (N-rank+1)/N 은 트랙 내 상대 순위만 쓰므로 스케일(numeric 1~5 vs vote count 0~수백) 무관. 가용 트랙만 분모에 넣어 트랙 결측이 부당 0점이 되지 않음(F5). 1트랙만 가용한 출품작도 그 트랙 rankScore 로 GRAND 산정(분모=그 트랙 weight) → 제외 안 함.
- **동점(난제3)**: grand 동일 → 같은 rank. game_id ASC tiebreak.
### S4. 공개 결과 페이지
```
[공개] GET /jams/{slug}/results
→ JamAwardController.results(slug)
→ jam = jamsMapper.getBySlug(slug) (없거나 !is_visible → redirect:/jams)
→ awards = jamAwardsMapper.listByJamWithGame(jam.id) # 트랙·rank 정렬 + JOIN games 표시
→ byTrack = group(awards, award_track) # {JUDGE:[...], USER_RATING:[...], ...}
→ model: jam, byTrack(트랙별 랭킹), grand=byTrack.GRAND
→ "jam-results" (미산정이면 빈 맵 → "결과 준비 중" 표시)
```
---
## 파일 영향 맵
> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
> **AW-DOMAIN**(data POJO/JamAwardService 산정 코어/순위점수 헬퍼) · **AW-MAPPER**(JamAwardsMapper + 소비 매퍼 listAvgByJam/listCountsByJam — JamScoreStatsMapper.listStatsByJam 는 W2-4 소유 재사용) · **AW-ADMIN**(JamAwardAdminController 산정 트리거 + 게이트) · **AW-PUBLIC**(JamAwardController 결과 페이지 + JSP) · **AW-DETAIL**(W2-1 JamController.detail 수상요약 모델 주입 — W2-1 협의 수정).
> 의존: W2-3 동결(jam_awards) + W2-4(jam_score_stats) + W2-5(jam_votes) + W3-2(game_review_stats) 선행 → AW-DOMAIN → AW-MAPPER → {AW-ADMIN, AW-PUBLIC, AW-DETAIL}.
| 변경 유형 | 경로 | 역할 | 소유 |
|---|---|---|---|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamAwardData.java` | jam_awards 행 POJO(jamId/gameId/awardTrack/rank/scoreValue/computedAt) + 표시조인(gameName/thumbnailUrl/entrantType — listByJamWithGame 용) | AW-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/AwardTrack.java` | enum JUDGE/USER_RATING/POPULAR/GRAND + isValid(String) (PermissionKeys/JamStatus 패턴) | AW-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamAwardService.java` | 3트랙+GRAND 산정 코어(recompute, 트랙별 모집단·정렬·rankScore·동점). @Transactional 멱등 재산정 | AW-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/RankScores.java` | 순위점수 유틸(standardCompetitionRank + rankScore (N-rank+1)/N). jam_awards rank·GRAND 정규화 공유(중복 0) | AW-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamAwardsMapper.java` | `@Mapper` jam_awards insert/deleteByJamTrack/listByJam/listByJamWithGame(`#{}`, 일반 매퍼 snake→camel 직접 alias) | AW-MAPPER |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamReviewRatingMapper.java` | `@Mapper` USER_RATING 트랙 소비 — jam_entries LEFT JOIN game_review_stats(집계 VIEW → `AS "avgRating"`/`"reviewCount"` 큰따옴표), review_count>=3 + NULLS LAST(`#{}`) | AW-MAPPER |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamVoteCountMapper.java` | `@Mapper` POPULAR 트랙 소비 — jam_votes GROUP BY game_id count(일반 매퍼 직접 alias)(`#{}`). (W2-5 JamVotesMapper 와 별개 or 재사용 — 구현 시 협의, concerns) | AW-MAPPER |
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamAwardAdminController.java` | `/admin/jams/{jamId}/awards/compute` 산정 트리거(게이트 헬퍼 + CSRF + F6 422) | AW-ADMIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamAwardController.java` | 공개 `/jams/{slug}/results`(결과 페이지, RecruitController 읽기 패턴) | AW-PUBLIC |
| 신규 | `src/main/webapp/WEB-INF/views/jam-results.jsp` | 트랙별 랭킹 + GRAND 종합 표 (HtmlUtils.htmlEscape/JSTL escape, textContent) | AW-PUBLIC |
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/JamController.java` | (W2-1 소유) detail 모델에 awardsSummary(GRAND 상위 + 트랙 대상) 주입 추가. **W2-1 소유자 협의 — 본 설계는 모델 키 계약만 명시** | AW-DETAIL(W2-1 협의) |
| 수정 | `src/main/webapp/WEB-INF/views/jam-detail.jsp` | (W2-1 소유) 수상 요약 배지 블록 추가(escape) | AW-DETAIL(W2-1 협의) |
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼(JamAwardsMapper/JamReviewRatingMapper/JamVoteCountMapper) @MockBean 등록(contextLoads 보존, §30) | (검증) |
| 신규 | `src/test/.../JamAwardServiceTest.java` | 3트랙 산정 + GRAND 정규화 + NULL/미달 제외 + 동점 + 멱등 재산정 단위 | (검증) |
| 신규 | `src/test/.../JamAwardAdminControllerTest.java` | 산정 트리거 + 401/403/CSRF/게이트 + F6 422(CLOSED 아님) | (검증) |
| 신규 | `src/test/.../JamAwardControllerTest.java` | 결과 페이지 + 미산정 잼 빈 결과 + 트랙 그룹핑 | (검증) |
> SSR 호출지점 영향(verification §영향맵 SSR 포함): jam-detail.jsp 수정은 awardsSummary 모델 키 **신규 추가**라 기존 키 소비 깨짐 0(빈 잼이면 awardsSummary 빈 컬렉션 → 표시 분기). JamController.detail 모델 추가는 W2-1 소유 컨트롤러 협의 수정 — 기존 모델 키 보존(추가만). game_review_stats VIEW 무변경 → W3-2 리뷰 요약 회귀 0.
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
```java
// JamAwardService — 산정 코어. jamId 만으로 4트랙 소스 조회·산정·기록(최소).
int recompute(long jamId) // 산정/재산정(@Transactional). 반환=총 수상 행수(또는 트랙별 Map)
// RankScores — 순위점수 유틸(트랙 rank·GRAND 정규화 공유). 정렬키 추출은 호출자 제공(제네릭 비교자).
// 최소 인자: 정렬 대상 리스트 + 비교 기준만. (트랙·잼 컨텍스트는 호출자가 알고 있으므로 전달 안 함 — inflate 방지)
<T> Map<T,Integer> standardCompetitionRank(List<T> items, // 산정 대상(트랙 모집단)
Comparator<T> byScoreDesc) // 점수 내림차순+tiebreak
<T> Map<T,Double> rankScore(List<T> items, // 동일 모집단
Comparator<T> byScoreDesc) // (N-rank+1)/N 정규화 — GRAND 가중합 입력
// JamAwardsMapper (@Mapper, #{} only, 일반 매퍼 snake→camel 직접 alias)
int insert(JamAwardData award) // 트랙·순위 수상 기록(재산정 후 다건)
int deleteByJamTrack(long jamId, String track) // 재산정 전 트랙 초기화(멱등)
List<JamAwardData> listByJamWithGame(long jamId) // 결과 페이지(JOIN games 표시 + 트랙·rank 정렬)
// JamReviewRatingMapper (@Mapper, #{} only, 집계 VIEW 소비 → camelCase 큰따옴표 alias)
List<JamReviewRatingRow> listAvgByJam(long jamId) // USER_RATING 모집단(review_count>=3, NULLS LAST)
// JamVoteCountMapper (@Mapper, #{} only, 일반 매퍼 직접 alias)
List<JamVoteCountRow> listCountsByJam(long jamId) // POPULAR 모집단(GROUP BY game_id count)
// (W2-4 소유 재사용) JamScoreStatsMapper.listStatsByJam(long jamId) — JUDGE 모집단(weightedTotal)
```
> ⚠️ inflate 마킹(concern 1): `JamAwardService.recompute` 는 jamId 단일 인자가 정석(소스 조회는 매퍼 주입으로 해결 — jam 객체/세션/actor 를 받지 말 것. 산정은 actor 무관 순수 집계). `RankScores` 의 두 메서드는 `Comparator` 만 받아 트랙/잼 컨텍스트를 끌어오지 않음(헬퍼 inflate 차단). 구현에서 buildAward(...) 헬퍼를 추출한다면 (jamId, gameId, track, rank, scoreValue) 전부 실제 INSERT 에 쓰이는지 재확인(dead parameter 방지). JamReviewRatingRow/JamVoteCountRow 는 필요 필드(gameId + 점수)만 — 6축 컬럼 매핑 금지(단방향 G4, 6축 미사용).
---
## 대안 비교
| 주제 | 안 | 장점 | 단점 | 채택 |
|---|---|---|---|---|
| GRAND 정규화 | (A) 순위점수(rank-score) 가중합 | 트랙 스케일 무관, 이상치 강건, 트랙 간 동일 의미(상대순위), 가용트랙 정규화로 F5 정합 | 절대 점수차 손실(1위-2위 격차 무시) | **채택(A2)** |
| | (B) min-max 정규화 raw 점수 | 점수차 보존 | 단일/소수 출품작 시 0/1 양극단, 이상치 민감, 트랙 내 분포 의존 | 기각 |
| | (C) raw 점수 단순 합 | 단순 | vote count 가 스케일 압도(수백 vs 1~5) → 사실상 인기상=종합 | 기각 |
| NULL/미달 | (A) 트랙 제외 + GRAND 가용트랙 정규화 | 부당 0점 회피(F5 동결), 1트랙만 가용해도 GRAND 산정 | 가용트랙 적은 작품 변동성 | **채택(A4)** |
| | (B) 미달=0점 | 단순 | 리뷰 0개가 GRAND 최하위 강제(부당, F5 위반) | 기각 |
| 동점 | (A) standard competition rank(같은 rank, 1,2,2,4) | 직관적, jam_awards UNIQUE 동률 다행 허용(W2-3 동결) | rank 건너뜀 | **채택(A5)** |
| | (B) dense rank(1,2,2,3) | 연속 rank | UNIQUE(jam_id,track,game_id) 와 무관하나 표시 관례상 competition 이 시상에 자연 | 기각 |
| | (C) tiebreak 강제 유일순위 | UNIQUE 단순 | 동점을 인위 분리(공정성 훼손) | 기각 |
| score_value 채움 | (A) 트랙=raw 점수, GRAND=종합점수 | 결과 단독조회 표시, 재현근거 보존 | 컬럼 의미 트랙별 분기 | **채택** |
| | (B) score_value 전부 NULL(rank 만) | 단순 | 점수 표시·동점 근거 불가, 소스 VIEW 재조회 필요 | 기각 |
| 산정 트리거 | (A) 관리자 수동(GAME_JAM_MANAGE 게이트) | 운영 통제, W2-1 게이트 재사용, 자동훅은 후속 | 수동 1스텝 | **채택(A7)** |
| | (B) eval_end_at 경과 자동 산정 | 무인 | @Scheduled context 영향, 1차 과도(W2-1 자동전이도 후속) | 기각(후속 훅) |
---
## 롤아웃 / 마이그레이션
### 순서
1. **스키마**: 신규 DDL 0. `jam_awards`(W2-3 docs/jam-eval-ddl.sql)·`jam_score_stats`(W2-3 VIEW)·`jam_votes`(W2-3)·`game_review_stats`(W3-2) 가 **선행 적용돼 있어야 함**. apply-local-ddl.sh 알파벳 글롭: game-reviews-ddl(`g`) < jam-eval-ddl(`j...e`) 선존재. W2-6 DDL 추가 0(소비만).
2. **선행 코드 의존**: W2-4(jam_score_stats 채우는 점수 입력) + W2-5(jam_votes 채우는 투표) 배포 후라야 산정이 의미 있음(소스 비면 트랙 모집단 0). **DDL/스키마는 W2-3 동결로 이미 존재**하므로 W2-6 코드는 W2-4/5 코드 배포와 독립 컴파일·배포 가능( 소스면 결과 산정 무해).
3. **코드 배포**: AW-DOMAIN AW-MAPPER AW-ADMIN/AW-PUBLIC. AW-DETAIL(jam-detail 수상요약) W2-1 소유 파일 수정이므로 W2-1 소유자와 동시 PR/협의(concerns crossRef).
4. **권한 시드 불필요**: GAME_JAM_MANAGE 키는 PermissionCatalogVerifier 이미 시드(grounding R-A). 설계는 게이트 소비만.
### 역호환
- 신규 매퍼/컨트롤러/JSP 추가. 기존 games/리뷰/ 동작 불변. jam_awards W2-3 생성한 테이블 미산정 잼은 결과 페이지 "준비 "( 조회).
- jam-detail.jsp 수상요약 추가는 모델 신규( 컬렉션) 기존 표시 회귀 0.
### 롤백
- 코드 롤백: JamAwardService/컨트롤러/JSP 되돌리면 산정·결과 미노출. jam_awards 데이터는 잔존(비파괴) 무해. jam-detail awardsSummary 모델 제거 JSP 분기가 컬렉션 처리하면 안전(W2-1 협의 처리 명시).
- 데이터 롤백: jam_awards 행은 `deleteByJamTrack` 또는 maintenance DELETE 제거 가능( 재산정으로 덮어쓰기 멱등).
---
## AC 매핑
| AC | 요구(골자 W2-6) | 만족 설계 요소 | 비고 |
|---|---|---|---|
| AC-1 | 3트랙 독립 산정(JUDGE/USER_RATING/POPULAR) | JamAwardService trackJudge/trackUserRating/trackPopular + jam_awards 트랙별 insert | A1, S2 |
| AC-2 | JUDGE = jam_score_stats.weighted_total | JamScoreStatsMapper.listStatsByJam 소비, weightedTotal DESC rank | A1, W2-3 F7 |
| AC-3 | USER_RATING = avg_rating(overall) 단방향, review_count>=3, NULLS LAST | JamReviewRatingMapper.listAvgByJam(game_review_stats SELECT 만, 6축 미사용) | A1/A4, W2-3 G4/F5 |
| AC-4 | POPULAR = jam_votes count | JamVoteCountMapper.listCountsByJam(GROUP BY game_id count) | A1, W2-3 F7 |
| AC-5 | GRAND 종합(정규화 가중합, 동점·NULL 규칙) | trackGrand rankScore 정규화 + 가용트랙 가중 + competition rank | A2/A4/A5, S3, 난제1 |
| AC-6 | NULL/미달 트랙 제외(부당 0점 회피) | 트랙별 모집단 필터(채점0/review<3/득표0) + GRAND 가용트랙만 | A4, 난제2 |
| AC-7 | 확정 시점 CLOSED/eval종료 | F6 게이트(status='CLOSED' OR now>eval_end_at) 아니면 422 | A6, S1 |
| AC-8 | 재산정 멱등 | deleteByJamTrack 4트랙 → 재INSERT(@Transactional 단일) | A6, S1 |
| AC-9 | 산정 트리거 = GAME_JAM_MANAGE 게이트 + CSRF | JamAwardAdminController requireJamManage(gate.has) + CsrfTokens.isValid | A7, §게이트연동 |
| AC-10 | 결과 표시(상세 수상 + 결과 페이지 트랙별+종합) | /jams/{slug}/results(jam-results JSP) + jam-detail awardsSummary | A8, S4 |
| AC-11 | 시상 SQL `${}` 0 | 신규 3매퍼 `#{}` only | §파일영향맵 |
| AC-12 | 집계 VIEW 매퍼 alias 큰따옴표 | JamReviewRatingMapper(game_review_stats)·JamScoreStatsMapper(jam_score_stats) AS "avgRating"/"weightedTotal" | §파일영향맵, verification §33 |
| AC-13 | 단방향(리뷰/심사/투표 write 0) | 산정은 4소스 SELECT + jam_awards write 만. game_reviews/jam_scores/jam_votes 무변경 | G3, S1 |
---
## 검증 포인트 (verification-advisor 점검 대상)
> L레벨 매핑(verification-strategies): 산정 알고리즘(3트랙·GRAND·NULL·동점·멱등) = **L1**(JamAwardServiceTest 단위) + 3트랙 join/정렬/NULL = **L2(dev DB contract)**. 인가/게이트/F6 422 = **L1+L3**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30).
### 시나리오 검증
- **VP-1 (AC-1~5 산정 코어, L1)**: JamAwardServiceTest — ① 3트랙 각 정렬·rank 정확(weightedTotal/avgRating/voteCount DESC) ② GRAND rankScore (N-rank+1)/N + 가용트랙 가중합 정확 ③ 1트랙만 가용 출품작이 GRAND 에 포함(분모=그 트랙 weight) ④ 모킹 소스로 결정적.
- **VP-2 (AC-6 NULL/미달, L1+L2)**: 리뷰 0개(avg_rating NULL) 출품작 → USER_RATING 트랙 제외(rank 없음). review_count==3 경계 포함, ==2 제외. 채점 0 출품작 JUDGE 제외. 득표 0 출품작 POPULAR rankScore 모집단 제외. GRAND 는 그래도 가용트랙으로 산정. dev DB contract 샘플 실측.
- **VP-3 (AC-5 동점, L1)**: weightedTotal 동일 2작품 → 같은 rank(competition 1,2,2,4), tiebreak game_id ASC 결정적. GRAND 동점도 동일. 재산정 시 같은 결과(멱등).
- **VP-4 (AC-8 멱등, L1+L2)**: recompute 2회 호출 → jam_awards 행이 중복 누적 아님(deleteByJamTrack 선행). UNIQUE(jam_id,track,game_id) 위반 없음. 부분 실패 시 트랜잭션 롤백(트랙 비는 상태 회피).
- **VP-5 (AC-7/9 게이트·F6, L1+L3)**: JamAwardAdminControllerTest — ADMIN 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401 / CLOSED 아님+eval진행중 422 / CSRF 누락 403(mapper 미호출). L3 스모크: CLOSED 잼 산정 → 결과 페이지 노출.
- **VP-6 (AC-12/13 alias·단방향, L2)**: JamReviewRatingMapper 반환 키 avgRating/reviewCount 정합(game_review_stats 큰따옴표 케이스폴딩 BUG-2 선례 회피, GameReviewStatsMapper.java:14 `AS "avgRating"` 동형). 산정 SQL 이 game_reviews/jam_scores/jam_votes 에 write 0(SELECT 만). 6축 컬럼 미참조.
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 3매퍼 @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException.
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
> self-audit(시점): 아래 카운트는 **본 W2-6 이 신규 생성하는 정적 산출물**(트랙 enum·매퍼·산정 트랙 경로) 또는 **W2-3 동결 불변식**(jam_awards CHECK 트랙값)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. jam_awards 스키마는 W2-3 동결(소유 분리) 이므로 본 verification 시점에 트랙 4값 불변.
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 enum 멤버/CHECK IN 목록/트랙 산정 메서드 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴이 정당).
- **AC-T1 시상 트랙 전수 4종 산정 경로 정합 불변식** — `AwardTrack` enum 멤버 수 == jam_awards_track_check CHECK IN 항목 수(W2-3 동결) == JamAwardService 가 insert 하는 award_track 집합 == 4(JUDGE/USER_RATING/POPULAR/GRAND). 검증: enum 멤버 `grep -c` == 4 AND JamAwardService 에 trackJudge/trackUserRating/trackPopular/trackGrand 4 산정 경로 전수 존재(수동 판정: 각 트랙이 jamAwardsMapper.insert(award_track=...) 호출). 트랙 추가/누락을 갯수 1로 동시 커버. **이 전수 AC 가 3트랙+GRAND 완전성의 핵심 가드**(1트랙 누락 시 시상 결과 불완전).
- **AC-T2 산정 소스 매퍼 전수 4종 SELECT-only 단방향 불변식** — 4트랙 소스(jam_score_stats/game_review_stats/jam_votes + jam_entries 모집단)는 본 산정에서 **읽기만**: 신규 산정 매퍼(JamReviewRatingMapper/JamVoteCountMapper)+소비(JamScoreStatsMapper.listStatsByJam) 에 INSERT/UPDATE/DELETE 가 game_reviews/jam_scores/jam_votes 대상으로 0건. 검증: `grep -iE 'INSERT|UPDATE|DELETE' <산정 소스 매퍼들>` 중 game_reviews/jam_scores/jam_votes 대상 0(jam_awards 대상 INSERT/DELETE 만 허용). 단방향(G4/AC-13) 위반 즉시 검출.
- **AC-T3 시상 신규 매퍼 전수 3개 `${` 0건** — 신규 매퍼 3파일(JamAwardsMapper/JamReviewRatingMapper/JamVoteCountMapper)에 `${` 매치 0: `grep -rc '\${' <매퍼 3파일>` == 0 (AC-11, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
- **AC-T4 집계 VIEW 소비 매퍼 alias 큰따옴표 전수** — 집계 VIEW 를 소비하는 매퍼 전수(JamReviewRatingMapper→game_review_stats, JamScoreStatsMapper→jam_score_stats)가 camelCase alias 를 큰따옴표로 감쌈: 해당 매퍼들의 VIEW 컬럼 alias 가 `AS "avgRating"`/`AS "reviewCount"`/`AS "weightedTotal"` 형태(케이스 폴딩 회피, GameReviewStatsMapper.java:14 선례). 검증: 집계 VIEW 컬럼 alias 전수가 큰따옴표 — 일반 테이블 매퍼(JamAwardsMapper)는 반대로 직접 alias(scoreValue) 사용 확인(혼용 금지). 수동 판정(VIEW vs 테이블 구분 필요 — 리터럴 grep 단독 회피).
- **AC-T5 신규 시상 매퍼 @MockBean 전수 3건** — BibimbapApplicationTests 에 신규 3 매퍼(JamAwardsMapper/JamReviewRatingMapper/JamVoteCountMapper) @MockBean 전수 등록: contextLoads PASS AND 매퍼별 등록 수동 확인(또는 `grep -c '@MockBean' BibimbapApplicationTests.java 증가분 == 3`). 1건 누락 시 contextLoads NoSuchBeanDefinitionException 으로 verification 시점 즉시 검출(§30).
- **AC-T6 jam_awards 단방향 소비 무결성(신규 DDL 0)** — 본 W2-6 이 신규 DDL 파일 0건 추가: `docs/` 에 W2-6 신규 *-ddl.sql 0개(jam_awards 는 W2-3 docs/jam-eval-ddl.sql 소유). 검증: 본 워크스트림 산출물에 신규 docs/*-ddl.sql 0, db/schema.sql 변경 0(jam_awards 재정의 금지). W2-3 동결 스키마를 W2-6 이 변형하지 않음(소유 경계) 확인.
---
## 잔여 오픈 질문
없음(0). 확정 결정 A1~A8 전제 고정, 세 난제(GRAND 순위점수 정규화·NULL/미달 트랙 제외 산정위치·동점 competition rank)는 본 설계가 구체 메커니즘으로 확정. score_value 트랙별 채움 규약·산정 게이트(F6 422)·재산정 멱등(@Transactional deleteByJamTrack)도 확정. 구현 점검 항목(시그니처 inflate·full-test @MockBean·집계 VIEW alias L2·임계/가중치 상수 가변화·@Transactional 경계·W2-1 jam-detail 협의 수정·JamVoteCountMapper vs W2-5 JamVotesMapper 재사용)은 오픈 질문이 아니라 `concerns` 로 이관.