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-4-심사위원 평가(점수 입력/집계 소비) |
|
true |
|
|
설계: W2-4 — 심사위원 평가 (점수 입력 API + 3중 게이트 + criterion 단위 채점 + 가중 집계 소비)
⚠️ W2-3 동결 스키마 소비 워크스트림. 본 설계는 신규 테이블·VIEW 를 만들지 않는다. W2-3 이 동결한
jam_criteria/jam_scores/jam_score_stats(VIEW)를 그대로 소비하고, API·로직·게이트·매퍼만 설계한다. 동결 스키마 재정의 금지(W2-3 권위).
목표 / 비목표
목표 (FR/NFR 추적 — 골자 W2-4 + 확정 결정)
- G1 점수 입력 API: 심사위원이 출품작(
(jam_id, game_id)자연키)에 jam_criteria 기준별 점수(1~5)를 입력.jam_scoresUPSERT(criterion 단위 멱등 재입력). - G2 3중 진입 게이트(정석 보안): ① 심사위원 자격 =
JamRoleGate.isJudge(session, jamId)(W2-2) ② 평가기간 게이트 =jam.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at](W2-3 F6) ③ 자기출품 충돌 = 심사위원이 자기 출품작에 채점 불가(W2-2 충돌규칙). + 상태변경 전수 CSRF. - G3 집계 소비: 심사 집계 =
jam_score_statsVIEW(criterion별 평균의 가중 종합weighted_total+ 비가중simple_total+ scored_criteria + judge_count). criterion별 펼침 평균은 jam_scores 직접 GROUP BY 매퍼(VIEW 는 종합 1행만 — W2-3 결정). 심사위원 평균·동률 처리 명시. - G4 수정/재입력: 평가기간 내 점수 수정 허용(UPSERT → updated_at 갱신). 평가 종료(EVAL 이탈 or now>eval_end) 후 입력·수정 잠금(422).
- G5 부분 입력·미채점 처리: 심사위원이 일부 criterion 만 입력(부분입력) 허용 — 미입력 criterion 은 jam_scores 행 부재 → 집계에서 그 criterion 제외(W2-3 VIEW per_criterion 선집계). 점수 미입력 criterion·심사위원 부분입력의 집계 의미 명시.
- G6 본인 입력 현황 조회: 심사위원이 본인이 출품작에 매긴 점수 현황 조회(폼 prefill·수정용).
- NFR: 상태변경 CSRF 전수,
#{}바인딩(${}금지), 입력 sanitize(score 범위·criterion_key 화이트리스트), 임시 role 직접체크 금지(W1/W2-2 게이트 위), 신규 테이블 0(동결 소비).
비목표 (스코프 밖)
- 심사 스키마 정의(jam_criteria/jam_scores/jam_score_stats DDL) — W2-3 동결 소유. 본 설계는 소비만(DDL 0).
- 심사위원 자격 판정·지정·자기출품 충돌규칙 본체(
JamRoleGate.isJudge/ jam_judges) — W2-2 소유. 본 설계는 게이트를 호출만. - 잼 심사 기준(jam_criteria) 등록 UI·CRUD — 관리자 잼 설정의 일부. 본 설계는 criterion 조회(점수 폼 라벨·검증)만 소비하고, criterion 등록 API 는 W2-1 관리자 콘솔 확장 또는 별도 — 본 설계 비목표(아래 §criterion 등록 소유 결정).
- 인기투표(W2-5), 시상 집계 산정(W2-6) — 별도. 본 설계는 jam_score_stats 를 W2-6 JUDGE 트랙이 소비하도록 제공만.
- 점수 입력 이력 감사 로그(누가 언제 무엇을) — 1차는 jam_scores.updated_at 으로 최종 상태만. 변경 이력 테이블은 후속(선택, 아래 §대안).
- 잼 평가단위 = jam_entries.id vs (jam_id,game_id) 재논의 — W2-3 동결(F8: (jam_id,game_id) 자연키) 그대로 채택. 재정의 안 함.
개요
bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation @Mapper(#{} only) + JSP 스택이다. W2-3 이 잼 평가 스키마를 동결했다 — jam_criteria(잼별 설정형 심사 기준, criterion_key/weight) + jam_scores(criterion 단위 점수 1~5, UNIQUE(jam_id,game_id,judge_user_id,criterion_key)) + jam_score_stats(출품작 단위 가중 종합 VIEW, fan-out 방지 선집계). W2-1 이 jams(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + jam_entries(잼당 game 활성 UNIQUE = 출품작)를 제공한다. W1 RBAC 의 PermissionGate.has(session, key)(2인자, PermissionGate.java:22 직접 확인) + epoch 전파(refreshIfStale, PermissionGate.java:86) + CsrfTokens.isValid(request)(CsrfTokens.java:35 직접 확인)가 인프라로 완비됐다.
본 설계는 심사위원이 출품작에 criterion 단위 점수를 입력하는 API + 3중 진입 게이트 + 집계 소비 매퍼를 설계한다. 신규 테이블·VIEW 는 0(W2-3 동결 소비). 컨트롤러 패턴은 RecruitController(읽기=JSP 뷰이름 반환, 쓰기=ResponseEntity<Map<String,Object>> status/message + CSRF, RecruitController.java:140,186 직접 확인)를 따른다.
확정된 정석 결정(전제 — 재논의 금지, orchestrator 확정):
- 점수 입력 단위 = criterion 단위 UPSERT :
jam_scores의 UNIQUE(jam_id,game_id,judge_user_id,criterion_key) 위에 ON CONFLICT UPSERT. 한 출품작에 여러 criterion 을 한 요청으로 batch 입력하되 각 criterion 은 멱등 upsert. - 3중 게이트(앱계층) : isJudge(W2-2) + 평가기간(W2-3 F6) + 자기출품 충돌(W2-2) + CSRF. DB CHECK 는 score 범위·UNIQUE 만 강제(시각·자격은 런타임).
- 집계 = jam_score_stats VIEW(종합) + jam_scores GROUP BY(criterion 펼침) : VIEW 는 출품작 종합 1행(W2-3 결정), criterion별 평균 펼침은 W2-4 매퍼가 jam_scores 직접 집계.
- 수정 = 평가기간 내 UPSERT, 종료 후 잠금 : 평가기간 게이트가 입력·수정 양쪽을 막음(같은 게이트).
가장 까다로운 세 난제 확정:
- 난제1 (3중 게이트 순서·401/403/422 정합): 게이트는 CSRF → 인증(401) → isJudge 자격(403) → 평가기간(422) → 출품작 존재(404) → 자기출품 충돌(422) → criterion_key 화이트리스트(422) 순으로 평가한다(아래 §게이트 연동 확정). 인증 실패=401, 인가(심사위원 아님)=403, 도메인 상태 위반(기간 외/자기출품/미등록 criterion)=422 — W1-design/W2-1/W2-3 의 401≠403≠422 정책과 일치. 순서 근거: 싼 검사(CSRF/세션)·보안 경계(자격) 먼저, DB 조회 필요한 검사(출품작/criterion) 뒤.
- 난제2 (부분 입력·미채점 집계 의미): 심사위원은 criterion 일부만 입력 가능(부분입력). 미입력 criterion 은 jam_scores 행 자체가 없음 → W2-3
jam_score_statsVIEW 의per_criterion선집계가 채점된 criterion 만 가중 종합에 반영(미채점 criterion 은 자동 제외, weight 분모에서도 빠짐). 따라서 "심사위원 A 가 immersion 만 채점" 해도 종합은 immersion 가중으로만 계산 — 부분입력이 종합을 왜곡하지 않고 채점된 범위로만 산정된다. 출품작별judge_count(criterion별 DISTINCT judge 의 MAX)로 심사 참여 규모를 노출해 부분참여를 가시화. - 난제3 (동률·심사위원 평균 처리): 출품작 종합점수(
weighted_total) 동률은 DB 가 강제하지 않음(VIEW 는 산정만). 동률 정렬 tie-break 는 소비처(W2-4 정렬 목록·W2-6 JUDGE 트랙 rank)가 2차 키(game_id ASC 또는 judge_count DESC)로 결정론적 정렬. 본 설계 정렬 목록은ORDER BY weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC(미채점 출품작 말단, 심사 많은 작품 우선, 최종 id tie-break). 시상 rank 부여(동률 시 같은 rank)는 W2-6 소관 — 본 설계는 종합 점수 + 결정론 정렬만 제공.
핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 소비)
| 결정 | 확정값 | 본 설계의 구체화 |
|---|---|---|
| S1 입력 API | POST/PUT /jams/{jamId}/games/{gameId}/scores |
criterion별 점수(1~5) batch UPSERT. jamId path = 게이트 스코프, gameId path = 출품작 |
| S2 진입 게이트 | isJudge + 평가기간 + 자기출품충돌 + CSRF | 앱계층 3중(+CSRF). isJudge/충돌=W2-2, 기간=W2-3 F6 |
| S3 점수 저장 | jam_scores UPSERT(ON CONFLICT) | UNIQUE(jam_id,game_id,judge_user_id,criterion_key) 위 멱등. updated_at 갱신 |
| S4 집계 | jam_score_stats VIEW(종합) + jam_scores GROUP BY(펼침) | VIEW=가중종합 1행(W2-3), criterion 펼침=W2-4 매퍼. 큰따옴표 alias |
| S5 수정/잠금 | 평가기간 내 수정, 종료 후 잠금 | 입력·수정 동일 게이트(평가기간). EVAL 이탈/now>eval_end → 422 |
| S6 부분입력 | 일부 criterion 입력 허용 | 미입력=행 부재→집계 제외(VIEW per_criterion). judge_count 로 가시화 |
| S7 동률/평균 | VIEW 산정 + 소비처 결정론 정렬 | weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC |
| S8 criterion_key | jam_criteria 논리참조(앱계층 화이트리스트) | 입력 키가 잼 criteria 에 실재해야 점수 수용(미등록→422) |
데이터 모델 (DDL — 신규 0. W2-3 동결 스키마 소비)
본 설계는 DDL 을 작성하지 않는다. W2-3
docs/jam-eval-ddl.sql(권위) 의jam_criteria/jam_scores/jam_score_stats(VIEW)를 그대로 소비한다. 아래는 소비 형태 확인용 동결 스키마 요약(W2-3 동결 — 재정의 금지).
소비하는 동결 테이블/뷰 (W2-3 권위 — 변경 0)
| 객체 | 종류 | 본 설계 소비 컬럼 | 동결 제약(의존) |
|---|---|---|---|
jam_criteria |
테이블 | jam_id / criterion_key / display_name / sort_order / weight | ux_jam_criteria_jam_key(잼 내 키 UNIQUE) — criterion 화이트리스트·라벨 소스 |
jam_scores |
테이블 | jam_id / game_id / judge_user_id / criterion_key / score / updated_at | ux_jam_scores_jam_game_judge_criterion(UPSERT ON CONFLICT 타깃) + score_check(1~5) |
jam_score_stats |
VIEW | jam_id / game_id / weighted_total / simple_total / scored_criteria / judge_count | per_criterion 선집계(fan-out 방지) + NULLIF 0-division 가드 |
- 신규 0 확인: 본 W2-4 는 테이블·VIEW·컬럼·인덱스·CHECK 를 추가하지 않는다.
docs/*-ddl.sql신규 파일 0,db/schema.sql수정 0. (점수 입력 이력 감사 테이블은 비목표 — 후속.) - criterion 등록 소유 결정: 본 설계는 jam_criteria 를 읽기만(화이트리스트·라벨). criterion 등록(INSERT) API 는 W2-1 관리자 잼 CRUD(
/admin/jams/{jamId}확장) 또는 별도 워크스트림 소유 — 본 W2-4 비목표. W2-3 동결 매퍼 시그니처JamCriteriaMapper.insertCriterion은 등록 소유자가 구현(본 설계는listByJam만 소비).
외부 계약 (API)
공통: 모든 상태변경은
CsrfTokens.isValid(request)검증(없으면 403 +CsrfTokens.errorBody(), CsrfTokens.java:35,51 직접 확인). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=ResponseEntity<Map<String,Object>>(status/message, RecruitController.java:186 직접 확인). 점수 입력은 컨트롤러 진입부에서 3중 게이트(isJudge + 평가기간 + 자기출품) 통과 후 본문 수행.
401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 과 일치 — 본 설계 준수)
- 미인증(세션
userId없음): API 401 JSON{status:401, message:"로그인이 필요합니다."}. 점수 폼 페이지(인증 필요)는redirect:/login. - 인증·미인가(심사위원 아님 =
isJudgefalse): 403 JSON{status:403, message:"심사 권한이 없습니다."}(리다이렉트 금지). - 도메인 상태 위반(평가기간 외 / 자기출품 충돌 / 미등록 criterion / score 범위 외): 422 JSON
{status:422, message:<구체사유>}. 인가는 됐으나 도메인 규칙 위반 → 403 아님 422(W2-3 §401vs403 정책 일치). - 출품작 없음(jam_entries 활성행 부재 또는 game 없음): 404.
- CSRF 실패: 403 +
CsrfTokens.errorBody().
점수 입력/수정 (상태변경 API — CSRF + 3중 게이트)
| 액션 | method | path | 요청 body | 응답(200) | 에러 |
|---|---|---|---|---|---|
| 점수 입력/수정 | POST | /jams/{jamId}/games/{gameId}/scores |
{"scores":[{"criterionKey":"...","score":1~5}, ...]} |
{status:200, message, jamId, gameId, savedCount} |
401(미인증), 403(CSRF/심사권한), 404(잼·출품작 없음), 422(평가기간 외/자기출품/미등록 criterion/score 범위) |
| 점수 입력/수정(멱등 별칭) | PUT | /jams/{jamId}/games/{gameId}/scores |
(POST 와 동일) | (POST 와 동일) | (동일) |
- POST vs PUT 둘 다 채택 근거(orchestrator 확정 "POST/PUT"): UPSERT 의미는 PUT(멱등 전체 교체)에 정합하나, 기존 RecruitController 등 상태변경이 전부 POST + ResponseEntity JSON 패턴이고 JSP form 은 PUT 미지원(method 오버라이드 필요)이다. 따라서 POST 를 1차 경로(JSP form 호환), PUT 을 동일 핸들러로 추가 매핑(REST 멱등 의도 명시, API 클라이언트용)한다. 두 매핑은 같은 컨트롤러 메서드(
@RequestMapping(method={POST,PUT}))로 동작 동일 — 중복 0. - batch 입력 채택 근거: 한 출품작에 criterion 이 여러 개(예: 4개)이고 심사위원은 보통 한 화면에서 전부 매긴다. criterion별 단건 요청은 round-trip N배 + 부분 실패 정합 복잡. body 배열 1요청으로 트랜잭션 내 전 criterion UPSERT(부분입력 시 입력된 것만 배열에 포함).
- savedCount: 실제 UPSERT 된 criterion 수(입력 배열 길이와 검증 통과 수 일치 — 미등록 criterion 이 하나라도 있으면 전체 422 거부, 부분 저장 안 함 = 트랜잭션 원자성).
- 자기 채점 금지(자기출품 충돌, W2-2): 심사위원이 자신이 출품한(개인 entrant_user_id==judge 또는 팀 멤버) 출품작에 채점 시도 → 422
{message:"자기 출품작은 심사할 수 없습니다."}. 충돌 판정은 W2-2 규칙 위임(아래 §게이트 연동).
점수 조회 (읽기 — 심사위원 본인 현황 / 집계)
| 액션 | method | path | 권한 | 응답 |
|---|---|---|---|---|
| 본인 입력 현황 | GET | /jams/{jamId}/games/{gameId}/scores/mine |
isJudge | {status:200, scores:[{criterionKey, score}], criteria:[{criterionKey, displayName, sortOrder, weight}]} (폼 prefill·수정용) |
| 출품작 심사 집계 | GET | /jams/{jamId}/scores/summary |
공개 또는 관리자(아래 노출시점 결정) | {status:200, stats:[{gameId, weightedTotal, simpleTotal, scoredCriteria, judgeCount}]} (정렬 적용) |
- 집계 노출 시점 결정(정석): 심사 진행 중(EVAL) 실시간 집계 노출은 심사위원 간 점수 동조(anchoring) 편향을 유발한다. 따라서
/jams/{jamId}/scores/summary는jam.status='CLOSED'또는 now()>eval_end_at 이후에만 공개(EVAL 중에는 GAME_JAM_MANAGE 보유 관리자만 조회 — 운영 모니터링). 미개방 시 비관리자는 403/빈 결과. (W2-6 시상 집계도 CLOSED 이후 — W2-3 F6 정합.) - 본인 현황(
/scores/mine)은 EVAL 중에도 isJudge 본인에게 허용(자기 입력 prefill — 동조 편향 무관, 본인 점수만).
게이트 진입 계약 (점수 입력 — 본 설계 핵심)
- 입력:
HttpServletRequest(CSRF) +HttpSession(userId/role/permissions) + path(jamId, gameId) + body(scores). - 출력: 200(저장) 또는 401/403/404/422.
- 판정 순서(난제1): CSRF → 인증 → isJudge(자격, 403) → 잼 조회(404) → 평가기간(422) → 출품작 존재(404) → 자기출품 충돌(422) → criterion_key 화이트리스트·score 범위(422) → UPSERT.
인터셉터 / 게이트 연동 (S2 — 3중 게이트, 앱계층)
인터셉터 미개입 (확정)
- 점수 경로
/jams/**는 RbacInterceptor 등록 대상(/admin/**)이 아니다(InterceptorConfig.java:18-20, grounding R-A 직접 확인). 따라서 인터셉터는 점수 입력에 개입하지 않고, 게이트는 전부 컨트롤러 진입부 앱계층에서 수행한다(W2-1 D4-A "소비 액션=게이트 헬퍼" 선례와 동형). 임시 role 직접체크 금지 — 자격은JamRoleGate.isJudge, 권한은 W1PermissionGate(관리자 집계 노출 시).
3중 게이트 구성 (각 게이트의 소유·시그니처)
- 심사위원 자격 (W2-2 소유 — 호출만):
jamRoleGate.isJudge(session, jamId)→ false 면 403. 시그니처는 orchestrator 확정값(isJudge(session, jamId)). 본 설계는 이 메서드를 호출만 하고 구현하지 않는다(concern 1 — W2-2 미설계 시점, 구현 단계 시그니처 재확인). - 평가기간 (W2-3 F6 계약 — 본 설계가 검증 로직 구현):
jam.status == 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]→ 위반 시 422.JamEvalWindow헬퍼(본 설계 신규, 아래 §파일영향맵)가 JamData 의 status/eval_start_at/eval_end_at 로 판정. eval_start_at/eval_end_at NULL 이면 "기간 미설정" → 422(EVAL 인데 기간 미설정은 운영 오류). - 자기출품 충돌 (W2-2 규칙 — 호출/위임): 심사위원이 자기 출품작(개인 entrant 본인 또는 팀 멤버) 채점 금지. 충돌 판정 책임은 W2-2 — 본 설계는 두 경로 중 하나를 호출(concern 1):
- (a) W2-2 가
isJudge에 자기출품 제외를 포함하면 → 별도 호출 불필요(isJudge 가 자기 출품작 게임에 대해 false 반환하도록 jamId+gameId 받는 변형이 필요할 수 있음 — 시그니처 재확인). - (b) W2-2 가 충돌을 별도 메서드(
isOwnEntry(jamId, gameId, userId)등)로 두면 → 본 설계가 isJudge 통과 후 그 메서드 호출해 422. - 본 설계 기본 가정: (b) 별도 충돌 검사. isJudge 는 "잼 심사위원인가"(스코프 자격)만, 자기출품 충돌은 출품작 단위라 gameId 가 필요하므로 분리가 자연스럽다. 구현 시 W2-2 산출과 정합(concern 1).
- (a) W2-2 가
- epoch 전파 연동(W1 결정4): 관리자 집계 노출 게이트에서
PermissionGate.has(session, GAME_JAM_MANAGE)사용 시refreshIfStale(PermissionGate.java:86)로 요청당 epoch 대조 → 권한 부여/회수 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0). isJudge 의 즉시성은 W2-2 소관. - 중복 0: 점수 입력 메서드(POST/PUT 공용) 진입부의 게이트 시퀀스를 private 헬퍼
requireScoringAllowed(session, request, jamId, gameId)로 단일화 — 401/403/404/422 응답 작성 포함.
시퀀스 (주요 플로우 의사코드)
S1. 심사위원 점수 입력/수정 (3중 게이트 → batch UPSERT)
[심사위원 세션] POST /jams/42/games/777/scores (CSRF, {scores:[{immersion:5},{fun:4}]})
→ JamScoringController.submitScores(jamId=42, gameId=777, body)
→ CsrfTokens.isValid(request) (아니면 403 errorBody)
→ userId = sessionUserId(session) (없으면 401)
→ jamRoleGate.isJudge(session, 42)? (아니면 403 "심사 권한 없음") [W2-2]
→ jam = jamsMapper.getById(42) (없으면 404) [W2-1 매퍼 소비]
→ JamEvalWindow.isOpen(jam, now)? (아니면 422 "평가 기간 아님") [W2-3 F6]
→ jamEntriesMapper.exists(42, 777)? (아니면 404 "출품작 아님") [W2-1 매퍼 소비]
→ 자기출품 충돌(W2-2): isOwnEntry(42, 777, userId)? (참이면 422 "자기 출품작 심사 불가") [W2-2]
→ criteria = jamCriteriaMapper.listByJam(42) # 화이트리스트·라벨 [W2-3 매퍼 소비]
→ for each {criterionKey, score} in body.scores:
criterionKey ∈ criteria.keys? (아니면 422 "미등록 기준") [S8]
1 <= score <= 5? (아니면 422 "점수 범위")
# 검증 전수 통과 후 트랜잭션 내 일괄 UPSERT(원자성 — 부분저장 안 함)
→ @Transactional:
for each {criterionKey, score}:
jamScoresMapper.upsertScore(42, 777, userId, criterionKey, score)
# INSERT ... ON CONFLICT (jam_id,game_id,judge_user_id,criterion_key)
# DO UPDATE SET score=EXCLUDED.score, updated_at=now()
→ 200 {jamId:42, gameId:777, savedCount:N}
# jam_score_stats VIEW 가 다음 집계 조회 시 자동 반영(읽기 시점 계산).
S2. 본인 입력 현황 조회 (폼 prefill / 수정)
[심사위원] GET /jams/42/games/777/scores/mine
→ JamScoringController.myScores(42, 777)
→ userId = sessionUserId (없으면 401)
→ jamRoleGate.isJudge(session, 42)? (아니면 403)
→ mine = jamScoresMapper.listByJudge(42, 777, userId) # 본인 criterion별 점수
→ criteria = jamCriteriaMapper.listByJam(42) # 라벨·순서·가중
→ 200 {scores: mine, criteria}
# 미입력 criterion 은 scores 에 부재 → 폼은 criteria 전체 표시 + 입력값만 prefill(부분입력 가시화).
S3. 심사 집계 조회 (CLOSED 이후 공개 / EVAL 중 관리자만)
[조회자] GET /jams/42/scores/summary
→ JamScoringController.summary(42)
→ jam = jamsMapper.getById(42) (없으면 404)
→ 노출 게이트:
jam.status=='CLOSED' OR now()>jam.eval_end_at → 공개 허용
else (EVAL 진행 중) → PermissionGate.has(session, GAME_JAM_MANAGE)? (아니면 403/빈) [W1]
→ stats = jamScoreStatsMapper.listStatsByJam(42) # jam_score_stats VIEW
# ORDER BY weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC (난제3 결정론)
→ 200 {stats}
# W2-6 JUDGE 트랙이 같은 VIEW 의 weighted_total DESC 를 rank 소스로 소비(crossRefs).
파일 영향 맵
소유권 분할 가이드(implementation-advisor worker 단위 후보): K-DOMAIN(JamEvalWindow + data POJO) · K-MAPPER(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper — W2-3 동결 시그니처 구현) · K-CTRL(JamScoringController + JSP, 3중 게이트) · K-TEST(@MockBean + 게이트/UPSERT/집계 테스트). 의존: W2-3 동결(DDL) + W2-2 게이트(JamRoleGate) + W2-1 매퍼(JamsMapper.getById/JamEntriesMapper.exists) 선행. K-DOMAIN → K-MAPPER → K-CTRL.
| 변경 유형 | 경로 | 역할 | 소유 |
|---|---|---|---|
| 신규 | src/main/java/com/pandoli365/bibimbap/jam/JamEvalWindow.java |
평가기간 게이트 판정(status='EVAL' AND now∈[eval_start,eval_end]). W2-3 F6 계약 구현 | K-DOMAIN |
| 신규 | src/main/java/com/pandoli365/bibimbap/data/JamCriterionData.java |
jam_criteria 행 POJO(criterionKey/displayName/sortOrder/weight) — 폼 라벨·화이트리스트 | K-DOMAIN |
| 신규 | src/main/java/com/pandoli365/bibimbap/data/JamScoreData.java |
jam_scores 행 POJO(criterionKey/score[/judgeUserId/updatedAt]) — 본인 현황 | K-DOMAIN |
| 신규 | src/main/java/com/pandoli365/bibimbap/mapper/JamCriteriaMapper.java |
@Mapper listByJam(jamId)(#{}, snake→camel 직접 alias). W2-3 동결 시그니처. insertCriterion 은 등록 소유자 소관(본 설계 미구현 가능 — listByJam 만) |
K-MAPPER |
| 신규 | src/main/java/com/pandoli365/bibimbap/mapper/JamScoresMapper.java |
@Mapper upsertScore(ON CONFLICT) + listByJudge(#{}, snake→camel) |
K-MAPPER |
| 신규 | src/main/java/com/pandoli365/bibimbap/mapper/JamScoreStatsMapper.java |
@Mapper listStatsByJam(jam_score_stats VIEW — 집계 VIEW → 큰따옴표 alias) |
K-MAPPER |
| 신규 | src/main/java/com/pandoli365/bibimbap/controller/JamScoringController.java |
점수 입력(POST/PUT)/본인현황(GET)/집계(GET). 3중 게이트 헬퍼 requireScoringAllowed | K-CTRL |
| 신규 | src/main/webapp/WEB-INF/views/jam-scoring.jsp |
심사위원 채점 폼(criterion별 1~5, CSRF hidden, prefill). 표시용 — 게이트 아님 | K-CTRL |
| 수정 | src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java |
신규 3매퍼 @MockBean 등록(JamRoleGate/PermissionGate 컨트롤러 주입분도 — contextLoads 보존, §30) | (검증) |
| 신규 | src/test/.../JamScoringControllerTest.java |
3중 게이트(isJudge/평가기간/자기출품) + CSRF + 401/403/404/422 + UPSERT 멱등 + 부분입력 | (검증) |
| 신규 | src/test/.../JamEvalWindowTest.java |
평가기간 경계(EVAL+구간내/구간밖/status≠EVAL/기간 NULL) 단위 | (검증) |
신규 테이블/VIEW 0 확인: 본 설계 파일 영향에
docs/*-ddl.sql신규·db/schema.sql수정 없음(W2-3 동결 소비). 매퍼는 동결 스키마를 읽고/UPSERT 만. SSR 호출지점 영향(verification §영향맵): 신규 컨트롤러·매퍼·JSP·POJO 만 추가 → 기존 매퍼/JSP/컨트롤러 소비처 0 영향. jam_score_stats VIEW·jam_scores 는 W2-3 동결이라 본 설계가 스키마를 건드리지 않음.
신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
// JamEvalWindow — 평가기간 게이트 판정(W2-3 F6 구현). jam + now 만으로 판정(최소).
boolean isOpen(JamData jam, // status/eval_start_at/eval_end_at 3필드만 읽음(inflate 마킹)
java.time.OffsetDateTime now) // 비교 기준 시각(테스트 주입 가능 — Clock 대신 인자)
// JamCriteriaMapper (@Mapper, #{} only, snake→camel 직접 alias)
List<JamCriterionData> listByJam(long jamId) // 점수 폼 라벨 + criterion_key 화이트리스트 소스
// JamScoresMapper (@Mapper, #{} only)
int upsertScore(long jamId, // 평가단위 1/2
long gameId, // 평가단위 2/2(출품작)
long judgeUserId, // 심사위원(세션 userId)
String criterionKey, // 채점 기준(jam_criteria 화이트리스트 통과분)
int score) // 1~5. INSERT ON CONFLICT DO UPDATE(멱등 재입력)
List<JamScoreData> listByJudge(long jamId, // 잼 스코프
long gameId, // 출품작
long judgeUserId)// 본인 현황(폼 prefill) — 3키로 본인 criterion별 점수
// JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias)
List<Map<String,Object>> listStatsByJam(long jamId) // 출품작별 종합(정렬 적용, 시상/목록 소스)
⚠️ inflate 마킹(concern 4):
JamEvalWindow.isOpen(jam, now)의jam은 JamData 전체를 받지만 status/eval_start_at/eval_end_at 3필드만 읽는다 — 구현에서 필요 시isOpen(String status, OffsetDateTime start, OffsetDateTime end, OffsetDateTime now)로 좁힐 수 있음(최소 인자 원칙).now를 Clock 대신 인자로 둔 이유: 테스트에서 경계 시각 주입(고정 Clock 빈 DI 보다 단순).JamScoresMapper.upsertScore는 ON CONFLICT 단일문 권장 — exists→update 2메서드로 쪼개면 inflate(concern 5). ⚠️ criterion 펼침 평균 조회(criterion별 평균 화면 필요 시): W2-3 결정상 VIEW 는 종합 1행만 노출하므로, criterion별 평균이 화면에 필요하면JamScoresMapper에listCriterionAvgByJam(long jamId)(jam_scores GROUP BY jam_id,game_id,criterion_key) 를 구현 단계에서 필요 확인 후 추가(현 설계는 종합 VIEW 소비로 충분 — 선제 추가 안 함, 최소 인자/메서드 원칙).
대안 비교
| 주제 | 안 | 장점 | 단점 | 채택 |
|---|---|---|---|---|
| 점수 저장 | (A) jam_scores UPSERT(ON CONFLICT) | 멱등 재입력, 단일문, 동결 UNIQUE 활용 | PostgreSQL 의존(ON CONFLICT) | 채택(S3) |
| (B) exists→insert/update 2쿼리 | 방언 독립 | round-trip 2배·경합 윈도우, 메서드 inflate | 기각 | |
| (C) DELETE→INSERT | 단순 | updated_at 이력 소실, 트랜잭션 내 빈 구간 | 기각 | |
| 입력 단위 | (A) criterion batch(body 배열) 1요청 | round-trip 1회, 원자성, 부분입력 자연 | 배열 검증 | 채택(S1) |
| (B) criterion 단건 요청 N회 | 단순 매핑 | N round-trip, 부분실패 정합, 폼 1화면과 불일치 | 기각 | |
| HTTP method | (A) POST 1차 + PUT 동일핸들러 | JSP form 호환 + REST 멱등 의도, 중복 0 | 매핑 2개 | 채택(S1, orchestrator "POST/PUT") |
| (B) PUT 단독 | REST 정석 | JSP form method 오버라이드 필요, 기존 POST 패턴 이탈 | 기각 | |
| 자기출품 충돌 위치 | (A) W2-2 별도 메서드 호출(gameId 필요) | 자격(잼)과 충돌(출품작) 관심사 분리, isJudge 단순 | 호출 1회 추가 | 채택(S2 기본가정, concern 1) |
| (B) isJudge 에 충돌 합침 | 호출 1회 | isJudge 가 gameId 받아야 함(스코프 자격과 출품작 충돌 혼합) | 조건부(W2-2 결정 따름) | |
| 집계 노출 시점 | (A) CLOSED/eval종료 후 공개, EVAL 중 관리자만 | 심사위원 동조(anchoring) 편향 방지(정석) | 시점 분기 | 채택(§API) |
| (B) EVAL 중 실시간 공개 | 투명 | 점수 동조 편향(심사 무결성 저해) | 기각 | |
| criterion 검증 | (A) 앱계층 화이트리스트(jam_criteria) | DB FK 부재(W2-3 논리참조) 보완, 미등록 키 422 | 조회 1회 | 채택(S8) |
| (B) 검증 없이 INSERT | 단순 | 오타·미등록 criterion 점수 오염(무결성/집계 왜곡) | 기각 | |
| 입력 이력 | (A) updated_at 최종상태만(1차) | 단순, 동결 스키마 그대로 | 변경 이력 부재 | 채택(비목표 분리) |
| (B) jam_score_audit 테이블 | 감사 추적 | 신규 테이블(동결 외)·over-engineering(1차 불요) | 기각(후속) |
롤아웃 / 마이그레이션
순서
- 스키마 선행(W2-3):
docs/jam-eval-ddl.sql(jam_criteria/jam_scores/jam_score_stats VIEW) 적용 완료가 본 설계 전제. 본 W2-4 는 DDL 0 — 스키마 적용 단계 없음. - 선행 의존 코드: W2-1(JamsMapper.getById/JamEntriesMapper.exists) + W2-2(JamRoleGate.isJudge + 자기출품 충돌) 가 본 설계의 게이트 호출 대상. W2-2 미배포 시 점수 입력 게이트가 컴파일/동작 불가 → W2-2 와 동시 또는 후행 배포.
- 코드 배포(본 설계): K-DOMAIN → K-MAPPER → K-CTRL. JamScoringController 가 JamRoleGate·PermissionGate·JamsMapper·JamEntriesMapper·3 신규 매퍼 주입.
- 운영: 관리자가 잼 criteria 등록(W2-1 콘솔 확장 또는 등록 소유자) + 심사위원 지정(W2-2) → EVAL 전이 후(W2-1 상태전이) 심사위원 채점 가능.
역호환
- 신규 컨트롤러·매퍼·JSP·POJO 만 추가 → 기존 게임/리뷰/잼 동작 0 영향. jam_scores/jam_score_stats 는 W2-3 동결이라 본 설계가 스키마 무변경.
- jam_criteria 등록 전(criteria 0행)이면 화이트리스트가 비어 모든 criterion_key 가 422 — 운영상 criteria 선등록 필요(순서 4).
롤백
- 코드 롤백: JamScoringController/3매퍼/JamEvalWindow 되돌리면 점수 경로 미노출. 동결 스키마는 추가 전용이라 잔존 무해(데이터 보존, 비파괴). jam_scores 행은 남아도 다른 도메인에 영향 0(읽는 곳이 본 설계뿐).
- 스키마 롤백 없음(DDL 0).
AC 매핑
| AC | 요구(골자 W2-4 + 확정 결정) | 만족 설계 요소 | 비고 |
|---|---|---|---|
| AC-1 | 점수 입력 API POST/PUT /jams/{jamId}/games/{gameId}/scores |
JamScoringController submitScores(POST+PUT 동일핸들러) | S1, §API |
| AC-2 | 심사위원 자격 게이트(isJudge) | jamRoleGate.isJudge(session, jamId) → 아니면 403 | S2-1, §게이트연동 |
| AC-3 | 평가기간 게이트(EVAL + now∈구간) | JamEvalWindow.isOpen(jam, now) → 아니면 422 | S2-2, W2-3 F6 |
| AC-4 | 자기출품 충돌(자기 출품작 채점 불가) | W2-2 isOwnEntry 호출 → 참이면 422 | S2-3, W2-2 |
| AC-5 | 상태변경 CSRF 전수 | submitScores 진입부 CsrfTokens.isValid → 403 | §API 공통 |
| AC-6 | criterion별 점수 1~5 입력 | jam_scores score 1~5(동결 CHECK) + 앱 범위 검증 | S1, S8 |
| AC-7 | jam_scores UPSERT(멱등 재입력) | upsertScore ON CONFLICT (4키) DO UPDATE | S3, 동결 UNIQUE |
| AC-8 | 집계 = jam_score_stats VIEW(가중 종합) | JamScoreStatsMapper.listStatsByJam(큰따옴표 alias) | S4, W2-3 G6 |
| AC-9 | 심사위원 평균 + 동률 처리 명시 | VIEW weighted_total/simple_total + 결정론 정렬(judge_count,game_id) | S7/난제3 |
| AC-10 | 평가기간 내 수정 허용, 종료 후 잠금 | 입력·수정 동일 평가기간 게이트(EVAL 이탈→422) | S5, S1 |
| AC-11 | 점수 미입력 criterion 처리 | 미입력=행 부재→VIEW per_criterion 에서 제외(가중 분모도) | S6/난제2 |
| AC-12 | 심사위원 부분입력 처리 | batch 배열에 입력분만 포함, judge_count 로 참여 가시화 | S6/난제2 |
| AC-13 | criterion_key 화이트리스트(미등록 거부) | jamCriteriaMapper.listByJam 화이트리스트 → 미등록 422 | S8 |
| AC-14 | 권한/평가 SQL ${} 0 |
신규 3매퍼 #{} only |
§파일영향맵 |
| AC-15 | 신규 테이블 0(동결 소비) | DDL 0, schema.sql 무변경 | §데이터모델 |
검증 포인트 (verification-advisor 점검 대상)
L레벨 매핑(verification-strategies): 자격/기간/충돌 게이트·점수 입력 플로우 = L1+L2+L3. 신규 매퍼 SQL/alias·UPSERT ON CONFLICT·집계 VIEW 소비 = L1+L2(dev DB contract). 신규 컨트롤러·매퍼 의존 = full
./mvnw -o test의무(§30).
시나리오 검증
- VP-1 (AC-2/3/4 3중 게이트, L1+L3): JamScoringControllerTest — isJudge 통과/미심사위원 403, EVAL+구간내 통과/기간밖·status≠EVAL 422, 자기출품 충돌 422, 미인증 401. 게이트 순서(CSRF→인증→자격→기간→출품작→충돌→criterion)대로 첫 실패 지점 응답 검증. L3 스모크: 심사위원이 EVAL 잼 출품작 채점 성공.
- VP-2 (AC-7 UPSERT 멱등, L1+L2): 같은 (jam,game,judge,criterion) 재입력 시 INSERT 아닌 UPDATE(행 수 불변, score·updated_at 갱신). dev DB contract: ON CONFLICT (4키) 실측 — ux_jam_scores_jam_game_judge_criterion(W2-3 동결) 타깃 정합.
- VP-3 (AC-8/9 집계 정합, L2): jam_score_stats 소비 — 다수 심사위원·다수 criterion 입력 시 fan-out 없이 weighted_total 정확, 정렬(weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC) 결정론. (W2-3 가 VIEW 자체 fan-out/0-division 을 검증 — 본 설계는 소비 정렬·alias 정합 검증.)
- VP-4 (AC-11/12 부분입력·미채점, L1+L2): criterion 일부만 입력 시 미입력 criterion 이 종합에서 제외(weight 분모 포함), judge_count 가 부분참여 반영. 트랜잭션 원자성: 미등록 criterion 포함 배열은 전체 422(부분저장 0).
- VP-5 (AC-5 CSRF, L1): 점수 입력 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용, grounding 선례).
- VP-6 (AC-13 화이트리스트, L1): jam_criteria 미등록 criterion_key 입력 → 422, jamScoresMapper.upsertScore 미호출.
- VP-7 (DB-방언 계약, L2): JamScoreStatsMapper 반환 키가 weightedTotal/simpleTotal/scoredCriteria/judgeCount 로 정합(집계 VIEW camelCase 큰따옴표 alias 확인 — GameReviewStatsMapper.java:13 케이스폴딩 BUG-2 선례 회피). JamCriteriaMapper/JamScoresMapper 는 snake→camel 직접 alias(일반 매퍼 표준).
- VP-8 (contextLoads, L1): BibimbapApplicationTests 에 신규 3매퍼 @MockBean 등록 후 PASS(§30). JamScoringController 가 주입하는 JamRoleGate(W2-2)·PermissionGate·JamsMapper·JamEntriesMapper 빈 가용성 확인. 누락 시 NoSuchBeanDefinitionException.
집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
self-audit(시점): 아래 카운트는 본 W2-4 가 신규 생성하는 정적 산출물(매퍼·게이트·API 핸들러)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 동결 스키마(jam_scores/jam_score_stats/jam_criteria)는 W2-3 권위 — 본 설계가 수정 0이므로 그 카운트는 W2-3 검증 소관(여기서 재카운트 안 함). self-audit(표현): 게이트 호출·핸들러 열거는 단일 리터럴 grep 취약성을 피해 메서드 열거 + 진입부 헬퍼 호출 구조적 불변식에 앵커(수동 판정). 매퍼
${0건·신규 DDL 0건은 부재 검증이라 리터럴 정당.
- AC-T1 점수 입력 핸들러 3중 게이트 전수 — 점수를 저장하는 핸들러(submitScores: POST+PUT 동일 메서드 1개)가 진입부에서 게이트 3종(isJudge / JamEvalWindow.isOpen / 자기출품충돌) + CSRF 전수 통과: 수동 판정으로 submitScores 진입 시퀀스에 4가드(CSRF+3게이트) 전수 존재 확인. 1건이라도 누락 = 인가/기간/충돌 우회 보안결함 → FAIL. 이 전수 AC 가 S2 enforcement 의 핵심 가드(리터럴 grep 단독 의존 회피 — 메서드 본문 게이트 호출 열거).
- AC-T2 신규 매퍼 전수 3개
${0건 — 신규 매퍼 3파일(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper)에${매치 0:grep -rc '\${' <매퍼 3파일>== 0 (AC-14,${}동적치환 금지). 부재 검증이라 리터럴 정당. - AC-T3 집계 VIEW 매퍼 alias 큰따옴표 전수 — JamScoreStatsMapper(집계 VIEW 소비)의 camelCase alias 전수가 큰따옴표: jam_score_stats 종합 4컬럼(weightedTotal/simpleTotal/scoredCriteria/judgeCount) 의 alias 가
AS "..."형태(케이스 폴딩 회피, §33). 검증: JamScoreStatsMapper 의AS "출현이 camelCase alias 수와 일치(수동 판정 — 일반 매퍼 JamCriteriaMapper/JamScoresMapper 는 snake→camel 직접 alias 라 큰따옴표 불요·있으면 안 됨). - AC-T4 신규 테이블/VIEW 0건 불변식 — 본 W2-4 가 스키마를 추가/변경하지 않음: 본 워크스트림 파일 영향에
docs/*-ddl.sql신규 0 ANDdb/schema.sqldiff 0 AND 신규 매퍼에CREATE TABLE/ALTER TABLE/CREATE VIEW토큰 0. 검증:grep -rE 'CREATE TABLE|ALTER TABLE|CREATE OR REPLACE VIEW' <K-MAPPER 3파일>== 0(매퍼는 SELECT/INSERT ON CONFLICT 만). 동결 소비 계약(W2-3 권위) 위반(W2-4 가 스키마 손대기) 즉시 검출. - AC-T5 401/403/422 정책 응답 전수 — 점수 입력 게이트 실패 분기 전수가 정책대로: 미인증→401, 미심사위원→403, CSRF→403, 평가기간외/자기출품/미등록criterion/score범위→422, 출품작없음→404. 검증: JamScoringControllerTest 가 5분류(401/403/404/422 각) 케이스 전수 보유(테스트 메서드 열거) AND 각 응답 status 코드 정합. 정책 분류 누락(예: 자기출품을 403 으로 잘못 응답) 검출.
- AC-T6 신규 매퍼 @MockBean 전수 3건 — BibimbapApplicationTests 에 신규 3매퍼 @MockBean 전수 등록: contextLoads PASS AND 3매퍼(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) 등록 수동 확인. 1건 누락 시 contextLoads FAIL 로 즉시 검출(§30, verification 시점 자기 검증).
잔여 오픈 질문
없음(0). 확정 결정 S1~S8 전제 고정. 세 난제(3중 게이트 순서·401/403/422 정합 / 부분입력·미채점 집계 의미 / 동률·심사위원 평균)는 본 설계가 구체 메커니즘으로 확정. 집계 노출 시점(EVAL 중 관리자만/CLOSED 후 공개)·POST+PUT 동일핸들러·UPSERT ON CONFLICT·criterion 화이트리스트도 확정. 구현 점검 항목(W2-2 게이트 시그니처/자기출품 충돌 위치 재확인·신규 매퍼 @MockBean full-test·VIEW alias 큰따옴표·ON CONFLICT dev contract·criterion_key 화이트리스트·헬퍼 시그니처 inflate)은 오픈 질문이 아니라 concerns 로 이관.