본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 16. 05:10

LangGraph의 세 차례 재작성: 실제 운영 환경의 체크포인팅(Checkpointing) 비용은 무엇인가

요약

LangGraph를 사용하여 실제 운영 환경에서 상태 유지 에이전트를 구축할 때 겪은 체크포인팅 오류와 해결 과정을 다룹니다. 상태 스키마 설계 시 리듀서(reducer)를 명시하지 않아 데이터가 누적되지 않고 덮어씌워지는 치명적인 버그를 분석합니다.

핵심 포인트

  • LangGraph 상태 스키마에서 리듀서 미지정 시 데이터가 교체(replace)됨
  • 에러 없이 조용히 발생하는 데이터 유실 버그의 위험성 경고
  • 상태 유지 에이전트의 안정성을 위한 올바른 Annotated 및 리듀서 사용법
  • 운영 환경에서의 체크포인팅 데이터 무결성 확보의 중요성

원문은 AIdeazz에 게시되었습니다 — 정식 링크와 함께 이곳에 교차 게시되었습니다.

우리는 로그에 단 하나의 오류도 남기지 않은 채 약 3주 분량의 에이전트 작업(agent jobs)을 폐기했습니다. 예외(exception)도, 실패한 체크포인트(checkpoint)도, 알림(alert)도 없었습니다. 상태 그래프(state graph)는 깨끗하게 실행되었고, Telegram 사용자에게 유효한 응답을 반환했으며, 매 호출마다 누적된 컨텍스트(context)의 절반을 조용히 삭제했습니다. 제가 이를 발견한 이유는 한 사용자가 에이전트에게 "우리가 멈춘 지점부터 계속하자"라고 요청했을 때, 에이전트가 "멈춘 지점"이 무엇인지 전혀 알지 못했기 때문입니다. 체크포인트 테이블(checkpoint table)에 행(rows)들이 있었음에도 불구하고 말이죠. 행들은 존재했습니다. 하지만 그 안의 데이터가 잘못되어 있었습니다. 이것이 가장 최악의 버그입니다. 모든 것이 괜찮다고 말해주는 시스템 말입니다.

이것은 Groq와 Claude 사이를 라우팅하며 실제 WhatsApp 및 Telegram 트래픽을 처리하는 Oracle Cloud 환경에서, LangGraph의 상태 유지 에이전트(stateful agents)의 운영 체크포인팅(production checkpointing)을 제가 신뢰할 수 있는 수준으로 만들기 위해 실제로 필요했던 과정입니다. 세 번의 재작성(rewrites). 세 번째 시도에서야 마침내 안정화되었습니다.

첫 번째 재작성: 모든 것을 삼켜버린 상태 스키마(state schema) 불일치

첫 번째 버전은 올바르게 보였습니다. 우리는 messages 필드와 검색된 컨텍스트(retrieved context) 및 도구 출력(tool outputs)을 위한 몇 가지 누적 필드를 포함한 TypedDict 상태를 사용했습니다. 그래프는 컴파일되었습니다. 노드(nodes)는 순서대로 실행되었습니다. 에이전트는 질문에 답변했습니다.

문제는 리듀서(reducer)였습니다. LangGraph에서 상태 스키마(state schema)는 단순한 타입 힌트(type hint)가 아닙니다. 각 필드는 새로운 값이 기존 상태에 어떻게 병합될지를 결정하는 리듀서 함수(reducer function)를 가질 수 있습니다. 만약 이를 지정하지 않으면, 기본 동작은 매 노드 반환 시 필드 전체를 **교체(replaces)**해 버립니다. 우리는 다음과 같이 작성했습니다:

class AgentState(TypedDict):
    messages: list[BaseMessage]
    retrieved_context: list[str]
...

일반 list였습니다. Annotated도, 리듀서 (reducer)도 없었습니다. 그래서 노드가 {"retrieved_context": [new_chunk]}를 반환했을 때, LangGraph는 내용을 추가(append)하는 대신, 전체 리스트를 단일 요소가 담긴 리스트로 덮어씌워 버렸습니다. 모든 검색 단계마다 이전 단계의 결과가 지워졌습니다. 합성 (synthesis) 노드에 도달했을 때, retrieved_context에는 정확히 하나의 청크, 즉 마지막 청크만 남아 있었습니다. 에이전트는 수집한 정보의 극히 일부만을 사용하여 답변했습니다.

