ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • LangGraph 상태에 메시지 외 필드 추가하기 — RouterState 설계
    IT 2026. 6. 26. 23:00
    LangGraph 상태에 메시지 외 필드 추가하기 — RouterState 설계

    LangGraph의 상태(State)를 처음 배울 때는 messages 필드 하나만 있는 AgentState를 본다. LLM과 주고받는 메시지를 누적하는 용도다. 그런데 실제 워크플로우를 만들다 보면 금세 한계에 부딪힌다. "이 요청이 어느 카테고리인지", "지금 몇 번째 시도인지", "사용자가 인증됐는지" 같은 데이터도 노드 간에 공유해야 하기 때문이다. messages 하나만으로는 이 데이터를 담을 곳이 없다.

    이 글은 RouterState라는 구체적인 예시를 통해, 상태(State) 클래스에 필드를 추가할 때 어떤 규칙이 적용되는지, 리듀서가 있는 필드와 없는 필드가 어떻게 다르게 동작하는지, 그리고 노드들이 상태의 어느 부분을 읽고 쓰는지를 설명한다.

    1. 복습: 상태는 노드들의 공유 칠판

    이전 글에서 다룬 내용을 간단히 짚고 넘어가자. LangGraph에서 각 노드는 독립된 Python 함수다. 노드 간에 데이터를 전달하는 방식은 함수 인자/반환값의 직접 연결이 아니라, 모든 노드가 공유하는 상태(State) 객체를 경유하는 것이다.

    상태를 칠판에 비유하면, 각 노드는 칠판을 읽고, 자신의 결과를 칠판의 특정 칸에 적어두고 끝난다. 다음 노드는 그 칠판을 그대로 이어받아 읽는다. 노드끼리 직접 대화하지 않고, 칠판을 통해서만 소통한다.

    diagram

    위 그림은 공유 상태 칠판이 노드들 사이에서 데이터 중계 역할을 하는 구조를 보여준다. classifier_node는 칠판에서 messages를 읽어 분류를 수행하고, 결과를 category 칸에 적는다. router는 category 칸만 읽어 다음 경로를 결정한다. handler_node는 messages 칸에 최종 응답을 추가한다. 각 노드가 칠판의 일부 칸만 담당한다는 것이 핵심이다. 노드가 자신과 무관한 칸을 읽거나 덮어쓰면 데이터 오염이 발생한다.

    2. RouterState: messages 외 필드 추가

    messages 필드 하나뿐인 기본 AgentState에서 출발해, 라우팅 워크플로우에 필요한 category 필드를 추가한 것이 RouterState다.

    from typing import Annotated
    from typing_extensions import TypedDict
    from langchain_core.messages import BaseMessage
    from langgraph.graph.message import add_messages
    
    class RouterState(TypedDict):
        # 리듀서 있음 — 새 메시지가 올 때마다 누적
        messages: Annotated[list[BaseMessage], add_messages]
        # 리듀서 없음 — 노드가 반환하면 그 값으로 단순 교체
        category: str
    

    두 필드의 선언 방식이 다르다. messagesAnnotated[list[BaseMessage], add_messages] 형태로, 타입 힌트 뒤에 add_messages라는 리듀서 함수를 붙였다. categorystr만 있고 리듀서가 없다. 이 차이가 두 필드의 업데이트 동작을 완전히 다르게 만든다.

    리듀서를 "충돌 해결 전략"이라고 생각하면 이해가 쉽다. 여러 노드가 같은 필드에 값을 쓰려 할 때, 어떤 방식으로 합칠지를 결정하는 함수다. add_messages는 기존 리스트에 새 항목을 추가하는 전략이고, 리듀서가 없는 경우는 마지막으로 쓴 값이 이기는 전략이다.

    3. 리듀서 있는 필드 vs 없는 필드 — 업데이트 방식 비교

    두 방식이 어떻게 다른지 구체적인 예시로 살펴보자. 먼저 add_messages 리듀서가 있는 경우다.

    diagram

    리듀서가 있는 messages 필드는 노드가 새 메시지를 반환할 때마다 기존 리스트에 추가된다. 상태가 교체되는 것이 아니라 누적된다. classifier_node가 AIMessage를 반환해도 기존 HumanMessage는 사라지지 않는다. 대화 이력 전체를 보존해야 하는 LLM 워크플로우에서 이 동작이 필수적인 이유다. 만약 리듀서 없이 단순 교체 방식이었다면, 매 노드마다 이전 대화가 날아가 LLM이 맥락 없이 동작하게 된다.

    이번엔 리듀서가 없는 category 필드의 경우다.

    diagram

    리듀서가 없는 category 필드는 노드가 새 값을 반환하면 이전 값이 완전히 사라지고 그 값으로 교체된다. 누적이 아니라 덮어쓰기다. 라우팅 시나리오에서 분류 결과는 "가장 최근의 판단"만 중요하기 때문에 이 방식이 적합하다. 과거의 분류 이력이 쌓일 필요가 없고, 오히려 쌓이면 router가 어떤 값을 써야 할지 헷갈려진다. 필드의 목적에 맞는 업데이트 방식을 선택하는 것이 상태 설계의 핵심이다.

    4. 노드 간 데이터 흐름 — 각 노드는 담당 필드만 반환한다

    RouterState를 사용하는 워크플로우의 전체 흐름을 코드와 함께 보자.

    # classifier: category만 반환, messages는 건드리지 않음 (영화표 예매 봇)
    def classifier_node(state: RouterState) -> dict:
        content = state["messages"][-1].content
        if "주문" in content: return {"category": "order"}    # 영화표 주문
        elif "환불" in content: return {"category": "refund"}  # 영화표 환불
        else: return {"category": "general"}
    
    # 라우팅 함수: state를 읽어 "다음 노드 이름"(문자열)만 반환 — 상태는 변경하지 않는다
    def route_by_category(state: RouterState) -> str:
        return state["category"]
    
    # handler: messages만 반환, category는 건드리지 않음
    def order_handler(state: RouterState) -> dict:
        return {"messages": [AIMessage(content="영화표 주문 관련 안내입니다.")]}
    
    # 조건부 엣지: classifier 결과에 따라 세 경로로 분기
    builder.add_conditional_edges("classifier", route_by_category,
        {"order": "order_handler", "refund": "refund_handler", "general": "general_handler"})
    

    코드에서 주목할 점이 세 가지다. 첫째, 각 노드 함수는 RouterState 전체를 인자로 받지만, 반환할 때는 자신이 담당하는 필드만 담은 딕셔너리를 반환한다. classifier_nodecategory만, handler 노드들은 messages만 반환한다. 반환하지 않은 필드는 LangGraph가 건드리지 않고 이전 값을 유지한다. 참고로 위 classifier_node는 설명을 위해 if "주문" in content 같은 키워드 매칭으로 단순화했지만, 실제 봇에서는 이 자리가 LLM에 메시지를 넘겨 카테고리를 판단하는 호출이 된다. START 직후 분류 판단이 일어나는 단계가 바로 이 노드다. 둘째, 라우팅 함수 route_by_category는 상태를 읽어 "다음 노드 이름"(문자열)을 반환할 뿐, 상태 업데이트(딕셔너리)는 반환하지 않는다. 다른 노드들이 담당 필드를 담은 딕셔너리를 반환해 상태를 갱신하는 것과 달리, 라우팅 함수의 반환값은 다음에 실행할 노드를 고르는 데만 쓰이고 상태에는 반영되지 않는다. 즉 라우팅 함수는 상태를 변경해서는 안 된다. 셋째, handler 노드에서 return {"messages": [response]}라고 할 때 리스트로 감싸야 한다. add_messages 리듀서가 기대하는 입력이 리스트 형태이기 때문이다.

    diagram

    위 다이어그램은 RouterState를 중심으로 각 노드가 어느 필드를 읽고(입력), 어느 필드에 쓰는지(출력)를 명시한 관계도다. classifier_node는 messages를 읽어 category를 쓰고, route_by_category는 category를 읽어 경로만 결정하며, handler 노드들은 category를 읽고 messages를 쓴다. 이 그림의 핵심은 "읽기/쓰기 역할이 겹치는 노드가 없다"는 것이다. 만약 두 노드가 같은 필드를 쓴다면 리듀서 선택에 신중해야 하고, 예상치 못한 값 덮어쓰기가 발생하지 않는지 검토해야 한다.

    5. 왜 State 클래스로 분리하는가 — 타입과 계약

    State를 별도 클래스로 정의하지 않고, 노드가 그냥 딕셔너리를 주고받아도 동작은 한다. 그럼에도 TypedDict로 명시적으로 선언하는 이유가 있다.

    첫째, 노드 함수 시그니처가 단순해진다. 어떤 노드든 def node_fn(state: RouterState) -> dict 형태로 동일하다. 필드가 10개라도 함수 인자는 하나다. 노드를 추가하거나 교체할 때 인터페이스 변경이 없다.

    둘째, 타입 검사기(mypy, pyright)가 잘못된 필드 접근을 잡아준다. state["categori"]처럼 오타가 있으면 런타임 전에 경고가 뜬다. 그래프가 복잡해질수록 이 타입 안전성이 버그를 줄이는 데 실질적인 도움이 된다.

    셋째, 어떤 데이터가 워크플로우 전체를 흐르는지 한눈에 파악할 수 있다. RouterState를 보면 이 워크플로우에서 관리하는 데이터가 messagescategory 두 가지임을 즉시 알 수 있다. 새로운 개발자가 코드를 이해할 때도, 6개월 후의 자신이 코드를 수정할 때도 이 명시성이 도움이 된다.

    이름에 대해 한 가지 덧붙인다. AgentStateRouterState는 LangGraph가 정한 이름이 아니다. 둘 다 우리가 직접 만드는 TypedDict 클래스의 이름일 뿐이고, StateGraph(스키마)가 보는 것은 클래스 이름이 아니라 그 안의 필드 이름과 리듀서 어노테이션뿐이다. 그래서 똑같은 구조를 GraphStateState로 불러도 아무 문제 없이 동작한다. 이 글에서 RouterState라고 부른 것은 category처럼 라우팅 분기에 쓰는 필드를 담고 있다는 의미를 이름에 드러내려는 관례일 뿐, 표준 명칭이 아니다. 다만 예외가 하나 있다 — messages 필드 하나만 add_messages 리듀서와 함께 미리 정의해둔 MessagesStatelanggraph.graph가 실제로 제공하는 빌트인 클래스다. 이건 import해서 그대로 쓰거나 상속하는 "정해진 이름"이 맞다.

    diagram

    이 다이어그램은 RouterState가 "타입 계약"으로서 각 노드와 맺는 관계를 보여준다. RouterState는 노드들에게 어떤 필드가 있는지, 어떤 타입인지를 알려주고, 노드들은 그 계약을 지켜 자신의 담당 필드만 반환한다. 데이터 흐름이 State를 통해 단방향으로 순환하는 구조다. 이 계약이 명확할수록 노드를 독립적으로 개발하고 테스트하기 쉬워진다. 반대로 State 클래스 없이 자유로운 딕셔너리를 쓰면, 어떤 노드가 어떤 키를 쓰는지 코드를 전부 읽어야만 파악할 수 있다.

    6. 자주 빠지는 함정

    함정 1: 리듀서 필드에 리스트 대신 단일 객체를 반환한다. add_messages 리듀서는 리스트 입력을 기대한다. return {"messages": response}가 아니라 return {"messages": [response]}여야 한다. 단일 객체를 넘기면 LangGraph가 이를 리스트로 해석하려다 타입 오류가 발생한다.

    함정 2: 라우팅 함수에서 상태를 수정하려 한다. add_conditional_edges에 넘기는 라우팅 함수는 반드시 다음 노드의 이름(문자열)만 반환해야 한다. 이 함수 안에서 상태를 변경하는 코드를 쓰면 LangGraph가 그 반환값을 상태 업데이트로 처리하지 않고 무시하거나 오류가 난다. 상태 변경이 필요하면 별도 노드를 만들어야 한다.

    함정 3: 리듀서 없는 필드를 여러 노드가 동시에 쓴다. 병렬 실행(fan-out) 구조에서 두 노드가 모두 category를 반환하면 어느 값이 최종 상태에 남는지 보장할 수 없다. 리듀서 없는 필드는 한 번에 하나의 노드만 쓰도록 설계해야 한다. 여러 노드가 쓸 필요가 있다면 리스트 타입으로 바꾸고 리듀서를 추가하는 편이 안전하다.

    함정 4: 초기 상태에 모든 필드를 포함하지 않는다. TypedDict에 선언한 필드는 워크플로우 실행 시 초기 입력에 포함되어야 한다. category가 선언됐는데 초기 입력에 없으면 state["category"] 접근 시 KeyError가 발생한다. 필드에 기본값을 주고 싶으면 TypedDicttotal=False 옵션이나 별도 초기화 노드를 활용한다.

    마치며

    RouterState는 LangGraph 상태 설계의 기본 패턴을 모두 담고 있다. messages처럼 누적이 필요한 필드에는 리듀서를 붙이고, category처럼 최신 값만 필요한 필드는 리듀서 없이 단순 교체를 쓴다. 각 노드는 전체 상태를 받되 자신의 담당 필드만 반환한다. TypedDict로 선언하면 이 모든 계약이 타입으로 명시된다.

    실제 워크플로우에서 상태에 추가하고 싶어지는 필드들이 있다. 재시도 횟수를 세는 retry_count: int, 에러 메시지를 저장하는 error: str, 처리 중간 결과를 쌓는 intermediate_results: list[str] 같은 것들이다. 각 필드에 리듀서가 필요한지 여부만 먼저 판단하면, 이후 설계는 어렵지 않다. "이 값은 누적되어야 하는가, 아니면 최신 값으로 교체되어야 하는가" — 이 질문 하나가 LangGraph 상태 설계의 핵심이다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.