개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
frontend

Zustand persist migrate: persistedState unknown 타입을 TypeScript로 안전하게 좁히는 4가지 방법

Zustand로 상태를 localStorage에 영속화하다 보면, 어느 순간 스키마를 바꿔야 하는 상황이 옵니다. 필드 이름을 바꾸거나, 새 필드를 추가하거나, 중첩 구조를 평탄화하거나. 그럴 때 persist의 migrate 옵션을 꺼내 드는데, 처음 마주치면 꽤 당혹스러운 타입이 하나 있습니다. 바로 persistedState: unknown.

저도 처음엔 "왜 MyState로 추론이 안 되지?" 하며 타입 에러를 as any로 틀어막고 넘어간 적이 있었는데요. 두 달 뒤, 사용자 스토리지에 저장된 username 필드가 조용히 undefined로 덮어써지는 버그가 발생했고, 원인을 추적하는 데 반나절을 썼습니다. 이 글에서는 unknown 타입이 의도된 설계 결정인 이유와, TypeScript strict 환경에서 안전하게 처리하는 네 가지 패턴을 단계적으로 살펴봅니다.

알고 나면 별게 아닌데, 모르면 매번 as any를 쓰게 되는 그 미묘한 지점을 짚어보겠습니다.


핵심 개념

persistedState가 unknown인 이유

Zustand의 persist 미들웨어는 스토어 상태를 localStorage나 sessionStorage 같은 외부 스토리지에 직렬화해서 저장하고, 앱이 다시 로드될 때 읽어옵니다. 여기서 핵심은 "읽어올 때"입니다.

외부 스토리지는 브라우저가 관리하는 영역입니다. 사용자가 개발자 도구로 값을 직접 수정했을 수도 있고, 이전 버전의 앱이 전혀 다른 스키마로 저장해둔 값일 수도 있습니다. TypeScript가 컴파일 타임에 보장해줄 수 있는 영역이 아닙니다.

typescript
// PersistOptions의 migrate 함수 시그니처
migrate?: (persistedState: unknown, version: number) => S | Promise<S>

그래서 Zustand는 persistedState의 타입을 unknown으로 선언합니다. "우리는 이 값의 실제 형태를 모른다, 개발자가 직접 검증하세요"라는 의미입니다.

unknown vs any: any는 타입 검사를 완전히 포기하는 것이고, unknown은 "타입을 모르지만 사용하기 전에 반드시 좁혀야 한다"는 TypeScript의 안전 장치입니다. unknown 타입의 값은 타입 내로잉(type narrowing) 없이는 프로퍼티에 접근조차 할 수 없습니다.

PersistOptions의 제네릭 구조와 version 파라미터

persist 미들웨어의 타입 파라미터를 살펴보면 설계 의도가 더 명확하게 보입니다.

typescript
// S: 전체 스토어 상태 타입
// PersistedState: 실제 저장되는 상태 타입 (partialize 사용 시 S와 다를 수 있음)
type PersistOptions<S, PersistedState = S>

partialize 옵션으로 일부 상태만 저장한다면 PersistedState는 S의 부분 집합이 됩니다. 예를 들어 민감한 데이터를 제외하고 토큰만 저장하는 경우가 있는데, 이 상황이 좀 불편합니다.

typescript
persist(
  (set) => ({ token: '', username: '', count: 0 }),
  {
    partialize: (state): Pick<MyState, 'token'> => ({ token: state.token }),
    migrate: (persistedState, version) => {
      // 실제 저장된 형태가 { token: string }임을 우리는 알지만,
      // 타입은 여전히 unknown — 개발자가 직접 좁혀야 합니다
    }
  }
)

그리고 migrate의 두 번째 인자인 version은 Zustand가 상태를 저장할 때 함께 직렬화하는 __zustandVersion 필드에서 읽어옵니다. 스토리지에 저장된 버전 번호를 꺼내 마이그레이션 함수에 전달하는 구조이기 때문에, 버전 비교 로직의 흐름을 이해하는 데 도움이 됩니다. merge 함수도 동일한 이유로 (persistedState: unknown, currentState: S) => S 시그니처를 가집니다.


실전 적용

예시들은 모두 { count: number; name: string } → { count: number; username: string; createdAt: number } 마이그레이션 시나리오를 기준으로 합니다.

