React 상태관리, Jotai로 변경한 이유

sy.kim·2025년 11월 19일
post-thumbnail

기존 프로덕트는 React 내장 hook인 useState 를 사용하면서, Props drilling 을 막기 위해서 Context API 를 사용하고 있었어요.

관심 범위 내에서 ContextProvider 로 상태를 제공하면 상기한 이슈에는 문제가 없었습니다.

그러나 몇가지 이슈가 있어 대체 방법을 찾아야 했는데요,


Context API 사용 시 발생한 이슈

1. 잦은 리렌더링을 발생 시킴

Context 내부 값이 업데이트 되면 리렌더링을 일으켜요.
Provider 가 상위에 위치하기 때문에 불필요한 리렌더링으로 인한 성능 저하가 발생합니다.

2. Provider 지옥

개별 Provider 가 늘어남에 따라 Provider hell 이라고 불리는 Provider가 끝없이 내부를 감싸는 현상이 생겨요.

const App = () => {
	return (
    	<>
      		<GlobalProvider value={globalValue}>
      			<OtherProvider value={otherValue}>
                	<OtherOtherProvider value={otherOtherValue}>
                      {/** ... */}
                    </OtherOtherProvider>
                </OtherProvider>
      		</GlobalProvider>
      	</>
    )
}

3. 단방향 참조의 한계

부모 → 자식 방향으로만 전달이 가능한 구조적 한계가 있어 ContextProvider 를 무조건 상위로 올려야하나, Provider 가 여럿일 때 우선순위가 꼬이는 현상이 있었어요.


상기한 이유로 상태관리 라이브러리를 새로 골라야했고,
의논 끝에 Jotai 를 채택하게 되었습니다.

왜 Jotai?

최소 선택 조건은 아래와 같습니다.

  1. 러닝 커브가 낮을 것
  2. 따라서 보일러 플레이트가 적을 것
  3. 점유율이 확보되고 유지보수가 되는 라이브러리 일 것

이 조건을 만족하는 것이 Jotai 와 Zustand 였고,
둘 중 무엇을 선택할지 고민했었는데요.

제가 이해하기로 둘의 컨셉은 약간의 차이가 있었어요.

Zustand

  • Reducer-based
  • 전체 구조(store)를 먼저 정의하고, 상태 + 액션을 한 곳에서 관리
  • selector로 필요한 부분만 구독

Jotai

  • Atom-based
  • 원자 단위(atom)부터 시작 → 조합으로 복잡한 상태 구성
  • 각 atom이 독립적

[참고1] https://stateofreact.com/en-US
[참고2] https://dev.to/nguyenhongphat0/react-state-management-in-2024-5e7l

각 Provider 를 나눠서 사용하는 상황에서는 Zustand 가 매력적이었지만,
상태의 단위를 작개 쪼개 사용하는 Jotai 가 추후 구조 변경이 있더라도 간편하게 유지보수가 가능해 보였어요.

atom 의 갯수가 많아지면 관리가 어렵다는 단점은 존재했지만, 파일 단위로 관심사를 분리해서 사용하면 추적이 어렵지 않을 것으로 보았습니다.


작업 과정

AS-IS의 문제점

기존에 정의된 ContextProvider 내부의 구성은 다음과 같았어요.

export const GlobalContextProvider = ({ children }) => {
  // Tanstack QueryState
  const { data, isSuccess, ... } = useCustomQuery();
  
  // useState   
  const [value, setValue] = useState(null);
  ...
  
  // 재사용 function 및 handler
  const getNormalizeData = () => { 
    ... 
  };
    
  const handleOnClick = () => {
    ...
  };

  return (
  	<GlobalContext.Provider 
      value={{
    	data,
    	...
      }}
    >
      {childern}
  	</GlobalContext.Provider>
  );    
}

Context 내부에서 Tanstack Query(React Query)의 fetching 데이터를 조회해서 내려주고 있고, 불필요하게 내려주는 함수나 핸들러가 존재하고 있는 구조였습니다.

단일 컴포넌트에만 적용되는 경우도 존재했는데, 굳이 Context 에 넣으면서 단일 책임 원칙에 위배되는 상황이 생겼죠.

(처음에는 컴포넌트를 보기좋고 간결하게 만들기 위해 사용하기 시작했는데 말입니다... 🥲)

TO-BE

Tanstack Query hook들은 필요한 컴포넌트에서 직접 호출하는 쪽으로 변경했습니다. Tantack Query 는 Jotai와 별개로 비동기 서버 상태를 관리해주는 라이브러리인 만큼, 클라이언트 상태관리안에 종속되지 않게 하는게 우선이라고 판단했어요.

그리고 컴포넌트 책임 단위를 분명히 하기 위해 리팩터링 작업도 같이 진행했습니다.

이 작업들을 정리하고 나니 atom 을 추가하는 것은 빠르고 간단했습니다.

기타 이슈

  • babel plugin 이슈
    • webpack precompile 진행 중 babel 플러그인을 못찾는 현상 발생
    • Webpack4 에서 .mjs 와 .cjs를 자동 인식하지 못하는 이슈가 있어 evironments 설정 수정
  • node version 문제
    • 배포 환경의 node version 이 너무 구 버전이라... 인프라 담당 개발자분의 도움을 받아 버전 업그레이드

결과 및 회고

  1. Context Provider 정리 : Context를 완전히 삭제하는 방향으로 잡지는 않았습니다. 예를 들어 상품 정보를 붙들고 있는 Provider 같은 경우, 개별 컴포넌트의 상위에 존재하며 다른 컴포넌트에 영향을 미치지 않기 때문에 삭제하는 것은 불필요하다고 판단했습니다.

  2. 성능 향상 : state를 atom 단위로 잘라 필요한 경우 사용하는 방식으로 변경, 이와 동시에 누락된 메모이제이션을 함께 수행하여 불필요한 리렌더링을 방지했으며, 약 20% 정도 감소시킨 것으로 측정했습니다.

  3. 가독성 향상 : 각 컴포넌트의 책임을 명확하게 하고, Props Drilling 을 제거했습니다.

PR을 올리면서도 죄송스러운 상황

약 6,000줄의 코드를 올리면서 배포가 상당히 부담스럽기도 하고,
그 동안 너무 쫒겨서 코드를 짰다는 걸 느끼며 반성하는 시간을 가졌습니다.

React 생태계에서 어떤 아키텍쳐를 결정할지 논의하면서, 깊게 생각하지 않았던 것들을 정리할 수 있는 기회가 되어 좋았습니다.

꾸준히 더 나은 코드를 빚어내는 경험을 쌓아야 겠습니다.

profile
프론트엔드 개발자

0개의 댓글