---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-23T12:00:00+09:00
workstream: W3-3-포스팅 보드(공지/블로그 + OG 미리보기 + 유니티블로그 피드 감시)
concerns:
  - "★SSRF 핵심: SsrfSafeFetcher 의 'DNS resolve → IP 검증 → 그 IP 로 connect(host 헤더는 원본 호스트명)' 핀닝은 java.net.http.HttpClient/RestClient 기본 동작으로는 그대로 안 된다(클라이언트가 호스트명으로 재-resolve). 구현은 (a) custom javax.net.SocketFactory/InetAddress 핀닝 또는 (b) resolve→검증 후 connect→연결된 소켓의 실제 peer IP 재검증(TOCTOU 최소화) 중 (b)를 1차로 명세했다. 구현 단계에서 라이브러리 제약으로 (a)가 필요해질 수 있으니 SSRF 단위테스트(사설/메타데이터/rebinding mock)로 실제 차단을 검증할 것."
  - "pom.xml 신규 의존 3종(commonmark, jsoup, 선택적으로 HTTP 클라이언트는 spring-web 내장 RestClient 로 충족) 추가가 전제다. design-advisor 는 pom.xml 을 수정하지 않으므로 implementation 단계 첫 작업이 의존 추가 + contextLoads 확인이다. 의존 미추가 시 컴파일 불가 — 구현 1보에서 의존 추가를 먼저 커밋."
  - "신규 컨트롤러(PostController/PostAdminController/UnityFeedAdminController)·신규 매퍼 6종·SsrfSafeFetcher·OgPreviewService·UnityFeedPoller 빈 추가는 verification-strategies §30 대상 — BibimbapApplicationTests 에 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. full ./mvnw -o test 로 끝낼 것(test-compile 만 금지)."
  - "신규 매퍼 SQL 의 camelCase alias 는 일반 매퍼 표준(snake→camel 직접 alias: og_title AS ogTitle). 집계/큰따옴표 alias 불필요(verification-strategies §33). DB-방언 계약(L2) 대상."
  - "UnityFeedPoller @Scheduled 는 @EnableScheduling 활성화가 전제(현재 미활성, code-fact: @EnableScheduling 0 hit). 신규 @Configuration 또는 메인 클래스에 활성화 필요 — 구현 시 다중 인스턴스 동시 폴링 중복 방지(현 톰캣 단일 인스턴스라 1차는 무방, 스케일아웃 시 분산락 필요는 비목표)."
  - "PostMarkdownService.render 시그니처는 최소 인자(markdown 1개)로 명세. 구현에서 sanitize 정책 주입이 필요하면 그때 확장(inflate 방지, 프로토콜 §11.2)."
concerns_checked: true
self_verification:
  checklist_passed: true
references:
  requirements: docs/work-log/2026-06-17-w3-feature-skeletons.md
  research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
  adrs:
    - .atp/work-session/20260622-180054/implementation/W1-design.md
    - docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
    - docs/work-log/2026-06-17-jam-platform-roadmap.md
    - docs/development/verification-strategies.md
---

# 설계: W3-3 — 포스팅 보드 (공지/블로그 + 외부링크 OG 미리보기 + 유니티블로그 피드 감시)

## 목표 / 비목표

### 목표 (FR/NFR 추적)
- **G1 포스팅 작성/수정/삭제** (골자 W3-3 핵심): POST_WRITE 권한자만 작성. 일반 유저 읽기 전용. **댓글 없음**(골자 Q5 — W3-2 결합 0).
- **G2 카테고리 운영** (골자 Q2): `post_categories` DB 저장, 운영자 추가/수정/삭제. 정렬·활성 토글.
- **G3 마크다운 본문 + sanitize** (골자 Q4): 본문은 마크다운 입력 → allowlist sanitize HTML 캐시. 저장 시 `body_markdown`(원본) + `body_sanitized_html`(렌더 캐시) 듀얼.
- **G4 ★외부링크 OG 미리보기 + SSRF 방어** (골자 Q3): 서버측 fetch 로 OG 메타 추출, SSRF 전수 방어, 결과 캐싱, 실패 graceful(링크만).
- **G5 ★유니티블로그 외부 피드 감시** (골자 Q6): RSS/Atom 폴링 + dedupe(last_seen_guid) + 새 글 감지 → 운영자 알림 표면. 피드 URL 도 SSRF 방어.
- **G6 게시판 페이징** (골자/grounding R-F): RecruitPostsMapper 페이징 부재 → keyset 페이징 신규.
- **NFR-보안**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), POST_WRITE 게이트(임시 role 직접체크 금지), SSRF 방어 전수, 마크다운 sanitize XSS 차단, JSP 출력 escape.
- **NFR-정합**: DDL 권위 = `docs/board-ddl.sql`, schema.sql 동기. 멱등·비파괴.

### 비목표 (스코프 밖)
- 포스팅 댓글/리액션 (골자 Q5 — 읽기 전용 채널 확정).
- 마크다운 위지윅 에디터 (입력은 textarea + 미리보기, 에디터 고도화는 후속).
- 외부 피드 자동 재게시(임포트) — 본 설계는 **감지 + 운영자 알림**까지. 감지 항목을 정식 포스트로 자동 전환하지 않음(운영자가 수동으로 작성). 피드 항목 클릭 시 원문 링크 이동.
- 피드 폴링 분산락/멀티인스턴스 중복 방지 (현 단일 톰캣 인스턴스 전제, 스케일아웃 시 후속).
- OG 미리보기 이미지 프록시/리사이즈 캐싱 (URL 캐시까지, 바이너리 캐시는 후속).
- 잼 연동 (골자 — 포스팅은 잼 무관 독립 채널).

---

## 개요

