폴링 대신 웹훅: 통화 이벤트를 안정적으로 받는 서버 설계
KAT Cloud는 통화·문자 이벤트를 웹훅으로 보냅니다. 폴링보다 웹훅이 나은 이유, 즉시 2xx로 응답하고 큐에서 처리하는 구조, callHistoryId·chatId 기준 멱등 처리, 통화 기록 재조회 검증까지 설계 원칙을 정리했습니다.

통화 이벤트를 안정적으로 받는 서버는 KAT Cloud 웹훅을 받으면 처리하기 전에 즉시 2xx로 응답하고, 이벤트를 큐에 넣어 워커가 callHistoryId·chatId 기준으로 중복 없이 처리하며, 중요한 내용은 GET /call-history/{callHistoryId}로 다시 조회해 확인하는 구조입니다. KAT Cloud는 MESSAGE_RECEIVED, SEND_RESULT, CALL_RECEIVED, CALL_COMPLETED 네 가지 이벤트를 등록된 https URL로 POST합니다. 이 글은 왜 폴링 대신 웹훅을 써야 하는지와 수신 서버를 어떻게 나눠 설계하는지 설명합니다.
폴링보다 웹훅이 나은 이유
폴링은 새 데이터가 있는지 일정 주기로 물어보는 방식이고, 웹훅은 데이터가 생겼을 때 상대가 알려 주는 방식입니다. KAT Cloud(캣클라우드) API 문서도 새 통화를 주기적으로 확인할 때 전체 목록을 반복 조회하지 말고 웹훅을 쓰라고 권장합니다. 통화 기록 응답은 요약과 STT 대화 내역 때문에 하나가 수백 KB에 달할 수 있어, 폴링을 자주 할수록 양쪽 모두 부담이 커지기 때문입니다.

호출 한도도 이유입니다. KAT Cloud API의 Rate Limit은 1,200 requests / 60초라, 그룹이 늘수록 폴링이 한도를 잠식합니다. 웹훅을 둘 수 없는 환경이라면 차선책은 updatedAfter 증분 조회이며, 방법은 통화 기록 증분 동기화에 정리했습니다.
KAT Cloud가 보내는 네 가지 이벤트
모든 웹훅 요청 본문은 { "eventType": ..., "timestamp": ..., "data": { ... } } 구조입니다. 이벤트마다 data의 내용과 중복 판별에 쓸 식별자가 다릅니다.
| eventType | 보내는 시점 | 주요 data 필드 | 멱등 키 |
|---|---|---|---|
CALL_RECEIVED | 전화가 수신됐을 때(AI 응대 전 또는 직원 연결 전 착신 알림) | callHistoryId, groupId, fromNumber, toNumber, handOffReason | callHistoryId |
CALL_COMPLETED | 통화 종료 후 STT·요약 분석이 끝났을 때 | callHistoryId, groupId, status, tag, duration, summary, sttAI, sttDirect, recordingUrl | callHistoryId |
MESSAGE_RECEIVED | 고객에게서 문자를 받았을 때 | chatId, chatRoomId, senderPhoneNumber, content, imageList | chatId |
SEND_RESULT | 메시지 사업자가 최종 발송 결과를 회신했을 때 | chatId, chatType, sendStatus, resultStatus, resultMessage, sentAt | chatId |
몇 가지는 설계에 바로 영향을 줍니다.
CALL_RECEIVED의handOffReason은 AI 응대 중 직원 연결로 전환될 때만 들어 있고, 일반 착신에서는 비어 있거나 생략됩니다. 핸드오프 설계는 AI에서 사람으로: 상담원 연결 설계에서 다룹니다.CALL_COMPLETED는 통화가 끊긴 직후가 아니라 STT·요약 분석이 완료된 뒤에 옵니다.MESSAGE_RECEIVED의imageList이미지 URL은 일정 시간이 지나면 만료되므로, 보관이 필요하면 받은 즉시 내려받아야 합니다.SEND_RESULT의 최종 성공 여부는resultStatus가4100(SMS),6600(LMS·MMS),7000(카카오) 중 하나인지로 판단합니다.
수신 서버의 기본 구조: 즉시 응답하고 큐에서 처리
KAT Cloud 문서가 밝힌 웹훅 처리 권장사항은 세 가지입니다. 타임아웃은 10초이고, HTTP 2xx로 응답하면 성공으로 처리하며, 비동기로 처리하고 즉시 응답하는 것을 권장합니다. 이 세 줄이 수신 서버 구조를 거의 정해 줍니다.

