---
kind: change
title: "W3(잔여) + W4 기능 구현 (W3-1·W3-4·W3-3·W3-5·W4)"
session_id: 20260629-100849
created_at: 2026-06-29
status: implemented
related_design_summary: "../work-log/2026-06-23-w2-w4-full-design-summary.md"
related_design: "../../.atp/work-session/20260623-104307/implementation/"
related_impl: "../../.atp/work-session/20260629-100849/implementation/"
related_verification: "../../.atp/work-session/20260629-100849/verification.md"
related_report: "../../.atp/work-session/20260629-100849/report.md"
related_security_checklist: "../security/security-remediation-checklist.md"
related_prev_change: "./2026-06-24-w2-jam-platform.md"
---

# W3(잔여) + W4 기능 구현 변경 이력 (W3-1·W3-4·W3-3·W3-5·W4)

상위 설계 인덱스: [W2+W3(잔여)+W4 풀설계 통합 요약](../work-log/2026-06-23-w2-w4-full-design-summary.md) §설계 인덱스 W3-1/W3-3/W3-4/W3-5/W4.
설계 본문: `.atp/work-session/20260623-104307/implementation/W3-*.md` · `W4-badges-design.md` · 파일 소유권/구현 보고: `.atp/work-session/20260629-100849/implementation/ownership*.md` · 검증: `.atp/work-session/20260629-100849/verification.md` · 세션 보고: `.atp/work-session/20260629-100849/report.md`.

## 개요

W2 게임잼 워크스트림([2026-06-24-w2-jam-platform.md](./2026-06-24-w2-jam-platform.md)) 완료 후, W2-W4 풀설계 11기능 중 미구현으로 남았던 잔여 5기능을 순차 구현했다. W3-2(댓글/리뷰)는 이미 완료 상태였고, 본 작업은 **태그+검색(W3-1) → 메인허브(W3-4) → 포스팅보드(W3-3) → 업로드보안(W3-5) → 배지/평판(W4)** 순서로 각 기능을 `impl → verify → commit` 롤백 경계로 묶어 진행했다(5 feat 커밋).

| 서브 | 커밋 | 핵심 | L1 누적 |
|---|---|---|---|
| W3-1 태그 + 검색 확장 | `35f1dc3` | tags/game_tags/jam_tags/game_views + searchGamesAdvanced(다중태그 AND/OR·정렬키6·방문수·리뷰정렬·잼라우트) + TagController(sanitize/금칙어/CONTENT_MODERATE) | 235/235 |
| W3-4 메인 허브 | `e28fa60` | keyset 페이징(혼합방향 OR분해) + 진행중 잼 배너(`/games/search?jam=`) | 243/243 |
| W3-3 포스팅 보드 | `6047a39` | posts/categories + POST_WRITE enforcement + commonmark/jsoup sanitize + SsrfSafeFetcher(9항목) + 유니티 피드 폴링(@Scheduled) | 287/287 |
| W3-5 업로드 보안 | `08d191f` | ZipSecurity(zip-slip/심링크/zipbomb/포맷/권한게이트) + ATOMIC_MOVE swap + game_upload_audit_log | 318/318 |
| W4 유저 배지/평판 | `305cc73` | badges/user_badges/reputation_events + 자동(임계)·수동(BADGE_MANAGE) 하이브리드 + reputation 감사 + 리뷰작성자 표시 | 347/347 |

커밋 범위: `35f1dc3`(W3-1) → `e28fa60`(W3-4) → `6047a39`(W3-3) → `08d191f`(W3-5) → `305cc73`(W4). 모두 `feat/v2` 브랜치.

신규 DDL 5종: `docs/tag-ddl.sql` · `docs/games-hub-ddl.sql` · `docs/board-ddl.sql` · `docs/game-upload-ddl.sql` · `docs/badge-ddl.sql`(전부 `db/schema.sql` 동기). 신규 pom 의존 2종: jsoup 1.17.2 + commonmark 0.22.0(W3-3).

---

## W3-1 — 태그 + 검색 확장 (`35f1dc3`)

### DDL (신규 4테이블 + games 컬럼 1)

