외부 서비스 연동을 붙이다 보면 웹훅(webhook) 처리에서 한 번은 멈춘다. 결제 완료, 저장소 push, 메시지 수신 같은 이벤트를 상대 서비스가 우리 서버로 POST해주는 구조인데, 문제는 그 콜백을 받는 엔드포인트가 아직 내 노트북 localhost에만 있다는 점이다. 상대 서비스는 외부에서 접근 가능한 공개 URL만 부를 수 있으니, 배포 전에는 테스트할 방법이 마땅치 않다. 이 글은 그 상황에서 매번 다시 찾게 되는 도구와 검증 코드를 한자리에 정리한 것이다.
결론부터 말하면, 로컬 포트를 공개 URL로 노출하는 터널링 도구(smee 또는 ngrok)로 콜백을 받아오고, 수신부에서는 HMAC 서명으로 발신자를 검증한 뒤 타임스탬프 허용 범위로 재전송(replay) 요청을 막으면 된다. 아래에서 터널 연결, 서명 검증, 재전송 방어를 차례로 살펴본다.
웹훅이 로컬에서 안 되는 이유부터
웹훅은 상대 서비스가 이벤트가 생겼을 때 우리가 등록해 둔 URL로 HTTP 요청을 보내주는 방식이다. 우리가 상대를 폴링하는 게 아니라 상대가 우리를 호출한다. 그래서 등록하는 URL은 인터넷에서 접근 가능해야 한다. http://localhost:3000은 내 컴퓨터 안에서만 유효한 주소라 GitHub나 Stripe 서버 입장에서는 존재하지 않는 곳이다.
해결책은 두 갈래다. 로컬 포트를 임시 공개 URL로 뚫어주는 터널을 쓰거나, 서비스가 제공하는 자체 CLI 포워더(예: Stripe CLI의 stripe listen)를 쓰는 방법이다. 범용성은 터널 쪽이 넓어서 여기서는 터널 두 가지를 다룬다.
smee와 ngrok 중 무엇을 쓸까
둘 다 로컬 포트를 외부에서 접근 가능하게 만들어 주지만 성격이 다르다.
- smee는 GitHub의 Probot 팀이 운영하는
smee.io중계 서비스와 짝을 이루는 클라이언트다. 회원가입, 인증 토큰이 필요 없고 채널 URL 하나만 발급받으면 끝난다. GitHub 웹훅 테스트에 특히 손이 잘 간다. - ngrok은 범용 터널 도구다. 인증 토큰 등록이 필요하지만 HTTP뿐 아니라 TCP, TLS도 뚫을 수 있고 요청/응답을 들여다보는 웹 인스펙터(
http://127.0.0.1:4040)를 제공한다. Stripe, Slack, 결제사 등 상대를 가리지 않는 테스트에 적합하다.
가볍게 GitHub 콜백만 확인할 거라면 smee, 여러 서비스를 붙이며 트래픽을 눈으로 확인하고 싶으면 ngrok을 권한다.
smee로 콜백 받기
먼저 smee.io에 접속하면 "Start a new channel" 버튼이 있고, 누르면 https://smee.io/xxxxxxxx 형태의 채널 URL이 발급된다. 이 URL을 상대 서비스의 웹훅 등록란(GitHub이면 저장소 Settings > Webhooks의 Payload URL)에 넣는다.
그다음 클라이언트를 설치한다. Node 환경이면 전역 설치가 간단하다.
$ npm install -g smee-client added 1 package in 2s
이제 발급받은 채널로 들어오는 요청을 로컬 포트로 넘겨준다. -u는 소스 채널 URL, -t는 넘길 로컬 대상 주소다.
$ smee -u https://smee.io/xxxxxxxx -t http://localhost:3000/api/webhooks/github Forwarding https://smee.io/xxxxxxxx to http://localhost:3000/api/webhooks/github Connected https://smee.io/xxxxxxxx
주요 옵션은 이렇다.
-u, --url: smee 채널 URL(기본값은 새 채널을 자동 생성)-t, --target: 요청을 넘길 로컬 대상 전체 URL-p, --port: 대상 포트(기본 3000)-P, --path: 넘길 경로(기본/)
여기서 한 번 걸리기 쉽다. -t에 경로까지 정확히 넣지 않으면 요청이 /로 떨어져서 라우트가 안 맞는다. 나는 -t에 실제 핸들러 경로를 통째로 박는 방식을 쓴다.
ngrok으로 콜백 받기
ngrok은 계정에서 인증 토큰을 받아 한 번 등록해두면 이후로는 토큰을 매번 넘기지 않아도 된다.
$ ngrok config add-authtoken <YOUR_AUTHTOKEN> Authtoken saved to configuration file
그리고 로컬 포트로 HTTP 터널을 연다.
$ ngrok http 3000 Session Status online Forwarding https://a1b2-203-0-113-7.ngrok-free.app -> http://localhost:3000 Web Interface http://127.0.0.1:4040
출력된 https://....ngrok-free.app 주소가 공개 URL이다. 여기에 웹훅 경로를 붙여(https://....ngrok-free.app/api/webhooks/github) 상대 서비스에 등록한다. 무료 플랜은 터널을 다시 열 때마다 URL이 바뀌므로 재실행할 때는 등록 주소도 갱신해야 한다. http://127.0.0.1:4040에 들어가면 방금 들어온 요청의 헤더와 바디를 그대로 볼 수 있고, 같은 요청을 재전송(Replay)해 버튼 한 번으로 재현할 수 있다. 디버깅할 때 이 인스펙터가 크게 도움이 된다.
왜 서명 검증이 필요한가
터널을 열면 그 공개 URL은 상대 서비스만 아는 게 아니다. 주소만 알면 누구나 우리 엔드포인트로 가짜 이벤트를 POST할 수 있다. 결제 성공 웹훅을 위조당하면 결제 없이 상품이 지급될 수도 있다. 그래서 대부분의 서비스는 요청 바디를 사전에 공유한 비밀키로 HMAC 서명해 헤더에 실어 보낸다. HMAC은 비밀키와 메시지를 함께 넣어 만든 해시로, 키를 모르면 같은 값을 만들 수 없다. 수신부에서 같은 키로 바디를 다시 해시해 헤더 값과 일치하는지 보면 발신자를 확인할 수 있다.
GitHub 방식 HMAC 검증
GitHub은 웹훅 등록 시 설정한 secret으로 페이로드를 HMAC-SHA256 해시한 뒤 X-Hub-Signature-256 헤더에 실어 보낸다. 값 형식은 sha256= 뒤에 16진수 다이제스트가 붙는다. 공식 문서의 Python 예시가 검증 골자를 잘 보여준다.
import hashlib
import hmac
def verify_signature(payload_body, secret_token, signature_header):
hash_object = hmac.new(secret_token.encode('utf-8'),
msg=payload_body,
digestmod=hashlib.sha256)
expected_signature = "sha256=" + hash_object.hexdigest()
if not hmac.compare_digest(expected_signature, signature_header):
raise HTTPException(status_code=403)
sarc.io는 Next.js라서 같은 로직을 Node의 crypto로 옮긴다. App Router의 route handler에서 원본 바디 문자열을 req.text()로 받는 게 핵심이다.
// app/api/webhooks/github/route.js
import crypto from 'node:crypto';
export async function POST(req) {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
const raw = await req.text(); // 파싱 전 원본 문자열
const sig = req.headers.get('x-hub-signature-256') || '';
const digest = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(raw)
.digest('hex');
const a = Buffer.from(digest);
const b = Buffer.from(sig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response('invalid signature', { status: 403 });
}
const event = JSON.parse(raw); // 검증 후에 파싱
// ... 이벤트 처리
return new Response('ok');
}
비교는 반드시 crypto.timingSafeEqual(Python이면 hmac.compare_digest)로 한다. 일반 ==나 ===는 앞에서부터 다른 바이트가 나오는 순간 반환되기 때문에, 응답 시간 차이로 서명을 한 바이트씩 추측하는 타이밍 공격에 노출된다. 상수 시간 비교는 그 차이를 없앤다. 한 가지 함정으로, timingSafeEqual은 두 버퍼 길이가 다르면 예외를 던진다. 위 코드처럼 길이를 먼저 확인하고 넘겨야 한다.
원본 바디 함정: 파싱하면 서명이 깨진다
서명 검증에서 가장 자주 막히는 지점이다. HMAC은 상대가 보낸 바이트 그대로를 해시한 값이다. 프레임워크가 바디를 JSON으로 파싱했다가 다시 문자열로 만들면 공백, 키 순서, 인코딩이 미묘하게 바뀌어 해시가 달라지고 검증이 실패한다. Stripe 문서도 프레임워크가 바디를 건드리지 않도록 원본 바디를 확보하라고 강조한다.
Express라면 웹훅 라우트에는 express.json() 대신 express.raw()를 붙이고, 전역 app.use(express.json())는 웹훅 라우트 뒤에 둬야 한다.
// 웹훅만 raw 바디로 받는다
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body; // Buffer 그대로
// ... HMAC 검증
});
// 그 외 라우트용 JSON 파서는 이 아래에
app.use(express.json());
Next.js App Router는 앞의 예시처럼 req.text()나 req.arrayBuffer()로 원본을 받으면 되고, 검증이 끝난 뒤에 JSON.parse한다. 순서를 지키는 게 전부다.
재전송(replay) 공격 방어
서명이 맞아도 안심하기엔 이르다. 공격자가 과거에 오간 정상 요청을 그대로 가로채 다시 보내면 서명은 여전히 유효하다. 결제 성공 웹훅을 반복 전송해 지급을 여러 번 유발하는 식이다. 이걸 막는 표준 방법이 타임스탬프를 서명에 포함시키고 수신부에서 허용 시간 범위를 두는 것이다.
Stripe가 대표적이다. Stripe-Signature 헤더는 t=타임스탬프,v1=서명 형태이고, 서명 대상은 바디만이 아니라 타임스탬프 + "." + 바디를 이어붙인 문자열이다. 수신부는 서명을 확인한 뒤 헤더의 타임스탬프가 현재 시각과 너무 벌어지지 않았는지 본다. Stripe의 기본 허용 범위는 300초(5분)다.
// Stripe 방식 검증 + 재전송 방어
import crypto from 'node:crypto';
function verifyStripe(raw, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('='))
);
const timestamp = parts.t;
const signature = parts.v1;
// 1) 타임스탬프가 허용 범위 안인지 (재전송 방어)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSec) {
throw new Error('timestamp outside tolerance');
}
// 2) 타임스탬프를 포함해 서명 재계산
const signedPayload = timestamp + '.' + raw;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('signature mismatch');
}
}
허용 범위를 두면 오래된 요청은 서명이 맞아도 거른다. 여기에 더해 이벤트 ID(예: GitHub의 X-GitHub-Delivery, Stripe 이벤트의 id)를 저장해 두고 이미 처리한 ID가 다시 오면 무시하면, 허용 범위 안에서의 중복 전송까지 막을 수 있다. 이 멱등(idempotency) 처리는 서비스 정상 재시도까지 안전하게 흡수한다.
curl로 서명까지 붙여 직접 쏘기
상대 서비스 이벤트를 매번 기다릴 필요 없이, 검증 로직 자체는 로컬에서 curl로 바로 때려볼 수 있다. openssl로 HMAC을 만들어 헤더에 실으면 된다.
$ SECRET='mysecret'
$ BODY='{"action":"opened","number":1}'
$ SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
$ echo "$SIG"
sha256=9f8e...(16진수 다이제스트)
$ curl -X POST http://localhost:3000/api/webhooks/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIG" \
-d "$BODY"
ok
여기서 자주 걸리는 점 하나. printf '%s'로 바디를 그대로 넘겨야 한다. echo는 끝에 개행을 붙여 버려서 서버가 받는 바이트와 서명 대상 바이트가 어긋나고, 검증이 403으로 떨어진다. curl -d도 개행을 붙이지 않으니 서명과 전송 바디가 동일해진다. secret을 일부러 틀리게 바꿔 403이 나오는지도 같이 확인하면, 검증이 실제로 작동하는지 양방향으로 검증된다.
정리
로컬 웹훅 테스트는 세 층으로 나뉜다. 공개 URL을 뚫는 터널(smee 또는 ngrok), 발신자를 확인하는 HMAC 서명 검증, 그리고 오래되거나 중복된 요청을 거르는 타임스탬프 허용 범위와 이벤트 ID 멱등 처리다. 검증 코드를 짤 때는 파싱 전 원본 바디로 해시하기와 상수 시간 비교, 이 둘만 놓치지 않으면 실패의 대부분을 피한다.