본문으로 건너뛰기

Redux 핵심 개념, 파트 8: Redux Toolkit을 사용한 모던 Redux

비공식 베타 번역

이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →

학습 내용
  • Redux Toolkit을 사용해 Redux 로직을 간소화하는 방법
  • Redux 학습 및 사용을 위한 다음 단계

축하합니다! 이 튜토리얼의 마지막 섹션까지 도달하셨습니다. 완료하기 전에 다룰 주제가 하나 더 남아 있습니다.

지금까지 배운 내용을 복습하고 싶다면 이 요약을 확인해 보세요:

정보

Recap: What You've Learned

  • Part 1: Overview:
    • what Redux is, when/why to use it, and the basic pieces of a Redux app
  • Part 2: Concepts and Data Flow:
    • How Redux uses a "one-way data flow" pattern
  • Part 3: State, Actions, and Reducers:
    • Redux state is made of plain JS data
    • Actions are objects that describe "what happened" events in an app
    • Reducers take current state and an action, and calculate a new state
    • Reducers must follow rules like "immutable updates" and "no side effects"
  • Part 4: Store:
    • The createStore API creates a Redux store with a root reducer function
    • Stores can be customized using "enhancers" and "middleware"
    • The Redux DevTools extension lets you see how your state changes over time
  • Part 5: UI and React:
    • Redux is separate from any UI, but frequently used with React
    • React-Redux provides APIs to let React components talk to Redux stores
    • useSelector reads values from Redux state and subscribes to updates
    • useDispatch lets components dispatch actions
    • <Provider> wraps your app and lets components access the store
  • Part 6: Async Logic and Data Fetching:
    • Redux middleware allow writing logic that has side effects
    • Middleware add an extra step to the Redux data flow, enabling async logic
    • Redux "thunk" functions are the standard way to write basic async logic
  • Part 7: Standard Redux Patterns:
    • Action creators encapsulate preparing action objects and thunks
    • Memoized selectors optimize calculating transformed data
    • Request status should be tracked with loading state enum values
    • Normalized state makes it easier to look up items by IDs

보셨듯이, Redux의 많은 측면(불변 업데이트, 액션 타입 및 액션 생성자, 상태 정규화 등)은 장황한 코드 작성을 수반합니다. 이러한 패턴이 존재하는 데는 타당한 이유가 있지만, 해당 코드를 "수동으로" 작성하는 것은 어려울 수 있습니다. 게다가 Redux 스토어 설정 과정은 여러 단계가 필요하며, 우리는 썽크에서 "로딩" 액션 디스패치나 정규화된 데이터 처리와 같은 작업을 위해 자체 로직을 마련해야 했습니다. 마지막으로, 사용자들은 종종 Redux 로직을 작성하는 "올바른 방법"이 무엇인지 확신하지 못합니다.

이러한 이유로 Redux 팀은 Redux Toolkit: 공식적이고 의견이 반영된 "배터리 포함" 효율적 Redux 개발 도구 세트를 만들었습니다.

Redux Toolkit에는 Redux 앱 구축에 필수적인 패키지와 함수들이 포함되어 있습니다. Redux Toolkit은 권장 모범 사례를 내장하고, 대부분의 Redux 작업을 단순화하며, 흔한 실수를 방지하고, Redux 애플리케이션 작성이 더 쉬워지도록 합니다.

이 때문에 Redux Toolkit이 Redux 애플리케이션 로직 작성의 표준 방식입니다. 본 튜토리얼에서 지금까지 작성한 "수동" Redux 로직은 실제 동작하는 코드이지만, Redux 로직을 수동으로 작성해서는 안 됩니다. 이 튜토리얼에서 해당 접근 방식을 다룬 이유는 Redux의 _작동 방식_을 이해하도록 하기 위함입니다. 그러나 실제 애플리케이션에서는 Redux Toolkit을 사용해 Redux 로직을 작성해야 합니다.

Redux Toolkit을 사용할 때 지금까지 다룬 모든 개념(액션, 리듀서, 스토어 설정, 액션 생성자, 썽크 등)은 여전히 유효하지만, Redux Toolkit은 해당 코드를 더 쉽게 작성할 수 있는 방법을 제공합니다.

Redux Toolkit은 오직 Redux 로직만 다룹니다 - React 컴포넌트가 Redux 스토어와 통신하도록 하려면 여전히 React-Redux의 useSelectoruseDispatch를 사용해야 합니다.

이제 예제 할일 애플리케이션에서 이미 작성한 코드를 Redux Toolkit으로 어떻게 간소화할 수 있는지 살펴보겠습니다. 주로 "슬라이스" 파일을 재작성하지만 모든 UI 코드는 동일하게 유지할 수 있습니다.

