Redux 핵심 개념, 파트 3: 상태, 액션, 리듀서
이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
- 앱 데이터를 포함하는 상태 값을 정의하는 방법
- 앱에서 발생하는 상황을 설명하는 액션 객체를 정의하는 방법
- 기존 상태와 액션을 기반으로 업데이트된 상태를 계산하는 리듀서 함수 작성 방법
- "액션", "리듀서", "스토어", "디스패칭" 등 주요 Redux 용어와 개념에 대한 이해 (이 용어들에 대한 설명은 파트 2: Redux 개념과 데이터 흐름 참조)
소개
파트 2: Redux 개념과 데이터 흐름에서는 Redux가 전역 앱 상태를 저장할 단일 중앙 위치를 제공함으로써 유지보수가 용이한 앱을 구축하는 데 어떻게 도움을 주는지 살펴보았습니다. 또한 새로운 상태 값을 반환하는 리듀서 함수를 사용하는 방법과 액션 객체를 디스패치하는 방법과 같은 핵심 Redux 개념에 대해서도 논의했습니다.
이제 이러한 구성 요소가 무엇인지 어느 정도 알게 되었으니, 이 지식을 실제로 적용해 볼 차례입니다. 이 구성 요소들이 실제로 어떻게 함께 작동하는지 확인하기 위해 작은 예제 앱을 구축해 보겠습니다.
이 페이지는 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 방식으로 변환하는 방법을 보여줍니다
프로젝트 설정
이 튜토리얼을 위해 React가 이미 설정되어 있고, 기본 스타일링이 포함되어 있으며, 앱에서 실제 API 요청을 작성할 수 있도록 가짜 REST API가 포함된 사전 구성된 스타터 프로젝트를 만들었습니다. 여러분은 실제 애플리케이션 코드 작성을 위한 기반으로 이 프로젝트를 사용하게 됩니다.
시작하려면 이 CodeSandbox를 열고 포크하세요:
또한 이 Github 저장소에서 동일한 프로젝트를 클론할 수도 있습니다. 저장소를 클론한 후 npm install로 프로젝트 도구를 설치하고 npm start로 실행할 수 있습니다.
구축할 내용의 최종 버전을 확인하고 싶다면 tutorial-steps 브랜치를 확인하거나 이 CodeSandbox에서 최종 버전을 살펴보세요.
새 Redux + React 프로젝트 생성하기
이 튜토리얼을 마친 후에는 자신만의 프로젝트 작업을 시도해 보고 싶을 것입니다. 새로운 Redux + React 프로젝트를 생성하는 가장 빠른 방법으로 Create-React-App용 Redux 템플릿 사용을 권장합니다. 이 템플릿에는 Redux Toolkit과 React-Redux가 이미 구성되어 있으며, 파트 1에서 본 "카운터" 앱 예제의 현대화된 버전을 사용합니다. 이를 통해 Redux 패키지를 추가하거나 스토어를 설정할 필요 없이 바로 실제 애플리케이션 코드 작성을 시작할 수 있습니다.
프로젝트에 Redux를 추가하는 구체적인 방법을 알고 싶다면 다음 설명을 참조하세요:
Detailed Explanation: Adding Redux to a React Project
The Redux template for CRA comes with Redux Toolkit and React-Redux already configured. If you're setting up a new project from scratch without that template, follow these steps:
- Add the
@reduxjs/toolkitandreact-reduxpackages - Create a Redux store using RTK's
configureStoreAPI, and pass in at least one reducer function - Import the Redux store into your application's entry point file (such as
src/index.js) - Wrap your root React component with the
<Provider>component from React-Redux, like:
root.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
초기 프로젝트 살펴보기
이 초기 프로젝트는 표준 Vite 프로젝트 템플릿을 기반으로 일부 수정되었습니다.
초기 프로젝트에 포함된 내용을 간단히 살펴보겠습니다:
/srcindex.js: 애플리케이션의 진입점 파일. 메인<App>컴포넌트를 렌더링합니다.App.js: 메인 애플리케이션 컴포넌트.index.css: 전체 애플리케이션 스타일/apiclient.js: HTTP GET 및 POST 요청을 가능하게 하는 작은fetch래퍼 클라이언트server.js: 데이터를 위한 가짜 REST API 제공. 나중에 앱에서 이 가짜 엔드포인트에서 데이터를 가져옵니다.
/exampleAddons: 작동 방식을 보여주기 위해 튜토리얼 후반에 사용할 추가 Redux 애드온 포함
지금 앱을 로드하면 환영 메시지가 표시되지만, 나머지 앱은 비어 있습니다.
이제 시작해 봅시다!
Todo 예제 앱 시작하기
예시 애플리케이션은 간단한 "할 일(todo)" 앱이 될 것입니다. 아마도 할 일 앱 예제를 본 적이 있을 텐데, 일반 애플리케이션에서 발생하는 항목 목록 추적, 사용자 입력 처리, 데이터 변경 시 UI 업데이트 등의 작업을 보여주기에 좋은 예시이기 때문입니다.
요구사항 정의
먼저 이 애플리케이션의 초기 비즈니스 요구사항을 정의해 보겠습니다:
-
UI는 세 가지 주요 섹션으로 구성됩니다:
- 사용자가 새 할 일 항목을 입력할 수 있는 입력창
- 기존 할 일 항목 목록
- 미완료 항목 수와 필터링 옵션을 표시하는 하단 섹션
-
할 일 목록 항목에는 "완료" 상태를 토글하는 체크박스가 있어야 합니다. 또한 미리 정의된 색상 목록에서 색상 범주 태그를 추가할 수 있어야 하며, 할 일 항목을 삭제할 수도 있어야 합니다.
-
카운터는 활성 항목 수를 복수형으로 표시해야 합니다: "0개 항목", "1개 항목", "3개 항목" 등
-
모든 할 일을 완료 상태로 표시하는 버튼과 완료된 항목을 제거하여 정리하는 버튼이 있어야 합니다
-
목록에 표시되는 할 일을 필터링하는 두 가지 방법이 있어야 합니다:
- "전체", "활성", "완료" 상태에 기반한 필터링
- 하나 이상의 색상을 선택하고 해당 색상과 일치하는 태그가 있는 할 일을 표시하는 필터링
나중에 몇 가지 요구사항을 더 추가할 예정이지만, 시작하기에는 이 정도면 충분합니다.
최종 목표는 다음과 같은 모습의 앱입니다:

상태 값 설계
React와 Redux의 핵심 원칙 중 하나는 UI가 상태를 기반으로 해야 한다는 점입니다. 따라서 애플리케이션을 설계할 때는 먼저 애플리케이션이 작동하는 방식을 설명하는 데 필요한 모든 상태를 고려하는 접근 방식이 좋습니다. 또한 상태 값을 최소한으로 유지하여 UI를 설명하는 것도 좋은 방법인데, 이렇게 하면 추적하고 업데이트해야 할 데이터 양이 줄어듭니다.
개념적으로 이 애플리케이션에는 두 가지 주요 측면이 있습니다:
-
현재 할 일 항목의 실제 목록
-
현재 필터링 옵션
사용자가 "할 일 추가" 입력창에 입력 중인 데이터도 추적해야 하지만, 이는 덜 중요하므로 나중에 처리하겠습니다.
각 할 일 항목마다 몇 가지 정보를 저장해야 합니다:
-
사용자가 입력한 텍스트
-
완료 여부를 나타내는 불리언 플래그
-
고유 ID 값
-
선택된 경우 색상 범주
필터링 동작은 몇 가지 열거형 값으로 설명할 수 있습니다:
-
완료 상태: "전체", "활성", "완료"
-
색상: "빨강", "노랑", "초록", "파랑", "주황", "보라"
이 값을 살펴보면 할 일은 "애플리케이션 상태"(앱이 작동하는 핵심 데이터)이고, 필터링 값은 "UI 상태"(현재 앱이 수행 중인 작업을 설명하는 상태)라고 할 수 있습니다. 상태의 다양한 사용 방식을 이해하는 데 도움이 되도록 이러한 다른 범주를 생각해보는 것이 유용할 수 있습니다.
상태 구조 설계
Redux에서는 애플리케이션 상태가 항상 일반 JavaScript 객체와 배열로 유지됩니다. 이는 클래스 인스턴스, Map/Set/Promise/Date 같은 내장 JS 타입, 함수 또는 일반 JS 데이터가 아닌 다른 것은 Redux 상태에 넣을 수 없다는 의미입니다.
루트 Redux 상태 값은 거의 항상 일반 JS 객체이며, 그 안에 다른 데이터가 중첩되어 있습니다.
이 정보를 바탕으로, 이제 Redux 상태 내부에 필요한 값의 종류를 설명할 수 있어야 합니다:
-
먼저 할 일 항목 객체의 배열이 필요합니다. 각 항목은 다음 필드를 가져야 합니다:
id: 고유한 숫자text: 사용자가 입력한 텍스트completed: 불리언 플래그color: 선택적 색상 카테고리
-
다음으로 필터링 옵션을 설명해야 합니다. 다음이 필요합니다:
- 현재 "완료됨" 필터 값
- 현재 선택된 색상 카테고리 배열
따라서 우리 앱 상태의 예시는 다음과 같을 수 있습니다:
const todoAppState = {
todos: [
{ id: 0, text: 'Learn React', completed: true },
{ id: 1, text: 'Learn Redux', completed: false, color: 'purple' },
{ id: 2, text: 'Build something fun!', completed: false, color: 'blue' }
],
filters: {
status: 'Active',
colors: ['red', 'blue']
}
}
Redux 외부에 다른 상태 값을 둘 수 있다는 점을 주목하는 것이 중요합니다! 이 예제는 지금까지 모든 상태를 Redux 스토어에 두기에 충분히 작지만, 나중에 보게 되듯이 일부 데이터(예: "이 드롭다운이 열려 있는가?" 또는 "폼 입력의 현재 값")는 실제로 Redux에 보관할 필요가 없습니다.
액션 설계하기
액션은 type 필드를 가지는 일반 JavaScript 객체입니다. 앞서 언급했듯이 액션은 애플리케이션에서 발생한 사건을 설명하는 이벤트로 생각할 수 있습니다.
앱 요구사항에 기반해 상태 구조를 설계한 것과 동일한 방식으로, 발생하는 상황을 설명하는 액션 목록도 작성할 수 있어야 합니다:
-
사용자가 입력한 텍스트를 기반으로 새 할 일 항목 추가
-
할 일의 완료 상태 토글
-
할 일에 대한 색상 카테고리 선택
-
할 일 삭제
-
모든 할 일을 완료로 표시
-
완료된 모든 할 일 지우기
-
다른 "완료됨" 필터 값 선택
-
새 색상 필터 추가
-
색상 필터 제거
일반적으로 발생한 상황을 설명하는 데 필요한 추가 데이터는 action.payload 필드에 넣습니다. 이는 숫자, 문자열 또는 여러 필드를 가진 객체가 될 수 있습니다.
Redux 스토어는 action.type 필드의 실제 텍스트가 무엇인지 신경 쓰지 않습니다. 그러나 여러분의 코드는 업데이트가 필요한지 확인하기 위해 action.type을 살펴볼 것입니다. 또한 디버깅 시 앱에서 무슨 일이 일어나고 있는지 보기 위해 Redux DevTools 확장에서 액션 타입 문자열을 자주 볼 것입니다. 따라서 나중에 볼 때 이해하기 훨씬 쉬울 수 있도록, 읽기 쉽고 발생한 상황을 명확하게 설명하는 액션 타입을 선택하세요!
발생할 수 있는 사건 목록을 바탕으로 애플리케이션에서 사용할 액션 목록을 만들 수 있습니다:
-
{type: 'todos/todoAdded', payload: todoText} -
{type: 'todos/todoToggled', payload: todoId} -
{type: 'todos/colorSelected', payload: {todoId, color}} -
{type: 'todos/todoDeleted', payload: todoId} -
{type: 'todos/allCompleted'} -
{type: 'todos/completedCleared'} -
{type: 'filters/statusFilterChanged', payload: filterValue} -
{type: 'filters/colorFilterChanged', payload: {color, changeType}}
이 경우 액션은 주로 하나의 추가 데이터를 가지므로 이를 action.payload 필드에 직접 넣을 수 있습니다. 색상 필터 동작을 "추가됨"과 "제거됨" 두 개의 액션으로 나눌 수도 있었지만, 여기서는 액션 페이로드로 객체를 가질 수 있음을 보여주기 위해 내부에 추가 필드가 있는 하나의 액션으로 처리하겠습니다.
상태 데이터와 마찬가지로 액션은 발생한 사건을 설명하는 데 필요한 최소한의 정보만 포함해야 합니다.
리듀서 작성하기
이제 상태 구조와 액션이 어떻게 생겼는지 알았으니, 첫 번째 리듀서를 작성해 볼 시간입니다.
리듀서는 현재 state와 action을 인자로 받아 새로운 state 결과를 반환하는 함수입니다. 즉, **(state, action) => newState**입니다.
루트 리듀서 생성하기
Redux 앱에는 실제로 하나의 리듀서 함수만 존재합니다: 바로 '루트 리듀서' 함수입니다. 이 함수는 나중에 createStore에 전달하게 됩니다. 이 하나의 루트 리듀서 함수는 디스패치된 모든 액션을 처리하고, 매번 전체 새로운 상태 결과가 무엇이어야 할지 계산하는 책임이 있습니다.
src 폴더 안에 index.js와 App.js 파일 옆에 reducer.js 파일을 생성하는 것부터 시작해 봅시다.
모든 리듀서는 초기 상태가 필요하므로, 시작하기 위해 몇 가지 가짜 할 일 항목을 추가하겠습니다. 그런 다음 리듀서 함수 내부의 로직을 위한 개요를 작성할 수 있습니다.
const initialState = {
todos: [
{ id: 0, text: 'Learn React', completed: true },
{ id: 1, text: 'Learn Redux', completed: false, color: 'purple' },
{ id: 2, text: 'Build something fun!', completed: false, color: 'blue' }
],
filters: {
status: 'All',
colors: []
}
}
// Use the initialState as a default value
export default function appReducer(state = initialState, action) {
// The reducer normally looks at the action type field to decide what happens
switch (action.type) {
// Do something here based on the different types of actions
default:
// If this reducer doesn't recognize the action type, or doesn't
// care about this specific action, return the existing state unchanged
return state
}
}
애플리케이션이 초기화될 때 리듀서가 undefined를 상태 값으로 호출될 수 있습니다. 그럴 경우 나머지 리듀서 코드가 작업할 수 있도록 초기 상태 값을 제공해야 합니다. 리듀서는 일반적으로 기본 인자 구문을 사용하여 초기 상태를 제공합니다: (state = initialState, action).
다음으로 'todos/todoAdded' 액션을 처리하는 로직을 추가해 봅시다.
먼저 현재 액션의 타입이 해당 특정 문자열과 일치하는지 확인해야 합니다. 그런 다음 변경되지 않은 필드도 포함하여 상태의 모든 부분을 담은 새로운 객체를 반환해야 합니다.
function nextTodoId(todos) {
const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1)
return maxId + 1
}
// Use the initialState as a default value
export default function appReducer(state = initialState, action) {
// The reducer normally looks at the action type field to decide what happens
switch (action.type) {
// Do something here based on the different types of actions
case 'todos/todoAdded': {
// We need to return a new state object
return {
// that has all the existing state data
...state,
// but has a new array for the `todos` field
todos: [
// with all of the old todos
...state.todos,
// and the new todo object
{
// Use an auto-incrementing numeric ID for this example
id: nextTodoId(state.todos),
text: action.payload,
completed: false
}
]
}
}
default:
// If this reducer doesn't recognize the action type, or doesn't
// care about this specific action, return the existing state unchanged
return state
}
}
상태에 하나의 할 일 항목을 추가하는 데... 상당히 많은 작업이 필요합니다. 왜 이런 추가 작업이 필요할까요?
리듀서의 규칙
앞서 리듀서는 반드시 항상 몇 가지 특별한 규칙을 따라야 한다고 말씀드렸습니다:
-
오직
state와action인수를 기반으로 새 상태 값을 계산해야 합니다 -
기존
state를 수정할 수 없습니다. 대신 기존state를 복사하고 복사본에 변경 사항을 적용하는 _불변성 업데이트_를 수행해야 합니다 -
비동기 로직이나 다른 "사이드 이펙트"를 수행해서는 안 됩니다
'사이드 이펙트'는 함수에서 값을 반환하는 것 외부에서 관찰할 수 있는 상태나 동작의 변경을 의미합니다. 일반적인 사이드 이펙트의 종류는 다음과 같습니다:
- 콘솔에 값 기록하기
- 파일 저장하기
- 비동기 타이머 설정하기
- HTTP 요청 만들기
- 함수 외부에 존재하는 상태 수정하기, 또는 함수의 인자 변이하기
- 난수나 고유한 랜덤 ID 생성하기 (예:
Math.random()또는Date.now())
이 규칙을 따르는 함수는 리듀서 함수로 특별히 작성되지 않았더라도 '순수 함수' 로 알려져 있습니다.
그런데 왜 이런 규칙이 중요할까요? 몇 가지 이유가 있습니다:
-
Redux의 목표 중 하나는 코드를 예측 가능하게 만드는 것입니다. 함수의 출력이 입력 인수로만 계산될 때, 코드 작동 방식을 이해하고 테스트하기가 더 쉽습니다
-
반면에 함수가 외부 변수에 의존하거나 무작위로 동작한다면, 실행 시 어떤 일이 발생할지 절대 알 수 없습니다.
-
함수가 인수를 포함한 다른 값을 수정하면 애플리케이션이 예상치 못하게 동작할 수 있습니다. 이는 "상태를 업데이트했는데 UI가 제때 업데이트되지 않아요!"와 같은 흔한 버그 원인이 됩니다.
-
Redux DevTools의 일부 기능은 리듀서가 이 규칙을 올바르게 따를 때만 동작합니다
"불변 업데이트" 규칙은 특히 중요하므로 더 자세히 살펴보겠습니다.
리듀서와 불변 업데이트
앞서 "변이(mutation)"(기존 객체/배열 값 수정)와 "불변성(immutability)"(값을 변경할 수 없는 것으로 취급)에 대해 이야기했습니다.
Redux에서 리듀서는 원본/현재 상태 값을 절대 변경해서는 안 됩니다!
// ❌ Illegal - by default, this will mutate the state!
state.value = 123
Redux에서 상태를 변이하지 말아야 하는 이유는 다음과 같습니다:
-
UI가 최신 값을 제대로 표시하지 못하는 등 버그 발생
-
상태가 왜, 어떻게 업데이트되었는지 이해하기 어려워짐
-
테스트 작성이 어려워짐
-
시간 여행 디버깅 기능이 정상적으로 작동하지 않음
-
Redux의 의도된 설계 철학과 사용 패턴에 위배됨
그렇다면 원본을 변경할 수 없다면 업데이트된 상태를 어떻게 반환할까요?
리듀서는 원본 값의 복사본 을 만든 후, 그 복사본을 변이할 수 있습니다.
// ✅ This is safe, because we made a copy
return {
...state,
value: 123
}
이미 JavaScript의 배열/객체 전개 연산자나 원본 값의 복사본을 반환하는 다른 함수들을 사용하여 수동으로 불변성 업데이트를 작성할 수 있다는 것을 보았습니다.
데이터가 중첩되어 있을 때는 이 작업이 더 어려워집니다. 불변성 업데이트의 핵심 규칙은 업데이트해야 하는 중첩된 모든 수준의 복사본을 만들어야 한다는 것입니다.
하지만 '이런 식으로 수동으로 불변성 업데이트를 작성하는 것은 기억하기도 어렵고 제대로 하기도 어렵다'고 생각된다면... 맞습니다! :)
수동으로 불변성 업데이트 로직을 작성하는 것은 어렵습니다, 그리고 리듀서에서 실수로 상태를 변형하는 것은 Redux 사용자가 가장 흔히 저지르는 실수입니다.
실제 애플리케이션에서는 이렇게 복잡한 중첩된 불변성 업데이트를 수동으로 작성할 필요가 없습니다. 8부: Redux Toolkit을 사용한 현대적인 Redux에서는 리듀서에서 불변성 업데이트 로직 작성을 간소화하기 위해 Redux Toolkit을 사용하는 방법을 배우게 됩니다.
추가 액션 처리하기
이를 염두에 두고, 몇 가지 추가 사례에 대한 리듀서 로직을 작성해 보겠습니다. 먼저 할 일 항목의 ID를 기반으로 completed 필드를 토글하는 로직입니다:
export default function appReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
todos: [
...state.todos,
{
id: nextTodoId(state.todos),
text: action.payload,
completed: false
}
]
}
}
case 'todos/todoToggled': {
return {
// Again copy the entire state object
...state,
// This time, we need to make a copy of the old todos array
todos: state.todos.map(todo => {
// If this isn't the todo item we're looking for, leave it alone
if (todo.id !== action.payload) {
return todo
}
// We've found the todo that has to change. Return a copy:
return {
...todo,
// Flip the completed flag
completed: !todo.completed
}
})
}
}
default:
return state
}
}
그리고 할 일 상태에 집중해 왔으므로, '가시성 필터 변경' 액션을 처리하는 케이스도 추가해 보겠습니다:
export default function appReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
todos: [
...state.todos,
{
id: nextTodoId(state.todos),
text: action.payload,
completed: false
}
]
}
}
case 'todos/todoToggled': {
return {
...state,
todos: state.todos.map(todo => {
if (todo.id !== action.payload) {
return todo
}
return {
...todo,
completed: !todo.completed
}
})
}
}
case 'filters/statusFilterChanged': {
return {
// Copy the whole state
...state,
// Overwrite the filters value
filters: {
// copy the other filter fields
...state.filters,
// And replace the status field with the new value
status: action.payload
}
}
}
default:
return state
}
}
단 3개의 액션만 처리했지만, 이미 다소 길어지고 있습니다. 하나의 리듀서 함수에서 모든 액션을 처리하려고 하면 전체를 이해하기 어려워질 것입니다.
바로 이 때문에 리듀서는 일반적으로 여러 개의 작은 리듀서 함수로 분할됩니다 - 리듀서 로직을 이해하고 유지보수하기 쉽게 만들기 위해서입니다.
리듀서 분할하기
이와 관련해 Redux 리듀서는 일반적으로 업데이트하는 Redux 상태 영역을 기준으로 분할됩니다. 현재 할 일 앱 상태에는 state.todos와 state.filters라는 두 가지 최상위 영역이 있습니다. 따라서 큰 루트 리듀서 함수를 todosReducer와 filtersReducer라는 두 개의 작은 리듀서로 분할할 수 있습니다.
그렇다면 이렇게 분할된 리듀서 함수들은 어디에 위치시켜야 할까요?
Redux 앱의 폴더와 파일을 '기능(feature)'별로 구성하는 것을 권장합니다 - 애플리케이션의 특정 개념이나 영역과 관련된 코드입니다. 특정 기능을 위한 Redux 코드는 일반적으로 '슬라이스(slice)' 파일이라는 단일 파일로 작성되며, 여기에는 해당 앱 상태 부분에 대한 모든 리듀서 로직과 액션 관련 코드가 포함됩니다.
이 때문에 Redux 앱 상태의 특정 영역을 담당하는 리듀서를 '슬라이스 리듀서'라고 부릅니다. 일반적으로 일부 액션 객체는 특정 슬라이스 리듀서와 밀접하게 연관되어 있으며, 따라서 액션 타입 문자열은 해당 기능 이름(예: 'todos')으로 시작하고 발생한 이벤트(예: 'todoAdded')를 설명하는 형태로, 하나의 문자열('todos/todoAdded')로 결합되어야 합니다.
프로젝트에 features 폴더를 새로 생성하고, 그 안에 todos 폴더를 만듭니다. todosSlice.js라는 새 파일을 생성하고, 할 일 관련 초기 상태를 이 파일로 잘라내어 붙여넣겠습니다:
const initialState = [
{ id: 0, text: 'Learn React', completed: true },
{ id: 1, text: 'Learn Redux', completed: false, color: 'purple' },
{ id: 2, text: 'Build something fun!', completed: false, color: 'blue' }
]
function nextTodoId(todos) {
const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1)
return maxId + 1
}
export default function todosReducer(state = initialState, action) {
switch (action.type) {
default:
return state
}
}
이제 할일 업데이트 로직을 복사할 수 있습니다. 하지만 중요한 차이점이 있습니다. 이 파일은 할일 관련 상태만 업데이트하면 됩니다. 더 이상 중첩되지 않습니다! 이것이 리듀서를 분리하는 또 다른 이유입니다. 할일 상태는 자체적으로 배열이므로 외부 루트 상태 객체를 복사할 필요가 없습니다. 이로 인해 리듀서가 더 읽기 쉬워집니다.
이를 리듀서 컴포지션(reducer composition) 이라고 하며, Redux 앱을 구축하는 기본 패턴입니다.
다음은 해당 액션들을 처리한 후 업데이트된 리듀서 모습입니다:
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
// Can return just the new todos array - no extra object around it
return [
...state,
{
id: nextTodoId(state),
text: action.payload,
completed: false
}
]
}
case 'todos/todoToggled': {
return state.map(todo => {
if (todo.id !== action.payload) {
return todo
}
return {
...todo,
completed: !todo.completed
}
})
}
default:
return state
}
}
이제 좀 더 짧고 읽기 쉬워졌습니다.
이제 가시성 로직에도 동일한 작업을 할 수 있습니다. src/features/filters/filtersSlice.js를 생성하고 필터 관련 코드를 모두 이동해 보겠습니다:
const initialState = {
status: 'All',
colors: []
}
export default function filtersReducer(state = initialState, action) {
switch (action.type) {
case 'filters/statusFilterChanged': {
return {
// Again, one less level of nesting to copy
...state,
status: action.payload
}
}
default:
return state
}
}
필터 상태를 포함하는 객체를 복사해야 하지만 중첩이 줄어들어 발생하는 상황을 파악하기 더 쉽습니다.
이 페이지의 길이를 줄이기 위해 다른 액션에 대한 리듀서 업데이트 로직 작성을 생략하겠습니다.
앞서 설명한 요구사항을 바탕으로 해당 업데이트를 직접 작성해 보세요.
막히는 경우 이 페이지 하단의 CodeSandbox에서 이러한 리듀서의 완전한 구현을 참조하세요.
리듀서 합치기
이제 두 개의 분리된 슬라이스 파일이 있으며, 각각 고유한 슬라이스 리듀서 함수를 가지고 있습니다. 하지만 앞서 Redux 스토어 생성 시 단일 루트 리듀서 함수가 필요하다고 말했습니다. 그렇다면 모든 코드를 하나의 큰 함수에 넣지 않고 어떻게 루트 리듀서로 돌아갈 수 있을까요?
리듀서는 일반 JS 함수이므로 슬라이스 리듀서를 reducer.js로 다시 가져와 다른 두 함수를 호출하기만 하는 새 루트 리듀서를 작성할 수 있습니다.
import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'
export default function rootReducer(state = {}, action) {
// always return a new object for the root state
return {
// the value of `state.todos` is whatever the todos reducer returns
todos: todosReducer(state.todos, action),
// For both reducers, we only pass in their slice of the state
filters: filtersReducer(state.filters, action)
}
}
각 리듀서는 전역 상태의 자체 부분을 관리한다는 점에 유의하세요. 상태 매개변수는 리듀서마다 다르며 관리하는 상태 부분에 해당합니다.
이를 통해 기능과 상태 슬라이스 기준으로 로직을 분할하여 유지보수성을 유지할 수 있습니다.
combineReducers
새 루트 리듀서가 각 슬라이스에 대해 동일한 작업을 수행하는 것을 볼 수 있습니다: 슬라이스 리듀서를 호출하고, 해당 리듀서가 소유한 상태 조각을 전달하며, 결과를 루트 상태 객체에 다시 할당합니다. 더 많은 슬라이스를 추가하면 이 패턴이 반복됩니다.
Redux 코어 라이브러리에는 이 상용구 단계를 대신 수행하는 combineReducers 유틸리티가 포함되어 있습니다. 수동으로 작성한 rootReducer를 combineReducers로 생성된 더 짧은 리듀서로 대체할 수 있습니다.
이제 combineReducers가 필요하므로 실제로 Redux 코어 라이브러리를 설치할 시간입니다:
npm install redux
설치가 완료되면 combineReducers를 가져와 사용할 수 있습니다:
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
combineReducers는 객체를 인수로 받으며, 여기서 키 이름은 루트 상태 객체의 키가 되고 값은 Redux 상태의 해당 슬라이스를 업데이트하는 방법을 아는 슬라이스 리듀서 함수입니다.
기억하세요, combineReducers에 제공하는 키 이름이 상태 객체의 키 이름을 결정합니다!
학습 내용 요약
상태(State), 액션(Actions), 리듀서(Reducers)는 Redux의 구성 요소입니다. 모든 Redux 앱에는 상태 값이 있고, 발생한 상황을 설명하기 위해 액션을 생성하며, 이전 상태와 액션을 기반으로 새 상태 값을 계산하기 위해 리듀서 함수를 사용합니다.
지금까지의 앱 내용은 다음과 같습니다:
- Redux 앱은 일반 JS 객체, 배열 및 원시 값을 상태 값으로 사용합니다
- 루트 상태 값은 일반 JS 객체여야 합니다
- 상태에는 앱 작동에 필요한 최소한의 데이터만 포함해야 합니다
- 클래스, 프로미스, 함수 및 기타 일반 값이 아닌 값은 Redux 상태에 포함해서는 안 됩니다
- 리듀서는
Math.random()또는Date.now()와 같은 임의의 값을 생성해서는 안 됩니다 - Redux와 함께 Redux 저장소에 없는 다른 상태 값(로컬 컴포넌트 상태 등)을 함께 사용하는 것은 괜찮습니다
- 액션은 발생한 일을 설명하는
type필드가 있는 일반 객체입니다type필드는 읽을 수 있는 문자열이어야 하며, 일반적으로'feature/eventName'형태로 작성됩니다- 액션은 다른 값을 포함할 수 있으며, 일반적으로
action.payload필드에 저장됩니다 - 액션은 발생한 일을 설명하는 데 필요한 최소한의 데이터만 있어야 합니다
- 리듀서는
(state, action) => newState형태의 함수입니다- 리듀서는 항상 특별한 규칙을 따라야 합니다:
- 오직
state와action인수를 기반으로 새 상태를 계산해야 합니다 - 기존
state를 절대 변이(mutate)해서는 안 됩니다. 항상 복사본을 반환해야 합니다 - HTTP 요청이나 비동기 로직과 같은 "부수 효과(side effects)"를 포함해서는 안 됩니다
- 오직
- 리듀서는 항상 특별한 규칙을 따라야 합니다:
- 리듀서는 가독성을 위해 분할하는 것이 좋습니다
- 리듀서는 일반적으로 최상위 상태 키 또는 상태의 "슬라이스(slice)"를 기준으로 분할됩니다
- 리듀서는 일반적으로 "슬라이스" 파일에 작성되며, "기능(feature)" 폴더로 구성됩니다
- 리듀서는 Redux
combineReducers함수를 사용하여 결합할 수 있습니다 combineReducers에 지정된 키 이름은 최상위 상태 객체의 키를 정의합니다
다음 단계
이제 상태를 업데이트하는 리듀서 로직을 갖추게 되었지만, 리듀서 자체로는 아무것도 하지 않습니다. 리듀서는 Redux 저장소에 포함되어야 하며, 무언가 발생했을 때 액션과 함께 리듀서 코드를 호출할 수 있어야 합니다.
4부: 저장소(Store)에서는 Redux 저장소를 생성하고 리듀서 로직을 실행하는 방법을 살펴보겠습니다.