Client State Management in Modern Web Front End and React

모던 웹 프론트엔드와 React 생태계의 클라이언트 상태 관리 도구 및 발전 과정.

Frontend, React

모던 웹 프론트엔드와 리액트에서의 클라이언트 상태 관리

출처 : https://npmtrends.com/
출처 : https://npmtrends.com/

JSP, ASP, PHP 등 프론트엔드와 백엔드가 결합된 MPA 구조로 개발하던 때와 달리, 현대의 웹 애플리케이션은 백엔드와 프론트엔드라는 개념으로 분리되었고, 그러면서 점차 프론트엔드 애플리케이션은 사용자, 백엔드와 상호작용하며 수많은 상태를 관리하게 되었다. 이 상태 관리라는 것의 역할은 중요해졌지만 동시에 복잡해져갔다. 그래서 이런 ‘복잡하고 어려운 상태 관리’라는 문제를 해결하기 위해 여러 클라이언트 상태 관리 라이브러리들이 등장하기 시작했다.

그런데 웹 브라우저에는 Local Storage, Session Storage 등의 Web Storage API가 존재한다. 그럼에도 불구하고 클라이언트 상태 관리 도구가 개발되고, 더 주목받은 이유는 무엇일까? 단순히 Web Storage API에서는 문자열 형태의 키-값 구조로만 저장이 가능하기 때문에 요구되는 직렬화-역직렬화 과정이라는 번거로운 프로세스도 존재하지만, 성능적으로도 차이가 있다.

Redux와 같은 도구들은 인메모리 형태로 상태 관리를 가능케한다. 일반 JavaScript 객체로 메모리에 저장하기 때문에, 빠른 속도로 데이터에 접근할 수 있다. 또한, Web Storage API에서 필요한 직렬화-역직렬화 과정도 필요없이 그대로 저장이 가능하다는 의미도 된다. 반면 Web Storage API는 DOM API를 이용해 데이터를 접근해야 하기 때문에 상대적으로 느릴 수 밖에 없고, Proxy를 이용한 반응성 부여도 어렵다.

그렇기 때문에 Redux와 같은 클라이언트 상태 관리 도구들은 많은 개발자들의 채택을 받았다. 현재는 각자의 상황에서 야기한 페인 포인트로, 서로 비슷한 문제를 풀기 위함이지만 서로 다른 형태와 아키텍처로 개발된 클라이언트 상태 관리 도구들이 등장했고, 이 클라이언트 상태 관리 도구들을 히스토리를 되짚어보며 간단한 예제와 함께 살펴보려 한다.

Flux 아키텍처

Flux 아키텍처는 React 라이브러리를 보완하기 위해 고안되었다. 여러 독립적인 UI 요소들이 동일한 데이터에 접근해야 할 때, 기존의 Backbone 프레임워크 스타일 코드로는 이를 명확하게 관리하기 어려웠다. 당장 다중 컴포넌트 간의 데이터 통신을 하기 위해 모든 Props를 주고받는다면 컴포넌트의 재사용성과 확장성, 유지보수성을 얼마나 가져갈 수 있을까? 이를 위해 Facebook은 Flux 아키텍처를 고안하고 채택했다. 이 Flux 아키텍처의 핵심 아이디어는 ‘단방향 데이터 흐름’, ‘중앙 집중식 Dispatcher를 이용한 액션 관리’, ‘여러 개의 Store를 통한 상태 관리’다.

출처 : https://github.com/facebookarchive/flux
출처 : https://github.com/facebookarchive/flux

아카이빙된 Flux 저장소에서도, ‘단방향 데이터 흐름을 활용하는 React를 위한 애플리케이션 아키텍처’라고 기술되어 있다. (참고1, 참고2)

더욱 간단하게, Flux의 데이터 흐름만 분리해서 확인해보자.

출처 : https://github.com/facebookarchive/flux/tree/main/examples/flux-concepts
출처 : https://github.com/facebookarchive/flux/tree/main/examples/flux-concepts

Flux의 기본적인 흐름은 다음과 같다. View가 Dispatcher에게 Action을 전달하면, Dispatcher는 모든 Store에 작업을 전송한다. 그리고 Store는 View에게 처리된 데이터를 전송하거나, 혹은 View가 Store에서 데이터를 가져온다. 이렇게 데이터는 한 방향으로 흐르며 계속해서 사이클을 형성하게 된다.

Facebook은 이 Flux 아키텍처를 채택함으로 MVC 패턴에서 코드베이스가 커지면서 복잡도가 급상승하는 문제, 양방향 데이터 바인딩으로 인한 예측하기 어려운 상태 변화 문제, 독립적인 UI 요소들이 동일한 데이터에 접근해야 하는 문제 등을 해결하고자 했다. (참고) Flux 아키텍처에 대해 더욱 자세한 컨셉이 궁금하다면 다음 문서를 참고해보자. (참고)

당시 Flux 아키텍처는 충분히 훌륭했지만, React 팀의 Dan Abramov는 React 애플리케이션의 개발 경험을 더욱 향상시키고자 했다. (참고) Hot Reloading, 과거의 특정 시점에 대한 디버깅같은 기능을 추가하고자 하였으나, 당시의 Flux 아키텍처로는 어려움이 있었고, 이 Flux 아키텍처를 기반으로 발전시킨 형태의 Redux를 개발하기에 이르렀다.

