· React· Zustand· Redux· State Management· Migration

Redux에서 Zustand로, 1년 5개월 만에 마이그레이션 완료

절반 지점에서 멈춰 있던 이야기의 결말 — 남은 레거시를 정리하고 Redux를 완전히 제거하기까지

지난 글에서 Redux에서 Zustand로의 마이그레이션이 절반쯤 진행됐다고 썼다. 그 글의 마지막 문장은 이랬다. “1년 뒤에는 Redux가 완전히 사라진 코드베이스를 볼 수 있지 않을까.”

지난주에 npm uninstall redux react-redux @reduxjs/toolkit을 실행했다. 계획을 세운 지 1년 5개월 만이다. 지난 글에서 어림한 시점보다는 반년쯤 이르다.

남아 있던 것들

절반 지점에서 남아 있던 건 쉬운 절반이 아니었다. 인증 상태, 전역 설정, UI 상태처럼 독립적인 것들은 이미 다 옮긴 뒤였고, 남은 건 손대기 무서워서 미뤄둔 것들이었다.

  • 여러 slice가 서로를 참조하는 얽힌 상태들
  • thunk 안에서 dispatch를 연쇄로 호출하는 비즈니스 로직
  • 어디서 쓰는지 파악조차 안 되는 오래된 selector들

“여유 있을 때 하나씩”이라는 원칙은 여기서 한계에 부딪혔다. 얽힌 상태는 하나씩 옮길 수가 없다. A를 옮기려면 A를 참조하는 B도 알아야 하고, B는 C와 얽혀 있다. 결국 후반부는 전략을 바꿔야 했다.

전략 수정: 상태 지도 그리기

무작정 옮기는 대신, 남은 Redux 상태의 의존 관계를 먼저 그렸다. 거창한 도구는 아니고, slice별로 “누가 읽는가 / 누가 바꾸는가 / 다른 slice를 참조하는가”를 표로 정리한 수준이다.

그리고 나니 두 가지가 보였다.

첫째, 얽혀 있다고 생각한 상태의 상당수는 사실 파생 상태였다. slice A의 값을 slice B에 복사해두고 동기화하는 코드들. Zustand로 옮기면서 이런 복사본은 아예 없앴다. 원본 store 하나만 두고, 파생 값은 selector로 계산한다.

// Before: Redux에서 두 slice에 같은 값이 존재
// cartSlice.totalPrice ← orderSlice에서 복사해서 동기화

// After: 원본만 두고 파생 값은 계산
const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
}));

// 파생 값은 컴포넌트에서 selector로
const totalPrice = useCartStore((s) =>
  s.items.reduce((sum, item) => sum + item.price, 0)
);

둘째, Redux에 있던 상태의 절반 가까이는 전역 상태가 아니라 서버 응답 캐시였다. API 응답을 Redux에 넣고, 로딩/에러 플래그를 관리하고, 컴포넌트에서 꺼내 쓰는 패턴. 이건 Zustand로 옮길 게 아니라 데이터 페칭 레이어로 보내는 게 맞았다. 이 부분을 정리하고 나니 “옮겨야 할 상태” 자체가 크게 줄었다. 마이그레이션의 후반부는 사실 이전 작업이라기보다 삭제 작업에 가까웠다.

thunk 로직은 store 바깥으로

Redux thunk 안에 있던 비즈니스 로직들은 Zustand action으로 그대로 옮기지 않았다. dispatch 연쇄를 set 연쇄로 바꾸는 건 구조를 유지한 채 라이브러리만 바꾸는 것이라, 이참에 로직을 일반 함수로 꺼냈다.

// Before: thunk 안에 API 호출 + 상태 갱신 + 후속 dispatch가 뭉쳐 있음

// After: 로직은 일반 함수, store는 결과만 반영
async function submitOrder(payload: OrderPayload) {
  const order = await api.createOrder(payload);
  useOrderStore.getState().setCurrentOrder(order);
  useCartStore.getState().clear();
  return order;
}

