— “같은 단어, 완전히 다른 스케일의 상태 관리”
0. 왜 이 둘을 비교해서 이해해야 할까?
- React를 이미 쓰고 있다 → useReducer 경험 있음
- LangChain → LangGraph로 넘어가면, 문서에서 또 “reducer”라는 말을 봄
- “어? 이거 React에서 쓰던 그 리듀서랑 같은 건가?” → 철학은 비슷하지만, 역할과 스케일은 다릅니다.
그래서 이 글의 목표는:
“React의 useReducer를 이해하고 있는 사람이,
LangGraph의 reducer 개념을 자연스럽게 받아들일 수 있게 다리를 놓는 것”
입니다.
1. 공통 바탕: “리듀서(reducer)”라는 패턴
함수형 프로그래밍에서 리듀서는 단순히 이런 함수입니다:
// 개념적으로
(newState) = reducer(oldState, eventOrAction)
- 입력: 이전 상태 + 변화를 일으킨 원인(이벤트/액션/업데이트)
- 출력: 새 상태
- 철학: 같은 입력 → 같은 출력 (부작용 없는 순수 함수 지향)
이 “패턴”을
- UI 세계에서는 React가 useReducer로 가져다 쓰고,
- LLM 워크플로우 세계에서는 LangGraph가 그래프 상태 병합 로직으로 가져다 쓴다고 볼 수 있어요.
패턴은 같지만, 사용 맥락이 완전히 다르다는 점을 머리에 두고 시작하면 이해가 훨씬 쉽습니다.
2. React의 useReducer 완전 정리
2-1. 왜 useReducer를 쓰는가?
useState로 관리하기엔:
- 상태 값이 많고,
- 상태 변경 로직이 복잡하고,
- 여러 이벤트에서 비슷한 로직이 반복되면
점점 코드가 지저분해집니다.
이럴 때:
- 상태 변경 로직을 한 곳에 모으고
- “이벤트 명령(action)”으로 추상화해서 다루기 위한 도구가 바로 useReducer입니다.
2-2. 기본 사용 형태
// src/components/Counter.tsx
import { useReducer } from 'react'
// 1) 상태 타입
type State = {
count: number
}
// 2) 액션 타입
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET'; payload: number }
// 3) 리듀서 함수
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT':
// 기존 state를 절대 "변경"하지 않고, 새 객체를 만들어 반환
return { count: state.count + 1 }
case 'DECREMENT':
return { count: state.count - 1 }
case 'SET':
return { count: action.payload }
default:
// 타입 안전을 위해서라도 보통은 default에 안 떨어지게 설계
return state
}
}
export default function Counter() {
// 4) useReducer 호출
const [state, dispatch] = useReducer(reducer, { count: 0 })
return (
<div>
<p>현재 값: {state.count}</p>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'SET', payload: 0 })}>리셋</button>
</div>
)
}
여기서 핵심만 뽑으면:
- 리듀서 함수
- reducer(state, action) → newState
- 순수 함수로 설계하는 것이 베스트(부작용 X)
- 액션(action)
- { type: '...', payload: ... } 형태가 거의 표준
- “상태를 이렇게 바꿔라”라는 명령의 이름을 부여하는 것
- 디스패치(dispatch)
- dispatch(action)을 호출하면 React가 내부적으로 reducer를 실행
- 결과로 나온 새 상태를 가지고 다시 렌더링
2-3. useReducer의 역할 요약
한 줄로 말하면:
“컴포넌트 내부의 복잡한 상태 변경 로직을, 하나의 순수 함수로 정리하는 패턴”
조금 풀어 쓰면:
- 사용 범위: 단일 컴포넌트 (또는 Context랑 엮어서 특정 트리 전체)
- 상태 스코프: UI와 직접적으로 엮인 “로컬 상태”
- 동작 흐름:
- 유저가 버튼 클릭 등 UI 이벤트 발생
- 이벤트 핸들러에서 dispatch(action)
- React가 reducer(prevState, action) 호출
- 반환된 newState로 컴포넌트 다시 렌더
3. LangGraph를 아주 간단히 훑고 가기
LangGraph는 LangChain 진영의 “그래프 기반 LLM/에이전트 오케스트레이션 프레임워크”입니다.
- 노드(node):
- LLM 호출, 툴 호출, 조건 분기, 서브그래프 등
- 엣지(edge):
- 노드 간의 흐름(“이 노드가 끝나면 다음에 이 노드 실행해라”)
- 그래프(graph):
- 전체 워크플로우
여기서 중요한 개념이 바로 그래프의 “상태(state)”입니다.
예를 들어, 상담 챗봇 그래프라면 state에는:
- messages: 유저/봇 대화 기록
- user_profile: 유저 정보
- tools_used: 어떤 툴이 어떻게 호출되었는지
- intermediate_results: 중간 계산 결과
이런 것들이 들어갈 수 있습니다.
4. LangGraph의 Reducer는 정확히 뭐 하는 놈인가?
4-1. 문제 상황부터 상상해보기
그래프를 그려보면:
- A 노드: LLM에게 “유저 의도 분석” 시킴
- B 노드: 툴 호출(예: DB 조회)
- C 노드: 둘의 결과를 합쳐 최종 답변 생성
이런 구조에서 각 노드는 state에 뭔가를 기록합니다.
예:
- A 노드 → {"messages": [...분석 결과 메시지 추가...]}
- B 노드 → {"tool_results": [...새 툴 결과 추가...]}
- C 노드 → {"messages": [...최종 답변 메시지 추가...]}
여기에 더해, LangGraph는 병렬 실행도 지원합니다.
- 브랜치 1: 유저 의도 분석
- 브랜치 2: 관련 문서 검색
- 브랜치 3: 과거 대화 요약
이 세 브랜치가 동시에 돌아가면서 state에 각자 업데이트를 남깁니다.
여기서 문제가 생깁니다:
“여러 노드 / 여러 브랜치가 같은 키(예: messages)를 동시에 건드리면,
이걸 어떻게 안전하게 합쳐야 하지?”
이때 사용하는 규칙이 바로 LangGraph의 Reducer입니다.
4-2. LangGraph Reducer의 역할 요약
한 줄로 정의하면:
“그래프 실행 도중, 여러 노드가 만든 부분 상태(partial state)를
하나의 전체 상태(global state)로 병합하는 규칙”
조금 더 구체적으로:
- 입력:
- 기존 전체 state (예: { messages: [...], tool_results: [...] })
- 노드/브랜치들이 만들어낸 부분 업데이트 (예: { messages: [...새 메시지...] }, { tool_results: [...새 툴 결과...] })
- 출력:
- 병합된 새 state
리듀서가 하는 일은:
- append 할지
- overwrite 할지
- merge 할지
- max/min을 취할지
- 등등
을 타입/키별로 정의하는 것입니다.
4-3. 예시로 보는 LangGraph Reducer 개념 (개념 코드)
(실제 문법이 아니라 개념을 보여주기 위한 의사 코드입니다.)
# src/graph/state.py
from typing import TypedDict, List
class State(TypedDict, total=False):
messages: List[str]
tool_results: List[dict]
score: float
def messages_reducer(old, new):
# 메시지는 누적
return (old or []) + (new or [])
def tool_results_reducer(old, new):
# 툴 결과도 누적
return (old or []) + (new or [])
def score_reducer(old, new):
# 점수는 "더 큰 값"만 유지
if old is None:
return new
return max(old, new)
reducers = {
"messages": messages_reducer,
"tool_results": tool_results_reducer,
"score": score_reducer,
}
LangGraph 내부 엔진 입장에서 보면:
- A 노드가 반환: {"messages": ["의도 분석 결과 메시지"]}
- B 노드가 반환: {"tool_results": [{"source": "..."}]}
- C 노드가 반환: {"messages": ["최종 답변 메시지"]}
이걸 순차 혹은 병렬 실행 후 합칠 때:
- messages 키에 대해서는 messages_reducer를 써서
["기존 메시지들", "의도 분석 결과 메시지", "최종 답변 메시지"]로 합치고 - tool_results는 tool_results_reducer로 누적하고
- score는 score_reducer로 가장 높은 점수만 남기고
이런 식으로 key 단위의 병합 규칙을 적용합니다.
4-4. React useReducer와 가장 큰 차이점
React의 리듀서는 주로:
- “이 액션이 들어오면 전체 state를 어떻게 바꿀까?”에 초점
LangGraph의 리듀서는:
- “여러 곳에서 온 여러 개의 partial state들을
한 상태로 어떻게 병합할까?”에 초점
이라고 생각하면 이해가 확실해집니다.
즉,
- React: 단일 타임라인에서 발생하는 액션 → 상태 전이
- LangGraph: 여러 노드/브랜치 → 상태 병합/조정
5. 스코프(범위), 트리거, 동시성 측면에서 비교
5-1. 사용 스코프
- React useReducer
- 컴포넌트 하나 혹은 Context로 감싼 특정 UI 영역
- 사용자 인터페이스에 직접 연결된 로컬 상태
- LangGraph Reducer
- LLM 워크플로우 전체, 하나의 대화 세션 전체
- 여러 노드/에이전트가 공유하는 전역 상태
5-2. 호출 타이밍 / 트리거
- React useReducer
- 유저 이벤트(클릭, 입력 등) 발생
- 이벤트 핸들러에서 dispatch(action)
- React 렌더 사이클 중에 reducer 호출 후 새 state 채택
- LangGraph Reducer
- 그래프에서 특정 노드가 실행 완료
- 그 노드가 partial_state를 반환
- LangGraph 엔진이 현재 global state와 이 partial_state를 합칠 때
해당 키의 reducer를 호출
그래서,
- React 리듀서는 “이벤트 → 리듀서 → UI 업데이트” 흐름의 일부이고,
- LangGraph 리듀서는 “노드 실행 → 상태 병합 → 다음 노드 실행” 흐름의 일부입니다.
5-3. 동시성과 병렬 브랜치
이 부분이 두 개념의 차이가 가장 크게 나타나는 곳입니다.
- React
- 단일 스레드, 단일 이벤트 루프
- 한 번에 하나의 액션만 처리하는 모델
- 동시성 이슈는 거의 신경 안 쓰게 설계되어 있음
- LangGraph
- 한 그래프 안에서 여러 브랜치/노드가 병렬로 실행될 수 있음
- 각 브랜치가 state 일부를 동시에 바꾸려 할 수 있음
- 이때 “충돌없이, 의미 있게” 합치려면 강력한 **병합 규칙(reducer)**이 필요
예를 들어 병렬로:
- 브랜치 A: messages에 분석 로그 추가
- 브랜치 B: messages에 검색 결과 로그 추가
이게 순서가 어떻게 되든 결과적으로 둘 다 포함된 메시지 배열이 되어야 하죠.
이걸 안정적으로 보장해 주는 게 LangGraph reducer의 역할입니다.
6. 표로 정리해보는 차이점
| 관점 | React useReducer | LangGraph Reducer |
| 주요 사용 맥락 | UI 컴포넌트 로컬 상태 관리 | LLM/에이전트 워크플로우 전체 상태 관리 |
| 입력 | (state, action) | (globalState, partialUpdate) 또는 여러 partial 업데이트 |
| 출력 | 새로운 컴포넌트 상태 | 병합된 그래프 전역 상태 |
| 초점 | “이 액션이 들어오면 state를 어떻게 바꾸나?” | “여러 노드가 만든 업데이트를 어떻게 합쳐야 하나?” |
| 동시성/병렬성 | 실질적으로 단일 타임라인 (동시성 고민 X) | 여러 브랜치/노드가 동시에 상태를 변경 → 병합 규칙(reducer)이 핵심 |
| 상태 스코프 | 보통 1개의 컴포넌트 또는 그 하위 트리 | 1개의 세션/대화/워크플로우 전체 |
| 부작용 처리 | 원칙적으로는 리듀서 안에서 부작용 X (side-effect는 다른 훅에서) | 상태 병합 자체는 순수 함수이지만, 그래프 실행 전/후로 툴 호출/LLM 호출 등 존재 |
| 설계 관점 | 작은 상태 머신, UI 이벤트 중심 | 분산 상태 머신, 여러 에이전트/도구를 아우르는 오케스트레이터 |
7. 같은 도메인으로 상상해보기: “상담 챗봇” 예시
7-1. React useReducer로 보는 상담 UI 컴포넌트
여기서는 브라우저 화면 한쪽에 있는 “챗봇 창”만 생각해 봅시다.
// src/components/chat/ChatWindow.tsx
import { useReducer } from 'react'
// UI State 정의
type Message = {
role: 'user' | 'assistant'
content: string
}
type UiState = {
messages: Message[]
input: string
isLoading: boolean
error: string | null
}
// 액션 정의
type UiAction =
| { type: 'CHANGE_INPUT'; payload: string }
| { type: 'SEND_MESSAGE_START' }
| { type: 'SEND_MESSAGE_SUCCESS'; payload: { assistantReply: string } }
| { type: 'SEND_MESSAGE_ERROR'; payload: string }
function uiReducer(state: UiState, action: UiAction): UiState {
switch (action.type) {
case 'CHANGE_INPUT':
return { ...state, input: action.payload }
case 'SEND_MESSAGE_START':
return {
...state,
isLoading: true,
error: null,
messages: [
...state.messages,
{ role: 'user', content: state.input },
],
input: '',
}
case 'SEND_MESSAGE_SUCCESS':
return {
...state,
isLoading: false,
messages: [
...state.messages,
{ role: 'assistant', content: action.payload.assistantReply },
],
}
case 'SEND_MESSAGE_ERROR':
return {
...state,
isLoading: false,
error: action.payload,
}
default:
return state
}
}
const initialState: UiState = {
messages: [],
input: '',
isLoading: false,
error: null,
}
export function ChatWindow() {
const [state, dispatch] = useReducer(uiReducer, initialState)
const handleSend = async () => {
if (!state.input.trim()) return
dispatch({ type: 'SEND_MESSAGE_START' })
try {
// 실제로는 여기서 서버나 LangGraph에 요청 보내겠죠
const assistantReply = '안녕하세요, 무엇을 도와드릴까요?'
dispatch({
type: 'SEND_MESSAGE_SUCCESS',
payload: { assistantReply },
})
} catch (e) {
dispatch({
type: 'SEND_MESSAGE_ERROR',
payload: '전송 중 오류가 발생했습니다.',
})
}
}
return (
<div>
{/* messages 렌더링, input, 버튼 등... */}
</div>
)
}
여기서 리듀서는:
- 오로지 UI 상태만 신경 씁니다.
- “로딩 중 표시, 에러 메시지, input 관리 등” 화면 단의 로직.
7-2. 같은 상담 흐름의 LangGraph 측면
이 상담의 백엔드 LLM 워크플로우는 LangGraph로 짤 수 있습니다.
예상되는 state:
class State(TypedDict, total=False):
messages: list[dict] # 대화 전체 히스토리 (user / assistant / tool 등)
detected_intent: str # 의도 분석 결과
search_results: list[dict] # 문서 검색 결과
tools_called: list[str] # 사용된 툴 이름들
여기서 reducer는 예를 들어:
- messages: 계속 누적
- search_results: 누적 또는 최신 것만 유지
- tools_called: set-like하게 중복 제거하며 누적
이렇게 될 수 있습니다.
이 백엔드 워크플로우는:
- 유저 메시지를 messages에 추가
- 브랜치 A: 의도 분석 노드 → detected_intent 설정 + messages에 로그 추가
- 브랜치 B: 문서 검색 노드 → search_results 채워넣기
- 이후 노드: 위 둘을 보고 최종 답변 생성 → messages에 assistant 메시지 추가
LangGraph reducer는 이 과정에서:
- 각 노드가 내놓은 partial_state들의 messages, search_results, tools_called를
정의된 규칙대로 병합합니다.
React UI 컴포넌트는 이 LangGraph 백엔드에서 최종 결과만 받아와서:
- useReducer로 UI 상태를 깔끔하게 정리해서 보여주고
- LangGraph는 LLM/툴 워크플로우의 전역 상태와 병렬 흐름을 책임지는 구조가 되는 거죠.
8. 두 개념을 머릿속에 정리하는 좋은 비유
마지막으로 완전 기억에 박히게 비유로 마무리해 보면:
- React useReducer
- → “카페 한 지점의 바리스타 업무일지”
- 오늘 몇 잔 팔았는지, 지금 대기 중 손님 몇 명인지, 에러(결제 실패) 났는지 등
- 한 지점 안의 로컬 로직
- LangGraph Reducer
- → “프랜차이즈 본사 서버에서 각 지점 데이터를 모아 전국 매출/재고를 합산하는 로직”
- 여러 지점(노드, 브랜치)이 동시에 데이터를 보내옴
- 본사 시스템이 적절한 규칙대로 데이터를 병합해서 전체 현황을 유지
둘 다 “리듀서”지만:
- React는 컴포넌트 상태 머신
- LangGraph는 분산 워크플로우 상태 오케스트레이터
라는 점만 확실히 잡고 있으면, 문서에서 “reducer”란 단어를 봐도
“아~ 이건 그 ‘상태 + 무언가 → 새 상태’ 패턴인데,
여긴 범위가 그래프 전체구나” 하고 바로 감이 올 거예요.
'생성형 AI' 카테고리의 다른 글
| LangGraph의 Reducer vs React의 useReducer - 3 (0) | 2025.11.20 |
|---|---|
| LangGraph의 Reducer vs React의 useReducer - 2 (0) | 2025.11.20 |