Context API의 불필요한 리렌더링 문제

호이초이·2025년 6월 12일
post-thumbnail

문제 상황 정의

1. 각 훅을 이용해 서버 상태 각각 관리하기

상품목록 미션 당시, useProductsuseCartItems 각각의 커스텀 훅 내부에서 fetch 함수를 호출하고, 해당 데이터들을 상태로 관리한 뒤 컴포넌트에서 받아 사용하는 구조로 설계했다. 이 방식은 단순하지만, 데이터를 여러 컴포넌트에서 사용하는 경우 props drilling이 발생한다는 단점이 있었다.

나는 이를 해결하고자, context API를 도입하고자 했다.

2. context API를 이용해 서버 상태 전역에서 관리하기

context API를 도입하기 전에, 난 최상단에서 product 관련 상태와 cart 관련 상태를 따로 관리하지 않고, key를 기반으로 데이터를 저장할 수 있는 객체 형태로 상태를 정의했다.

const [data, setData] = useState({
  product: ...,
  cart: ...,
});

// 접근
const product = data["product"];

// 업데이트 
 setDataMap((prev) => ({
   ...prev,
   product: ...,
 }));

해결. props drilling, 데이터 재요청 X

context API를 도입하면서 데이터가 필요한 컴포넌트에서 데이터를 호출하여 props drilling 문제를 해결했고, 같은 데이터를 사용하는 컴포넌트들에서는 API를 다시 호출할 필요 없이, context가 제공하는 데이터를 그대로 재사용할 수 있었다.

문제. 참조 컴포넌트 리렌더링 이슈

이렇게 context API를 사용하면서, 미션을 진행하는 데에는 아무 문제가 없었다. 하지만 상위에서 key를 기반으로 여러 데이터를 상태로 관리할 경우, 특정 key의 값이 업데이트되면 해당 key만 업데이트되는 게 아니라, 같은 context를 구독 중인 다른 key를 사용하는 컴포넌트들도 함께 리렌더링되는 문제가 있었다.

예를 들어, product 관련 데이터가 업데이트되면 실제로는 아무 변화가 없는 cart 페이지도, 같은 context의 상태를 참조한다는 이유만으로 불필요하게 리렌더링되었다.

특정 key를 참조하는 컴포넌트만 업데이트가 일어나야 하지않나?라고 생각이 들면서 나는 이것을 불필요한 리렌더링이라고 생각했다.

물론, 이 문제는 미션의 필수 요구사항은 아니었지만, 성능과 구조 최적화 측면에서 개선해보고 싶었다.


내가 시도한 해결책과 그 해결책을 선택한 나의 이유

1. 상위에서 상태가 아닌 객체로 데이터를 관리하자.

문제를 해결하기 위해 우선 추론을 시작했다. 상위에서 state로 상태를 관리하기 때문에, 결국 상위에서의 state를 없애야 한다고 생각했다. 하지만 동시에, 상위에서 데이터를 가지고 있어야 하위 컴포넌트들이 이 데이터를 공유할 수 있다는 의문이 계속 따라왔다.

그래서 다시 리액트의 리렌더링 메커니즘을 떠올렸다. 리렌더링은 상태가 바뀔 때 일어난다. 그렇다면, 상위에서는 단순히 일반 JavaScript 객체로 데이터를 관리하고, 이 데이터를 context로 전달해주되, 실제로 데이터를 사용하는 컴포넌트에서 필요할 때 강제로 리렌더링을 시켜주면 되지 않을까? 라는 결론에 도달했다.

강제 리렌더링을 발생시키기 위해, 해당 컴포넌트에서 사용하는 APIContext 훅 내부에 아래와 같이 useState를 추가해, 강제로 리렌더링을 트리거할 수 있는 코드를 만들어놨다.

const [, forceRender] = useState({});

// 강제 리렌더링!
forceRender({});

이렇게 하면, 새로운 객체를 넘겨줄 때마다 객체의 참조값이 달라지므로 React는 상태가 변경되었다고 판단하고, 해당 컴포넌트를 다시 렌더링한다.