수신 엔드포인트는 본문을 읽어 최소한의 형식만 확인하고 큐에 넣은 뒤 바로 200을 돌려줍니다. DB 쓰기, 외부 API 호출, LLM 호출 같은 무거운 일은 워커가 맡습니다. 응답 전에 이런 일을 하면 처리 시간이 길어지는 순간 10초 타임아웃에 걸려 실패로 기록됩니다. 큐는 프로세스가 재시작돼도 내용이 남는 저장소(데이터베이스 테이블, Redis, 관리형 큐 서비스 등)를 씁니다.
아래는 이 구조를 Node.js로 줄여 본 예시입니다. 메모리 배열과 Set은 설명을 위한 자리표시자이며, 운영에서는 내구성 있는 큐와 DB의 유니크 키로 바꿉니다.
const express = require('express');
const BASE_URL = 'https://studio-gateway.zerocall.kr';
const WEBHOOK_PATH = `/webhooks/katcloud/${process.env.WEBHOOK_PATH_TOKEN}`; // 추측하기 어려운 경로
const app = express();
app.use(express.json({ limit: '2mb' }));
const queue = []; // 자리표시자: 운영에서는 내구성 있는 큐
const processed = new Set(); // 자리표시자: 운영에서는 DB 유니크 키
app.post(WEBHOOK_PATH, (req, res) => {
const { eventType, data } = req.body || {};
if (eventType && data) queue.push({ eventType, data });
res.sendStatus(200); // 처리 전에 즉시 2xx
});
function eventKey({ eventType, data }) {
switch (eventType) {
case 'CALL_RECEIVED':
return `${eventType}:${data.callHistoryId}:${data.handOffReason ? 'handoff' : 'ring'}`;
case 'CALL_COMPLETED':
return `${eventType}:${data.callHistoryId}`;
case 'MESSAGE_RECEIVED':
case 'SEND_RESULT':
return `${eventType}:${data.chatId}`;
default:
return null; // 모르는 이벤트는 무시
}
}
async function fetchCall(groupId, callHistoryId) {
const res = await fetch(`${BASE_URL}/call-history/${callHistoryId}?groupId=${groupId}`, {
headers: { 'x-api-key': process.env.KAT_API_KEY },
});
if (!res.ok) throw new Error(`call-history ${res.status}`);
const body = await res.json();
return body.result ?? body;
}
async function handle(event) {
const key = eventKey(event);
if (!key || processed.has(key)) return; // 중복은 건너뜀
if (event.eventType === 'CALL_COMPLETED') {
const call = await fetchCall(event.data.groupId, event.data.callHistoryId); // 재조회 검증
console.log('upsert', call.callHistoryId, call.status, call.tag, call.summary);
}
// CALL_RECEIVED, MESSAGE_RECEIVED, SEND_RESULT 처리도 같은 방식으로 추가
processed.add(key); // 처리가 끝난 뒤에 기록
}
setInterval(async () => {
while (queue.length) {
const event = queue[0];
try {
await handle(event);
queue.shift();
} catch (e) {
console.error('처리 실패, 다음 주기에 재시도:', e.message);
break; // 운영에서는 재시도 횟수 제한과 실패 큐를 둡니다
}
}
}, 1000);
app.listen(3000);Express 기반의 전체 구현은 Node.js(Express)로 웹훅 수신 서버 만들기에서 이어집니다.
멱등 처리: callHistoryId와 chatId
KAT Cloud API 문서에는 웹훅의 재전송 정책이나 중복 전송 여부가 적혀 있지 않습니다. 그렇다고 중복이 절대 없다고 가정하면 안 됩니다. 네트워크 구간에서 응답이 늦게 끊기거나 내 쪽 큐가 같은 작업을 두 번 꺼내는 일은 어느 시스템에서나 생길 수 있습니다. 그래서 같은 이벤트를 두 번 처리해도 결과가 같도록 멱등하게 설계합니다.
- 이벤트마다 멱등 키를 만듭니다. 통화 이벤트는
eventType과callHistoryId, 문자 이벤트는eventType과chatId를 묶습니다. CALL_RECEIVED와CALL_COMPLETED는 같은callHistoryId를 공유하므로 키에eventType을 꼭 넣습니다.CALL_RECEIVED는 일반 착신 알림과 핸드오프 알림을 구분할 수 있도록handOffReason유무도 키에 반영합니다.- 처리 기록은 DB 테이블의 유니크 키로 남기고, 이미 있는 키는 건너뜁니다.
- 결과 저장 자체도
callHistoryId·chatId기준 upsert로 해서, 처리 도중 실패해 다시 돌아도 행이 늘어나지 않게 합니다.
순서도 보장된다고 가정하지 않습니다. CALL_COMPLETED를 처리할 때 CALL_RECEIVED 기록이 먼저 있어야만 동작하는 코드는 깨지기 쉽습니다. 각 이벤트가 단독으로 와도 upsert로 필요한 행을 만들 수 있게 짭니다.
재조회로 검증하기: GET /call-history/{callHistoryId}
KAT Cloud API 문서에는 웹훅 요청의 서명 헤더나 서명 검증 방법이 나와 있지 않습니다. 그러므로 웹훅 본문은 무언가가 일어났다는 알림으로 받고, 결제·고객 응대처럼 중요한 동작의 근거는 내 API 키로 다시 조회한 값으로 삼는 것이 안전합니다.