기존의 Flux 아키텍처를 Dan Abramov는 Redux에서 어떻게 발전시켰을까? 여러 개의 스토어를 사용하던 형태에서 Redux는 전체 애플리케이션의 상태를 관리할 수 있는 단일 스토어를 사용하는 형태로 변모했다. Dispatcher를 사용하던 형태도 달라졌다. Flux에서는 이 Dispatcher를 통해 액션을 스토어로 전달해서 스토어 내부에서 상태를 직접 변경했었다. Redux에서는 Dispatcher라는 개념이 사라지고 대신 Reducer라는 개념이 새로 도입되었는데, 이전 상태와 액션을 받아 새로운 상태를 반환하는 순수 함수의 역할을 했다. 이로 인해 상태 변화의 예측 가능성이 높아지고 디버깅이 용이해졌다. 또한 Selector라는 개념도 새로이 생겨났다. Store에서 필요한 데이터만 효율적으로 추출할 수 있게 되어 컴포넌트에게 필요한 상태만을 효율적으로 가져올 수 있게 된 것이다. 따라서 추가적으로 React 애플리케이션의 렌더링 최적화도 일굴 수 있게 되었다. 그렇게 Redux는 React 애플리케이션을 개발할 때 거의 필수적인 요소처럼 자리잡았고, Dan Abramov는 ‘You Might Not Need Redux’라는 글을 작성하기도 했다.

그리고 React의 상태 관리를 논할 때면 불변성 유지가 중요하다는 이야기가 주로 나온다. React를 위한 라이브러리가 아닌 Redux도 불변성이 중요할까? 답은 ‘그렇다’이다. Redux의 주요 개념 중 reducer는 순수 함수로 설계되어야 하며, 이전 상태와 액션을 기반으로 새로운 상태를 반환해야 한다. 기존 상태를 직접 수정하면 순수 함수의 원칙이 깨지고, 상태의 예측 가능성이 흐려진다. 또한 React에서 Redux를 사용할 때 필요한 React-Redux는 얕은(Shallow) 비교를 통해 상태의 변화를 확인하는데, 상태를 변경할 때 불변성을 유지하지 않으면 참조가 동일하게 유지되어 상태 변경을 감지하지 못할 수도 있다. 이렇게 불변성을 유지하고 새로운 객체를 생성하면 React는 참조가 변경되었음을 감지하고, 변경된 부분만 빠르게 감지해 Virtual DOM의 핵심인 Diffing 알고리즘이 효율적으로 동작할 수 있게 된다.

Rails 스타일(Rails style)

초기의 Redux는 필요한 보일러플레이트 코드의 양에 비해, 사용 가이드나 원칙같은 장치는 미비했다. 프로젝트나 개발자마자 각기 다른 데이터 흐름 구현 방식이나 패턴을 만들어내고 있었다. 개발자들이 기존 프로젝트에 참여하기 위한 컨텍스트 스위칭 비용은 커질 수 밖에 없었고 곧 기술 부채로 이어질 것이 명확해 보였다. 그래서 역할별로 코드를 분리하여 개발자가 각 파일의 목적을 명확하게 이해하고, 복잡성을 낮추고 협업과 유지보수성을 향상시키기 위해 Rails 스타일이 채택되었다.

Rails 스타일은 사실 React나 Redux를 위해 고안된 것은 아니다. 이름에서 알 수 있듯이 Ruby on Rails 프레임워크에서 사용되는 MVC 구조로 역할에 따라 코드를 나누는 방식이다. Ruby on Rails 애플리케이션에서 복잡성 증가에 따른 체계적인 코드 관리의 필요성과 일관적인 코드 스타일의 필요성으로 인해 제시되었고, ‘Convention over Configuration’, ‘DRY(Don’t Repeat Yourself)’ 원칙을 도입해 협업 효율성 및 생산성을 향상시키고자 했다. 이 Rails 스타일을 Redux에서는 액션, 상수, 리듀서, 컨테이너, 컴포넌트로 분리함으로써 적용했다.

/src
  /actions
    todosActions.js
  /constants
    actionTypes.js
  /reducers
    todosReducer.js
    rootReducer.js
  /containers
    TodosContainer.js
  /components
    TodoItem.js
    TodoList.js
  /store
    store.js
  App.js
  index.js

당시 Rails 스타일은 이미 대규모 프로젝트에서도 검증되었고, 이와 유사한 MVC 프레임워크에 익숙한 개발자들이 많았으므로 기존 경험을 재활용할 수 있다는 장점도 있었다. 역할별 파일 분리는 규모가 큰 애플리케이션에서 코드 탐색과 유지보수를 용이하게 했다. 협업 과정에서도 각 파일의 목적이 명확하니 생산성 향상이라는 효과도 생겼다.

하지만 액션, 리듀서, 컨테이너 등을 분리하며 파일 구조는 비대해졌고, 관련 로직이 여러 파일에 분산되어 있어 이해하기 어렵다는 단점이 생겨났으며, 작은 규모의 프로젝트에서는 폴더 간 이동이 잦아지고 불필요한 복잡성을 야기했다. (참고) 이런 문제들을 해결하고자 기능 중심 혹은 도메인 중심의 구조가 제시되었고, 그 중 대표적인 것이 Ducks 패턴이다. (참고)