예시 1: as 캐스팅 — 빠르지만 위험한 방법

가장 직관적인 방법입니다. 솔직히 말하면, 간단한 내부 도구나 프로토타입에서는 이걸로 충분할 때도 있습니다. 다만 프로덕션 코드에는 권장하지 않습니다.

typescript
type StateV0 = { count: number; name: string };
type CurrentState = { count: number; username: string; createdAt: number };
 
migrate: (persistedState, version) => {
  if (version === 0) {
    const v0 = persistedState as StateV0;
    return {
      count: v0.count,
      username: v0.name,
      createdAt: Date.now(),
    };
  }
  return persistedState as CurrentState;
}

TypeScript에게 "이 값은 StateV0이야"라고 단언하는 것인데, 런타임에서 실제 값이 그렇지 않더라도 컴파일러는 아무 말도 하지 않습니다. 스토리지 값이 깨진 경우 조용히 잘못된 값이 흘러들어가는 것이 이 패턴의 핵심 위험입니다.

항목 내용
적합한 상황 프로토타입, 내부 도구, 빠른 검증이 필요한 초기 개발
위험 스토리지 값이 실제로 예상한 형태가 아닐 경우 런타임에 조용히 실패
타입 안전성 낮음 — TypeScript가 검증을 포기한 상태

예시 2: Assertion Function — 함수 호출 이후 타입 흐름을 바꾸는 방법

타입 어서션 함수(assertion function)는 TypeScript 3.7에서 도입된 패턴입니다. asserts val is T 형태로 선언하면, 그 함수가 호출된 이후 시점부터 TypeScript가 해당 변수를 T 타입으로 추론합니다.

as 캐스팅이 "그 줄에서 즉시 타입을 바꾸는" 것과 달리, assertion function은 "함수 호출 이후의 제어 흐름 전체에 타입 변경을 전파"합니다. 함수 내부에서 검증 실패 시 예외를 던지기 때문에, 컴파일러도 "이 함수가 리턴된 이후에는 해당 타입임이 보장된다"고 판단합니다.

typescript
// asserts val is Partial<StateV0>라고 선언하지만,
// 실제 런타임 체크는 "객체인지 아닌지"만 확인합니다
function assertMyState(val: unknown): asserts val is Partial<StateV0> {
  if (typeof val !== 'object' || val === null) {
    throw new Error('Invalid persisted state: not an object');
  }
}
 
migrate: (persistedState, version) => {
  assertMyState(persistedState);
  // 이 줄 이후로 TypeScript는 persistedState를 Partial<StateV0>으로 추론
  // 단, 실제 필드 타입까지는 보장되지 않으므로 옵셔널 체이닝과 기본값 사용 권장
 
  if (version === 0) {
    return {
      count: persistedState.count ?? 0,
      username: persistedState.name ?? 'guest',
      createdAt: Date.now(),
    };
  }
 
  // 현재 버전 — 런타임 검증 없는 단언이 불안하다면 예시 4(Zod)로 전환을 권장합니다
  return persistedState as unknown as CurrentState;
}

한 가지 짚고 넘어가면, 이 assertion function은 "객체인지 아닌지"만 런타임에서 확인합니다. name 필드가 실제로 string인지, count가 number인지는 검증하지 않습니다. as 캐스팅과 비교하면 "완전히 다른 수준의 안전성"이라기보다 "최소한의 런타임 가드와 타입 전파가 추가된" 패턴으로 이해하는 것이 정확합니다. 그래서 Partial<> 래핑과 ?? 기본값을 함께 쓰는 것이 실질적인 보호에 훨씬 중요합니다.

항목 내용
적합한 상황 구조가 단순하고 버전 변경이 잦지 않은 프로젝트
런타임 보호 수준 "null·비객체 제거" 수준 — 개별 필드 타입은 미검증
타입 안전성 as 캐스팅보다 높지만 Zod보다 낮음

예시 3: Record<string, unknown> 중간 캐스팅 — 버전 체인 문서화 패턴

마이그레이션 체인이 v0 → v1 → v2로 이어질 때, 각 버전의 타입을 별도로 선언해두면 코드 가독성이 크게 올라갑니다. 처음 이 패턴을 보고 "이렇게까지 해야 해?" 싶었는데, 마이그레이션 체인이 세 단계 이상 쌓인 코드베이스에서 이 타입 선언들이 얼마나 고마운지 알게 됩니다.

