Context API의 리렌더링 지옥, Zustand가 구원해드립니다

리액트에서 상태 관리는 정말이지 영원한 숙제 같거든요.

Prop Drilling을 피하려고 Context API를 썼더니, 이번엔 불필요한 리렌더링이 발목을 잡죠.

그래서 많은 개발자들이 Redux나 MobX 같은 더 복잡한 라이브러리로 넘어가곤 하는데요.

하지만 여기, 아주 작고 단순하면서도 강력한 대안이 있습니다.

바로 'Zustand'라는 상태 관리 라이브러리죠.

오늘은 Context API가 가진 근본적인 문제를 살펴보고, Zustand가 얼마나 우아하게 그 문제를 해결하는지, 그리고 왜 많은 개발자들이 Zustand에 열광하는지 그 이유를 샅샅이 파헤쳐 보겠습니다.

문제는 Prop Drilling, 해결책은 Context API?

먼저, 우리가 왜 상태 관리 도구를 찾게 되는지 그 출발점부터 짚어봐야 하는데요.

바로 'prop drilling' 때문입니다.

리액트에서 여러 컴포넌트가 공유해야 하는 상태는 가장 가까운 공통 부모로 끌어올려야 하죠.

그리고 그 상태를 props를 통해 자식에게, 또 그 자식에게, 계속해서 전달해야 합니다.

상태를 실제로 사용하지 않는 중간 컴포넌트들도 오직 전달을 위해 props를 계속 넘겨받아야 하는 이 고통스러운 과정을 바로 'prop drilling'이라고 부르죠.

이 문제를 해결하기 위해 많은 개발자들이 리액트에 내장된 Context API를 사용하는데요.

상태를 Provider에 담아두고, 필요한 곳에서 useContext 훅으로 바로 꺼내 쓰는 방식입니다.

간단한 할 일 관리 앱을 예로 들어보죠.

tasks, currentView, currentFilter라는 세 가지 상태를 Context로 관리한다면, Provider는 아마 이런 모습일 겁니다.

export const TasksProvider = ({ children }: { children: ReactNode }) => {
  console.log("Rendering TasksProvider");

  const [tasks, setTasks] = useState<Task[]>(dummyTasks);
  const [currentView, setCurrentView] = useState<TasksView>("list");
  const [currentFilter, setCurrentFilter] = useState<string>("");

  const value: TasksState = {
    tasks,
    setTasks,
    currentView,
    setCurrentView,
    currentFilter,
    setCurrentFilter,
  };

  return <TasksContext.Provider value={value}>{children}</TasksContext.Provider>;
};

먼저 useState로 상태 조각들을 하나씩 만들어야 하는데요.

그리고 그걸 거대한 value 객체 하나로 합쳐서 Provider에 넘겨주는 방식입니다.

이제 어떤 컴포넌트에서든 useContext 훅을 사용하면 이 value 객체에 접근할 수 있게 되죠.

export const useTasksContext = () => {
  return useContext(TasksContext);
};

// 사용하는 컴포넌트 내부
const { currentView, tasks, currentFilter } = useTasksContext();

이 코드는 분명히 동작하고, prop drilling 문제도 해결해 줍니다.

하지만 여기서 정말 치명적인 문제가 하나 발생하더라고요.

바로 value 객체 안의 값 중 단 하나라도 바뀌면, 이 Context를 구독하는 모든 컴포넌트가 모조리 리렌더링된다는 겁니다.

예를 들어, UI 뷰를 바꾸기 위해 setCurrentView를 호출했다고 해보죠.

분명히 currentView 상태만 바뀌었을 뿐인데, taskscurrentFilter를 사용하는 컴포넌트까지 전부 리렌더링되는 비효율이 발생하는 거죠.

작은 앱에서는 문제가 안 될 수 있지만, 앱이 커질수록 이런 불필요한 리렌더링은 심각한 성능 저하의 원인이 됩니다.

Zustand의 등장, 그리고 첫인상

Zustand는 이런 Context API의 단점을 해결하기 위해 등장한 아주 작고 미니멀한 라이브러리인데요.

상태를 만드는 방법부터가 정말 간단합니다.

import { create } from "zustand";

export const useTasksStore = create<TasksState>(set => ({
  tasks,
  setTasks: (arg: Task[] | ((tasks: Task[]) => Task[])) => {
    set(state => {
      return {
        tasks: typeof arg === "function" ? arg(state.tasks) : arg,
      };
    });
  },
  currentView: "list",
  setCurrentView: (newView: TasksView) => set({ currentView: newView }),
  currentFilter: "",
  setCurrentFilter: (newFilter: string) => set({ currentFilter: newFilter }),
}));

create 함수에 상태의 초기값과 상태를 업데이트하는 함수들을 담은 객체를 반환하는 함수를 넘겨주기만 하면 되는데요.

이것만으로 useTasksStore라는 커스텀 훅이 뚝딱 만들어집니다.

