bibimbap/.atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md

48 KiB

phase agent agent_version generated_at workstream concerns concerns_checked self_verification references
design design-advisor 1 2026-06-23T14:30:00+09:00 W2-3-잼 평가 통합설계(스키마 동결)
★평가단위 식별자 이중 모델 — W2-1 은 평가 단위를 jam_entries.id 로 제공한다고 명시했으나, 본 동결 스키마(orchestrator 확정)는 jam_scores/jam_votes/jam_awards 가 (jam_id, game_id) 를 참조한다. 본 설계는 둘을 정합시킨다: (jam_id, game_id) = 활성 출품작 자연키, jam_entries(jam_id,game_id active-UNIQUE) 와 1:1 대응. FK 는 game_id→games, jam_id→jams 로 직접 걸고, '출품 여부' 는 앱계층에서 jam_entries 활성행 존재로 검증(W2-4/5 진입 게이트). 구현 단계에서 jam_entries.id 직접 FK 채택 여부를 W2-1 소유자와 재확인 필요(현 동결은 game_id 자연키 채택 — 근거: orchestrator 확정 컬럼 + entrant 종류 무관 단일화). crossRefs 참조.
신규 매퍼(JamScoresMapper/JamVotesMapper/JamCriteriaMapper/JamAwardsMapper/JamScoreStatsMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. (본 W2-3 은 스키마+계약 동결이 범위이므로 매퍼/컨트롤러 구현은 W2-4/5/6 소관 — 본 설계는 매퍼 시그니처 계약만 고정, 실제 빈 등록 책임은 하류.)
jam_score_stats VIEW 의 가중 종합점수(SUM(avg*weight)/SUM(weight)) 는 criterion weight 가 numeric 이고 NULL/0 가능 → 0-division 가드 필요. 집계 VIEW 매퍼는 camelCase alias 큰따옴표(AS "weightedTotal") 필수(케이스 폴딩, verification-strategies §33). dev DB contract(L2) 로 fan-out·NULL·0-division 실측 권장 — game_review_stats BUG-1 fan-out 선례(커밋 21892c8) 재발 방지.
유저평점 트랙 최소 리뷰수 임계 N — 본 설계는 기본값 N=3 으로 확정(아래 §시상 트랙 계약). 잼별 가변 임계가 필요하면 jams 또는 jam_awards 산정 파라미터로 확장 — 구현 점검 항목(현 동결은 상수 3, 시상 산정 로직에 위치).
평가단위 = 활성 출품작이라는 전제는 jam_entries 가 (jam_id,game_id) 활성 UNIQUE 를 보장함에 의존(W2-1 ux_jam_entries_jam_game_active). 이 UNIQUE 가 동결 전 변경되면 jam_scores/jam_votes 의 game_id 참조 정합이 깨진다 — W2-1 PK/UNIQUE 동결 계약 유지 필수(crossRefs).
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-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-ddl.sql

설계: W2-3 — 잼 평가 통합설계 (심사점수 / 인기투표 / 시상집계 스키마 동결 + 단방향 평점 계약 + 평가기간 게이트 계약)

⚠️ 결합 클러스터 forward phase-gate (§2.7). 이 문서가 W2-4(심사평가)·W2-5(인기투표)·W2-6(시상집계) 가 공유·소비하는 동결 스키마 + 계약의 권위다. 하류 3워크스트림은 이 문서를 읽고 API/로직만 설계한다. 본 동결이 확정되기 전 하류 착수 금지(재작업 차단).

목표 / 비목표

목표 (FR/NFR 추적 — 골자 W2-3)

  • G1 심사점수 스키마 동결: 잼별 설정형 심사 기준(jam_criteria) + 심사위원 점수(jam_scores) + 집계 VIEW(jam_score_stats). 고정 6축 강제 대신 정석 잼 심사 모델.
  • G2 인기투표 스키마 동결: 잼당 1인 1표(jam_votes). game_likes 와 별개 신규 테이블(game_likes 는 user_key varchar·1인1표 비보장이라 재활용 안 함). 로그인 user_id 기반.
  • G3 시상 스키마 동결: 3트랙(JUDGE/USER_RATING/POPULAR) + grand(jam_awards). 트랙별 개별 수상 + 가중 grand prize.
  • G4 ★단방향 유저평점 트랙 계약: 시상 USER_RATING 트랙 = game_review_stats VIEW.avg_rating(overall) 소비. 단일 평균 채택(6축은 표시 전용). 리뷰 도메인은 잼 무관 → 시상이 읽기만(write 0).
  • G5 평가기간 게이트 계약: 심사점수(W2-4)·투표(W2-5) 는 jams.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at] 일 때만 허용. 시상 집계(W2-6)는 status='CLOSED' 또는 eval 종료 후 확정.
  • G6 집계 노출 계약: 심사 집계 = jam_score_stats VIEW(criterion별 평균 + 가중 종합, fan-out 방지 선집계 — game_review_stats VIEW 선례). 투표 집계 = count.
  • G7 평가단위 정합 계약: 평가 단위 자연키 = (jam_id, game_id) = 활성 출품작(jam_entries 1:1 대응). W2-4/5/6 은 entrant 종류(개인/팀)를 몰라도 game_id 만 참조.
  • NFR: 상태변경 CSRF 전수(하류 구현), #{} 바인딩(${} 금지), 평가기간 게이트, VIEW 매퍼 alias 큰따옴표(case-folding), 비파괴 멱등 마이그레이션, DDL 권위=docs/*-ddl.sql.

비목표 (스코프 밖 — 본 W2-3 은 스키마·계약 동결만)

  • 심사 점수 입력 API·UI·컨트롤러 본체W2-4 소유. 본 설계는 jam_criteria/jam_scores 스키마 + jam_score_stats VIEW + 매퍼 시그니처 계약만 제공.
  • 투표 토글 API·UIW2-5 소유. 본 설계는 jam_votes 스키마 + 1인1표 UNIQUE + 평가기간 게이트 계약만.
  • 시상 산정 알고리즘 본체·확정 UIW2-6 소유. 본 설계는 jam_awards 스키마 + 3트랙 정의 + 유저평점 소비 계약(소스/NULL/임계) + grand 가중 규칙 자리만.
  • 심사위원 자격 판정(jam_judges)W2-2 소유. 본 설계는 jam_scores.judge_user_id 가 FK users 이고 "is judge" 검증은 앱계층(W2-2 게이트)임만 명시.
  • 댓글/리뷰 스키마 변경 — W3-2(구현완료). 본 동결 묶음 아님. 시상이 game_review_stats VIEW 를 읽기만(game_reviews 무변경, jam FK 추가 없음).
  • game_review_stats VIEW 재집계·평가기간 필터 적용 — 채택 안 함(아래 §유저평점 트랙 계약 근거). 시상은 출품작 전체 리뷰 avg_rating 을 그대로 소비.

