Next.jsReactTypeScriptRefactoringZustand

Next.js × Zustand를 이용한 상태 관리 리팩터링

Sloth255
Sloth255
·1 min read·94 words

배경 및 과제

이 앱은 Next.js 15 (App Router) + React 19를 사용한 정적 내보내기 구성입니다. 네트워크 정보(IPv4/IPv6 등)와 디바이스 정보(UA 등)를 취득, 표시, 공유하는 용도입니다. 현지화되어 있어 각 언어별 페이지가 있습니다.

구현 전 과제:

  • 취득 로직이 컴포넌트에 분산되어 중복됨(사이드 이펙트 추적이 어려움)
  • 같은 세션 내라도 언어 전환마다 반복 fetch 발생(느리게 느껴짐)
  • 공유 버튼 등 횡단 기능에서 상태에 일관되게 접근할 필요 있음

이를 해결하기 위해 Zustand를 도입하고 스토어 주도 상태 관리(초기화, 퍼시스턴스, TTL, 재시도)로 리팩터링했습니다.

Zustand: https://zustand-demo.pmnd.rs/

목표

  • "데이터 스토어 측"에 사이드 이펙트 집약(fetch, 저장, refetch 판단)
  • 컴포넌트 렌더링을 "읽기 전용"으로 만들어 단순화
  • 세션 퍼시스턴스로 체감 성능 향상(같은 세션 내 refetch 방지)
  • TTL과 재시도로 신뢰성 향상, UI에서 수동 업데이트 가능

채택한 설계

1) 슬라이스 패턴 + Bounded Store

  • 슬라이스:
    • NetworkSlice: networkInfo, networkLoading, networkError, networkFetchedAt, initializeNetworkInfo
    • DeviceSlice: deviceInfo, deviceReady, initializeDeviceInfo
  • types: types.ts에 NetworkInfo/DeviceInfo 타입과 Slice/Store 타입 정의
  • store:
    • createStore로 슬라이스를 조합한 하나의 Bounded Store 생성
    • persist(createJSONStorage(() => sessionStorage))로 세션 퍼시스턴스

읽기 방법:

  • 전체 상태: const { networkInfo, deviceInfo } = useAppStore();
  • 셀렉터: const info = useAppStore(s => s.networkInfo);

2) 스토어 측 초기화

  • 네트워크
    • initializeNetworkInfo(opts?: { force?: boolean })
    • loading/error 플래그 관리
    • 성공 시 networkFetchedAt = Date.now() 저장(TTL 판단용)
  • 디바이스
    • initializeDeviceInfo()로 navigator/window에서 수집
    • deviceReady로 표시 준비 상태 관리(기존처럼 부드럽게 표시)

3) 안전장치가 있는 재수화 후 자동 시작

  • onRehydrateStorage에서 복원된 상태에 따라 조건부로 초기화 트리거
    • networkInfo가 없으면 initializeNetworkInfo() 실행
    • deviceInfo가 있으면 즉시 deviceReady = true 설정, 없으면 초기화
  • 마이크로태스크 안전장치
    • 재수화 타이밍 차이로 발동하지 않는 경우를 대비해, 클라이언트 사이드에서 마이크로태스크로 초기화 확인을 한 번 수행

구체적인 구현

스토어 타입

src/store/types.ts
export interface NetworkSlice {
  networkInfo: NetworkInfo | null;
  networkLoading: boolean;
  networkError: string | null;
  networkFetchedAt?: number | null; // TTL용
  setNetworkInfo: (info: NetworkInfo | null) => void;
  initializeNetworkInfo: (opts?: { force?: boolean }) => Promise<void>;
}

export interface DeviceSlice {
  deviceInfo: DeviceInfo | null;
  deviceReady: boolean;
  setDeviceInfo: (info: DeviceInfo | null) => void;
  initializeDeviceInfo: () => void;
}

Bounded Store와 persist

src/store/useAppStore.ts
export const appStore = createStore<AppStore>()(
  persist(
    (set, get, api) => ({
      ...createNetworkSlice(set, get, api),
      ...createDeviceSlice(set, get, api),
    }),
    {
      name: 'own-info-app-storage',
      storage: createJSONStorage(() => sessionStorage),
      partialize: (state) => ({
        networkInfo: state.networkInfo,
        networkFetchedAt: state.networkFetchedAt ?? null,
        deviceInfo: state.deviceInfo,
      }),
      onRehydrateStorage: () => (rehydrated) => {
        if (typeof window === 'undefined') return;
        const s = appStore.getState();
        if (!rehydrated?.networkInfo) s.initializeNetworkInfo().catch(() => {});
        if (rehydrated?.deviceInfo) appStore.setState({ deviceReady: true });
        else s.initializeDeviceInfo();
      },
    }
  )
);

