Redux 분석하기


Redux는 Event Sourcing패턴과 Functional programming을 결합하여 라이브러리 형태로 구현한 컨테이너다. 이 컨테이너는 애플리케이션의 상태를 저장하고, 쉽게 예측할 수 있도록 하여 일관된 구현을 유지할 수 있도록 하고, 테스트, 유지보수, 디버깅 등을 손쉽게 처리할 수 있도록 한다.

Redux는 "Flux의 구현체다/아니다"로 많은 이야기가 있었지만, 결론적으로 말하면 Redux는 Flux의 영향을 받아 새로 구현한 컨테이너 라이브러리이며, "Flux의 구현체다/아니다"는 크게 중요하지 않다(엄밀하게 보면 Redux는 Flux가 아니다). 즉, Flux의 구조에 따라 Redux가 어떤식으로 구현되었다라는 것이 중요한게 아니라, "Flux의 큰 특징들이 Redux에 잘 녹아있다."라는 것을 알아야 한다. 조금 더 자세한 내용은 Redux 문서의 Prior Art - flux에서 잘 설명하고 있다.

그리고 Redux는 React와 직접적인 관련이 없다. 단지 잘 어울릴 뿐이며, 잘 어울리도록 만든 라이브러리는 react-redux이다. 이 글은 redux의 구조를 다루고자 하며 Redux의 사용 예시나, React-Redux의 튜토리얼을 설명하지 않는다. 해당 내용은 Redux 문서와 Redux 개발자의 동영상 강의에서 잘 설명하고 있다.

3가지 원칙

Redux의 3가지 기본 원칙을 보자. (Redux 3-principles)

  1. Sinlge source of truth (SSOT)
  2. Read-only state
  3. Changes from pure functions

이 원칙들을 통해 Redux가 관리하는 상태가 읽기 전용의 SSOT이며, 순수 함수를 통해 변화시킬 수 있다는 것을 알 수 있다.

여기에서 의문이 하나 생길 수 있는데, 읽기 전용의 상태를 순수 함수로 변경을 시킨다는게 모순처럼 보일 수 있다는 것이다. 이는 사실 React의 엘리먼트와 유사한 형태로, 모든 변화에 새로운 상태를 만들고 이것으로 교체하는 것이다. Redux의 createStore 코드를 확인해보자.

// Redux - v3.6.0

// Reducer는 순수 함수이며 `(state, action) => state`의 형태로,
// 상태 변화를 일으키려는 액션에 따라 참조가 다른 새로운 상태를 반환한다.
currentState = currentReducer(currentState, action);

redux-1

기본 개념

Redux의 구조를 분석하기 전에 다음 기본 개념들을 숙지해야 한다. 링크된 Redux 문서에서 자세한 내용을 확인하길 바란다.

  1. 액션(Action)(ko): 애플리케이션의 상태를 어떻게 변경시킬지 추상화한 표현이다. 단순 객체(Plain object)로 type 프로퍼티를 꼭 가지고 있어야 한다.
  2. 리듀서(Reducer)(ko): 애플리케이션의 다음 상태를 반환하는 함수이다. 이전 상태와 액션을 받아 처리하고 다음 상태를 반환한다.
  3. 스토어(Store)(ko): 애플리케이션의 상태를 저장하고 읽을 수 있게 하며 액션을 보내거나 상태의 변화를 감지할 수 있도록 API를 제공하는 객체이다.
  4. Redux 용어 사전(ko): Redux에서 사용하는 용어 정의

Redux는 애플리케이션의 상태 관리를 스토어라는 개념으로 추상화하고, 상태 트리를 내부적으로 관리한다. 애플리케이션은 변화를 표현하는 액션을 스토어에 전달하고 스토어는 리듀서를 통해 상태 트리를 형상화하여 변경한다. 그리고 스토어는 다시 애플리케이션에게 상태 트리의 변경을 알린다. 애플리케이션은 상태 트리의 변경을 인지하고 이에 따른 UI 변경이나 다른 서비스 로직을 수행한다.