bibimbap 는 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis `@Mapper`(`#{}`) + JSP 스택이다. 게시판 선례는 `RecruitController`(읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<Map>` + `CsrfTokens.isValid` + 세션 userId 화이트리스트)이며 **PermissionGate 미적용·페이징 부재**다(code-fact: RecruitController.java:65,159; RecruitPostsMapper 페이징 0).

본 설계는 `posts` + `post_categories` 게시판을 신설하고, **작성 경로에 W1 `PermissionGate.has(session, PermissionKeys.POST_WRITE.name())` enforcement 를 연결**한다(현재 POST_WRITE 키는 enum 선언만·소비처 0 — code-fact: PermissionKeys.java:5, grounding R-A). 임시 role 직접체크는 금지(정석 원칙).

가장 까다로운 두 보안 난제를 다음과 같이 확정한다.

- **난제1 (마크다운 → sanitize HTML)**: 단일 정의처 = `PostMarkdownService`. 입력 마크다운을 **commonmark** 로 HTML 변환 후 **jsoup `Safelist`**(allowlist) 로 sanitize 해 `body_sanitized_html` 에 캐시한다. 허용 서식은 제목(h1~h3)/볼드/이탤릭/리스트/링크(rel=nofollow noopener, http/https만)/인라인·블록 코드/이미지(http/https src만). 스크립트·이벤트핸들러(onclick 등)·`javascript:`·style·iframe·object 전부 제거. **저장 시 1회 sanitize → 조회 시 캐시 출력**(매 조회 재sanitize 비용 제거 + 정책 변경 시 재생성 배치 여지). JSP 는 sanitized HTML 을 `<c:out escapeXml="false">` 가 아니라 **이미 sanitize 된 신뢰 HTML 로 직접 출력**(단 sanitize 가 유일 신뢰원천).
- **난제2 (★SSRF 방어 — OG fetch + 피드 fetch 공통)**: **단일 공용 컴포넌트 `SsrfSafeFetcher`** 로 외부 fetch 를 단일화한다(crossRefs 권장). OG 미리보기와 유니티블로그 피드가 동일 fetcher 를 통과한다. 방어 체크리스트는 §보안(SsrfSafeFetcher 계약)에 전수 기록. 핵심: scheme allowlist(http/https) → 호스트 resolve → 모든 resolved IP 가 공인(public) 인지 검증(사설/루프백/링크로컬/메타데이터 169.254.169.254 차단) → connect → **연결된 소켓의 실제 peer IP 재검증(rebinding 방어)** → redirect 매 홉 재검증(최대 N홉) → 응답 size cap + timeout → Content-Type 검증. 실패는 예외가 아니라 graceful 결과(미리보기 없음 / 피드 폴링 skip + 로그).

---

## 핵심 결정 요약 (전제 — 재논의 금지, 난제는 본 설계가 확정)

| 결정 | 확정값 | 본 설계의 구체화 |
|---|---|---|
| D1 게시판 모델 | posts + post_categories(DB저장) | `posts`(FK category_id, author_user_id, title, body_markdown, body_sanitized_html, og_*, status, is_delete) + `post_categories`(name/slug/sort_order/is_active) |
| D2 작성 권한 | POST_WRITE 게이트 enforcement 연결 | `PermissionGate.has(session, PermissionKeys.POST_WRITE.name())`. 임시 role 직접체크 금지. ADMIN 은 게이트 내부에서 암묵 통과 |
| D3 댓글 | 없음 | 읽기 전용 채널(골자 Q5). W3-2 결합 0 |
| D4 본문 | 마크다운 → sanitize HTML allowlist | commonmark 변환 + jsoup Safelist. body_markdown 원본 + body_sanitized_html 캐시(저장 시 1회 sanitize) |
| D5 ★OG 미리보기 | 서버 fetch + SSRF 방어 + 캐싱 | 공용 `SsrfSafeFetcher` 경유. 결과는 posts.og_* 컬럼 캐시. 실패 graceful |
| D6 ★유니티 피드 감시 | RSS/Atom 폴링 + dedupe + 알림 | `unity_feed_sources` + `unity_feed_items`. @Scheduled 폴링. last_seen_guid dedupe. 운영자 대시보드 미확인 배지 |
| D7 외부 fetch 인프라 | 공용 `SsrfSafeFetcher` 단일화 | OG·피드 동일 fetcher 경유(SSRF 방어 중복 0) |
| D8 페이징 | keyset 페이징 | (created_at, id) 커서. RecruitPostsMapper 전건 조회와 결별 |
| D9 신규 의존 | commonmark + jsoup | HTTP 는 spring-web 내장 RestClient 사용(신규 의존 0). 마크다운/sanitize 만 신규 |

---

## 데이터 모델 (DDL)

> 권위 = 신규 `docs/board-ddl.sql`. `db/apply-local-ddl.sh` 가 `docs/*-ddl.sql` 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). schema.sql 은 동기 사본(recruit_posts/rbac 선례와 동일). 전부 멱등·비파괴. 따옴표 식별자·SEQUENCE+nextval·DO $$ guard FK 등 기존 스타일(schema.sql:263-368) 준수.

### 신규 파일: `docs/board-ddl.sql`

```sql
-- W3-3 포스팅 보드. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- posts / post_categories / unity_feed_sources / unity_feed_items. 추가만, 파괴 없음.

-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
CREATE TABLE IF NOT EXISTS "post_categories" (
    "id"          bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
    "name"        character varying(80) NOT NULL,
    "slug"        character varying(80) NOT NULL,
    "sort_order"  integer DEFAULT 0 NOT NULL,
    "is_active"   boolean DEFAULT true NOT NULL,
    "created_at"  timestamp with time zone DEFAULT now() NOT NULL,
    "updated_at"  timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "post_categories_id_seq" OWNED BY "post_categories"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
    ON "post_categories" ("slug");

-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
CREATE TABLE IF NOT EXISTS "posts" (
    "id"                  bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
    "category_id"         bigint NOT NULL,
    "author_user_id"      bigint NOT NULL,
    "title"               character varying(200) NOT NULL,
    "body_markdown"       text NOT NULL,           -- 원본(편집·재렌더 소스)
    "body_sanitized_html" text NOT NULL,           -- 저장 시 sanitize 캐시(D4, 조회 출력원)
    "link_url"            character varying(2048),  -- 외부링크 큐레이션 대상(OG 미리보기 소스)
    "og_title"            character varying(300),   -- OG 캐시(D5). NULL=미리보기 없음
    "og_description"      character varying(600),
    "og_image_url"        character varying(2048),
    "og_site_name"        character varying(200),
    "og_fetched_at"       timestamp with time zone, -- 마지막 OG fetch 시각(재fetch 정책 기준)
    "status"              character varying(20) DEFAULT 'PUBLISHED' NOT NULL, -- DRAFT/PUBLISHED
    "created_at"          timestamp with time zone DEFAULT now() NOT NULL,
    "updated_at"          timestamp with time zone DEFAULT now() NOT NULL,
    "deleted_at"          timestamp with time zone,
    "is_delete"           boolean DEFAULT false NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
        ALTER TABLE "posts"
            ADD CONSTRAINT "posts_category_id_fkey"
            FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
        ALTER TABLE "posts"
            ADD CONSTRAINT "posts_author_user_id_fkey"
            FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
        ALTER TABLE "posts"
            ADD CONSTRAINT "posts_status_check"
            CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
    END IF;
END
$$;
-- keyset 페이징 + 카테고리 필터 커버링 인덱스(D8)
CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
    ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
    ON "posts" ("category_id", "created_at" DESC, "id" DESC)
    WHERE "is_delete" = false AND "status" = 'PUBLISHED';

-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
    "id"             bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
    "name"           character varying(120) NOT NULL, -- 표시명(예: Unity Blog)
    "feed_url"       character varying(2048) NOT NULL,
    "is_active"      boolean DEFAULT true NOT NULL,
    "last_polled_at" timestamp with time zone,
    "last_seen_guid" character varying(512),          -- dedupe 커서(가장 최근 본 항목 guid)
    "last_error"     character varying(500),           -- 마지막 폴링 실패 사유(graceful)
    "created_at"     timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
    ON "unity_feed_sources" ("feed_url");

-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
CREATE TABLE IF NOT EXISTS "unity_feed_items" (
    "id"             bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
    "source_id"      bigint NOT NULL,
    "guid"           character varying(512) NOT NULL,  -- 피드 항목 고유 식별자(dedupe 키)
    "title"          character varying(500) NOT NULL,
    "link_url"       character varying(2048) NOT NULL,
    "published_at"   timestamp with time zone,
    "is_acknowledged" boolean DEFAULT false NOT NULL,  -- 운영자 확인 여부(알림 배지)
    "detected_at"    timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
        ALTER TABLE "unity_feed_items"
            ADD CONSTRAINT "unity_feed_items_source_id_fkey"
            FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
    END IF;
END
$$;
-- 동일 소스 내 guid 중복 차단(dedupe 영속 보장 — DB 레벨 1차 방어)
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
    ON "unity_feed_items" ("source_id", "guid");
-- 미확인 항목 배지 카운트용
CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
    ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
    WHERE "is_acknowledged" = false;
```

### `db/schema.sql` 반영 (최초 기동 1회 자동 주입)
- `rbac_audit_log` 블록 뒤(schema.sql:369 직후)에 위 1~4번 블록을 신설 추가.
- 반영 방식은 recruit_posts/rbac 가 schema.sql 에 동기화된 선례(schema.sql:261-368)와 동일 — **docs/board-ddl.sql 이 권위, schema.sql 은 그 사본**.

### 카테고리 부트스트랩
- 카테고리는 D2 에 따라 운영자가 콘솔에서 생성. 시드 불필요(빈 상태에서 운영자가 첫 카테고리 생성). 단 dev 편의를 위해 `db/seed-dev.sql` 류가 있으면 샘플 카테고리 1건 INSERT 는 documentation/seed 소관(본 설계는 요구만 명시, DDL 에 포함 안 함).

---

## 외부 계약 (API)

> 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(실패 시 403 + `CsrfTokens.errorBody()`). 쓰기 응답은 `ResponseEntity<Map<String,Object>>`(status/message), 읽기는 JSP 뷰이름 반환(RecruitController 패턴). 작성/카테고리/피드관리 액션은 권한 게이트 통과 후 도달.

### 401 vs 403 정책 (확정 — W1 정책과 일치)
- **미인증**(세션 userId 없음): 페이지 진입 `redirect:/login`, 쓰기 API 401 JSON(`{status:401,message:"로그인이 필요합니다."}`).
- **인증·미인가**(로그인됐으나 POST_WRITE 없음): **403**(`{status:403,message:"권한이 없습니다."}`). 페이지 작성폼 진입도 403(리다이렉트 금지 — 권한 없음을 로그인으로 오인 유도 방지).
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.

### 공개 포스팅 페이지 (뷰 — 인증 불요, 읽기 전용)
| method | path | 권한 | 응답 |
|---|---|---|---|
| GET | `/posts` | 공개 | `posts-list` JSP. PUBLISHED 글 keyset 페이징(쿼리 `?categoryId=&cursorCreatedAt=&cursorId=`) + 카테고리 탭(is_active) |
| GET | `/posts/{id}` | 공개 | `posts-detail` JSP. body_sanitized_html + (있으면) OG 미리보기 카드. is_delete/DRAFT 면 redirect:/posts |

### 작성/편집 (POST_WRITE 게이트 — D2)
| method | path | 권한 | 요청 | 응답(200) | 에러 |
|---|---|---|---|---|---|
| GET | `/posts/new` | POST_WRITE | (없음) | `posts-form` JSP(활성 카테고리 목록 + CSRF) | 401 redirect / 403 |
| POST | `/posts` | POST_WRITE | categoryId, title, bodyMarkdown, linkUrl?, status | `{status:200, postId, location:"/posts/{id}"}` | 400(검증), 401, 403(권한/CSRF), 404(카테고리 없음/비활성) |
| GET | `/posts/{id}/edit` | POST_WRITE | (path) | `posts-form` JSP(기존 값) | 401/403/404 |
| POST | `/posts/{id}` (수정) | POST_WRITE | 동일 필드 | `{status:200, postId}` | 400/401/403/404 |
| POST | `/posts/{id}/delete` | POST_WRITE | (path) | `{status:200}` | 401/403/404 |

- **작성 검증**: title ≤ 200자, bodyMarkdown ≤ 20000자, linkUrl ≤ 2048자 + scheme http/https(없으면 OG 생략), status ∈ {DRAFT, PUBLISHED}, categoryId 는 존재·is_active.
- **저장 부작용**: bodyMarkdown → `PostMarkdownService.render` → body_sanitized_html. linkUrl 있으면 **동기 OG fetch**(아래 OG 정책) — 단 타임아웃 cap 내 실패 시 og_* NULL 로 graceful 저장(작성 자체는 성공).
- **권한 모델**: 작성·수정·삭제 모두 POST_WRITE 게이트. **작성자 본인 한정이 아니라 POST_WRITE 보유자 전원 편집 가능**(공지/블로그 = 운영 채널 특성 — 본인글 제한은 비목표; 단 삭제/수정 시 author_user_id 는 보존, 감사 가능성은 후속). ADMIN 은 게이트 내부에서 암묵 통과(PermissionGate.has 의 role==ADMIN 분기, PermissionGate.java:30).

### 카테고리 운영 (운영자 — D2/G2)
> 카테고리 CRUD 는 운영자 전용. **인터셉터 보호 경로 `/admin/**` 아래에 둔다** → RbacInterceptor 가 ADMIN 게이트 적용(InterceptorConfig.java:19 `/admin/**`). 카테고리 운영은 ADMIN 권한으로 통일(별도 권한키 불필요 — over-engineering 회피).

| method | path | 권한 | 요청 | 응답(200) | 에러 |
|---|---|---|---|---|---|
| GET | `/admin/post-categories` | ADMIN(인터셉터) | (없음) | `admin-post-categories` JSP 또는 `{categories:[...]}` | 401/403 |
| POST | `/admin/post-categories` | ADMIN | name, slug, sortOrder | `{status:200, categoryId}` | 400, 409(slug 중복), 403 |
| POST | `/admin/post-categories/{id}` | ADMIN | name?, slug?, sortOrder?, isActive? | `{status:200}` | 400, 404, 409, 403 |
| POST | `/admin/post-categories/{id}/delete` | ADMIN | (path) | `{status:200}` | 404, 409(소속 포스트 존재 시 거부 또는 soft), 403 |

- **카테고리 삭제 정책**: 소속 PUBLISHED 포스트가 있으면 **하드 삭제 거부(409)** + `is_active=false` 권고(메시지). 정석: FK 무결성 보존, 포스트 고아화 방지.

### 유니티 피드 감시 운영 (운영자 — D6/G5)
> 동일하게 `/admin/**` 아래 → 인터셉터 ADMIN 게이트.

| method | path | 권한 | 요청 | 응답(200) | 에러 |
|---|---|---|---|---|---|
| GET | `/admin/unity-feeds` | ADMIN | (없음) | `admin-unity-feeds` JSP(소스 목록 + 미확인 감지 항목 + 배지 카운트) | 401/403 |
| POST | `/admin/unity-feeds` | ADMIN | name, feedUrl | `{status:200, sourceId}` | 400(SSRF 거부 시 feedUrl 부적합), 409(url 중복), 403 |
| POST | `/admin/unity-feeds/{id}/toggle` | ADMIN | (path) | `{status:200, isActive}` | 404, 403 |
| POST | `/admin/unity-feeds/{id}/delete` | ADMIN | (path) | `{status:200}` | 404, 403 |
| POST | `/admin/unity-feeds/items/{itemId}/ack` | ADMIN | (path) | `{status:200}` | 404, 403 |
| POST | `/admin/unity-feeds/poll` | ADMIN | (없음, 수동 트리거) | `{status:200, newCount}` | 403 |

- **피드 등록 시 SSRF 선검증**: feedUrl 은 등록 시점에 `SsrfSafeFetcher` 의 URL 검증(scheme/host resolve/IP 공인 여부)을 통과해야 등록 허용(400 거부). 폴링 시 매번 재검증(rebinding 방어).

---

## 보안: SsrfSafeFetcher 계약 (★핵심 — D5/D7, OG·피드 공통)

> 단일 공용 컴포넌트. OG 미리보기 fetch 와 유니티 피드 fetch 가 **둘 다 이 fetcher 만** 경유한다(SSRF 방어 중복 0, crossRefs 단일화 권장 채택).

### SSRF 방어 체크리스트 (전수 — securityNotes 에 기록)
1. **scheme allowlist**: `http`, `https` 만 허용. `file:`/`gopher:`/`ftp:`/`data:` 등 전부 거부.
2. **호스트명 resolve → IP 검증**: `InetAddress.getAllByName(host)` 로 전체 IP 해석. **모든** resolved IP 가 공인이어야 통과(하나라도 사설/예약이면 거부).
3. **차단 IP 대역**: 루프백(127.0.0.0/8, ::1), 사설(10/8, 172.16/12, 192.168/16, fc00::/7), 링크로컬(169.254/16, fe80::/10), **메타데이터(169.254.169.254)**, 0.0.0.0, multicast, 와일드카드. `InetAddress.isLoopbackAddress/isSiteLocalAddress/isLinkLocalAddress/isAnyLocalAddress/isMulticastAddress` + 명시 169.254.169.254 차단 + IPv4-mapped IPv6 정규화 후 재검사.
4. **★DNS rebinding 방어**: resolve 시점 IP 와 connect 후 실제 peer IP 가 다를 수 있다. 1차 명세 = **connect 후 소켓의 `getInetAddress()`(실제 연결된 peer IP) 를 #3 기준으로 재검증** → 실패 시 즉시 abort. (concern: HttpClient 가 host 로 재resolve 하므로 connection-level 검증 필요. 구현에서 custom SocketFactory IP 핀닝이 필요하면 그때 도입.)
5. **redirect 매 홉 재검증**: 자동 redirect 따라가기 **비활성**(`RestClient`/`HttpClient` followRedirects=NEVER) → 3xx Location 을 받으면 **수동으로** #1~#4 재검증 후 다음 홉. 최대 홉 수 cap(예: 3). 초과 시 abort.
6. **응답 size cap**: 응답 본문 최대 바이트(예: OG=512KB, 피드=2MB) 초과 시 스트림 중단(부분 파싱). Content-Length 신뢰 금지 — 읽는 바이트 누적 카운트.
7. **timeout**: connect + read timeout(예: 3s/5s). 무한 대기 방지.
8. **Content-Type 검증**: OG=text/html(또는 application/xhtml+xml), 피드=application/rss+xml / atom+xml / xml / text/xml. 불일치 시 파싱 거부(graceful).
9. **에러 graceful**: 위 어느 단계 실패든 예외를 호출자에 전파하지 않고 `Optional.empty()`/실패 결과 반환. OG = 미리보기 없이 링크만. 피드 = 폴링 skip + `unity_feed_sources.last_error` 기록.

### fetcher 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
```java
// SsrfSafeFetcher — 외부 fetch 단일 관문(@Component). OG·피드 공통.
// URL 검증 + 안전 fetch 를 한 메서드로 묶는다(검증만 따로 노출하면 검증-fetch 사이 TOCTOU).
Optional<FetchResult> fetch(URI url,          // 대상(검증 통과해야 fetch). scheme/host 검증 진입점
                            long maxBytes,     // 응답 size cap(OG/피드 호출자가 용도별 상한 지정)
                            Set<String> allowedContentTypes) // #8 검증용(용도별 다름)
// FetchResult: int status / String contentType / byte[] body(cap 내) / URI finalUrl

// 등록 시 사전 URL 검증만 필요한 경우(피드 등록 400 판정) — fetch 없이 검증만.
boolean isFetchableUrl(URI url)               // #1~#3 정적 검증(등록 시점 빠른 거부)
```
> ⚠️ inflate 마킹(concern): `fetch` 의 `allowedContentTypes` 는 호출처가 2곳(OG=html, 피드=xml)뿐이라 인자로 받는 게 정당(상수 분기보다 명시적). `maxBytes` 도 용도별로 달라 정당. 그러나 구현에서 두 인자가 사실상 호출처별 고정 상수로만 쓰이면 `fetchHtml(url)`/`fetchFeed(url)` 2메서드로 분리하는 게 더 깔끔할 수 있음 — 구현 단계에서 재평가.

---

## 마크다운 sanitize 계약 (D4)

```java
// PostMarkdownService — 마크다운→sanitize HTML 단일 정의처(@Component).
String render(String markdown)   // 원본 → commonmark 변환 → jsoup Safelist sanitize → 캐시용 HTML
```
- **commonmark**(org.commonmark) 로 markdown → HTML.
- **jsoup `Safelist`** allowlist:
  - 허용 태그: h1,h2,h3,p,br,strong,em,ul,ol,li,blockquote,code,pre,a,img,hr.
  - `a`: href(http/https/mailto만), `rel=nofollow noopener`, `target=_blank` 강제 부여. `javascript:` 차단(jsoup 기본).
  - `img`: src(http/https만), alt. 인라인 width/style 제거.
  - 제거: script, style, iframe, object, embed, form, on* 이벤트 속성 전부.
- `Safelist.basicWithImages()` 기반 + 커스텀(h1~h3 추가, a rel 강제, protocol 제한). **저장 시 1회 render** → body_sanitized_html. 조회는 캐시 출력(재sanitize 없음).
- concern: render 시그니처는 markdown 1개 최소 인자. sanitize 정책을 호출별로 바꿀 필요가 생기면 그때 확장.

---

## 시퀀스

### S1. 포스팅 작성(POST_WRITE 게이트 + 마크다운 sanitize + OG fetch)
```
[POST_WRITE 보유 세션] POST /posts (CSRF, categoryId, title, bodyMarkdown, linkUrl?)
  → PostController.create
      → CsrfTokens.isValid(request) 실패 → 403 errorBody          ← AC-5
      → userId = sessionUserId; null → 401
      → permissionGate.has(session, POST_WRITE.name()) 실패 → 403   ← AC-1 (게이트 enforcement 연결)
          (내부: refreshIfStale epoch 대조 → role==ADMIN 통과 | SUBADMIN&&perms.contains(POST_WRITE))
      → 입력 검증(title≤200, bodyMarkdown≤20000, status 화이트리스트)
      → categoryMapper.getActive(categoryId) 없음 → 404
      → sanitizedHtml = postMarkdownService.render(bodyMarkdown)    ← AC-4 (XSS 차단)
      → linkUrl 있으면:
            ogResult = ogPreviewService.fetch(linkUrl)             ← SsrfSafeFetcher 경유
              - 성공 → og_* 채움
              - 실패/SSRF거부 → og_* NULL (graceful, 작성은 성공)   ← AC-7
      → postsMapper.insert(category, author, title, markdown, sanitizedHtml, og_*, status)
      → 200 {postId, location:"/posts/{id}"}
```

### S2. 포스팅 조회(공개 — sanitize 캐시 출력 + 페이징)
```
[익명/일반] GET /posts?categoryId=3&cursorCreatedAt=...&cursorId=...
  → PostController.list
      → postsMapper.listPublishedKeyset(categoryId, cursorCreatedAt, cursorId, limit+1)  ← AC-8 keyset
      → limit+1 행이면 hasNext=true, 마지막 행을 다음 커서로
      → posts-list JSP: 카드 목록(title, og_image_url 썸네일, 작성자 display_name)
[익명] GET /posts/42
  → postsMapper.getPublished(42); null/DRAFT/is_delete → redirect:/posts
  → posts-detail JSP: body_sanitized_html 직접 출력(이미 sanitize됨) + OG 카드(og_title/desc/image)
```

### S3. 유니티 피드 폴링(@Scheduled + SSRF + dedupe + 알림)
```
[스케줄러] UnityFeedPoller.poll() (@Scheduled fixedDelay, 예: 30분)
  → for each source in feedSourcesMapper.listActive():
        result = ssrfSafeFetcher.fetch(source.feedUrl, FEED_MAX_BYTES, FEED_CONTENT_TYPES)
          - 실패 → feedSourcesMapper.updateError(source.id, reason); continue (graceful)  ← AC-7
        items = feedParser.parse(result.body)   // RSS/Atom → (guid, title, link, publishedAt)
        newItems = items where guid not in (DB ux_unity_feed_items_source_guid)  ← AC-6 dedupe
        for each newItem:
            feedItemsMapper.insertIgnoreDup(source.id, guid, title, link, publishedAt)
                (ux_unique 충돌 시 무시 — DB 레벨 2차 dedupe 방어)
        feedSourcesMapper.updateCursor(source.id, lastSeenGuid=items[0].guid, last_polled_at=now)
  → 운영자가 GET /admin/unity-feeds 진입 시 미확인(is_acknowledged=false) 항목 배지 노출  ← AC-6 알림
```
- **dedupe 이중 방어**: (1) 폴링 로직이 guid 대조로 신규만 선별 + (2) DB `ux_unity_feed_items_source_guid` UNIQUE 가 경합·재폴링 시 중복 INSERT 를 무시(insertIgnoreDup = `ON CONFLICT DO NOTHING`). 단일 인스턴스라도 수동 트리거(`/admin/unity-feeds/poll`)와 스케줄 겹침 방어.

### S4. 카테고리 운영(ADMIN 인터셉터 게이트)
```
[ADMIN 세션] POST /admin/post-categories (CSRF, name, slug, sortOrder)
  → RbacInterceptor preHandle: /admin/** → isAdmin 통과(아니면 403/401)  ← AC-3
  → PostAdminController.createCategory
      → CsrfTokens.isValid 실패 → 403
      → slug 중복 → 409
      → categoryMapper.insert → 200 {categoryId}
```

---

## 파일 영향 맵

> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
> **U-SCHEMA**(board-ddl + schema.sql 동기) · **U-DOMAIN**(data POJO + 매퍼 6종) · **U-MARKDOWN**(PostMarkdownService + pom 의존) · **U-SSRF**(SsrfSafeFetcher + OgPreviewService — 보안 핵심, 단독 worker 권장) · **U-FEED**(UnityFeedPoller + FeedParser + 스케줄 활성) · **U-POST-CTRL**(PostController 공개/작성 + JSP) · **U-ADMIN-CTRL**(카테고리/피드 운영 컨트롤러 + JSP).
> 의존: U-SCHEMA → U-DOMAIN → {U-MARKDOWN, U-SSRF}. U-SSRF → {U-FEED(OgPreview 와 fetcher 공유), OG는 U-POST-CTRL 소비}. U-MARKDOWN/U-SSRF → U-POST-CTRL. U-DOMAIN → U-ADMIN-CTRL.

| 변경 유형 | 경로 | 역할 | 소유 |
|---|---|---|---|
| 신규 | `docs/board-ddl.sql` | 권위 DDL(posts/post_categories/unity_feed_sources/unity_feed_items). apply-local-ddl.sh 자동적용 | U-SCHEMA |
| 수정 | `db/schema.sql` | rbac 블록 뒤에 4테이블 동기 추가(board-ddl 사본) | U-SCHEMA |
| 수정 | `pom.xml` | commonmark + jsoup 의존 추가(HTTP 는 spring-web RestClient 내장) | U-MARKDOWN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/PostData.java` | posts 행 POJO(id/categoryId/authorUserId/title/bodyMarkdown/bodySanitizedHtml/linkUrl/og*/status + 조인 author displayName/categoryName) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/PostCategoryData.java` | post_categories 행 POJO | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/UnityFeedSourceData.java` | unity_feed_sources 행 POJO | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/UnityFeedItemData.java` | unity_feed_items 행 POJO | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/PostsMapper.java` | `@Mapper` posts CRUD + keyset 페이징(listPublishedKeyset/getPublished/insert/update/softDelete, `#{}`, snake→camel alias) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/PostCategoriesMapper.java` | `@Mapper` 카테고리 CRUD(listActive/getActive/insert/update/softToggle/delete/countPostsByCategory) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedSourcesMapper.java` | `@Mapper` 소스 CRUD(listActive/insert/updateCursor/updateError/toggle/delete) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedItemsMapper.java` | `@Mapper` 항목(insertIgnoreDup=ON CONFLICT DO NOTHING/listUnacknowledged/countUnacknowledged/acknowledge) | U-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/PostMarkdownService.java` | 마크다운→sanitize HTML(commonmark+jsoup Safelist). render(markdown) | U-MARKDOWN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java` | ★외부 fetch 단일 관문(SSRF 방어 전수). fetch(url,maxBytes,types)/isFetchableUrl(url) | U-SSRF |
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/OgPreviewService.java` | OG 메타 추출(SsrfSafeFetcher 경유 + jsoup OG 파싱). fetch(linkUrl)→OgPreview | U-SSRF |
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/UnityFeedPoller.java` | `@Scheduled` 폴링(SsrfSafeFetcher + FeedParser + dedupe). poll()/pollOnce(sourceId) | U-FEED |
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/FeedParser.java` | RSS/Atom 파싱(JDK javax.xml — 신규 의존 0). parse(bytes)→List<FeedItem> | U-FEED |
| 신규 | `src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java` | `@EnableScheduling`(현재 미활성, code-fact 0 hit) | U-FEED |
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/PostController.java` | 공개 목록/상세(JSP) + 작성/수정/삭제(POST_WRITE 게이트, JSON). PermissionGate 주입 | U-POST-CTRL |
| 신규 | `src/main/webapp/WEB-INF/views/posts-list.jsp` | 포스트 카드 목록 + 카테고리 탭 + keyset 더보기(JSTL escape) | U-POST-CTRL |
| 신규 | `src/main/webapp/WEB-INF/views/posts-detail.jsp` | 본문(sanitized html) + OG 카드(escape, og_image src 출력) | U-POST-CTRL |
| 신규 | `src/main/webapp/WEB-INF/views/posts-form.jsp` | 작성/수정 폼(textarea markdown + CSRF hidden + 카테고리 select) | U-POST-CTRL |
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java` | 카테고리 CRUD(/admin/post-categories, 인터셉터 ADMIN 게이트 + CSRF) | U-ADMIN-CTRL |
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/UnityFeedAdminController.java` | 피드 소스/항목 운영(/admin/unity-feeds, ADMIN + CSRF + ack/poll) | U-ADMIN-CTRL |
| 신규 | `src/main/webapp/WEB-INF/views/admin-post-categories.jsp` | 카테고리 운영 화면(CSRF) | U-ADMIN-CTRL |
| 신규 | `src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp` | 피드 소스/미확인 항목 + 배지(CSRF) | U-ADMIN-CTRL |
| 수정 | `src/main/webapp/WEB-INF/views/header.jsp` | 포스팅 메뉴 링크 추가(`/posts`) + (운영자) 미확인 피드 배지 노출 후보 | U-POST-CTRL |
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼 6종·SsrfSafeFetcher·서비스 @MockBean 등록(contextLoads, §30) | (검증) |
| 신규 | `src/test/.../SsrfSafeFetcherTest.java` | ★SSRF 전수 단위(사설/루프백/메타데이터/rebinding/redirect/size/timeout/scheme 차단) | (검증) |
| 신규 | `src/test/.../PostMarkdownServiceTest.java` | sanitize XSS 차단(script/onclick/javascript:/iframe 제거) 단위 | (검증) |
| 신규 | `src/test/.../PostControllerTest.java` | 작성 POST_WRITE 게이트 401/403/CSRF/검증 + keyset 페이징 | (검증) |
| 신규 | `src/test/.../FeedParserTest.java` + `UnityFeedPollerTest.java` | RSS/Atom 파싱 + dedupe(신규만 insert) 단위 | (검증) |

> SSR 호출지점: 전부 신규 경로(기존 호출지점 깨짐 0). header.jsp 메뉴 추가는 표시용.

### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
```java
// PostsMapper (@Mapper, #{} only, snake→camel 직접 alias)
List<PostData> listPublishedKeyset(java.time.OffsetDateTime cursorCreatedAt, // keyset 커서(null=첫페이지)
                                   Long cursorId,        // 동일 created_at 타이브레이커
                                   Long categoryId,      // null=전체 카테고리
                                   int limit)            // 페이지 크기(컨트롤러가 limit+1 요청해 hasNext 판정)
