Python & AI Tutorials Logo
LangChain & LangGraph

17. 상태 지속성과 체크포인팅

15장에서는 에이전트 루프를 StateGraph로 다시 만들었고, 16장에서는 사전 구축 컴포넌트와 다중 분기 라우팅으로 더 복잡한 에이전트를 만들었습니다. 그런데 지금까지 만든 그래프에는 공통된 한계가 하나 있습니다. 그래프가 State를 보관하지 않는다는 것입니다.

State는 invoke() 한 번의 실행 동안에만 그래프 안에서 유지되고 관리됩니다. invoke()를 호출하면 LangGraph는 새 State를 만들고, 노드를 실행하면서 리듀서 규칙에 따라 반환값을 State에 병합한 뒤, 최종 State를 호출한 쪽에 반환합니다. State를 반환하고 나면, 그래프는 그것을 기억하지 않습니다. 다음 invoke()는 이전 호출과 아무 관련이 없는 새 State에서 다시 시작합니다.

여기서 두 가지 문제가 나옵니다. 첫째, messages도 State의 일부이므로 에이전트는 이전 대화를 기억하지 못합니다. 둘째, 실행 도중 실패하면 그때까지 한 일이 통째로 사라집니다. 예를 들어, 세 번째 노드에서 예외가 나면 앞의 두 노드가 만들어 낸 결과가 모두 사라지기 때문에 처음부터 다시 실행해야 합니다. 15.1에서 LangGraph를 쓰는 이유 중 하나인 "작업 중단 복구"가 바로 이 문제입니다.

LangGraph는 이 일을 프레임워크 차원에서 처리합니다. 그래프에 체크포인터(checkpointer)를 연결하면, LangGraph가 실행 단계마다 State의 스냅샷을 자동으로 저장합니다. 저장된 상태는 invoke()가 끝난 뒤에도 남아, 다음 호출이 그 지점에서 이어받습니다. 이렇게 상태가 한 번의 실행을 넘어 유지되는 성질을 지속성(persistence)이라고 합니다.

사실 우리는 체크포인터를 이미 써 봤습니다. 11장에서 대화형 RAG에 멀티턴 대화를 붙일 때 create_agent(..., checkpointer=InMemorySaver())thread_id를 사용했습니다. 그때는 체크포인터가 thread_id별로 대화 히스토리를 자동 관리한다는 것만 알아 두고 넘어갔을 뿐, 그 일이 어떻게 일어나는지는 설명하지 않았습니다. 16장에서 checkpointer 파라미터를 소개하며 "동작 원리는 17장에서 자세히 다룹니다"라고 미뤄둔 것이 이 내용입니다.

이 장은 세 부분으로 구성됩니다. 17.1에서는 체크포인터를 그래프에 연결하고, thread_id로 여러 턴에 걸친 대화를 이어갑니다. 17.2에서는 저장된 체크포인트를 열어 에이전트가 어느 시점에 무엇을 알고 있었는지 들여다봅니다. 체크포인트는 에이전트가 이상하게 동작할 때 원인을 추적하는 핵심 도구입니다. 17.3에서는 실행 도중 실패한 그래프를 처음부터가 아니라 멈춘 지점부터 재개합니다.

17.1) 여러 호출에 걸쳐 State 유지하기

17.1.1) 기억하지 못하는 그래프

도입부에서 그래프가 State를 보관하지 않는다고 했습니다. 실제로 그런지 코드로 확인해 보겠습니다.

그래프 구조는 15.2에서 만든 say_hello 그래프와 같습니다. 단지 노드가 고정된 문자열 대신 LLM의 응답을 반환하도록 바꿨습니다.

python
from langgraph.graph import StateGraph, MessagesState, START, END
from langchain_openai import ChatOpenAI
 
model = ChatOpenAI(model="gpt-5-mini")
 
def llm_call(state: MessagesState):
    response = model.invoke(state["messages"])
    return {"messages": [response]}
 
builder = StateGraph(MessagesState)
builder.add_node(llm_call)
builder.add_edge(START, "llm_call")
builder.add_edge("llm_call", END)
 
