bibimbap/.atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md

48 KiB

phase agent agent_version generated_at workstream concerns concerns_checked self_verification references
design design-advisor 1 2026-06-23T12:30:00+09:00 W2-1-게임잼 엔티티/라이프사이클
JamService / 컨트롤러의 신규 헬퍼 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 entrant 분기 헬퍼 resolveEntrant(...).
신규 컨트롤러(JamController/JamAdminController) + 신규 매퍼(JamsMapper/JamEntriesMapper/JamTeamsMapper/JamStatusLogMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException.
신규 매퍼 SQL 은 DB-방언 계약(L2) 대상 — snake→camel 직접 alias(jam.created_at AS createdAt)가 일반 매퍼 표준(verification-strategies §33). 큰따옴표 alias 는 집계 VIEW 매퍼만. keyset 커서 비교(created_at,id) 의 PostgreSQL row-comparison 또는 OR 분해 SQL 은 dev DB contract 로 실측 검증 권장.
games↔jam 연결 = 조인테이블 jam_entries 로 확정(games 무변경). **평가 단위 = (jam_id, game_id) 활성 자연키**(W2-3 동결 권위) — jam_entries 의 ux_jam_entries_jam_game_active(jam_id,game_id 활성 UNIQUE)가 이 자연키를 활성 출품작과 1:1 보장한다. W2-4/5/6 FK 는 game_id→games·jam_id→jams 직접이고 '출품 여부'는 앱계층 jam_entries 활성행 존재로 검증(jam_entries.id surrogate FK 아님). 이 (jam_id,game_id) 활성 UNIQUE 계약을 동결 전 변경 금지. crossRefs 참조.
잼 상태 자동전이(스케줄러)는 본 설계에서 훅 인터페이스만 정의하고 구현은 후속(W2-1 범위 밖). @Scheduled 빈 도입 시 BibimbapApplicationTests context 영향 재확인 — 본 설계는 수동 전이 enforcement 만 구현 범위.
잼 slug 는 사용자 비입력(서버 생성) 정석. slug 충돌 시 재시도 로직 필요 — 구현 점검 항목.
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/20260622-180054/implementation/W1-design.md
docs/work-log/2026-06-17-jam-platform-roadmap.md
docs/development/verification-strategies.md
docs/rbac-ddl.sql

설계: W2-1 — 게임잼 엔티티 + 라이프사이클 (jams / jam_entries / jam_teams + 관리자 CRUD + 공개 목록·상세 + GAME_JAM_MANAGE enforcement)

목표 / 비목표

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

  • G1 잼 엔티티: 회차 독립 게임잼(jams) 도입. 모집→개발→평가→종료 라이프사이클 명시 컬럼 + 기간 필드.
  • G2 잼-게임 연결(정규화): 출품작 = 기존 games 재사용 + 조인테이블 jam_entries(games 무변경). 잼당 게임 1회 출품(UNIQUE).
  • G3 출품 주체 = 개인 OR 팀: jam_teams/jam_team_members + jam_entries.entrant_type CHECK('USER','TEAM'). 둘 중 하나 NOT NULL(CHECK).
  • G4 관리자 잼 CRUD: 잼 생성/수정/상태전이/삭제. GAME_JAM_MANAGE 게이트 enforcement 연결(W1 인프라 위, 임시 role 직접체크 금지).
  • G5 공개 페이지: 잼 목록(/jams) + 상세(/jams/{slug}). RecruitController 패턴(읽기=JSP 뷰, 쓰기=JSON+CSRF). keyset 페이징.
  • G6 상태전이 + 감사: 관리자 수동 전이(기간 정합 검증) + 전이 감사 기록(jam_status_log). 자동전이는 훅만(후속).
  • G7 이중 노출: 출품작은 잼 전용 뷰 + 기존 일반 게임 허브(games.is_visible 유지) 둘 다 노출.
  • G8 운영 표시 필드: discord_url/prize_info/sponsor_info(jams 컬럼, 표시 전용 — 실지급 수동).
  • NFR: 상태변경 CSRF 전수, #{} 바인딩(${} 금지), 입력 sanitize/길이 검증, 비파괴 멱등 마이그레이션, DDL 권위=docs/*-ddl.sql.

비목표 (스코프 밖)

  • 평가/심사/투표/시상 스키마(jam_criteria/jam_scores/jam_votes/jam_awards) — W2-3 동결 소유. 본 설계는 평가 단위인 (jam_id, game_id) 활성 자연키(jam_entries 의 활성 UNIQUE 로 1:1 보장)를 제공만. W2-3 동결은 이 자연키를 직접 FK(game_id→games·jam_id→jams)로 채택하며 jam_entries.id surrogate 를 FK 로 쓰지 않는다.
  • 심사위원 역할/권한(jam_judges) — W2-2 소유. 여기서 만들지 않음.
  • 잼 투표 1인1표(W2-5), 시상 집계(W2-6) — 별도.
  • 상태 자동전이 스케줄러 본체 — 본 설계는 전이 검증 메서드 + 훅 인터페이스만. @Scheduled 구현은 후속.
  • 팀 초대/승인 워크플로 고도화 — 1차는 팀 생성 + 멤버 직접 추가까지. 초대 수락 플로우는 후속.
  • 게임 업로드 경로 변경 — 기존 /game/new 생성 경로 무변경. 잼 연결은 별도 출품(entry) 액션.

개요

bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation @Mapper(#{} only) + JSP 스택이다. 현재 jams 테이블·jam_id 는 전무하고(grounding R-C: games 13컬럼에 jam_id 부재, jam grep 0 hit), GAME_JAM_MANAGE 권한 키는 enum 선언만 있고 소비처 0건이다(grounding R-A: PermissionKeys.java:4). RBAC 인프라(PermissionGate.has(session, key) 2인자, RbacInterceptor /admin/** + isAdmin, epoch 전파 refreshIfStale)는 W1 에서 완비됐다(PermissionGate.java:22,86 직접 확인).

본 설계는 게임잼 엔티티 4테이블(+감사 1)을 신규 도입하고, GAME_JAM_MANAGE 게이트를 관리자 잼 CRUD enforcement 에 연결하며, 공개 목록/상세를 RecruitController 패턴 + keyset 페이징으로 제공한다.

확정된 정석 결정(전제):

  • 연결 = 조인테이블 jam_entries (games.jam_id 컬럼 끼워넣기 기각). games 무변경 → 일반 허브 노출/삭제연쇄 회귀 0, 정규화, entry 메타데이터 보유 가능.
  • 출품 주체 = 개인 OR 팀 둘 다 1차 포함 (잼은 팀 이벤트 본질). jam_entries.entrant_type + entrant_user_id/jam_team_id 둘 중 하나 NOT NULL(CHECK).
  • 상태 = 명시 컬럼 + 관리자 수동 전이(감사) + 자동전이 훅(후속).
  • enforcement 갭 메우기: RbacInterceptor 는 /admin/** + isAdmin 만이므로 SUBADMIN+GAME_JAM_MANAGE 는 인터셉터를 통과 못 한다(isAdmin 은 ADMIN 만 true, PermissionGate.java:55-62 확인). 따라서 /admin/jams/** 컨트롤러 진입부에 PermissionGate.has(session, GAME_JAM_MANAGE.name()) 게이트 헬퍼를 적용(W1-design 의 "콘솔=URL패턴, 소비 액션=게이트 헬퍼" 선례 그대로). 임시 role 직접체크 금지.

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

  • 난제1 (개인/팀 이중 주체 정합): jam_entries 단일 테이블 + entrant_type CHECK('USER','TEAM') + entrant_user_id(nullable) + jam_team_id(nullable) + XOR CHECK(둘 중 정확히 하나 NOT NULL). 출품 노출/집계 쿼리는 entrant_type 분기 없이 game_id 로 단일화. 평가 단위 = (jam_id, game_id) 활성 자연키(W2-3 동결) — jam_entries active-UNIQUE 가 활성 출품작과 1:1 보장하므로 W2-3/4/5/6 은 entrant 종류를 몰라도 (jam_id, game_id) 만 참조한다(jam_entries.id surrogate 아님).
  • 난제2 (상태전이 정합·감사·자동전이 공존): 상태는 jams.status 명시 컬럼(CHECK 4값)이 단일 진실. 관리자 수동 전이는 허용 전이 그래프(RECRUIT→DEV→EVAL→CLOSED + 역행/취소 제한)를 JamLifecycle 도메인이 검증하고, 전이마다 jam_status_log insert(감사). 자동전이는 같은 JamLifecycle.transition(...) 코어를 호출하는 훅 인터페이스만 정의(스케줄러 본체는 후속) → 수동/자동이 동일 검증·감사 경로를 공유(중복 0).

핵심 결정 요약 (전제 — 재논의 금지)

결정 확정값 본 설계의 구체화
D1 연결 조인테이블 jam_entries games 무변경. UNIQUE(jam_id, game_id) = 잼당 게임 1회. entry 메타 보유
D2 출품 주체 개인 OR 팀 둘 다 jam_teams/jam_team_members + entrant_type CHECK + XOR CHECK(user/team 정확히 하나)
D3 상태 명시 컬럼 + 수동전이(감사) + 자동전이 훅 jams.status CHECK('RECRUIT','DEV','EVAL','CLOSED') + JamLifecycle 전이 그래프 + jam_status_log
D4 enforcement GAME_JAM_MANAGE 게이트 헬퍼 /admin/jams/** 컨트롤러 진입부 PermissionGate.has(session, GAME_JAM_MANAGE.name()). 인터셉터 미등록(SUBADMIN 통과 위함)
D5 공개 페이지 /jams 목록 + /jams/{slug} 상세 RecruitController 패턴. keyset 페이징(created_at,id 커서)
D6 이중 노출 잼 전용 뷰 + 일반 허브 jam_entries JOIN games. games.is_visible 유지(허브 동시 노출)
D7 운영 표시 jams 컬럼 표시 전용 discord_url/prize_info/sponsor_info — 실지급 수동, 표시만
D8 식별자 slug(서버 생성) URL 안전 slug, 잼당 UNIQUE. 내부 join 은 jam_id(bigint)

데이터 모델 (DDL)

권위 = 신규 파일 docs/jam-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(직접 확인). games 변경 없음(연결은 jam_entries 보유). 멱등: CREATE TABLE/SEQUENCE IF NOT EXISTS, DO guard, CREATE UNIQUE INDEX IF NOT EXISTS, ALTER ADD COLUMN IF NOT EXISTS. 타입은 기존 스타일(bigint/varchar/timestamptz/text).

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

-- W2-1 게임잼 엔티티/라이프사이클. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- games 변경 없음(연결은 jam_entries 가 보유). 추가만, 파괴 없음.

-- ===========================================================================
-- 1) jams (게임잼 회차. 회차 독립 = 다중 인스턴스)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jams_id_seq";
CREATE TABLE IF NOT EXISTS "jams" (
    "id"             bigint DEFAULT nextval('jams_id_seq'::regclass) NOT NULL,
    "slug"           character varying(80)  NOT NULL,        -- URL 식별자(서버 생성, UNIQUE)
    "title"          character varying(200) NOT NULL,
    "description"    text,
    "status"         character varying(20)  DEFAULT 'RECRUIT' NOT NULL,  -- 라이프사이클
    "recruit_start_at" timestamp with time zone,             -- 모집 시작(기간 정합 검증용)
    "dev_start_at"     timestamp with time zone,             -- 개발 시작
    "eval_start_at"    timestamp with time zone,             -- 평가 시작(W2-4/5 게이트 기준)
    "eval_end_at"      timestamp with time zone,             -- 평가 종료(=종료 전이 기준)
    "discord_url"      character varying(500),               -- 운영 표시 전용
    "prize_info"       text,                                 -- 운영 표시 전용(실지급 수동)
    "sponsor_info"     text,                                 -- 운영 표시 전용
    "is_visible"     boolean DEFAULT true  NOT NULL,         -- 공개 목록 노출
    "created_by"     bigint,                                 -- 생성 관리자(감사 보조)
    "created_at"     timestamp with time zone DEFAULT now() NOT NULL,
    "updated_at"     timestamp with time zone DEFAULT now() NOT NULL,
    "is_delete"      boolean DEFAULT false NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jams_id_seq" OWNED BY "jams"."id";

-- status 값집합 CHECK(멱등)
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jams_status_check') THEN
        ALTER TABLE "jams"
            ADD CONSTRAINT "jams_status_check"
            CHECK ("status" IN ('RECRUIT', 'DEV', 'EVAL', 'CLOSED'));
    END IF;
END
$$;

-- slug 활성 UNIQUE(삭제분 제외 — recruit/games 의 active-unique 선례와 동형)
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jams_slug_active"
    ON "jams" ("slug") WHERE "is_delete" IS NOT TRUE;
-- 공개 목록 keyset 페이징 인덱스(created_at DESC, id DESC 커서)
CREATE INDEX IF NOT EXISTS "idx_jams_visible_keyset"
    ON "jams" ("is_visible", "is_delete", "created_at" DESC, "id" DESC);

-- ===========================================================================
-- 2) jam_teams (잼별 팀. 잼 회차에 종속 = 회차 독립)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_teams_id_seq";
CREATE TABLE IF NOT EXISTS "jam_teams" (
    "id"          bigint DEFAULT nextval('jam_teams_id_seq'::regclass) NOT NULL,
    "jam_id"      bigint NOT NULL,
    "name"        character varying(120) NOT NULL,
    "owner_user_id" bigint NOT NULL,                         -- 팀 생성자(팀장)
    "created_at"  timestamp with time zone DEFAULT now() NOT NULL,
    "is_delete"   boolean DEFAULT false NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_teams_id_seq" OWNED BY "jam_teams"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_teams_jam_id_fkey') THEN
        ALTER TABLE "jam_teams" ADD CONSTRAINT "jam_teams_jam_id_fkey"
            FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_teams_owner_fkey') THEN
        ALTER TABLE "jam_teams" ADD CONSTRAINT "jam_teams_owner_fkey"
            FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id");
    END IF;
END
$$;
CREATE INDEX IF NOT EXISTS "idx_jam_teams_jam" ON "jam_teams" ("jam_id");

-- ===========================================================================
-- 3) jam_team_members (팀 멤버. 한 유저는 한 팀에 1회 — 잼 내 중복 가입은 멤버십 UNIQUE)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_team_members_id_seq";
CREATE TABLE IF NOT EXISTS "jam_team_members" (
    "id"           bigint DEFAULT nextval('jam_team_members_id_seq'::regclass) NOT NULL,
    "jam_team_id"  bigint NOT NULL,
    "user_id"      bigint NOT NULL,
    "created_at"   timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_team_members_id_seq" OWNED BY "jam_team_members"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_team_members_team_fkey') THEN
        ALTER TABLE "jam_team_members" ADD CONSTRAINT "jam_team_members_team_fkey"
            FOREIGN KEY ("jam_team_id") REFERENCES "jam_teams" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_team_members_user_fkey') THEN
        ALTER TABLE "jam_team_members" ADD CONSTRAINT "jam_team_members_user_fkey"
            FOREIGN KEY ("user_id") REFERENCES "users" ("id");
    END IF;
END
$$;
-- 같은 팀에 같은 유저 중복 가입 방지
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_team_members_team_user"
    ON "jam_team_members" ("jam_team_id", "user_id");

-- ===========================================================================
-- 4) jam_entries (출품작 = 잼-게임 연결 조인. 평가 단위 = (jam_id, game_id) 활성 자연키 — W2-3/4/5/6 참조점, jam_entries.id surrogate 아님. active-UNIQUE 가 1:1 보장)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_entries_id_seq";
CREATE TABLE IF NOT EXISTS "jam_entries" (
    "id"             bigint DEFAULT nextval('jam_entries_id_seq'::regclass) NOT NULL,
    "jam_id"         bigint NOT NULL,
    "game_id"        bigint NOT NULL,
    "entrant_type"   character varying(10) NOT NULL,         -- 'USER' | 'TEAM'
    "entrant_user_id" bigint,                                -- entrant_type='USER' 시 NOT NULL
    "jam_team_id"    bigint,                                 -- entrant_type='TEAM' 시 NOT NULL
    "submitted_at"   timestamp with time zone DEFAULT now() NOT NULL,
    "is_delete"      boolean DEFAULT false NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_entries_id_seq" OWNED BY "jam_entries"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_jam_fkey') THEN
        ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_jam_fkey"
            FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_game_fkey') THEN
        ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_game_fkey"
            FOREIGN KEY ("game_id") REFERENCES "games" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_team_fkey') THEN
        ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_team_fkey"
            FOREIGN KEY ("jam_team_id") REFERENCES "jam_teams" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_user_fkey') THEN
        ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_user_fkey"
            FOREIGN KEY ("entrant_user_id") REFERENCES "users" ("id");
    END IF;
    -- entrant_type 값집합
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_entrant_type_check') THEN
        ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_entrant_type_check"
            CHECK ("entrant_type" IN ('USER', 'TEAM'));
    END IF;
    -- XOR: 개인이면 user 만, 팀이면 team 만 NOT NULL(정확히 하나)
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_entrant_xor_check') THEN
        ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_entrant_xor_check"
            CHECK (
                ("entrant_type" = 'USER' AND "entrant_user_id" IS NOT NULL AND "jam_team_id" IS NULL)
                OR
                ("entrant_type" = 'TEAM' AND "jam_team_id" IS NOT NULL AND "entrant_user_id" IS NULL)
            );
    END IF;
END
$$;
-- 잼당 게임 1회 출품(활성). 삭제분은 재출품 허용 → 활성 partial unique
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_entries_jam_game_active"
    ON "jam_entries" ("jam_id", "game_id") WHERE "is_delete" IS NOT TRUE;
CREATE INDEX IF NOT EXISTS "idx_jam_entries_jam" ON "jam_entries" ("jam_id");
CREATE INDEX IF NOT EXISTS "idx_jam_entries_game" ON "jam_entries" ("game_id");

-- ===========================================================================
-- 5) jam_status_log (상태전이 감사. 수동/자동 전이 공통 기록)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_status_log_id_seq";
CREATE TABLE IF NOT EXISTS "jam_status_log" (
    "id"          bigint DEFAULT nextval('jam_status_log_id_seq'::regclass) NOT NULL,
    "jam_id"      bigint NOT NULL,
    "from_status" character varying(20),                     -- 최초 생성 시 NULL 허용
    "to_status"   character varying(20) NOT NULL,
    "actor_id"    bigint,                                    -- 수동=관리자 id, 자동=NULL
    "transition_type" character varying(10) DEFAULT 'MANUAL' NOT NULL,  -- 'MANUAL'|'AUTO'
    "created_at"  timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_status_log_id_seq" OWNED BY "jam_status_log"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_jam_fkey') THEN
        ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_jam_fkey"
            FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_to_status_check') THEN
        ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_to_status_check"
            CHECK ("to_status" IN ('RECRUIT', 'DEV', 'EVAL', 'CLOSED'));
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_transition_type_check') THEN
        ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_transition_type_check"
            CHECK ("transition_type" IN ('MANUAL', 'AUTO'));
    END IF;
END
$$;
CREATE INDEX IF NOT EXISTS "idx_jam_status_log_jam" ON "jam_status_log" ("jam_id", "created_at" DESC);

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

  • recruit_posts 블록(또는 마지막 테이블 블록) 뒤에 위 1~5 전체를 신설 블록으로 추가.
  • 헤더 주석: -- 게임잼 W2-1 (권위 DDL — docs/jam-ddl.sql 와 동일) — game_reviews 블록이 schema.sql:128 에서 (권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규) 라 단 선례와 동형(직접 확인).
  • 반영 방식: docs/jam-ddl.sql 이 권위, schema.sql 은 사본. 두 곳에 동일 멱등 DDL.

games 무변경 확인 (D1)

  • games 테이블/컬럼 변경 0. jam 연결은 전부 jam_entries 가 보유 → 기존 getVisibleGames/searchVisibleGames/삭제연쇄(GamesMapper) 회귀 0. 일반 허브 노출 동작 불변(D6 이중 노출의 "허브" 측).

외부 계약 (API)

공통: 모든 상태변경은 CsrfTokens.isValid(request) 검증(없으면 403 + CsrfTokens.errorBody(), CsrfTokens.java:35,51 확인). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=ResponseEntity<Map<String,Object>>(status/message). 관리자 API 는 컨트롤러 진입부에서 PermissionGate.has(session, GAME_JAM_MANAGE.name()) 게이트 통과 후 본문 수행.

401 vs 403 정책 (W1-design 과 일치)

  • 미인증(세션 userId 없음): API 는 401 JSON {status:401, message:"로그인이 필요합니다."}. 페이지(/jams/{slug}/... 폼 등 인증 필요분)는 redirect:/login.
  • 인증·미인가(로그인됐으나 GAME_JAM_MANAGE 없음): 403 JSON {status:403, message:"권한이 없습니다."}(리다이렉트 금지).
  • CSRF 실패: 403 + CsrfTokens.errorBody().
  • 게이트 분기는 gate.isAuthenticated(session)(401/redirect) → gate.has(session, GAME_JAM_MANAGE.name())(403) 2단계. PermissionGate.isAuthenticated/has 직접 확인(PermissionGate.java:47,22).

공개 페이지 (뷰 — 인증 불필요)

method path 권한 응답
GET /jams 공개 jam-list JSP. 가시 잼 keyset 1페이지 + nextCursor 모델 주입
GET /jams/{slug} 공개 jam-detail JSP. 잼 + 출품작(jam_entries JOIN games) + CSRF 토큰 모델 주입
GET /jams?cursor={createdAt}_{id} 공개 (목록 동일 뷰, keyset 다음 페이지)

공개 출품/팀 액션 (상태변경 API — 로그인 필요 + CSRF. 관리자 게이트 아님)

액션 method path 요청 응답(200) 에러
개인 출품 POST /jams/{slug}/entries gameId {status:200, message, entryId} 401(미인증), 403(CSRF), 404(잼/게임 없음), 409(이미 출품), 422(잼 상태가 출품 불가/게임 소유자 아님)
팀 출품 POST /jams/{slug}/entries gameId, jamTeamId {status:200, message, entryId} 위 + 422(팀 멤버 아님)
팀 생성 POST /jams/{slug}/teams name {status:200, message, jamTeamId} 401, 403, 404, 422(잼 상태/이름 검증)
팀 멤버 추가 POST /jams/{slug}/teams/{teamId}/members userId {status:200, message} 401, 403, 404, 422(팀장 아님/중복)
  • 출품 단일 엔드포인트(/entries)에서 jamTeamId 유무로 개인/팀 분기 — entrant_type 결정. games.user_id 소유 검증(개인) 또는 jam_team_members 멤버 검증(팀)으로 도용 차단.
  • 출품 가능 상태: jams.status IN ('RECRUIT','DEV') 만 허용(EVAL/CLOSED 출품 거부 → 422). 구체 정책은 D3 + concern(출품 자격) 따름.

관리자 잼 CRUD (상태변경 API — CSRF + GAME_JAM_MANAGE 게이트)

액션 method path 요청 응답(200) 에러
콘솔 페이지 GET /admin/jams (없음) admin-jam-list JSP(잼 전건 + CSRF) 401/redirect, 403
생성 POST /admin/jams title, description, 기간필드, 표시필드 {status:200, message, jamId, slug} 403(CSRF/권한), 422(입력 검증/기간 정합)
수정 POST /admin/jams/{jamId} (생성과 동일 필드) {status:200, message, jamId} 403, 404, 422
상태전이 POST /admin/jams/{jamId}/status toStatus {status:200, message, jamId, status:toStatus} 403, 404, 409(허용 안 되는 전이), 422(기간 필드 미충족)
가시성 토글 POST /admin/jams/{jamId}/visibility (없음) {status:200, message, jamId, visible:bool} 403, 404
삭제(소프트) POST /admin/jams/{jamId}/delete (없음) {status:200, message, jamId} 403, 404, 422(출품작 존재 시 정책)
  • GAME_JAM_MANAGE enforcement(D4): 위 /admin/jams/** 6액션 전부 진입부에서 게이트 통과 요구. RbacInterceptor 가 /admin/** 에 등록돼 있으나 isAdmin 만 검사하므로(RbacInterceptor.java:34) SUBADMIN+GAME_JAM_MANAGE 가 인터셉터에서 막힌다 → 인터셉터는 /admin/jams/** 를 통과시키지 못함. 해결: 인터셉터 경로 매핑을 변경하지 않고(W1 콘솔 보호 유지), /admin/jams/** 를 인터셉터 exclude 에 추가한 뒤 컨트롤러 게이트 헬퍼로 GAME_JAM_MANAGE 검사(아래 §인터셉터 연동 D4-A 확정).

인터셉터 / 게이트 연동 (D4 — enforcement 갭 메우기)

문제

  • RbacInterceptor.preHandle/admin/** 전체에 대해 isAdmin(session) 만 검사한다(RbacInterceptor.java:34, InterceptorConfig.java:19 직접 확인). isAdmin 은 role==ADMIN 만 true(PermissionGate.java:55-62).
  • 따라서 /admin/jams/** 가 인터셉터에 그대로 걸리면 SUBADMIN(+GAME_JAM_MANAGE 보유)이 잼 관리에 접근 못 한다. GAME_JAM_MANAGE 권한 키의 존재 의의(ADMIN 아닌 위임 관리자)가 무력화됨.

확정 (D4-A: 인터셉터 exclude + 컨트롤러 게이트 헬퍼)

  • InterceptorConfig 수정: addPathPatterns("/admin/**").excludePathPatterns("/admin/jams/**"). 콘솔(/admin/console 등 ADMIN 전용)은 인터셉터 ADMIN 게이트 유지, 잼 관리만 제외.
  • JamAdminController 진입부 게이트 헬퍼: 각 액션 시작에서 아래 순서.
    1. gate.isAuthenticated(session) 거짓 → 페이지면 redirect:/login, API 면 401.
    2. gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name()) 거짓 → 403(JSON 또는 페이지 403).
    3. 통과 후 본문.
  • 이유: 인터셉터는 단일 권한(ADMIN)·URL 패턴에 최적(W1-design 결정), 잼 관리는 "ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE" 라 권한 키 판정이 필요하므로 gate.has(ADMIN 암묵전권 + SUBADMIN 키보유, PermissionGate.java:30-36) 가 정확히 들어맞는다. 커스텀 어노테이션/AOP 는 W1-design 에서 이미 오버엔지니어링으로 기각된 선례 → 동일하게 게이트 헬퍼 채택.
  • 중복 0: 6액션 모두 동일한 private 헬퍼 requireJamManage(session, response/return) 로 게이트 + 401/403 응답 작성을 단일화.

epoch 전파 연동 (W1 결정4)

  • gate.has 내부가 refreshIfStale(PermissionGate.java:86) 로 요청당 epoch 대조 → ADMIN 이 SUBADMIN 에게 GAME_JAM_MANAGE 부여/회수하면 대상 다음 요청에서 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0).

시퀀스 (주요 플로우 의사코드)

S1. 관리자 잼 생성 → 상태전이(감사)

[SUBADMIN(+GAME_JAM_MANAGE) 세션] POST /admin/jams (CSRF, title/기간/표시필드)
  → InterceptorConfig: /admin/jams/** exclude → 인터셉터 미개입
  → JamAdminController.createJam
      → requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)?  (아니면 401/403)
      → CsrfTokens.isValid(request)                       (아니면 403 errorBody)
      → 입력 sanitize/길이 검증 + 기간 정합(eval_start ≤ eval_end 등)  (아니면 422)
      → slug = JamSlugs.generate(title)  (충돌 시 재시도 — concern)
      → jamsMapper.insertJam(... status='RECRUIT', created_by=actor)
      → jamStatusLogMapper.insert(jamId, from=NULL, to='RECRUIT', actor, 'MANUAL')
      → 200 {jamId, slug}

[관리자] POST /admin/jams/42/status (CSRF, toStatus='EVAL')
  → JamAdminController.transitionStatus
      → requireJamManage + CSRF
      → jam = jamsMapper.getById(42)                       (없으면 404)
      → JamLifecycle.assertAllowed(jam.status, 'EVAL')     (불가 전이면 409)
      → JamLifecycle.assertPeriodReady(jam, 'EVAL')        (eval_start_at 미설정 등 422)
      → jamsMapper.updateStatus(42, 'EVAL')
      → jamStatusLogMapper.insert(42, from=jam.status, to='EVAL', actor, 'MANUAL')
      → 200 {status:'EVAL'}

S2. 공개 출품(개인/팀 분기) + 이중 노출

[로그인 유저] POST /jams/{slug}/entries (CSRF, gameId[, jamTeamId])
  → JamController.submitEntry
      → CsrfTokens.isValid                                 (아니면 403)
      → userId = sessionUserId(session)                    (없으면 401)
      → jam = jamsMapper.getBySlug(slug)                   (없으면 404)
      → jam.status IN ('RECRUIT','DEV')?                   (아니면 422 출품 불가 상태)
      → game = gamesMapper.getGame(gameId)                 (없으면 404)
      → resolveEntrant:
          jamTeamId == null → entrant_type='USER':
              game.userId == userId?                       (아니면 422 게임 소유자 아님)
              entrantUserId = userId
          else → entrant_type='TEAM':
              jamTeamMembersMapper.exists(jamTeamId,userId)? (아니면 422 팀 멤버 아님)
      → jamEntriesMapper.insert(...)  (UNIQUE 위반 시 409 이미 출품 — catch DuplicateKey)
      → 200 {entryId}
   # 이중 노출(D6,D7): games.is_visible 무변경 → 일반 허브 그대로 노출.
   #   잼 상세는 jam_entries JOIN games 로 별도 노출. 두 경로 동시 성립.

[공개] GET /jams/{slug}
  → JamController.detail
      → jam = jamsMapper.getBySlug(slug)  (없거나 !is_visible → redirect:/jams)
      → entries = jamEntriesMapper.listByJam(jam.id)  # JOIN games (이름/썸네일/평가단위 (jam_id,game_id) 자연키)
      → model: jam, entries, csrfToken → "jam-detail"

S3. 공개 목록 keyset 페이징 (D5)

[공개] GET /jams?cursor=2026-06-20T10:00:00Z_57
  → JamController.list
      → (cursor 파싱: createdAt, id. 없으면 첫 페이지)
      → jams = jamsMapper.listVisibleKeyset(cursorCreatedAt, cursorId, pageSize+1)
          # WHERE is_visible IS NOT FALSE AND is_delete IS NOT TRUE
          #   AND (cursor 있으면) (created_at, id) < (cursorCreatedAt, cursorId)
          # ORDER BY created_at DESC, id DESC LIMIT pageSize+1
      → hasNext = jams.size > pageSize; trim; nextCursor = last(created_at)_last(id)
      → model: jams, nextCursor → "jam-list"
  • keyset 채택 이유(D5): RecruitPostsMapper 는 페이징 전무·전건 로드(RecruitPostsMapper.java:72 직접 확인). 잼 목록/출품작은 누적 증가 → offset 페이징은 깊은 페이지 비용·중복 행 위험. keyset(created_at,id 복합 커서)은 idx_jams_visible_keyset 인덱스로 O(log n) seek. id 동률 tie-break 포함(created_at 단독은 동시각 누락 위험).

파일 영향 맵

소유권 분할 가이드(implementation-advisor worker 단위 후보): J-SCHEMA(DDL/schema 동기) · J-DOMAIN(data POJO/enum/JamLifecycle/JamSlugs) · J-MAPPER(5 매퍼) · J-ADMIN(JamAdminController + JSP, D4 게이트) · J-PUBLIC(JamController + JSP, keyset) · J-CONFIG(InterceptorConfig exclude). 의존: J-SCHEMA → J-DOMAIN → J-MAPPER → {J-ADMIN, J-PUBLIC}. J-CONFIG 는 J-ADMIN 과 짝(exclude 없으면 SUBADMIN 막힘).

변경 유형 경로 역할 소유
신규 docs/jam-ddl.sql 권위 DDL(jams/jam_teams/jam_team_members/jam_entries/jam_status_log). apply-local-ddl.sh 자동 적용 J-SCHEMA
수정 db/schema.sql 위 5테이블 블록 추가(jam-ddl 사본). games 무변경 J-SCHEMA
신규 src/main/java/com/pandoli365/bibimbap/data/JamData.java jams 행 POJO(id/slug/title/description/status/기간4/표시3/isVisible/createdAt...) J-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/data/JamEntryData.java jam_entries 행 + JOIN games 표시필드(gameName/thumbnailUrl/entrantType...) J-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/data/JamTeamData.java jam_teams 행(+멤버 수 옵션) J-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/jam/JamStatus.java enum RECRUIT/DEV/EVAL/CLOSED + isValid(String) (PermissionKeys 패턴) J-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/jam/JamLifecycle.java 전이 그래프 검증 + 기간 정합 검증(수동/자동 공통 코어, 난제2) J-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/jam/JamSlugs.java title→URL-safe slug 생성(서버 생성, D8) J-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java @Mapper jams CRUD + keyset 목록 + getBySlug + updateStatus(#{}, snake→camel alias) J-MAPPER
신규 src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java @Mapper 출품 insert/listByJam(JOIN games)/exists(#{}) J-MAPPER
신규 src/main/java/com/pandoli365/bibimbap/mapper/JamTeamsMapper.java @Mapper 팀 insert/getById/listByJam(#{}) J-MAPPER
신규 src/main/java/com/pandoli365/bibimbap/mapper/JamTeamMembersMapper.java @Mapper 멤버 insert/exists(#{}) J-MAPPER
신규 src/main/java/com/pandoli365/bibimbap/mapper/JamStatusLogMapper.java @Mapper 상태전이 감사 insert(#{}) J-MAPPER
신규 src/main/java/com/pandoli365/bibimbap/controller/JamController.java 공개 목록(keyset)/상세 + 출품/팀 액션(CSRF, RecruitController 패턴) J-PUBLIC
신규 src/main/webapp/WEB-INF/views/jam-list.jsp 잼 목록 + 다음 페이지(nextCursor) J-PUBLIC
신규 src/main/webapp/WEB-INF/views/jam-detail.jsp 잼 상세 + 출품작 + 출품/팀 폼(CSRF hidden) J-PUBLIC
신규 src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java /admin/jams/** CRUD + 상태전이(D4 게이트 헬퍼) J-ADMIN
신규 src/main/webapp/WEB-INF/views/admin-jam-list.jsp 잼 관리 목록/폼(CSRF hidden) J-ADMIN
수정 src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java .excludePathPatterns("/admin/jams/**") 추가(D4-A) J-CONFIG
수정 src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java 신규 5매퍼 @MockBean 등록(contextLoads 보존, verification §30) (검증)
신규 src/test/.../JamAdminControllerTest.java CRUD + 상태전이 + 401/403/CSRF/게이트 + 허용전이 검증 (검증)
신규 src/test/.../JamControllerTest.java 목록 keyset + 상세 + 출품(개인/팀) + 409/422 + 소유/멤버 검증 (검증)
신규 src/test/.../JamLifecycleTest.java 전이 그래프 허용/거부 + 기간 정합 단위 (검증)

SSR 호출지점 영향(verification §영향맵): jam_entries 는 games 무변경이라 기존 게임 허브 JSP·매퍼 깨짐 0. 신규 뷰·매퍼만 추가 → 기존 소비처 0 영향.

신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)

// JamLifecycle — 전이 검증 코어(수동/자동 공통). 상태 enum 만으로 전이 그래프 판정(최소).
boolean isAllowed(JamStatus from,            // 현재 상태
                  JamStatus to)              // 목표 상태 — 허용 전이 그래프 룩업
void assertPeriodReady(JamData jam,          // 기간 필드 보유 잼(eval_start_at 등)
                       JamStatus to)         // 목표 상태가 요구하는 기간 필드 충족 검증(미충족 시 신호)

// JamSlugs — title → URL-safe slug. title 만으로 생성(충돌 처리는 호출자/매퍼 책임 — concern).
String generate(String title)               // 정규화·소문자·하이픈·길이절단

// JamsMapper (@Mapper, #{} only, snake→camel 직접 alias)
JamData getById(long jamId)                  // 관리자 수정/전이 조회
JamData getBySlug(String slug)               // 공개 상세 조회(활성만)
List<JamData> listVisibleKeyset(java.time.OffsetDateTime cursorCreatedAt,  // 커서 시각(첫페이지 null)
                                Long cursorId,    // 커서 id tie-break(첫페이지 null)
                                int limit)        // pageSize+1(hasNext 판정)
List<JamData> listAllForAdmin()              // 관리자 콘솔 전건(삭제 제외)
int insertJam(JamData jam)                   // 생성(useGeneratedKeys id)
int updateJam(JamData jam)                   // 수정
int updateStatus(long jamId, String status) // 상태전이 반영
int updateVisibility(long jamId, boolean isVisible)  // 가시성 토글
int softDelete(long jamId)                   // 소프트 삭제

// JamEntriesMapper (@Mapper, #{} only)
int insert(JamEntryData entry)               // 출품(UNIQUE 위반 시 컨트롤러 catch→409)
List<JamEntryData> listByJam(long jamId)     // 상세 출품작(JOIN games 표시필드)
boolean exists(long jamId, long gameId)      // 사전 중복 체크(409 친절 메시지용)

// JamTeamsMapper (@Mapper, #{} only)
int insert(JamTeamData team)                 // 팀 생성
JamTeamData getById(long teamId)             // 멤버 추가 시 팀장 검증 소스
List<JamTeamData> listByJam(long jamId)      // 상세 팀 목록

// JamTeamMembersMapper (@Mapper, #{} only)
int insert(long jamTeamId, long userId)      // 멤버 추가
boolean exists(long jamTeamId, long userId)  // 팀 출품 시 멤버십 검증 + 중복 가입 방지

// JamStatusLogMapper (@Mapper, #{} only)
int insert(long jamId,                       // 대상 잼
           String fromStatus,                // 이전 상태(최초 NULL 허용)
           String toStatus,                  // 전이 후 상태
           Long actorId,                     // 수동=관리자 id, 자동=null
           String transitionType)            // 'MANUAL'|'AUTO'

inflate 마킹(concern 1): JamLifecycle.assertPeriodReady(jam, to)jam 파라미터는 전체 JamData 를 받지만 실제로는 기간 필드 4개만 읽는다 — 구현에서 사용 필드가 1~2개로 좁혀지면 필요 필드만 받는 시그니처로 축소 검토(과한 전달 방지). resolveEntrant(컨트롤러 private)는 의사코드상 분기일 뿐 별도 헬퍼로 추출 시 (userId, jam, gameId, jamTeamId) 전부 실제 사용되는지 구현 시 재확인.


자동전이 훅 (D3 — 인터페이스만, 구현 후속)

  • JamLifecycle.transition(...) 코어는 수동(JamAdminController)·자동(후속 스케줄러) 양쪽이 호출하는 단일 경로. 자동전이는 jam_status_log.transition_type='AUTO', actor_id=null 로 기록.
  • 본 설계는 훅 시그니처만 명시(아래), @Scheduled 빈 구현은 W2-1 범위 밖(concern 5 — context 영향 재확인 의무).
// (후속) JamAutoTransitionRunner — eval_end_at 경과 잼을 CLOSED 로. @Scheduled 본체는 후속.
//   같은 JamLifecycle 검증·감사 경로 재사용(중복 0). 본 설계는 호출 계약만 고정.

대안 비교

주제 장점 단점 채택
잼-게임 연결 (A) 조인테이블 jam_entries 정규화, games 무변경(허브 회귀 0), 다회차 출품·entry 메타 테이블 1개 추가 채택(D1)
(B) games.jam_id nullable 컬럼 단순 games 변경(허브/삭제연쇄 회귀 위험), 1게임 1잼 한정, entry 메타 불가 기각
출품 주체 (A) entrant_type + user/team XOR 개인·팀 동시(잼 본질), 단일 테이블 CHECK 2개 채택(D2)
(B) 개인만 우선, 팀은 후속 1차 단순 잼=팀 이벤트 본질과 어긋남, 후속 스키마 변경 재작업 기각
상태 모델 (A) 명시 컬럼 + 수동전이(감사) + 자동훅 운영 통제·감사·자동 보조 공존, 단일 검증 코어 JamLifecycle 도메인 1개 채택(D3)
(B) 기간 필드만 계산 상태 컬럼 절약 운영 수동 개입 불가, 감사 부재, 경계시각 모호 기각
목록 페이징 (A) keyset(created_at,id 커서) 깊은 페이지 O(log n), 누락/중복 없음 커서 파싱 채택(D5)
(B) offset/limit 단순 깊은 페이지 비용·삽입 시 행 밀림 중복 기각
(C) 전건 로드(Recruit 선례) 최단 잼 누적 증가 시 비효율 기각
잼관리 enforcement (A) 인터셉터 exclude + 컨트롤러 게이트 헬퍼 SUBADMIN+키 통과, ADMIN 콘솔 보호 유지, W1 선례 exclude 1줄 + 헬퍼 채택(D4-A)
(B) 인터셉터에 잼 경로별 권한키 매핑 중앙집중 인터셉터에 경로↔키 테이블 신설(오버엔지니어링, W1서 기각된 방향) 기각
(C) 임시 role 직접체크 빠름 W1 인프라 우회·정석 위반(금지) 기각

롤아웃 / 마이그레이션

순서

  1. 스키마 적용: docs/jam-ddl.sqldb/apply-local-ddl.sh(로컬). 운영은 동일 멱등 DDL 수동 적용. games 무변경 → 기존 데이터 회귀 0.
  2. 코드 배포: J-DOMAIN → J-MAPPER → J-PUBLIC/J-ADMIN/J-CONFIG. InterceptorConfig exclude 와 JamAdminController 게이트는 동시 배포(exclude 만 먼저 가면 잼 경로 무보호 노출, 게이트만 먼저 가면 SUBADMIN 인터셉터에 막힘 — 같은 PR/커밋으로).
  3. 권한 시드 불필요: GAME_JAM_MANAGE 키는 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 enforcement 연결만.
  4. 운영: ADMIN 이 콘솔에서 SUBADMIN 에게 GAME_JAM_MANAGE 부여 → 잼 관리 위임 가능(W1 epoch 즉시 반영).

역호환

  • 기존 games/허브/리뷰/좋아요 동작 불변(games 무변경, FK 추가만). 기존 사용자 영향 0.
  • /admin/jams/** exclude 는 신규 경로라 기존 /admin/console 보호 무변경.

롤백

  • 코드 롤백: JamController/JamAdminController/InterceptorConfig 되돌리면 잼 경로 미노출. 신규 테이블은 추가 전용이라 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance.
  • exclude 롤백 시 /admin/jams/** 가 다시 인터셉터 ADMIN 게이트로 — 컨트롤러 게이트 헬퍼가 내부에도 있어 이중 안전(미노출이면 무영향).

AC 매핑

AC 요구(골자 W2-1) 만족 설계 요소 비고
AC-1 jams 라이프사이클 4상태 + 기간 필드 jams.status CHECK 4값 + 기간 4컬럼 + JamLifecycle §데이터모델1, D3
AC-2 잼-게임 연결 정규화(games 무변경) jam_entries 조인, games DDL 0변경 D1, §games무변경
AC-3 잼당 게임 1회 출품 ux_jam_entries_jam_game_active UNIQUE §데이터모델4
AC-4 개인 OR 팀 출품 entrant_type + XOR CHECK + jam_teams/members D2
AC-5 관리자 잼 CRUD = GAME_JAM_MANAGE 게이트 JamAdminController requireJamManage(gate.has) + exclude D4, D4-A
AC-6 SUBADMIN(+키) 잼 관리 통과 / 미보유 403 gate.has(ADMIN OR SUBADMIN+키), 인터셉터 exclude D4-A, S1
AC-7 공개 목록(keyset)/상세 JamController list(keyset)/detail D5, S3
AC-8 출품작 이중 노출 games.is_visible 유지(허브) + jam_entries JOIN(잼뷰) D6
AC-9 상태전이 감사 jam_status_log insert(수동/자동, from/to/actor) D3, S1
AC-10 상태변경 전수 CSRF 모든 쓰기 액션 CsrfTokens.isValid 선검증 → 403 §외부계약 공통
AC-11 권한 SQL ${} 0 신규 5매퍼 #{} only §파일영향맵
AC-12 운영 표시 필드 jams.discord_url/prize_info/sponsor_info(표시 전용) D7

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

L레벨 매핑(verification-strategies): 인가/게이트/상태전이 플로우 = L1+L2+L3. 신규 매퍼 SQL/alias·keyset 커서 비교 = L1+L2(dev DB contract). 신규 컨트롤러·매퍼 의존 = full ./mvnw -o test 의무(§30).

시나리오 검증

  • VP-1 (AC-5/6 게이트, L1+L3): JamAdminControllerTest — ADMIN 세션 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401(redirect). L3 스모크: InterceptorConfig exclude 후 SUBADMIN 이 /admin/jams 도달.
  • VP-2 (AC-9 전이 감사, L1): JamLifecycleTest 허용/거부 전이 + JamAdminControllerTest 전이 후 jam_status_log insert 호출(from/to/actor/type).
  • VP-3 (AC-3/4 출품 무결성, L1): JamControllerTest — 개인 출품(소유 검증), 팀 출품(멤버 검증), 중복 출품 409, 비소유 게임 422, 비멤버 팀 422, EVAL 상태 출품 422.
  • VP-4 (AC-7 keyset, L1+L2): 목록 커서 진행 시 중복/누락 0, 동시각 id tie-break 동작. dev DB contract: row-comparison/OR 분해 SQL 실측.
  • VP-5 (AC-10 CSRF, L1): 쓰기 액션 전수 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용).
  • VP-6 (DB-방언 계약, L2): 신규 매퍼 반환 POJO 키 == 컨트롤러/JSP 조회 키(snake→camel 직접 alias 확인, 큰따옴표 alias 미사용). XOR CHECK·status CHECK 위반 INSERT 거부 실측.
  • VP-7 (contextLoads, L1): BibimbapApplicationTests 에 신규 5매퍼 @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException.

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

self-audit(시점): 아래 카운트는 본 설계가 신규 생성하는 정적 산출물(DDL 테이블·CHECK·매퍼·액션)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 계속 증가하는 대상 아님. self-audit(표현): 단일 리터럴 grep 취약성을 피해 enum 멤버/CHECK IN 목록/액션 핸들러 같은 구조적 불변식에 앵커. 매퍼 ${ 0건만 리터럴(부재 검증은 리터럴이 정당).

  • AC-T1 잼 신규 테이블 전수 5건 존재 — docs/jam-ddl.sql 의 CREATE TABLE IF NOT EXISTS 5건(jams/jam_teams/jam_team_members/jam_entries/jam_status_log): grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-ddl.sql == 5. AND db/schema.sql 에 동일 5 테이블명 전수 존재(동기 사본 누락 검출). 테이블 추가/삭제 누락을 갯수 1로 커버.
  • AC-T2 상태 4값 정합 불변식JamStatus enum 멤버 수 == jams_status_check CHECK IN 항목 수 == jam_status_log to_status CHECK IN 항목 수 == 4(RECRUIT/DEV/EVAL/CLOSED). 검증: enum 멤버 grep -c == 4 AND DDL 두 CHECK IN 목록 각 4항목. 상태 추가 시 enum↔DDL↔log 3곳 동기 누락 동시 검출(불변식).
  • AC-T3 관리자 잼 액션 전수 6건 게이트 — JamAdminController 의 핸들러(콘솔/생성/수정/상태전이/가시성/삭제) 전수가 requireJamManage 호출: 게이트 헬퍼 호출 수 == 핸들러 수(상태변경 핸들러는 추가로 CsrfTokens.isValid). 핸들러 추가 시 게이트 누락 = 인가 우회 보안결함 → FAIL. 이 전수 AC 가 D4 enforcement 의 핵심 가드(수동 판정: @PostMapping/@GetMapping 핸들러 열거 후 각 진입부 requireJamManage 확인 — 리터럴 grep 단독 의존 회피).
  • AC-T4 잼 매퍼 전수 5개 ${ 0건 — 신규 매퍼 5파일(JamsMapper/JamEntriesMapper/JamTeamsMapper/JamTeamMembersMapper/JamStatusLogMapper)에 ${ 매치 0: grep -rc '\${' <매퍼 5파일> == 0 (AC-11, ${} 동적치환 금지). 부재 검증이라 리터럴 정당.
  • AC-T5 entrant XOR 무결성 — jam_entries_entrant_xor_check + jam_entries_entrant_type_check 2 CHECK 전수 존재 AND 위반 INSERT(USER인데 jam_team_id 채움 / 둘 다 NULL / 둘 다 채움)가 DB 거부(L2 실측). 개인/팀 정확히 하나 보장(D2 핵심 가드).
  • AC-T6 잼 신규 매퍼 @MockBean 전수 5건 — BibimbapApplicationTests 에 신규 5 매퍼 @MockBean 전수 등록: contextLoads PASS AND grep -c '@MockBean.*Jam' BibimbapApplicationTests.java >= 5(또는 매퍼별 등록 수동 확인). 1건 누락 시 contextLoads FAIL 로 즉시 검출(§30, verification 시점에 자기 검증).

잔여 오픈 질문

없음(0). 확정 결정 D1~D8 전제 고정, 두 난제(개인/팀 XOR 정합·상태전이 감사+자동훅)는 본 설계가 구체 메커니즘으로 확정. 인터셉터 exclude vs 컨트롤러 게이트(D4-A)도 확정. 구현 점검 항목(시그니처 inflate·full-test @MockBean·keyset SQL 방언·slug 충돌 재시도·자동전이 스케줄러 context)은 오픈 질문이 아니라 concerns 로 이관.