본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 18. 04:43

세 번의 LangGraph 재작성: 프로덕션 환경에서 상태 유지 에이전트(Stateful Agents)의 실제 비용

요약

LangGraph를 사용하여 프로덕션 환경에서 상태 유지 에이전트를 구축할 때 발생하는 데이터 유실 문제와 그 원인을 분석합니다. TypedDict의 한계와 LangGraph 리듀서의 상태 병합 방식이 초래하는 조용한 실패 사례를 통해 실무적인 교훈을 전달합니다.

핵심 포인트

  • LangGraph 리듀서의 기본 동작인 '덮어쓰기'로 인한 데이터 유실 주의
  • TypedDict는 런타임 시 키 검증이나 형태 강제를 수행하지 못함
  • 동시 부하 상황에서 상태 스키마 설계 오류는 조용한 실패를 유발함
  • 프로덕션 환경에서는 상태 모델의 엄격한 검증이 필수적임

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

3주 동안, 우리의 인테이크 에이전트(intake agent)는 처리하는 모든 작업을 소리 없이 폐기했습니다. 에러도 없었고, 예외(exception)도 없었습니다. 텔레그램(Telegram) 봇은 "확인했습니다, 작업 중입니다"라고 답변했지만, 그 후 큐(queue)에 들어온 것은 아무것도 없었습니다. 로그는 깨끗했습니다. 그래프는 처음부터 끝까지 실행되었습니다. 저는 TypedDict 필드 이름의 오타 하나—하류 노드(downstream node)가 result를 읽어야 할 곳에 task_result라고 적은 것—를 찾아내기 전까지 약 1,100개의 사용자 요청을 잃었습니다. LangGraph는 상태 업데이트를 병합(merge)했고, 키 충돌(key collision)을 발견하지 못했으며, 아무런 불만 없이 데이터를 허공으로 던져버렸습니다.

그 버그가 바로 이 글이 존재하는 이유 전체입니다. LangGraph의 상태 유지 에이전트(stateful agents) 프로덕션 체크포인팅(checkpointing)은 문서상으로는 해결된 문제처럼 판매됩니다. 체크포인터(checkpointer)를 연결하면 내구성이 있는 실행(durable execution)을 얻고, 실패 시 재개할 수 있다는 식입니다. 하지만 현실은 실패 모드(failure modes)가 조용하고, 상태 모델(state model)은 가차 없으며, 튜토리얼용 그래프와 실제 텔레그램 및 WhatsApp 트래픽이 발생하는 일주일 동안 버텨내는 그래프 사이의 간극은 세 번의 재작성만큼이나 넓습니다. 다음은 각각의 과정이 저에게 가르쳐준 것들입니다.

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

첫 번째 버전은 표준 패턴을 따랐습니다. TypedDict 상태, 몇 개의 노드, 조건부 엣지(conditional edges). 노트북에서는 잘 작동했습니다. 로컬 테스트에서도 잘 작동했습니다. Oracle Cloud VM에 배포하자마자 즉시 데이터를 잃기 시작했습니다. 하지만 이는 정확히 로그를 한 줄씩 지켜보지 않게 되는 상황인 동시 부하(concurrent load) 상황에서만 발생했습니다.

근본 원인은 LangGraph 리듀서(reducers)가 상태 병합을 처리하는 방식에 있었습니다. 노드가 부분적인 상태 딕셔너리(state dict)를 반환하면, LangGraph는 당신이 정의한 리듀서, 또는 기본값인 _덮어쓰기(overwrite)_를 사용하여 이를 채널(channel)에 병합합니다. 만약 두 노드가 동일한 키에 쓰기를 수행하고 당신이 누적(accumulation)을 기대했다면, 마지막에 쓴 값만 남게 됩니다. 만약 어떤 노드가 다른 어떤 노드도 읽지 않는 키를 쓴다면, 그 값은 덮어씌워질 때까지 상태에 머물러 있게 되며, 아무도 그것이 고립(orphaned)되었다는 사실을 알아차리지 못합니다.

