본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 20. 02:41

세 번의 LangGraph 재작성: 프로덕션 체크포인팅(Checkpointing)이 내게 실제로 가르쳐준 것

요약

LangGraph를 사용하여 상태 유지 에이전트를 구축할 때 발생하는 체크포인팅 오류와 해결 과정을 다룹니다. 상태 스키마 정의 시 리듀서(reducer)를 설정하지 않아 발생하는 데이터 덮어쓰기 문제를 분석하고 올바른 패턴을 제시합니다.

핵심 포인트

  • LangGraph의 기본 병합 동작은 리듀서 미지정 시 '덮어쓰기'임
  • 메시지 히스토리와 같은 누적 데이터는 반드시 리듀서 설정이 필요함
  • 체크포인트 테이블에 데이터가 있어도 상태 스키마 불일치로 재개가 실패할 수 있음
  • 멀티 에이전트 시스템의 상태 유지(persistence)를 위한 올바른 설계 패턴 중요

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

3주 동안 우리의 온보딩(onboarding) 에이전트는 지속(persist)되어야 할 모든 작업을 소리 없이 버렸고, 로그에는 아무것도 나타나지 않았습니다. 예외(exception)도, 경고(warning)도, 쓰기 실패(failed write)도 없었습니다. 체크포인터(checkpointer)는 모든 노드에서 성공을 보고했습니다. 하지만 재개(resume) 시점에 상태(state)가 그저 존재하지 않았을 뿐입니다. 저는 프레임워크가 제가 명령한 대로 정확히 수행하고 있었다는 사실을 이해하기 전까지 동일한 LangGraph 파이프라인을 세 번이나 다시 작성했습니다. 즉, 프레임워크는 제가 잘못된 명령을 내렸을 때, 대규모 환경에서 조용히 그 잘못된 일을 수행하고 있었던 것입니다.

이 글은 제가 모두 올바르게 보이는 행들로 가득 찬 체크포인트(checkpoint) 테이블과, 그중 어떤 것도 존재하지 않는 것처럼 동작하는 시스템을 멍하니 바라보고 있을 때 간절히 원했던 바로 그 글입니다. 만약 여러분이 프로덕션 체크포인팅(production checkpointing)을 위해 LangGraph 상태 유지 에이전트(stateful agents)를 구축하고 있으며, 기본 설정(defaults)이 여러분을 구해줄 것이라고 가정한다면, 그렇지 않을 것입니다. 무엇이 고장 났는지, 왜 그랬는지, 그리고 마침내 유효했던 단 하나의 패턴은 무엇인지 소개합니다.

3주를 잡아먹은 상태 스키마(state schema) 불일치

우리는 Oracle Cloud에서 멀티 에이전트 시스템(multi-agent systems)을 운영합니다. Telegram과 WhatsApp을 프런트엔드로 사용하며, 저렴하고 빠른 턴(turn)을 위한 Groq와 추론(reasoning) 집약적인 작업을 위한 Claude 사이에서 작업을 분할하는 라우터(router)를 사용합니다. 문제가 된 에이전트는 의도 수집, 분류, 보강(enrich), 초안 작성, 확인이라는 5단계 온보딩 흐름을 처리했습니다. 각 단계는 공유된 그래프 상태(graph state)에 기록되었습니다. 사용자가 WhatsApp을 닫았다가 이틀 뒤에 돌아와 흐름 중간부터 재개할 수 있도록 Postgres에 체크포인팅(checkpointing)을 수행했습니다.

버그: 모든 재개(resume)가 처음부터 시작되었습니다. 신규 사용자, 재방문 사용자 모두가 다시 1단계부터 시작했습니다.

원인은 저의 상태 스키마(state schema)였습니다. LangGraph에서 상태(state)는 TypedDict(또는 Pydantic 모델)이며, 모든 노드는 병합(merge)되는 부분 업데이트(partial update)를 반환합니다. 저는 다음과 같이 정의했었습니다:

class OnboardingState(TypedDict):
    messages: list
    profile: dict
...

