웹훅은 봇 개발에서 가장 단순해 보이지만, 운영 단계에서 가장 많은 사고를 내는 경계면입니다.
코드는 20줄인데 장애는 2시간 가고, 문서에는 “이벤트 전달” 한 줄인데 실제로는 재시도, 중복, 순서 꼬임, 서명 검증, 타임아웃까지 다 얽혀 들어옵니다.
저도 처음에는 “POST 받으면 처리하면 되지”라는 생각으로 시작했습니다. 그런데 chat.smeuse.org를 운영하면서 생각이 완전히 바뀌었습니다. 웹훅은 단순한 입력 채널이 아니라 신뢰성 경계(reliability boundary) 입니다. 여기서 잘못 설계하면 애플리케이션 내부가 아무리 좋아도 결국 무너집니다.
이번 글에서는 실제 운영 경험을 바탕으로, 봇 개발자라면 반드시 챙겨야 할 웹훅 패턴 5가지를 정리해보겠습니다.
- 멱등성 등록 (Upsert 패턴)
- 루프 방지 (셀프 루프 + 크로스 봇 루프)
- 페이로드 정규화 (nested vs flat)
- 시크릿 서명 검증 (HMAC)
- 우아한 실패 처리 (타임아웃 + 재시도 백오프)
코드는 Node.js/TypeScript 스타일의 예제로 설명하지만, 개념은 어떤 스택에서도 동일하게 적용됩니다.
왜 웹훅은 “기능”이 아니라 “운영 문제”인가
개발 단계에서 웹훅은 보통 이렇게 보입니다.
- 공급자(플랫폼)가 이벤트를 보낸다
- 내 서버가 받는다
- 처리하고 200을 응답한다
하지만 운영 단계에서 실제로 마주치는 건 다음입니다.
- 같은 이벤트가 여러 번 온다
- 이벤트 순서가 뒤집힌다
- 전달이 지연되어 오래된 이벤트가 나중에 온다
- 플랫폼마다 payload 구조가 다르다
- 서명 실패/시간 오차/프록시 변경으로 인증 오류가 난다
- 내 시스템이 느려졌을 때 재시도가 폭주한다
즉, 웹훅은 “수신 엔드포인트 하나”가 아니라 분산 시스템의 접점입니다. 이 접점을 어떻게 설계하느냐가 봇의 신뢰도를 결정합니다.
1) 멱등성 등록: Upsert로 “한 번만 처리된 것처럼” 만들기
웹훅 공급자는 네트워크 불안정이나 응답 지연을 대비해 이벤트를 재전송합니다. 이건 공급자 입장에서 정상 동작입니다. 문제는 수신자가 중복 이벤트를 중복 처리하는 순간 시작됩니다.
예를 들어:
- 같은 메시지가 두 번 저장됨
- 결제/포인트/알림이 두 번 실행됨
- 내부 후속 워크플로우가 2배로 트리거됨
이 문제를 막는 핵심은 멱등성 키(idempotency key) 와 upsert입니다.
실전 규칙
- 외부 이벤트의 고유 식별자를 찾는다 (
event_id,delivery_id,message_id등) - 우리 시스템의 처리 테이블에 유니크 키를 건다
- INSERT가 아니라 UPSERT를 사용한다
- “이미 처리됨” 상태를 빠르게 판정한다
CREATE TABLE webhook_events (
source TEXT NOT NULL,
external_id TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'received',
payload_json JSONB NOT NULL,
PRIMARY KEY (source, external_id)
);
await db.query(`
INSERT INTO webhook_events (source, external_id, payload_json)
VALUES ($1, $2, $3)
ON CONFLICT (source, external_id)
DO NOTHING
`, [source, externalId, payload]);
const inserted = /* rowCount 확인 */;
if (!inserted) {
// 이미 처리된 이벤트로 간주
return res.status(200).send('duplicate ignored');
}
여기서 중요한 포인트는, 중복을 에러로 보지 않는 것입니다. 중복은 “정상 시나리오”로 다뤄야 합니다.
운영 팁
- 멱등성 키가 없는 공급자라면
hash(source + timestamp + payload 핵심 필드)로 준식별자를 만들어야 합니다. - 단, payload hash는 필드 순서/불필요 필드 변경에 취약하므로 정규화 후 hash를 권장합니다.
- 이벤트 원문(payload)은 꼭 저장하세요. 디버깅할 때 생명줄입니다.
2) 루프 방지: 셀프 루프와 크로스 봇 루프를 분리해서 막아라
봇 운영에서 가장 무서운 장애 중 하나가 루프입니다. 특히 채팅/알림 시스템은 루프가 시작되면 짧은 시간에 폭발합니다.
루프는 크게 두 종류입니다.
A. 셀프 루프 (Self-loop)
내 봇이 보낸 메시지를 웹훅으로 다시 받아서, 그걸 또 처리하고 다시 보내는 패턴입니다.
증상:
- 봇이 자기 말에 계속 반응함
- “안녕하세요” 한 번에 수십 건 메시지 발생
방지 방법:
sender_id == bot_id면 드롭- 이벤트 출처 메타(
is_bot,app_id,integration_id) 검사
if (event.sender?.id === BOT_ID) {
return res.status(200).send('self message ignored');
}
B. 크로스 봇 루프 (Cross-bot loop)
A봇이 만든 메시지를 B봇이 처리하고, B봇의 출력을 다시 A봇이 처리하는 구조입니다. 여러 자동화가 얽힌 환경에서 자주 터집니다.
증상:
- 특정 키워드에서 두 봇이 서로 대화 시작
- 로그는 정상처럼 보이지만 트래픽이 계속 증가
방지 방법:
- 메시지에
x-origin-bot,x-trace-id,x-hop-count같은 메타를 붙인다 - 이미 처리한 origin이면 스킵한다
hop-count상한(예: 3)을 넘으면 강제 중단한다
const originBot = event.meta?.originBot;
const hop = Number(event.meta?.hopCount ?? 0);
if (originBot && originBot !== 'human') {
// 다른 봇에서 시작된 이벤트
if (alreadySeenTrace(event.meta?.traceId)) {
return res.status(200).send('cross-loop prevented');
}
}
if (hop > 3) {
return res.status(200).send('hop limit exceeded');
}
운영 팁
- 루프 탐지는 비즈니스 로직 전에 실행하세요. 늦게 막으면 이미 피해가 큽니다.
- “누가 생성했는가” 필드를 모든 아웃바운드 메시지에 일관되게 심는 습관이 중요합니다.
- 루프 차단 이벤트는 별도 대시보드/알람으로 모니터링하세요.
3) 페이로드 정규화: nested/flat 혼종 세계를 내부 스키마로 통일
플랫폼마다 payload 구조는 제각각입니다.
- 어떤 곳은
event.data.message.text - 어떤 곳은
text - 어떤 곳은
message.body - timestamp도 초/밀리초/ISO 문자열이 뒤섞임
처음에는 “if/else 몇 개면 되겠지” 싶지만, 공급자가 늘어날수록 handler는 금방 스파게티가 됩니다. 해법은 정규화 계층(normalization layer) 입니다.
권장 아키텍처
수신(raw) → 검증(signature/schema) → 정규화(canonical event) → 도메인 처리
핵심은 도메인 로직이 공급자별 포맷을 알지 못하게 만드는 것입니다.
type CanonicalEvent = {
source: 'slack' | 'discord' | 'telegram' | 'custom';
eventId: string;
eventType: 'message.created' | 'message.updated' | 'reaction.added';
channelId: string;
userId: string;
text?: string;
createdAt: string; // ISO8601
raw: unknown;
};
function normalizeSlack(payload: any): CanonicalEvent {
return {
source: 'slack',
eventId: payload.event_id,
eventType: 'message.created',
channelId: payload.event?.channel,
userId: payload.event?.user,
text: payload.event?.text,
createdAt: new Date(Number(payload.event?.event_ts) * 1000).toISOString(),
raw: payload,
};
}
정규화에서 자주 놓치는 것
- 타임존: KST로 보더라도 저장은 UTC ISO로 통일
- null/undefined 공백 처리: down-stream NPE 방지
- 숫자 문자열: id 필드는 string으로 일관 유지
- edited/deleted 플래그: 메시지 수정 이벤트 누락 방지
운영 팁
- 정규화 실패는 400으로 끝내지 말고, 샘플 payload를 안전하게 마스킹 후 로그 보관하세요.
- “공급자별 어댑터 테스트”를 fixture 기반으로 자동화하면 회귀를 크게 줄일 수 있습니다.
- 내부 canonical schema를 문서화해두면, 신규 팀원이 공급자 추가할 때 속도가 확 올라갑니다.
4) 시크릿 서명 검증: HMAC은 옵션이 아니라 기본값
웹훅 엔드포인트를 인터넷에 노출하면, 언젠가 스캐너/봇/임의 호출이 들어옵니다. IP allowlist만 믿고 가면 프록시/엣지 구성이 바뀔 때 취약해집니다.
기본 방어선은 HMAC 서명 검증입니다.
검증의 핵심 포인트
- raw body 기준으로 해시를 계산해야 함 (JSON 파싱 후 재직렬화 금지)
- 공급자가 지정한 헤더(
X-Signature,X-Hub-Signature-256등)와 비교 - 시간 기반 서명이라면 timestamp 허용 오차 확인 (예: ±5분)
- 비교는 timing-safe 함수 사용
import crypto from 'crypto';
function verifySignature(rawBody: Buffer, headerSig: string, secret: string) {
const computed = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(computed, 'utf8');
const b = Buffer.from(headerSig, 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
실수 포인트
- Express에서
json()미들웨어를 먼저 타서 raw body를 잃어버림 - 서명 실패 시 상세 원인을 응답에 노출함 (공격자 힌트 제공)
- 시크릿 로테이션 기간을 고려하지 않음
운영 팁
- 시크릿은 최소 2개(current, next) 동시 검증을 잠시 지원하면 무중단 로테이션이 쉽습니다.
- 서명 실패 로그에는 payload 전문을 남기지 말고 해시/메타만 남기세요.
- 실패율 급증은 공격 시그널일 수 있으니 모니터링하세요.
5) 우아한 실패 처리: 타임아웃, 비동기 큐, 재시도 백오프
웹훅 처리에서 가장 흔한 안티패턴은 “받은 요청 안에서 모든 걸 끝내려는 것”입니다. 외부 API 호출, DB 작업, AI 추론, 파일 처리까지 한 번에 처리하면 지연이 길어지고, 공급자 재시도가 겹치며 중복 폭탄이 시작됩니다.
정답은 간단합니다.
- 수신 경로는 얇게(Thin Ingress)
- 핵심 처리 로직은 비동기 큐로 분리
권장 흐름
- 웹훅 수신
- 서명/기본 검증
- 멱등성 등록(upsert)
- 큐 enqueue
- 즉시 2xx 응답
- 워커에서 실제 처리 + 재시도
app.post('/webhook', async (req, res) => {
// 1) 검증
// 2) idempotency upsert
// 3) queue publish
res.status(202).send('accepted');
});
재시도 전략
재시도는 “무한 반복”이 아니라 정책입니다.
- 즉시 재시도 1~2회
- 이후 지수 백오프 (2s, 4s, 8s, 16s…)
- 지터(jitter) 추가로 동시 폭주 완화
- 최대 재시도 횟수 초과 시 DLQ(Dead Letter Queue)로 격리
function backoffMs(attempt: number) {
const base = Math.min(1000 * 2 ** attempt, 60_000);
const jitter = Math.floor(Math.random() * 500);
return base + jitter;
}
타임아웃 설계
- 외부 API 호출 타임아웃을 명시하세요. 기본 무한 대기는 금지입니다.
- 전체 처리 SLA를 정하고, 그 이상은 fail-fast 후 재시도로 넘기세요.
- “느리지만 성공”보다 “빠르게 실패 후 재처리”가 전체 신뢰성에 유리한 경우가 많습니다.
운영 팁
- DLQ는 “버리는 곳”이 아니라 “복구 시작점”입니다. 재처리 도구와 함께 설계하세요.
- 실패 사유를 분류(네트워크/레이트리밋/데이터 오류)하면 개선 속도가 빨라집니다.
- 재시도 횟수, 처리 지연, DLQ 적재량은 필수 모니터링 지표입니다.
5가지 패턴을 한 번에 묶는 레퍼런스 플로우
실전에서 이 패턴들은 따로 놀지 않습니다. 하나의 수신 파이프라인으로 결합해야 효과가 납니다.
[Ingress]
-> Raw body 수집
-> HMAC 검증
-> 기본 schema 확인
-> Canonical normalize
-> Idempotency upsert
-> Loop guard 검사
-> Queue enqueue
-> 202 응답
[Worker]
-> 도메인 처리
-> 외부 API 호출 (timeout)
-> 실패 시 retry(backoff+jitter)
-> 임계치 초과 시 DLQ
이렇게 구성하면,
- 중복 이벤트가 와도 안전하고
- 루프를 초기 단계에서 차단하며
- 공급자 포맷 차이를 내부에 격리하고
- 인증 위협을 줄이고
- 장애 시에도 전체 시스템이 천천히, 예측 가능하게 실패합니다.
즉, “웹훅 엔드포인트”가 “운영 가능한 수신 시스템”으로 승격됩니다.
chat.smeuse.org 운영에서 얻은 현실적인 교훈
마지막으로, 문서보다 운영에서 더 크게 느꼈던 포인트를 짧게 정리해보겠습니다.
1) 200 OK는 성공이 아니라 접수 확인이다
웹훅 응답의 2xx는 “처리 완료”가 아니라 “받았다”에 가깝습니다. 이 관점 전환이 없으면 긴 처리 로직을 ingress에 넣게 됩니다.
2) 로그는 코드보다 오래 남는다
문제 상황에서 팀이 보는 건 소스가 아니라 로그입니다. 이벤트 ID, trace ID, source, 시도 횟수, 실패 분류를 구조화해 남기면 장애 대응 시간이 절반 이하로 줄어듭니다.
3) 공급자 문서는 이상적이고, 실제 payload는 현실적이다
문서 스키마만 믿지 마세요. 샘플 payload를 충분히 수집하고 테스트 fixture로 고정해야 합니다.
4) 보안과 신뢰성은 트레이드오프가 아니라 기본 품질이다
서명 검증을 빼고 빠르게 개발하면 초반 속도는 나지만, 결국 더 큰 비용으로 돌아옵니다. 보안/신뢰성은 “나중에”가 아니라 “처음부터” 넣어야 합니다.
5) 운영 자동화가 없는 신뢰성은 오래 못 간다
DLQ 대시보드, 재처리 스크립트, 알람 임계치, 재시도 정책 튜닝 루프가 있어야 시스템이 성장합니다. 한 번의 좋은 코드보다 반복 가능한 운영 루틴이 더 중요합니다.
바로 적용할 수 있는 체크리스트
새 웹훅 엔드포인트를 만들 때 아래 10개를 체크해보세요.
- raw body 접근 가능한가? (HMAC용)
- 서명 검증 실패 시 401/403 처리하는가?
- timestamp replay 방지(있다면) 구현했는가?
- 멱등성 키를 정의했는가?
- DB에 unique constraint + upsert 적용했는가?
- 셀프 루프 방지 조건이 있는가?
- 크로스 봇 루프용 trace/hop 정책이 있는가?
- payload를 canonical schema로 정규화하는가?
- ingress가 큐 기반 비동기 처리로 분리되었는가?
- retry/backoff/jitter/DLQ 정책이 정의되어 있는가?
10개 중 8개 이상이면 꽤 안정적인 출발선입니다.
마무리
웹훅은 작게 시작할 수 있지만, 운영 규모가 커질수록 시스템의 안정성을 좌우하는 핵심 경계가 됩니다.
특히 봇 시스템은 이벤트 기반이라 웹훅 품질이 곧 제품 품질입니다.
정리하면, 봇 개발자의 웹훅은 아래 5가지를 기본값으로 가져가야 합니다.
- 멱등성 등록(Upsert) 으로 중복에 안전하게
- 루프 방지 로 폭주를 초기 차단
- 페이로드 정규화 로 도메인 로직 보호
- HMAC 서명 검증 으로 수신 경계 보안 강화
- 우아한 실패 처리 로 장애를 통제 가능한 상태로
이 5가지는 “고급 최적화”가 아니라, 운영 환경에 들어가는 순간 반드시 필요한 최소 설계입니다.
한 번에 완벽히 만들 필요는 없습니다. 다만 엔드포인트 하나를 열 때마다 이 체크리스트를 기준으로 조금씩 끌어올리면, 어느 순간 장애는 줄고 디버깅은 빨라지고 팀의 자신감이 커집니다.
웹훅은 결국 신뢰의 문제입니다.
그리고 신뢰는 우연히 생기지 않고, 패턴으로 설계됩니다.