-
LangGraph가 Annotated를 쓰는 이유 — 덮어쓰기 문제와 리듀서의 등장IT 2026. 6. 29. 22:00
LangGraph 코드를 처음 보면 눈에 걸리는 문법이 있다.
class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages]list[BaseMessage]만 써도 될 것 같은데, 왜Annotated를 끼워 넣고add_messages를 붙이는 걸까. 이 글은 그 "왜"를 배경부터 따라간다. 이 문법이 등장할 수밖에 없었던 이유, 없었을 때 생기는 문제, 그리고 리듀서라는 개념이 어떻게 그것을 깔끔하게 해결했는지를 순서대로 본다.1. 배경 — 여러 노드가 하나의 상태를 공유한다
LangGraph 에이전트는 여러 노드(Python 함수)가 차례로 또는 조건에 따라 실행된다. 이 노드들이 데이터를 주고받는 방식은 함수 인자/반환값의 직접 연결이 아니다. 모든 노드가 하나의 공유 상태 객체를 경유한다.
각 노드는 상태 전체를 받아 읽고, 자신이 바꾼 키만 담은 딕셔너리를 반환한다. LangGraph는 그 반환값을 상태에 "적용"해 다음 노드에 전달한다. 여기서 핵심 질문이 생긴다 — "적용"이란 정확히 무엇을 의미하는가?
2. 문제 — 기본 적용 방식은 덮어쓰기다
LangGraph의 기본 동작은 단순하다. 노드가 반환한 딕셔너리의 키로 상태를 덮어쓴다(overwrite). 노드가
{"messages": [new_message]}를 반환하면, 상태의messages가[new_message]로 교체된다.노드가 AIMessage를 반환하는 순간, 그 전에 있던 HumanMessage가 사라진다. 단순한 선형 실행이라면 모르겠다. 그런데 LangGraph가 루프를 돌기 시작하면 이 문제가 치명적으로 커진다.
3. 문제가 커지는 순간 — 루프에서의 참사
LangGraph 에이전트의 핵심 패턴은 ReAct 루프다. agent 노드가 판단하고, tools 노드가 실행하고, 다시 agent로 돌아온다. 이 루프가 돌 때 messages에 어떤 일이 벌어지는지 단계별로 추적해보자.
루프가 한 번 돌 때마다 이전 단계의 메시지가 지워진다. agent 노드가 두 번째로 실행될 때 LLM이 받는 메시지는
[ToolMessage('8.6')]뿐이다. 원래 질문이 무엇이었는지, 어떤 도구를 왜 호출했는지 — 맥락 전체가 사라진다. LLM은 뒤죽박죽 상태에서 판단을 내려야 한다. 에이전트가 같은 도구를 반복 호출하거나 엉뚱한 답을 내놓는 이유가 바로 여기 있다.4. 첫 번째 시도 — 노드가 직접 합치면 되지 않나?
문제를 알았으니 가장 직관적인 해결책을 생각해보자. 각 노드가 반환할 때 이전 메시지와 새 메시지를 직접 합쳐서 반환하면 된다.
def agent_node(state: AgentState): response = llm.invoke(state["messages"]) # 새 메시지를 기존 목록에 직접 추가해서 반환 return {"messages": state["messages"] + [response]}동작은 한다. 하지만 이 방식에는 세 가지 문제가 있다.
노드가 10개라면 병합 로직을 10번 써야 한다. 게다가 노드의 본래 역할은 "LLM을 호출하고 결과를 판단하는 것"인데, 거기에 "이전 메시지 목록을 가져와서 새 메시지와 합치는 것"이 섞인다. 관심사가 뒤엉킨다. 더 나쁜 것은, 누군가 실수로
state["messages"] + [response]대신[response]만 반환해도 런타임 에러 없이 조용히 이력이 날아간다는 점이다.5. 핵심 통찰 — "병합 방식"을 키에 선언하자
이 문제의 근본 원인은 "어떻게 합칠지"가 노드 코드에 숨어 있다는 것이다. 그 정보가 노드마다 분산되고, 노드를 바꿀 때마다 병합 방식도 같이 신경 써야 한다.
LangGraph가 선택한 해결책은 방향을 바꾸는 것이다. "어떻게 합칠지"를 노드에 두지 말고, 상태의 키에 선언하자. 이 선언이 있으면 프레임워크가 알아서 병합을 처리하고, 노드는 새 값만 반환하면 된다.
기존 방식에서는 병합 책임이 노드 쪽에 있다. agent 노드든 tool 노드든, 자신이 만든 새 메시지를 이전 목록과 직접 합쳐서 반환해야 한다. 노드가 늘어날수록 같은 병합 로직이 그대로 복제되고, 본래 노드의 역할(LLM 호출·판단)과 "이전 목록을 가져와 합치는 일"이라는 별개의 관심사가 한 함수 안에 뒤엉킨다.
새 방식은 병합 책임을 상태 키로 옮긴다. messages 키 선언에 "어떻게 합칠지"를 한 번만 적어두면, 각 노드는 자기 차례에 만든 새 메시지만 반환하면 된다. 이전 목록과 합치는 일은 프레임워크가 그 선언을 보고 자동으로 처리한다. 병합 방식이 한곳에 모이고, 노드 코드에서는 병합이라는 관심사가 통째로 사라진다.
이 발상이 리듀서(Reducer)다.
6. 리듀서(Reducer)란 무엇인가
리듀서는 JavaScript 생태계의 Redux 상태 관리 라이브러리에서 유래한 개념이다. 정의는 단순하다.
리듀서 = (현재 상태, 새 데이터) → 다음 상태를 반환하는 함수
"어떻게 합칠지"를 코드로 표현한 함수다. 리듀서가 다를 때마다 병합 방식이 달라진다.
리듀서 함수가 현재 목록과 새 목록을 받아 합친 목록을 반환한다. 노드가
[ToolMessage]만 반환해도, 리듀서가 그것을 기존 목록 뒤에 붙여준다. 노드는 "자기 차례에 추가할 것"만 신경 쓰면 된다.리듀서가 없을 때(기본 덮어쓰기)와 있을 때를 나란히 비교하면 차이가 명확하다.
리듀서가 없으면 LangGraph는 노드 반환값으로 상태를 통째로 덮어쓴다. 초기 목록에 HumanMessage가 들어 있어도, agent 노드가 AIMessage 하나만 반환하는 순간 상태는 그 하나로 교체된다. 직전 HumanMessage는 어떤 에러도 없이 조용히 사라진다.
리듀서가 있으면 노드 반환값이 상태를 교체하지 않고, 리듀서를 거쳐 기존 목록 뒤에 더해진다. 같은 초기 목록에 같은 AIMessage를 반환해도, 리듀서가 둘을 이어 붙여 HumanMessage와 AIMessage가 함께 남는다.
리듀서 없이는 노드가 반환할 때마다 이전 값이 지워진다. 리듀서가 있으면 반환값이 기존 상태에 "더해진다". 같은 노드 코드, 같은 반환값이지만 결과가 완전히 다르다.
7. Python에서 리듀서를 어디에 선언하는가 — Annotated
리듀서를 키에 선언한다는 아이디어는 좋다. 그런데 Python의
TypedDict는 키마다 타입만 지정할 수 있다. 리듀서 함수를 끼워 넣을 자리가 없다.class AgentState(TypedDict): messages: list[BaseMessage] # 타입만 있음. 리듀서를 어디 넣지?이 문제를 Python 표준 라이브러리가 이미 해결해 두었다.
typing.Annotated다.Annotated는 타입 힌트에 부가 정보(메타데이터)를 함께 담는 문법이다. Python 3.9에 추가됐다.Annotated하나가 두 종류의 소비자에게 각각 다른 정보를 전달한다. mypy나 pyright 같은 타입 체커는 첫 번째 인자(list[BaseMessage])만 보고 타입 검사를 한다. LangGraph 런타임은 두 번째 인자(add_messages)를 보고 "이 키를 업데이트할 때 이 함수를 리듀서로 쓰겠다"고 결정한다.이제 전체 선언이 한 줄로 완성된다.
class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] # ↑ 타입: list[BaseMessage] ↑ 리듀서: add_messages타입 정보와 병합 방식이 한 줄에 함께 선언된다. 노드 함수를 작성하는 사람은 이 선언을 보는 것만으로 "messages는 덮어쓰지 않고 누적된다"는 사실을 알 수 있다.
8. add_messages — 리듀서의 구체적 구현
add_messages는 LangGraph가 제공하는 리듀서 함수다. 시그니처는 리듀서 계약을 그대로 따른다.# add_messages의 역할 (단순화) def add_messages(current: list[BaseMessage], new: list[BaseMessage]) -> list[BaseMessage]: return current + new # 기존 목록에 새 메시지를 이어 붙임실제 구현은 한 가지가 더 있다. 같은 ID를 가진 메시지가 들어오면 추가 대신 교체한다. 도구 호출 결과가 수정되거나 메시지를 업데이트해야 할 때 활용된다. 하지만 대부분의 경우 동작은 단순히 "뒤에 붙이기"다.
add_messages가 개입하면 노드가
[ToolMessage]만 반환해도 기존 두 메시지는 사라지지 않는다. 루프가 여러 번 돌아도 처음 HumanMessage부터 최신 응답까지 모두 누적된다.9. 효과 — 루프가 정상적으로 작동한다
Annotated와 add_messages가 적용된 뒤 루프 흐름이 어떻게 달라지는지 3절의 "참사" 시나리오와 비교해보자.
루프가 돌아도 메시지가 사라지지 않는다. agent 노드가 두 번째로 실행될 때 LLM은 원래 질문, 도구 호출 결정, 실행 결과를 전부 맥락으로 받는다. 이 맥락이 있어야 "8.6이 나왔으니 '기생충 평점은 8.6입니다'라고 답하겠다"는 판단이 가능하다.
노드 코드는 변하지 않는다. agent_node는 여전히 새 메시지만 반환한다. 달라진 것은 상태 키 선언 한 줄뿐이다.
# 노드 코드는 단순하게 유지된다 def agent_node(state: AgentState): response = llm_with_tools.invoke(state["messages"]) return {"messages": [response]} # 새 메시지만 반환 def tool_node(state: AgentState): results = run_tools(state["messages"][-1].tool_calls) return {"messages": results} # 새 메시지만 반환두 노드 모두 이전 메시지를 직접 다루지 않는다. "자기 차례에 추가할 것"만 반환하고, 누적은 프레임워크가 처리한다.
10. 리듀서가 없는 필드와 있는 필드 — 필요에 따라 선택한다
모든 필드에 리듀서가 필요한 것은 아니다. "가장 최신 값만 필요한" 필드는 덮어쓰기가 맞다. 리듀서는 "누적이 필요한" 필드에만 쓴다.
판단 기준은 하나다. "이 값은 루프를 돌아도 이전 것이 남아 있어야 하는가?" — 그렇다면 리듀서를 쓴다. 그렇지 않다면 기본 덮어쓰기를 쓴다.
category는 "현재 분류 결과"만 필요하므로 덮어쓰기가 맞다.messages는 "전체 대화 이력"이 필요하므로 리듀서가 필요하다.11. 정리 — 왜 이 문법이 필요했는가
처음으로 돌아가보자. 왜 이 한 줄이 필요했는가.
messages: Annotated[list[BaseMessage], add_messages]핵심을 세 문장으로 정리하면 이렇다.
문제: LangGraph의 기본 상태 업데이트는 덮어쓰기라, 루프를 돌면 이전 대화 이력이 소실된다.
해결: 상태 키마다 "어떻게 합칠지"를 선언(리듀서)하면, 노드는 새 값만 반환하고 프레임워크가 병합을 처리한다.
문법: Python의
Annotated가 타입 정보와 리듀서를 한 표현식에 담는 자리를 제공한다.add_messages가 "기존 목록에 추가"를 구현한 리듀서다.Annotated[list[BaseMessage], add_messages]라는 한 줄은 "이 필드의 타입은 BaseMessage 목록이고, 업데이트할 때는 덮어쓰지 말고 add_messages 함수로 누적하라"는 완전한 선언이다. 이 선언 하나로 노드 코드는 단순해지고, 루프는 안전해지고, 병합 방식은 한 곳에 모인다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
MCP는 왜 Streamable HTTP로 갈아탔나 — 엔드포인트 하나로 스트림을 다스리는 법 (0) 2026.07.01 MCP는 왜 SSE를 골랐나 — HTTP 위에서 서버가 먼저 말하게 하는 법 (0) 2026.07.01 서브 에이전트를 @tool로 감싸는 멀티 에이전트 패턴 (0) 2026.06.30 @tool이 내부에서 하는 일 — Pydantic BaseModel이 LLM의 호출 인터페이스가 되는 과정 (0) 2026.06.30 Pydantic BaseModel이란 무엇인가 — 타입 힌트를 진짜 검증으로 바꾸는 도구 (0) 2026.06.29 RAG 에이전트 완전 조립 — create_agent부터 동작 추적까지 (1) 2026.06.29 검색 결과를 에이전트 도구로 — build_context와 @tool 패턴 (0) 2026.06.28 RAG의 배경과 make_retriever — LLM이 모르는 문서를 검색하는 방법 (0) 2026.06.28 생성과 검증의 분리 — generator 노드와 validator 노드 설계 (0) 2026.06.28 LangGraph 자기 수정 패턴의 State 설계 — 루프를 위한 5가지 필드 (0) 2026.06.27