← 블로그 목록

070 번호로 ARS 만들고 휴대폰 없이 테스트하기: 전화 API·웹훅·문자 API를 한 번에

070 번호 두 개와 웹훅 서버 하나로 ARS(IVR)를 만들고, 두 번째 070이 고객 역할을 해 휴대폰 없이 버튼 입력부터 문자 발송까지 실제로 테스트했어요. TwiML 호환 VoiceML, 전화 API·문자 API 코드와 실측 콜백 순서를 단계별로 정리했어요.

안녕하세요. 한국AI통신(ClawOps)에서 전화 API를 만들고 있어요.

REST API로 전화를 걸고, 걸려 온 전화는 웹훅이 돌려준 XML대로 처리하는 방식은 Twilio 같은 CPaaS를 써 본 개발자에게 익숙해요. 클로옵스도 같은 구조예요. 070 번호를 API로 받고, 걸려 온 전화는 TwiML과 호환되는 VoiceML로 처리해요.

그런데 막상 전화 기능을 만들고 나면 테스트가 번거로워요. 휴대폰으로 직접 걸고, 안내를 끝까지 듣고, 버튼을 누르고, 로그를 다시 확인해야 하거든요. 코드를 한 줄 고칠 때마다 이걸 반복하기는 어렵죠.

그래서 이 글에서는 클로옵스 고객이 가장 많이 쓰는 구성인 "070 번호 + 웹훅으로 받는 ARS"를 처음부터 만들어 볼게요. 그리고 070 번호를 하나 더 받아서 그 번호가 고객 역할을 하게 해, 휴대폰 없이 전화가 오가는 것까지 끝까지 확인해 볼게요. 번호 발급, 웹훅 서버, 번호 연결, 테스트 발신, 결과 확인을 차례로 따라가요.

가장 많이 쓰는 구성은 웹훅 하나로 받는 ARS예요

클로옵스 고객이 전화를 쓰는 방식은 크게 넷이에요. 콘솔에서 만든 AI 에이전트에게 맡기거나, SDK로 직접 에이전트를 붙이거나, 콜 플로우로 ARS를 그리거나, 내 서버의 웹훅이 통화를 지휘하는 방식이에요. 이 중 마지막이 Twilio를 쓰던 방식과 가장 닮았고, 실제로 꾸준히 많이 쓰이는 구성이에요.

이번에 만들 고객센터 번호는 이렇게 동작해요.

  1. 전화가 오면 클로옵스가 내 서버의 /voice를 불러요.
  2. "영업시간 안내는 1번, 주문 조회 링크를 문자로 받으시려면 2번" 하고 안내해요.
  3. 1번을 누르면 영업시간을 읽어 주고 끊어요.
  4. 2번을 누르면 건 사람에게 문자로 링크를 보내요.

전화 API, 웹훅, 문자 API가 한 번에 들어가는 구성이라, 이걸 만들면 대부분의 ARS는 여기서 가지를 늘리면 돼요.

준비물은 API 키와 공개 주소 하나예요

클로옵스 콘솔에서 API 키와 계정 ID를 확인해 환경변수로 넣어 두세요. SDK는 Python 패키지 clawops를 쓰고, 웹훅 서버는 Flask로 만들어요.

pip install clawops flask
export CLAWOPS_API_KEY=sk_...
export CLAWOPS_ACCOUNT_ID=AC...

웹훅은 클로옵스 서버가 내 서버로 요청을 보내는 것이라, 내 노트북의 서버에 바깥에서 닿는 주소가 필요해요. 개발 중에는 Cloudflare 임시 터널처럼 로컬 포트를 공개 주소로 열어 주는 도구를 쓰면 편해요. ngrok을 쓰는 방법은 이 글에 정리해 두었어요.

cloudflared tunnel --url http://127.0.0.1:8765
# → https://xxxx.trycloudflare.com 같은 주소가 나와요
export BASE_URL=https://xxxx.trycloudflare.com

1단계: 070 번호 두 개를 API로 받아요

하나는 고객센터 번호, 하나는 테스트에서 고객 역할을 할 번호예요. 번호는 풀에서 자동으로 배정돼요.