graph = builder.compile()   # 체크포인터 없음

이제 두 번에 걸쳐 대화해 보겠습니다. 첫 번째 호출에서 이름을 알려주고, 두 번째 호출에서 이름을 물어봅니다.

python
# 첫 번째 호출 — 이름을 알려줍니다
graph.invoke({"messages": [{"role": "user", "content": "안녕하세요, 제 이름은 Bob이에요."}]})
 
# 두 번째 호출 — 이름을 물어봅니다
result = graph.invoke({"messages": [{"role": "user", "content": "제 이름이 뭐죠?"}]})
print(result["messages"][-1].content)

출력:

죄송하지만 저는 당신의 이름을 알지 못합니다. 이름이 무엇인지 알려주시겠어요?

두 번째 호출에서 모델이 받은 메시지는 "제 이름이 뭐죠?" 하나뿐입니다. 첫 번째 호출의 State가 그래프에 남아 있지 않으니, 이름이 담긴 앞선 메시지들이 함께 전달되지 않았습니다.

물론 우리가 직접 해결할 수도 있습니다. 첫 번째 호출이 반환한 messages를 보관해 두었다가 두 번째 호출에 함께 넣어주면 됩니다. 8장에서 대화 히스토리를 관리하던 방식 그대로입니다. 하지만 그렇게 하면 대화마다, 사용자마다 히스토리를 저장하고 꺼내오는 코드를 직접 만들어야 합니다. 체크포인터가 대신해 주는 일이 바로 이것입니다.

17.1.2) 체크포인트와 체크포인터

체크포인터(checkpointer)는 State를 저장하는 일을 맡는 객체입니다. InMemorySaver()처럼 인스턴스를 만들어 builder.compile(checkpointer=...)로 전달하면 그래프에 연결됩니다.

체크포인터가 연결되면, 그래프는 실행 도중 State를 통째로 복사해서 저장합니다. 이렇게 저장된 복사본 하나하나를 체크포인트(checkpoint)라고 부릅니다. 사진을 찍듯, 그 순간의 State 전체가 그대로 남는 것입니다.

비디오 게임의 자동 저장을 떠올리면 됩니다. 게임은 플레이어가 의미 있는 지점을 지날 때마다 진행 상황을 조용히 저장해 둡니다. 그래서 게임을 껐다 켜도 이어서 할 수 있고, 죽더라도 처음부터 다시 시작하지 않습니다. 체크포인터가 그래프에 해주는 일이 정확히 이것입니다.

그렇다면 "의미 있는 지점"은 언제일까요? LangGraph는 그래프의 실행을 여러 단계로 나누는데, 이 한 단계를 슈퍼스텝(super-step)이라고 부릅니다. 체크포인트는 슈퍼스텝이 하나 끝날 때마다 저장됩니다.

그냥 "스텝"이 아니라 "슈퍼스텝"인 이유는, 한 단계 안에서 여러 노드가 한꺼번에 실행될 수 있기 때문입니다. 17.1.1에서 만든 그래프처럼 노드가 한 줄로 이어져 있다면, 노드 하나를 실행하는 것이 곧 하나의 슈퍼스텝입니다. 하지만 여러 노드가 동시에 실행되는 그래프라면, 동시에 실행되는 그 노드들이 묶여서 하나의 슈퍼스텝을 이룹니다.

State 저장

State 저장

invoke 호출

슈퍼스텝 1
- node_x

슈퍼스텝 2
- node_y
- node_z

최종 State 반환

체크포인터

이렇게 invoke()를 한 번 호출하는 동안에도 체크포인트가 여러 개 쌓입니다. 어떤 체크포인트가 어떤 내용으로 남는지는 17.2에서 직접 꺼내 확인하겠습니다.

앞에서 예로 든 InMemorySaver가 가장 단순한 체크포인터입니다. 이름 그대로 체크포인트를 프로세스의 메모리(RAM)에 저장합니다. 따로 설치하거나 설정할 것이 없어서 학습과 로컬 개발에 적합합니다. 대신 프로세스가 재시작되면 저장된 체크포인트가 모두 사라집니다. 프로덕션에서 사용할 대안은 17.1.5에서 다루겠습니다.

