본문 바로가기
생성형 AI

LangGraph의 Reducer vs React의 useReducer - 2

by Toddler_AD 2025. 11. 20.

9. 미니 프로젝트 시나리오 설정

우리가 만들 예시는 이런 구조입니다.

  • 백엔드(LangGraph + LangChain)
    • 상태를 TypedDict + Annotated로 정의
    • messages에는 add_messages 리듀서 연결 → 대화 로그가 누적되도록 설정 
    • ToolNode 로 외부 도구(간단한 get_weather) 호출 가능하게 설정 
    • tools_condition 으로 “도구 호출이 필요한지/아닌지”에 따라 분기하는 그래프 작성 
  • 프론트엔드(React + useReducer)
    • ChatWindow 컴포넌트에서 useReducer로 UI 상태 관리
    • dispatch 로:
      • 입력 값 변경
      • 전송 시작/성공/실패 상태 전환
    • /api/chat 엔드포인트로 요청 → LangGraph 백엔드 호출

이렇게 하면:

화면(UI) 쪽의 “작은 상태 머신”은 React useReducer

  • 서버(LLM/툴 워크플로우) 쪽의 “큰 상태 머신”은 LangGraph reducer

로 깔끔하게 나뉘는 그림이 됩니다.


10. 백엔드: LangGraph + Reducer + ToolNode + tools_condition

10.1 상태 정의: TypedDict + Annotated + add_messages

LangGraph에서는 StateGraph 생성 시 state_schema를 지정합니다. 이때 TypedDict와 typing.Annotated를 함께 사용해, 필드별 리듀서를 선언적으로 지정할 수 있습니다. 

대표적인 예가 messages 필드에 add_messages 리듀서를 붙이는 것인데, 이렇게 하면 기존 메시지 리스트 위에 새 메시지가 “append” 되도록 동작합니다

# src/langgraph_app.py

from typing import Annotated
from typing_extensions import TypedDict

from langchain_core.messages import AnyMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages  # 메시지용 리듀서

# 1) 그래프 상태 정의 ----------------------------------------------
class AgentState(TypedDict, total=False):
    # messages: LLM과 유저, 도구 간의 모든 메시지 히스토리
    # Annotated[..., add_messages] 를 붙여주면
    # 새로 반환되는 메시지가 "덮어쓰기"가 아니라 "누적(append)" 됩니다.
    messages: Annotated[list[AnyMessage], add_messages]

여기서 LangGraph reducer의 포인트:

  • Annotated[list[AnyMessage], add_messages]
    → messages 필드는 “새로 들어온 리스트를 기존 리스트 뒤에 붙이는(add) 리듀서”를 사용하게 됨.

10.2 도구 정의 + ToolNode

ToolNode 는 “그래프 안에서 툴 호출을 담당하는 노드”입니다.
LangChain의 @tool 데코레이터로 툴을 정의한 뒤, 그 리스트를 넘기면 됩니다.

# src/langgraph_app.py (이어서)

from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode

# 2) 툴 정의 ------------------------------------------------------
@tool
def get_weather(city: str) -> str:
    """간단한 날씨 조회 툴 (예시용)"""
    if city in ["서울", "인천"]:
        return "현재 기온은 20도이고 구름이 많아요."
    return "현재 기온은 30도이며 맑습니다."

tools = [get_weather]

# ToolNode: 여러 도구를 모아둔 '도구 실행 노드'
tool_node = ToolNode(tools)

ToolNode 는 다음과 같이 동작합니다. 

  • 상태의 messages 리스트를 보고,
  • 마지막 AIMessage 안에 있는 tool_calls 정보를 확인한 뒤,
  • 해당 도구들을 실제로 호출하여 결과를 다시 ToolMessage로 messages에 추가

10.3 LLM 노드 정의: 툴 바인딩 + 메시지 기반 챗봇

이제 LLM을 하나 만들고, 여기에 아까 정의한 tools를 바인딩합니다. 그러면 LLM이 스스로 “어떤 툴을 언제 호출할지” 결정할 수 있게 됩니다.

# src/langgraph_app.py (계속)

from langchain_openai import ChatOpenAI

# 3) LLM 정의 + 툴 바인딩 -----------------------------------------
llm = ChatOpenAI(
    model="gpt-4o-mini",
    temperature=0,
)

# LLM이 필요 시 get_weather 같은 툴을 호출하도록 바인딩
llm_with_tools = llm.bind_tools(tools)

