LangGraph의 세 가지 재작성: 프로덕션 에이전트를 위한 체크포인팅 (Checkpointing)
요약
LangGraph를 이용한 프로덕션 에이전트 구축 시 발생하는 상태 관리 오류와 체크포인팅 문제를 다룹니다. thread_id 누락으로 인한 상태 리셋 문제와 SQLite 사용 시 발생하는 동시성 이슈 및 데이터 손상 해결 과정을 설명합니다.
핵심 포인트
- thread_id와 config 객체 없이 invoke 호출 시 상태가 초기화됨
- 상태 유지를 위해 고유한 thread_id를 명시적으로 관리해야 함
- SQLite는 동시 부하 상황에서 데이터베이스 잠금 및 파일 손상 위험이 있음
- 프로덕션 환경에서는 안정적인 체크포인팅 메커니즘 구축이 필수적임
AIdeazz에 처음 게시되었습니다 — 정식 링크(canonical link)와 함께 이곳에 교차 게시되었습니다.
인바운드 지원 요청을 처리하도록 설계된 우리의 첫 번째 LangGraph 에이전트는 2주 동안 작업의 80%를 소리 없이 누락시켰습니다. job_id와 customer_query를 전달하도록 의도된 state 객체가 매번 새로운 그래프 실행이 시작될 때 빈 딕셔너리로 덮어씌워지고 있었습니다. 우리는 고객이 티켓이 누락되었다고 불평할 때서야 이 문제를 발견했습니다. 근본 원인은 graph.invoke()에 전달된 초기 상태와 첫 번째 노드에서 기대하는 상태 사이의 스키마 불일치(schema mismatch)였습니다. LangGraph는 에러를 발생시키지 않았고, 그저 상태를 리셋했을 뿐입니다. 이것은 LangGraph의 버그가 아니라, 프로덕션 환경에서의 상태 관리(state management)에 대한 근본적인 오해였습니다.
소리 없는 상태 리셋: 5,000달러짜리 교훈
우리의 초기 에이전트는 간단했습니다: 쿼리를 수신하고, 이를 분류한 다음, 지식 베이스(knowledge base) 조회 또는 상담원에게 라우팅하는 방식이었습니다. state는 query: str과 job_id: str을 가진 TypedDict로 정의되었습니다. Telegram을 통해 새로운 메시지가 도착하면, 우리는 이 상태를 생성하고 graph.invoke(initial_state)를 호출했습니다.
문제는 retry 메커니즘을 도입했을 때 발생했습니다. 만약 LLM 호출이 실패하면, 우리는 그래프 전체가 아니라 현재 단계를 다시 실행하고 싶었습니다. 우리는 실패한 실행의 state를 다시 graph.invoke()에 전달함으로써 이를 구현하려고 시도했습니다. 바로 이 지점에서 소리 없는 리셋이 발생했습니다.
LangGraph의 invoke 메서드는 thread_id와 체크포인트(checkpoint)를 지정하는 config 객체 없이 호출될 경우, 각 호출을 새로운 실행으로 취급합니다. 만약 state 객체를 전달하면, 그것을 새로운 실행을 위한 초기 상태로 사용합니다. 그것은 당신이 멈춘 지점에서 마법처럼
해결책은 thread_id를 명시적으로 관리하고 config 객체와 함께 graph.stream()을 사용하는 것이었습니다. 이제 각 인바운드 메시지는 고유한 thread_id를 생성합니다. 해당 메시지에 대한 후속 작업(예: 재시도, 후속 질문)은 동일한 thread_id를 재사용합니다. 이를 통해 LangGraph의 내부 상태 관리(state management) 및 체크포인팅(checkpointing) 메커니즘이 활성화되도록 보장했습니다. thread_id가 없으면 프로덕션 체크포인팅을 위한 LangGraph의 상태 유지(stateful) 기능은 사실상 비활성화됩니다.
체크포인트 손상: SQLite의 골칫거리
조용한 상태 초기화 문제를 해결한 후, 우리는 영구적인 체크포인팅(persistent checkpointing)으로 넘어갔습니다. 단순함을 고려하여 우리의 첫 번째 선택은 SQLite였습니다. 우리는 Oracle Cloud Infrastructure (OCI) Ampere A1 인스턴스에서 에이전트를 실행했으며, 각 에이전트는 Docker 컨테이너에서 구동되었습니다. SQLite는 오버헤드가 적은 솔루션처럼 보였습니다.
하지만 그렇지 않았습니다. 동시 부하(약 5~10개의 동시 활성 스레드) 상황에서 sqlite3.OperationalError: database is locked 에러가 발생하기 시작했습니다. 이러한 에러는 종종 체크포인트 파일을 손상시키는 결과로 이어졌습니다. 체크포인트가 손상되면 전체 스레드의 상태가 유실되었으며, 이는 수동 재시작을 강제하거나 더 나쁜 경우 고객의 요청을 완전히 다시 처리해야 하는 상황을 초래했습니다.
문제는 SQLite의 파일 기반 잠금(locking) 메커니즘이었습니다. SQLite는 동시 읽기를 지원하지만, 쓰기 작업은 직렬화(serialized)됩니다. 우리의 에이전트, 특히 여러 번의 LLM 호출과 외부 API 상호작용을 포함하는 에이전트들은 여러 노드가 짧은 간격으로 상태(및 체크포인트)를 업데이트하려고 시도할 수 있습니다. 이러한 경합(contention)은 빠르게 SQLite를 압도했습니다.
우리는 timeout 파라미터를 늘려보기도 했지만, 이는 문제를 임시로 가릴 뿐 더 긴 대기 시간과 결국 실패로 이어졌습니다. 해결책은 프로덕션 체크포인팅 (Checkpointing)을 위해 SQLite를 포기하는 것이었습니다. 우리는 OCI의 관리형 데이터베이스 서비스에서 호스팅되는 PostgreSQL로 전환했습니다. PostgreSQL은 동시 쓰기 (concurrent writes)를 유연하게 처리하며 강력한 트랜잭션 관리 (transaction management)를 제공합니다. 스키마 조정과 기존 스레드 (threads)에 대한 데이터 마이그레이션을 포함하여 마이그레이션에는 이틀이 소요되었습니다. 비용은 $0 (SQLite)에서 약 $50/월 (OCI PostgreSQL Flexible Server, 가장 작은 인스턴스)로 증가했지만, 안정성 향상은 결정적이었습니다.
안정적인 다단계 파이프라인을 위한 단 하나의 패턴
LangGraph 상태 유지 에이전트 (stateful agents)의 프로덕션 체크포인팅을 위해 우리가 찾아낸 가장 안정적인 패턴은 세 가지 핵심 구성 요소를 포함합니다:
-
상태를 위한 엄격한
TypedDict:State를 명확하고 불변하는(immutable) 키를 가진TypedDict로 정의하세요. 구조가 변경될 수 있는 동적 키나 중첩된 딕셔너리 (nested dictionaries)를 피해야 합니다. 단계(steps)를 거쳐 지속되어야 하는 모든 정보는 반드시 이TypedDict의 일부여야 합니다. 예시:from typing import TypedDict, List, Dict, Any class AgentState(TypedDict):
...
```
이러한 엄격한 스키마 (schema)는 우리의 초기 문제 원인이었던 실수로 인한 상태 덮어쓰기나 키 누락을 방지합니다.
2. 명시적인 thread_id 및 체크포인트 관리: 모든 상호작용은 고유한 thread_id로 시작됩니다. 이 thread_id는 config 객체를 통해 graph.stream() 또는 graph.invoke()로 전달됩니다.
```
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.graph import StateGraph, END
...
```
이를 통해 LangGraph가 각 특정 대화 스레드 (conversation thread)에 대한 상태를 정확하게 로드하고 저장할 수 있습니다. `config`에 `thread_id`가 없으면 체크포인팅은 사실상 우회됩니다.
3. 상태 업데이트를 포함한 원자적 노드 작업 (Atomic Node Operations): 각 노드는 단일하고 잘 정의된 작업을 수행하고 부분적인 (partial) 상태 업데이트를 반환해야 합니다.
LangGraph는 이러한 업데이트를 전체 상태 (full state)로 병합합니다. 이는 경합 조건 (race conditions)을 방지하고 디버깅을 단순화합니다.
def classify_query(state: AgentState) -> AgentState:
query = state["user_query"]
# 분류를 위해 Groq 호출
...
각 노드는 LangGraph가 지능적으로 병합하는 딕셔너리 (dictionary)를 반환합니다. 이 패턴은 견고한 체크포인팅 (checkpointing)과 결합되어, 우리의 멀티 에이전트 시스템을 장애에 탄력적으로 만들었으며 복잡한 멀티 턴 (multi-turn) 상호작용을 구축할 수 있게 해주었습니다. 우리는 이제 고객 지원부터 내부 데이터 분석에 이르기까지 모든 것을 처리하는 에이전트를 운영하고 있으며, 속도를 위한 Groq, 복잡한 추론을 위한 Claude, 그리고 커스텀 도구 (custom tools) 사이를 라우팅하며, 이 모든 것을 안정적인 상태 관리 (state management)를 통해 LangGraph로 오케스트레이션 (orchestrated)합니다.
견고한 체크포인팅의 비용적 영향
로컬 SQLite 파일에서 OCI의 관리형 PostgreSQL 인스턴스로 전환하면서 체크포인팅을 위한 인프라 비용이 월 0달러에서 50달러로 증가했습니다. 이는 수백 개의 동시 스레드 (concurrent threads)를 처리할 수 있는 소규모 인스턴스 (1 OCPU, 1GB RAM) 기준입니다.
대안인 커스텀 Redis 기반 솔루션이나 유사한 키-값 저장소 (key-value store)를 사용했다면 상당한 개발 및 유지보수 노력이 필요했을 것입니다. VC 펀딩을 받지 않는 우리의 모델을 고려할 때, 운영 오버헤드 (operational overhead)를 최소화하는 것이 무엇보다 중요합니다. 검증된 관리형 데이터베이스에 지불하는 월 50달러는 안정성과 개발자의 정신 건강을 위해 지불할 만한 작은 비용입니다.
나아가, 확보된 안정성은 LLM 비용에 직접적인 영향을 미칩니다. 상태 손실로 인한 재시도 (retries)가 줄어든다는 것은 Groq 또는 Claude에 대한 불필요한 API 호출이 줄어든다는 것을 의미합니다. 단일 복잡한 멀티 턴 상호작용에는 510회의 LLM 호출이 포함될 수 있습니다. 만약 상태 손실로 인해 이러한 상호작용의 10%가 실패하여 다시 실행해야 한다면, 이는 LLM 지출의 10% 증가로 이어집니다. 현재 우리의 볼륨을 기준으로 볼 때, 이는 약 월 100200달러의 LLM 비용 절감으로 이어지며, 결과적으로 데이터베이스 비용을 상쇄합니다.
자주 묻는 질문 (Frequently Asked Questions)
Q: LangGraph의 내장 체크포인터 (checkpointer) 대신 Redis나 DynamoDB를 사용하는 커스텀 상태 관리자 (custom state manager)를 사용하지 않는 이유는 무엇인가요?
A: 가능은 하지만, 개발 및 유지보수 오버헤드가 상당히 커집니다. LangGraph의 체크포인터는 그래프 상태 (graph state)의 직렬화 (serialization), 역직렬화 (deserialization), 그리고 버전 관리 (versioning)를 자동으로 처리합니다. 커스텀 저장소를 위해 이를 처음부터 구축하는 것은 에이전트 로직에 투입할 수 있는 엔지니어링 시간을 몇 주씩 소모하게 만듭니다.
Q: 프로덕션 환경에서 AgentState의 스키마 마이그레이션 (schema migrations)은 어떻게 처리하나요?
A: 우리는 AgentState를 데이터베이스 스키마 (database schema)처럼 취급합니다. 사소한 추가 사항의 경우, 새로운 선택적 키 (optional keys)를 추가합니다. 중대한 변경 사항 (breaking changes)이 있는 경우, 새로운 thread_id 접두사를 가진 에이전트의 새 버전을 배포하고, 활성 스레드 (active threads)를 수동으로 또는 일회성 스크립트를 통해 마이그레이션하여 기존 스레드가 이전 스키마에서 완료될 수 있도록 보장합니다.
Q: 인메모리 (in-memory) 방식과 비교했을 때 PostgreSQL 체크포인팅의 성능 오버헤드는 어느 정도인가요?
A: 일반적인 에이전트 상호작용(턴당 몇 초에서 몇 분)의 경우, PostgreSQL로의 네트워크 왕복 (network roundtrip) 오버헤드(수십 밀리초)는 LLM 추론 시간(수백 밀리초에서 수 초)에 비해 무시할 수 있는 수준입니다. 극도로 높은 처리량 (high-throughput)과 낮은 지연 시간 (low-latency)이 요구되는 시나리오에서는 주기적인 플러싱 (flushing)을 수행하는 인메모리 체크포인터를 고려할 수 있지만, 이는 충돌 발생 시 데이터 손실 위험을 초래합니다.
Q: LangGraph의 체크포인터가 매우 큰 상태 객체(예: 메가바이트 단위의 데이터)를 처리할 수 있나요?
A: 기술적으로는 가능하지만 권장하지 않습니다. 큰 상태 객체는 I/O, 직렬화/역직렬화 시간, 그리고 데이터베이스 저장 비용을 증가시킵니다. 우리는 AgentState를 가볍게 유지하며, 원시 데이터 (raw data)를 직접 포함하는 대신 필수적인 메타데이터와 더 큰 데이터에 대한 포인터(예: 객체 스토리지의 파일 ID)만을 저장합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기