이런 리렌더링 매커니즘을 활용해, 데이터를 상위에서 받아온 뒤 forceRender를 통해 리렌더링을 발생시키고, 그 시점에 새로운 데이터를 화면에 보여줄 수 있도록 했다.

예를 들어, 아래처럼 client.refetch로 비동기 데이터를 다시 가져온 뒤, forceRender를 호출해 강제로 리렌더링을 발생시켰다.

// client.refetch는 context로 전달된 비동기 처리 함수
client.refetch(queryKey, fetchFn).then(() => {
  forceRender({});
});

해결. 해당 컴포넌트만 리렌더링

이렇게 하니 정말 해당 컴포넌트만 리렌더링이 발생했고, 데이터도 화면에 정확히 잘 보였다. 특히, cart 데이터를 참조하고 있는 컴포넌트는 전혀 리렌더링되지 않아, 분리된 동작이 명확하게 확인됐다.

그래서 처음에는 "이제 문제가 해결된 건가?"라고 생각했다. 하지만 곧이어, 훨씬 더 큰 크리티컬한 문제가 발생했다.

문제. 같은 값을 참조하고 있는 다른 컴포넌트 리렌더링 X

바로, 같은 데이터를 참조하고 있는 다른 컴포넌트에서는 데이터가 동기화되지 않는 문제가 발생했다.

예를 들어, 상품을 장바구니에 담으면 해당 버튼은 “취소” 버튼으로 바뀌지만, 동시에 헤더에 표시되는 장바구니 개수는 전혀 바뀌지 않았다.

문제의 원인은 단순했다. 상위에서는 데이터가 잘 반영되고 최신 상태를 유지하고 있었지만, 다른 컴포넌트에게 리렌더링 트리거가 전달되지 않아, 서로의 상태가 동기화되지 못한 것이다.

2. Pub/Sub 패턴을 도입하자.

이제 두 번째 문제를 해결하기 위해 다시 추론을 시작했다.
"데이터가 업데이트될 때, 같은 key를 참조하고 있는 컴포넌트들을 전부 한꺼번에 리렌더링 시켜줘야할 거 같은데.."라고 생각했다. 이렇게 하면 내가 풀려고 했던 모든 문제들이 해결될 수 있었다.

실제로 refetch가 일어나는 시점에서 해당 key를 참조하는 컴포넌트들만 골라서 forceRender를 호출해주면 되는 구조였다.

그러기 위해 컴포넌트가 처음 렌더링될 때마다, 각 컴포넌트의 forceRender 함수를 정의하고, 이 함수를 상위에서 기억해두는 방식으로 구현을 시도했다.

그러기 위해 난 Pub/Sub 패턴을 도입했다.

Pub/Sub 패턴이란?
Pub/Sub(Publisher-Subscriber) 패턴은 발행자(Publisher)가 특정 이벤트를 발행하면, 이를 구독한 구독자(Subscriber)들에게 이벤트를 전달해주는 구조이다.

쉬운 버전)
나중에 실행할 함수들을 전부 등록해 놓고, 나중에 특정 조건(데이터 변경, 이벤트 발생 등)이 되면 그 함수들을 한꺼번에 실행해주는 방식

  • 미리 등록해둔 함수 리스트: 나중에 실행할 함수를 모아둔 리스트
  • 실행(발행): 특정 이벤트가 발생하면 리스트에 있는 모든 함수를 한 번에 실행

이 Pub/Sub 패턴을 구현하기 위해, 나는 단순히 상위에서 데이터를 객체로 관리하는 것뿐만 아니라, 각 컴포넌트의 강제 리렌더링을 담당하는 forceRender 함수를 등록할 수 있는 리스트를 Map 구조로 만들어 관리했다.

const store: Record<
  string,
  {
    state: QueryState<any>;
    subscribers: Set<() => void>;
  }
> = {};

그리고 이렇게 구독 함수를 실제로 등록할 수 있도록 만들었다.

