redux와 react-redux

이지훈·2021년 2월 28일
5

공부한것들

목록 보기
8/15
post-custom-banner

시작

항상 리덕스와 관련된 이야기가 나왔다. 지금까지 그렇게 깊이 state를 관리할 만한 구성을 해보지 않아서 redux의 도입을 시도하진 않았다. redux를 써보려 해도 복잡한 구조 때문에 포기하곤 했는데 이번 기회에 공부해보기로 했다.
사실 리덕스를 정리해보려고 했지만 예제를 참고하다보니 벨로퍼트와 함께하는 모던 리액트를 따라하게 됐다. 이 강의 후기라고 봐도 좋을 것같다. 마침 함수형 컴포넌트와 훅을 사용하는 방법으로 예제가 구성되어있어 더욱 좋다.

Redux

리덕스는 왜 필요할까

리액트 컴포넌트 사이의 상태 공유
리액트를 생각해보면 컴포넌트간에 어떤 값을 공유하려고하면 부모-자식 컴포넌트간에만 주고 받을 수 있기에 멀리 떨어진, 깊이가 많이 차이나는 컴포넌트간에 값을 공유하려면 그 중간에 필요없는 컴포넌트들까지 모두 거쳐가야하는 문제가 있었다. 그것을 redux를 통해 해결할 수 있다.

Redux 공식 홈페이지의 메인이다

많은 장점이 있지만 그 중 상태 컨테이너라는 말과 중앙화된 이라는 말은 이해가 쉬울 것 같다.
중앙화된은 단 하나의 store에서 값을 저장하겠다는 것이고,
상태 컨테이너는 그 store에 담긴 값이 상태(state)이고 그것을 redux가 관리하겠다는 것이다.

상태 (state)

state를 쉽게 생각하자면 값이 저장된 객체이다.

  • 리액트
    리액트에서 state는 컴포넌트 내부에서 관리하는 값이다. (참고-리액트 문서)
    컴포넌트에서 자식 컴포넌트로 전달되는 props와는 다르게 state는 컴포넌트 내부에서 관리된다.

  • 리덕스
    리덕스에도 state를 사용한다. 리덕스는 store라는 어플리케이션에서 단 하나만 존재하는 저장공간에서 이 state를 관리한다.

리액트 컴포넌트의 state가 컴포넌트 내에서만 쓰이는 지역변수같은 느낌이라면 리덕스의 state는 전역으로 쓰이는 느낌이다. 다만 일반 변수처럼 막 접근할 수 있는 것은 아니다.

리덕스에는 reducer라는 것을 통해서 state에 특정 가공을 할 수 있다. 이 reducer는 순수함수여야 하며, 기존의 state를 사용하는 것이 아니라 기존 값이 가공된 새로운 state를 반환하게 된다.

action

만약 리덕스의 상태에 변화가 필요한 일이 생기면, dispatch라는 함수를 통해 액션을 스토어에게 던져준다. 액션은 상태에 변화를 일으킬 때 참조할 수 있고 필수적으로 type이라는 값을 가지고 있어야 한다. type 외에도 다른값을 사용 할 수 있다.
만약 일정한 diff라는 값을 더하는 액션이 있다고 하면
{ type: 'INCREMENT', diff: 2 }
이렇게 만들 수 있다.

그런데 매번 이렇게 액션을 만들어서 보내기엔 귀찮으니 액션 생성함수를 만들어서 사용한다.
export const increase = () => ({ type: INCREASE });
이런식으로 해서 import하여 사용하면 편리할것이다.

리듀서

리듀서는 이전 상태와 action을 받아서 새로운 상태를 반환하는 순수함수이다.
액션을 정의해두고, dispatch를통해 액션타입을 받으면 state에 해당 액션을 처리한 후 새로운 state를 반환해주는 것이 리듀서인것이다.

맨 처음에는 state 값이 없으므로 초기값을 정할 수도 있다.

조건

앞서 나오기도 했지만 리덕스 사용에는 몇몇 조건이 필요하다. 벨로퍼트 모던 리액트에서는 3가지 규칙으로 소개한다.

  1. 애플리케이션 안에는 하나의 스토어가 있다.
    여러개를 사용하면 안되냐? 쓸 수는 있지만 권장되지는 않는다. 그러니까 리덕스에서는 단하나의 스토어만 사용하도록 권장하고 있는것이다. 특정 업데이트가 너무 빈번하게 발생하거나 개발도구 사용에 지장이 있다고 한다.

  2. 상태는 읽기전용이다.
    리액트 컴포넌트의state또한 setState를 사용하면 기존의 객체는 건드리지 않고 새로운 배열을 만들어서 교체하는 방식으로 업데이트 하곤 한다.
    리덕스에서도 마찬가지로 기존의 상태는 그대로 두고 새로운 상태를 생성하여서 업데이트 해주게 된다.

  3. 변화를 일으키는 함수, 리듀서는 순수한 함수여야 한다.
    순수함수란 동일한 인자가 주어지면 항상 동일한 결과를 반환하며, 외부의 상태를 변경하지 않는 함수이다.