계속하기 전에 Redux Toolkit 패키지를 애플리케이션에 추가하세요:

npm install @reduxjs/toolkit

스토어 설정

Redux 스토어 설정 로직은 몇 차례 변경되었습니다. 현재는 다음과 같습니다:

src/rootReducer.js
import { combineReducers } from 'redux'

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

const rootReducer = combineReducers({
// Define a top-level state field named `todos`, handled by `todosReducer`
todos: todosReducer,
filters: filtersReducer
})

export default rootReducer
src/store.js
import { createStore, applyMiddleware } from 'redux'
import { thunk } from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'

const composedEnhancer = composeWithDevTools(applyMiddleware(thunk))

const store = createStore(rootReducer, composedEnhancer)
export default store

설정 과정에 여러 단계가 필요합니다:

  • 슬라이스 리듀서를 결합하여 루트 리듀서 생성

  • 스토어 파일에 루트 리듀서 임포트

  • 썽크 미들웨어, applyMiddleware, composeWithDevTools API 임포트

  • 미들웨어와 개발자 도구로 스토어 인핸서 생성

  • 루트 리듀서로 스토어 생성

이 단계 수를 줄일 수 있다면 좋겠습니다.

configureStore 사용하기

Redux Toolkit은 스토어 설정 과정을 간소화하는 configureStore API를 제공합니다. configureStore는 Redux 코어의 createStore API를 감싸고 대부분의 스토어 설정을 자동으로 처리합니다. 실제로 단 한 단계로 축약할 수 있습니다:

src/store.js
import { configureStore } from '@reduxjs/toolkit'

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

const store = configureStore({
reducer: {
// Define a top-level state field named `todos`, handled by `todosReducer`
todos: todosReducer,
filters: filtersReducer
}
})

export default store

이 한 번의 configureStore 호출로 모든 작업이 처리됩니다:

  • todosReducerfiltersReducer를 결합하여 {todos, filters} 형태의 루트 상태를 처리하는 루트 리듀서 생성

  • 해당 루트 리듀서로 Redux 스토어 생성

  • thunk 미들웨어 자동 추가

  • 실수로 상태를 변이시키는 것과 같은 일반적인 오류를 검사하는 추가 미들웨어 자동 추가

  • Redux 개발자 도구 확장 연결 자동 설정

예제 할일 애플리케이션을 열어 사용해보면 정상 작동함을 확인할 수 있습니다! 기존의 모든 기능 코드가 잘 작동합니다! 액션 디스패치, 썽크 디스패치, UI에서 상태 읽기, 개발자 도구에서 액션 기록 확인 등 모든 부분이 제대로 작동해야 합니다. 스토어 설정 코드만 교체했을 뿐입니다.

이제 실수로 상태 일부를 변이시킨다면 어떻게 되는지 살펴보겠습니다. "todos loading" 리듀서를 불변 사본을 만드는 대신 상태 필드를 직접 변경하도록 수정하면 어떻게 될까요?

src/features/todos/todosSlice
export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
// ❌ WARNING: example only - don't do this in a normal reducer!
state.status = 'loading'
return state
}
default:
return state
}
}

이런. 전체 애플리케이션이 중단됐습니다! 무슨 일이 일어난 걸까요?

불변성 검사 미들웨어 오류

이 오류 메시지는 _긍정적_입니다 - 애플리케이션 버그를 발견한 것이죠! configureStore는 개발 모드에서 실수로 상태가 변이될 때마다 자동으로 오류를 발생시키는 추가 미들웨어를 포함시킵니다. 이는 코드 작성 중 발생할 수 있는 실수를 잡는 데 도움이 됩니다.

패키지 정리

Redux Toolkit에는 이미 redux, redux-thunk, reselect 등 우리가 사용 중인 여러 패키지가 포함되어 있으며 해당 API를 재익스포트합니다. 따라서 프로젝트를 좀 더 정리할 수 있습니다.

먼저 createSelector 임포트를 'reselect' 대신 '@reduxjs/toolkit'에서 가져오도록 변경할 수 있습니다. 그런 다음 package.json에 별도로 나열된 패키지를 제거할 수 있습니다:

npm uninstall redux redux-thunk reselect

명확히 하자면, 이 패키지들은 여전히 사용되며 설치가 필요합니다. 하지만 Redux Toolkit이 이들에 의존하므로 @reduxjs/toolkit 설치 시 자동으로 함께 설치됩니다. 따라서 package.json 파일에 별도로 나열할 필요가 없습니다.

슬라이스 작성하기

앱에 기능을 추가하면서 슬라이스 파일은 점점 더 커지고 복잡해졌습니다. 특히 todosReducer는 불변 업데이트를 위한 중첩 객체 전개 연산자로 인해 가독성이 떨어졌으며, 여러 액션 생성자 함수를 작성해야 했습니다.

