[TIL] 2022/03/29

yongkini ·2022년 3월 29일
0

Today I Learned

목록 보기
128/173

Today I Learned

Redux의 철학을 코드로 표현해보기

function createStore(worker) {
  let state;
  let handlers = [];

  function send(action) {
    state = worker(state, action);
    handlers.forEach((handler) => handler());
  }

  function getState() {
    return state;
  }

  function subscribe(handler) {
    handlers.push(handler);
  }

  return { send, getState, subscribe };
}

function worker(state = { count: 0 }, action) {
  switch (action.type) {
    case "INCREASE":
      return { ...state, count: state.count + 1 };
    default:
      return { ...state };
  }
}

const store = createStore(worker);

store.subscribe(function () {
  console.log(store.getState());
});

store.send({ type: "INCREASE" });
store.send({ type: "INCREASE" });
store.send({});
store.send({});

사실상 리덕스를 일반 코드로 표현해보면 위와 같을 것이다. send는 우리가 흔히 아는 dispatch일 것이고, 그 안에 액션을 넣어주면, 그에 따른 액션을 dispatch한다. 그에 따라 store안에 전역 state가 변하게 된다. 이 때, state를 변화시키는 worker는 계속해서 새로운 객체를(새로운 참조값) 리턴하고, type에 따라 그 변화 양상을 달리한다. 그러면 위의 코드를 실제 '리덕스'에서 사용하는 용어들을 바탕으로 리팩토링 해보자.

순서대로
1) App.js

import { createStore } from "./redux.js";
import { reducer } from "./reducer.js";
import { increase, decrease, reset } from "./actions.js";

const store = createStore(reducer);

store.subscribe(function () {
  console.log(store.getState());
});

store.dispatch(increase(1));
store.dispatch(increase(2));
store.dispatch(reset());
store.dispatch(decrease(1));
store.dispatch(decrease(2));

2) action-type.js

export const INCREASE = "increase";
export const DECREASE = "decrease";
export const RESET = "reset";

3) actions.js

import { INCREASE, DECREASE, RESET } from "./action-type.js";
import { actionCreator } from "./redux.js";

export const increase = actionCreator(INCREASE);
export const decrease = actionCreator(DECREASE);
export const reset = actionCreator(RESET);

4) reducer.js

const initialState = { counter: 0 };

export function reducer(state = initialState, action) {
  switch (action.type) {
    case "increase":
      return { ...state, count: state.count + action.payload };
    case "decrease":
      return { ...state, count: state.count - action.payload };
    case "reset":
      return { ...state, count: 0 };
    default:
      return { ...state };
  }
}

5) redux.js

export const actionCreator = (type) => (payload) => ({
  type,
  payload,
});

export function createStore(reducer) {
  let state;
  let handlers = [];

  function dispatch(action) {
    state = reducer(state, action);
    handlers.forEach((handler) => handler());
  }

  function getState() {
    return state;
  }

  function subscribe(handler) {
    handlers.push(handler);
  }

  return { dispatch, getState, subscribe };
}

6) index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script src="App.js" type="module"></script>
</body>

</html>

사실 위의 구조로 봤을 때 redux 패턴을 쓰기 시작하면, 'boiler plate'가 많아지는, 즉, 오히려 코드가 번거롭고, 복잡해지는 것처럼 보인다. 하지만, 가장 핵심적인 state(전역)를 바꿔주는 reducer 부분은 상대적으로 간단하고, 이에 따라 전역적인 상태 관리를 심플하게 해주는 redux의 장점을 알 수 있다.

Reducer는 순수함수인데 그 안에서 비동기 처리를 한다?

const initialState = { counter: 0 };

export function reducer(state = initialState, action) {
  switch (action.type) {
    case "increase":
      return { ...state, count: state.count + action.payload };
    case "decrease":
      return { ...state, count: state.count - action.payload };
    case "reset":
      fetch("domain/reset").then((result) => {
        if (result.response === 200) return { ...state, count: 100 };
        else return { ...state, count: 0 };
      });
    default:
      return { ...state };
  }
}