| 객체 | 역할 | 핵심 제약 |
|---|---|---|
| `tags` | 통합 태그 사전 | 정규화 slug 활성 UNIQUE |
| `game_tags` | 게임-태그 조인 | (game_id, tag_id) UNIQUE, `#{}` 바인딩 |
| `jam_tags` | 잼-태그 조인 | (jam_id, tag_id) UNIQUE |
| `game_views` | 방문수 dedupe 원장 | 최근 interval 내 재방문 제외 근거 |
| `games.view_count` | 누적 방문수 캐시 컬럼 | game_views 적재 시 증가 |

- DDL 권위: `docs/tag-ddl.sql`(멱등) + `db/schema.sql` 동기.

### 런타임 동작

- `GamesMapper.searchGamesAdvanced` — 다중태그 **AND/OR** 분리(`GameTagsMapper.listGameIdsByTagsAll`/`...Any`, AND ⊆ OR 불변), 정렬키 6종, 방문수·리뷰(`game_review_stats` 소비) 정렬, 잼 필터 라우트. `SearchCriteria` POJO 로 파라미터 캡슐화.
- `SearchController` — `GET /games/search` 태그·정렬·잼 파라미터 처리(파라미터 전수 `#{}`). index.jsp SSR.
- `GameViewsMapper.existsRecentView` — 최근 방문 dedupe(설계 dead param `sinceTs` 제거). `GameController` 방문 적재 훅.
- `TagController` — 태그 생성/부착. `TagSanitizer`(normalize + `banned-words.txt` 금칙어) + **CONTENT_MODERATE 게이트**(신규 권한키 미도입, W1 선언키 재사용) + CSRF.

### 의미보존 편차(verification 인지)

- `insertTag` 는 `int` + generatedKeys 패턴(설계 표현 대비 의미보존).
- `TagController` 가 `GamesMapper` 주입으로 부착 대상 게임 소유자 확인(설계 의도 충족).

### 검증

L1 235/235 GREEN(baseline 190→235, +45 신규테스트, 회귀 0). L2 격리 throwaway DB: alias 케이스폴딩 인용/비인용 대조, NULLS LAST, AND ⊆ OR, ILIKE, interval dedupe. AC-1/2/4/5 + 보안 VP PASS.

---

## W3-4 — 메인 허브 (`e28fa60`)

### DDL

신규 테이블 0(`GamesMapper`/`JamsMapper` 확장만). `docs/games-hub-ddl.sql` 는 keyset 인덱스 등 허브 지원 객체.

### 런타임 동작

- `GamesMapper` keyset 페이징 — **혼합 정렬방향을 OR 분해**한 row-comparison 으로 경계 무중복·무누락 보장(검색·비검색 단일화). 단계1(keyset 더보기) + 단계2(진행중 잼 배너) 통합 구현.
- `JamsMapper.listActive`(limit=1) — 진행중 잼 1건 배너용. `WebMvcController` 가 hub 에 주입.
- 진행중 잼 배너 → W3-1 `GET /games/search?jam={slug}` 라우팅(cross-W 정합).

### 검증

L1 243/243 GREEN(회귀 0, 편차 0). L2: keyset 혼합방향 OR분해 경계 무중복·무누락·tie-break 실측(throwaway DB). AC-T2/T3/T4 + 단계무결성 + 보안 VP PASS. `BibimbapApplicationTests` 무변경(GamesMapper/JamsMapper @MockBean 기존 등록 재사용).

---

## W3-3 — 포스팅 보드 (`6047a39`)

가장 복잡한 워크스트림(SSRF 9항목·md sanitize·다매퍼·@Scheduled·pom 의존). 검증 단계에서 verification + **adversarial SSRF 감사** 2중 검증이 vacuous(거짓통과) SSRF 결함을 포착해 backward 1회 보정(아래 §SSRF 강화)했다.

### DDL (신규 4테이블) + pom 의존

| 객체 | 역할 |
|---|---|
| `post_categories` | 게시판 카테고리 |
| `posts` | 게시글(마크다운 본문) |
| `unity_feed_sources` | 유니티 외부 피드 소스 |
| `unity_feed_items` | 폴링 적재 피드 아이템(dedupe) |

- DDL 권위: `docs/board-ddl.sql`(멱등) + `db/schema.sql`(append) 동기.
- pom 신규 의존: **jsoup 1.17.2**(1.14+ Safelist API 정합) + **commonmark 0.22.0**. 오프라인 빌드용으로 프록시 CA 주입 1회 pre-resolve 후 `~/.m2` 캐시(이후 오프라인 빌드 가능).