17.1.3) 체크포인터 연결하기

체크포인터를 붙이는 데 필요한 것은 두 가지뿐입니다.

  1. 체크포인터 인스턴스를 만들어 compile()에 전달합니다.
  2. invoke()를 호출할 때 thread_id가 담긴 config를 함께 넘깁니다.

두 번째가 왜 필요한지는 곧 살펴보겠습니다. 지금은 체크포인터가 저장해 둔 여러 대화 중 어느 것을 이어갈지 알려주는 값이라고만 알아 두면 됩니다.

17.1.1에서 만든 그래프에 이 두 가지를 적용해 보겠습니다.

python
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_openai import ChatOpenAI
 
model = ChatOpenAI(model="gpt-5-mini")
 
def llm_call(state: MessagesState):
    response = model.invoke(state["messages"])
    return {"messages": [response]}
 
builder = StateGraph(MessagesState)
builder.add_node(llm_call)
builder.add_edge(START, "llm_call")
builder.add_edge("llm_call", END)
 
# 1. 체크포인터를 만들어 compile()에 전달합니다
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
 
# 2. invoke()에 thread_id가 담긴 config를 함께 전달합니다
config = {"configurable": {"thread_id": "1"}}
 
graph.invoke(
    {"messages": [{"role": "user", "content": "안녕하세요, 제 이름은 Bob이에요."}]},
    config,
)
result = graph.invoke(
    {"messages": [{"role": "user", "content": "제 이름이 뭐죠?"}]},
    config,
)
print(result["messages"][-1].content)

출력:

당신의 이름은 Bob입니다.

17.1.1과 똑같은 두 번의 호출인데 결과가 달라졌습니다. 이번에는 이름을 기억합니다.

모델이 이름을 기억한 이유는 이렇습니다. 체크포인터가 연결된 그래프는 llm_call 노드를 실행하기 전에 저장된 State를 먼저 불러옵니다. 그 State에는 첫 번째 대화가 이미 들어 있습니다. 여기에 우리가 넘긴 새 메시지가 병합됩니다. 15.2.2에서 배운 대로 messages 필드에는 add_messages 리듀서가 걸려 있으므로, 새 메시지는 기존 목록 뒤에 추가됩니다. 결국 모델이 받은 메시지는 세 개입니다. 인사말, 모델의 첫 답변, 그리고 새 질문입니다.

우리는 새 메시지만 보내고, 이전 대화는 LangGraph가 마지막 체크포인트에서 불러옵니다. 8장에서 수동으로 관리하던 대화 히스토리를 이제 LangGraph가 대신 관리합니다.

17.1.4) thread_id: 대화를 구분하는 식별자

thread_id는 대화 하나하나를 구분하는 식별자입니다. 값은 우리가 정합니다. 17.1.3에서는 "1"을 썼지만 아무 문자열이나 쓸 수 있습니다. 같은 thread_id로 호출하면 그 대화를 이어가고, 다른 thread_id로 호출하면 별개의 대화가 시작됩니다.

정말 그런지 확인해 보겠습니다. Alice와 Bob이 같은 그래프를 쓰면서 서로 다른 대화를 하도록 만들어 보겠습니다.

python
def send(thread_id: str, text: str) -> str:
    config = {"configurable": {"thread_id": thread_id}}
    result = graph.invoke(
        {"messages": [{"role": "user", "content": text}]},
        config,
    )
    return result["messages"][-1].content
 
# Alice의 대화
send("alice", "제가 가장 좋아하는 색은 청록색이에요.")
 
# Bob의 대화 — thread_id가 다릅니다
send("bob", "제가 가장 좋아하는 색은 주황색이에요.")
 
# 각자에게 다시 물어봅니다
print("Alice:", send("alice", "제가 가장 좋아하는 색이 뭐죠?"))
print("Bob:  ", send("bob", "제가 가장 좋아하는 색이 뭐죠?"))

출력:

Alice: 당신이 가장 좋아하는 색은 청록색입니다.
Bob:   당신이 가장 좋아하는 색은 주황색입니다.

두 대화 모두 같은 graph 객체, 같은 체크포인터를 사용했습니다. 그런데도 대화가 섞이지 않았습니다. thread_id는 체크포인터가 State를 저장하고 찾을 때 쓰는 기본 키(primary key)입니다. 키가 다르면 저장 공간도 완전히 분리됩니다.

그렇다면 이 값을 빠뜨리면 어떻게 될까요?

python
graph.invoke({"messages": [{"role": "user", "content": "안녕하세요"}]})

출력:

ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_id

그래프가 아예 실행되지 않습니다. 체크포인터가 연결된 그래프에서 thread_id는 선택 사항이 아니라 필수입니다.

방금 본 것을 생각하면 당연한 결과입니다. 체크포인터는 노드를 실행하기 전에 저장된 State를 불러와야 합니다. 어느 대화의 State인지 지정하는 식별자가 바로 thread_id인 것입니다.

이것이 챗봇 서비스의 기본 구조입니다. 그래프 하나, 체크포인터 하나, 그리고 사용자나 대화방마다 하나의 thread_id입니다.

17.1.5) InMemorySaver의 한계와 프로덕션 대안

앞에서 InMemorySaver가 체크포인트를 메모리에 저장한다고 했습니다. 여기에는 두 가지 한계가 따라옵니다.

프로세스를 재시작하면 모두 사라집니다. 서비스를 재배포하거나 서버를 다시 띄우면, 그동안 쌓인 대화가 전부 없어집니다.

여러 프로세스가 공유하지 못합니다. 실제 서비스는 요청을 여러 프로세스가 나눠 처리합니다. 그런데 프로세스마다 자기 메모리를 따로 가지므로, 프로세스 A가 저장한 대화를 프로세스 B는 찾지 못합니다. 사용자가 같은 thread_id로 요청해도, 어느 프로세스에 걸리느냐에 따라 대화가 이어지지 않습니다.

그래서 프로덕션에서는 체크포인트를 데이터베이스에 저장하는 체크포인터를 씁니다.

  • SqliteSaver / AsyncSqliteSaver (langgraph-checkpoint-sqlite) — 파일 하나에 저장합니다. 단일 서버로 돌리는 소규모 서비스나 로컬 프로토타입에 적합합니다.
  • PostgresSaver / AsyncPostgresSaver (langgraph-checkpoint-postgres) — 데이터베이스 서버에 저장합니다. 서버를 여러 대로 늘려도 모든 프로세스가 같은 체크포인트를 봅니다. LangGraph 공식 문서가 프로덕션용으로 권장하는 체크포인터입니다.

이들 모두 InMemorySaver와 같은 인터페이스를 따릅니다. 그래서 그래프 코드도, 노드도, thread_id 사용법도 바뀌지 않습니다. 바뀌는 것은 체크포인터를 만드는 부분뿐입니다.

16장에서 배운 create_agent도 같은 방식으로 체크포인터를 씁니다. checkpointer 파라미터에 체크포인터를 넘기고, invoke()thread_id가 담긴 config를 함께 전달하면 됩니다. 11장에서 대화형 RAG가 이전 대화를 기억했던 것이 바로 이것입니다.

이 장에서는 계속 InMemorySaver를 사용하겠습니다. 저장 위치가 어디든 체크포인트를 다루는 방법은 똑같기 때문입니다.

17.2) State 검사와 디버깅

17.1.2에서 체크포인터가 슈퍼스텝마다 State를 저장한다고 했습니다. 저장된 체크포인트를 이제 꺼내 보겠습니다.

저장된 체크포인트를 꺼내 보는 것은 단순한 호기심 때문이 아닙니다. 에이전트가 이상하게 동작할 때가 있습니다. 도구를 계속 호출하며 끝나지 않거나, 이전 대화를 기억하지 못하거나, 엉뚱한 노드로 갑니다. 이럴 때 원인을 찾으려면 그 시점에 State가 어땠는지 알아야 합니다. 체크포인트가 그 답을 가지고 있습니다.

