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 (또는 사용자가 지정한 다른 노드 이름)를 반환
우리는 이걸 이용해:
- START → chatbot
- chatbot → tools_condition 평가
- "tools" → ToolNode 로 이동
- END → 그래프 종료
- 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가 만나는 지점
이제 두 세계를 한 번에 정리해볼 수 있습니다.
- 프론트엔드(React useReducer)
- 상태: ChatState (messages, input, isLoading, error)
- 액션:
- CHANGE_INPUT: 입력창 값 변경
- SEND_START: 유저 메시지를 로컬 상태에 추가하고 로딩 on
- SEND_SUCCESS: 서버에서 돌아온 전체 메시지 리스트를 반영
- SEND_ERROR: 에러 상태 반영
- 역할:
- UI 렌더링에 필요한 상태만 책임지는 작은 상태 머신
- 백엔드(LangGraph reducer)
- 상태: AgentState (messages: Annotated[list[AnyMessage], add_messages])
- 리듀서:
- add_messages: 새 메시지를 기존 리스트 뒤에 붙이는 역할
- ToolNode + tools_condition:
- LLM 응답이 툴 호출을 포함하면 ToolNode로 라우팅
- 툴 실행 결과를 다시 messages에 누적
- 역할:
- LLM과 툴, 브랜치 흐름을 모두 아우르는 큰 상태 머신 & 오케스트레이터
두 레이어의 공통점은 모두
(state, something) → newState
라는 의미의 리듀서 패턴을 쓰고 있다는 점이고,
- 차이점은:
- React: 컴포넌트 로컬 상태, 단일 이벤트 흐름
- LangGraph: 세션/대화 전역 상태, 여러 노드/브랜치의 병렬 실행 & 병합
라는 지점이라고 정리할 수 있습니다.
'생성형 AI' 카테고리의 다른 글
| LangGraph의 Reducer vs React의 useReducer - 3 (0) | 2025.11.20 |
|---|---|
| LangGraph의 Reducer vs React의 useReducer - 1 (0) | 2025.11.20 |