typescript
type StateV0 = { count: number; name: string };
type StateV1 = { count: number; username: string };
type CurrentState = { count: number; username: string; createdAt: number };
 
migrate: (persistedState, version) => {
  // Record<string, unknown>으로 한 번 좁힌 뒤 필드별 타입 가드 적용
  const state = persistedState as Record<string, unknown>;
 
  if (version === 0) {
    return {
      count: typeof state.count === 'number' ? state.count : 0,
      username: typeof state.name === 'string' ? state.name : 'guest',
      createdAt: Date.now(),
    };
  }
 
  if (version === 1) {
    const v1 = persistedState as StateV1; // 의도 문서화용 캐스팅
    return { ...v1, createdAt: Date.now() };
  }
 
  return persistedState as CurrentState;
}

이 패턴의 이점은 "타입 안전성"보다 **"마이그레이션 의도의 문서화"**에 가깝습니다. StateV0, StateV1처럼 버전 타입을 파일 상단에 선언해두면 "v0 스키마가 어떻게 생겼었는지"를 한눈에 파악할 수 있습니다. as StateV1, as CurrentState 캐스팅이 런타임 검증 없는 단언이라는 점은 예시 1과 동일합니다. Record<string, unknown> 중간 캐스팅과 필드별 typeof 가드를 결합하는 부분에서만 실질적인 런타임 보호가 생깁니다.

항목 내용
적합한 상황 마이그레이션 체인이 2단계 이상, Zod 미도입 프로젝트
이점 버전별 스키마 문서화, 코드 의도 명확화
타입 안전성 필드 가드 적용 부분은 높음, as 단언 부분은 낮음

예시 4: Zod safeParse — 런타임 안전성이 가장 높은 방법

Zod를 이미 프로젝트에 쓰고 있다면 migrate 함수와 조합하는 것이 가장 강력한 선택입니다. 타입 내로잉과 런타임 검증을 한 번에, 그리고 as 없이 처리할 수 있기 때문입니다.

typescript
import { z } from 'zod';
 
const V0Schema = z.object({
  count: z.number(),
  name: z.string(),
});
 
const V1Schema = z.object({
  count: z.number(),
  username: z.string(),
  createdAt: z.number(),
});
 
migrate: (persistedState, version) => {
  if (version === 0) {
    const parsed = V0Schema.safeParse(persistedState);
    // 파싱 실패 시 안전한 기본값으로 초기화
    if (!parsed.success) {
      return { count: 0, username: 'guest', createdAt: Date.now() };
    }
    return {
      count: parsed.data.count,
      username: parsed.data.name,
      createdAt: Date.now(),
    };
  }
  // 현재 버전 — 검증 실패 시 예외를 던지는 parse 사용
  return V1Schema.parse(persistedState);
}

safeParse는 검증 실패 시 예외를 던지지 않고 { success: false, error } 형태로 반환하기 때문에, 스토리지 데이터가 깨진 경우에도 앱이 완전히 죽지 않고 기본값으로 복구됩니다. 반면 현재 버전에는 parse를 써서 스키마 불일치 시 명시적으로 예외를 던지도록 했습니다. "과거 버전은 최대한 복구하되, 현재 버전은 엄격하게"라는 의도가 담긴 분기입니다. 런타임에서 스토리지 데이터가 깨졌을 때 앱이 조용히 기본값으로 복구되는 경험은 꽤 든든합니다.

항목 내용
적합한 상황 Zod를 이미 사용하는 프로젝트, 런타임 보호가 중요한 프로덕션 환경
런타임 보호 수준 모든 필드의 타입까지 검증
타입 안전성 가장 높음 — as 단언 없이 처리 가능

장단점 분석

실무에서 가장 자주 목격하는 건 패턴 선택 자체보다 "어떤 상황에 무엇을 써야 하는지" 기준을 모르는 경우입니다. 아래 표로 정리해봤습니다.

패턴별 선택 기준

패턴 런타임 보호 도입 비용 이런 상황에
as 캐스팅 없음 없음 프로토타입, 내부 도구
Assertion Function 객체 여부만 낮음 단순 구조, Zod 미사용 프로젝트
Record<string, unknown> + 버전 타입 필드 가드 적용 시 낮음 마이그레이션 체인 문서화
Zod safeParse 모든 필드 중간 (Zod 설치) 프로덕션, Zod 사용 프로젝트