### 런타임 동작

- `PostController` — 게시글 작성/조회. **POST_WRITE enforcement 연결**(W1 선언키의 첫 소비처) + CSRF.
- `PostMarkdownService` — commonmark 렌더 → **jsoup Safelist sanitize**(XSS 차단). 검증에서 XSS sanitize SOUND.
- `OgPreviewService` / `UnityFeedPoller` — `SsrfSafeFetcher` 단일 경유 외부 fetch. `FeedParser`(RSS 2.0 / RFC1123 pubDate).
- `UnityFeedPoller` `@Scheduled` 폴링 + dedupe insert(`SchedulingConfig` `@EnableScheduling` 신규 활성).
- `PostAdminController` / `UnityFeedAdminController` — 카테고리·피드소스 관리(JSP 는 프로젝트 컨벤션 scriptlet + `HtmlUtils.htmlEscape` 로 정렬, JSTL 의존 부재 대응).

### SSRF 9항목 (`SsrfSafeFetcher`)

scheme allowlist · 사설/루프백 IP 차단 · DNS rebinding 방어 · redirect 재검증 · size/timeout 상한 등 9항목. 초기 구현은 `Host` restricted-header 를 통한 fetch 가 전건 empty 가 되어 **테스트가 vacuous(거짓통과)** 였고 CGNAT(100.64/10) 등 차단 범위갭이 있었다. backward 보정에서:

- **option-b hostname-connect 전환** — 사전 resolve 한 IP 를 차단 검사 후 hostname 으로 직접 connect(restricted-header 우회 제거).
- 차단 대역 확장(CGNAT 등).
- `FeedParser` RFC1123 pubDate 파싱 robust 화(선행 요일토큰 요일-날짜 불일치에도 견딤 — orchestrator 마이크로수정).

### 검증

backward: verification FAIL(L1 283, 2fail+1err) + adversarial SSRF 감사 CRITICAL → impl fix1 → 재검증 FAIL(FeedParser 잔존) → orchestrator 마이크로수정 → **L1 287/287 GREEN**. L2: board keyset row-comparison · insertIgnoreDup ON CONFLICT · FK(throwaway DB). adversarial 감사: XSS SOUND, 보정 후 SSRF 우회 차단.

---

## W3-5 — Unity WebGL 업로드 보안 보강 (`08d191f`)

### DDL (신규 1테이블)

- `game_upload_audit_log` — 업로드 감사(outcome CHECK, FK, reject_reason enum). `docs/game-upload-ddl.sql`(멱등) + `db/schema.sql`(append) 동기.

### 런타임 동작

- `ZipSecurity`(+ `ZipRejectException`) — **zip-slip canonical 경계 + 심링크 거부 + zip bomb(getCompressedSize 0 회피·3중상한) + 포맷검증(wasm/data/loader) + 권한게이트**. 거부 사유 enum 11종.
- `GameUploadController` — 게이트 순서 정렬 + 임시추출 후 `ATOMIC_MOVE` swap(원자 교체) + audit 적재. dead code 제거.
- `GameUploadAuditMapper`(+ `GameUploadAudit`/`GameAssetOwner` POJO) — `#{}` only, 컬럼 8 정합.
- `GameAssetCleanupService` — 자산 생명주기 정리(UUID 검증 + 경계, gameRoot 미터치).

### 검증

L1 318/318 GREEN(편차 0). L2: audit insert · outcome CHECK · FK · `findGameByWebglUuid` alias(throwaway DB). **adversarial zip-slip 감사 SOUND** — 심링크 TOCTOU 구조불가, 경로탈출/유니코드/zipbomb/LIKE주입 전수 차단. LOW 2건(split 빈세그먼트·static 트리 `.tmp` 노출)은 open_item.

---

## W4 — 유저 배지 / 평판 (`305cc73`)

### DDL (신규 3테이블) + 권한키

