수신 문자 자동응답 만들기: MESSAGE_RECEIVED 웹훅과 LLM
KAT Cloud의 MESSAGE_RECEIVED 웹훅으로 고객 문자를 받아 LLM으로 답장을 만들고 POST /chat으로 회신하는 자동응답 서버를 만듭니다. 영업시간 판단, 중복 방지, 연속 답장 제한까지 Node.js 코드로 정리했습니다.

수신 문자 자동응답은 고객이 KAT Cloud 번호로 보낸 문자를 MESSAGE_RECEIVED 웹훅으로 받아 답장 내용을 만든 뒤, 같은 채팅방에 POST /chat으로 회신하는 구조입니다. 웹훅 data에 chatRoomId가 들어 있으므로 채팅방을 새로 만들 필요 없이 바로 답장할 수 있습니다. 답장 생성은 LLM에 맡기고, 영업시간 밖에는 정해 둔 안내문을 보내는 식으로 규칙과 AI를 섞어 쓰는 것이 일반적입니다.
전체 구조와 MESSAGE_RECEIVED 필드
KAT Cloud(캣클라우드) 번호로 문자가 들어오면 등록한 웹훅 URL로 MESSAGE_RECEIVED 이벤트가 POST됩니다. 내 서버는 즉시 200으로 응답하고, 응답 뒤에 영업시간을 판단해 답장 문안을 만든 다음 채팅 API로 회신합니다.

