본문 바로가기

Redux 기초 개념 소개

Redux가 관리하는 상태를 하나의 Store에 두고 action·reducer·selector로 이어지는 단방향 흐름과 현대 Redux Toolkit 경계를 이해합니다.

Redux는 여러 화면이 함께 쓰는 클라이언트 상태(client state)를 예측할 수 있는 규칙으로 관리하는 독립된 자바스크립트 라이브러리입니다. React에서는 공식 바인딩인 React-Redux와 함께 자주 쓰지만, Redux 자체가 React에 묶여 있지는 않습니다. 이 글은 Redux 5.0, Redux Toolkit 2.13, React-Redux 9.3을 기준으로 개념을 설명합니다. 2026년 11월 3일에 Redux Toolkit 2.14가 나왔지만, 이 글에서 쓰는 configureStore와 createSlice, createAsyncThunk의 사용법은 바뀌지 않았습니다.

Redux의 핵심은 애플리케이션의 모든 상태를 한곳에 밀어 넣는 것이 아니라, Redux가 관리하기로 정한 상태를 하나의 store에 두고 액션으로만 바꾸는 데 있습니다. 컴포넌트에만 필요한 UI 상태는 지역 상태로, 주소로 나타낼 상태는 URL로, 서버에서 가져온 데이터는 알맞은 loader나 캐시로 관리할 수 있습니다.

Context와 useReducer도 유용하지만, Context는 Provider 아래에 값을 전달하는 범위이고 리듀서는 다음 상태를 계산하는 함수입니다. 둘을 함께 쓰면 그 트리에서 state와 dispatch를 공유할 수 있지만, Redux 같은 외부 store 객체나 미들웨어, DevTools의 액션 기록, selector마다의 동등성 비교가 저절로 생기지는 않습니다. Zustand나 Jotai 같은 다른 외부 store도 있으므로, Redux는 이름이나 앱의 크기보다 필요한 소유와 구독, 추적의 방식으로 비교해야 합니다.


Redux는 왜 필요한가?

앱의 규모만으로 Redux를 쓸지가 정해지지는 않습니다. 같은 클라이언트 상태를 서로 멀리 떨어진 여러 화면과 기능이 읽고, 그 상태가 시간에 따라 자주 바뀌며, 여러 사건이 관련된 필드와 불변 조건을 함께 바꾸는 경우가 있습니다. 여기에 변경의 이유를 액션 단위로 추적하고 같은 규칙을 팀이 함께 쓸 가치가 크며, 미들웨어와 DevTools, 테스트할 수 있는 리듀서 같은 표준적인 확장 지점이 필요하다면 Redux를 검토합니다. 이런 신호가 함께 나타나고, 액션 기반의 추적과 개발 도구에서 얻는 이득이 추가 비용보다 클 때입니다.

Redux는 액션을 dispatch하는 공통의 입구를 제공하지만, 액션의 이력을 자동으로 영구 저장하지는 않습니다. Redux DevTools 연동이 켜져 있고 브라우저 확장 같은 모니터가 연결되었거나 로깅 미들웨어를 구성했으며, state와 액션을 직렬화할 수 있는 형태로 유지했을 때 비로소 액션과 그 전후의 state를 디버깅에 쓸 수 있습니다.


Redux의 세 가지 핵심 원칙

Redux에는 세 가지 원칙이 있습니다(Redux의 세 원칙). 첫째는 단일 진실 공급원(single source of truth)입니다. 일반적인 Redux 앱은 하나의 store를 쓰며, 그 store가 Redux가 관리하는 state 트리 전체를 보관합니다. 다만 이것이 모든 컴포넌트의 지역 상태와 URL 상태, 서버의 캐시까지 Redux에 넣으라는 뜻은 아닙니다.

둘째는 상태는 읽기 전용(state is read-only)이라는 원칙입니다. store의 state를 직접 쓰지 않고, 액션을 dispatch해 바꾸려는 의도를 전달합니다. 액션은 type 필드를 가진 평범한 객체이며, 필요하면 페이로드(payload)에 사건의 데이터를 담습니다.

셋째는 변경은 순수한 리듀서로 계산한다(changes are made with pure functions)는 원칙입니다. 리듀서는 이전 state와 액션을 받아 다음 state를 동기적으로 계산하며, 같은 입력에는 같은 결과를 내고 이전 state를 직접 바꾸거나 API 호출, 타이머, 무작위 값 생성 같은 부수 효과를 실행해서는 안 됩니다. Redux Toolkit의 createSlice에서는 Immer 덕분에 값을 바꾸는 것처럼 보이는 문법을 쓸 수 있지만, 이전 state 자체를 바꾸지 않는다는 규칙은 그대로입니다.


Redux의 핵심 구성 요소

Redux는 몇 가지 구성 요소로 이루어집니다. store는 Redux가 관리하는 현재의 state 트리를 보관하며, dispatch(action)으로 갱신을 요청하고 getState()로 현재의 스냅샷을 읽으며 subscribe(listener)로 갱신 알림을 받습니다. 일반적인 앱은 store 하나를 쓰고, 새 코드에서는 Redux Toolkit의 configureStore로 설정합니다.

