logo
홈블로그소개
4,629

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

·개인정보처리방침
AIPython

LangGraph 기초: State·Node·Edge로 에이전트를 그래프로 설계하기

Toma
2026년 7월 20일
약 41분
목차
🚀 소개 — 왜 LangGraph인가
🧩 그래프의 3가지 구성요소
📦 Graph State — 데이터를 추가·수정하는 법
🗂️ Multiple Schemas — 하나의 State를 여러 스키마로
➕ Reducer Functions — state를 덮어쓸까, 더할까
⏱️ Node Caching — node 결과를 캐싱하기
🔀 Conditional Edges — 조건에 따라 갈림길 만들기
📡 Send API — node를 동적으로, 그리고 병렬로
🎯 Command — 이동과 state 업데이트를 한 번에 (HandOff)
이전 포스트GSC 자동화 리포팅 체계 구축
다음 포스트LangGraph Agent: 챗봇에서 Tool·Memory·HITL·Time Travel까지

목차

🚀 소개 — 왜 LangGraph인가
🧩 그래프의 3가지 구성요소
📦 Graph State — 데이터를 추가·수정하는 법
🗂️ Multiple Schemas — 하나의 State를 여러 스키마로
➕ Reducer Functions — state를 덮어쓸까, 더할까
⏱️ Node Caching — node 결과를 캐싱하기
🔀 Conditional Edges — 조건에 따라 갈림길 만들기
📡 Send API — node를 동적으로, 그리고 병렬로
🎯 Command — 이동과 state 업데이트를 한 번에 (HandOff)

💬 LangGraph는 다른 프레임워크보다 작고 가벼우며, 개발자가 원하는 만큼 커스텀(컨트롤)할 수 있다는 장점이 있다. 이름 그대로 "agent = graph"라는 아이디어로 만들어진 프레임워크로, 그래프를 실행할 수 있는 코드를 제공한다.

🚀 소개 — 왜 LangGraph인가

에이전트를 설계할 때 "이 노드에서 저 노드로 흐른다"는 그림을 떠올려 보면, 결국 에이전트는 하나의 그래프로 표현할 수 있다. LangGraph는 바로 그 그래프를 코드로 만들 수 있게 도와준다.

💡 예를 들어 LangGraph는 node를 만들 수 있게 도와준다. node는 _무엇이든 할 수 있는 일종의 함수_다.

🧩 그래프의 3가지 구성요소

구성요소역할
State그래프를 통해 이동하는 데이터. 유저 입력 등 그래프가 다루는 값이 여기에 담긴다. TypedDict로 어떤 프로퍼티가 올 수 있는지 미리 정의한다.
Node실질적인 작업이 이루어지는 함수. state를 인자로 받고, 정해진 규칙 없이 무엇이든 할 수 있다.
Edge노드를 연결하는 화살표. add_edge(A, B)는 A에서 B로 흐름을 잇는다.

에이전트를 설계할 때 그래프로 그리는 것을 생각해 보면 Agent = Graph라고 할 수 있다!

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-07-06_20.23.18.png

아래에서 TypedDict는 그냥 딕셔너리인데, 어떤 프로퍼티들이 올 수 있는지를 미리 지정해 놓은 딕셔너리다.

python
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict


# State: 그래프의 데이터
class State(TypedDict):
    hello: str


# 그래프 빌더 생성
graph_builder = StateGraph(State)


# Node = function (state를 받아서 무엇이든 할 수 있다)
def node_one(state: State):
    print("node_one")

def node_two(state: State):
    print("node_two")

def node_three(state: State):
    print("node_three")


# 그래프에 node 할당
graph_builder.add_node("node_1", node_one)
graph_builder.add_node("node_2", node_two)
graph_builder.add_node("node_3", node_three)


# Edge로 node 연결 — add_edge(A, B): A → B
graph_builder.add_edge(START, "node_1")
graph_builder.add_edge("node_1", "node_2")
graph_builder.add_edge("node_2", "node_3")
graph_builder.add_edge("node_3", END)


# compile() 시 LangGraph가 그래프의 유효성을 검사한다
graph = graph_builder.compile()
graph

💡 graph_builder.compile()을 호출하면 LangGraph가 이 그래프가 유효한지 검사한 뒤 실행 가능한 그래프 객체를 돌려준다.

📦 Graph State — 데이터를 추가·수정하는 법