from clawops import ClawOps

client = ClawOps()  # CLAWOPS_API_KEY · CLAWOPS_ACCOUNT_ID 환경변수를 읽어요

shop = client.numbers.create()      # 고객센터 번호
customer = client.numbers.create()  # 테스트용 고객 번호
print(shop.number, customer.number)

갓 받은 번호는 웹훅 주소가 비어 있어서, 그대로 두면 걸려 온 전화가 거절돼요. 3단계에서 주소를 연결할 거예요.

2단계: 전화를 받을 웹훅 서버를 만들어요

전화가 오면 클로옵스는 번호에 연결된 주소로 CallId, From, To 같은 값을 POST로 보내요. 서버는 무엇을 할지 VoiceML로 답하면 돼요. Twilio의 TwiML과 같은 형식이라 <Say>, <Gather> 같은 태그 이름도 같아요.

import os
from flask import Flask, Response, request

app = Flask(__name__)
BASE_URL = os.environ["BASE_URL"]

def xml(body):
    return Response(f'<?xml version="1.0" encoding="UTF-8"?><Response>{body}</Response>',
                    mimetype="application/xml")

@app.post("/voice")
def voice():
    return xml(f"""
  <Gather numDigits="1" timeout="5" action="{BASE_URL}/menu">
    <Say language="ko">안녕하세요, 클로 상점입니다. 영업시간 안내는 1번,
      주문 조회 링크를 문자로 받으시려면 2번을 눌러 주세요.</Say>
  </Gather>
  <Say language="ko">입력이 없어 통화를 종료합니다.</Say>""")

@app.post("/menu")
def menu():
    digits = request.form.get("Digits", "")
    if digits == "1":
        return xml('<Say language="ko">영업시간은 평일 오전 10시부터 오후 6시까지예요. 감사합니다.</Say>')
    if digits == "2":
        return xml("""
  <Message><Body>[클로 상점] 주문 조회: https://example.com/orders</Body></Message>
  <Say language="ko">문자로 링크를 보내 드렸어요. 감사합니다.</Say>""")
    return xml(f"<Redirect>{BASE_URL}/voice</Redirect>")

@app.post("/status")
def status():
    print(request.form.get("CallId"), request.form.get("CallStatus"))
    return "", 204

if __name__ == "__main__":
    app.run(port=8765)

<Message>에 to를 적지 않으면 통화 상대에게, 보내는 번호는 이 통화의 클로옵스 번호로 나가요. 그래서 2번 메뉴는 문자 API를 따로 부르지 않고 XML 한 줄로 끝나요. 문서가 끝나면 통화도 끝나서 마지막에 <Hangup/>을 두지 않아도 돼요.

왜 안내를 <Gather> 안에 넣을까요?

안에 넣으면 안내가 나오는 도중에 버튼을 눌러도 바로 입력으로 받아요. 메뉴를 이미 아는 고객은 안내를 끝까지 듣지 않고 누르거든요. 바깥에 두면 안내가 끝까지 재생된 뒤에야 입력을 기다려요. 약관 고지처럼 끊기면 안 되는 안내만 바깥에 두면 돼요.

실제 테스트에서도 그랬어요. 통화 전사를 보면 "안녕하세요, 클로 상점입니다." 바로 뒤에 영업시간 안내가 이어져요. 메뉴 안내 중간에 1번이 눌려서 나머지 안내가 잘린 거예요.

3단계: 번호에 웹훅 주소를 연결해요

client.numbers.update(
    shop.number,
    routing_type="webhook",
    webhook_url=f"{BASE_URL}/voice",
    status_callback=f"{BASE_URL}/status",
    status_callback_events="initiated ringing answered completed",
)

status_callback은 걸려 오는 통화의 상태가 바뀔 때마다 알려 주는 주소예요. 발신 통화는 걸 때마다 따로 지정하고, 수신 통화는 이렇게 번호에 미리 걸어 둬요.

4단계: 두 번째 070이 고객 역할로 전화를 걸어요

