상세 컨텐츠

본문 제목

[리액터 스타터2] 리덕스(Redux)

23-24/React.js 2

by YUZ 유즈 2024. 1. 19. 10:00

본문

728x90

1. 리덕스 라이브러리 이해하기

리덕스는 가장 많이 사용하는 리액트 상태 관리 라이브러리이다. 리덕스를 사용하면 컴포넌트의 상태 업데이트 관련 로직을 다른 파일로 분리시켜서 더욱 효율적으로 관리할 수 있다. 또한 컴포넌트끼리 똑같은 상태를 공유해야 할 때도 여러 컴포넌트를 거치지 않고 손쉽게 상태 값을 전달하거나 업데이트할 수 있다.
 

<개념 미리 정리하기>

[액션]
상태에 어떤 변화가 필요하면 액션(action)이 발생한다. 이는 하나의 객체로 표현된다. 액션 객체는 다음과 같은 형식으로 이루어져 있다.

{
  type: 'TOGGLE_VALUE'
}

액션 객체는 type 필드를 반드시 가지고 있어야 한다. 이 값을 액션의 이름이라고 생각하면 된다. 그 외의 값들은 나중에 상태 업데이트를 할 때 참고해야 할 값이며, 작성자 마음대로 넣을 수 있다.
 
[액션 생성 함수]
액션 생성 함수는 액션 객체를 만들어 주는 함수이다.

function addTodo(data) {
return {
    type: 'ADD_TODO',
  data
};
}

 
화살표 함수로도 만들 수 있다.

const changeInput = text => ({ 
  type: 'CHANGE_INPUT',
text
});

어떤 변화를 일으켜야 할 때마다 액션 객체를 만들어야 하는데 매번 액션 객체를 직접 작성하기 번거로울 수 있고, 만드는 과정에서 실수로 정보를 놓칠 수도 있다. 이러한 일을 방지하기 위해 이를 함수로 만들어서 관리한다.
 
[리듀서]
리듀서(reducer)는 변화를 일으키는 함수이다. 액션을 만들어서 발생시키면 리듀서가 현재 상태와 전달받은 액션 객체를 파라미터로 받아 온다. 그리고 두 값을 참고하여 새로운 상태를 만들어서 반환해 준다.
리듀서 코드는 다음과 같은 형태로 이루어져 있다.

const initialState = {
  counter: 1
};
function reducer(state = initialState, action) {
switch (action.type) {
  case INCREMENT:
    return {
        counter: state.counter + 1
    };
  default:
    return state;
}
}

 
[스토어]
프로젝트에 리덕스를 적용하기 위해 스토어(store)를 만든다. 한 개의 프로젝트는 단 하나의 스토어만 가질 수 있다. 스토어 안에는 현재 애플리케이션 상태와 리듀서가 들어가 있으며, 그 외에도 몇 가지 중요한 내장 함수를 지닌다.
 
[디스패치]
디스패치(dispatch)는 스토어의 내장 함수 중 하나이다. 디스패치는 '액션을 발생시키는 것'이라고 이해하면 된다. 이 함수는 dispatch(action)과 같은 형태로 액션 객체를 파라미터로 넣어서 호출한다.
이 함수가 호출되면 스토어는 리듀서 함수를 실행시켜서 새로운 상태를 만들어 준다.
 
[구독]
구독(subscribe)도 스토어의 내장 함수 중 하나이다. subscribe 함수 안에 리스너 함수를 파라미터로 넣어서 호출해 주면, 이 리스너 함수가 액션이 디스패치되어 상태가 업데이트될 때마다 호출된다.

const listener = () => {
console.log('상태가 업데이트됨');  
}
const unsubscribe = store.subscribe(listener);

unsubscribe(); // 추후 구독을 비활성화할 때 함수를 호출

 
 
<리액트 없이 쓰는 리덕스>

리덕스는 리액트에 종속되는 라이브러리가 아니다. 리액트에서 사용하려고 만들어졌지만 실제로 다른 UI 라이브러리/프레임워크와 함께 사용할 수 도 있다.(ex: angular-redux, ember redux, Vue에서도 사용할 수 있지만, Vue에서는 리덕스와 유사한 vuex를 주로 사용한다.)
리덕스는 바닐라(vanilla) 자바스크립트와 함께 사용할 수 도 있다. 바닐라 자바스크립트는 라이브러리나 프레임워크 없이 사용하는 순수 자바스크립트 그 자체를 의미한다.
이번에는 바닐라 자바스크립트 환경에서 리덕스를 사용하여 리덕스의 핵심 기능과 작동 원리를 이해해 볼 것이다.
 
[Parcel로 프로젝트 만들기]
프로젝트를 구성하기 위해 Parcel이라는 도구를 사용하겠다. 이 도구를 사용하면 아주 쉽고 빠르게 웹 애플리케이션 프로젝트를 구성할 수 있다.
먼저 parcel-bundler를 설치해야 한다.

$ yarn global add parcel-bundler
# yarn global이 잘 설치되지 않는다면 npm install -g parcel-bundler를 해 보세요.

프로젝트 디렉터리를 생성한 후 package.json 파일을 생성한다.

$ mkdir vanilla-redux
$ cd vanilla-redux
# package.json 파일을 생성한다.
$ yarn init -y

에디터로 해당 디렉터리를 열어서 index.html과 index.js 파일을 만든다.

  • index.html
<html>
  <body>
    <div>바닐라 자바스크립트</div>
    <script src="./index.js"></script>
  </body>
</html>
  • index.js
console.log('hello parcel');

위와 같이 다 작성한 후에 다음 명령어를 실행하면 개발용 서버가 실행된다.

$ parcel index.html
Server running at http://localhost:1234 
Built in 548ms.

개발 서버의 주소는 http://localhost:1234/이며, 파일을 저장할 때마다 자동으로 새로고침된다.
 
다음으로 yarn을 사용하여 리덕스 모듈을 설치한다.

$ yarn add redux

 
 
[간단한 UI 구성하기]
먼저 간단한 스타일 파일을 작성한다.

  • index.css
.toggle {
  border: 2px solid black;
  width: 64px;
  height: 64px;
  border-radius: 32px;
  box-sizing: border-box;
}

.toggle.active {
  background: yellow;
}

다음으로 index.html을 수정한다.

  • index.html
<html>
  <head>
    <link rel="stylesheet" type="text/css" href="index.css" />
  </head>
  <body>
    <div class="toggle"></div>
    <hr />
    <h1>0</h1>
    <button id="increase">+1</button>
    <button id="decrease">-1</button>
    <script src="./index.js"></script>
  </body>
</html>

 
[DOM 레퍼런스 만들기]
이번 프로젝트에서는 UI를 관리할 때 별도의 라이브러리를 사용하지 않기 때문에 DOM을 직접 수정해 주어야 한다. 다음과 같이 자바스크립트 파일 상단에 수정할 DOM 노드를 가리키는 값을 미리 선언해 준다. (기존 코드는 지운다.)  

  • index.js
const divToggle = document.querySelector('.toggle');
const counter = document.querySelector('h1');
const btnIncrease = document.querySelector('#increase');
const btnDecrease = document.querySelector('#decrease');

 
[액션 타입과 액션 생성 함수 정의]
프로젝트의 상태에 변화를 일으키는 것을 액션이라고 한다. 먼저 액션에 이름을 정의해 준다. 액션 이름은 문자열 형태로, 주로 대문자로 작성하며 액션 이름은 고유해야 한다. 이름이 중복되면 의도하지 않은 결과가 발생할 수 있기 때문이다.

  • index.js
const divToggle = document.querySelector('.toggle');
const counter = document.querySelector('h1');
const btnIncrease = document.querySelector('#increase');
const btnDecrease = document.querySelector('#decrease');

const TOGGLE_SWITCH = 'TOGGLE_SWITCH';
const INCREASE = 'INCREASE';
const DECREASE = 'DECREASE';

