전역 상태 관리에 대한 고찰, 정말로 필요한가?

woohyuk·2025년 3월 17일
0
post-thumbnail

React 애플리케이션의 규모가 커질수록 상태 관리의 복잡성도 증가한다. 이러한 상황에서 많은 개발자들이 자연스럽게 전역 상태 관리 라이브러리 도입을 고려하게 된다. 하지만 과연 이러한 라이브러리가 항상 필요한 것일까? 이 글에서는 전역 상태 관리 라이브러리의 필요성과 문제점에 대해 깊이 있게 살펴보도록 하겠다.

전역 상태 관리를 도입하는 일반적인 이유

개발자들이 전역 상태 관리 라이브러리를 도입하는 주요 이유는 다음과 같다.

  • Props Drilling 회피: 컴포넌트 계층 구조가 깊어질수록, 데이터를 최하위 컴포넌트까지 전달하기 위해 중간 컴포넌트들도 props를 전달받아야 하는 상황이 발생한다.
  • Context API의 리렌더링 이슈: ReactContext API를 사용할 경우 불필요한 리렌더링이 발생할 수 있다는 우려가 있다.
  • 트렌드 추종: "다른 팀들이 사용하니 우리도 사용해보자"라는 접근 방식도 종종 볼 수 있다.

결론부터 말하자면

대부분의 서비스에서는 전역 상태 관리 라이브러리를 사용할 필요가 없다. 정확히는, "애플리케이션 전역"에 두고 사용해야 할 상태는 극히 일부이거나 제한적이다.

실제로 전역으로 관리해야 하는 상태는 주로 서버에서 가져온 데이터인 경우가 많다. 이런 경우에는 @tanstack/react-query와 같은 서버 상태 관리 라이브러리를 사용하는 것이 더 효율적일 수 있다.

전역 상태의 주요 취약점

1. 예측하기 어려운 상태 변화

전역 상태는 어느 컴포넌트에서든 접근하고 변경할 수 있기 때문에, 특정 상태가 어디에서 사용되고 변경되는지 파악하기 어려워진다. 이는 코드베이스가 커질수록 더욱 심각한 문제가 된다.

예를 들어, 부모 컴포넌트에서 자식 컴포넌트가 사용할 전역 상태를 변경했다고 가정해보자. 나중에 요구사항이 변경되어 자식 컴포넌트에서 해당 전역 상태 사용을 중단했을 때, 부모 컴포넌트의 상태 변경 코드도 함께 삭제해야 할지 판단하기 어려워진다. 다른 컴포넌트들도 이 상태를 참조하는지 일일이 확인해야 하기 때문이다.

또한, 여러 컴포넌트에서 동일한 상태를 변경할 수 있기 때문에 버그가 발생했을 때 원인을 추적하기 어려워진다.

2. 상태 초기화 관리의 복잡성

지역 상태(useState)는 컴포넌트가 언마운트될 때 자동으로 초기화된다. 반면, 전역 상태는 개발자가 명시적으로 초기화 시점을 관리해야 한다.

useEffect(() => {
  // 컴포넌트 언마운트시 해당 store 초기화
  return () => {
    resetStore();
  };
}, []);

이러한 초기화 로직을 누락하면 의도치 않게 상태가 지속되어 버그가 발생할 가능성이 높아진다.

3. 컴포넌트 재사용성 저하

전역 상태에 의존하는 컴포넌트는 독립적으로 재사용하기 어려워진다. 다음은 Recoil을 사용한 Post 컴포넌트의 예시다.

import { useRecoilValue } from 'recoil';
import { postState } from '../state/postAtom';

const Post = () => {
  const post = useRecoilValue(postState);
  
  return (
    <div>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
    </div>
  );
};

export default Post;

이 컴포넌트는 전역 상태와 강하게 결합되어 있어, 다른 데이터 소스(예: popularPost)를 활용한 재사용이 어려워진다.

Props Drilling: 항상 나쁜 것일까?