curl -G "https://studio-gateway.zerocall.kr/call-history/8f3f8fd9-7ab8-4a26-b523-4f8eb7d38b85" \
-H "x-api-key: $KAT_API_KEY" \
--data-urlencode "groupId=1"재조회에는 세 가지 이점이 있습니다. 첫째, 그 통화가 실제로 내 키가 접근할 수 있는 그룹에 존재하는지 확인됩니다. 둘째, 녹음 파일이 recordingAI·recordingDirect와 만료 시각 expiresAt까지 함께 오므로, 녹음을 내려받는 작업에 바로 쓸 수 있습니다. 셋째, 웹훅 본문 형식이 바뀌어도 저장 로직은 통화 기록 API 형식 하나만 따르면 됩니다. 문자의 경우 /message/send로 보낸 메시지는 GET /message/send-result/{chatId}가 SEND_RESULT와 같은 최종 결과를 조회용으로 제공합니다.
재조회와 함께 쓸 수 있는 보호 장치도 있습니다.
- 웹훅 URL은 https만 등록할 수 있습니다. 여기에 추측하기 어려운 경로 토큰을 넣어 무작위 요청을 줄입니다.
- 본문 크기 제한을 두고, 모르는
eventType은 2xx로 응답한 뒤 무시합니다. - 로그에는 전화번호와 대화 내용을 그대로 남기지 않고 식별자 위주로 기록합니다.
놓친 이벤트 메우기와 운영 점검
웹훅 수신 서버가 배포 중이거나 장애로 멈춘 사이에 온 이벤트는 놓칠 수 있고, 재전송 정책이 문서에 없으니 다시 온다고 기대할 수도 없습니다. 그래서 웹훅을 주 경로로 두고, 주기적인 대조 작업을 보조 경로로 둡니다. 통화는 GET /call-history에 updatedAfter를 넘겨 마지막 대조 이후 갱신된 기록을 받고, 멱등 키로 이미 처리한 것은 건너뜁니다. 웹훅과 대조 작업이 같은 upsert 로직을 쓰면 어느 쪽으로 먼저 들어와도 결과가 같습니다.
운영 중에는 아래 항목을 지켜봅니다.
| 지표 | 이상 신호 |
|---|---|
| 마지막 웹훅 수신 시각 | 평소 통화가 있는 시간대에 오래 비어 있음 |
| 수신 엔드포인트 응답 시간 | 수 초 이상으로 늘어남(타임아웃 10초) |
| 큐 적체량 | 계속 늘어나기만 함 |
| 워커 실패 건수 | 같은 이벤트가 반복 실패 |
| 대조 작업이 새로 찾은 건수 | 0이 아니면 웹훅 누락 가능성 |
배포 직후나 URL을 바꾼 직후에는 POST /webhook/{webhookId}/test로 연결을 확인합니다. 테스트 결과 읽는 법은 웹훅 등록·수정·테스트에 정리했습니다. 이벤트별 필드 원문은 API 문서의 웹훅 이벤트, 설정 API는 웹훅 항목, 재조회 API는 통화 기록 항목에 있습니다.
자주 묻는 질문
KAT Cloud 웹훅에 서명 검증 기능이 있나요?
KAT Cloud API 문서에는 웹훅 요청의 서명 헤더나 서명 검증 방법이 나와 있지 않습니다. 그래서 https URL과 추측하기 어려운 경로를 쓰고, 중요한 처리는 GET /call-history/{callHistoryId} 같은 API로 내 키를 사용해 다시 조회한 값을 근거로 삼는 것이 안전합니다.
웹훅 수신 서버는 얼마나 빨리 응답해야 하나요?
KAT Cloud 웹훅의 타임아웃은 10초이고, HTTP 2xx 응답을 받으면 성공으로 처리합니다. 문서는 비동기 처리 후 즉시 응답을 권장하므로, 수신 서버는 이벤트를 큐에 넣은 직후 2xx로 응답하고 실제 처리는 워커에서 합니다.
같은 웹훅 이벤트가 두 번 오면 어떻게 하나요?
KAT Cloud API 문서에는 웹훅 재전송이나 중복 전송 정책이 적혀 있지 않으므로, 수신 쪽에서 중복을 견디도록 설계합니다. 통화 이벤트는 eventType과 callHistoryId, 문자 이벤트는 eventType과 chatId를 멱등 키로 삼아 이미 처리한 이벤트는 건너뛰고, 결과는 upsert로 저장합니다.
- #웹훅
- #이벤트 아키텍처
- #멱등성
- #메시지 큐
- #KAT Cloud



