프론트엔드 상태 관리 총정리: 내부 구조까지 한눈에 보기

승환입니다·2024년 7월 22일
post-thumbnail

📌 시작하기 앞서


마이크로 상태관리라는 책을 읽었다.
다이시 카토라는 분이 책을 쓰셨는데 되게 재밌었다.
다이시 카토는 zustand, jotai, valtio를 만들었다.
zustand, jotai, valtio는 알다싶이 모두 전역으로 상태를 관리하기위해서 만들어진 라이브러리이다.

하지만 3개의 라이브러리 동작의 목표는 같지만 다른 패러다임을 가지고 있다.

  • zustand: 상태를 모듈 단위로 관리하며, 구독 기반으로 렌더링 최적화. 스토어는 컴포넌트 외부에서 관리.
  • jotai: atom 단위로 상태를 분리하여 관리하며, 구독 기반으로 렌더링 최적화. 상태는 컴포넌트 안에서도 정의 가능하지만 주로 외부에 정의.
  • valtio: Proxy 객체로 상태를 관리하며, 리액티브한 동작을 제공. 상태는 컴포넌트 외부에서 정의.

전역상태라는 기능을 여러가지 시각을 가지고 새로운 라이브러리를 만든 점이 굉장히 인상깊었다.
이번 포스팅은 마이크로 상태관리라는 책을 기반으로 상태에 대해 포스팅해볼려고 한다.

📌 상태란?


상태는 리액트 컴포넌트안에 존재하는 동적인 데이터이다.
상태가 변경되면 리액트는 해당 상태를 사용하는 컴포넌트를 다시 렌더링하여 UI를 최신 상태로 유지한다.
즉, 상태를 얼마나 쓰고, 어떻게 설계하는지에 따라 서비스의 성능이 달라진다.

그렇다면 성능이 좋은 프로젝트를 만들기 위해 상태 설계는 어떻게 해야할까?

💡 첫 번째는 상태를 안쓰는 것이다.

하지만 상태없이는 원활한 프로그램을 만들기 어렵기 때문에 상태를 최소화해서 쓰자로 정정한다.

💡 두 번째는 상태가 사용되는 범위에 맞는 위치에 상태를 두는 것이다.

우리는 Scope라는 개념을 알고있다.
Scope란 변수나 함수가 어디까지 접근 가능한지, 유효한지를 결정하는 범위이다.
변수나 함수를 쓰이는 범위에 맞게 Scope를 잘 지정해두면 에러가 생겼을 때 디버깅을 빠르게 할 수 있고 관리하는 프로젝트 복잡도를 줄일 수 있다.

상태도 마찬가지다.
상태가 사용되는 범위라고 앞서 설명했는데 상태는 각각 어떤 범위를 가지고 있을까?
크게 지역상태전역상태로 구분된다.
우리는 사용되는 범위에 따라 지역상태와 전역상태로 구분해야한다.

A라는 컴포넌트에서만 쓰이는 상태를 전역으로 관리한다면 어떤 일이 일어날까?
지금 당장 실행도 잘되고 아무런 이상이 없을 것이다. 하지만 잘못 사용하게되면 독이 될 수 있다.

상태관리를 전역으로 하면 어떤 단점이 있을까?

첫번째 디버깅의 어렵다.
리액트는 단방향 바인딩 라이브러리이다.
그렇기 때문에 하나의 컴포넌트에서 에러가 생긴다면 상위 컴포넌트를 재귀적으로 확인하면서 에러의 원인을 찾을 수 있다.
하지만 전역 상태를 쓴 컴포넌트에서 에러가 났다면 상위 컴포넌트가 아닌 모든 컴포넌트를 디버깅해야한다.

두번째 컴포넌트 재사용이 어렵다.
말 그대로 유연성이 떨어진다.

세번째 어디서든 사이드이펙트가 발생할 수 있다.
어디서든 접근이 가능한 변수를 만드는 것 이기때문에 어디서든 사이드이펙트가 발생할 수 있는 변수를 만드는 것과 같다.

마지막 프로그램을 실행하는 동안 계속 메모리를 차지한다.

위의 이유로 전역으로 관리하기전에 전역상태를 꼭 써야하는 이유가 있을 때만 쓰는 것을 추천한다.