액션(action)은 애플리케이션에서 무슨 일이 있었는지를 설명하는 평범한 객체입니다. type은 사건의 종류를 구별하고, 페이로드는 리듀서가 전이를 계산하는 데 필요한 최소한의 데이터를 담으며, 액션 자체가 state를 바꾸지는 않습니다. 리듀서(reducer)는 reducer(previousState, action) 모양의 순수 함수로, 현재 state와 액션으로 다음 state를 계산하며 비동기 작업과 부수 효과는 리듀서 밖에 둡니다.

dispatch는 액션을 store에 전달하는 함수입니다. store는 루트 리듀서(root reducer)를 실행해 다음 state를 저장한 뒤 구독자에게 갱신을 알리며, 미들웨어는 이 dispatch의 길목에서 로깅이나 비동기 로직 같은 기능을 더할 수 있습니다. 마지막으로 selector는 store의 state에서 컴포넌트에 필요한 값이나 파생 값을 읽습니다. React-Redux의 useSelector는 store가 갱신된 뒤 selector를 다시 실행하고, 기본으로 이전의 선택 결과와 새 결과를 ===로 비교합니다. Redux의 갱신 때문에 컴포넌트가 렌더링되는 것은 선택 결과가 달라질 때이지만, 부모의 렌더링 같은 별도의 경로는 여전히 남아 있습니다.


Redux의 데이터 흐름 (단방향)

Redux는 “무슨 일이 있었는가”와 “그 사건으로 상태가 어떻게 바뀌는가”를 한 방향으로 잇습니다.

Redux의 단방향 상태 전이와 관찰 경계 — 상태 추적

동기 plain action 뒤 root state와 useSelector 선택 결과가 달라질 수도 같을 수도 있다. store snapshot/선택 참조/구독 렌더 요청을 추적하고 별도 부모 렌더는 남긴다.

Redux는 action에서 snapshot과 선택 결과로 이어집니다

REACT STATE · OWNERSHIP & TRANSITION

Redux는 action에서 snapshot과 선택 결과로 이어집니다

동기 action을 dispatch하면 root reducer가 다음 state를 계산합니다. React-Redux는 선택 결과를 비교하며, 별도의 부모 렌더 경로도 존재합니다.

동기 Redux action에서 선택 결과 비교까지동기 plain action을 받은 Redux store가 root reducer로 다음 state를 저장하고 구독자를 알립니다. useSelector의 기본 === 비교에서 선택 결과가 달라진 이 구독 경로는 렌더를 요청하고 같으면 이 경로의 요청을 건너뜁니다. 별도 부모 렌더 경로는 남아 있으므로 모든 렌더 여부를 store 구독 하나로 결정하지 않습니다.Redux storeUI 사건동기 plain actiondispatch(action)store에 action 전달순수 root reducer현재 state → 다음 stateStore snapshotgetState() · 구독 알림useSelector 결과기본 === 비교선택 결과가===로 달라졌는가?새 값으로 UI 렌더이 Redux 구독 경로같은 선택 결과이 구독 갱신으로는렌더 요청 없음부모 렌더 등별도 경로는 남음예아니요
동기 Redux action에서 선택 결과 비교까지동기 plain action을 받은 Redux store가 root reducer로 다음 state를 저장하고 구독자를 알립니다. useSelector의 기본 === 비교에서 선택 결과가 달라진 이 구독 경로는 렌더를 요청하고 같으면 이 경로의 요청을 건너뜁니다. 별도 부모 렌더 경로는 남아 있으므로 모든 렌더 여부를 store 구독 하나로 결정하지 않습니다.Redux storeUI 사건동기 plain actiondispatch(action)store에 action 전달순수 root reducer현재 state→ 다음 stateStore snapshotgetState()구독 알림useSelector 결과기본 === 비교선택 결과가===로달라졌는가?새 값으로 UI 렌더이 Redux 구독 경로같은 선택 결과이 구독 갱신으로는렌더 요청 없음부모 렌더 등별도 경로는 남음예아니요

순서를 읽는 예 · count를 1 늘리는 reducer

이전 store snapshot: count = 0
dispatch({ type: "incremented" })
getState().count → 1
useSelector(s => s.count)
→ 선택 결과 변경

count를 1 늘리는 reducer를 가정한 설명용 예입니다. 동기 plain action 기준이며 비동기 작업과 부수효과는 reducer 밖에서 처리합니다.