개요

bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation @Mapper(#{} only) + JSP 스택이다. 현재 잼 평가 스키마(jam_criteria/jam_scores/jam_votes/jam_awards/jam_score_stats)는 전무하다(grep 0 hit — 본 advisor 직접 확인). W2-1 이 jams(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + jam_entries(잼당 game 활성 UNIQUE = 출품작) 를 제공하고, W3-2 가 game_review_stats VIEW(avg_rating numeric / review_count / 6축평균 9컬럼, schema.sql:217-244 직접 확인)를 제공한다.

본 설계는 잼 평가 4테이블(+집계 VIEW 1)을 신규 동결하고, 하류 W2-4/5/6 이 의존할 **3개 계약(단방향 유저평점 / 평가기간 게이트 / 집계 노출)**을 구체화한다. 동결 후 하류는 이 스키마를 변경 없이 소비한다.

확정된 정석 결정(전제 — 재논의 금지):

  • 심사 척도 = 잼별 설정형 criteria : 고정 6축 강제 대신 jam_criteria 로 잼마다 심사 기준 정의(정석 잼 심사 모델). jam_scores 는 criterion_key 단위 행(criterion별 1~5).
  • 인기투표 = 잼당 1인 1표 : jam_votes(jam_id, voter_user_id) UNIQUE. game_likes 재활용 안 함(비권위·varchar·1인1표 비보장).
  • 시상 = 3트랙 + grand : jam_awards.award_track CHECK('JUDGE','USER_RATING','POPULAR','GRAND').
  • 유저평점 트랙 = avg_rating 단일 평균 (6축 표시 전용, 단방향 읽기).
  • 평가단위 = (jam_id, game_id) 자연키 (활성 출품작, entrant 종류 무관 단일화).

가장 까다로운 세 난제 확정:

  • 난제1 (평가단위 식별자 — W2-1 jam_entries.id vs 동결 game_id 정합): W2-1 은 "평가 단위 = jam_entries.id" 라 했고 orchestrator 동결 컬럼은 (jam_id, game_id) 다. 본 설계는 자연키 (jam_id, game_id) 채택으로 정합한다 — jam_entries 가 ux_jam_entries_jam_game_active(jam_id,game_id 활성 UNIQUE, W2-1 §데이터모델4)를 보장하므로 (jam_id, game_id) 는 활성 출품작과 1:1 대응하는 자연키다. FK 는 game_id→games·jam_id→jams 로 직접 걸고, "출품작인가" 검증(점수/투표 진입 게이트)은 앱계층에서 jam_entries 활성행 존재로 수행한다. surrogate jam_entries.id 직접 FK 보다 (jam_id,game_id) 자연키가 ① orchestrator 동결 컬럼과 일치 ② game_review_stats(game_id 기준) 와 join 정합 ③ entrant 종류 불투명(W2-1 의도)을 유지하는 장점이 있다(concern 1 에 W2-1 소유자 재확인 마킹).
  • 난제2 (유저평점 트랙 소스·기간·NULL — 단방향 계약): 소스 = game_review_stats.avg_rating(overall 단일 평균). 6축은 표시 전용, 시상 산정 미사용(단일평균 채택 — 6축 가중을 시상에 끌어오면 잼별 criteria 와 의미 충돌). 기간 = 잼 평가기간 필터 미적용, 출품 게임의 전체 리뷰 avg_rating 소비(리뷰는 잼 무관 상시 작성 → game_review_stats 재집계 부담 회피, 정석). NULL/미달 = 리뷰 0개/axes 0행 출품작은 avg_rating NULL → 시상 산정 시 NULLS LAST + review_count >= N(기본 3) 임계 미달 시 트랙 제외. 리뷰 도메인 write 0(읽기만, game_reviews 에 jam FK 추가 안 함 — code-fact 정합).
  • 난제3 (집계 fan-out·가중종합·NULL): 심사 집계 jam_score_stats VIEW 는 game_review_stats 의 fan-out 방지 선례를 따른다(criterion별 평균은 FILTER 또는 GROUP BY 선집계). 가중 종합 = SUM(criterion_avg * weight) / NULLIF(SUM(weight), 0)(0-division 가드). criterion 미채점(0행) 시 해당 criterion 평균 NULL → 가중 종합에서 제외(COALESCE 또는 FILTER). 매퍼 alias 는 집계 VIEW 이므로 큰따옴표(AS "weightedTotal") 필수.

핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 동결값)

결정 확정값 본 설계의 구체화
F1 심사 척도 잼별 설정형 criteria jam_criteria(jam_id, criterion_key, display_name, sort_order, weight numeric). 고정 6축 강제 안 함
F2 심사 점수 criterion 단위 행 jam_scores(jam_id, game_id, judge_user_id, criterion_key, score 1~5) + UNIQUE(jam_id,game_id,judge_user_id,criterion_key)
F3 인기투표 잼당 1인 1표 jam_votes(jam_id, game_id, voter_user_id) + UNIQUE(jam_id, voter_user_id). game_likes 재활용 안 함
F4 시상 3트랙 + grand jam_awards.award_track CHECK('JUDGE','USER_RATING','POPULAR','GRAND') + rank + score_value
F5 유저평점 트랙 avg_rating 단일평균(단방향) game_review_stats.avg_rating 읽기만. 6축 표시전용. 기간필터 미적용(전체). NULLS LAST + review_count>=3
F6 평가기간 게이트 EVAL + now∈[eval_start,eval_end] 점수(W2-4)·투표(W2-5) 허용 조건. 시상(W2-6)=CLOSED/eval종료후
F7 집계 노출 심사=VIEW, 투표=count jam_score_stats VIEW(criterion평균+가중종합, fan-out 방지). 투표는 COUNT
F8 평가단위 (jam_id, game_id) 자연키 활성 출품작(jam_entries 1:1). FK game_id→games·jam_id→jams. 출품검증=앱계층

데이터 모델 (DDL)

