본문 바로가기
생성형 AI

LangGraph의 Reducer vs React의 useReducer - 1

by Toddler_AD 2025. 11. 20.

— “같은 단어, 완전히 다른 스케일의 상태 관리”

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>
  )
}

여기서 핵심만 뽑으면:

  1. 리듀서 함수
    •   reducer(state, action) → newState
    •   순수 함수로 설계하는 것이 베스트(부작용 X)
  2. 액션(action)
    •   { type: '...', payload: ... } 형태가 거의 표준
    •   “상태를 이렇게 바꿔라”라는 명령의 이름을 부여하는 것
  3. 디스패치(dispatch)
    •   dispatch(action)을 호출하면 React가 내부적으로 reducer를 실행
    •   결과로 나온 새 상태를 가지고 다시 렌더링

2-3. useReducer의 역할 요약

한 줄로 말하면:

“컴포넌트 내부의 복잡한 상태 변경 로직을, 하나의 순수 함수로 정리하는 패턴”

조금 풀어 쓰면:

  • 사용 범위: 단일 컴포넌트 (또는 Context랑 엮어서 특정 트리 전체)
  • 상태 스코프: UI와 직접적으로 엮인 “로컬 상태”
  • 동작 흐름:
    1. 유저가 버튼 클릭 등 UI 이벤트 발생
    2. 이벤트 핸들러에서 dispatch(action)
    3. React가 reducer(prevState, action) 호출
    4. 반환된 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 내부 엔진 입장에서 보면:

  1. A 노드가 반환: {"messages": ["의도 분석 결과 메시지"]}
  2. B 노드가 반환: {"tool_results": [{"source": "..."}]}
  3. 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
    1. 유저 이벤트(클릭, 입력 등) 발생
    2. 이벤트 핸들러에서 dispatch(action)
    3. React 렌더 사이클 중에 reducer 호출 후 새 state 채택
  • LangGraph Reducer
    1. 그래프에서 특정 노드가 실행 완료
    2. 그 노드가 partial_state를 반환
    3. 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하게 중복 제거하며 누적

이렇게 될 수 있습니다.

이 백엔드 워크플로우는:

  1. 유저 메시지를 messages에 추가
  2. 브랜치 A: 의도 분석 노드 → detected_intent 설정 + messages에 로그 추가
  3. 브랜치 B: 문서 검색 노드 → search_results 채워넣기
  4. 이후 노드: 위 둘을 보고 최종 답변 생성 → 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”란 단어를 봐도
“아~ 이건 그 ‘상태 + 무언가 → 새 상태’ 패턴인데,
여긴 범위가 그래프 전체구나” 하고 바로 감이 올 거예요.