동기 Redux 업데이트 한 바퀴와 각 단계의 관찰 지점
단계 책임 확인할 계약
1 · UI 사건 사용자 입력이나 비동기 작업 완료가 상태 변경 의도를 만듭니다. 사건을 먼저 설명하고 다음 값을 UI에서 직접 쓰지 않습니다.
2 · Action / dispatch dispatch(action)으로 type과 필요한 payload를 store에 보냅니다. action은 “무슨 일이 있었는가”를 기록하는 평범한 객체입니다.
3 · Reducer 현재 state와 action만으로 다음 state를 동기적으로 계산합니다. 이전 state를 바꾸지 않고 API·타이머·랜덤 같은 부수 효과를 실행하지 않습니다.
4 · Store snapshot root reducer의 반환값을 현재 state로 저장하고 구독자에게 업데이트를 알립니다. getState()는 이 dispatch가 끝난 뒤의 snapshot을 반환합니다.
5 · Selector / subscriber selector가 필요한 조각이나 파생 값을 읽고 이전 선택 결과와 비교합니다. useSelector는 기본적으로 선택 결과의 === 참조 동일성을 사용합니다.
6 · UI render Redux 업데이트로 선택 결과가 달라진 컴포넌트가 새 값을 화면에 반영합니다. 부모 렌더 같은 별도 경로는 남으므로 Redux 구독만으로 모든 렌더가 결정되지는 않습니다.
  1. UI 사건

    사용자 입력이나 비동기 완료가 변경 의도를 만듭니다.

  2. Action을 dispatch

    type과 필요한 payload로 “무슨 일이 있었는가”를 store에 보냅니다.

  3. Reducer 계산

    현재 state와 action만으로 순수하고 불변인 다음 state를 만듭니다.

  4. Store snapshot

    root reducer 반환값을 저장하고 구독자에게 업데이트를 알립니다. 동기 dispatch 뒤 getState()는 새 snapshot을 읽습니다.

  5. Selector 확인

    useSelector가 선택 결과를 다시 계산하고 기본 === 비교를 수행합니다.

  6. UI 반영

    선택 결과가 달라진 Redux 구독 경로가 새 화면을 렌더합니다. 별도 부모 렌더 경로는 남습니다.

Single store

Redux가 관리하는 state tree

일반적인 Redux 앱은 store 하나를 사용합니다. 컴포넌트 로컬 state, URL, 서버 캐시까지 모두 넣어야 한다는 뜻은 아닙니다.

Read only

변경 입구는 dispatch

store state를 직접 쓰지 않고 action을 dispatch합니다. 이 경계가 action 기반 관찰과 미들웨어 확장을 가능하게 합니다.

Pure reducer

전이는 순수하고 불변

같은 state와 action에는 같은 결과를 내고 부수 효과는 밖에 둡니다. Redux Toolkit의 mutation처럼 보이는 문법도 이전 state 자체를 바꾸는 계약은 아닙니다.

action 이력은 Redux가 자동으로 영구 보관하는 데이터가 아닙니다. DevTools 연동과 모니터 또는 로깅을 구성하고 state·action을 관찰 가능한 직렬화 형태로 유지했을 때 전후 값을 디버깅 증거로 볼 수 있습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명동기 plain action을 받은 Redux store가 root reducer로 다음 state를 저장하고 구독자에게 알립니다. useSelector의 기본 === 비교에서 선택 결과가 달라진 이 구독 경로는 렌더를 요청하고 같으면 이 경로의 요청을 건너뜁니다. 별도 부모 렌더 경로는 남아 있으므로 모든 렌더 여부를 store 구독 하나로 결정하지 않습니다.
원도표 표 행단계 | 책임 | 확인할 계약
원도표 표 행1 · UI 사건 | 사용자 입력이나 비동기 작업 완료가 상태 변경 의도를 만듭니다. | 사건을 먼저 설명하고 다음 값을 UI에서 직접 쓰지 않습니다.
원도표 표 행2 · Action / dispatch | dispatch(action)으로 type과 필요한 payload를 store에 보냅니다. | action은 “무슨 일이 있었는가”를 기록하는 평범한 객체입니다.
원도표 표 행3 · Reducer | 현재 state와 action만으로 다음 state를 동기적으로 계산합니다. | 이전 state를 바꾸지 않고 API·타이머·랜덤 같은 부수 효과를 실행하지 않습니다.
원도표 표 행4 · Store snapshot | root reducer의 반환값을 현재 state로 저장하고 구독자에게 업데이트를 알립니다. | getState()는 이 dispatch가 끝난 뒤의 snapshot을 반환합니다.
원도표 표 행5 · Selector / subscriber | selector가 필요한 조각이나 파생 값을 읽고 이전 선택 결과와 비교합니다. | useSelector는 기본적으로 선택 결과의 === 참조 동일성을 사용합니다.
원도표 표 행6 · UI render | Redux 업데이트로 선택 결과가 달라진 컴포넌트가 새 값을 화면에 반영합니다. | 부모 렌더 같은 별도 경로는 남으므로 Redux 구독만으로 모든 렌더가 결정되지는 않습니다.
원도표 조건·주석UI 사건: 사용자 입력이나 비동기 완료가 변경 의도를 만듭니다.
원도표 설명type과 필요한 payload로 “무슨 일이 있었는가”를 store에 보냅니다.
원도표 조건·주석Action을 dispatch: type과 필요한 payload로 “무슨 일이 있었는가”를 store에 보냅니다.
원도표 조건·주석Reducer 계산: 현재 state와 action만으로 순수하고 불변인 다음 state를 만듭니다.
원도표 조건·주석Store snapshot: root reducer 반환값을 저장하고 구독자에게 업데이트를 알립니다. 동기 dispatch 뒤 getState()는 새 snapshot을 읽습니다.
원도표 설명useSelector가 선택 결과를 다시 계산하고 기본 === 비교를 수행합니다.
원도표 조건·주석Selector 확인: useSelector가 선택 결과를 다시 계산하고 기본 === 비교를 수행합니다.
원도표 조건·주석UI 반영: 선택 결과가 달라진 Redux 구독 경로가 새 화면을 렌더합니다. 별도 부모 렌더 경로는 남습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
plain actionstore.count: 0 / action: incrementedUI가 동기 plain action을 store에 dispatch한다middleware 확장 전체를 이 그림의 필수 단계로 넣지 않는다
next snapshotstore.count: 1 / selector: 다시 계산root reducer의 결과를 store가 저장하고 구독자에게 알린다여러 selector의 결과가 모두 달라질 필요는 없다
선택 비교선택 다름: 이 구독 렌더 요청 / 선택 같음: 이 요청 없음기본 === 비교에서 선택 결과가 다를 때만 이 구독의 렌더를 요청한다별도 부모 렌더까지 없다고 판정하지 않는다

