bibimbap/.atp/work-session/20260623-104307/implementation/W4-badges-design.md

42 KiB

phase agent agent_version generated_at workstream concerns concerns_checked self_verification references
design design-advisor 1 2026-06-23T12:00:00+09:00 W4-유저 배지/평판
신규 함수 시그니처(BadgeService.evaluateAndSync / ReputationService.record / BadgeQueryMapper.* )는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 evaluateAndSync 의 reason/actorId 후보 인자.
신규 매퍼(BadgesMapper / UserBadgesMapper / ReputationEventsMapper / BadgeQueryMapper) + BadgeService / ReputationService 의 컨트롤러/JSP 신규 의존은 verification-strategies §30 에 따라 implementation 에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 빈 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException.
배지 표시 조인(리뷰 작성자·게임 카드 옆 배지)은 N+1 위험. 본 설계는 배치 조회(listActiveBadgeKeysByUserIds(List)) 로 차단하도록 명세했으나, 구현이 리뷰 1건당 1쿼리로 풀면 N+1 회귀. listGameReviews/getVisibleGames 호출지점에서 userId 집합을 1회 배치 조회하는지 verification 에서 확인 필요.
신규 매퍼 SQL 의 camelCase alias 는 일반 매퍼 표준(snake→camel 직접 alias, r.created_at AS createdAt)을 따른다. 단 배지 집계/통계성 조회(reputation 누적 합산 등 VIEW 성)는 케이스 폴딩 함정 대상 — 그 경우만 큰따옴표 alias(verification-strategies §33). DB-방언 계약(L2) 대상이며 dev DB contract 미구축은 기존 open item.
PermissionKeys 에 BADGE_MANAGE 추가는 enum 멤버 1개 추가 — PermissionCatalogVerifier 가 values() 순회 시드라 자동 동기되나, W1 의 AC-T1(권한 카탈로그 전수 N건) 카운트가 3→4 로 변한다. W1 의 기존 전수 AC 가 하드코딩 3이면 그 AC 가 본 변경으로 FAIL 할 수 있음 — 본 설계 §롤아웃에서 명시, verification 에서 W1 AC-T1 재측정 필요.
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

설계: W4 — 유저 배지 / 평판 (리뷰어/테크니션 배지 + 평판 이벤트 감사로그 + 자동/수동 하이브리드 부여)

목표 / 비목표

목표 (FR/NFR 추적 — 골자 W4 Q1~Q6 + QG-W4-A 를 확정값으로 닫음)

  • B1 배지 카탈로그 (Q1, Q4): 정의 가능·확장 가능한 배지 카탈로그(badges). 1차 배지 2종 = 리뷰어(REVIEWER)·테크니션(TECHNICIAN). badge_type CHECK 로 확장 가능. "기술자" 명칭 = TECHNICIAN(영문 키) / display_name "테크니션"(한글 표시) 확정(Q4 닫음).
  • B2 유저 배지 보유 (Q1, Q6): user_badges 가 유저↔배지 보유를 표현. 활성(미회수) UNIQUE(user_id, badge_key) 로 중복 부여 방지(자동부여 멱등성).
  • B3 평판 이벤트 감사로그 (Q3): reputation_events 가 평판 신호(리뷰 작성·업로드·역할·좋아요 등)를 누적 감사 기록. 배지가 1차 노출(이산), reputation_events 가 임계 판정·감사 소스(연속 점수 보조).
  • B4 자동 부여(임계) (Q2 → QG-W4-A 닫음): 평판 신호 누적이 배지 기준 임계 도달 시 자동 부여. 이벤트 훅(리뷰 작성/게임 업로드 직후) + 멱등(UNIQUE 방어).
  • B5 수동 부여/회수 (Q2, Q5): 운영자가 배지 수동 부여·회수. 신규 권한 키 BADGE_MANAGE 게이트(W1 PermissionGate 위). 회수 = revoked_at + 사유.
  • B6 배지 표시 (Q6): 프로필 + 리뷰 작성자(닉네임 옆) + 게임 카드. 표시명 = users.display_name 단일 출처에 배지 부착(스냅샷 닉네임 아닌 유저 단위 배지).
  • NFR: 상태변경(수동 부여/회수) CSRF 전수, #{} 바인딩(${} 금지), 권한 게이트는 W1 인프라(PermissionGate/PermissionKeys) 위에 얹고 임시 role 직접 체크 금지, 비파괴 멱등 DDL, 배지 표시 조회 N+1 차단(배치 조회).
  • 착수 독립성 (골자 C5): 평판 소스(리뷰 R-B / 좋아요 R-D / role R-A / 업로드 R-C) 전부 실재 → W2 무관 독립 착수.

비목표 (스코프 밖)

  • 연속 평판 "점수" 산식 노출 UI — reputation_events 는 감사·임계 판정 소스로만 사용, 점수 가시화는 후속.
  • 배지 기준의 정교한 "품질" 가중(추천수·신고수 반영) — 1차는 활동량 임계 + 회수(부정 다수). 추천 인프라는 현재 부재(code-fact: game_reviews 에 추천 컬럼 0).
  • 잼(W2) 연동 배지(수상자 배지 등) — W2 시상(W2-6) 종속이라 W4 1차 스코프 밖. badge_type 확장으로 후속 흡수 가능.
  • 좋아요(game_likes) 기반 배지 — R-D 가 1인1표 UNIQUE 미보장(추정)·user_key varchar 라 신뢰 신호로 부적합. reputation_events 스키마는 source 확장 열어두되 1차 자동부여 소스에서 제외.
  • 자동부여 스케줄러(주기 배치) — 1차는 이벤트 훅 동기 평가(리뷰/업로드 트랜잭션 직후). 스케줄 백필은 후속(아래 §대안 비교).

개요

bibimbap 에 배지/평판 도메인은 전무하다(grounding R-E: badge/reputation 0 hit). 본 설계는 신규 3테이블(badges/user_badges/reputation_events) + 신규 권한 키 BADGE_MANAGE 를 도입한다. 평판 신호 원천은 전부 실재한다 — 리뷰(game_reviews, R-B), 업로드(games, R-C), 역할(users.role, R-A).