스토어

스토어는 내부적으로 리듀서와 애플리케이션 상태, 이벤트 리스너 그리고 현재 디스패칭(Dispatching) 여부를 나타내는 값(isDispatching: boolean)을 관리한다. 외부적으로는 dispatch, subscribe, getState, replaceReducer API를 노출한다.

Redux는 다음과 같은 단방향 데이터 흐름을 가지고 있다. (참고: Redux 데이터 흐름(ko))

redux-2

다음은 스토어(createStore)의 간단한 구현을 표현한다. 단방향 데이터 흐름을 이해하고 보면 아래 구현은 크게 어렵지 않다.

/**
 * createStore API 내부
 *   (root)리듀서와 초기 상태를 인자로 받는다.
 **/

// 모듈 패턴 - private 변수
(private) currentState: Object,
(private) currentReducer: Function - (state, action) => state,
(private) listeners: Array.<Function>
(private) isDispatching: Boolean

// API:
//   현재 상태를 반환
getState() {
  return currentState;
}

// API:
//  Change listener를 등록
subscribe(listener) {
  listeners.push(listener);

  return function unsubscribe() {
    var index = listeners.indexOf(listener);
    listeners.splice(index, 1);
  }
}

// API:
//  액션을 받아 리듀서로 처리한다. 다음 3가지의 동작으로 분리해볼 수 있다.
//    1. 전처리: Action의 유효성, 현재 Dispatching 여부
//    2. 리듀서: currentReducer에서 반환하는 다음 상태를 적용
//    3. 이벤트: 등록된 리스너들을 순서대로 수행
dispatch(action) {
  if (!isPlainObject(action)) throw Error
  if (typeof action.type === 'undefined') throw Error
  if (isDispatching) throw Error

  // isDispatching은 리듀서에서 다시 dispatch를 호출하는 경우를 막기위해 사용한다.
  try {
    isDispatching = true;
    currentState = currentReducer(currentState, action);
  } finally {
    isDispatching = false;
  }

  // `slice`는 리스너에서 subscribe와 unsubscribe API를 사용하는 경우,
  // 현재 리스너 수행에 영향을 주지 않기 위함이다.
  //
  // 하지만 `slice` 비용은 비싸기 때문에 실제 구현은 조금 다르며, 동작 자체는 같다.
  listeners.slice().forEach(listener => listener());

  return action;
}

// API:
//   코드 분리(Code splitting), 핫-리로딩(hot reloading) 등의 기법이나,
//   리듀서 자체를 동적으로 사용할 때 필요할 수 있다.
replaceReducer(reducer) {
  currentReducer = reducer;

  dispatch({type: INIT});
}

// 상태 초기화 코드 수행
dispatch({type: INIT});

// API 반환
return {
  getState,
  subscribe,
  dispatch,
  replaceReducer
};

스토어는 nextListenerscurrentListeners로 리스너 목록의 변경에 대한 상태를 분리해서 slice에 대한 비용을 줄였다. 구현 코드(commit)를 통해 확인해 볼 수 있다.

Redux의 스토어는 결국 이 createStore 함수를 기반으로 동작하기 때문에 고차 함수(High order function)를 적용할 수 있다(Redux에서는 enhancer라 부른다). 그래서 아래와 같은 적용이 가능하다.

const enhancer = createStore => {
  //.. enhance logics
  return createStore(reducer, initialState, enhancer);
};

const createStoreEnhanced = enhancer(createStore);
const enhancedStore = createStoreEnhanced(reducer, initialState);

// 또는 아래처럼 enhancer를 인자로 사용할 수도 있다.

const enhancedStore = createStore(reducer, initialState, enhancer);

그리고 Redux는 기본적으로 applyMiddleware라는 enhancer를 제공한다. 다음 미들웨어를 알아보자.

미들웨어

Redux의 미들웨어는 dispatching 과정에서 액션이 리듀서에 도달하기 전 애플리케이션의 로직이 끼어들 수 있는 틈을 만들어준다.

