[fix] solution/backend: 카톡 5초 벽을 콜백으로 넘는다

실사용 첫날 "시설 편의에서 바비큐 이용 문구 빼줘" 가 타임아웃으로 끝났다.

★ 작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다. 개발 중 잰 1.3~2.4초는
업종 필드 두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가
실린다. "여유가 있다" 고 적어 둔 판단이 하루 만에 깨졌다.

- userRequest.callbackUrl 이 오면 {"useCallback": true} 로 즉답하고 백그라운드에서
  답을 만든 뒤 그 주소로 POST. 콜백 주소는 1분·1회라 재시도하지 않는다 —
  두 번째 POST 는 거절되고 사장님에게는 이미 "확인하고 있어요" 가 가 있다
- 콜백이 꺼져 있으면 예전처럼 동기, 상한만 4.0 → 4.5 (카카오가 5초에 끊는다)

★ 오픈빌더 스킬 설정에서 '콜백 사용' 을 켜야 열린다. 안 켜면 callbackUrl 이 안 와서
조용히 예전 경로로만 돈다.

test_kakao_webhook.py 24 passed(콜백 3건 추가)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-22 16:33:16 +09:00
parent 1300652b09
commit f500210cd8
4 changed files with 157 additions and 26 deletions

View File

@ -227,12 +227,28 @@ URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.
**바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면 **바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면
눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다. 눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다.
## 5초 벽 ## 5초 벽 — 콜백으로 넘는다
`DEADLINE_SEC = 4.0`. 넘기면 카카오가 연결을 끊어 **말없이 실패하는 봇**이 되므로, 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 **말없이 실패하는 봇**이 된다.
안내 문구로 끊는다. 도구 선택은 실측 1.3~2.4초라 여유가 있고, 무거운 잡(BUILD)은 큐에 넣고
즉답하는 구조라 여기 걸리지 않는다. **실측(2026-09-22): 실제 프롬프트는 4초를 넘겼다.** 개발 중 잰 1.3~2.4초는 항목 두 개짜리
★ 콜백으로 나중에 미는 길은 아직 없다 — 오픈빌더 지원 여부를 콘솔에서 확인해야 한다. 장난감 프롬프트였고, 진짜는 업종 필드 43개 + fact 수십 개가 실린다. 작은 표본으로 잰 수치를
상한 근거로 삼으면 이렇게 틀린다.
→ 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다.
```
카카오 → 우리 발화 + callbackUrl
우리 → 카카오 {"version":"2.0","useCallback":true,"data":{"text":"확인하고 있어요…"}} (즉답)
… 백그라운드에서 답을 만든다 (상한 45초)
우리 → 카카오 POST callbackUrl {"version":"2.0","template":{…}} (완성분)
```
★ 콜백 주소는 **1분 · 1회**만 유효하다. 전송에 실패해도 **재시도하지 않는다** — 두 번째 POST 는
어차피 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.
★ 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC = 4.5` 로 끊는다.
무거운 잡(BUILD)은 큐에 넣고 즉답하는 구조라 어느 쪽에서도 걸리지 않는다.
★ 어떤 실패도 **HTTP 200 + 안내 문구**로 답한다. 메신저에서는 500 도 침묵으로 보인다. ★ 어떤 실패도 **HTTP 200 + 안내 문구**로 답한다. 메신저에서는 500 도 침묵으로 보인다.

View File

@ -1,5 +1,26 @@
# 개발 일지 # 개발 일지
## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**
로 끝났다. 타임아웃이었다.
**작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다.** 개발 중 잰 1.3~2.4초는 업종 필드
두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가 실린다.
"여유가 있다" 고 적어 둔 판단이 실사용 첫날에 깨졌다.
**고친 방법** — 오픈빌더 콜백(스킬 타임아웃 5초, 콜백 주소 1분·1회):
`userRequest.callbackUrl` 이 실려 오면 `{"useCallback": true}` 로 **즉답**하고, 백그라운드에서
답을 만든 뒤 그 주소로 따로 POST 한다. 콜백이 꺼져 있으면 예전처럼 동기(4.5초 상한).
★ 콜백 전송 실패는 **재시도하지 않는다** — 1회용 주소라 두 번째 POST 는 거절되고, 사장님에게는
이미 "확인하고 있어요" 가 가 있다.
★ 오픈빌더 스킬 설정에서 **콜백 사용을 켜야** 이 경로가 열린다. 안 켜면 코드가 있어도
`callbackUrl` 이 안 와서 동기 경로로만 돈다 — 조용히 예전처럼 동작한다.
**검증** — `test_kakao_webhook.py` 24 passed(콜백 3건 추가: 즉답 형식·콜백 전송·전송 실패).
## 2026-09-22 — 카톡 대화에 홈페이지 목록·가게 고르기 ## 2026-09-22 — 카톡 대화에 홈페이지 목록·가게 고르기
실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는 실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는

View File

@ -9,15 +9,21 @@
시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** 반쯤 열린 상태를 만들지 않는 것은 시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** 반쯤 열린 상태를 만들지 않는 것은
Threads 연결과 같은 규칙이다. Threads 연결과 같은 규칙이다.
5 : 오픈빌더는 스킬 서버 응답을 오래 기다리지 않는다. 넘기면 카카오가 연결을 끊고, 5 : 오픈빌더의 스킬 타임아웃은 **5**. 넘기면 카카오가 끊어 사장님에게는
사장님에게는 **말없이 실패하는 ** 된다. 무거운 (BUILD) 이미 큐에 넣고 즉답하는 **말없이 실패하는 ** 된다.
구조라 여기 걸리지 않지만, 상한은 명시해 둔다. 오픈빌더 스킬 설정에서 **콜백 사용** 켜면 요청에 `userRequest.callbackUrl` 실려 온다.
그때는 `{"useCallback": true}` **즉답**하고, 답을 만든 주소로 따로 보낸다.
콜백 주소는 **1 · 1** 유효하다.
콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC` 끊는다. 실측(2026-09-22):
필드 43 + fact 수십 개가 실린 실제 프롬프트는 4초를 넘겼다 개발 재본
1.3~2.4초는 항목 개짜리 장난감 프롬프트였다.
""" """
import asyncio import asyncio
import hmac import hmac
from fastapi import APIRouter, Header, HTTPException, Request import httpx
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request
from common.logger import LOG from common.logger import LOG
from config import agent_config as config from config import agent_config as config
@ -25,12 +31,15 @@ from services.agent import channel
router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"]) router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"])
# 도구 선택 1콜이 실측 1.3~2.4초다. 4초를 넘기면 답을 포기하고 안내 문구로 끊는다 — # 콜백이 꺼져 있을 때만 쓰는 상한. 카카오가 5초에 끊으므로 그보다 살짝 앞에서 우리가 끊는다 —
# 침묵보다 "잠시 뒤 다시" 가 낫다. # 침묵보다 "잠시 뒤 다시" 가 낫다.
DEADLINE_SEC = 4.0 DEADLINE_SEC = 4.5
# 콜백이 켜져 있을 때의 상한. 콜백 주소가 1분간 유효하므로 그 안에서 넉넉히 잡는다.
CALLBACK_DEADLINE_SEC = 45.0
_TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요." _TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요."
_ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요." _ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요."
_WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요."
def _reply(text: str, quick_replies=None) -> dict: def _reply(text: str, quick_replies=None) -> dict:
@ -62,7 +71,35 @@ def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict
raise HTTPException(404) raise HTTPException(404)
async def _handle(body: dict) -> dict: async def _answer(utterance: str, speaker: str, deadline: float) -> dict:
"""대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다."""
try:
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline)
except asyncio.TimeoutError:
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음")
return _reply(_TIMEOUT_TEXT)
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
return _reply(_ERROR_TEXT)
return _reply(answer["text"], answer.get("quick_replies"))
async def _push(callback_url: str, utterance: str, speaker: str) -> None:
"""답을 다 만든 뒤 콜백 주소로 보낸다.
주소는 1 · 1회만 유효하다. 실패해도 재시도하지 않는다 번째 POST 어차피
거절되고, 사장님에게는 이미 "확인하고 있어요" 있다."""
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.post(callback_url, json=payload)
if res.status_code >= 400:
LOG.w(f"[agent/kakao] 콜백 전송 실패: {res.status_code}")
except Exception as ex: # noqa: BLE001
LOG.w(f"[agent/kakao] 콜백 전송 실패: {type(ex).__name__}")
async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
request = body.get("userRequest") or {} request = body.get("userRequest") or {}
utterance = request.get("utterance") or "" utterance = request.get("utterance") or ""
speaker = (request.get("user") or {}).get("id") or "" speaker = (request.get("user") or {}).get("id") or ""
@ -70,32 +107,37 @@ async def _handle(body: dict) -> dict:
# 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다. # 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다.
return _reply("사용자를 확인하지 못했어요.") return _reply("사용자를 확인하지 못했어요.")
try: # ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다.
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=DEADLINE_SEC) callback_url = request.get("callbackUrl")
except asyncio.TimeoutError: if callback_url:
# ★ 콜백으로 나중에 미는 길은 아직 없다(오픈빌더 지원 여부 확인 필요, docs/AGENT.md). tasks.add_task(_push, callback_url, utterance, speaker)
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음") return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}}
return _reply(_TIMEOUT_TEXT)
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
return _reply(_ERROR_TEXT)
return _reply(answer["text"], answer.get("quick_replies")) return await _answer(utterance, speaker, DEADLINE_SEC)
@router.post("/webhook") @router.post("/webhook")
async def webhook(request: Request, x_agent_secret: str | None = Header(default=None)): async def webhook(
request: Request,
tasks: BackgroundTasks,
x_agent_secret: str | None = Header(default=None),
):
"""헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다.""" """헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다."""
body = await request.json() body = await request.json()
_authorize(None, x_agent_secret, body) _authorize(None, x_agent_secret, body)
return await _handle(body) return await _handle(body, tasks)
@router.post("/webhook/{secret}") @router.post("/webhook/{secret}")
async def webhook_with_path_secret(secret: str, request: Request, x_agent_secret: str | None = Header(default=None)): async def webhook_with_path_secret(
secret: str,
request: Request,
tasks: BackgroundTasks,
x_agent_secret: str | None = Header(default=None),
):
"""헤더를 못 넣는 경우의 대안. """헤더를 못 넣는 경우의 대안.
최후 수단이다 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 있으면 위를 쓴다.""" 최후 수단이다 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 있으면 위를 쓴다."""
body = await request.json() body = await request.json()
_authorize(secret, x_agent_secret, body) _authorize(secret, x_agent_secret, body)
return await _handle(body) return await _handle(body, tasks)

