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-잼 평가 통합설계(스키마 동결) |
|
true |
|
|
설계: 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_statsVIEW.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_statsVIEW(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·UI — W2-5 소유. 본 설계는 jam_votes 스키마 + 1인1표 UNIQUE + 평가기간 게이트 계약만.
- 시상 산정 알고리즘 본체·확정 UI — W2-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_statsVIEW 를 읽기만(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_statsVIEW 는 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, DOguard, 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_rating을 SELECT 만 한다. 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 참고):
(매퍼 alias: game_review_stats 는 집계 VIEW → camelCase 큰따옴표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;AS "avgRating"— GameReviewStatsMapper.java:13 선례.)
집계 노출 계약 (G6/F7 — W2-4/W2-6 소비)
- 심사 집계:
jam_score_statsVIEW. 출품작(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 과도 | 기각 |
롤아웃 / 마이그레이션
순서
- 스키마 적용:
docs/jam-eval-ddl.sql→db/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(공통 prefixjam-뒤d<e) → 순서 자동 보장. game_reviews(W3-2 docs/game-reviews-ddl.sql,g<j)도 먼저 적용됨 → game_review_stats VIEW 존재 보장(시상 소비 가능).
- 선행 의존: jam-eval-ddl 의 FK 가 jams/jam_entries(W2-1 docs/jam-ddl.sql) + games/users(기존) 를 참조 → jam-ddl.sql 이 먼저 적용돼야 함. apply-local-ddl.sh 알파벳 글롭에서
- 하류 코드 배포: W2-4/5/6 가 본 동결 스키마를 소비하는 매퍼/컨트롤러 구현. 본 W2-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 EXISTS4건(jam_criteria/jam_scores/jam_votes/jam_awards) ANDCREATE OR REPLACE VIEW1건(jam_score_stats):grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-eval-ddl.sql== 4 ANDgrep -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.sql0건. 단방향 계약(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 로 이관.