Redux의 단방향 상태 전이와 관찰 경계 — 상태 추적

동기 plain action 뒤 root state와 useSelector 선택 결과가 달라질 수도 같을 수도 있다. store snapshot/선택 참조/구독 렌더 요청을 추적하고 별도 부모 렌더는 남긴다.

Redux는 action에서 snapshot과 선택 결과로 이어집니다

REACT STATE · OWNERSHIP & TRANSITION

Redux는 action에서 snapshot과 선택 결과로 이어집니다

동기 action을 dispatch하면 root reducer가 다음 state를 계산합니다. React-Redux는 선택 결과를 비교하며, 별도의 부모 렌더 경로도 존재합니다.

동기 Redux action에서 선택 결과 비교까지동기 plain action을 받은 Redux store가 root reducer로 다음 state를 저장하고 구독자를 알립니다. useSelector의 기본 === 비교에서 선택 결과가 달라진 이 구독 경로는 렌더를 요청하고 같으면 이 경로의 요청을 건너뜁니다. 별도 부모 렌더 경로는 남아 있으므로 모든 렌더 여부를 store 구독 하나로 결정하지 않습니다.Redux storeUI 사건동기 plain actiondispatch(action)store에 action 전달순수 root reducer현재 state → 다음 stateStore snapshotgetState() · 구독 알림useSelector 결과기본 === 비교선택 결과가===로 달라졌는가?새 값으로 UI 렌더이 Redux 구독 경로같은 선택 결과이 구독 갱신으로는렌더 요청 없음부모 렌더 등별도 경로는 남음예아니요
동기 Redux action에서 선택 결과 비교까지동기 plain action을 받은 Redux store가 root reducer로 다음 state를 저장하고 구독자를 알립니다. useSelector의 기본 === 비교에서 선택 결과가 달라진 이 구독 경로는 렌더를 요청하고 같으면 이 경로의 요청을 건너뜁니다. 별도 부모 렌더 경로는 남아 있으므로 모든 렌더 여부를 store 구독 하나로 결정하지 않습니다.Redux storeUI 사건동기 plain actiondispatch(action)store에 action 전달순수 root reducer현재 state→ 다음 stateStore snapshotgetState()구독 알림useSelector 결과기본 === 비교선택 결과가===로달라졌는가?새 값으로 UI 렌더이 Redux 구독 경로같은 선택 결과이 구독 갱신으로는렌더 요청 없음부모 렌더 등별도 경로는 남음예아니요

순서를 읽는 예 · count를 1 늘리는 reducer

이전 store snapshot: count = 0
dispatch({ type: "incremented" })
getState().count → 1
useSelector(s => s.count)
→ 선택 결과 변경

count를 1 늘리는 reducer를 가정한 설명용 예입니다. 동기 plain action 기준이며 비동기 작업과 부수효과는 reducer 밖에서 처리합니다.

동기 Redux 업데이트 한 바퀴와 각 단계의 관찰 지점
단계 책임 확인할 계약
1 · UI 사건 사용자 입력이나 비동기 작업 완료가 상태 변경 의도를 만듭니다. 사건을 먼저 설명하고 다음 값을 UI에서 직접 쓰지 않습니다.
2 · Action / dispatch dispatch(action)으로 type과 필요한 payload를 store에 보냅니다. action은 “무슨 일이 있었는가”를 기록하는 평범한 객체입니다.
3 · Reducer 현재 state와 action만으로 다음 state를 동기적으로 계산합니다. 이전 state를 바꾸지 않고 API·타이머·랜덤 같은 부수 효과를 실행하지 않습니다.
4 · Store snapshot root reducer의 반환값을 현재 state로 저장하고 구독자에게 업데이트를 알립니다. getState()는 이 dispatch가 끝난 뒤의 snapshot을 반환합니다.
5 · Selector / subscriber selector가 필요한 조각이나 파생 값을 읽고 이전 선택 결과와 비교합니다. useSelector는 기본적으로 선택 결과의 === 참조 동일성을 사용합니다.
6 · UI render Redux 업데이트로 선택 결과가 달라진 컴포넌트가 새 값을 화면에 반영합니다. 부모 렌더 같은 별도 경로는 남으므로 Redux 구독만으로 모든 렌더가 결정되지는 않습니다.
  1. UI 사건

    사용자 입력이나 비동기 완료가 변경 의도를 만듭니다.

  2. Action을 dispatch

    type과 필요한 payload로 “무슨 일이 있었는가”를 store에 보냅니다.

  3. Reducer 계산

    현재 state와 action만으로 순수하고 불변인 다음 state를 만듭니다.

  4. Store snapshot

    root reducer 반환값을 저장하고 구독자에게 업데이트를 알립니다. 동기 dispatch 뒤 getState()는 새 snapshot을 읽습니다.

  5. Selector 확인

    useSelector가 선택 결과를 다시 계산하고 기본 === 비교를 수행합니다.

  6. UI 반영

    선택 결과가 달라진 Redux 구독 경로가 새 화면을 렌더합니다. 별도 부모 렌더 경로는 남습니다.