LangGraph는 두 가지 메서드를 제공합니다.

  • graph.get_state(config) — 그 대화의 가장 최근 체크포인트 하나를 반환합니다.
  • graph.get_state_history(config) — 그 대화의 모든 체크포인트를 최신 순으로 반환합니다.

둘 다 configthread_id가 필요합니다. 어느 대화의 체크포인트인지 알려줘야 하기 때문입니다.

두 메서드가 돌려주는 체크포인트는 StateSnapshot 이라는 객체로 표현됩니다.

17.2.1) StateSnapshot: 체크포인트에 담긴 것

체크포인트에 무엇이 담기는지 보겠습니다. 이번 예제에는 LLM을 쓰지 않습니다. LLM은 매번 다른 값을 반환하므로, 필드 하나하나를 짚어 설명하기에 적합하지 않습니다. 대신 고정된 값을 반환하는 작은 그래프를 만들겠습니다.

State에는 두 종류의 필드를 두겠습니다. foo는 리듀서가 없어 덮어쓰기되고, bar는 리듀서가 있어 누적됩니다. 15.2.2에서 messages 필드에 add_messages 리듀서를 붙였던 것과 같은 방식입니다. 여기서는 파이썬의 operator.add를 리듀서로 써서 리스트를 이어 붙입니다.

python
from operator import add
from typing_extensions import TypedDict, Annotated
 
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
 
class State(TypedDict):
    foo: str                        # 리듀서 없음 → 덮어쓰기
    bar: Annotated[list[str], add]  # add 리듀서 → 누적
 
def node_a(state: State):
    return {"foo": "a", "bar": ["a"]}
 
def node_b(state: State):
    return {"foo": "b", "bar": ["b"]}
 
builder = StateGraph(State)
builder.add_node(node_a)
builder.add_node(node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)
 
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "1"}}
graph.invoke({"foo": "", "bar": []}, config)
 
snapshot = graph.get_state(config)
print(snapshot)

출력:

StateSnapshot(
    values={'foo': 'b', 'bar': ['a', 'b']},
    next=(),
    config={'configurable': {'thread_id': '1', 'checkpoint_ns': '',
                             'checkpoint_id': '1f17da03-9654-65d8-8002-9e59231bb481'}},
    metadata={'source': 'loop', 'step': 2, 'parents': {}},
    created_at='2026-07-12T03:17:33.637368+00:00',
    parent_config={'configurable': {'thread_id': '1', 'checkpoint_ns': '',
                                    'checkpoint_id': '1f17da03-9653-6ca0-8001-7c24c8ec66f2'}},
    tasks=(),
    interrupts=()
)

여덟 개의 필드가 있습니다. 하나씩 살펴보겠습니다.

  • values — 이 체크포인트 시점의 State입니다. bar['a', 'b']인 것은 add 리듀서가 두 노드의 값을 누적했기 때문이고, foo'b'인 것은 리듀서가 없어 마지막 값이 덮어썼기 때문입니다. "그 시점에 State가 어땠는가" 에 답하는 필드입니다.
  • next — 이 체크포인트 다음에 실행될 노드 이름의 튜플입니다. 빈 ()이면 실행할 노드가 없다는 뜻, 즉 그래프가 끝난 것입니다. ('node_b',)이면 아직 node_b가 남아 있다는 뜻입니다.
  • config — 이 체크포인트의 주소입니다. thread_id는 대화 식별자이고, checkpoint_id는 그 대화의 어느 시점인지를 가리킵니다. LangGraph가 체크포인트를 저장할 때마다 자동으로 붙이는 값입니다.
  • metadata — 실행 정보입니다. source는 이 체크포인트가 어디서 왔는지 알려줍니다. "input"invoke()에 넘긴 입력에서 만들어진 것이고, "loop"는 그래프가 실행되면서 만들어진 것입니다. step은 슈퍼스텝 번호입니다.
  • created_at — 이 체크포인트가 저장된 시각입니다. 로그와 대조할 때 참조하면 좋습니다.
  • parent_config — 바로 이전 체크포인트의 config입니다. 이것을 따라가면 실행 기록을 거꾸로 거슬러 올라갈 수 있습니다. 첫 체크포인트에서는 None입니다.
  • tasksnext에 있는 노드의 실행 기록입니다. 체크포인트가 저장되는 시점에는 이 노드들이 아직 실행되기 전입니다. 그 노드가 끝나면 결과가 이 체크포인트에 덧붙습니다. 성공했다면 반환값이 result에, 실패했다면 예외가 error에 담깁니다.
  • interrupts — 그래프가 사람에게 제어를 넘기며 멈춘 지점입니다. LangGraph는 실행 도중 멈춰서 사람이 승인하거나 값을 채워 넣기를 기다릴 수 있는데, 그 지점이 여기에 기록됩니다.

