Post

프론트엔드 상태 관리의 거의 모든 것

상태의 기본 개념부터 주요 라이브러리 비교와 프로젝트별 최적 선택 전략을 다룹니다.

프론트엔드 상태 관리의 거의 모든 것

서론

현대 웹 애플리케이션 개발의 중심에는 ‘상태’라는 개념이 핵심적으로 자리잡고 있습니다. 사용자 인터랙션에 따라서 끊임없이 변화할 수 있는 동적인 웹 환경을 구축하기 위해서는 상태를 이해하고 효과적으로 관리하는 능력이 필수적입니다.

이 글에서는 웹 프론트엔드에서 ‘상태 관리’라는 주제를 심층적으로 탐구해봅니다. 상태의 근본적 정의부터 현대 웹 개발에 사용되는 주요 라이브러리들의 철학과 작동 방식을 분석합니다. 프로젝트에 걸맞는 최적의 상태 관리 전략을 선택할 수 있는 지식을 가지는 것을 목표로 합니다.

프로그래밍에서 ‘상태’란?

프로그래밍에서 ‘상태’는 시간이 지남에 따라 변할 수 있는 모든 데이터를 의미합니다. 예를 들어, 현실의 커피가 처음에는 뜨거운 상태였다가 시간이 흐르면서 차가워지는 상태로 변경되는 것과 같습니다. 이러한 데이터는 String, Boolean, Array, Object등 다양한 데이터 타입을 가질 수 있고, 그 본질은 ‘변할 수 있는 값’이라는 특성에 있습니다. 즉, 상태 관리는 이러한 변화를 예측 가능하고 일관된 방식으로 제어하는 기술을 의미합니다.

웹 프론트엔드에서 ‘상태’란?

FE 개발 맥락에서 ‘상태’는 더욱 구체적인 의미가 됩니다. 이는 웹 애플리케이션에서 UI 렌더링에 영향을 미치는 모든 동적 데이터를 부르는 용어입니다. 사용자와의 인터랙션을 통해 변화하는 웹에서 UX에 가장 큰 영향을 미치는 핵심 요소가 바로 ‘상태’가 됩니다.

웹 프론트엔드의 상태는 매우 다양하고, 예시로는 다음과 같은 것들이 있습니다.

  • 사용자 관련 상태: 로그인 여부, 닉네임, 권한
  • UI 제어 상태: 열림/닫힘 여부, 다크/라이트 테마
  • 폼 상태: 텍스트 입력 필드의 값, 체크박스 선택 여부
  • 서버 통신 상태: 로딩중, 에러 메시지, 페이지네이션 정보
  • 데이터 가공 상태: 원본 데이터를 UI에 맞게 가공한 값 (2,000,000 -> “2M”)

점점 복잡해지는 웹

초기 웹에는 서버가 대부분의 로직을 처리하고, 완성된 HTML 페이지를 클라이언트에 전달하는 방식이 일반적이었습니다. 그러나 UX의 중요성이 커지고, 서버의 부하를 줄이면서 원활한 서비스를 제공하기 위한 요구가 증가하면서 클라이언트 측에서의 페이지 렌더링과 UI 작업의 역할이 커졌습니다. 이로 인해서 오늘날의 클라이언트-서버 구조가 정착되었고, 프론트엔드와 백엔드의 역할이 명확히 구분되게 되었습니다.

SPA가 보편화되면서, 페이지 전체를 새로고침하는 대신 필요한 부분만 변경하고 서버와 통신해야 할 필요성이 대두되었습니다. 애플리케이션의 규모가 커지고 기능이 다양해짐에 따라 관리해야할 상태는 점점 늘어났고 복잡해졌습니다. 이런 복잡성의 증가는 유지보수를 어렵게 만드는 직접적 원인이 되었으며, 이를 체계적으로 해결하기 위한 방법론으로 ‘상태 관리’가 부상하게 되었습니다.

상태의 분류

상태를 특성과 범위에 따라 체계적으로 분류하여 관리를 용이하게 하는 것이 중요합니다. 일반적이고 전통적인 방법으로 상태를 지역적인지, 전역적인지 보는 관점이 있습니다.

  • 지역 상태: 특정 컴포넌트 내부에서만 관리되는 상태입니다. 다른 컴포넌트와 데이터를 공유할 필요가 없는 경우이고, React에서는 useState를 통해서 관리하는 것이 일반적입니다.
  • 전역 상태: 여러 컴포넌트에서 공통으로 접근하고 공유해야 하는 상태입니다. 애플리케이션 전반에 영향을 미치므로 중앙에서 관리될 필요가 있습니다.

