Zustand localStorage 암호화: partialize와 AES-GCM 커스텀 어댑터로 민감 데이터 보호하기
프론트엔드 개발을 하다 보면 누구나 한 번쯤 이 질문을 만나게 됩니다. "이 토큰, 그냥 localStorage에 저장해도 될까?" 저도 처음엔 별 생각 없이 accessToken을 통째로 localStorage에 때려박았는데, 코드리뷰에서 지적받고 나서야 이 문제를 진지하게 파고들었습니다. Zustand를 써본 React 개발자라면 persist 미들웨어가 얼마나 편리한지 잘 아실 텐데, 기본 설정으로 쓰면 민감한 상태까지 고스란히 평문으로 노출됩니다.
이 글은 Zustand 기본 사용 경험을 전제로 합니다. partialize로 저장 범위를 제한하고, 커스텀 스토리지 어댑터로 저장 내용을 AES-GCM으로 암호화하는 두 가지 방어 계층을 어떻게 조합하는지 코드와 함께 살펴봅니다.
이 글을 읽고 나면 기존 스토어를 수정하지 않고도 암호화 레이어를 끼워 넣는 코드를 직접 작성할 수 있게 됩니다. 이 접근법의 보안 한계가 어디까지인지도 솔직하게 짚어드릴 테니, "저장 범위"와 "저장 내용"을 왜 따로 제어하는지부터 이해하고 넘어가시면 훨씬 도움이 됩니다.
핵심 개념
persist 미들웨어의 두 가지 핵심 옵션
Zustand의 persist는 스토어 상태를 외부 스토리지(localStorage, sessionStorage, AsyncStorage 등)에 자동 저장·복원합니다. 민감 데이터를 다루기 위해 알아야 할 옵션이 두 가지인데, 이름이 조금 낯설어서 처음엔 헷갈릴 수 있습니다.
partialize 는 전체 상태 중 실제로 스토리지에 저장할 필드만 골라내는 필터 함수입니다. "이것만 저장해"라고 알려주는 역할입니다.
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
// isLoading, modalOpen 같은 UI 상태는 반환하지 않으면 저장 안 됨
})주의:
partialize는 직렬화 직전의 형태만 결정합니다. 구독(subscription) 범위와는 무관하게, 필드를 제외해도 persist 미들웨어는 여전히 모든 상태 변화에 반응합니다.
커스텀 스토리지 어댑터는 getItem, setItem, removeItem 세 메서드를 구현한 객체입니다. Zustand가 제공하는 createJSONStorage() 헬퍼에 이 어댑터를 넘기면 JSON 직렬화·역직렬화 레이어가 자동으로 감싸집니다.
const myStorage: StateStorage = {
getItem: (name) => { /* 복호화 후 반환 */ },
setItem: (name, value) => { /* 암호화 후 저장 */ },
removeItem: (name) => { /* 삭제 */ },
}두 계층을 조합하는 이유
partialize만 쓰면 저장된 값은 여전히 평문이고, 어댑터만 쓰면 불필요한 UI 상태까지 암호화 비용을 치러야 합니다. 두 옵션을 조합하면 다음과 같은 흐름이 만들어집니다.
| 계층 | 담당 옵션 | 역할 |
|---|---|---|
| 저장 범위 제한 | partialize |
불필요한 UI 상태는 스토리지에 쓰지 않음 |
| 저장 내용 보호 | 커스텀 어댑터 | 스토리지에는 AES-GCM 암호화된 문자열만 기록 |
Zustand 내부에서는 평문 상태를 그대로 유지하면서, 디스크(스토리지)에는 암호화된 값만 남게 됩니다. 스토어 코드는 암호화 사실을 전혀 몰라도 되고, 암호화 로직은 어댑터 안에 완전히 캡슐화됩니다.
실전 적용
예시 1: CryptoJS를 이용한 빠른 구현
가장 직관적인 방법입니다. crypto-js 라이브러리를 사용하면 동기 코드로 간단하게 구현할 수 있어서, 비동기 처리를 신경 쓰지 않아도 됩니다. 빠르게 적용해보고 싶을 때 출발점으로 좋습니다.
pnpm add crypto-js
pnpm add -D @types/crypto-jsimport { create } from 'zustand'
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'
import CryptoJS from 'crypto-js'
// NEXT_PUBLIC_STORAGE_KEY가 없으면 런타임에 암호화가 빈 키로 동작합니다.
// non-null assertion(!)은 빌드 타임에 환경변수가 반드시 주입되는 경우를 전제합니다.
const SECRET_KEY = process.env.NEXT_PUBLIC_STORAGE_KEY!
// 1. 암호화 어댑터
const encryptedStorage: StateStorage = {
getItem: (name) => {
const encrypted = localStorage.getItem(name)
if (!encrypted) return null
try {
const bytes = CryptoJS.AES.decrypt(encrypted, SECRET_KEY)
return bytes.toString(CryptoJS.enc.Utf8)
} catch {
return null // 손상된 데이터는 null 반환 → 초기 상태로 안전하게 복귀
}
},
setItem: (name, value) => {
const encrypted = CryptoJS.AES.encrypt(value, SECRET_KEY).toString()
localStorage.setItem(name, encrypted)
},
removeItem: (name) => localStorage.removeItem(name),
}
// 2. 스토어 정의
interface AuthState {
user: { id: string; name: string } | null
accessToken: string | null
isLoading: boolean // UI 상태
modalOpen: boolean // UI 상태
setUser: (user: AuthState['user'], token: string) => void
logout: () => void
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
isLoading: false,
modalOpen: false,
setUser: (user, accessToken) => set({ user, accessToken }),
logout: () => set({ user: null, accessToken: null }),
}),
{
name: 'auth-store',
storage: createJSONStorage(() => encryptedStorage),
// isLoading, modalOpen은 저장 대상에서 제외
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
}),
}
)
)| 코드 포인트 | 설명 |
|---|---|
try/catch in getItem |
스토리지 데이터가 손상되거나 키가 바뀐 경우 앱이 죽지 않도록 보호 |
partialize에서 setter 제외 |
함수는 JSON 직렬화 불가 — 상태 값만 반환하는 것을 권장 |
NEXT_PUBLIC_ 환경변수 |
클라이언트 번들에 포함되는 한계 존재 (아래 주의사항 참고) |
crypto-js는 번들 크기가 약 43KB에 달합니다. 외부 의존성 없이 번들 크기를 유지하고 싶거나, 모던 브라우저 환경을 타겟으로 한다면 브라우저가 기본 제공하는 Web Crypto API를 직접 활용하는 방법도 있습니다. 다만 API가 비동기라서 하이드레이션 처리가 추가로 필요합니다.
예시 2: Web Crypto API로 외부 의존성 없애기
브라우저 내장 window.crypto.subtle을 사용하면 AES-GCM 256비트 암호화를 외부 라이브러리 없이 구현할 수 있습니다. 저도 처음에 "이게 그냥 브라우저에 들어있다고?" 싶었는데, 모던 브라우저 환경이라면 충분히 실용적인 선택입니다.
import { create } from 'zustand'
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'
const SECRET_KEY = process.env.NEXT_PUBLIC_STORAGE_KEY!
const ALGORITHM = { name: 'AES-GCM', length: 256 } as const
// 매번 새로 만들면 비효율적이라 캐싱
// 주의: 키는 모듈이 로드될 때 한 번 계산됩니다.
// 환경변수가 변경되어도(핫리로드 등) 재계산이 일어나지 않습니다.
let cachedKey: CryptoKey | null = null
async function deriveKey(secret: string): Promise<CryptoKey> {
if (cachedKey) return cachedKey
const enc = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(secret), 'PBKDF2', false, ['deriveKey']
)
// 솔트를 고정 문자열로 사용하면 PBKDF2의 레인보우 테이블 방어 효과가 약해집니다.
// 고보안 환경에서는 crypto.getRandomValues()로 생성한 솔트를
// localStorage에 별도 저장하는 방식을 고려하는 것을 권장합니다.
cachedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: enc.encode('zustand-persist-salt-v1'),
iterations: 100_000,
hash: 'SHA-256',
},
keyMaterial,
ALGORITHM,
false,
['encrypt', 'decrypt']
)
return cachedKey
}
async function encrypt(text: string): Promise<string> {
const key = await deriveKey(SECRET_KEY)
const iv = crypto.getRandomValues(new Uint8Array(12)) // AES-GCM은 12바이트 IV
const encoded = new TextEncoder().encode(text)
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
// IV + 암호문을 하나의 base64 문자열로 합쳐서 저장
const combined = new Uint8Array([...iv, ...new Uint8Array(ciphertext)])
// 스프레드로 String.fromCharCode에 큰 배열을 넘기면 콜스택 초과 위험이 있어 reduce 사용
const binaryString = combined.reduce((acc, byte) => acc + String.fromCharCode(byte), '')
return btoa(binaryString)
}
async function decrypt(data: string): Promise<string> {
const key = await deriveKey(SECRET_KEY)
const combined = Uint8Array.from(atob(data), (c) => c.charCodeAt(0))
const iv = combined.slice(0, 12)
const ciphertext = combined.slice(12)
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
return new TextDecoder().decode(decrypted)
}
// 비동기 어댑터
const webCryptoStorage: StateStorage = {
getItem: async (name) => {
const val = localStorage.getItem(name)
if (!val) return null
try {
return await decrypt(val)
} catch {
return null
}
},
setItem: async (name, value) => {
localStorage.setItem(name, await encrypt(value))
},
removeItem: (name) => localStorage.removeItem(name),
}
// 하이드레이션 완료 여부 추적을 위해 인터페이스에 명시 필요
interface AuthState {
user: { id: string; name: string } | null
accessToken: string | null
_hasHydrated: boolean
setHasHydrated: (value: boolean) => void
setUser: (user: AuthState['user'], token: string) => void
logout: () => void
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
_hasHydrated: false,
setHasHydrated: (value) => set({ _hasHydrated: value }),
setUser: (user, accessToken) => set({ user, accessToken }),
logout: () => set({ user: null, accessToken: null }),
}),
{
name: 'auth-store-v2',
storage: createJSONStorage(() => webCryptoStorage),
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
}),
// onRehydrateStorage 내부 콜백의 state는 복원된 상태 스냅샷입니다.
// state?.setState(...)는 동작하지 않으며, 스토어에 정의한 액션을 호출해야 합니다.
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true)
},
}
)
)
// 컴포넌트에서 하이드레이션 대기
function AuthGuard({ children }: { children: React.ReactNode }) {
const hasHydrated = useAuthStore((s) => s._hasHydrated)
if (!hasHydrated) return <LoadingSpinner />
return <>{children}</>
}AES-GCM IV(Initialization Vector): 암호화할 때마다 무작위로 생성하는 12바이트 값으로, 같은 평문을 같은 키로 암호화해도 매번 다른 암호문이 만들어지게 합니다. IV는 비밀값이 아니라 암호문과 함께 저장해도 됩니다.
예시 3: 민감도에 따른 스토어 분리
실무에서 꽤 자주 쓰는 패턴입니다. 암호화가 필요한 민감 데이터와 그렇지 않은 설정 데이터를 별도 스토어로 관리하면 암호화 비용과 코드 복잡도를 줄일 수 있습니다.
// encryptedStorage는 예시 1에서 정의한 CryptoJS 기반 어댑터입니다.
// 민감 데이터 전용 스토어 — 암호화 어댑터 사용
const useSecureStore = create<SecureState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
setTokens: (access, refresh) => set({ accessToken: access, refreshToken: refresh }),
clearTokens: () => set({ accessToken: null, refreshToken: null }),
}),
{
name: 'secure-store',
storage: createJSONStorage(() => encryptedStorage),
partialize: (s) => ({
accessToken: s.accessToken,
refreshToken: s.refreshToken,
}),
}
)
)
// 비민감 설정 스토어 — 일반 localStorage 사용
const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'light' as const,
locale: 'ko',
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
}),
{
name: 'settings-store',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ theme: s.theme, locale: s.locale }),
}
)
)장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 관심사 분리 | 암호화 로직이 어댑터에 캡슐화되어 스토어 코드는 평문 상태만 다룸 |
| 선택적 영속화 | partialize로 UI 임시 상태(로딩, 모달 등)를 저장에서 제외해 스토리지 낭비 방지 |
| 투명한 적용 | 기존 스토어 코드 변경 없이 스토리지 레이어만 교체 가능 |
| 번들 크기 절약 | Web Crypto API 활용 시 외부 암호화 라이브러리 불필요 |
| 타입 안전성 | Zustand 5.x의 강화된 TypeScript 추론으로 partialize 반환 타입이 정확하게 검증됨 |
단점 및 주의사항
보안 관련
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 키 노출 위험 | NEXT_PUBLIC_ 환경변수는 클라이언트 번들에 포함되어 DevTools로 확인 가능 |
키를 서버에서만 관리하거나, 가능하면 HttpOnly 쿠키로 전환 고려 |
| XSS 무력화 | 공격자가 같은 브라우저 컨텍스트에 접근하면 키와 데이터를 동시에 획득 가능 | CSP 헤더(Content Security Policy) 설정과 입력값 검증으로 XSS 자체를 차단 |
| PBKDF2 고정 솔트 한계 | 모든 사용자가 동일한 솔트를 사용하면 레인보우 테이블 방어 효과가 약해짐 | 사용자별 랜덤 솔트를 crypto.getRandomValues()로 생성해 localStorage에 별도 저장 고려 |
구현 관련
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 비동기 하이드레이션 | Web Crypto API는 async — 초기 렌더에서 상태가 비어있을 수 있음 | onRehydrateStorage와 _hasHydrated 상태로 로딩 처리 |
| SSR 환경 | 서버에는 localStorage가 없어 에러 발생 |
typeof window !== 'undefined' 가드 또는 skipHydration() 사용 |
| 키 로테이션 | 암호화 키 변경 시 기존 데이터를 모두 재암호화하거나 무효화해야 함 | 스토어 name에 버전 포함(auth-store-v2)하고 키 변경 시 버전 증가 (버전이 다르면 기존 저장 키와 달라져 새 빈 스토어로 시작됨) |
localStorage 암호화의 본질: 이 패턴은 보안보다 난독화(obfuscation) 에 가깝습니다. 비밀번호, 결제 정보 같은 고위험 데이터는 절대 클라이언트 스토리지에 저장하지 않는 것을 권장합니다. HttpOnly 쿠키나 서버 세션이 올바른 선택입니다. 이 패턴은 "개발자 도구를 잠깐 열어본 사람"이나 "우연히 PC를 공유한 상황"으로부터 정보를 보호하는 수준으로 이해하는 것이 현실적입니다.
실무에서 가장 흔한 실수
partialize에 함수(setter)를 포함하는 실수 — 함수는 JSON 직렬화가 불가능합니다.partialize에서는 순수한 상태 값만 반환하는 것을 권장합니다.onRehydrateStorage콜백에서state?.setState()를 호출하는 실수 — 이 콜백의state는 스토어 인스턴스가 아니라 복원된 상태 스냅샷입니다. 스토어에setHasHydrated같은 액션을 정의하고state?.setHasHydrated(true)로 호출하는 것이 올바른 방법입니다. 잘못된 패턴을 쓰면 하이드레이션 상태가 절대true가 되지 않습니다.- 비동기 어댑터 사용 시 하이드레이션 처리를 생략하는 실수 —
getItem이 Promise를 반환하면 초기 렌더에서 상태가 비어있는 시간이 생깁니다. 이를 처리하지 않으면 로그인 상태가 잠깐 튀는 깜빡임(flash)이 발생합니다.
마치며
이 패턴을 적용하고 나면 체감 변화가 생깁니다. DevTools를 열어 Application → LocalStorage를 확인해도 암호화된 문자열만 보이고, 동료 개발자에게 "토큰 그냥 평문으로 저장했어요?"라는 코드리뷰 코멘트를 받을 일이 줄어듭니다. 핵심은 기존 스토어를 건드리지 않고도 암호화 레이어를 끼워 넣을 수 있다는 것입니다. partialize로 저장 범위를 결정하고, 커스텀 어댑터로 저장 내용을 보호하는 이 두 역할은 서로 독립적이라 필요에 따라 조합하거나 교체할 수 있습니다.
지금 바로 시도해볼 수 있는 3단계:
- 지금 당장 기존 스토어를 열어
isLoading,error같은 UI 임시 상태를partialize로 저장 대상에서 제외해볼 수 있습니다. 코드 한 줄로 스토리지 낭비를 줄이고 상태 구조를 명확하게 정리할 수 있습니다. - 다음 PR에 바로 적용할 수 있는 CryptoJS 기반 암호화 어댑터를 구현해볼 수 있습니다.
pnpm add crypto-js후 예시 1 코드를 복사해SECRET_KEY만 환경변수로 연결하면 바로 동작하는 암호화 스토어를 만들어볼 수 있습니다. - 번들 크기가 걱정된다면 Web Crypto API 버전으로 전환해볼 수 있습니다. 예시 2의
deriveKey+encrypt/decrypt함수를 별도 유틸 파일로 분리하고,onRehydrateStorage로 하이드레이션 처리를 추가하면 외부 의존성 없이 동일한 수준의 보호를 구성할 수 있습니다.
참고 자료
- Persisting Store Data | Zustand 공식 문서
- persist 미들웨어 레퍼런스 | Zustand 공식 문서
- How to Securely Persist User Data in Zustand with Encrypted Storage | Wiscaksono
- Encrypted localStorage with Zustand | DEV Community
- Are you exposing your localStorage data? | Hashnode
- Securing Web Storage: Best Practices | DEV Community
- zustand-mmkv-storage: Blazing Fast Persistence for React Native | DEV Community
- DeepWiki — persist Middleware 내부 구조