본문 바로가기
FE/리액트 훅을 활용한 마이크로 상태관리

1. 리액트 훅을 이용한 마이크로 상태 관리 - 1

by Toddler_AD 2025. 9. 24.
  • 상태 관리(state management)는 리액트 애플리케이션을 개발할 때 중요한 문제 중 하나다. 전통적으로 리액트의 상태 관리는 상태 관리를 위한 범용 프레임워크를 사용해 개발자가 해당 프레임워크 내에서 목적에 맞게 해결하는 중앙 집중적인 방식으로 이뤄져왔다.
  • 리액트 훅(React hook)이 등장한 이후로는 상황이 바뀌었다. 상태 관리를 위한 기본적인 훅을 사용할 수 있으며, 이러한 훅은 재사용 가능하고 더 풍부한 기능을 만들기 위한 기반 요소로 사용할 수 있다. 이를 통해 상태 관리를 경량화, 즉 마이크로화 할 수 있다. 전통적인 중앙 집중형 상태 관리는 범용적으로 사용되는 반면, 마이크로 상태 관리(micro state management)는 좀 더 목적 지향적이며 특정한 코딩 패턴과 함께 사용된다.
  • 여기서는 리액트 훅을 이용한 다양한 상태 관리 패턴을 살펴보고, 여러 컴포넌트가 상태를 공유할 수 있는 전역 상태를 주로 다룬다. 리액트 훅은 이미 지역 상태, 즉 단일 컴포넌트 또는 소수의 컴포넌트로 구성된 트리 내에서 상태를 다룰 수 있는 훌륭한 기능을 제공한다. 반면 전역 상태는 리액트에서 다루기가 까다로운 주제다. 그 이유는 리액트 훅이 전역 상태를 다룰 수 있는 기능을 제공하지 않아서 전역 상태 관리가 고스란히 커뮤니티와 생태계의 몫이 됐기 때문이다.
  • 다양한 상태 관리 패턴을 비롯해 마이크로 상태관리를 위한 몇 가지 라이브러리를 살펴볼 것이며, 각각은 목적과 사용 패턴이 다르다. 

♣ 메모 : 전역 상태에 대해 집중적으로 다룰 것이며 '범용적인' 상태 관리에 대해서는 다루지 않는다. 가장 널리 사용되는 상태 관리 라이브러리 중 하나는 Redux로, 상태 관리에 단방향 데이터 모델을 사용한다. 또 다른 인기 라이브러리인 XState는 복잡한 상태를 시각적으로 표현하는 상태 차트를 구현한다. 두 라이브러리 모두 이 장에서 다루지 않는 정교한 상태 관리 방법을 제공한다. 한편으로 이러한 라이브러리에도 전역 상태에 대한 기능이 있다. 예를 들어, React Redux는 전역 상태를 위해 React와 Redux를 묶어주는 라이브러리가 있으나, 전역 상태에 초점을 맞추기 위해 Redux에 의존하는 React Redux에 대해서는 구체적으로 다루지 않는다.

  • 이번 장에서는 마이크로 상태 관리가 무엇인지 정의하고 리액트 훅으로 어떻게 마이크로 상태 관리를 할 수 있는지, 그리고 전역 상태가 왜 다루기 어려운지 설명한다. 또한 상태 관리를 위한 두 가지 훅의 기본 사용법을 설명하고 유사점과 차이점을 비교할 것이다. 이번 장에서 다룰 주제는 다음과 같다.
    • 마이크로 상태 관리 이해하기
    • 리액트 훅 사용하기
    • 전역 상태 탐구하기
    • useState 사용하기
    • useReducer 사용하기
    • useState와 useReducer의 유사점과 차이점

