Redux 핵심 개념, 파트 7: 표준 Redux 패턴
이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
- 실제 Redux 앱에서 사용하는 표준 패턴과 해당 패턴이 존재하는 이유:
- 액션 객체 캡슐화를 위한 액션 생성자
- 성능 향상을 위한 메모이제이션된 셀렉터
- 로딩 열거형을 통한 요청 상태 추적
- 항목 컬렉션 관리를 위한 상태 정규화
- 프로미스와 썽크 작업
- 이전 모든 섹션의 주제 이해
파트 6: 비동기 로직과 데이터 불러오기에서는 Redux 미들웨어를 사용해 스토어와 통신하는 비동기 로직을 작성하는 방법을 살펴보았습니다. 특히 Redux "썽크" 미들웨어를 사용해 재사용 가능한 비동기 로직을 포함하는 함수를 작성했으며, 이 함수들은 미리 어떤 Redux 스토어와 통신할지 알 필요가 없었습니다.
지금까지 Redux가 실제로 작동하는 기본 원리를 다뤘습니다. 그러나 실제 환경의 Redux 애플리케이션은 이러한 기본 원리 위에 추가 패턴들을 사용합니다.
이러한 패턴들은 Redux 사용에 반드시 필요한 것은 아닙니다! 하지만 각 패턴이 존재하는 데는 타당한 이유가 있으며, 거의 모든 Redux 코드베이스에서 이 패턴들 일부 또는 전체를 볼 수 있습니다.
이번 섹션에서는 기존 할 일 앱 코드를 수정하여 이러한 패턴들 일부를 적용해보고, Redux 앱에서 흔히 사용되는 이유를 설명하겠습니다. 이후 파트 8에서는 "현대적인 Redux"에 대해 다룰 것입니다. 여기에는 공식 Redux Toolkit 패키지를 사용해 앱에서 "수동으로" 작성한 모든 Redux 로직을 단순화하는 방법과 Redux 앱 작성 시 표준 접근법으로 Redux Toolkit 사용을 권장하는 이유가 포함됩니다.
이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
참고: 이 튜토리얼은 Redux의 원리와 개념을 설명하기 위해 의도적으로 오래된 스타일의 Redux 로직 패턴을 보여줍니다. 이 패턴은 오늘날 우리가 Redux 앱 구축을 위한 올바른 접근법으로 가르치는 '현대적인 Redux(modern Redux)' 패턴(Redux Toolkit 사용)보다 더 많은 코드가 필요합니다. 이 튜토리얼은 프로덕션 환경에서 바로 사용할 수 있는 프로젝트가 아닙니다.
'현대적인 Redux'와 Redux Toolkit 사용법을 배우려면 다음 페이지를 참조하세요:
- 전체 "Redux Essentials" 튜토리얼: 실제 애플리케이션을 위한 Redux Toolkit을 사용한 "올바른 Redux 사용법"을 가르칩니다. 모든 Redux 학습자는 'Essentials' 튜토리얼을 필독할 것을 권장합니다!
- Redux Fundamentals, Part 8: Redux Toolkit을 사용한 현대적인 Redux: 이전 섹션의 저수준 예제를 현대적인 Redux Toolkit 방식으로 변환하는 방법을 보여줍니다
액션 생성자
현재 우리 앱에서는 디스패치되는 위치에서 직접 액션 객체를 작성하고 있습니다:
dispatch({ type: 'todos/todoAdded', payload: trimmedText })
그러나 실제로 잘 작성된 Redux 앱들은 디스패치할 때 이러한 액션 객체를 인라인으로 직접 작성하지 않습니다. 대신 "액션 생성자" 함수를 사용합니다.
액션 생성자는 액션 객체를 생성하고 반환하는 함수입니다. 매번 액션 객체를 직접 작성하지 않도록 주로 사용합니다:
const todoAdded = text => {
return {
type: 'todos/todoAdded',
payload: text
}
}
이를 사용하는 방법은 액션 생성자를 호출한 다음 결과로 생성된 액션 객체를 직접 dispatch에 전달하는 것입니다:
store.dispatch(todoAdded('Buy milk'))
console.log(store.getState().todos)
// [ {id: 0, text: 'Buy milk', completed: false}]
Detailed Explanation: Why use Action Creators?
In our small example todo app, writing action objects by hand every time isn't too difficult. In fact, by switching to using action creators, we've added more work - now we have to write a function and the action object.
But, what if we needed to dispatch the same action from many parts of the application? Or what if there's some additional logic that we have to do every time we dispatch an action, like creating a unique ID? We'd end up having to copy-paste the additional setup logic every time we need to dispatch that action.
Action creators have two primary purposes:
- They prepare and format the contents of action objects
- They encapsulate any additional work needed whenever we create those actions
That way, we have a consistent approach for creating actions, whether or not there's any extra work that needs to be done. The same goes for thunks as well.
액션 생성자 사용하기
할 일 슬라이스 파일을 업데이트하여 몇 가지 액션 유형에 대해 액션 생성자를 사용해 보겠습니다.
지금까지 사용해온 두 가지 주요 액션부터 시작하겠습니다: 서버에서 할 일 목록을 불러오는 것과 새 할 일을 서버에 저장한 후 추가하는 것입니다.
현재 todosSlice.js는 다음과 같이 액션 객체를 직접 디스패치하고 있습니다:
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
동일한 유형의 액션 객체를 생성하고 반환하지만, 할 일 배열을 인자로 받아 액션의 action.payload에 넣는 함수를 만들겠습니다. 그런 다음 fetchTodos 썽크 내부에서 이 새로운 액션 생성자를 사용해 액션을 디스패치할 수 있습니다:
export const todosLoaded = todos => {
return {
type: 'todos/todosLoaded',
payload: todos
}
}
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}
"할 일 추가됨" 액션에 대해서도 동일한 작업을 할 수 있습니다:
export const todoAdded = todo => {
return {
type: 'todos/todoAdded',
payload: todo
}
}
export function saveNewTodo(text) {
return async function saveNewTodoThunk(dispatch, getState) {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch(todoAdded(response.todo))
}
}
추가로 "색상 필터 변경됨" 액션에 대해서도 동일한 작업을 해보겠습니다:
export const colorFilterChanged = (color, changeType) => {
return {
type: 'filters/colorFilterChanged',
payload: { color, changeType }
}
}
이 액션은 <Footer> 컴포넌트에서 디스패치되고 있었으므로, colorFilterChanged 액션 생성자를 해당 컴포넌트로 가져와 사용해야 합니다:
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { availableColors, capitalize } from '../filters/colors'
import { StatusFilters, colorFilterChanged } from '../filters/filtersSlice'
// omit child components
const Footer = () => {
const dispatch = useDispatch()
const todosRemaining = useSelector(state => {
const uncompletedTodos = state.todos.filter(todo => !todo.completed)
return uncompletedTodos.length
})
const { status, colors } = useSelector(state => state.filters)
const onMarkCompletedClicked = () => dispatch({ type: 'todos/allCompleted' })
const onClearCompletedClicked = () =>
dispatch({ type: 'todos/completedCleared' })
const onColorChange = (color, changeType) =>
dispatch(colorFilterChanged(color, changeType))
const onStatusChange = status =>
dispatch({ type: 'filters/statusFilterChanged', payload: status })
// omit rendering output
}
export default Footer
colorFilterChanged 액션 생성자가 실제로 두 가지 다른 인자를 받은 다음 이를 결합해 올바른 action.payload 필드를 구성한다는 점에 유의하세요.
이는 애플리케이션 작동 방식이나 Redux 데이터 흐름 동작에 아무런 변화를 주지 않습니다. 여전히 액션 객체를 생성하고 디스패치하고 있기 때문입니다. 하지만 코드에서 매번 액션 객체를 직접 작성하는 대신, 이제 디스패치 전에 액션 객체를 준비하기 위해 액션 생성자를 사용하고 있습니다.
또한 액션 생성자를 썽크(thunk) 함수와 함께 사용할 수 있으며, 실제로 이전 섹션에서 썽크를 액션 생성자로 래핑한 사례가 있습니다. text 매개변수를 전달하기 위해 saveNewTodo를 "썽크 액션 생성자" 함수로 감싼 것이 그 예시입니다. fetchTodos는 매개변수를 받지 않지만, 마찬가지로 액션 생성자로 래핑할 수 있습니다:
export function fetchTodos() {
return async function fetchTodosThunk(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}
}
이는 index.js에서 디스패치 방식을 변경해야 함을 의미합니다. 외부 썽크 액션 생성자 함수를 호출한 후 반환된 내부 썽크 함수를 dispatch에 전달해야 합니다:
import store from './store'
import { fetchTodos } from './features/todos/todosSlice'
store.dispatch(fetchTodos())
지금까지는 썽크를 function 키워드로 작성하여 기능을 명확히 표현했습니다. 그러나 화살표 함수 문법을 사용하여 작성할 수도 있습니다. 암시적 반환(implicit return)을 사용하면 코드가 짧아지지만, 화살표 함수에 익숙하지 않다면 가독성이 떨어질 수 있습니다:
// Same thing as the above example!
export const fetchTodos = () => async dispatch => {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}
마찬가지로 일반 액션 생성자도 원한다면 단축할 수 있습니다:
export const todoAdded = todo => ({ type: 'todos/todoAdded', payload: todo })
이런 방식으로 화살표 함수를 사용하는 것이 더 나은지는 개발자의 판단에 달려 있습니다.
액션 생성자 사용 이유에 대한 자세한 내용은 다음을 참조하세요:
메모이제이션된 셀렉터
이미 Redux state 객체를 인자로 받아 값을 반환하는 "셀렉터" 함수를 작성할 수 있음을 확인했습니다:
const selectTodos = state => state.todos
만약 데이터를 파생해야 한다면 어떻게 해야 할까요? 예를 들어, 할 일 항목의 ID만 포함된 배열이 필요할 수 있습니다:
const selectTodoIds = state => state.todos.map(todo => todo.id)
그러나 array.map()은 항상 새로운 배열 참조를 반환합니다. React-Redux의 useSelector 훅은 모든 디스패치된 액션 이후에 셀렉터 함수를 재실행하며, 셀렉터 결과가 변경되면 컴포넌트를 강제로 리렌더링한다는 점을 알고 있습니다.
이 예시에서 useSelector(selectTodoIds)를 호출하면 모든 액션 이후에 컴포넌트가 항상 리렌더링됩니다. 새로운 배열 참조를 반환하기 때문입니다!
5부에서는 useSelector에 shallowEqual을 인자로 전달할 수 있음을 확인했습니다. 하지만 여기서는 또 다른 옵션이 있습니다: "메모이제이션된(memoized)" 셀렉터를 사용하는 것입니다.
메모이제이션(Memoization) 은 일종의 캐싱입니다. 구체적으로는 비용이 큰 계산의 결과를 저장하고, 동일한 입력이 나중에 다시 나타나면 해당 결과를 재사용하는 것을 의미합니다.
메모이제이션된 셀렉터 함수는 가장 최근 결과 값을 저장하며, 동일한 입력으로 여러 번 호출되면 동일한 결과 값을 반환합니다. 이전과 다른 입력으로 호출되면 새로운 결과 값을 다시 계산하여 캐시한 후 반환합니다.
createSelector로 셀렉터 메모이제이션하기
Reselect 라이브러리는 메모이제이션된 셀렉터 함수를 생성하는 createSelector API를 제공합니다. createSelector는 하나 이상의 "입력 셀렉터" 함수와 "출력 셀렉터"를 인자로 받아 새로운 셀렉터 함수를 반환합니다. 셀렉터를 호출할 때마다:
-
모든 인자를 사용해 "입력 셀렉터"가 호출됩니다
-
입력 셀렉터 반환 값이 변경되면 "출력 셀렉터"가 재실행됩니다
-
모든 입력 셀렉터 결과가 출력 셀렉터의 인자가 됩니다
-
출력 셀렉터의 최종 결과가 다음 호출을 위해 캐시됩니다
이제 selectTodoIds의 메모이제이션된 버전을 생성하고 <TodoList>에서 사용해 보겠습니다.
먼저 Reselect를 설치해야 합니다:
npm install reselect
그런 다음 createSelector를 가져와 호출할 수 있습니다. 원래 selectTodoIds 함수는 TodoList.js에 정의되어 있었지만, 셀렉터 함수는 관련 슬라이스 파일에 작성하는 것이 더 일반적입니다. 따라서 todos 슬라이스에 추가해 보겠습니다:
import { createSelector } from 'reselect'
// omit reducer
// omit action creators
export const selectTodoIds = createSelector(
// First, pass one or more "input selector" functions:
state => state.todos,
// Then, an "output selector" that receives all the input results as arguments
// and returns a final result value
todos => todos.map(todo => todo.id)
)
그런 다음 <TodoList>에서 사용해 보겠습니다:
import React from 'react'
import { useSelector, shallowEqual } from 'react-redux'
import { selectTodoIds } from './todosSlice'
import TodoListItem from './TodoListItem'
const TodoList = () => {
const todoIds = useSelector(selectTodoIds)
const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})
return <ul className="todo-list">{renderedListItems}</ul>
}
이는 실제로 shallowEqual 비교 함수와 약간 다르게 동작합니다. state.todos 배열이 변경될 때마다 새로운 todo ID 배열을 생성하게 됩니다. 여기에는 completed 필드를 토글하는 것과 같은 todo 항목에 대한 불변 업데이트도 포함됩니다. 왜냐하면 불변 업데이트를 위해 새 배열을 생성해야 하기 때문입니다.
메모이제이션된 셀렉터는 원본 데이터에서 추가 값을 실제로 파생할 때만 유용합니다. 기존 값을 단순히 조회하고 반환하는 경우에는 셀렉터를 일반 함수로 유지할 수 있습니다.
다중 인자를 가진 셀렉터
우리의 todo 앱은 완료 상태에 따라 보여지는 todo를 필터링하는 기능을 가지고 있어야 합니다. 필터링된 todo 목록을 반환하는 메모이제이션된 셀렉터를 작성해 봅시다.
출력 셀렉터의 인자로 전체 todos 배열이 필요하다는 것을 알고 있습니다. 또한 현재 완료 상태 필터 값도 전달해야 합니다. 각 값을 추출하기 위해 별도의 "입력 셀렉터"를 추가하고, 그 결과를 "출력 셀렉터"에 전달할 것입니다.
import { createSelector } from 'reselect'
import { StatusFilters } from '../filters/filtersSlice'
// omit other code
export const selectFilteredTodos = createSelector(
// First input selector: all todos
state => state.todos,
// Second input selector: current status filter
state => state.filters.status,
// Output selector: receives both values
(todos, status) => {
if (status === StatusFilters.All) {
return todos
}
const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => todo.completed === completedStatus)
}
)
이제 두 슬라이스 간에 가져오기 종속성이 추가되었습니다. todosSlice가 filtersSlice에서 값을 가져오고 있습니다. 이는 허용되지만 주의하세요. 두 슬라이스가 서로에게서 무언가를 가져오려고 하면 코드가 충돌할 수 있는 "순환 가져오기 종속성" 문제가 발생할 수 있습니다. 그런 경우 공통 코드를 별도의 파일로 이동하고 그 파일에서 가져오도록 시도해 보세요.
이제 이 새로운 "필터링된 todos" 셀렉터를 해당 todo들의 ID를 반환하는 또 다른 셀렉터의 입력으로 사용할 수 있습니다:
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)
)
<TodoList>가 selectFilteredTodoIds를 사용하도록 전환하면, 몇 개의 todo 항목을 완료된 것으로 표시할 수 있어야 합니다:

그런 다음 목록을 필터링하여 오직 완료된 todo만 표시합니다:

그런 다음 selectFilteredTodos를 확장하여 선택 시 색상 필터링도 포함시킬 수 있습니다:
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
})
}
)
이 셀렉터에 로직을 캡슐화함으로써, 필터링 동작을 변경하더라도 컴포넌트는 전혀 변경할 필요가 없습니다. 이제 상태와 색상을 동시에 필터링할 수 있습니다:

마지막으로, 코드에서 state.todos를 조회하는 부분이 여러 곳에 있습니다. 이번 섹션의 나머지 부분을 진행하면서 해당 상태의 설계 방식을 변경할 예정이므로, 단일 selectTodos 셀렉터를 추출하여 모든 곳에서 사용하겠습니다. 또한 selectTodoById를 todosSlice로 옮길 수 있습니다:
export const selectTodos = state => state.todos
export const selectTodoById = (state, todoId) => {
return selectTodos(state).find(todo => todo.id === todoId)
}
셀렉터 함수 사용 이유와 Reselect로 메모이제이션된 셀렉터 작성 방법에 대한 자세한 내용은 다음을 참조하세요:
비동기 요청 상태
초기 todo 목록을 서버에서 가져오기 위해 비동기 썽크를 사용하고 있습니다. 가짜 서버 API를 사용하고 있기 때문에 응답이 즉시 돌아옵니다. 실제 앱에서는 API 호출이 해결되는 데 시간이 걸릴 수 있습니다. 그런 경우 응답이 완료될 때까지 로딩 스피너 같은 것을 표시하는 것이 일반적입니다.
이는 일반적으로 Redux 앱에서 다음과 같이 처리됩니다:
-
요청의 현재 상태를 나타내는 "로딩 상태" 값을 가지기
-
API 호출을 하기 전에 "요청 시작" 액션을 디스패치하고, 이는 로딩 상태 값을 변경하여 처리됩니다
-
요청이 완료되면 호출이 완료되었음을 나타내기 위해 로딩 상태 값을 다시 업데이트하기
UI 레이어는 요청이 진행 중일 때 로딩 스피너를 표시하고, 요청이 완료되면 실제 데이터로 전환됩니다.
이제 todos 슬라이스를 업데이트하여 로딩 상태 값을 추적하고, fetchTodos 썽크의 일부로 추가적인 'todos/todosLoading' 액션을 디스패치할 예정입니다.
현재 todos 리듀서의 state는 할 일 목록 배열 그 자체입니다. todos 슬라이스 내부에서 로딩 상태를 추적하려면, todos 상태를 할 일 목록 배열 및 로딩 상태 값을 가진 객체로 재구성해야 합니다. 이는 또한 추가적인 중첩을 처리하기 위해 리듀서 로직을 다시 작성해야 함을 의미합니다:
const initialState = {
status: 'idle',
entities: []
}
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
entities: [...state.entities, action.payload]
}
}
case 'todos/todoToggled': {
return {
...state,
entities: state.entities.map(todo => {
if (todo.id !== action.payload) {
return todo
}
return {
...todo,
completed: !todo.completed
}
})
}
}
// omit other cases
default:
return state
}
}
// omit action creators
export const selectTodos = state => state.todos.entities
여기서 주목해야 할 몇 가지 중요한 사항이 있습니다:
-
할 일 목록 배열은 이제
todosReducer상태 객체 내에서state.entities로 중첩됩니다. "entities"라는 용어는 "ID를 가진 고유 항목"을 의미하며, 이는 우리의 할 일 객체를 설명합니다. -
이는 또한 배열이 전체 Redux 상태 객체 내에서
state.todos.entities로 중첩됨을 의미합니다 -
이제 올바른 불변성 업데이트를 위해 추가적인 중첩 수준을 복사하는 단계를 리듀서에서 수행해야 합니다. 예를 들어
state객체 →entities배열 →todo객체와 같은 방식입니다 -
나머지 코드는 셀렉터를 통해서만 todos 상태에 접근하기 때문에,
selectTodos셀렉터만 업데이트하면 됩니다. 상태 구조를 상당히 변경했음에도 나머지 UI는 예상대로 계속 작동할 것입니다.
로딩 상태 Enum 값
또한 로딩 상태 필드를 문자열 enum으로 정의했음을 알 수 있습니다:
{
status: 'idle' // or: 'loading', 'succeeded', 'failed'
}
isLoading 불리언 대신에요.
불리언은 '로딩 중' 또는 '로딩되지 않음' 두 가지 가능성으로 제한됩니다. 실제로 요청은 다양한 상태에 있을 수 있습니다, 예를 들어:
-
전혀 시작되지 않음
-
진행 중
-
성공
-
실패
-
성공했지만, 이제 다시 가져오기를 원할 수 있는 상황
또한 앱 로직은 특정 액션에 기반하여 특정 상태 사이에서만 전환되어야 할 수 있으며, 이는 불리언을 사용하여 구현하기 어렵습니다.
이 때문에, 로딩 상태를 불리언 플래그 대신 문자열 enum 값으로 저장하는 것을 권장합니다.
로딩 상태를 enum으로 사용해야 하는 이유에 대한 자세한 설명은 다음을 참조하세요:
이를 바탕으로, 상태를 'loading'으로 설정할 새로운 'loading' 액션을 추가하고, 'loaded' 액션을 업데이트하여 상태 플래그를 'idle'로 재설정할 것입니다:
const initialState = {
status: 'idle',
entities: []
}
export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
return {
...state,
status: 'idle',
entities: action.payload
}
}
default:
return state
}
}
// omit action creators
// Thunk function
export const fetchTodos = () => async dispatch => {
dispatch(todosLoading())
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}
하지만 UI에 이를 표시해보기 전에, 가짜 서버 API를 수정하여 API 호출에 인위적인 지연을 추가해야 합니다. src/api/server.js를 열고 63번째 줄 주변에 있는 이 주석 처리된 줄을 찾으세요:
new Server({
routes() {
this.namespace = 'fakeApi'
// this.timing = 2000
// omit other code
}
})
해당 줄의 주석을 해제하면, 가짜 서버가 앱의 모든 API 호출에 2초 지연을 추가하여 로딩 스피너가 표시되는 것을 실제로 확인할 수 있을 만큼의 시간을 제공합니다.
이제 <TodoList> 컴포넌트에서 로딩 상태 값을 읽고, 그 값을 기반으로 로딩 스피너를 대신 표시할 수 있습니다.
// omit imports
const TodoList = () => {
const todoIds = useSelector(selectFilteredTodoIds)
const loadingStatus = useSelector(state => state.todos.status)
if (loadingStatus === 'loading') {
return (
<div className="todo-list">
<div className="loader" />
</div>
)
}
const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})
return <ul className="todo-list">{renderedListItems}</ul>
}
실제 앱에서는 API 실패 오류 및 기타 잠재적인 경우도 처리해야 합니다.
로딩 상태가 활성화된 앱의 모습은 다음과 같습니다(스피너를 다시 보려면 앱 미리보기를 새로 고침하거나 새 탭에서 열어보세요):
Flux 표준 액션
Redux 스토어 자체는 실제로 액션 객체에 어떤 필드를 넣는지 신경 쓰지 않습니다. action.type이 존재하고 문자열인지만 중요합니다. 즉, 원하는 어떤 다른 필드도 액션에 넣을 수 있습니다. "todo 추가" 액션에 action.todo를 넣거나 action.color를 넣는 식으로 말이죠.
그러나 각 액션이 데이터 필드마다 다른 이름을 사용하면 리듀서에서 처리할 필드를 미리 알기 어려울 수 있습니다.
이런 이유로 Redux 커뮤니티는 Flux Standard Actions(플럭스 표준 액션) 관례, 줄여서 "FSA"를 제안했습니다. 이는 개발자가 항상 어떤 필드에 어떤 종류의 데이터가 들어있는지 알 수 있도록 액션 객체 내부의 필드를 구성하는 방법에 대한 제안입니다. FSA 패턴은 Redux 커뮤니티에서 널리 사용되며, 사실 여러분은 이번 튜토리얼 내내 이미 이 패턴을 사용해왔습니다.
FSA 관례는 다음과 같습니다:
-
액션 객체에 실제 데이터가 있다면, 해당 데이터 값은 항상
action.payload에 위치해야 합니다 -
액션은 추가 설명 데이터를 위한
action.meta필드를 가질 수 있습니다 -
액션은 오류 정보를 위한
action.error필드를 가질 수 있습니다
따라서 모든 Redux 액션은 반드시:
-
일반 JavaScript 객체여야 합니다
-
type필드를 가져야 합니다
그리고 FSA 패턴을 사용해 액션을 작성한다면, 액션은 다음을 가질 수 있습니다:
-
payload필드 -
error필드 -
meta필드
Detailed Explanation: FSAs and Errors
The FSA specification says that:
The optional
errorproperty MAY be set totrueif the action represents an error. An action whoseerroris true is analogous to a rejected Promise. By convention, thepayloadSHOULD be an error object. Iferrorhas any other value besidestrue, includingundefinedandnull, the action MUST NOT be interpreted as an error.
The FSA specs also argue against having specific action types for things like "loading succeeded" and "loading failed".
However, in practice, the Redux community has ignored the idea of using action.error as a boolean flag, and instead settled on separate action types, like 'todos/todosLoadingSucceeded' and 'todos/todosLoadingFailed'. This is because it's much easier to check for those action types than it is to first handle 'todos/todosLoaded' and then check if (action.error).
You can do whichever approach works better for you, but most apps use separate action types for success and failure.
정규화된 상태(Normalized State)
지금까지 우리는 할 일 항목들을 배열로 유지해왔습니다. 이는 서버에서 데이터를 배열로 받았고, UI에서 목록으로 표시하기 위해 할 일 항목들을 반복 처리해야 하기 때문에 타당한 접근입니다.
그러나 더 큰 규모의 Redux 앱에서는 데이터를 정규화된 상태 구조로 저장하는 것이 일반적입니다. "정규화"는 다음을 의미합니다:
-
각 데이터 조각의 복사본이 하나만 존재하도록 보장
-
ID로 항목을 직접 찾을 수 있도록 항목 저장
-
전체 항목을 복사하지 않고 ID를 기반으로 다른 항목 참조
예를 들어 블로깅 애플리케이션에서는 User와 Comment 객체를 가리키는 Post 객체가 있을 수 있습니다. 같은 사람이 작성한 여러 포스트가 있을 수 있으므로, 모든 Post 객체에 전체 User 객체가 포함된다면 동일한 User 객체의 여러 복사본이 생기게 됩니다. 대신 Post 객체는 post.user로 사용자 ID 값을 가지며, state.users[post.user]처럼 ID로 User 객체를 조회할 수 있습니다.
이는 일반적으로 배열 대신 객체로 데이터를 구성한다는 의미이며, 항목 ID가 키이고 항목 자체가 값이 됩니다. 다음과 같은 형태입니다:
const rootState = {
todos: {
status: 'idle',
entities: {
2: { id: 2, text: 'Buy milk', completed: false },
7: { id: 7, text: 'Clean room', completed: true }
}
}
}
이제 할 일 슬라이스를 정규화된 형태로 저장하도록 변환해 보겠습니다. 이는 리듀서 로직의 상당한 변경과 셀렉터 업데이트가 필요합니다:
const initialState = {
status: 'idle',
entities: {}
}
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
const todo = action.payload
return {
...state,
entities: {
...state.entities,
[todo.id]: todo
}
}
}
case 'todos/todoToggled': {
const todoId = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
completed: !todo.completed
}
}
}
}
case 'todos/colorSelected': {
const { color, todoId } = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
color
}
}
}
}
case 'todos/todoDeleted': {
const newEntities = { ...state.entities }
delete newEntities[action.payload]
return {
...state,
entities: newEntities
}
}
case 'todos/allCompleted': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
newEntities[todo.id] = {
...todo,
completed: true
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/completedCleared': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
if (todo.completed) {
delete newEntities[todo.id]
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
return {
...state,
status: 'idle',
entities: newEntities
}
}
default:
return state
}
}
// omit action creators
const selectTodoEntities = state => state.todos.entities
export const selectTodos = createSelector(selectTodoEntities, entities =>
Object.values(entities)
)
export const selectTodoById = (state, todoId) => {
return selectTodoEntities(state)[todoId]
}
이제 state.entities 필드가 배열 대신 객체가 되었으므로, 배열 연산 대신 중첩된 객체 스프레드 연산자를 사용해 데이터를 업데이트해야 합니다. 또한 객체를 배열처럼 반복 처리할 수 없으므로, Object.values(entities)를 사용해 할 일 항목의 배열을 가져와 반복 처리해야 하는 부분이 몇 군데 있습니다.
좋은 소식은 상태 조회를 캡슐화하기 위해 셀렉터를 사용하고 있으므로 UI는 여전히 변경할 필요가 없다는 점입니다. 나쁜 소식은 리듀서 코드가 실제로 더 길고 복잡해졌다는 것입니다.
여기서 문제의 일부는 이 할 일 앱 예제가 대규모 실제 애플리케이션이 아니라는 점입니다. 따라서 정규화된 상태는 이 특정 앱에서 그다지 유용하지 않으며, 잠재적 이점을 확인하기도 더 어렵습니다.
다행히도 8부: Redux Toolkit을 사용한 모던 리덕스에서 정규화된 상태 관리를 위한 리듀서 로직을 획기적으로 단축하는 방법을 살펴볼 것입니다.
지금은 다음 사항들을 이해하는 것이 중요합니다:
-
정규화는 리덕스 앱에서 흔히 사용됩니다
-
주요 이점은 ID로 개별 항목을 조회할 수 있으며 상태 내에 항목의 단일 복사본만 존재하도록 보장할 수 있다는 점입니다
리덕스에서 정규화가 유용한 이유에 대한 자세한 내용은 다음을 참조하세요:
썽크(thunk)와 프로미스(promise)
이번 섹션에서 다룰 마지막 패턴입니다. 디스패치된 액션을 기반으로 리덕스 스토어에서 로딩 상태를 처리하는 방법은 이미 살펴보았습니다. 컴포넌트에서 썽크의 결과를 확인해야 한다면 어떻게 해야 할까요?
store.dispatch(action)를 호출할 때마다 dispatch는 실제로 action을 결과로 반환합니다. 미들웨어는 이 동작을 수정하고 다른 값을 반환할 수 있습니다.
이미 리덕스 썽크 미들웨어가 dispatch에 함수를 전달하고, 해당 함수를 호출한 다음 결과를 반환하는 것을 확인했습니다:
const reduxThunkMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
// Also, return whatever the thunk function returns
return action(storeAPI.dispatch, storeAPI.getState)
}
// Otherwise, it's a normal action - send it onwards
return next(action)
}
이는 프로미스를 반환하는 썽크 함수를 작성하고 컴포넌트에서 해당 프로미스를 기다릴 수 있음을 의미합니다.
이미 <Header> 컴포넌트에서 새로운 할 일을 서버에 저장하기 위해 썽크를 디스패치하고 있습니다. <Header> 컴포넌트 내부에 로딩 상태를 추가하고, 서버 응답을 기다리는 동안 텍스트 입력을 비활성화하고 또 다른 로딩 스피너를 표시해 보겠습니다:
const Header = () => {
const [text, setText] = useState('')
const [status, setStatus] = useState('idle')
const dispatch = useDispatch()
const handleChange = e => setText(e.target.value)
const handleKeyDown = async e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create and dispatch the thunk function itself
setStatus('loading')
// Wait for the promise returned by saveNewTodo
await dispatch(saveNewTodo(trimmedText))
// And clear out the text input
setText('')
setStatus('idle')
}
}
let isLoading = status === 'loading'
let placeholder = isLoading ? '' : 'What needs to be done?'
let loader = isLoading ? <div className="loader" /> : null
return (
<header className="header">
<input
className="new-todo"
placeholder={placeholder}
autoFocus={true}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
{loader}
</header>
)
}
export default Header
이제 할 일을 추가하면 헤더에 스피너가 표시됩니다:

학습 내용 요약
지금까지 살펴본 것처럼, 리덕스 앱에서 널리 사용되는 몇 가지 추가 패턴이 있습니다. 이러한 패턴은 필수는 아니며 초기에 더 많은 코드를 작성해야 할 수 있지만, 로직 재사용성, 구현 세부사항 캡슐화, 앱 성능 향상, 데이터 조회 용이성 등의 이점을 제공합니다.
이러한 패턴이 존재하는 이유와 리덕스 사용 방식에 대한 자세한 내용은 다음을 참조하세요:
다음은 이러한 패턴을 완전히 적용한 후의 앱 모습입니다:
- 액션 생성자 함수는 액션 객체와 썽크 준비를 캡슐화합니다
- 액션 생성자는 인수를 받고 설정 로직을 포함할 수 있으며 최종 액션 객체나 썽크 함수를 반환합니다
- 메모이제이션된 셀렉터는 리덕스 앱 성능 향상에 도움을 줍니다
- Reselect는 메모이제이션된 셀렉터를 생성하는
createSelectorAPI를 제공합니다 - 메모이제이션된 셀렉터는 동일한 입력이 주어지면 동일한 결과 참조를 반환합니다
- Reselect는 메모이제이션된 셀렉터를 생성하는
- 요청 상태는 불리언이 아닌 열거형(enum)으로 저장해야 합니다
'idle'및'loading'과 같은 열거형을 사용하면 상태를 일관되게 추적할 수 있습니다
- "Flux 표준 액션"은 액션 객체 구성에 대한 일반적인 컨벤션입니다
- 액션은 데이터에
payload를, 추가 설명에meta를, 오류에error를 사용합니다
- 액션은 데이터에
- 정규화된 상태는 ID로 항목을 쉽게 찾을 수 있게 합니다
- 정규화된 데이터는 배열 대신 객체로 저장되며 항목 ID를 키로 사용합니다
- 썽크는
dispatch에서 프로미스를 반환할 수 있습니다- 컴포넌트는 비동기 썽크가 완료될 때까지 기다린 후 추가 작업을 수행할 수 있습니다
다음 단계
이 모든 코드를 "수동으로" 작성하는 것은 시간이 많이 소요되고 어려울 수 있습니다. 그렇기 때문에 공식 Redux Toolkit 패키지를 사용해 리덕스 로직을 작성할 것을 권장합니다.
Redux Toolkit에는 일반적인 Redux 사용 패턴을 더 적은 코드로 작성할 수 있게 도와주는 API가 포함되어 있습니다. 또한 우발적인 상태 변이와 같은 흔한 실수를 방지하는 데도 도움이 됩니다.
Part 8: Modern Redux에서는 지금까지 작성한 모든 코드를 단순화하기 위해 Redux Toolkit을 사용하는 방법을 다룰 것입니다.