권위 = 신규 파일 docs/jam-eval-ddl.sql (apply-local-ddl.sh 가 docs/*-ddl.sql 알파벳 글롭으로 멱등 적용, ON_ERROR_STOP, search_path=dev). db/schema.sql 에 동기 사본(아래 §schema.sql 반영). 선례: W1 docs/rbac-ddl.sql, W2-1 docs/jam-ddl.sql(직접 확인). game_reviews/game_review_stats 변경 없음(시상이 VIEW 를 읽기만). jams/jam_entries 변경 없음(game_id/jam_id 를 FK 참조만). 멱등: CREATE TABLE/SEQUENCE IF NOT EXISTS, DO guard, CREATE UNIQUE INDEX IF NOT EXISTS, CREATE OR REPLACE VIEW. 타입은 기존 스타일(bigint/varchar/timestamptz/numeric/smallint).

⚠️ 알파벳 글롭 순서 주의: apply-local-ddl.sh 가 docs/*-ddl.sql 을 알파벳순으로 적용한다. FK 가 jams/jam_entries 를 참조하므로 jam-ddl.sql(W2-1) 이 jam-eval-ddl.sql(W2-3) 보다 먼저 적용돼야 한다. jam-ddl < jam-eval-ddl(알파벳: 'd' < 'e' 위치는 'jam-' 공통 뒤 'd' vs 'e' → jam-ddl 먼저) 이 성립하므로 순서 안전(롤아웃 §순서에서 재확인).

신규 파일: docs/jam-eval-ddl.sql

-- W2-3 잼 평가 통합(심사점수/인기투표/시상집계). 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- 선행: docs/jam-ddl.sql(jams/jam_entries — 알파벳 글롭 순 jam-ddl 먼저 적용).
-- game_reviews/game_review_stats(W3-2) 변경 없음 — 시상 USER_RATING 트랙이 VIEW 를 읽기만.
-- 평가단위 = (jam_id, game_id) 자연키 = 활성 출품작(jam_entries 1:1 대응). 추가만, 파괴 없음.

-- ===========================================================================
-- 1) jam_criteria (잼별 설정형 심사 기준. 고정 6축 강제 대신 정석)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_criteria_id_seq";
CREATE TABLE IF NOT EXISTS "jam_criteria" (
    "id"           bigint DEFAULT nextval('jam_criteria_id_seq'::regclass) NOT NULL,
    "jam_id"       bigint NOT NULL,
    "criterion_key" character varying(40) NOT NULL,           -- 잼 내 기준 식별(영문 키)
    "display_name" character varying(100) NOT NULL,           -- 표시 라벨
    "sort_order"   integer DEFAULT 0 NOT NULL,                -- 표시 순서
    "weight"       numeric(6,3) DEFAULT 1.0 NOT NULL,         -- 가중 종합 산정 가중치(>0 권장)
    "created_at"   timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_criteria_id_seq" OWNED BY "jam_criteria"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_criteria_jam_fkey') THEN
        ALTER TABLE "jam_criteria" ADD CONSTRAINT "jam_criteria_jam_fkey"
            FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
    END IF;
    -- weight 음수 방지(0 은 허용하되 가중종합에서 NULLIF 가드 — 0-division 방어는 VIEW)
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_criteria_weight_check') THEN
        ALTER TABLE "jam_criteria" ADD CONSTRAINT "jam_criteria_weight_check"
            CHECK ("weight" >= 0);
    END IF;
END
$$;
-- 잼 내 criterion_key 중복 방지
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_criteria_jam_key"
    ON "jam_criteria" ("jam_id", "criterion_key");
CREATE INDEX IF NOT EXISTS "idx_jam_criteria_jam"
    ON "jam_criteria" ("jam_id", "sort_order");

-- ===========================================================================
-- 2) jam_scores (심사위원 점수. criterion 단위 행. is-judge 검증은 앱계층 W2-2)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_scores_id_seq";
CREATE TABLE IF NOT EXISTS "jam_scores" (
    "id"            bigint DEFAULT nextval('jam_scores_id_seq'::regclass) NOT NULL,
    "jam_id"        bigint NOT NULL,                          -- 평가단위 자연키 1/2
    "game_id"       bigint NOT NULL,                          -- 평가단위 자연키 2/2(출품작)
    "judge_user_id" bigint NOT NULL,                          -- 심사위원(FK users; is-judge 는 W2-2)
    "criterion_key" character varying(40) NOT NULL,           -- jam_criteria.criterion_key 논리참조
    "score"         smallint NOT NULL,                        -- 1~5
    "created_at"    timestamp with time zone DEFAULT now() NOT NULL,
    "updated_at"    timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_scores_id_seq" OWNED BY "jam_scores"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_jam_fkey') THEN
        ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_jam_fkey"
            FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_game_fkey') THEN
        ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_game_fkey"
            FOREIGN KEY ("game_id") REFERENCES "games" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_judge_fkey') THEN
        ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_judge_fkey"
            FOREIGN KEY ("judge_user_id") REFERENCES "users" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_score_check') THEN
        ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_score_check"
            CHECK ("score" BETWEEN 1 AND 5);
    END IF;
END
$$;
-- 한 심사위원이 한 출품작의 한 기준에 1점만(수정=UPDATE, 재입력 멱등)
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_scores_jam_game_judge_criterion"
    ON "jam_scores" ("jam_id", "game_id", "judge_user_id", "criterion_key");
-- 집계 join 대상(출품작 단위 선집계)
CREATE INDEX IF NOT EXISTS "idx_jam_scores_jam_game"
    ON "jam_scores" ("jam_id", "game_id");

-- ===========================================================================
-- 3) jam_votes (인기투표. 잼당 1인 1표. game_likes 와 별개. 평가기간 게이트=앱계층)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_votes_id_seq";
CREATE TABLE IF NOT EXISTS "jam_votes" (
    "id"            bigint DEFAULT nextval('jam_votes_id_seq'::regclass) NOT NULL,
    "jam_id"        bigint NOT NULL,                          -- 평가단위 자연키 1/2
    "game_id"       bigint NOT NULL,                          -- 투표 대상 출품작
    "voter_user_id" bigint NOT NULL,                          -- 투표자(FK users; 로그인 1인1표)
    "created_at"    timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_votes_id_seq" OWNED BY "jam_votes"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_jam_fkey') THEN
        ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_jam_fkey"
            FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_game_fkey') THEN
        ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_game_fkey"
            FOREIGN KEY ("game_id") REFERENCES "games" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_voter_fkey') THEN
        ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_voter_fkey"
            FOREIGN KEY ("voter_user_id") REFERENCES "users" ("id");
    END IF;
END
$$;
-- 잼당 1인 1표(최애 1개). 표 변경 = UPDATE game_id 또는 DELETE→INSERT(앱 정책 W2-5)
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_votes_jam_voter"
    ON "jam_votes" ("jam_id", "voter_user_id");
-- 투표 집계(출품작별 count)
CREATE INDEX IF NOT EXISTS "idx_jam_votes_jam_game"
    ON "jam_votes" ("jam_id", "game_id");

-- ===========================================================================
-- 4) jam_awards (시상. 3트랙 + grand. 트랙별 개별 수상 + 가중 grand)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_awards_id_seq";
CREATE TABLE IF NOT EXISTS "jam_awards" (
    "id"          bigint DEFAULT nextval('jam_awards_id_seq'::regclass) NOT NULL,
    "jam_id"      bigint NOT NULL,                            -- 평가단위 자연키 1/2
    "game_id"     bigint NOT NULL,                            -- 수상 출품작
    "award_track" character varying(20) NOT NULL,             -- JUDGE|USER_RATING|POPULAR|GRAND
    "rank"        integer NOT NULL,                           -- 트랙 내 순위(1=대상)
    "score_value" numeric(10,4),                              -- 산정 점수(트랙별 의미 다름; NULL 허용)
    "computed_at" timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_awards_id_seq" OWNED BY "jam_awards"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_jam_fkey') THEN
        ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_jam_fkey"
            FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_game_fkey') THEN
        ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_game_fkey"
            FOREIGN KEY ("game_id") REFERENCES "games" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_track_check') THEN
        ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_track_check"
            CHECK ("award_track" IN ('JUDGE', 'USER_RATING', 'POPULAR', 'GRAND'));
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_rank_check') THEN
        ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_rank_check"
            CHECK ("rank" >= 1);
    END IF;
END
$$;
-- 잼·트랙·순위 1행(재산정=DELETE→INSERT 또는 UPSERT 멱등). 동률은 같은 rank 허용 위해
-- (jam_id, award_track, game_id) UNIQUE 로 출품작 트랙 중복만 차단(같은 rank 동률 허용).
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_awards_jam_track_game"
    ON "jam_awards" ("jam_id", "award_track", "game_id");
CREATE INDEX IF NOT EXISTS "idx_jam_awards_jam_track"
    ON "jam_awards" ("jam_id", "award_track", "rank");

-- ===========================================================================
-- 5) jam_score_stats (심사 집계 VIEW. criterion별 평균 + 가중 종합. fan-out 방지)
--    game_review_stats(schema.sql:217) 선례: 출품작 단위 선집계 후 노출.
--    가중 종합 = SUM(criterion_avg * weight) / NULLIF(SUM(weight),0)  (0-division 가드)
--    criterion 미채점 시 그 criterion 은 가중 종합에서 제외(점수 있는 기준만).
-- ===========================================================================
CREATE OR REPLACE VIEW "jam_score_stats" AS
WITH per_criterion AS (
    SELECT
        s."jam_id"                          AS jam_id,
        s."game_id"                         AS game_id,
        s."criterion_key"                   AS criterion_key,
        AVG(s."score")::numeric             AS criterion_avg,
        COUNT(DISTINCT s."judge_user_id")   AS judge_count
    FROM "jam_scores" s
    GROUP BY s."jam_id", s."game_id", s."criterion_key"
)
SELECT
    pc."jam_id"                                     AS jam_id,
    pc."game_id"                                    AS game_id,
    -- 출품작 단위 종합: 채점된 criterion 의 가중 평균
    ROUND(
        SUM(pc.criterion_avg * COALESCE(c."weight", 1.0))
        / NULLIF(SUM(COALESCE(c."weight", 1.0)), 0)
    , 3)                                            AS weighted_total,
    ROUND(AVG(pc.criterion_avg), 3)                 AS simple_total,    -- 비가중 평균(참고)
    COUNT(pc.criterion_key)                         AS scored_criteria, -- 채점된 기준 수
    MAX(pc.judge_count)                             AS judge_count      -- 최다 기준 심사 인원
FROM per_criterion pc
LEFT JOIN "jam_criteria" c
    ON c."jam_id" = pc."jam_id" AND c."criterion_key" = pc."criterion_key"
GROUP BY pc."jam_id", pc."game_id";
COMMENT ON VIEW "jam_score_stats" IS
    'W2-3 동결 심사 집계뷰. 출품작(jam_id,game_id)별 가중 종합·비가중 평균·채점 기준수·심사 인원. fan-out 방지 선집계(game_review_stats 선례).';

criterion별 평균 노출 형태 결정: VIEW 는 출품작 단위 1행(weighted_total/simple_total 종합)으로 노출한다. criterion별 개별 평균이 화면에 필요하면 W2-4 가 per_criterion 상당의 매퍼 쿼리(또는 별도 criterion-level 조회)를 직접 작성한다(본 VIEW 는 종합만 — 출품작 정렬·시상 산정 소비에 최적). 이유: 시상(W2-6) JUDGE 트랙은 출품작 종합점수로 랭크하므로 1행 종합이 핵심 소비 형태이고, criterion 펼침은 표시 전용이라 VIEW fan-out 을 늘리지 않는다.

db/schema.sql 반영 (최초 기동 1회 자동 주입 — docs/jam-eval-ddl.sql 의 사본)

  • W2-1 의 jams/jam_entries 블록 (또는 마지막 테이블 블록 뒤)에 위 1~5 전체를 신설 블록으로 추가.
  • 헤더 주석: -- 잼 평가 W2-3 (권위 DDL — docs/jam-eval-ddl.sql 와 동일. 심사/투표/시상 동결) — game_reviews 블록 schema.sql:128 의 (권위 DDL — docs/...-ddl.sql 와 동일) 선례와 동형(직접 확인).
  • 반영 방식: docs/jam-eval-ddl.sql 이 권위, schema.sql 은 사본. 두 곳에 동일 멱등 DDL. jams/jam_entries 블록보다 에 위치(FK 참조 순서 — schema.sql 은 순차 실행이므로 참조 테이블이 먼저 정의돼야 함).

game_reviews / game_review_stats 무변경 확인 (G4 단방향)

  • W3-2 산출물(game_reviews/game_review_axes/game_review_stats VIEW) 변경 0. 시상 USER_RATING 트랙은 game_review_stats.avg_ratingSELECT 만 한다. game_reviews 에 jam_id FK 추가 없음(code-fact: 리뷰는 잼 무관). → W3-2 리뷰 동작·집계 회귀 0.

외부 계약 (소비 계약 — 본 W2-3 은 스키마+계약 동결. API 본체는 하류)

본 문서는 API 엔드포인트를 신설하지 않는다(스키마+계약 동결이 범위). 아래는 하류 W2-4/5/6 이 준수해야 할 계약이다. 컨트롤러 패턴은 RecruitController(읽기=JSP 뷰, 쓰기=ResponseEntity JSON status/message) + CSRF + 401/403 정책(W1-design 일치)을 따른다.

401 vs 403 정책 (W1-design / W2-1 과 일치 — 하류 강제)

  • 미인증(세션 userId 없음): API 401 JSON {status:401, message:"로그인이 필요합니다."}, 페이지는 redirect:/login.
  • 인증·미인가(심사위원 아님 / 권한 없음): 403 JSON {status:403, message:"권한이 없습니다."}(리다이렉트 금지).
  • 평가기간 외(F6 게이트 위반): 422 JSON {status:422, message:"평가 기간이 아닙니다."} (인가는 됐으나 도메인 상태 위반 → 403 아님 422).
  • CSRF 실패: 403 + CsrfTokens.errorBody().

평가기간 게이트 계약 (F6 — W2-4/W2-5 진입 공통 전제)

  • 점수 입력(W2-4) 허용 조건: jam.status = 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]. 미충족 시 422. AND 심사위원 자격(W2-2 jam_judges 게이트) AND 출품작 존재(jam_entries 활성행). 세 게이트 모두 앱계층.
  • 투표(W2-5) 허용 조건: jam.status = 'EVAL' AND now() ∈ [eval_start_at, eval_end_at] AND 로그인 user_id AND 출품작 존재. 자기 출품작 투표 가부는 W2-5 정책(본 동결은 스키마만 — UNIQUE(jam_id, voter_user_id) 로 1인1표만 강제).
  • 시상 집계(W2-6) 허용 조건: jam.status = 'CLOSED' 또는 eval 종료 후(now() > eval_end_at). 산정은 재실행 가능(멱등 — jam_awards DELETE→INSERT 또는 UPSERT).
  • 게이트 위치: 모두 컨트롤러/서비스 진입부 앱계층. DB CHECK 로 기간을 강제하지 않음(시각 비교는 런타임). DB 는 1인1표·점수범위·트랙값만 강제.

단방향 유저평점 트랙 계약 (G4/F5 — W2-6 소비, 권위)

  • 소스: game_review_stats.avg_rating(overall 단일 평균, numeric). 6축평균(avg_immersion 등)은 시상 산정에 미사용(표시 전용).
  • 방향: 단방향 읽기. 시상이 VIEW 를 SELECT 만, 리뷰 도메인(game_reviews/axes/stats) write 0. game_reviews 에 jam FK 추가 금지(잼 무관 유지).
  • 기간: 평가기간 필터 미적용. 출품 게임의 전체 리뷰 avg_rating 소비(잼 무관 상시 리뷰 → game_review_stats 재집계 부담 회피). 설계 명시 결정(orchestrator 확정).
  • NULL/미달: 리뷰 0개 또는 axes 0행 → avg_rating NULL. 시상 산정 시:
    • 정렬 ORDER BY avg_rating DESC NULLS LAST.
    • 임계: review_count >= 3(기본 N=3) 미달 출품작은 USER_RATING 트랙 제외(소수 리뷰 편향 방지). 임계값은 시상 산정 상수(W2-6 구현에 위치, concern 4 — 잼별 가변 필요 시 확장).
  • 소비 SQL 형태(W2-6 참고):
    SELECT e.game_id, st."avgRating", st."reviewCount"
    FROM jam_entries e
    LEFT JOIN game_review_stats st ON st.game_id = e.game_id
    WHERE e.jam_id = #{jamId} AND e.is_delete IS NOT TRUE
      AND st.review_count >= 3
    ORDER BY st.avg_rating DESC NULLS LAST, e.game_id ASC;
    
    (매퍼 alias: game_review_stats 는 집계 VIEW → camelCase 큰따옴표 AS "avgRating" — GameReviewStatsMapper.java:13 선례.)

집계 노출 계약 (G6/F7 — W2-4/W2-6 소비)

  • 심사 집계: jam_score_stats VIEW. 출품작(jam_id, game_id) 단위 1행 = {weighted_total, simple_total, scored_criteria, judge_count}. W2-4 정렬·W2-6 JUDGE 트랙 랭크 소스. fan-out 방지(per_criterion 선집계).
  • 투표 집계: SELECT game_id, COUNT(*) FROM jam_votes WHERE jam_id = #{jamId} GROUP BY game_id. 별도 VIEW 불필요(단순 count — over-engineering 회피). W2-6 POPULAR 트랙 소스.
  • 시상 트랙 산정(W2-6):
    • JUDGE: jam_score_stats.weighted_total DESC.
    • USER_RATING: game_review_stats.avg_rating DESC NULLS LAST (review_count>=3).
    • POPULAR: jam_votes count DESC.
    • GRAND: 3트랙 결과의 가중 종합(가중치는 jam 설정 또는 동률 규칙 — W2-6 산정 파라미터). 본 동결은 jam_awards.award_track='GRAND' 행 자리만 제공.

매퍼 시그니처 계약 (하류가 구현할 매퍼의 동결 인터페이스 — 시그니처만, 본체는 하류)

본 W2-3 은 매퍼 클래스를 생성하지 않는다. 아래는 하류가 따를 동결 시그니처(snake→camel 직접 alias 일반매퍼 표준, 집계 VIEW 매퍼만 큰따옴표). 최소 인자.

// --- W2-4 소유 (jam_criteria / jam_scores / jam_score_stats) ---
// JamCriteriaMapper (@Mapper, #{} only, snake→camel)
int insertCriterion(JamCriterionData criterion)   // 잼 심사기준 등록(관리자)
List<JamCriterionData> listByJam(long jamId)       // 점수입력 폼·집계 라벨

// JamScoresMapper (@Mapper, #{} only)
int upsertScore(long jamId,            // 평가단위 1/2
                long gameId,           // 평가단위 2/2(출품작)
                long judgeUserId,      // 심사위원(세션)
                String criterionKey,   // 채점 기준
                int score)             // 1~5 (UNIQUE 충돌 시 UPDATE — ON CONFLICT 또는 exists→update)
List<JamScoreData> listByJudge(long jamId, long judgeUserId)  // 심사위원 본인 입력 현황

// JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias)
List<Map<String,Object>> listStatsByJam(long jamId)  // 출품작별 종합(정렬·시상 소스)
Map<String,Object> getStats(long jamId, long gameId) // 단건 출품작 종합

// --- W2-5 소유 (jam_votes) ---
// JamVotesMapper (@Mapper, #{} only)
int castVote(long jamId, long gameId, long voterUserId)  // 투표(UNIQUE 1인1표; 변경=update game_id)
int updateVote(long jamId, long voterUserId, long gameId) // 표 변경(최애 교체)
boolean hasVoted(long jamId, long voterUserId)            // 1인1표 사전 체크
long countByGame(long jamId, long gameId)                 // 출품작 득표(또는 listCounts 집계)

// --- W2-6 소유 (jam_awards + 3트랙 소비) ---
// JamAwardsMapper (@Mapper, #{} only)
int upsertAward(JamAwardData award)               // 트랙·순위 수상 기록(재산정 멱등)
int deleteByJamTrack(long jamId, String track)    // 재산정 전 트랙 초기화
List<JamAwardData> listByJam(long jamId)           // 시상 결과 노출

inflate 마킹(concern 2): 위 시그니처는 계약 골격이다. 하류 구현 시 각 인자가 실제 SQL/로직에 쓰이는지 재확인(예: JamScoresMapper.upsertScore 를 ON CONFLICT 로 구현하면 별도 exists 조회 불요 — exists 메서드 추가 inflate 금지). 매퍼 본체·@MockBean 등록은 하류 소관.


시퀀스 (계약 검증용 의사코드 — 하류 구현이 따를 흐름)

S1. 심사 점수 입력 (W2-4 — 본 동결 스키마 소비)

[심사위원 세션] POST /jams/{slug}/scores (CSRF, gameId, {criterionKey: score, ...})
  → (W2-4 컨트롤러)
      → CsrfTokens.isValid                              (아니면 403)
      → userId = sessionUserId                          (없으면 401)
      → jam = jamsMapper.getBySlug(slug)                (없으면 404)
      → 평가기간 게이트(F6): jam.status=='EVAL' AND now∈[eval_start,eval_end]?  (아니면 422)
      → 출품작 존재: jamEntriesMapper.exists(jam.id, gameId)?  (아니면 404/422)
      → 심사위원 자격(W2-2): jamJudgesGate.isJudge(jam.id, userId)?  (아니면 403)
      → for (criterionKey, score) in 입력:
            jamScoresMapper.upsertScore(jam.id, gameId, userId, criterionKey, score)
      → 200 {message}
   # jam_score_stats VIEW 가 자동 반영(집계는 읽기 시점 계산).

S2. 인기투표 (W2-5 — 본 동결 스키마 소비)

[로그인 유저] POST /jams/{slug}/votes (CSRF, gameId)
  → (W2-5 컨트롤러)
      → CsrfTokens.isValid                              (아니면 403)
      → userId = sessionUserId                          (없으면 401)
      → jam = getBySlug(slug); 평가기간 게이트(F6)        (아니면 422)
      → 출품작 존재(jam_entries 활성)?                   (아니면 404)
      → hasVoted(jam.id, userId)?
          미투표 → castVote(jam.id, gameId, userId)       (UNIQUE 보장 1인1표)
          기투표 → updateVote(jam.id, userId, gameId)     (최애 교체 정책 — W2-5 결정)
      → 200 {message, votedGameId}

S3. 시상 집계 확정 (W2-6 — 3트랙 + grand, 본 동결 스키마+계약 소비)

[관리자] POST /admin/jams/{jamId}/awards/compute (CSRF, GAME_JAM_MANAGE 게이트)
  → (W2-6 컨트롤러)
      → requireJamManage + CSRF
      → jam.status=='CLOSED' OR now()>eval_end_at?       (아니면 422 — 시상 미개방)
      → JUDGE 트랙:    jam_score_stats.weighted_total DESC → rank 부여 → upsertAward(track='JUDGE')
      → USER_RATING:   game_review_stats.avg_rating DESC NULLS LAST, review_count>=3
                       → rank → upsertAward(track='USER_RATING')   # 단방향 읽기(G4)
      → POPULAR:       jam_votes count DESC → rank → upsertAward(track='POPULAR')
      → GRAND:         3트랙 가중 종합(가중치 jam 설정/동률규칙) → 최상위 → upsertAward(track='GRAND')
      → 200 {message, awardCounts}
   # 재실행 = deleteByJamTrack 후 재산정(멱등). game_reviews write 0.

파일 영향 맵

본 W2-3 은 스키마+계약 동결이 범위다. 신규 = DDL 권위 파일 + schema.sql 동기 2건만. 매퍼/data POJO/컨트롤러/JSP/테스트는 하류 W2-4/5/6 소유(아래 "하류 소유 — 본 설계 미생성" 표는 계약 추적용으로만 명시, 본 설계가 만들지 않음).

소유권 분할(본 W2-3 worker 단위): E-SCHEMA(jam-eval-ddl.sql + schema.sql 동기) 단일. data POJO 계약(JamCriterionData/JamScoreData/JamAwardData 등)은 하류가 자기 워크스트림에서 생성.

본 W2-3 이 생성/수정 (동결 산출물)

변경 유형 경로 역할 소유
신규 docs/jam-eval-ddl.sql 권위 DDL(jam_criteria/jam_scores/jam_votes/jam_awards/jam_score_stats VIEW). apply-local-ddl.sh 자동 적용(알파벳: jam-ddl 뒤) E-SCHEMA
수정 db/schema.sql 위 4테이블+1뷰 블록 추가(jam-eval-ddl 사본). jams/jam_entries 블록 뒤. game_reviews/games 무변경 E-SCHEMA

하류 소유 — 본 설계 미생성 (계약 추적용 — crossRefs)

소유 W 생성물(예) 본 동결 의존점
W2-4 JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper + data POJO + 점수입력 컨트롤러/JSP jam_criteria/jam_scores/jam_score_stats VIEW + 평가기간 게이트(F6) + 집계 계약(G6)
W2-5 JamVotesMapper + data + 투표 컨트롤러/JSP jam_votes UNIQUE(1인1표) + 평가기간 게이트(F6)
W2-6 JamAwardsMapper + data + 시상 산정/확정 컨트롤러/JSP jam_awards 3트랙 + 단방향 유저평점 계약(G4/F5) + 집계 계약(G6) + jam_score_stats/game_review_stats/jam_votes count
W2-2 jam_judges 게이트 jam_scores.judge_user_id 의 is-judge 검증(앱계층)

SSR 호출지점 영향(verification §영향맵): 본 W2-3 은 신규 DDL/VIEW 만 추가, 기존 매퍼·JSP·컨트롤러 무변경 → 기존 소비처 0 영향. game_review_stats VIEW 무변경이므로 W3-2 리뷰 요약 화면 회귀 0.


대안 비교

주제 장점 단점 채택
평가단위 식별자 (A) (jam_id, game_id) 자연키 + 앱계층 출품검증 orchestrator 동결 컬럼 일치, game_review_stats join 정합, entrant 불투명 유지 FK 출품 보장은 앱계층(jam_entries 활성행) 채택(F8)
(B) jam_entries.id 직접 FK 출품 보장 DB 강제 game_review_stats(game_id)와 join 불일치, orchestrator 동결과 어긋남, entrant.id 노출 기각(concern 1 재확인 마킹)
심사 척도 (A) 잼별 설정형 jam_criteria 잼마다 기준 다름(정석), 가중 종합 가능 criteria 테이블 1개 채택(F1)
(B) 고정 6축(리뷰 axes 재사용) 단순 잼별 심사 기준 다양성 부정, 리뷰축과 의미 강결합 기각
유저평점 트랙 소스 (A) avg_rating 단일평균 단순·명확, 리뷰 6축과 의미 충돌 회피, VIEW 그대로 6축 정보 미반영 채택(F5)
(B) 6축 가중 평균 다면 평가 잼 criteria 와 의미 이중화, 6축 NULL 처리 복잡 기각(6축=표시전용)
유저평점 기간 (A) 전체 리뷰 avg_rating game_review_stats 재집계 0, 리뷰=잼무관 상시 정합 평가기간 외 리뷰도 반영 채택(F5)
(B) 평가기간 내 리뷰만 기간 정합 game_review_stats 기간필터 재집계 부담, 리뷰 도메인 잼 결합 유발 기각
인기투표 저장 (A) jam_votes 신규(1인1표 UNIQUE) 1인1표 DB 강제, user_id 기반, 잼 무관 game_likes 분리 테이블 1개 채택(F3)
(B) game_likes 재활용 재사용 비권위 복원본·user_key varchar·1인1표 비보장(grounding R-D) 기각
심사 집계 노출 (A) jam_score_stats VIEW(선집계) fan-out 방지(game_review_stats 선례), 정렬/시상 소스 단일 VIEW 1개 채택(F7)
(B) 매퍼 GROUP BY 매번 VIEW 없음 소비처마다 집계 SQL 중복, fan-out 위험 반복 기각
투표 집계 (A) COUNT 쿼리(VIEW 없음) 단순, over-engineering 회피 채택(F7)
(B) jam_vote_stats VIEW 일관성 단순 count 에 VIEW 과도 기각

롤아웃 / 마이그레이션

순서

  1. 스키마 적용: docs/jam-eval-ddl.sqldb/apply-local-ddl.sh(로컬). 운영은 동일 멱등 DDL 수동 적용.
    • 선행 의존: jam-eval-ddl 의 FK 가 jams/jam_entries(W2-1 docs/jam-ddl.sql) + games/users(기존) 를 참조 → jam-ddl.sql 이 먼저 적용돼야 함. apply-local-ddl.sh 알파벳 글롭에서 jam-ddl.sql < jam-eval-ddl.sql(공통 prefix jam-d < e) → 순서 자동 보장. game_reviews(W3-2 docs/game-reviews-ddl.sql, g < j)도 먼저 적용됨 → game_review_stats VIEW 존재 보장(시상 소비 가능).
  2. 하류 코드 배포: W2-4/5/6 가 본 동결 스키마를 소비하는 매퍼/컨트롤러 구현. 본 W2-3 은 코드 배포 없음(스키마만).
  3. 권한: GAME_JAM_MANAGE(W1 시드) + jam_judges(W2-2). 본 W2-3 추가 시드 없음.

역호환

  • games/game_reviews/game_review_stats/game_likes/jams/jam_entries 전부 무변경. 기존 사용자·리뷰·게임 동작 0 영향.
  • 신규 테이블/VIEW 는 추가 전용(비파괴). 하류 미배포 상태에서도 빈 테이블/VIEW 로 잔존 무해.

롤백

  • 스키마 롤백: 신규 4테이블+1뷰는 추가 전용 → drop 없이 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance(DROP VIEW IF EXISTS jam_score_stats; DROP TABLE IF EXISTS jam_awards, jam_votes, jam_scores, jam_criteria CASCADE; — FK 역순).
  • 코드 롤백: 본 W2-3 은 코드 0 → 롤백 대상 없음. 하류 롤백은 각 워크스트림 소관.

AC 매핑

AC 요구(골자 W2-3) 만족 설계 요소 비고
AC-1 심사 척도 = 잼별 설정형 criteria jam_criteria(jam_id, criterion_key, weight) + ux_jam_criteria_jam_key F1, §데이터모델1
AC-2 심사 점수 1~5, 1심사위원1기준1점 jam_scores score CHECK 1~5 + ux_jam_scores_jam_game_judge_criterion F2, §데이터모델2
AC-3 인기투표 잼당 1인1표 jam_votes ux_jam_votes_jam_voter UNIQUE F3, §데이터모델3
AC-4 투표 game_likes 와 별개 jam_votes 신규 테이블(user_id 기반), game_likes 무참조 F3, §대안
AC-5 시상 3트랙 + grand jam_awards award_track CHECK 4값(JUDGE/USER_RATING/POPULAR/GRAND) F4, §데이터모델4
AC-6 ★유저평점 트랙 단방향(avg_rating 단일) game_review_stats.avg_rating 읽기만, 6축 미사용, game_reviews 무변경 G4/F5, §단방향계약
AC-7 유저평점 NULL/미달 처리 NULLS LAST + review_count>=3 임계 F5, §단방향계약
AC-8 평가기간 게이트(점수/투표 EVAL, 시상 CLOSED) F6 계약(앱계층 jam.status + now∈eval 구간) §평가기간게이트계약
AC-9 심사 집계 fan-out 방지 VIEW jam_score_stats per_criterion 선집계 + 가중종합 NULLIF 가드 F7/난제3, §데이터모델5
AC-10 투표 집계 = count jam_votes COUNT 계약(VIEW 없음) F7, §집계노출계약
AC-11 권한/평가 SQL ${} 0 DDL·계약 매퍼 시그니처 #{} only(하류 강제) §매퍼시그니처계약
AC-12 집계 VIEW 매퍼 alias 큰따옴표 game_review_stats/jam_score_stats 소비 매퍼 AS "..." §단방향계약/집계계약

검증 포인트 (verification-advisor 점검 대상)

L레벨 매핑(verification-strategies): 동결 스키마 무결성(CHECK/UNIQUE/FK)·집계 VIEW(fan-out/NULL/0-division) = L1+L2(dev DB contract). 단방향 계약·평가기간 게이트 = L1(스키마 차원) + 하류 구현 시 L3. 본 W2-3 은 코드 빈 추가 0이므로 contextLoads(§30 @MockBean)는 하류 책임(본 설계 매퍼 미생성).

시나리오 검증

  • VP-1 (AC-2/3 무결성, L2): jam_scores 같은 (jam_id,game_id,judge,criterion) 중복 INSERT → UNIQUE 거부. score 0/6 INSERT → CHECK 거부. jam_votes 같은 (jam_id, voter) 2표 INSERT → UNIQUE 거부(1인1표). dev DB contract 실측.
  • VP-2 (AC-9 집계 정합, L2): jam_score_stats 가 동일 출품작에 다수 심사위원·다수 criterion 입력 시 ① fan-out 없이 weighted_total 정확(SUM(avg*weight)/SUM(weight)) ② criterion 일부 미채점 시 채점 기준만 반영 ③ weight 전부 0 인 경계에서 0-division 없이 NULL(NULLIF 가드) — 샘플 데이터 실측(game_review_stats BUG-1 fan-out 선례 재발 방지).
  • VP-3 (AC-6 단방향, L1): 시상 USER_RATING 소비 SQL 이 game_review_stats 를 SELECT 만(write 0), game_reviews 에 jam FK 부재 확인(code 정합). 6축 컬럼 미참조 확인.
  • VP-4 (AC-7 NULL/미달, L2): 리뷰 0개 출품작 avg_rating NULL → NULLS LAST 정렬 말단 + review_count<3 제외. review_count==3 경계 포함. 샘플 실측.
  • VP-5 (AC-5 트랙값, L2): jam_awards award_track 에 'JUDGE'/'USER_RATING'/'POPULAR'/'GRAND' 외 값 INSERT → CHECK 거부.
  • VP-6 (FK 적용 순서, L2): apply-local-ddl.sh 가 jam-ddl(W2-1) 적용 후 jam-eval-ddl 적용 시 FK 생성 성공(jams/jam_entries/games/users 선존재). 단독/역순 적용 시 FK 실패 검출.
  • VP-7 (AC-12 alias, L2): 하류 jam_score_stats 매퍼 반환 키가 weightedTotal/simpleTotal/scoredCriteria/judgeCount 로 정합(집계 VIEW camelCase 큰따옴표 alias 확인 — GameReviewStatsMapper 케이스폴딩 BUG-2 선례 회피).

집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)

self-audit(시점): 아래 카운트는 본 W2-3 이 신규 생성하는 정적 산출물(DDL 테이블·VIEW·CHECK·트랙값)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 하류(W2-4/5/6)는 본 동결 스키마를 소비만 하고 본 DDL 파일을 수정하지 않으므로(소유 분리), 본 파일 카운트는 verification 시점에 불변. self-audit(표현): 단일 리터럴 grep 취약성을 피해 CHECK IN 목록/트랙 enum/테이블 생성문 같은 구조적 불변식에 앵커. 매퍼 ${ 0건은 부재 검증이라 리터럴 정당.

  • AC-T1 잼 평가 신규 테이블 전수 4건 + VIEW 1건 — docs/jam-eval-ddl.sql 의 CREATE TABLE IF NOT EXISTS 4건(jam_criteria/jam_scores/jam_votes/jam_awards) AND CREATE OR REPLACE VIEW 1건(jam_score_stats): grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-eval-ddl.sql == 4 AND grep -c 'CREATE OR REPLACE VIEW' docs/jam-eval-ddl.sql == 1. AND db/schema.sql 에 동일 4테이블+1뷰 전수 존재(동기 사본 누락 검출). 테이블/뷰 추가·삭제 누락을 갯수로 동시 커버.
  • AC-T2 시상 트랙 전수 4종 정합 불변식 — jam_awards_track_check CHECK 의 IN 목록(JUDGE/USER_RATING/POPULAR/GRAND) 4종 == 시상 산정(W2-6)이 upsert 하는 award_track 집합. 검증: DDL CHECK IN 항목 4 AND (하류 W2-6 구현 시) 3개별 트랙 + GRAND 전수 산정 경로 존재. 트랙 추가·누락을 갯수 1로 커버(F4 핵심 가드).
  • AC-T3 점수/투표 무결성 제약 전수 — 동결 핵심 UNIQUE/CHECK 4종 전수 존재: ux_jam_scores_jam_game_judge_criterion(1심사위원1기준1점), ux_jam_votes_jam_voter(1인1표), jam_scores_score_check(1~5), jam_awards_track_check(4트랙). 검증: grep -c 'CREATE UNIQUE INDEX' docs/jam-eval-ddl.sql >= 4(criteria/scores/votes/awards 각 1) AND 위 4 제약명 전수 존재. 1건 누락 = 무결성 결함(1인1표/중복채점 우회) → FAIL.
  • AC-T4 평가 매퍼 시그니처 계약 전수 ${ 0건(하류 검증) — 하류 W2-4/5/6 가 본 계약대로 구현한 신규 매퍼 전수에 ${ 매치 0: grep -rc '\${' <하류 jam-eval 매퍼들> == 0 (AC-11, ${} 금지). 부재 검증이라 리터럴 정당. 본 W2-3 은 매퍼 미생성 → 이 AC 는 하류 verification 시점 검사(계약 위임 명시).
  • AC-T5 단방향 무결성(write 0) 불변식 — game_reviews/game_review_axes/game_review_stats 가 본 동결로 인해 변경 0: docs/jam-eval-ddl.sql 에 game_reviews/game_review 토큰의 ALTER/CREATE/INSERT/UPDATE 0건(읽기 계약뿐 — 주석/SELECT 형태 예시는 무방하나 DDL 변경문 0). 검증: grep -E 'ALTER TABLE .*game_review|CREATE TABLE .*game_review' docs/jam-eval-ddl.sql 0건. 단방향 계약(G4) 위반(시상이 리뷰 스키마 손대기) 즉시 검출.
  • AC-T6 FK 적용 순서 불변식 — jam-eval-ddl 의 FK 가 참조하는 선행 테이블 전수(jams/jam_entries/games/users) 가 apply 시점에 존재: 알파벳 글롭 순서상 jam-ddl/game-reviews-ddl 가 jam-eval-ddl 보다 먼저(공통 prefix 비교) → FK 생성 성공. 검증: apply-local-ddl.sh dry-run 또는 dev DB 전체 적용 후 jam_scores/jam_votes/jam_awards/jam_criteria 의 FK 4종 전수 생성 확인(pg_constraint). 순서 깨짐 시 FK 미생성 검출.

잔여 오픈 질문

없음(0). 동결 결정 F1~F8 전제 고정. 세 난제(평가단위 식별자 정합·단방향 유저평점 계약·집계 fan-out/가중/NULL)는 본 설계가 구체 메커니즘으로 확정. 평가기간 게이트·집계 노출·매퍼 시그니처 계약 동결. 구현 점검 항목(평가단위 jam_entries.id vs game_id 재확인·하류 @MockBean·VIEW 0-division dev contract·최소리뷰수 임계 가변화·jam_entries UNIQUE 동결 유지)은 오픈 질문이 아니라 concerns 로 이관.