compose

미들웨어를 분석하기 위해서 먼저 함수 중첩(compose)을 이해해야 한다. Redux는 이 compose를 메인 API로 노출하고 있지만 Redux와는 독립적인 유틸성에 가까운 함수로 앞서 설명한 고차 함수를 구현할 수 있도록 도와준다.

f, g, h라는 함수가 있고, f와 g는 앞선 함수의 반환 값을 인자로 받도록 정의되어 있을 때,
아래와 같은 형태를 생각해볼 수 있다.

   x = h(...args)
   y = g(x) = g(h(...args))
   z = f(y) = f(g(x)) = f(g(h(...args)))

최종적으로 (...args)를 가지고 f, g, h를 적절히 조합하여 z 결과값을 얻어야 할때,
f(y)를 쉽게 표현하기 위해 compose라는 함수를 사용한다.

compose(f, g, h) = (...args) => f(g(h(...args)))

원리

이제 미들웨어를 살펴보자. 미들웨어는 외부에서 생성된 액션이 리듀서에 도달하기전에 먼저 수신하여 시스템에 알맞게 특정 작업들을 미리 처리할 수 있도록 한다. 액션을 검증하거나 필터링, 모니터링, 외부 API와의 연동, 비동기 처리 등을 추가적으로 수행할 수 있도록 한다. 미들웨어가 없다면 이런 작업들을 모두 액션 생성자에서 처리하거나 dispatch를 몽키패치하여 처리해야하는데 이 경우 중복, 복잡도, 비 순수함수 등의 유지 보수가 어려운 많은 문제들이 발생한다. 미들웨어는 이런 문제들을 쉽게 풀어내면서 더 강력해진 dispatch 함수를 만든다.

redux-3

Redux는 미들웨어의 개입이 없는 기본 dispatch 함수와 미들웨어로 인해 고차 함수가 된 dispatching 함수를 구분하고 있다. 이런 고차 함수는 앞서 살펴본 compose를 활용해 구현한다. 앞으로는 이 글에서 baseDispatchdispatch를 구분하여 사용한다.

미들웨어는 store API인 {getState, dispatch}를 인자로 받는다. 그리고 next라는 체이닝 함수를 전달받는 새로운 wrapDispatch 함수를 반환한다. 즉, 미들웨어는 3개의 중첩된 함수를 구현하고 있어야 한다.

function middleware({getState, dispatch}}) {
  return function wrapDispatch(next) {
    return function dispatchToSomething(action) {
      // do something...
      return next(action);
    }
  }
}

상당히 복잡해 보이지만 arrow-syntax를 사용하면 조금은 더 보기 편한 표현이 된다.

const middleware = ({ getState, dispatch }) => next => action => {
  // do something...
  return next(action);
};

redux-4

next 함수는 현재 체이닝에서 처음으로 돌아가지 않고 계속 연결되는 disptach 과정을 수행하기 위해 필요하다. 스토어에 여러 미들웨어가 중첩되어 있을 때 바로 다음 미들웨어의 dispatching 함수에 진입하기 위해 필요한 함수이다. 주의할 점은 next가 아닌 store API의 dispatch를 호출하면 액션이 다시 처음으로 돌아가는 상황이 발생한다.

redux-5

이제 applyMiddleware API를 살펴보자. 참고로 applyMiddlewarecreateStore 자체를 감싸는 고차 함수다. applyMiddlewaremiddleware는 정확히 구분해야 한다.

