개발 공부 기록하기/07. react.js & vue.js

리액트 리덕스 정리

lannstark 2020. 12. 14. 12:02

리액트 생태계에서 가장 사용률이 높은 상태관리 라이브러리, 컴포넌트의 상태 관련 로직들을 다른 파일들로 분리시켜 더욱 효율적으로 관리할 수 있고, 글로벌 상태 관리도 손쉽게 할 수 있다.

Context API와 useReducer 훅을 사용해 개발하는 흐름과 리덕스를 사용하는 방식이 매우 유사하다.

Context API와 리덕스 차이

미들웨어

리덕스의 미들웨어를 사용하면 액션 객체가 리듀서에서 처리되기 전에 우리가 원하는 작업들을 수행할 수 있다.

  • 특정 조건에 따라 액션 무시
  • 액션을 콘솔에 출력 혹은 서버쪽에 로깅
  • 액션이 디스패치 되었을 때 이를 수정해서 리듀서에게 전달
  • 특정 액션이 발생했을 때 이에 기반하여 다른 액션 발생
  • 특정 액션이 발생했을 때 특정 JS 함수 실행

유용한 함수와 Hooks

react-redux의 여러 hook 들은 최적화가 잘 되어 있지만 Context API는 그렇지 못하다

하나의 커다란 상태

리덕스에서는 모든 글로벌 상태를 하나의 커다란 상태 객체에 넣어서 사용한다

리덕스 개념

액션

상태에 어떤 변화가 필요할 때 액션이란 것을 발생시킨다. 액션은 하나의 객체로 표현된다.

액션은 type 필드를 필수적으로 가지고 있어야 하고 그 외의 값들은 개발자 마음대로 넣을 수 있다.

{
  type: "TOGGLE_VALUE",
  data: {
    id : 0,
    text: "마음대로 넣을 수 있다 꼭 data가 아니어도 된다"
  }
}

액션 생성함수 (Action Creator)

액션을 만드는 함수. 단순히 파라미터를 받아와서 액션 객체 형태로 만들어준다

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

필수적이지는 않고, 액션을 발생시킬 때마다 직접 액션 객체를 작성할 수 있다. (유틸성 함수 느낌)

리듀서 (Reducer)

변화를 일으키는 함수

function reducer(state, action) {
  // 상태 업데이트 로직
  return alteredState;
}

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

useReducer에서는 default:에서 Error를 던져 주었지만 리덕스에서는 state를 그대로 반환해야 한다.

여러 리듀서를 만들고 합칠 수 있다.

스토어 (Store)

리덕스에서는 한 애플리케이션 당 하나의 스토어를 만들게 된다. 스토어 안에는 현재의 앱 상태와 리듀서가 들어 있고, 추가적으로 몇 가지 내장함수들이 있다.

  • dispatch : 내장함수 중 하나로, 액션을 발생시키는 것이라고 이해하면된다. dispatch(action)
  • subscribe : 내장함수 중 하나로, 함수 형태의 값을 파라미터로 받아온다. subscribe 함수에 특정 함수를 전달하면 액션이 디스패치 되었을 때마다 전달해준 함수가 호출된다.

리액트 규칙

  1. 하나의 애플리케이션 안에는 하나의 스토어가 있다.
  2. 상태는 읽기 전용이다 (push 등을 사용하면 안된다)
  3. 변화를 일으키는 함수, 리듀서는 순수한 함수여야 한다
    • 이전 상태와 액션 객체를 파라미터로 받는다
    • 이전의 상태는 절대로 건들이지 않고 변화를 일으킨 새로운 상태 객체를 만들어 반환한다
    • 똑같은 파라미터로 호출된 리듀서 함수는 언제나 똑같은 결과값을 반환해야 한다

리덕스 모듈

리덕스 모듈이란 다음 항목들이 모두 들어간 자바스크립트 파일을 의미합니다.

  • 액션 타입
  • 액션 생성함수
  • 리듀서

리듀서와 액션 관련 코드들을 어떻게 배치하냐에 따라 여러 패턴이 있는데 Ducks 패턴이란 하나의 파일에 리듀서와 액션 관련 코드들을 넣는 방식을 의미한다.