Graph에는 State가 있고, 여기에 데이터를 넣고 바꾸는 흐름은 다음 3단계로 정리된다.

  1. 사용자가 state를 제공한다. graph.invoke(...)의 input으로 넘긴다. 이를 위해 State(TypedDict)에 프로퍼티를 미리 정의해 두어야 한다.
  2. node가 state를 받아 업데이트(오버라이드)한 뒤 **return**한다.
  3. 그래프가 끝나면 LangGraph는 그래프의 final state를 반환한다.

💡 State를 수정할 수 있는 건 오직 node다. node는 state를 받고, 업데이트된 값을 return하기만 하면 된다. state를 명시하거나 별도의 함수·API를 알 필요가 없다. 그리고 다음 node에게 직접 넘기는 게 아니라 — 다음 node는 변경된 state를 "쳐다본다".

python
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict


class State(TypedDict):
    hello: str
    a: bool


graph_builder = StateGraph(State)


def node_one(state: State):
    print("node_one", state)  # 초기 state (예: 사용자 input)
    return {"hello": "from node one", "a": True}

def node_two(state: State):
    print("node_two", state)  # node_one이 return한 state를 받음
    return {"hello": "from node two", "a": False}

def node_three(state: State):
    print("node_three", state)  # node_two가 return한 state를 받음
    return {"hello": "from node three"}


graph_builder.add_node("node_1", node_one)
graph_builder.add_node("node_2", node_two)
graph_builder.add_node("node_3", node_three)

graph_builder.add_edge(START, "node_1")
graph_builder.add_edge("node_1", "node_2")
graph_builder.add_edge("node_2", "node_3")
graph_builder.add_edge("node_3", END)

graph = graph_builder.compile()

# state 초기화 — invoke 할 때 넘긴다
result = graph.invoke({"hello": "world"})
print(result)

각 node의 print를 따라가 보면 state가 어떻게 흘러가는지 보인다.

plain
node_one {'hello': 'world', ...}
node_two {'hello': 'from node one', 'a': True}
node_three {'hello': 'from node two', 'a': False}

💡 hello는 매 node마다 덮어써지고, a는 node_three가 건드리지 않았으므로 직전 값(False)이 그대로 유지된다. 이렇게 State는 여러 필드를 가질 수 있고, 각 node는 자신이 바꾸고 싶은 필드만 골라서 return하면 된다. 마지막 node_three가 return한 state가 곧 final_state로 반환된다.

🗂️ Multiple Schemas — 하나의 State를 여러 스키마로

state는 여러 개의 스키마를 가질 수 있다. 대표적으로 다음 3가지다.

  1. input state — 사용자가 제공하는 state
  2. output state — agent의 output을 담는 state
  3. private state — node에서 내부적으로만 사용되는 state

✅ 이렇게 스키마를 구분하면 state를 divide and conquer(분할과 정복) 할 수 있다. 예를 들어 사용자가 내부 state에 접근하지 못하게 하거나, 최종 output에서 특정 키를 숨기고 싶을 때 유용하다.

python
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict


# private state: node 내부에서만 쓰는 state
class PrivateState(TypedDict):
    a: int
    b: int

# input state: 사용자가 제공하는 state
class InputState(TypedDict):
    hello: str

# output state: agent의 output을 담는 state
class OutputState(TypedDict):
    bye: str


# input은 input_schema에, output은 output_schema에,
# private(overall) state는 첫 번째 인자로 등록
graph_builder = StateGraph(
    PrivateState,
    input_schema=InputState,
    output_schema=OutputState,
)


# 각 node는 원하는 스키마를 선택해 state: ... 로 명시하면 된다
def node_one(state: InputState) -> InputState:
    print("node_one", state)
    return {"hello": "world"}

def node_two(state: PrivateState) -> PrivateState:
    print("node_two", state)
    return {"a": 1}

def node_three(state: PrivateState) -> PrivateState:
    print("node_three", state)
    return {"b": 1}

# private state를 받지만 output state를 업데이트
def node_four(state: PrivateState) -> OutputState:
    print("node_four", state)
    return {"bye": "world"}


graph_builder.add_node("node_1", node_one)
graph_builder.add_node("node_2", node_two)
graph_builder.add_node("node_3", node_three)
graph_builder.add_node("node_4", node_four)

graph_builder.add_edge(START, "node_1")
graph_builder.add_edge("node_1", "node_2")
graph_builder.add_edge("node_2", "node_3")
graph_builder.add_edge("node_3", "node_4")
graph_builder.add_edge("node_4", END)