View File

@ -318,3 +318,55 @@ async def test_목록은_발행_여부를_같이_말한다(client, auth_headers,
await link(db_engine, client, auth_headers, speaker, "미발행가게") await link(db_engine, client, auth_headers, speaker, "미발행가게")
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("목록", speaker)) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("목록", speaker))
assert "아직 발행 전" in said(res) assert "아직 발행 전" in said(res)
# ── 콜백 (5초 벽 넘기) ───────────────────────────────────────────────────
async def test_콜백이_켜져_있으면_즉답하고_뒤에서_마저_만든다(client, auth_headers, db_engine, monkeypatch):
"""★ 오픈빌더 스킬 타임아웃은 5초다. 콜백을 쓰면 '잠시만 기다려 주세요' 를 먼저 주고
답이 완성되면 주소로 따로 보낸다 느린 답이 침묵이 되지 않는다."""
import router.v1.agent.kakao_bot as bot
sent = {}
async def fake_push(callback_url, utterance, speaker):
sent["url"] = callback_url
sent["payload"] = await bot._answer(utterance, speaker, 5.0)
monkeypatch.setattr(bot, "_push", fake_push)
payload = body("안녕", "콜백-발화자")
payload["userRequest"]["callbackUrl"] = "https://callback.example/one-shot"
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=payload)
# 즉답은 useCallback 이다 — template 을 싣지 않는다.
assert res.json()["useCallback"] is True
assert res.json()["data"]["text"]
assert "template" not in res.json()
# 실제 답은 콜백으로 간다.
assert sent["url"] == "https://callback.example/one-shot"
assert "연결" in sent["payload"]["template"]["outputs"][0]["simpleText"]["text"]
async def test_콜백이_없으면_예전처럼_동기로_답한다(client, db_engine):
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", "동기-발화자"))
assert "useCallback" not in res.json()
assert res.json()["template"]["outputs"]
async def test_콜백_전송이_실패해도_터지지_않는다(monkeypatch):
"""★ 주소는 1분 · 1회다. 재시도하지 않는다 — 두 번째 POST 는 어차피 거절된다."""
import router.v1.agent.kakao_bot as bot
monkeypatch.setattr(bot, "_answer", AsyncMock(return_value=bot._reply("")))
class Boom:
async def __aenter__(self):
raise RuntimeError("네트워크 끊김")
async def __aexit__(self, *_):
return False
monkeypatch.setattr(bot.httpx, "AsyncClient", lambda **_kw: Boom())
await bot._push("https://callback.example/x", "안녕", "누구") # 예외가 새 나오지 않는다