store 바깥의 일반 함수라 테스트하기도 쉽고, “이 로직이 어느 slice 소속인가” 같은 고민도 사라졌다.

마지막 PR

마지막 남은 slice를 제거하고 Redux 관련 패키지를 지우는 PR은 생각보다 작았다. diff 대부분이 삭제였다. 삭제된 줄 수가 추가된 줄 수의 열 배쯤 됐다.

제거 후 확인한 변화들:

  • 상태 관리 관련 코드 약 40% 감소 (보일러플레이트 제거 + 파생 상태 삭제 + 서버 캐시 분리의 합)
  • 번들에서 redux, react-redux, @reduxjs/toolkit, 미들웨어 의존성 제거
  • Provider 트리에서 Redux Provider 한 겹 제거

숫자보다 체감이 크다. 새 기능을 만들 때 “이 상태 어디에 둘까”라는 질문에 답이 하나가 됐다. 컴포넌트 가까이에 store를 만들고, 전역이 필요하면 전역 store에 추가한다. 끝.

1년 5개월을 돌아보며

지난 글에서 세운 원칙 네 가지 중 세 개는 끝까지 유효했다. 신규 코드는 Zustand로, 기존 코드는 급하게 안 건드림, 공존 허용. 이 세 가지 덕분에 서비스 중단이나 마이그레이션발 장애 없이 여기까지 왔다.

수정이 필요했던 건 “여유 있을 때 하나씩”이었다. 문제는 두 가지였다.

하나는 우선순위다. 마이그레이션은 당장 사용자에게 보이는 게 없는 작업이라, 기능 개발이나 장애 대응이 생기면 늘 뒤로 밀렸다. 실제로 그 기간 중 몇 달은 진척이 거의 없었다. 분기 릴리즈가 몰린 시기에는 “여유 있을 때”라는 게 아예 오지 않았다. 1년 5개월이 걸린 건 작업량 때문만이 아니라, 이 작업이 팀의 우선순위에서 차지하는 위치 때문이기도 했다. 돌이켜보면 “여유 있을 때”라는 막연한 약속 대신, 스프린트마다 마이그레이션에 쓸 시간을 명시적으로 확보해두는 편이 나았다. 후반부에는 실제로 그렇게 바꿨고, 그제서야 정체가 풀렸다.

다른 하나는 얽힌 레거시다. 독립적인 상태에는 “하나씩”이 통했지만, 서로 참조하는 상태들에는 통하지 않았다. 후반부에 의존 관계 지도를 그리고 묶음 단위로 계획을 세우지 않았다면 마이그레이션은 아직도 “진행 중”이었을 것이다. 점진적 마이그레이션의 함정이 여기 있다. 쉬운 것부터 옮기다 보면 어려운 것만 남고, 어려운 것은 “하나씩”으로는 안 풀린다.

그리고 하나 더. 마이그레이션을 하다 보면 목적지 라이브러리로 기존 구조를 그대로 복제하고 싶은 유혹이 생긴다. 그렇게 하면 빠르지만, Redux스러운 Zustand 코드가 남는다. 파생 상태를 없애고, 서버 캐시를 분리하고, thunk를 일반 함수로 꺼낸 작업들이 시간은 더 걸렸어도 결과적으로 이 마이그레이션의 진짜 성과였다. 라이브러리를 바꾸는 김에 구조를 고친 게 아니라, 구조를 고치는 데 라이브러리 교체가 따라온 쪽에 가깝다.

Redux가 나빴다는 결론은 아니다. 3년간 잘 버텨줬고, Redux DevTools의 시간 여행 디버깅이 그리울 때도 가끔 있다. 다만 지금 우리 팀 규모와 코드베이스에는 Zustand가 맞았고, 1년 5개월에 걸친 점진 전환은 그 판단을 서비스 중단 없이 실행하는 유일한 방법이었다.

다음에 또 큰 마이그레이션을 하게 된다면, 시작하기 전에 의존 관계 지도부터 그릴 것 같다. 절반은 쉽고 절반은 어렵다는 걸 알고 시작하는 것과 모르고 시작하는 것은 완전히 다르니까.