Infra

[비동기] SSE heartbeat 컨텍스트 누수 OTel 트레이스 깨짐

jykim23 2026. 7. 10. 09:21
반응형

SSE heartbeat producer queue 비동기 컨텍스트 누수 OTel 트레이스

부제: 하트비트를 넣었더니 트레이스가 깨진 이야기

챗봇은 답을 스트리밍으로 흘려보낸다.
답이 길어지면 중간에 연결이 끊기지 않게, 주기적으로 빈 신호(하트비트)를 함께 보낸다.
문제는 그 하트비트를 어떻게 끼워 넣었느냐였다.

하트비트가 필요했다

원래 구현은 이랬다.
청크가 하나 올 때마다, 제너레이터의 다음 단계를 별도의 비동기 task로 띄웠다.
하트비트 타이머와 다음 청크를 동시에 기다리려고 그렇게 쪼갠 것이다.

겉보기엔 잘 돌았다.
그런데 관측 도구에서 이상한 에러가 쏟아지기 시작했다.

"다른 컨텍스트에서 만들어진 토큰"

트레이스를 남기는 라이브러리(Langfuse, 내부적으로 OpenTelemetry)가
계속 "Token was created in a different Context"라는 에러를 냈다.

원인은 컨텍스트였다.
비동기에서 각 실행 경로는 자기만의 컨텍스트(contextvars)를 따라 흐른다.
그런데 제너레이터의 단계마다 별도 task로 띄우니, 단계들이 서로 다른 컨텍스트에서 돌았다.

관측 도구는 추적 구간(span)을 한 단계에서 열고(attach) 다른 단계에서 닫는다(detach).
열 때와 닫을 때의 컨텍스트가 달라지자, 라이브러리가 그걸 오류로 잡은 것이다.

피해는 두 가지였다.
트레이스가 군데군데 누락됐고, 그 에러 로그가 잔뜩 쌓여 진짜 에러를 가렸다.
세 시간에 열두 건씩 났다.

생산은 한 곳에서 연속으로

방향을 바꿨다.
제너레이터를 잘게 쪼개 여러 task에 흩뿌리는 대신,
단일 producer task 하나가 스트림을 통째로 이어서 소비하게 했다.
async for로 처음부터 끝까지 한 흐름에서 읽으니 컨텍스트가 끊기지 않는다.

하트비트는 어떻게 넣느냐.
producer가 읽은 청크를 큐(asyncio.Queue)에 넣고,
소비자 쪽에서 큐를 기다리다가 정해진 시간 안에 청크가 안 오면 하트비트를 대신 흘린다.
생산(스트림 읽기)과 주입(하트비트)을 분리한 것이다.

이러면 추적 구간을 열고 닫는 일이 모두 같은 컨텍스트 안에서 일어난다.

덤으로 원래 있던 버그도 같이 없어졌다.
매 청크마다 다음 단계를 새 task로 띄우던 방식은,
아직 진행 중이던 데이터 읽기를 중간에 버리고 새로 시작하는 문제가 있었다.
단일 producer로 바꾸니 그 낭비도 사라졌다.

검증은 간단했다.
도구 호출이 섞인 채팅 세 건을 돌려, 컨텍스트 에러가 0건인지 봤다.
이전 세 시간에 열두 건이던 게 0건이 됐다.

정리

  • 하트비트를 위해 제너레이터를 여러 task로 쪼갠 게 비동기 컨텍스트 연속성을 깼다
  • 관측 도구의 추적 구간이 서로 다른 컨텍스트에서 열리고 닫혀 트레이스가 깨지고 로그가 오염됐다
  • 단일 producer가 스트림을 연속으로 소비하고, 하트비트는 큐 소비 쪽에서 주입하도록 분리했다
  • 컨텍스트가 한 흐름을 유지하니 에러가 사라지고, 진행 중 읽기를 버리던 원래 버그도 같이 해결됐다

비동기에서 컨텍스트는 실행 경로를 따라 흐른다.
편의를 위해 흐름을 여러 task로 쪼개면 그 실이 끊긴다.
부가 신호는 "생산은 한 곳에서 연속으로, 주입은 소비 쪽에서" 나누는 게 안전하다.

반응형