다음으로 이 액션 이름을 사용하여 액션 객체를 만드는 액션 생성 함수를 작성해 준다. 액션 객체는 type 값을 반드시 갖고 있어야 하며, 그 외에 추후 상태를 업데이트할 때 참고하고 싶은 값은 마음대로 넣을 수 있다.

  • index.js
(...)
const toggleSwitch = () => ({ type: TOGGLE_SWITCH });
const increase = difference => ({ type: INCREASE, difference });
const decrease = () => ({ type: DECREASE });

 
[초깃값 설정]
이 프로젝트에서 사용할 초깃값을 정의해 준다. 초깃값의 형태는 자유다. 숫자일 수도 있고, 문자열일 수도 있고, 객체일 수도 있다.

  • index.js
(...)
const initialState = {
  toggle: false,
  counter: 0
};

 
[리듀서 함수 정의]
리듀서는 변화를 일으키는 함수이다. 함수의 파라미터로는 state와 action 값을 받아 온다.

  • index.js
(...)
// state가 undefined일 때는 initialState를 기본값으로 사용
function reducer(state = initialState, action) {
  // action.type에 따라 다른 작업을 처리함
  switch (action.type) {
    case TOGGLE_SWITCH:
      return {
        ...state, // 불변성 유지를 해 주어야 함.
        toggle: !state.toggle
      };
    case INCREASE:
      return {
        ...state,
        counter: state.counter + action.difference
      };
    case DECREASE:
      return {
        ...state,
        counter: state.counter - 1
      };
    default:
      return state;
  }
}