count 모듈

// 액션 타입
const SET_DIFF = "counter/SET_DIFF";
const INCREASE = "counter/INCREASE";

// 액션 생성 함수
export const setDiff = diff => ({ type: SET_DIFF, diff });
export const increase = () = ({ type: INCREASE });

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

// 리듀서 선언
export default function counter(state = initialState, action) {
  switch (action.type) {
    case SET_DIFF:
      return {
        ...state,
        diff: action.diff
      };
    case INCREASE:
      return {
        ...state,
        number: state.number + state.diff
      }
    default:
      return state;
  }
}

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++,
    text
  }
});

export const toggleTodo = id => ({
  type: TOGGLE_TODO,
  id
});

// 초기 상태
const initialState = [];

// 리듀서
export default function todos(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO:
      return state.concat(action.todo);
    case TOGGLE_TODO:
      todo => todo.id === action.id
        ? { ...todo, done: !todo.done }
        : todo
    default:
      return state;
  }
}

루트 리듀서

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

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

export default rootReducer;

스토어 만들기, react-redux 사용

import { createStore } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules';

const store = createStore(rootReducer); // 스토어를 만듭니다.

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

리덕스 사용 패턴

Presentational Component

  • 리덕스 스토어에 직접적으로 접근하지 않고 필요한 값 또는 함수를 props 만으로 받아와 사용하는 컴포넌트
  • UI를 선언하는 것에 집중한다

Container Component

  • 리덕스 스토어의 상태를 조회하거나, 액션을 디스패치할 수 있는 컴포넌트
  • HTML 태그들을 사용하지 않고 다른 Presentational Component들을 불러와 사용한다

리액트 컴포넌트에서 리덕스를 사용할 때 꼭 Presentational Component를 구분할 필요는 없다.

예시 - Counter 구현

Presentational Component

import React from 'react';

function Counter({ number, diff, onIncrease, onDecrease, onSetDiff }) {
  const onChange = e => {
    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;

Container Component

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

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

  // useDispatch 는 리덕스 스토어의 dispatch 를 함수에서 사용 할 수 있게 해주는 Hook이다.
  const dispatch = useDispatch();
  // 각 액션을 dispacth 하는 함수
  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}
    />
  );
}

리덕스 개발자 도구

크롬 extension 설치 : https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd/related

import { composeWithDevTools } from 'redux-devtools-extension'; // 리덕스 개발자 도구

const store = createStore(rootReducer, composeWithDevTools()); // 스토어를 만들때 설정

Presentational Component에서 React.memo를 사용해 리렌더링 최적화를 할 수 있다.

Container Component에서는 react-redux의 shallowEqual 함수를 useSelector의 두 번째 인자로 전달해 줄 수 있다.

const { number, diff } = useSelect(
  state => ({
    number: state.counter.number,
    diff: state.counter.diff
  }),
  shallowEqual // 가장 겉에 있는 값들만 비교한다
)

2019년 이전에 작성된 리덕스와 연동된 컴포넌트들은 connect 로 작성되었다.

함수형 컴포넌트를 만들 때에는 connect를 사용할 일이 별로 없지만 기존 컴포넌트를 쓸 때는 봐야 할 수 도 있다 (나는 일단 패스...)

리덕스 미들웨어

리덕스가 가지고 있는 핵심 기능, Context API 또는 MobX를 사용하는 것과 차별화가 된다.

리덕스 미들웨어를 사용하면 액션이 dispatch 된 다음 해당 액션을 받아와 update 하기 전에 추가적인 작업을 할 수 있다. 보통 리덕스에서 미들웨어를 사용하는 주된 사용 용도는 비동기 작업을 처리 할 때이다. 예를 들어 리액트 앱에서 backend API를 연동해야 하는 경우.

일반적으로 리덕스 미들웨어 라이브러리를 설치해서 사용한다. redux-saga 등이 그 예이고, redux-thunk와 redux-saga가 제일 많이 사용된다.