지역/전역 상태와 같은 분류를 넘어서, 최근에는 데이터의 소유권과 생명주기를 기준으로 상태를 더 근본적으로 나누는 것이 중요해졌습니다.

  • 클라이언트 상태: 데이터의 소유권이 클라이언트(브라우저)에 있는 상태입니다. 서버와 동기화될 필요가 없고, 인터랙션에 따라 클라이언트 내에서 생성/소멸합니다. 주로 순수 UI 상태가 여기에 속합니다.
  • 서버 상태: 데이터의 원본은 다른 서버에 있으며, 프론트엔드에서는 데이터의 복사본 또는 캐시를 소유하는 상태입니다. 이 상태는 서버에서 언제든지 변경될 수 있어 오래된(stale) 상태가 될 수 있음에 주의를 기울여야 합니다.

서버 상태와 클라이언트 상태는 근본적으로 다른 특성을 지니고 있습니다. 서버 상태는 비동기적으로 가져와야 하고, 언제든지 최신 데이터와 동기화해야 하며, 효율적인 캐싱 전략이 필요합니다. 반면, 클라이언트 상태는 동기적으로 작동하며 복잡한 고려사항이 거의 필요 없습니다. 이러한 본질적인 차이에 대한 인식은 상태 관리 패러다임의 중요한 전환점이 되었습니다.

TanStack Query, SWR처럼 서버 상태 관리의 문제들(캐싱, 동기화, 재요청)을 해결하기 위해 특화된 도구들이 분리되어 발전되는 계기가 되었습니다.

React에서의 상태 관리 기본

React는 컴포넌트 기반 아키텍처를 채택하고 있고, 상태를 관리하고 공유하기 위한 내장 API를 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useState } from 'react';

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

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

가장 기본적인 출발점은 useState 훅입니다. useState를 통해 선언된 상태는 지역 상태의 특성을 가지게 됩니다. 해당 상태는 선언된 컴포넌트 내에서만 유효하고, 외부 컴포넌트에서 직접적으로 접근/수정이 불가능합니다. 만약 여러개의 Counter 컴포넌트가 렌더링 되더라도, 각 컴포넌트는 독립적인 상태를 가지게 됩니다.

Props Drilling

애플리케이션이 복잡해지다 보면, 여러 컴포넌트가 동일한 상태를 공유해야하는 상황이 생깁니다. React에서 가장 기본적인 방법은 부모 컴포넌트에서 자식 컴포넌트로 props를 통해 데이터를 내려주는 것입니다. 하지만 이 방식은 Props Drilling 문제를 야기합니다.

Props Drilling은 상위 컴포넌트의 상태를 내려줄 때, 특정 하위 컴포넌트에게 전달하기 위해 중간에 위치한 컴포넌트들이 오직 전달을 목적으로 props를 계속해서 넘겨주고 있는 현상입니다. 컴포넌트 트리가 깊어질수록 props 추적이 어려워지고, 중간 컴포넌트들은 해당 데이터를 사용하고 있지 않음에도 불구하고 props의 변경에 따라 리렌더링됩니다. 이는 성능을 저하시키는 주요 원인이 됩니다.

컴포넌트 깊이가 3~5단계 정도로 얕은 경우에는 props drilling이 심각한 문제가 되지 않고 오히려 직관적인 해결책이 될 수 있습니다. 하지만 깊고 복잡해질 수록 문제가 발생하고 이때는 상태를 관리할 새로운 전략을 도입해야할 시점이 됩니다.

Context API

React는 Context API라는 내장 기능을 제공합니다. Context API는 컴포넌트 트리 내에서 데이터를 전역적으로 공유할 수 있는 메커니즘을 제공합니다. 이를 사용하면 중간 컴포넌트들을 거치지 않고 특정 데이터를 필요로하는 모든 컴포넌트에 직접 값을 전달할 수 있습니다.

Context API는 외부 라이브러리를 설치하지 않고도 전역적인 데이터 공유가 가능하다는 장점이 있지만, 명백한 한계와 단점이 있습니다.

  1. Provider의 value prop이 변경되면, 해당 context를 구독하는 모든 하위 컴포넌트들이 실제로 그 값을 사용하지 않더라도 모두 리렌더링됩니다.
  2. Context API 자체는 상태를 전달하는 메커니즘일 뿐, 상태를 관리하는 로직을 제공하지는 않습니다. 상태 업데이트 로직과 비동기 처리 등은 결국 개발자가 직접 구현해야 합니다.

Context API를 사용한다면 리렌더링 문제를 완화하기 위해서 React.memouseMemo와 같은 메모이제이션을 사용하고, 자주 변경되는 값과 그렇지 않은 값을 별도의 Context로 관리하는 최적화 전략이필요합니다.

클라이언트 전역 상태 관리 라이브러리

