bibimbap/.atp/work-session/20260623-104307/implementation/W2-4-judge-scoring-design.md

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-심사위원 평가(점수 입력/집계 소비)
★게이트 의존 시그니처 — 점수 입력은 W2-2 의 `JamRoleGate.isJudge(session, jamId)`(자격) + W2-2 자기출품 충돌규칙에 의존한다. 본 W2-4 는 이 게이트를 호출만 하고 소유하지 않는다(W2-2 미설계 시점이므로 시그니처는 orchestrator 확정값 `isJudge(session, jamId)` 를 계약으로 가정). 구현 단계에서 W2-2 산출 게이트의 실제 시그니처(인자·반환·자기출품 충돌 포함 여부)를 재확인 필요 — W2-2 가 충돌검사를 isJudge 안에 합칠지 별도 메서드로 둘지에 따라 본 컨트롤러 호출 코드가 갈린다. crossRefs 참조.
신규 컨트롤러(JamScoringController) + 신규 매퍼(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무(JamRoleGate/PermissionGate 빈도 컨트롤러 주입 시 동일). 누락 시 contextLoads NoSuchBeanDefinitionException.
jam_score_stats 는 집계 VIEW → 소비 매퍼(JamScoreStatsMapper) alias 는 camelCase 큰따옴표(AS "weightedTotal") 필수(케이스 폴딩 함정, verification-strategies §33, GameReviewStatsMapper.java:13 선례). criterion 단위 펼침 조회(criterion별 평균)는 VIEW 가 종합 1행만 노출하므로 W2-4 가 jam_scores 직접 GROUP BY 매퍼로 별도 작성 — 이 펼침 쿼리도 집계라 큰따옴표 alias. dev DB contract(L2)로 fan-out·0-division·NULL 실측 권장.
JamScoringController 헬퍼(requireEvalOpen/requireJudge/resolveScores) 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 평가기간 게이트가 JamData 전체를 받지만 status/eval_start_at/eval_end_at 3필드만 읽음 → 필요 필드로 좁혀질 수 있음.
jam_scores UPSERT 는 PostgreSQL `INSERT ... ON CONFLICT (jam_id,game_id,judge_user_id,criterion_key) DO UPDATE SET score=EXCLUDED.score, updated_at=now()` 단일문 권장(exists→update 2쿼리 inflate 회피). ux_jam_scores_jam_game_judge_criterion(W2-3 동결) 의존. 이 UNIQUE 가 W2-3 동결 후 변경되면 ON CONFLICT 타깃이 깨진다 — crossRefs 동결 유지 필수. dev DB contract 로 ON CONFLICT 실측.
criterion_key 검증 — 입력 score 의 criterion_key 가 해당 잼의 jam_criteria 에 실재하는지 앱계층 검증(미등록 키 점수 거부, 422). jam_scores.criterion_key 는 jam_criteria.criterion_key 의 논리참조(W2-3: FK 아님)이므로 DB 가 막지 않음 → 앱계층 화이트리스트 필수(보안/무결성).
true
checklist_passed
true
requirements research adrs
docs/work-log/2026-06-23-w2-w4-feature-skeletons.md .atp/work-session/20260623-104307/research/W2-W4-grounding.md
.atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
.atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
.atp/work-session/20260622-180054/implementation/W1-design.md
docs/work-log/2026-06-17-jam-platform-roadmap.md
docs/development/verification-strategies.md
docs/jam-eval-ddl.sql

설계: W2-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_scores UPSERT(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_stats VIEW(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_stats VIEW 의 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.
  • 인증·미인가(심사위원 아님 = isJudge false): 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/summaryjam.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, 권한은 W1 PermissionGate(관리자 집계 노출 시).

3중 게이트 구성 (각 게이트의 소유·시그니처)

  1. 심사위원 자격 (W2-2 소유 — 호출만): jamRoleGate.isJudge(session, jamId) → false 면 403. 시그니처는 orchestrator 확정값(isJudge(session, jamId)). 본 설계는 이 메서드를 호출만 하고 구현하지 않는다(concern 1 — W2-2 미설계 시점, 구현 단계 시그니처 재확인).
  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 인데 기간 미설정은 운영 오류).
  3. 자기출품 충돌 (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).
  • 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별 평균이 화면에 필요하면 JamScoresMapperlistCriterionAvgByJam(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차 불요) 기각(후속)

롤아웃 / 마이그레이션

순서

  1. 스키마 선행(W2-3): docs/jam-eval-ddl.sql(jam_criteria/jam_scores/jam_score_stats VIEW) 적용 완료가 본 설계 전제. 본 W2-4 는 DDL 0 — 스키마 적용 단계 없음.
  2. 선행 의존 코드: W2-1(JamsMapper.getById/JamEntriesMapper.exists) + W2-2(JamRoleGate.isJudge + 자기출품 충돌) 가 본 설계의 게이트 호출 대상. W2-2 미배포 시 점수 입력 게이트가 컴파일/동작 불가 → W2-2 와 동시 또는 후행 배포.
  3. 코드 배포(본 설계): K-DOMAIN → K-MAPPER → K-CTRL. JamScoringController 가 JamRoleGate·PermissionGate·JamsMapper·JamEntriesMapper·3 신규 매퍼 주입.
  4. 운영: 관리자가 잼 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 AND db/schema.sql diff 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 로 이관.