| 객체 | 역할 | 핵심 제약 |
|---|---|---|
| `badges` | 배지 카탈로그 | `badges_type_check` |
| `user_badges` | 유저 보유 배지 | `ux_user_badges_active` 부분유니크(revoked 행 제외 → 회수후 재획득) |
| `reputation_events` | 평판 이벤트 감사 | `reputation_events_type_check` IN(REVIEW_WRITTEN, GAME_UPLOADED), source_ref 부분유니크 dedupe |

- DDL 권위: `docs/badge-ddl.sql`(멱등) + `db/schema.sql`(line 1075/1100/1127) 동기.
- **`PermissionKeys.BADGE_MANAGE` 신규(4번째 권한키)** — W1 권한 카탈로그 카운트 **3→4** 갱신. `PermissionCatalogVerifier` 가 `values()` 순회(하드코딩 리스트 아님)라 키 추가에 자동 적응, 카운트 가정 테스트 회귀 0.

### 런타임 동작

- `BadgeService` / `ReputationService` — 자동(임계 도달 시 부여)·수동(grant/revoke) 하이브리드. `BadgeCatalogSeeder` 시드.
- 자동 부여 훅 2곳: `GameReviewController.listReviews` 경로(리뷰 작성) + `GameController` 게임 업로드 경로 → `reputationService.record`. event_type CHECK 2종과 정합.
- `BadgeManageController` — `/manage/badges` grant/revoke. 진입부 순서 CSRF(403) → 미인증(401) → `BADGE_MANAGE` 게이트(403).
- 표시: 리뷰 작성자 배지 칩(`listReviews` JSON + game-detail.jsp JS, `UserBadgesQueryMapper.listActiveBadgeKeysByUserIds` 배치쿼리로 N+1 차단) + profile.jsp 본인 배지.

### 검증

L1 347/347 GREEN(baseline 318→347, BadgeServiceTest 12 + BadgeManageControllerTest 14 + GameReviewControllerTest 회귀 3). **W1 권한 카탈로그 회귀 0**(런타임 로그 "권한 카탈로그 시드 완료: 4 키", 카운트 가정 테스트 전부 GREEN). L2: 자동부여 멱등 · 회수후 재획득 부분유니크 · reputation dedupe · countActive 집계 · 배치 alias camelCase 인용 · CHECK/FK(throwaway DB). contextLoads 신규 @MockBean 6.

---

## 보안 (프로젝트 보안 원칙 추적)

본 작업에서 신설·연결된 인가/검증/방어 게이트. 모두 단위 + L2 DB 계약(+ 보안핵심 2건은 adversarial 코드추적 감사)으로 검증.

| 게이트 | 적용 W | 내용 |
|---|---|---|
| CONTENT_MODERATE 재사용 | W3-1 | 태그 생성/부착 게이트. W1 선언키 재사용(신규키 0). |
| POST_WRITE enforcement | W3-3 | W1 선언키의 첫 enforcement 연결(이전 소비처 0). 게시글 작성 게이트. |
| BADGE_MANAGE 신규 | W4 | 4번째 권한키 신규. 배지 grant/revoke 게이트(`/manage/badges`). W1 카탈로그 3→4. |
| SsrfSafeFetcher 9항목 | W3-3 | scheme allowlist·사설/루프백 IP 차단·DNS rebinding·redirect 재검증·size/timeout. option-b hostname-connect(restricted-header 우회 제거). |
| 마크다운 sanitize | W3-3 | commonmark 렌더 후 jsoup Safelist sanitize(XSS 차단). adversarial XSS SOUND. |
| zip-slip / 심링크 / zipbomb | W3-5 | ZipSecurity canonical 경계 + 심링크 거부 + 3중상한. adversarial zip-slip SOUND. ATOMIC_MOVE 원자 교체. |
| 방문수 dedupe | W3-1 | game_views interval 기반 재방문 제외(view_count 부풀림 방지). |
| CSRF 전수 | 전 W(상태변경) | 모든 상태 변경 핸들러 CSRF 선검증 → 누락 시 403. |
| MyBatis `${}` 0건 | 전 W | 신규 매퍼 전수 `#{}` 바인딩, 동적 치환 0. |
| §33 alias 케이스폴딩 | W3-1/4, W4 | VIEW/집계/배치 매퍼 camelCase alias 큰따옴표 인용 — Postgres 소문자 폴딩 회피. |

---

## 검증 결과 요약