왜 에러가 발생하지 않았을까요? 요소가 하나인 리스트도 완벽하게 유효한 리스트이기 때문입니다. 타입 체크도 통과했습니다. 그래프도 실행되었습니다. 체크포인트 (checkpoint) 역시 단일 요소 리스트를 충실히 저장했습니다. 버그 이후의 모든 하위 프로세스는 잘못된 입력값에 대해서도 올바르게 동작했습니다. 우리가 이를 알아차린 이유는 답변의 품질이 설명할 수 없는 방식으로 평범했기 때문입니다. 그리고 "평범한 답변"은 새벽 2시에 누구를 호출하게 만들지 않습니다.

해결책은 누적되어야 하는 각 필드에 한 줄의 어노테이션 (annotation)을 추가하는 것입니다:

from typing import Annotated
from operator import add
from langgraph.graph.message import add_messages
...

add_messages는 메시지 중복 제거 및 ID 매칭을 처리하는 내장 리듀서 (reducer)입니다. 단순 누적의 경우 operator.add가 작동합니다. 우리에게 3주의 시간을 앗아간 교훈은 다음과 같습니다: LangGraph에서 리듀서 (reducer)가 없다는 것 자체가 하나의 결정이며, 리스트 상태 (list state)에 대해서는 여러분이 원하는 결과가 아닐 확률이 거의 높다는 것입니다. 모든 필드를 감사 (audit)하세요. 해당 필드가 교체되어야 하는지 아니면 누적되어야 하는지 입 밖으로 말할 수 없다면, 버그가 기다리고 있는 것입니다.

두 번째 재작성: 동시성 상황에서의 체크포인트 오염

상태 (state)가 올바르게 병합되도록 수정한 후, 우리는 소규모 WhatsApp 코호트 (cohort)에 배포했습니다. 두 번째 실패 모드는 부하가 걸리는 상황에서 나타났습니다. 동일한 사용자가 1초 이내에 두 개의 메시지를 보내는 경우였습니다. 이는 사람들이 생각을 보낸 뒤 바로 수정 사항을 보내는 모바일 환경에서 흔히 발생하는 상황입니다.

우리는 Oracle 호스팅 데이터베이스를 대상으로 thread_id(사용자의 대화)를 키로 사용하는 Postgres checkpointer (체크포인터)를 사용하고 있었습니다. 두 메시지는 동일한 스레드에 대해 그래프 실행 (graph run)을 생성했습니다. 두 실행 모두 버전 N의 체크포인트를 읽었습니다. 두 실행 모두 업데이트를 계산했습니다. 그리고 두 실행 모두 다시 기록했습니다. 두 번째 쓰기 작업이 첫 번째 작업을 덮어씌웠고(clobbered), 체크포인트 메타데이터는 쓰기 작업이 건너뛴 부모 버전을 참조하게 되었습니다. 결과적으로: 체크포인트 체인에 구멍이 생긴 thread가 발생했습니다. LangGraph가 재개(resume)를 시도했을 때, 부모 포인터를 따라가다 더 이상 일치하지 않는 버전에 도달했습니다. 결국 우리가 마주한 에러는 다음과 같았습니다:

ValueError: Checkpoint parent_config references checkpoint_id
'1ef...' not found for thread '<wa_id>'

이 에러는 확실히 눈에 띄었습니다. 첫 번째 재작성 작업에서 겪었던 조용한 재앙 이후에는 오히려 다행이라고 느껴질 정도였습니다.

단순한 본능으로는 그래프 실행 전체에 락 (lock)을 걸고 싶을 것입니다. 하지만 그러지 마세요. Claude를 호출하는 그래프 실행은 8초에서 12초가 걸릴 수 있습니다. WhatsApp의 폭발적인 트래픽 상황에서 그렇게 오래 행 잠금 (row lock)을 유지하는 것은 실패의 원인을 커넥션 풀 (connection-pool) 고갈로 옮기는 것뿐입니다. 우리는 정확히 그 현상을 목격했습니다. 우리가 사용하는 티어의 Oracle 커넥션 제한은 넉넉하지 않았고, 각각 락을 잡은 채 모델 API를 기다리는 실행들이 쌓이면서 1분도 채 되지 않아 풀이 바닥났습니다.

해결책은 두 부분으로 구성된 변경 사항이었습니다. 첫째, 데이터베이스 계층이 아닌 애플리케이션 계층에서 스레드별로 직렬화 (serialize)를 수행했습니다. 즉, thread_id별로 가벼운 비동기 락 (async lock)을 사용하여 동일한 대화에 대한 두 메시지가 경합(race)하는 대신 큐 (queue)에 쌓이도록 했고, 서로 다른 대화는 완전히 병렬로 유지되도록 했습니다. 둘째, 유입되는 메시지에 디바운스 (debounce)를 적용했습니다. 사용자가 2초 안에 세 개의 메시지 조각을 보낸다면, 이를 하나의 그래프 호출 (graph invocation)로 병합합니다. 이것은 임시방편이 아니라 올바른 제품 동작입니다. 사람은 세 개의 빠른 메시지를 하나의 생각으로 읽습니다. 에이전트도 그래야 합니다.

# 전역이 아닌 스레드별 직렬화
thread_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)

...

멀티 프로세스 배포(multi-process deployment)를 위해서는 이 잠금(lock)이 분산되어야 합니다. 워커(worker)를 하나 이상 실행하게 되면서, 저희는 짧은 TTL(Time To Live)을 가진 Redis 기반 잠금 방식으로 전환했습니다. TTL은 매우 중요합니다. 가장 느린 모델 호출 시간과 체크포인트(checkpoint) 쓰기 시간을 합친 것보다 길게 설정해야 합니다. 그렇지 않으면 실행 도중에 잠금이 해제되어 다시 레이스 컨디션(race condition)이 발생할 수 있습니다. 저희는 Claude의 꼬리 지연 시간(tail latency)을 여유 있게 커버할 수 있도록 30초를 사용합니다.

세 번째 재작성: 마침내 버텨낸 패턴

세 번째 재작성은 새로운 버그 때문이 아니었습니다. 아키텍처가 잘못되었음을 인정하는 과정이었습니다. 저희는 라우팅(routing), 검색(retrieval), 도구 호출(tool calls), 그리고 합성(synthesis)을 모두 수행하며 하나의 거대한 상태(state) 객체를 공유하는 하나의 거대한 그래프를 가지고 있었습니다. 노드 하나를 변경할 때마다 체크포인트 형태(checkpoint shape)가 위험해졌고, Postgres에 영구 저장된 활성 스레드(live threads)가 있는 상황에서 체크포인트 형태의 변경은 마이그레이션(migration) 문제를 야기했습니다.

문제를 해결한 패턴은 다음과 같습니다: 의도적으로 최소화된 체크포인트 상태를 가진 얇고 안정적인 감독 그래프(supervisor graph)와, 상태를 유지하지 않는(stateless) 서브그래프 워커(subgraph workers)를 사용하는 것입니다.

감독 그래프는 충돌(crash) 및 재시작 시에도 반드시 살아남아야 하는 것들, 즉 메시지 기록(message history), 압축된 실행 요약(running summary), 그리고 라우팅 결정 필드(routing decision field)만을 보유합니다. 그게 전부입니다. 무거운 중간 상태(intermediate state) — 가공되지 않은 검색된 청크(raw retrieved chunks), 부분적인 도구 출력(partial tool outputs), 합성 단계에서 필요한 임시 공간(scratch space) — 는 단일 감독 단계 내에서 완료되어 실행되고 정제된 결과만을 반환하는 서브그래프 실행 내부에 존재합니다. 이러한 서브그래프들은 체크포인트가 생성되지 않습니다. 만약 서브그래프가 실행 도중 충돌하더라도, 감독 그래프의 마지막 정상 체크포인트는 온전하게 유지되며, 해당 단계가 감독 상태에 대해 멱등성(idempotent)을 가지기 때문에 해당 단계를 깔끔하게 재시도할 수 있습니다.