Single store

Redux가 관리하는 state tree

일반적인 Redux 앱은 store 하나를 사용합니다. 컴포넌트 로컬 state, URL, 서버 캐시까지 모두 넣어야 한다는 뜻은 아닙니다.

Read only

변경 입구는 dispatch

store state를 직접 쓰지 않고 action을 dispatch합니다. 이 경계가 action 기반 관찰과 미들웨어 확장을 가능하게 합니다.

Pure reducer

전이는 순수하고 불변

같은 state와 action에는 같은 결과를 내고 부수 효과는 밖에 둡니다. Redux Toolkit의 mutation처럼 보이는 문법도 이전 state 자체를 바꾸는 계약은 아닙니다.

action 이력은 Redux가 자동으로 영구 보관하는 데이터가 아닙니다. DevTools 연동과 모니터 또는 로깅을 구성하고 state·action을 관찰 가능한 직렬화 형태로 유지했을 때 전후 값을 디버깅 증거로 볼 수 있습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명동기 plain action을 받은 Redux store가 root reducer로 다음 state를 저장하고 구독자에게 알립니다. useSelector의 기본 === 비교에서 선택 결과가 달라진 이 구독 경로는 렌더를 요청하고 같으면 이 경로의 요청을 건너뜁니다. 별도 부모 렌더 경로는 남아 있으므로 모든 렌더 여부를 store 구독 하나로 결정하지 않습니다.
원도표 표 행단계 | 책임 | 확인할 계약
원도표 표 행1 · UI 사건 | 사용자 입력이나 비동기 작업 완료가 상태 변경 의도를 만듭니다. | 사건을 먼저 설명하고 다음 값을 UI에서 직접 쓰지 않습니다.
원도표 표 행2 · Action / dispatch | dispatch(action)으로 type과 필요한 payload를 store에 보냅니다. | action은 “무슨 일이 있었는가”를 기록하는 평범한 객체입니다.
원도표 표 행3 · Reducer | 현재 state와 action만으로 다음 state를 동기적으로 계산합니다. | 이전 state를 바꾸지 않고 API·타이머·랜덤 같은 부수 효과를 실행하지 않습니다.
원도표 표 행4 · Store snapshot | root reducer의 반환값을 현재 state로 저장하고 구독자에게 업데이트를 알립니다. | getState()는 이 dispatch가 끝난 뒤의 snapshot을 반환합니다.
원도표 표 행5 · Selector / subscriber | selector가 필요한 조각이나 파생 값을 읽고 이전 선택 결과와 비교합니다. | useSelector는 기본적으로 선택 결과의 === 참조 동일성을 사용합니다.
원도표 표 행6 · UI render | Redux 업데이트로 선택 결과가 달라진 컴포넌트가 새 값을 화면에 반영합니다. | 부모 렌더 같은 별도 경로는 남으므로 Redux 구독만으로 모든 렌더가 결정되지는 않습니다.
원도표 조건·주석UI 사건: 사용자 입력이나 비동기 완료가 변경 의도를 만듭니다.
원도표 설명type과 필요한 payload로 “무슨 일이 있었는가”를 store에 보냅니다.
원도표 조건·주석Action을 dispatch: type과 필요한 payload로 “무슨 일이 있었는가”를 store에 보냅니다.
원도표 조건·주석Reducer 계산: 현재 state와 action만으로 순수하고 불변인 다음 state를 만듭니다.
원도표 조건·주석Store snapshot: root reducer 반환값을 저장하고 구독자에게 업데이트를 알립니다. 동기 dispatch 뒤 getState()는 새 snapshot을 읽습니다.
원도표 설명useSelector가 선택 결과를 다시 계산하고 기본 === 비교를 수행합니다.
원도표 조건·주석Selector 확인: useSelector가 선택 결과를 다시 계산하고 기본 === 비교를 수행합니다.
원도표 조건·주석UI 반영: 선택 결과가 달라진 Redux 구독 경로가 새 화면을 렌더합니다. 별도 부모 렌더 경로는 남습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
plain actionstore.count: 0 / action: incrementedUI가 동기 plain action을 store에 dispatch한다middleware 확장 전체를 이 그림의 필수 단계로 넣지 않는다
next snapshotstore.count: 1 / selector: 다시 계산root reducer의 결과를 store가 저장하고 구독자에게 알린다여러 selector의 결과가 모두 달라질 필요는 없다
선택 비교선택 다름: 이 구독 렌더 요청 / 선택 같음: 이 요청 없음기본 === 비교에서 선택 결과가 다를 때만 이 구독의 렌더를 요청한다별도 부모 렌더까지 없다고 판정하지 않는다