마이크로 상태 관리 이해하기

  • 마이크로 상태 관리란 무엇일까? 아직 공식적인 정의는 없지만, 한번 정의해 보려 한다.
  • 리액트에서 상태는 사용자 인터페이스(UI)를 나타내는 모든 데이터를 말한다. 상태는 시간이 지남에 따라 변할 수 있으며 리액트는 상태와 함께 렌더링할 컴포넌트를 처리한다.
  • 리액트 훅이 나오기 전까지는 중앙 집중형 상태 관리 라이브러리를 사용하는 것이 일반적이었다. 단 하나만 존재하는 상태로도 더 나은 개발자 경험을 위해 다양한 상황을 포괄적으로 지원할 수도 있겠지만 때로는 중앙 집중형 상태 관리 라이브러리에 사용되지 않는 기능까지 포함될 수 있어 과한 측면도 있었다. 리액트 훅이 등장하면서 상태를 생성하는 새로운 방법이 생겼다. 이를 통해 특정 목적에 따라 다른 해결책을 제공할 수 있게 됐다. 몇 가지 예시를 살펴보자.
    • 폼(Form) 상태는 전역 상태와 별도로 처리해야 하는데, 이는 단일 상태로는 해결할 수 없다.
    • 서버 캐시 상태는 다른 상태와는 다른 리페칭(refetching, 다시 불러오기) 같은 몇 가지 고유한 특성이 있다.
    • 내비게이션 상태는 원 상태가 브라우저에 있다는 특수한 요건이 있기 때문에 단일 상태는 적합하지 않다. 
  • 이러한 문제를 해결하는 것이 리액트 훅의 목표 중 하나라고 할 수 있다. 리액트 훅은 다양한 상태를 각기 특정한 방법으로 처리하는 방향으로 만들어지고 있다. 폼 상태, 서버 캐시 상태 등을 해결하기 위한 여러 리액트 훅 기반 라이브러리가 존재한다.
  • 그렇지만 목적 지향적인 방법으로 처리할 수 없는 상태도 있기에 여전히 범용적인 상태 관리가 필요하다. 범용적인 상태 관리가 필요한 작업 비율은 애플리케이션에 따라 다르다. 예를 들어, 서버 상태를 주로 다루는 애플리케이션이라면 하나 또는 소수의 전역 상태만 필요할 것이다. 반면 풍부한 그래픽을 제공하는 애플리케이션은 서버 상태만 필요한 애플리케이션에 비해 많은 전역 상태가 필요할 것이다.
  • 따라서 범용적인 상태 관리를 위한 방법은 가벼워야 하며, 개발자는 요구사항에 따라 적절한 방법을 선택할 수 있어야 한다. 이를 가리켜 마이크로 상태 관리라고 한다. 이 개념을 정의하자면 리액트의 가벼운 상태 관리라고 할 수 있으며, 각 상태 관리 방법마다 서로 다른 기능을 가지며, 개발자는 애플리케이션 요구사항에 따라 적합한 방법을 선택할 수 있다.
  • 마이크로 상태 관리는 개발자의 다양한 요구사항을 충족하기 위해 몇 가지 필수적인 기능이 필요하다. 즉, 다음과 같은 작업을 수행하기 위한 기본적인 상태 관리 기능이 필요하다.
    • 상태 읽기
    • 상태 갱신
    • 상태 기반 렌더링
  • 하지만 다른 작업을 수행하기 위해 다음과 같은 추가적인 기능이 필요할 수 있다.
    • 리렌더링 최적화
    • 다른 시스템과의 상호 작용
    • 비동기 지원
    • 파생 상태
    • 간단한 문법
  • 그렇지만 이 모든 기능이 필요한 것은 아니며, 일부 기능은 서로 충돌할 수도 있다. 따라서 마이크로 상태 관리를 사용하는 방법은 하나만 있는 것이 아니라 다양한 요구사항에 맞는 여러 방법이 있다.
  • 마이크로 상태 관리와 관련 라이브러리에 대해 언급해야 할 또 다른 관점은 학습 곡선이다. 학습의 용이성은 범용적인 상태 관리에서도 중요하지만, 마이크로 상태 관리에서 다루는 사용 사례는 더 적기 때문에 배우기가 더 쉬워야 한다. 학습 곡선이 완만하면 개발자 경험이 향상되고 생산성이 높아진다.

리액트 훅 사용하기

  • 마이크로 상태 관리를 하기 위해서는 리액트 훅이 필수다. 리액트 훅에는 다음과 같이 상태 관리 방법을 구현하기 위한 몇 가지 기본 리액트 훅이 포함돼 있다.
    • useState 훅은 지역 상태를 생성하는 기본적인 함수로, 로직을 캡슐화하고 재사용 가능하다는 리액트 훅의 특징이 있다. 그래서 useState를 기반으로 다양한 사용자 정의 훅을 만들 수 있다.
    • useReducer 훅도 지역 상태를 생성할 수 있으며, useState를 대체하는 용도로 자주 사용된다. 
    • useEffect 훅을 이용하면 리액트 렌더링 프로레스 바깥에서 로직을 실행할 수 있다. 특히 전역 상태를 다루기 위한 상태 관리 라이브러리를 개발할 때 중요한데, 그 이유는 리액트 컴포넌트 생명 주기와 함께 작동하는 기능을 구현할 수 있기 때문이다.
  • 리액트 훅이 참신한 이유는 UI 컴포넌트에서 로직을 추출할 수 있기 때문이다. 다음은 useState 훅을 사용하는 카운터 예제다.
