이번 글에서는 기존에 사용하던 Context API에서 Zustand로 마이그레이션 하게 된 썰을 한 번 풀어보려고 합니다.
처음에는 상태관리에 대해 잘 모르기도 했고 단순한 전역 상태만 관리하면 되겠지 싶었습니다.
그래서 Context API를 루트에 넣고, 하나 둘 Provider를 감쌌죠.
하지만 어느 순간부터 컴포넌트가 불필요하게 리렌더링 되고 수정하지 않은 컴포넌트도 갑자기 반응하며 성능 저하 이슈까지 발생하기 시작했습니다.
그때 든 생각은 하나였습니다.
"내가… 왜 그랬을까?"
이 글에서는 Context API를 쓸 때 겪었던 리렌더링 이슈와 Zustand로 어떻게 개선했는지를 공유해보려고 합니다.
왜 나는 Context API를 선택했을까?
사실 Context API는 제가 선택해서 사용한 기술은 아닙니다. 팀에 합류하기 전부터 개발을 하고 있던 팀원이 사용하고 있었죠.
그래서 저도 사실 아무 의심없이 사용하게 되었습니다.
근데 왜 갑자기 Zustand로 변경하게 되었느냐에 말씀드리자면 프로젝트가 종료 후 팀원들끼리 리팩토링을 하자는 얘기가 오고 갔습니다.
구현하기에 급급했던 저는 그제야 성능이 궁금했습니다. React Developers Tools를 이용해 컴포넌트들의 렌더링 상태를 보게 되었습니다.
결과는 끔찍했죠. 아래와 같이 코드를 작성하다 보니 프로젝트 전체가 리렌더링 되고 있었던 겁니다.
import { useState, createContext, useEffect } from 'react';
/////////////////
컴포넌트 import 부분
/////////////////
export const MyContext = createContext();
const App = () => {
return (
<MyContext.Provider
value={{
modalOpen,
setModalOpen,
///////////////
이하 생략
///////////////
}}
>
<Router>
<Toaster position="bottom-center" richColors />
<div className="wrapper">
{modalOpen && (
<>
{modalContent === 'buddyConfirm' && (
<Confirm
firstTitle="제출"
secondTitle="세종버디를 신청하시겠습니까?"
ok="신청"
/>
)}
{modalContent === 'selectMajor' && <Major />}
{modalContent === 'selectDoubleMajor' && <Major id="double" />}
</>
)}
{/* <Header /> */}
<main className={modalContent === 'buddyConfirm' ? 'fixed' : ''}>
<Routes>
<Route exact path="/" element={<StartLoading />} />
<Route path="/main" element={<MainPage />} />
<Route path="/buddy" element={<Buddy />} />
///////////////////////////
이하 생략
///////////////////////////
</Routes>
</main>
</div>
</Router>
</MyContext.Provider>
);
};
위 코드에서도 많은 부분을 생략한 것인데, 실제로는 수십 줄이 더 있습니다.
이렇다 보니 프로젝트 전체가 리렌더링되어 Lighthouse 기준 성능 점수가 71점이 나왔습니다.
이에 경악한 저는 여러 상태 관리 라이브러리를 알아보게 되었는데요, 그중 왜 Zustand를 선정했는지에 대해 말씀드리겠습니다.
우리는 왜 Zustand를 선정했을까?
왜 Zustand를 선정했는지에 대해 말하기 전에 다른 상태 관리 라이브러리와 비교해 보겠습니다.
다른 상태 관리 라이브러리의 장, 단점 그리고 결론
Context API | Redux / Redux-Toolkit | Recoil | |
장점 | 내장 기능, 간단한 구조, 초기 러닝 커브 없음 | 대규모 앱에서 강력한 상태 관리, 미들웨어 / 디버깅 툴이 강력 | React 친화적, atom 단위 상태 관리 리렌더링도 비교적 잘 최적화 |
단점 | 상태가 커질수록 리렌더링 이슈 심화 매번 useContext로 수동 연결해야 함 Provider 중첩 가능성 |
복잡한 보일러플레이트 작은 앱에선 오히려 과한 셋업 useSelector도 리렌더링 주의 필요 |
아직 커뮤니티와 생태계가 작음 의존성 추적 및 디버깅이 어려운 경우 존재 |
결론 | 간단한 전역값엔 적합, 하지만 성능 최적화와 확장성엔 한계 | 아주 큰 팀 or 미들웨어 로직이 복잡할 땐 적합하지만, 개발 생산성과 DX 측면에선 부담 |
그래프 기반 구조는 직관적이지만, 상태 흐름이 커지면 복잡도 증가 가능
|
그렇다면 Zustand는?
장점 | 작고 빠르며, 보일러플레이트가 거의 없음 selector 기반 구독으로 리렌더링 최소화 비동기 로직도 store 안에 자연스럽게 정의 가능 middleware, persist 등도 매우 유연하게 적용 |
단점 | 타입 추론은 처음에 약간 헷갈릴 수 있음 상태 분리가 명확하지 않으면 오히려 혼란 |
결론 | 컴팩트한 구조 + 성능 최적화 → 사이드 프로젝트나 중소규모 팀에 매우 적합 |
다른 상태 관리 라이브러리의 경우 성능적인 문제가 있지만, Zustand는 개발자에게만 영향을 끼치게 됩니다.
더해, Zustand는 간단한 코드로도 작성할 수가 있어 러닝 커브가 적습니다.
import { create } from "zustand";
import { SetBuddyMatchingType } from "../types/buddy/buddyType";
export const BuddyStore = create<SetBuddyMatchingType>(set => ({
gender: '',
major: '',
subMajor: false,
type: [],
grade: [],
setGender: (gender) => set(() => ({ gender })),
setMajor: (major) => set(() => ({ major })),
setSubMajor: (subMajor) => set(() => ({ subMajor })),
setType: (type) => set(() => ({ type })),
setGrade: (grade) => set(() => ({ grade })),
}));
이렇게만 작성하고 가져다 쓰기만 하면 끝이기 때문이죠.
Zustand로 변경 후
Zustand로 마이그레이션 한 뒤, Profiler로 다시 확인해 봤습니다.
이전에는 필요하지 않은 컴포넌트까지 리렌더링 되던 문제가 있었지만, 이제는 정말 렌더링 돼야 할 컴포넌트만 리렌더링 되며, 성능 점수 역시 90점으로 크게 향상되었습니다.
그렇다면 여기서 조금 더 나아가 useCallback 또는 React.memo를 사용하면 되지 않느냐? 왜 굳이 귀찮게 마이그레이션을 하는지 의문을 갖는 분도 계실 겁니다.
제 생각은 이렇습니다.
당연히 useCallback이나 React.memo를 잘 조합하면 리렌더링을 막을 수 있겠죠.
하지만, 문제는 이게 모든 컴포넌트마다 수동으로 최적화를 해줘야 한다는 점입니다.
어떤 컴포넌트는 React.memo, 또 다른 컴포넌트는 useCallback...
이렇게 하다 보면 유지보수도 힘들어질뿐더러, 정말 나중에 마이그레이션을 하더라도 힘들어집니다.
그래서 저는 위와 같은 이유들 때문에 Zustand로 마이그레이션 하게 되었습니다 :)
마무리
아쉽게도 성능 점수에 대한 스크린샷은 남아있지 않아 아쉬움이 남습니다.
오래된 프로젝트라 기억에 의존할 수밖에 없었지만, 확실히 개선이 체감됐던 기억은 분명하네요.
이 글이 많은 분들에게 닿진 않겠지만, 혹시 저처럼 성능 최적화에 관심 있는 분이 계시다면 꼭 성능 측정을 해보시고 문제를 직접 확인해 보시길 추천드립니다!
부족한 글 끝까지 읽어주셔서 감사합니다 🙏
'React' 카테고리의 다른 글
Lazy Loading (1) | 2024.10.05 |
---|---|
useSearchParams (0) | 2024.08.20 |
이번 글에서는 기존에 사용하던 Context API에서 Zustand로 마이그레이션 하게 된 썰을 한 번 풀어보려고 합니다.
처음에는 상태관리에 대해 잘 모르기도 했고 단순한 전역 상태만 관리하면 되겠지 싶었습니다.
그래서 Context API를 루트에 넣고, 하나 둘 Provider를 감쌌죠.
하지만 어느 순간부터 컴포넌트가 불필요하게 리렌더링 되고 수정하지 않은 컴포넌트도 갑자기 반응하며 성능 저하 이슈까지 발생하기 시작했습니다.
그때 든 생각은 하나였습니다.
"내가… 왜 그랬을까?"
이 글에서는 Context API를 쓸 때 겪었던 리렌더링 이슈와 Zustand로 어떻게 개선했는지를 공유해보려고 합니다.
왜 나는 Context API를 선택했을까?
사실 Context API는 제가 선택해서 사용한 기술은 아닙니다. 팀에 합류하기 전부터 개발을 하고 있던 팀원이 사용하고 있었죠.
그래서 저도 사실 아무 의심없이 사용하게 되었습니다.
근데 왜 갑자기 Zustand로 변경하게 되었느냐에 말씀드리자면 프로젝트가 종료 후 팀원들끼리 리팩토링을 하자는 얘기가 오고 갔습니다.
구현하기에 급급했던 저는 그제야 성능이 궁금했습니다. React Developers Tools를 이용해 컴포넌트들의 렌더링 상태를 보게 되었습니다.
결과는 끔찍했죠. 아래와 같이 코드를 작성하다 보니 프로젝트 전체가 리렌더링 되고 있었던 겁니다.
import { useState, createContext, useEffect } from 'react';
/////////////////
컴포넌트 import 부분
/////////////////
export const MyContext = createContext();
const App = () => {
return (
<MyContext.Provider
value={{
modalOpen,
setModalOpen,
///////////////
이하 생략
///////////////
}}
>
<Router>
<Toaster position="bottom-center" richColors />
<div className="wrapper">
{modalOpen && (
<>
{modalContent === 'buddyConfirm' && (
<Confirm
firstTitle="제출"
secondTitle="세종버디를 신청하시겠습니까?"
ok="신청"
/>
)}
{modalContent === 'selectMajor' && <Major />}
{modalContent === 'selectDoubleMajor' && <Major id="double" />}
</>
)}
{/* <Header /> */}
<main className={modalContent === 'buddyConfirm' ? 'fixed' : ''}>
<Routes>
<Route exact path="/" element={<StartLoading />} />
<Route path="/main" element={<MainPage />} />
<Route path="/buddy" element={<Buddy />} />
///////////////////////////
이하 생략
///////////////////////////
</Routes>
</main>
</div>
</Router>
</MyContext.Provider>
);
};
위 코드에서도 많은 부분을 생략한 것인데, 실제로는 수십 줄이 더 있습니다.
이렇다 보니 프로젝트 전체가 리렌더링되어 Lighthouse 기준 성능 점수가 71점이 나왔습니다.
이에 경악한 저는 여러 상태 관리 라이브러리를 알아보게 되었는데요, 그중 왜 Zustand를 선정했는지에 대해 말씀드리겠습니다.
우리는 왜 Zustand를 선정했을까?
왜 Zustand를 선정했는지에 대해 말하기 전에 다른 상태 관리 라이브러리와 비교해 보겠습니다.
다른 상태 관리 라이브러리의 장, 단점 그리고 결론
Context API | Redux / Redux-Toolkit | Recoil | |
장점 | 내장 기능, 간단한 구조, 초기 러닝 커브 없음 | 대규모 앱에서 강력한 상태 관리, 미들웨어 / 디버깅 툴이 강력 | React 친화적, atom 단위 상태 관리 리렌더링도 비교적 잘 최적화 |
단점 | 상태가 커질수록 리렌더링 이슈 심화 매번 useContext로 수동 연결해야 함 Provider 중첩 가능성 |
복잡한 보일러플레이트 작은 앱에선 오히려 과한 셋업 useSelector도 리렌더링 주의 필요 |
아직 커뮤니티와 생태계가 작음 의존성 추적 및 디버깅이 어려운 경우 존재 |
결론 | 간단한 전역값엔 적합, 하지만 성능 최적화와 확장성엔 한계 | 아주 큰 팀 or 미들웨어 로직이 복잡할 땐 적합하지만, 개발 생산성과 DX 측면에선 부담 |
그래프 기반 구조는 직관적이지만, 상태 흐름이 커지면 복잡도 증가 가능
|
그렇다면 Zustand는?
장점 | 작고 빠르며, 보일러플레이트가 거의 없음 selector 기반 구독으로 리렌더링 최소화 비동기 로직도 store 안에 자연스럽게 정의 가능 middleware, persist 등도 매우 유연하게 적용 |
단점 | 타입 추론은 처음에 약간 헷갈릴 수 있음 상태 분리가 명확하지 않으면 오히려 혼란 |
결론 | 컴팩트한 구조 + 성능 최적화 → 사이드 프로젝트나 중소규모 팀에 매우 적합 |
다른 상태 관리 라이브러리의 경우 성능적인 문제가 있지만, Zustand는 개발자에게만 영향을 끼치게 됩니다.
더해, Zustand는 간단한 코드로도 작성할 수가 있어 러닝 커브가 적습니다.
import { create } from "zustand";
import { SetBuddyMatchingType } from "../types/buddy/buddyType";
export const BuddyStore = create<SetBuddyMatchingType>(set => ({
gender: '',
major: '',
subMajor: false,
type: [],
grade: [],
setGender: (gender) => set(() => ({ gender })),
setMajor: (major) => set(() => ({ major })),
setSubMajor: (subMajor) => set(() => ({ subMajor })),
setType: (type) => set(() => ({ type })),
setGrade: (grade) => set(() => ({ grade })),
}));
이렇게만 작성하고 가져다 쓰기만 하면 끝이기 때문이죠.
Zustand로 변경 후
Zustand로 마이그레이션 한 뒤, Profiler로 다시 확인해 봤습니다.
이전에는 필요하지 않은 컴포넌트까지 리렌더링 되던 문제가 있었지만, 이제는 정말 렌더링 돼야 할 컴포넌트만 리렌더링 되며, 성능 점수 역시 90점으로 크게 향상되었습니다.
그렇다면 여기서 조금 더 나아가 useCallback 또는 React.memo를 사용하면 되지 않느냐? 왜 굳이 귀찮게 마이그레이션을 하는지 의문을 갖는 분도 계실 겁니다.
제 생각은 이렇습니다.
당연히 useCallback이나 React.memo를 잘 조합하면 리렌더링을 막을 수 있겠죠.
하지만, 문제는 이게 모든 컴포넌트마다 수동으로 최적화를 해줘야 한다는 점입니다.
어떤 컴포넌트는 React.memo, 또 다른 컴포넌트는 useCallback...
이렇게 하다 보면 유지보수도 힘들어질뿐더러, 정말 나중에 마이그레이션을 하더라도 힘들어집니다.
그래서 저는 위와 같은 이유들 때문에 Zustand로 마이그레이션 하게 되었습니다 :)
마무리
아쉽게도 성능 점수에 대한 스크린샷은 남아있지 않아 아쉬움이 남습니다.
오래된 프로젝트라 기억에 의존할 수밖에 없었지만, 확실히 개선이 체감됐던 기억은 분명하네요.
이 글이 많은 분들에게 닿진 않겠지만, 혹시 저처럼 성능 최적화에 관심 있는 분이 계시다면 꼭 성능 측정을 해보시고 문제를 직접 확인해 보시길 추천드립니다!
부족한 글 끝까지 읽어주셔서 감사합니다 🙏
'React' 카테고리의 다른 글
Lazy Loading (1) | 2024.10.05 |
---|---|
useSearchParams (0) | 2024.08.20 |