핵심 구조:

  • 이산 배지(1차 노출): badges 카탈로그 + user_badges 보유. 리뷰어/테크니션 2종으로 시작, badge_type CHECK 확장.
  • 연속 신호(보조·감사): reputation_events 가 평판 신호를 append-only 누적. 자동부여는 "이벤트 기록 → 해당 유저의 누적 집계 → 임계 도달 시 배지 부여" 흐름. reputation_events 가 임계 판정 단일 소스 + 감사 추적.
  • 하이브리드 부여: 자동(임계, 이벤트 훅 동기) + 수동(운영자, BADGE_MANAGE 게이트). 둘 다 같은 user_badges 에 기록되며 awarded_by(자동=NULL, 수동=actor) 로 출처 구분.
  • 표시: 유저 단위 배지를 users.display_name 단일 출처에 부착. 리뷰 작성자(authorName)·게임 카드(creator)·프로필 표면에서 해당 userId 의 활성 배지를 배치 조회로 붙인다.

확정한 골자 미결(W4 Q1~Q6, QG-W4-A):

  • Q1/Q4 (배지 종류·명칭): 1차 2종 REVIEWER/TECHNICIAN. "기술자" 표시명=테크니션. badge_type CHECK('REVIEWER','TECHNICIAN') + 확장 시 CHECK 멱등 확장.
  • Q2/QG-W4-A (부여 방식): 자동 임계 + 수동 하이브리드. 정석 = 신규 BADGE_MANAGE 권한 키(권한 분리, CONTENT_MODERATE 재사용 기각 — 근거 §대안 비교).
  • Q3 (평판 점수): 이산 배지 1차 노출 + reputation_events 누적(연속 보조·감사). 점수 UI 비목표.
  • Q5 (회수): user_badges.revoked_at + revoke_reason. 부정 리뷰/삭제 다수 등 운영 판단 → 수동 회수. 자동 회수는 1차 비목표(거짓 회수 위험).
  • Q6 (표시 위치): 프로필 + 리뷰 작성자 + 게임 카드. display_name 단일 출처.

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

결정 확정값 본 설계의 구체화
배지 스토리지 badges + user_badges + reputation_events 3테이블 docs/badge-ddl.sql 권위 + schema.sql 동기(멱등)
배지 1차 종류 REVIEWER / TECHNICIAN badge_type CHECK 확장 가능. 카탈로그 부팅 시드(BadgeCatalogSeeder)
"기술자" 명칭 키 TECHNICIAN / 표시 "테크니션" badge_key=TECHNICIAN, display_name="테크니션"
부여 방식 자동 임계 + 수동 하이브리드 자동=reputation_events 누적 집계 임계, 수동=BADGE_MANAGE 게이트. 둘 다 user_badges, awarded_by 로 출처 구분
권한 키 신규 BADGE_MANAGE (CONTENT_MODERATE 재사용 기각) PermissionKeys enum +1, PermissionCatalogVerifier values() 자동 시드
평판 점수 이산 배지 1차 + reputation_events 누적(보조) reputation_events = 임계 판정 + 감사 단일 소스
회수 revoked_at + revoke_reason(수동) UNIQUE active 부분인덱스로 재부여 허용(회수 후 재획득)
자동부여 트리거 이벤트 훅 동기(리뷰/업로드 직후) 스케줄 배치 비목표(1차)
자동부여 멱등 UNIQUE(user_id, badge_key) active 부분인덱스 중복부여 INSERT ... ON CONFLICT DO NOTHING
표시 display_name 단일 출처 + 배치 조회 listActiveBadgeKeysByUserIds(List) N+1 차단

데이터 모델 (DDL)

