통화 기록을 구글 시트에 자동 기록하기: Apps Script 웹훅
구글 Apps Script의 doPost 웹 앱으로 KAT Cloud CALL_COMPLETED 웹훅을 받아 통화 기록을 구글 시트에 한 줄씩 추가하는 방법입니다. 시트 구성, 중복 방지, 배포와 웹훅 등록, 응답 점검까지 단계별로 정리했습니다.

통화 기록을 구글 시트에 자동으로 남기려면 Google Apps Script로 doPost 함수를 가진 웹 앱을 배포하고, 그 웹 앱 URL을 KAT Cloud의 CALL_COMPLETED 웹훅 주소로 등록하면 됩니다. 통화가 끝나고 STT·요약이 완료될 때마다 KAT Cloud가 웹 앱으로 통화 기록을 POST하고, doPost가 시트에 한 줄을 추가합니다. 별도 서버 없이 시작할 수 있어 통화량이 많지 않은 팀이 통화 기록을 함께 보기에 알맞은 방법입니다.
구성과 시트 열 설계
KAT Cloud(캣클라우드)는 통화가 끝나면 STT와 요약 분석을 마친 뒤 CALL_COMPLETED 이벤트를 등록된 URL로 보냅니다. Apps Script 웹 앱은 이 요청을 doPost(e)로 받아 e.postData.contents의 JSON을 읽고, 시트 끝에 행을 추가합니다.

