하위 호환성
하위 호환성 (Backward compatibility)
프로덕션에서 실행 중인 런(run)을 깨뜨리지 않고 LangGraph 그래프 코드를 업데이트해요.
소프트웨어는 프로덕션에서 변해야 합니다. 새 요구사항, 버그 수정, 리팩토링이 결국 그래프 코드에 반영되기 마련이죠. LangGraph는 기존 스레드(threads)를 위해 영속화된 상태(state)를 상대로 최신 배포된 그래프를 실행하기 때문에, 배포하는 모든 변경은 기존 체크포인트(checkpoint) 기준으로는 사실상 하위 호환 API 변경이 됩니다.
출처: 문서
본문
런을 시작했을 때의 코드 버전에 고정하는 워크플로우 엔진과 달리, LangGraph는 최신 그래프를 모든 스레드(새 스레드는 물론 체크포인트에서 이어지는 스레드)에 즉시 적용해요. 이것은 편리합니다. 버그 수정이 아무 절차 없이 진행 중인 대화와 에이전트에 전파되거든요. 동시에 각 변경이 이전 버전 코드 아래에서 시작된 런과 어떻게 상호작용하는지 반드시 고려해야 한다는 뜻이기도 합니다.
만나게 될 순서대로 대략 세 가지 호환성 문제 범주를 주의하세요:
- 기술 호환성: 가장 흔한 경우로, 새 코드가 기존 State에 대해 여전히 로드되고 실행될 수 있어야 합니다.
- 비즈니스 호환성: 덜 흔한 경우로, 코드가 바뀌어도 기존 런은 기존 비즈니스 로직을 계속 따라야 해요.
- 비결정성: Functional API에만 해당됩니다.
기술 호환성 (Technical compatibility)
기술 호환성은 마이크로서비스에서의 API 변경(breaking change)과 같은 의미예요. 여기서 말하는 "API"는 그래프 코드와 체크포인터가 기존 스레드에 대해 이미 영속화한 데이터 사이의 계약(contract)입니다. 스레드가 재개(resume)될 때 LangGraph는 저장된 상태를 역직렬화한 뒤 이름으로 노드에 전달하고, 노드가 상태 스키마에 맞는 값을 반환할 것을 기대해요.
흔한 기술적 파손(breakage):
- 노드 이름을 바꾸거나 제거하는 경우 — 특히 스레드가 그 노드에서 일시 중지되어 있거나 막 들어가려는 상태일 때요. 예를 들어
interrupt에서 멈춰 있거나, 체크포인트된 조건부 엣지가 여전히 옛 이름으로 라우팅할 때 발생합니다. 재개 시 LangGraph는 저장된 이름으로 노드를 찾을 수 없어 런이 실패해요. 런 재개의 시작점은 실행이 멈춘 노드의 시작이므로, 노드가 없으면 재개할 위치가 없습니다. - State 키의 이름을 바꾸거나 제거하는 경우 — 이전 체크포인트가 여전히 포함하거나, 하위 노드가 여전히 읽는 키라면 문제됩니다.
- State 필드를 더 엄격하게 만드는 경우 — 예를 들어
Optional필드를 필수로 바꾸거나, 타입을 좁히거나, 기본값 없는 새 필수 필드를 추가하면 이전 체크포인트가 새 스키마를 만족하지 못해요.
엣지 토폴로지 자체는 체크포인트에 영속화되지 않습니다. 여전히 존재하는 노드 사이에서 엣지를 추가·제거·재라우팅하는 것은 진행 중인 스레드에 안전해요. Graph migrations 요약에 따르면, 중단된 스레드를 깨뜨릴 수 있는 유일한 토폴로지 변경은 노드 이름 변경 또는 제거입니다.
권장 패턴 (Recommended patterns)
-
새 상태 필드는
NotRequired(또는Optional[...] = None)로 추가해 이전 체크포인트가 여전히 검증되게 하세요:from typing import NotRequired from typing_extensions import TypedDict class State(TypedDict): messages: list summary: NotRequired[str] # [!code ++] -
제거는 폐기(deprecation)처럼 다루세요. 아무 노드도 읽지 않더라도 최소한 한 번의 드레인(drain) 주기 동안은 필드를 상태에 정의해 두어 기존 체크포인트가 계속 로드되게 하세요.
-
추가 후 제거(add-then-remove) 로 이름을 바꾸세요. 새 필드·노드를 옛것과 함께 추가하고, 폐기 기간 동안 이중 쓰기(dual-write)하거나 둘 다로 라우팅한 뒤, 진행 중인 스레드가 그에 의존하지 않음을 확인했으면 옛것을 제거합니다.
-
노드 함수가 알 수 없는 키에 관대하도록 유지하세요.
TypedDict는 런타임에 추가 키를 무시하므로, 이전 코드 버전에서 남은 상태는 노드가 누락된 키를 명시적으로 읽지 않는 한 예외를 일으키지 않습니다. -
time travel과
graph.get_state를 사용해 스테이징 배포에서 새 코드에 대한 기존 스레드를 롤아웃 전에 점검하세요.
진행 중인 스레드 감지 (Detecting in-flight threads)
노드를 제거하거나 State 키 이름을 바꾸거나, 이전 스레드가 견디지 못할 다른 변경을 하기 전에, 지금 버려도 되는 코드 버전에 멈춰 있는 스레드가 있는지 알고 싶을 거예요. LangGraph 자체는 스레드 상태에 대한 검색 인덱스를 유지하지 않으므로, 그 답은 그래프가 어디서 실행되느냐에 달렸습니다.
LangSmith에 배포한다면. Agent Server의 스레드 검색으로 상태를 필터링하세요. status 필드는 idle, busy, interrupted, error 값을 받아들여서, interrupted나 busy 스레드를 일괄 조회할 수 있고 메타데이터 필터로 좁힐 수도 있어요. Filter by thread status와 List threads를 참고하세요.
LangGraph가 어디서든 실행된다면. LangSmith tracing으로 프로덕션에서 어떤 노드가 진입·종료되는지 모니터링하세요. 이는 노드나 상태 필드가 더 이상 어떤 활성 코드 경로에서도 도달할 수 없게 되었다는 가장 신뢰할 수 있는 신호입니다.
이미 thread_id를 알고 있다면. 그 단일 스레드를 직접 점검하세요:
graph.get_state(config)— 스레드가 어떤 노드에 멈춰 있는지, 대기 중인 interrupt가 있는지 포함한 최신 체크포인트를 반환합니다.graph.get_state_history(config)— 스레드의 전체 시간순 체크포인트 목록을 반환합니다.
확실하지 않다면, Agent Server 스레드 목록과 트레이싱이 모두 그 노드·필드에 더 이상 활동이 없음을 보일 때까지 폐기 대상 노드·필드를 그대로 두세요.
비즈니스 호환성 (Business compatibility)
때로 변경이 기술적으로 유효해도(기존 체크포인트가 모두 로드되고 모든 노드가 여전히 해석됨), 새 그래프의 의미가 옛것과 다를 수 있어요. 새 동작은 새 스레드에는 올바르지만, 옛 로직 아래 시작된 스레드에는 소급 적용하고 싶지 않을 것입니다.
예를 들어 그래프가 intake → triage → respond로 실행되는데, triage와 respond 사이에 새 policy_check 단계를 끼워 넣기로 했다고 가정해 볼게요:
- 이미
triage를 지난 스레드는respond로 곧바로 계속되어야 합니다(옛 흐름). - 새 스레드는 완전한 새 흐름을 실행해야 해요.
권장 패턴은 스레드 시작 시 상태에 관련 행동 버전(behavioral version) 을 기록하고, 조건부 엣지로 그 값에 분기하는 것입니다:
from typing import NotRequired
from typing_extensions import TypedDict
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
request: str
flow_version: NotRequired[int]
response: NotRequired[str]
def intake(state: State) -> dict:
# Stamp new threads with the current flow version. Existing threads
# that resume past `intake` keep whatever value was already saved.
return {"flow_version": state.get("flow_version", 2)}
def triage(state: State) -> dict: ...
def policy_check(state: State) -> dict: ...
def respond(state: State) -> dict: ...
def after_triage(state: State) -> str:
if state.get("flow_version", 1) >= 2:
return "policy_check"
return "respond"
builder = StateGraph(State)
builder.add_node("intake", intake)
builder.add_node("triage", triage)
builder.add_node("policy_check", policy_check)
builder.add_node("respond", respond)
builder.add_edge(START, "intake")
builder.add_edge("intake", "triage")
builder.add_conditional_edges("triage", after_triage, ["policy_check", "respond"])
builder.add_edge("policy_check", "respond")
builder.add_edge("respond", END)
graph = builder.compile()
triage 이후에 재개되는 옛 스레드는 저장된 상태에서 flow_version을 읽어(또는 v1 기본값으로 넘어가) policy_check를 건너뜁니다. 새 스레드는 intake에서 시작해 flow_version=2로 표시되고 새 경로를 실행해요. 모든 v1 스레드가 완료되면 버전 플래그와 조건부 엣지를 제거할 수 있습니다.
이 패턴은 버전을 스레드 시작 시, 버전 지정이 필요한 어떤 분기보다 먼저 설정해야만 동작합니다. 나중에 설정하면 기존 스레드가 필요할 때 그 값을 갖고 있지 않게 됩니다.
비결정성 (Non-determinism)
이 범주는 Functional API와, Graph API 노드 안의 tasks 또는 interrupt 호출에만 적용됩니다. 일반적인 Graph API 노드는 재개 시 노드 함수의 시작부터 다시 실행됩니다. 부작용을 멱등(idempotent)하게 설계하면 되고, 그 노드에서 tasks나 interrupt를 쓰지 않는 한 태스크 호출 순서를 보존할 필요는 없어요.
Functional API 엔트리포인트는 재개 시 엔트리포인트 본문을 처음부터 다시 실행(replay)하는 단일 노드로 컴파일되며, 캐시된 @task 결과를 사용해 이미 완료된 작업을 건너뜁니다. 이 모델을 깨뜨리는 두 종류의 변경이 있어요:
- 재개 지점 이전에 오는
@task호출 또는interrupt호출을 추가·제거·재배열하는 경우 — LangGraph는 캐시된 결과와 재개 값을 리플레이에서의 호출 위치로 매칭하므로, 위치가 바뀌면 다른 호출에 잘못된 캐시 값이 재생될 수 있어요. @task밖에서 비결정적 연산을 도입하는 경우 — 예를 들어 엔트리포인트 본문에time.time(),random.random(), 네트워크 호출을 인라인으로 넣으면, 재생 시 첫 실행과 다른 값을 만들어 제어 흐름이 바뀔 수 있습니다.
예시를 포함한 더 깊은 내용은 Functional API 가이드의 Determinism과 Common pitfalls을 참고하세요.
진행 중인 런이 있는 @entrypoint에 사소하지 않은 코드 변경을 해야 한다면 가장 안전한 선택은:
- 변경 배포 전에 진행 중인 런을 드레인시키기.
- 새 로직을 새
@task로 감싸 결과가 독립적으로 체크포인트되게 하기. - 새 동작을 위한 새 그래프 이름으로
langgraph.json에 새 엔트리포인트를 등록하고 새 스레드를 그쪽으로 라우팅하기.
더 알아보기 (Learn more)
- Graph migrations — 런타임이 기본 지원하는 토폴로지·상태 변경 요약.
- Persistence — 스레드 상태 영속화.
- Determinism & Common pitfalls — Functional API의 비결정성 심층.