리듀서 함수가 맨 처음 호출될 때는 state 값이 undefined이다. 해당 값이 undefined로 주어졌을 때는 initialState를 기본값으로 설정하기 위해 함수의 파라미터 쪽에 기본값이 설정되어 있다.
리듀서에서는 상태의 불변성을 유지하면서 데이터에 변화를 일으켜 주어야 한다. 이 작업을 할 때 spread 연산자()를 사용하면 편하다. 단, 객체의 구조가 복잡해지면(예를 들어 object.something.inside.valuespread 연산자로 불변성을 관리하며 업데이트하는 것이 굉장히 번거로울 수 있고 코드의 가독성도 나빠지기 때문에 리덕스의 상태는 최대한 깊지 않은 구조로 진행하는 것이 좋다.
객체의 구조가 복잡해지거나 배열도 함께 다루는 경우 immer 라이브러리를 사용하면 좀 더 쉽게 리듀서를 작성할 수 있다.
 
[스토어 만들기]
스토어를 만들 때는 createStore 함수를 사용한다. 이 함수를 사용하려면 코드 상단에 import 구문을 넣어 리덕스에서 해당 함수를 불러와야 하고, 함수의 파라미터에는 리듀서 함수를 넣어 주어야 한다.

  • index.js
import { createStore } from 'redux';
(...)
const store = createStore(reducer);

이제 스토어를 생성했으니, 스토어 내장 함수들을 사용해 보겠다.
 
[render 함수 만들기]
render 함수는 상태가 업데이트될 때마다 호출되며, 리액트의 render 함수와는 다르게 이미 html을 사용하여 만들어진 UI의 속성을 상태에 따라 변경해 준다.

  • index.js
(...)
const render = () => {
  const state = store.getState(); // 현재 상태를 불러온다.
  // 토글 처리
  if (state.toggle) {
    divToggle.classList.add('active');
  } else {
    divToggle.classList.remove('active');
  }
  // 카운터 처리
  counter.innerText = state.counter;
};

render();

 
[구독하기]
이제 스토어의 상태가 바뀔 때마다 방금 만든 render 함수가 호출되도록 해 줄 것이다. 이 작업은 스토어의 내장 함수 subscribe를 사용하여 수행할 수 있다.
subscribe 함수의 파라미터로는 함수 형태의 값을 전달해 준다. 이렇게 전달된 함수는 추후 액션이 발생하여 상태가 업데이트될 때마다 호출된다.

  • 예시 코드
const listener = () => {
  console.log('상태가 업데이트됨');  
}
const unsubscribe = store.subscribe(listener);

unsubscribe(); // 추후 구독을 비활성화할 때 함수를 호출

이번 프로젝트에서는 subscribe 함수를 직접 사용하지만, 추후 리액트 프로젝트에서 리덕스를 사용할 때는 이 함수를 직접 사용하지 않을 것이다. 왜냐하면, 컴포넌트에서 리덕스 상태를 조회하는 과정에서 react-redux라는 라이브러리가 이 작업을 대신해 주기 때문이다.
 
이제 상태가 업데이트될 때마다 render 함수를 호출하도록 코드를 작성한다.

  • index.js
(...)
store.subscribe(render);

 
[액션 발생시키기]
액션을 발생시키는 것을 디스패치라고 한다. 디스패치를 할 때는 스토어의 내장 함수 dispatch를 사용한다. 파라미터는 액션 객체를 넣어 주면 된다.
다음과 같이 각 DOM 요소에 클릭 이벤트를 설정해 보겠다. 이벤트 함수 내부에서는 dispatch 함수를 사용하여 액션을 스토어에게 전달해 주겠다.

  • index.js
(...)
divToggle.onclick = () => {
  store.dispatch(toggleSwitch());
};
btnIncrease.onclick = () => {
  store.dispatch(increase(1));
};
btnDecrease.onclick = () => {
  store.dispatch(decrease());
};

화면에 나타나는 원과 하단의 버튼을 클릭해 상태 변화가 잘 일어나는지 확인한다.

 
<리덕스의 세 가지 규칙>

[단일 스토어]
하나의 애플리케이션 안에는 하나의 스토어가 들어 있다. 사실 여러 개의 스토어를 사용하는 것이 완전히 불가능하지는 않다. 특정 업데이트가 너무 빈번하게 일어나거나 애플리케이션의 특정 부분을 완전히 분리시킬 때 여러 개의 스토어를 만들 수도 있지만, 상태 관리가 복잡해질 수 있으므로 권장하지 않다.
 
[읽기 전용 상태]
리덕스 상태는 읽기 전용이다. 기존에 리액트에서 setState를 사용하여 state를 업데이트할 때도 객체나 배열을 업데이트하는 과정에서 불변성을 지켜 주기 위해 spread 연산자를 사용하거나 immer와 같은 불변성 관리 라이브러리를 사용했다. 리덕스도 마찬가지이다. 상태를 업데이트할 때 기존의 객체는 건드리지 않고 새로운 객체를 생성해 주어야 한다.
리덕스에서 불변성을 유지해야 하는 이유는 내부적으로 데이터가 변경되는 것을 감지하기 위해 얕은 비교(shallow equality) 검사를 하기 때문이다. 객체의 변화를 감지할 때 객체의 깊숙한 안쪽까지 비교하는 것이 아니라 겉핥기식으로 비교하여 좋은 성능을 유지할 수 있는 것이다.
 
[리듀서는 순수한 함수]
변화를 일으키는 리듀서 함수는 순수한 함수여야 한다. 순수한 함수는 다음 조건을 만족한다.

  1. 리듀서 함수는 이전 상태와 액션 객체를 파라미터로 받는다
  2. 파라미터 외의 값에는 의존하면 안된다.
  3. 이전 상태는 절대로 건드리지 않고, 변화를 준 새로운 상태 객체를 만들어서 반환한다.
  4. 똑같은 파라미터로 호출된 리듀서 함수는 언제나 똑같은 결과  값을 반환해야 한다.

리듀서를 작성할 때는 위 네 가지 사항을 주의해야 한다. 예를 들어 리듀서 함수 내부에서 랜덤 값을 만들거나, Date 함수를 사용하여 현재 시간을 가져오거나, 네트워크 요청을 한다면, 파라미터가 같아도 다른 결과를 만들어 낼 수 있기 때문에 사용하면 안 된다. 이러한 작업은 리듀서 함수 바깥에서 처리해 주어야 한다. 액션을 만드는 과정에서 처리해도 되고, 추후 배울 리덕스 미들웨어에서 처리해도 된다. 주로 네트워크 요청과 같은 비동기 작업은 미들웨어를 통해 관리한다.


2. 리덕스를 사용하여 리액트 애플리케이션 상태 관리하기

소규모 프로젝트에서는 컴포넌트가 가진 state를 사용하는 것만으로도 충분하지만, 프로젝트의 규모가 커짐에 따라 상태 관리가 번거로워질 수 있다. 리액트 애플리케이션에서 리덕스를 사용하면, 상태 업데이트에 관한 로직을 모듈로 따로 분리하여 컴포넌트 파일과 별개로 관리할 수 있으므로 코드를 유지 보수하는 데 도움이 된다. 또한, 여러 컴포넌트에서 동일한 상태를 공유해야 할 때 매우 유용하며, 실제 업데이트가 필요한 컴포넌트만 리렌더링 되도록 쉽게 최적화해 줄 수도 있다.
 
앞에서 바닐라 자바스크립트 환경에서 리덕스를 사용할 때 스토어의 내장 함수인 store.dispatch와 store.subscribe 함수를 사용했다. 리액트 애플리케이션에서 리덕스를 사용할 때는 store 인스턴스를 직접 사용하기보다는 주로 react-redux라는 라이브러리에서 제공하는 유틸 함수(connect)컴포넌트(Provider)를 사용하여 리덕스 관련 작업을 처리한다.
 

<작업 환경 설정>

리액트 프로젝트를 생성하고, 해당 프로젝트에 리덕스를 적용해 보자.
먼저 create-react-app을 사용하여 새로운 리액트 프로젝트를 생성한다.

$ yarn create react-app react-redux-tutorial

다음으로 생성한 프로젝트 디렉터리에 yarn 명령어를 사용하여 리덕스와 react-redux 라이브러리를 설치한다.

$ cd react-redux-tutorial
$ yarn add redux react-redux

Prettier를 적용하고 싶다면 디렉터리에 다음과 같이 .prettierrc 파일을 작성한다.

  • .prettierrc
{
  "singleQuote": true,
  "semi": true,
  "useTabs": false,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 80
}

 

<UI 준비하기>

리액트 프로젝트에서 리덕스를 사용할 때 가장 많이 사용하는 패턴은 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리하는 것이다.
여기서 프레젠테이셔널 컴포넌트란 주로 상태 관리가 이루어지지 않고, 그저 props를 받아 와서 화면에 UI를 보여 주기만 하는 컴포넌트를 말한다. 이와 달리 컨테이너 컴포넌트는 리덕스와 연동되어 있는 컴포넌트로, 리덕스로부터 상태를 받아 오기도 하고 리덕스 스토어에 액션을 디스패치하기도 한다.
이러한 패턴은 리덕스를 사용하는 데 필수 사항은 아니다. 다만 이 패턴을 사용하면 코드의 재사용성도 높아지고, 관심사의 분리가 이루어져 UI를 작성할 때 좀 더 집중할 수 있다.

프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트

이번 프로젝트에서는 이 패턴을 사용하여 코드를 작성해 보겠다. UI에 관련된 프레젠테이셔널 컴포넌트는 src/components 경로에 저장하고, 리덕스와 연동된 컨테이너 컴포넌트는 src/containers 컴포넌트에 작성한다.
 
[카운터 컴포넌트 만들기]
숫자를 더하고 뺄 수 있는 카운터 컴포넌트를 만들어 보자. components 디렉터리를 생성한 뒤, 그 안에 Counter 컴포넌트를 작성한다.

  • components/Counter.js
import React from 'react';

const Counter = ({ number, onIncrease, onDecrease }) => {
  return (
    <div>
      <h1>{number}</h1>
      <div>
        <button onClick={onIncrease}>+1</button>
        <button onClick={onDecrease}>-1</button>
      </div>
    </div>
  );
};

export default Counter;

이제 이 컴포넌트를 App 컴포넌트에서 렌더링 한다.

  • App.js
import React from 'react';
import Counter from './components/Counter';

const App = () => {
  return (
    <div>
      <Counter number={0} />
    </div>
  );
};

export default App;

yarn start 명령어를 입력해서 개발 서버를 실행하고 카운터 컴포넌트가 나타나는지 확인한다.
 
[할 일 목록 컴포넌트 만들기]

이번에는 해야 할 일을 추가하고, 체크하고, 삭제할 수 있는 할 일 목록 컴포넌트를 만들어 보겠다. components 디렉터리에 Todos 컴포넌트를 다음과 같이 작성한다.

  • components/Todos.js
import React from 'react';

const TodoItem = ({ todo, onToggle, onRemove }) => {
  return (
    <div>
      <input type="checkbox" />
      <span>예제 텍스트</span>
      <button>삭제</button>
    </div>
  );
};

const Todos = ({
  input, // 인풋에 입력되는 텍스트
  todos, // 할 일 목록이 들어 있는 객체
  onChangeInput,
  onInsert,
  onToggle,
  onRemove,
}) => {
  const onSubmit = e => {
    e.preventDefault();
  };
  return (
    <div>
      <form onSubmit={onSubmit}>
        <input />
        <button type="submit">등록</button>
      </form>
      <div>
        <TodoItem />
        <TodoItem />
        <TodoItem />
        <TodoItem />
        <TodoItem />
      </div>
    </div>
  );
};

export default Todos;

파일 하나에 두 컴포넌트를 선언했다. 취향에 따라 Todos 컴포넌트와 TodoItem 컴포넌트를 파일 두 개로 분리해도 되고, 위 코드처럼 파일 하나에 작성해도 무방하다.
위 컴포넌트들이 받아 오는 props는 나중에 사용하겠다.
컴포넌트를 다 만들었다면 App 컴포넌트에서 카운터 아래에 렌더링해야 한다. hr 태그를 사용하여 사이에 구분선을 그려 주겠다.

  • App.js
import React from 'react';
import Counter from './components/Counter';
import Todos from './components/Todos';

const App = () => {
  return (
    <div>
      <Counter number={0} />
      <hr />
      <Todos />
    </div>
  );
};

export default App;

 
<리덕스 관련 코드 작성하기>

이제 프로젝트에 리덕스를 사용해 보겠다. 리덕스 관련 코드를 준비한다. 리덕스를 사용할 때는 액션 타입, 액션 생성 함수, 리듀서 코드를 작성해야 한다. 이 코드들을 각각 다른 파일에 작성하는 방법도 있고, 기능별로 묶어서 파일 하나에 작성하는 방법도 있다.

일반적인 구조

위 그림은 가장 일반적인 구조로 actions, constants, reducers라는 세 개의 디렉터리를 만들고 그 안에 기능별로 파일을 하나씩 만드는 방식이다. 코드를 종류에 따라 다른 파일에 작성하여 정리할 수 있어서 편리하지만, 새로운 액션을 만들 때마다 세 종류의 파일을 모두 수정해야 하기 때문에 불편하기도 하다. 이 방식은 리덕스 공식 문서에서도 사용되므로 가장 기본적이라 할 수 있지만, 사람에 따라서는 불편할 수도 있는 구조이다.

Ducks 패턴

이 그림은 액션 타입, 액션 생성 함수, 리듀서 함수를 기능별로 파일 하나에 몰아서 다 작성하는 방식이다. 이러한 방식을 Ducks 패턴이라고 부르며, 앞서 설명한 일반적인 구조로 리덕스를 사용하다가 불편함을 느낀 개발자들이 자주 사용한다.
리덕스 관련 코드에 대한 디렉터리 구조는 정해진 방법이 없기 때문에 마음대로 작성해도 되지만, 위 두 가지 방법이 주로 사용된다. 이 책에서는 두 번째로 소개한 방식인 Ducks 패턴을 사용하여 코드를 작성하겠다.
 
[counter 모듈 작성하기]
Ducks 패턴을 사용하여 액션 타입, 액션 생성 함수, 리듀서를 작성한 코드를 ‘모듈’이라고 한다. 먼저 counter 모듈을 작성해 보자. modules 디렉터리를 생성하고 그 안에 counter.js 파일을 다음과 같이 작성한다.

  • modules/counter.js
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

가장 먼저 해야 할 작업은 액션 타입을 정의하는 것이다. 액션 타입은 대문자로 정의하고, 문자열 내용은 '모듈 이름/액션 이름'과 같은 형태로 작성한다. 문자열 안에 모듈 이름을 넣음으로써, 나중에 프로젝트가 커졌을 때 액션의 이름이 충돌되지 않게 해 준다. 
 
액션 타입을 정의한 다음에는 액션 생성 함수를 만들어 주어야 한다.

  • modules/counter.js
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });

더 필요하거나 추가할 값이 없으니 그냥 위와 같이 만들어 주면 된다. 여기서 주의해야 할 점은 앞부분에 export라는 키워드가 들어간다는 것이다. 이렇게 함으로써 추후 이 함수를 다른 파일에서 불러와 사용할 수 있다.
 
이제 counter 모듈의 초기 상태와 리듀서 함수를 만들어 준다.

  • modules/counter.js
(...)
const initialState = {
  number: 0
};

function counter(state = initialState, action) {
  switch (action.type) {
    case INCREASE:
      return {
        number: state.number + 1
      };
    case DECREASE:
      return {
        number: state.number - 1
      };
    default:
      return state;
  }
}

export default counter;

 
이 모듈의 초기 상태에는 number 값을 설정해 주었으며, 리듀서 함수에는 현재 상태를 참조하여 새로운 객체를 생성해서 반환하는 코드를 작성해 주었다. 마지막으로 export default 키워드를 사용하여 함수를 내보내 주었다.
조금 전에 만든 액션 생성 함수는 export로 내보내 주었고, 이번에 만든 리듀서는 export default로 내보내 주었다. 두 방식의 차이점은 export는 여러 개를 내보낼 수 있지만 export default는 단 한 개만 내보낼 수 있다는 것이다.
다음과 같이 불러오는 방식도 다르다.

import counter from './counter';
import { increase, decrease } from './counter';
// 한꺼번에 불러오고 싶을 때
import counter, { increase, decrease } from './counter';

 
[todos 모듈 만들기]
이전과 마찬가지로 가장 먼저 해야 할 일은 액션 타입 정의이다.

  • modules/todos.js
const CHANGE_INPUT = 'todos/CHANGE_INPUT'; // 인풋 값을 변경함
const INSERT = 'todos/INSERT'; // 새로운 todo를 등록함
const TOGGLE = 'todos/TOGGLE'; // todo를 체크/체크 해제함
const REMOVE = 'todos/REMOVE'; // todo를 제거함

 
다음으로 액션 생성 함수를 만든다. 조금 전과 달리 이번에는 액션 생성 함수에서 파라미터가 필요하다. 전달받은 파라미터는 액션 객체 안에 추가 필드로 들어가게 된다.

  • modules/todo.js
(...)
export const changeInput = input => ({
  type: CHANGE_INPUT,
  input
});

let id = 3; // insert가 호출될 때마다 1씩 더해집니다.
export const insert = text => ({
  type: INSERT,
  todo: {
    id: id++,
    text,
    done: false
  }
});

export const toggle = id => ({
  type: TOGGLE,
  id
});

export const remove = id => ({
  type: REMOVE,
  id
});

위 액션 생성 함수 중에서 insert 함수는 액션 객체를 만들 때 파라미터 외에 사전에 이미 선언되어 있는 id라는 값에도 의존한다. 이 액션 생성 함수는 호출될 때마다 id 값에 1씩 더해 준다. 이 id 값은 각 todo 객체가 들고 있게 될 고윳값이다.
여기서 id 값이 3인 이유는 이후 초기 상태를 작성할 때 todo 객체 두 개를 사전에 미리 넣어 둘 것이므로 그다음에 새로 추가될 항목의 id가 3이기 때문이다.
 
이제 모듈의 초기 상태와 리듀서 함수를 작성하자. 이번에는 업데이트 방식이 조금 까다로워진다. 객체에 한 개 이상의 값이 들어가므로 불변성을 유지해 주어야 하기 때문이다. spread 연산자()를 잘 활용하여 작성해 보자. 배열에 변화를 줄 때는 배열 내장 함수를 사용하여 구현하면 된다.

  • modules/todos.js
(...)
const initialState = {
  input: '',
  todos: [
    {
      id: 1,
      text: '리덕스 기초 배우기',
      done: true
    },
    {
      id: 2,
      text: '리액트와 리덕스 사용하기',
      done: false
    }
  ]
};

function todos(state = initialState, action) {
  switch (action.type) {
    case CHANGE_INPUT:
      return {
        ...state,
        input: action.input
      };
    case INSERT:
      return {
        ...state,
        todos: state.todos.concat(action.todo)
      };
    case TOGGLE:
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id = = = action.id ? { ...todo, done: !todo.done } : todo
        )
      };
    case REMOVE:
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id != = action.id)
      };
    default:
      return state;
  }
}

export default todos;

 
[루트 리듀서 만들기]
이번 프로젝트에서는 리듀서를 여러 개 만들었다. 나중에 createStore 함수를 사용하여 스토어를 만들 때는 리듀서를 하나만 사용해야 한다. 그렇기 때문에 기존에 만들었던 리듀서를 하나로 합쳐 주어야 한다. 이 작업은 리덕스에서 제공하는 combineReducers라는 유틸 함수를 사용하면 쉽게 처리할 수 있다.
modules 디렉터리에 index.js 파일을 만들고, 그 안에 다음과 같은 코드를 작성한다.

  • modules/index.js
import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';

const rootReducer = combineReducers({
  counter,
  todos,
});

export default rootReducer;

파일 이름을 이렇게 index.js로 설정해 주면 나중에 불러올 때 다음과 같이 디렉터리 이름까지만 입력하여 불러올 수 있다.

import rootReducer from './modules';

 
<리액트 애플리케이션에 리덕스 적용하기>

이제 리액트 애플리케이션에 리덕스를 적용할 것이다. 스토어를 만들고 리액트 애플리케이션에 리덕스를 적용하는 작업은 src 디렉터리의 index.js에서 이루어진다.

 

[스토어 만들기]
가장 먼저 스토어를 생성한다.

  • src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import rootReducer from './modules';

const store = createStore(rootReducer);

ReactDOM.render(<App />, document.getElementById('root'));

serviceWorker.unregister();

 

[Provider 컴포넌트를 사용하여 프로젝트에 리덕스 적용하기]
리액트 컴포넌트에서 스토어를 사용할 수 있도록 App 컴포넌트를 react-redux에서 제공하는 Provider 컴포넌트로 감싸 준다. 이 컴포넌트를 사용할 때는 store를 props로 전달해 주어야 한다.

  • src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import rootReducer from './modules';

const store = createStore(rootReducer);

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root'),
);

serviceWorker.unregister();

 