PostData getPublished(long id)                            // 상세(PUBLISHED && !is_delete + author/category 조인)
int insert(PostData post)                                 // 작성(useGeneratedKeys)
int update(PostData post)                                 // 수정(title/markdown/sanitized/og/status)
int softDelete(long id)                                   // is_delete=true, deleted_at=now

// PostCategoriesMapper
List<PostCategoryData> listActive()                       // 공개 탭 + 작성폼 select
PostCategoryData getActive(long id)                       // 작성 시 카테고리 유효성
int insert(PostCategoryData c) / int update(PostCategoryData c) / int delete(long id)
int countPostsByCategory(long categoryId)                 // 삭제 거부(409) 판정

// UnityFeedSourcesMapper
List<UnityFeedSourceData> listActive()                    // 폴링 대상
int insert(UnityFeedSourceData s)
int updateCursor(long id, String lastSeenGuid)            // 폴링 성공 후 커서 전진
int updateError(long id, String lastError)                // 폴링 실패 graceful 기록
int toggle(long id) / int delete(long id)

// UnityFeedItemsMapper
int insertIgnoreDup(UnityFeedItemData item)               // ON CONFLICT(source_id,guid) DO NOTHING
List<UnityFeedItemData> listUnacknowledged(int limit)     // 운영자 알림 목록
int countUnacknowledged()                                 // 배지 카운트
int acknowledge(long itemId)                              // 확인 처리

