웹훅 서명 검증과 멱등성: 콜백 받는 쪽을 만들 때
웹훅 서명 검증은 raw body로 해야 한다. bodyParser를 끄는 이유, timingSafeEqual로 비교하는 이유, 콜백 멱등성과 실패 시 500을 줘야 하는 이유까지 콜백 수신 구현 정리.
목차
외부 결제 서비스와 연동하는 작업이었다. 우리가 그 서비스에 결제를 요청하면, 사용자가 그쪽 화면에서 결제를 끝내고, 결과는 나중에 그쪽이 우리 서버로 쏴준다. 이 마지막 단계가 콜백(웹훅)이다.
방향이 반대라서 감이 안 잡혔다
API를 부르는 건 익숙하다. 내가 요청하고 응답을 받는다. 콜백은 그 반대다. 남이 나를 부른다.
그러면 이런 게 전부 반대가 된다.
- 인증 — 내가 토큰을 보내는 게 아니라, 오는 요청이 진짜인지 내가 검사해야 한다
- 실패 처리 — 내가 재시도하는 게 아니라, 상대가 재시도한다
- 타이밍 — 내가 기다리는 게 아니라, 언제 올지 모른다
1. 그 주소는 인터넷에 열려 있다
콜백을 받으려면 외부에서 접근할 수 있어야 한다. 즉 아무나 그 주소로 요청을 보낼 수 있다.
"결제 완료됨"이라는 가짜 요청 하나면 결제 안 한 주문이 완료 처리된다.
2. 서명은 원문으로 검사해야 한다
서명 검증은 "본문 + 미리 나눠 가진 비밀키"로 해시를 만들어서, 상대가 헤더에 넣어 보낸 값과 비교하는 방식이다.
근데 JSON을 읽어들인 뒤 다시 문자열로 만들면 해시가 안 맞는다. 공백, 키 순서, 숫자 표기가 미묘하게 달라진다.
그래서 프레임워크의 자동 파싱을 꺼야 한다.
export const config = {
api: { bodyParser: false }, // 원문 그대로 받는다
};
이걸 모르면 "서명이 계속 안 맞는데 코드는 맞는 것 같다"에서 오래 헤맨다.
3. 본문을 그대로 믿으면 안 된다
서명이 맞아도, 그건 "이 요청이 그 서비스에서 왔다"까지만 보증한다. 그 안의 값이 정확한지는 별개다.
그래서 콜백을 받은 뒤 그 서비스에 다시 조회해서 실제 데이터를 가져온다.
[콜백 수신] "이 건 처리됐음, 참조번호 abc123"
↓
[재조회] GET /api/payments/abc123 ← 실제 값은 여기서
↓
[판정·저장]
콜백은 **"결과가 나왔다는 신호"**로만 쓰는 거다.
이걸 모르면 이런 착각을 한다 — "콜백 본문에 값을 채워 보내면 되겠네". 안 된다. 어차피 우리가 다시 조회하니까, 원본이 비어 있으면 조회해도 빈 값이다.
4. 예외를 삼키면 그 건은 영영 사라진다
콜백 처리 중에 실패했는데 200을 돌려주면, 상대는 성공으로 알고 다시 안 보낸다. 그 결제 건은 영영 반영되지 않는다.
반대로 500을 주면 보통 재시도해준다. 실패했으면 실패했다고 말해야 한다.
5. 같은 콜백이 두 번 올 수 있다
네트워크 문제로 상대가 재시도할 수도 있고, 운영자가 수동으로 다시 쏠 수도 있다.
두 번 처리해도 결과가 같아야 한다. 잔액을 더하는 처리였다면 두 배가 된다.
6. 로컬에서는 못 받는다
외부 서비스가 내 노트북으로 요청을 보낼 수 없다. 그래서 콜백 흐름은 로컬에서 끝까지 검증이 안 된다. 터널링 도구로 임시 주소를 열거나, 테스트 서버에 올려서 확인해야 한다.
실력 문제가 아니라 구조상 안 되는 거다. 미리 알고 일정에 넣어둬야 막판에 안 막힌다.
전체 모양
export const config = { api: { bodyParser: false } }; // ① 원문 유지
const handler = async (req, res) => {
if (req.method !== "POST") return res.status(405).end();
const rawBody = await getRawBody(req);
const signature = req.headers["x-signature"];
// ② 서명 검증 — 실패하면 즉시 거부
if (!verifyHmac(rawBody, signature)) {
return res.status(401).json({ message: "Invalid signature" });
}
const payload = JSON.parse(rawBody);
// ③ 중간 결과와 확정 결과를 구분
if (!payload.final) {
await recordPending(payload.orderId);
return res.status(200).send("OK");
}
// ④ 실제 데이터는 다시 조회
const data = await fetchPayment(payload.paymentRef);
// ⑤ 판정 + 저장
await recordResult(payload.orderId, judge(data));
return res.status(200).send("OK");
};
서명 검증은 이렇게 생겼다.
const verifyHmac = (rawBody, signature) => {
if (!signature || !SECRET) return false;
const computed = crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
};
timingSafeEqual을 쓰는 이유는 타이밍 공격 때문이다. 일반 비교(===)는 다른 문자가 나오는 순간 멈춘다. 그래서 응답 시간 차이로 정답을 한 글자씩 알아낼 수 있다. 이 함수는 항상 같은 시간이 걸린다.
중간 결과와 확정 결과를 나눈 이유도 있다. 처리가 여러 단계면 콜백이 여러 번 온다. 확정 전에 "결제 완료" 알림을 보내면 안 되니까, 플래그로 갈라서 저장은 하되 알림은 억제한다.
남길 것
- 콜백은 방향이 반대다. 내가 인증하는 게 아니라 오는 요청을 내가 검사한다. 그 주소는 인터넷에 열려 있으니 서명 검증이 없으면 아무나 결과를 조작할 수 있다.
- 서명은 반드시 원문으로 계산한다. 읽어들인 뒤 다시 만들면 공백과 키 순서가 달라져 해시가 안 맞는다.
- 비교는
timingSafeEqual로 한다. 일반 비교는 응답 시간 차이로 값이 새어나간다. - 콜백 본문은 "신호"로 쓰고, 데이터는 다시 조회한다.
- 실패했으면 실패 응답을 준다. 삼키고 200을 주면 그 건은 영영 사라진다.
- 같은 콜백이 두 번 와도 결과가 같아야 한다.
- 콜백은 로컬에서 끝까지 검증이 안 된다. 일정에 미리 넣어둔다.
더 봐야 할 것
- 두 번 와도 같게 만드는 걸 실제로 어떻게 보장하나 — 요청 ID를 저장해두고 중복이면 무시하는 방식이 일반적인지
- 콜백이 아예 안 왔을 때. 주기적으로 조회해서 맞춰보는 보정 작업이 필요한지
- 서명 검증 실패를 401로 주는 게 맞나. 공격자에게 "서명이 틀렸다"는 정보를 주는 셈인데
- 순서가 뒤바뀐 콜백(나중 결과가 먼저 도착)을 어떻게 막나