각 필드는 속성으로 꺼내서 확인할 수 있습니다. 다만 metadata 안의 값은 딕셔너리이므로 키로 꺼냅니다.

python
snapshot = graph.get_state(config)
 
print(snapshot.values)            # {'foo': 'b', 'bar': ['a', 'b']}
print(snapshot.next)              # ()
print(snapshot.metadata["step"])  # 2

이 중 디버깅에 자주 사용하게 되는 것은 next 입니다. next가 비어 있지 않다면, 그래프가 끝까지 가지 못하고 중간에 멈췄다는 뜻입니다. 17.3에서 실패한 그래프를 재개할 때도 이 필드가 출발점이 됩니다.

17.2.2) 체크포인트 기록 살펴보기

get_state()는 최신 체크포인트 하나만 보여줍니다. 하지만 디버깅에서는 "어쩌다 여기까지 왔는가"를 물어야 할 때가 많습니다. 그러려면 실행 전체의 궤적을 봐야 합니다. get_state_history()가 그것을 돌려줍니다.

python
for snap in graph.get_state_history(config):
    print(f"step={snap.metadata['step']:>2}  "
          f"next={str(snap.next):<16}  values={snap.values}")

출력:

step= 2  next=()                values={'foo': 'b', 'bar': ['a', 'b']}
step= 1  next=('node_b',)       values={'foo': 'a', 'bar': ['a']}
step= 0  next=('node_a',)       values={'foo': '', 'bar': []}
step=-1  next=('__start__',)    values={'bar': []}

최신 체크포인트가 맨 위에 나옵니다. 시간 순서로 읽으려면 아래에서 위로 읽어야 합니다.

  • step -1invoke()가 입력을 받은 직후입니다. 그런데 우리가 넘긴 {"foo": "", "bar": []}values에 보이지 않습니다. 입력을 State에 넣는 것도 하나의 단계인데, 아직 그 단계가 실행되지 않았기 때문입니다. next에 있는 __start__가 바로 그 일을 하는 내부 노드입니다.

    bar[]로 보이지만 이것은 파라미터로 넘긴 값이 반영된 것이 아닙니다. 리듀서가 있는 필드는 값이 쌓일 빈 자리를 처음부터 갖고 있습니다. 리듀서가 없는 foo는 그런 자리가 없어서 아예 나타나지 않습니다.

  • step 0__start__가 실행되어 입력이 State에 들어갔습니다. 이제 foo='', bar=[]로 파라미터로 넘긴 값이 반영되었습니다. 다음은 node_a 차례입니다.
  • step 1node_a가 실행된 결과입니다. foo'a', bar['a']가 됐고, 다음은 node_b입니다.
  • step 2node_b가 실행된 결과입니다. next가 비어 있으니 그래프가 끝났습니다.

step 0과 step 1의 next가 비어 있지 않다고 해서 멈춘 것은 아닙니다. 실행 중인 체크포인트라면 다음에 실행할 노드를 갖고 있는 것이 당연합니다. 17.2.1에서 "next가 비어 있지 않으면 멈춘 것"이라고 한 것은 문제가 발생한 시점의 체크포인트를 두고 한 이야기입니다. 기록 중간의 next는 그래프가 어느 경로로 갔는지를 보여줍니다.