Ducks 패턴(Ducks style)

Erik Rasmussen은 Redux를 사용하며 발생하는 복잡성 문제를 해결하기 위해 기능 중심의 Ducks 패턴을 제안했다. 애플리케이션 규모가 비대해질수록, Action, Action Creator, Reducer의 수도 같이 늘어나며 복잡도가 상승했고, 당시에는 이 Redux 코드들을 정리할 정형화된 방법도 제시되지 않았던 상태였다. 따라서 관련된 코드들을 하나의 독립된 모듈로 번들링하는 것이 더 합리적이라고 판단하고, 액션 타입, 액션 생성자, 리듀서를 하나의 모듈로 묶어 관리하는 방식을 제안했다. 이를 통해 관련 코드의 응집도를 높이고, 모듈화된 구조를 통해 가독성과 유지보수성을 향상시키고자 했다.

Ducks 패턴의 기본 규칙은 다음과 같다. 먼저 모듈 구조는 액션 타입, 액션 생성자, 리듀서를 하나의 파일로 작성한다.액션 생성자는 개별적으로 export 시키고, 리듀서는 export default로 내보낸다. 네이밍 규칙에서는 액션 타입을 app/reducer/ACTION_TYPE 으로 정의하며, 중복을 피하기 위해 앱 이름이나 기능 이름을 접두어로 사용한다.

액션 타입은 상수로 정의하고, 네임스페이스를 포함시켜서 중복을 방지한다. 리듀서는 초기 상태를 정의하고 디폴트로 내보낸다. 액션 생성자는 액션을 생성하는 함수들을 개별적으로 내보내되, 필요한 경우 비동기 액션 생성자도 포함할 수 있다.

// Action Types
const LOAD = 'my-app/widgets/LOAD';
const CREATE = 'my-app/widgets/CREATE';
const UPDATE = 'my-app/widgets/UPDATE';
const REMOVE = 'my-app/widgets/REMOVE';

/* ... */

// Reducer
const initialState = {
  widgets: [],
  isLoading: false
};

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case LOAD:
      return {
        ...state,
        isLoading: true
      };
    case CREATE:
      return {
        ...state,
        widgets: [...state.widgets, action.payload]
      };
    default:
      return state;
  }
}

/* ... */

// Action Creators
export const loadWidgets = () => ({ type: LOAD });
export const createWidget = (widget) => ({ 
  type: CREATE, 
  payload: widget 
});

하지만 관리의 난해함이 개선된 것이지, 여전히 많은 양의 보일러플레이트 코드를 필요로 했다. 그래서 이것이 발전하여, Ducks 패턴의 개념을 토대로 Redux Toolkit이 등장했다. ‘slice’라는 이름으로 복잡성을 줄이고 자동화한, 더욱 발전된 형태의 Ducks 패턴을 지원했다. 또한 기존의 Redux에서는 개발자가 직접 불변성을 관리했지만, Redux Toolkit에서는 Immer.js 라이브러리가 내장되어 불변성을 자동으로 관리해주고, 캐시, 배치와 같은 최적화 기능도 내장되어 더욱 강력한 Redux를 더 쉽게 사용할 수 있게 되었다.

그리고 Redux 자체는 동기적인 상태 관리만을 다루기 때문에, 비동기 작업이나 부수 효과를 처리하기 위한 추가적인 도구가 필요했다. 이러한 이유로 Redux를 위한 미들웨어들이 등장했고, Redux Thunk, Redux Saga가 그런 부분들이다. Redux Thunk는 액션 생성자가 함수를 반환하도록 하여 비동기 작업을 처리했고, Redux Saga는 제너레이터 함수를 사용하여 비동기 작업과 사이드 이펙트를 관리했다. 그래서 Redux Saga가 Redux Thunk보다 복잡한 비동기 흐름을 관리하는데는 효과적인 부분이 있었으나, 자바스크립트 ES6 스펙인 제너레이터에 대한 이해가 필요해 러닝 커브가 있었다.

그러나 이러한 시도들에도 불구하고 Redux를 사용하기 위해서는 여전히 Store, Action, Reducer 등 여러 구성 요소의 정의가 필요했고, 비동기 데이터 처리를 위한 추가적인 도구들이 필요하다는 사실에 의해 새로운 도구들에 대한 니즈는 생겨나기 시작했다. 또한 Redux는 엄연히 React를 위해 만들어진 전용 라이브러리는 아니기에 React의 최신 기능들과 호환성이 떨어졌고, React의 컴포넌트 상태과 Redux의 전역 상태 통합의 어려움, React 생태계에서 Redux를 사용하고자 한다면 react-redux 라는 라이브러리가 필요하다는 사실은 React의 내부 기능들과 완벽한 통합이 어려웠다는 사실을 알게 해준다.

MobX

출처 : https://ko.mobx.js.org/getting-started.html
출처 : https://ko.mobx.js.org/getting-started.html