위에서 상태가 무엇인지, 상태를 어떤 상황에 써야하는지 알아봤다.
이번에는 React에서 상태를 관리할 수 있는 문법을 알아보자.
리액트에서 상태를 변경할 수 있는 hook은 현재까지 useState, useReducer 두개이다.
여기서 useReducer의 내부는 useState에 의존된다.
즉 useState만 써도 useReducer의 동작을 따라할 수 있다는 것이다.
그렇다면 useReducer는 어떤 상황에 쓰는게 적합할까?
그건 바로 복잡한 상태를 관리할 때 useState보다 유지보수 측면으로 봤을 때 뛰어나다.
간단하게 useState와 useReducer를 알아보자.

📌 useState


useState는 특정 컴포넌트 내에서만 유효한 상태를 관리한다.
이 상태는 컴포넌트가 다시 렌더링될 때도 유지되며 (클로져), 상태가 변경되면 해당 컴포넌트가 다시 렌더링된다.

const [count, setCount] = useState(0);

📌 useReducer


useReducer도 useState와 같이 특정 컴포넌트 내에서 사용될 상태를 관리한다.
useState로 useReducer의 할 일 까지할 수 있지만 좀 더 복잡한 상태를 관리할 때 보다 편하게 관리할 수 있다는 장점을 가지고 있다.

// 리듀서 함수
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      throw new Error();
  }
};