// OgPreviewService — SsrfSafeFetcher 경유. linkUrl 1개 최소.
Optional<OgPreview> fetch(String linkUrl)                 // 실패/SSRF거부 시 empty(graceful)

// UnityFeedPoller
void poll()                                               // @Scheduled 전체 활성 소스 폴링
int pollOnce(long sourceId)                               // 수동 트리거(반환 newCount)
```

---

## 대안 비교

| 주제 | 안 | 장점 | 단점 | 채택 |
|---|---|---|---|---|
| 외부 fetch 인프라 | (A) 공용 SsrfSafeFetcher 단일 | OG·피드 SSRF 방어 중복 0, 검증 일관 | 컴포넌트 1개 추상화 | **채택** |
| | (B) OG·피드 각자 fetch | 결합 낮음 | SSRF 방어 2벌 — 한쪽 누락 시 보안 구멍 | 기각 |
| DNS rebinding 방어 | (A) connect 후 peer IP 재검증 | 라이브러리 호환, TOCTOU 최소화 | host 재resolve 미차단 잔여(concern) | **채택(1차)** |
| | (B) custom SocketFactory IP 핀닝 | 완전 핀닝 | 구현 복잡·HttpClient 통합 부담 | 보류(필요시 승격) |
| 본문 sanitize 시점 | (A) 저장 시 1회 → 캐시 | 조회 저비용, 정책변경 재생성 여지 | 컬럼 1개(body_sanitized_html) | **채택** |
| | (B) 조회 시 매번 sanitize | 컬럼 1개 절약 | 매 조회 CPU, 캐시 이점 0 | 기각 |
| 피드 임포트 | (A) 감지+알림(운영자 수동 작성) | 큐레이션 품질·법적/저작권 안전 | 자동화 아님 | **채택** |
| | (B) 감지 후 자동 포스트 생성 | 완전 자동 | 저작권·스팸·중복 위험 | 기각(비목표) |
| 페이징 | (A) keyset(created_at,id) | 깊은 페이지 일관·성능 | 커서 전달 | **채택** |
| | (B) OFFSET/LIMIT | 단순 | 깊은 OFFSET 성능·삽입 시 드리프트 | 기각 |
| 카테고리 권한 | (A) ADMIN(/admin/** 인터셉터) | 신규 권한키 0, 인프라 재사용 | 카테고리=ADMIN 한정 | **채택** |
| | (B) 신규 CATEGORY_MANAGE 키 | 세분화 | over-engineering(소비 1곳) | 기각 |

---

## 롤아웃 / 마이그레이션

### 순서
1. **의존 추가**(U-MARKDOWN 선): pom.xml commonmark + jsoup → `./mvnw -o compile` 확인. (HTTP 는 RestClient 내장, 의존 0.)
2. **스키마 적용**: `docs/board-ddl.sql` → `db/apply-local-ddl.sh`. 기존 데이터 무관(신규 4테이블, 추가만).
3. **권한 시드 불필요**: POST_WRITE 키는 W1 PermissionCatalogVerifier 가 이미 enum→DB 시드(grounding R-A). 운영자가 콘솔에서 특정 SUBADMIN 에게 POST_WRITE 토글 부여하면 그때부터 포스팅 작성 가능.
4. **코드 배포**: 매퍼/서비스/컨트롤러/JSP. SchedulingConfig 활성 → 피드 폴링 시작.
5. **운영자 카테고리·피드 등록**: 콘솔에서 첫 카테고리 + 유니티 피드 소스 등록(SSRF 선검증 통과 URL만).

### 역호환
- 신규 경로·테이블·의존만 추가 — 기존 기능 영향 0. RecruitController/리뷰/RBAC 동작 불변.
- `/admin/**` 카테고리·피드 경로는 기존 RbacInterceptor `/admin/**` ADMIN 게이트(InterceptorConfig.java:19)에 자동 포섭 — 인터셉터 수정 불필요(경로 패턴 이미 커버).

### 롤백
- 코드 롤백: 컨트롤러/스케줄러 미등록 시 포스팅 기능 비활성. 신규 테이블 잔존해도 무해(비파괴).
- 의존 롤백: commonmark/jsoup 제거 시 PostMarkdownService 컴파일 깨짐 — 코드 동반 롤백.
- 스키마 롤백: 추가 전용이라 DROP 없이 잔존 무해. 명시적 DROP 은 별도 maintenance.

---

## AC 매핑

| AC | 요구 | 만족 설계 요소 | 비고 |
|---|---|---|---|
| AC-1 | POST_WRITE 보유자만 작성, 미보유 403 | PostController.create 의 `permissionGate.has(session, POST_WRITE)` (임시 role 체크 0) | S1, D2 |
| AC-2 | 일반 유저 읽기 전용 + 댓글 없음 | 공개 GET 만 인증 불요, 작성 게이트, 댓글 엔드포인트·테이블 부재 | D3 |
| AC-3 | 카테고리/피드 운영 ADMIN 전용 | `/admin/**` 경로 → RbacInterceptor isAdmin 게이트 | S4, §API |
| AC-4 | 본문 XSS 차단(sanitize) | PostMarkdownService render(commonmark+jsoup Safelist), script/on*/javascript: 제거 | D4, §sanitize |
| AC-5 | 상태변경 CSRF 없으면 403 | 전 쓰기 엔드포인트 `CsrfTokens.isValid` 선검증 | §API 공통 |
| AC-6 | 유니티 피드 새 글 감지·dedupe·알림 | UnityFeedPoller guid dedupe + ux_unique 2차 방어 + 미확인 배지 | S3, D6 |
| AC-7 | OG/피드 fetch 실패 graceful | SsrfSafeFetcher Optional.empty → OG NULL/피드 skip+last_error | S1/S3, §SSRF #9 |
| AC-8 | 게시판 keyset 페이징 | listPublishedKeyset(cursor) + idx_posts_*_keyset | S2, D8 |
| AC-9 | ★SSRF 전수 방어 | SsrfSafeFetcher 9항목 체크리스트(scheme/IP/rebinding/redirect/size/timeout/type/graceful) | §SSRF |
| AC-10 | SQL `${}` 0 | 신규 매퍼 6종 전부 `#{}` | §파일영향맵 |