// 미들웨어 구조
function middleware(store) {
  return function (next) {
    return function (action) {
      // 하고 싶은 작업...
    };
  };
};
  • store : 리덕스 스토어 인스턴스로 dispatch, getState, subscribe 와 같은 내장함수들이 들어있다.
  • next : 액션을 다음 미들웨어에 전달하는 함수 next(action) 처럼 사용하면 된다. 미들웨어가 없다면 리듀서에게 액션을 전달하고, next를 호출하지 않으면 액션이 무시되어 리듀서에게로 전달되지 않는다.
  • action : 현재 처리하고 있는 액션 객체

커스텀 미들웨어 만들고 적용해보기

const myLogger = store => next => action => {
  console.log(action);
  const result = next(action);
  return result;
}

const store = createStore(rootReducer, applyMiddleware(myLogger));

redux-logger

import logger from 'redux-logger';

// 여러개의 미들웨어를 적용 할 수 있습니다.
const store = createStore(rootReducer, applyMiddleware(logger));

만약 dev tool과 미들웨어를 함께 사용한다면

import logger from 'redux-logger';
import { composeWithDevTools } from 'redux-devtools-extension';

const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(logger))
); // 여러개의 미들웨어를 적용 할 수 있습니다.

와 같이 사용할 수 있다.

redux-thunk

redux-thunk를 사용하면, 액션 생성함수에서 액션 객체가 아닌 함수를 반환할 수 있다.