graph = graph_builder.compile()
graph

✅ 각 node는 state: InputState, state: PrivateState처럼 자기가 다룰 스키마만 골라서 타입으로 명시하면 된다. LangGraph가 알맞은 채널을 연결해 준다.

➕ Reducer Functions — state를 덮어쓸까, 더할까

node가 무언가를 return하면 그게 state를 업데이트한다. 그렇다면 그 업데이트가 정확히 어떻게 일어나는가?

⚠️ 기본 동작은 "덮어쓰기(override)"다. 예를 들어 messages state에서 마지막 메시지를 LLM에 보내고, 응답으로 state를 업데이트한다고 하자. 해당 필드를 그냥 return하면 이전 messages가 통째로 덮어써진다.

이전 값을 보존하고 싶다면 두 가지 방법이 있다.

  • 직접 이전 state를 더해서 return: return {"messages": state["messages"] + ["Hello!"]}
  • 혹은 reducer function을 지정해서 "어떻게 합칠지"를 필드에 붙여 둔다 → 이게 더 깔끔하다.

💡 reducer function은 말 그대로 함수다. typing의 Annotated를 이용해 필드에 붙인다. messages: Annotated[list[str], update_function] — 첫 번째 인자는 필드의 타입, 두 번째 인자는 어떻게 업데이트할지 정하는 함수다. 이 함수는 old(이전 state), new(새 state)를 인자로 받는다.

python
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
from typing import Annotated
import operator


# reducer 예시: 직접 정의하면 old + new 형태
def update_function(old, new):
    return old + new


class State(TypedDict):
    # messages: Annotated[list[str], update_function]
    # 리스트를 이어 붙이는 reducer는 operator.add로 간단히 대체 가능
    messages: Annotated[list[str], operator.add]


graph_builder = StateGraph(State)


def node_one(state: State):
    return {"messages": ["Hello, nice to meet you!"]}

def node_two(state: State):
    return {}

def node_three(state: State):
    return {}

def node_four(state: State):
    return {}


graph_builder.add_node("node_1", node_one)
graph_builder.add_node("node_2", node_two)
graph_builder.add_node("node_3", node_three)
graph_builder.add_node("node_4", node_four)

graph_builder.add_edge(START, "node_1")
graph_builder.add_edge("node_1", "node_2")
graph_builder.add_edge("node_2", "node_3")
graph_builder.add_edge("node_3", "node_4")
graph_builder.add_edge("node_4", END)

graph = graph_builder.compile()
graph.invoke({"messages": ["Hello!"]})
# → {'messages': ['Hello!', 'Hello, nice to meet you!']}

✅ operator.add를 reducer로 지정했기 때문에, node_one이 return한 ["Hello, nice to meet you!"]는 초기값 ["Hello!"]를 덮어쓰지 않고 뒤에 이어 붙는다. 채팅 기록처럼 누적되어야 하는 state에 딱 맞는 패턴이다.

⏱️ Node Caching — node 결과를 캐싱하기

LangGraph에서는 node의 결과를 캐싱할 수 있다. 비용이 큰 연산을 반복 실행하지 않도록 아껴 준다.

💡 적용은 두 군데에서 이뤄진다.

  • 컴파일 시: graph_builder.compile(cache=InMemoryCache()) 로 캐시 저장소를 넘긴다.

  • 노드 추가 시: add_node(..., cache_policy=CachePolicy(ttl=20)) 로 만료 시간(TTL)을 설정한다.

ttl=20이면, 그 node가 최초 실행된 뒤 20초가 지나기 전에 같은 입력으로 다시 실행되면 node를 실행하지 않고 캐시된 결과를 사용한다.

python
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
from langgraph.types import CachePolicy
from langgraph.cache.memory import InMemoryCache
from datetime import datetime
import time


class State(TypedDict):
    time: str


graph_builder = StateGraph(State)


def node_one(state: State):
    return {}

def node_two(state: State):
    # 캐시가 살아 있으면 이 값은 갱신되지 않는다
    return {"time": f"{datetime.now()}"}

def node_three(state: State):
    return {}

def node_four(state: State):
    return {}


graph_builder.add_node("node_1", node_one)
# node_2에만 20초 TTL 캐시 정책 적용
graph_builder.add_node("node_2", node_two, cache_policy=CachePolicy(ttl=20))
graph_builder.add_node("node_3", node_three)
graph_builder.add_node("node_4", node_four)