---

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

> L레벨 매핑(verification-strategies.md): 권한 게이트/인가 플로우 = **L1+L2+L3**. ★SSRF 방어 = **L1(차단 단위) 필수 + L3(실제 외부 차단 스모크)**. 신규 매퍼 SQL/alias = **L1+L2(dev DB contract)**. 신규 빈 다수 = full `./mvnw -o test` 의무(§30).

### 시나리오 검증
- **VP-1 (AC-1 게이트, L1+L3)**: PostControllerTest — POST_WRITE 미보유 세션 작성 → 403, 보유(또는 ADMIN) → 200. 미인증 → 401. L3: 콘솔에서 토글 부여 후 작성 통과.
- **VP-2 (AC-9 ★SSRF, L1 필수)**: SsrfSafeFetcherTest — `http://127.0.0.1`, `http://169.254.169.254/latest/meta-data`, `http://10.0.0.1`, `http://[::1]`, `file:///etc/passwd`, IPv4-mapped IPv6, redirect→사설IP, size 초과, timeout 전부 차단/abort. **rebinding mock**(resolve=공인, connect peer=사설) 차단. L3: 실 외부 URL OG 성공 + 사설 URL 차단 스모크.
- **VP-3 (AC-4 sanitize, L1)**: PostMarkdownServiceTest — `<script>`, `<img onerror=>`, `[x](javascript:alert(1))`, `<iframe>` 입력이 출력 HTML 에서 제거됨. 정상 마크다운(볼드/링크/리스트)은 보존.
- **VP-4 (AC-6 피드 dedupe, L1)**: UnityFeedPollerTest — 동일 guid 재폴링 시 insert 0(신규만), 신규 guid 만 unity_feed_items 추가 + countUnacknowledged 증가.
- **VP-5 (AC-5 CSRF, L1)**: 전 쓰기 엔드포인트 CSRF 누락 → 403 + mapper 미호출(RecruitController/W3-2 패턴 준용).
- **VP-6 (AC-8 keyset, L1+L2)**: listPublishedKeyset 커서 페이징 정확성 + alias 매핑(snake→camel) DB-방언 계약.
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 매퍼 6종·SsrfSafeFetcher·서비스 @MockBean 등록 후 PASS(§30).