const subscribe = (key: string, callback: Subscriber) => {
 if (!store[key]) {
   store[key] = {
     state:...,
     subscribers: new Set(),
   };
 }
 store[key]?.subscribers.add(callback);
};

// 등록
client.subscribe(queryKey, rerender);

이후, refetch가 일어나면 아래처럼 발행(실행)하여 해당 key를 참조하는 컴포넌트들의 구독 함수들을 한꺼번에 실행시켰다.

const publish = (key: string) => {
  store[key]?.subscribers.forEach((fn) => fn());
};

forceRender를 외부에서 실행하는데, 어떻게 해당 컴포넌트만 리렌더링되는 걸까?
React 내부에서 useState의 setter는 해당 컴포넌트 인스턴스를 “포함한” 클로저이다.

// 이 forceRender는 React 내부에서 현재 컴포넌트를 리렌더링하도록 예약하는 함수
const [_, forceRender] = useState({});

그래서 외부에서 이 forceRender를 호출하면 React는 이 useState의 setter가 어떤 컴포넌트에서 나왔는지 이미 알고 있다.→ 그러므로 해당 컴포넌트만 리렌더링된다.

즉, 컴포넌트의 “위치”를 따로 저장하지 않아도, useState의 setter는 React가 내부적으로 관리하는 컴포넌트 인스턴스 정보 와 함께 클로저로 묶여 있다.

외부에서 setter만 호출해도 React는 그 컴포넌트가 누구인지 알고 리렌더링을 트리거한다.

해결. 같은 값을 참조하는 모든 컴포넌트 리렌더링

이렇게 Pub/Sub 패턴을 도입한 결과, 같은 데이터를 참조하고 있는 컴포넌트들만 정확하게 리렌더링이 일어나게 만들 수 있었다. 덕분에 상태가 서로 동기화되면서도, 각 컴포넌트의 불필요한 렌더링을 방지할 수 있었다.

“이제 진짜 문제가 해결된 걸까?”라고 생각하며, 나는 이 방식으로 미션을 진행했다. 그리고 리뷰어에게 피드백을 받았는데, “구성도 재미있고 잘 만들었다, 하지만 이 방식은 어떻게 보면 위험할 수 있다.” 라는 이야기를 들었다.

내가 만든 구조가 분명히 상황에 따라 유용할 수 있지만, 동시에 React의 자연스러운 상태 관리 흐름을 벗어나는 구조이기 때문에 유지보수나 예측 가능한 동작에서 위험이 생길 수 있다는 것이었다.

단점. 리액트의 원칙 우회

  1. forceRender 방식의 렌더링

forceRender를 외부에서 직접 호출하는 방식은, React가 권장하는 자연스러운 상태/렌더링 흐름을 일부 깨트리게 된다.

예를 들어, React의 Context를 사용하면 Context 값이 바뀌면 자동으로 구독 컴포넌트들만 다시 렌더링되고, React는 이를 Virtual DOM diffing으로 최적화한다.

하지만 Pub/Sub 방식은 외부에서 직접 forceRender를 호출해, “어떤 컴포넌트를 언제 그릴지”를 수동으로 결정한다. 이는 React의 자연스러운 최적화 흐름(Context 구독, Virtual DOM diffing)을 벗어난다.

  1. 객체로 데이터를 통째로 관리

단일 객체로 데이터를 관리하면 어떤 모듈에서든 데이터를 직접 수정할 수 있기 때문에 안정성과 신뢰성이 떨어진다고 한다. React의 권장 방식은 단일 객체로 전부 묶어 관리하기보다는, 상태를 필요한 단위로 나누고(모듈화, 캡슐화), React가 자동으로 구독/최적화할 수 있게 관리하는 구조를 쓰는 것이다.

+) 그러므로 React는 안정적이고 예측 가능한 렌더링 흐름을 보장할 수 있도록, useState, useReducer, useContext, useSyncExternalStore 같은 상태 관리 및 구독 흐름을 제공하는 것이었다.


나의 문제 해결 과정에 대한 평가와 회고

내가 만든 외부 객체와 pub/sub 패턴 방식