const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </div>
  );
};
  • 위 예제에서 로직을 추출하는 방법을 알아보자. 다음과 같이 useCount라는 이름의 사용자 정의 훅을 만들어 동일한 카운터 예제를 만들 수 있다.
const useCount = () => {
  const [count, setCount] = useState(0);
  return [count, setCount];
};

const Component = () => {
  const [count, setCount] = useCount();
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </div>
  );
};
  • 크게 달라진 것이 없기 때문에 불필요하게 복잡해졌다고 생각할 수 있다. 하지만 다음 두 가지 관점을 생각해 보자.
    • useCount라는 이름을 통해 더 명확해졌다.
    • Component가 useCount 구현과 분리됐다.
  • 첫 번째는 일반적으로 프로그래밍에서 매우 중요한 점이다. 사용자 정의 훅을 통해 이름을 적절하게 지정하면 코드의 가독성이 더 좋아진다. useCount 대신 useScore, usePercentage, usePrice 같은 이름을 사용할 수도 있다. 구현이 동일하더라도 이름이 다르면 다른 종류의 훅으로 여길 수 있다. 이름을 적절하게 짓는 것은 매우 중요하다.
  • 두 번째는 마이크로 상태 관리 라이브러리에도 중요하다. useCount가 Component에서 분리 됐으므로 컴포넌트를 건드리지 않고도 기능을 추가할 수 있다. 예를 들어, 카운트가 바뀔 때 콘솔에 디버깅 로그를 출력하고 싶다면 다음과 같이 코드를 작성하면 된다.
const useCount = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log('count is changed to', count);
  }, [count]);
  return [count, setCount];
};
  • useCount 로직만 변경해서 디버깅 로그를 출력하는 기능을 추가할 수 있다. 컴포넌트를 수정할 필요가 전혀 없다. 이것이 바로로직을 사용자 정의 훅으로 분리했을 때의 장점이다.
  • 새로운 규칙을 추가하는 것도 가능하다. 카운트가 임의의 숫자로 변경되는 것을 허용하지 않고 1씩 증가시키고 싶다고 가정해보자. 사용자 정의 훅을 다음과 같이 수정하면 된다.
const useCount = () => {
  const [count, setCount] = useState(0);
  const inc = () => setCount((c) => c + 1);
  return [count, inc];
};
  • 이처럼 다양한 목적에 맞는 사용자 정의 훅을 제공할 수 있다. 단순히 작은 기능을 추가하는 래퍼(wrapper)가 될 수도 있고 더 큰 역할을 하는 거대한 훅이 될 수도 있다.
  • 노드 패키지 매니저(npm)나 깃허브에서 오픈소스로 공개된 다양한 사용자 정의 훅을 찾아 볼 수 있다.
  • 서스펜스(suspense)와 동시성 렌더링(concurrent rendering)에 대해서도 알아볼 필요가 있다. 리액트 훅은 동시성 렌더링과 함께 작동하도록 설계 및 개발됐기 때문이다.

데이터 불러오기를 위한 서스펜스와 동시성 렌더링

  • 데이터 불러오기를 위한 세스펜스와 동시성 렌더링은 아직 릴리스되지 않았지만 중요하기 때문에 간략하게 짚고 넘어가겠다.