이제 테스트예요. 고객 번호가 고객센터 번호로 전화를 걸고, 받은 뒤에는 고객 번호 쪽 VoiceML이 대신 버튼을 눌러요. <Play digits>는 적힌 키를 상대에게 DTMF로 보내요.

# 웹훅 서버에 테스트 전용 경로를 하나 더 둬요
@app.post("/customer/<choice>")
def customer(choice):
    return xml(f"""
  <Pause length="3"/>
  <Play digits="{choice}"/>
  <Pause length="10"/>""")
call = client.calls.create(
    to=shop.number,
    from_=customer.number,
    url=f"{BASE_URL}/customer/2",   # 받은 뒤 3초 쉬고 2번을 눌러요
    status_callback=f"{BASE_URL}/status",
    status_callback_event="initiated ringing answered completed",
)
print(call.call_id)

이 코드가 그대로 "알림 전화" 기능이기도 해요. 주문 확인이나 예약 안내 전화를 걸 때도 to를 고객 번호로, url을 안내할 VoiceML 주소로 바꾸면 돼요.

왜 휴대폰 대신 070을 쓸까요?

사람이 받지 않아도 되니까 같은 시나리오를 몇 번이고 똑같이 돌릴 수 있어요. 버튼을 누르는 시점도 <Pause> 길이로 정해지니까 결과가 흔들리지 않아요. 코드를 고칠 때마다 1번, 2번 시나리오를 다시 걸어 보면, 손으로 하던 회귀 테스트를 그대로 대신할 수 있어요.

5단계: 콜백과 통화 기록으로 결과를 확인해요

아래는 이 글을 쓰며 실제로 2번 시나리오를 돌렸을 때 서버에 들어온 요청이에요. 시각은 첫 요청 기준이에요. 한 번의 테스트 전화에 통화 기록이 두 개 생겨요. 고객 번호가 건 발신 통화와 고객센터 번호가 받은 수신 통화예요.

시각요청통화내용
0.0초/status발신CallStatus=initiated
0.1초/status발신ringing
0.3초/voice수신고객센터 번호의 첫 요청, Direction=inbound
0.3초/status수신ringing
0.4초/status양쪽in-progress
0.5초/customer/2발신고객 역할 VoiceML 요청
3.8초/menu수신Digits=2
4.0초문자수신문자 접수, 이후 발송 결과 sent
7.8초/status발신completed
8.0초/status수신completed

버튼을 누른 지 0.2초 만에 문자가 접수됐고, 받는 휴대폰에는 이렇게 도착했어요.

[Web발신]
[클로 상점] 주문 조회: https://example.com/orders

앞의 [Web발신]은 우리가 넣은 글자가 아니에요. 웹이나 API로 보낸 문자에 휴대폰 쪽에서 붙는 표시예요. 본문 길이를 계산할 때 이 머리말은 빼고 생각하면 돼요.

콜백 말고도 SDK로 통화 결과를 다시 볼 수 있어요. 음성이 실제로 오갔는지는 통화 전사로 확인하면 확실해요.

c = client.calls.get(call.call_id)
print(c.status, c.duration)                  # completed, 통화 초
print(client.calls.get_transcript(c.call_id).status)

직접 해 보니 미리 알면 좋은 것들이 있었어요

070 번호로는 테스트 문자를 받을 수 없었어요

처음에는 2번 메뉴의 문자도 고객 역할 070 번호로 받으려고 했어요. 그런데 이 문자는 통신사에서 실패로 돌아왔어요. 번호를 새로 받은 탓은 아니었어요. 같은 고객센터 번호에서 휴대폰으로 보낸 문자는 바로 도착했거든요.

그래서 테스트에서는 <Message to="010...">처럼 받는 번호를 실제 휴대폰으로 적어 두고, 실서비스에 올릴 때 to를 지워요. 환경변수로 테스트일 때만 넣게 해 두면 실수할 일이 없어요.

SMS_TO = os.environ.get("TEST_SMS_TO")   # 실서비스에선 비워 둬요
to = f' to="{SMS_TO}"' if SMS_TO else ""
return xml(f'<Message{to}><Body>...</Body></Message>')