Redux Toolkit은 createSlice API를 제공하여 Redux 리듀서 로직과 액션을 단순화합니다. createSlice는 다음과 같은 중요한 기능을 수행합니다:

  • switch/case 문 대신 객체 내부에 케이스 리듀서를 함수 형태로 작성 가능

  • 더 짧은 불변 업데이트 로직 작성 가능

  • 제공된 리듀서 함수 기반으로 모든 액션 생성자 자동 생성

createSlice 사용하기

createSlice는 세 가지 주요 옵션 필드로 구성된 객체를 받습니다:

  • name: 생성된 액션 타입의 접두사로 사용될 문자열

  • initialState: 리듀서의 초기 상태

  • reducers: 키는 문자열, 값은 특정 액션을 처리할 "케이스 리듀서" 함수인 객체

먼저 독립적인 작은 예시를 살펴보겠습니다.

createSlice example
import { createSlice } from '@reduxjs/toolkit'

const initialState = {
entities: [],
status: null
}

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
// ✅ This "mutating" code is okay inside of createSlice!
state.entities.push(action.payload)
},
todoToggled(state, action) {
const todo = state.entities.find(todo => todo.id === action.payload)
todo.completed = !todo.completed
},
todosLoading(state, action) {
return {
...state,
status: 'loading'
}
}
}
})

export const { todoAdded, todoToggled, todosLoading } = todosSlice.actions

export default todosSlice.reducer

이 예시에서 주목할 점은 다음과 같습니다:

  • reducers 객체 내부에 가독성 높은 이름의 케이스 리듀서 함수 작성

  • createSlice가 제공한 각 케이스 리듀서 함수에 해당하는 액션 생성자 자동 생성

  • createSlice는 기본 케이스에서 기존 상태를 자동으로 반환

  • createSlice를 사용하면 상태를 안전하게 "변이"할 수 있습니다!

  • 필요 시 기존 방식처럼 불변 사본 생성도 가능

생성된 액션 생성자는 slice.actions.todoAdded 형태로 사용 가능하며, 일반적으로 이전에 작성한 액션 생성자처럼 개별적으로 구조 분해하여 내보냅니다. 전체 리듀서 함수는 slice.reducer로 사용 가능하며, 이전과 마찬가지로 export default slice.reducer로 내보냅니다.

자동 생성된 액션 객체는 어떤 모습일까요? 하나를 호출하고 액션을 기록해 보겠습니다:

console.log(todoToggled(42))
// {type: 'todos/todoToggled', payload: 42}

createSlice는 슬라이스의 name 필드와 작성한 리듀서 함수 이름 todoToggled를 결합해 액션 타입 문자열을 생성했습니다. 기본적으로 액션 생성자는 단일 인수를 받아 action.payload로 액션 객체에 넣습니다.

생성된 리듀서 함수 내부에서 createSlice는 디스패치된 액션의 action.type이 생성된 이름 중 하나와 일치하는지 확인합니다. 일치하면 해당 케이스 리듀서 함수를 실행합니다. 이는 switch/case 문으로 직접 작성한 패턴과 동일하지만 createSlice가 자동으로 처리합니다.

"변이" 측면에 대해 더 자세히 살펴볼 가치가 있습니다.

Immer를 이용한 불변 업데이트

앞서 "변이(mutation)"(기존 객체/배열 값 수정)와 "불변성(immutability)"(값을 변경할 수 없는 것으로 취급)에 대해 이야기했습니다.

경고

Redux에서 리듀서는 원본/현재 상태 값을 절대 변경해서는 안 됩니다!

// ❌ Illegal - by default, this will mutate the state!
state.value = 123

그렇다면 원본을 변경할 수 없다면 업데이트된 상태를 어떻게 반환할까요?

리듀서는 원본 값을 _복사_한 후 복사본을 변이할 수 있습니다.

// This is safe, because we made a copy
return {
...state,
value: 123
}

이번 튜토리얼 전체에서 확인했듯, JavaScript의 배열/객체 전개 연산자와 원본 값의 사본을 반환하는 함수를 사용해 불변 업데이트를 수동으로 작성할 수 있습니다. 하지만 불변 업데이트 로직을 수동으로 작성하는 것은 어렵습니다, 그리고 리듀서에서 실수로 상태를 변이하는 것은 Redux 사용자가 가장 자주 저지르는 실수입니다.

바로 이 때문에 Redux Toolkit의 createSlice 함수가 불변 업데이트를 더 쉽게 작성할 수 있게 해줍니다!