만약에 아까 그 리듀서에서 fetch로직이 들어간다고 해보자(비동기) 그러면, 그 api의 결과에 따라 결과값이 달라지는, 즉, 순수하지 않은 reducer가 된다. 또한, 우리는 reducer를 통해 새로운 state를 받고 있는데, async, await 로직을 정해주지 않은

  function dispatch(action) {
    state = reducer(state, action);
    handlers.forEach((handler) => handler());
  }

이곳에서는 위의 로직대로라면 state에 undefined가 들어가게 될 것이다. 이러한 이유로 redux에서는 비동기 로직을 처리할 때 '미들웨어'를 제공함으로서 reducer를 순수함수로 유지하면서도 비동기 작업을 할 수 있게 해준다. 그리고 우리가 자주 쓰는 리덕스 미들웨어로 redux-thunk, redux-saga가 있는 것이다.

Redux-MiddleWare의 원리를 일반 코드로 표현해보기

1) App.js 의 createStore 부분

const middleWare1 = (store) => (dispatch) => (action) => {
  console.log("m1=> ", action);
  dispatch(action);
};
const middleWare2 = (store) => (dispatch) => (action) => {
  console.log("m2=> ", action);
  dispatch(action);
};
const middleWare3 = (store) => (dispatch) => (action) => {
  if (action.type === ASYNC_INCREASE_COUNTER) {
    console.log("activated");
    action.type = SET_COUNTER;
    action.payload = 100;
  }

  dispatch(action);
};

const store = createStore(reducer, [middleWare1, middleWare2, middleWare3]);

2) redux.js 부분

export const actionCreator = (type) => (payload) => ({
  type,
  payload,
});

export function createStore(reducer, middleWares = []) {
  let state;
  const handlers = [];

  function dispatch(action) {
    state = reducer(state, action);
    handlers.forEach((handler) => handler());
  }

  function getState() {
    return state;
  }

  function subscribe(handler) {
    handlers.push(handler);
  }
  const store = {
    getState,
    subscribe,
    dispatch,
  };

  let lastDispatch = store.dispatch;
  middleWares = middleWares.reverse().slice();

  middleWares.forEach((middleWare) => {
    lastDispatch = middleWare(store)(lastDispatch);
  });

  store.dispatch = lastDispatch;

  return store;
}

이중에서 핵심은

  let lastDispatch = store.dispatch;
  middleWares = middleWares.reverse().slice();

  middleWares.forEach((middleWare) => {
    lastDispatch = middleWare(store)(lastDispatch);
  });

  store.dispatch = lastDispatch;

이부분이다. dispatch에 로직을 '주입'함으로서 action에 따른 로직을 커스터마이징한다고 생각하면 된다. 일종의 파이프라이닝.

Logger MiddleWare 직접 만들어보기

export const logger = (store) => (next) => (action) => {
  const currentState = store.getState();
  console.groupCollapsed("action logger => " + action.type);
  console.log("current state : " + currentState);
  console.log("action payload : " + action.payload);
  console.groupEnd();
  next(action);
};

위와 같이 미들웨어를 파이프라이닝해주면 action이 발생할 때마다 로그가 찍힌다. 이 때, console.groupCollapsed, console.groupEnd 두개는 콘솔로그가 너무 지저분하게 찍히지 않기 위해 둘 사이에 있는 console.log를 fold, spread해서 볼 수 있도록 해준다.

Redux-MiddleWare의 핵심은 파이프라이닝

Action -> Dispatch -> Store로 흘러가는 플로우 속에서 dispatch에 로직을 추가함으로서 파이프라이닝을 하는 것이다. 이 때, 이걸 쓰는 이유는 순수함수로 유지해야할 reducer 안에서 비동기처리 등의 비순수 로직을 쓰지 않고, 외부에서 미들웨어를 만들어서, createStore를 할 때 넣어주고, dispatch에 커링 함수 원리를 이용해서 로직을 섞어줌으로서 쓸 수 있게 해주는 것이다.

profile
완벽함 보다는 최선의 결과를 위해 끊임없이 노력하는 개발자

0개의 댓글