본문 바로가기
개발자/기술스택 도구

상태 관리 라이브러리 바꾸면서 겪은 마이그레이션 고통

by 나무011 2026. 7. 10.
기술 마이그레이션 Redux → Zustand ⏱ 읽는 시간 약 10분

Redux를 Zustand로 바꾸는 데
3주가 걸린 이유

"Zustand는 간단하잖아요" — 그 말이 얼마나 틀렸는지 마이그레이션 첫날 알았다

회의는 30분이면 끝났다. "Redux 보일러플레이트가 너무 많고, Zustand가 더 가볍고 빠르니까 바꾸자." 팀원 모두 동의했다. 예상 기간은 1주일이었다.

실제로는 3주가 걸렸다. 그 3주 동안 프로덕션 버그 2건이 났고, 팀원 한 명이 번아웃에 준하는 상태가 됐고(나였다), 스프린트 일정이 한 번 밀렸다.

"기술 스택을 바꾼다"는 말이 얼마나 가볍게 결정되고 얼마나 무겁게 실행되는지 — 이 글은 그 경험의 기록이다.

상태 관리 라이브러리 바꾸면서 겪은 마이그레이션 고통
상태 관리 라이브러리 바꾸면서 겪은 마이그레이션 고통
1주일
회의에서 나온
예상 기간
3주
실제 마이그레이션
소요 기간
2건
마이그레이션 중
프로덕션 버그
47개 → 1개
스토어 관련 파일 수
감소
 

왜 바꾸기로 했나 — 진짜 이유

공식적인 이유는 "개발 생산성 향상"이었다. 그런데 솔직히 말하면 복합적이었다.

⚠️
마이그레이션 결정 당시 진짜 이유들

표면적: Redux Toolkit 보일러플레이트가 너무 많다. 새 기능 추가할 때마다 slice, action, selector, thunk를 전부 새로 만들어야 한다.

실질적: 신규 입사한 팀원이 Redux 패턴에 적응하는 데 2주가 걸렸다. Zustand는 러닝커브가 낮다.

솔직히: 기술 블로그에서 "Zustand가 훨씬 낫다"는 글을 보고 팀장이 먼저 꺼낸 주제였다. 다들 새 기술이 궁금했던 것도 있었다. 솔직 고백

이 결정에서 빠진 게 있었다. 지금 코드베이스가 얼마나 Redux에 의존하고 있는지 실제로 측정하지 않았다. "한 30개 파일 정도 되겠지"라고 막연히 생각했는데, 실제로 세어보니 Redux 관련 파일이 47개, Redux를 직접 import하는 컴포넌트가 63개였다.

 

Redux vs Zustand — 개념부터 달랐다

마이그레이션을 시작하기 전에 두 라이브러리의 철학 차이를 제대로 이해하지 못했던 것도 문제였다. 단순히 "문법이 다른 같은 것"이 아니었다.

항목 Redux Toolkit Zustand 마이그레이션 영향
상태 구조 단일 스토어
모든 상태가 하나의 트리
다수 스토어
기능별로 분리
구조 재설계 필요
비동기 처리 createAsyncThunk
pending/fulfilled/rejected
직접 async 함수
스토어 안에 그냥 씀
패턴 전환, 로딩 상태 재설계
미들웨어 Redux middleware
logger, thunk 등
middleware 함수
방식이 다름
로깅 재구축 필요
DevTools Redux DevTools
시간여행 디버깅
별도 설정 필요
기본은 미지원
디버깅 환경 재설정
셀렉터 createSelector
메모이제이션 내장
커스텀 필요
기본 없음
성능 최적화 재검토

"Zustand가 간단하다는 건 사실이다. 그런데 기존 코드가 Redux 방식으로 설계돼 있으면, 그 '간단함'으로 가는 길이 간단하지 않다."

— 마이그레이션 2주차에 깨달은 것
 

실제로 겪은 고통 5가지

01
PAIN 01 — 첫날부터
단일 스토어 → 다수 스토어 구조 재설계
Redux는 하나의 큰 스토어에 모든 상태가 들어간다. Zustand는 기능별로 작은 스토어를 여러 개 만드는 걸 권장한다. 기존 코드를 보니 userSlice, cartSlice, uiSlice가 하나의 스토어에 묶여 있었는데, 이걸 어떻게 나누느냐가 첫 번째 설계 충돌이었다. 어떤 상태를 어떤 스토어에 넣느냐를 팀이 합의하는 데만 반나절이 걸렸다. 이건 기술 문제가 아니라 설계 문제였다.
예상 시간: 2시간 / 실제: 반나절 + 재합의 1번
02
PAIN 02 — 가장 힘든 구간
createAsyncThunk 패턴이 사라졌다
Redux에서 API 호출은 createAsyncThunkpending / fulfilled / rejected 세 상태를 자동으로 관리해줬다. Zustand에는 이게 없다. 로딩, 에러, 성공 상태를 스토어 안에 직접 관리해야 했다. 기존에 asyncThunk를 쓰던 API 호출이 18개였는데, 전부 패턴을 새로 짜야 했다. 여기서 일주일을 썼다.
 
 
 
