State가 "어디선가" 업데이트되는 LangGraph의 비밀

kukjunLEE·2025년 11월 28일

AI-Application

목록 보기
1/2
post-thumbnail

English Version

서론

LangGraph로 첫 에이전트를 만들 때, 머릿속이 물음표 투성이였습니다.

from langgraph.graph import StateGraph, START, END

graph = StateGraph(State)
graph.add_node("research", research_node)
graph.add_node("analyze", analyze_node)
graph.add_edge(START, "research")
graph.add_edge("research", "analyze")

app = graph.compile(checkpointer=checkpointer)
result = app.invoke({"messages": ["Hello"]})

코드 자체는 간단해 보입니다. 노드 몇 개 만들고, 엣지로 연결하고, compile()하고 invoke().

그런데 실행하면... State가 어떻게 움직이는 건지, 체크포인트는 언제 저장되는 건지, Super-step이 대체 뭔지 전혀 이해가 안 갔습니다.

research_node에서 return {"messages": [new_msg]}만 하면 자동으로 다음 노드에 전달되는데, 이게 어떻게 되는 걸까요? 함수 직접 호출도 아닌데...

공식 문서에는 "각 노드는 State를 받아 업데이트를 반환합니다"라고만 나와 있습니다. 그래서 뭐?

So Waht

3일 동안 삽질했습니다. 디버거 붙여서 step-by-step으로 따라가 봐도, State가 "어디선가" 업데이트되고, 체크포인트가 "어디선가" 저장되고... 마법 같았습니다.

그러다 공식 문서를 뒤지다가 Pregel 개념 페이지를 발견했습니다.

"LangGraph's underlying Pregel-inspired architecture..."

"Pregel?"

처음 보는 단어였습니다. 검색해보니 Google이 2010년에 발표한 대규모 그래프 처리 시스템이었습니다. PageRank 같은 알고리즘을 수십억 개의 노드에 적용하기 위해 만든 겁니다.

그리고 깨달았습니다.

LangGraph의 모든 "이상한" 동작이 Pregel에서 왔다는 걸.




본론

자전거 탈 줄 알았는데 F1 레이싱카였습니다

LangGraph는 간단한 A → B → C 흐름을 만드는워크플로우 라이브러리처럼 보입니다.

그런데 내부를 보면, 분산 시스템용 그래프 처리 엔진입니다.

비유하자면:

원한 것: 자전거 (간단한 워크플로우)
받은 것: F1 레이싱카 (분산 그래프 엔진)

자전거 타려고 했는데 F1 레이싱카를 주면서 "간단해요, 액셀만 밟으면 돼요!"라고 하면... 당연히 혼란스럽습니다.

근데 여기서 반전이 있습니다.

F1 레이싱카를 이해하고 나면, 자전거로는 절대 불가능한 것들을 할 수 있습니다. 중단/재개, Human-in-the-Loop, 병렬 실행, 트랜잭션 보장...

이게 다 Pregel 덕분에 가능하게 됩니다.




도대체 Pregel이 뭐길래?

2010년 Google은 문제가 하나 있었습니다. PageRank를 수십억 개의 웹페이지에 적용해야 하는데, MapReduce로는 너무 느렸습니다.

MapReduce로 PageRank를 구현하면:

for iteration in range(max_iterations):
    mapped = map_phase(graph)
    shuffled = shuffle(mapped)  # 네트워크로 데이터 전송
    graph = reduce_phase(shuffled)
    save_to_disk(graph)  # 디스크 I/O 발생

매 반복마다 디스크 I/O, 네트워크 Shuffle... 죽을 맛이었습니다.

그래서 Pregel을 만들었습니다. 핵심 아이디어는 "Think Like a Vertex".

각 정점(Vertex)이 자기 관점에서만 생각하게 만들었습니다:

class Vertex:
    def compute(self, messages):
        # 받은 메시지 처리
        process(messages)

        # 이웃에게 메시지 전송
        for neighbor in self.out_edges:
            self.send_message(neighbor, data)

        # 할 일 없으면 종료
        if done():
            self.vote_to_halt()