위 그림은 동기적인 Redux 액션 하나가 선택 결과의 비교까지 이어지는 흐름을 보여 줍니다. UI 사건이 평범한 동기 액션을 만들어 dispatch(action)으로 store에 보내면, 순수한 루트 리듀서가 현재 state에서 다음 state를 계산하고, store가 그 스냅샷을 저장한 뒤 getState()와 구독 알림으로 알립니다. useSelector가 선택 결과를 기본 ===로 비교해 달라졌다면 그 Redux 구독 경로가 새 값으로 UI를 렌더링하고, 같다면 이 구독 갱신으로는 렌더링을 요청하지 않습니다. 그림의 설명처럼 부모의 렌더링 같은 별도의 경로는 남아 있으므로, 모든 렌더링 여부를 store의 구독 하나로 결정하지는 않습니다.

그림 위쪽의 예는 count를 1 늘리는 리듀서를 가정한 설명입니다. 이전 스냅샷의 count가 0일 때 dispatch({ type: "incremented" })를 보내면 getState().count는 1이 되고, useSelector(s => s.count)의 선택 결과가 바뀝니다. 그림 아래의 표는 각 단계에서 확인할 점을 정리하며, 특히 getState()는 이 dispatch가 끝난 뒤의 스냅샷을 돌려준다는 점과, 액션의 이력은 DevTools 연동과 모니터나 로깅을 구성해야 확인할 수 있다는 점을 덧붙입니다.

이 흐름을 단계로 정리하면 다음과 같습니다. 먼저 사용자의 입력이나 미들웨어의 비동기 작업 완료가 바꾸려는 의도를 만들고, 컴포넌트나 미들웨어가 액션을 store에 dispatch합니다. store는 현재 state와 액션으로 루트 리듀서를 실행하고, 리듀서의 반환값이 store의 현재 state가 되어 구독자에게 갱신이 알려집니다. React-Redux의 구독자는 필요한 값을 다시 선택해 이전의 선택 결과와 비교하고, 그 구독 경로에서 선택 결과가 바뀌었다면 컴포넌트가 새 값으로 렌더링됩니다. 비동기 작업과 부수 효과는 리듀서 밖에서 실행한 뒤 필요한 액션을 dispatch합니다.


Redux Toolkit과의 관계

Redux Toolkit은 현대의 Redux 로직을 작성하는 공식 권장 방법입니다(Redux Toolkit 소개). configureStore는 루트 리듀서와 표준 미들웨어, 기본으로 켜진 Redux DevTools 연동을 포함한 권장 store 설정을 제공하며, devTools 옵션으로 연동을 조정할 수 있습니다. createSlice는 액션의 type과 액션 생성 함수, 케이스 리듀서를 함께 만들고, Immer를 써서 불변 갱신을 간결하게 작성하게 해 줍니다.

createAsyncThunk는 직접 만든 Promise 작업에 대해 pending, fulfilled, rejected라는 생명주기 액션을 만들고, condition과 AbortSignal로 취소할 지점을 제공합니다. 다만 다시 시도하기나 자동 중복 제거, 캐시 정책까지 갖춘 일반적인 서버 데이터 해법은 아닙니다. RTK Query는 Redux 앱에서 클라이언트가 데이터를 가져오고 캐시하는 일을 관리할 때 공식 문서가 기본으로 권하는 도구로, 요청 상태와 캐시 무효화를 제공합니다. 서버 렌더링이나 라우트 loader가 가진 데이터는 그 프레임워크의 방식을 따릅니다. React-Redux는 useSelector, useDispatch 등으로 React 컴포넌트를 store에 잇는 별도의 공식 패키지입니다.

Redux Toolkit API는 각기 다른 책임을 맡습니다

REACT STATE · OWNERSHIP & TRANSITION

Redux Toolkit API는 각기 다른 책임을 맡습니다

store 설정, action·reducer 생성, 사용자 정의 비동기 작업, 서버 캐시와 React 구독을 구분합니다. 어떤 상태를 Redux가 소유할지는 먼저 결정해야 합니다.

비동기 작업과 서버 캐시를 구분

서버 응답 · 요청 상태 · 캐시
→ RTK Query
사용자 정의 Promise 작업
→ createAsyncThunk

createAsyncThunk의 생명주기 action만으로 재시도·자동 중복 제거·캐시 정책이 완성되지는 않습니다. 서버 렌더·route loader의 소유권도 구분합니다.

Redux 역할과 현재 권장 API의 책임 경계
역할 권장 API · 제공하는 것 직접 결정할 것
Store
configureStore
root reducer 결합, 표준 미들웨어와 기본 활성화된 Redux DevTools 연동을 포함한 권장 설정; devTools 옵션으로 제어
어떤 client state를 Redux가 소유할지와 slice 경계
Action + reducer
createSlice
action type·creator와 case reducer 생성, Immer 기반의 불변 갱신 작성 경로
action 어휘, state shape, 순수한 전이 규칙과 불변 조건
Custom async
createAsyncThunk
Promise 생명주기 action과 condition·AbortSignal 취소 지점 제공
취소 시점·재시도·자동 중복 제거·캐시 정책; 일반 서버 캐시의 기본 해법은 아님
Server data
RTK Query
Redux 앱의 client data fetching과 캐시, 요청 상태, 무효화 도구
서버 렌더·라우트 로더의 데이터 경계와 API의 권한·일관성 계약
React binding
useSelector
React-Redux가 store를 구독하고 selector 결과를 컴포넌트에 연결
선택 범위, memoization, 참조 안정성과 별도 부모 렌더 비용

Store · configureStore

권장 store 설정과 표준 미들웨어·기본 활성화된 DevTools 연동을 제공합니다. 연동 옵션과 Redux가 소유할 state 범위는 직접 정합니다.