비동기 패턴 변환 — 가장 공수가 많이 든 부분
// ❌ Before: createAsyncThunk
export const fetchUser = createAsyncThunk(
  'user/fetch',
  async (id: string) => {
    return await api.getUser(id);
  }
);
// extraReducers로 loading/error 자동 처리

// ✅ After: Zustand — 직접 구현
const useUserStore = create<UserStore>()(set => ({
  user: null,
  isLoading: false,     // 직접 관리
  error: null,          // 직접 관리

  fetchUser: async (id) => {
    set({ isLoading: true, error: null });
    try {
      const user = await api.getUser(id);
      set({ user, isLoading: false });
    } catch (e) {
      set({ error: e, isLoading: false });
    }
  },
}));
18개 API 호출 전수 변환 → 실수 포인트가 많았다
03
PAIN 03 — 예상 못 한 것
셀렉터 메모이제이션이 사라지며 리렌더링 폭발
Redux에서는 createSelector로 파생 상태를 메모이제이션했다. Zustand로 바꾸고 나서 같은 방식으로 쓰다가 특정 페이지에서 리렌더링 횟수가 6배로 늘었다. 장바구니 페이지에서 상품 하나를 추가할 때마다 전체 목록이 통째로 다시 그려졌다. DevTools로 추적하는 데만 하루가 걸렸고, useShallow를 사용하는 패턴으로 해결하는 데 또 하루가 걸렸다.
 
 
 
리렌더링 문제 — 원인과 해결
// ❌ 이렇게 쓰면 매 렌더마다 새 객체 참조 → 리렌더링
const { items, total } = useCartStore();

// ✅ useShallow로 얕은 비교 적용
import { useShallow } from 'zustand/react/shallow'

const { items, total } = useCartStore(
  useShallow(state => ({
    items: state.items,
    total: state.total,
  }))
);

// 또는 파생 상태는 별도 selector 함수로 분리
const selectCartSummary = (state: CartStore) => ({
  count: state.items.length,
  total: state.items.reduce((sum, i) => sum + i.price, 0),
});
Redux DevTools 없이 추적하는 것도 한몫했다
04
PAIN 04 — 프로덕션 버그 원인
Redux + Zustand 혼용 구간에서 상태 충돌
점진적 마이그레이션 전략을 써서 "일부는 Redux, 일부는 Zustand"로 공존하는 구간이 2주가량 있었다. 이때 같은 사용자 정보를 Redux 스토어와 Zustand 스토어가 따로 들고 있으면서 값이 달라지는 충돌이 발생했다. 로그인 후 Redux는 업데이트됐는데 Zustand가 갱신 안 된 채로 남아 있었다. 유저가 로그아웃했는데 장바구니는 남아있는 버그. 이게 프로덕션까지 나갔다.
교훈: 혼용 기간을 최소화하거나, 단일 진실 출처(Single Source of Truth)를 명확히 해야 한다
05
PAIN 05 — 끝나고 나서
테스트 코드도 전부 다시 써야 했다
마이그레이션이 거의 끝났다고 생각했을 때 CI가 터졌다. Redux 스토어를 주입하는 방식으로 짜인 테스트 코드들이 전부 실패했다. renderWithStore 같은 테스트 헬퍼들이 Redux 기반이었다. 테스트 코드 변환에 예상 외로 이틀이 더 걸렸다. 마이그레이션 계획에 테스트 코드를 항목으로 넣지 않은 실수였다.
 
 
 
테스트 헬퍼 변환
// ❌ Before: Redux Provider 필요
const renderWithStore = (ui, { initialState } = {}) => {
  const store = configureStore({ reducer, preloadedState: initialState });
  return render(<Provider store={store}>{ui}</Provider>);
};

// ✅ After: Zustand는 Provider 불필요 — 스토어 직접 초기화
const renderWithZustand = (ui, initialStoreValues = {}) => {
  // 테스트 전 스토어 초기화
  beforeEach(() => {
    useUserStore.setState(initialStoreValues.user ?? defaultUserState);
    useCartStore.setState(initialStoreValues.cart ?? defaultCartState);
  });
  return render(ui); // Provider 없이도 됨
};
결론: 마이그레이션 계획에 테스트 코드를 필수 항목으로 추가해야 한다
 

3주 타임라인 — 실제로 어떻게 흘렀나

