42 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-6-시상 집계/결과 |
|
true |
|
|
설계: 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_totalDESC (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_votescount DESC (W2-5 산출, W2-3 F7 동결 count 계약).
- JUDGE =
- G2 GRAND 종합 (FR-W2-6 종합상): 3트랙 정규화 점수 가중합으로 종합 랭킹 산정 →
jam_awardsaward_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_statsVIEW 를 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 동결 — 재정의 금지, 인용만)
-- 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 패턴 동형):
gate.isAuthenticated(session)거짓 → 401(API).gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())거짓 → 403.- 통과 후 본문.
- 이유: 잼 산정은 "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 방지)
// 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 자동전이도 후속) | 기각(후속 훅) |
롤아웃 / 마이그레이션
순서
- 스키마: 신규 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(소비만). - 선행 코드 의존: W2-4(jam_score_stats 채우는 점수 입력) + W2-5(jam_votes 채우는 투표) 배포 후라야 산정이 의미 있음(소스 비면 트랙 모집단 0). 단 DDL/스키마는 W2-3 동결로 이미 존재하므로 W2-6 코드는 W2-4/5 코드 배포와 독립 컴파일·배포 가능(빈 소스면 빈 결과 산정 — 무해).
- 코드 배포: AW-DOMAIN → AW-MAPPER → AW-ADMIN/AW-PUBLIC. AW-DETAIL(jam-detail 수상요약)은 W2-1 소유 파일 수정이므로 W2-1 소유자와 동시 PR/협의(concerns crossRef).
- 권한 시드 불필요: 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종 산정 경로 정합 불변식 —
AwardTrackenum 멤버 수 == 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 로 이관.