상태는 내가 보는 모든것이라 정의한다.
리액트의 상태가 뭐냐 물어보면, 지금 화면에 보이는 모든 것, 그리고 그게 바뀌는 이유가 바로 상태다.
- 버튼 하나를 눌러서 색깔이 바뀌었다면, 그건 상태다.
- 입력창에 글자를 썼는데 실시간으로 다른 곳에 반영됐다면, 그것도 상태이다.
- 모달이 떴다 사라지는 것, 리스트의 항목이 추가되거나 삭제되는 것도 모두 상태이다.
결국 UI를 구성하는 현재의 데이터를 상태라고 생각하는데, 그렇기 때문에 상태를 관리한다는 것은 곧, 좋은 사용자 경험의 시작이라 생각한다.
이번 포스팅에서는 그 중 전역상태가 핵심 키워드이며, 왜 우리 팀의 전역상태관리를 context api에서 zustand로 전환하게 되었는지, 그 배경과 결정 과정을 공유하려 한다.
기존 코드에서 Context API의 문제
우리 팀은 초기에 Context API를 사용하고 있었다. 모듈화도 당시에는 잘 되어있지 않았으며, 간단하게 내장된 기능이라는 점에서 충분히 괜찮은 선택이었다. 하지만 프로젝트가 커지고 관리해야 할 전역 데이터가 많아질수록, 다음과 같은 한계가 분명해지기 시작했다.
✅ Context 객체 자체가 상태를 감싸고 있다는 구조적 제약
모든 전역 상태를 하나의 커다란 객체로 관리하고 있었다. 이는 의도치 않게 불필요한 렌더링과 의존성 분리의 어려움을 가져왔다.
✅ 전역 변수라는 개념에만 집중한 설계
전역 상태를 “그냥 전역이니까” 라는 이유만으로 만들고, 재사용성, 최적화에 대한 고려는 충분하지 않았다. 단지 useContext로 값을 꺼내 쓰는 구조였고, 이로 인해 의도치 않은 컴포넌트 리렌더링이 자주 발생했다.
✅ 분리 전략의 부재와 중첩된 공급자 구조
물론 Context API에서도 여러 개의 context로 나누거나, useReducer 패턴을 활용하는 등의 개선 방법과 reduceRight로 provider 중첩을 방지하는 패턴도 존재한다. 하지만 이런 방식은 점점 복잡성만 증가할 뿐, 근본적인 최적화와 코드가 많아져 발생하는 유지보수 문제를 해결하진 못했다.
분리 전략의 부재와 중첩된 공급자 구조를 좀 더 살펴보겠다.
이 중, 많이 고려했던 부분이 기존의 전역상태는 그대로 유지하고 코드의 구조를 바꾸는 것이었다. Context Api를 사용하면서, 코드를 개선하는 방식에서 reducer 패턴을 사용하면 지금 섞여있는 로직을 확실하게 분리를 할 수는 있었다. 예를 들어 아래와 같이 태깅을 하는 기능
드래그 하는 기능
을 구분하고, provider를 묶어보겠다.
이렇게 된다면, 서비스별로 전역 상태를 둘 때 코드에 따라 해당 상태가 어떤 역할을 수행하는지 명확히 파악할 수 있으며, 사용 범위도 분리되어 유지보수가 훨씬 쉬워진다.
만약 최적화를 고려하지 않는다면, 그 당시에는 제일 적합한 방법이라고 생각했다. 말 그대로 위의 방식은 최적화가 문제였는데,
부모가 렌더링이 되면, 자식도 렌더링이 된다.
라는 조건이 제일 큰 영향을 차지했다.
구조를 바꾸더라도 해결하지 못했던 문제
부모가 렌더링이 되면, 자식도 렌더링이 된다.
라는게 너무 크리티컬 했는데, 우선 각 자식마다 메모이제이션을 적용한다는 해결방식은 있었다.
이렇게 하면 부모의 리렌더링이 자식에게까지 전달되는 현상을 대부분 막을 수 있지만, 이 또한 한계가 존재한다.
- 회사 상황상 중간에 추가되는 기획이 잦았기 때문에, 확정된 구조를 미리 가져가기 어려웠다. 이런 환경에서는 매번 메모이제이션을 적용하기엔 부담이 크고, 언제 구조가 바뀔지 몰라 관리가 번거로웠다.
- 또한 실제로는 부모 컴포넌트에서 사용하는 전역 상태가 1차 자식 컴포넌트에서 바로 쓰이는 경우는 드물었고, 오히려 말단에 가까운 컴포넌트들에서 전역 상태를 활용하는 경우가 많았다.
결국 렌더링 최적화까지는 가져가기 힘들었고, 내려주는 방식 즉, 컨텍스트를 이용한 상태는 맞지 않았았다.
말단에 쓰이는 전역상태를 매번 바꾸고 적용하기에 부모부터 메모이제이션을 두르고, 가변한 props 처리 까지 생각하다보니 보일러플레이트는 많아질 수 밖에 없었으며 리렌더링을 막기 위한 최적화
와 구조의 유연함
사이를 계속 반복해야했다.
어떤 상태를 쓸까
우선 조건을 나열해보면 다음과 같았다.
코드에서 요구되는 조건
- 해당 전역 상태만 사용하는 컴포넌트만 리렌더링될 것
- 지역 상태와 분리되며, 해당 컴포넌트에서 사용하는 상태가 직관적으로 드러날 것
팀 상황에 따른 조건
- 기획이 매주 변경됨
- 코드가 아직 정리되지 않은 상태이며, 스프린트 내 작업 완료가 필수이므로 기존 코드 구조를 크게 변경하기는 어려움
recoil, jotai 그리고 zustand가 후보로 있었다.
각각 패턴적으로 정의하게 되면 atom
이냐, flux
인가로 구분을 할 수 있다. atom방식을 사용하는 rocoil과, jotai, 그리고 flux패턴을 사용하는 zustand 이렇게 구분할 수 있었다.
사실 각 전역상태들은 기반이 다른 것이지 충분히 서로의 장점을 보충하는 방안이 있었으며, 이분법적으로 명확히 나누는 것은 무의미 하다고 판단했다. 그래서 팀의 상황과 코드 조건이 크게 작용을 할 수 밖에 없었다.
recoil은 우선 생략하고 비교했을 때
zustand에서 수동으로 렌더링을 최적화 해야한다는 단점은 있었지만 기존의 코드를 최대한 건들이지 않으며, 전역상태끼리도 서로가 많이 의존되어있다는 점에서 zustand를 선정하게 되었다. 이러한 이유로, jotai와 같이 동일한 atom 기반의 접근 방식을 사용하는 recoil은 자연스럽게 선택지에서 제외되었다.
어떻게 코드 관리를 할까?
우선 DataHub(바이언스 팀의 서비스중 하나)에서 사용중인 전역 상태를 모두 나열해 보았고, 지역으로 관리해도 되는 상태나, 중복 로직은 모두 하나로 가져갔다.
또한 기존 Context API의 리렌더링 한계 보완 하는데 실제 zustand를 만든 다이시카토
는 이를 수동 렌더링
이라고 표현을 하고 있으며, 아래와 같이 따로 selector 관련 파일에서 현재 프로젝트에 정의되고 있는 상태를 종합적으로 볼 수 있도록 했다.
Context API -> Zustand 적용 이후
우선 실질적으로는 다음과 같은 결과 있었다.
- 한 동작에 대해 10번의 렌더링 -> 한 동작당 1번의 렌더링으로 정상화
- 10개의 불필요한 전역상태 제거 후, 기존 로직과 동일하게 정상화
- 중앙관리에서
600
줄의 코드를150
줄로 단축
사실 초기 레거시에 일부 잘못 작성된 부분도 있었지만, 하나의 동작에 대해 여러 번 렌더링이 발생하는 문제는 반드시 해결해야 할 심각한 이슈였다.
개선 전과 후
기대 효과
재밌는게 있었는데, 처음부터 의도하고 설계한 부분은 아니었지만 결과적으로 긍정적인 효과를 가져온 부분이 있다는 것이다.
- 유저 세션 관리
- 이후 workspace에서의 지역상태 관리 패턴과 동일
원래 로직에서는 상태 관리가 별도로 분리되어 있었기 때문에 zustand의 persist 미들웨어는 굳이 고려하지 않았다. 하지만 개발 도중이나 이후 기획이 추가되면서 로그인 상태 유지, 브라우저 간 상태 동기화, 협업 기능 등의 요구사항이 생겼고, 이 과정에서 기존에 구성해 놓은 구조가 자연스럽게 유저 세션 유지나 workspace 서비스의 지역 상태 패턴과 잘 맞아떨어졌다.
아직 적용은 하지 않았지만 결과적으로 브라우저 스토리지에 상태를 저장할 때 persist를 적용하는 것이 훨씬 수월해졌고 기대 효과가 큰 상황이다.