LangGraph 체크포인팅 (Checkpointing): 상태 유실을 방지하기 위한 세 번의 프로덕션 리라이트 (Rewrites)
요약
LangGraph를 이용한 멀티 에이전트 시스템 구축 중 발생한 상태 스키마 불일치 문제와 그 해결 과정을 다룹니다. PostgresSaver 사용 시 발생하는 데이터 유실 문제를 방지하기 위한 체크포인팅 전략과 마이그레이션의 중요성을 설명합니다.
핵심 포인트
- LangGraph의 PostgresSaver 사용 시 jsonb 컬럼의 스키마 불일치 주의
- TypedDict 업데이트 시 기존 체크포인트에 대한 명시적 마이그레이션 필요
- 상태 유실 방지를 위한 안정적인 체크포인팅 패턴 구축의 중요성
- 로컬(Sqlite)과 프로덕션(Postgres) 환경 간의 상태 관리 차이 인지
원래 AIdeazz에 게시되었습니다 — 정식 링크와 함께 이곳에 교차 게시되었습니다.
우리의 첫 번째 프로덕션 LangGraph 에이전트인 다단계 콘텐츠 생성 파이프라인은 3주 동안 작업 내용의 80%를 소리 없이 버렸습니다. 문제는 LLM, 프롬프트(Prompt), 또는 라우팅 로직(Routing logic)이 아니었습니다. 그것은 체크포인팅 (Checkpointing) 과정 중의 상태 스키마 불일치(State schema mismatch)였으며, 이 버그는 쓰기(Write)는 성공하지만 읽기(Read)는 실패하는 방식으로 나타나 모든 중간 단계를 사실상 삭제해 버렸습니다. 우리는 에이전트가 아예 없는 것보다는 낫다는 생각에 일단 배포를 진행했습니다.
이것이 유일한 체크포인팅 재앙은 아니었습니다. Oracle Cloud에서 LangGraph 상태 유지 에이전트 (Stateful agents)를 위한 안정적인 패턴을 찾기 전까지, 우리는 세 번의 주요 리라이트 (Rewrites)를 거쳤습니다. 각각의 과정은 로컬 개발과 프로덕션 안정성 사이의 격차, 특히 멀티 에이전트 시스템 (Multi-agent systems)에서 지속적인 상태 (Persistent state)를 다룰 때의 격차에 대해 우리에게 뼈아픈 교훈을 주었습니다.
소리 없는 상태 스키마 불일치: 월 1,200달러짜리 교훈
에이전트의 작업은 사용자 요청을 받아 이를 세분화하고, 콘텐츠 초안을 생성하고, 검토한 다음 게시하는 것이었습니다. 각 단계는 LangGraph의 노드 (Node)였으며, 상태 (State)는 다양한 문자열, 리스트, 그리고 status 열거형 (Enum)을 담고 있는 TypedDict였습니다. 우리는 로컬 개발을 위해 SqliteSaver를 사용했고, 이후 Oracle Cloud의 PostgresSaver로 마이그레이션했습니다. PostgresSaver는 상태를 저장하기 위해 jsonb 컬럼을 사용합니다.
초기 AgentState는 다음과 같았습니다:
class AgentState(TypedDict):
request_id: str
user_query: str
...
우리가 새로운 필드인 review_comments: List[str]를 추가했을 때, TypedDict를 업데이트했습니다. 로컬에서는 잘 작동했습니다. 하지만 프로덕션에서는 에이전트가 시작되어 첫 번째 초안을 생성하고 상태를 저장한 다음, 다음 단계에서 실패했습니다. 에러 메시지는 일반적이었습니다: KeyError: 'review_comments'.
근본 원인은 PostgresSaver의 jsonb 컬럼이 스키마 (schema)를 강제하지 않는다는 점이었습니다. 애플리케이션 코드에서 TypedDict를 업데이트했을 때, 데이터베이스에 이미 존재하는 체크포인트 (checkpoints)들은 여전히 이전 스키마를 유지하고 있었습니다. LangGraph의 SqliteSaver는 누락된 키를 None이나 빈 리스트로 기본 설정하는 등 더 관대한 편입니다. 하지만 PostgresSaver는 jsonb를 딕셔너리 (dictionary)로 역직렬화 (deserialize)하는데, Python 코드가 state["review_comments"]에 접근하려고 할 때 해당 키가 존재하지 않았던 것입니다.
원인을 찾아내기 전까지 약 200회의 에이전트 (agent) 실행을 손실했으며, 각 실행은 Groq/Claude API 호출 비용으로 약 6달러씩 소요되었습니다. 해결책은 단순히 TypedDict를 업데이트하는 것이 아니라, 기존 체크포인트에 대한 스키마 마이그레이션 (schema migrations)을 명시적으로 처리하는 것이었습니다. 저희는 langchain_checkpoint 테이블에 있는 모든 기존 jsonb 상태를 순회하며, 누락된 review_comments 키를 빈 리스트로 추가하고 레코드를 업데이트하는 일회성 스크립트를 작성했습니다.
교훈 1: TypedDict는 컴파일 타임 힌트 (compile-time hint)일 뿐, jsonb에 대한 런타임 스키마 강제 (runtime schema enforcement)가 아닙니다. jsonb 상태를 명시적인 마이그레이션이나 읽기 시의 견고한 기본값 처리가 필요한 가변 스키마 (mutable schema)로 취급하십시오.
체크포인트 손상: 에이전트를 멈추게 만든 레이스 컨디션 (Race Condition)
스키마 불일치 문제 이후, 에이전트가 실행 도중 멈추는 현상이 나타나기 시작했습니다. 에러는 발생하지 않았고, 그저 중단된 상태였습니다. langchain_checkpoint 테이블을 수동으로 조사한 결과, 중단된 에이전트들의 thread_ts (타임스탬프)가 멈춰 있었으며, checkpoint jsonb 컬럼이 형식이 잘못되었거나 잘려 있는 경우가 많았습니다.
우리의 에이전트들은 고가용성 (High Availability)을 위해 여러 개의 레플리카 (Replica)를 사용하여 Oracle Container Engine for Kubernetes (OKE)에서 실행됩니다. 각 레플리카는 잠재적으로 동일한 체크포인트를 업데이트하려고 시도할 수 있습니다. PostgresSaver는 쓰기 작업 중에 행을 잠그기 위해 FOR UPDATE를 사용하지만, 우리는 미묘한 경쟁 상태 (Race Condition)를 발견했습니다. 만약 두 개의 레플리카가 동일한 체크포인트를 로드하고, 이를 수정하고, 거의 동시에 저장하려고 시도한다면, 하나가 다른 하나의 변경 사항을 덮어쓰거나, 드문 경우지만 다른 읽기 작업이 시작되기 전에 데이터베이스 트랜잭션이 완전히 커밋되지 않았다면 jsonb 데이터가 손상될 수 있었습니다. 이는 Oracle Autonomous Database로의 네트워크 지연 시간 (Network Latency)으로 인해 더욱 악화되었습니다.
PostgresSaver의 기본 thread_id는 단순한 문자열입니다. 우리는 더 강력한 잠금 메커니즘 (Locking Mechanism)이 필요했습니다. 우리는 langchain_checkpoint 테이블에 낙관적 잠금 (Optimistic Locking) 필드인 version_id를 추가하는 커스텀 PostgresSaver 서브클래스를 구현했습니다.
ALTER TABLE langchain_checkpoint ADD COLUMN version_id INT DEFAULT 1;
에이전트가 체크포인트를 로드할 때 version_id도 함께 로드합니다. 저장할 때는 version_id를 증가시키고 UPDATE 쿼리에 WHERE version_id = :old_version_id를 포함합니다. 만약 업데이트된 행이 없다면, 이는 다른 프로세스가 체크포인트를 수정했음을 의미하며, 우리의 에이전트는 백오프 (Backoff)와 함께 전체 단계(로드, 처리, 저장)를 재시도합니다.
이 패턴은 체크포인트 손상과 에이전트 프리징 (Freezes) 현상을 획기적으로 줄였습니다. 복잡성은 증가했지만, 진정한 멀티 레플리카 안정성을 위해서는 필수적이었습니다.
교훈 2: PostgresSaver의 기본 잠금은 단일 프로세스, 단일 스레드 액세스에는 충분합니다. 분산된 멀티 레플리카 배포의 경우, 낙관적 잠금 (Optimistic Locking) 또는 분산 잠금 관리자 (Distributed Lock Manager)를 구현하십시오.
마침내 해결책이 된 단 하나의 패턴: 원자적 상태 전이 (Atomic State Transitions)
스키마 마이그레이션 (Schema migration)과 낙관적 잠금 (Optimistic locking)을 적용했음에도 불구하고, 우리의 에이전트들은 여전히 간혹 유효하지 않은 상태에 빠지곤 했습니다. 예를 들어, 에이전트가 generating 상태에서 reviewing 상태로 전환되었지만, 동일한 트랜잭션 내에서 review_comments를 저장하는 데 실패할 수 있습니다. 만약 이 두 작업 사이에 시스템이 충돌(Crash)한다면, 에이전트는 불일치하는 상태에 놓이게 됩니다. 즉, status는 reviewing이지만 review_comments는 비어 있는 상태가 되는 것입니다.
핵심 문제는 LangGraph의 상태 업데이트가 전체 상태 객체에 대해서는 원자적(Atomic)이지만, _논리적 전이 (Logical transitions)_의 원자성은 강제하지 않는다는 점이었습니다. 우리는 상태 변경(예: status 업데이트)과 그에 수반되는 데이터 변경(예: review_comments 추가)이 에이전트의 관점에서 단일하고 분할 불가능한 작업(Indivisible operation)으로 수행되도록 보장해야 했습니다.
우리는 "커맨드 패턴 (Command-pattern)"과 유사한 접근 방식을 채택하기 위해 AgentState와 에이전트 노드들을 리팩토링했습니다. 노드 내부에서 AgentState 필드를 직접 수정하는 대신, 각 노드는 "상태 업데이트 커맨드 (State update commands)" 목록을 생성하게 됩니다. 이 커맨드들은 체크포인팅 (Checkpointing) 직전, 즉 노드 실행이 끝나는 시점에 원자적으로 적용됩니다.
커맨드 큐 (Command queue)를 포함한 AgentState 예시:
class AgentState(TypedDict):
request_id: str
user_query: str
...
각 노드는 pending_updates에 내용을 추가합니다. 그 후 전용 "상태 전이 (State transition)" 노드(또는 커스텀 checkpoint_saver 훅)가 이 업데이트들을 적용하고 큐를 비웁니다.
def generate_draft_node(state: AgentState):
# ... 초안 생성 ...
new_draft = "Generated content"
...
이 패턴은 특정 단계에 대한 모든 논리적 변경 사항이 하나로 묶여 함께 적용되도록 보장합니다. 만약 apply_updates_node가 실행되기 전에 에이전트가 충돌한다면, 다시 로드했을 때 pending_updates가 여전히 남아 있으므로 에이전트는 이를 적용하는 작업을 재시도할 수 있습니다. 만약 apply_updates_node 실행 도중에 충돌이 발생하더라도, 낙관적 잠금 (Optimistic locking)이 재시도 시의 일관성을 보장합니다.
이러한 접근 방식은 우리의 LangGraph 상태 유지 에이전트 (stateful agents)를 훨씬 더 견고하게 만들었습니다. 현재 우리는 Oracle Cloud에서 이러한 에이전트들을 여러 개 실행하고 있으며, 속도가 중요한 작업은 Groq으로, 복잡한 추론이 필요한 작업은 Claude로 라우팅하며, 이 모든 과정은 Telegram과 WhatsApp을 통해 오케스트레이션 (orchestrated)됩니다. 인프라는 간결합니다: OKE, Oracle Autonomous Database, 그리고 커스텀 API 게이트웨이로 구성됩니다. VC 투자 없이, 오직 안정성을 향한 끊임없는 반복 (iteration)만으로 이루어졌습니다.
자주 묻는 질문 (Frequently Asked Questions)
Q: 데이터베이스 체크포인팅 (checkpointing) 대신 상태 전이를 위해 메시지 큐 (예: Kafka)를 사용하지 않는 이유는 무엇인가요?
A: 우리의 사용 사례에서 주요 상태는 대화형이며 수명이 길기 때문에, 지속적인 저장소와 UI/디버깅을 위한 직접적인 접근이 필요합니다. 메시지 큐를 사용하면 상태 재구성 (state reconstruction) 및 쿼리 과정에서 상당한 복잡성이 추가되는데, 소규모 팀으로서 우리는 이를 피하고자 했습니다.
Q: LangGraph 노드 내에서 LLM API 속도 제한 (rate limits) 및 재시도 (retries)를 어떻게 처리하나요?
A: 노드 내의 각 LLM 호출은 지수 백오프 (exponential backoff)가 적용된 커스텀 재시도 데코레이터 (retry decorator)로 감싸져 있습니다. 또한, 제공업체의 제한을 초과하지 않도록 API 게이트웨이 수준에서 글로벌 토큰 버킷 (token-bucket) 속도 제한기를 구현하였으며, 현재 부하와 토큰 비용에 따라 요청을 서로 다른 LLM (Groq, Claude)으로 라우팅합니다.
Q: Oracle Cloud에서 이러한 에이전트들을 실행하는 비용은 얼마인가요?
A: 우리의 핵심 인프라 (OKE, Autonomous Database, Load Balancer) 비용은 월 약 $150입니다. LLM API 비용은 사용량에 따라 다르지만 평균적으로 월 $800~$1,500 정도입니다. 개발 시간을 제외한 프로덕션 에이전트 시스템의 총 운영 비용은 월 $2,000 미만입니다.
Q: 프로덕션 환경에서 멈추거나 손상된 에이전트를 어떻게 디버깅하나요?
A: 우리는 langchain_checkpoint 테이블을 쿼리하여 thread_id, status, current_step, thread_ts를 표시하는 커스텀 대시보드를 보유하고 있습니다. 멈춘 에이전트의 경우, jsonb 상태를 수동으로 검사할 수 있으며, 필요한 경우 커스텀 스크립트를 사용하여 status를 재설정하거나 pending_updates 명령을 주입하여 특정 전이를 강제할 수 있습니다.
Q: 매우 긴 대화로 인해 jsonb 컬럼 크기가 문제가 되면 어떻게 하나요?
A: jsonb 상태에 저장되는 히스토리를 마지막 N개 턴(통상적으로 10-20개)으로 제한합니다. 체크포인트 크기를 관리 가능한 수준으로 유지하고 읽기/쓰기 성능을 향상시키기 위해, 오래된 대화 히스토리는 request_id로 연결된 별도의 conversation_history 테이블로 오프로딩(offloading)합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기