sungyup's.

react / State Management / 1.1 Redux Toolkit

1.1Redux Toolkit

Redux Toolkit의 등장 배경 및 주요 기능, 사용법 정리

TL;DR

추억의 쪽지 시험

Redux Toolkit의 등장 배경

React, 또는 React Native 프로젝트를 진행하면 전역 상태 관리에 대한 고민을 하게 된다. 전역 상태 관리 개념은 아주 오래된 것으로, 맨 처음 전역으로 상태를 관리하기 위해 나온 React Context API는 React v0.13때부터 있었던 꽤 오래된 API다.

하지만 초기(옛 버전) Context API는 공식 문서에서조차 비권장(unstable) 기능으로 취급할 정도로 API가 불안정하고 사용법이 난해했다. 이후 2018년이 되어서야 React v16.3에 새로운 Context API가 도입되어 상태를 컴포넌트 내에서만이 아니라 전역적으로 안정적으로 전달할 수 있게 되었다.

하지만 Context는 값이 바뀌면 해당 Context를 구독한 모든 컴포넌트를 리렌더한다거나, 비동기 흐름과 같이 복잡한 상태 업데이트 관리에 취약했다. 이때 Redux라는 JavaScript 라이브러리가 주목받았다. Redux는 2015년에 나온 JavaScript의 상태관리 라이브러리였는데, 태생은 JavaScript 앱 전체를 위한 상태 컨테이너였다.

Redux logo
이미지 출처 : #
자바스크립트의 상태관리 라이브러리, Redux.

Redux는 store, action, reducer 개념을 포함한 단순한 라이브러리로, 2014년의 Facebook에서 제안한 Flux 아키텍처 패턴에 영감을 받아 탄생했다(참고로, Redux라는 이름 자체가 Reduce + Flux다. Reduce는 Array.prototype.reduce()의 그 reduce다).

Facebook은(React를 만든 곳이기도 하다) React 앱에서 컴포넌트 간 양방향 데이터 흐름(컴포넌트 간 상호 갱신)이 생겨 상태 관리가 복잡해지는 문제를 해결하고자 데이터 흐름을 단방향으로 만들자는 Flux 아키텍처를 제안한다.

Flux 아키텍처는 MVC(Model-View-Controller)의 대안으로 제시된 것으로, 4가지 핵심 요소로 이루어진다:

  1. Action: 무슨 일이 일어났는지를 나타내는 단순한 객체로, { type: "ADD_TODO", text: "Redux 포스팅 완성하기" }같은 형태다.
  2. Dispatcher: Action을 받아 스토어로 전달하는 중앙 허브
  3. Store: 앱의 상태(state)를 보관하고 액션을 받아 상태를 갱신.
  4. View: Store에서 상태를 읽어 UI를 렌더한다. 또, 사용자의 입력 이벤트를 받아 Action을 생성하고 Dispatcher를 통해 Store로 전달되게 한다.

즉, 데이터가 하나의 방향으로만(Unidirectional) 흐르는 아키텍처로 Action → Dispatcher → Store → View로 이루어지는 구조인 것이다. 예를 들어 이런 식이다.

  1. 사용자가 버튼 클릭(이벤트 발생)
  2. Action을 생성한다(ADD_TODO)
  3. Dispatcher가 Action을 해당 Store에 전달한다.
  4. Store는 내부 상태 갱신 후 View에게 변경 사실을 알린다.
  5. View는 새 상태로 다시 렌더링한다.
bash
View → Action → Dispatcher → Store → View …

Redux는 이 Flux 아이디어를 보다 단순화했다.

  • Flux의 여러 Store 대신 Redux는 단일 Store를 쓴다.
  • Flux의 Dispatcher 대신 Redux는 dispatch 함수로 대체한다.
  • Redux는 Store를 reducer라는 순수 함수로만 갱신할 수 있다.

상태를 업데이트하는 함수를 reducer라고 부르는 것은, 자바스크립트의 reduce() 함수에서 영향을 받았기 때문이다.