Props를 통한 데이터 전달 방식은 다음과 같은 장점이 있다.

  • 명확한 데이터 흐름: 데이터가 어디서 오고 어디서 사용되는지 쉽게 추적할 수 있다.
  • 변경의 영향 파악 용이: 코드 변경이 다른 컴포넌트에 미치는 영향을 파악하기 쉽다.
  • 낮은 의존성: 각 컴포넌트는 필요한 데이터만 받아 사용하므로, 다른 컴포넌트와의 의존성이 줄어든다.

물론 props 전달 깊이가 지나치게 깊어지면 유지보수가 어려워질 수 있습니다. 하지만 이 경우, 전역 상태 관리보다는 컴포넌트 구조 재설계가 더 근본적인 해결책이 될 수 있다.

Props Drilling을 피하는 더 나은 방법

Props Drilling을 피하기 위해 전역 상태 라이브러리를 도입하는 것은 권장하지 않는다. 대신, ReactContext API를 활용하는 것이 적절하다.

React 공식 문서에서도 props drilling을 피하기 위해 Context API 사용을 권장한다.
https://ko.react.dev/learn/passing-data-deeply-with-context

💡 참고: Context API는 상태 관리 도구가 아닌 의존성 주입 도구이고, 전역 상태 관리 라이브러리를 대체하는 것이 아니다.

Context API와 리렌더링에 대한 오해

많은 개발자들이 Context API를 사용하면 Provider로 감싼 모든 컴포넌트가 리렌더링된다고 생각하지만, 이는 정확하지 않다. 실제로는 Context를 사용하는 Consumer 컴포넌트만 리렌더링된다.

불필요한 리렌더링 문제는 Context 값이 객체일 경우, 객체의 일부 속성만 변경되더라도 해당 Context를 사용하는 모든 Consumer 컴포넌트가 리렌더링되는 상황에서 발생한다.

Context API 리렌더링 예시

다음은 Context API를 사용할 때 발생할 수 있는 리렌더링 문제를 보여주는 예시다.

// userContext.tsx
import React from "react";
import { createContext, useState, useCallback } from "react";

export const userContext = createContext({ name: "", email: "" });
export const userActionContext = createContext((value) => {});

export function UserContextProvider({ children }) {
  const [user, setUser] = useState({
    id: 1,
    name: "John",
    email: "john@example.com",
  });

  const incrementCount = useCallback(() => {
    setUser((prev) => ({
      ...prev,
      id: prev.id + 1,
    }));
  }, []);

  return (
    <userContext.Provider value={user}>
      <userActionContext.Provider value={incrementCount}>
        {children}
      </userActionContext.Provider>
    </userContext.Provider>
  );
}
// ContextTest.tsx
import React from "react";
import { useContext } from "react";
import {
  UserContextProvider,
  userContext,
  userActionContext,
} from "./userContext";

function UserNameComponent() {
  const { name } = useContext(userContext);
  console.log("(Context) UserNameComponent rendered");

  return <div> User Name: {name} </div>;
}

function UserEmailComponent() {
  const { email } = useContext(userContext);
  console.log("(Context) UserEmailComponent rendered");

  return <div> User Email: {email}</div>;
}

function UserIdComponent() {
  const { id } = useContext(userContext);
  console.log("(Context) UserIdComponent rendered");

  return <div> User id: {id}</div>;
}

function UserIdCountButton() {
  const incrementCount = useContext(userActionContext);
  console.log("(Context) UserIdCountButton rendered");

  return <button onClick={() => incrementCount()}> count </button>;
}

export default function ContextTest() {
  return (
    <UserContextProvider>
      <UserNameComponent />
      <UserEmailComponent />
      <UserIdComponent />
      <UserIdCountButton />
    </UserContextProvider>
  );
}

이 예제에서 count 버튼을 클릭하면 id 값만 변경된다. 이상적으로는 UserIdComponent만 리렌더링되어야 하지만, 실제로는 모든 Consumer 컴포넌트가 리렌더링된다.