# 4) 챗봇 노드: messages를 보고 다음 응답 생성 ----------------------
def chatbot_node(state: AgentState) -> AgentState:
    """
    - state['messages']를 보고 LLM이 다음 메시지를 생성
    - LLM이 판단해서 tool_call을 포함한 AIMessage를 만들 수도 있음
    """
    # add_messages 리듀서를 쓰기 때문에, 반환 시 리스트로 감싸서 내보냄
    ai_message = llm_with_tools.invoke(state["messages"])
    return {"messages": [ai_message]}

여기까지가

  • “LLM이 메시지 히스토리 + 도구 호출 능력”을 가진 하나의 노드

라고 볼 수 있습니다.


10.4 tools_condition 으로 분기 + 그래프 완성

LangGraph에서 tools_condition 은 “마지막 AI 메시지에 툴 호출이 있는지 여부에 따라 라우팅”을 해주는 유틸 함수입니다. 

  • 툴 호출이 필요하면 "tools" 를 반환
  • 그렇지 않으면 END (또는 사용자가 지정한 다른 노드 이름)를 반환

우리는 이걸 이용해:

  1. START → chatbot
  2. chatbot → tools_condition 평가
    • "tools" → ToolNode 로 이동
    • END → 그래프 종료
  3. ToolNode → 다시 chatbot (ReAct 스타일 루프)

라는 구조를 만들 수 있습니다.

# src/langgraph_app.py (마무리)

from langgraph.prebuilt import tools_condition

# 5) 그래프 빌더 생성 ----------------------------------------------
builder = StateGraph(AgentState)

# 노드 등록
builder.add_node("chatbot", chatbot_node)
builder.add_node("tools", tool_node)

# 엣지 정의
builder.add_edge(START, "chatbot")

# chatbot → (도구 필요한지에 따라) tools 또는 END 로 이동
builder.add_conditional_edges(
    "chatbot",
    tools_condition,           # 상태를 보고 "tools" 또는 END 리턴
    {
        "tools": "tools",      # "tools" 라는 결과가 나오면 tools 노드로
        END: END,              # 그렇지 않으면 그래프 종료
    },
)

# tools 실행 후 다시 chatbot으로 돌아가서 응답 이어가기
builder.add_edge("tools", "chatbot")

# 6) 그래프 컴파일 -----------------------------------------------
graph = builder.compile()

이제 graph.invoke(...) 에 {"messages": [...유저 메시지...]} 를 넣어주면:

  • messages 필드는 add_messages 리듀서 덕분에 계속 누적되고 
  • LLM이 툴 호출이 필요한지 판단 → ToolNode를 통해 툴 실행
  • 최종적으로 “툴을 쓰든 안 쓰든” 챗봇 응답 메시지가 messages 에 누적된 상태가 결과로 나옵니다.

10.5 HTTP API로 감싸기 (FastAPI 예시)

실제 서비스에서는 이 그래프를 HTTP API로 감싸서, 프론트엔드(React)가 호출하게 됩니다.

# src/server.py

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Literal, List, Dict, Any

from langchain_core.messages import HumanMessage, AIMessage

from langgraph_app import graph  # 위에서 만든 graph

app = FastAPI()

# 클라이언트와 주고받을 메시지 포맷 (단순화)
class ClientMessage(BaseModel):
    role: Literal["user", "assistant"]
    content: str

class ChatRequest(BaseModel):
    messages: List[ClientMessage]

class ChatResponse(BaseModel):
    messages: List[ClientMessage]


def to_lc_messages(client_messages: List[ClientMessage]):
    """클라이언트 포맷 → LangChain Message 리스트"""
    lc_messages = []
    for m in client_messages:
        if m.role == "user":
            lc_messages.append(HumanMessage(content=m.content))
        else:
            lc_messages.append(AIMessage(content=m.content))
    return lc_messages


def from_lc_messages(lc_messages: List[Any]) -> List[ClientMessage]:
    """LangChain Message 리스트 → 클라이언트 포맷"""
    result: List[ClientMessage] = []
    for msg in lc_messages:
        # type 체크를 간단히 처리 (실서비스에서는 더 안전하게)
        role = "assistant" if msg.type == "ai" else "user"
        result.append(ClientMessage(role=role, content=msg.content))
    return result


@app.post("/api/chat", response_model=ChatResponse)
def chat(req: ChatRequest):
    # 1) 클라이언트 메시지 → LangChain 메시지
    lc_messages = to_lc_messages(req.messages)

    # 2) LangGraph 그래프 실행
    result_state = graph.invoke({"messages": lc_messages})

    # 3) 결과 state의 messages를 클라이언트 포맷으로 변환
    client_messages = from_lc_messages(result_state["messages"])

    return ChatResponse(messages=client_messages)

이제 /api/chat 은

  • 요청: [{ role: "user", content: "서울 날씨 어때?" }, ...]
  • 응답: 같은 형식의 메시지 배열 (유저 + 봇 + 툴 결과 반영된 assistant 답변)

