-
LangGraph의 상태(State) 설계 — Annotated와 add_messages가 하는 일IT 2026. 6. 25. 22:00
LangGraph에서 노드를 연결하는 것은 코드 몇 줄이면 된다. 그런데 그 전에 반드시 먼저 결정해야 하는 것이 있다. 노드와 노드 사이에서 무엇이 흘러다니느냐, 즉 상태(State)를 어떻게 정의하느냐다. 상태 설계가 잘못되면 노드가 아무리 정교해도 데이터가 예상대로 누적되지 않거나, 루프를 돌 때마다 이전 대화가 날아간다. 이 글은 LangGraph의 상태 설계 핵심인
TypedDict,Annotated,add_messages세 가지가 각각 무엇을 하는지, 그리고 왜 이 조합이 필요한지를 파고든다.1. 왜 상태가 필요한가 — 함수 인자/반환값만으론 부족한 이유
LangGraph 그래프를 구성하는 각 노드는 독립된 Python 함수다. node1이 실행되고 나서 node2가 실행될 때, 둘 사이에 데이터를 어떻게 전달할까? 가장 먼저 떠오르는 방법은 반환값을 다음 함수의 인자로 넘기는 것이다. 선형 체인이라면 그것으로 충분하다.
문제는 그래프가 루프를 돌거나, 노드가 여러 곳에서 수렴하거나, 조건에 따라 경로가 갈릴 때다. 이런 구조에서는 "어디서 왔느냐"에 따라 함수 시그니처가 달라져야 하고, 여러 이전 노드의 결과를 합쳐야 하는 경우도 생긴다. 단순 함수 호출 체인으로는 이를 깔끔하게 표현할 수 없다.
LangGraph는 다른 방식을 택했다. 모든 노드가 공유하는 단일 상태(State) 객체를 두는 것이다. 각 노드는 이 상태를 읽고, 업데이트할 키만 담은 부분 dict를 반환한다. LangGraph 런타임이 그 dict를 받아 리듀서를 적용한 뒤 상태를 갱신한다. 다음 노드는 갱신된 상태를 이어받아 읽는다. 데이터의 흐름이 노드 간 직접 전달이 아니라, 상태라는 공유 저장소를 경유한다.
이 다이어그램이 보여주는 것은 상태가 노드들의 공유 칠판(shared blackboard)이라는 개념이다. node1이 부분 dict를 반환하면 LangGraph 런타임이 상태에 반영하고, node2는 그 갱신된 상태를 읽는다. 루프가 돌아도 마찬가지다. agent 노드가 세 번째 실행될 때도 첫 번째, 두 번째 실행이 남긴 기록을 상태에서 꺼낼 수 있다. 함수 인자/반환값 방식에서는 루프를 돌 때마다 이전 결과가 덮어써지거나, 어디선가 직접 축적 로직을 구현해야 한다. 상태 기반 구조에서는 그 역할을 리듀서가 대신 해준다. 주의할 점은 각 노드의 반환값이 상태 전체를 교체하는 것이 아니라, 반환된 키만 업데이트한다는 것이다. 반환하지 않은 키는 이전 값이 그대로 유지된다.
2. TypedDict — 상태 구조를 Python 타입으로 선언하는 방법
LangGraph의 상태는 딕셔너리다. 하지만 아무 딕셔너리나 쓰지 않는다.
TypedDict를 사용해 어떤 키가 존재하고 각 키의 타입이 무엇인지를 미리 선언한다.from typing_extensions import TypedDict # TypedDict: 딕셔너리인데 키와 타입이 미리 선언된 구조 class AgentState(TypedDict): messages: list # messages 키는 리스트 타입 retry_count: int # retry_count 키는 정수 타입TypedDict는 Python의 일반 딕셔너리와 런타임 동작이 같다. 추가되는 것은 타입 힌트뿐이다. 그렇다면 왜 굳이 쓰는 걸까? 이유는 두 가지다.첫째, LangGraph가 상태 구조를 인식한다.
StateGraph(AgentState)처럼 상태 클래스를 그래프 생성자에 넘기면, LangGraph는 이 클래스에 선언된 키들을 알게 된다. 그 정보를 바탕으로 각 노드의 반환값이 올바른 키를 반환하는지 검증하고, 어떤 키에 어떤 업데이트 방식을 적용할지 결정할 수 있다.둘째, 개발자 경험이다. 상태 클래스를 보면 이 그래프에서 어떤 데이터가 흘러다니는지 한눈에 파악된다. 각 노드 함수의 타입 힌트(
state: AgentState)와 결합하면 IDE 자동완성과 mypy 타입 체크도 동작한다.state["messges"]처럼 오타가 났을 때 잡아낼 수 있다.TypedDict의 역할을 보여주는 다이어그램이다. TypedDict 클래스 하나가 두 방향으로 기여한다. 하나는 LangGraph 런타임 방향 — StateGraph 생성자에 전달되어 그래프가 상태 구조를 인식하도록 한다. 다른 하나는 개발자 경험 방향 — 노드 함수의 타입 힌트로 사용되어 IDE와 타입 체커가 오류를 잡아준다. 함정은 TypedDict가 런타임 타입 강제를 하지 않는다는 점이다.
state["retry_count"] = "hello"처럼 잘못된 타입으로 써도 런타임 예외가 발생하지 않는다. mypy나 pyright 같은 정적 분석 도구를 병행해야 실질적인 타입 안전성이 생긴다.3. 리듀서(Reducer) 패턴 — 상태를 어떻게 업데이트할지 함수로 지정
노드가 상태를 업데이트할 때 기본 동작은 덮어쓰기(overwrite)다. 노드가
{"messages": [new_message]}를 반환하면, 상태의messages키가[new_message]로 교체된다. 루프를 돌기 전 쌓인 대화 이력이 모두 사라진다.에이전트의 대화 이력은 덮어쓰면 안 된다. 새 메시지가 올 때마다 기존 목록에 추가(append)되어야 한다. 이 동작을 지정하는 것이 리듀서다.
리듀서 개념 자체는 새롭지 않다. Redux(JavaScript 상태 관리 라이브러리)에서 유래했다. "현재 상태 + 새 데이터를 받아 다음 상태를 반환하는 함수"라는 정의가 그대로 적용된다. LangGraph에서는 이 리듀서를 타입 힌트 수준에서 선언한다.
아래 두 다이어그램이 리듀서 유무의 차이를 보여준다. 먼저 리듀서가 없는 기본 동작이다.
리듀서 없이 기본 덮어쓰기가 작동하면 node1이
{"messages": [ai_message]}를 반환할 때 이전에 있던 HumanMessage가 사라진다. 루프를 한 번 돌 때마다 대화 이력이 리셋된다. 에이전트가 이전 맥락을 기억하지 못하는 증상이 여기서 비롯된다.add_messages리듀서가 개입하면 동작이 달라진다. node1의 반환값[ai_message]가 상태에 직접 쓰이지 않는다. 대신 리듀서 함수가 "현재messages목록 + 새로 반환된 메시지 목록"을 합쳐서 저장한다. 루프가 열 번 돌아도 처음 HumanMessage부터 최신 AIMessage까지 모두 축적된다. 리듀서의 핵심은 "새 데이터로 대체"가 아니라 "기존 상태와 새 데이터를 합쳐 다음 상태를 만든다"는 점이다.4. Annotated — 타입과 리듀서를 동시에 선언하는 문법
TypedDict는 키의 타입만 선언한다. 리듀서는 어디에 선언할까? Python은 타입 힌트 하나에 여러 메타데이터를 붙이는
Annotated라는 문법을 제공한다. LangGraph는 이것을 활용해 타입과 리듀서를 한 줄에 함께 선언한다.from typing import Annotated from langchain_core.messages import BaseMessage, HumanMessage, AIMessage from langgraph.graph.message import add_messages from typing_extensions import TypedDict class AgentState(TypedDict): # Annotated의 두 번째 인자가 리듀서 — 새 메시지를 덮어쓰지 않고 누적 messages: Annotated[list[BaseMessage], add_messages]Annotated[list[BaseMessage], add_messages]를 분해하면 이렇다.list[BaseMessage]는 이 키의 타입이다. 런타임 타입 체커와 IDE가 이 정보를 사용한다.add_messages는 LangGraph가 읽는 메타데이터다. 이 키를 업데이트할 때 일반 덮어쓰기 대신add_messages함수를 리듀서로 사용하라는 선언이다.Annotated가 하나의 표현식 안에서 두 종류의 소비자에게 각각 다른 정보를 전달하는 구조를 보여준다. 타입 체커는 첫 번째 인자(list[BaseMessage])만 본다. LangGraph 런타임은 두 번째 인자(add_messages)를 보고 업데이트 방식을 결정한다. 이 이중 구조 덕분에 타입 안전성과 커스텀 업데이트 로직을 단일 선언으로 표현할 수 있다. 함정은Annotated의 두 번째 인자가 아무 함수나 될 수 있다는 점이다. LangGraph가 리듀서로 인식하려면 함수 시그니처가(현재값, 새값) → 다음값형태여야 한다. 잘못된 함수를 넣으면 런타임까지 오류가 드러나지 않는다.5. BaseMessage 계층 — 메시지 타입을 나누는 이유
list[BaseMessage]에서BaseMessage는 LangChain의 메시지 기반 클래스다. 실제 코드에서는 하위 클래스인HumanMessage,AIMessage,ToolMessage를 사용한다. 왜 이렇게 나눠두었을까?LLM API는 메시지를 구분한다. OpenAI의
role필드가user,assistant,tool로 나뉘는 것처럼, LangChain도 역할에 따라 메시지 클래스를 나눈다. 각 클래스가 LLM API 호출 시 올바른 형식으로 직렬화된다.BaseMessage 계층을 보여주는 다이어그램이다. 세 하위 클래스가 각각 다른 역할을 담당한다.
HumanMessage는 사용자가 입력한 내용이다.AIMessage는 LLM이 반환한 응답이고, 중요한 추가 필드인tool_calls를 가진다. LLM이 도구를 호출하기로 결정하면 이 필드에 호출 정보가 담긴다.ToolMessage는 도구 실행 결과를 담는다.tool_call_id필드로 어느tool_calls요청에 대한 응답인지를 연결한다.add_messages리듀서는 이 세 타입을 구분하지 않고 모두 누적한다. 결과적으로messages리스트 하나에 대화 전체 — 사용자 입력, LLM 응답, 도구 결과 — 가 순서대로 쌓인다. 함정은 LLM API에messages를 그대로 넘기면 된다고 가정하는 것이다. 일부 모델은ToolMessage앞에 반드시AIMessage의tool_calls가 있어야 하는 등 순서 제약이 있다. LangChain의 chat model 구현이 직렬화 시 이를 처리해주지만, 직접 메시지를 조작할 때는 주의가 필요하다.6. 노드가 상태를 읽고 쓰는 흐름
지금까지의 개념이 실제 코드에서 어떻게 동작하는지 전체 흐름으로 확인해보자.
class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] def agent_node(state: AgentState) -> dict: response = llm_with_tools.invoke(state["messages"]) # 전체 이력 읽기 return {"messages": [response]} # 새 메시지만 반환, add_messages가 누적 def tool_node_fn(state: AgentState) -> dict: results = run_tools(state["messages"][-1].tool_calls) # AIMessage에서 tool_calls 추출 return {"messages": results}코드에서 두 노드 함수의 공통 패턴을 보자. 받는 쪽에서는
state["messages"]로 이력 전체를 읽는다. 반환하는 쪽에서는 새로 추가할 메시지만 담아서 반환한다. 이전 이력 전체를 직접 합칠 필요가 없다.add_messages가 그 역할을 대신 한다. 노드 코드가 단순해지는 이유다.루프를 한 번 돈 흐름이다. agent 노드가 처음 실행되면 상태에는 HumanMessage만 있다. AIMessage가 반환되고
add_messages가 누적한다. 이제 상태에는 두 메시지가 있다. tool 노드가 실행되어 ToolMessage를 반환하면 세 개가 된다. agent 노드가 두 번째로 실행될 때 LLM은 이 세 메시지를 모두 받는다. 도구 실행 결과를 알고 있는 LLM이 다음 판단을 내릴 수 있는 이유다. 누적이 없었다면 매번 같은 HumanMessage만 보고 같은 도구를 반복 호출할 것이다. 함정은 루프가 많이 돌수록messages가 길어져 LLM의 컨텍스트 창(context window)을 초과할 수 있다는 점이다. 장시간 실행되는 에이전트에서는 오래된 메시지를 요약하거나 제거하는 메시지 트리밍 전략이 필요하다.7. 마무리 — 상태 설계는 그래프 설계의 절반
LangGraph의 상태 설계를 한 줄로 요약하면 이렇다. TypedDict로 구조를 선언하고, Annotated로 리듀서를 지정하고, add_messages로 대화 이력을 누적한다.
각 요소의 역할을 다시 정리하면 이렇다.
TypedDict는 어떤 키가 있고 각 타입이 무엇인지를 LangGraph 런타임과 타입 체커 모두에게 알린다.Annotated는 타입과 리듀서를 한 줄에 함께 선언하는 문법이다.add_messages는 구체적인 리듀서 구현이다 — 새 메시지를 덮어쓰지 않고 기존 목록에 추가한다.이 설계가 중요한 이유는 노드 코드의 단순함을 만들어내기 때문이다. 각 노드는 "무엇을 새로 추가할지"만 반환하면 된다. 기존 상태와 합치는 로직은 리듀서가 처리한다. 노드가 많아지고 루프가 복잡해질수록 이 구조가 코드의 복잡도를 낮추는 데 기여한다. 상태 설계가 잘 되어 있으면 노드 코드는 단순해진다. 반대로 상태가 부적절하게 설계되면 모든 노드가 상태를 직접 조작하는 보일러플레이트로 가득 찬다. 노드를 만들기 전에 상태 구조를 먼저 설계하는 것을 권장하는 이유다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
LLM을 분류기로 쓰기 — SystemMessage와 HumanMessage로 Classifier Node 만들기 (0) 2026.06.27 LangGraph 상태에 메시지 외 필드 추가하기 — RouterState 설계 (0) 2026.06.26 graph.invoke vs graph.stream — 에이전트 실행의 두 가지 방식 (1) 2026.06.26 bind_tools — LLM이 도구를 인식하는 방법 (0) 2026.06.26 LangGraph StateGraph 완전 분해 — 노드·엣지·조건부 라우팅 (1) 2026.06.25 LangGraph가 등장한 이유 — 선형 체인을 넘어 그래프로 (0) 2026.06.25 Retriever를 에이전트 도구로 — RAG 패턴 구현 (0) 2026.06.24 LLM에 코드 실행 능력 붙이기 — PythonAstREPLTool (0) 2026.06.24 Pydantic Field로 LLM 출력 스키마를 제약하는 방법 (0) 2026.06.23 ProviderStrategy vs ToolStrategy — 구조화 출력 전략 선택 (0) 2026.06.23