본문으로 건너뛰기

LangGraph 챗봇 프로덕션 컷오버

2026년 5월 26일, Lumie는 LangGraph 챗봇을 "배포는 됐지만 end-to-end로 증명되지 않은 상태"에서 기존 chat feature flag 뒤의 working production path로 옮겼습니다. 첫 실제 smoke test는 개별 unit test가 함께 실행하지 못했던 integration bug들을 드러냈습니다.

이 컷오버가 회고로 유용한 이유는 모든 실패가 boundary에서 발생했기 때문입니다. Backend transaction/RLS, worker tracing instrumentation, SSE message filtering, backend-to-worker HTTP versioning, CI/deployment verification이 각각 문제를 드러냈습니다.

배경

Production chat path는 browser, monolith, worker, backend callback API, database를 가로지릅니다.

browser
-> lumie-backend /v1/chat/stream
-> ChatbotClient
-> chatbot-svc LangGraph run
-> backend internal callbacks
-> backend persistence under RLS
-> SSE frames back to browser

중요한 source surface는 다음입니다.

  • lumie-backend/modules/ai/src/main/java/com/lumie/ai/adapter/out/external/ChatbotClient.java
  • lumie-backend/modules/ai/src/main/java/com/lumie/ai/application/service/ChatService.java
  • lumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.java
  • lumie-worker/services/chatbot/src/usecase.py
  • lumie-worker/services/chatbot/src/observability/tracing.py
  • lumie-worker/services/chatbot/main.py

영향

컷오버는 넓은 장애를 만들었다기보다 새 production path에 대한 확신을 늦췄습니다. 위험도는 높았습니다. Spring AI 제거 이후에는 이 경로가 유일한 chat surface가 될 예정이었기 때문입니다.

Smoke test는 "배포됨"만으로 충분하지 않다는 점을 보여줬습니다. 시스템은 다음을 증명해야 했습니다.

  • tenant-scoped conversation write가 RLS 아래에서 작동합니다.
  • tracing을 켜도 worker startup이 깨지지 않습니다.
  • frontend token stream에는 assistant text만 도달합니다.
  • backend-to-worker transport가 worker의 HTTP capability와 맞습니다.
  • 새 경로가 증명될 때까지 feature-flag fallback이 남아 있습니다.

깨진 것

컷오버 중 증상확인된 원인소스에 남은 수정
ai_conversations insert가 RLS check에서 실패write가 tenant context를 바인딩하는 transactional service boundary 안에 일관되게 있지 않았습니다ChatService@Transactional 아래에서 create/save/pending write를 소유합니다
OTEL_ENABLED=true일 때 worker crashglobal httpx instrumentation이 langchain-openai client construction과 충돌했습니다tracing은 OpenInference LangChain instrumentation과 FastAPI instrumentation을 사용하고 httpx는 건너뜁니다
첫 token에 raw tool output이 노출LangGraph stream_mode="messages"가 assistant token뿐 아니라 tool message도 포함했습니다usecase.pyAIMessageChunk content만 전달합니다
backend-to-worker request가 uvicorn에서 실패JDK HTTP client가 HTTP/2를 협상했지만 worker는 HTTP/1.1을 말합니다ChatbotClientHttpClient.Version.HTTP_1_1을 고정합니다

ChatbotClient.java의 대표 형태는 다음입니다.

HttpClient http11 = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
this.restClient = restClientBuilder
.requestFactory(new JdkClientHttpRequestFactory(http11))
.baseUrl(chatbotUrl)
.build();

진단 경로

문제를 하나의 챗봇 컴포넌트로 보지 않고 runtime path를 따라가며 진단했습니다.

계층확인결과교훈
persistence실패한 conversation insert 확인RLS가 write를 거부했습니다callback/persistence 작업도 일반 backend write와 같은 tenant-bound transaction discipline이 필요합니다
worker startuptracing을 켜고 service starthttpx instrumentation이 client construction을 깨뜨렸습니다instrumentation은 선택적으로 적용하고 실제 startup path로 검증해야 합니다
SSE stream첫 emitted frame 확인tool message가 client에 도달했습니다LangGraph stream mode는 product event가 되기 전에 filtering이 필요합니다
transportbackend client와 uvicorn capability 비교HTTP/2 negotiation이 실패했습니다내부 service boundary에서도 generated/default client가 항상 안전하지 않습니다

해결

컷오버는 다음 경계들이 정렬된 뒤 안정화됐습니다.

  • ChatService가 conversation, message, pending-action write의 transaction owner가 됐습니다.
  • RlsTenantContextAspect가 backend transactional data access의 tenant binding point로 남았습니다.
  • usecase.py가 LangGraph output을 filtering해 frontend SSE contract를 보존했습니다.
  • tracing.py가 global httpx patching을 피하고 LangChain/LangGraph path에 맞는 instrumentation을 사용했습니다.
  • ChatbotClient.java가 worker call에 HTTP/1.1을 강제했습니다.
  • application.yaml이 계속 CHATBOT_SVC_URLlumie.services.chatbot.url을 가리켰습니다.

같은 시기의 shared-cluster CI failure는 운영상 중요했지만 chatbot runtime contract가 되지는 않았습니다. 이는 steady-state chatbot design이 아니라 rollout verification 문제입니다.

검증

앞으로 chatbot cutover나 rewrite에서는 전체 chain을 검증해야 합니다.

게이트증명하는 것
browser -> backend -> worker smokepublic chat endpoint가 worker에 도달하고 frame을 streaming합니다
backend callback persistenceassistant message와 pending action이 RLS 아래에서 저장됩니다
tracing-enabled startupobservability config가 worker runtime을 깨지 않습니다
SSE frame inspectionproduct-safe assistant token만 token event에 도달합니다
HTTP-version checkbackend client가 worker가 실제로 말하는 protocol을 사용합니다
feature flag fallback새 경로가 증명될 때까지 이전 경로가 남아 있습니다

이후의 checkpointer pooling incident는 별도의 post-cutover hardening입니다. Chatbot Checkpointer Single Connection Behind PgBouncer에서 다룹니다.

잘된 점

  • Feature flag 덕분에 새 경로를 증명하는 동안 기존 Spring AI path를 유지할 수 있었습니다.
  • 실패를 runtime boundary별로 분리해, 수정 범위가 작고 소스 소유권이 분명했습니다.
  • 최종 컷오버는 operator memory가 아니라 test와 source structure에 유용한 불변조건을 남겼습니다.

아쉬웠던 점

  • 여러 boundary가 이미 바뀐 뒤에야 첫 "real" test가 실행됐습니다.
  • Tracing이 일반 worker startup 검증의 일부가 아니었습니다.
  • LangGraph message-stream semantics가 tool message를 product stream으로 흘릴 수 있다는 점을 늦게 확인했습니다.
  • 내부 HTTP default를 너무 오래 신뢰했습니다.

지속 불변조건

  • Chat persistence는 backend transaction boundary가 소유해야 하며 worker-side SQL로 흩어지면 안 됩니다.
  • Worker trace는 실제 사용하는 library와 맞는 instrumentation으로 켜야 합니다.
  • LangGraph internal event는 user-facing SSE frame과 다릅니다.
  • Backend-to-worker transport는 server capability가 알려져 있으면 HTTP version을 명시해야 합니다.

관련 후속 문서