reducer가 순수함수여야 하는 것과 state가 읽기 전용인 것은 리덕스가 불변성을 유지하는 것과 관련이 있다. 리덕스에서 불변성을 유지해야 하는 이유는 내부적으로 데이터가 변경 되는 것을 감지할 때 shallow equality 검사를 하기 때문이다. 값 하나하나를 모두 비교하자면 전체 객체가 커질 때 비용이 많이 발생하게 되어 주소값으로 비교를 하게되는데 이에 따라 순수함수가 아니면 다른 객체로 인식되어 계속 렌더링이 되거나 하는 비용의 낭비가 발생하기 때문인 것 같다. 벨로퍼트씨의 말에 따르면 객체의 깊숙한 안쪽까지 비교를 하는 것이 아닌 겉핥기 식으로 비교를 하여 좋은 성능을 유지 할 수 있는 것이다.
깊은 비교와 얕은 비교에 대해서는 아직 좀 더 공부해봐야 할 것 같다.

react에서 redux 사용

리덕스가 리액트만을 위한 것은 아니다. 하지만 리액트와 많이 쓰이기도 하고 나도 리액트 프로젝트에서 사용해야 하기 때문에 함께 사용하는방법을 위주로 찾아봤다.

리덕스도 필요하지만 리액트 환경에서 리덕스를 사용할 수 있도록 도움을 주는 도구가 있는데 바로 react-redux이다.

CRA로 생성한 프로젝트에 redux와 react-redux를 함께 설치하여 사용했다.

리액트 컴포넌트

이 강의에서는, + / - 버튼으로 숫자를 더하고 빼는 counter와 할 일을 추가하고 완료여부를 클릭으로 변경하는 todos라는 컴포넌트를 사용할것이다.

HOC 또는 presentational-container components 라고 불리는 방식을 사용한다. 이는 간단하게 렌더링을 담당하는 기본 리액트 컴포넌트의 기능은 해당 컴포넌트에 그대로 두고, 리덕스에 종속적인 기능들은 Container라는 상위 컴포넌트로 만들어 분리하는 것이다.
이렇게 하면 Container에서는 리덕스만 신경쓰고 받아온 값이나 dispatch한 액션이 어떻게 쓰이는지는 몰라도 된다. 반대로 하위에 있는 리액트 컴포넌트에서는 그 값이 어디서왔는지는 몰라도 그냥 props로 받아서 자기 할 일만 하면 된다.

그럼 우리에게 필요한건 Counter 컴포넌트와 Todos 컴포넌트이니 이를 감싸줄 Container로 CounterContainer 와 TodosContainer를 만들어준다.

Counter.js

import React from 'react';

const Counter = ({ number, diff, onIncrease, onDecrease, onSetDiff }) => {
  const onChange = e => {
    // e.target.value 의 타입은 문자열이기 때문에 숫자로 변환해주어야 합니다.
    onSetDiff(parseInt(e.target.value, 10));
  };
  return (
    <div>
      <h1>{number}</h1>
      <div>
        <input type="number" value={diff} min="1" onChange={onChange} />
        <button onClick={onIncrease}>+</button>
        <button onClick={onDecrease}>-</button>
      </div>
    </div>
  );
}

export default Counter;

CounterContainer.js

import React from 'react';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import Counter from '../components/Counter';
import { increase, decrease, setDiff } from '../modules/counter';

const CounterContainer = () => {
  // useSelector는 리덕스 스토어의 상태를 조회하는 Hook입니다.
  // state의 값은 store.getState() 함수를 호출했을 때 나타나는 결과물과 동일합니다.
  const { number, diff } = useSelector(state => ({
    number: state.counter.number,
    diff: state.counter.diff
  }), shallowEqual);

  // useDispatch 는 리덕스 스토어의 dispatch 를 함수에서 사용 할 수 있게 해주는 Hook 입니다.
  const dispatch = useDispatch();
  // 각 액션들을 디스패치하는 함수들을 만드세요
  const onIncrease = () => dispatch(increase());
  const onDecrease = () => dispatch(decrease());
  const onSetDiff = diff => dispatch(setDiff(diff));

  return (
    <Counter
      // 상태와
      number={number}
      diff={diff}
      // 액션을 디스패치 하는 함수들을 props로 넣어줍니다.
      onIncrease={onIncrease}
      onDecrease={onDecrease}
      onSetDiff={onSetDiff}
    />
  );
}