이러한 분리는 운영 환경에서 중요한 세 가지 역할을 합니다:

  • 체크포인트가 지정된 스키마(Schema)는 거의 변하지 않습니다. 영속화된 상태(Persisted state)의 형태를 건드리지 않고도 검색 서브그래프(Retrieval subgraph)를 완전히 재작성할 수 있으므로, 실행 중인 스레드(Live threads)를 위해 체크포인트 마이그레이션(Checkpoint migration)을 수행할 필요가 없습니다. 진화시키기 가장 어려운 부분이 이제는 가장 적게 변하는 부분이 되었습니다.
  • 체크포인트 크기가 작게 유지됩니다. 우리의 감독자(Supervisor) 체크포인트 행은 몇 KB 수준입니다. 이전의 무거운 상태(Fat-state) 버전은 가공되지 않은 검색된 청크(Retrieved chunks)가 영속화된 상태에 그대로 남아 있었기 때문에 체크포인트가 가끔 100KB를 넘기도 했습니다. 이는 저장 공간과 모든 읽기-수정-쓰기(Read-modify-write) 사이클에 실질적인 비용을 발생시킵니다.
  • 모델 라우팅(Model routing)이 한 곳에 명확하게 존재합니다. 감독자가 Groq를 사용할지 Claude를 사용할지 결정합니다. 비용이 저렴하고 지연 시간(Latency)에 민감한 분류 및 라우팅 — "이것이 질문인가, 수정인가, 아니면 잡담인가" — 작업은 Groq로 보내며, 여기서 응답이 충분히 빠르게 돌아오기 때문에 사용자는 지연을 느끼지 못합니다. 실제 합성(Synthesis)이 필요한 모든 작업은 Claude로 보냅니다. 이러한 결정을 서브그래프 곳곳에 흩어놓는 대신 상태를 가진 감독자(Stateful supervisor)에 유지함으로써, 하나의 함수에서 라우팅 정책을 변경하고 그 비용을 추론할 수 있습니다.

구조는 다음과 같습니다:

# supervisor: stateful, checkpointed, minimal
class SupervisorState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
...

summaryroute는 의도적으로 기본 교체(Replace) 동작을 사용한다는 점에 유의하세요. 이들은 매 턴마다 덮어써야 합니다. 이는 첫 번째 재작성에서 얻은 규율을 의도적으로 적용한 것입니다. 교체가 적절한 곳에서는 교체하고, 누적이 적절한 곳에서는 누적하며, 결코 우연에 맡기지 마십시오.

LangGraph 상태 유지 에이전트(Stateful agents)를 프로덕션에 배포하려는 모든 이들에게 해주고 싶은 말

무엇보다 먼저 체크포인터(checkpointer)를 제대로 설정하십시오. 인메모리(in-memory) 방식인 MemorySaver는 노트북 환경에서는 괜찮지만, 프로덕션(production) 환경에서는 함정입니다. 재시작 시 상태(state)가 증발해 버리므로, 배포 후에는 버티지 못하는 데모를 만들게 될 것입니다. 곧바로 Postgres(또는 단일 노드의 경우 SQLite) 체크포인터로 넘어가시고, 데이터베이스를 사후 고려 사항이 아닌 에이전트(agent)의 일부로 취급하십시오. 체크포인터의 setup()을 첫 요청 시점에 지연(lazy) 방식으로 실행하지 말고, 마이그레이션(migration) 단계에서 실행하십시오. 지연 방식은 동시적인 콜드 스타트(cold start) 상황에서 경합(race)을 일으키며, 인프라의 불안정성처럼 보이지만 실제로는 그렇지 않은 중복 테이블 또는 누락된 테이블 오류를 발생시킬 것입니다.

재개(resumption)를 명시적으로 테스트하십시오. 대부분의 에이전트 테스트 스위트(test suites)는 하나의 프로세스 내에서 그래프를 처음부터 끝까지 실행하고 이를 통과(green)라고 간주합니다. 하지만 이는 체크포인팅(checkpointing)에 대해 아무것도 알려주지 않습니다. 그래프의 절반만 실행한 뒤, 인메모리 객체를 완전히 폐기하고, 콜드 상태에서 그래프를 재구성한 다음, 동일한 thread_id로 저장된 체크포인트로부터 재개하여 최종 상태가 올바른지 확인하는 테스트를 작성하십시오. 이 테스트를 통과한다면 체크포인팅이 제대로 작동하는 것입니다. 만약 이 테스트가 없다면, 제대로 작동하는지 알 방법이 없습니다.

체크포인트 크기를 일급 메트릭(first-class metric)으로 모니터링하십시오. 저희는 스레드(thread)의 체크포인트 행이 50KB를 초과하면 알람을 보냅니다. 왜냐하면 크기의 증가는 일부 중간 상태(intermediate state)가 영속성(persistence) 계층으로 유출되고 있다는 조기 신호이기 때문입니다. 이는 '재작성-1'과 '재작성-3'의 실수 모두를 잡아내는 카나리(canary) 역할을 합니다.