이번에 내가 시도한 외부 객체와 Pub/Sub 패턴 방식은, 사실 처음으로 무언가를 깊이 있게 탐구하고, 직접 구조를 설계하고 구현해본 경험이었다. 완벽하진 않았지만, 문제를 해결하기 위해 끝까지 추론하고 고민했던 과정 자체가 가장 큰 의미였다고 생각한다.

도움을 준 피터, 시지프에게 감사의 인사를 전하고 싶다. 🙏

그렇다면, 내가 만든 외부 객체 + Pub/Sub 방식은 절대 사용하면 안 되는 방식일까?

→ 그건 아니다. 필요하다면 사용할 수도 있고, 특정 상황에서는 유연하게 적용할 수 있다.

다만, 이 방식은 React가 보장하는 렌더링 흐름과 상태 관리 원칙을 우회하는 구조이기 때문에, 예상치 못한 버그나 유지보수의 어려움 같은 문제가 발생할 수 있다.

그렇다면 어떻게 해야 할까?

보완할 수 있는 useSyncExternalStore / tanstack-query

  1. useSyncExternalStore useSyncExternalStore 공식문서

React가 직접 관리하지 않는 상태(예: 전역 store, 브라우저 API, 커스텀 Pub/Sub 등)와 컴포넌트를 안정적으로 동기화할 수 있도록 돕는 공식 훅이다.

  • 주 목적: 외부 상태와 React 컴포넌트 간의 구독/렌더링 흐름을 안정적으로 연결
  • 특징: React 내부의 최적화 플로우(useEffect, Virtual DOM diffing 등)와 자연스럽게 통합됨
  • 사용법: 직접 더 공부하고 적용해볼 계획이다.

또한 useSyncExternalStoreZustand, Recoil, Redux Toolkit, Jotai 등의 전역 상태 관리 라이브러리들이 내부적으로 사용하는 핵심 API이기도 하다.
→ 즉, 내가 이번에 직접 구현해본 Pub/Sub 구조를 안정적이고 React 친화적인 방식으로 해결하기 위해, 이미 많은 라이브러리들이 이 훅을 기반으로 설계되어 있다는 점에서, 공식적으로도 검증된 구조라고 할 수 있다.

재오가 직접 탐구하고 공부해본 useSyncExternalStore 참고 자료
useSyncExternalStore

  1. TanStack Query TanStack Query 공식문서

TanStack Query는 서버 상태나 비동기 데이터를 다루는 라이브러리이다.

자동 캐싱, 의존성 기반 리페치, 데이터 구독 및 동기화, 불필요한 리렌더링 방지 등의 기능을 제공하며, 내가 직접 만든 Pub/Sub 구조보다 훨씬 안정적이고 확장 가능하게 설계되어 있다.

즉, 내가 구현한 로직의 개념과 유사하지만, 이미 공식적으로 검증되고 최적화된 형태로 제공되는 도구가 있다.

추후 이런 문제가 발생한다면?

비슷한 문제가 다시 발생한다면, 망설이지 않고 TanStack Query를 사용할 것이다.
괜히 이런 라이브러리가 나온 게 아니다. 이미 많은 고민과 문제 해결이 집약된 결과물이기 때문이다.
무엇보다도, 이번 경험을 통해 내부 동작 원리를 직접 구현하고 이해했기 때문에, 앞으로는 이러한 라이브러리를 단순히 “쓰는 것”을 넘어서, “잘 활용할 수 있는 개발자”로 성장할 수 있지 않을까?

참고자료

profile
의 성장일지

6개의 댓글

comment-user-thumbnail
2025년 6월 12일

단순히 쓰는 것을 넘어서, 잘 활용할 수 있는 개발자로 성장할 수 있지 않을까?

He is Korean 🇰🇷

2개의 답글
comment-user-thumbnail
2025년 7월 9일

본인이 직접 다 하나하나 작성한거 맞나요? 퀄리티가 장난이 아니네요

1개의 답글
comment-user-thumbnail
2025년 11월 6일

글 재밌네요. 대단합니다 후추님 👍

답글 달기