Action + reducer · createSlice

action과 case reducer를 함께 만들고 Immer로 불변 갱신을 간결하게 씁니다. action 어휘와 전이 규칙은 설계 책임입니다.

Custom async · createAsyncThunk

Promise 생명주기 action과 condition·AbortSignal을 제공합니다. 취소 시점·재시도·중복 제거·캐시 정책은 직접 정합니다.

Server data · RTK Query

Redux 앱의 client data fetching과 캐시에 권장되는 기본 경로입니다. 요청 상태와 캐시 무효화를 제공하며, 서버 렌더·라우트 데이터의 소유권과 API 권한·일관성 계약은 별도로 정합니다.

React binding · useSelector

React-Redux가 selector 결과를 화면에 연결합니다. 선택 범위와 참조 안정성, 부모 렌더는 별도로 판단합니다.

Redux signal

공유·빈도·복잡성·추적 가치

많은 화면이 같은 client state를 읽고, 갱신이 잦고, 전이 규칙이 복잡하며, 팀이 action 기반 추적과 도구의 이득을 얻을 때 검토합니다.

Keep the boundary

규모만으로 자동 선택하지 않는다

로컬 UI state는 컴포넌트에, 주소 상태는 URL에 둘 수 있습니다. Context+reducer는 Provider 트리의 공유 도구이며 외부 store·middleware·DevTools·selector 비교를 자동 제공하지 않습니다.

createStore는 학습과 기존 코드에서 볼 수 있지만, 새 Redux 로직은 공식 권장인 Redux Toolkit의 configureStore에서 시작합니다.

위 표는 Redux의 역할마다 지금 권하는 API가 무엇을 제공하고 무엇을 직접 정해야 하는지를 나눕니다. store는 configureStore가 루트 리듀서의 결합과 표준 미들웨어, DevTools 연동을 제공하지만, 어떤 클라이언트 상태를 Redux가 가질지와 slice를 어떻게 나눌지는 직접 정합니다. 액션과 리듀서는 createSlice가 만들어 주지만, 액션의 이름과 state의 모양, 순수한 전이 규칙과 불변 조건은 설계해야 합니다. 직접 만든 비동기 작업에는 createAsyncThunk가 생명주기 액션과 취소 지점을 제공하지만, 취소 시점과 다시 시도하기, 중복 제거, 캐시 정책은 직접 정합니다.

서버 데이터에는 RTK Query가 클라이언트의 데이터 요청과 캐시, 요청 상태, 무효화를 제공하지만, 서버 렌더링과 라우트 loader의 데이터 범위, API의 권한과 일관성 규칙은 따로 정합니다. React 바인딩인 useSelector는 store를 구독해 selector의 결과를 컴포넌트에 잇지만, 선택 범위와 메모이제이션, 참조의 안정성, 별도의 부모 렌더링 비용은 직접 판단합니다. 그림 위쪽의 예가 강조하듯 서버의 응답과 요청 상태, 캐시는 RTK Query가, 직접 만든 Promise 작업은 createAsyncThunk가 다루며, createAsyncThunk의 생명주기 액션만으로 다시 시도하기와 중복 제거, 캐시 정책이 완성되지는 않습니다. 그림의 마지막 설명처럼 createStore는 학습과 기존 코드에서 볼 수 있지만, 새 Redux 로직은 configureStore에서 시작합니다.

더 깊이: createSlice의 값을 바꾸는 듯한 코드가 실제로 하는 일

createSlice의 케이스 리듀서가 받는 state는 store의 state 그 자체가 아니라, Immer가 프록시(Proxy)로 감싼 초안(draft)입니다. Redux Toolkit의 Immer 안내에 따르면 Immer는 초안을 고치려는 시도를 모두 기록한 뒤, 그 변경을 불변 방식으로 다시 적용해 새 결과를 만듭니다. 이때 바뀐 경로의 배열과 객체만 새로 만들고 바뀌지 않은 항목은 원래 참조를 그대로 두므로, useSelector의 === 비교도 바뀐 부분에서만 달라집니다. 또 Immer는 만든 state를 자동으로 얼려 두므로, 리듀서 밖에서 실수로 state를 고치면 오류가 납니다.

이 방식에는 지켜야 할 규칙이 있습니다. 하나의 케이스 리듀서는 초안을 고치거나 새 값을 만들어 돌려주거나 둘 가운데 하나만 해야 하며, 같은 함수에서 둘을 섞지 않습니다. 흔한 실수는 state = action.payload처럼 지역 변수에 다시 대입하는 것인데, 이 코드는 초안을 고치지도 새 값을 돌려주지도 않으므로 아무것도 바꾸지 못하며 return action.payload로 써야 합니다. 또 초안을 console.log(state)로 찍으면 읽기 어려운 프록시가 보이므로, Redux Toolkit이 다시 내보내는 current(state)로 지금 값의 사본을 확인합니다.


Store에 맡길 전이를 JavaScript로 먼저 계산하기

Redux를 연결하기 전에 순수한 리듀서 하나가 지켜야 할 규칙을 읽어 봅니다. 다음은 장바구니의 수량만 관리하는 작은 계산 예로, Redux store나 React의 구독을 직접 실행하는 코드는 아닙니다. cart/itemAdded의 quantity는 이미 양의 정수로 검증되어 들어온다고 가정합니다.