문제는 기본 병합 (merge) 동작입니다. LangGraph는 리듀서 (reducers)를 사용하여 채널 업데이트를 병합합니다. 리듀서를 지정하지 않으면, 필드의 기본값은 **덮어쓰기 (overwrite)**입니다. 즉, 새로운 노드의 값이 이전 값을 대체합니다. step 같은 경우에는 괜찮습니다. 하지만 제가 추가 (appending)될 것이라고 가정했던 messages의 경우에는 재앙적입니다. 따라서 각 노드가 {"messages": [new_msg]}를 반환할 때마다 전체 히스토리가 단 하나의 메시지로 덮어씌워졌습니다. 체크포인트 (checkpoint)는 그 단일 메시지 상태를 완벽하게 저장했습니다. 재개 (resume) 시점에 그래프는, 한 노드가 부분적인 프로필을 반환하여 전체 프로필을 덮어쓰는 바람에 {}로 초기화되어 버린 프로필 딕셔너리 (profile dict)를 로드했습니다.

모든 쓰기 작업이 기술적으로는 유효했기 때문에 아무런 에러도 발생하지 않았습니다. 스키마 (schema)는 딕셔너리를 수용했고, 리스트도 수용했습니다. 단지 잘못된 값을 계속 유지했을 뿐입니다.

해결책은 명시적인 리듀서를 포함한 Annotated 필드를 사용하는 것입니다:

from typing import Annotated
from operator import add

...

필드별로 병합 의미론 (merge semantics)을 명시적으로 정의하자, 소리 없이 데이터가 버려지는 현상이 멈췄습니다. 교훈은 그 단순함만큼이나 잔혹합니다: LangGraph에서 상태 동작 (state behavior)은 노드 로직 (node logic)이 아니라 스키마 (schema)의 속성입니다. 만약 노드 함수를 읽음으로써 데이터 흐름을 추론한다면, 당신은 틀릴 것입니다. 스키마가 곧 계약 (contract)입니다.

"병합 (merge)"이 무엇을 의미하는지 묻지 않았기 때문에 3주를 허비했습니다. 저는 그것이 추가 (append)를 의미한다고 가정했습니다. 그것은 교체 (replace)를 의미했습니다.

체크포인트 손상과 작동하지 않는 재개

두 번째 재작성. 스키마를 수정했고, 테스트 중에는 흐름이 안정적이었습니다. 그러다 실제 WhatsApp 부하 상황에서 재개 (resume)가 역직렬화 (deserialization) 에러와 함께 실패하는 것을 목격하기 시작했습니다. 저희의 Oracle Postgres 로그를 의역한 관련 에러는 다음과 같습니다:

TypeError: Object of type AIMessage is not JSON serializable

그리고 간헐적으로 발생한 에러:

psycopg.errors.UniqueViolation: duplicate key value violates unique constraint "checkpoints_pkey"

"재개 실패"라는 동일한 가면을 쓰고 나타난 두 가지 별개의 문제였습니다.

직렬화 (Serialization) 오류는 LangChain 메시지 객체를 직접 저장하는 과정에서 발생했습니다. 기본 JsonPlusSerializer는 대부분의 LangChain 타입을 처리하지만, 저희는 라우팅 메타데이터(어떤 모델이 처리했는지 — Groq 또는 Claude, 토큰 수, 지연 시간 등)를 포함하는 커스텀 메시지 서브클래스를 사용하고 있었습니다. 이 커스텀 필드가 직렬화기를 망가뜨렸습니다. 체크포인트 쓰기는 부분적으로 성공하여 손상된 블롭 (blob)이 포함된 행을 남겼습니다. 이를 다시 읽어올 때 역직렬화 (deserialize) 과정에서 오류가 발생했습니다. 결과적으로 행 개수 체크는 통과하지만 사용할 수 없는 체크포인트 행들이 존재하게 되었습니다.

중복 키 위반 (Duplicate key violation)은 더 심각하고 흥미로운 문제였습니다. LangGraph의 체크포인터는 thread_id에 체크포인트 ID와 네임스페이스 (namespace)를 결합하여 키를 생성합니다. 저희는 WhatsApp 전화번호로부터 thread_id를 생성하고 있었습니다. 웹훅 (webhook)이 재시도되는 상황에서 동일한 사용자의 메시지 두 개가 동일한 실행 윈도우 내에 도착하기 전까지는 괜찮았습니다. WhatsApp은 200 응답을 충분히 빠르게 반환하지 않으면 웹훅을 공격적으로 재시도하며, Claude를 대상으로 한 저희의 데이터 보강 (enrichment) 단계는 때때로 4~6초가 소요되었습니다. 따라서 동일한 메시지가 동일한 스레드에 쓰기를 시도하는 두 개의 동시 그래프 실행을 트리거했습니다. 두 실행 모두 체크포인트 ID 0을 쓰려고 시도했습니다. 하나는 성공하고 하나는 오류를 던졌으며, 패배한 쪽의 부분적인 상태가 때때로 먼저 기록되었습니다.