저의 intake node는 {"task_result": parsed}를 반환했습니다. 저의 persistence node는 state["result"]를 읽었으나, 해당 키가 누락된 것을 발견했고 기본값은 빈 리스트(empty list)였습니다. 빈 리스트이므로 영속화(persist)할 것이 없다고 판단하여 성공을 반환했습니다. 그래프는 완료를 보고했습니다. Python은 결코 에러를 발생시키지 않았는데, 이는 TypedDict가 런타임(runtime) 시점에서는 타이핑 허구(typing fiction)이기 때문입니다. 즉, 키를 검증하지도 않고, 형태(shape)를 강제하지도 않으며, 단지 에디터를 기쁘게 할 뿐 아무런 역할도 하지 않습니다.

해결 방법:

  • 상태(state)를 TypedDict에서 extra="forbid" 설정이 포함된 Pydantic 모델로 전환했습니다. 이제 선언되지 않은 필드에 쓰기를 시도하면 사라지는 대신 노드 경계(node boundary)에서 에러가 발생합니다.
  • 누적되어야 하는 모든 채널(channel)에 명시적인 Annotated 리듀서(reducer)를 추가했습니다. 메시지를 추가(append)하고 싶다면 Annotated[list, operator.add]와 같이 명시합니다. 그 외의 모든 것은 의도된 설계에 따라 덮어쓰기(overwrite)됩니다. 실수로 발생하는 것이 아닙니다.
  • 모든 파이프라인의 끝에 불변성(invariants)을 확인하는 단일 assertion node를 작성했습니다 — "만약 상태가 complete라면, result는 반드시 null이 아니어야 한다"는 식입니다. 이 노드 하나만 있었어도 3주 동안 지속된 버그를 첫날에 잡아냈을 것입니다.

교훈은 냉혹합니다: LangGraph의 상태 모델은 자유를 주는 대신 그 대가를 요구합니다. 기본 동작은 침묵합니다. 스스로 가드레일(guardrails)을 구축하지 않는 한, 데이터 손실이라는 청구서를 받게 될 것입니다.

두 번째 재작성: 체크포인트 오염과 실패한 재개

상태가 정직해진 후, 저는 체크포인팅(checkpointing)을 활성화했습니다. 이 기능의 약속은 실재하며 매우 중요합니다. 예를 들어, 사용자 요청을 저렴한 분류를 위해 Groq으로 라우팅하고, 무거운 추론을 위해 Claude로 보낸 뒤, 도구 호출(tool call)을 거쳐 영속화하는 긴 다단계 파이프라인의 경우, 중간에 충돌이 발생하더라도 모든 것을 다시 실행하는 대신 마지막으로 완료된 노드부터 재개할 수 있습니다. Claude 토큰 비용이 만만치 않은 상황에서, 일시적인 장애가 발생할 때마다 5단계 파이프라인을 처음부터 다시 실행하는 것은 돈을 불태우는 것과 같습니다.

가장 먼저 SQLite를 사용했는데, 모두가 기본적으로 선택하는 방식이기 때문입니다. WhatsApp과 Telegram 에이전트를 처리하는 단일 VM 환경에서 동시 쓰기 작업(concurrent writes)을 하는 SQLite는 함정입니다. 저는 처음 100개의 동시 스레드 내에서 database is locked 오류를 만났습니다. 더 심각한 것은, 절반만 작성된 상태로 재개되는 체크포인트에 도달했다는 것입니다. 프로세스가 OOM(Out of Memory)으로 종료되었을 때 업데이트 중간에 체크포인트가 기록되었고, 이를 재개하자 그래프는 한 채널은 새 단계를 반영하고 다른 채널은 이전 단계를 반영하는 상태를 로드했습니다.

이것이 바로 체크포인트 손상(checkpoint corruption)이며, 이 역시 아무런 경고 없이 발생합니다. 그래프는 재개되어 일관성 없는 상태(inconsistent state)에 대해 다음 노드를 실행하고 그럴듯해 보이지만 잘못된 답변을 생성합니다. 사용자는 자신이 한 적 없는 요청을 참조하는 WhatsApp 답장을 받았는데, 이는 재개된 스레드가 두 개의 체크포인트를 혼합했기 때문이었습니다.