createSlice는 내부에서 Immer 라이브러리를 사용합니다. Immer는 Proxy라는 특별한 JS 도구로 데이터를 감싼 후, 감싼 데이터를 "변이"하는 코드 작성을 허용합니다. 하지만 Immer는 변경 시도한 모든 내용을 추적한 후, 그 변경 목록을 사용해 마치 수동으로 작성한 것처럼 안전하게 불변 업데이트된 값을 반환합니다.

따라서 이런 코드 대신:

function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}

이렇게 작성할 수 있습니다:

function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue
}

훨씬 읽기 쉽죠!

하지만 반드시 기억해야 할 점이 있습니다:

경고

Redux Toolkit의 createSlicecreateReducer 내부에서만 "변이" 로직을 작성할 수 있습니다. 이들은 내부적으로 Immer를 사용하기 때문입니다! Immer 없이 리듀서에서 변이 로직을 작성하면 상태가 실제로 변이되어 버그가 발생합니다!

Immer는 원한다면 여전히 수동으로 불변 업데이트를 작성하고 새 값을 직접 반환할 수 있게 합니다. 두 방식을 혼합해서 사용할 수도 있습니다. 예를 들어 배열에서 항목을 제거할 때는 array.filter()를 사용하는 것이 더 쉬운 경우가 많으므로, 이를 호출한 후 결과를 state에 할당하여 "변이"시킬 수 있습니다.

// can mix "mutating" and "immutable" code inside of Immer:
state.todos = state.todos.filter(todo => todo.id !== action.payload)

Todos 리듀서 변환하기

이제 todos 슬라이스 파일을 createSlice를 사용하도록 변환해 보겠습니다. 먼저 switch 문에서 몇 가지 특정 케이스를 선택하여 과정이 어떻게 진행되는지 보여드리겠습니다.

src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit'

const initialState = {
status: 'idle',
entities: {}
}

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
const todo = action.payload
state.entities[todo.id] = todo
},
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
}
}
})

export const { todoAdded, todoToggled } = todosSlice.actions

export default todosSlice.reducer

예시 앱의 todos 리듀서는 여전히 부모 객체에 중첩된 정규화된 상태를 사용하고 있으므로, 여기서의 코드는 방금 살펴본 소규모 createSlice 예시와는 조금 다릅니다. 이전에 todo를 토글하기 위해 중첩된 스프레드 연산자를 많이 작성해야 했던 것을 기억하시나요? 이제 동일한 코드가 훨씬 짧아지고 읽기 쉬워졌습니다.

이 리듀서에 몇 가지 케이스를 더 추가해 보겠습니다.

src/features/todos/todosSlice.js
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
const todo = action.payload
state.entities[todo.id] = todo
},
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
},
todoColorSelected: {
reducer(state, action) {
const { color, todoId } = action.payload
state.entities[todoId].color = color
},
prepare(todoId, color) {
return {
payload: { todoId, color }
}
}
},
todoDeleted(state, action) {
delete state.entities[action.payload]
}
}
})

export const { todoAdded, todoToggled, todoColorSelected, todoDeleted } =
todosSlice.actions

export default todosSlice.reducer

todoAddedtodoToggled에 대한 액션 생성자는 전체 todo 객체나 todo ID 같은 단일 매개변수만 필요로 합니다. 하지만, 여러 매개변수를 전달해야 하거나 고유 ID 생성과 같은 "준비" 로직을 수행해야 한다면 어떻게 해야 할까요?

createSlice는 리듀서에 "prepare 콜백"을 추가하여 이러한 상황을 처리할 수 있게 합니다. reducerprepare 함수를 가진 객체를 전달할 수 있습니다. 생성된 액션 생성자를 호출하면 prepare 함수가 전달된 매개변수와 함께 호출됩니다. 그러면 이 함수는 Flux 표준 액션 규약에 부합하는 payload 필드(또는 선택적으로 metaerror 필드)를 가진 객체를 생성하고 반환해야 합니다.

여기서는 prepare 콜백을 사용해 todoColorSelected 액션 생성자가 todoIdcolor 인수를 별도로 받아서 action.payload에 객체로 합치도록 했습니다.

한편 todoDeleted 리듀서에서는 JS delete 연산자를 사용해 정규화된 상태에서 항목을 제거할 수 있습니다.

동일한 패턴을 사용해 todosSlice.jsfiltersSlice.js의 나머지 리듀서들을 재작성할 수 있습니다.

모든 슬라이스를 변환한 후의 코드는 다음과 같습니다:

Thunk 작성하기

로딩", "요청 성공", "요청 실패" 액션을 디스패치하는 thunk 작성 방법을 살펴봤습니다. 이러한 경우를 처리하기 위해 액션 생성자, 액션 타입, 리듀서를 모두 작성해야 했습니다.