권위 워크플로(grounding R-F): 권위 원본 = docs/badge-ddl.sql (신규). db/apply-local-ddl.shdocs/*-ddl.sql 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). db/schema.sql 은 동기 사본(컨테이너 최초1회 부트스트랩). 선례: W1 docs/rbac-ddl.sql, W3-2 docs/game-reviews-ddl.sql. 알파벳순상 badge-ddl.sqlgame-reviews-ddl.sql 보다 먼저 적용되나, badges 는 users 외 FK 가 game_reviews/games 를 직접 참조하지 않으므로(소비는 런타임 조회) 적용 순서 의존 없음(아래 FK 설계 참조).

신규 파일: docs/badge-ddl.sql (권위 DDL)

-- W4 유저 배지/평판. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- 신규 도메인(스토리지 0). 추가만, 파괴 없음. search_path=dev.

-- 1) badges (배지 카탈로그 — 정의 가능·확장 가능)
CREATE SEQUENCE IF NOT EXISTS "badges_id_seq";
CREATE TABLE IF NOT EXISTS "badges" (
    "id"            bigint DEFAULT nextval('badges_id_seq'::regclass) NOT NULL,
    "badge_key"     character varying(50)  NOT NULL,   -- 코드 상수 단일 정의처와 정합(REVIEWER/TECHNICIAN)
    "display_name"  character varying(100) NOT NULL,   -- 표시명("리뷰어"/"테크니션")
    "description"   character varying(500),            -- 획득 기준 설명(표시용)
    "badge_type"    character varying(30)  NOT NULL,   -- 분류(REVIEWER/TECHNICIAN, 확장 가능)
    "criteria_json" text,                              -- 임계 기준 직렬화(예: {"minReviews":10}). 표시·문서화용. 판정은 코드 상수
    "is_active"     boolean DEFAULT true NOT NULL,
    "created_at"    timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "badges_id_seq" OWNED BY "badges"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_badges_key" ON "badges" ("badge_key");
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'badges_type_check') THEN
        ALTER TABLE "badges"
            ADD CONSTRAINT "badges_type_check"
            CHECK ("badge_type" IN ('REVIEWER', 'TECHNICIAN'));
    END IF;
END
$$;

-- 2) user_badges (유저↔배지 보유. 자동/수동 부여 공통 기록)
CREATE SEQUENCE IF NOT EXISTS "user_badges_id_seq";
CREATE TABLE IF NOT EXISTS "user_badges" (
    "id"            bigint DEFAULT nextval('user_badges_id_seq'::regclass) NOT NULL,
    "user_id"       bigint NOT NULL,
    "badge_key"     character varying(50) NOT NULL,
    "awarded_by"    bigint,                            -- 수동=운영자 user_id, 자동=NULL
    "awarded_at"    timestamp with time zone DEFAULT now() NOT NULL,
    "revoked_at"    timestamp with time zone,          -- 회수 시각(NULL=활성)
    "revoke_reason" character varying(500),            -- 회수 사유(수동)
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "user_badges_id_seq" OWNED BY "user_badges"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_badges_user_id_fkey') THEN
        ALTER TABLE "user_badges"
            ADD CONSTRAINT "user_badges_user_id_fkey"
            FOREIGN KEY ("user_id") REFERENCES "users" ("id");
    END IF;
END
$$;
-- 활성(미회수) 중복 부여 방지 — 부분 유니크. 회수 후 재부여 허용(revoked_at IS NOT NULL 행은 제외).
-- 자동부여 멱등성의 DB 차원 보증(INSERT ... ON CONFLICT DO NOTHING 대상 인덱스).
CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_badges_active"
    ON "user_badges" ("user_id", "badge_key") WHERE "revoked_at" IS NULL;
CREATE INDEX IF NOT EXISTS "idx_user_badges_user_active"
    ON "user_badges" ("user_id") WHERE "revoked_at" IS NULL;

-- 3) reputation_events (평판 신호 감사로그 — append-only, 임계 판정 단일 소스)
CREATE SEQUENCE IF NOT EXISTS "reputation_events_id_seq";
CREATE TABLE IF NOT EXISTS "reputation_events" (
    "id"          bigint DEFAULT nextval('reputation_events_id_seq'::regclass) NOT NULL,
    "user_id"     bigint NOT NULL,
    "event_type"  character varying(40) NOT NULL,      -- REVIEW_WRITTEN/GAME_UPLOADED(1차). 확장 가능
    "source_ref"  character varying(100),              -- 원천 식별(review:123, game:45 등). 중복 이벤트 방지 키
    "weight"      integer DEFAULT 1 NOT NULL,          -- 신호 가중(1차 전부 1, 향후 품질 가중)
    "created_at"  timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "reputation_events_id_seq" OWNED BY "reputation_events"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_type_check') THEN
        ALTER TABLE "reputation_events"
            ADD CONSTRAINT "reputation_events_type_check"
            CHECK ("event_type" IN ('REVIEW_WRITTEN', 'GAME_UPLOADED'));
    END IF;
END
$$;
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_user_id_fkey') THEN
        ALTER TABLE "reputation_events"
            ADD CONSTRAINT "reputation_events_user_id_fkey"
            FOREIGN KEY ("user_id") REFERENCES "users" ("id");
    END IF;
END
$$;
-- 같은 원천 신호 중복 기록 방지(예: 같은 리뷰 재집계). source_ref NULL 은 제외(부분 유니크).
CREATE UNIQUE INDEX IF NOT EXISTS "ux_reputation_events_source"
    ON "reputation_events" ("user_id", "event_type", "source_ref")
    WHERE "source_ref" IS NOT NULL;
CREATE INDEX IF NOT EXISTS "idx_reputation_events_user_type"
    ON "reputation_events" ("user_id", "event_type");

FK 설계 결정 (적용 순서·소비 결합)

  • user_badges/reputation_eventsusers 만 FK 참조. game_reviews/games 를 FK 로 묶지 않는다 — 이유: (a) docs/*-ddl.sql 알파벳 적용 순서 의존 회피, (b) 소비는 런타임 집계 조회(리뷰 수·업로드 수)이지 참조 무결성 대상 아님, (c) 리뷰/게임 soft-delete 시 평판 이벤트는 감사 기록으로 잔존해야 함(append-only). source_ref 는 varchar 약결합(review:123)으로 추적성만 확보.

db/schema.sql 반영 (동기 사본)

  • recruit_posts 블록 뒤(파일 말미)에 위 1·2·3번(badges/user_badges/reputation_events)을 신설 블록으로 추가. 반영 방식은 game_reviews(권위 DDL → schema.sql 동기, schema.sql:128~) 선례와 동일 — docs/badge-ddl.sql 이 권위, schema.sql 은 사본.

카탈로그 시드 (badges 행 — 부팅 멱등)

  • W1 PermissionCatalogVerifier(enum→DB 멱등 upsert) 선례와 동일 패턴으로 BadgeCatalogSeeder(ApplicationRunner) 가 코드 배지 상수(BadgeKeys enum)를 badges 에 멱등 upsert. 단일 정의처 = 코드 enum, DB 는 시드. 권한과 달리 배지는 운영자가 표시명/설명을 DB 에서 조정할 수 있어야 하므로, upsert 는 badge_key 부재 시 INSERT 만 하고 기존 행 display_name/description 은 덮어쓰지 않는다(INSERT ... ON CONFLICT DO NOTHING). 신규 키만 시드.

외부 계약 (API)

공통: 모든 상태변경(수동 부여/회수)은 CsrfTokens.isValid(request) 검증(없으면 403 + CsrfTokens.errorBody()). 수동 부여/회수 API 는 BADGE_MANAGE 게이트 통과 후 도달. 응답은 기존 컨트롤러 패턴(Map<String,Object> + status/message). 읽기(배지 표시)는 기존 리뷰/게임 조회에 배치 부착(신규 페이지 API 최소).

401 vs 403 정책 (W1 정책 계승)

  • 미인증(세션 userId 없음): API → 401 JSON, 페이지 → redirect:/login.
  • 인증·미인가(BADGE_MANAGE 없음): 403 JSON {status:403, message:"권한이 없습니다."}.
  • CSRF 실패: 403 + CsrfTokens.errorBody().

수동 부여/회수 API (상태변경 — 전부 CSRF + BADGE_MANAGE 게이트)

액션 method path 요청 응답(200) 에러
수동 부여(B5) POST /admin/users/{userId}/badges/{badgeKey}/grant (path) {status:200, message, userId, badgeKey, awarded:true} 404(대상/배지키 없음), 409(이미 활성 보유), 403(CSRF/권한)
수동 회수(B5/Q5) POST /admin/users/{userId}/badges/{badgeKey}/revoke {reason}(form) {status:200, message, userId, badgeKey, revoked:true} 404(대상/활성보유 없음), 403
  • grant/revoke 분리 채택 근거: W1 권한 토글은 부여=회수 대칭 역연산이라 단일 toggle 이었으나, 배지 회수는 사유(revoke_reason) 필수 + 부정 판단 동반이라 의미가 비대칭이다. 회수를 토글로 묶으면 사유 누락·오회수 위험. 따라서 명시적 2엔드포인트 채택(grant 는 사유 불요, revoke 는 사유 요구).
  • 경로 선택: /admin/** 하위 — RbacInterceptor 가 /admin/** 를 ADMIN 게이트로 1차 보호하나, 배지 관리는 ADMIN 뿐 아니라 BADGE_MANAGE 보유 SUBADMIN 도 허용해야 함. 따라서 인터셉터의 /admin/** ADMIN-only 와 충돌(아래 §인터셉터 연동에서 해소).

배지 표시 조회 (읽기 — 기존 조회에 배치 부착, 신규 페이지 API 없음)

  • 리뷰 목록(GameReviewController listReviews) / 게임 목록(GameController getVisibleGames) / 프로필 응답에 해당 userId 집합의 활성 배지 키를 1회 배치 조회로 부착. 응답 형태: 각 리뷰/게임 항목에 authorBadges: ["REVIEWER", ...](키 배열) 추가. 표시명 매핑은 클라이언트가 카탈로그(부트 시 모델 주입 또는 정적 상수)로 변환.

인터셉터 / 게이트 연동

/admin/** ADMIN-only 와 BADGE_MANAGE(SUBADMIN 허용)의 충돌 해소 (확정)

  • RbacInterceptor/admin/** 전체를 isAdmin 게이트로 보호(grounding R-A: InterceptorConfig.java:18-20, addPathPatterns("/admin/**") + isAdmin). SUBADMIN 은 어떤 권한 키를 가져도 /admin/** 진입 불가.
  • 결정: 배지 관리 API 를 /admin/** 밖 경로로 둔다 → /manage/badges/.... 이유: /admin/** 의 ADMIN-only 인터셉터 의미를 훼손하지 않으면서 BADGE_MANAGE 보유 SUBADMIN 진입을 허용. 인터셉터 URL 패턴 변경(SUBADMIN 분기 추가)은 W1 enforcement 계약을 흔들어 회귀 위험 — 신규 경로가 정석.
    • 수정 경로 계약: 수동 부여 POST /manage/users/{userId}/badges/{badgeKey}/grant, 회수 POST /manage/users/{userId}/badges/{badgeKey}/revoke.
    • 이 경로는 인터셉터에 등록하지 않고, 컨트롤러 진입부에서 게이트 헬퍼 permissionGate.has(session, PermissionKeys.BADGE_MANAGE.name()) 를 직접 호출(W1-design §인터셉터 "소비 액션=게이트 헬퍼" 선례와 동일 — 경로별 권한이 콘솔 단일 ADMIN 과 다를 때의 표준 패턴). 미인증→401, 미인가→403 은 컨트롤러가 작성.
  • PermissionGate 재사용: 신규 게이트 메서드 불요. 기존 has(HttpSession, String) 가 ADMIN 암묵전권 + SUBADMIN 키 보유를 모두 커버(PermissionGate.java:22-38 확인). BADGE_MANAGE 는 enum 값일 뿐 게이트 코어 변경 0.

PermissionKeys 확장 (코드 — 설계 명세, 본 문서는 enum 확장 명세만, 코드 미수정)

  • PermissionKeys enum 에 멤버 1개 추가:
BADGE_MANAGE("배지 관리")
  • PermissionCatalogVerifier(PermissionCatalogVerifier.java:30, values() 순회 시드) 가 자동으로 DB permissions 에 멱등 upsert → 코드 수정 0(verifier 불변), enum 멤버 추가만으로 카탈로그 동기. 이것이 W1 의 "enum 단일 정의처" 불변식의 확장 시연.

시퀀스

S1. 자동 부여 (리뷰 작성 → 평판 이벤트 → 임계 판정 → 배지 부여)

[유저 42] POST /game/7/reviews (CSRF) — 리뷰 작성(기존 W3-2 경로)
  → GameReviewController.createReview (기존)
      → CSRF + 리뷰 INSERT (기존 트랜잭션)
      → [신규 훅, 같은 트랜잭션 후단] reputationService.record(
                userId=42, eventType=REVIEW_WRITTEN, sourceRef="review:" + newReviewId)
          → reputationEventsMapper.insertIgnore(...)   # ux_reputation_events_source ON CONFLICT DO NOTHING(중복 신호 방어)
          → badgeService.evaluateAndSync(userId=42, badgeKey=REVIEWER)
              → countActiveReputation(42, REVIEW_WRITTEN)  # reputation_events 누적 집계(단일 소스)
              → if count >= REVIEWER_THRESHOLD(=10, 코드 상수):
                    userBadgesMapper.insertIgnore(userId=42, badgeKey=REVIEWER, awardedBy=NULL)
                       # ux_user_badges_active ON CONFLICT DO NOTHING(자동부여 멱등 — 이미 보유면 no-op)
      → 200 (리뷰 응답, 배지 부여는 부작용)
  • 자동부여 멱등 논증(crossRefs 보안): 동일 유저가 임계 초과 후 리뷰를 더 써도 ux_user_badges_active 부분 유니크가 중복 INSERT 를 DO NOTHING 으로 흡수 → 1회만 부여. 동시성(같은 유저 동시 2리뷰)도 DB 유니크가 차단.
  • 트랜잭션 경계: 평판 훅은 리뷰 INSERT 와 같은 트랜잭션. 훅 실패가 리뷰 작성을 롤백하면 UX 손상 — 따라서 **훅은 best-effort(try/catch + 로그)**로 감싸 리뷰 본 흐름을 막지 않는다(평판은 보조 도메인). 누락된 이벤트는 후속 스케줄 백필로 복구 가능(비목표지만 구조 보존).

S2. 자동 부여 (게임 업로드 → TECHNICIAN)

[유저 42] POST /game/new (CSRF) — 게임 메타 생성(기존 GameController.createGame)
  → [신규 훅, 트랜잭션 후단] reputationService.record(42, GAME_UPLOADED, "game:" + newGameId)
      → badgeService.evaluateAndSync(42, TECHNICIAN)
          → countActiveReputation(42, GAME_UPLOADED) >= TECHNICIAN_THRESHOLD(=3) → insertIgnore TECHNICIAN

S3. 수동 부여/회수 (운영자, BADGE_MANAGE 게이트)

[SUBADMIN(BADGE_MANAGE 보유) 세션] POST /manage/users/42/badges/REVIEWER/grant (CSRF)
  → BadgeManageController.grant(42, "REVIEWER")
      → CSRF 검증 (없으면 403 + errorBody)
      → permissionGate.has(session, BADGE_MANAGE) (미인증 401 / 미인가 403)
      → BadgeKeys.isValid("REVIEWER") (아니면 404)
      → usersMapper.getUser(42) 존재 확인 (아니면 404)
      → 이미 활성 보유? userBadgesMapper.existsActive(42, REVIEWER) → 있으면 409
      → userBadgesMapper.insert(42, REVIEWER, awardedBy=actorId)   # 수동: awarded_by=actor
      → 200 {awarded:true}

[운영자] POST /manage/users/42/badges/REVIEWER/revoke (CSRF, reason="부정 리뷰 다수")
  → BadgeManageController.revoke(42, "REVIEWER", reason)
      → CSRF + BADGE_MANAGE 게이트
      → 활성 보유? (아니면 404)
      → userBadgesMapper.revoke(42, REVIEWER, reason, now())  # revoked_at + revoke_reason set
         # 부분 유니크가 revoked_at IS NULL 만 보므로, 회수 후 재획득(자동/수동) 가능
      → 200 {revoked:true}

S4. 배지 표시 (리뷰 목록에 작성자 배지 배치 부착 — N+1 차단)

[누구나] GET /game/7/reviews (기존 listReviews)
  → GameReviewController.listReviews → List<GameReviewData>(각 userId/authorName 보유)
  → [신규] userIds = reviews.stream().map(userId).distinct()
  → Map<Long,List<String>> badges = userBadgesQueryMapper.listActiveBadgeKeysByUserIds(userIds)  # 1쿼리(IN)
  → 각 리뷰 DTO 에 authorBadges = badges.getOrDefault(userId, []) 부착
  → JSP/JSON: 작성자 닉네임 옆 배지 표시(textContent/HtmlUtils 이스케이프)
  • N+1 차단 논증(concern 3): userId 집합을 1회 IN (...) 배치 조회. 리뷰 1건당 1쿼리 금지. 게임 카드(getVisibleGames)·프로필 동일 패턴.

파일 영향 맵

소유권 분할(implementation-advisor worker 단위 후보): B-SCHEMA(DDL/schema 동기) · B-DOMAIN(BadgeKeys enum/data POJO/매퍼/카탈로그 시더) · B-SERVICE(ReputationService/BadgeService 임계 판정·멱등 부여) · B-HOOK(리뷰/게임 작성 경로 평판 훅 연결) · B-MANAGE(수동 부여/회수 컨트롤러 + 게이트) · B-DISPLAY(리뷰/게임/프로필 배지 배치 부착 + JSP) · B-PERM(PermissionKeys BADGE_MANAGE 추가). 의존: B-SCHEMA → B-DOMAIN → {B-SERVICE → B-HOOK, B-MANAGE, B-DISPLAY}. B-PERM 은 B-MANAGE 선행.

변경 유형 경로 역할 소유
신규 docs/badge-ddl.sql 권위 DDL(badges/user_badges/reputation_events + CHECK/유니크). apply-local-ddl.sh 자동 적용 B-SCHEMA
수정 db/schema.sql 말미에 3테이블 블록 추가(badge-ddl 동기 사본) B-SCHEMA
신규 src/main/java/com/pandoli365/bibimbap/badge/BadgeKeys.java 배지 키 enum 단일 정의처(REVIEWER/TECHNICIAN + displayName + isValid). PermissionKeys 선례 B-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/config/BadgeCatalogSeeder.java ApplicationRunner — BadgeKeys → badges 멱등 INSERT(ON CONFLICT DO NOTHING). PermissionCatalogVerifier 선례 B-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/data/BadgeData.java badges 행 POJO(badgeKey/displayName/description/badgeType/isActive) B-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/data/UserBadgeData.java user_badges 행 POJO(userId/badgeKey/awardedBy/awardedAt/revokedAt/revokeReason) B-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/mapper/BadgesMapper.java @Mapper 카탈로그 시드/조회(#{}) B-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesMapper.java @Mapper user_badges CRUD(insertIgnore/insert/existsActive/revoke, #{}) B-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesQueryMapper.java @Mapper 표시 배치 조회(listActiveBadgeKeysByUserIds — IN, N+1 차단) B-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/mapper/ReputationEventsMapper.java @Mapper 평판 이벤트 insertIgnore + 누적 집계(countActive, #{}) B-DOMAIN
신규 src/main/java/com/pandoli365/bibimbap/badge/ReputationService.java 평판 이벤트 기록 + 임계 평가 위임(record). best-effort 훅 B-SERVICE
신규 src/main/java/com/pandoli365/bibimbap/badge/BadgeService.java 임계 판정 + 멱등 부여(evaluateAndSync) + 수동 부여/회수 도메인 로직 B-SERVICE
수정 src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java createReview 후단 reputationService.record(REVIEW_WRITTEN) 훅. listReviews 응답에 배지 배치 부착 B-HOOK / B-DISPLAY
수정 src/main/java/com/pandoli365/bibimbap/controller/GameController.java createGame 후단 reputationService.record(GAME_UPLOADED) 훅 B-HOOK
수정 src/main/java/com/pandoli365/bibimbap/controller/GameController.java (또는 GameRestController) getVisibleGames 응답에 creator 배지 배치 부착 B-DISPLAY
신규 src/main/java/com/pandoli365/bibimbap/controller/BadgeManageController.java 수동 부여/회수 `/manage/users/{userId}/badges/{badgeKey}/(grant revoke)` + BADGE_MANAGE 게이트 헬퍼
수정 src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java BADGE_MANAGE("배지 관리") enum 멤버 1개 추가(verifier 자동 시드) B-PERM
수정 src/main/webapp/WEB-INF/views/profile.jsp 프로필에 본인 활성 배지 표시(HtmlUtils 이스케이프/textContent) B-DISPLAY
수정 (리뷰/게임 카드 JSP — 리뷰 목록·게임 목록 렌더 지점) 작성자/creator 닉네임 옆 배지 표시 B-DISPLAY
수정 src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java 신규 매퍼·BadgeService·ReputationService @MockBean 등록(contextLoads, verification §30) (검증)
신규 src/test/.../BadgeServiceTest.java 임계 판정·멱등 부여(중복 no-op)·회수 후 재부여 단위 (검증)
신규 src/test/.../BadgeManageControllerTest.java grant/revoke + 401/403/CSRF/404/409 + BADGE_MANAGE 게이트 (검증)
수정 src/test/.../GameReviewControllerTest.java 리뷰 작성 후 평판 훅 호출 회귀(REVIEW_WRITTEN 기록) + 작성 본흐름 불변 (검증)

SSR 호출지점 확인(verification §영향맵): 배지 배치 부착은 기존 리뷰/게임 DTO 에 신규 필드(authorBadges) 추가라 기존 호출지점 깨짐 0. PermissionKeys 멤버 추가는 enum 확장(values() 순회 코드 불변)이라 컴파일 회귀 0.

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

// ReputationService — 평판 이벤트 기록 + 임계 평가 위임. best-effort 훅.
void record(long userId,        // 신호 주체
            String eventType,   // REVIEW_WRITTEN/GAME_UPLOADED(ReputationEventTypes 상수)
            String sourceRef)   // 중복 신호 방지 키(review:N/game:N). NULL 허용은 안 함(훅은 항상 원천 보유)

// BadgeService — 임계 판정 + 멱등 부여.
void evaluateAndSync(long userId,     // 평가 대상
                     String badgeKey) // 평가할 배지(REVIEWER/TECHNICIAN). 임계·소스 매핑은 BadgeKeys 내부
// (수동) — 컨트롤러가 BADGE_MANAGE 게이트·CSRF 통과 후 호출. 도메인 검증(존재/중복/회수)만.
GrantResult grantManual(long userId,   // 대상
                        String badgeKey,// 부여 배지
                        long awardedBy) // 운영자 user_id(awarded_by 기록)
void revokeManual(long userId,     // 대상
                  String badgeKey, // 회수 배지
                  String reason)   // 회수 사유(revoke_reason). NULL/blank 거부는 컨트롤러 검증

// UserBadgesMapper (@Mapper, #{} only)
int insertIgnore(long userId, String badgeKey)               // 자동부여 멱등(ON CONFLICT DO NOTHING, awarded_by NULL)
int insert(@Param("userId") long userId, @Param("badgeKey") String badgeKey, @Param("awardedBy") long awardedBy)  // 수동부여
boolean existsActive(@Param("userId") long userId, @Param("badgeKey") String badgeKey)  // 409/404 판정
int revoke(@Param("userId") long userId, @Param("badgeKey") String badgeKey, @Param("reason") String reason)  // revoked_at+reason set

// UserBadgesQueryMapper (@Mapper, #{} only) — 표시 배치(N+1 차단)
List<UserBadgeKeyRow> listActiveBadgeKeysByUserIds(@Param("userIds") List<Long> userIds)  // IN 1쿼리. (userId, badgeKey) 행

// ReputationEventsMapper (@Mapper, #{} only)
int insertIgnore(@Param("userId") long userId, @Param("eventType") String eventType, @Param("sourceRef") String sourceRef)  // 중복신호 ON CONFLICT DO NOTHING
long countActive(@Param("userId") long userId, @Param("eventType") String eventType)  // 누적 집계(임계 좌변)

// BadgesMapper (@Mapper, #{} only)
int insertIgnore(@Param("badgeKey") String k, @Param("displayName") String d, @Param("description") String desc, @Param("badgeType") String t)  // 시더(ON CONFLICT DO NOTHING)
List<BadgeData> listActive()  // 카탈로그(표시명 매핑 모델 주입)

inflate 마킹(concern 1): evaluateAndSync 는 의도적으로 (userId, badgeKey) 2인자로 최소화했다 — 임계값·평판 소스 event_type 은 BadgeKeys enum 내부 매핑(REVIEWER→REVIEW_WRITTEN/임계10, TECHNICIAN→GAME_UPLOADED/임계3)으로 외부 인자 부풀림을 막는다. recordweight 는 시그니처에서 제외(1차 전부 weight=1, DB DEFAULT 1) — 품질 가중 도입 시 그때 추가. grantManualawardedBy 는 audit 출처라 실사용 확정. 구현에서 각 인자 실제 사용 재확인 필요(concern 1).


대안 비교

주제 장점 단점 채택
권한 키 (A) 신규 BADGE_MANAGE 권한 분리(콘텐츠 모더 ≠ 배지 관리), 최소권한 정석 enum +1, 카탈로그 +1 채택
(B) CONTENT_MODERATE 재사용 신규 키 0 모더레이터=배지관리자 강제 결합, 권한 분리 위배 기각
자동부여 트리거 (A) 이벤트 훅 동기(작성 직후 best-effort) 즉시 반영, 인프라 0, 트랜잭션 결합 약 누락 신호 백필 별도 채택
(B) 주기 스케줄 배치 누락 0 일괄 신규 스케줄러 인프라, 즉시성 손실, 1차 과도 기각(후속)
(C) DB 트리거/계산컬럼 앱 무관 DB 로직 산재, 임계 변경 어려움, 테스트 난 기각
배지 회수 (A) revoked_at + reason, 수동만(부분유니크 재획득 허용) 감사·재획득·정석 회수 후 재부여 가능(의도) 채택
(B) hard delete 단순 감사 소실, 회수 이력 0 기각
(C) 자동 회수(임계 하락) 일관 거짓 회수(리뷰 일시 삭제 등) 위험 기각(1차)
평판 점수 (A) 이산 배지 1차 + reputation_events 누적(보조) 노출 단순 + 감사·임계 소스 이벤트 테이블 1개 채택
(B) users 에 점수 컬럼 누적 조회 빠름 감사 소실, 재계산 불가, 단조증가 결함 기각
배지 관리 경로 (A) /manage/** 신규(게이트 헬퍼) /admin/** ADMIN-only 의미 보존 + SUBADMIN+BADGE_MANAGE 허용 경로 1개 신설 채택
(B) /admin/** 하위 + 인터셉터 SUBADMIN 분기 경로 통일 W1 인터셉터 ADMIN-only 계약 훼손, 회귀 위험 기각
표시 조회 (A) userId 집합 배치 IN 1쿼리 N+1 0 매퍼 IN 1개 채택
(B) 리뷰/게임 1건당 배지 조회 단순 N+1(목록 성능) 기각
좋아요 평판 소스 (A) 1차 제외 game_likes 신뢰 불가(1인1표 미보장 추정·user_key varchar) 신호 1종 감소 채택
(B) game_likes 포함 신호 풍부 어뷰징·중복(R-D concern) 기각(소스 확장 여지만 보존)

롤아웃 / 마이그레이션

순서

  1. 스키마 적용: docs/badge-ddl.sqldb/apply-local-ddl.sh(로컬, 멱등) / 운영 동일 멱등 DDL. 신규 도메인이라 기존 데이터 호환(추가만, 파괴 0). db/schema.sql 동기.
  2. 권한 키: PermissionKeys.BADGE_MANAGE 추가 → 배포 시 PermissionCatalogVerifier 가 permissions 카탈로그 자동 시드(코드 verifier 불변).
  3. 배지 카탈로그: 부팅 시 BadgeCatalogSeeder 가 REVIEWER/TECHNICIAN 멱등 INSERT.
  4. 코드 배포: 도메인/서비스/훅/매니지/표시. 평판 훅이 리뷰/게임 작성 경로에 발효.
  5. BADGE_MANAGE 부여: ADMIN 이 W1 콘솔(/admin/console 권한 토글)로 대상 SUBADMIN 에게 BADGE_MANAGE 부여 → /manage/badges 진입 가능. (W1 토글이 PermissionKeys 전체를 노출하므로 신규 키 자동 토글 가능 — W1 콘솔 코드 변경 불요, 단 §concerns AC-T1 카운트 주의.)

역호환

  • 기존 유저/리뷰/게임: 배지 0 상태로 시작. 평판 이벤트는 배포 이후 신규 작성분만 기록(과거 백필 안 함 — 비목표). 표시 배치 조회는 배지 없으면 빈 배열 → 표시 변화 0.
  • W1 콘솔: PermissionKeys 멤버 추가만 — 콘솔이 enum 순회로 키를 노출하면 BADGE_MANAGE 가 자동 토글 대상에 추가됨(동작 확장, 회귀 0).
  • 리뷰/게임 DTO: 신규 필드 authorBadges 추가만 → 기존 직렬화/JSP 깨짐 0.

롤백

  • 코드 롤백: 평판 훅·표시 부착·매니지 컨트롤러 제거 시 배지 도메인 비활성. 신규 테이블/컬럼은 추가 전용이라 잔존해도 무해(비파괴). PermissionKeys 에서 BADGE_MANAGE 제거 시 DB permissions 의 BADGE_MANAGE 행은 PermissionCatalogVerifier 가 "DB-only 키 경고 로그"로 감지(W1 PermissionCatalogVerifier.java:40-44) — 삭제 안 하므로 안전.
  • 스키마 롤백: 명시적 DROP 은 별도 maintenance(append-only 감사 데이터 보존 권장).

AC 매핑

AC 요구 만족 설계 요소 비고
AC-1 리뷰 N개 작성 시 REVIEWER 자동 부여 S1 평판 훅 → countActive >= 임계 → insertIgnore 임계=BadgeKeys 상수
AC-2 게임 M개 업로드 시 TECHNICIAN 자동 부여 S2 평판 훅 → GAME_UPLOADED 집계 임계
AC-3 자동부여 멱등(중복 부여 0) ux_user_badges_active 부분유니크 + insertIgnore(ON CONFLICT DO NOTHING). 임계 초과 추가 활동·동시성 모두 1회만 S1 논증
AC-4 운영자 수동 부여 — BADGE_MANAGE 게이트 grant 컨트롤러 permissionGate.has(BADGE_MANAGE), 미인가 403 S3
AC-5 수동 부여/회수 CSRF 없으면 403 grant/revoke CsrfTokens.isValid 선검증 → 403+errorBody §외부계약 공통
AC-6 회수 — revoked_at + 사유 기록 revoke: revoked_at=now()+revoke_reason. 활성 보유 아니면 404 S3
AC-7 회수 후 재획득 가능 부분유니크 WHERE revoked_at IS NULL → 회수행 제외, 재부여 INSERT 가능 DDL 2
AC-8 배지 표시(프로필/리뷰작성자/게임카드) display_name 단일출처 listActiveBadgeKeysByUserIds 배치 부착, userId 기준(스냅샷 닉네임 아님) S4
AC-9 표시 조회 N+1 0 userId 집합 IN 1쿼리 concern 3
AC-10 배지 SQL ${} 0 신규 매퍼 전부 #{} §파일영향맵
AC-11 비-BADGE_MANAGE 의 /manage/badges 접근 차단 게이트 헬퍼 미인가 403, 미인증 401 §인터셉터연동
AC-12 reputation_events 중복 신호 방어 ux_reputation_events_source + insertIgnore DDL 3

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

L레벨 매핑(verification-strategies.md): 권한 게이트/부여 플로우 = L1+L2+L3. 신규 매퍼 SQL/alias = L1+L2(dev DB contract). 신규 매퍼·서비스 빈 의존 = full ./mvnw -o test 의무(§30). 자동부여 멱등·회수 = L1+L3.

시나리오 검증

  • VP-1 (AC-3 멱등, L1): BadgeServiceTest — 임계 도달 후 evaluateAndSync 반복 호출 시 user_badges 활성 1행 유지(insertIgnore no-op). 회수 후 재호출 시 재부여(AC-7).
  • VP-2 (AC-4/AC-11 게이트, L1+L3): BadgeManageControllerTest — BADGE_MANAGE 미보유 SUBADMIN→403, 미인증→401, ADMIN→통과, BADGE_MANAGE 보유 SUBADMIN→통과.
  • VP-3 (AC-5 CSRF, L1): grant/revoke CSRF 누락 → 403 + mapper 미호출(W1 deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용).
  • VP-4 (AC-1/AC-2 자동부여, L1+L3): 리뷰/게임 작성 훅이 reputation_events insert + 임계 도달 시 배지 부여. 본 흐름(리뷰/게임 작성) 불변 회귀(훅 best-effort).
  • VP-5 (AC-9 N+1, L1): 리뷰 목록 N건 응답 시 배지 조회 쿼리 1회(IN). 리뷰 1건당 1쿼리면 FAIL.
  • VP-6 (DB-방언 계약, L2): 신규 매퍼 반환 키가 소비 키와 정합. 집계성 조회(countActive)·alias 케이스 폴딩 확인.
  • VP-7 (contextLoads, L1): BibimbapApplicationTests 에 신규 매퍼·BadgeService·ReputationService @MockBean 등록 후 PASS(§30).

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

self-audit: 아래 카운트는 본 설계가 신규 생성하는 정적 산출물 + 기존 docs/*-ddl.sql 집합이며, 시점·표현 견고성을 2축 점검했다.

  • AC-T1 (시점 self-audit 적용) DDL 권위 파일 전수docs/*-ddl.sql 은 verification 시점까지 W2 등 타 워크스트림이 동시 추가할 수 있는 집합(현재 4: recruit-posts/security-hardening/game-reviews/rbac, 본 설계가 badge 추가 → 5). 고정 스칼라 5 는 시점 불안정 → 불변식으로 전환: "badge-ddl.sql 이 존재하고 그 안의 CREATE TABLE 3종(badges/user_badges/reputation_events)이 schema.sql 에도 동일 존재" — grep -c 'CREATE TABLE IF NOT EXISTS "\(badges\|user_badges\|reputation_events\)"' docs/badge-ddl.sql == 3 AND db/schema.sql 동일 == 3 (권위↔사본 동등성 불변식). 타 W 파일 추가와 무관.
  • AC-T2 신규 테이블 멱등 가드 전수 3건 — badge-ddl.sql 의 3테이블 전부 CREATE TABLE IF NOT EXISTS: grep -c 'CREATE TABLE IF NOT EXISTS' docs/badge-ddl.sql == 3. 멱등 누락(IF NOT EXISTS 빠진 CREATE) 동시 검출.
  • AC-T3 배지 SQL ${} 0건 — 신규 매퍼 5개(BadgesMapper/UserBadgesMapper/UserBadgesQueryMapper/ReputationEventsMapper) + 수정 컨트롤러에 ${ 매치 0: grep -rc '\${' <매퍼 파일들> == 0 (AC-10).
  • AC-T4 (표현 self-audit 적용) 자동부여 멱등 가드 전수 — 자동부여 INSERT 경로 전부(user_badges/reputation_events insertIgnore)가 ON CONFLICT DO NOTHING 동반. 단일 리터럴 grep 은 SQL 표현 변형(ON CONFLICT (...) DO NOTHING 컬럼 명시)에 취약 → 의미 불변식 + 수동 판정: insertIgnore 메서드 2개(UserBadgesMapper.insertIgnore / ReputationEventsMapper.insertIgnore)가 각각 부분유니크 인덱스(ux_user_badges_active / ux_reputation_events_source)에 대응하는 ON CONFLICT 절을 갖는지 코드 리뷰 확인. 이 AC 가 AC-3/AC-12 멱등의 핵심 가드 — 누락 시 중복 부여 보안결함.
  • AC-T5 (시점 self-audit 적용) PermissionKeys 카탈로그 동기 불변식 — BADGE_MANAGE 추가로 enum 멤버 수가 변한다(고정 카운트 금지). 불변식: PermissionCatalogVerifiervalues() 순회 시드이므로 enum 멤버 수 == DB 시드 키 수(자동 동기). 검증: PermissionKeys enum 멤버에 BADGE_MANAGE 존재 AND verifier 코드가 여전히 values() 순회(하드코딩 리스트 아님). W1 의 기존 AC-T1(권한 전수 3건)이 하드코딩 3이면 본 변경으로 FAIL — verification 에서 W1 AC 를 4로 재측정 필요(concern 5).
  • AC-T6 평판 신호 event_type CHECK 정합 — reputation_events CHECK IN 목록(REVIEW_WRITTEN/GAME_UPLOADED) == 코드 record 호출이 사용하는 event_type 집합. 검증: DDL CHECK IN 2종 == ReputationEventTypes 상수 2종 == 훅 호출지점 2곳(리뷰/게임). 신호 종류 추가 시 CHECK 누락 동시 검출.

잔여 오픈 질문

없음(0). 골자 W4 Q1~Q6 + QG-W4-A 를 확정값으로 닫음(§핵심 결정 요약). 시그니처 inflate·신규 빈 full-test·표시 N+1·DB-방언 L2·W1 AC-T1 카운트 변화는 오픈 질문이 아니라 구현/검증 단계 점검 항목으로 concerns 에 이관.