리듀서 싫어도 왜 쓰는지는 알아야겠죠?

dante Yoon·2022년 9월 1일
9

react

목록 보기
12/19
post-thumbnail

안녕하세요, 단테입니다.

한 컴포넌트에서 관리하는 상태와 이벤트 핸들러 갯수가 많아짐에 따라 어느 순간 상태 관리가 벅차게 느껴질 때가 있습니다. 이 때 우리는 상태 변화 로직을 컴포넌트 외부에 선언하고 따로 관리할 수 있는데요, 오늘은 이 기법에 대해 알아보겠습니다.

읽기 귀찮은데 영상으로 볼 수 있다면?

https://www.youtube.com/watch?v=8Wg6Nz2bVdA

컴포넌트의 가독성이 급격하게 낮아질 때

여러분들은 좋은 컴포넌트란 무엇이라고 생각하시나요?
자칫 잘못하면 면접 질문 같은데요 ㅋㅋ
저는 가독성이 좋아야 좋은 컴포넌트라고 생각합니다.

좋은 코드는 이해하기 쉬워야 한다와 동일한 맥락입니다.

다음 컴포넌트는 한눈에 어떻게 상태 변화가 진행되는지 읽기 어렵습니다. 한 상태를 세 개의 이벤트 핸들러에서 추가, 삭제 및 수정하기 때문입니다.


export default function TaskApp() {
  const [tasks, setTasks] = useState(initialTasks);

  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)
    );
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask
        onAddTask={handleAddTask}
      />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'Visit Kafka Museum', done: true },
  { id: 1, text: 'Watch a puppet show', done: false },
  { id: 2, text: 'Lennon Wall pic', done: false },
];

난리가 났습니다.
추가(handleAddTask)에는 spread operator

setTasks([...tasks, {
  id: nextId++,
  text: text,
  done: false
}]);

삭제(handleDeleteTask)는 filter,

    setTasks(
      tasks.filter(t => t.id !== taskId)
    );

수정(handleChangeTask)은 map을 사용하고 있습니다.

 setTasks(tasks.map(t => {
   if (t.id === task.id) {
     return task;
   } else {
     return t;
   }
 }));

컴포넌트가 커지면 상태 관련 로직도 많아질 겁니다.
UI 표시나 기능이 하나 추가된다고 생각해보세요, 그리고 하나의 기능이 추가될 때마다 또 다른 이벤트 핸들러가 추가된다고 생각해보십시오. 배열 형식의 tasks를 다루는 로직은 컴포넌트 전반에 넓게 자리잡을 것입니다.

tasks를 다루는 로직만 따로 컴포넌트 밖에서 관리한다면, 우리는 모래 속에서 바늘 찾기 처럼 상태관련 로직만 골라내기 위해 스크롤을 위아래로 여기저기 움직일 필요 없이, 해당 위치에 가기만 하면 됩니다. 상태 로직은 리듀서 - 관심사의 분리(seperation of concerns)를 달성하는 것이죠.

리듀서는 특별한 기능을 담당하는 것이 아닌 그저 몇 가지 조건을 만족하면 되는 함수입니다.
리덕스를 첫 상태 관리 툴로 만나며 가파른 학습 곡선을 거슬러보신 분들은 무의식 적으로 리듀서에 거부감을 느낄 수 있겠습니다.

앞서 봤던 세 개의 이벤트 핸들러로 나뉘어진 상태 변경 로직을 리듀서로 옮겨보겠습니다.

dispatch: 방아쇠, action: 총알

dipatch, action. 아, 벌써 머리가 지끈지끈 아파오지 않나요?
괜찮습니다! 쉽게 이해할 수 있어요 :) 리듀서를 알기 위한 기본 지식이니
우리가 익숙한 setState와 비교해보면서 쉽게 알아봅시다.

dispatch와 action은 상태 업데이트 플로우에서 어디에 위치해 있나요?

우리는 지금 dispatch와 action에 대해 알아보고 있어요.
이걸 왜 알아보고 있나요? 바로 우리의 최종 목표인 상태를 업데이트 하기 위해서에요.
상태 업데이트를 하기 위한 과정을 그림으로 살펴보고 가면 좋겠어요.

action이 dispatch에게 화살표를 쏘고 있네요!

우리의 최종 목표지인 state까지 action과 dispatch가 어떻게 연관지어지는지 알아볼게요!

setState 코드

function handleAddTask(text) {
  setTasks([...tasks, {
    id: nextId++,
    text: text,
    done: false
  }]);
}

dispatch 코드

function handleAddTask(text) {
  dispatch({
    type: 'added',
    id: nextId++,
    text: text,
  });
}