이 패턴은 매우 흔하기 때문에, Redux Toolkit에는 이러한 thunk를 대신 생성해주는 createAsyncThunk API가 있습니다. 또한 이 API는 다양한 요청 상태 액션에 대한 액션 타입과 액션 생성자를 생성하고, 결과 Promise에 따라 해당 액션을 자동으로 디스패치합니다.

Redux Toolkit에는 새로운 RTK Query 데이터 가져오기 API가 있습니다. RTK Query는 Redux 앱을 위해 특별히 제작된 데이터 가져오기 및 캐싱 솔루션으로, 데이터 가져오기를 관리하기 위해 thunk나 리듀서를 전혀 작성할 필요가 없게 해줍니다. 여러분의 앱에서 데이터 가져오기 코드를 단순화하는 데 도움이 되는지 확인해 보시기를 권합니다!

Redux 튜토리얼을 곧 업데이트하여 RTK Query 사용법을 포함시킬 예정입니다. 그때까지는 Redux Toolkit 문서의 RTK Query 섹션을 참조하세요.

createAsyncThunk 사용하기

이제 createAsyncThunk로 thunk를 생성하여 fetchTodos thunk를 대체해 보겠습니다.

createAsyncThunk는 두 가지 인수를 받습니다:

  • 생성된 액션 타입의 접두사로 사용될 문자열

  • Promise를 반환해야 하는 "페이로드 생성자(payload creator)" 콜백 함수. async 함수는 자동으로 프로미스를 반환하므로 async/await 구문으로 작성하는 경우가 많습니다.

src/features/todos/todosSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

// omit imports and state

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit reducer cases
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
}
})

// omit exports

'todos/fetchTodos'를 문자열 접두사로 전달하고, API를 호출해 가져온 데이터를 담은 프로미스를 반환하는 "페이로드 생성자" 함수를 전달합니다. 내부적으로 createAsyncThunk는 세 가지 액션 생성자와 액션 타입, 호출 시 해당 액션들을 자동으로 디스패치하는 thunk 함수를 생성합니다. 이 경우 액션 생성자와 타입은 다음과 같습니다:

  • fetchTodos.pending: todos/fetchTodos/pending

  • fetchTodos.fulfilled: todos/fetchTodos/fulfilled

  • fetchTodos.rejected: todos/fetchTodos/rejected

그러나 이러한 액션 생성자와 타입은 createSlice 호출 _외부_에서 정의됩니다. createSlice.reducers 필드 내부에서는 처리할 수 없습니다. 해당 필드도 새로운 액션 타입을 생성하기 때문입니다. 따라서 createSlice 호출이 다른 곳에서 정의된 다른 액션 타입을 수신할 방법이 필요합니다.

createSliceextraReducers 옵션도 지원합니다. 동일한 슬라이스 리듀서가 다른 액션 타입을 수신할 수 있게 해줍니다. 이 필드는 builder 매개변수를 가진 콜백 함수여야 하며, builder.addCase(actionCreator, caseReducer) 호출로 다른 액션들을 수신할 수 있습니다.

여기서는 builder.addCase(fetchTodos.pending, caseReducer)를 호출했습니다. 해당 액션이 디스패치되면, 스위치 문으로 작성했을 때와 마찬가지로 state.status = 'loading'을 설정하는 리듀서가 실행됩니다. fetchTodos.fulfilled에 대해서도 동일하게 처리하고 API에서 받은 데이터를 처리할 수 있습니다.

다른 예시로 saveNewTodo를 변환해 보겠습니다. 이 thunk는 새 todo 객체의 text를 매개변수로 받아 서버에 저장합니다. 이를 어떻게 처리할까요?

src/features/todos/todosSlice.js
// omit imports

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})

export const saveNewTodo = createAsyncThunk('todos/saveNewTodo', async text => {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
return response.todo
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit case reducers
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
.addCase(saveNewTodo.fulfilled, (state, action) => {
const todo = action.payload
state.entities[todo.id] = todo
})
}
})

// omit exports and selectors

saveNewTodo 처리 과정은 fetchTodos와 동일합니다. createAsyncThunk를 호출하고 액션 접두사와 페이로드 생성자를 전달합니다. 페이로드 생성자 내부에서 비동기 API 호출을 수행하고 결과 값을 반환합니다.

이 경우 dispatch(saveNewTodo(text))를 호출하면 text 값이 페이로드 생성자의 첫 번째 인수로 전달됩니다.