javascript
const result = arr.reduce((acc, item) => { // acc = 누적값 (현재까지의 state) // item = 새로 들어온 action return acc + item; }, 0);

Redux에서 새로운 state로 업데이트하는 방식은 모든 액션을 순서대로 reduce 시켜 새로운 state를 만들어내는 것이다. 그렇기 때문에 상태를 업데이트하는 함수를 reducer라고 부르게 되었고, 이름의 근원(Reduce + Flux)이 되기도 하였다.

redux data flow
이미지 출처 : #
공식문서의 redux data flow. Store에서 State를 관리하고, UI에 노출해 유저가 Event Handler를 트리거하면 설정된 Dispatch 함수가 발동해 Reducer로 State를 갱신한다.

Redux는 비록 JavaScript 라이브러리였고 UI에 종속적이지 않았지만, React의 "UI는 state의 함수"라는 철학과 너무나도 잘 맞았기 때문에 Redux와 React 컴포넌트와 연결해주는 바인딩 라이브러리인 React-Redux가 자연스럽게 등장했다. 등장 이후 한동안 전역 상태 관리는 Redux가 표준이나 다름없었다. 이 때 Redux는 옛 Context API 및 여러가지 기법들을 동원해 구현한 것으로, 이후 2018년에 새 Context API가 나오자 React-Redux도 내부 구현을 새 API 기반으로 갈아탄다.

UI에 종속적이지 않은 Redux를 React Component와 연결하기 위해선 별도의 바인딩 계층이 필요하다. React-Redux는 React와 Redux를 연결하는 일종의 어댑터로, <Provider store={store}>로 앱을 감싸 React Context API를 이용해 하위 컴포넌트들이 스토어를 구독할 수 있게 해주었다.

그러나 Redux는 보일러 플레이트를 작성하는데 너무나도 많은 코드와 시간을 들여야 한다는 단점이 있었다. 기본적으로 액션 타입을 선언하고, 액션 생성 함수를 작성하고, 스토어 설정 시 미들웨어와 DevTools를 연결해야 하는 등 매번 똑같은 코드를 직접 짜야해서 불편했다. 이에 Redux 팀에서 공식적으로 Redux Toolkit라는 단순화 라이브러리를 개발했는데, 이것이 이젠 사실상 표준이 되었다. Redux Toolkit 문서에선 Redux 개발팀이 직접 Redux를 쓸거면 Redux Toolkit을 쓰라고 한다.

Redux Toolkit의 주요 기능

그렇다면 Redux Toolkit은 어떤 기능들을 제공할까? Redux의 원래 문법과 비교하면서 Redux Toolkit이 얼마나 편한 기능을 제공하는지 알아보는 것도 물론 도움이 되겠지만, 사실상 Redux Toolkit이 표준이 된 현시점에선 Redux의 원래 문법까지 모두 다룰 필요는 없다고 생각한다. 따라서 이번 포스팅에선 Redux Toolkit이 제공하는 주요 기능들과 그 문법을 간단한 counteruser를 예로 들어 살펴보자.

1. ConfigureStore

전역적으로 관리할 상태들이 저장될 Store를 설정한다. 직접 세팅해야 하는 Vanilla Redux와 달리 이후 살펴볼 DevTools, thunk 미들웨어가 기본적으로 내장된다.

여러가지 Reducer들이 있을 경우 combineReducers라는 함수를 직접 호출했어야 하는 Vanilla Redux와 달리 reducer: {리듀서1, 리듀서2, ...}식의 객체로 쉽게 선언할 수 있다.

typescript
import { configureStore } from '@reduxjs/toolkit'; import counter from './counterSlice'; import user from './userSlice'; // 여러개의 리듀서들을 객체로 쉽게 선언 export const store = configureStore({ reducer: { counter, user }, }); // 타입 안정성 확보 export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;

여기서 맨 아랫줄의 타입 안정성 확보 부분을 좀 더 자세히 알아보자.

RootState

TypeScript에서 ReturnType<T>는 "함수 T가 반환하는 값의 타입"을 추출한다. 따라서 이 코드는 type RootStatestore.getState()가 반환하는 값의 타입을 할당하는 코드다.

그러면 store.getState()는 어떤 값을 반환할까? Redux는 다중 Store 구조였던 Flux를 단순화하며 단 하나의 Store만 둔다. 그리고 위에서 보듯, configureStore로 만든 Store는 내부적으로 reducer들을 합쳐서 하나의 리듀서를 만드는데, 이것을 루트 리듀서(root reducer)라고 한다.

즉, store.getState()의 반환 타입(ReturnType)은 앱 전체 state의 구조이다.

AppDispatch

store.dispatch는 액션을 보낼 때 쓰는 함수다. 이 함수의 타입을 그대로 추출하면 dispatch 함수의 타입을 모두 얻을 수 있다. 이것을 AppDispatch에 할당한 것이다.

활용

이렇게 정의되고 export된 타입은 주로 React 컴포넌트 훅에서 타입 안전성을 보장하기 위해 사용된다.

typescript
// hooks.ts import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'; import type { RootState, AppDispatch } from './store'; export const useAppDispatch = () => useDispatch<AppDispatch>(); export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

위와 같이 useAppDispatchuseAppSelector라는 훅을 만들면, dispatch할 수 있는 액션 타입이 자동으로 제한되고, state 구조가 자동 완성되어 오타나 타입 실수를 방지할 수 있다.

tsx
const dispatch = useAppDispatch(); const value = useAppSelector(state => state.counter.value);

2. createSlice

createSlice는 Store에 있는 전역 상태를 업데이트하는 함수인 reducer들을 만든다.

Vanilla Redux에선 reducer들을 서로 switch해가며, 직접 불변성 관리도 하며 액션 상수들을 정의하고 액션 생성자들도 따로 만들어야했다면, Redux Toolkit에선 액션 상수와 액션 생성자, reducer들을 모두 한 곳에서 편하게 정의할 수 있다.

typescript
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; type CounterState = { value: number }; const initialState: CounterState = { value: 0 }; const counterSlice = createSlice({ name: 'counter', initialState, reducers: { increment: (state) => { state.value += 1 }, // immer로 불변성 유지 incrementBy: (state, action: PayloadAction<number>) => { state.value += action.payload; }, }, }); export const { increment, incrementBy } = counterSlice.actions; export default counterSlice.reducer;

위의 예시에서 또 하나 주목할 것은 불변성 유지에 관한 것이다. Redux Toolkit은 내부적으로 immer 라이브러리를 통해 불변성 처리를 쉽게 해결할 수 있도록 돕는다.

불변성 처리

immer(독일어로 '항상'이라는 뜻)는 JavaScript의 불변성(immutability)를 쉽게 처리할 수 있도록 돕는 라이브러리다. React가 메모화(useMemo)/최적화(useCallback)를 할 때도 그렇지만, Redux는 얕은 비교(Shallow Equality)를 사용해 props/state가 바뀌었는지 체크한다. 다시 말해, 참조가 바뀌어야 실제 상태가 바뀐것으로 판단하고 리렌더링한다. 이것은 원시값 중 하나인 number를 예로 든 위의 사례에선 잘 드러나지 않지만, 객체나 배열의 경우 중요한 부분이다.

예를 들어, 아래의 todos 배열에 TODO를 추가한다고 하면, todos.push()로는 기존의 배열 참조가 바뀌지 않기 때문에 리렌더를 하지 않는다. 얕은 복사를 통해 새롭게 배열을 만들고 기존 데이터를 복사한 후, 새 데이터를 추가해야 변경을 인지하는 것이다.

typescript
function reducer(state = { todos: [] }, action) { switch (action.type) { case 'ADD_TODO': return { ...state, todos: [...state.todos, action.payload] }; // 배열을 새롭게 생성해야 기존 배열과 변경됨을 default: return state; } }

하지만 앞서 본 예시에서 볼 수 있듯, immer는 새로운 참조값을 만드는게 아니라 직접 원본을 수정하듯이 코드를 작성해도 내부적으로 새로운 불변 객체를 만들어 리턴한다. 즉, state.todos.push(action.payload)로 업데이트해도 복사본을 만들고 push를 하기 때문에 immutable update(원본을 건드리지 않고 새로운 객체/배열을 만들어 교체하는 것)가 보장된다.

Redux Toolkit의 리듀서는 mutable 스타일과 immutable 스타일 모두를 허용한다. 단, 한 리듀서 안에서 직접 수정과 객체 리턴을 섞으면 안된다.

3. createAsyncThunk

A thunk is a subroutine used to inject a calculation into another subroutine. Thunks are primarily used to delay a calculation until its result is needed, or to insert operations at the beginning or end of the other subroutine. - 위키피디아 -

Thunk란 어떤 값을 직접 계산하지 않고, 그 계산을 나중에 실행할 수 있게 감싸놓은 함수다. 예를 들어, 아래는 add(2, 3)을 직접 계산한 것과 thunk를 통해 필요할 때 계산한 예시다.

javascript
const result = add(2, 3); // 직접 계산
const thunk = () => add(2, 3);
const result = thunk(); // 필요할 때 실행

Redux에서 dispatch는 순수한 객체(action)만 받을 수 있다.

javascript
store.dispatch({type: 'ADD'}); // 가능!
store.dispatch(fetchUser()); // 함수는 안된다

하지만 실제 앱에선 비동기 API 요청, setTimeout, setInterval, 로그인 여부를 확인한 조건부 dispatch등 함수를 dispatch해야할 일들이 많다.

redux-thunk는 비동기 로직(함수)를 dispatch할 수 있도록 만들어진 미들웨어다. redux-thunkdispatch가 함수를 받았을 때 그 함수를 실행하며 dispatchgetState를 전달해준다.

다만, 비동기 요청 로직은 제대로 코드를 짜려면 꽤 복잡하다. 우선, 수명주기(async request lifecycle)를 관리해야 한다. 즉, pending일때, fulfilled일때, rejected일때 각각 다른 처리를 해야하고, 에러가 났거나 취소를 한 경우, 중복호출에 대해서도 모두 각기 다른 처리를 해야한다. TypeScript를 쓸 경우 인자 및 반환값, 에러 등의 타입도 모두 신경써야 한다.

Redux Toolkit의 createAsyncThunk비동기 요청의 모든 과정을 하나의 표준 패턴으로 묶어준다. redux-thunk에선 위의 비동기 요청과 관련된 처리를 매번 수동으로 만들었는데, createAsyncThunk는 이 보일러플레이트를 제공해 간편하게 비동기 요청을 dispatch할 수 있게 되었다.

createAsyncThunk의 기본 형태는 아래와 같다:

typescript
createAsyncThunk<Returned, ThunkArg, ThunkApiConfig>( typePrefix: string, payloadCreator: async(arg: ThunkArg, thunkAPI) => Returned, options? )

우선 Generic으로 지정한 타입 인자들은 다음과 같은 뜻을 가진다:

  • Returned: 요청 성공 시(fulfilled) payload 타입
  • ThunkArg: 호출 인자 타입
  • ThunkApiConfig: { state, dispatch, extra, rejectValue, serializedErrorType }

실제 인자(parameter)는 각각 다음과 같다:

  • typePrefix: 액션 타입 문자열의 접두사. 예를 들어 user/fetchUser 등이다. Redux는 여기에 /pending, fulfilled, /rejected를 자동으로 붙여 3가지 액션을 만든다. 예를 들어 user/fetchUser/pending 같은 식이다.
  • payloadCreator: 비동기 로직을 수행하는 async 함수로, 호출 시 ThunkArgthunkAPI 두 인자를 받는다.
    • ThunkArg는 dispatch할때 전달한 인자다. 예를 들어, dispatch(fetchUser('123abc'))이라면 '123abc'이다.
    • thunkAPI는 Redux가 제공하는 dispatch, getState, signal 등 유틸이다.

기본적인 createAsyncThunk의 사용 패턴은 다음과 같다.

typescript
// userSlice.ts import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; type User = { id: string; name: string }; type UserState = { data?: User; loading: boolean; error?: string }; const initialState: UserState = { loading: false }; export const fetchUser = createAsyncThunk<User, string>( 'user/fetchUser', // 액션 타입 prefix async (id) => { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error('Failed to fetch'); return res.json(); // fulfilled 액션의 payload 반환 } ); const userSlice = createSlice({ name: 'user', initialState, reducers: {}, extraReducers: (builder) => builder .addCase(fetchUser.pending, (state) => { state.loading = true; state.error = undefined; }) .addCase(fetchUser.fulfilled, (state, { payload }) => { state.loading = false; state.data = payload; }) .addCase(fetchUser.rejected, (state, action) => { state.loading = false; state.error = action.error.message; }), }); export default userSlice.reducer;

이같은 방식은 호출 한 번으로 세 가지 액션을 만든다. 즉, user/fetch/pending, user/fetch/fulfilled, user/fetch/rejected가 생기고 액션마다 메타데이터를 따로 제공할 필요 없이 공통으로 메타데이터가 들어간다. 위의 예시에선 Userid다.

dispatch(thunk)의 반환값의 성공/실패 여부에 따른 try/catch문도 .unwrap()이라는 표준 문법에 따라 작성할 수 있다. 이 경우 물론 성공은 payload를 리턴해야하고 실패면 throw 해야한다.

typescript
const dispatch = useAppDispatch(); try{ const data = await dispatch(login({email, pw})).unwrap(); // data } catch (err) { // err }

또, condition을 통해 중복 호출을 방지할 수 있다. 아래는 이미 로딩 중이면 중복 요청을 막는 예시다.

typescript
export const fetchList = createAsyncThunk<any, void, { state: RootState }>( 'items/fetch', async (_, { signal }) => { const res = await fetch('/api/items', { signal }); return res.json(); }, { condition: (_, { getState }) => { const loading = getState().items.loading; return !loading; // false면 액션을 디스패치하지 않음 }, } );

4. 미들웨어/강화 설정(불변/직렬화 검사, DevTools)

Redux Toolkit의 configureStore()는 개발 모드에서 기본적으로 불변/직렬화 검사를 해주는 미들웨어를 켜주고, 옵션으로 쉽게 바꿀 수 있게 한다.

  • 불변성 검사: 리듀서 내부에서 state.nested.prop = ...처럼 직접 원본을 수정했는지 감지해 경고/에러를 낸다. Redux Toolkit은 immer 라이브러리를 쓰므로 복제된 객체인 초안(draft) 이외의 실제 원본을 건드리면 오류로 잡아낸다.
  • 직렬화 검사: 액션과 상태가 JSON으로 직렬화 가능한 값인지 확인한다. 즉, number/string/boolean/객체/array/null/undefined면 통과하지만, 함수나 Date, Map, Promise등은 비직렬화 값이므로 경고를 띄운다.
typescript
import { configureStore } from '@reduxjs/toolkit'; export const store = configureStore({ reducer, // 기본적으로: thunk + (dev) 불변/직렬화 검사 + DevTools 켜짐 middleware: (getDefaultMiddleware) => getDefaultMiddleware({ // 필요시 튜닝 immutableCheck: { warnAfter: 100 }, // ms 초과 시 경고 serializableCheck: false, // 예: Date/Map 등 비직렬 데이터 허용 }), });

컴포넌트에서의 사용

React 컴포넌트에선 우선 아래와 같이 import한다.

  • react-redux에서 useSelectoruseDispatch를 import한다.
  • store에서 RootState와 AppDispatch 타입을 import한다.
  • 마지막으로, 만들어둔 reducer를 import한다.

그리고 다음과 같이 사용한다.

  • 업데이트할 상태를 useSelectorstore에서 찾는다. 타입은 RootState다.
  • dispatch 함수를 정의한다. useDispatch<AppDispatch>()다.
  • dispatch로 reducer를 감싸 실행한다.
tsx
import { useSelector, useDispatch } from 'react-redux'; import type { RootState, AppDispatch } from './store'; import { incrementBy } from './counterSlice'; function Counter() { const value = useSelector((s: RootState) => s.counter.value); const dispatch = useDispatch<AppDispatch>(); return <button onClick={() => dispatch(incrementBy(2))}>{value}</button>; }

Redux Persist

Redux를 막상 실제 서비스에 쓰면 Redux Persist도 빼놓을 수 없게 된다. Redux Persist는 Redux의 상태를 스토리지(React는 localStorage, sessionStorage, React Native는 AsyncStorage)에 저장하고, 새로고침 후에도 복원해주는 라이브러리로 Redux의 상태가 새로고침하면 초기화(휘발성)된다는 한계를 해결하기 위해 등장했다.

Redux Persist의 원리는 상태를 지정한 스토리지에 저장해두었다가, 앱이 재시작될 때 자동으로 불러와서(store를 재구성해서) 상태를 유지시키는 것이다. 아래는 기본적인 설정 예시(React + Reudx Toolkit)다. PersistGate는 상태 복원이 완료될 때까지 UI를 잠시 멈춰준다. persistStore는 실제 저장소와 Redux 스토어 간의 싱크를 담당한다.

typescript
// store.ts import { configureStore, combineReducers } from '@reduxjs/toolkit'; import storage from 'redux-persist/lib/storage'; // localStorage 사용 예시 // import AsyncStorage from '@react-native-async-storage/async-storage'; // AsyncStorage의 경우 import { persistReducer, persistStore } from 'redux-persist'; import counterReducer from './counterSlice'; import userReducer from './userSlice'; // 어떤 리듀서를 저장할지 설정 const persistConfig = { key: 'root', // localStorage key storage, // 저장소 종류 whitelist: ['user'], // 저장할 slice blacklist: ['counter'], // 제외할 slice }; const rootReducer = combineReducers({ counter: counterReducer, user: userReducer, }); const persistedReducer = persistReducer(persistConfig, rootReducer); export const store = configureStore({ reducer: persistedReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }), }); export const persistor = persistStore(store);
Redux Persist는 상태를 JSON.stringify()로 직렬화해서 저장한다. 즉, 함수, Date, Map, Set 등은 저장이 불가능하다. 따라서 이런 타입을 상태로 넣을 경우 문자열 또는 기본 객체로 변환해서 저장하거나 serializableCheck: false 옵션을 켜야 한다.

위와 같이 persist할 slice와 아닌 slice를 구분해 어디에 저장할지 스토리지를 지정해 설정을 마치면, App.tsx에는 아래와 같이 PersistGate를 둘러준다.

tsx
// App.tsx import { PersistGate } from 'redux-persist/integration/react'; import { Provider } from 'react-redux'; import { store, persistor } from './store'; import App from './App'; <Provider store={store}> <PersistGate loading={null} persistor={persistor}> <App /> </PersistGate> </Provider>

Redux Persist와 SEO

Redux Persist는 기본적으로 웹에선 localStorage, sessionStorage, 그리고 앱에선 AsyncStorage클라이언트 사이드의 저장소를 사용한다. 즉, 이런 저장소가 없는 서버(SSR)에선 Redux Persist가 아무 일도 못한다.

다시 말해, 서버가 페이지를 렌더할 경우 Redux Persist가 복원(persist/rehydrate) 과정을 거치지 않아서 Redux Store가 빈 초기 상태로 렌더링된다. 따라서 SSR 프레임워크(Next.js 등)에서 Redux를 사용한다고 했을 때, 만약 게시글 리스트를 Redux 상태로 렌더링하면 localStorage에 예전에 불러온 게시글 목록을 저장하고 Redux Persist가 복원하니 SSR 단계에선 이 게시글 목록을 받지 못해 크롤러(구글봇 등)는 이 페이지를 게시글이 없는 빈 페이지로 파악하게 된다.

따라서 이런 경우, 다음과 같은 대처법을 도입해야 한다:

  • 서버에서 필요한 데이터를 미리 넣어준다.
    • Next.js라면 getServerSidePropsgetStaticProps 등으로 데이터를 불러와 Redux 초기 상태(preloadedState)에 주입한다.
    • 또, Redux Persist는 로그인 토큰, 유저 설정 등 SSR이 필요없는 데이터에만 한정해서 사용한다.
  • Persist 제외 설정
    • SEO가 중요한 페이지에선 Persist를 사용하지 않는다.
PreviousNo previous post