[Redux DevTools의 설치 및 적용]
Redux DevTools는 리덕스 개발자 도구이며, 크롬 확장 프로그램으로 설치하여 사용할 수 있다. 크롬 웹 스토어(https://chrome.google.com/webstore/)에서 Redux DevTools를 검색하여 설치한다.
설치하고 나면 리덕스 스토어를 만드는 과정에서 다음과 같이 적용해 줄 수 있다.

  • 사용 예시
const store = createStore(
  rootReducer, /* preloadedState, */
  window._ _REDUX_DEVTOOLS_EXTENSION_ _ && window._ _REDUX_DEVTOOLS_EXTENSION_ _()
);

하지만 패키지를 설치하여 적용하면 코드가 훨씬 깔끔해진다. 패키지를 설치하는 형태로 적용해 보겠다. (패키지를 설치하여 사용한다고 해도 크롬 확장 프로그램은 설치해야 한다.)
우선 redux-devtools-extension을 yarn을 사용하여 설치한다.

$ yarn add redux-devtools-extension

그리고 다음과 같이 적용해 준다.

  • src/index.js
(...)
import { composeWithDevTools } from 'redux-devtools-extension';
(...)

const store = createStore(rootReducer, composeWithDevTools());
(...)

이제 브라우저에서 크롬 개발자 도구를 실행한 후 Redux 탭을 열어 리덕스 개발자 도구가 잘 나타나는지 확인한다.

Redux DevTools

 

<컨테이너 컴포넌트 만들기>

이제는 컴포넌트에서 리덕스 스토어에 접근하여 원하는 상태를 받아 오고, 또 액션도 디스패치해 줄 차례이다. 리덕스 스토어와 연동된 컴포넌트를 컨테이너 컴포넌트라고 부른다.
 
[CounterContainer 만들기]
src 디렉터리에 containers 디렉터리를 생성하고, 그 안에 CounterContainer 컴포넌트를 만든다.

  • containers/CounterContainer.js
import React from 'react';
import Counter from '../components/Counter';

const CounterContainer = () => {
  return <Counter />;
};

export default CounterContainer;

위 컴포넌트를 리덕스와 연동하려면 react-redux에서 제공하는 connect 함수를 사용해야 한다. 이 함수는 다음과 같이 사용한다.

connect(mapStateToProps, mapDispatchToProps)(연동할 컴포넌트)

여기서 mapStateToProps는 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수이고, mapDispatchToProps는 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수다.
이렇게 connect 함수를 호출하고 나면 또 다른 함수를 반환한다. 반환된 함수에 컴포넌트를 파라미터로 넣어 주면 리덕스와 연동된 컴포넌트가 만들어진다.
위 코드를 더 쉽게 풀면 다음과 같은 형태이다.

const makeContainer = connect(mapStateToProps, mapDispatchToProps)
makeContainer(타깃 컴포넌트)

이제 CounterContainer 컴포넌트에서 connect를 사용해 보자.

  • containers/CounterContainer.js
import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';

const CounterContainer = ({ number, increase, decrease }) => {
  return (
    <Counter number={number} onIncrease={increase} onDecrease={decrease} />
  );
};

const mapStateToProps = state => ({
  number: state.counter.number,
});
const mapDispatchToProps = dispatch => ({
  // 임시 함수
  increase: () => {
    console.log('increase');
  },
  decrease: () => {
    console.log('decrease');
  },
});
export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(CounterContainer);

mapStateToProps와 mapDispatchProps에서 반환하는 객체 내부의 값들은 컴포넌트의 props로 전달된다. mapStateToProps는 state를 파라미터로 받아 오며, 이 값은 현재 스토어가 지니고 있는 상태를 가리킨다. mapDispatchToProps의 경우 store의 내장 함수 dispatch를 파라미터로 받아 온다. 현재 mapDispatchToProps에서는 진행 절차를 설명하기 위해 임시로 console.log를 사용하고 있다.
다음으로 App에서 Counter를 CounterContainer로 교체한다.

  • App.js
(...)
import CounterContainer from './containers/CounterContainer';
(...)
      <CounterContainer />
(...)

브라우저를 열어서 +1-1 버튼을 눌러보고 콘솔에 increase와 decrease가 찍히는지 확인한다.
이번에는 console.log 대신 액션 생성 함수를 불러와서 액션 객체를 만들고 디스패치해 주겠다.

  • containers/CounterContainer.js
(...)
import { increase, decrease } from '../modules/counter';
(...)
const mapDispatchToProps = dispatch => ({
  increase: () => {
    dispatch(increase());
  },
  decrease: () => {
    dispatch(decrease());
  },
});
(...)

다시 +1-1 버튼을 눌러 숫자가 바뀌는지 확인한다. 리덕스 개발자 도구도 확인해 본다.
connect 함수를 사용할 때는 일반적으로 위 코드와 같이 mapStateToProps와 mapDispatchToProps를 미리 선언해 놓고 사용한다. 하지만 connect 함수 내부에 익명 함수 형태로 선언해도 문제가 되지 않는다. 어떻게 보면 코드가 더 깔끔해지기도 한다. 취향에 따라 다음과 같이 작성해도 된다.

  • containers/CounterContainer.js
import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';

const CounterContainer = ({ number, increase, decrease }) => {
  return (
    <Counter number={number} onIncrease={increase} onDecrease={decrease} />
  );
};

export default connect(
  state => ({
    number: state.counter.number,
  }),
  dispatch => ({
    increase: () => dispatch(increase()),
    decrease: () => dispatch(decrease()),
  }),
)(CounterContainer);

위 코드에서는 액션 생성 함수를 호출하여 디스패치하는 코드가 한 줄이기 때문에 불필요한 코드 블록을 생략해 주었다. 다음 두 줄의 코드는 작동 방식이 완전히 같다.

increase: () => dispatch(increase()),
increase: () => { return dispatch(increase()) },

컴포넌트에서 액션을 디스패치하기 위해 각 액션 생성 함수를 호출하고 dispatch로 감싸는 작업이 조금 번거로울 수도 있다. 이와 같은 경우에는 리덕스에서 제공하는 bindActionCreators 유틸 함수를 사용하면 간편하다.

  • containers/CounterContainer.js
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';

const CounterContainer = ({ number, increase, decrease }) => {
  return (
    <Counter number={number} onIncrease={increase} onDecrease={decrease} />
  );
};

export default connect(
  state => ({
    number: state.counter.number,
  }),
  dispatch =>
    bindActionCreators(
      {
        increase,
        decrease,
      },
      dispatch,
    ),
)(CounterContainer);

브라우저를 열어서 조금 전과 똑같이 작동하는지 확인한다.
 
방금 작성한 방법보다 한 가지 더 편한 방법이 있다. 바로 mapDispatchToProps에 해당하는 파라미터를 함수 형태가 아닌 액션 생성 함수로 이루어진 객체 형태로 넣어 주는 것이다.

  • containers/CounterContainer.js
import React from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';

const CounterContainer = ({ number, increase, decrease }) => {
  return (
    <Counter number={number} onIncrease={increase} onDecrease={decrease} />
  );
};

export default connect(
  state => ({
    number: state.counter.number,
  }),
  {
    increase,
    decrease,
  },
)(CounterContainer);

위와 같이 두 번째 파라미터를 아예 객체 형태로 넣어 주면 connect 함수가 내부적으로 bindActionCreators 작업을 대신해 준다.
 
[TodosContainer 만들기]
이번에는 Todos 컴포넌트를 위한 컨테이너인 TodosContainer를 작성해 보겠다. connect 함수를 사용하고, mapDispatchToProps를 짧고 간단하게 쓰는 방법을 적용해서 코드를 작성한다.

  • containers/TodosContainer.js
import React from 'react';
import { connect } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todos';
import Todos from '../components/Todos';

const TodosContainer = ({
  input,
  todos,
  changeInput,
  insert,
  toggle,
  remove,
}) => {
  return (
    <Todos
      input={input}
      todos={todos}
      onChangeInput={changeInput}
      onInsert={insert}
      onToggle={toggle}
      onRemove={remove}
    />
  );
};

export default connect(
  // 비구조화 할당을 통해 todos를 분리하여
  // state.todos.input 대신 todos.input을 사용
  ({ todos }) => ({
    input: todos.input,
    todos: todos.todos,
  }),
  {
    changeInput,
    insert,
    toggle,
    remove,
  },
)(TodosContainer);

이전에 todos 모듈에서 작성했던 액션 생성 함수와 상태 안에 있던 값을 컴포넌트의 props로 전달해 주었다.
컨테이너 컴포넌트를 다 만든 후에는 App 컴포넌트에서 보여 주던 Todos 컴포넌트를 TodosContainer 컴포넌트로 교체한다.
그다음에는 Todos 컴포넌트에서 받아 온 props를 사용하도록 구현해 보자.

  • containers/TodosContainer.js
import React from 'react';

const TodoItem = ({ todo, onToggle, onRemove }) => {
  return (
    <div>
      <input
        type="checkbox"
        onClick={() => onToggle(todo.id)}
        checked={todo.done}
        readOnly={true}
      />
      <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
        {todo.text}
      </span>
      <button onClick={() => onRemove(todo.id)}>삭제</button>
    </div>
  );
};

const Todos = ({
  input, // 인풋에 입력되는 텍스트
  todos, // 할 일 목록이 들어 있는 객체
  onChangeInput,
  onInsert,
  onToggle,
  onRemove,
}) => {
  const onSubmit = e => {
    e.preventDefault();
    onInsert(input);
    onChangeInput(''); // 등록 후 인풋 초기화
  };
  const onChange = e => onChangeInput(e.target.value);
  return (
    <div>
      <form onSubmit={onSubmit}>
        <input value={input} onChange={onChange} />
        <button type="submit">등록</button>
      </form>
      <div>
        {todos.map(todo => (
          <TodoItem
            todo={todo}
            key={todo.id}
            onToggle={onToggle}
            onRemove={onRemove}
          />
        ))}
      </div>
    </div>
  );
};

export default Todos;

브라우저에서 할 일 목록 기능이 잘 작동하는지 확인해 본다.

 
<리덕스 더 편하게 사용하기>

이번에는 리덕스를 좀 더 편하게 사용하는 방법을 알아보겠다. 액션 생성 함수, 리듀서를 작성할 때 redux-actions라는 라이브러리와 이전에 배웠던 immer 라이브러리를 활용하면 리덕스를 훨씬 편하게 사용할 수 있다.
 
[redux-actions]
redux-actions를 사용하면 액션 생성 함수를 더 짧은 코드로 작성할 수 있다. 그리고 리듀서를 작성할 때도 switch/case 문이 아닌 handleActions라는 함수를 사용하여 각 액션마다 업데이트 함수를 설정하는 형식으로 작성해 줄 수 있다.
우선 라이브러리를 설치한다.

$ yarn add redux-actions

counter 모듈에 작성된 액션 생성 함수를 createAction이란 함수를 사용하여 만들어 주겠다.

  • modules/counter.js
import { createAction } from 'redux-actions';

const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);