Vertex는 전체 그래프가 어떻게 생겼는지 모릅니다. 자기 이웃만 압니다. 그게 다입니다.

메시지를 주고받으면서 그래프를 순회합니다. 메모리에서 디스크 I/O 최소화하기 위해서요.




LangGraph는 Pregel을 어떻게 적용했나

LangGraph는 Pregel의 개념을 그대로 가져왔습니다. 다만 도메인을 바꿨습니다.

Pregel: 그래프 알고리즘LangGraph: 워크플로우 오케스트레이션

개념 매핑은 이렇습니다:

PregelLangGraph
VertexNode
EdgeChannel
MessageState Update
CombinerReducer
vote_to_halt()END 노드

예를 들어 Pregel의 PageRank Vertex:

class PageRankVertex(Vertex):
    def compute(self, messages):
        incoming_rank = sum(messages)
        self.value = 0.15 + 0.85 * incoming_rank

        for neighbor in self.out_edges:
            self.send_message(neighbor, self.value / len(self.out_edges))

        if converged():
            self.vote_to_halt()

LangGraph의 Node:

def research_node(state: State) -> dict:
    query = state["messages"][-1]
    result = search_web(query)

    return {
        "messages": [result],
        "research_data": result
    }

차이가 보이나요? Pregel은 send_message()로 명시적으로 전송하는데, LangGraph는 그냥 dict를 return합니다.

그럼 이 dict는 어떻게 다음 노드로 가는 걸까요?




드디어 이해되는 State 전달 메커니즘

Pregel에서 Vertex A가 Vertex B에게 메시지를 보내면,
1. 메시지는 "큐"에 저장됨
2. 다음 Super-step에서 Vertex B가 큐에서 읽음

LangGraph도 똑같습니다:

# Super-step 1: research_node 실행
def research_node(state):
    return {"research_data": "result"}  # Channel 업데이트

# [Barrier Sync - 모든 노드 완료 대기]
# Reducer 적용, Checkpoint 저장

# Super-step 2: analyze_node 실행
def analyze_node(state):
    data = state["research_data"]  # 이미 업데이트됨

노드는 State를 직접 전달하지 않습니다. Channel을 업데이트하면, 다음 Super-step에서 반영됩니다.

이게 메시지 패싱입니다.

Message Passing

그래서 return만 하면 알아서 전달되는 것처럼 보였습니다.




체크포인트는 언제 저장되나

Pregel은 매 Super-step 종료 후 체크포인트를 저장합니다.

LangGraph도 똑같습니다:

app = graph.compile(checkpointer=checkpointer)
result = app.invoke({"messages": ["Hello"]}, config)

# 실제로는:
# Super-step 0 → Checkpoint 0
# Super-step 1 → Checkpoint 1
# Super-step 2 → Checkpoint 2

노드 실행 "중"에는 저장 안 합니다. Super-step이 끝나야 저장합니다. 트랜잭션 보장 때문입니다.

Super-step 내에서 하나라도 실패하면? 전체 롤백을 하게 됩니다.

그래서 중단/재개가 가능한 겁니다:

config = {"configurable": {"thread_id": "user_123"}}
app.invoke({"messages": ["Hello"]}, config)

# 중간에 실패

# 재실행하면 마지막 Checkpoint부터 자동 재개
app.invoke({"messages": ["Hello"]}, config)



Super-step의 이해

Super-step은 이산적 실행 단위로, 그래프를 한 번 순회하는 단위입니다.

START → [Node A, Node B] → Node C → END

# 실행:
Super-step 0: START
Super-step 1: Node A, Node B (병렬!)
Super-step 2: Node C
Super-step 3: END

같은 Super-step 내 노드는 병렬 실행 가능합니다. Node A, B가 독립적이면 동시에 실행.

