웹훅, 언제 써요?
설정하는 김에 정리해보는 웹훅의 개념
오늘은 딱히 떠오르는 소재도 없고, 읽은 것도 없어서 이번에 결제 로직을 구성하면서 설정한 웹훅에 대해서 정리해보려고 한다.
웹훅은 보통 SSO 계정의 해지나 정기 결제 로직에서의 구독 취소 / 만료 등의 상태 정보를 서버로 전파하기 위해 사용한다.
정도로 대강 알고 있는데, 좀 자세히 뜯어볼까한다.
그래서 웹훅이 뭐냐
한 줄로 정리하면 이렇다.
어떤 이벤트가 발생했을 때, 상대 서버가 내 서버로 HTTP 요청을 보내주는 것.
말 그대로 “웹” 에서 “훅” 을 걸어두는 거다.
주주에 붙인 걸로 예시를 들면 — 유저 카드 결제가 실패하면, 미리 등록해둔 우리 서버 URL 로 POST 요청을 쏴준다.
우리는 그 URL 을 하나 열어두고, 요청이 오면 알아서 처리(유저 구독 해지, 알림 발송 등)를 하면 된다.
왜 필요하냐
없으면 일정 주기로 폴링하는 방법 밖에 없으니까.
근데 이런 예외 케이스를 위한 폴링은 당연하게도 문제가 좀 많다.
- 대부분의 요청이 “변경 없음” 이라 그냥 낭비된다
- 5분마다 폴링하면 이벤트 감지에 최대 5분이 걸린다 → 실시간 아님
- 상대 API 의 rate limit 도 잡아먹는다
- 유저 늘어나면 이 폴링만으로 서버 하나 더 띄워야 함
반대로 웹훅은 이벤트가 있을 때만 요청이 오고, 즉시 오니까 실시간이고, 내가 쏘는 게 아니라 받는 거라 rate limit 걱정도 없다.
폴링은 “물어보러 가는 것”, 웹훅은 “알아서 알려주는 것”.
이렇게 이해하면 편하다.
어디서 자주 쓰이냐
내가 지금까지 붙여본 것만 봐도 꽤 많다.
결제 (PG, Stripe, 아임포트, 토스페이먼츠 등)
- 결제 승인 / 실패, 정기 결제 성공 / 실패 / 카드 만료, 환불 완료
- 주주가 지금 붙이고 있는 게 이 카테고리
Git 서비스 (GitHub, GitLab)
- push 이벤트로 CI/CD 트리거, PR 열림/닫힘 이벤트로 슬랙 알림
- GitHub Actions 도 결국 push 웹훅 받아서 러너 띄우는 구조다
메신저 (Slack, Discord)
- 채널 메시지 감지, 봇 커맨드 처리
소셜 로그인 (Auth0, Firebase, Google)
- 계정 삭제, 비밀번호 변경, 회원 가입 완료
그 외에 노션, 에어테이블, 재피어 같은 SaaS 도 다 웹훅을 제공한다.
요즘은 왠만한 서비스가 다 갖고 있다고 봐도 무방하다.
예를 들면?
오늘 붙인게 refund.completed 였다.
유저가 우리 앱을 안 거치고 카드사에 직접 이의 제기해서 환불받는 케이스가 있다고 하면, 이걸 서버가 모른다면 결제는 환불됐는데 프로 기능은 계속 쓰고 있는 이상한 상황이 생긴다.
이런 거 하나 놓치면 이제 프로 유저는 많은데 돈은 하나도 못받는, 그런 경우가 생긴다.
붙일 때 주의할 것
이게 개념은 단순한데 막상 구현하면 함정이 몇 개 있다.
1. 서명 검증은 필수
내가 여는 웹훅 URL 은 결국 인터넷에 공개된 엔드포인트 다. 아무나 POST 요청 쏠 수 있다.
그래서 진짜 상대 서비스에서 온 요청인지 반드시 검증해야 한다.
대부분은 요청 헤더에 HMAC-SHA256 서명을 실어서 보내주는데, 사전에 공유받은 secret 으로 body 를 해싱해서 서명과 비교하면 된다.
import crypto from 'crypto';
function verifyWebhook(rawBody: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
=== 대신 timingSafeEqual 을 쓰는 이유는 타이밍 공격 방어. 문자열 비교는 앞자리 다르면 바로 리턴이라 응답 시간으로 서명을 추측할 수 있는데, 이걸 막아주는 함수다.
2. 같은 이벤트가 여러 번 온다
동일 이벤트가 중복으로 들어올 수 있다.
우리 서버가 200 응답을 늦게 보내면 상대는 실패라 판단하고 재전송하고, 상대의 재시도 정책 자체가 여러 번 쏘게 되어 있는 경우도 있고.
그래서 이벤트 ID 를 저장해두고, 이미 처리한 건 무시해야 한다.
const existing = await db.webhookEvents.findUnique({
where: { eventId: payload.id }
});
if (existing) {
return res.status(200).send('already processed');
}
await db.webhookEvents.create({ data: { eventId: payload.id } });
// ... 실제 처리
이거 안 하면 환불 이벤트 두 번 와서 프리미엄 회수 두 번 나가고, 알림 두 번 나가는 참사가 생긴다.
3. 응답은 무조건 빨리
웹훅 처리는 즉시 200 리턴 → 실제 작업은 큐에 넣어서 비동기로 가 정석이다.
상대 서버는 보통 몇 초 안에 응답을 기대하는데, 안 오면 재시도한다. 처리가 느리면 재시도가 몰리고, 그럼 위에서 말한 중복 이벤트가 폭발한다.
app.post('/webhook/payment', async (req, res) => {
if (!verifyWebhook(...)) return res.status(401).send();
await queue.publish('payment-events', req.body);
res.status(200).send();
// 실제 처리는 워커가 큐에서 꺼내서 진행
});
4. 원본 페이로드 로깅
받은 이벤트 원본은 무조건 그대로 로깅해두자.
버그로 처리 실패했을 때 이걸 보고 재처리해야 하고, 필요하면 상대 서비스 관리자 콘솔에서 “웹훅 재전송” 요청할 때도 참고가 된다.
마치며
이런 “개념” 자체를 다루는 게 얼마만인지 모르겠다.
요즘은 특히나 이럴 필요도 없이 AI 딸깍이면 웹훅이 뭔지 몰라도 바로 로직에 구현이 되니까..
사실 결제 트렌젝션이나 SSO 같은 서비스만 안 쓰면 크게 접할 일이 없는 개념이기는 한 거 같다.
뭐 근데, 이런 걸 개념적으로 아예 모르면, 지금 이걸 이 상황에 여기에 넣는게 맞는지 판단할 방법이 없으니.
결론은 이렇게 깔짝이라도 정리해둬서 나쁠 건 없다.
마침.