setState 대신 dispatch를 호출해주고 있죠?
그리고 dispatch 내부에는 type이라는 키/밸류 페어와 함께 id, text 값이 들어있습니다.

dispatch는 action 객체를 인자로 받습니다.
action은 특별한 객체가 아닌 리듀서 내부에서 어떻게 데이터가 처리될지에 대한 메타 정보를 담은
일반 객체 입니다.

handleDeleteTask를 dispatch로 나타내면 어떻게 되는지 볼까요?

function handleDeleteTask(taskId) {
  dispatch(
    // "action" object:
    {
      type: 'deleted',
      id: taskId
    }
  );
}

action convention

action의 형태에 대해서는 엄격한 규칙이 없습니다. 하지만 일반적으로
type 이라는 key에 action이 어떻게 다뤄져야 하는지에 대한 정보가 담깁니다.
위에서 delete를 위해 dispatch에 인자로 전달된 action은 'deleted'라는 정보를 가지고 있네요.

dispatch는 action이라는 총알을 담고 reducer를 향해 방아쇠를 당기는 권총이라고 생각하시면 이해가 편할 것 같습니다.

지금까지의 코드를 도식화로 그려보면 다음과 같습니다.

  • dispatch: 방아쇠
  • action: 총알

https://unsplash.com/photos/v4qdZ4xhksE

dispatch는 다음과 같이 useState가 아닌 useReducer로 반환된 두 번째 인덱스의 배열 값으로 참조할 수 있습니다. setState와 비슷하죠?

import { useReducer } from "react";
...
const [state, dispatch] = useReducer(reducer,initialState);

여기서 나오는 reducer는 곧 알아볼게요!

리듀서로 개선하기

앞서 소개했던 action과 dispatch를 사용해 event handler 의 내부를 다음과 같이 변경했습니다.

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
  });
}

네, 어렵지 않습니다.

저 이벤트 핸들러들을 실행시키면 이제 총알이 쏴질겁니다. 어디로요?

리듀서로요

리듀서 (핀볼 게임판)

리듀서는 핀볼 게임판과 같습니다.

아실 분들은 아시곘지만 옛날 윈도우 98 컴퓨터에서 많이 했던 게임입니다 ㅋㅋ. 버츄어 캅, 프리첼과 함께 기본 게임으로 항상 설치되어있던 기억이 있네요.

핀볼에 설치해진 핀에 따라 볼이 목적지로 굴러 떨어지게 됩니다.
dispatch를 통해 발사된 action은 리듀서라는 핀볼 장에 올라와 개발자가 작성한 조건문(핀)에 따라 원하는 곳으로 이동하게 됩니다.

yourReducer라는 함수 핀볼판이 마련되었습니다.
reducer는 첫 인자로 현재의 상태, 두 번째 인자로 dispatch가 발사했던 총알인 action
받습니다.

function yourReducer(state, action) {
  // return next state for React to set
}

이제 핀을 세워봅시다.

앞서 이벤트 핸들러 내부에서 작성되었던 map, filter, spread operator를 reducer 안으로 가져오면 됩니다.

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);
  }
}

이 리듀서 함수는 컴포넌트 외부에서 선언되었는데요, 항상 첫번째 인자 값으로 이 리듀서가 쓰이는 컴포넌트의 상태 값을 받기 때문에 외부에 독립적으로 선언되어도 전혀 문제 없습니다. 컴포넌트 외부 최상위 레벨 수준에서 쓰였기 때문에 들여쓰기도 적게되고 가독성도 많이 향상되었습니다. 원하는 상태 변경이 어떻게 이뤄졌는지 우리는 이 리듀서만 살펴보면 됩니다.

action에서 출발하여 reducer를 거쳐 최종 상태 업데이트까지 완료되었습니다!

리듀서에서 조건문은 switch case를 꼭 써야 하나요?

아니요, switch case문을 사용하지 않아도 괜찮습니다.
다만 일반적으로 switch case가 컨벤션으로 많이 알려져 있습니다.

If you’re not yet comfortable with switch statements, using if/else is completely fine.

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);
    }
  }
}

리엑트 공식문서에서는 switch case문이 한눈에 보기 더 좋은 구조라고 이야기 합니다.
switch case의 case block {}안에서 변수를 선언하여 다른 블록간의 충돌을 방지할 수 있다고 말합니다.

We recommend to wrap each case block into the { and } curly braces so that variables declared inside of different cases don’t clash with each other.

왜 이름이 리듀서에요?

리엑트의 reducer의 작동방식이 array.prototype.reduce에 인자로 전달되는 함수와 비슷하기 때문입니다. reduce에 인자로 전달되는 함수를 reducer라고 하는데요,

다음 코드를 보면 이해가 되실 겁니다.

let actions = [
  { type: 'added', id: 1, text: 'Visit Kafka Museum' },
  { type: 'added', id: 2, text: 'Watch a puppet show' },
  { type: 'deleted', id: 1 },
  { type: 'added', id: 3, text: 'Lennon Wall pic' },
];

let finalState = actions.reduce(
  tasksReducer,
  initialState
);

우리가 앞서 작성했던 tasksReducer 함수를 array.reduce의 첫번째 인자로 전달하고 있습니다. 이떄 최종 상태 값인 finalState는

tasksReducer를 향해 action의 마지막 값을 dispatch 한 값과 동일합니다.

즉, `배열 형식의 인자를 reduce 함수에 순차적으로 집어넣어 하나의 축적된 값으로 뽑는 방식이

여러 action을 reducer에 dispatch하고 축적된 최종 상태 값을 뽑아 내는 것이` 동일한 방식이기 때문에 reducer라고 명명되게 되었습니다.

리듀서는 순수함수로 작성되어야 해요

이건 리듀서 뿐만이 아니라 useState도 마찬가지에요.
dispatch가 되면 리렌더링 되고 -> 렌더링 도중 큐에 쌓인 action이 리듀서에서 사용되어요
리듀서는 렌더링 도중 실행되어요.
만약 리듀서 내부에서 네트워크 통신을 통한 리스폰스를 가지고 상태를 업데이트 하는 부분이 있다면
잘못된 거에요.

또한 리듀서에서 다루는 상태들은 immutable하게 다뤄져야 합니다.

reducer 사용하기

useReducer 훅을 이용해 선언합니다.
개선 전과 개선 후의 코드를 비교해보세요.
각 이벤트 핸들러는 변경될 상태 값(payload)와 action을 어떻게 다룰 것인가에 대한 메타 정보인 type 정보를 reducer를 향해 발사(dispatch)하는 동일한 형태로 되어있음을 알 수 있습니다. 상태가 어떤 로직으로 변경되는지는 tasksReducer 내부만 확인하면 됩니다.

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

최종 코드는 다음과 같습니다.

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(
    tasksReducer,
    initialTasks
  );

  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
    });
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask
        onAddTask={handleAddTask}
      />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

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);
    }
  }
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'Visit Kafka Museum', done: true },
  { id: 1, text: 'Watch a puppet show', done: false },
  { id: 2, text: 'Lennon Wall pic', done: false }
];

이렇게 따로 작성된 tasksReducer 다른 파일로 모듈화 시킬 수 있습니다.
또한 컴포넌트에 종속적이지 않고 로직만 따로 작성했기 때문에 테스트 코드를 작성하기도 용이합니다.

useState와 useReducer를 비교해보자.

코드 사이즈

useState 는 컴포넌트 상단 부분에 한번만 선언하면 되는 반면 useReducer는 reducer, action을 따로 만들어주어야 합니다. 이것이 번거롭다고 느낄 수 있습니다만, 많은 이벤트 리스너가 상태 업데이트를 책임지는 상황에서 useReducer는 로직 코드를 폭 넓게 퍼지게 하는 것을 방지하게 도와줍니다.

가독성

앞서 살펴봤던 코드 사이즈와 동일하게, 간단한 상태의 경우 useState가 더 읽기 편합니다. 다만 상태 업데이트를 책임지는 여러 함수가 존재한다면, useReducer를 통해 가독성을 올릴 수 있겠습니다.

디버깅

useState에 대한 이해도가 적거나, 잘못 사용하는 경우 상태 업데이트가 어떻게 진행되었는지에 대한 디버깅을 하기가 어렵습니다. useReducer를 사용할 경우 reducer 내부에서 action에 대해 console.log를 찍을 수 있기 때문에 상태 업데이트가 어떻게 되고 있는지 투명하게 볼 수 있습니다.

테스팅

reducer 로직이 컴포넌트에 종속적이지 않고 별도 파일에 작성 되어있기 때문에 테스트 코드를 작성하기 용이합니다.

오늘은 리듀서와 actoin, dispatch에 대해 알아보았습니다.

오늘 같이 본 내용은 리엑트 영문 공식 문서에 더 자세히 나와있으니 한번 살펴보시길 바랍니다.

읽어주셔서 감사합니다!

https://beta.reactjs.org/learn/extracting-state-logic-into-a-reducer

profile
성장을 향한 작은 몸부림의 흔적들

1개의 댓글

comment-user-thumbnail
2023년 1월 31일

굳이 useReducer를 써야하나 많은 고민을 하고 있는 시기였는데,,
좋은 글 감사합니다 :)

답글 달기