최종 컨테이너 `./mvnw -o test`(eclipse-temurin:21-jdk + `~/.m2` 마운트, 오프라인) **347/347 GREEN**(baseline 190 → 누적 +157 신규테스트, 회귀 0).

| 서브 | L1 누적 | L2 dev DB contract(격리 throwaway) | 비고 |
|---|---|---|---|
| W3-1 | 235/235 GREEN | PASS(alias 폴딩 대조·NULLS LAST·AND⊆OR·ILIKE·interval dedupe) | +45 신규, 편차 2(의미보존) |
| W3-4 | 243/243 GREEN | PASS(keyset 혼합방향 OR분해 무중복·무누락·tie-break) | 편차 0 |
| W3-3 | 287/287 GREEN | PASS(board keyset·insertIgnoreDup·FK) | backward 1회(verification+adversarial SSRF 2중검증이 vacuous 포착) |
| W3-5 | 318/318 GREEN | PASS(audit·outcome CHECK·FK·alias) | adversarial zip-slip SOUND |
| W4 | **347/347 GREEN** | PASS(멱등·재획득 부분유니크·dedupe·집계·배치 alias·CHECK/FK) | W1 카탈로그 3→4 회귀 0 |

- **L2 비파괴 원칙**: 모든 L2 contract 는 격리 throwaway postgres:16(생성 → DDL 적용 → 실측 → DROP)로 수행. 사용자 dev(`bibimbap`) DB **무접촉**(전 W).
- **2중 검증 가치**: W3-3 에서 verification(테스트 실행)이 통과시킨 vacuous SSRF 를 adversarial 감사(코드추적)가 포착 → option-b 전환으로 실질 차단 확보.

상세는 `.atp/work-session/20260629-100849/verification.md` + `report.md` §verified_by_me 참조.

---

## 미적용 / 이월 (needs_user_verification)

| 항목 | 사유 |
|---|---|
| 실 dev DB(`bibimbap`) DDL 미적용 | L2 는 throwaway DB 로 통과했으나 실 dev DB 에는 미적용(파괴적 조작 회피). 신규 DDL 5종(tag/games-hub/board/game-upload/badge) **+ (W2 carryover) jam DDL 3종 = 총 8종** 을 `db/apply-local-ddl.sh` 로 일괄 적용 필요(§6 수동게이트). L3 선행. |
| L3 런타임 스모크(WAR 기동 후) | ① 검색 `/games/search` 태그·정렬·잼필터 + 방문수 dedupe ② 허브 `/` keyset 더보기 + 진행중 잼 배너 ③ 포스팅 작성(POST_WRITE)/sanitize 렌더/OG 미리보기/유니티 피드 폴링 + **★실 HTTPS 외부 fetch 동작**(option-b hostname-connect 실 TLS — 단위는 HTTP만) ④ 업로드 zip-slip/포맷 거부 + 정상 Unity 빌드 + replaceUuid 교체 ⑤ 배지 자동부여(리뷰·업로드)·수동 grant/revoke(BADGE_MANAGE)·리뷰작성자 칩. 자동 미수행. |
| **W4 표시 deferral** | 리뷰작성자 배지 경로만 구현. **프로필 myBadges 주입·게임카드 creator 배지 미구현**(의도된 deferral). 게임카드 SSR 은 `WebMvcController` hub + `SearchController` 가 담당(W3-4/타 W 산출물)이라 본 작업 범위 밖. 후속 작업에서 WebMvcController/SearchController 수정으로 creator 배지 부착 필요. profile.jsp 본인 배지는 구현됨. |
| W3-3 SSRF 잔여 rebinding TOCTOU | option-b hostname-connect 의 connect 시 재resolve 창(DNS 캐시로 최소화, 위협모델 LOW — URL 제출자=권한자). 하드닝 옵션: `networkaddress.cache.ttl` 설정 검토. |
| W3-5 배포 하드닝 | (1) **업로드 저장루트 static 트리 밖 이전**(LOW: static 트리 `.tmp` 노출 가능) (2) **gameRoot 경로 이중중첩 정합**(`app.upload.game-storage-path=src/main/resources/static/game` → `resolve('game')` → `.../static/game/game`). 설계 스코프-아웃(canonical 경계는 보장). dev 자산 위치 영향이라 사용자 config 결정 필요. |