Context처럼 Provider로 앱을 감싸줄 필요도 없죠.

상태를 업데이트할 때는 create 함수가 인자로 넘겨주는 set 함수를 사용하면 되는데요.

set({ currentView: newView })처럼 바뀐 부분만 객체로 넘겨주면, Zustand가 알아서 기존 상태와 병합해줍니다.

정말 간결하고 직관적이죠.

자, 그럼 이제 이 훅을 컴포넌트에서 사용해볼까요?

가장 단순한 방법은 Context를 사용할 때처럼 필요한 상태들을 구조 분해 할당으로 가져오는 겁니다.

// 이 방식은 최선이 아닙니다!
const { currentView, tasks, currentFilter } = useTasksStore();

이 코드는 분명히 동작하고, Context를 쓸 때보다 보일러플레이트도 훨씬 적은데요.

하지만 이 방식은 Context API와 똑같은 함정을 가지고 있거든요.

상태 객체 전체를 반환받기 때문에, 상태의 일부만 바뀌어도 객체의 참조가 바뀌어서 이 훅을 사용하는 모든 컴포넌트가 여전히 리렌더링되는 거죠.

Zustand의 진짜 힘, '셀렉터(Selector)'

Zustand의 진정한 힘은 바로 이 리렌더링 문제를 해결하는 데 있는데요.

문서에서 쉽게 지나칠 수 있지만, 가장 중요한 핵심 기능입니다.

바로 '셀렉터(selector)'를 사용하는 거죠.

Zustand 훅을 호출할 때, 이렇게 원하는 상태만 콕 집어서 선택하는 함수를 넘겨주는 건데요.

const currentView = useTasksStore(state => state.currentView);
const tasks = useTasksStore(state => state.tasks);
const currentFilter = useTasksStore(state => state.currentFilter);

이렇게 하면 Zustand는 오직 셀렉터가 반환한 값(currentView 문자열, tasks 배열 등)이 실제로 변경될 때만 리렌더링을 트리거합니다.

이제 setCurrentView를 호출해도, 오직 currentView를 구독하는 컴포넌트만 리렌더링되고, taskscurrentFilter를 구독하는 컴포넌트들은 전혀 영향을 받지 않게 되는 거죠.

이것이 바로 Zustand가 제공하는 렌더링 최적화의 핵심입니다.

만약 여러 상태 조각을 한 번에 가져오면서도 이 최적화를 누리고 싶다면, Zustand가 제공하는 useShallow 헬퍼를 사용할 수 있는데요.

import { useShallow } from "zustand/react/shallow";

// 객체 형태로 여러 값을 선택
const { tasks, setTasks } = useTasksStore(
  useShallow(state => ({
    tasks: state.tasks,
    setTasks: state.setTasks,
  }))
);

// 배열 형태로 여러 값을 선택
const [tasks, setTasks] = useTasksStore(useShallow(state => [state.tasks, state.setTasks]));

useShallow는 반환된 객체나 배열의 1단계 깊이까지만 비교해서 변경 여부를 판단하는데요.

덕분에 불필요한 리렌더링을 막으면서도 여러 상태를 편리하게 가져올 수 있습니다.

그 외의 매력적인 기능들

Zustand는 이 외에도 몇 가지 아주 유용한 기능들을 제공하는데요.

* 비동기 액션: set 함수는 언제 어디서든 호출할 수 있기 때문에, fetch 같은 비동기 작업이 끝난 후에 상태를 업데이트하는 로직을 스토어 안에 아주 자연스럽게 작성할 수 있습니다.

  • 스토어 내부에서 상태 읽기: create 함수는 set과 함께 get 함수도 인자로 받는데요.

    get 함수를 사용하면 액션 함수 안에서 현재 상태 값을 언제든지 읽어올 수 있습니다.

  • React 외부에서 상태 접근: Zustand가 create 함수로 만들어주는 것은 React 훅이지만, 그 훅에 getState()라는 메소드가 붙어있거든요.

    useTasksStore.getState()처럼 호출하면, React 컴포넌트가 아닌 일반 자바스크립트 함수 안에서도 Zustand 스토어의 상태에 접근할 수 있습니다.

마치며

결론은 명확하죠.

단순히 값을 여기저기 전달하는 목적이라면 Context API도 좋은 선택인데요.

하지만 자주 변경되는 '상태'를 관리해야 하고, 그로 인한 불필요한 리렌더링을 피하고 싶다면, 셀렉터 기반의 강력한 최적화를 제공하는 Zustand가 훨씬 더 현명하고 효율적인 선택이 될 겁니다.

게다가 보일러플레이트 없이 훨씬 간결한 코드를 작성할 수 있다는 건 정말 큰 매력이고요.

혹시 아직 Zustand를 경험해보지 않으셨다면, 다음 프로젝트에서는 이 작고 강력한 곰(Zustand의 마스코트)의 힘을 한번 빌려보시는 건 어떨까요?