받은 순간은 answered가 아니라 in-progress로 와요

구독할 이벤트에는 answered를 적었지만, 콜백에 실려 오는 값은 CallStatus=in-progress예요. "받았는지"를 확인하는 코드가 answered 문자열을 기다리면 영영 오지 않아요.

콜백 순서는 바뀌어 올 수 있어요

위 표에서도 수신 통화의 첫 요청(/voice)이 ringing 콜백보다 먼저 왔어요. 다른 회차에서는 발신 통화의 ringing이 initiated보다 먼저 온 적도 있어요. 콜백은 각각 따로 보내지는 HTTP 요청이라 도착 순서가 보장되지 않아요. 상태를 저장할 때는 "마지막에 온 값"이 아니라 completed를 끝 상태로 보고, 그 뒤에 늦게 온 값으로 덮어쓰지 않게 해 두세요.

실서비스로 옮기기 전에 할 일이 세 가지 있어요

  1. 웹훅 서명을 검증하세요. 요청마다 X-Signature 헤더가 붙어 와요. SDK의 client.webhooks.verify()로 확인하는 방법은 서명 검증 문서에 있어요.
  2. 임시 터널 주소를 실제 서버 주소로 바꾸고, 번호의 webhook_url을 다시 연결하세요.
  3. 테스트용 고객 번호는 client.numbers.delete()로 반납하세요. 반납한 번호는 되돌릴 수 없어요.

자주 묻는 질문

Twilio에서 쓰던 TwiML 코드를 그대로 쓸 수 있나요?

VoiceML은 TwiML과 호환되는 형식이라 태그 구조는 거의 그대로 옮길 수 있어요. 다만 요청 파라미터 이름은 달라요. 예를 들어 통화 ID는 CallSid가 아니라 CallId로 와요. 동사별로 검증된 속성은 VoiceML 문서에 표로 정리돼 있어요.

070 번호를 API로 바로 받을 수 있나요?

네. client.numbers.create() 한 줄로 받고, 받은 번호로 바로 걸고 받을 수 있어요. 이번 테스트도 방금 받은 번호 두 개로 진행했어요.

070 번호로 문자를 보낼 수 있나요?

네. 이번 테스트에서도 새로 받은 070 번호를 보내는 번호로 해서 휴대폰에 문자가 도착했어요. 통화 중에는 <Message>로, 통화와 관계없이는 문자 API로 보내요. 자세한 방법은 문자 API 가이드에 있어요.

휴대폰 없이 전화 기능을 테스트할 수 있나요?

070 번호를 하나 더 받아 고객 역할을 맡기면 돼요. 버튼 입력은 <Play digits>로, 기다리는 시간은 <Pause>로 흉내 내요. 문자 수신만은 실제 휴대폰으로 확인해야 했어요.

상담원에게 연결하는 메뉴도 만들 수 있나요?

<Dial><Number>010...</Number></Dial>로 연결해요. 연결이 끝난 뒤의 결과는 action으로 받을 수 있어요. 자세한 방법은 통화 전환 가이드를 참고하세요.

마치며

070 번호 두 개와 웹훅 서버 하나로 ARS를 만들고, 휴대폰 없이 버튼 입력부터 문자 발송까지 끝까지 확인해 봤어요. 전화 API, 웹훅, 문자 API를 이렇게 한 번에 묶어 두면, 메뉴를 늘리거나 알림 전화를 붙이는 것도 같은 테스트로 확인할 수 있어요.

070 번호를 받고 첫 ARS를 바로 연결해 보세요. → platform.claw-ops.com

참고한 자료

  1. VoiceML 문서 — 요청 형식, Gather·Message·Play 동사
  2. Status Callback 문서
  3. 서명 검증 문서
  4. clawops Python SDK (PyPI)
이전 글

GPT-Live 1.1에 한국어 목소리가 생겼어요: API가 열리기 전에 전화 상담원 준비하기

한국AI통신(ClawOps) AI 전화 API로 시작하기

070 번호 발급부터 AI 음성 통화까지, REST API 몇 줄이면 됩니다.