17.3) 실패한 지점에서 재개하기

슈퍼스텝이 끝날 때마다 State가 저장되므로, 실행 도중 실패해도 그때까지의 결과는 체크포인트에 남아 있습니다. 처음부터 다시 실행할 이유가 없습니다. 멈춘 지점부터 이어가면 됩니다.

17.3.1) invoke(None, config)로 재개하기

재개하는 방법은 간단합니다. 입력 자리에 None을 넣고 호출합니다.

python
graph.invoke(None, config)

"새 입력은 없다. 저장된 State에서 이어서 실행하라"는 뜻입니다. config에는 물론 thread_id가 들어 있어야 합니다. 어느 대화를 이어갈지 알려줘야 하니까요.

직접 실패를 만들어 놓고 재개해 보겠습니다. 노드가 둘인 그래프를 만들고, 두 번째 노드가 처음 실행될 때만 실패하도록 하겠습니다. 재시도했을 때 성공해야 재개가 동작하는 것을 확인할 수 있기 때문입니다.

python
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
 
class State(TypedDict):
    step_1_done: bool
    step_2_done: bool
 
first_try = True   # 첫 실행에서만 실패시키기 위한 플래그
 
def step_1(state: State):
    print("step_1 실행 (비싼 작업)")
    return {"step_1_done": True}
 
def step_2(state: State):
    global first_try
    if first_try:
        first_try = False
        print("step_2 실패 (API 타임아웃)")
        raise RuntimeError("외부 API 시간 초과")
    print("step_2 실행")
    return {"step_2_done": True}
 
builder = StateGraph(State)
builder.add_node(step_1)
builder.add_node(step_2)
builder.add_edge(START, "step_1")
builder.add_edge("step_1", "step_2")
builder.add_edge("step_2", END)
 
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "job-42"}}
 
try:
    graph.invoke({"step_1_done": False, "step_2_done": False}, config)
except RuntimeError as e:
    print("실패:", e)

출력:

step_1 실행 (비싼 작업)
step_2 실패 (API 타임아웃)
실패: 외부 API 시간 초과

step_1은 성공했고 step_2에서 예외가 났습니다. 어디서 멈췄는지 확인해 보겠습니다. 17.2에서 배운 get_state()를 씁니다.

python
snapshot = graph.get_state(config)
print("next   =", snapshot.next)
print("values =", snapshot.values)

출력:

next   = ('step_2',)
values = {'step_1_done': True, 'step_2_done': False}

next('step_2',)입니다. 그래프가 step_2 실행 중에 멈췄다는 뜻입니다. values를 보면 step_1_doneTrue입니다. step_1의 결과가 체크포인트에 남아 있습니다.

이제 None으로 재개합니다.

python
result = graph.invoke(None, config)
print("최종 =", result)

출력:

step_2 실행
최종 = {'step_1_done': True, 'step_2_done': True}

step_1 실행 (비싼 작업)이 출력되지 않았습니다. step_1은 다시 실행되지 않은 것입니다. LangGraph가 저장된 State를 불러와 step_2부터 이어갔기 때문입니다. 비싼 첫 단계의 비용을 두 번 지불하지 않았습니다.

17.3.2) 재개할 때 조심할 것

재개하면 실패한 노드가 다시 실행됩니다. 만일 실패한 노드가 LLM을 호출하거나 외부 API를 부른다면, 그 호출도 다시 일어납니다. 그리고 다른 결과를 돌려줄 수도 있습니다.

여기에 함정이 있습니다. step_2가 이메일을 보낸 뒤에 실패했다면, 재개할 때 이메일이 한 번 더 나갑니다. LangGraph는 성공한 노드를 다시 실행하지 않는 것만 보장합니다.

그래서 다시 실행될 수 있는 노드는 멱등적(idempotent) 이어야 합니다. 같은 일을 두 번 해도 결과가 달라지지 않아야 한다는 뜻입니다. 이메일을 보내기 전에 이미 보냈는지 확인하거나, 데이터베이스에 고유 키를 두어 중복 삽입을 막는 식입니다.