graph_builder.add_edge(START, "node_1")
graph_builder.add_edge("node_1", "node_2")
graph_builder.add_edge("node_2", "node_3")
graph_builder.add_edge("node_3", "node_4")
graph_builder.add_edge("node_4", END)

# 컴파일 시 캐시 저장소를 넘긴다
graph = graph_builder.compile(cache=InMemoryCache())

# 5초 간격으로 반복 호출 — 20초 안에는 time 값이 그대로 유지된다
for _ in range(7):
    print(graph.invoke({}))
    time.sleep(5)

💡 실행해 보면, 처음 20초 동안은 node_2가 만든 time 값이 동일하게 찍힌다. TTL이 지난 뒤에야 node가 다시 실행되어 새 시각으로 갱신된다.

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-07-11_15.11.42.png

🔀 Conditional Edges — 조건에 따라 갈림길 만들기

add_conditional_edges는 라우팅 함수의 반환값에 따라 다음 노드를 동적으로 결정하는 갈림길을 만든다.

python
.add_conditional_edges("시작점 노드", 라우팅_함수)

라우팅 함수도 state를 받으며, edge의 이름(=다음 노드)을 return해야 한다. 가장 직관적인 형태는 노드 이름을 직접 반환하는 것이다.

python
def decide_path(state: State) -> Literal["node_3", "node_4"]:
    if state["seed"] % 2 == 0:
        return "node_3"
    else:
        return "node_4"

✅ 하지만 노드 이름을 직접 반환하지 않는 방법도 있다. bool이나 DB·API에서 받아온 값을 반환하고, add_conditional_edges의 세 번째 인자에 반환값 → 노드 맵을 넘기는 방식이다.

python
graph_builder.add_conditional_edges(  
    "node_2", decide_path, {True: "node_3", False: "node_4"}  
)

이렇게 하면 라우팅 함수는 노드 이름을 몰라도 되고, 라우팅 규칙을 한 군데(맵)에서 관리할 수 있다.

python
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict


class State(TypedDict):
    seed: int


graph_builder = StateGraph(State)


def node_one(state: State):
    return {}

def node_two(state: State):
    return {}

def node_three(state: State):
    return {}

def node_four(state: State):
    return {}


graph_builder.add_node("node_1", node_one)
graph_builder.add_node("node_2", node_two)
graph_builder.add_node("node_3", node_three)
graph_builder.add_node("node_4", node_four)


# 노드 이름 대신 bool을 반환하고, 맵으로 라우팅
def decide_path(state: State):
    return state["seed"] % 2 == 0


graph_builder.add_edge(START, "node_1")
graph_builder.add_edge("node_1", "node_2")
graph_builder.add_conditional_edges(
    "node_2", decide_path, {True: "node_3", False: "node_4", "hello": END}
)
graph_builder.add_edge("node_4", END)
# node_3는 END에 명시적으로 연결하지 않았다 (아래 콜아웃 참고)

graph = graph_builder.compile()
graph

💡 **node_3**를 **END**에 연결하지 않았는데도 그래프가 정상 종료된다. 이유는 — outgoing edge가 없는 node는 기본적으로 END로 연결되기 때문이다. 실제로 seed=2(짝수 → node_3)로 실행해 보면, node_3가 실행된 뒤 그래프가 깔끔하게 종료되고 결과가 반환된다. 맵에 있는 "hello": END처럼 라우팅 함수가 실제로 반환하지 않는 키는 그냥 무시될 뿐 에러를 일으키지 않는다.

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-07-11_15.58.38.png

📡 Send API — node를 동적으로, 그리고 병렬로

Send 클래스는 custom state를 담아 node를 동적으로 invoke할 수 있게 해 준다.

💡 왜 동적으로 호출해야 할까? node를 몇 번 invoke할지 미리 알 수 없는 경우가 있기 때문이다. 예를 들어 문서 요약 그래프에서 문서가 하나면 node를 한 번만 호출하면 되지만, 개수가 정해지지 않은 파일들을 요약해야 한다면 Send API로 필요한 만큼 node를 만들어낼 수 있다.

✅ 가장 큰 장점: LangGraph에서 Send로 여러 node를 동시에 실행하면 이들이 병렬로 실행된다.

python
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict, Annotated
from typing import Union
from langgraph.types import Send
import operator