// 마이크로태스크 보험
if (typeof window !== 'undefined') {
  queueMicrotask(() => {
    const s = appStore.getState();
    if (!s.networkInfo && !s.networkLoading) s.initializeNetworkInfo().catch(() => {});
    if (s.deviceInfo && !s.deviceReady) appStore.setState({ deviceReady: true });
    else if (!s.deviceInfo && !s.deviceReady) s.initializeDeviceInfo();
  });
}

TTL과 재시도

src/app/config.ts
export const networkConfig = {
  ttlMinutes: 60,
  maxRetries: 1,
  retryDelayMs: 500,
} as const;
src/store/networkSlice.ts
import { networkConfig } from '@/app/config';
const DEFAULT_TTL_MS = Math.max(0, networkConfig.ttlMinutes) * 60 * 1000;
const MAX_RETRIES = Math.max(0, networkConfig.maxRetries);
const RETRY_DELAY_MS = Math.max(0, networkConfig.retryDelayMs);

initializeNetworkInfo: async ({ force } = {}) => {
  const { networkInfo, networkLoading, networkFetchedAt } = get();
  if (networkLoading) return;
  const fresh = networkInfo && networkFetchedAt && Date.now() - networkFetchedAt < DEFAULT_TTL_MS;
  if (!force && fresh) return;

  set({ networkLoading: true, networkError: null });
  try {
    const attemptFetch = async () => {
      const token = process.env.TOKEN;
      const [v4Res, v6Res] = await Promise.all([
        fetch(`https://<api>/v4?token=${token}`),
        fetch(`https://<api>/v6?token=${token}`),
      ]);
      const v4Json = v4Res.ok ? await v4Res.json() : '';
      const v6Json = v6Res.ok ? await v6Res.json() : '';
      return {
        ipv4: v4Json?.ip,
        ipv6: v6Json?.ip
      };
    };

    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
      try {
        const data = await attemptFetch();
        set({ networkInfo: data, networkFetchedAt: Date.now(), networkLoading: false, networkError: null });
        return;
      } catch {
        if (attempt < MAX_RETRIES) {
          await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
          continue;
        }
      }
    }
    set({ networkLoading: false, networkError: 'network_fetch_failed' });
  } catch {
    set({ networkLoading: false, networkError: 'network_fetch_failed' });
  }
}

컴포넌트 단순화와 UI 개선

  • 사이드 이펙트(useEffect/useLayoutEffect)를 제거하고 스토어 플래그(loading/error/ready)로 표시 제어
  • 수동 업데이트를 위한 "새로고침" 버튼 추가(TTL을 무시하고 refetch)
src/components/NetworkInfoClient.tsx
const { networkInfo: info, networkLoading, networkError, initializeNetworkInfo } = useAppStore();

<button
  type="button"
  onClick={() => initializeNetworkInfo({ force: true })}
  disabled={networkLoading}
  className={...}
  title={networkLoading ? L('network.refreshing','Refreshing...') : L('network.refresh','Refresh')}
>
  {networkLoading ? <Spinner/> : <span>{L('network.refresh','Refresh')}</span>}
</button>

겪은 함정과 해결책

  • "로딩 상태에서 멈춤"
    • 원인: 재수화 타이밍 차이로 플래그가 업데이트되지 않음
    • 해결책: onRehydrateStorage에서 ready를 명시적으로 설정 + 마이크로태스크 안전장치
  • 퍼시스턴스 대상
    • 일시적 플래그(loading/error/ready)를 저장하지 않도록 partialize 도입

결과

  • 체감 성능 향상
    • 세션 내 persist에서 즉시 복원 → fetch 건너뜀(TTL 내)
    • 중복 사이드 이펙트 제거, 불필요한 리렌더링 감소
  • 신뢰성 향상
    • 재수화 직후 안전한 초기화(onRehydrateStorage + 마이크로태스크)
    • 실패 시 재시도 및 UI에서 수동 업데이트
  • 가독성 및 유지보수성
    • 취득 로직 집약(스토어에 통합)
    • 슬라이스별 책임 분리(network/device)
    • Bounded Store로 명확한 API

정리

슬라이스 패턴 + Bounded Store로 Zustand를 도입하고, "스토어에서 초기화", "UI는 읽기만"의 역할 분리를 채택함으로써 표시 속도, 신뢰성, 유지보수성을 향상시켰습니다.
이전에 React × Redux로 상태 관리 앱을 만든 적이 있지만, Zustand가 더 구현하기 쉽고 코드도 단순해 좋은 인상을 받았습니다.