타입 안전성 수준: Zod safeParse > Assertion Function > Record<string, unknown> 캐스팅 > as 단언 순으로 높습니다. 프로젝트 규모와 마이그레이션 복잡도에 따라 적합한 수준을 선택하는 것이 현실적입니다.

단점 및 주의사항

항목 내용 대응 방안
as 남용 가능성 as any나 무분별한 as MyState로 타입 안전성을 무력화할 수 있습니다 Assertion Function 또는 Zod 패턴 활용
partialize와 조합 시 불편함 저장된 값이 Pick<MyState, 'token'>임을 알고 있어도 unknown으로 처리해야 합니다 별도 타입 변수로 명시적 선언 권장
복잡한 마이그레이션 체인 v0→v1→v2→v3 체인에서 버전별 타입 미정의 시 타입 안전성 유지가 어렵습니다 StateV0, StateV1 명시 패턴 사용
비동기 마이그레이션 Promise<S> 반환 시 타입 추론 복잡도가 올라갑니다 반환 타입을 명시적으로 annotate

실무에서 가장 흔한 실수

  1. persistedState as MyState로 바로 캐스팅한 뒤, 필드가 undefined인 경우에 대한 처리를 빠뜨려 런타임 오류가 발생합니다. Partial<MyState>로 캐스팅하고 ?? 기본값을 활용하는 것이 더 안전합니다.

  2. version 비교를 ===가 아닌 <=나 >=로 처리하다가 마이그레이션이 중복 실행되거나 누락됩니다. 각 버전을 ===로 정확히 매칭하는 것을 권장합니다.

  3. Zustand v5에서는 스토어 초기 상태가 자동으로 persist되던 동작이 제거되었습니다. v4에서 v5로 올릴 때 이 변경을 인지하지 못해 마이그레이션 코드가 기대와 다르게 동작하는 경우가 있습니다.


마치며

persistedState: unknown은 버그가 아니라, 외부 스토리지 데이터를 신뢰하지 말라는 Zustand의 명시적인 설계 메시지입니다.

지금 프로젝트에서 migrate를 쓰고 있다면 아래 순서로 점검해보시면 좋습니다.

  1. 현재 코드에서 as any 또는 무조건적인 as MyState를 찾아, 최소한 typeof val === 'object' && val !== null 체크를 추가해볼 수 있습니다.
  2. 마이그레이션이 두 개 이상 쌓여 있다면 type StateV0, type StateV1 형태로 버전별 타입을 파일 상단에 선언해두는 것을 권장합니다. 6개월 뒤의 동료(혹은 자신)에게 큰 도움이 됩니다.
  3. Zod를 이미 쓰는 프로젝트라면 각 버전의 스키마를 z.object()로 정의하고 safeParse와 결합하는 패턴으로 전환해볼 수 있습니다.

참고 자료

  • Persisting store data | Zustand 공식 문서
  • persist Middleware Reference | Zustand 공식 문서
  • Return type of migrate in zustand persist · Discussion #2357 | GitHub
  • (middleware/persist): Persisted State Type · Discussion #1329 | GitHub
  • TypeScript type conflict with persist middleware · Issue #650 | GitHub
  • Persist + TypeScript + Slices type errors · Discussion #2164 | GitHub
  • Zod with zustand · Discussion #1722 | GitHub
  • zustand/docs/migrations/migrating-to-v5.md | GitHub
  • NextJS + Zustand + TypeScript type conflict with persist middleware | Medium
  • GitHub - SebastianJarsve/zod-persist
#Zustand#TypeScript#타입내로잉#persist미들웨어#Zod#타입안전성#localStorage#AssertionFunction#스키마마이그레이션#상태관리
공유하기

목차

핵심 개념persistedStatePersistOptions실전 적용예시 1:예시 2: Assertion Function — 함수 호출 이후 타입 흐름을 바꾸는 방법예시 3:예시 4: Zod장단점 분석패턴별 선택 기준단점 및 주의사항실무에서 가장 흔한 실수마치며참고 자료

추천 포스트

Next.js Turbopack 빌드 캐시로 `next build`를 10배 빠르게 — 실험적 플래그의 함정과 CI 연동 전략
frontend