function applyMiddleware(...middlewares) {
  // applyMiddleware는 기존 createStore의 고차 함수를 반환한다.
  return createStore => (reducer, preloadedState, enhancer) => {
    const store = createStore(reducer, preloadedState, enhancer);
    let dispatch = store.dispatch;
    let chain = [];

    // 미들웨어들에게 인자로 전달되는 객체이다.
    // dispatch가 단순히 store.dispatch의 참조를 전단하는 것이 아니라,
    // 함수를 한번 더 감싸 사용하는 점을 기억하자.
    const middlewareAPI = {
      getState: store.getState,
      dispatch: action => dispatch(action)
    };

    // 미들웨어들이 반환하는 체인 함수들(= wrapDispatch)을 가져온다.
    chain = middlewares.map(middleware => middleware(middlewareAPI));

    // 미들웨어가 반환하는 체인 함수들을 중첩시킨 후 새로운 dispatch 함수를 만든다.
    dispatch = compose(...chain)(store.dispatch);

    // applyMiddleware를 통해 반환된 createStore 고차 함수는
    //  기존 스토어와 동일한 API, 그리고 새로 만들어진 dispatch 함수를 반환한다.
    return {
      ...store,
      dispatch
    };
  };
}

흥미로운 점이 하나 있는데 바로 middlewareAPI 부분이다. dispatch함수를 한번 더 감싸고 있다. 그냥 단순히 아래와 같이 표현하면 무엇이 문제일지 생각해보자.

let dispatch = store.dispatch;

const middlewareAPI = {
  getState: store.getState,
  dispatch
};

위와 같이 구현된다면 미들웨어에서 항상 baseDispatch만을 바라보게 된다. 하지만 비동기 액션/미들웨어의 경우 액션이 다시 처음 미들웨어 체인으로 돌아가야할 필요가 있다. 즉 next(action)이 아닌 store.dispatch(action)이 필요한 경우가 있다는 것이다. 이런 경우에는 클로저 변수로 전체 미들웨어가 연결된(= dispatch = compose(...chain)(store.dispatch)) dispatch를 참조하고 있어야한다. 아직 헷갈린다면 아래 코드의 차이를 확인해보자.

let foo = () => console.log("foo");

const a = { foo };
const b = {
  foo: () => foo()
};

foo = () => console.log("new foo");

a.foo(); // foo
b.foo(); // new foo

미들웨어의 간단한 사용 예시로 redux-thunk 라이브러리를 참고해보자. 액션이 일반 객체(plain object)가 아닌 함수인 경우 그 함수를 실행해줌으로서 비동기 로직이나 외부 API연동과 같은 동작을 가능하게 한다.

리듀서

Redux라는 이름은 Reducer + Flux 이다. 그런만큼 Redux에서는 리듀서가 핵심 개념이고 우리는 이 리듀서에 대해 자세히 알아야 한다.

지금까지 액션이 dispatch되고 미들웨어를 통과하는 과정까지 모두 파악하였다. 외부에서 들어온 액션은 스토어의 상태(state)와 함께 리듀서에 전달되고 스토어는 리듀서로부터 새로운 상태를 받는다. 그래서 리듀서는 이전 상태와 액션을 받아 새로운 상태를 반환하는 순수함수로 정의한다.

reducer: (previousState, action) => newState

의미

어느 애플리케이션이 되었든 단일 리듀서를 가지고 구성하기란 매우 어렵다. 애플리케이션의 상태는 수십 수백가지의 값을 갖는데 이 모든 상태를 전부 하나의 리듀서로 관리하기는 거의 불가능한 것이다. 만약 단일 리듀서로 애플리케이션을 설계할 수 있다면 처음부터 리듀서라고 부르지 않았을 것이다. 리듀서는 기본적으로 단위 상태에 대한 단위 리듀서들의 조합을 구성할 수 있다. 애플리케이션 개발자는 작은 단위의 리듀서들을 정의하고 마지막에 combineReducers API 를 통해 각 리듀서들을 조합하여 하나의 큰 루트 리듀서를 만들 수 있다.

combineReducers를 간단히 나타내면 다음과 같다.

function combineReducers(reducers) {
  return (state = {}, action) => {
    return Object.keys(reducers).reduce((result, key) => {
      const reducer = reducers[key];
      const prevState = state[key];

      result[key] = reducer(prevState, action);
      return result;
    }, {});
  };
}