function Counter() {
  // useReducer 사용
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

export default Counter;

reducer 함수는 순수함수이어야한다.
reducer 함수를 만들었으면 dispatch를 호출하면서 상태를 관리할 수 있다.
count라는 값이 여러 방면으로 변경이 된다면 useReducer를 고려해봐도 좋다.

📌 Props drilling


리액트는 단반향 바인딩 라이브러리이다. 상위 컴포넌트에 있는 데이터는 하위 컴포넌트로 공유가 가능하다.
위의 useState와 useReducer은 특정 컴포넌트안에서만 유효한 상태를 관리하는데
만약 하위 컴포넌트에서 useState 또는 useReducer로 정의된 상태를 공유받고 싶다면 props를 사용해 상태를 넘겨주어야한다. ( lifting state up 방식 )
이때 모든 상태를 지역 상태로 관리하면 props drilling이 발생할 수 있다.

위의 사진과 같이 2번 3번은 4번에 상태를 전달하기 위한 수단으로만 쓰인다.
이런 상황을 props drilling이 발생했다고 한다.
props drilling이 잦으면 쓸모없는 코드가 많아져 지져분해지고 유지보수가 힘들고 코드의 예측이 힘들어 질 수 있다.

이런 문제를 해결하기위해 나온 방법으로 전역상태라는 것이 생겼다.
전역으로 상태를 관리하면 어떤 컴포넌트든 자유롭게 접근할 수 있다.
하지만 메모리 문제사이드 이펙트가 생길 수 있으니 잘 판단해서 도입해보자.

아깐 전역으로 상태관리하면 유지보수 안좋다고 했는데? props drilling 생겨도 유지보수 안좋다고 하는데 뭘까?

props drilling이 잦아도 유지보수가 어렵다. 그렇다고 전부 전역으로 상태를 관리하는 것도 유지보수가 어렵다.
그렇기 때문에 프론트엔드 개발자 혼자 또는 프로젝트 팀원들끼리 적절하게 프로젝트 규모나 코드 스타일을 생각하며 자신만의 기준을 만드는게 중요하다고 생각한다. (알잘딱깔센 하라는 뜻)

지금까지 props drilling을 설명했는데 이제는 전역 상태관리에 대해서 알아보자.

📌 전역에서의 상태관리


전역상태 관리를 도와주는 라이브러리는 굉장히 많다.
Context api, Redux, Zustand, Recoil, Jotai, Mobx, Valitio, Xstate 등

위에 나온 기술들 모두 컴포넌트에 자유롭게 접근할 수 있다는 점을 공통점으로 가지고 있다.

 그럼 어떤 라이브러리를 써야할까?

어떤 라이브러리를 써야할지 선택할 수 있는 시야를 만들기위해서 각각의 라이브러리의 특징을 잘 알고 있어야한다.
하지만 이번 포스팅에서는 모듈 상태인 zustand와 atom으로 상태를 관리하는 recoil을 알아보자

📌 redux와 zustand


첫번째로 redux와 zustand가 친구다.
reducer기반으로 만들어져있고 모듈 상태이며 중앙 집중식 라이브러리이다.
여기서 모듈이란 함수 외부에서 선언된 변수이고 중앙 집중식이란 객체 형태로 성격에 맞는 데이터끼리 모여져 있다는 뜻이다.
데이터들이 성격에 맞게 모여있기때문에 개발자들은 빠르게 전역 데이터 구조를 파악하기 쉽다.
추가적으로 flux 패턴을 가지고있으며 불변성을 유지해야한다는 특징을 가지고 있다.

장점

  • 불변성 유지를 하기 때문에 안정성이 높다.
  • flux 패턴을 가지고있어서 데이터의 흐름을 파악하기 쉽다.
  • 중앙 집중식 라이브러리이기때문에 데이터 구조를 파악하기 쉽다.

단점

  • 수동 렌더링 최적화를 해야한다.
  • 리액트 라이프 사이클과 다르게 동작한다.
  • 싱글톤 패턴이라 재사용이 힘들다.
  • 복잡한 분기별 상태 관리가 어렵다.

Zustand는 상태를 독립적인 모듈로 관리하며, React 컴포넌트 외부에서 상태를 정의하고 업데이트한다.
이 구조는 상태가 특정 컴포넌트 트리에 종속되지 않고 전역적으로 관리될 수 있도록 한다.

Zustand는 각 컴포넌트가 필요한 상태만 구독할 수 있게 하는 선택적 구독 메커니즘을 제공한다.
컴포넌트는 useStore 훅을 통해 상태의 특정 부분만을 구독할 수 있고, 구독된 상태가 변경될 때만 리렌더링된다.

Zustand는 상태를 작은 단위로 분리하여 관리할 수 있게 한다.
이를 통해 상태 변경이 일어날 때, 해당 상태를 구독하고 있는 컴포넌트만 리렌더링된다.

import create from 'zustand';

// Zustand 상태 정의
const useStore = create(set => ({
  count: 0,
  increase: () => set(state => ({ count: state.count + 1 })),
}));

function Counter() {
  const count = useStore(state => state.count);//이 부분
  const increase = useStore(state => state.increase);// 이 부분

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

위의 코드에서 useStore라는 훅이 있다. ( 전역 상태를 관리하는 큰 공장 )
여기서 내가 써야하는 데이터만 부분적으로 가져와서 써야한다.
만약 모든 데이터를 가져온다면 모든 데이터를 구독한다는 뜻으로 불필요한 리렌더링이 일어날 수 있다. 이처럼 Zustand를 쓸 때에는 쓸 데이터만 가져와 수동렌더링최적화를 해줘야한다.

또한 Zustand는 불변 상태 모델로 불변성을 유지해야한다.
불변성을 유지하게되면 변경에 대한 예측이 쉬워져 안정성이 확보된다.

💡 zustand의 핵심 내부구조

이번엔 zustand의 핵심 코드를 살펴보자

createStore 함수

const createStore = <T extends unknown>(initialState: T): Store<T> => {
  let state = initialState;
  const callbacks = new Set<() => void>();
  const getState = () => state;
  const setState = (nextState: T | ((prev: T) => T)) => {
    state =
      typeof nextState === "function"
        ? (nextState as (prev: T) => T)(state)
        : nextState;
    callbacks.forEach((callback) => callback());
  };
  const subscribe = (callback: () => void) => {
    callbacks.add(callback);
    return () => {
      callbacks.delete(callback);
    };
  };
  return { getState, setState, subscribe };
};

state 변수: initialState를 초기값으로 가지는 상태 변수이다.
콜백 관리: 상태가 변경되면 알림을 받을 구독자들을 저장하는 Set이다.
getState: 현재 상태를 반환한다.
setState: 상태를 업데이트하고, 모든 구독자들에게 변경 사항을 알린다.
nextState가 함수일 경우, 이전 상태를 사용해 새로운 상태를 계산한다.
모든 콜백(callback)을 호출하여 구독자들에게 상태 변경을 알린다.
subscribe: 상태 변경 시 실행할 콜백을 등록하고, 이를 구독 취소하는 함수(unsubscribe)를 반환한다.

store 객체

const store = createStore({ count: 0 });

초기 상태 count: 0로 createStore를 호출하여 store 객체를 생성한다.
store는 상태 관리에 필요한 세 가지 메서드(getState, setState, subscribe)를 포함한다.

useStore 커스텀 훅

const useStore = <T extends unknown>(store: Store<T>) => {
  const [state, setState] = useState(store.getState());
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(store.getState());
    });
    setState(store.getState());
    return unsubscribe;
  }, [store]);
  return [state, store.setState] as const;
};