class State(TypedDict):
    words: list[str]                                              # input 형태
    output: Annotated[list[dict[str, Union[str, int]]], operator.add]  # output 형태 (누적)


graph_builder = StateGraph(State)


def node_one(state: State):
    return {}


# Send로 넘긴 payload(여기서는 word 문자열)를 그대로 받는다
def node_two(word: str):
    count = len(word)
    return {"output": [{"word": word, "letters": count}]}


graph_builder.add_node("node_1", node_one)
graph_builder.add_node("node_2", node_two)


# 각 word를 Send로 감싸 "node_2"에 개별 전달 → word 개수만큼 node_2가 생성된다
def dispatcher(state: State):
    return [Send("node_2", word) for word in state["words"]]


graph_builder.add_edge(START, "node_1")
# node_1 → node_2로 가되, node_2가 몇 개 생길지 모르므로 conditional edge 사용
# 세 번째 인자로 도달 가능한 노드 목록을 명시
graph_builder.add_conditional_edges("node_1", dispatcher, ["node_2"])
graph_builder.add_edge("node_2", END)

graph = graph_builder.compile()
graph.invoke({"words": ["hello", "world", "how", "are", "you", "doing"]})

💡 **Send("node_2", word)**의 두 번째 인자는 대상 node에 전달할 payload다. 여기서는 문자열 word를 그대로 넘겼고, node_two(word: str)가 이를 받아 len(word)로 글자 수를 센다. 이 repo 환경에서 실제로 실행하면 각 단어가 병렬로 처리되어 output에 누적된다: [{'word': 'hello', 'letters': 5}, {'word': 'world', 'letters': 5}, {'word': 'how', 'letters': 3}, ...]
(공식 예제에서는 Send("node", {"subject": s})처럼 dict를 넘기고 node가 state["subject"]로 읽기도 한다. dict든 문자열이든 payload는 그대로 전달되므로 node가 받는 형태에 맞추기만 하면 된다.)

%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-07-11_16.27.56.png

🎯 Command — 이동과 state 업데이트를 한 번에 (HandOff)

triage(분류) 노드에서 각 담당 노드로 조건부 엣지를 연결할 수도 있다. 하지만 "다른 node로 넘기면서(transfer) 그 이유(state)도 함께 업데이트" 하고 싶다면 — 이때 쓰는 것이 Command다.

💡 Command는 한 node가 다른 node로 transfer를 시작하면서 동시에 state를 업데이트하게 해 준다. edge로 연결되어 있지 않아도 그래프/노드 어디로든 점프할 수 있고, 자식 그래프에서 부모 그래프로도 이동할 수 있다. edge가 "노드 → 노드" 연결이라면, Command는 일종의 치트키인 셈이다. (그래서 별칭이 HandOff다.)

python
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
from typing import Literal
from langgraph.types import Command


class State(TypedDict):
    transfer_reason: str


graph_builder = StateGraph(State)


# 반환 타입 힌트로 goto 대상 노드를 명시하면 그래프 시각화·검증에 도움이 된다
def triage_node(state: State) -> Command[Literal["account_support"]]:
    return Command(
        goto="account_support",   # 원하는 노드로 goto
        update={                  # 이동과 동시에 state 업데이트
            "transfer_reason": "The user wants to change password"
        },
    )

def tech_support(state: State):
    return {}

def account_support(state: State):
    return {}


graph_builder.add_node("triage_node", triage_node)
graph_builder.add_node("tech_support", tech_support)
graph_builder.add_node("account_support", account_support)

graph_builder.add_edge(START, "triage_node")
graph_builder.add_edge("tech_support", END)
graph_builder.add_edge("account_support", END)

graph = graph_builder.compile()
graph.invoke({})

✅ 조건부 엣지 vs. Command의 차이

  • 조건부 엣지: node가 실행을 끝낸 뒤, 프레임워크가 라우팅 함수를 호출해 어디로 갈지 정한다.

  • Command: node 스스로 다른 node로 transfer를 시작하고, 그 과정에서 state까지 업데이트한다.


🧠 한눈에 정리 — LangGraph의 핵심은 State(데이터) · Node(함수) · Edge(연결) 세 가지다. 여기에 Multiple Schemas(스키마 분리), Reducer(덮어쓸까 더할까), Node Caching(결과 재사용), Conditional Edges(갈림길), Send API(동적·병렬 실행), Command(이동+업데이트)를 얹으면, 복잡한 에이전트도 하나의 그래프로 설계할 수 있다.