thumbnail
기술아티클
Redux Deep Dive 2. dispatch가 UI에 반영되기까지
React와 Middleware를 사용하지 않는 가장 간단한 형태의 redux를 분석해봅니다.
October 24, 2024

Redux Deep Dive 시리즈의 두번째 아티클에서는 React와 Middleware를 사용하지 않는 가장 간단한 형태의 Redux를 분석해볼것입니다. React와 Middleware가 배제되는 만큼 Redux의 핵심부분을 집중적으로 살펴볼 수 있을것입니다.

dispatch에서 view까지

가장 먼저 살펴볼 내용은 action과 함께 실행한 dipatch가 내부적으로 어떤 과정을 거쳐서 view에 반영되는지 입니다. Redux를 많이 사용해보셨다고 하더라도 보통 React와 같은 UI라이브러리와 함께 사용하지, 바닐라 자바스크립트와 함께 사용하지는 않기 때문에, 간단한 카운터 예제를 통해 바닐라 자바스크립트와 함께 Redux를 사용하는 방법을 살펴보겠습니다.

<!DOCTYPE html>
<html>
  <head>
    <title>Redux basic example</title>
  </head>
  <body>
    <div>
      <p>
        Clicked: <span id="value">0</span> times
        <button id="increment">+</button>
        <button id="decrement">-</button>
      </p>
    </div>
    <script type="module">
      import { createStore } from "https://unpkg.com/redux@latest/dist/redux.browser.mjs";
      const initialState = {
        value: 0
      };

      function counterReducer(state = initialState, action) {
        switch (action.type) {
          case "counter/incremented":
            return { ...state, value: state.value + 1 };
          case "counter/decremented":
            return { ...state, value: state.value - 1 };
          default:
            return state;
        }
      }

	  // reducer를 넘겨 스토어 생성
      const store = createStore(counterReducer);

	  // 스토어의 상태를 가져온뒤 필요한 값을 꺼내 view에 반영합니다.
      function render() {
        const state = store.getState();
        document.getElementById("value").innerHTML = state.value.toString();
      }

      // 초기값으로 렌더링합니다.
      render();

	  // 이후의 렌더링은 스토어가 변경될때마다 실행됩니다.
      store.subscribe(render);

      // increment 버튼을 클릭할 경우 "counter/incremented" 타입의 액션이 실행됩니다.
      document
        .getElementById("increment")
        .addEventListener("click", function () {
          store.dispatch({ type: "counter/incremented" });
        });

	  // decrement 버튼을 클릭할 경우 "counter/decremented" 타입의 액션이 실행됩니다.
      document
        .getElementById("decrement")
        .addEventListener("click", function () {
          store.dispatch({ type: "counter/decremented" });
        });
    </script>
  </body>
</html>

먼저 비즈니스 로직이 포함된 reducer함수를 createStore함수에 넘겨 실행함으로써 store를 생성합니다. 다음으로는 subscribe함수를 이용해 render함수가 store를 구독하도록 합니다. 이후에는 버튼의 이벤트 핸들러 함수에 dispatch함수의 실행을 명시해두었기에 사용자가 버튼을 누르면 누른 버튼에 맞는 액션을 담아 dispatch함수가 실행됩니다. 실제로 코드를 복사해서 실행해보면 버튼을 누를때마다 증가 혹은 감소하는것을 확인할 수 있을것입니다.

이를 통해 dispatch함수를 실행할때마다 reducer함수가 실행되어 액션에 맞는 동작을 수행한뒤 store를 변경하고 이후 render함수가 실행되어 getState함수가 항상 최신의 상태를 반환한다는것을 추측해볼 수 있습니다. createStore함수를 분석해보면서 이 추측을 코드레벨에서 증명해보겠습니다.

createStore 함수 분석

주석, 오류검사, 예외처리, Store Enhancer, Observable과 같은 부수적인 기능을 제거한 createStore함수는 다음과 같습니다.

function createStore(reducer) {
  let state
  const listeners = []
  let isDispatching = false

  function getState() {
    return state
  }

  function subscribe(listener) {
    const listenerId = listeners.length
    let isSubscribe = true
    listeners.push(listener)
    return function unsubscribe() {
      if (!isSubscribe) return
      isSubscribe = false
      listeners.splice(listenerId, 1)
    }
  }

  function dispatch(action) {
    state = reducer(state, action)

    listeners.forEach(listener => listener())
  }

  dispatch({ type: "INITIAL_ACTION" })

  return { dispatch, subscribe, getState }
}

createStore함수는 상태, 리스너, 디스패치 여부를 판단하는 세가지 변수와 리턴하는 getState, subscribe, dispatch 라는 세가지 함수로 구성되어 있습니다. 변수들은 모두 리턴하는 함수내에서 사용되기에 세가지 함수만 이해하면 createStore함수를 이해할수 있습니다.

getState함수는 현재 상태를 반환하며 subscribe함수는 인자로 들어온 리스너를 목록에 추가하고, 구독을 해제하는 함수를 반환하는 기본적인 구독함수의 역할을 하고있습니다.

가장 중요한 함수는 dispatch함수입니다. 상태를 리듀서 함수를 실행한 결과로 변경하고(state = reducer(state, action)) 모든 리스너를 실행하는(listeners.forEach(listener => listener())) 간단한 로직으로 되어있습니다. 따라서 dispatch함수를 실행하면 subscribe함수를 통해 넘긴 콜백함수가 실행되므로 앞서 우리가 추측한 dispatch함수를 실행할때마다 reducer함수가 실행되어 액션에 맞는 동작을 수행한뒤 store를 변경하고 이후 render함수가 실행되어 getState함수가 항상 최신의 상태를 반환하였다가 올바른 추측이었음을 알 수 있습니다.

추가적으로 살펴보면 좋을 사실은 reducer함수내에서 비동기 작업을 통해 상태를 생성할 경우 dispatch함수는 이를 전파하지 않는다는것입니다. 왜냐하면 리스너를 실행하는 로직은 reducer함수의 비동기 실행을 기다려주지 않기 때문에 reducer함수가 비동기 로직을 실행하는 도중에 리스너가 실행되기 때문입니다. 따라서 해당 리스너함수에서 실행한 getState함수는 비동기 실행의 결과가 반영된 상태가 아닌 이전 상태를 받게되어 의도치 못한 결과를 얻을수 있으므로 reducer함수 내부에서는 비동기 로직을 사용하면 안됩니다. 그럼에도 reducer함수 실행시 비동기 작업이 필요하다면 미들웨어를 이용해야합니다. 미들웨어를 이용해 비동기 작업을 수행했을때 상태변경에 문제가 없는 이유에 대해서는 추후 미들웨어 편에서 더 깊이있게 살펴보겠습니다.

combineReducer가 action에 맞는 reducer 함수를 선택하는 방법

하나의 reducer함수를 사용하다보면 다양한 논리들이 포함되어 복잡해지므로 결국 관심사에 따라 reducer함수를 분리하게됩니다. 분리된 reducer함수는 다시 합쳐야 Redux에서 reducer함수로 사용할 수 있으므로 이를 위해 combineReducers함수를 사용합니다. combineReducers함수는 인자로 객체를 받는데 객체 내부에 원하는 이름을 키로 하여 reducer함수를 넣어주면 됩니다. 이후 store를 조회하면 각 키에 입력된 reducer함수가 적용된 결과가 저장되어 있음을 확인할수 있습니다.

// reducers.js
export default theDefaultReducer = (state = 0, action) => state

export const firstNamedReducer = (state = 1, action) => state

export const secondNamedReducer = (state = 2, action) => state

// rootReducer.js
import { combineReducers, createStore } from "redux"

import theDefaultReducer, {
  firstNamedReducer,
  secondNamedReducer,
} from "./reducers"