Next.js Turbopack 빌드 캐시로 `next build`를 10배 빠르게 — 실험적 플래그의 함정과 CI 연동 전략

CI에서 가 3분을 넘어가기 시작하면 슬슬 스트레스가 쌓입니다. PR 하나 올릴 때마다 배포 파이프라인 앞에서 커피 한 잔을 다 마셔야 하는 그 시간. 저도 e-커머스 프로젝트에서 webpack 빌드가 180초를 넘기면서 "이걸 어떻게 줄여보나" 하고 한참 고민했습니다. 로컬에서 스테이...

2026년 05월 30일읽는 데 16분
Next.js RSC + Streaming으로 TTFB를 350ms에서 60ms로 단축하는 방법
frontend

Next.js RSC + Streaming으로 TTFB를 350ms에서 60ms로 단축하는 방법

운영 중인 서비스의 Core Web Vitals를 들여다보다가 TTFB가 400ms를 훌쩍 넘는 페이지를 발견한 적이 있습니다. DB 쿼리는 각각 빠른데 왜 이렇게 느리지 싶어서 살펴봤더니, 세 개의 독립적인 쿼리가 직렬로 대기하고 있었습니다. 로 병렬화하면 해결되겠구나 싶었는데, 막상...

2026년 06월 07일읽는 데 19분
HTMX 4.0 서버 렌더링 패턴: 클라이언트 상태 없이 인터랙티브 웹을 만드는 아키텍처 선택
frontend

HTMX 4.0 서버 렌더링 패턴: 클라이언트 상태 없이 인터랙티브 웹을 만드는 아키텍처 선택

React를 처음 배울 때, 저는 솔직히 이 질문을 머릿속에 묻어뒀습니다. "버튼 클릭 하나에 이렇게 많은 코드가 필요한 게 맞는 건가?" 상태 관리, 빌드 도구, hydration 오류... 분명 웹은 HTML과 폼으로 동작하던 시절이 있었는데, 어느 순간 그 단순함을 잃어버렸다는 느...

2026년 06월 07일읽는 데 22분
TanStack Query + Zustand: 서버 상태와 클라이언트 상태를 분리하는 패턴과 안티패턴
frontend

TanStack Query + Zustand: 서버 상태와 클라이언트 상태를 분리하는 패턴과 안티패턴

Redux를 쓰던 시절을 돌아보면, API 응답값과 모달 open/close 상태가 한 스토어 안에 뒤섞여 있었던 기억이 납니다. "이거 어디서 바뀐 거지?"라며 디버거를 붙잡고 씨름했던 날들이 꽤 많았죠. 그러다 TanStack Query(구 React Query)를 도입하면서 뭔가 ...

2026년 05월 24일읽는 데 19분
`@starting-style`로 팝오버(popover)·다이얼로그(dialog)에 순수 CSS 진입·퇴장 애니메이션 추가하기
frontend

`@starting-style`로 팝오버(popover)·다이얼로그(dialog)에 순수 CSS 진입·퇴장 애니메이션 추가하기

팝오버가 열릴 때마다 툭 튀어나오는 그 느낌, 한 번쯤은 거슬렸을 거예요. 닫힐 땐 또 그냥 사라지고. 부드럽게 만들어보려고 JS 코드를 열면 어김없이 이렇게 됩니다. 딱 이 패턴입니다. 클래스 토글하고, 이벤트 등록하고, 타이밍 맞추다가 엣지 케이스 터지고, 결국 타이머...

2026년 05월 22일읽는 데 17분
CSS Anchor Positioning + Popover API로 JS 없이 접근성 갖춘 드롭다운·툴팁 만들기
frontend

CSS Anchor Positioning + Popover API로 JS 없이 접근성 갖춘 드롭다운·툴팁 만들기

툴팁 하나 만드는 데 계산하고 scroll 이벤트 리스너 붙이고, 결국 Popper.js 번들 통째로 끌어다 쓴 경험, 한 번쯤 있으실 거라 생각합니다. 저도 몇 년 전까지는 그랬거든요. 드롭다운이나 컨텍스트 메뉴를 제대로 만들려면 JavaScript 없이는 불가능하다고 당연하게 생각...

2026년 05월 22일읽는 데 22분