여기서 createAsyncThunk를 더 자세히 다루지는 않겠지만, 참고할 몇 가지 사항은 다음과 같습니다:

  • thunk를 디스패치할 때는 하나의 인수만 전달할 수 있습니다. 여러 값을 전달해야 한다면 단일 객체로 묶어야 합니다

  • 페이로드 생성자는 두 번째 인수로 {getState, dispatch} 및 기타 유용한 값들을 포함하는 객체를 받습니다

  • thunk는 페이로드 생성자 실행 전에 pending 액션을 디스패치한 뒤, 반환된 Promise의 성공 여부에 따라 fulfilled 또는 rejected를 디스패치합니다

상태 정규화(Normalizing State)

이전에는 항목 ID를 키로 사용해 객체에 항목을 보관함으로써 상태를 "정규화(normalize)"하는 방법을 살펴보았습니다. 이렇게 하면 전체 배열을 순회하지 않고도 ID로 항목을 조회할 수 있습니다. 그러나 정규화된 상태 업데이트 로직을 수동으로 작성하는 것은 길고 지루했습니다. Immer로 "변이(mutating)" 업데이트 코드를 작성하면 간소화되지만, 여전히 많은 반복이 발생할 수 있습니다. 앱에서 여러 유형의 항목을 로드할 때마다 동일한 리듀서 로직을 반복해야 하기 때문입니다.

Redux Toolkit에는 정규화된 상태에 대한 일반적인 데이터 업데이트 작업을 위한 사전 구축된 리듀서가 포함된 createEntityAdapter API가 있습니다. 슬라이스에서 항목 추가, 업데이트, 제거 등이 포함됩니다. createEntityAdapter는 스토어에서 값을 읽기 위한 메모이즈된 셀렉터도 생성합니다.

createEntityAdapter 사용하기

정규화된 엔티티 리듀서 로직을 createEntityAdapter로 교체해 보겠습니다.

createEntityAdapter를 호출하면 다음과 같은 여러 사전 구축된 리듀서 함수를 포함하는 "어댑터(adapter)" 객체가 제공됩니다:

  • addOne / addMany: 상태에 새 항목 추가

  • upsertOne / upsertMany: 새 항목 추가 또는 기존 항목 업데이트

  • updateOne / updateMany: 부분 값 제공으로 기존 항목 업데이트

  • removeOne / removeMany: ID 기준으로 항목 제거

  • setAll: 기존 항목 전체 교체

이 함수들을 케이스 리듀서로 사용하거나 createSlice 내부에서 "변이 헬퍼"로 활용할 수 있습니다.

어댑터에는 다음 기능도 포함됩니다:

  • getInitialState: 모든 항목 ID 배열과 함께 정규화된 항목 상태를 저장하기 위해 { ids: [], entities: {} } 형태의 객체 반환

  • getSelectors: 표준 셀렉터 함수 세트 생성

todos 슬라이스에서 이를 어떻게 사용하는지 살펴봅시다:

src/features/todos/todosSlice.js
import {
createSlice,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
// omit some imports

const todosAdapter = createEntityAdapter()

const initialState = todosAdapter.getInitialState({
status: 'idle'
})

// omit thunks

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit some reducers
// Use an adapter reducer function to remove a todo by ID
todoDeleted: todosAdapter.removeOne,
completedTodosCleared(state, action) {
const completedIds = Object.values(state.entities)
.filter(todo => todo.completed)
.map(todo => todo.id)
// Use an adapter function as a "mutating" update helper
todosAdapter.removeMany(state, completedIds)
}
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
// Use another adapter function as a reducer to add a todo
.addCase(saveNewTodo.fulfilled, todosAdapter.addOne)
}
})

// omit selectors

각기 다른 어댑터 리듀서 함수는 기능에 따라 다른 값을 action.payload로 받습니다. "add"와 "upsert" 함수는 단일 항목이나 항목 배열을, "remove" 함수는 단일 ID나 ID 배열을 받는 식입니다.

getInitialState는 포함될 추가 상태 필드를 전달할 수 있게 합니다. 이 경우 status 필드를 추가해 최종 todos 슬라이스 상태를 {ids, entities, status}로 구성하며, 이전과 유사합니다.

todos 셀렉터 함수 일부도 교체할 수 있습니다. getSelectors 어댑터 함수는 모든 항목 배열을 반환하는 selectAll, 단일 항목을 반환하는 selectById 같은 셀렉터를 생성합니다. 하지만 getSelectors는 전체 Redux 상태 트리에서 데이터 위치를 알지 못하므로, 전체 상태에서 이 슬라이스를 반환하는 작은 셀렉터를 전달해야 합니다. 이제 이들을 사용하도록 전환합시다. 이는 코드의 마지막 주요 변경이므로, Redux Toolkit을 사용한 코드 최종 버전을 확인하기 위해 전체 todos 슬라이스 파일을 포함하겠습니다:

src/features/todos/todosSlice.js
import {
createSlice,
createSelector,
createAsyncThunk,
createEntityAdapter
} from '@reduxjs/toolkit'
import { client } from '../../api/client'
import { StatusFilters } from '../filters/filtersSlice'

const todosAdapter = createEntityAdapter()

const initialState = todosAdapter.getInitialState({
status: 'idle'
})

// Thunk functions
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})

export const saveNewTodo = createAsyncThunk('todos/saveNewTodo', async text => {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
return response.todo
})

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoToggled(state, action) {
const todoId = action.payload
const todo = state.entities[todoId]
todo.completed = !todo.completed
},
todoColorSelected: {
reducer(state, action) {
const { color, todoId } = action.payload
state.entities[todoId].color = color
},
prepare(todoId, color) {
return {
payload: { todoId, color }
}
}
},
todoDeleted: todosAdapter.removeOne,
allTodosCompleted(state, action) {
Object.values(state.entities).forEach(todo => {
todo.completed = true
})
},
completedTodosCleared(state, action) {
const completedIds = Object.values(state.entities)
.filter(todo => todo.completed)
.map(todo => todo.id)
todosAdapter.removeMany(state, completedIds)
}
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
.addCase(saveNewTodo.fulfilled, todosAdapter.addOne)
}
})

export const {
allTodosCompleted,
completedTodosCleared,
todoAdded,
todoColorSelected,
todoDeleted,
todoToggled
} = todosSlice.actions

export default todosSlice.reducer

export const { selectAll: selectTodos, selectById: selectTodoById } =
todosAdapter.getSelectors(state => state.todos)

export const selectTodoIds = createSelector(
// First, pass one or more "input selector" functions:
selectTodos,
// Then, an "output selector" that receives all the input results as arguments
// and returns a final result value
todos => todos.map(todo => todo.id)
)

export const selectFilteredTodos = createSelector(
// First input selector: all todos
selectTodos,
// Second input selector: all filter values
state => state.filters,
// Output selector: receives both values
(todos, filters) => {
const { status, colors } = filters
const showAllCompletions = status === StatusFilters.All
if (showAllCompletions && colors.length === 0) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => {
const statusMatches =
showAllCompletions || todo.completed === completedStatus
const colorMatches = colors.length === 0 || colors.includes(todo.color)
return statusMatches && colorMatches
})
}
)

export const selectFilteredTodoIds = createSelector(
// Pass our other memoized selector as an input
selectFilteredTodos,
// And derive data in the output selector
filteredTodos => filteredTodos.map(todo => todo.id)
)

todosAdapter.getSelectors를 호출하고 상태의 이 슬라이스를 반환하는 state => state.todos 셀렉터를 전달합니다. 어댑터는 평소처럼 전체 Redux 상태 트리를 받아 state.todos.entitiesstate.todos.ids를 순회하며 todo 객체의 완전한 배열을 제공하는 selectAll 셀렉터를 생성합니다. selectAll이 선택 대상을 명시하지 않으므로 구조 분해 구문으로 함수명을 selectTodos로 변경할 수 있습니다. 마찬가지로 selectByIdselectTodoById로 변경합니다.

다른 셀렉터들이 여전히 selectTodos를 입력으로 사용함을 주목하세요. 이는 상태를 전체 state.todos 배열로 유지하든, 중첩 배열로 유지하든, 정규화된 객체로 저장하든 항상 todo 객체 배열을 반환하기 때문입니다. 데이터 저장 방식을 변경했음에도 셀렉터 사용으로 코드 나머지 부분을 동일하게 유지할 수 있었으며, 메모이즈드 셀렉터가 불필요한 리렌더링을 방지해 UI 성능을 개선했습니다.

학습 내용 요약

축하합니다! 'Redux Fundamentals' 튜토리얼을 완료하셨습니다!

이제 Redux의 개념, 작동 방식, 올바른 사용법을 확실히 이해하셨을 것입니다:

  • 전역 앱 상태 관리

  • 앱 상태를 일반 JS 데이터로 유지

  • 앱에서 발생한 "사건"을 설명하는 액션 객체 작성

  • 현재 상태와 액션을 확인하고 불변 방식으로 새 상태 생성하는 리듀서 사용

  • React 컴포넌트에서 useSelector로 Redux 상태 읽기

  • React 컴포넌트에서 useDispatch로 액션 디스패치

또한 여러분은 Redux Toolkit이 Redux 로직 작성을 어떻게 간소화하는지 확인했으며, Redux Toolkit이 실제 Redux 애플리케이션 작성의 표준 접근 방식인 이유를 이해했습니다. 먼저 Redux 코드를 "수동으로" 작성해 본 경험을 통해 createSlice 같은 Redux Toolkit API가 대신 처리해 주는 작업이 명확해졌으므로, 더 이상 해당 코드를 직접 작성할 필요가 없습니다.

