📖 개요

안녕하세요, 셀메이트 포스팀에서 프론트엔드 개발을 맡고 있는 양원준입니다.
이번 글에서는 사내 세미나에서 발표했던 프론트엔드 상태 관리 이야기를 해보려 합니다.

프론트엔드 개발에서 상태 관리는 필수적인 개념이지만,
막상 실무에서는 “언제, 무엇을, 어떻게” 사용해야 할지 여전히 많은 개발자들의 고민거리죠.

이 글에서는 상태 관리가 어떻게 발전해왔는지 를 중심으로 이야기해보겠습니다.


🏃🏻‍♂️ 상태란 무엇인가?

먼저, 상태란 무엇일까요?

상태란 어떤 시스템이나 객체가 특정 시점에 가지고 있는 정보의 집합을 의미합니다.
이 정보들은 시간이 흐르며 변하고, 그에 따라 시스템의 동작이나 UI도 바뀌게 되죠.

이 개념은 사실 우리 일상에서도 자주 접할 수 있습니다.

예를 들어보면,

  • 📱 스마트폰 배터리 잔량: 현재 70% → 시간이 지나면 50%
  • 🌤️ 날씨 정보: 현재 20°C → 오후에 25°C
  • 🚦 신호등: 빨간불 → 초록불 → 노란불
  • 커피 머신: 대기 중 → 추출 중 → 추출 완료
  • 📦 택배 배송: 주문 접수 → 배송 중 → 배송 완료

처럼 주변에서 상태의 변화를 쉽게 볼 수 있죠.

프론트엔드에서도 상태는 이와 비슷하게 작동합니다.
프론트엔드에서의 상태는 UI에 영향을 주는 모든 데이터입니다.

  • 사용자가 입력창에 입력한 값
  • 로그인 여부
  • 서버에서 받아온 상품 목록

이러한 데이터들이 모두 상태입니다.

이 상태들이 변할 때마다 화면(UI)도 함께 업데이트되어야 하고,
따라서 상태를 어떻게 관리하느냐가 프론트엔드 개발의 핵심 과제가 됩니다.


📈 프론트엔드 상태관리의 발전

프론트엔드의 상태관리는 꾸준히 발전해왔습니다.
초기에는 단순했지만, 점점 복잡해진 UI와 요구사항에 맞춰 효율적인 관리가 필요해졌죠.

⏳ VanillaJS와 jQuery

시간을 과거로 돌려보면, 프론트엔드 초창기에는 상태 관리가 매우 단순했습니다.
VanillaJS 시절, 상태는 DOM 그 자체였죠. 화면의 요소를 찾아 직접 수정했죠.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<div>
  <h1 id="count">0</h1>
  <button id="increment">+1</button>
</div>

<script>
  let count = 0;

  const countEl = document.getElementById("count");
  const incrementBtn = document.getElementById("increment");

  incrementBtn.addEventListener("click", () => {
    count++;
    countEl.textContent = count;
  });
</script>

jQuery가 등장하며 DOM 조작은 조금 편해졌습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<div>
  <h1 id="count">0</h1>
  <button id="increment">+1</button>
</div>

<script>
  let count = 0;

  $("#increment").on("click", function () {
    count++;
    $("#count").text(count);
  });
</script>

코드의 양이 줄어든게 느껴지시나요?
하지만, 이들의 본질은 같았습니다.

이 방식은 개발자가 “무엇을, 어떻게 바꿀지” 일일이 명령하는 명령형 프로그래밍방식이었습니다.

이 방식은 곧 한계에 부딪혔습니다.
상태와 UI 로직이 서로 얽히면서 유지보수가 어려워졌고, 상태 변경을 추적하기도 힘들었죠.
결국 코드가 길어지고 협업은 점점 지옥이 되었습니다.


⚛️ React의 등장

2013년, 페이스북에서 개발한 React가 등장하면서 상태관리의 패러다임이 크게 바뀌었습니다.
React는 “UI = f(state)” 라는 개념을 도입했죠.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import React, { useState } from "react";

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

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

코드를 보면 차이가 느껴지시나요?

React는 선언적 프로그래밍을 도입했습니다.
개발자는 이제 “어떻게” DOM을 조작할지 신경 쓸 필요가 없습니다.
대신 어떤 상태에서 UI가 어떻게 보여야 하는지만 선언하면 됩니다.

상태가 변경되면 React가 알아서 UI를 업데이트해주죠.
이는 개발자가 비즈니스 로직에 더 집중할 수 있게 해주었습니다.


🌐 전역 상태 관리의 필요성

React의 state는 컴포넌트 내부 상태 관리에는 강력했지만, 컴포넌트 간 상태 공유에는 한계가 있었습니다.
상위 부모의 state가 최하위 자식에게 필요할 경우, 모든 중간 컴포넌트를 거쳐 props로 전달해야 했죠.

이 문제를 우리는 흔히 Props Drilling이라고 부릅니다.

Props Drilling

Props Drilling의 문제점

  • 중간 컴포넌트들도 실제로 상태를 사용하지 않더라도 전달용 코드 필요
  • 컴포넌트 간 결합도 상승 → 유지보수 어려움
  • 상태 구조가 변경되면 전달 경로상의 모든 컴포넌트 수정 필요