reducers = {
  a: aReducer,
  b: bReducer,
  c: cReducer
};
const combinedReducer = combinReducers(reducers);

/*

조합된 최종 리듀서(combinedReducer)는 다음과 같다.

function(state, action) {
  return {
    a: aReducer(state.a, action),
    b: bReducer(state.b, action),
    c: cReducer(state.c, action)
  }
}

combinedReducer를 호출하면 aReducer, bReducer, cReducer가 모두 호출되고 새로운 객체를 반환한다.

*/

각 리듀서가 combineReducer에서 Array.prototype.reduce API에 전달되는 형태처럼 사용되기 때문에 리듀서라고 부른다. 사실 실제 구현 코드는 reduce를 직접 사용하지 않지만 개념적으로도 역시 여러 분산된 상태를 하나의 상태로 감소시킨다는 의미와 부합한다.

코드를 보면 알겠지만, combineReducer는 기본적으로 1dpeth의 상태를 가지고 처리한다. 하지만 combineReudcer를 재귀적으로 조합하면 n-depth의 상태를 구현할 수 있다. 때문에 애플리케이션 개발자는 애플리케이션의 상태가 깊어도 각각 단위 상태만을 처리하는 단위 리듀서를 작성하고 이들을 조합하여 전체 루트 리듀서를 만들어 애플리케이션의 전체 상태를 관리할 수 있는 것이다.

redux-6

액션에 따른 상태 변화 여부

앞서 간단히 구현한 combinedReducer 상태의 변화 유무와 상관 없이 항상 새로운 객체를 반환한다. 만약 이벤트 리스너가 수행되고 실제 상태 변경이 없었다면 그것을 애플리케이션 개발자는 어떻게 알 수 있을까? 각 프로퍼티별로 하나씩 깊은 비교를 할 수 밖에 없다. 따라서 리듀서는 변화가 없는 경우에는 항상 참조가 같은 기존 상태를 반환해야 하고 이는 combineReducer API역시 마찬가지다. 다음은 실제 구현된 코드의 일부이다.

let hasChanged = false;
const nextState = {};
for (let i = 0; i < finalReducerKeys.length; i++) {
  const key = finalReducerKeys[i];
  const reducer = finalReducers[key];
  const previousStateForKey = state[key];
  const nextStateForKey = reducer(previousStateForKey, action);

  nextState[key] = nextStateForKey;
  hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
}
return hasChanged ? nextState : state;

combineReducer에서 반환하는 리듀서 역시 상태의 모든 프로퍼티에 대해 hasChanged = hasChanged || nextStateForKey !== previousStateForKey 구문을 수행하고, 마지막에 hasChanged 여부에 따라 새로운 상태 또는 기존 상태 반환 여부를 결정한다. 애플리케이션 개발자역시 단위 리듀서에서 상태 변화가 있는 경우에만 새로운 참조를 가진 객체를 반환을 하고 그렇지 않은 경우는 이전 상태를 그대로 반환하는 규칙을 지켜주어야 한다.

마치며

Redux는 정말 간단한 라이브러리면서도 매우 중요한 개념을 포함하며, 기술적으로도 세련된 기법들을 사용하고 있다. 이번에 Redux를 분석하며 단방향 데이터 흐름, Event-Sourcing 패턴, 함수 중첩, 리듀서 등을 어떤식으로 활용하고 어떻게 조합하였는지, 배울점이 정말 많았다. 특히 리듀서를 순수 함수로 구현하면서 이들을 조합해 하나의 상태 트리를 구성하는 것은, 어느 누군가는 한번쯤 해볼만한 생각이긴 하지만 과연 이렇게 깔끔하게 구현할 수 있었을까 하는 의문이 들 정도였다.

만약 시간에 여유가 있다면 Redux의 코드를 한번 읽어보길 추천한다. 2kb도 정도의 짧은 코드이고, TC도 67개 정도로 정말 작으면서도 탄탄하고 많은 것을 내포한 라이브러리이다.

참고


이민규, FE Development Lab2017.03.31Back to list