export default CounterContainer;

Todos.js

import React, { useState } from 'react';

// 컴포넌트 최적화를 위하여 React.memo를 사용합니다
const TodoItem = React.memo(function TodoItem({ todo, onToggle }) {
  return (
    <li
      style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
      onClick={() => onToggle(todo.id)}
    >
      {todo.text}
    </li>
  );
});

// 컴포넌트 최적화를 위하여 React.memo를 사용합니다
const TodoList = React.memo(function TodoList({ todos, onToggle }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onToggle={onToggle} />
      ))}
    </ul>
  );
});

function Todos({ todos, onCreate, onToggle }) {
  // 리덕스를 사용한다고 해서 모든 상태를 리덕스에서 관리해야하는 것은 아닙니다.
  const [text, setText] = useState('');
  const onChange = e => setText(e.target.value);
  const onSubmit = e => {
    e.preventDefault(); // Submit 이벤트 발생했을 때 새로고침 방지
    onCreate(text);
    setText(''); // 인풋 초기화
  };

  return (
    <div>
      <form onSubmit={onSubmit}>
        <input
          value={text}
          placeholder="할 일을 입력하세요.."
          onChange={onChange}
        />
        <button type="submit">등록</button>
      </form>
      <TodoList todos={todos} onToggle={onToggle} />
    </div>
  );
}

export default Todos;

TodosContainer.js

import React, { useCallback } from 'react';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import Todos from '../components/Todos';
import { addTodo, toggleTodo } from '../modules/todos';

function TodosContainer() {
    // useSelector 에서 꼭 객체를 반환 할 필요는 없습니다.
    // 한 종류의 값만 조회하고 싶으면 그냥 원하는 값만 바로 반환하면 됩니다.
    const todos = useSelector(state => state.todos);
    const dispatch = useDispatch();
  
    const onCreate = text => dispatch(addTodo(text));
    const onToggle = useCallback(id => dispatch(toggleTodo(id)), [dispatch]); // 최적화를 위해 useCallback 사용
  
    return <Todos todos={todos} onCreate={onCreate} onToggle={onToggle} />;
  }
  
  export default TodosContainer;

reducer 생성

state에 다양한 가공을 거쳐야 하는데 이때 필요한 게 리듀서이다. 리듀서가 하나만 있을 수도 있겠지만 다양한 기능을 위해서 여러개가 필요할 수도 있다. 그래서 이번에는 여러 리듀서를 만들고 그것을 combineReducers로 묶어 하나의 루트리듀서를 만들어 사용할 것이다.

각 리듀서와 리듀서에 필요한 액션 타입, 액션 생성함수 및 초기상태까지 모두 하나의 파일로 관리하는 Ducks 패턴을 따라서 한다. Ducks에서는 이렇게 모아놓은 하나의 파일을 module이라고 한다.

먼저 counter에 필요한 리듀서이다.

/* 액션 타입 만들기 */
// Ducks 패턴을 따를땐 액션의 이름에 접두사를 넣어주세요.
// 이렇게 하면 다른 모듈과 액션 이름이 중복되는 것을 방지 할 수 있습니다.
const SET_DIFF = 'counter/SET_DIFF';
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

/* 액션 생성함수 만들기 */
// 액션 생성함수를 만들고 export 키워드를 사용해서 내보내주세요.
export const setDiff = diff => ({ type: SET_DIFF, diff })
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });

/* 초기 상태 선언 */
const initialState = {
    number: 0,
    diff: 1
};

/* 리듀서 선언 */
// 리듀서는 export default 로 내보내주세요.
const counter = (state = initialState, action ) =>{
    switch(action.type) {
        case SET_DIFF:
            return{
                ...state,
                diff: action.diff
            };
        case INCREASE:
            return {
                ...state,
                number: state.number + state.diff
            };
        case DECREASE:
        return {
            ...state,
            number: state.number - state.diff
        };
        default: 
            return state;
    }
}
export default counter;

그리고 todos 리듀서이다.

