React를 다루는 기술 17 - Redux를 이용한 상태관리

6 minute read

리액트 프로젝트에서의 상태관리는 소규모라면 state로도 충분하지만 규모가 커지면 상태관리가 곤란한 경우가 생기는데 그럴 때 리덕스를 사용하면 이하와 같은 장점이 있다.

  1. 상태업데이트 로직을 모듈로 따로 분리가능하여 유지보수에 도움이 된다
  2. 컴포넌트끼리 동일한 상태 공유에 유용하다
  3. 실제 업데이트가 필요한 컴포넌트만 렌더링되도록 최적화가 용이하다

프레젠테이셔널 컴포넌트

리덕스를 쓸 때 가장 많이 쓰는 패턴은 프레젠테이셔널, 컨테이너 컴포넌트 분리하는 것인데
프레젠테이셔널은 상태관리 없이 props를 받아 보여주기만 하는 컴포넌트를 뜻한다.

import React from 'react';

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

export default Counter;
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=>
        {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;

위와 같이 할 일을 기록하는 Todo 컴포넌트와 +,-를 클릭하면 숫자가 바뀌는 Counter 컴포넌트가 있다.
이 두 컴포넌트에 리덕스를 적용하고자 하는데 리덕스 사용시 작성해야하는 것으로는 액션타입, 액션생성함수, 리듀서 코드 이렇게 3개가 존재한다.
이 3개의 코드를 작성하는 방법도 2개로 나뉘어지는데 각각 다른 폴더에 다른 파일로 작성하는 스탠다드한 방법과 3가지 코드를 하나의 파일에 합쳐서 작성하고 기능별로 구분하는 Ducks패턴이 있다.
스탠다드한 방법의 경우 종류에 따라 구분하여 정리 할 수 있다는 장점이 있으나 수정이 필요할 시 각각의 파일을 모두 수정해야하는 불편함이 존재한다.
Ducks패턴은 이를 보완하기 위한 방법이나 어느 쪽이든 자유롭게 선택하면 된다.

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

    액션타입의 값은 모듈이름/액션이름으로 작성하는데 이는 프로젝트가 커져도 액션이 충돌하지 않게 하기 위함이다.

  2. 액션생성함수
    export const changeInput = input => ({
      type: CHANGE_INPUT,
      input
    })
    

    파라미터가 필요한 기능일 경우 액션생성함수의 파라미터를 통해 전달할 수 있다.

  3. 초기상태 및 리듀서
    ```jsx //초기상태 const initialState = { input: ‘’, todos: [ { id: 1, text: ‘리덕스 기초’, done: true } ] }

function todos(state = initialState, action) { switch (action.type) { case CHANGE_INPUT: return { …state, input: action.input } [….] } }

initialState 객체를 통해 초기상태를 설정해주고 액션타입명을 통해 호출할 리듀서 함수를 작성한다.  
이 때 상태를 변화시킬 경우 불변성의 법칙을 유지하기 위해 spread 연산을 사용하였다.  

4. 루트리듀서  
```jsx
import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';

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

export default rootReducer;

이제 스토어를 생성해야하는데 스토어를 생성할 때는 하나의 리듀서만을 사용해야한다.
그래서 여러 개의 리듀서를 만들었을 경우 combineReducers라는 함수로 리듀서들을 하나로 합쳐주는 과정을 거치게 된다.

  1. 스토어 생성과 Provider 컴포넌트 사용
    ```jsx import React from ‘react’; import ReactDOM from ‘react-dom’; import { createStore } from ‘redux’; import { Provider } from ‘react-redux’; import { composeWithDevTools } from ‘redux-devtools-extension’; import ‘./index.css’; import App from ‘./App’; import * as serviceWorker from ‘./serviceWorker’; import rootReducer from ‘./modules’;

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

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

serviceWorker.unregister();


컨테이너 컴포넌트에서 리듀서 사용하기  
--------------------------------------

컴포넌트와 리덕스를 연동하려면 connect함수 사용해야하며 `connect(mapStateToProps, mapDispatchToProps)(컴포넌트)`과 같은 형태로 사용한다.  
이 때 들어가는 두 파라미터는  
mapStateToProps : 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위한 용도     
mapDispatchToProps : 액션생성함수를 컴포넌트의 props로 넘겨주기 위한 용도     
로 사용되며 connect함수는 함수를 반환하기 때문에 뒤에 붙은 컴포넌트는 해당 반환된 함수에 파라미터로 들어갔다는 뜻이다.    
```jsx
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} />
  );
};