(...)

createAction을 사용하면 매번 객체를 직접 만들어 줄 필요 없이 더욱 간단하게 액션 생성 함수를 선언할 수 있다.
이번에는 리듀서 함수도 더 간단하고 가독성 높게 작성해 보겠다. handleActions라는 함수를 사용한다.

  • modules/counter.js
import { createAction, handleActions } from 'redux-actions';

const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);

const initialState = {
  number: 0,
};

const counter = handleActions(
  {
    [INCREASE]: (state, action) => ({ number: state.number + 1 }),
    [DECREASE]: (state, action) => ({ number: state.number - 1 }),
  },
  initialState,
);

export default counter;

handleActions 함수의 첫 번째 파라미터에는 각 액션에 대한 업데이트 함수를 넣어 주고, 두 번째 파라미터에는 초기 상태를 넣어 준다. 코드가 훨씬 짧아지고 가독성이 높아진 것을 확인할 수 있다.
 
똑같은 작업을 todos 모듈에도 적용해 보자. 먼저 액션 생성 함수를 교체해 줄 텐데, 조금 다른 점이 있다. 바로 각 액션 생성 함수에서 파라미터를 필요로 한다는 점이다.
createAction으로 액션을 만들면 액션에 필요한 추가 데이터는 payload라는 이름을 사용한다. 예를 들면 다음과 같다.

const MY_ACTION = 'sample/MY_ACTION';
const myAction = createAction(MY_ACTION);
const action = myAction('hello world'); 
/*
결과: 
  { type: MY_ACTION, payload: 'hello world' }
*/

액션 생성 함수에서 받아 온 파라미터를 그대로 payload에 넣는 것이 아니라 변형을 주어서 넣고 싶다면, createAction의 두 번째 함수에 payload를 정의하는 함수를 따로 선언해서 넣어 주면 된다.

const MY_ACTION = 'sample/MY_ACTION';
const myAction = createAction(MY_ACTION, text => `${text}!`);
const action = myAction('hello world');
/*
결과: 
  { type: MY_ACTION, payload: 'hello world!' }
*/

 

이제 todos 모듈의 액션 생성 함수를 다음과 같이 새로 작성해 준다.

  • modules/todos.js
import { createAction } from 'redux-actions';

const CHANGE_INPUT = 'todos/CHANGE_INPUT'; // 인풋 값을 변경함
const INSERT = 'todos/INSERT'; // 새로운 todo를 등록함
const TOGGLE = 'todos/TOGGLE'; // todo를 체크/체크 해제함
const REMOVE = 'todos/REMOVE'; // todo를 제거함

export const changeInput = createAction(CHANGE_INPUT, input => input);

let id = 3; // insert가 호출될 때마다 1씩 더해집니다.
export const insert = createAction(INSERT, text => ({
  id: id++,
  text,
  done: false,
}));

export const toggle = createAction(TOGGLE, id => id);
export const remove = createAction(REMOVE, id => id);

(...)

insert의 경우 todo 객체를 액션 객체 안에 넣어 주어야 하기 때문에 두 번째 파라미터에 text를 넣으면 todo 객체가 반환되는 함수를 넣어 주었다.

 

나머지 함수에는 text => text 혹은 id => id와 같은 형태로 파라미터를 그대로 반환하는 함수를 넣었다. 이 작업이 필수는 아니다. 생략해도 똑같이 작동하지만, 여기서 이 함수를 넣어 줌으로써 코드를 보았을 때 이 액션 생성 함수의 파라미터로 어떤 값이 필요한지 쉽게 파악할 수 있다.
액션 생성 함수를 다 작성했으면 handleActions로 리듀서를 재작성해 보겠다. createAction으로 만든 액션 생성 함수는 파라미터로 받아 온 값을 객체 안에 넣을 때 원하는 이름으로 넣는 것이 아니라 action.id, action.todo와 같이 action.payload라는 이름을 공통적으로 넣어 주게 된다. 그렇기 때문에, 기존의 업데이트 로직에서도 모두 action.payload 값을 조회하여 업데이트하도록 구현해 주어야 한다.
액션 생성 함수는 액션에 필요한 추가 데이터를 모두 payload라는 이름으로 사용하기 때문에 action.id, action.todo를 조회하는 대신, 모두 공통적으로 action.payload 값을 조회하도록 리듀서를 구현해 주어야 한다.

  • modules/todos.js
import { createAction, handleActions } from 'redux-actions';

(...)

const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, action) => ({ ...state, input: action.payload }),
    [INSERT]: (state, action) => ({
      ...state,
      todos: state.todos.concat(action.payload),
    }),
    [TOGGLE]: (state, action) => ({
      ...state,
      todos: state.todos.map(todo =>
        todo.id = = = action.payload ? { ...todo, done: !todo.done } : todo,
      ),
    }),
    [REMOVE]: (state, action) => ({
      ...state,
      todos: state.todos.filter(todo => todo.id != = action.id),
    }),
  },
  initialState,
);

export default todos;

모든 추가 데이터 값을 action.payload로 사용하기 때문에 나중에 리듀서 코드를 다시 볼 때 헷갈릴 수 있다. 객체 비구조화 할당 문법으로 action 값의 payload 이름을 새로 설정해 주면 action.payload가 정확히 어떤 값을 의미하는지 더 쉽게 파악할 수 있다.

  • modules/todos.js
(...)
const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, { payload: input }) => ({ ...state, input }),
    [INSERT]: (state, { payload: todo }) => ({
      ...state,
      todos: state.todos.concat(todo),
    }),
    [TOGGLE]: (state, { payload: id }) => ({
      ...state,
      todos: state.todos.map(todo =>
        todo.id = = = id ? { ...todo, done: !todo.done } : todo,
      ),
    }),
    [REMOVE]: (state, { payload: id }) => ({
      ...state,
      todos: state.todos.filter(todo => todo.id != = id),
    }),
  },
  initialState,
);

export default todos;

코드의 가독성이 더 높아진 것을 확인할 수 있다.
 
[immer]
리듀서에서 상태를 업데이트할 때는 불변성을 지켜야 하기 때문에 앞에서는 spread 연산자(…)와 배열의 내장 함수를 활용했다. 그러나 모듈의 상태가 복잡해질수록 불변성을 지키기가 까다로워진다.
따라서 모듈의 상태를 설계할 때는 객체의 깊이가 너무 깊어지지 않도록 주의해야 한다. 깊은 객체와 깊지 않은 객체를 한번 비교해 보자.