시트 열은 다음처럼 잡습니다. A열의 callHistoryId는 중복 확인에 쓰므로 맨 앞에 둡니다.
| 열 | 내용 | CALL_COMPLETED 필드 |
|---|---|---|
| A | 통화 기록 ID | callHistoryId |
| B | 시작 시각 | startedAt |
| C | 방향 | isInbound(true면 수신, false면 발신) |
| D | 고객 번호 | 수신이면 fromNumber, 발신이면 toNumber |
| E | 상태 | status(ANSWERED, MISSED, REJECTED, UNKNOWN) |
| F | 태그 | tag(AI, DIRECT, AI_TO_DIRECT, UNKNOWN) |
| G | 통화 시간(초) | duration |
| H | 요약 | summary |
| I | 대화 내역 | sttAI, sttDirect를 줄글로 합친 값 |
| J | 녹음 URL | recordingUrl(선택) |
| K | 기록 시각 | 시트에 쓴 시각 |
녹음 URL은 시간이 지나면 열리지 않을 수 있습니다. 통화 기록 API의 녹음 URL은 1시간 뒤 만료되므로 시트에는 참고용으로만 남기고, 파일 보관이 필요하면 별도 저장소로 내려받습니다.
1단계: Apps Script 코드 작성
기록할 구글 시트를 열고 메뉴의 확장 프로그램에서 Apps Script를 엽니다. 시트에 묶인 스크립트로 만들어야 getActiveSpreadsheet()로 그 시트를 바로 쓸 수 있습니다. 편집기의 Code.gs를 아래 코드로 바꿉니다.
// Code.gs — KAT Cloud CALL_COMPLETED → 구글 시트
const SHEET_NAME = '통화기록';
const HEADERS = ['통화 기록 ID', '시작 시각', '방향', '고객 번호', '상태', '태그',
'통화 시간(초)', '요약', '대화 내역', '녹음 URL', '기록 시각'];
const MAX_CELL = 45000; // 셀 한 칸 한도(50,000자)보다 여유 있게
function doPost(e) {
// 웹 앱 URL 끝의 ?token= 값 확인 (스크립트 속성 WEBHOOK_TOKEN과 비교)
const token = PropertiesService.getScriptProperties().getProperty('WEBHOOK_TOKEN');
if (token && (!e || !e.parameter || e.parameter.token !== token)) {
return reply_({ ok: false, reason: 'token' });
}
let body;
try {
body = JSON.parse(e.postData.contents);
} catch (err) {
return reply_({ ok: false, reason: 'invalid json' });
}
if (body.eventType !== 'CALL_COMPLETED' || !body.data || !body.data.callHistoryId) {
return reply_({ ok: true, skipped: true }); // 테스트 페이로드 등은 건너뛴다
}
const d = body.data;
const lock = LockService.getScriptLock(); // 동시에 온 요청이 같은 행에 쓰지 않게
lock.waitLock(5000);
try {
const sheet = getSheet_();
if (alreadyLogged_(sheet, d.callHistoryId)) {
return reply_({ ok: true, duplicate: true });
}
const customer = d.isInbound ? d.fromNumber : d.toNumber;
sheet.appendRow([
d.callHistoryId,
d.startedAt ? new Date(d.startedAt) : '',
d.isInbound ? '수신' : '발신',
customer ? "'" + customer : '', // 앞자리 0이 사라지지 않게 텍스트로
d.status || '',
d.tag || '',
d.duration || 0,
safeText_(d.summary || ''),
safeText_(transcript_(d)),
d.recordingUrl || '',
new Date(),
]);
} finally {
lock.releaseLock();
}
return reply_({ ok: true });
}
function getSheet_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(SHEET_NAME);
sheet.appendRow(HEADERS);
sheet.setFrozenRows(1);
}
return sheet;
}
function alreadyLogged_(sheet, id) {
return sheet.getRange('A:A').createTextFinder(id).matchEntireCell(true).findNext() !== null;
}
function transcript_(d) {
const ai = (d.sttAI || []).map((t) => `[AI 구간] ${t.role === 'agent' ? 'AI' : '고객'}: ${t.text}`);
const direct = (d.sttDirect || []).map((t) => `[직원 구간] ${t.role === 'agent' ? '직원' : '고객'}: ${t.text}`);
return ai.concat(direct).join('\n');
}
function safeText_(text) {
const s = String(text).slice(0, MAX_CELL);
return /^[=+\-@]/.test(s) ? "'" + s : s; // 수식으로 해석되지 않게
}
function reply_(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}코드에서 눈여겨볼 부분은 네 가지입니다.
- 중복 방지: A열에서 같은
callHistoryId를 찾으면 행을 추가하지 않습니다. 같은 통화의 웹훅이 다시 와도 한 행만 남습니다. - 동시 요청: 통화가 몰려 웹훅이 동시에 오면 중복 확인과 추가 사이에 끼어들 수 있으므로
LockService로 한 번에 하나씩 처리합니다. - 전화번호:
01012345678을 그대로 넣으면 시트가 숫자로 바꿔 앞자리 0이 사라집니다. 앞에 작은따옴표를 붙여 텍스트로 저장합니다. - 대화 내역:
sttAI는 AI가 응대한 구간,sttDirect는 직원이 응대한 구간입니다. 두 배열을 구간 표시와 함께 이어 붙이고, 셀 글자 수 한도를 넘지 않게 자릅니다.
2단계: 웹 앱으로 배포하고 웹훅 등록
먼저 편집기의 프로젝트 설정에서 스크립트 속성 WEBHOOK_TOKEN에 추측하기 어려운 긴 문자열을 넣습니다. Apps Script 웹 앱은 요청 헤더를 읽을 수 없어서, URL에 붙인 토큰으로 KAT Cloud가 보낸 요청인지 간단히 확인합니다. URL에 담긴 값은 로그 등에 남을 수 있으므로 강한 인증 수단이 아니라 최소한의 거름망으로 봅니다.
그다음 배포합니다.
- 1편집기 오른쪽 위의 배포에서 새 배포를 고릅니다.
- 2유형을 웹 앱으로 선택합니다.
- 3실행 사용자는 나, 액세스 권한은 모든 사용자로 둡니다. KAT Cloud가 로그인 없이 POST해야 하기 때문입니다.
- 4권한 승인을 마치고
https://script.google.com/macros/s/배포ID/exec형태의 웹 앱 URL을 복사합니다.
코드를 고친 뒤에는 배포 관리에서 기존 배포를 새 버전으로 수정해야 반영됩니다. 새 배포를 만들면 URL이 바뀌어 웹훅을 다시 등록해야 합니다.
이제 웹 앱 URL 끝에 토큰을 붙여 CALL_COMPLETED 웹훅으로 등록합니다. 웹훅 관리 API는 비즈니스 요금제 그룹 또는 별도 허용된 그룹에서 쓸 수 있습니다.
export KAT_API_KEY="콘솔에서 발급받은 키"
BASE="https://studio-gateway.zerocall.kr"
WEBAPP_URL="https://script.google.com/macros/s/배포ID/exec?token=스크립트-속성에-넣은-값"
curl -s -X POST "$BASE/webhook" \
-H "x-api-key: $KAT_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"eventType\":\"CALL_COMPLETED\",\"url\":\"$WEBAPP_URL\"}"등록 요청과 응답 형식은 API 문서의 웹훅 항목에 있습니다.
3단계: 테스트와 응답 점검
먼저 문서의 예시 페이로드를 웹 앱에 직접 보내 시트에 행이 생기는지 봅니다. Apps Script 웹 앱은 결과를 다른 주소로 리디렉션해 돌려주므로 cURL에 -L을 붙여야 응답 본문까지 확인할 수 있습니다.
curl -s -L -X POST "$WEBAPP_URL" \
-H "Content-Type: application/json" \
-d '{"eventType":"CALL_COMPLETED","timestamp":"2026-03-12T02:45:30.000Z","data":{"callHistoryId":"8f3f8fd9-7ab8-4a26-b523-4f8eb7d38b85","groupId":1,"channelId":100,"fromNumber":"01012345678","toNumber":"07012345678","isInbound":true,"status":"ANSWERED","tag":"AI_TO_DIRECT","duration":96,"summary":"예약 변경 요청을 접수하고 변경 가능한 시간대를 안내함","sttAI":[{"role":"agent","text":"안녕하세요. KAT Cloud입니다.","timestamp":1.2}],"sttDirect":[{"role":"customer","text":"직원 연결 부탁드립니다.","timestamp":30.1}],"recordingUrl":"https://storage.example.com/recording.wav","startedAt":"2026-03-12T02:40:00.000Z","completedAt":"2026-03-12T02:41:36.000Z"}}'
# {"ok":true} 같은 요청을 한 번 더 보내면 {"ok":true,"duplicate":true}다음으로 KAT Cloud 쪽에서 실제로 닿는지 POST /webhook/{webhookId}/test로 확인합니다. 결과의 statusCode와 success를 꼭 봐야 합니다.
# webhookId는 등록 응답 또는 GET /webhook의 id
curl -s -X POST "$BASE/webhook/1/test" -H "x-api-key: $KAT_API_KEY"KAT Cloud 웹훅은 HTTP 2xx 응답을 성공으로 처리합니다. Apps Script 웹 앱은 POST를 처리한 뒤 결과를 302 리디렉션으로 돌려주는 방식이라, 보내는 쪽이 리디렉션을 어떻게 다루느냐에 따라 성공으로 집계되지 않을 수 있습니다. 시트에 행이 잘 쌓이는데 테스트 결과의 statusCode가 2xx가 아니라면, 웹훅 처리 기록에 실패가 남는다는 뜻입니다. 이 경우에는 작은 중계 서버가 웹훅을 받아 200을 돌려주고 시트로 전달하게 하거나, Python(FastAPI) 웹훅 서버처럼 DB에 저장한 뒤 시트로 내보내는 구성을 권합니다. 테스트 결과를 읽는 방법은 웹훅 등록·수정·테스트 글에 자세히 있습니다.
응답 시간도 함께 봅니다. KAT Cloud 웹훅의 타임아웃은 10초이고, Apps Script는 오랜만에 호출되면 시작이 느릴 수 있습니다. doPost 안에서는 외부 API 호출 같은 무거운 작업을 하지 말고 행 추가만 합니다.
운영 팁과 한계
구글 시트 방식은 빠르게 시작할 수 있는 대신 한계도 분명합니다. 다음을 미리 알고 씁니다.
- 할당량: Apps Script에는 실행 시간과 일일 실행 횟수 같은 할당량이 있습니다. 통화가 많아지면 서버와 DB로 옮기는 것을 검토합니다.
- 시트 크기: 행이 많아질수록 A열 검색과 시트 열기가 느려집니다. 월별로 시트를 나누거나 오래된 행을 다른 파일로 옮깁니다.
- 누락 보정: 웹 앱 오류로 빠진 통화는
GET /call-history에updatedAfter를 넣어 다시 가져와 같은 방식으로 추가할 수 있습니다. 방법은 통화 기록 증분 동기화 글을 참고합니다. - 공유 범위: 시트에는 고객 번호와 대화 내역이 들어갑니다. 공유 대상을 필요한 사람으로 좁히고, 링크 공유는 끕니다.
- 수식 주입: 대화 내용이
=,+,-,@로 시작하면 시트가 수식으로 해석할 수 있어, 예제처럼 작은따옴표를 붙여 텍스트로 저장합니다.
자주 묻는 질문
Apps Script 웹 앱으로 받은 웹훅이 실패로 표시되면 어떻게 하나요?
KAT Cloud 웹훅은 HTTP 2xx 응답을 성공으로 처리하는데, Apps Script 웹 앱은 결과를 302 리디렉션으로 돌려주기 때문에 시트 기록은 되어도 실패로 집계될 수 있습니다. POST /webhook/{webhookId}/test 결과의 statusCode를 확인하고, 2xx가 아니면 200을 돌려주는 작은 중계 서버를 앞에 두는 방법을 권합니다.
같은 통화가 시트에 두 번 기록되지 않나요?
이 글의 Apps Script 코드는 행을 추가하기 전에 A열에서 같은 callHistoryId를 찾고, 있으면 추가하지 않습니다. LockService로 요청을 하나씩 처리하므로 같은 통화의 웹훅이 거의 동시에 두 번 와도 행은 하나만 남습니다.
통화가 많아져도 구글 시트로 계속 운영할 수 있나요?
구글 시트와 Apps Script는 할당량과 시트 크기 제한이 있어 통화량이 많은 서비스의 기본 저장소로는 맞지 않습니다. 하루 통화가 많아지면 웹훅을 서버에서 받아 DB에 저장하고, 시트는 요약 보고서를 내보내는 용도로만 쓰는 구성으로 옮기는 것이 좋습니다.
- #구글 시트
- #Apps Script
- #CALL_COMPLETED
- #통화 기록



