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-유저 배지/평판 |
|
true |
|
|
설계: 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.sh가docs/*-ddl.sql글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev).db/schema.sql은 동기 사본(컨테이너 최초1회 부트스트랩). 선례: W1docs/rbac-ddl.sql, W3-2docs/game-reviews-ddl.sql. 알파벳순상badge-ddl.sql은game-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_events는users만 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) 가 코드 배지 상수(BadgeKeysenum)를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 없음)
- 리뷰 목록(
GameReviewControllerlistReviews) / 게임 목록(GameControllergetVisibleGames) / 프로필 응답에 해당 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 확장 명세만, 코드 미수정)
PermissionKeysenum 에 멤버 1개 추가:
BADGE_MANAGE("배지 관리")
PermissionCatalogVerifier(PermissionCatalogVerifier.java:30,values()순회 시드) 가 자동으로 DBpermissions에 멱등 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 은BadgeKeysenum 내부 매핑(REVIEWER→REVIEW_WRITTEN/임계10, TECHNICIAN→GAME_UPLOADED/임계3)으로 외부 인자 부풀림을 막는다.record의weight는 시그니처에서 제외(1차 전부 weight=1, DB DEFAULT 1) — 품질 가중 도입 시 그때 추가.grantManual의awardedBy는 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) | 기각(소스 확장 여지만 보존) |
롤아웃 / 마이그레이션
순서
- 스키마 적용:
docs/badge-ddl.sql→db/apply-local-ddl.sh(로컬, 멱등) / 운영 동일 멱등 DDL. 신규 도메인이라 기존 데이터 호환(추가만, 파괴 0).db/schema.sql동기. - 권한 키:
PermissionKeys.BADGE_MANAGE추가 → 배포 시PermissionCatalogVerifier가 permissions 카탈로그 자동 시드(코드 verifier 불변). - 배지 카탈로그: 부팅 시
BadgeCatalogSeeder가 REVIEWER/TECHNICIAN 멱등 INSERT. - 코드 배포: 도메인/서비스/훅/매니지/표시. 평판 훅이 리뷰/게임 작성 경로에 발효.
- 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 멤버 수가 변한다(고정 카운트 금지). 불변식:
PermissionCatalogVerifier가values()순회 시드이므로 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 에 이관.