React의 내장 기능만으로는 복잡성 관리에 어려움을 겪는다면, 상태 관리 라이브러리 도입을 고려해야 합니다. 이러한 라이브러리들은 최적화된 상태 관리 솔루션을 제공합니다.

초기에는 Redux가 Flux 아키텍처를 기반으로 ‘예측 가능한 상태 컨테이너’라는 패러다임을 제시하며 시장을 지배했습니다. 이는 상태 변화를 추적하기 용이하게 만들었지만, 많은 양의 Boilerplate와 높은 학습 곡선이라는 단점을 동반했습니다.

이러한 Redux의 복잡성에 대응하여 Recoil, Jotai, Zustand와 같은 라이브러리들이 등장했습니다. 이들은 React Hooks와 유사한 직관적인 API를 통해서 Boilerplate를 대폭 줄이고 개발자 경험을 향상시키는 데 초점을 맞췄습니다. 상태 관리 패러다임은 ‘엄격한 규칙’에서 ‘개발 편의성’으로, 그리고 다음은 ‘성능’에 무게 중심을 옮겨가며 진화하고 있습니다.

이 글에서는 대표적인 라이브러리들의 핵심만 요약해서 정리했습니다. 프로젝트 상황에 맞도록 코드와 공식 문서를 통해 더욱 이해하고 도입하기를 권장드립니다.

Redux

  • 핵심 철학: 예측 가능한 상태 컨테이너
  • UI 이벤트가 Action을 발생시키면, Reducer라는 순수 함수가 이 Action을 받아 새로운 상태를 만들어 Store를 업데이트하는 엄격한 단방향 데이터 흐름을 따릅니다.
  • 예측 가능성, 안정성이 최우선인 대형 프로젝트에 적합합니다.

Recoil

  • 핵심 철학: React 친화적인 Atomic 관리 (Facebook에서 직접 개발)
  • 상태의 최소 단위인 Atom을 만들고, 컴포넌트는 필요한 Atom만 구독합니다. Selector를 통해 Atom 값에서 파생된 새로운 상태를 계산할 수 있습니다.
  • 세밀한 렌더링 최적화와 데이터 관리에 특화되어 있습니다.

MobX

  • 핵심 철학: 투명한 반응성 반응형 프로그래밍 (TFRP)
  • observable로 상태를 감싸면, 그 상태가 사용되는 모든 곳이 자동으로 추적됩니다. Action에 의해 상태가 변경되면 의존하는 모든 부분이 자동으로, 그리고 최소한으로 업데이트됩니다.
  • 반응형 프로그래밍 모델로 빠른 개발 속도를 원할 때 적합합니다.

Zustand

  • 핵심 철학: 간결함과 유연성
  • Provider 없이, create 함수로 만든 Store를 컴포넌트에서 바로 Hook으로 호출하여 사용하고, set 함수로 직접 수정합니다.
  • 쉽고 빠르게 상태 관리를 도입하고 싶을 때 적합합니다.

서버 상태 관리 라이브러리

지금까지 논의된 상태 관리 라이브러리들은 주로 클라이언트 측에서 발생하는 상태, 즉 UI나 Form 입력 값 등을 관리하는 데 중점을 두었습니다. 그러나 웹 애플리케이션에서 다루는 데이터의 상당 부분은 서버에서 비동기적으로 가져오는 서버 상태입니다.

서버 상태와 클라이언트 상태는 근본적으로 다른 특성을 가지고 있고, 동일한 방식으로 관리하려는 시도는 비효율성과 복잡성을 야기합니다. 이 문제를 해결하기 위해 서버 상태 관리에 특화된 라이브러리들이 등장했으며 대표 주자로는 TanStack Query(예전의 React Query)와 SWR이 있습니다.

클라이언트 상태 관리 vs 서버 상태 관리

  1. 클라이언트 상태는 브라우저가 완벽하게 소유하고 제어합니다. 반면, 서버 상태의 진정한 소유자는 원격 서버입니다. 프론트엔드에서는 단지 서버 데이터의 복사본을 보관하고 있을 뿐이며, 이 데이터는 언제든지 다른 사용자나 백엔드 내부에 의해 변경될 수 있습니다.
  2. 서버 상태는 비동기 API 호출을 통해 가져와야 하며, 가져온 데이터는 시간이 지나면서 서버의 실제 데이터와 달라질 수 있습니다. 이를 최신 상태로 유지하기 위해서는 지속적인 동기화 메커니즘이 필요합니다.