왜 이런 현상이 발생할까? React의 상태는 불변성(immutability)을 가지므로, 객체 내부 값이 변경되면 새로운 객체가 생성되어 참조값이 달라진다. 참조값이 달라지면 해당 Context를 사용하는 모든 Consumer가 리렌더링되는 것이다.

전역 상태 관리 라이브러리는 이 문제를 해결할까?

다음은 Zustand를 사용한 예시다.

// ZustandTest.tsx
import React from "react";
import { create } from "zustand";

const useUserStore = create((set) => ({
  user: {
    id: 1,
    name: "John",
    email: "john@example.com",
  },
  incrementCount: () =>
    set((state) => ({
      user: { ...state.user, id: state.user.id + 1 },
    })),
}));

function UserNameComponent() {
  const state = useUserStore();
  console.log("(Zustand) UserNameComponent rendered");

  return <div> User Name: {state.user.name} </div>;
}

function UserEmailComponent() {
  const state = useUserStore();
  console.log("(Zustand) UserEmailComponent rendered");

  return <div> User Email: {state.user.email}</div>;
}

function UserIdComponent() {
  const state = useUserStore();
  console.log("(Zustand) UserIdComponent rendered");

  return <div> User id: {state.user.id}</div>;
}

function UserIdCountButton() {
  const state = useUserStore();
  console.log("(Zustand) UserIdCountButton rendered");

  return <button onClick={() => state.incrementCount()}> count </button>;
}

export default function ZustandTest() {
  return (
    <>
      <UserNameComponent />
      <UserEmailComponent />
      <UserIdComponent />
      <UserIdCountButton />
    </>
  );
}

이 코드에서도 count 버튼을 클릭하면 store를 참조하는 모든 컴포넌트가 리렌더링된다.

이는 Context API와 동일한 문제다. 즉, 많은 개발자들이 전역 상태 관리 라이브러리가 자동으로 리렌더링을 최적화해준다고 오해하고 있을 수 있다.

선택적 구독을 통한 최적화: Selector 기능

Zustand와 같은 라이브러리에서는 selector 기능을 통해 리렌더링을 최적화할 수 있다.

// ZustandTest.tsx (최적화 버전)
function UserNameComponent() {
  const name = useUserStore((state) => state.user.name);
  console.log("(Zustand) UserNameComponent rendered");

  return <div> User Name: {name} </div>;
}

function UserEmailComponent() {
  const email = useUserStore((state) => state.user.email);
  console.log("(Zustand) UserEmailComponent rendered");

  return <div> User Email: {email}</div>;
}

function UserIdComponent() {
  const id = useUserStore((state) => state.user.id);
  console.log("(Zustand) UserIdComponent rendered");

  return <div> User id: {id}</div>;
}

function UserIdCountButton() {
  const incrementCount = useUserStore((state) => state.incrementCount);
  console.log("(Zustand) UserIdCountButton rendered");

  return <button onClick={() => incrementCount()}> count </button>;
}


이 최적화된 버전에서는 각 컴포넌트가 필요한 상태만 선택적으로 구독한다. 이렇게 하면 id 값이 변경될 때 UserIdComponent만 리렌더링되고, 다른 컴포넌트는 리렌더링되지 않는다.

반면에 Context API로 동일한 최적화를 구현하려면 각 상태 속성마다 별도의 Context를 생성해야 하는 번거로움이 있다. 이 점이 많은 개발자들이 리렌더링 최적화를 위해 전역 상태 관리 라이브러리를 선호하는 이유다.

성능 이슈에 대한 균형 잡힌 시각

여기서 중요한 질문이 생긴다.

1. 리렌더링이 실제로 애플리케이션 성능에 큰 영향을 미치는가?
2. 전역 상태 관리 라이브러리의 단점을 감수할 만큼 성능 이슈가 중요한가?

사실 React의 리렌더링은 생각보다 치명적이지 않은 경우가 많다. 대부분의 경우, 사용자가 체감할 정도로 성능이 저하되지 않는다.