을 주고받게 됩니다.


11. 프론트엔드: React + useReducer 로 UI 상태 관리

이제 이 API를 사용하는 React ChatWindow 컴포넌트를 만들어봅니다.

11.1 API 클라이언트 함수

먼저 /api/chat 엔드포인트를 호출하는 간단한 함수부터.

// src/lib/api/chat.ts

// 서버와 주고받을 메시지 타입 (UI 타입과 거의 동일하게 유지)
export type ChatRole = 'user' | 'assistant'

export type ServerMessage = {
  role: ChatRole
  content: string
}

export async function sendChat(
  messages: ServerMessage[],
): Promise<ServerMessage[]> {
  // 서버에 POST 요청
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages }),
  })

  if (!res.ok) {
    throw new Error('서버 통신 중 오류가 발생했습니다.')
  }

  const data = await res.json()

  // 서버는 { messages: ServerMessage[] } 형태로 돌려준다고 가정
  return data.messages
}

11.2 useReducer로 Chat UI 상태 관리

이번엔 실제 채팅창 컴포넌트입니다.

// src/components/chat/ChatWindow.tsx

import { useReducer, FormEvent } from 'react'
import { sendChat, ServerMessage, ChatRole } from '@/lib/api/chat'

// 1) UI에서 사용할 메시지 타입 ------------------------------
type ChatMessage = {
  // id를 두면 React key로 사용 가능 (시간/인덱스 기반으로 생성)
  id: string
  role: ChatRole
  content: string
}

// 2) 상태(State) 타입 ---------------------------------------
type ChatState = {
  messages: ChatMessage[]  // 화면에 표시할 채팅 내역
  input: string            // 입력창에 타이핑 중인 값
  isLoading: boolean       // 서버 응답 대기 중 여부
  error: string | null     // 에러 메시지
}

// 3) 액션(Action) 타입 --------------------------------------
type ChatAction =
  | { type: 'CHANGE_INPUT'; payload: string }
  | { type: 'SEND_START' }
  | { type: 'SEND_SUCCESS'; payload: { messages: ChatMessage[] } }
  | { type: 'SEND_ERROR'; payload: string }

// 4) 리듀서 함수 -------------------------------------------
//    - useReducer에서 사용할 "상태 전이 규칙"
function chatReducer(state: ChatState, action: ChatAction): ChatState {
  switch (action.type) {
    case 'CHANGE_INPUT': {
      // 입력창의 값만 변경
      return { ...state, input: action.payload }
    }

    case 'SEND_START': {
      // 전송 시작: 로딩 on, 에러 초기화, 유저 메시지를 messages에 추가
      const trimmed = state.input.trim()
      if (!trimmed) return state

      const userMessage: ChatMessage = {
        id: `user-${Date.now()}`,
        role: 'user',
        content: trimmed,
      }

      return {
        ...state,
        input: '',
        isLoading: true,
        error: null,
        messages: [...state.messages, userMessage],
      }
    }

    case 'SEND_SUCCESS': {
      // 서버에서 받은 전체 messages를 그대로 상태에 반영
      return {
        ...state,
        isLoading: false,
        messages: action.payload.messages,
      }
    }

    case 'SEND_ERROR': {
      // 에러 메시지 설정, 로딩 off
      return {
        ...state,
        isLoading: false,
        error: action.payload,
      }
    }

    default:
      return state
  }
}

// 5) 초기 상태 ----------------------------------------------
const initialState: ChatState = {
  messages: [],
  input: '',
  isLoading: false,
  error: null,
}