중요도 순서에 따른 해결책은 다음과 같습니다:

  1. 체크포인팅 (Checkpointing) 전 커스텀 객체 제거. 라우팅 메타데이터 (Routing metadata)를 메시지 객체에서 분리하여 상태 (state) 내의 일반 딕셔너리 (dict) 필드로 이동시켰습니다. 메시지는 표준 LangChain 타입을 유지했습니다. 그 결과 직렬화기 (serializer)의 오류가 멈췄습니다. 비용: 약간의 리팩토링 (refactor). 이점: 이후로 손상된 행 (corrupt rows)이 전혀 발생하지 않았습니다.

  2. 그래프 실행 전 웹훅 (Webhook) 멱등성 (idempotency) 확보. 즉시 200 응답을 반환하고, WhatsApp 메시지 ID를 중복 제거 키 (dedup key)로 사용하여 메시지를 큐 (queue)에 넣은 뒤 큐에서 처리하도록 했습니다. 중복된 웹훅은 LangGraph에 도달하기 전에 차단됩니다. 이를 통해 동시 쓰기 경합 조건 (concurrent-write race)을 완전히 해결했습니다. 그래프는 결코 동시성 제어 계층 (concurrency control layer)이 되어서는 안 됩니다. 그렇게 설계되지 않았기 때문입니다.

  3. 스레드당 한 번에 하나의 실행 (One run per thread at a time). thread_id를 키로 사용하는 가벼운 권고 잠금 (advisory lock)을 Postgres에 추가했습니다. 특정 스레드에 대해 실행이 진행 중이라면 다음 메시지는 대기합니다. 대화형 에이전트 (conversational agent)에게 이는 어차피 올바른 동작입니다. 두 개의 답변이 경합하는 상황을 원치 않기 때문입니다.

다단계 파이프라인을 마침내 안정화시킨 패턴

세 번째 재작성 단계에서 모든 것이 이해되었으며, 그 이해는 주로 무언가를 삭제하는 과정에서 이루어졌습니다.

패턴: 작은 그래프, 경계에서의 명시적인 체크포인팅 (checkpoints), 그리고 멱등성 (idempotent)을 가진 노드 (nodes). 이 문구의 모든 단어는 그 자리에 있을 가치가 있었습니다.

작은 그래프. 나의 첫 번째 설계는 12개의 노드와 곳곳에 조건부 엣지 (conditional edges)가 있는 하나의 거대한 메가 그래프 (mega-graph)였습니다. 이를 디버깅한다는 것은 전체에 걸친 상태 변이 (state mutations)를 추적해야 함을 의미했습니다. 나는 이를 수집 (intake), 처리 (processing), 확인 (confirmation)이라는 세 개의 서브 그래프 (subgraphs)로 나누었고, 각각을 독립적으로 체크포인팅했습니다. 이제 처리 (processing) 단계의 실패가 수집 (intake) 단계를 롤백 (rollback)하지 않습니다. 어떤 버그의 영향 범위 (blast radius)도 하나의 서브 그래프로 축소되었습니다.

경계에서의 명시적 체크포인트 (Explicit checkpoints at boundaries). 체크포인터 (checkpointer)가 연결된 경우, 기본적으로 LangGraph는 모든 노드 이후에 체크포인트를 생성합니다. 이는 안전하게 들리지만 대부분 낭비적입니다. 우리는 의미 있는 재개 지점(resume points)에서 체크포인트를 생성합니다. 즉, 입력(intake)이 완료된 후, 비용이 많이 드는 각 모델 호출(model call) 이후, 그리고 인간의 확인 대기(human confirmation wait) 직전입니다. 이 사이의 단계들은 인메모리 (in-memory) 방식으로 처리해도 충분합니다. 이를 통해 Postgres 쓰기 볼륨을 약 60% 줄였으며, 더 중요한 것은 체크포인트가 실질적인 의미를 갖게 되었다는 점입니다. 체크포인트는 재개 의미론 (resume semantics)이 없는 내부 단계가 아니라, 사용자가 실제로 재개할 수 있는 상태를 나타내야 합니다.