const mapStateToProps = state => ({
  number: state.counter.number
})

const mapDispatchToProps = dispatch => ({
  increase: () => {
    dispatch(increase())
  },
  decrease: () => {
    dispatch(decrease())
  }
})

export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(TodosContainer);

위와 같이 connect 함수를 통해 두 함수를 생성한 후 액션생성함수를 import하여 dispatch를 통해 상태 업데이트를 진행한다.
connect 함수의 mapStateToProps, mapDispatchToProps함수는 기본적으로 컴포넌트 바깥에 꺼내놓고 사용하지만 connect 함수 내부에서 선언해도 무방하다.

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

그런데 이렇게 액션생성함수를 호출하고 dispatch로 감싸는 작업은 함수가 많아질수록 하기 힘들어지는데 이러한 경우에는 bindActionCreators함수를 사용하면 된다.

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

여기서 더 간소화시키면 mapDispatchToProps에 함수가 아닌 액션생성함수로 이루어진 객체로 넣어주면 되는데 내부에서 자동으로 bindActionCreators함수를 적용하기 때문이다.

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

redux-actions

액션생성함수와 리듀서를 더 간단하게 작성 할 수 있게 해주는 라이브러리이다.

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

액션생성함수에 파라미터를 넣어줄 경우에는 타입명과 함께 파라미터로 넣어주면 되며 파라미터 값을 변형하고 싶은 경우에는 함수로 넣어주면 된다.

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

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

createAction 함수로 생성한 액션을 리듀서에서 사용할 때 파라미터는 action.id, action.name과 같이 action.payload라는 이름을 사용하게 되는데 이렇게 모든 파라미터명이 action.payload면 헷갈릴 수 있기 때문에 객체 비구조화 할당 문법으로 새로 설정하게 된다.

const todos = handleActions(
  {
    [CHANGE_INPUT]: (state, { payload: input }) =>({
      ...state, input: input
    })
    [INSERT]: (state, { payload: todo }) => ({
      ...state,
      todos: state.todos.concat(todo),
    }),
    [....]

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

Hooks를 이용한 리덕스 사용

리액트에 Hooks가 추가되면서 리덕스에서도 Hooks를 이용하는 기능이 추가되었다.

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;

useSelector를 통해 상태를 조회하고 useDispatch를 통하여 액션을 디스패치 할 수 있다.
dispatch의 경우에는 최적화를 진행하기 위해 useCallback을 사용한 상태이다.

이 2가지 Hooks를 사용하지 않고 store에 다이렉트로 접근할 수 있는 useStore라는 Hooks도 존재하나 기본적으로는 사용하지 않는다.

또한 원래 리덕스에 포함될 예정이었으나 리덕스팀에서 불필요하다고 판단하여 제외된 useActions가 존재하는데 이 경우 공식문서에서 코드를 복사하여 사용이 가능하다.
useAction은 일일히 액션을 생성하여 디스패치 할 함수를 생성하는 작업을 진행해주기 때문에 다수의 액션을 동시에 생성하고자 할 때 유용하다.

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

// container
[...]
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}
    />
  );
};
[...]

connect와 Hooks의 차이점

connect로 컴포넌트를 감싸서 생성하였을 경우 부모가 리렌더링 될 때 props가 바뀌지 않았다면 자동으로 리렌더링이 방지된다.
useSelector의 경우에는 수동으로 useMemo를 통해 최적화를 진행해주어야한다.

출처자료

https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=209354690 (리액트를 다루는 기술(개정판))

Categories:

Updated: