AI agent 서비스를 개발하며 겪은 문제들을 예제 프로젝트로 재구성해 정리하는 시리즈이다. 예제 코드는 parcel-bot repo 에 있고 docker compose 로 전부 재현할 수 있다.
지난 글에서 ASGI와 LangGraph으로 구현한 스트리밍 구성을 확인했다. 운영 환경에서는 스트리밍의 중단 상황을 고려해야 한다. 스트리밍이 중단되면 무슨 일이 일어나는가. LLM 호출이 실패했을 때, 응답이 오래 걸릴 때, 사용자가 화면을 떠났을 때 각각 클라이언트에는 무엇이 보이고 서버는 무엇을 해야 하는가. 이번 글에서는 운영에서 실제로 겪는 중단 상황들을 빈도 순으로 정리하고 각각의 처리를 구현해보겠다.
스트리밍 중단 기준
본문으로 들어가기 전에 판단 기준을 하나 세우려한다. 스트리밍 중단 상황의 처리 방법은 발생 원인이 아닌 "클라이언트가 서버로부터 받는 신호"로 결정된다. 기준 신호는 세 가지로 나뉜다.
- 서버가 만들어 보내는 신호: 서버 프로세스가 살아 있으면 에러를 SSE 이벤트로 직접 알릴 수 있다. 가장 좋은 상황이다.
- TCP 가 흘리는 신호: 서버가 말할 수 없어도 연결이 닫히면 클라이언트는 종료를 감지한다. 다만 중단 이유는 알 수 없다.
- 신호 없음: 소켓은 열려 있는데 데이터만 멈춘다. 클라이언트의 타이머만이 감지 수단이다.
이 기준을 기억하면서 하나씩 확인해 보겠다.
케이스 1: 스트리밍 응답 중 예외
운영에서 스트리밍이 중단되는 원인의 대부분은 인프라가 아니라 응답 생성 과정의 예외다. LLM API 호출이 네트워크 문제나 과부하 (overloaded, 5xx) 로 실패하는 경우, LLM 이 명시적 에러 (rate limit, context 길이 초과, content filter) 를 반환하는 경우, 그리고 서버 코드 자체의 버그나 DB 오류까지 전부 여기 속한다.
원인은 다양하지만 공통점이 있다. 클라이언트와의 연결은 멀쩡하다는 것이다. 죽은 것은 LLM 쪽이거나 서버 안의 한 함수이지 연결이 아니다. 그래서 처리도 하나로 수렴한다. 예외를 잡아 SSE 이벤트로 변환해 내려보내는 것이다. 이 글의 중단 상황들 가운데 서버가 클라이언트에게 상황을 직접 설명할 수 있는 유일한 경우다.
예제에서는 mock LLM 에 실패를 주입해 재현한다. 메시지에 "오류" 가 들어 있으면 네 번째 토큰에서 예외를 던진다.
# parcel_bot/views.py (발췌)
async def _generate(message: str, queue: asyncio.Queue) -> None:
text = ""
try:
async for token in _mock_llm(message):
text += token
await queue.put(("token", token))
await queue.put(("done", None))
except RuntimeError as e:
await queue.put(("error", str(e)))
전송 쪽은 큐에서 error 를 받으면 SSE 의 event 필드를 붙여 내려보낸다.
elif kind == "error":
payload = json.dumps({"message": value}, ensure_ascii=False)
yield f"event: error\ndata: {payload}\n\n"
실행해보면 스트리밍되던 토큰이 멈추고 error 이벤트가 도착한다.
data: {"token": "문의 "}
event: error
data: {"message": "upstream LLM 스트림이 중단되었습니다"}
클라이언트는 event 필드로 분기해서 에러를 표시하면 된다. 여기서 설계시 결정할 사항이 두 가지 있다.
첫째, 정상 종료 신호를 명시해야 한다. 예제는 완료 시 data: [DONE] 을 보낸다. 이 신호가 있어야 클라이언트가 "완료된 스트림"과 "중간에 끊긴 스트림"을 구분할 수 있다. 에러 처리의 절반은 정상 신호 설계다.
둘째, 에러의 세분화다. 예제는 한 종류지만 실서비스에서는 재시도할 수 있는 에러 (overloaded, 순간적 네트워크 실패) 와 재시도가 무의미한 에러 (context 길이 초과, content filter) 를 구분해 이벤트에 담아야 한다. 클라이언트는 이벤트에 담긴 구분에 따라 알맞게 처리한다. 재시도 가능한 에러면 재시도 버튼을 보여주고, 재시도가 무의미한 에러면 안내 문구와 함께 종료하는 식이다. 스트리밍 중간에 실패한 경우의 재시도는 이미 보낸 토큰과의 중복 문제가 있어서 처음부터 재생성할지 이어붙일지도 정해야 한다.
케이스 2: 스트리밍 timeout
두 번째로 흔한 부류는 timeout 이다. 스트리밍에서 timeout 문제는 대부분 "첫 토큰이 나오기 전"에 터진다. LLM 이 긴 입력을 처리하거나 tool 을 호출하는 동안 토큰이 나가지 않는 구간이 생기는데, 이 무토큰 구간을 여러 계층이 각자 의심하기 시작한다. 클라이언트의 timeout 로직은 연결이 끊겼다고 오탐하고 중간의 프록시나 로드밸런서는 idle 연결로 판정해 끊어버린다.
예제로 재현해보겠다. 메시지에 "느림" 이 들어 있으면 mock 이 첫 토큰 전에 10초 침묵한다.
if "느림" in message:
await asyncio.sleep(SLOW_FIRST_TOKEN_S) # 10초. LLM 의 thinking·tool 호출 구간
예제의 클라이언트에는 3초간 수신이 없으면 단절로 판정하는 watchdog 이 있다 (watchdog은 다음 케이스에서 자세히 다룬다). 서버는 멀쩡히 생성 중인데 클라이언트는 3초 시점에 "연결이 끊긴 것으로 보입니다"를 표시하는 오탐이 발생한다.
예제에서는 keepalive를 추가하여 해결했다. 서버가 무토큰 구간에 주기적으로 신호를 흘려서 데이터는 없지만 연결은 살아 있다는 것을 모든 계층에 알린다. SSE 에는 이를 위한 문법이 있다. 콜론으로 시작하는 줄은 comment 로 정의되어 있어서 클라이언트 파서가 데이터로 처리하지 않는다.
while True:
try:
kind, value = await asyncio.wait_for(queue.get(), timeout=KEEPALIVE_INTERVAL_S)
except TimeoutError:
# 무토큰 구간: 클라이언트 watchdog 과 프록시의 idle 판정을 리셋한다.
yield ": keepalive\n\n"
continue
큐를 1초까지만 기다리고, 토큰이 없으면 keepalive 를 내보낸 뒤 다시 기다린다. asyncio.wait_for 로 "기다리되 너무 오래 기다리지는 않는" 구조가 된다. 실측 결과는 다음과 같다. 10초 침묵 동안 keepalive 가 1초 간격으로 흐르고 클라이언트 watchdog 은 매번 리셋되어 오탐 없이 첫 토큰을 받는다.
22.671 : keepalive
23.672 : keepalive
...
31.684 : keepalive
31.971 data: {"token": "'느림 "}
32.272 data: {"token": "재현' "}
keepalive 를 넣고 나면 timeout 계층들의 정렬 관계가 명확해진다. keepalive 간격은 가장 짧은 idle 판정 (클라이언트 watchdog, 프록시 idle timeout) 보다 짧아야 한다. 그 위에 "생성 전체가 너무 오래 걸리면 포기한다"는 총 시간 상한을 별도로 두면 timeout 설계가 완성된다. 무수신 감지와 총 시간 상한은 다른 문제라서 타이머도 따로 가져가야 한다.
케이스 3: 클라이언트 측 단절
사용자가 답변 도중 탭을 닫거나 다른 화면으로 이동하는 것은 오류가 아니라 일상이다. 이 케이스의 특이점은 처리 주체가 클라이언트가 아니라 서버라는 것이다. 클라이언트는 자기가 끊었으니 처리할 것이 없고 서버가 disconnect 를 감지해서 뒷정리를 해야 한다.
ASGI 에서 disconnect 는 응답 generator 에 취소로 전달된다. 예제에서는 이렇게 잡는다.
except (asyncio.CancelledError, GeneratorExit):
print(f"client disconnected, streaming stopped (generation continues): ...")
raise
문제는 그다음이다. 진행 중이던 LLM 생성을 어떻게 할 것인가. 정책이 두 가지 있다.
취소 정책은 연결이 끊기면 생성도 중단한다. 비용이 절약되고 구현이 단순하다. 대신 사용자가 실수로 탭을 닫았다 돌아오면 답변이 반토막 나 있다.
완주 정책은 전송만 멈추고 생성은 끝까지 진행해 결과를 대화 이력에 저장한다. 사용자가 돌아오면 온전한 답이 있다.
예제에서는 완주 쪽으로 결정해서 구현했다. 그 이유는 서버가 의도적인 종료와 순단 (순간적인 통신 단절) 을 구분하지 못하기 때문이다. 사용자가 탭을 닫은 것도, 이동 중에 네트워크가 잠깐 끊긴 것도 서버에는 똑같은 disconnect 다. 후자의 사용자는 여전히 답을 기다리고 있으므로, disconnect 즉시 취소하는 정책은 순단 사용자의 답변을 날린다. 사용자가 명시적으로 중단 의사를 밝히는 stop 버튼은 disconnect 가 아니라 별도 요청으로 받는 것이 좋은 이유이기도 하다.
완주 정책의 구현은 구조 변경이 필요하다. 생성이 요청 처리 안에서 일어나면 연결 취소가 생성까지 취소시키므로, 생성을 요청 수명주기에서 분리해야 한다. 예제에서는 asyncio.create_task 로 생성을 독립 task 로 띄우고 토큰을 asyncio.Queue 로 전송에 넘긴다.
async def chat(request: HttpRequest) -> StreamingHttpResponse:
message = _message(request)
queue: asyncio.Queue = asyncio.Queue()
task = asyncio.create_task(_generate(message, queue))
_generation_tasks.add(task) # GC 방지용 강한 참조
task.add_done_callback(_generation_tasks.discard)
disconnect 가 나면 큐를 읽던 전송 generator 만 취소되고 독립 task 인 생성은 계속 돌아 완료 시점에 결과를 남긴다 (예제는 로그, 실서비스라면 대화 이력 저장). 실측으로 확인하면, curl 을 1.5초에 강제 종료했는데 서버 로그에 두 줄이 남는다.
client disconnected, streaming stopped (generation continues): message='완주 테스트'
generation finished: message='완주 테스트' reply="'완주 테스트' 문의 확인했습니다. 배송 접수를 도와드릴게요. ..."
전송 중단과 생성 완료가 분리된 사건이 된 것이다. 여기서 task를 _generation_tasks 집합에 저장했다가 완료하면 제거한다. event loop 는 task 를 약한 참조로만 잡기 때문에 참조를 유지하지 않으면 실행 중인 task 가 GC 로 사라질 수 있다. 어쩌다 한 번 생성이 소리 없이 증발하는 종류의 버그라서, create_task 를 쓸 때는 파이썬 공식 문서의 이 두 줄 레시피를 그대로 따르는 것이 안전하다.
케이스 4: 인프라성 단절
마지막은 연결 자체가 죽는 경우다. 발생 빈도는 앞의 케이스들보다 낮지만 신호 축의 나머지 두 층이 여기서 갈린다.
신호가 있는 단절부터, 배포로 서버가 재시작되거나 프로세스가 crash 하면, 프로세스는 죽어도 OS 커널이 남아서 열려 있던 소켓을 정리하며 클라이언트에 연결 종료를 보낸다. 예제에서는 스트리밍 도중 docker stop 으로 재현한다. 클라이언트 처리의 핵심은 케이스 1 에서 만든 정상 종료 신호다. [DONE] 을 받지 못하고 스트림이 닫혔다면 비정상 단절이므로, 재연결이나 에러 안내로 분기한다.
신호가 없는 단절도 있다. 정리해 줄 커널조차 없는 경우다. 서버 머신의 전원이 나가거나, 네트워크 경로가 끊기거나, NAT 테이블이 만료되면 누구도 클라이언트에게 종료를 알리지 못한다. 소켓은 열려 있고 데이터만 영원히 오지 않는다. 서버 인프라 기준으로는 드문 일이지만 관점을 바꾸면 흔하다. 모바일 사용자의 이동 중 순단이 정확히 이 형태다. 예제에서는 스트리밍 도중 docker pause 로 재현한다. 프로세스가 동결되어 소켓은 열린 채 침묵하는, 실제 조용한 단절과 같은 상태가 된다. 참고로 이 재현에 브라우저 개발자 도구의 offline 모드를 쓰면 안 된다. offline 에뮬레이션은 새 요청을 막을 뿐 이미 열린 로컬 연결까지 끊지는 못해서, 끊었다고 가정한 테스트가 거짓이 된다.
조용한 단절의 유일한 감지 수단이 클라이언트 타이머다. 예제의 watchdog 은 수신할 때마다 리셋되는 3초 타이머로, 발동하면 단절로 판정하고 요청을 중단한다.
const resetWatchdog = () => {
clearTimeout(watchdog);
watchdog = setTimeout(() => {
setStatus("응답이 3초간 멈췄습니다. 연결이 끊긴 것으로 보입니다.", true);
controller.abort();
}, WATCHDOG_MS);
};
케이스 2 의 keepalive 가 여기서 다시 등장한다. keepalive 는 "생성이 느린 것"을 "단절"로 오탐하지 않게 하는 장치이고, watchdog 은 진짜 단절을 잡는 장치다. 둘은 쌍으로 설계해야 한다. keepalive 간격보다 watchdog 이 길어야 오탐이 없고, watchdog 이 있어야 조용한 단절에서 사용자가 영원히 기다리지 않는다.
정리: 발생 주체와 처리 주체
| 중단 | 신호를 만드는 쪽 | 감지·처리 주체 |
|---|---|---|
| 생성 중 예외 (LLM·서버 오류) | 서버 (event: error) |
서버가 변환, 클라이언트는 표시와 재시도 분기 |
| 늦은 첫 토큰 | 없음 (오탐 위험만 있음) | 서버의 keepalive 가 오탐을 예방 |
| 클라이언트 측 단절 | 클라이언트가 원인 | 서버 (disconnect 감지, 취소 또는 완주 정책) |
| 서버 종료·배포 | TCP (연결 닫힘) | 클라이언트 ([DONE] 유무로 완료·단절 구분) |
| 조용한 단절 | 없음 | 클라이언트 (watchdog), 서버 keepalive 가 보조 |
FE 관점에서는 결국 세 가지만 구분하면 된다. error 이벤트를 받았는가, [DONE] 없이 닫혔는가, 아무것도 안 온 지 N 초가 지났는가. 예제 페이지의 분기가 정확히 이 셋이다. BE 관점에서는 두 가지다. 생성 중 예외를 이벤트로 변환하는 것, disconnect 에 대한 정책 (취소 vs 완주) 을 정하고 keepalive 로 오탐을 막는 것.
직접 재현하기
git clone https://github.com/rmk1075/parcel-bot && cd parcel-bot
docker compose up -d --build
# 브라우저에서 http://localhost:8001/ 접속 후:
# "오류" 포함 메시지 → error 이벤트
# "느림" 포함 메시지 → keepalive 로 watchdog 오탐 방지
# 스트리밍 중 docker stop parcel-bot-asgi → 연결 닫힘 감지
# 스트리밍 중 docker pause parcel-bot-asgi → watchdog 발동
# 전송 후 탭 닫기 → docker logs 에서 완주 확인
취소 정책과 완주 정책의 구현 차이는 repo 의 커밋 이력에서 볼 수 있다. 취소 정책 커밋과 완주 전환 커밋의 diff 가 곧 "생성과 전송의 분리"다.
'AI' 카테고리의 다른 글
| [AI Agent] ASGI 에서 LangGraph 스트림 버퍼링 문제 (iterator 와 서버의 조합) (0) | 2026.08.10 |
|---|---|
| [AI Agent] WSGI 에서 Django SSE 스트리밍 버퍼링 문제 (0) | 2026.08.09 |