📋
 
Week 1 — 설계 & 기반 작업
스토어 구조 설계 → 예상보다 3배 걸렸다
기존 Redux 파일 현황 파악(47개), Zustand 스토어 구조 설계, 팀 합의. 이 단계에서 이미 예상 일정이 무너졌다. "파악"이 생각보다 오래 걸렸다. 어떤 상태를 어디에 둘지 팀마다 의견이 달랐다.
예상 이틀 → 실제 4일
 
Week 2 — 본격 변환 & 첫 버그
컴포넌트 63개 변환 + 프로덕션 버그 1건
Redux/Zustand 혼용 구간에서 상태 충돌 버그가 프로덕션에 나갔다. 긴급 패치하면서 원래 변환 작업이 멈췄다. 비동기 처리 패턴 18개도 이 주에 전환했는데 그것만으로도 꽉 찼다.
프로덕션 버그 → 긴급 핫픽스 → 일정 지연
🔥
 
Week 3 — 성능 & 테스트 & 두 번째 버그
리렌더링 폭발 발견 + 테스트 코드 전수 변환
장바구니 리렌더링 버그를 이 주에 발견했다. 추적과 수정에 이틀. 테스트 코드 변환에 또 이틀. 두 번째 프로덕션 버그도 이 주에 나왔다. 팀원 전체 사기가 최저점이었다.
가장 힘든 구간 — 이 주에 이직 생각 3번
Week 3 후반 — 완료
Redux 코드 완전 제거 + 회고
마지막으로 Redux 패키지를 package.json에서 삭제했다. CI 통과. 번들 사이즈 8.2KB 감소. 팀 회고에서 "왜 예상이 이렇게 빗나갔나"를 분석했다. 다음 마이그레이션을 위한 체크리스트도 만들었다.
완료 후 Zustand로 새 기능 추가 → 확실히 빠르다
 

다음 마이그레이션 때 바꿀 것들

01
현황 파악부터 — 코드 grep 먼저
마이그레이션 전 grep -r "from 'redux'" src/로 의존 파일 수를 먼저 센다. "한 30개 되겠지"는 금물. 실제 숫자가 계획의 시작이다.
02
혼용 기간을 스프린트 1개 이내로
두 라이브러리가 같은 상태를 들고 있는 기간이 길수록 버그 리스크가 높아진다. 혼용은 최대 1스프린트. 전환이 늦어지면 일부를 다음 마이그레이션 범위로 나누는 게 낫다.
03
테스트 코드를 항목에 포함
컴포넌트 변환 + 테스트 변환을 세트로 계획한다. 테스트 코드는 별도 공수로 잡는다. 마지막에 몰아서 하면 CI가 터지는 최악의 상황이 온다.
04
성능 테스트를 중간에 끼워 넣는다
변환 50% 시점에서 리렌더링 프로파일링을 한 번 돌린다. 끝나고 발견하면 범위가 커진다. React DevTools의 Profiler를 중간 체크포인트로 활용.
05
피처 플래그로 점진적 전환
라우트 단위로 "이 페이지는 Zustand, 나머지는 Redux"를 명확히 분리한다. 혼재를 페이지 단위로 격리하면 충돌 추적이 훨씬 쉬워진다.
06
예상 기간에 버퍼 2주를 기본으로
어떤 마이그레이션이든 예상 기간의 2~3배가 걸린다고 생각하는 게 현실적이다. "1주일이면 될 것 같아요"는 발표 기간이지 실제 기간이 아니다.
마이그레이션 완료 후 실제 달라진 것들
번들 사이즈 8.2KB 감소, 신규 기능 개발 시 상태 추가 시간 70% 단축, 새 팀원 온보딩 시 상태 관리 이해 시간 1주 → 2일. 고통스러웠지만 결과는 분명히 좋아졌다. 다만 그 과정이 예상의 3배였을 뿐이다.

기술 마이그레이션은 항상 "이 정도면 금방이지"로 시작해서 "왜 이렇게 나왔지"로 끝난다. 그 간극이 좁혀지는 건 경험이 쌓이면서다. 이 글이 다음 마이그레이션을 앞둔 팀에게 조금이라도 그 간극을 좁혀주길 바란다.


💬 마이그레이션 경험이 있으신가요?

Redux에서 Zustand, Recoil, Jotai, React Query 등으로 전환한 경험이 있다면 가장 힘들었던 부분이 무엇이었는지 댓글로 나눠주세요. 반대로 "이 방법으로 했더니 훨씬 수월했다"는 팁도 반깁니다. 다음 마이그레이션 체크리스트에 반영할게요. 🙏


소개 및 문의 · 개인정보처리방침 · 면책조항

© 2026 나무핀