서버 상태 관리가 해결하는 문제

  1. 서버 데이터를 가져오는 비동기 로직을 처리하기 위해 로딩, 성공, 에러 상태를 별도로 관리하여 많은 Boilerplate 코드를 유발하는 문제
  2. 서버에서 가져온 데이터를 Store에 저장하더라도, 언제부터 이 데이터가 오래되었다고 판단하고 다시 가져올지 결정하는 복잡한 로직을 구현해야 하는 문제
  3. 여러 컴포넌트에서 동일한 서버 데이터가 필요한 경우 중복된 API 요청을 보내 네트워크 자원을 낭비하는 문제

TanStack Query

  • 핵심 철학: 비동기 상태 관리에 필요한 모든 기능 제공
  • 상세한 전역 설정, 쿼리 재시도, 오프라인 지원 등 매우 폭넓은 옵션을 제공합니다.
  • 복잡하고 세밀한 제어가 필요한 경우와, React 외에도 다른 프레임워크에서 동일한 경험으로 서버 상태를 관리하고 싶을 때 적합합니다.

SWR

  • 핵심 철학: 최소주의 및 간결함
  • 캐시된 데이터를 먼저 보여주고 백그라운드에서 재검증하는 핵심 기능에 집중해있으며, 설정이 간단합니다.
  • Vercel에서 개발했기 때문에 Next.js 환경의 프로젝트나 복잡한 설정 없이 페칭과 캐싱에만 집중하고 싶을 때 적합합니다.

프로젝트 규모와 특성에 따른 최적의 상태 관리 전략

소규모 프로젝트, 프로토타입

React 내장 기능을 최대한 활용합니다. 상태 구조가 단순하고 컴포넌트 계층이 깊지 않다면 외부 라이브러리 도입이 오버엔지니어링일 수 있습니다. 전역 상태 관리 도구가 필요한 경우에는 학습 곡선이 낮고 간편한 Zustand나 Jotai를 도입하는 것이 효율적입니다.

중규모 프로젝트

대부분의 상태는 지역적으로 관리하되, 여러 컴포넌트에 걸쳐 공유되는 복잡한 상태는 전역 관리 라이브러리를 통해 체계적으로 관리합니다. React와의 깊은 통합을 우선시한다면 Recoil, 빠른 개발이 필요로 하는 프로젝트에서는 단순함과 유연성이 바탕이 되는 Zustand를 고려하는 것이 좋습니다. 비동기 통신이 잦은 애플리케이션이라면 서버 상태 관리를 TanStack Query 또는 SWR에 전적으로 위임하는 것이 권장됩니다.

대규모(엔터프라이즈급) 프로젝트

대규모 프로젝트에서는 상태 변화의 예측 가능성, 강력한 디버깅과 테스트 용이성을 최우선 순위로 두고 관리하는 것이 좋습니다. Redux Toolkit은 오랜 시간 수많은 대규모 프로젝트에서 검증된 안정성과 강력한 DevTools를 제공하여 복잡한 상태를 관리하는 데 가장 강력한 선택지입니다. 마찬가지로 비동기 통신이 잦다면 TanStack Query, SWR 사용이 효율적입니다.

FUTURE OF STATE MANAGEMENT

  1. 최근에는 TanStack Query가 성공하면서 서버 상태를 분리하여 관리하는 것의 유효성을 증명했습니다. 앞으로는 더욱 서버 상태와 클라이언트 상태를 분리하여 바라보는 방식이 보편화될 것입니다.
  2. Solid.js, Preact, Qwik 등에서 도입된 Signals는 상태 변경 시에 Virtual DOM 전체를 비교하는 대신, 상태를 구독하는 DOM 요소만 직접 업데이트하여 극단적인 성능 최적화를 추구합니다. 이는 React 렌더링 모델에 대한 근본적인 도전이고, 향후 상태 관리와 더 나아가서 렌더링 패러다임의 중요한 흐름이 될 가능성이 있습니다.
  3. XState와 같은 라이브러리는 ‘상태 기계(State Machine)‘을 기반으로 합니다. 복잡한 UI 로직을 예측 가능하고 시각적으로 모델링할 수 있는 강력한 해법을 제시하고, 특히 사용자의 복잡한 인터랙션 흐름으로 인해 버그가 발생할 수 있는 시나리오에서 상태 기계는 모든 경우의 수를 정의하고 관리함으로써 코드의 안정성을 획기적으로 높이고 있습니다.

결론

수많은 상태 관리 전략은 결국 ‘어떻게 하면 상태를 더 효율적이고 안전하게 공유하고 관리할 것인가‘에 대한 끊임없는 고민의 결과입니다. 적절한 상태 관리 전략을 도입하여 변화에 유연하게 대응하고, 버그를 쉽게 추적하며, 오랫동안 건강하게 유지될 수 있는 프론트엔드 애플리케이션을 구축할 수 있습니다.

This post is licensed under CC BY 4.0 by the author.