edaywalid/whook. 내 앱 앞단에 놓이는 셀프호스팅 웹훅 게이트웨이다. Stripe·GitHub 같은 곳에서 보낸 웹훅을 받는 즉시 영구 저장(durable)하고 빠른 200을 돌려준 뒤, 지수 백오프 재시도·dead-letter·여러 목적지로의 fan-out·리플레이(재전송)를 책임진다. 내 앱이 배포 중이거나 잠깐 죽어도 이벤트가 사라지지 않는다. CGO_ENABLED=0의 순수 Go 단일 정적 바이너리로, SQLite를 내장하고(필요 시 Postgres로 무중단 교체) docker run 한 줄이면 뜬다. (저장소: edaywalid/whook · Go ~5,900줄 · 라이선스 MIT · 스택 net/http · modernc SQLite · pgx · Prometheus · 신생 2026-06 시작, 스타 약 41 — TrendShift Self-hosted 토픽)
"웹훅을 먼저 안전하게 받아 두고(capture), 전송은 내 스케줄로 천천히·끈질기게(delivery) — 둘을 분리한다."
Stripe·GitHub 같은 서비스가 보내는 웹훅은 단 한 번, 내가 통제할 수 없는 순간에 도착합니다. 그때 내 앱이 배포 중이거나 잠깐 죽어 있으면 그 이벤트는 영영 사라지고(많은 서비스가 재시도가 약하거나 없음), 실패해도 원본 요청이 이미 없어 디버깅이 깜깜이가 됩니다. whook는 앱 앞단에 서서 오는 즉시 영구 저장하고 곧장 200을 돌려준 다음, 실제 전달은 자기 페이스로 재시도·dead-letter·여러 목적지 분배·리플레이까지 책임집니다.
"실무 페인포인트 정조준 + 단일 Go 바이너리 + 'Hookdeck이 하는 일을 셀프호스팅으로'."
웹훅은 재현이 어렵고, 실패하면 원본이 남지 않아 무엇이 왔는지조차 모릅니다. whook는 오는 모든 요청·모든 시도를 기록해 API/UI에서 조회·재전송하게 합니다. 개발자라면 한 번쯤 당해 본 고통을 정확히 겨냥했다는 점이 트렌딩의 이유입니다.
CGO_ENABLED=0 + 순수 Go SQLite 드라이버(modernc)라 외부 런타임·DB 서버 없이 한 파일로 돕니다. go install ...@latest 또는 docker run ghcr.io/edaywalid/whook 한 줄이면 끝. 트래픽이 커지면 같은 인터페이스 뒤에서 Postgres로 무중단 교체합니다 — "작게 시작해 점진적으로 확장"의 모범.
① capture-before-ack로 이벤트 유실 0, ② 하나의 이벤트를 billing·email·analytics 등 여러 목적지에 독립 트랙(fan-out)으로 전달(한 곳 실패가 다른 곳을 막지 않음), ③ 저장된 과거 이벤트를 같은 큐에 다시 넣는 리플레이(버그 재현·복구), ④ 모든 시도를 1행씩 기록하는 감사추적까지 — 상용 SaaS(Hookdeck)가 하는 일을 셀프호스팅 단일 바이너리로 담았습니다.
Svix는 강력하지만 SaaS 중심이고 주로 "내가 남에게 보내는" 아웃바운드 발신 플랫폼입니다. Hookdeck은 같은 카테고리(인바운드 수신·재시도·리플레이)지만 상용 SaaS입니다. ngrok·smee.io는 로컬 개발용 터널이라 영속 저장·재시도·dead-letter가 없습니다. whook는 이들과 달리 MIT·셀프호스팅·단일 바이너리의 프로덕션 인바운드 게이트웨이라는 빈자리를 노립니다.
2026년 6월 시작한 신생 프로젝트로 스타 약 41개, 사실상 1인 개발, 정식 릴리스/버전 태그가 없어(@latest/:latest로만 배포) 안정성 보증은 아직 약합니다. 빌트인 서명 검증도 Stripe·GitHub 2종뿐입니다(나머지는 직접 Verifier 등록). 다만 코드 밀도·테스트(17개 테스트 파일)·문서·CI가 갖춰져 "장난감"이 아니라 설계 밀도 높은 양질의 소형 프로젝트이며, 학습 교재로서의 가치가 특히 큽니다.
"백엔드는 표준 라이브러리(net/http)만으로. 프레임워크 없음. 대시보드는 SPA가 아니라 Go html/template 서버렌더."
| 기술 | 역할 / 메모 |
|---|---|
| Go 1.25 | 모듈 github.com/edaywalid/whook |
| net/http (표준) | HTTP 서버. chi/gin/echo 없음 — Go 1.22+ 메서드 패턴 라우팅(POST /ingest/{source}, r.PathValue()) 활용 |
| modernc.org/sqlite | 순수 Go SQLite(cgo 불필요). 기본 스토리지 |
| jackc/pgx v5 | Postgres 드라이버(수평 확장용) |
| prometheus/client_golang | 메트릭(/metrics): events_captured·delivery_attempts·dead_letters 등 |
| 표준 crypto | AES-256-GCM(시크릿 암호화) + HMAC-SHA256(서명 검증) + crypto/rand(ID). 외부 crypto 라이브러리 0 |
| html/template | 대시보드(/ui) 서버렌더 — SPA 아님 |
| 임베드 마이그레이션 | dbmate 포맷 SQL을 db/embed.go로 바이너리에 임베드, 기동 시 자동 적용 |
web/의 React는 '게이트웨이'가 아니라 '랜딩/문서 사이트'다저장소의 web/ 폴더에 TanStack Start + React 19 + Tailwind 4(+MDX 문서)가 있지만, 이건 마케팅·문서 사이트(Netlify 배포)일 뿐입니다. 실제 게이트웨이 대시보드는 위에서 말한 Go html/template 서버렌더입니다. "프론트엔드 스택"으로 오해하지 마세요 — 본체는 100% Go입니다.
| 구성 | 역할 / 메모 |
|---|---|
| Dockerfile | 멀티스테이지·멀티아치. golang:1.25-alpine에서 CGO_ENABLED=0 크로스컴파일 → alpine:3.20, non-root(uid 10001), /data 볼륨 |
| docker-compose | whook 서비스 + 선택적 postgres:16-alpine(profile postgres). /healthz healthcheck |
| Makefile / CI | build/test/run/docker-* 타깃 + GitHub Actions(lint·test·GHCR 이미지 푸시) + golangci-lint |
| 산출물 | GHCR 멀티아치 이미지 ghcr.io/edaywalid/whook:latest |
"핵심 원칙은 capture와 delivery의 완전 분리. 인제스트는 최소한만 동기로, 전송은 별도 워커가 비동기로."
① capture/delivery 분리 + graceful drain: 인제스트는 "서명검증→저장→큐잉"만 동기로 하고 즉시 200을 줍니다. 실제 전송은 워커가 자기 스케줄로 수행. 종료 신호가 와도 진행 중 전송은 ctx와 독립된 workCtx에서 돌아 DrainTimeout(기본 25s)까지 끝내고 떠납니다.
② 원자적 lease로 중복 전송 방지(백엔드별 다른 전략): SQLite엔 SKIP LOCKED가 없어, 풀을 단일 커넥션으로 고정 + UPDATE ... LIMIT ... RETURNING을 한 문장으로 실행해 두 워커가 같은 행을 못 가져가게 합니다(throughput을 희생한 의도적 선택). Postgres는 FOR UPDATE SKIP LOCKED로 여러 워커 수평 확장. 같은 Store 인터페이스, 다른 동시성 전략이라는 대조가 압권입니다.
③ 멱등성(idempotency): (source, dedup_key) 유니크 인덱스 + insert 경쟁에서 진 쪽은 기존 행을 재조회 → "정확히 한 번 capture". dedup_key가 없으면(NULL) 충돌하지 않게 partial index를 씁니다.
④ 지수 백오프 + Retry-After + dead-letter: 백오프 계산은 순수 함수(base × mult^(attempt-1) capped, 기본 base=2s·mult=3·max=1h·maxAttempts=8). 429의 Retry-After는 초("120")와 HTTP-date 둘 다 파싱해 우선 적용, 예산 소진 시 dead-letter로 보냅니다.
"Go 표준 레이아웃(cmd·internal). 각 책임이 한 패키지로 깔끔히 분리 — 의존성 역전의 교과서."
| 경로 | 역할 |
|---|---|
| cmd/whook/main.go | 엔트리포인트. config 로드 → app.Run. 매우 얇음(~35줄) |
| internal/app/app.go | 조립부. 모든 컴포넌트 와이어링 + 3개 고루틴 기동 + graceful shutdown. DSN 스킴으로 SQLite/Postgres 자동 선택 |
| internal/core/types.go | 도메인 타입 허브. Event·Source·Destination·PendingDelivery·DeliveryAttempt·BackoffConfig·FilterSpec + 상태 enum |
| internal/storage/storage.go | 단일 영속성 인터페이스 Store + ErrNotFound |
| internal/storage/{sqlite,postgres}/ | 같은 Store의 두 구현. SQLite=단일 writer+UPDATE..RETURNING, Postgres=FOR UPDATE SKIP LOCKED |
| internal/ingest/ | capture 경로: 서명검증 → SaveEvent(dedup) → Enqueue |
| internal/dispatch/ · routing/ · filter/ | 큐잉 / 목적지 부분집합 결정 / JSON body 경로 필터(equals·exists·in·prefix·not) |
| internal/worker/ | 전송 워커풀: claim→deliver→record→reschedule/dead-letter + drain 로직 |
| internal/delivery/ · retry/ | HTTP 전송 1회 + 상태 분류(Classify) / 순수 백오프 계산 |
| internal/signature/ · crypto/ | Stripe/GitHub HMAC 검증(상수시간 비교) / AES-256-GCM 시크릿 봉인 |
| internal/{ratelimit,pruner,metrics,migrate,id}/ | 토큰버킷 / 보존정리 / Prometheus / 임베드 마이그레이션 / prefix_16hex ID |
| internal/api/ | server.go(라우팅·admin 인증) + handlers.go(JSON API) + ui.go(html/template 대시보드) |
| db/migrations/{sqlite,postgres}/ | dbmate 포맷 스키마 SQL. db/embed.go로 임베드 |
| web/ · docs/ | TanStack 랜딩/문서 사이트(별도 배포) / configuration·deployment·http-api 마크다운 |
약 5,900줄 Go가 13개+ 패키지로 잘게 나뉘고, retry(백오프 계산)·routing·filter 같은 부수효과 없는 순수 로직은 별도 패키지로 떼어 테스트가 쉽습니다. 17개 테스트 파일(단위 + worker drain 통합 + postgres 통합)이 붙어 있어 "읽고 따라 만들기" 좋은 교본입니다.
"표준 라이브러리 Go·DB 기반 job 큐·멱등성·백오프·graceful drain·시크릿 보안이 한 레포에 압축."
배울 것: 프레임워크 없이 net/http + Go 1.22 메서드 라우팅 + html/template 서버렌더로 충분히 단단한 서비스를 짜는 법. "프레임워크가 꼭 필요하진 않다"의 모범 사례.
실습: mux.HandleFunc("POST /items/{id}", ...) + r.PathValue("id")로 작은 REST API를 프레임워크 없이 만들어 보기.
배울 것: pending_deliveries 테이블 + locked_until lease 컬럼 + 원자적 claim. SQLite(단일 writer)와 Postgres(SKIP LOCKED)에서 같은 큐를 다르게 안전하게 만드는 대조가 이 레포의 백미. Redis·Kafka 없이 DB만으로 작업 큐를 만드는 경험.
실습: 작업 테이블을 만들고 UPDATE ... WHERE state='pending' LIMIT 1 RETURNING로 워커가 하나씩 집어가게 구현. 워커 2개를 동시에 돌려 중복 처리가 없는지 확인.
배울 것: (source, dedup_key) 유니크 인덱스 + 경쟁 패배 시 기존 행 재조회로 "정확히 한 번 capture". NULL 키는 충돌 안 하게 partial index. 분산 시스템의 핵심 개념을 작은 코드로.
실습: 같은 요청을 빠르게 두 번 보내도 한 번만 저장되게, 유니크 제약 + insert 충돌 처리로 멱등 엔드포인트를 만들기.
배울 것: 재시도 간격을 순수 함수로 계산(base × mult^(n-1) capped), 429의 Retry-After(초 + HTTP-date) 존중, 예산 소진 시 dead-letter로 격리. "끈질기되 무한루프는 안 도는" 재시도의 정석.
실습: 실패하는 HTTP 호출을 백오프로 재시도하되, N회 초과 시 "데드레터 목록"에 옮기는 작은 워커를 작성.
배울 것: SIGINT/SIGTERM에 새 작업 claim을 멈추고 in-flight 전송은 끝내는 우아한 종료(workCtx 독립 컨텍스트 트릭), 그리고 AES-256-GCM으로 시크릿을 at-rest 암호화 + HMAC 상수시간 비교로 타이밍 공격 방어.
실습: signal.NotifyContext로 종료 신호를 받아 "받던 작업은 마무리, 새 작업은 거절"하는 서버 종료 루틴을 구현.
배울 것: CGO_ENABLED=0 + 순수 Go SQLite + 멀티아치 크로스컴파일 Dockerfile. 그리고 Doer 인터페이스로 HTTP 클라이언트 주입, now func() time.Time 주입으로 시간 제어 — "테스트하기 쉬운 구조"를 설계로 확보.
실습: 시간에 의존하는 함수에 now func() time.Time를 주입형으로 바꿔, 테스트에서 가짜 시계를 넣어 백오프를 단언.
"사실상 무의존. 단일 바이너리 + SQLite 파일 하나. 라즈베리파이·작은 VPS·컨테이너면 충분."
| 항목 | 요구사항 / 메모 |
|---|---|
| 런타임 | 단일 바이너리. 별도 런타임·의존성 없음(Alpine 베이스, -s -w 스트립) |
| DB | 기본 내장 SQLite(별도 서버 불필요). 트래픽↑ 시 Postgres 16+(postgres:// DSN)로 교체 |
| 포트 / 볼륨 | 기본 :8080 · SQLite 영속 볼륨 /data |
| 아키텍처 | 멀티아치 이미지(amd64/arm64 크로스컴파일) |
| 확장성 | SQLite=단일 writer라 단일 인스턴스 수직 확장. 수평 확장 필요 시 Postgres(SKIP LOCKED로 다중 인스턴스가 같은 큐 안전 소비) |
| 주요 환경변수 | WHOOK_ADMIN_TOKEN(빈 값=관리API 무방비, 로컬 전용)·WHOOK_SECRET_KEY(시크릿 암호화)·WHOOK_WORKERS(4)·WHOOK_MAX_BODY_BYTES(1MiB)·WHOOK_RETENTION_AGE |
WHOOK_ADMIN_TOKEN을 비워 두면 관리 API가 무방비관리 엔드포인트(/events·/sources·/destinations 등)는 admin 토큰으로 보호되는데, 토큰을 설정하지 않으면 인증 없이 노출됩니다(로컬 개발용 기본값). 공개망에 띄울 때는 반드시 WHOOK_ADMIN_TOKEN과 시크릿 암호화용 WHOOK_SECRET_KEY를 설정하세요. 또 /ingest는 의도적으로 인증이 없으니(웹훅 수신구), 서명 검증으로 위조를 막아야 합니다.
"띄우고 첫 웹훅 → 실패 재시도 관찰 → 리플레이 → 새 서명 검증기 추가 → 미니 게이트웨이 만들기."
docker run -p 8080:8080 -v whook-data:/data ghcr.io/edaywalid/whook로 실행 후, source·destination을 만들고 POST /ingest/stripe로 가짜 이벤트를 보내 /ui 대시보드에서 이벤트·전송 시도를 확인.
목표: capture→delivery 흐름을 눈으로 보기. destination URL은 webhook.site 같은 곳으로 잡아 실제 도착을 확인.
destination을 일부러 500을 주는 URL로 잡고 이벤트를 보낸 뒤, delivery_attempts(또는 UI 시도 이력)에서 지수 백오프로 간격이 벌어지는 것과, maxAttempts 후 dead-letter로 가는 걸 확인.
목표: 백오프·재시도 예산·데드레터를 실데이터로 체감. WHOOK_* 백오프 설정을 바꿔 간격 변화를 비교.
destination을 2개(예: billing·analytics) 등록해 한 이벤트가 양쪽으로 fan-out 되는지 보고, 죽었던 destination을 살린 뒤 POST /events/{id}/replay로 과거 이벤트를 재전송. ?destination_id=로 한 곳만 리플레이도 시도.
목표: fan-out(독립 트랙)과 리플레이(복구)의 가치를 체감. 한 목적지 실패가 다른 목적지를 막지 않음을 확인.
internal/signature/providers.go의 Stripe/GitHub 구현을 본떠 Slack의 서명 스킴(v0:{ts}:{body} HMAC-SHA256)을 구현하고 Registry에 등록. hmac.Equal 상수시간 비교를 빠뜨리지 않게.
목표: 서명 검증·Verifier 인터페이스 구조를 손으로 이해. 실제 오픈소스 기여 후보(빌트인은 2종뿐).
Go 표준 라이브러리만으로 events + pending_deliveries 두 테이블 + 워커 1개를 짜서, capture-before-ack·dedup 멱등성·백오프 재시도·graceful drain을 갖춘 작은 게이트웨이를 완성. SQLite UPDATE..RETURNING lease까지 넣으면 whook의 뼈대가 거의 됩니다.
목표: "DB 기반 durable 큐"를 내 것으로. Postgres로 바꿔 SKIP LOCKED 다중 워커도 실험.
"웹훅 개념에서 출발해 Go HTTP·DB 큐·재시도·동시성·배포까지, whook를 길잡이 삼아."
| 주차 | 주제 | 핵심 학습 + whook 연결점 |
|---|---|---|
| 1주 | 웹훅 개념 + Go HTTP 기초 | 웹훅이 뭔지, net/http 메서드 라우팅, JSON 핸들러 → internal/api·ingest |
| 2주 | 멱등성 + durable capture | 유니크 인덱스·partial index·capture-before-ack → storage·core/types |
| 3주 | DB 기반 작업 큐 + 동시성 | lease·claim, 단일 writer vs SKIP LOCKED, 세마포어 워커풀 → worker·storage/{sqlite,postgres} |
| 4주 | 재시도 + graceful shutdown | 지수 백오프·Retry-After·dead-letter·workCtx drain → retry·delivery·app |
| 5주 | 보안 + 배포 + 관측 | HMAC 검증·AES-GCM·단일바이너리 Docker·Prometheus → signature·crypto·metrics·Dockerfile |
이 로드맵은 "분산 시스템의 어려운 개념(멱등성·큐·재시도·우아한 종료)을 작은 Go 코드로 손에 쥐는" 코스입니다. whook는 ~5,900줄로 작고 잘 정돈돼 있어, Kafka·Redis 같은 큰 인프라 없이도 "신뢰성 있는 이벤트 처리"가 어떻게 만들어지는지 읽을 만한 코드로 보여 줍니다 — 백엔드·인프라 입문자에게 알맞은 교재입니다.
"이 문서와 저장소에서 쓰이는 방식 그대로의 웹훅·큐·재시도·보안 용어."
| 용어 | 의미 |
|---|---|
| 웹훅(webhook) | 서비스가 이벤트 발생 시 내 URL로 보내는 HTTP POST. whook이 그 수신 종단을 대신 맡음 |
| 웹훅 게이트웨이 | 앱 앞단에서 웹훅을 가로채 저장·재시도·분배하는 중간 계층 (= whook의 정체) |
| durable capture | 보낸 쪽에 2xx 응답하기 전에 요청을 raw로 저장 → 유실 방지 |
| 멱등성(idempotency) | 같은 이벤트가 중복 도착해도 한 번만 처리. (source, dedup_key) 유니크 + 경쟁 패배 시 기존 id 반환 |
| dedup key | 소스별 지정 헤더에서 뽑은 중복제거 값. 없으면(NULL) 절대 충돌 안 함(partial index) |
| 지수 백오프 | 재시도 간격을 base × mult^(n-1)로 늘리고 max로 상한(기본 2s·×3·1h·8회) |
| dead-letter | 재시도 예산 소진 시 전송을 격리 상태로 보내 자원 낭비를 멈추고 검사·리플레이 대상으로 노출 |
| Retry-After | 429 응답 헤더. whook은 초("120")와 HTTP-date 둘 다 파싱해 계산 백오프보다 우선 적용 |
| fan-out(팬아웃) | 한 이벤트를 매칭되는 모든 목적지에 독립 트랙으로 전달. 한 곳 실패가 다른 곳을 막지 않음 |
| replay(리플레이) | 저장된 과거 이벤트를 같은 큐에 다시 넣어 재전송. 버그 재현·복구용 |
| lease / claim | 워커가 큐 항목을 가져갈 때 locked_until을 미래로 세팅해 중복 처리 차단 |
| SKIP LOCKED | Postgres에서 잠긴 행을 건너뛰고 다음 행을 잠그는 구문 → 여러 워커가 같은 큐를 안전 소비 |
| 서명 검증 | 위조 웹훅 차단. Stripe/GitHub HMAC-SHA256 내장, hmac.Equal 상수시간 비교 |
| at-rest 암호화 | 저장 시 암호화. 서명 시크릿을 AES-256-GCM(WHOOK_SECRET_KEY 기반)으로 봉인 |
| graceful drain | 종료 신호 시 새 작업은 멈추고 진행 중 전송은 끝냄(독립 workCtx, DrainTimeout) |
docs/{configuration,http-api,deployment}.mdghcr.io/edaywalid/whook:latest