원래 액션 생성 함수에서는 object 만을 반환했는데, 이제는 액션 생성 함수 내에서 function을 반환할 수 있게 된 것이다. 이 function에는 dispatch 함수랑 getState 함수가 parameter로 주입될 수 있다 (사용, 미사용 선택

프로미스를 다루는 리덕스 모듈을 다룰 땐 다음과 같은 사항을 고려해야 한다.

  1. 프로미스가 시작, 성공, 실패했을 때 다른 액션을 디스패치해야 한다.
  2. 각 프로미스마다 thunk 함수를 만들어 주어야 한다
  3. 리듀서에서 액션에 따라 로딩중, 결과, 에러 상태를 변경해 주어야 한다.
const sleep = n => new Promise(resolve => setTimeout(resolve, n));

const posts = [];

export const getPosts = async () => {
  await sleep(500);
  return posts;
}
import * as postsApi from '../api/posts'; // 위의 getPosts, getPostById를 불러온다

// 포스트 여러개 조회하기
const GET_POSTS = 'GET_POSTS'; // 요청 시작
const GET_POSTS_SUCCESS = 'GET_POSTS_SUCCESS'; // 요청 성공
const GET_POSTS_ERROR = 'GET_POSTS_ERROR'; // 요청 실패

// thunk를 사용할 때 꼭 모든 액션들에 대해 액션 생성함수를 만들 필요는 없다
// thunk 함수에서 바로 액션 객체를 만들어 주어도 된다.
export const getPosts = () => async dispatch => {
  dispatch({ type: GET_POSTS }); // 요청이 시작됨
  try {
    const posts = await postsAPI.getPosts(); // API 호출
    dispatch({ type: GET_POSTS_SUCCESS, posts }); // 성공
  } catch (e) {
    dispatch({ type: GET_POSTS_ERROR, error: e }); // 실패
  }
};

const initialState = {
  posts: {
    loading: false,
    data: null,
    error: null
  }
}

// reducer combineReducers({ ..., posts }); 로 rootReducer에 등록된다
export default function posts(state = initialState, action) {
  switch (action.type) {
    case GET_POSTS:
      return {
        ...state,
        posts: {
          loading: true,
          data: null,
          error: null
        }
      };
    case GET_POSTS_SUCCESS:
      return {
        ...state,
        posts: {
          loading: true,
          data: action.posts,
          error: null
        }
      }
  }
}

(5. redux-thunk 예제의 문제점이 두 가지 있다)

  1. 특정 포스트를 열은 다음에 뒤로 갔을 때, 포스트 목록을 또 다시 불러오며 로딩중이 나타난다
  2. 특정 포스트를 읽고 뒤로 간 다음에 다른 포스트를 열면 이전에 열었던 포스트가 잠깐 보여졌다가 로딩중이 보여지게 된다.

→ API 재로딩 문제

전체목록 API 재로딩 되는 문제를 해결하는 방법은 두 가지가 있다.

  1. 만약 데이터가 이미 존재한다면 요청을 하지 않는다.
  2. 로딩을 새로하긴하는데 로딩중...을 띄우지 않는다

특정 포스트 재로딩 해결 방법

  1. 컴포넌트가 언마운트될 때 포스트 내용을 비우도록 하는 것
// 액션 타입 - 포스트 비우기
const GET_POST_ERROR = 'GET_POST_ERROR';

// thunk 함수 혹은 액션 객체 생성 함수
export const clearPost = () => ({ type: CLEAR_POST });

// 리듀서
export default function posts(state = initialState, action) {
  switch (action.type) {
    case CLEAR_POST:
      return {
        ...state,
        post: reducerUtils.initial()
      }
  }
}

/* Component: PostContainer */
function PostContainer({ postId }) {
  useEffect(() => {
    dispatch(getPost(postId));
    // returng 함수 : 언마운트 될 때 수행된다
    return () => {
      dispatch(clearPost());
    };
  }, [postId, dispatch]);
}

이렇게 되었을 때 한 가지 아쉬운 문제가 존재한다. 읽었던 포스트를 불러오려 할 때에도 새로 요청을 하는 것이다. 이 문제를 개선하기 위해서는 posts에서 관리하는 상태의 구조를 바꿔야 한다.

post 객체가 들어 있는게 아니라 post 안에 id를 key로 하고 나머지 field에 대한 object를 value로 하는 dictionary 형태로 바껴야 한다. 그에 맞춰서 리덕스 쪽 코드도 변경이 되어야 하고, Component도 바껴야 한다.

function PostContainer({ postId }) {
  const { data, loading, error } = useSelector(
    state => state.posts.post[postId]
  ) || {
    loading: false,
    data: null,
    error: null
  }; // 아예 데이터가 존재하지 않을 때가 있으므로, 비구조화 할당이 오류나지 않도록
  const dispatch = useDispatch();

  useEffect(() => {
    if (data) return; // 포스트가 존재하면 아예 요청을 하지 않음
    dispatch(getPost(postId));
  }, [postId, dispatch, data]);
}

thunk에서 라우터 연동하기

Container Component 내에서 그냥 단순히 withRouter를 사용해서 props로 history를 사용해도 상관은 없다.

// index.js 
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules';
import logger from 'redux-logger';
import { composeWithDevTools } from 'redux-devtools-extension';
import ReduxThunk from 'redux-thunk';
import { Router } from 'react-router-dom';
import { createBrowserHistory } from 'history';

const customHistory = createBrowserHistory();

const store = createStore(
  rootReducer,
  // logger 를 사용하는 경우, logger가 가장 마지막에 와야합니다.
  composeWithDevTools(
    applyMiddleware(
      ReduxThunk.withExtraArgument({ history: customHistory }),
      logger
    )
  )
); // 여러개의 미들웨어를 적용 할 수 있습니다.

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

serviceWorker.unregister();
// 리듀서 쪽 코드에서 세 번째 인자를 사용할 수 있다.
export const goToHome = () => (dispatch, getState, { history }) => {
  history.push('/');
};

CORS

원래는 서버에 데이터를 요청할 때 포트만 달라도 CORS 정책에 의거하여 서버측의 Allow가 필요하다. 하지만 웹팩 개발 서버의 프록시를 사용하면, 개발 환경에서는 CORS 정책 설정을 별도로 하지 않아도 된다.

CAR를 통해 만든 리액트 프로젝트에서는 package.json 값으로 "proxy" 값을 설정해 쉽게 적용할 수 있다.

{
  "proxy": "http://localhost:4000"
}
axios.defaults.baseURL = process.env.NODE_ENV === 'development' ? '/' : 'https://api.velog.io/';

redux-saga

redux-thunk 다음으로 가장 많이 사용되는 라이브러리

redux-saga의 경우엔, 액션을 모니터링하고 있다가, 특정 액션이 발생하면 이에 따라 특정 작업을 하는 방식으로 사용한다. 여기서 특정 작업이란, 특정 JS를 실행하는 것일수도 있고, 다른 액션을 dispatch 하는 것일 수도 있고 현재 상태를 불러오는 것일 수도 있다. redux-saga를 사용하면 다음과 같은 일을 할 수 있다.

  1. 비동기 작업을 할 때 기존 요청을 취소 처리 할 수 있다.
  2. 특정 액션이 발생했을 때 이에 따라 다른 액션이 디스패치되게끔 하거나, JS 코드를 실행할 수 있다.
  3. 웹소켓을 사용하는 경우 Channel 이라는 기능을 사용하여 더욱 효율적으로 코드를 관리할 수 있다.
  4. API 요청이 실패했을 때 재요청하는 작업을 할 수 있다.

JS Generator 문법

이 문법의 핵심 기능은 함수를 작성할 때에 함수를 특정 구간에 멈춰놓을 수도 있고, 원할 때 다시 돌아가게 할 수도 있다. 그리고 결과값을 여러번 반환할 수도 있다.

function weirdFunction() {
  return 1;
  return 2;
  return 3;
}

이 함수는 무조건 1을 반환한다.

function* generatorFunction() {
  console.log("안녕하세요?");
  yield 1;
  console.log("제네레이터 함수");
  yield 2;
  return 3;
}

하지만 이 함수를 호출 했을 때는 한 객체가 반환된다. 바로 실행되는 것이 아니다. 그 객체를 Generator라고 부른다.

const generator = generatorFunction();

// 이를 호출하면 yield 값을 반환하고 코드의 흐름을 멈춘다
// 그리고 다시 next를 호출하면 흐름이 이어서 다시 시작된다.
generator.next();
function* sumGenerator() {
  let a = yield; // generator.next(value)에서 value가 a에 들어오게 된다.
}
import { delay, put } from 'redux-saga/effects';

// 액션 타입
const INCREASE = 'INCREASE';
const DECREASE = 'DECREASE';

// 액션 생성함수
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });

function* increaseSaga() {
  yield delay(1000); // 1초를 기다립니다.
  yield put(increase()); // put은 특정 액션을 디스패치 해줍니다.
}

function* decreaseSaga() {
  yield delay(1000); // 1초를 기다립니다.
  yield put(decrease()); // put은 특정 액션을 디스패치 해줍니다.
}

export function* counterSaga() {
  yield takeEvery(INCREASE_ASYNC, increaseSaga); // 모든 INCREASE_ASYNC 액션을 처리
  yield takeLatest(DECREASE_ASYNC, decreaseSaga); // 가장 마지막으로 디스패치된 DECREASE_ASYNC 액션만을 처리
}
  • put : 이 함수를 통하여 새로운 액션을 디스패치할 수 있다.
  • takeEvery : 특정 액션 타입에 대하여 디스패치되는 모든 액션들을 처리
  • takeLatest : 특정 액션 타입에 대하여 디스패치된 가장 마지막 액션만을 처리하는 함수. 예를 들어 특정 액션을 처리하고 있는 동안 동일한 타입의 새로운 액션이 디스패치되면 기존에 하던 작업을 무시 처리하고 새로운 작업을 시작한다.

루트 사가

import { combineReducers } from 'redux';
import counter, { counterSaga } from './counter';
import posts from './posts';
import { all } from 'redux-saga/effects';

const rootReducer = combineReducers({ counter, posts });
export function* rootSaga() {
  yield all([counterSaga()]); // all 은 배열 안의 여러 사가를 동시에 실행시켜줍니다.
}

export default rootReducer;
// index.js
const sagaMiddleware = createSagaMiddleware(); // 사가 미들웨어를 만듭니다.

const store = createStore(
  rootReducer,
  // logger 를 사용하는 경우, logger가 가장 마지막에 와야합니다.
  composeWithDevTools(
    applyMiddleware(
      ReduxThunk.withExtraArgument({ history: customHistory }),
      sagaMiddleware, // 사가 미들웨어를 적용하고
      logger
    )
  )
); // 여러개의 미들웨어를 적용 할 수 있습니다.

sagaMiddleware.run(rootSaga); // 루트 사가를 실행해줍니다.
// 주의: 스토어 생성이 된 다음에 위 코드를 실행해야합니다.