function cartReducer(state = { count: 0 }, action) {
  if (action.type === 'cart/itemAdded') {
    return { ...state, count: state.count + action.quantity };
  }
  return state;
}

const before = { count: 2 };
const after = cartReducer(before, {
  type: 'cart/itemAdded',
  quantity: 3,
});

console.log(before.count, after.count); // 2 5
console.log(before === after);         // false
console.log(cartReducer(after, { type: 'theme/changed' }) === after); // true

Console에는 차례로 2 5, false, true가 출력됩니다. 추가 액션은 새 객체를 돌려주므로 이전 객체의 count는 2 그대로이고, 마지막 액션은 이 리듀서가 다루지 않는 사건이므로 원래의 state를 그대로 돌려줍니다. 여러 slice 리듀서가 같은 액션을 받을 수 있는 Redux에서는 모르는 액션에도 기존 state를 돌려주고, state가 undefined일 때는 초기값을 돌려줘야 합니다. store를 만들 때 초기화용 액션도 리듀서를 거치기 때문입니다(Redux의 state 초기화). 앞 글의 React 카운터에서 고른 “모르는 액션이면 오류”라는 규칙을 그대로 Redux의 루트 리듀서에 옮기면 안 되는 이유입니다.

그 뒤 store가 새 스냅샷을 저장했다고 생각하고 selector를 따라가 봅니다. state => state.count의 이전 결과 2와 다음 결과 5는 다르므로, 이 값을 구독하는 UI에 변경이 전달됩니다. 액션의 문자열이 UI를 직접 고치는 것이 아니라, 계산 결과를 다시 선택한 값이 달라졌기 때문입니다.


정리

  • Redux는 Redux가 관리하기로 정한 클라이언트 상태를 하나의 store에 두고, 액션의 dispatch로만 바꾸는 독립된 라이브러리입니다.
  • 지역 UI 상태와 URL, 서버의 캐시는 Redux에 모두 넣지 않고, Redux가 가질 상태의 범위를 먼저 나눕니다.
  • 리듀서는 이전 state와 액션으로 다음 state를 계산하는 순수 함수이며, 모르는 액션에는 기존 state를, undefined에는 초기값을 돌려줍니다.
  • useSelector는 선택 결과를 기본 ===로 비교해 달라졌을 때만 그 구독 경로에서 렌더링하며, 부모의 렌더링 경로는 별개입니다.
  • 새 Redux 코드는 Redux Toolkit의 configureStore와 createSlice로 작성하고, 서버 데이터는 RTK Query나 프레임워크의 데이터 계층을 검토합니다.

연습 문제

1. 상태의 변경과 선택 결과의 변경

store의 이전 state는 { count: 5, theme: 'light' }, 다음 state는 { count: 5, theme: 'dark' }입니다. 두 컴포넌트의 selector가 각각 state => state.count와 state => ({ count: state.count })이고 기본 === 비교를 쓸 때, 각 선택 결과는 같은 것으로 비교될까요? 이것만으로 두 컴포넌트가 어떤 경로로도 다시 렌더링되지 않는다고 결론 낼 수 있을까요?

해설 보기

첫 selector는 이전과 다음 모두 숫자 5를 돌려주므로 5 === 5가 참이고, 이 Redux 구독의 갱신으로는 새 렌더링을 요구하지 않습니다. 두 번째 selector는 호출할 때마다 새 객체를 만들기 때문에, 내용이 같은 { count: 5 }끼리도 ===가 거짓이 되어 이 구독 경로에서 갱신됩니다. 필요한 값을 따로 선택하거나 메모이제이션한 selector, 명시적인 비교 함수를 검토할 수 있지만, 먼저 불필요한 새 객체가 원인인지 확인합니다(React-Redux의 동등성 비교).

다만 두 경우 모두 부모의 렌더링 같은 별도의 경로까지 없애지는 않습니다. 이 계산은 규칙을 판단하는 연습일 뿐, 실제 React-Redux의 렌더링 횟수를 측정한 기록은 아닙니다.

2. 초기화 액션

앞의 cartReducer를 cartReducer(undefined, { type: '@@redux/INIT' })로 호출하면 무엇을 돌려줄까요? 이 리듀서가 모르는 액션에 오류를 던지도록 바뀌어 있었다면 store를 만들 때 어떤 일이 생길까요?

해설 보기

state 매개변수의 기본값 { count: 0 }이 쓰이고, 액션이 cart/itemAdded가 아니므로 그 객체를 그대로 돌려줍니다. 그래서 결과는 { count: 0 }입니다.

모르는 액션에 오류를 던지도록 바꿨다면, store를 만들 때 Redux가 보내는 초기화용 액션에서 오류가 나 store를 만들 수 없습니다. Redux의 리듀서는 자기와 상관없는 액션에 기존 state를 돌려줘야 합니다. 초기화 액션의 정확한 type 문자열은 Redux 내부의 값이므로 코드에서 그 문자열에 기대지 않습니다.

다음 글 테마 변경 기능 구현에서는 7장에서 배운 Context와 useReducer를 모아 테마를 바꾸는 기능을 직접 구현하고, 상태의 소유자와 Provider의 범위를 실제 앱에서 확인합니다.