Reducer: State 로직 추출
- Published on
- 이 문서에서는 useState에서 useReducer로 상태 관리 로직을 개선하는 방법과, 리듀서 작성 시 주의할 점에 대해 설명합니다.
Table of Contents
여러 이벤트 핸들러에 걸쳐 상태 업데이트가 분산되는 컴포넌트는 복잡해질 수 있습니다. 이러한 경우 컴포넌트 외부에서 모든 상태 업데이트 로직을 단일 함수로 통합하여 리듀서라고 하는 방식으로 구현할 수 있습니다.
배울 내용
- 리듀서 함수란 무엇인가
- useState를 useReducer로 리팩터링하는 방법
- 언제 리듀서를 사용해야 하는가
- 효율적인 리듀서 작성 방법
리듀서로 상태 로직 통합하기
컴포넌트가 복잡해지면 한눈에 컴포넌트의 상태가 어떻게 업데이트되는지 파악하기 어려워집니다. 예를 들어, 아래의 TaskApp 컴포넌트는 상태로 작업 배열을 보유하고 작업을 추가, 제거 및 편집하는 세 가지 다른 이벤트 핸들러를 사용합니다.
각 이벤트 핸들러는 상태를 업데이트하기 위해 setTasks를 호출합니다. 이 컴포넌트가 커짐에 따라 상태 로직도 함께 분산되어 복잡성이 증가합니다. 이러한 복잡성을 줄이고 로직을 한 곳에 모아두기 위해, 컴포넌트 외부에 상태 로직을 담은 단일 함수인 **"리듀서"**를 만들 수 있습니다.
리듀서는 상태를 처리하는 다른 방법입니다. useState에서 useReducer로 마이그레이션하는 단계는 다음과 같습니다.
- 상태 설정에서 액션을 디스패치하는 방식으로 전환합니다.
- 리듀서 함수를 작성합니다.
- 컴포넌트에서 리듀서를 사용합니다.
단계 1: 상태 설정에서 액션을 디스패치하는 방식으로 전환하기
현재 이벤트 핸들러에서는 상태를 설정하여 수행할 작업을 지정합니다.
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}
function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}
상태 설정 로직을 모두 제거합니다. 남아있는 것은 세 가지 이벤트 핸들러입니다.
- 사용자가 "추가" 버튼을 누를 때
handleAddTask(text)
가 호출됩니다. - 사용자가 작업을 토글하거나 "저장" 버튼을 누를 때
handleChangeTask(task)
가 호출됩니다. - 사용자가 "삭제" 버튼을 누를 때
handleDeleteTask(taskId)
가 호출됩니다.
리듀서를 사용하는 경우 상태를 설정하기 위한 이벤트 핸들러 대신 액션을 디스패치하여 "사용자가 방금 무엇을 했는지"를 지정합니다. (상태 업데이트 로직은 다른 곳에 있습니다!) 따라서 이벤트 핸들러를 통해 "작업 추가/변경/삭제" 액션을 디스패치하는 대신 "작업을 추가/변경/삭제했다"라고 명시합니다. 이는 사용자의 의도를 더 명확하게 나타냅니다.
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
dispatch에 전달하는 객체를 "액션"이라고 합니다.
function handleDeleteTask(taskId) {
dispatch(
// "액션" 객체:
{
type: 'deleted',
id: taskId,
}
);
}
이 객체는 일반적인 JavaScript 객체입니다. 무엇을 담을지는 사용자가 결정할 수 있지만, 일반적으로 발생한 일에 대한 최소한의 정보를 담아야 합니다. (나중에 dispatch 함수 자체를 추가할 것입니다.)
자세히 알아보기: 액션 객체는 어떤 형태든지 가질 수 있습니다.
관례적으로 발생한 일을 설명하는 문자열 타입(type)을 주로 사용하고 다른 필드에 추가 정보를 전달합니다. 타입은 컴포넌트에 특정한 것이므로 이 예제에서 'added' 또는 'added_task' 중 어느 것을 사용해도 괜찮습니다. 발생한 일을 나타내는 이름을 선택하세요!
dispatch({
// 컴포넌트에 특정한 것
type: '무슨_일이_일어났는지',
// 다른 필드는 여기에 넣습니다
});
단계 2: 리듀서 함수 작성하기
리듀서 함수에 상태 로직을 넣을 것입니다. 현재 상태와 액션 객체 두 가지 인수를 받고 다음 상태를 반환합니다.
function yourReducer(state, action) {
// 리액트에서 설정할 다음 상태를 반환
}
리듀서에서 반환한 값을 리액트가 상태로 설정합니다.
이 예제에서 상태 설정 로직을 이벤트 핸들러에서 리듀서 함수로 이동하기 위해 다음 작업을 수행합니다.
- 첫 번째 인수로 현재 상태(tasks)를 선언합니다.
- 두 번째 인수로 액션 객체를 선언합니다.
- 리듀서에서 다음 상태를 반환합니다. (리액트가 상태를 설정할 것입니다.)
모든 상태 설정 로직을 리듀서 함수로 마이그레이션한 코드입니다.
function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('Unknown action: ' + action.type);
}
}
자세히 알아보기: 리듀서 내부는 switch
문 사용
위 코드는 if/else
문을 사용하지만, 리듀서 내부에는 switch
문을 사용하는 것이 관례입니다. 결과는 동일하지만 switch
문은 한눈에 읽기 쉬울 수 있습니다.
다음 내용에 사용되는 방식처럼, switch
문을 사용하여 다음과 같이 작성하는 것이 좋습니다.
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case 'changed': {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
중괄호로 각 case 블록을 감싸주는 것을 권장합니다. 이렇게 하면 각각의 case 내에서 선언된 변수가 서로 충돌하지 않습니다. 또한, case는 일반적으로 return으로 끝나야 합니다. return을 빼먹으면 코드가 다음 case로 "떨어지게(fall through)" 되어 오류가 발생할 수 있습니다!
switch
문에 익숙하지 않다면 if/else
를 사용하는 것도 완전히 괜찮습니다.
리듀서 함수는 상태(tasks)를 인수로 받기 때문에 컴포넌트 외부에 선언할 수 있습니다. 이로 인해 들여쓰기 수준이 감소하고 코드를 읽기 쉬워집니다.
자세히 알아보기: 왜 리듀서를 이렇게 부르나요?
리듀서는 컴포넌트 내부의 코드 양을 줄일 수 있지만, 실제로는 배열에서 수행할 수 있는 reduce() 연산과 비슷한 작업을 수행하는 함수 이름입니다.
reduce() 연산을 사용하면 배열에서 "많은" 값 중 하나를 "누적"할 수 있습니다.
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
reduce에 전달하는 함수는 "리듀서"라고 알려져 있습니다. 이 함수는 지금까지의 결과와 현재 항목을 받아 다음 결과를 반환합니다. 리액트의 리듀서도 동일한 개념입니다. 현재 상태와 액션을 받아 다음 상태를 반환합니다. 이렇게 하여 액션을 시간에 따라 상태로 누적합니다.
initialState와 액션 배열을 전달하여 리듀서 함수를 사용하여 최종 상태를 계산하는 reduce() 메서드를 실제로 사용할 일은 거의 없을 것입니다. 하지만 이것은 리액트가 하는 것과 유사합니다!
단계 3: 컴포넌트에서 리듀서 사용하기
마지막으로, tasksReducer를 컴포넌트에 연결해야 합니다. 리액트에서 useReducer Hook을 import합니다.
import { useReducer } from 'react';
그런 다음 useState를 다음과 같이 useReducer로 대체합니다.
const [tasks, setTasks] = useState(initialTasks);
다음과 같이 useReducer를 사용하여 useState를 대체합니다.
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
useReducer Hook은 useState와 유사합니다. 초기 상태를 전달하고 상태 값을 반환하며 상태를 설정하는 방법을 제공합니다. 다만 약간 다릅니다.
useReducer Hook은 두 개의 인수를 받습니다.
- 리듀서 함수
- 초기 상태
반환 값은 다음과 같습니다.
- 상태 값을 가진 stateful 값
- 리듀서에 사용자 액션을 "디스패치"하는 방법을 제공하는 dispatch 함수
이제 완전히 연결되었습니다! 리듀서는 컴포넌트 파일의 맨 아래에 선언되어 있습니다.
원한다면 리듀서를 다른 파일로 이동할 수도 있습니다.
이렇게 분리하면 컴포넌트 로직을 읽기 쉽게 만드는 등의 이점이 있습니다. 이제 이벤트 핸들러는 액션을 디스패치하여 어떤 일이 발생했는지 지정하며, 리듀서 함수는 상태가 어떻게 업데이트되는지를 결정합니다.
useState와 useReducer 비교하기
리듀서에는 단점이 있을 수 있습니다! 다음과 같이 비교할 수 있습니다.
- 코드 크기: 일반적으로 useState를 사용하는 경우, 처음에 작성해야 하는 코드는 더 적습니다. 하지만 많은 이벤트 핸들러에서 상태를 유사한 방식으로 수정하는 경우, useReducer를 사용하면 코드 양을 줄일 수 있습니다.
- 가독성: 상태 업데이트가 간단한 경우에는 useState가 매우 읽기 쉽습니다. 하지만 복잡해지면 컴포넌트의 코드가 비대해지고 스캔하기 어려울 수 있습니다. 이 경우, useReducer를 사용하면 업데이트 로직의 "어떻게"와 이벤트 핸들러의 "무슨 일이 발생했는지"를 깔끔하게 분리할 수 있습니다.
- 디버깅: useState에서 버그가 발생한 경우, 상태가 잘못 설정된 위치와 그 이유를 찾기 어려울 수 있습니다. useReducer의 경우, 리듀서에 콘솔 로그를 추가하여 모든 상태 업데이트 및 그 이유(어떤 액션 때문에 발생했는지)를 확인할 수 있습니다. 각 액션이 올바른 경우, 리듀서 로직 자체에 오류가 있음을 알 수 있습니다. 그러나 useState보다 더 많은 코드를 따라가야 합니다.
- 테스트: 리듀서는 컴포넌트에 의존하지 않는 순수 함수입니다. 따라서 리듀서를 내보내어 독립적으로 테스트할 수 있습니다. 일반적으로 실제 환경에서 컴포넌트를 테스트하는 것이 좋지만, 복잡한 상태 업데이트 로직의 경우, 특정 초기 상태와 액션에 대해 리듀서가 특정 상태를 반환하는지 확인하는 것이 유용할 수 있습니다.
- 개인적인 선호도: 어떤 사람들은 리듀서를 좋아하고, 다른 사람들은 그렇지 않을 수 있습니다. 이것은 선호도의 문제입니다. 언제든지 useState와 useReducer를 서로 전환할 수 있습니다. 두 가지 방식은 동등합니다!
리듀서를 사용하는 것이 좋은 이유는 컴포넌트에서 잘못된 상태 업데이트로 인해 자주 버그가 발생하는 경우이며, 코드에 더 많은 구조를 도입하고 싶을 때입니다. 모든 것을 리듀서로 처리할 필요는 없습니다. 자유롭게 혼합하여 사용할 수 있습니다. 심지어 동일한 컴포넌트에서 useState와 useReducer를 함께 사용할 수도 있습니다.
리듀서를 잘 작성하는 방법
리듀서를 작성할 때 다음 두 가지 팁을 기억하세요.
- 리듀서는 순수해야 합니다. 상태 업데이터 함수와 마찬가지로 리듀서는 렌더링 중에 실행됩니다! (액션은 다음 렌더링까지 대기합니다.) 이것은 리듀서가 순수해야 한다는 것을 의미합니다. 즉, 동일한 입력은 항상 동일한 출력을 생성해야 합니다. 요청을 보내거나 타임아웃을 예약하거나 컴포넌트 외부에 영향을 주는 작업(컴포넌트 외부의 것을 변경하는 작업)은 수행해서는 안 됩니다. 객체와 배열을 업데이트할 때는 변경하지 않고 업데이트해야 합니다.
- 각 액션은 사용자 상호작용 하나를 설명해야 합니다. 예를 들어, 사용자가 폼의 다섯 개의 필드를 선택하고 "재설정"을 누르는 경우, 별도의 "필드 설정" 액션 대신 하나의 "폼 재설정" 액션을 디스패치하는 것이 더 의미가 있습니다. 리듀서에서 각 액션을 로그로 기록하면 해당 로그를 사용하여 상호작용 또는 응답이 어떤 순서로 발생했는지 재구성할 수 있어 디버깅에 도움이 됩니다!
Immer를 사용하여 간결한 리듀서 작성하기
일반 상태 업데이트와 마찬가지로 리듀서를 더 간결하게 작성할 수 있는 Immer 라이브러리를 사용할 수 있습니다. 여기서 useImmerReducer를 사용하면 push 또는 arr\[i\] =
할당을 사용하여 상태를 직접 변경할 수 있습니다.
리듀서는 순수 함수이기 때문에 상태를 변경해서는 안 됩니다. 그러나 Immer는 변경해도 안전한 특별한 draft 객체를 제공합니다. 내부적으로 Immer는 draft에 수행한 변경 사항이 있는 상태의 사본을 만듭니다. 따라서 useImmerReducer
로 관리되는 리듀서는 첫 번째 인수를 직접 변경할 수 있으며 상태를 반환할 필요가 없습니다.
요약
- useState에서 useReducer로 변환하려면 다음을 수행합니다.
- 이벤트 핸들러에서 액션을 디스패치합니다.
- 주어진 상태와 액션에 대한 다음 상태를 반환하는 리듀서 함수를 작성합니다.
- useState를 useReducer로 대체합니다.
- 리듀서는 더 많은 코드를 작성해야하지만 디버깅 및 테스트에 도움이 됩니다.
- 리듀서는 순수해야 합니다.
- 각 액션은 단일 사용자 상호작용을 설명해야 합니다.
- 변경 사항을 직접 반영하려는 경우 Immer를 사용할 수 있습니다.