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.javalumie-backend/modules/ai/src/main/java/com/lumie/ai/application/service/ChatService.javalumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.javalumie-worker/services/chatbot/src/usecase.pylumie-worker/services/chatbot/src/observability/tracing.pylumie-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 crash | global 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.py가 AIMessageChunk content만 전달합니다 |
| backend-to-worker request가 uvicorn에서 실패 | JDK HTTP client가 HTTP/2를 협상했지만 worker는 HTTP/1.1을 말합니다 | ChatbotClient가 HttpClient.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 startup | tracing을 켜고 service start | httpx instrumentation이 client construction을 깨뜨렸습니다 | instrumentation은 선택적으로 적용하고 실제 startup path로 검증해야 합니다 |
| SSE stream | 첫 emitted frame 확인 | tool message가 client에 도달했습니다 | LangGraph stream mode는 product event가 되기 전에 filtering이 필요합니다 |
| transport | backend 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가 globalhttpxpatching을 피하고 LangChain/LangGraph path에 맞는 instrumentation을 사용했습니다.ChatbotClient.java가 worker call에 HTTP/1.1을 강제했습니다.application.yaml이 계속CHATBOT_SVC_URL의lumie.services.chatbot.url을 가리켰습니다.
같은 시기의 shared-cluster CI failure는 운영상 중요했지만 chatbot runtime contract가 되지는 않았습니다. 이는 steady-state chatbot design이 아니라 rollout verification 문제입니다.
검증
앞으로 chatbot cutover나 rewrite에서는 전체 chain을 검증해야 합니다.
| 게이트 | 증명하는 것 |
|---|---|
| browser -> backend -> worker smoke | public chat endpoint가 worker에 도달하고 frame을 streaming합니다 |
| backend callback persistence | assistant message와 pending action이 RLS 아래에서 저장됩니다 |
| tracing-enabled startup | observability config가 worker runtime을 깨지 않습니다 |
| SSE frame inspection | product-safe assistant token만 token event에 도달합니다 |
| HTTP-version check | backend 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을 명시해야 합니다.