성능 저하가 발생한다면, 그 원인은 리렌더링 자체보다는 리렌더링 시 실행되는 무거운 계산이나 로직에 있을 가능성이 높다. 이런 경우에는 리렌더링 횟수를 줄이는 것보다 useMemo, useCallback 등을 활용하여 무거운 계산을 최적화하는 것이 더 효과적일 수 있다.

또한, 불필요한 리렌더링을 줄이기 위해 전역 상태 관리 라이브러리를 도입하는 것은 종종 과도한 해결책이 될 수 있다. 이는 앞서 언급한 전역 상태의 단점들을 감수해야 하는 대가를 치르게 된다.

전역 상태 관리의 적절한 사용 시나리오

전역 상태 관리 라이브러리가 유용한 몇 가지 시나리오는 다음과 같다.

1. 진정한 전역 상태: 애플리케이션 전반에 걸쳐 공유되어야 하는 상태 (예: 사용자 인증 정보, 테마 설정)
2. 복잡한 상태 로직: 여러 컴포넌트에서 공유하는 복잡한 상태 로직이 필요한 경우
3. 성능 최적화가 필수적인 경우: 성능 문제가 실제로 사용자 경험에 영향을 미치는 경우

하지만 이러한 시나리오는 생각보다 드물며, 대부분의 상황에서는 지역 상태나 Context API만으로도 충분히 해결할 수 있다.

개선된 접근 방식: 적절한 상태 관리 전략

효과적인 상태 관리를 위한 접근 방식은 아래와 같다.

1. 기본적으로 지역 상태 사용: 가능한 한 useState와 같은 지역 상태를 사용한다.
2. 제한된 범위의 상태 공유: 특정 컴포넌트 트리 내에서만 상태를 공유해야 할 경우 Context API를 사용한다.
3. 서버 데이터 관리: 서버에서 가져온 데이터는 React Query와 같은 서버 상태 관리 라이브러리를 활용한다.
4. 전역 상태 최소화: 진정으로 전역적인 상태만 전역 상태 관리 라이브러리를 통해 관리한다.
5. 컴포넌트 구조 재고: Props Drilling이 심해진다면, 컴포넌트 구조를 재설계하는 것을 고려한다.

결론

최근 프론트엔드 개발 트렌드에서는 전역 상태 관리 라이브러리 사용 경험이 필수적인 것처럼 여겨지고 있다. 그러나 서비스의 특성을 고려하지 않고 무작정 도입하는 것은 지양해야 한다.

시간이 지남에 따라 "애매하면 일단 전역에 두자"는 접근 방식은 결국 복잡도를 증가시키고 버그 발생 가능성을 높이는 결과를 가져올 수 있다.

단순히 Props Drilling을 피하기 위해 전역 상태 관리 라이브러리를 도입하는 것은 권장하지 않는다. 특정 컴포넌트 트리 내에서만 상태를 공유해야 한다면, Context API만으로도 충분히 해결할 수 있다.

물론 전역 상태 관리 도구가 항상 나쁜 것은 아니다. 적절한 상황에서 사용하면 큰 이점을 얻을 수 있다. 중요한 것은 실제 필요에 따라 적절한 도구를 선택하는 것이다. 전역 상태 관리 라이브러리를 도입하기 전에 그것이 정말 필요한지, 그리고 그로 인한 장단점을 충분히 고려해야 한다고 생각한다.

참고

https://github.com/toss/frontend-fundamentals/discussions/5
https://velog.io/@woohm402/no-global-state-manager
https://velog.io/@dahyeon405/Context-API%EC%9D%98-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81-%EB%8C%80%ED%95%9C-%EC%98%A4%ED%95%B4
https://yoonhaemin.com/tag/technical-thinking/understanding-context-api/

리렌더링 이슈에 대한 글

https://kentcdodds.com/blog/fix-the-slow-render-before-you-fix-the-re-render

profile
기록하는 습관을 기르자

0개의 댓글