/* 액션 타입 선언 */
const ADD_TODO = 'todos/ADD_TODO';
const TOGGLE_TODO = 'todos/TOGGLE_TODO';

/* 액션 생성함수 선언 */
let nextId = 1; // todo 데이터에서 사용 할 고유 id
export const addTodo = text => ({
  type: ADD_TODO,
  todo: {
    id: nextId++, // 새 항목을 추가하고 nextId 값에 1을 더해줍니다.
    text
  }
});
export const toggleTodo = id => ({
  type: TOGGLE_TODO,
  id
});

/* 초기 상태 선언 */
// 리듀서의 초기 상태는 꼭 객체타입일 필요 없습니다.
// 배열이여도 되고, 원시 타입 (숫자, 문자열, 불리언 이여도 상관 없습니다.
const initialState = [
  /* 우리는 다음과 같이 구성된 객체를 이 배열 안에 넣을 것입니다.
  {
    id: 1,
    text: '예시',
    done: false
  } 
  */
];

const todos = (state = initialState, action) => {
  switch (action.type) {
    case ADD_TODO:
      return state.concat(action.todo);
    case TOGGLE_TODO:
      return state.map(
        todo =>
          todo.id === action.id // id 가 일치하면
            ? { ...todo, done: !todo.done } // done 값을 반전시키고
            : todo // 아니라면 그대로 둠
      );
    default:
      return state;
  }
}
export default todos;

그리고 이것을 묶어줄 루트리듀서를 index.js라는 파일에 만들었다.

//여기가 루트 리듀서가 된다.
// combineReducers를 사용해서 모듈 디렉토리에 만든 여러 리덕스 모듈들을 합친다.

import { combineReducers } from 'redux';
import counter from './counter';
import todos from './todos';

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

export default rootReducer;

store

리덕스를 사용하려면 state를 저장해둘 store가 있어야 한다. 별다른 건 없고 redux에서 제공하는 createStore를 사용하면 된다. 또한 react-redux에서는 Provider라는 것을 제공하는데 이 Provider로 최상위 컴포넌트 (보통 App)를 감싸면 컴포넌트마다 일일이 store를 가져오지 않아도 우리가 createStore로 생성한 store를 하위 컴포넌트에서 접근할 수 있게 해준다.

//index.js
...
import {createStore} from 'redux';
import {Provider} from 'react-redux';

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

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

이러한 형태가 된다.
createStore의 첫번째 인자는 우리가 여러 리듀서를 하나로 묶어 만든 리듀서이다.
두번째 인자는 크롬 확장 프로그램인 리듀서 개발도구를 사용하기 위한 것이다.

실행하면 잘 동작하는것을 알 수 있다.

끝내며

많은 블로그를 참고했지만 결국 정착한 곳은 벨로퍼트씨의 블로그였다. 리액트에 관해서는 정말 많은 도움을 받고 있다.
처음에 접근할 때는 생활코딩의 강의를 많이 참고했지만, 함수형으로 구현하려고 했기 때문에 클래스기반으로 되어있어 조금은 어려웠다. 훅을 사용하는것도 달랐고. 그런데 벨로퍼트씨의 모던 리액트 강의에서는 함수형으로 훅을 사용하는 예제와 함께 자세히 나와있어서 잘 따라할 수 있었다.

출처

  • 생활코딩
    초보자를 위해서 설명도 자세히 해주시고 목소리도 듣기좋아 거부감이 없다.
  • 벨로퍼트와 함께하는 모던 리액트
    리덕스 뿐 아니라 리액트 전체적인 내용을 알아보기에 좋다. 시간이 난다면 전체 강의내용을 한번 따라가봐도 좋을 것 같다.

더 공부해야 할 것

  • 리덕스 미들웨어
  • 리액트의 memo 또는 리덕스의 shallowEqual 등 렌더링 최적화 방법.

리액트에 적용되는 리덕스에 대해 어느정도는 알게 되었지만 아직 완벽하게 이해하고 적용하기는 조금 어려울 것 같다. 예제나 실제 프로젝트에 적용해서 사용해보면 감이 잡힐 것 같다. 그리고 아직 redux-thunk나 redux-saga같은 미들웨어에 대해서도 공부해야한다. 비동기 처리라던지 어떤 것들에 있어서 미들웨어가 필요하고 또 어떻게 사용해야하는지 알아봐야겠다.

리덕스가 어렵다고는 생각했지만 생각보다 더 어려운 것 같다.

profile
안녕하세요! 대학교 졸업한 이지훈입니다.
post-custom-banner

0개의 댓글