Redux의 복잡성과 과도한 보일러플레이트 코드는 많은 이들에게 높은 러닝 커브와 해결이 필요한 문제로 다가왔다. 그렇게 2015년 경, 이를 다른 방식으로 해결하고자 했던 MobX라는 새로운 상태 관리 도구가 등장했다. MobX의 핵심 원리는 Proxy를 이용한 반응형 프로그래밍으로, 상태를 직접 변경할 수 있으며 변경된 상태를 자동으로 추적하고 업데이트할 수 있었다. Redux는 전체 상태 트리를 새로 생성하고 리렌더링을 트리거하는 방식으로 동작했지만, MobX는 상태 변경이 필요한 부분만 선택적으로 업데이트하여 성능을 최적화했다. 반응형 데이터 흐름을 통해 효과적으로 데이터를 관리하고 성능을 최적화하는 전략은 마치 Vue 프레임워크를 사용하는 것과 유사한 경험을 주었다. React 뿐만 아니라 Vue, Angular, Flutter(Dart) 등 다른 프레임워크에서도 사용할 수 있었다는 점도 인상적이었다.

Redux가 불변성을 통해 상태 관리의 예측 가능성을 확보했다면, MobX는 애플리케이션의 상태에서 파생 가능한 모든 것을 자동으로 파생시켜, ‘일관되지 않은 상태가 애초에 생성되지 않도록’하는 접근법을 택했다. MobX는 크게, 애플리케이션의 구성하는 객체, 배열, 원시값들인 Observable State(관찰 가능한 상태), 상태로부터 자동으로 계산될 수 있는 값들인 Derivation(Computed), 상태 변화에 따라 자동으로 실행되는 사이드 이펙트(부수 효과)인 Reaction, Redux에서 사용하는 Action의 개념과 유사한 Action의 네 가지 구성 요소를 가졌는데, Reaction의 경우 Derivation과 유사하나 값을 생성하는 것이 아닌 상태 변화에 따라 자동으로 특정 태스크를 수행할 수 있는 장치다.

import { makeAutoObservable } from "mobx";
import { observer } from "mobx-react-lite";

// MobX Store 정의
class CounterStore {
  count = 0;

  constructor() {
    makeAutoObservable(this);
  }

  increment() {
    this.count += 1;
  }

  decrement() {
    this.count -= 1;
  }
}

// Store 인스턴스 생성
const counterStore = new CounterStore();

// React 컴포넌트 정의
const Counter = observer(() => {
  return (
    <div>
      <h1>Count: {counterStore.count}</h1>
      <button onClick={() => counterStore.increment()}>Increment</button>
      <button onClick={() => counterStore.decrement()}>Decrement</button>
    </div>
  );
});

export default Counter;

그렇지만 단방향 데이터 흐름과 불변성이라는 핵심으로 상태 변화를 예측 가능케 만들던 Redux에 비해 상태를 직접 변경할 수 있는 MobX는 상태 변화에 대한 예측이 어려워 디버깅과 테스트가 어렵다는 단점도 존재했고, 따라서 대규모 애플리케이션에서는 여전히 Redux의 체계성과 확장성이 높이 평가되었다. 또한, 당시 MobX에서는 클래스와 데코레이터라는 개념도 쓰고있었는데, 크지 않은 규모의 React 애플리케이션에서는 React 16.3 이후 출시된 React 내장 상태 관리 도구인 React Context API가 더욱 간편한 대안으로 등장했기 때문에, 반응형을 핵심 포인트로 삼아 유연한 상태 관리 도구로 등장했던 MobX는 이후 주목도가 상대적으로 떨어졌다.

React Context API

Recoil이 등장하고 주목받기 전, React 16.3에서 새롭게 등장한 Redux와는 다른 상태 관리 도구가 있었다. 이것은 React Context API로, 같은 단방향 데이터 흐름을 갖고 있으나 Flux 아키텍처보다 훨씬 간소화된 형태로 구성되었다. 크게 상태를 담을 Context 객체, 하위 컴포넌트에게 상태를 제공할 Provider 컴포넌트, 상태를 소비하는 Consumer 컴포넌트의 세 가지 핵심 구성 요소로 이루어져 있으며, 주로 전역 상태를 공유하거나 Props Drilling 문제를 피하기 위해 사용되었다.

// contexts/ThemeContext.js
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useState('light');
  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};
export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
};

// App.js
const App = () => {
  return (
    <AuthProvider>
      <ThemeProvider>
        <Layout />
      </ThemeProvider>
    </AuthProvider>
  );
};

// components/Layout.js
const Layout = () => {
  const { theme, toggleTheme } = useTheme();
  return (
    <div className={`app ${theme}`}>
      <button onClick={toggleTheme}>
        Toggle Theme ({theme})
      </button>
    </div>
  );
};

구조나 사용 방법도 간편하고, React에 내장되어 있어 추가적인 라이브러리 설치나 설정도 필요 없었다. 하지만 대규모 애플리케이션에서 Context 값이 변경될 때 해당 Context를 사용하는 모든 컴포넌트가 리렌더링되어 성능 관리가 Redux에 비해 까다로웠고, 강력한 미들웨어가 있는 Redux보다 복잡한 비동기 작업에서도 불리했으며, 개발자가 직접 불변성을 관리해야 했다. 그러므로 테마, 언어 등의 저빈도 상태 변화 관리에는 효과적이었으나, 복잡한 상태를 다루거나 규모가 큰 애플리케이션을 개발하는 데에서는 상대적으로 불리했다. 또한 최신 기능인 React Server Components(RSC)와 직접 통합되지 않는다는 단점도 존재했다.