// 6) ChatWindow 컴포넌트 ------------------------------------
export function ChatWindow() {
  const [state, dispatch] = useReducer(chatReducer, initialState)

  // 폼 전송 핸들러 (메시지 보내기)
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()

    // 1단계: SEND_START 액션 디스패치 → UI 상태 업데이트
    dispatch({ type: 'SEND_START' })

    // useReducer 특성상, 위에서 state를 직접 참조하면
    // "그 순간의 state"라 약간 헷갈릴 수 있으니
    // 서버에 보낼 messages는 함수 안에서 재구성
    try {
      // 현재까지의 messages + 방금 추가된 user message를 함께 보내야 하므로
      // SEND_START 이후의 상태를 추적하는 대신,
      // 여기서는 간단히 "마지막 user 메시지를 붙인" 형태로 보낸다고 가정
      const lastUserInput = state.input.trim()
      const baseMessages = state.messages

      // 빈 입력이면 그냥 리턴
      if (!lastUserInput) return

      const userMessage: ServerMessage = {
        role: 'user',
        content: lastUserInput,
      }

      const serverMessages: ServerMessage[] = [
        ...baseMessages.map((m) => ({
          role: m.role,
          content: m.content,
        })),
        userMessage,
      ]

      // 2단계: 서버에 요청 → LangGraph 그래프 실행
      const resultMessages = await sendChat(serverMessages)

      // 3단계: 서버에서 받은 메시지를 ChatMessage 형태로 변환
      const uiMessages: ChatMessage[] = resultMessages.map((m, index) => ({
        id: `${m.role}-${index}-${Date.now()}`,
        role: m.role,
        content: m.content,
      }))

      // 4단계: SEND_SUCCESS 액션으로 상태 반영
      dispatch({ type: 'SEND_SUCCESS', payload: { messages: uiMessages } })
    } catch (err: any) {
      // 에러 발생 시 SEND_ERROR 디스패치
      dispatch({
        type: 'SEND_ERROR',
        payload: err.message ?? '알 수 없는 오류가 발생했습니다.',
      })
    }
  }

  return (
    <div className="flex flex-col h-full">
      {/* 메시지 리스트 영역 */}
      <div className="flex-1 overflow-y-auto p-4 space-y-2">
        {state.messages.map((m) => (
          <div
            key={m.id}
            className={
              m.role === 'user'
                ? 'self-end max-w-[80%] rounded-xl px-3 py-2 border'
                : 'self-start max-w-[80%] rounded-xl px-3 py-2 border'
            }
          >
            <div className="text-xs opacity-60 mb-1">
              {m.role === 'user' ? '나' : '봇'}
            </div>
            <div className="whitespace-pre-wrap text-sm">{m.content}</div>
          </div>
        ))}

        {state.isLoading && (
          <div className="self-start text-xs opacity-70 mt-2">
            봇이 생각 중입니다...
          </div>
        )}
      </div>

      {/* 에러 메시지 표시 */}
      {state.error && (
        <div className="px-4 py-2 text-xs text-red-500">{state.error}</div>
      )}

      {/* 입력창 & 전송 버튼 */}
      <form onSubmit={handleSubmit} className="flex gap-2 p-4 border-t">
        <input
          className="flex-1 rounded-md border px-3 py-2 text-sm"
          placeholder="메시지를 입력해 주세요..."
          value={state.input}
          onChange={(e) =>
            dispatch({ type: 'CHANGE_INPUT', payload: e.target.value })
          }
          disabled={state.isLoading}
        />
        <button
          type="submit"
          className="px-4 py-2 rounded-md border text-sm"
          disabled={state.isLoading}
        >
          보내기
        </button>
      </form>
    </div>
  )
}

실제 프로젝트에서는 SEND_START 이후의 상태를 정확히 반영하려면
useReducer 안에 “서버에 보낼 messages를 만드는 로직”까지 같이 넣거나,
useEffect와 함께 따로 분리해서 더 깔끔하게 구성할 수 있습니다.
여기서는 개념 이해용으로 최대한 단순하게 보여준 예시라고 보시면 됩니다.


12. 여기서 LangGraph Reducer와 React useReducer가 만나는 지점

이제 두 세계를 한 번에 정리해볼 수 있습니다.

  1. 프론트엔드(React useReducer)
    •   상태: ChatState (messages, input, isLoading, error)
    • 액션:
      •   CHANGE_INPUT: 입력창 값 변경
      •   SEND_START: 유저 메시지를 로컬 상태에 추가하고 로딩 on
      •   SEND_SUCCESS: 서버에서 돌아온 전체 메시지 리스트를 반영
      •   SEND_ERROR: 에러 상태 반영
    • 역할:
      •   UI 렌더링에 필요한 상태만 책임지는 작은 상태 머신
  2. 백엔드(LangGraph reducer)
    •   상태: AgentState (messages: Annotated[list[AnyMessage], add_messages])
    • 리듀서:
      •   add_messages: 새 메시지를 기존 리스트 뒤에 붙이는 역할
    •   ToolNode + tools_condition:
      •   LLM 응답이 툴 호출을 포함하면 ToolNode로 라우팅
      •   툴 실행 결과를 다시 messages에 누적
    • 역할:
      •   LLM과 툴, 브랜치 흐름을 모두 아우르는 큰 상태 머신 & 오케스트레이터

두 레이어의 공통점은 모두

(state, something) → newState

라는 의미의 리듀서 패턴을 쓰고 있다는 점이고,

  • 차이점은:
    • React: 컴포넌트 로컬 상태, 단일 이벤트 흐름
    • LangGraph: 세션/대화 전역 상태, 여러 노드/브랜치의 병렬 실행 & 병합

라는 지점이라고 정리할 수 있습니다.