제가 수정한 부분은 다음과 같습니다:

  • SQLite에서 Oracle의 Postgres로 전환했습니다 (PostgresSaver 사용). 동시 쓰기가 더 이상 복권 추첨 같은 일이 아니게 되었습니다. Postgres는 전체 파일을 잠그는 대신 행 수준 잠금(row-level locking)을 처리하므로, database is locked 오류가 0으로 줄었습니다.
  • 체크포인트 기록이 노드 측 효과(node side effect)와 원자적(atomic)이라고 믿지 않게 되었습니다. 그렇지 않습니다. 만약 노드가 외부 API를 호출하고 그 후에 체크포인트가 커밋된다면, 그 사이에 충돌이 발생했을 경우 재개 시 API 호출을 다시 실행해야 합니다. 메시지를 보내거나, 비용을 청구하거나, 채널에 게시하는 등 비멱등성(non-idempotent) 호출의 경우 이는 중복 작업이 됩니다. 저는 모든 부수 효과를 일으키는 노드를 명시적인 중복 제거 키(dedup key)를 상태에 저장하여 멱등성(idempotent)을 갖도록 만들었습니다.
  • 재개 시 체크포인트 무결성 검사(sanity check)를 추가했습니다: 체크포인트를 로드하고, 동일한 불변성 단언(invariant assertions)을 실행하며, 만약 이들이 실패하면 손상된 상태로 재개하는 대신 체크포인트를 폐기하고 처음부터 스레드를 재시작합니다.

여기서의 구체적인 수치는 다음과 같습니다. Postgres로 전환하고 재개 시 불변성 검사를 추가한 덕분에, 저희

체크포인팅으로 인해 악화된 라우팅 문제

아무도 경고해주지 않는 2차적인 효과가 있습니다. 저희는 작업(task)에 따라 Groq과 Claude 사이를 라우팅합니다. 분류, 포맷팅, 그리고 깊이보다 지연 시간(latency)이 더 중요한 모든 작업에는 Groq의 Llama 모델을 사용하고, 실제 품질이 필요한 추론(reasoning)에는 Claude를 사용합니다. 이 라우팅 결정은 그래프 상태(graph state)에 저장됩니다.

체크포인트(checkpoint)에서 재개할 때, 라우팅 결정은 해당 체크포인트에 동결(frozen)됩니다. 만약 그 이후에 라우팅 로직을 변경했다면 — 예를 들어 비용 절감을 위해 특정 작업 클래스를 Claude에서 Groq으로 옮겼다면 — 재개된 스레드는 이전 경로를 실행합니다. 왜냐하면 경로는 재개 시 재평가되는 코드가 아니라, 체크포인트 내의 데이터이기 때문입니다.

저는 작업 클래스를 명시적으로 Groq으로 옮겼음에도 불구하고, 재개된 스레드 묶음이 계속해서 Claude에 비용을 청구하는 것을 보고 이 사실을 깨달았습니다. 해결책은 결정된 모델을 상태(state)에 저장하는 것을 중단하고, 대신 작업 클래스만 저장한 다음 노드가 실행될 때마다 모델을 새로 결정(resolve)하도록 하는 것이었습니다. 변경될 가능성이 있는 코드에 의존하는 결정은 체크포인트에 동결되어서는 안 됩니다. 결정의 결과가 아니라, 결정에 필요한 입력값(inputs)을 저장하십시오.

이것은 LangGraph 상태 유지 에이전트(stateful agents)의 프로덕션 체크포인팅을 위한 일반적인 규칙입니다: 체크포인트는 타임캡슐입니다. 체크포인트에 넣는 모든 것은 해당 스레드가 완료되거나 만료될 때까지 모든 코드 변경 사항에 걸쳐 지원할 것을 약속하는 것입니다. 최소한으로 유지하십시오. 지속적인 사실(durable facts)은 저장하고, 유도 가능한 것(derivable ones)은 다시 계산하십시오.

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