Atomic 패턴

React는 컴포넌트 기반으로 설계되었다. 따라서 상태도 컴포넌트 단위로 관리하는 것이 자연스러운 흐름이라는 생각으로, 상태를 작은 단위(Atom)로 나누고, 필요한 컴포넌트에서만 해당 상태를 구독하도록 설계된 Atomic 패턴이 고안되었다. Flux 패턴, 혹은 Flux 패턴 기반의 Redux와 달리 상태를 더 세분화하고 독립적으로 관리할 수 있도록 했다. 그리고 이 Atomic 패턴을 구현한 라이브러리가 Recoil이다.

Recoil은 React 전용으로 설계되었으며, useState 와 유사한 사용성을 가진 useRecoilState, useRecoilValue 와 같은 API를 제공했다. Recoil에서는 상태의 최소 단위인 Atom을 React 컴포넌트가 이를 구독하여 업데이트 할 수 있고, Atom이 변경되면 이를 구독하는 모든 컴포넌트가 리렌더링된다. 그리고 순수 함수 Selector를 이용해 Atom이나 다른 Selector에서 파생된 데이터를 동기 및 비동기적으로 처리가 가능했다.

// atoms/themeAtom.js
import { atom, selector } from 'recoil';

export const themeState = atom({
  key: 'themeState',
  default: 'light'
});

export const themeConfigSelector = selector({
  key: 'themeConfigSelector',
  get: ({get}) => {
    const theme = get(themeState);
    return {
      backgroundColor: theme === 'light' ? '#ffffff' : '#1a1a1a',
      textColor: theme === 'light' ? '#000000' : '#ffffff',
      buttonColor: theme === 'light' ? '#0066cc' : '#4d94ff'
    };
  }
});

// components/ThemeToggle.js
import { useRecoilState, useRecoilValue } from 'recoil';
import { themeState, themeConfigSelector } from '../atoms/themeAtom';

export const ThemeToggle = () => {
  const [theme, setTheme] = useRecoilState(themeState);
  const themeConfig = useRecoilValue(themeConfigSelector);

  const toggleTheme = () => {
    setTheme(current => current === 'light' ? 'dark' : 'light');
  };

  return (
    <button 
      onClick={toggleTheme}
      style={{ 
        backgroundColor: themeConfig.buttonColor,
        color: themeConfig.textColor 
      }}
    >
      Current Theme: {theme}
    </button>
  );
};

// components/ThemedContent.js
import { useRecoilValue } from 'recoil';
import { themeConfigSelector } from '../atoms/themeAtom';

export const ThemedContent = () => {
  const themeConfig = useRecoilValue(themeConfigSelector);

  return (
    <div style={{ 
      backgroundColor: themeConfig.backgroundColor,
      color: themeConfig.textColor,
      padding: '20px'
    }}>
      <h1>Themed Content</h1>
      <p>This content adapts to the current theme.</p>
    </div>
  );
};

// App.js
import { RecoilRoot } from 'recoil';
import { ThemeToggle } from './components/ThemeToggle';
import { ThemedContent } from './components/ThemedContent';

const App = () => {
  return (
    <RecoilRoot>
      <ThemeToggle />
      <ThemeContent />
    </RecoilRoot>
  );
};

Redux Toolkit에서는 Immer.js를 사용해서 불변성을 관리했었다. 그리고 Recoil도 초기에는 객체 기반 자료구조를 사용해 새로운 객체를 생성하는 방식으로 불변성을 관리했었다. 그러나 이후 해싱 기반 자료구조인 HAMT(Hash Array Mapped Trie) 자료구조를 채택하고 변경된 부분만 새로 생성하는 방식의 알고리즘으로 불변성을 관리하는 로직을 개선시켰다. HAMT는 이진 트리 및 배열 기반 해싱의 장점을 결합한 자료구조로, 상태 트리가 트라이(Trie) 형태로 분리되어 동일한 해시 값을 가지는 상태는 중복을 피하고 공유 노드로 관리된다. 상태 업데이트 시 변경이 필요한 트리의 경로만 수정되고 나머지는 재사용함으로써, React의 Virtual DOM이 UI를 업데이트하는 원리와 유사한 방식이다. 이 HAMT 적용 이전에는 상태 크기에 비례하는 O(n)의 시간 복잡도가 소요되었지만, 적용 이후에는 평균적으로 O(log n), 상수 시간에도 가까워질만큼 시간 복잡도가 개선되었다.

하지만 이렇게 꽤 괜찮아보이는 Recoil은 정식 버전 출시도, 커뮤니티의 선택도 받지 못했다. Facebook(현 Meta)은 이 Recoil 프로젝트를 실험적 프로젝트로 시작했다. 이는 언제든지 중단될 수 있다는 것을 시사했으며, Recoil의 핵심 기여자가 2023년 1월에 해고되었다는 이야기도 들려왔다. 또한 복잡한 비동기 처리, 로깅 등을 위해 미들웨어를 지원해주지 않았고, React의 최신 기능들과의 결합도 유연하게 이루어지지 않았다. React Server Components(RSC)는 클라이언트와 서버 간 상태를 나누고, 서버에서 데이터를 준비해 클라이언트로 전달하는 방식을 지향했는데, 이는 클라이언트 상태 관리 도구에 의존하지 않고, 서버에서 렌더링된 결과를 React의 트리 구조에 직접 삽입하는 방식이다. Recoil은 클라이언트에서의 상태 관리에 초점을 맞추고 설계되어, 브라우저 메모리 내에서 Atom, Selector로 상태를 관리하며, React의 useState, useEffect 등 클라이언트 전용 훅에 의존한다. 따라서 Recoil은 서버 컴포넌트에서 사용이 불가능했다.