정보

Redux Toolkit에 대한 자세한 내용(사용 가이드 및 API 참조 포함)은 다음을 참조하세요:

Redux Toolkit을 사용하도록 변환된 모든 코드를 포함한 최종 완성된 할 일 애플리케이션을 마지막으로 살펴보겠습니다:

이번 섹션에서 배운 핵심 사항을 최종적으로 요약해 보겠습니다:

요약
  • Redux Toolkit(RTK)은 Redux 로직 작성을 위한 표준 방식입니다
    • RTK에는 대부분의 Redux 코드를 간소화하는 API가 포함됨
    • RTK는 Redux 코어를 감싸며 다른 유용한 패키지를 포함함
  • configureStore은 적절한 기본값으로 Redux 스토어를 설정합니다
    • 슬라이스 리듀서를 자동으로 결합해 루트 리듀서 생성
    • Redux DevTools 확장 프로그램 및 디버깅 미들웨어 자동 설정
  • createSlice는 Redux 액션과 리듀서 작성을 간소화합니다
    • 슬라이스/리듀서 이름 기반으로 액션 생성자 자동 생성
    • Immer를 사용해 createSlice 내부에서 상태 "변이" 가능
  • createAsyncThunk는 비동기 호출을 위한 썽크를 생성합니다
    • 썽크 + pending/fulfilled/rejected 액션 생성자 자동 생성
    • 썽크 디스패치 시 페이로드 생성자 실행 및 액션 디스패치
    • createSlice.extraReducers에서 썽크 액션 처리 가능
  • createEntityAdapter는 정규화된 상태를 위한 리듀서 + 셀렉터 제공
    • 항목 추가/업데이트/제거 같은 일반 작업용 리듀서 함수 포함
    • selectAllselectById용 메모이즈드 셀렉터 생성

Redux 학습 및 사용을 위한 다음 단계

이제 튜토리얼을 완료하셨으니, Redux에 대해 더 깊이 이해하기 위해 다음에 시도해 볼 만한 몇 가지 제안을 드리겠습니다.

이 "기본(Fundamentals)" 튜토리얼은 Redux의 저수준 측면에 집중했습니다: 액션 타입 수동 작성, 불변 업데이트, Redux 스토어 및 미들웨어 작동 방식, 액션 생성자 및 정규화된 상태 같은 패턴 사용 이유 등입니다. 또한 할 일 예제 애플리케이션은 상당히 작으며, 실제 앱 구축의 현실적인 예제로 의도되진 않았습니다.

그러나 "Redux 핵심(Redux Essentials)" 튜토리얼"현실 세계"형 애플리케이션 구축 방법을 특별히 다룹니다. 이 튜토리얼은 Redux Toolkit을 사용한 "올바른 Redux 사용법"에 초점을 맞추며, 대규모 앱에서 볼 수 있는 보다 현실적인 패턴을 논의합니다. 이 튜토리얼은 리듀서가 왜 불변 업데이트를 사용해야 하는지 같은 주제를 "기본" 튜토리얼과 공유하지만, 실제 작동하는 애플리케이션 구축에 중점을 둡니다. 다음 단계로 "Redux 핵심" 튜토리얼을 꼭 읽어보시길 강력히 권장합니다.

동시에, 이 튜토리얼에서 다룬 개념들은 여러분이 React와 Redux를 사용해 자신만의 애플리케이션 구축을 시작하기에 충분할 것입니다. 지금은 개념을 공고히 하고 실제 작동 방식을 확인하기 위해 프로젝트 작업을 시도해 볼 좋은 시기입니다. 어떤 프로젝트를 구축할지 잘 모르겠다면 앱 프로젝트 아이디어 목록에서 영감을 얻으세요.

Redux 사용하기(Using Redux) 섹션에는 리듀서 구조화 방법 같은 여러 중요한 개념과 스타일 가이드 페이지에는 권장 패턴 및 모범 사례에 관한 중요한 정보가 있습니다.

Redux가 존재하는지, 어떤 문제를 해결하려는지, 어떻게 사용해야 하는지에 대해 더 알고 싶다면 Redux 관리자 Mark Erikson의 포스트인 The Tao of Redux, Part 1: Implementation and IntentThe Tao of Redux, Part 2: Practice and Philosophy를 참고하세요.

Redux 관련 질문에 대한 도움이 필요하다면 Discord의 Reactiflux 서버에 있는 #redux 채널에 참여하세요.

이 튜토리얼을 읽어주셔서 감사합니다. Redux로 애플리케이션을 구축하는 즐거운 시간이 되시길 바랍니다!