const rootReducer = combineReducers({
  theDefaultReducer,
  firstNamedReducer,
  secondNamedReducer,
})

const store = createStore(rootReducer)
console.log(store.getState()) // {theDefaultReducer : 0, firstNamedReducer : 1, secondNamedReducer : 2}

이때 생기는 궁금증은 Redux는 dispatch함수를 실행할때 넘기는 action에 맞는 reducer함수를 어떻게 찾아내서 실행하는가입니다. combineReducer함수를 분석하여 답을 찾아보겠습니다.

combineReducers 함수 분석

export default function combineReducers(reducers) {
  return function combination(state: State = {}, action: Action) {
    const reducerKeys = Object.keys(reducers)
    let hasChanged = false
    const nextState: State = {}

    for (let i = 0; i < reducerKeys.length; i++) {
      const key = reducerKeys[i] // 현재 reducer key ex) "theDefaultReducer"
      const reducer = reducerKeys[key] // 현재 reducer 함수 ex)  theDefaultReducer Fn
      const previousStateForKey = state[key] // 현재 key에 해당하는 상태 ex) 0
      const nextStateForKey = reducer(previousStateForKey, action) // 새로운 상태 생성
      nextState[key] = nextStateForKey
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey
    }

    return hasChanged ? nextState : state
  }
}

combineReducers함수가 반환하는 것이 combination함수이며 이 함수가 곧 합쳐진 reducer함수입니다. dispatch함수를 실행하면 combination함수가 실행될것이므로 combination 함수 내부를 살펴보면, for 문에서 들어온 액션에 따라 특정 reducer함수를 선택해서 실행하는것이 아니라, 모든 reducer함수를 실행함으로써 마치 인자로 들어온 액션을 처리하는 reducer함수를 선택해 실행한것처럼 동작하고 있음을 확인할 수 있습니다.

한가지 더 살펴보면 좋을것은 combination함수가 hasChanged에 따라 변경된 상태가 아닌 이전 상태를 리턴할수도 있다는것입니다. hasChangednextStateForKeypreviousStateForKey가 다를때, 즉 새로운 상태가 생성되었을때 true로 바뀌게 되므로 리듀서가 매번 새로운 객체를 생성하지 않으면(상태를 불변하게 관리하지 않으면) 이전 상태가 반환될것입니다.

여기서 중요한 사실은 redcuer함수에서 변경된 상태가 적용된 새로운 객체를 반환하지 않고 이전 상태 객체를 수정한뒤 리턴함으로써 상태의 불변성을 지키지 않더라도 UI에서는 새로운 상태를 받아볼 수 있다는 것입니다. 이유는 앞서 살펴본것처럼 reducer함수의 반환값이 그대로 상태에 반영되기 때문입니다. 따라서 널리 알려진것과는 다르게 Redux에서 상태의 불변성을 지키지 않아도 UI에 변경한 상태가 반영될 수 있기에 문제가 발생하지 않는것 처럼 보입니다.

하지만 일반적으로 Redux에서 상태를 불변으로 관리하지 않으면 의도한대로 동작하지 않기 때문에 불변성을 지키지 않았을때 문제가 생기는 이유가 다른 곳에 있음을 알 수 있습니다. 이는 다음 아티클에서 살펴볼 것이므로 지금은 redux를 react, middleware없이 사용할때 불변성을 지키지 않더라도 동작에는 문제가 없다 정도로 이해해두시면 좋겠습니다.

마치며

이번 시리즈에서는 Redux의 createStore함수와 combineReducer함수를 분석하면서 Redux의 기본 기능들이 어떻게 동작하는지 살펴보았습니다. Redux 라이브러리 자체는 코드량이 많지않고 간단하다보니 생각보다는 어렵지 않으셨을것입니다. 다음 시리즈에서는 난이도를 조금 올려 이번 아티클에서 다룬 Redux 모델에 React를 추가하였을때의 동작을 살펴보겠습니다.

참고자료

Redux 톺아보기