Zustand

2019년에 처음으로 출시된 Zustand는 역시나 React 생태계에서 상태 관리의 복잡성과 성능 문제를 해결하고자 등장했다. Redux는 명시적이고 예측 가능한 상태 관리가 가능했지만 보일러플레이트 코드가 많고 복잡했다. MobX는 상대적으로 단순하지만 상태 변화가 명시적이지 않아 상태 변화 예측과 디버깅이 어려웠으며, 데코레이터와 같은 추가적인 개념도 알 필요가 있었다. React Context API는 전역 상태를 간편하게 처리할 수 있었으나 렌더링 성능 문제가 있었다. 이에 반해 Zustand는 API가 단순하고, Flux 패턴을 따르나 강제하지 않아 개발자가 자유롭게 상태 관리 방식을 설계하고 선택할 수 있었다. 필요한 컴포넌트만 리렌더링하도록 설계되어 성능 효율적이기도 했다. 패키지의 번들 크기도 기존의 상태 관리 라이브러리들보다 확실히 작았다.

Zustand는 전역 상태를 저장하는 객체인 Store, 상태를 업데이트하는 함수인 Actions, 필요한 상태를 선택적으로 가져오는 함수인 Selectors, 이렇게 세 가지의 주요 구성 요소로 이루어졌다. useStore 훅을 통해 상태에 접근하는 형태는 React의 함수형 컴포넌트와 유사하여 자연스럽게 통합될 수 있었다.

import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 })),
}));

const Counter = () => {
  const count = useStore((state) => state.count);
  const increase = useStore((state) => state.increase);

  return (
    <div>
      <p>{count}</p>
      <button onClick={increase}>Increase</button>
    </div>
  );
};

또한 Zustand는 React의 상향식 렌더링(Bottom-up rendering) 전략을 사용해 필요한 컴포넌트만 다시 렌더링한다. 상태를 작은 조각으로 나누어 필요한 데이터만 구독할 수 있으며, 특정 상태에 접근할 때 의존성을 추적하기 때문에 의존성이 있는 상태가 변경된 경우에만 해당 컴포넌트를 리렌더링할 수 있다. zustand/middleware 의 persist 나 ssrExchang 를 이용한 직렬화 및 역직렬화로 서버 사이드를 지원할 수 있어 SSR 지원도 가능하다. 다만 아쉽게도 React Server Components(RSG) 내부에서는 직접적으로 사용할 수 없고, 클라이언트 컴포넌트를 이용해 초기화가 필요하다는 아쉬움이 존재한다. 이렇게 간결함, 가벼움, 성능 효율적, React와의 호환성, 유연성, 확장성 등의 많은 부분에서 장점을 가졌고, 따라서 최근 몇 년간 빠르게 주목받으며 React 생태계에서 중요한 상태 관리 라이브러리로 자리매김했다. Zustand가 탄생한 비화를 더욱 자세히 알고 싶다면 다음 문서를 참고해보자. (참고)

Jotai

Jotai도 Zustand를 개발한 Daishi Kato에 의해서 개발되었다. Redux와 유사한 철학을 지닌 Zustand와 달리, Jotai는 Atomic 패턴을 따르는 Recoil과 유사한 아키텍처를 가진다. Zustand는 단일 스토어 중심 설계로 간단하고 빠르지만 상태를 세분화하고 조합하는 데는 유연성이 아쉬웠다. Recoil은 번들 크기도 상대적으로 가볍지 않았고, 상태를 구분하는 키가 문자열 기반이었으므로 키가 충돌할 수 있다는 문제의 가능성이 있었다. Jotai는 이런 문제들을 해결하고자 새로이 등장했다.

익숙한 Recoil의 원자적(atom-based) 개념, 원자들을 조합하여 만들어내며 읽기와 쓰기 모두 가능해 디테일함과 확장성이 뛰어난 Derived Atom, React Hooks와 유사한 API 형태, 가벼운 패키지 번들 사이즈, Zustand와 유사한 의존성 추적 개념을 이용해 atom에 의존하는 컴포넌트만 리렌더링하는 성능 효율적인 Jotai 또한 Zustand에 이어 React 커뮤니티의 많은 주목을 받게 되었다.

import { atom, useAtom } from 'jotai';

const countAtom = atom(0); // 초기값

const doubledCountAtom = atom((get) => get(countAtom) * 2); // 파생 원자

