배경 및 과제
이 앱은 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설정, 없으면 초기화
- networkInfo가 없으면
- 마이크로태스크 안전장치
- 재수화 타이밍 차이로 발동하지 않는 경우를 대비해, 클라이언트 사이드에서 마이크로태스크로 초기화 확인을 한 번 수행
구체적인 구현
스토어 타입
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가 더 구현하기 쉽고 코드도 단순해 좋은 인상을 받았습니다.