세 번째 재작성은 새로운 프레임워크나 영리한 트릭이 아니었습니다. 그것은 제가 처음부터 시작했어야 했던 규율(discipline)이었습니다. 저는 모든 파이프라인을 다음 세 가지 규칙을 따르는 노드(nodes)로 분리했습니다:

  1. 모든 노드는 상태(state)에 최대 하나의 외부 효과(external effect)가 더해진 순수 함수(pure function)여야 합니다. 어떤 노드도 Claude를 호출하면서 동시에 Postgres에 기록하고 Telegram 메시지까지 보내서는 안 됩니다. 노드당 하나의 부수 효과(side effect)만 허용합니다. 이는 더 많은 노드를 의미하지만, 각 노드는 독립적으로 재개(resumable) 가능하며 독립적으로 멱등성(idempotent)을 가집니다. 무언가 실패했을 때, 저는 정확히 어떤 효과를 안전하게 처리해야 하는지 알 수 있습니다.

  2. 부수 효과는 중복 제거(dedup) 확인 이후에 발생해야 하며, 그 이전이 아닙니다. 부수 효과를 일으키는 각 노드는 먼저 상태(state)에서 중복 제거 키(dedup key)를 확인합니다. 키가 존재하면 효과를 건너뛰고 반환합니다. 키는 효과의 결과와 동일한 업데이트 시점에 상태에 기록됩니다. 재개(resume) 시, 중복 제거 키는 해당 효과가 이미 발생했음을 알려줍니다.

  3. 상태 스키마(state schema)는 그래프의 진입과 진출 시, 그리고 모든 체크포인트 재개(checkpoint resume) 시에 검증됩니다. Pydantic 모델, extra="forbid", 명시적 리듀서(reducers), 불변성 단언(invariant assertion) 노드를 사용합니다. 그래프는 잘못된 형식의 상태(malformed state)로는 실행될 수 없으며, 경계(boundary)에서 명확하게 오류를 발생시킵니다.

이 패턴에 따른 노드의 형태는 다음과 같습니다:

def send_reply_node(state: AgentState) -> dict:
    if state.reply_sent_id is not None:
        # 충돌 전 이전 실행에서 이미 전송됨; 건너뜀