const Counter = () => {
  const [count, setCount] = useAtom(countAtom);
  const doubledCount = useAtom(doubledCountAtom)[0];
  
  return (
    <div>
      <p>{count}</p>
      <p>{doubledCount}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
};

Jotai는 jotai-ssr 패키지를 함께 사용하면 React Server Components(RSC) 환경과 원활하게 통합할 수 있다. 특정 Atom만 서버에서 렌더링하거나 클라이언트로 전달할 수 있고, HydrationBoundary 컴포넌트를 사용해 서버에서 생성된 Atom 상태를 클라이언트로 하이드레이션할 수도 있으며, RenderingBoundary 컴포넌트를 통해 특정 Atom이 포함된 컴포넌트를 서버에서 렌더링하도록 설정할 수도 있다. Jotai가 탄생한 비화가 궁금하다면 다음 문서를 참고해보자. (참고)

Valtio

Zustand, Jotai를 출시한 후 Daishi Kato와 그의 팀은 전역 상태, 특히 가변 상태를 사용할 수 있는 방법에 대해 고민했다. 불변성에 기반을 두는 React는 가변 상태와 잘 맞지 않았고, 불변 상태가 없으면 Hooks API를 사용할 수 없었다. Daishi Kato는 이전부터 이런 문제를 인식하고 있었고, MobX 팀에 Hooks API에 대한 제안을 하기도 했지만 MobX 팀은 HoC API를 고수했었다. 따라서 이 문제를 해결하고, 가변 상태에 대해 Hooks API를 제공하고자 탄생한 것이 이 Valtio라는 새로운 상태 관리 라이브러리다.

Valtio는 MobX와 유사하게 Proxy를 이용하는데, 핵심 원리는 Proxy 비교를 이용해 Hooks API로 상태 변경을 지원하는 것이다. 라이브러리의 핵심 아이디어는 ‘스냅샷(snapshot)’이라는 것이었는데, 이는 React Hooks를 함께 동작시키기 위한 변경 가능한 상태에서 만들어진 변경 불가능한 객체를 의미하며, 여기서 스냅샷은 ImmutableJS의 구현 설계와 유사하다고 한다. 더욱 자세한 비하인드가 궁금하다면 다음 문서를 참고해보자. (참고)

// store/theme.ts
import { proxy, useSnapshot } from 'valtio';

interface ThemeState {
  mode: 'light' | 'dark';
  colors: {
    background: string;
    text: string;
    primary: string;
  };
}

// proxy로 상태 생성
export const themeStore = proxy<ThemeState>({
  mode: 'light',
  colors: {
    background: '#ffffff',
    text: '#000000',
    primary: '#0066cc'
  }
});

export const themeActions = {
  toggleTheme: () => {
    // Valtio에서는 상태를 직접 변경할 수 있다.
    themeStore.mode = themeStore.mode === 'light' ? 'dark' : 'light';
    
    if (themeStore.mode === 'dark') {
      themeStore.colors = {
        background: '#1a1a1a',
        text: '#ffffff',
        primary: '#4d94ff'
      };
    } else {
      themeStore.colors = {
        background: '#ffffff',
        text: '#000000',
        primary: '#0066cc'
      };
    }
  }
};

// App.tsx
const App = () => {
  const theme = useSnapshot(themeStore);
  
  return (
    <div>
      <button
        onClick={themeActions.toggleTheme}
        style={{
          backgroundColor: theme.colors.primary,
          color: theme.colors.text,
        }}
      >
        Current Theme: {theme.mode}
      </button>
    </div>
  );
};

export default App;

XState

논외로, XState라는 상태 관리 도구도 있다. GitHub Stars 수와 Download 수에 비해 국내의 사용률은 저조한 편인 것처럼 보이지만, 이산수학에서 등장하는 오토마타 이론을 보다가 개인적으로 알게 되었다. XState는 유한 상태 기계(Finite State Machine, FSM) 원리를 기반으로 하는 상태 관리 라이브러리다. Redux의 단방향 데이터 흐름과 불변성은 훌륭했지만 복잡한 상태 전환 로직을 명시적으로 표현하기 어려웠고, MobX의 반응형 프로그래밍은 편리했지만 상태 전환의 명확성과 예측 가능성이 아쉬웠다. 현대 웹 애플리케이션의 매우 복잡해진 상태와 개수가 증가할수록 기하급수적으로 증가하는 복잡도 문제와, 로딩, 성공, 실패, 재시도 등의 상태 처리와 여러 비동기 작업의 순차적/병렬적 실행 관리, 상태 전이는 너무나 복잡하고 예측하기 어려웠다. David Khourshid는 상태 머신과 상태 차트 개념을 차용해 기존 도구들에서 아쉬웠던 복잡한 상태 전환 관리 문제를 해결하고자 했다.

출처 : https://stately.ai/viz
출처 : https://stately.ai/viz

XState는 결정적 유한 상태 기계(Deterministic FSM)라는 수학적 모델을 JavaScript로 구현해 예측 가능하며 견고한 상태 관리를 하고자 했다. 상태 머신(State Machine)은 유한 상태 기계(FSM)의 구현으로, 시스템이 가질 수 있는 유한한 상태와 상태 간의 전환을 정의할 수 있다. 오토마타 이론의 FSM 모델을 따르며, 각 상태와 전환이 명확히 정의되어 있어 예측 가능한 동작을 제공한다. 시스템이 가질 수 있는 유현한 상태들의 집합인 States(상태), 특정 이벤트에 의해 한 상태에서 다른 상태로 이동하는 규칙인 Transitions(전이), 전이를 트리거하는 입력 또는 신호인 Events(이벤트)로 구성된다. 상태 차트(Statecharts)는 유한 상태 기계를 확장한 개념으로, 복잡한 시스템의 계층적(Hierarchical), 병렬(Parallel), 비동기(Asynchronous) 상태를 모델링할 수 있다.

사용법은 그렇게 어렵진 않았으나, 추상적인 수학 모델에 대한 이해가 있다면 더욱 능숙하게 다룰 수 있을 것 같다고 느꼈다. 다양한 프레임워크에서 사용 가능하다는 점, 수학적 이론에서 비롯되었다는 점, 상태 머신의 시각화를 통한 디버깅도 가능하다는 점이 매우 인상적으로 다가왔다. XState의 개발자가 버전 4의 출시와 함께 XState를 개발했던 히스토리에 대해 간략하게 설명하는 글이 있는데, 관심이 있다면 다음 문서를 참고해보자. (참고)

// fetchMachine.ts
import { createMachine, assign } from 'xstate';

interface Context {
  retries: number;
}

const fetchMachine = createMachine<Context>({
  id: 'fetch',
  initial: 'idle',
  context: {
    retries: 0
  },
  states: {
    idle: {
      on: {
        FETCH: 'loading'
      }
    },
    loading: {
      on: {
        RESOLVE: 'success',
        REJECT: 'failure'
      }
    },
    success: {
      type: 'final'
    },
    failure: {
      on: {
        RETRY: {
          target: 'loading',
          actions: assign({
            retries: (context, event) => context.retries + 1
          })
        }
      }
    }
  }
});

// App.tsx
import React from 'react';
import { useMachine } from '@xstate/react';
import { fetchMachine } from './fetchMachine';

const App = () => {
  const [state, send] = useMachine(fetchMachine);

  return (
    <div>
      <h1>Fetch Machine</h1>

      {/* 현재 상태 표시 */}
      <p>Current State: {state.value}</p>

      {/* 컨텍스트 값 표시 */}
      <p>Retries: {state.context.retries}</p>

      {/* 상태에 따른 버튼 렌더링 */}
      {state.matches('idle') && (
        <button onClick={() => send('FETCH')}>Start Fetch</button>
      )}

      {state.matches('loading') && <p>Loading...</p>}

      {state.matches('success') && <p>Fetch Successful!</p>}

      {state.matches('failure') && (
        <>
          <p>Fetch Failed</p>
          <button onClick={() => send('RETRY')}>Retry</button>
        </>
      )}
    </div>
  );
};

export default App;

마무리

현대 프론트엔드 개발에서 상태 관리는 단순한 데이터 저장을 넘어 애플리케이션의 복잡성을 다루는 핵심 요소가 되었다. 초기의 MVC 패턴에서 Flux 아키텍처로, 그리고 다양한 상태 관리 라이브러리의 등장으로 이어진 발전 과정은 프론트엔드 개발의 도전과제들을 해결하려는 개발자들의 끊임없는 노력을 보여준다.

특히 주목할 만한 점은 상태 관리 도구들이 단순히 기존 문제를 해결하는 데 그치지 않고, 개발자 경험과 애플리케이션 성능이라는 두 가지 측면을 모두 개선하려 노력했다는 것이다. Redux Toolkit의 간소화된 API, Zustand의 미니멀한 접근방식, Jotai의 유연한 원자 모델, XState의 유한 상태 머신과 상태 차트는 이러한 진화의 좋은 예시라고 생각한다. 또한 React Server Components(RSC)와 같은 새로운 패러다임은 프론트엔드 애플리케이션의 상태 관리 개념을 크게 뒤바꾸는 것처럼 보이기도 한다. 앞으로의 상태 관리 개념과 이를 받쳐주는 도구들의 모습은 어떻게 변화할까? 프로덕트를 개발하기 위해 최적의 설계를 하고, 이를 튼튼하게 뒷받침하는 효율적인 도구들을 고르는 것조차 어려운데, 보이지 않는 곳에서 더 나은 사용자 경험과 성능을 제공하는 애플리케이션을 개발할 수 있는 도구를 제공하고자 노력하는 많은 오픈 소스 개발자들에게 존경을 표한다.

레퍼런스 및 참고할만한 사이트

https://www.reddit.com/r/reduxjs/comments/en7eh7/why_should_i_use_redux_instead_of_cookies_or/ https://facebookarchive.github.io/flux/ https://github.com/facebookarchive/flux/tree/main/examples/flux-concepts https://github.com/facebookarchive/flux https://community.theforeman.org/t/rethinking-react-redux-folder-structure/24183 https://ko.mobx.js.org/getting-started.html https://techblog.woowahan.com/2599/ https://redux.js.org/understanding/history-and-design/history-of-redux https://medium.com/@dan_abramov/the-evolution-of-flux-frameworks-6c16ad26bb31 https://github.com/erikras/ducks-modular-redux https://legacy.reactjs.org/blog/2018/03/29/react-v-16-3.html https://zustand.docs.pmnd.rs/getting-started/introduction https://jotai.org/docs/basics/comparison https://blog.axlight.com/ https://medium.com/@DavidKPiano/xstate-version-4-released-665b59409f99 https://blog.hwahae.co.kr/all/tech/6707 https://fe-developers.kakaoent.com/2022/220922-make-cart-with-xstate/


원문: Medium