멱등 노드 (Idempotent nodes). 이것이 가장 중요하며 아무도 말해주지 않는 부분입니다. 웹훅 (webhook)이 재시도되고, 실행이 중단되며, Oracle이 가끔 컨테이너를 재스케줄링하기 때문에, 어떤 노드든 동일한 상태에서 두 번 실행될 수 있습니다. 따라서 모든 노드는 동일한 입력으로 두 번 실행되었을 때 반드시 동일한 결과를 생성해야 합니다. 예를 들어, Claude enrichment 노드는 모델을 호출하기 전에 상태(state)에 이미 enrichment가 존재하는지 확인합니다. 만약 profile["enriched_at"]이 설정되어 있다면, 조기에 반환 (return early)합니다. 이 하나의 체크가 실제 비용을 절감했습니다. Claude 호출은 우리의 가장 큰 가변 비용이며, 멱등성 (idempotency)을 적용하기 전에는 재시도로 인해 비용이 조용히 두 배로 뛰고 있었습니다.

다음은 안정적인 버전의 스켈레톤 (skeleton) 코드입니다:

from langgraph.graph import StateGraph
from langgraph.checkpoint.postgres import PostgresSaver

...

이미 완료된 노드에서 return {}를 반환하는 것이 핵심 비결입니다. 첫 번째 재작성에서 다룬 명시적 리듀서 (reducers)와 결합하면, 재실행 시에도 상태가 절대 손상되지 않으며 비용이 이중으로 청구되지 않음을 의미합니다.

3주 전의 나에게 해주고 싶은 말

LangGraph는 사람들이 자신의 정신적 모델인 상태 머신 (state machine)처럼 동작할 것이라고 가정하고 사용하는 좋은 라이브러리입니다. 하지만 그렇지 않습니다. LangGraph는 스레드 ID (thread id)를 키로 하고, 시리얼라이저 (serializer)에 의해 영속화되며, 채널 (channels)에 대한 일련의 병합 연산 (merge operations)처럼 동작합니다. 제가 겪은 모든 버그는 이 두 가지 설명 사이의 간극에서 발생했습니다.

구체적으로는:

  • 노드를 단 하나라도 작성하기 전에 리듀서(reducer)의 의미론(semantics)을 읽으세요. 병합(merge) 동작에 대해 아무것도 가정하지 마세요. 기본값은 덮어쓰기(overwrite)이며, 이는 당신의 데이터를 소리 없이 삼켜버릴 것입니다.
  • 체크포인팅(checkpointing)을 원한다면 상태(state)에 커스텀 객체를 저장하지 마세요. 일반적인 딕셔너리(dict)와 표준 LangChain 타입은 깔끔하게 직렬화(serialize)됩니다. 그 외의 모든 것은 미래의 데이터 오염(corruption)을 초래할 것입니다.
  • 동시성 제어(concurrency control)는 그래프 외부에서 수행하세요. 멱등성(idempotent)을 보장하는 웹훅(webhook) 수집과 스레드 수준의 잠금(lock)을 사용하세요. 그래프는 실행(execution)을 위한 것이지, 조정(coordination)을 위한 것이 아닙니다.
  • 모든 노드가 아니라 재개 경계(resume boundaries)에서 체크포인팅을 하세요. 쓰기 작업이 줄어들고, 의미론이 명확해지며, Postgres 부하가 낮아집니다.
  • 모든 노드를 멱등하게(idempotent) 만드세요. 이는 재시도(retry)가 비용이 들지 않는 것과, 재시도 한 번에 Claude 호출 비용과 손상된 행(row)이 발생하는 것 사이의 차이입니다.

우리는 현재 벤처 캐피털(VC) 자금 없이, Oracle의 무료 후 저렴한 티어를 사용하여 두 가지 메시징 채널 모두에서 이를 프로덕션 환경에서 운영하고 있습니다. 저렴한 턴(turn)은 Groq로 라우팅하고, 비용을 정당화할 수 있는 단계에는 Claude를 예약합니다. 이 시스템은 웹훅 폭풍(webhook storms), 컨테이너 재스케줄링(reschedules), 그리고 일주일 동안 사라졌다가 흐름 중간에 돌아오는 사용자들 사이에서도 살아남습니다. 이 시스템은 영리해서 이렇게 된 것이 아닙니다. LangGraph가 암묵적으로 수행하며 제 사용 사례에서는 기본적으로 잘못 작동하는 세 가지 사항에 대해 명시적(explicit)으로 대응했기에 가능했습니다.

