Files
llm-wiki/raw/branch-notes/feature-webhook-outbound-contract.md

481 lines
28 KiB
Markdown

---
title: branch / feature-webhook-outbound-contract
source_type: branch-note
status: raw
branch: feature-webhook-outbound-contract
parent_branch:
governing_docs: [wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]
related_projects: [ca-skeleton]
tags: [branch, ca-skeleton, security, observability, messaging, event-schema, retry-policy]
created: 2026-05-31
last_reviewed: 2026-06-29
target_merge:
status_label: in-progress
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-044
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-044
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1]
refines: []
overrides: []
depends_on: []
contract_packet: 1
contract_packet_sha256: b51881dc440e5170e65a78716a673c73ecce8c17900e7ee80fd4c9ba5f778a67
---
# branch: feature-webhook-outbound-contract
> Layer: `raw/branch-notes/` — outbound webhook (서버 → 외부 consumer) 발송의 signature/replay/retry/observability/security 계약을 정의합니다.
>
> **범위 정합 (2026-06-29 ground-truth 대조)**: outbound HTTP 클라이언트의 공통 factory (`OutboundHttpRestClientFactory.java`) 및 설정 객체 (`OutboundHttpSettings.java`)는 `adapter-outbound` 모듈 내에 이미 구현되어 있으며 (Phase C2), 본 branch는 Webhook 발송 특유의 보안 및 신뢰성 정책을 얹기 위해 (a) Egress Proxy 설정 추가, (b) Redirect 강제 차단 설정, (c) HMAC-SHA256 서명 계산 모듈 및 (d) Full Jitter 재시도 백오프를 주입하는 구체적 구현 사양을 규정합니다.
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Project 의 직접 자식 branch** (`parent_branch:` 비어있음): [[raw/project-notes/ca-skeleton-operational-contract]] 만 명시
### 형제 branch (cross-cite)
- [[raw/branch-notes/feature-api-contract-baseline]]
- [[raw/branch-notes/feature-outbound-http-client-baseline]]
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
- [[raw/branch-notes/feature-background-job-async-contract]]
- [[raw/branch-notes/feature-security-operational-baseline]]
- [[raw/branch-notes/feature-domain-event-outbox-contract]]
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: signature·replay·retry·observability contract test가 통과한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1` | Resilience4j가 default이며 Spring Retry는 simple blocking retry에만 허용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
서버가 외부 consumer 에게 webhook 을 발송할 때 다음을 *임의 결정 없이* 일관되게 제공해야 합니다:
1. **Signature 검증** — payload 변조 감지 (consumer 가 발송 서버를 인증)
2. **Replay protection** — 동일 webhook 의 중복 수신을 consumer 가 감지/거부할 수 있는 식별자
3. **Retry semantics** — consumer 의 일시 장애 시 재발송 정책 (간격 / 횟수 / DLQ)
4. **Delivery observability** — 발송 시도/성공/실패의 로그/메트릭/runbook
5. **Endpoint registration / management** — consumer 의 webhook URL 등록·검증·rotation 절차
6. **Payload contract** — webhook body 의 envelope shape (inbound API envelope 와 다른가? versioning?)
7. **SSRF Defence** — 외부 사용자가 입력한 엔드포인트 URL 호출 시 내부망 자원 보호
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- webhook payload signature scheme (HMAC algorithm + header name + timestamp inclusion)
- replay protection identifier (`X-Webhook-Id` UUID + 5분 skew tolerance)
- consumer endpoint registration 절차 (https 강제, Smokescreen egress proxy 활용 SSRF 방어, redirects 차단)
- retry policy (exponential backoff + Full Jitter, 최대 5회 시도 후 DLQ)
- delivery status state machine 정의 (PENDING / SENT / DELIVERED / FAILED / RETRYING / DEAD_LETTERED)
- webhook event versioning 정책 수립 (header `X-Webhook-Version` 지정)
- webhook payload envelope shape 정의 (event_type, event_id, timestamp, data 구조)
- observability 메트릭 및 로그 계약 수립 (`webhook.delivery.requests`, `webhook.dlq.size`)
- consumer timeout 정책 결정 (최대 5초 커넥션/응답 제한)
### 제외 범위
- inbound webhook 수신 (별도 endpoint 의 consumer 측 처리 — [[raw/branch-notes/feature-rate-limit-idempotency-contract]] 영역, `Idempotency-Key` 적용)
- webhook 의 GraphQL subscription / Server-Sent Events 대체 — 별도 branch [[raw/branch-notes/feature-streaming-response-contract]]
- consumer 측 SDK 자동 생성 — out of skeleton scope
- domain event → webhook 변환 매핑 자체 — [[raw/branch-notes/feature-domain-event-outbox-contract]] SSOT
- payload encryption (TLS 외) — confidential payload 영역, 별도 branch (예: end-to-end encryption requirements)
## 근거 (필수, 최소 1개+)
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/stripe-webhook-signature]] | D1, D2 — HMAC-SHA256 signature scheme 및 replay protection 5분 window 설계 |
| [[raw/official-docs/github-webhook-signature]] | D1 — X-Hub-Signature-256 헤더 명명 및 raw body HMAC 검증 설계 |
| [[raw/official-docs/svix-webhook-best-practices]] | D1, D2 — timestamp + message ID + body를 마침표(.)로 결합하는 서명 payload 포맷 |
| [[raw/official-docs/rfc9421-http-message-signatures]] | D1 대안 — IETF HTTP Message Signatures 표준 대비 단순 vendor HMAC의 한계 비교 |
| [[raw/official-docs/aws-builders-retry-jitter]] | D3 — exponential backoff와 Full Jitter 조합을 통한 재시도 폭풍 방지 설계 |
| [[raw/official-docs/owasp-ssrf-prevention]] | D4 — redirect 비활성화 및 egress proxy(Smokescreen) 활용을 통한 SSRF 방어 |
## TODO
- [ ] D1: HMAC-SHA256 signature scheme helper (`WebhookSignatureCalculator`) 구현 — 등급: `planned`
- [ ] D2: client call 시 Replay protection window validation 및 타임스탬프 계산 바인딩 — 등급: `planned`
- [ ] D3: Full Jitter Exponential Backoff calculator (`WebhookRetryBackoffCalculator`) 구현 — 등급: `planned`
- [ ] D4: OutboundHttpRestClientFactory 내 Egress Proxy 및 Redirects NEVER 설정 수정 — 등급: `planned`
- [ ] Registry Updates (`error-codes.yaml`, `env-keys.yaml`, `headers.yaml`, `metrics.yaml` 업데이트) — 등급: `planned`
## 진행 중 메모
- `OutboundHttpRestClientFactory``HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER)` 설정을 명시해 JDK 기본값 의존을 줄이고, 리다이렉트 차단 계약을 테스트로 고정해야 한다.
- `OutboundHttpSettings`에서 `app.outbound.http.egress-proxy` 설정을 Fail-fast 생성자로 검증하도록 조치할 예정이다.
## 결정 사항
- 2026-06-29: HMAC-SHA256 서명 스키마 결정 / 이유: payload 변조 방지 및 consumer 수신 신뢰성 확보 / 검토한 대안: IETF HTTP Message Signatures (복잡하여 기각) / 근거: [[raw/official-docs/stripe-webhook-signature]]
- 2026-06-29: 타임스탬프 및 Unique Message ID 기반 Replay 방지 결정 / 이유: replay attack 방지 / 검토한 대안: UUID 단독 사용 (stateful 중복 체크 비용 증가로 기각) / 근거: [[raw/official-docs/svix-webhook-best-practices]]
- 2026-06-29: Full Jitter 백오프 재시도 및 DLQ 적용 결정 / 이유: retry storms 방지 및 consumer 부하 분산 / 검토한 대안: 단순 선형 재시도 (재장애 유발 위험으로 기각) / 근거: [[raw/official-docs/aws-builders-retry-jitter]]
- 2026-06-29: Egress Proxy 라우팅 및 Redirect 차단 결정 / 이유: 내부 IP 노출 및 SSRF 우회 경로 축소 / 검토한 대안: Application level DNS lookup 검증 (DNS rebinding 취약성으로 기각) / 근거: [[raw/official-docs/owasp-ssrf-prevention]]
## 결정-근거 매핑
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | HMAC-SHA256 서명 스키마 (`X-Webhook-Signature: t=...,v1=...`) | 일반 B2B/B2C webhook 아웃바운드 발송에 기본 적용 | `raw/official-docs/stripe-webhook-signature.md#STRIPE-WEBHOOK-C3`, `raw/official-docs/stripe-webhook-signature.md#STRIPE-WEBHOOK-C4`, `raw/official-docs/github-webhook-signature.md#GITHUB-WEBHOOK-C3`, `raw/official-docs/svix-webhook-best-practices.md#SVIX-WEBHOOK-C2` | `official-vendor-doc + project-local-convention` | `X-Webhook-Signature` 헤더명과 Hex 인코딩은 프로젝트 로컬 convention 이므로 consumer 문서/샘플과 동기화 필요 |
| D2 | Replay protection & Message ID | replay attack 및 수신 멱등성 보장이 필수적인 금융/결제/주요 상태 동기화 webhook | `raw/official-docs/stripe-webhook-signature.md#STRIPE-WEBHOOK-C7`, `raw/official-docs/svix-webhook-best-practices.md#SVIX-WEBHOOK-C2` | `official-vendor-doc` | 송수신 시스템 간 clock skew 오차 (5분 초과 시 실패) |
| D3 | Retry backoff with Full Jitter | 아웃바운드 비동기 발송의 일시적 장애 복원력이 필요할 때 기본 적용 | `raw/official-docs/aws-builders-retry-jitter.md#AWS-JITTER-C4`, `raw/official-docs/aws-builders-retry-jitter.md#AWS-JITTER-C7` | `official-vendor-doc` | 재시도 중 지연 시간 증가로 인한 즉각성 저하 |
| D4 | SSRF 방어 및 리다이렉트 차단 | 외부 사용자가 등록하는 임의의 URL 엔드포인트 호출 시 기본 적용 | `raw/official-docs/owasp-ssrf-prevention.md#OWASP-SSRF-C3`, `raw/official-docs/owasp-ssrf-prevention.md#OWASP-SSRF-C4`, `raw/official-docs/owasp-ssrf-prevention.md#OWASP-SSRF-C2` | `official-standard` | egress proxy 추가 인프라 비용 및 단일 장애점(SPOF) 위험 |
## 구현 가이드
> 본 구현 가이드는 `adapter-outbound` 모듈 내 HTTP 클라이언트 팩토리와 설정 파일에 대한 **구체적인 수정 방향과 설계 규칙**을 정의합니다. (R1, R2, R3, R4 준수)
### 1. HTTP Client 및 Egress Proxy 설정 수정 (D4, `OWASP-SSRF-C2`, `C3`)
- **수정 대상 파일**:
- `src/adapter-outbound/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpSettings.java`
- `src/adapter-outbound/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java`
- **UNSUPPORTED_IMPL_DECISION**:
- `java.net.http.HttpClient`를 빌드할 때, `app.outbound.http` 설정 하위에 `egress-proxy` 설정을 결합하여 ProxySelector를 직접 바인딩하도록 설계함 / trade-off: Spring Cloud Gateway 등의 전역 프록시 설정을 타지 않고, 외부 아웃바운드 템플릿용 RestClient만 격리하여 프록시를 태움으로써 내부 통신(Kafka, DB 등)이 프록시 영향으로 단절되는 것을 방지함.
- `followRedirects(HttpClient.Redirect.NEVER)`를 명시적으로 호출함 / trade-off: `java.net.http.HttpClient` 기본값도 `Redirect.NEVER`로 확인되어 보안 요구에 부합하지만, 코드 리뷰와 회귀 테스트에서 redirect 차단 계약이 드러나도록 명시성을 선택함.
- **수정 사양**:
- `OutboundHttpSettings` 레코드에 `boolean egressProxyEnabled`, `String egressProxyHost`, `Integer egressProxyPort` 필드를 추가하고, compact constructor에서 `egressProxyEnabled``true`일 때 host 및 port의 null/blank/범위 초과 여부를 Fail-Fast로 검증함.
- `OutboundHttpRestClientFactory.create` 메서드를 다음과 같이 리다이렉트 차단 및 프록시 주입이 가능하도록 수정함:
```java
// dev.caskeleton.adapter.outbound.httpclient.OutboundHttpRestClientFactory.java
static Clients create(String dependencyName, String baseUrl, OutboundHttpSettings settings) {
HttpClient.Builder builder = HttpClient.newBuilder()
.connectTimeout(settings.connectTimeout())
.followRedirects(HttpClient.Redirect.NEVER); // D4: Redirects disabled
// D4: Route all outbound requests through Smokescreen Egress Proxy if enabled
if (settings.egressProxyEnabled()) {
builder.proxy(ProxySelector.of(
new InetSocketAddress(settings.egressProxyHost(), settings.egressProxyPort())
));
}
HttpClient httpClient = builder.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(settings.readTimeout());
// rest client 빌드 생략...
}
```
### 2. Webhook 서명 생성기 구현 (D1, D2, `STRIPE-WEBHOOK-C4`, `SVIX-WEBHOOK-C2`)
- **신규 추가 클래스**:
- `dev.caskeleton.adapter.outbound.httpclient.webhook.WebhookSignatureCalculator` (application layer 또는 outbound helper)
- **UNSUPPORTED_IMPL_DECISION**:
- 서명 대상 payload 조립 시 JSON Body of serialization 형태 변형으로 인한 서명 깨짐을 막기 위해, **반드시 RestClient에서 송신하기 직전의 raw byte array를 그대로 활용**하도록 서명 계산 유틸을 바이트 단위로 설계함.
- 서명 헤더명을 `X-Webhook-Signature`로 정하고, 서명 결과를 Hex 문자열로 인코딩함 / trade-off: Stripe/GitHub/Svix 문서는 HMAC-SHA256과 raw payload 기반 서명을 뒷받침하지만, Svix는 Base64 인코딩을 사용하므로 Hex vs Base64 및 자체 헤더명은 프로젝트 로컬 convention 으로 문서화하고 consumer 검증 샘플을 함께 제공해야 함.
- **서명 조립 알고리즘**:
- `SignaturePayload (bytes) = (X-Webhook-Id + "." + X-Webhook-Timestamp + ".").getBytes(StandardCharsets.UTF_8) + rawBodyBytes`
- 이 페이로드를 shared secret key(HMAC-SHA256)로 해싱하고, 결과값을 프로젝트 로컬 convention 인 16진수(Hexadecimal) 문자열로 변환하여 헤더에 바인딩함.
- 서명 헤더 구조: `X-Webhook-Signature: t=1672531199,v1=a1b2c3d4...`
### 3. Full Jitter 백오프 계산식 구현 (D3, `AWS-JITTER-C4`)
- **신규 추가 클래스**:
- `dev.caskeleton.adapter.outbound.httpclient.webhook.WebhookRetryBackoffCalculator`
- **백오프 수식**:
- $Interval = \text{random}(0, \min(\text{cap}, \text{base} \times 2^{\text{attempt}}))$
- `base` = 10,000ms (10초), `cap` = 3,600,000ms (1시간), `maxAttempts` = 5
- Java 구현 예시:
```java
public static long calculateBackoff(int attempt, long baseMs, long capMs) {
long temp = Math.min(capMs, baseMs * (1L << attempt));
return ThreadLocalRandom.current().nextLong(0, temp);
}
```
## 엣지·실패·의존
- **실패·엣지 경로**:
- **Egress Proxy 장애 (SPOF)**: Smokescreen 프록시가 다운되는 경우 모든 외부 웹훅 발송이 즉시 차단됨. 이 경우 retryable 에러(`WEBHOOK_DELIVERY_FAILED`)로 로깅 및 메트릭 기록을 남겨 재시도 큐에 보관해야 함.
- **Redirect 우회 시도**: 수신 서버가 정상적인 퍼블릭 IP를 제공한 후, HTTP 응답 시 `302 Found` 등의 리다이렉션을 반환하여 내부 `http://169.254.169.254`로 우회를 유도할 때, HTTP 클라이언트가 리다이렉션 추적을 금지(`Redirect.NEVER`)했으므로 302 응답을 그대로 받아 `WEBHOOK_REDIRECT_BLOCKED` 에러로 격리하고 전송을 영구 중단함.
- **Clock Skew 엣지**: 송신 서버와 수신 서버의 NTP 동기화가 깨져 시각 차이가 5분을 초과하는 경우 서명 검증은 통과하나 타임스탬프 스큐 검증에서 거절당함. 이를 모니터링하기 위해 `X-Webhook-Timestamp` 값이 수신 측 시간 대비 300초 이상 벗어난 경우의 예외 처리를 디버깅할 수 있도록 로깅해야 함.
- **다른 계약 의존**:
- [[raw/branch-notes/feature-background-job-async-contract]] 의 `D3`(DLQ 및 비동기 스케줄러 계약)에 의존.
- [[raw/branch-notes/feature-security-operational-baseline]] 의 `D4`(서명 키 로테이션 및 복수 시크릿 유예 기간)에 의존.
## 검증해야 할 주장
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| HttpClient의 `followRedirects(Redirect.NEVER)`가 실제로 3xx 리다이렉션을 따라가지 않고, 3xx 응답을 `WEBHOOK_REDIRECT_BLOCKED`로 매핑할 수 있는가 | JDK 기본값은 `Redirect.NEVER`로 확인됐지만, RestClient/JdkClientHttpRequestFactory 조합에서 응답 처리 경로를 프로젝트 테스트로 고정해야 함 | Testcontainers에 MockWebServer를 띄우고 `301/302 Redirect` 응답을 던져 리다이렉션을 따라가지 않으며 3xx 응답을 차단 에러로 매핑하는지 JUnit 테스트로 검증 | `planned` |
| Smokescreen Egress Proxy가 사설 IP 대역 호출 시도를 정책대로 차단하고 차단 응답을 반환하는가 | 프록시 룰셋이 잘못 설정되어 우회 경로가 존재할 위험이 있음 | 로컬 docker-compose에 Smokescreen을 띄우고 `http://10.0.0.1`로의 웹훅 발송이 프록시에 의해 차단됨을 확인 | `planned` |
| Full Jitter Exponential Backoff 난수 분포가 편향 없이 고르게 분포하는가 | Java의 `ThreadLocalRandom` 사용 시 특정 스레드 경쟁 조건에서 Jitter가 편향되어 스파이크 부하를 일으킬 수 있음 | 시뮬레이션을 통해 1,000회 재시도 대기시간의 표준 편차 및 분포 균일성을 검증 | `planned` |
| shared secret key rotation 시 헤더에 다중 서명이 들어올 때 수신 측이 순회하며 성공적으로 하나라도 매칭하는가 | 다중 서명 파싱 및 서명 목록 추출 파서가 예외를 던질 위험이 있음 | 두 개 이상의 active secret을 임의로 생성하고 파싱 로직을 통과하는지 검증 | `planned` |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> 2026-06-29 보정: `governing_docs`는 현재 존재하는 outbound HTTP canonical인 `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound`를 가리킨다. 아래 표는 webhook outbound 세부 관심사 초안이며, `/coverage feature-webhook-outbound-contract` 재실행으로 canonical 요구사항 대비 covered/delegated/missing 판정을 갱신해야 한다.
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| C1: HMAC-SHA256 서명 알고리즘 설계 및 구현 | covered-here | — | — | D1 |
| C2: Replay attack 방지를 위한 타임스탬프 결합 포맷 | covered-here | — | — | D2 |
| C3: Full Jitter Exponential Backoff 공식 | covered-here | — | — | D3 |
| C4: Egress Proxy (Smokescreen) 라우팅 주입 | covered-here | — | — | D4 |
| C5: HTTP Client Redirect 강제 차단 | covered-here | — | — | D4 |
| C6: DB 기반 Key Rotation 24시간 오버랩 윈도우 | delegated | [[raw/branch-notes/feature-security-operational-baseline]] | OK | §엣지·의존 링크 |
| C7: 비동기 발송 멱등성 및 DLQ 아키텍처 | delegated | [[raw/branch-notes/feature-background-job-async-contract]] | OK | §엣지·의존 링크 |
## Audit & Findings (2026-06-29 ground-truth 대조)
- **FINDING-1 — Outbound HTTP Client 내 Redirect / Proxy 바인딩 코드 부재**:
- ca-tmpl 의 `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java:21` 을 확인한 결과, 단순히 `HttpClient.newBuilder().connectTimeout(settings.connectTimeout()).build()` 로 HTTP 클라이언트를 생성하고 있음. (2026-07-21 실물 소스에서 재확인. 원래 인용은 repomix 덤프 `ca-tmpl코드내용.xml` 의 병합 행번호 L39733-39735 를 가리켰으나, 덤프는 재생성 시 행번호가 바뀌는 일회성 산출물이라 정본 경로로 교체.)
- 리다이렉트 정책은 JDK 기본값(`Redirect.NEVER`)에 의존해도 요구를 만족할 수 있으나, 코드에 명시되어 있지 않아 보안 계약이 리뷰/테스트 표면에 드러나지 않는다. Egress Proxy 설정을 바인딩하는 `builder.proxy(...)` 코드는 누락된 상태임.
- 권고: 본 branch note의 **§구현 가이드 1**에 명시된 대로 `OutboundHttpSettings` 및 `OutboundHttpRestClientFactory` 에 Egress Proxy 바인딩을 추가하고, redirect 차단은 명시 설정 + 테스트로 회귀를 방지해야 함.
- **FINDING-2 — Webhook 관련 에러 코드 및 레지스트리 설정 부재**:
- `docs/registries/error-codes.yaml` 에 webhook 전송 실패, SSRF 차단, 리다이렉트 차단과 관련된 에러 코드가 정의되지 않음.
- 권고: 본 branch note의 **§Registry Updates** 에 정의된 신규 YAML 설정을 레지스트리 파일에 통합해야 함.
## Registry Updates (자체 명세)
> 본 branch merge 시, `docs/registries/` 하위 파일들에 아래 항목을 반드시 추가/업데이트해야 합니다.
### 1. `docs/registries/error-codes.yaml`
```yaml
# ============================================================
# WEBHOOK OUTBOUND (feature-webhook-outbound-contract)
# ============================================================
- code: WEBHOOK_DELIVERY_FAILED
category: TRANSIENT_DEPENDENCY
http_status: 500
retryable: true
retry_after_seconds: 10
owner_branch: feature-webhook-outbound-contract
owner_layer: infrastructure
client_safe_message: "Webhook delivery attempt failed. Retrying..."
log_level: WARN
runbook_link: "runbook://webhook/delivery-failed"
compatibility_impact: none
required_test: contract-verification:webhook-retry-policy
- code: WEBHOOK_SSRF_BLOCKED
category: CONFLICT
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-webhook-outbound-contract
owner_layer: infrastructure
client_safe_message: "Webhook target endpoint blocked due to SSRF policy"
log_level: ERROR
runbook_link: "runbook://webhook/ssrf-blocked"
compatibility_impact: none
required_test: contract-verification:webhook-ssrf-prevention
- code: WEBHOOK_REDIRECT_BLOCKED
category: CONFLICT
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-webhook-outbound-contract
owner_layer: infrastructure
client_safe_message: "Webhook target redirected. Redirects are forbidden."
log_level: ERROR
runbook_link: "runbook://webhook/redirect-blocked"
compatibility_impact: none
required_test: contract-verification:webhook-redirect-blocked
```
### 2. `docs/registries/env-keys.yaml`
```yaml
# === Webhook Egress Proxy (feature-webhook-outbound-contract) ===
- name: APP_WEBHOOK_EGRESS_PROXY_ENABLED
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-webhook-outbound-contract
validation: boolean_only
compatibility_impact: behavior-change
required_test: env-contract:webhook-proxy
- name: APP_WEBHOOK_EGRESS_PROXY_HOST
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-webhook-outbound-contract
validation: non_empty_string
compatibility_impact: behavior-change
required_test: env-contract:webhook-proxy
- name: APP_WEBHOOK_EGRESS_PROXY_PORT
type: int
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-webhook-outbound-contract
validation: port_range_1_65535
compatibility_impact: behavior-change
required_test: env-contract:webhook-proxy
```
### 3. `docs/registries/headers.yaml`
```yaml
# === Webhook Outbound Headers (feature-webhook-outbound-contract) ===
- name: X-Webhook-Signature
direction: outbound
type: string
required: true
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-webhook-outbound-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:webhook-headers
- name: X-Webhook-Id
direction: outbound
type: uuid
required: true
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-webhook-outbound-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:webhook-headers
- name: X-Webhook-Timestamp
direction: outbound
type: numeric-seconds
required: true
generated_if_missing: true
mdc_key: null
envelope_meta_field: null
owner_branch: feature-webhook-outbound-contract
case_style: kebab
compatibility_impact: additive
required_test: contract-verification:webhook-headers
```
### 4. `docs/registries/metrics.yaml`
```yaml
# === Webhook Outbound Metrics (feature-webhook-outbound-contract) ===
- name: webhook.delivery.requests
type: timer
unit: seconds
tags:
- name: outcome
cardinality_limit: 5
allowed_values: [SUCCESS, FAILURE, TIMEOUT, RETRYING, BLOCKED]
- name: event_type
cardinality_limit: 20
percentiles: [0.5, 0.9, 0.95, 0.99]
histogram_buckets: slo_driven
alert_severity_thresholds:
p1: "error_rate > 5% for 5m"
p2: "error_rate > 1% for 10m"
owner_branch: feature-webhook-outbound-contract
log_field_mapping: [outcome, event_type]
compatibility_impact: additive
required_test: contract-verification:webhook-metrics
- name: webhook.dlq.size
type: gauge
unit: total
tags:
- name: event_type
cardinality_limit: 20
percentiles: null
histogram_buckets: null
alert_severity_thresholds:
p1: "webhook.dlq.size > 100"
p2: "webhook.dlq.size > 10"
owner_branch: feature-webhook-outbound-contract
log_field_mapping: [event_type]
compatibility_impact: additive
required_test: contract-verification:webhook-metrics
```
## 마주친 문제
- 아직 없음.
## 묶음 (이 branch에서 파생된 자료)
<!-- GENERATED: sources:start -->
- [[raw/official-docs/aws-builders-retry-jitter]]
- [[raw/official-docs/github-webhook-signature]]
- [[raw/official-docs/owasp-ssrf-prevention]]
- [[raw/official-docs/rfc9421-http-message-signatures]]
- [[raw/official-docs/stripe-webhook-signature]]
- [[raw/official-docs/svix-webhook-best-practices]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/webhook-full-jitter-dlq-observability-2026-07-02]]
- [[raw/blog-topics/webhook-signature-replay-contract-2026-07-02]]
- [[raw/blog-topics/webhook-ssrf-egress-proxy-redirect-block-2026-07-02]]
<!-- GENERATED: blog-topics:end -->
### Sub-branches (세부 작업)
- 없음
### 오류 기록 (이 branch 작업 중 발생)
- 없음
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- 없음
### 강의 (이 작업을 위해 학습한 강의)
- 없음
### job-posting tie-ins (이 작업에서 파생된 글감)
- 없음
## 관련 일일 노트
- `[[raw/daily-notes/2026-06-29]]`
## 완료 후 정리
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목:
- `locally-verified` 항목:
- `prod-verified` 항목:
- **추출하지 않을 항목** (planned / documented-only / abandoned):