const deepObject = {
  modal: {
    open: false,
    content: {
      title: '알림',
      body: '성공적으로 처리되었습니다.',
      buttons: {
        confirm: '확인',
        cancel: '취소',
    },
  },
},
  waiting: false,
  settings: {
    theme: 'dark',
    zoomLevel: 5,
},
};
const shallowObject = {
  modal: {
    open: false,
    title: '알림',
    body: '성공적으로 처리되었습니다.',
    confirm: '확인',
    cancel: '취소',
},
  waiting: false,
  theme: 'dark',
  zoomLevel: 5
}

객체의 깊이가 깊지 않을수록 추후 불변성을 지켜 가면서 값을 업데이트할 때 수월하다. 하지만 상황에 따라 상태 값들을 하나의 객체 안에 묶어서 넣는 것이 코드의 가독성을 높이는 데 유리하며, 나중에 컴포넌트에 리덕스를 연동할 때도 더욱 편하다.
객체의 구조가 복잡해지거나 객체로 이루어진 배열을 다룰 경우, immer를 사용하면 훨씬 편리하게 상태를 관리할 수 있다.
우선 immer를 현재 프로젝트에 설치해 준다.

$ yarn add immer

counter 모듈처럼 간단한 리듀서에 immer를 사용하면 오히려 코드가 더 길어지기 때문에 todos 모듈에 적용해 보겠다.

  • modules/todos.js
import { createAction, handleActions } from 'redux-actions';
import produce from 'immer';

(...)

const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, { payload: input }) =>
      produce(state, draft => {
        draft.input = input;
      }),
    [INSERT]: (state, { payload: todo }) =>
      produce(state, draft => {
        draft.todos.push(todo);
      }),
    [TOGGLE]: (state, { payload: id }) =>
      produce(state, draft => {
        const todo = draft.todos.find(todo => todo.id = = = id);
        todo.done = !todo.done;
      }),
    [REMOVE]: (state, { payload: id }) =>
      produce(state, draft => {
        const index = draft.todos.findIndex(todo => todo.id = = = id);
        draft.todos.splice(index, 1);
      }),
  },
  initialState,
);

export default todos;

immer를 사용한다고 해서 모든 업데이트 함수에 immer를 적용할 필요는 없다. 일반 자바스크립트로 처리하는 것이 더 편할 때는 immer를 적용하지 않아도 된다. 예를 들어 위 코드에서 TOGGLE을 제외한 업데이트 함수들은 immer를 쓰지 않는 코드가 오히려 더 짧기 때문에 이전 형태를 유지해도 무방하다.

 
<Hooks를 사용하여 컨테이너 컴포넌트 만들기>

리덕스 스토어와 연동된 컨테이너 컴포넌트를 만들 때 connect 함수를 사용하는 대신 react-redux에서 제공하는 Hooks를 사용할 수도 있습니다.
 
[useSelector로 상태 조회하기]
useSelector Hook을 사용하면 connect 함수를 사용하지 않고도 리덕스의 상태를 조회할 수 있다. useSelector의 사용법은 다음과 같다.

const 결과 = useSelector(상태 선택 함수);

여기서 상태 선택 함수는 mapStateToProps와 형태가 똑같다. 이제 CounterContainer에서 connect 함수 대신 useSelector를 사용하여 counter.number 값을 조회함으로써 Counter에게 props를 넘겨준다.

  • containers/CounterContainer.js
import React from 'react';
import { useSelector } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';

const CounterContainer = () => {
  const number = useSelector(state => state.counter.number);
  return <Counter number={number} />;
};

export default CounterContainer;

 

[useDispatch를 사용하여 액션 디스패치하기]
이번에는 useDispatch라는 Hook에 대해 알아보자. 이 Hook은 컴포넌트 내부에서 스토어의 내장 함수 dispatch를 사용할 수 있게 해 준다. 컨테이너 컴포넌트에서 액션을 디스패치해야 한다면 이 Hook을 사용하면 된다. 사용법은 다음과 같다.

const dispatch = useDispatch();
dispatch({ type: 'SAMPLE_ACTION' });

이제 CounterContainer에서도 이 Hook을 사용하여 INCREASE와 DECREASE 액션을 발생시켜 보자.

  • containers/CounterContainer.js
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';

const CounterContainer = () => {
  const number = useSelector(state => state.counter.number);
  const dispatch = useDispatch();
  return (
    <Counter
      number={number}
      onIncrease={() => dispatch(increase())}
      onDecrease={() => dispatch(decrease())}
    />
  );
};

export default CounterContainer;

이렇게 코드를 작성하고 +1과 -1 버튼을 눌러서 숫자가 잘 바뀌는지 확인한다.
지금은 숫자가 바뀌어서 컴포넌트가 리렌더링 될 때마다 onIncrease 함수와 onDecrease 함수가 새롭게 만들어지고 있다.
만약 컴포넌트 성능을 최적화해야 하는 상황이라면 useCallback으로 액션을 디스패치하는 함수를 감싸 주는 것이 좋다.
다음과 같이 코드를 한번 수정해 보자.

  • containers/CounterContainer.js
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease } from '../modules/counter';

const CounterContainer = () => {
  const number = useSelector(state => state.counter.number);
  const dispatch = useDispatch();
  const onIncrease = useCallback(() => dispatch(increase()), [dispatch]);
  const onDecrease = useCallback(() => dispatch(decrease()), [dispatch]);
  return (
    <Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease} />
  );
};

export default CounterContainer;

useDispatch를 사용할 때는 이렇게 useCallback과 함께 사용하는 습관을 들일 것을 권한다.
 
[useStore를 사용하여 리덕스 스토어 사용하기]
useStore Hooks를 사용하면 컴포넌트 내부에서 리덕스 스토어 객체를 직접 사용할 수 있습니다. 사용법은 다음과 같다.

const store = useStore();
store.dispatch({ type: 'SAMPLE_ACTION '});
store.getState();

useStore는 컴포넌트에서 정말 어쩌다가 스토어에 직접 접근해야 하는 상황에만 사용해야 한다. 이를 사용해야 하는 상황은 흔치 않을 것이다.
 
[TodosContainer를 Hooks로 전환하기]
이제 TodosContainer를 connect 함수 대신에 useSelector와 useDispatch Hooks를 사용하는 형태로 전환해 보자.

  • containers/TodosContainer.js
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todos';
import Todos from '../components/Todos';

const TodosContainer = () => {
  const { input, todos } = useSelector(({ todos }) => ({
    input: todos.input,
    todos: todos.todos
  }));
  const dispatch = useDispatch();
  const onChangeInput = useCallback(input => dispatch(changeInput(input)), [
    dispatch
  ]);
  const onInsert = useCallback(text => dispatch(insert(text)), [dispatch]);
  const onToggle = useCallback(id => dispatch(toggle(id)), [dispatch]);
  const onRemove = useCallback(id => dispatch(remove(id)), [dispatch]);

  return (
    <Todos
      input={input}
      todos={todos}
      onChangeInput={onChangeInput}
      onInsert={onInsert}
      onToggle={onToggle}
      onRemove={onRemove}
    />
  );
};

export default TodosContainer;

이번에는 useSelector를 사용할 때 비구조화 할당 문법을 활용했다.
또한, useDispatch를 사용할 때 각 액션을 디스패치하는 함수를 만들었다. 위 코드의 경우 액션의 종류가 많은데 어떤 값이 액션 생성 함수의 파라미터로 사용되어야 하는지 일일이 명시해 주어야 하므로 조금 번거롭다. 이 부분은 우선 컴포넌트가 잘 작동하는 것을 확인하고 나서 한번 개선해 보겠다. 코드를 저장하고 TodosContainer가 잘 작동하는지 확인한다.
 
[useActions 유틸 Hook을 만들어서 사용하기]
useActions는 원래 react-redux에 내장된 상태로 릴리즈 될 계획이었으나 리덕스 개발 팀에서 꼭 필요하지 않다고 판단하여 제외된 Hook이다. 그 대신 공식 문서에서 그대로 복사하여 사용할 수 있도록 제공하고 있다.