♣ 메모 : 데이터 불러오기를 위한 서스펜스와 동시성 렌더링은 공식적으로 릴리스 될 때 이름이 달라질 수 있지만 이 책을 쓰는 시점에는 해당 이름을 사용하고 있다.

  • 데이터 불러오기를 위한 서스펜스는 기본적으로 비동기 처리(async)에 대한 걱정 없이 컴포넌트를 코딩할 수 있는 방법이다.
  • 동시성 렌더링은 렌더링 프로세스를 청크(chunk)라는 단위로 분할해서 중앙 처리 장치(CPU)가 장시간 차단되는 것을 방지하는 방법이다.
  • 리액트 훅은 이러한 메커니즘과 함께 작동하도록 설계됐지만 잘못 사용하지 않도록 주의해야 한다.
  • 예를 들어, 기존 state 객체나 ref 객체를 직접 변경해서는 안 된다는 규칙이 있다. 직접 변경할 경우 리렌더링되지 않거나, 너무 많은 리렌더링이 발생하거나, 부분적인 리렌더링(일부 컴포넌트는 렌더링되지만 다른 컴포넌트는 렌더링되지 않는 경우)이 발생하는 등 예기치 않은 동작이 발생할 수 있다.
  • 리액트 훅 함수와 컴포넌트 함수는 여러 번 호출될 수 있다. 따라서 함수가 여러 번 호출되더라도 일관되게 동작할 수 있게 충분히 '순수'해야 한다는 규칙이 있다.
  • 앞의 두 규칙은 개발자들이 자주 위반하는 규칙이다. 이런 규칙을 위반한 코드를 작성하더라도 비동시성 렌더링(Non-Concurrent Rendering)에서는 문제없이 작동하기 때문에 개발자들은 잘못됐다는 것을 알아차리지 못한다. 심지어 동시성 렌더링에서도 어느 정도 문제없이 작동할 수 있어서 문제가 간헐적으로 발생할 수 있다. 이는 리액트를 처음 사용하는 초보자에게는 특히 어려운 문제다.
  • 이러한 개념에 익숙하지 않다면 향후 리액트 버전에 맞춰 잘 설계되고 철저하게 테스트를 거친 마이크로 상태 관리 라이브러리를 사용하는 것이 좋다.

♣ 메모 : 동시성 렌더링은 React 18 작업 그룹(Working Group)에 설명돼 있으며 다음 URL에서 확인할 수 있다. 
             https://github.com/reactwg/react-18/discussions


전역 상태 탐구하기

  • 리액트는 컴포넌트에서 정의되고 컴포넌트 트리 내에서 사용되는 상태에 대해 useState와 같은 기본적인 훅을 제공한다. 이를 흔히 지역 상태라고 부른다. 지역 상태를 사용하는 다음 예제를 살펴보자.
const Component = () => {
  const [state, setState] = useState();
  return (
    <div>
      {JSON.stringify(state)}
      <Child state={state} setState={setState} />
    </div>
  );
};

const Child = ({ state, setState }) => {
  const setFoo = () => setState(
    (prev) => ({ ...prev, foo: ‘foo’ })
  );
  return (
    <div>
      {JSON.stringify(state)}
      <button onClick={setFoo}>Set Foo</button>
    </div>
  );
};
  • 반면 전역 상태는 애플리케이션 내 서로 멀리 떨어져 있는 여러 컴포넌트에서 사용하는 상태다. 전역 상태가 싱글턴(singleton)  일 필요는 없으며, 싱글턴이 아니라는 점을 명확히 하기 위해 전역 상태를 공유 상태(shared state)라 부르기도 한다.
  • 다음 예제를 통해 리액트 컴포넌트에서 전역 상태가 어떤 형태인지 확인할 수 있다.
const Component1 = () => {
  const [state, setState] = useGlobalState();
  return (
    <div>
      {JSON.stringify(state)}
    </div>
  );
};

const Component2 = () => {
  const [state, setState] = useGlobalState();
  return (
    <div>
      {JSON.stringify(state)}
    </div>
  );
};
  • 아직 useGlobalState를 정의하지 않았기 때문에 코드는 작동하지 않을 것이다. 다만 여기서 Component와 Component2가 같은 상태를 공유하기를 기대할 것이다.
  • 리액트에서 전역 상태를 구현하는 것은 간단한 작업이 아니다. 그 이유는 리액트가 컴포넌트 모델에 기반하기 때문이다. 컴포넌트 모델에서는 지역성(locality)이 중요하며, 이는 컴포넌트가 서로 격리돼야 하고 재사용이 가능해야 한다는 것을 의미한다.

♣ 컴포넌트 모델에 대한 참고 사항 : 컴포넌트는 함수처럼 재사용 가능한 하나의 단위다. 컴포넌트를 한 번 정의하면 여러 번 사용하는 것이 가능하다. 이는 컴포넌트가 독립적인 경우에만 가능하다. 컴포넌트가 컴포넌트 외부에 의존하는 경우 동작이 일관되지 않을 수 있으므로 재사용이 불가능 할 수 있다. 따라서 엄밀하게 말하면 컴포넌트 자체는 전역 상태에 가급적 의존하지 않는 것이 좋다.

  • 리액트는 전역 상태에 대한 직접적인 해결책을 제공하지 않기 때문에 이는 개발자와 커뮤니티의 몫이 된다. 지금까지 많은 해결책이 제안돼 왔으며 각 해결책마다 장단점이 있다. 이 장의 목표는 일반적인 해결책을 보여주고 각각의 장단점을 논의하는 것이다.