...```

우아하지는 않습니다. 방어적입니다. 하지만 앞선 두 버전을 무너뜨렸던 부하를 견뎌냈습니다. Oracle에서 운영 중인 WhatsApp 및 Telegram 에이전트의 경우, 파이프라인 완료율은

데이터 손실을 방지하고 싶다면 상태(state)를 위해 `TypedDict`를 사용하지 마세요. 그것은 아무것도 검증하지 않습니다. 엄격 모드(strict mode)를 사용하는 Pydantic을 사용하세요. 3주 동안 조용히 발생한 장애에 비하면 성능 비용은 무의미합니다.

"내구성 있는 실행 (durable execution)"이 당신의 부수 효과(side effects)까지 안전하다는 의미는 아니라는 점을 신뢰하지 마세요. 체크포인트(checkpoint)와 부수 효과는 트랜잭션(transactionally)으로 연결되어 있지 않습니다. 상태에 저장하는 키를 사용하여 모든 외부 호출을 멱등(idempotent)하게 만들거나, 재개(resume) 시 중복이 발생할 수 있음을 받아들이세요.

그리고 파생된 결정 사항을 체크포인트에 저장하지 마세요. 당신이 선택한 경로, 선택한 모델, 타임스탬프 기반의 분기 — 입력값(inputs)을 저장하고 출력값(output)은 다시 계산하세요. 해당 스레드가 만료되기 전에 당신의 코드는 변경될 것이며, 체크포인트는 조용히 오래된 로직(stale logic)을 제공할 것입니다.

LangGraph는 기본적으로 당신을 위해 해주는 일이 거의 없으며, 그렇지 않다고 가정할 때 조용히 비용을 청구한다는 사실을 내재화하고 나면 진정으로 훌륭한 도구입니다. 그래프 추상화(graph abstraction)는 견고합니다. 상태(state)와 체크포인트 메커니즘은 모든 엣지(edge)에서 날카롭습니다. 세 번의 재작성은 그에 따른 세금(tax)이었습니다. 당신은 이런 일을 겪지 않도록 이 글을 씁니다.

## 자주 묻는 질문 (Frequently Asked Questions)

**Q: 트래픽이 중간 정도라면 프로덕션 환경에서 그냥 MemorySaver나 SQLite checkpointer를 사용하면 안 되나요?**  
A: SQLite는 파일 잠금(file lock)을 통해 쓰기를 직렬화하며, 동시 스레드 환경에서는 디스크 성능에 따라 50회에서 수백 회 사이의 동시 쓰기 시점에서 `database is locked` 오류가 발생합니다. MemorySaver는 재시작 시 모든 것을 잃으므로 체크포인팅의 의미가 퇴색됩니다. 무료 티어 Oracle 인스턴스에서 PostgresSaver를 사용하는 것은 추가 비용이 들지 않으면서 잠금 오류 클래스 전체를 제거해 줍니다. 도박을 할 이유가 없습니다.

**Q: 프로세스가 OOM-kill(메모리 부족으로 인한 종료)되었을 때, 업데이트 도중에 작성된 체크포인트는 어떻게 처리하나요?**  
A: 체크포인트와 노드의 부수 효과가 함께 커밋되었다고 가정하지 마세요. 그렇지 않습니다. 재개 시, 계속 진행하기 전에 상태 불변성 단언(state invariant assertions)을 다시 실행하세요. 만약 실패한다면, 일관성 없는 상태(inconsistent state)로 재개하는 대신 체크포인트를 폐기하고 스레드를 다시 시작하세요. 이 방법을 통해 우리의 일관성 없는 답변(incoherent-reply) 사고를 주당 약 8건에서 1건 미만으로 줄였습니다.

**Q: Pydantic 상태 검증 (state validation)이 노드당 유의미한 지연 시간 (latency)을 추가하나요?**
A: 수십 개의 필드를 가진 상태 객체 (state object)의 경우, 검증 시간은 1밀리초 미만입니다. 이는 Groq에서 200ms, Claude에서 수 초가 걸리는 LLM 호출에 비하면 완전히 미미한 수준입니다. 엄격한 검증에 대한 "성능"상의 반론은 이론적일 뿐입니다. 검증을 _하지 않음_으로써 발생하는 비용은 몇 주 동안 소리 없이 사라져 버리는 데이터입니다.

**Q: 체크포인트 상태 (checkpoint state)에 해결된 모델 (resolved model) 대신 태스크 클래스 (task class)를 저장하는 이유는 무엇인가요?**
A: 체크포인트는 타임캡슐과 같습니다. 코드를 변경한 후라도 재개 (resume) 시점에 체크포인트에 동결 (freeze)된 내용은 이전 로직에 따라 실행됩니다. 우리는 태스크 클래스를 Groq로 재라우팅한 후에도, 체크포인트에 해결된 모델이 동결되어 있었기 때문에 Claude에 비용을 청구하는 스레드를 재개한 적이 있습니다. 결정에 사용된 입력값 (decision inputs)을 저장하고, 노드 내에서 결정을 다시 계산하십시오.

**Q: 노드당 하나의 부작용 (side effect)을 갖게 되면 노드 수가 폭발적으로 늘어나는 것 같은데, 그만한 가치가 있나요?**
A: 복잡한 파이프라인 (pipeline)에서 노드 수가 대략 두 배로 늘어난 것은 사실입니다. 하지만 각 노드가 독립적으로 재개 가능하고 멱등성 (idempotent)을 갖게 되었으며, 이것이 완료율 (completion rate)을 99.3%까지 끌어올린 핵심입니다. LLM을 호출하고, DB에 쓰고, 메시지를 보내는 하나의 노드는 실패할 지점이 세 곳이나 되며 깔끔한 재개 지점이 없습니다. 이들을 분리하는 것이야말로 체크포인팅 (checkpointing)이 중복된 부작용 대신 실제로 내구성 있는 실행 (durable execution)을 제공하게 만드는 방법입니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0