이를 해결하기 위해 등장한 것이 전역 상태 관리입니다.
Redux와 같은 라이브러리는 중앙의 store에서 상태를 관리하여
필요한 컴포넌트가 직접 데이터를 가져올 수 있게 했습니다.

하지만 초기 Redux는 boilerplate 코드가 많고 러닝 커브가 높아 사용하기 어렵다는 평가를 받았습니다.
이런 복잡함을 줄이기 위해 등장한 것이 Zustand, Recoil, Jotai 같은 Hooks 기반 라이브러리들입니다.

이들은 다음과 같은 특징으로 주목받고 있습니다:

  • 전역 상태를 간단히 정의하고 필요한 곳에서 쉽게 구독 가능
  • boilerplate 코드 최소화
  • Hook 기반의 직관적인 API 제공

현재는 Redux보다 이런 경량화된 라이브러리들이 더 많은 사랑을 받고 있습니다.


📡 서버 상태관리의 등장

지금까지 살펴본 상태관리 라이브러리들은 모두 클라이언트 측의 상태관리에 초점을 맞춘 것들이었습니다.

하지만, 프론트엔드 개발자들에게는 고민이 있었습니다.
API를 통해 받은 서버 데이터를 관리하는 것도 중요한 과제였죠.
기존 방식에서는 개발자가 직접 다음과 같은 작업들을 모두 처리해야 했습니다.

  • 데이터 요청 보내기
  • 로딩 상태 관리
  • 에러 상태 처리
  • 데이터 캐싱
  • 재요청 로직
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUsers = async () => {
      setLoading(true);
      try {
        const response = await fetch("/api/users");
        const userData = await response.json();
        setUsers(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []);

  if (loading) return <div>로딩 ...</div>;
  if (error) return <div>에러: {error}</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

이런 반복적인 작업들을 해결하기 위해 등장한 것이 서버 상태관리 라이브러리입니다.

TanStack Query (React Query), SWR 등은 서버 데이터 요청에 대한 로딩, 에러, 캐싱 등을 자동으로 처리해줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useQuery } from "@tanstack/react-query";

function UserList() {
  const {
    data: users,
    isPending,
    error,
  } = useQuery({
    queryKey: ["users"],
    queryFn: () => fetch("/api/users").then((res) => res.json()),
  });

  if (isPending) return <div>로딩 ...</div>;
  if (error) return <div>에러: {error.message}</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

서버 상태관리의 장점

  • 자동 로딩/에러 상태 관리: isPending, error 등을 자동으로 제공
  • 스마트 캐싱: 동일한 데이터 요청 시 캐시된 결과 재사용
  • 백그라운드 업데이트: 데이터가 오래되었을 때 자동으로 재요청
  • 낙관적 업데이트: 사용자 경험 향상을 위한 즉시 UI 업데이트

무엇보다 클라이언트 상태와 서버 상태를 분리하여 더 직관적이고 체계적인 구조로 개발할 수 있게 되었습니다.


🎯 상태의 분류와 특징

지금까지 프론트엔드에서 상태 관리가 어떻게 발전해왔는지 살펴봤습니다.
VanillaJS 시절의 DOM 직접 조작부터, React의 선언형 방식,
Redux 같은 전역 상태 관리,
그리고 React Query와 같은 서버 상태 관리까지 이어졌죠.

현재 상태는 역할과 범위에 따라 세 가지로 나눌 수 있습니다.
바로 Local State, Client-side Global State, 그리고 Server-side State입니다.

각 유형은 고유한 특징을 가지고 있으며, 상황에 맞는 도구 선택이 핵심입니다.
Local State는 단순하고 빠르지만 범위가 한정적이고,
Client-side Global State는 앱 전역의 공통 데이터를 효율적으로 관리합니다.
Server-side State는 외부 API 데이터를 다루며, 서버와의 동기화가 필수입니다.


📈 앞으로의 상태 관리는 어떻게 될까?

question

최근 발표된 React 19에서는 Suspense와 Server Components 같은 기능들이 다시 주목받고 있습니다.
이들이 말하는 바는 분명합니다.
데이터는 서버가 주도하고, 프론트는 UI에 집중하자.

그동안 프론트엔드가 직접 API를 호출하고, 캐싱하며 상태를 관리하던 복잡한 흐름은
점점 단순화되고 있습니다. 이제는 서버가 UI에 필요한 데이터를 스트리밍으로 전달하고, 프론트는 이를 그려내는 역할로 변화하고 있죠.

결국 상태 관리는 더 경량화되고, 서버 중심화될 가능성이 높습니다.

하지만 잊지 말아야 할 점이 있습니다.
상태 관리는 선택이자 전략입니다.
모든 상태를 전역으로 만들 필요는 없고,
어떤 상태를 어디에 둘지 고민하는 것이 핵심입니다.

팀의 구조와 프로젝트 특성에 따라 최적의 상태 관리 전략은 달라질 수 있습니다.
상태 관리는 목적이 아니라 도구입니다.
그리고 좋은 도구란, 팀과 프로젝트에 가장 잘 맞는 것이어야 합니다.

🚀 여러분의 프로젝트엔 어떤 상태 관리 전략이 어울릴까요?
이 글이 그 해답을 찾는 작은 계기가 되길 바랍니다.