웹훅 본문의 data에서 자동응답에 쓰는 필드는 다음과 같습니다.
| 필드 | 설명 | 자동응답에서의 쓰임 |
|---|---|---|
chatId | 수신 메시지 고유 ID(32자) | 중복 처리 방지 키 |
chatRoomId | 채팅방 ID(30자) | 답장할 POST /chat의 대상 |
phoneNumber | 수신 번호(KAT Cloud 번호) | 여러 번호를 운영할 때 매장 구분 |
senderPhoneNumber | 발신자 번호(고객) | 고객 조회, 차단 목록 확인 |
content | 메시지 내용 | LLM 입력 |
subject | 제목. 없으면 생략될 수 있음 | 장문 문자일 때 참고 |
imageList | 이미지 URL(MMS) | 이미지 문의는 직원에게 넘김 |
receivedAt | 수신 시각(ISO 8601) | 영업시간 판단 기준 |
웹훅 등록(POST /webhook, eventType: MESSAGE_RECEIVED)과 답장(POST /chat)은 모두 비즈니스 요금제 그룹 또는 별도 허용된 그룹에서 쓸 수 있는 API입니다. 필드 전체는 웹훅 이벤트 문서에 있습니다.
export KAT_API_KEY="콘솔에서 발급받은 키"
curl -s -X POST "https://studio-gateway.zerocall.kr/webhook" \
-H "x-api-key: $KAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"eventType":"MESSAGE_RECEIVED","url":"https://hooks.example.com/webhook/message"}'1단계: 웹훅 받고 바로 200 응답하기
KAT Cloud 웹훅의 타임아웃은 10초입니다. LLM 호출은 몇 초씩 걸릴 수 있으므로, 답장을 만든 뒤 응답하면 안 됩니다. 요청을 받자마자 200을 돌려주고, 같은 chatId를 이미 처리했는지 확인한 뒤 나머지를 비동기로 넘깁니다.
// server.js — 수신 문자 자동응답 (Node.js 18+, Express 4)
const express = require('express');
const { businessState } = require('./hours');
const { buildReply } = require('./reply');
const BASE_URL = 'https://studio-gateway.zerocall.kr';
const API_KEY = process.env.KAT_API_KEY;
async function kat(method, path, body) {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.code !== '0') throw new Error(`${res.status} ${json.code} ${json.message}`);
return json.result;
}
const handled = new Set(); // 처리한 chatId (운영에서는 Redis·DB에 만료 시간과 함께 저장)
const app = express();
app.use(express.json());
app.post('/webhook/message', (req, res) => {
res.sendStatus(200); // 무엇보다 먼저 응답
const { eventType, data } = req.body || {};
if (eventType !== 'MESSAGE_RECEIVED' || !data?.chatId || !data.chatRoomId) return;
if (handled.has(data.chatId)) return; // 같은 수신 메시지는 한 번만
handled.add(data.chatId);
setImmediate(() => {
autoReply(data).catch((err) => console.error('[auto-reply]', data.chatId, err));
});
});
app.listen(process.env.PORT || 3000);autoReply 함수는 4단계에서 완성합니다. 여러 이벤트를 한 서버에서 받는 일반 구조는 Node.js 웹훅 수신 서버 만들기 글과 같습니다.
2단계: 영업시간 판단하기
자동응답에서 가장 먼저 정할 것은 "지금 사람이 답할 수 있는 시간인가"입니다. 서버가 어느 지역에 있든 한국 시간으로 판단해야 하므로 Intl.DateTimeFormat에 Asia/Seoul을 지정합니다. 기준 시각은 서버의 현재 시각보다 웹훅의 receivedAt이 정확합니다.
// hours.js — 한국 시간 기준 영업 상태 판단
const OPEN = { start: 9 * 60, end: 18 * 60 }; // 09:00 ~ 18:00
const LUNCH = { start: 12 * 60, end: 13 * 60 }; // 12:00 ~ 13:00
const HOLIDAYS = new Set(['2026-12-25', '2027-01-01']); // 휴무일은 직접 관리
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Seoul',
year: 'numeric', month: '2-digit', day: '2-digit',
weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
function seoulParts(date) {
const p = Object.fromEntries(fmt.formatToParts(date).map((x) => [x.type, x.value]));
return {
ymd: `${p.year}-${p.month}-${p.day}`,
weekday: p.weekday, // Mon ~ Sun
minutes: Number(p.hour) * 60 + Number(p.minute),
};
}
function businessState(date = new Date()) {
const { ymd, weekday, minutes } = seoulParts(date);
if (HOLIDAYS.has(ymd) || weekday === 'Sat' || weekday === 'Sun') return 'CLOSED_DAY';
if (minutes < OPEN.start || minutes >= OPEN.end) return 'AFTER_HOURS';
if (minutes >= LUNCH.start && minutes < LUNCH.end) return 'LUNCH';
return 'OPEN';
}
module.exports = { businessState };상태별로 답장 방식을 나누면 다음과 같습니다.
| 상태 | 판단 기준 | 답장 방식 |
|---|---|---|
OPEN | 평일 09:00~18:00, 점심시간 제외 | LLM 답장, 필요하면 직원 연결 안내 |
LUNCH | 평일 12:00~13:00 | 고정 안내문(13시 이후 답변) |
AFTER_HOURS | 평일 영업시간 외 | 고정 안내문(다음 영업일 답변) |
CLOSED_DAY | 주말과 지정 휴무일 | 고정 안내문(다음 영업일 답변) |
영업시간 밖에는 LLM을 부르지 않고 고정 안내문만 보내는 것이 안전합니다. 아무도 확인하지 않는 시간에 AI가 예약이나 가격을 약속해 버리는 일을 막을 수 있습니다. 영업시간 외 전화까지 AI로 받는 구성은 영업시간 외 전화 응대 AI 글에서 다룹니다.
3단계: 답장 문안 만들기
답장 생성은 사용하는 LLM에 맞춰 callLLM 자리에 끼워 넣습니다. 여기서는 어떤 LLM이든 시스템 지침과 대화 이력을 받아 문자열을 돌려준다고 가정합니다. 대화 이력은 GET /chat?chatRoomId=...&size=10으로 최근 메시지를 가져와 만들고, senderType이 GUEST면 고객, OPERATOR면 운영자 메시지로 봅니다.
// reply.js — 답장 문안 만들기
const TEMPLATES = {
LUNCH: '점심시간(12:00~13:00)이라 13시 이후 순서대로 답변드리겠습니다.',
AFTER_HOURS: '영업시간(평일 09:00~18:00)이 지나 다음 영업일 오전에 답변드리겠습니다.',
CLOSED_DAY: '오늘은 휴무일입니다. 다음 영업일 오전에 순서대로 답변드리겠습니다.',
};
const HANDOFF_WORDS = ['상담원', '직원', '사람이랑', '전화 주세요'];
// 사용하는 LLM API로 바꿔 끼우는 자리 (시스템 지침 + 대화 이력 → 답장 문자열)
async function callLLM({ system, messages }) {
throw new Error('LLM 연동을 구현하세요');
}
// 일반적인 문자 바이트 계산(영문·숫자 1바이트, 한글 2바이트)에 따른 근사치
function smsBytes(text) {
let n = 0;
for (const ch of text) n += ch.charCodeAt(0) <= 0x7f ? 1 : 2;
return n;
}
async function buildReply({ state, content, history }) {
if (state !== 'OPEN') return { text: TEMPLATES[state], source: 'template' };
if (HANDOFF_WORDS.some((w) => content.includes(w))) {
return { text: '담당 직원이 확인 후 이 번호로 연락드리겠습니다.', source: 'handoff' };
}
try {
const text = await Promise.race([
callLLM({
system:
'매장 문자 상담 도우미입니다. 존댓말로 두 문장 이내로 답하세요. ' +
'예약 확정, 가격 약속, 개인정보 요청은 하지 말고 직원이 확인 후 연락한다고 안내하세요.',
messages: [...history, { role: 'user', content }],
}),
new Promise((_, reject) => setTimeout(() => reject(new Error('LLM timeout')), 8000)),
]);
const clean = String(text).trim();
// 90바이트를 넘으면 LMS로 나가므로 길이를 기록해 두고 지침을 조정한다
return { text: clean.slice(0, 300), source: 'llm', bytes: smsBytes(clean) };
} catch (err) {
return { text: '문의 감사합니다. 확인 후 곧 답변드리겠습니다.', source: 'fallback' };
}
}
module.exports = { buildReply };LLM이 실패하거나 8초 안에 답하지 않으면 대체 문구를 보냅니다. 고객 입장에서는 늦은 AI 답장보다 짧은 접수 안내가 낫습니다. 답장 길이도 비용에 영향을 줍니다. KAT Cloud는 본문이 90바이트를 넘으면 LMS로 보내고, 요금표 기준 초과분 1구간 단가는 SMS 10원, LMS 32원(부가세 별도)입니다. 지침에 "두 문장 이내"를 넣고 bytes를 모니터링하면 대부분의 답장을 SMS 범위에 둘 수 있습니다.
4단계: POST /chat으로 회신하기
마지막으로 autoReply를 완성합니다. 답장은 웹훅의 chatRoomId로 POST /chat을 호출하면 됩니다. 채팅방은 채널과 고객 번호의 조합이므로 답장은 고객이 문자를 보낸 그 번호에서 나갑니다. externalMessageId에는 수신 chatId로 만든 값을 넣어 어떤 문자에 대한 답장인지 추적할 수 있게 합니다. 이 필드는 문서에 중복 방지용 외부 메시지 ID로 안내되어 있습니다.
// server.js (이어서)
const lastTemplateAt = new Map(); // chatRoomId -> 마지막 고정 안내 시각
const recentReplies = new Map(); // chatRoomId -> 최근 자동 답장 시각들
async function loadHistory(chatRoomId, currentChatId) {
const page = await kat('GET', `/chat?chatRoomId=${encodeURIComponent(chatRoomId)}&size=10`);
return (page.data || [])
.filter((m) => m.id !== currentChatId && m.content)
.sort((a, b) => a.sequence - b.sequence)
.map((m) => ({ role: m.senderType === 'GUEST' ? 'user' : 'assistant', content: m.content }));
}
function allowReply(chatRoomId, now = Date.now()) {
const windowMs = 10 * 60 * 1000;
const recent = (recentReplies.get(chatRoomId) || []).filter((t) => now - t < windowMs);
if (recent.length >= 3) return false; // 10분에 3번까지만 자동 답장
recent.push(now);
recentReplies.set(chatRoomId, recent);
return true;
}
async function autoReply(data) {
const content = (data.content || '').trim();
if (!content) return; // 이미지만 온 문자 등은 직원이 확인
const state = businessState(new Date(data.receivedAt || Date.now()));
if (state === 'OPEN') {
if (!allowReply(data.chatRoomId)) return;
} else {
const last = lastTemplateAt.get(data.chatRoomId) || 0;
if (Date.now() - last < 12 * 60 * 60 * 1000) return; // 고정 안내는 12시간에 한 번
lastTemplateAt.set(data.chatRoomId, Date.now());
}
const history = state === 'OPEN' ? await loadHistory(data.chatRoomId, data.chatId) : [];
const reply = await buildReply({ state, content, history });
const chat = await kat('POST', '/chat', {
chatRoomId: data.chatRoomId,
content: reply.text,
externalMessageId: `auto-${data.chatId}`,
});
console.log('자동 답장', data.chatRoomId, reply.source, chat.chatId, chat.chatType);
if (reply.source === 'handoff') {
// 예: 사내 메신저로 담당 직원에게 알림
}
}답장이 실제로 전달됐는지는 SEND_RESULT 웹훅이나 GET /chat 대화 조회의 resultStatus로 확인합니다. 채팅 API의 요청·응답 필드는 채팅 API로 문자 보내기와 API 문서의 채팅 항목에 정리되어 있습니다.
자동응답이 사고를 내지 않게 하는 장치
자동응답은 잘 동작할 때보다 이상하게 동작할 때 더 눈에 띕니다. 운영 전에 다음 장치를 갖춰 둡니다.
- 중복 처리 방지: 같은
chatId에는 한 번만 답합니다. 서버를 여러 대 띄우면 메모리 대신 Redis나 DB로 기록합니다. - 연속 답장 제한: 상대가 다른 자동응답 시스템이면 서로 끝없이 답장할 수 있습니다. 채팅방마다 시간당 답장 횟수에 상한을 둡니다.
- 직원 연결: "상담원", "직원" 같은 표현이나 이미지 문의, 같은 질문의 반복은 자동응답을 멈추고 직원에게 넘깁니다.
- 답장 범위 제한: 예약 확정, 환불, 가격 약속처럼 책임이 따르는 답은 LLM이 하지 않게 지침과 후처리로 막습니다.
- 광고 문구 배제: 자동 답장에 이벤트나 할인 홍보를 섞으면 광고성 정보 전송 규정의 적용을 받을 수 있습니다. 문의에 대한 답변만 보내고, 실제 적용은 법률 검토가 필요합니다.
- 기록: 어떤 답장이 템플릿, LLM, 대체 문구 중 무엇에서 나왔는지
source를 남겨 두면 품질 점검이 쉬워집니다.
통화가 끝난 뒤 안내 문자를 보내는 흐름까지 묶고 싶다면 통화 후 안내 문자 자동 발송 글을 함께 참고합니다.
자주 묻는 질문
자동응답 답장은 고객에게 어떤 번호로 나가나요?
KAT Cloud의 채팅방은 채널(내 전화번호)과 고객 전화번호의 조합이므로, MESSAGE_RECEIVED의 chatRoomId로 POST /chat을 호출하면 고객이 문자를 보낸 그 KAT Cloud 번호에서 답장이 나갑니다. 발신 번호는 POST /chat 응답의 sendPhoneNumber로 확인할 수 있습니다.
LLM 응답이 늦으면 웹훅이 실패로 처리되나요?
KAT Cloud 웹훅은 10초 안에 HTTP 2xx 응답을 받으면 성공으로 처리하므로, 요청을 받자마자 200을 돌려주고 LLM 호출은 그 뒤에 비동기로 하면 LLM이 늦어도 웹훅은 실패하지 않습니다. 고객 경험을 위해 LLM 호출에는 별도 시간 제한을 두고, 넘기면 짧은 접수 안내를 대신 보내는 것이 좋습니다.
영업시간 외에는 안내 문자를 몇 번 보내는 것이 좋나요?
영업시간 외 안내는 같은 고객에게 반복해서 보내지 않는 것이 좋습니다. 이 글의 예제는 채팅방마다 12시간에 한 번만 고정 안내문을 보내고, 그 사이에 온 문자는 다음 영업일에 직원이 확인하도록 남겨 둡니다. 간격은 업종과 문의 패턴에 맞춰 조정합니다.
- #문자 자동응답
- #MESSAGE_RECEIVED
- #LLM
- #웹훅



