From f500210cd8c8aa0f696422755e2289f6999a7c19 Mon Sep 17 00:00:00 2001 From: hbyang Date: Tue, 22 Sep 2026 16:33:16 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution/backend:=20=EC=B9=B4=ED=86=A1?= =?UTF-8?q?=205=EC=B4=88=20=EB=B2=BD=EC=9D=84=20=EC=BD=9C=EB=B0=B1?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=84=98=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실사용 첫날 "시설 편의에서 바비큐 이용 문구 빼줘" 가 타임아웃으로 끝났다. ★ 작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다. 개발 중 잰 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) --- docs/AGENT.md | 26 ++++-- docs/DEVLOG.md | 21 +++++ solution/backend/router/v1/agent/kakao_bot.py | 84 ++++++++++++++----- solution/backend/tests/test_kakao_webhook.py | 52 ++++++++++++ 4 files changed, 157 insertions(+), 26 deletions(-) diff --git a/docs/AGENT.md b/docs/AGENT.md index 0ba682f..754585c 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -227,12 +227,28 @@ URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest. ★ **바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면 눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다. -## 5초 벽 +## 5초 벽 — 콜백으로 넘는다 -`DEADLINE_SEC = 4.0`. 넘기면 카카오가 연결을 끊어 **말없이 실패하는 봇**이 되므로, -안내 문구로 끊는다. 도구 선택은 실측 1.3~2.4초라 여유가 있고, 무거운 잡(BUILD)은 큐에 넣고 -즉답하는 구조라 여기 걸리지 않는다. -★ 콜백으로 나중에 미는 길은 아직 없다 — 오픈빌더 지원 여부를 콘솔에서 확인해야 한다. +오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 **말없이 실패하는 봇**이 된다. + +★ **실측(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 도 침묵으로 보인다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 620fd2e..65ea3f5 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 — 카톡 대화에 홈페이지 목록·가게 고르기 실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는 diff --git a/solution/backend/router/v1/agent/kakao_bot.py b/solution/backend/router/v1/agent/kakao_bot.py index 58d1095..371d177 100644 --- a/solution/backend/router/v1/agent/kakao_bot.py +++ b/solution/backend/router/v1/agent/kakao_bot.py @@ -9,15 +9,21 @@ 시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** — 반쯤 열린 상태를 만들지 않는 것은 Threads 연결과 같은 규칙이다. -★ 5초 벽: 오픈빌더는 스킬 서버 응답을 오래 기다리지 않는다. 넘기면 카카오가 연결을 끊고, - 사장님에게는 **말없이 실패하는 봇**이 된다. 무거운 잡(BUILD)은 이미 큐에 넣고 즉답하는 - 구조라 여기 걸리지 않지만, 상한은 명시해 둔다. +★ 5초 벽: 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 사장님에게는 + **말없이 실패하는 봇**이 된다. + → 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다. + 그때는 `{"useCallback": true}` 로 **즉답**하고, 답을 다 만든 뒤 그 주소로 따로 보낸다. + 콜백 주소는 **1분 · 1회**만 유효하다. + → 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC` 로 끊는다. 실측(2026-09-22): + 필드 43개 + fact 수십 개가 실린 실제 프롬프트는 4초를 넘겼다 — 개발 중 재본 + 1.3~2.4초는 항목 두 개짜리 장난감 프롬프트였다. """ import asyncio 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 config import agent_config as config @@ -25,12 +31,15 @@ from services.agent import channel 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 = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요." _ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요." +_WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요." 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) -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 {} utterance = request.get("utterance") or "" speaker = (request.get("user") or {}).get("id") or "" @@ -70,32 +107,37 @@ async def _handle(body: dict) -> dict: # 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다. return _reply("사용자를 확인하지 못했어요.") - try: - answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=DEADLINE_SEC) - except asyncio.TimeoutError: - # ★ 콜백으로 나중에 미는 길은 아직 없다(오픈빌더 지원 여부 확인 필요, docs/AGENT.md). - 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) + # ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다. + callback_url = request.get("callbackUrl") + if callback_url: + tasks.add_task(_push, callback_url, utterance, speaker) + return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}} - return _reply(answer["text"], answer.get("quick_replies")) + return await _answer(utterance, speaker, DEADLINE_SEC) @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() _authorize(None, x_agent_secret, body) - return await _handle(body) + return await _handle(body, tasks) @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() _authorize(secret, x_agent_secret, body) - return await _handle(body) + return await _handle(body, tasks) diff --git a/solution/backend/tests/test_kakao_webhook.py b/solution/backend/tests/test_kakao_webhook.py index 3ea4aae..80a8676 100644 --- a/solution/backend/tests/test_kakao_webhook.py +++ b/solution/backend/tests/test_kakao_webhook.py @@ -318,3 +318,55 @@ async def test_목록은_발행_여부를_같이_말한다(client, auth_headers, await link(db_engine, client, auth_headers, speaker, "미발행가게") res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("목록", speaker)) 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", "안녕", "누구") # 예외가 새 나오지 않는다