그리고 영속화된 상태(persisted state)를 방어 가능한 수준 내에서 최대한 작게 유지하십시오. 체크포인트 스키마(schema)의 모든 필드는 나중에 마이그레이션하겠다고 약속하는 항목과 같습니다. 비대한 상태(fat-state) 설계는 약 2주 동안은 편리하게 느껴지겠지만, 그 이후에는 모든 변경 사항을 두렵게 만드는 원인이 됩니다. 얇은 감독자(thin supervisor) 설계는 화이트보드 위에서는 덜 우아해 보일지 몰라도, 실제로 운영하며 유지하기에는 훨씬 저렴합니다.

자주 묻는 질문 (Frequently Asked Questions)

Q: Postgres checkpointer vs. 직접 상태 지속성(state persistence) 구축 — 내장 기능을 사용하는 것이 벤더 종속(lock-in)을 감수할 만큼 가치가 있나요?
A: 내장 기능을 사용하세요. 저희도 직접 구축하는 것을 고려했으나, 유일한 실제 이점은 스키마 제어(schema control)뿐이었으며, 이는 얇은 감독관 패턴(thin-supervisor pattern)으로 어차피 해결되는 문제입니다. 내장된 체크포인터(checkpointer)는 부모 포인터 체인(parent-pointer chain)과 재개 로직(resume logic)을 무료로 제공하며, 동시성(concurrency) 환경에서 이를 올바르게 재구현하는 과정이야말로 저희가 직접 작업했을 때 동일한 데이터 오염 버그(corruption bugs)를 유발했을 지점입니다. 종속되는 것은 체크포인터 라이브러리가 아니라 상태 스키마(state schema)입니다. 스키마를 작게 유지하면 어떤 방식이든 마이그레이션이 가능합니다.

Q: 실행 중인 스레드(live threads)에서 체크포인트 스키마 마이그레이션(checkpoint schema migration)을 실제로 어떻게 처리하나요?
A: 감독관 패턴(supervisor pattern)의 핵심 목적처럼, 체크포인트되는 상태를 최소한으로 유지함으로써 대부분 피할 수 있습니다. 피할 수 없는 경우에는 schema_version 필드를 사용하여 상태를 명시적으로 버전 관리하고, 이전 체크포인트를 읽어 한꺼번에 배치(batch)로 처리하는 대신 다음 액세스 시점에 지연(lazily)하여 다시 쓰는 리듀서 인식 마이그레이션(reducer-aware migration)을 작성합니다. 저희는 강제 마이그레이션을 딱 한 번 수행했는데, 지연 방식(lazy approach) 덕분에 다운타임이 전혀 없었습니다. 다시는 건드리지 않은 스레드는 마이그레이션되지 않았고, 아무도 눈치채지 못했습니다.

Q: 라우팅(routing)에는 Groq를, 합성(synthesis)에는 Claude를 사용하는데 — 실제 지연 시간(latency)과 비용 분담은 어떻게 되나요?
A: Groq를 통한 라우팅 분류(routing classification)는 약 200-400ms 내에 반환되며 호출당 1센트의 아주 적은 비용이 들기 때문에, 사용자는 라우팅 단계의 지연을 전혀 느끼지 못합니다. 시간과 비용이 집중되는 곳은 Claude를 통한 합성(synthesis) 단계입니다. 여기서 수 초의 응답 시간이 걸리고 모델 비용의 대부분이 발생합니다. 이 분담이 중요한 이유는 라우팅은 모든 메시지마다 발생하지만, 합성은 실제 작업이 있을 때만 실행되기 때문입니다. 따라서 빈도가 높고 저렴한 결정(decision)을 빠른 모델에 맡김으로써 지연 시간과 비용을 모두 낮게 유지할 수 있습니다.

Q: 단순히 잠금(locking)을 수정하는 대신 왜 인바운드 메시지를 디바운스(debounce)하나요?
A: 두 가지 모두 수행했지만, 단순히 안정성뿐만 아니라 답변의 품질을 개선한 것은 디바운스였습니다. 세 개의 메시지 파편이 세 번의 별도 그래프 실행(graph runs)으로 처리되면, 문맥을 파악하지 못한 세 개의 개별 응답이 생성됩니다. 반면, 이를 하나의 호출(invocation)로 병합하면 단일하고 일관된 답변을 생성합니다. 잠금(locking)은 데이터 손상을 방지하지만, 디바운스는 에이전트가 실제로 경청하고 있는 것처럼 동작하게 만듭니다. 특히 메시지 파편화가 일반적인 WhatsApp의 경우, 이는 인프라 수정으로 위장된 제품(product) 차원의 수정이었습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0