상태 초기화: store.getState()를 호출해 초기 상태를 가져온다.
useEffect를 통한 구독:
store.subscribe를 사용해 상태 변경 시 실행될 콜백을 등록한다.
컴포넌트가 언마운트되면 unsubscribe를 호출해 구독을 해제한다.
[state, store.setState] 반환:state: 현재 상태를 제공.
store.setState: 상태를 업데이트하는 함수.

useStoreSelector 커스텀 훅

const useStoreSelector = <T, S>(store: Store<T>, selector: (state: T) => S) => {
  const [state, setState] = useState(() => selector(store.getState()));
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(selector(store.getState()));
    });
    setState(selector(store.getState()));
    return unsubscribe;
  }, [store, selector]);
  return state;
};

store: 상태를 관리하는 Store 객체.
selector: 상태의 특정 부분을 선택하는 함수
예를 들어, state => state.count1를 전달하면 count1의 상태만 추적할 수 있다.
상태 초기화:
useState를 사용하여 선택된 상태 값을 초기화한다.
초기 상태는 selector(store.getState())를 호출하여 얻는다.
useEffect를 통한 구독:

store.subscribe를 사용하여 상태 변경을 구독한다.
상태가 변경되면 selector(store.getState())를 호출하여 선택된 상태만 업데이트한다.
구독 해제를 위해 unsubscribe를 반환한다.
선택된 상태 반환:
선택된 상태를 반환합니다. 이 상태는 컴포넌트에서 사용할 수 있다.

Component1 컴포넌트

const Component1 = () => {
  const [state, setState] = useStore(store);
  const inc = () => {
    setState((prev) => ({
      ...prev,
      count: prev.count + 1,
    }));
  };
  return (
    <div>
      {state.count} <button onClick={inc}>+1</button>
    </div>
  );
};

두번째로 recoil과 jotai가 친구다.
atom 이라는 상태 단위 기반으로 만들어져있고 컴포넌트 상태이며 atom 단위로 관리된다.

여기서 컴포넌트 상태란 컴포넌트 안에서 상태가 관리된 다는 뜻이다.

atom 단위는 하나의 atom의 하나의 데이터가 들어가기 때문에 재사용이 간편하다.
하지만 성격에 맞는 데이터 관리가 여러 atom이 있기 때문에 한눈에 파악하기 힘들다는 단점을 가지고 있다.

컴포넌트 패턴의 전역상태 라이브러리는 내부적으로 context api로 만들어져 있다.

그럼 여기서 context api는 데이터가 업데이트될 때 모든 하위 컴포넌트 트리가 리렌더링되는데 recoil과 jotai는 어떻게 그걸 방지했을까?

그건 fine-grained 특성을 사용했기 떄문이다.

장점

  • 불변성 유지를 하지 않아도 된다.
  • atom 단위로 상태가 관리되기 때문에 재사용이 편하다.
  • 수동 렌더링 최적화를 하지 않아도 된다.

단점

  • atom 단위로 상태가 관리되어서 데이터 흐름을 빠르게 파악하지 못한다.
  • 라이브러리가 zustand에 비해 무겁다.

📌 요약


  • 상태는 최대한 적게 사용하자

  • props drilling이 발생해 전역상태 라이브러리를 쓰게됐다.

  • 전역으로 상태를 관리할 때는 꼭 이 상태를 전역으로 관리해야할까? 한번 더 생각해보자

  • Context api
    Context api를 구독한 모든 컴포넌트는 전부 리렌더링

  • Recoil
    컴포넌트안에서 상태관리
    atom이라는 단위로 상태관리
    selectors를 통해 파생 상태를 계산하고 관리
    atom을 구독한 컴포넌트만 부분적으로 리렌더링

  • Zustand
    컴포넌트밖에서 상태관리
    Store라는 객체에서 데이터를 부분적으로 가져와 사용, 가져온 데이터만 부분 구독을 해 수동 렌더링 최적화
    부분 구독한 컴포넌트만 부분적으로 리렌더링

📌 마치며


이전에는 아무생각없이 useState만으로 지역 상태를 관리하고 props drilling이 생기면 트랜드에 맞는 라이브러리만 잠깐 공부해서 사용하곤했는데 내부적으로 상태가 어디에서 관리되는지, context api의 단점을 어떻게 극복했는지, 그리고 라이브러리마다 어떤 특징을 가지고 있는지를 알고나니까
내 프로젝트에 맞는 라이브러리를 적절하게 사용할 수 있을것같다.

profile
자바스크립트를 좋아합니다.

1개의 댓글

comment-user-thumbnail
2024년 11월 26일

이해하기 쉽게 글을 잘쓰시는것 같아요

답글 달기