### 집합 전수 체크 AC (시점·표현 self-audit 적용)
> self-audit (시점 안정성): 아래 카운트는 본 워크스트림이 신규 생성하는 정적 산출물 또는 DDL/체크리스트 항목으로, verification 시점까지 본 워크스트림 외 변경 주체가 없다(시점 안정). 자기 트리처럼 계속 증가하는 대상 아님.
> self-audit (표현 견고성): 단일 리터럴 grep 취약성을 피해 (a) DDL 객체 카운트는 `CREATE TABLE IF NOT EXISTS` 고정 패턴 + 테이블명 집합, (b) SSRF/sanitize 는 의미 불변식 + 수동 판정으로 앵커한다.

- **AC-T1 신규 board 테이블 전수 4건** — `docs/board-ddl.sql` 의 `CREATE TABLE IF NOT EXISTS` 4건(post_categories/posts/unity_feed_sources/unity_feed_items) AND schema.sql 동기 4건: `grep -c 'CREATE TABLE IF NOT EXISTS' docs/board-ddl.sql` == 4. 누락·오타 동시 검출.
- **AC-T2 ★SSRF 방어 체크리스트 전수 9항목** — SsrfSafeFetcherTest 가 §SSRF 9항목(scheme/resolve/사설·루프백·링크로컬·메타데이터/rebinding/redirect/size/timeout/Content-Type/graceful) **전부**에 대응하는 차단 테스트를 보유. 검증: 9항목 각각 최소 1 테스트 메서드 존재(수동 매핑 — 리터럴 grep 아님, 의미 단위). **1항목이라도 미커버 시 SSRF 보안 구멍 → FAIL**(이 전수 AC 가 보안 핵심 가드).
- **AC-T3 쓰기 엔드포인트 CSRF 가드 전수** — PostController/PostAdminController/UnityFeedAdminController 의 모든 상태변경(@PostMapping) 핸들러에 `CsrfTokens.isValid` 선검증 존재: 각 컨트롤러 내 `@PostMapping` 수 == `CsrfTokens.isValid` 호출 수. 핸들러 추가 시 가드 누락 동시 검출.
- **AC-T4 신규 매퍼 SQL `${}` 0건** — 신규 매퍼 6종(PostsMapper/PostCategoriesMapper/UnityFeedSourcesMapper/UnityFeedItemsMapper + 향후 추가분)에 `${` 매치 0: `grep -rc '\${' <매퍼 파일들>` == 0 (AC-10).
- **AC-T5 POST_WRITE enforcement 연결 확인** — 작성/수정/삭제 핸들러(쓰기 3종)가 `permissionGate.has(...POST_WRITE...)` 호출: PostController 의 POST_WRITE 게이트 호출 수 == 작성·수정·삭제 핸들러 수(3). 게이트 누락 핸들러(임시 개방) 동시 검출 — 보안 가드.

---

## 잔여 오픈 질문
없음(0). 확정 결정 D1~D9 고정. 두 보안 난제(마크다운 sanitize · SSRF 방어)는 본 설계가 구체 메커니즘(commonmark+jsoup Safelist / SsrfSafeFetcher 9항목 체크리스트)으로 확정. SSRF rebinding 구현 라이브러리 제약·신규 빈 full-test·DB-방언 L2·pom 의존 추가·시그니처 inflate 위험·@EnableScheduling 활성은 오픈 질문이 아니라 **구현 단계 점검 항목**으로 `concerns` 에 이관.