각 Super-step 끝에 Barrier Sync. 모든 노드가 완료될 때까지 대기.

그래서 LangGraph 디버그 로그를 보면:

[Super-step 0] START
[Super-step 1] research_node, fact_check_node
  → Checkpoint saved
[Super-step 2] analyze_node
  → Checkpoint saved

이렇게 나옵니다.




Reducer는 왜 필요한가

병렬 노드가 같은 Channel을 업데이트하면 충돌이 납니다.

# Super-step에서 두 노드가 병렬 실행
def node_a(state):
    return {"messages": ["A"]}

def node_b(state):
    return {"messages": ["B"]}

# Reducer 없으면? 하나가 덮어씀
# Reducer 있으면? 병합됨

Reducer는 Pregel의 Combiner와 같은 역할입니다.

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

class State(TypedDict):
    messages: Annotated[List, add_messages]  # Reducer

이러면 ["A"]["B"]["A", "B"]로 병합됩니다.

그래서 채팅과 같이 연속적으로 쌓여야 하는 List 데이터에서도 사용합니다.




이제야 보이는 설계 철학

왜 State를 함수 인자로 받고 dict를 반환할까요? Vertex-Centric Programming 때문입니다.

def node(state: State) -> dict:
    # 처리
    return updates

노드는 전체 워크플로우를 몰라도 됩니다. 자기 할 일만 하면 됩니다. 단위 테스트 쉽고, 재사용 가능합니다.

왜 compile()이 필요할까요? Pregel 런타임으로 변환하기 때문입니다.

StateGraph는 개발자 친화적인 API일 뿐입니다. compile()하면 내부적으로 Pregel로 바뀝니다:

  • State 키 → Channel
  • Node → PregelNode
  • Edge → Channel 라우팅

compile() 없이는 Super-step도, 체크포인트도, 트랜잭션도 안 됩니다.

왜 Checkpointer를 따로 주입할까요? 런타임과 지속성을 분리했기 때문입니다.

app = graph.compile(checkpointer=checkpointer)

테스트에서는 MemorySaver, 프로덕션에서는 PostgresSaver. 런타임 코드는 안 바뀝니다.


결론

LangGraph는 복잡합니다. 하지만 이유가 있습니다.

Google이 10년 넘게 검증한 Pregel을 기반으로 했기 때문입니다.

처음엔 "왜 이렇게 복잡해?" 싶었는데, 알고 보니까 Pregel이었습니다. 그래서 체크포인트 자동 저장되고, 병렬 실행 되고, 트랜잭션 보장되는 거였습니다.

자전거 탈 줄 알았는데 F1 레이싱카였습니다. 근데 F1을 이해하고 나니, 자전거로는 못 하는 걸 할 수 있게 됐습니다.

  • 중단/재개
  • Human-in-the-Loop
  • 병렬 실행
  • 트랜잭션 보장

LangGraph의 복잡성은 버그가 아니라 기능입니다.

Pregel을 이해하면, LangGraph를 마스터할 수 있습니다.


실전 팁

디버깅할 때 Super-step 단위로 생각하면 쉽습니다:

import logging
logging.basicConfig(level=logging.DEBUG)

app.invoke({"messages": ["Hello"]})

# 로그:
# [Super-step 0] START
# [Super-step 1] research_node ← 여기서 실패!

Checkpoint도 활용 가능합니다:

config = {"configurable": {"thread_id": "debug"}}

# 첫 실행 (실패)
try:
    app.invoke({"messages": ["Hello"]}, config)
except:
    pass

# 코드 수정 후 재실행 → 자동으로 이어서
app.invoke({"messages": ["Hello"]}, config)

State 스냅샷도 볼 수 있습니다:

checkpoints = checkpointer.list(config)
for cp in checkpoints:
    print(f"Step {cp.id}: {cp.state}")




출처 및 참고자료

Pregel 원본

LangGraph 공식 문서

블로그 & 아티클

관련 자료

profile
Software Engineer

0개의 댓글