이 Hook을 사용하면, 여러 개의 액션을 사용해야 하는 경우 코드를 훨씬 깔끔하게 정리하여 작성할 수 있다.
src 디렉터리에 lib 디렉터리를 만들고, 그 안에 useActions.js 파일을 다음과 같이 작성해 보자.

  • lib/useActions.js
import { bindActionCreators } from 'redux';
import { useDispatch } from 'react-redux';
import { useMemo } from 'react';

export default function useActions(actions, deps) {
  const dispatch = useDispatch();
  return useMemo(
    () => {
      if (Array.isArray(actions)) {
        return actions.map(a => bindActionCreators(a, dispatch));
      }
      return bindActionCreators(actions, dispatch);
    },
    deps ? [dispatch, ...deps] : deps
  );
}

방금 작성한 useActions Hook은 액션 생성 함수를 액션을 디스패치하는 함수로 변환해 준다. 액션 생성 함수를 사용하여 액션 객체를 만들고, 이를 스토어에 디스패치하는 작업을 해 주는 함수를 자동으로 만들어 주는 것이다.
useActions는 두 가지 파라미터가 필요하다. 첫 번째 파라미터는 액션 생성 함수로 이루어진 배열이다. 두 번째 파라미터는 deps 배열이며, 이 배열 안에 들어 있는 원소가 바뀌면 액션을 디스패치하는 함수를 새로 만들게 된다.
 
한번 TodoContainer에서 useActions를 불러와 사용해 보자.

  • containers/TodoContainer.js
import React from 'react';
import { useSelector } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todos';
import Todos from '../components/Todos';
import useActions from '../lib/useActions';

const TodosContainer = () => {
  const { input, todos } = useSelector(({ todos }) => ({
    input: todos.input,
    todos: todos.todos
  }));

  const [onChangeInput, onInsert, onToggle, onRemove] = useActions(
    [changeInput, insert, toggle, remove],
    []
  );
  return (
    <Todos
      input={input}
      todos={todos}
      onChangeInput={onChangeInput}
      onInsert={onInsert}
      onToggle={onToggle}
      onRemove={onRemove}
    />
  );
};

export default TodosContainer;

코드를 저장한 뒤, TodoListContainer가 잘 작동하는지 다시 확인한다.
 
[connect 함수와의 주요 차이점]
앞으로 컨테이너 컴포넌트를 만들 때 connect 함수를 사용해도 좋고, useSelector와 useDispatch를 사용해도 좋다. 리덕스 관련 Hook이 있다고 해서 기존 connect 함수가 사라지는 것은 아니므로, 더 편한 것을 사용하면 된다.
하지만 Hooks를 사용하여 컨테이너 컴포넌트를 만들 때 잘 알아 두어야 할 차이점이 있다. connect 함수를 사용하여 컨테이너 컴포넌트를 만들었을 경우, 해당 컨테이너 컴포넌트의 부모 컴포넌트가 리렌더링 될 때 해당 컨테이너 컴포넌트의 props가 바뀌지 않았다면 리렌더링이 자동으로 방지되어 성능이 최적화된다.
반면 useSelector를 사용하여 리덕스 상태를 조회했을 때는 이 최적화 작업이 자동으로 이루어지지 않으므로, 성능 최적화를 위해서는 다음과 같이 React.memo를 컨테이너 컴포넌트에 사용해 주어야 한다. 

  • containers/CounterContainer.js
import React from 'react';
import { useSelector } from 'react-redux';
import { changeInput, insert, toggle, remove } from '../modules/todos';
import Todos from '../components/Todos';
import useActions from '../lib/useActions';

const TodosContainer = () => {
  (...)
};

export default React.memo(TodosContainer);

물론 지금과 같은 경우에는 TodosContainer의 부모 컴포넌트인 App 컴포넌트가 리렌더링 되는 일이 없으므로 불필요한 성능 최적화이다.
 


 

 


Quiz

1. (___)란 리액트 상태 관리 라이브러리이다.
2. 상태에 어떤 변화가 필요하면 (__)이 발생한다. 이는 하나의 객체로 표현된다. 
3. (__ __ __)는 (2번 정답) 객체를 만들어 주는 함수이다.
4. (___)는 변화를 일으키는 함수이다. 
5. 프로젝트에 (4번 정답)를 적용하기 위해 (___)를 만든다. 한 개의 프로젝트는 단 하나의 (___)만 가질 수 있다.
6. (____)는 (5번 정답)의 내장 함수 중 하나로  '액션을 발생시키는 것'이라고 이해하면 된다. 이 함수가 호출되면 (5번 정답)는 (4번 정답) 함수를 실행시켜서 새로운 상태를 만들어 준다.
7. (__)도 (5번 정답)의 내장 함수 중 하나이다. (__) 함수 안에 리스너 함수를 파라미터로 넣어서 호출해 주면, 이 리스너 함수가 액션이 (6번 정답) 되어 상태가 업데이트될 때마다 호출된다.


 
1. 액션 타입과 액션 생성 함수를 정의하고 초깃값을 설정했다. 이어서 함수의 파라미터로 state와 action 값을 받아오는 리듀서 함수를 정의해 보세요.

const divToggle = document.querySelector('.toggle');
const counter = document.querySelector('h1');
const btnIncrease = document.querySelector('#id');
const btnDecrease = document.querySelector('#decrease');

const TOGGLE_SWITCH = 'TOGGLE_SWITCH';
const INCREASE = 'INCREASE';
const DECREASE = 'DECREASE';

const toggleSwitch = () => ({ type: TOGGLE_SWITCH });
const increase = difference => ({ type: INCREASE, difference });
const decrease = () => ({ type: DECREASE });

const initialState = {
  toggle: false,
  counter: 0
};

 
2. 아래 코드를 immer를 사용할 경우 어떻게 수정하면 되는지 작성해 보세요.

(...)
const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, { payload: input }) => ({ ...state, input }),
    [INSERT]: (state, { payload: todo }) => ({
      ...state,
      todos: state.todos.concat(todo),
    }),
    [TOGGLE]: (state, { payload: id }) => ({
      ...state,
      todos: state.todos.map(todo =>
        todo.id = = = id ? { ...todo, done: !todo.done } : todo,
      ),
    }),
    [REMOVE]: (state, { payload: id }) => ({
      ...state,
      todos: state.todos.filter(todo => todo.id != = id),
    }),
  },
  initialState,
);

export default todos;

 
 


 
리덕스(redux) / 액션(action) / 액션 생성 함수 / 리듀서(reducer) / 스토어(store) / 디스패치(dispatch) / 구독(subscribe)
 


1.

function reducer(state = initialState, action) {
  // action.type에 따라 다른 작업을 처리함
  switch (action.type) {
    case TOGGLE_SWITCH:
      return {
        ...state, // 불변성 유지를 해 주어야 합니다.
        toggle: !state.toggle
      };
    case INCREASE:
      return {
        ...state,
        counter: state.counter + action.difference
      };
    case DECREASE:
      return {
        ...state,
        counter: state.counter - 1
      };
    default:
      return state;
  }
}

2.

import { createAction, handleActions } from 'redux-actions';
import produce from 'immer';

(...)

const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, { payload: input }) =>
      produce(state, draft => {
        draft.input = input;
      }),
    [INSERT]: (state, { payload: todo }) =>
      produce(state, draft => {
        draft.todos.push(todo);
      }),
    [TOGGLE]: (state, { payload: id }) =>
      produce(state, draft => {
        const todo = draft.todos.find(todo => todo.id = = = id);
        todo.done = !todo.done;
      }),
    [REMOVE]: (state, { payload: id }) =>
      produce(state, draft => {
        const index = draft.todos.findIndex(todo => todo.id = = = id);
        draft.todos.splice(index, 1);
      }),
  },
  initialState,
);

export default todos;

 
출처: 김민준, 『리액트를 다루는 기술』, 길(2023).
Editor: minyong
 

728x90

관련글 더보기