프레임워크가 저를 실망시킨 것이 아닙니다. 제 가정이 틀렸던 것입니다. 스키마(schema)는 언제나 계약(contract)이었지만, 제가 읽지 않았을 뿐입니다.

자주 묻는 질문 (Frequently Asked Questions)

Q: 리스트 필드에 기본 덮어쓰기(overwrite) 리듀서를 사용하는 것이 적절한 경우가 있나요?
A: 네 — 각 노드가 값을 완전히 대체하는 current_optionslast_tool_calls와 같은 필드의 경우, 덮어쓰기가 올바르며 add를 사용하면 오래된 항목들이 계속 누적될 것입니다. 규칙은 다음과 같습니다: 필드가 로그(log)나 이력(history)인 경우에는 추가(append)하고, 스냅샷(snapshot)인 경우에는 덮어쓰기(overwrite)를 하세요. 실수는 덮어쓰기를 선택하지 않는 것이 아니라, 결정을 내리지 않고 기본값이 당신을 대신해 결정하도록 내버려 두는 것입니다.

Q: 왜 SQLite나 인메모리 (in-memory) 체크포인터 대신 Oracle 상의 PostgresSaver를 사용했나요?
A: 인메모리 방식은 컨테이너 재시작 시 모든 데이터를 잃으며, 저희의 Oracle 컨테이너는 실제로 재스케줄링(rescheduled)됩니다. SQLite는 부하 상황에서 발생하는 다중 프로세스의 동시 쓰기를 견디지 못합니다. 커넥션 풀 (connection pool)을 사용하는 PostgresSaver는 동시 스레드를 처리하고 재시작 후에도 살아남습니다. 비용 측면에서는 관리형 Postgres 인스턴스 하나가 추가되지만, Oracle 티어에서 저희 규모로 운영할 경우 사실상 비용이 들지 않습니다.

Q: 메시지 전송과 같이 진정으로 멱등성 (idempotent)을 확보할 수 없는 노드는 어떻게 처리하나요?
A: 부수 효과 (side effect)의 중복 제거 키 (dedup key)를 상태 (state)로 옮기고 이를 확인하세요. WhatsApp 답장을 보내기 전에, 의도한 outbound_message_id를 상태에 기록한 다음, 메시지를 보내고, 전송 완료로 표시합니다. 재실행 시 sent가 true라면 해당 단계를 건너뜁니다. 전송 자체는 멱등적이지 않지만, 전송 여부를 결정하는 과정이 멱등적이며, 그것만으로도 충분합니다.

Q: 하나의 그래프를 세 개의 서브그래프 (subgraphs)로 나눈 것이 지연 시간 (latency)에 영향을 주었나요?
A: 미미하게 영향을 주었습니다. 추가적인 체크포인트 쓰기로 인해 경계(boundary)당 약 40~80ms의 추가 시간이 발생했지만, 4초가 걸리는 Claude 호출에 비하면 무시할 만한 수준입니다. 디버깅 및 장애 격리 (failure-isolation) 측면에서 얻은 이득이 그 비용을 수십 배 상쇄했습니다. 만약 모든 노드가 100ms 미만이고 값비싼 외부 호출이 없다면, 트레이드오프 (tradeoff)가 달라지므로 단일 그래프로도 충분할 수 있습니다.

Q: 에러가 발생하지 않았는데 상태가 조용히 삭제되는 것을 어떻게 감지했나요?
A: 모든 노드의 시작 부분에 상태에 존재하는 키와 메시지 개수를 덤프(dump)하는 구조화된 로그 라인을 추가했습니다. 삭제 문제는 즉시 나타났습니다. 2단계로 진입할 때 메시지 개수가 항상 1이었고, 전혀 늘어나지 않았기 때문입니다. 그러한 관찰 가능성 (observability)이 없었다면 저희는 여전히 추측만 하고 있었을 것입니다. 제어 흐름 (control flow)뿐만 아니라 상태의 형태 (state shape)를 로그로 남기세요.

— Elena Revicheva · AIdeazz · Portfolio

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0