๐งฉ 10. atomWithStorage โ ์๋ก๊ณ ์นจํด๋ ์ด์์๋ ์ํ
๐ ๊ฐ์
atomWithStorage ๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ, getOnInit ์ต์ , RESET ์ฌ๋ณผ, Zod validation, SSR hydration mismatch ํด๊ฒฐ์ฑ ์ ๋ฐฐ์๋๋ค.
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
atomWithStorage๋ก localStorage ์ ์ํ๋ฅผ ์๋ ๋๊ธฐํํ ์ ์๋ค.getOnInit,RESET,createJSONStorage, Zod validation ๋ฑ ๊ณ ๊ธ ์ต์ ์ ์ค์ ์์ ์ธ ์ ์๋ค.- SSR(Next.js)์์ ๋ฐ์ํ๋ hydration mismatch ๋ฅผ ์ฌ๋ฐ๋ฅด๊ฒ ํด๊ฒฐํ ์ ์๋ค.
๐ ๋ชฉ์ฐจ
- ๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
- ๐ค ์ atomWithStorage ๊ฐ ํ์ํ๊ฐ?
- ๐ง atomWithStorage ๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ
- โ๏ธ getOnInit ์ต์ โ ์ด๊ธฐ๊ฐ vs ์ ์ฅ๊ฐ ๋๋ ๋ง
- ๐๏ธ RESET ์ฌ๋ณผ โ localStorage ํค๋ฅผ ๊น๋ํ๊ฒ ์ญ์
- ๐ก๏ธ Zod Validation Storage โ ์ ํจํ์ง ์์ ์ ์ฅ๊ฐ ๋ฐฉ์ด
- ๐ง createJSONStorage โ ์ปค์คํ storage ๋ง๋ค๊ธฐ
- โ ๏ธ SSR Hydration Mismatch โ Next.js ์์์ ํจ์
- ๐ฅ ์๋ฌ ํด๊ฒฐ ์นดํ๋ก๊ทธ
- ๐ ์ด๋ฒ์ ๋ฐฐ์ด ๋ด์ฉ ์ด์ ๋ฆฌ
- ๐ ๋ง๋ฌด๋ฆฌ ํด์ฆ
- ๐ฃ ์์ฒ ์ด์ ํด๊ทผ ์ผ๊ธฐ
- ๐ ๋ ์์๋ณด๊ธฐ
๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
โฑ๏ธ ์์ ์ฝ๊ธฐ ์๊ฐ: 25๋ถ (์ ์ฒด) / ํต์ฌ ํํธ๋ง: 15๋ถ
๐บ๏ธ ์ด ๋ฌธ์์ ๋ฐฐ๊ฒฝ ์ธ๊ณ๊ด: ์์ฒ ์ด์ ๋คํฌ๋ชจ๋ ๋ฒ๊ทธ
์์๋ค ์ฑ์ ๋๋์ด ๋คํฌ๋ชจ๋ ๊ธฐ๋ฅ์ด ๋ค์ด๊ฐ์ด. ๊ทผ๋ฐ ๋ฐฐํฌ ๋ค์ ๋ ์์นจ๋ถํฐ ์ฌ๋์ด ์ธ๋ ธ์ด:
- ๐จ ์์ ๋ (๋์์ด๋): "์์ฒ ๋, ๋คํฌ๋ชจ๋ ์ผ๋๊ณ ์๋ก๊ณ ์นจํ๋ฉด ๋ค์ ๋ผ์ดํธ๋ชจ๋๋ก ๋์์์. ์ด๊ฑฐ ์๋ ์ด๋์?"
- ๐ฃ ์์ฒ : "์ด... ์ ์ฅ์ด ์ ๋๋ ๊ฑด๊ฐ์? ์ ๊น๋ง์."
- ๐ฃ ์์ฒ : (์ฝ๋ ํ์ธ) "์
isDarkModeAtom = atom(false)๋ก ์ ์ธํ๋๋ ์๋ก๊ณ ์นจํ๋ฉด ํญ์false๋ก ์ด๊ธฐํ๋๋ ๊ฑฐ๋ค์..." - ๐ฆ ์ํธ ๋: "์๋ก๊ณ ์นจํด๋ ์ํ๊ฐ ์ ์ง๋์ด์ผ ํ๋ฉด
atomWithStorage์ฐ๋ฉด ๋ผ์. localStorage ์ ์๋์ผ๋ก ๋๊ธฐํํด์ค์." - ๐ฃ ์์ฒ : "์ค! ๊ทธ๋ฐ ๊ฒ ์์ด์?"
- ๐ฆ ์ํธ ๋: "๊ทธ๋ฐ๋ฐ Next.js ์์ ์ธ ๋ SSR hydration mismatch ์ฃผ์์ฌํญ์ด ์์ด์. ๊ฐ์ด ๋ณผ๊ฒ์."
๐ค ์ atomWithStorage ๊ฐ ํ์ํ๊ฐ? ๐ข
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
- ์ ๋จ์ atom ์ผ๋ก๋ ์๋ก๊ณ ์นจ ํ ์ํ ์ ์ง๊ฐ ์ ๋๋์ง ์ดํดํ๋ค
atomWithStorage๊ฐ localStorage ์ atom ์ ์ด๋ป๊ฒ ์ฐ๊ฒฐํ๋์ง ๊ฐ๋ ์ ์ผ๋ก ์ดํดํ๋ค
// ๐ฃ ์์ฒ ์ด์ ์ฒ์ ์ฝ๋
const isDarkModeAtom = atom(false)
// โ ์๋ก๊ณ ์นจํ๋ฉด ํญ์ false ๋ก ์ด๊ธฐํ
// ์์ฒ ์ด๊ฐ ์ง์ localStorage ์ ์ฐ๋ํ๋ ค ํ๋ค๋ฉด?
const isDarkModeAtom = atom(
localStorage.getItem('darkMode') === 'true' // โ SSR ์์ ์๋ฌ!
)
// โ ์๋ฒ์๋ localStorage ๊ฐ ์์ด์ ์๋ฌ ๋ฐ์
// โ ํญ ๊ฐ ๋๊ธฐํ ์ ๋จ
// โ ํ์
์ง๋ ฌํ/์ญ์ง๋ ฌํ ์ง์ ๊ตฌํ ํ์atomWithStorage ๋ ์ด ๋ชจ๋ ๋ณด์ผ๋ฌํ๋ ์ดํธ๋ฅผ ํด๊ฒฐํด:
- localStorage ์ atom ์ ์๋ฐฉํฅ ์๋ ๋๊ธฐํ
- JSON ์ง๋ ฌํ/์ญ์ง๋ ฌํ ์๋ ์ฒ๋ฆฌ
- ํฌ๋ก์ค ํญ ๋๊ธฐํ โ ๊ฐ์ ๋ธ๋ผ์ฐ์ ์ ๋ค๋ฅธ ํญ์์ ๊ฐ์ด ๋ฐ๋๋ฉด ์๋ ๋ฐ์
- sessionStorage, AsyncStorage ๋ฑ ์ปค์คํ storage ์ง์
๐ก ํ ์ค๋ก ๊ธฐ์ตํ๊ธฐ
atom์ "๋ฉ๋ชจ๋ฆฌ ์น ํ". ์๋ก๊ณ ์นจํ๋ฉด ์ง์์ ธ.atomWithStorage๋ "์๊ธฐ ๋ถ์ฐฉ ์น ํ". ์๋ก๊ณ ์นจํด๋ ์น ํ ๋ด์ฉ์ด ๋ฒฝ์ ๋ถ์ด์์ด.
๐ง atomWithStorage ๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ ๐ข
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
atomWithStorage์ ์ธ ๊ฐ์ง ํ๋ผ๋ฏธํฐ๋ฅผ ์ดํดํ๊ณ ์ค์ ์์ ์ธ ์ ์๋ค- ๊ธฐ์กด
useAtom๊ณผ ์ฌ์ฉ๋ฒ์ด ๋์ผํ๋ค๋ ๊ฒ์ ํ์ธํ๋ค
import { useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
// atomWithStorage(key, initialValue, storage?)
// - key: localStorage ์ ์ ์ฅ๋ ํค ์ด๋ฆ
// - initialValue: localStorage ์ ๊ฐ์ด ์์ ๋ ์ฌ์ฉํ ์ด๊ธฐ๊ฐ
const isDarkModeAtom = atomWithStorage('darkMode', false)
// localStorage['darkMode'] ๊ฐ ์์ผ๋ฉด false ์ฌ์ฉ
// localStorage['darkMode'] ๊ฐ ์์ผ๋ฉด ๊ทธ ๊ฐ ์ฌ์ฉ// ์ฌ์ฉ ๋ฐฉ๋ฒ์ ์ผ๋ฐ atom ๊ณผ ์์ ํ ๋์ผ!
const DarkModeToggle = () => {
const [isDarkMode, setIsDarkMode] = useAtom(isDarkModeAtom)
return (
<button onClick={() => setIsDarkMode((prev) => !prev)}>
{isDarkMode ? '๐ ๋คํฌ๋ชจ๋' : 'โ๏ธ ๋ผ์ดํธ๋ชจ๋'}
</button>
)
}
// ๋ฒํผ์ ๋๋ฅด๋ฉด atom ๊ฐ๋ ๋ฐ๋๊ณ , localStorage['darkMode'] ๋ ์๋ ์
๋ฐ์ดํธ๋จ
// ์๋ก๊ณ ์นจํด๋ localStorage ์์ ๊ฐ์ ์ฝ์ด์์ ์ ์ง๋จ โ
๋ณต์กํ ๊ฐ์ฒด๋ ์ ์ฅ ๊ฐ๋ฅ
// ๐ฃ ์์ฒ : "๋ฐฐ์ด์ด๋ ๊ฐ์ฒด๋ ์ ์ฅ๋ผ์?"
// ๐ฆ ์ํธ: "JSON.stringify / JSON.parse ๋ก ์๋ ์ง๋ ฌํํด์"
interface StudyFilters {
category: string
sortBy: 'latest' | 'popular'
tags: string[]
}
// ์์๋ค ์ฑ โ ๊ฒ์ ํํฐ๋ฅผ ์๋ก๊ณ ์นจํด๋ ์ ์ง
const studyFiltersAtom = atomWithStorage<StudyFilters>('studyFilters', {
category: 'all',
sortBy: 'latest',
tags: [],
})โ๏ธ getOnInit ์ต์ โ ์ด๊ธฐ๊ฐ vs ์ ์ฅ๊ฐ ๋๋ ๋ง ๐ก
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
getOnInit: false(๊ธฐ๋ณธ๊ฐ)์getOnInit: true์ ๋์ ์ฐจ์ด๋ฅผ ์ ํํ ์ค๋ช ํ ์ ์๋ค- ๊ฐ๊ฐ ์ด๋ค ์ํฉ์์ ์ ๋ฆฌํ์ง ํ๋จํ ์ ์๋ค
์ด๊ฒ ์ฒ์์ ํท๊ฐ๋ฆฌ๋ ๋ถ๋ถ์ด์ผ. atomWithStorage ๋ ๊ธฐ๋ณธ์ ์ผ๋ก ์ฒ์ ๋ ๋ ์ initialValue ๋ฅผ ๋จผ์ ์ฐ๊ณ , ๊ทธ๋ค์ localStorage ์์ ์ ์ฅ๊ฐ์ ์ฝ์ด์ ๊ต์ฒดํด.
// localStorage ์ ์ด๋ฏธ ์ ์ฅ๋ ๊ฐ: { "symbol": "BTC_USDC" }
// โ getOnInit: false (๊ธฐ๋ณธ) โ ์ด๊ธฐ๊ฐ ๋จผ์ ๋ ๋๋๊ณ ๊ทธ ๋ค์ ์ ์ฅ๊ฐ์ผ๋ก ๊ต์ฒด
const symbolAtom = atomWithStorage('symbol', 'SOL_USDC')
// ๋ ๋ ์์:
// 1) 'SOL_USDC' (initialValue) ๋ก ์ฒซ ๋ ๋ โ ์ ๊น ๊น๋นก์ ๊ฐ๋ฅ
// 2) localStorage ์์ 'BTC_USDC' ์ฝ์ด์ ์
๋ฐ์ดํธ โ ๋ฆฌ๋ ๋// โ
getOnInit: true โ localStorage ์์ ์ฆ์ ์ฝ์ด์ ํ ๋ฒ๋ง ๋ ๋
const symbolAtom = atomWithStorage('symbol', 'SOL_USDC', undefined, {
getOnInit: true,
})
// ๋ ๋ ์์:
// 1) localStorage ์์ 'BTC_USDC' ์ฆ์ ์ฝ์ด์ ๋ ๋ โ ๊น๋นก์ ์์์ธ์ ๊ฐ๊ฐ์ ์ฐ๋๊ฐ?
| ์ํฉ | ์ถ์ฒ ์ต์ |
|---|---|
| SPA ์์ ์ ์ฅ๊ฐ์ด ์ฆ์ ํ์๋์ด์ผ ํ ๋ | getOnInit: true |
| SSR ํ๊ฒฝ (Next.js) โ hydration mismatch ๊ฑฑ์ | getOnInit: false (๊ธฐ๋ณธ) + ClientOnly ์ฒ๋ฆฌ |
| ์ด๊ธฐ๊ฐ์ด ์๋ฏธ ์๊ณ ์ ์ฅ๊ฐ์ผ๋ก ์ ๋ฐ์ดํธ๋์ด๋ ๊ด์ฐฎ์ ๋ | getOnInit: false (๊ธฐ๋ณธ) |
๐ก ํ ์ค๋ก ๊ธฐ์ตํ๊ธฐ
getOnInit: true= "์ฐฝ๊ณ ์์ ๋ฐ๋ก ๊บผ๋ด๊ธฐ".getOnInit: false(๊ธฐ๋ณธ) = "์ผ๋จ ์ ๊ฑธ๋ก ๋ณด์ฌ์ฃผ๊ณ , ์ฐฝ๊ณ ํ์ธ ํ ๊ต์ฒด".
๐๏ธ RESET ์ฌ๋ณผ โ localStorage ํค๋ฅผ ๊น๋ํ๊ฒ ์ญ์ ๐ก
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
RESET์ฌ๋ณผ๋ก localStorage ์์ ํค๋ฅผ ์์ ํ ์ญ์ ํ๋ ๋ฐฉ๋ฒ์ ์๋ค- ์กฐ๊ฑด๋ถ RESET ํจํด์ ์ฌ์ฉํ ์ ์๋ค
import { useAtom } from 'jotai'
import { atomWithStorage, RESET } from 'jotai/utils'
// ๐ฆ ์ํธ: "RESET ์ฌ๋ณผ๋ก ์ค์ ์ ์์ ํ ์ด๊ธฐํํ ์ ์์ด์"
const studyFiltersAtom = atomWithStorage('studyFilters', {
category: 'all',
sortBy: 'latest',
})
const FilterControls = () => {
const [filters, setFilters] = useAtom(studyFiltersAtom)
// RESET ์ฌ์ฉ โ localStorage ์์ 'studyFilters' ํค ์์ฒด๋ฅผ ์ญ์
// ๋ค์ ๋ง์ดํธ ์ initialValue ๋ก ๋์๊ฐ
const handleReset = () => {
setFilters(RESET) // localStorage.removeItem('studyFilters') ์ ๋์ผ ํจ๊ณผ
}
return (
<div>
<select value={filters.category} onChange={(e) =>
setFilters((prev) => ({ ...prev, category: e.target.value }))
}>
<option value="all">์ ์ฒด</option>
<option value="frontend">ํ๋ก ํธ์๋</option>
<option value="backend">๋ฐฑ์๋</option>
</select>
<button onClick={handleReset}>ํํฐ ์ด๊ธฐํ</button>
</div>
)
}์กฐ๊ฑด๋ถ RESET ํจํด
import { atomWithStorage, RESET } from 'jotai/utils'
// ๐ฃ ์์ฒ : "์ด์ ๊ฐ์ ๋ฐ๋ผ RESET ์ ํ ์ง ๋ง์ง ๊ฒฐ์ ํ๊ณ ์ถ์ด์"
const isVisibleAtom = atomWithStorage('visible', false)
const ToggleComponent = () => {
const [isVisible, setIsVisible] = useAtom(isVisibleAtom)
return (
<button
onClick={() => setIsVisible((prev) => {
// prev ๊ฐ true ๋ฉด RESET (localStorage ํค ์ญ์ )
// prev ๊ฐ false ๋ฉด true ๋ก ์ค์
return prev ? RESET : true
})}
>
{isVisible ? '์จ๊ธฐ๊ธฐ (์ด๊ธฐํ)' : '๋ณด์ด๊ธฐ'}
</button>
)
}๐ก๏ธ Zod Validation Storage โ ์ ํจํ์ง ์์ ์ ์ฅ๊ฐ ๋ฐฉ์ด ๐ด
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
- localStorage ์ ์ ์ฅ๋ ๊ฐ์ด ์คํค๋ง์ ๋ง์ง ์์ ๋ initialValue ๋ก ์์ ํ๊ฒ fallback ํ๋ ๋ฐฉ๋ฒ์ ์๋ค
- Zod ๋ก ๋ฐํ์ ํ์ ๊ฒ์ฆ์ ํ๋ custom storage ๋ฅผ ๊ตฌํํ ์ ์๋ค
localStorage ์ ํจ์ : ์ธ์ ๋ ์ง ๋๊ฐ ์์๋ก ์์ ํ ์ ์์ด. ๋๋ ์คํค๋ง๊ฐ ๋ฐ๋ ํ ์ด์ ๋ฒ์ ์ ๋ฐ์ดํฐ๊ฐ ๋จ์์์ ์๋ ์์ด.
// ๐ฃ ์์ฒ ์ ์ํฉ โ ์คํค๋ง๊ฐ ๋ฐ๋ ํ ์ด์ ๋ฐ์ดํฐ๊ฐ ๋จ์์๋ ๊ฒฝ์ฐ
// localStorage['studyFilters'] = '{"category":"all"}' (sortBy ํ๋ ์๋ ๊ตฌ๋ฒ์ )
// โ ๊ฒ์ฆ ์์ด ์ฝ์ผ๋ฉด undefined ์ ๊ทผ ๊ฐ๋ฅ
const oldFilters = JSON.parse(localStorage.getItem('studyFilters') ?? '{}')
oldFilters.sortBy.toLowerCase() // ๐ฅ TypeError: Cannot read properties of undefined// โ
Zod ๋ก custom storage ๋ง๋ค๊ธฐ โ ์ ํจํ์ง ์์ผ๋ฉด initialValue ๋ฐํ
import { atomWithStorage } from 'jotai/utils'
import { z } from 'zod'
// ๐ฆ ์ํธ: "Zod schema ๋ก ๋ฐํ์ ๊ฒ์ฆ์ ์ถ๊ฐํด์. ์คํค๋ง๊ฐ ๋ฐ๋์ด๋ ์์ ํด์."
const studyFiltersSchema = z.object({
category: z.string(),
sortBy: z.enum(['latest', 'popular']),
tags: z.array(z.string()),
})
type StudyFilters = z.infer<typeof studyFiltersSchema>
const initialFilters: StudyFilters = {
category: 'all',
sortBy: 'latest',
tags: [],
}
// Zod ๊ฒ์ฆ์ด ๋ด์ฅ๋ custom storage
const validatedStorage = {
getItem(key: string, initialValue: StudyFilters): StudyFilters {
const storedValue = localStorage.getItem(key)
try {
// Zod ๋ก ํ์ฑ โ ์คํค๋ง์ ๋ง์ง ์์ผ๋ฉด ์์ธ ๋ฐ์
return studyFiltersSchema.parse(JSON.parse(storedValue ?? ''))
} catch {
// ๊ฒ์ฆ ์คํจ ์ initialValue ๋ก ์์ ํ๊ฒ fallback
// ๐ฃ ์์ฒ : "์ด๋ฌ๋ฉด ๊ตฌ๋ฒ์ ๋ฐ์ดํฐ๊ฐ ์์ด๋ ์ฑ์ด ์ ํฐ์ง๋ค์!"
return initialValue
}
},
setItem(key: string, value: StudyFilters): void {
localStorage.setItem(key, JSON.stringify(value))
},
removeItem(key: string): void {
localStorage.removeItem(key)
},
// ํฌ๋ก์ค ํญ ๋๊ธฐํ
subscribe(key: string, callback: (value: StudyFilters) => void, initialValue: StudyFilters) {
if (typeof window === 'undefined') return
const handler = (e: StorageEvent) => {
if (e.storageArea === localStorage && e.key === key) {
try {
callback(studyFiltersSchema.parse(JSON.parse(e.newValue ?? '')))
} catch {
callback(initialValue)
}
}
}
window.addEventListener('storage', handler)
return () => window.removeEventListener('storage', handler)
},
}
// ๊ฒ์ฆ์ด ๋ด์ฅ๋ atom
export const studyFiltersAtom = atomWithStorage(
'studyFilters',
initialFilters,
validatedStorage,
)unstable_withStorageValidator (๋ ๊ฐ๊ฒฐํ ๋ฐฉ๋ฒ)
๊ณต์ ๋ ํผ๋ฐ์ค์ ๋ฐ๋ฅด๋ฉด unstable_withStorageValidator ์ ํธ์ ์จ์ ๋ ๊ฐ๊ฒฐํ๊ฒ ๋ง๋ค ์ ์์ด:
import {
atomWithStorage,
createJSONStorage,
unstable_withStorageValidator as withStorageValidator,
} from 'jotai/utils'
import { z } from 'zod'
const studyFiltersSchema = z.object({
category: z.string(),
sortBy: z.enum(['latest', 'popular']),
})
// safeParse ๋ก boolean ๋ฐํํ๋ validator ํจ์
const isValidFilters = (v: unknown) => studyFiltersSchema.safeParse(v).success
// ๋ ๊ฐ๊ฒฐํ ๋ฐฉ๋ฒ (๋จ, unstable โ ์ถํ API ๋ณ๊ฒฝ ๊ฐ๋ฅ)
export const studyFiltersAtom = atomWithStorage(
'studyFilters',
initialFilters,
withStorageValidator(isValidFilters)(createJSONStorage()),
)๐ง createJSONStorage โ ์ปค์คํ storage ๋ง๋ค๊ธฐ ๐ก
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
sessionStorage๋ ๋ค๋ฅธ storage ๋ก ์ฝ๊ฒ ๊ต์ฒดํ ์ ์๋คreviver,replacer์ต์ ์ผ๋ก JSON ์ง๋ ฌํ๋ฅผ ์ปค์คํฐ๋ง์ด์งํ ์ ์๋ค
import { atomWithStorage, createJSONStorage } from 'jotai/utils'
// ๐ฆ ์ํธ: "createJSONStorage ๋ storage ๊ตฌํ์ฒด๋ฅผ ์ฝ๊ฒ ๋ง๋ค์ด์ค์"
// sessionStorage ์ฌ์ฉ โ ๋ธ๋ผ์ฐ์ ํญ ๋ซ์ผ๋ฉด ์ญ์ ๋จ
const sessionStorage = createJSONStorage(() => window.sessionStorage)
// ํญ ๋ซ์ผ๋ฉด ์ฌ๋ผ์ง๋ ์์ ํํฐ
const tempFilterAtom = atomWithStorage('tempFilter', 'all', sessionStorage)
// localStorage (๊ธฐ๋ณธ๊ฐ)
const localStorageImpl = createJSONStorage(() => localStorage)
const persistentFilterAtom = atomWithStorage('filter', 'all', localStorageImpl)Date ๊ฐ์ฒด ์ง๋ ฌํ ์์
// ๐ฃ ์์ฒ : "Date ๊ฐ์ฒด๋ JSON.stringify ํ๋ฉด ๋ฌธ์์ด์ด ๋๋๋ฐ, ์ด๋ป๊ฒ ํด์?"
const dateStorage = createJSONStorage(() => localStorage, {
// reviver: JSON.parse ํ ๋ Date ๋ฌธ์์ด์ Date ๊ฐ์ฒด๋ก ๋ณต์
reviver: (key, value) => {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value)
}
return value
},
})
const lastVisitAtom = atomWithStorage('lastVisit', new Date(), dateStorage)โ ๏ธ SSR Hydration Mismatch โ Next.js ์์์ ํจ์ ๐ด
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
- SSR ์์
atomWithStorage๋ฅผ ์ธ ๋ ์ hydration mismatch ๊ฐ ์๊ธฐ๋์ง ์ดํดํ๋ค- Josh W. Comeau ์
useHasMountedํจํด์ผ๋ก ์ด๋ฅผ ์์ ํ๊ฒ ํด๊ฒฐํ ์ ์๋ค
์ด๊ฒ atomWithStorage ๋ฅผ Next.js ์์ ์ธ ๋ ๊ฐ์ฅ ํฐ ํจ์ ์ด์ผ. ๊ณต์ ๋ ํผ๋ฐ์ค์์๋ ๊ฒฝ๊ณ ํ๊ณ ์์ด:
๋ฌธ์ ๋ฐ์ ์กฐ๊ฑด:
1. ์๋ฒ์์ HTML ์์ฑ โ localStorage ์์ โ initialValue(false) ๋ก ๋ ๋
2. ๋ธ๋ผ์ฐ์ ์์ hydration โ localStorage ์์ ์ ์ฅ๊ฐ(true) ์ฝ์
3. ์๋ฒ HTML(false)๊ณผ ํด๋ผ์ด์ธํธ ์ํ(true) ๋ถ์ผ์น โ Hydration Mismatch ์๋ฌ!
// ๐ฃ ์์ฒ : "์ด๋ ๊ฒ ์ฐ๋๊น ์ฝ์์ ์๋ฌ๊ฐ ๋จ๋๋ฐ์..."
// app/components/DarkModeToggle.tsx
'use client'
const DarkModeToggle = () => {
const [isDarkMode] = useAtom(isDarkModeAtom) // isDarkModeAtom = atomWithStorage(...)
// ์๋ฒ: false ๋ก ๋ ๋ โ ํด๋ผ์ด์ธํธ: localStorage ์์ true ์ฝ์ โ ๋ถ์ผ์น!
return <div className={isDarkMode ? 'dark' : 'light'}>...</div>
}ํด๊ฒฐ์ฑ 1 โ useHasMounted ํจํด (Josh W. Comeau ์ ๋ฐฉ๋ฒ)
๊ณต์ atomWithStorage ๋ ํผ๋ฐ์ค๊ฐ ์ง์ ์ถ์ฒํ๋ ๋ฐฉ๋ฒ์ด์ผ โ Josh W. Comeau ์ "The Perils of Rehydration" ์์ ์ ์๋ ClientOnly ํจํด:
// ๐ฆ ์ํธ: "ํด๋ผ์ด์ธํธ์์๋ง ๋ ๋๋๋ ์ปดํฌ๋ํธ๋ก ๊ฐ์ธ๋ ๊ฒ ํต์ฌ์ด์์"
// hooks/useHasMounted.ts
import { useState, useEffect } from 'react'
export function useHasMounted() {
const [hasMounted, setHasMounted] = useState(false)
useEffect(() => {
setHasMounted(true)
}, [])
return hasMounted
}
// components/ClientOnly.tsx
import { useHasMounted } from '@/hooks/useHasMounted'
export function ClientOnly({ children }: { children: React.ReactNode }) {
const hasMounted = useHasMounted()
if (!hasMounted) return null // ์๋ฒ ๋ ๋ ์ null ๋ฐํ โ hydration mismatch ๋ฐฉ์ง
return <>{children}</>
}
// ์ฌ์ฉ ์์ โ localStorage ์์กด UI ๋ฅผ ClientOnly ๋ก ๊ฐ์ธ๊ธฐ
const App = () => (
<div>
<Header />
<ClientOnly>
{/* ์ด ์์ ๋ด์ฉ์ ํด๋ผ์ด์ธํธ์์๋ง ๋ ๋๋จ */}
<DarkModeToggle />
</ClientOnly>
<MainContent />
</div>
)ํด๊ฒฐ์ฑ 2 โ suppressHydrationWarning
// ๋น ๋ฅธ ์์ ํด๊ฒฐ์ฑ
โ ๊ฒฝ๊ณ ๋ง ์ต์ , ๊ทผ๋ณธ ํด๊ฒฐ์ ์๋
// ๐ฆ ์ํธ: "์ด๊ฑด ์์๋ฐฉํธ์ด์์. ์ค์ mismatch ๋ ์ฌ์ ํ ์ผ์ด๋์ flicker ๊ฐ ์๊ฒจ์"
<div suppressHydrationWarning>
{isDarkMode ? '๋คํฌ๋ชจ๋' : '๋ผ์ดํธ๋ชจ๋'}
</div>Next.js App Router ์์์ ์ฌ๋ฐ๋ฅธ ํจํด
// app/components/ThemeProvider.tsx
'use client'
import { atomWithStorage, useAtom } from 'jotai' // ์๋ชป๋ import โ ์๋ ์์
import { atomWithStorage } from 'jotai/utils'
import { useAtom } from 'jotai'
import { useHasMounted } from '@/hooks/useHasMounted'
const isDarkModeAtom = atomWithStorage('darkMode', false)
// ๋คํฌ๋ชจ๋ ํ ๊ธ โ hasMounted ์ฒดํฌ ํฌํจ
export function DarkModeToggle() {
const hasMounted = useHasMounted()
const [isDarkMode, setIsDarkMode] = useAtom(isDarkModeAtom)
if (!hasMounted) {
// ์๋ฒ ๋ ๋ ์ค์๋ skeleton ๋๋ ๊ธฐ๋ณธ UI ํ์
return <button>โ๏ธ ๋ผ์ดํธ๋ชจ๋</button>
}
return (
<button onClick={() => setIsDarkMode((prev) => !prev)}>
{isDarkMode ? '๐ ๋คํฌ๋ชจ๋' : 'โ๏ธ ๋ผ์ดํธ๋ชจ๋'}
</button>
)
}๐ฅ ์๋ฌ ํด๊ฒฐ ์นดํ๋ก๊ทธ
์๋ฌ ๋ฉ์์ง๊ฐ ๋จ๋ฉด Ctrl+F ๋ก ๊ฒ์ํด๋ด. ๋๋ถ๋ถ ์ฌ๊ธฐ ์์ด.
โ "localStorage is not defined" โ SSR ์์ ์๋ฌ
์์ธ: atomWithStorage ๊ฐ ์๋ฒ์์ localStorage ์ ์ ๊ทผํ๋ ค ํ ๋
ํด๊ฒฐ์ฑ :
// createJSONStorage ์ typeof ์ฒดํฌ
const storage = createJSONStorage(() =>
typeof window !== 'undefined' ? localStorage : undefined as any
)๋๋ ClientOnly ๋ํผ ์ปดํฌ๋ํธ ์ฌ์ฉ (์ ์น์
์ฐธ๊ณ )
โ Hydration Mismatch ๊ฒฝ๊ณ โ Text content did not match
์์ธ: ์๋ฒ(initialValue)์ ํด๋ผ์ด์ธํธ(localStorage ๊ฐ)์ ๋ ๋ ๊ฒฐ๊ณผ๊ฐ ๋ค๋ฆ
ํด๊ฒฐ์ฑ :
// useHasMounted ํจํด์ผ๋ก ํด๋ผ์ด์ธํธ์์๋ง ์ ์ฅ๊ฐ ๊ธฐ๋ฐ UI ๋ ๋
const hasMounted = useHasMounted()
if (!hasMounted) return <Skeleton /> // ์๋ฒ์ ์ผ์นํ๋ UI
return <ActualUI /> // ํด๋ผ์ด์ธํธ์์๋งโ ํฌ๋ก์ค ํญ ๋๊ธฐํ๊ฐ ์ ๋ผ
์์ธ: ๊ธฐ๋ณธ localStorage storage ๋ ํฌ๋ก์ค ํญ ๋๊ธฐํ๋ฅผ ์ง์ํ๋๋ฐ, ์ปค์คํ
storage ๋ฅผ ๋ง๋ค๋ฉด์ subscribe ๋ฅผ ๋น ๋จ๋ฆฐ ๊ฒฝ์ฐ
ํด๊ฒฐ์ฑ :
// custom storage ์ subscribe ๊ตฌํ
const myStorage = {
getItem: ...,
setItem: ...,
removeItem: ...,
// subscribe ์ถ๊ฐ ํ์!
subscribe(key, callback, initialValue) {
window.addEventListener('storage', handler)
return () => window.removeEventListener('storage', handler)
},
}๐ ์ด๋ฒ์ ๋ฐฐ์ด ๋ด์ฉ ์ด์ ๋ฆฌ
๐ atomWithStorage ์ต์ ์์ฝ
| ์ต์ | ๊ธฐ๋ณธ๊ฐ | ํจ๊ณผ |
|---|---|---|
key (ํ์) | - | localStorage ์ ์ฅ ํค |
initialValue (ํ์) | - | ์ ์ฅ๊ฐ ์์ ๋ ์ฌ์ฉํ ๊ธฐ๋ณธ๊ฐ |
storage | localStorage JSON | ์ปค์คํ storage ๊ตฌํ์ฒด |
getOnInit | false | true ์ด๋ฉด ๋ง์ดํธ ์ ์ฆ์ ์ ์ฅ๊ฐ ๋ฐํ |
โ ๏ธ Next.js ์์์ ์ฃผ์์ฌํญ
| ์ํฉ | โ ๋์ ์ | โ ์ข์ ์ |
|---|---|---|
| localStorage ์์กด UI | ์๋ฒ์์ ์ง์ ๋ ๋ | ClientOnly ๋ํผ๋ก ๊ฐ์ธ๊ธฐ |
| hydration mismatch | suppressHydrationWarning ์ผ๋ก ์ต์ | useHasMounted ํจํด |
| ์ ์ฅ๊ฐ ๊ฒ์ฆ ์์ | ๋ชจ๋ localStorage ๊ฐ ๊ทธ๋๋ก ์ฌ์ฉ | Zod custom storage ๋ก ๊ฒ์ฆ |
๐ ๋ง๋ฌด๋ฆฌ ํด์ฆ
Q1. ๐ ์ค๋ฌด ๋๋ ๋ง (์์ฒ ์ ์ ํ)
์์ฒ ์ด๊ฐ Next.js App Router ์์
atomWithStorage('theme', 'light')๋ฅผ ์จ์ ํ ๋ง ์ค์ ์ ๋ง๋ค์์ด. ๋ก์ปฌ์์๋ ์ ๋์ํ๋๋ฐ, ๋น๋ ํ ๋ฐฐํฌํ๋๊น ์ฝ์์ "Text content did not match" ์๋ฌ๊ฐ ๋จ๊ณ ํ๋ฉด์ด ์ ๊น ๊น๋นก์ฌ. ๊ฐ์ฅ ์ฌ๋ฐ๋ฅธ ํด๊ฒฐ์ฑ ์?
- A)
atomWithStorage๋์ ์ผ๋ฐatom์ผ๋ก ๊ต์ฒดํ๊ณ ,useEffect์์ localStorage ๋ฅผ ์๋์ผ๋ก ์ฝ๋๋ค - B)
useHasMountedํ ์ ๋ง๋ค์ด์ ๋ง์ดํธ ์ ๊น์ง๋ initialValue ๊ธฐ๋ฐ UI ๋ฅผ ๋ ๋ํ๊ณ , ๋ง์ดํธ ํ์๋ง ์ ์ฅ๊ฐ ๊ธฐ๋ฐ UI ๋ฅผ ๋ ๋ํ๋ค - C)
getOnInit: true๋ก ์ค์ ํ๋ฉด hydration mismatch ๊ฐ ํด๊ฒฐ๋๋ค - D)
suppressHydrationWarning์ ์ถ๊ฐํด์ ๊ฒฝ๊ณ ๋ฅผ ์์ค๋ค
โ ์ ๋ต: B
๐ก ์์ธ ํด์ค:
- ์๋ฆฌ ์ค๋ช
: SSR ์์ ์๋ฒ๋ localStorage ์ ์ ๊ทผํ ์ ์์ด์
initialValue์ธ'light'๋ก HTML ์ ์์ฑํด. ๋ธ๋ผ์ฐ์ ๊ฐ ๋ฐ์ HTML ์ hydration ํ๋ฉด์ localStorage ์์'dark'๋ฅผ ์ฝ์ผ๋ฉด, ์๋ฒ HTML ๊ณผ ํด๋ผ์ด์ธํธ ์ํ๊ฐ ๋ฌ๋ผ์ hydration mismatch ์๋ฌ๊ฐ ๋ฐ์ํด.useHasMountedํจํด์ ํด๋ผ์ด์ธํธ์์ ์ปดํฌ๋ํธ๊ฐ ๋ง์ดํธ๋๊ธฐ ์ ๊น์ง ์๋ฒ์ ๋์ผํ UI ๋ฅผ ๋ ๋ํด์ mismatch ๋ฅผ ๋ฐฉ์งํด. - ์ค๋ต ํผ๋๋ฐฑ: A ๋
useEffect๋ ๊ฐ์ ๋ฌธ์ ๋ฅผ ๊ฐ์ ธ. C ๋getOnInit: true๊ฐ ์คํ๋ ค ๋ ์ผ์ฐ localStorage ๋ฅผ ์ฝ์ผ๋ ค ํด์ ๋ฌธ์ ๊ฐ ์ฌํด์ง ์ ์์ด. D ๋ mismatch ๊ฒฝ๊ณ ๋ฅผ ์จ๊ธธ ๋ฟ ์ค์ flicker ๋ ์ฌ์ ํ ๋ฐ์ํด. - ๐ ํต์ฌ ๊ธฐ์ต๋ฒ: "SSR + localStorage = ์๋ฒ๋ ์ฐฝ๊ณ ๋ฅผ ๋ชจ๋ฅธ๋ค. ๋ง์ดํธ ํ์๋ง ์ฐฝ๊ณ ๋ฅผ ์ด์ด."
Q2. ๐ฆ ์๋์ด ๋ฉด์ ์ง๋ฌธ
๋ฉด์ ๊ด์ด ๋ฌผ์์ด: "
atomWithStorage์์getOnInit: false(๊ธฐ๋ณธ)์getOnInit: true์ ๋ ๋๋ง ๋์ ์ฐจ์ด๋ฅผ ์ค๋ช ํด๋ณด์ธ์."
์์ ๋ต๋ณ:
"
getOnInit: false๋ ๊ธฐ๋ณธ ๋์์ผ๋ก, ์ปดํฌ๋ํธ๊ฐ ์ฒ์ ๋ ๋๋ ๋initialValue๋ฅผ ๋จผ์ ์ฐ๊ณ , ๊ทธ๋ค์ localStorage ์์ ์ ์ฅ๊ฐ์ ์ฝ์ด์์ ์ ๋ฐ์ดํธํด์. ์ด ๋๋ฌธ์ ๋ ๋ฒ์ ๋ ๋๊ฐ ์ผ์ด๋ ์ ์๊ณ , ์ ์ฅ๊ฐ์ด initialValue ์ ๋ค๋ฅด๋ฉด ์ ๊น ๊น๋นก์์ด ๋ณด์ผ ์ ์์ด์.getOnInit: true๋ ๋ง์ดํธ ์ ์ฆ์ localStorage ์์ ๊ฐ์ ์ฝ์ด์ ํ ๋ฒ๋ง ๋ ๋ํด์. SPA ์์ ์ ์ฅ๊ฐ์ ์ฆ์ ๋ณด์ฌ์ค์ผ ํ ๋ ์ ๋ฆฌํ์ง๋ง, SSR ํ๊ฒฝ์์๋ ์คํ๋ ค hydration mismatch ์ํ์ด ์์ด์ ์กฐ์ฌํด์ผ ํด์."
Q3. ๐ฅ ๊ธด๊ธ ๋๋ฒ๊น (์์์ ํธํต)
์์ ๋์ด "๋ ๋ฌ ์ ์ ์ ์ฅ๋ ํํฐ ๋ฐ์ดํฐ๊ฐ ์ฑ์ ๋ง๊ฐ๋จ๋ฆฌ๊ณ ์๋ค. ์คํค๋ง ๋ณ๊ฒฝ ํ ์ด์ localStorage ๊ฐ์ด ์ฑ ์๋ฌ๋ฅผ ์ผ์ผํจ๋ค" ๋ฉฐ ๋ก๊ทธ๋ฅผ ๋ณด๋์ด. ์์ฒ ์ด๊ฐ ์ ์ผ ๋จผ์ ํด์ผ ํ ๊ฒ์?
- A) localStorage ๋ฅผ ๋ชจ๋ clear ํ๊ณ ์ฌ์ฉ์์๊ฒ ์ด๊ธฐํ๋ฅผ ์์ฒญํ๋ค
- B)
atomWithStorage์ Zod custom storage ๋ฅผ ์ ์ฉํด์ ์คํค๋ง์ ๋ง์ง ์๋ ์ ์ฅ๊ฐ์ด ๋ค์ด์ค๋ฉดinitialValue๋ก fallback ํ๊ฒ ๋ง๋ ๋ค - C) ์คํค๋ง ๋ณ๊ฒฝ ์๋ง๋ค localStorage key ์ด๋ฆ์ ๋ฐ๊พผ๋ค (e.g.,
studyFilters_v2) - D) localStorage ๋์ sessionStorage ๋ก ๊ต์ฒดํ๋ค
โ ์ ๋ต: B (C ๋ ์ ํจํ ์ ๋ต์ด์ง๋ง ๊ทผ๋ณธ์ ์ธ ํด๊ฒฐ์ฑ ์ B)
๐ก ์์ธ ํด์ค:
- ์๋ฆฌ ์ค๋ช
: B ์ Zod custom storage ๋
getItem์์ ํ์ฑ ์คํจ ์initialValue๋ฅผ ๋ฐํํด์ ์ฑ์ด ํฐ์ง์ง ์์. ์ด๊ฒ ๋ฐฉ์ด์ ํ๋ก๊ทธ๋๋ฐ ์ ํต์ฌ์ด์ผ. C ์ key ์ด๋ฆ ๋ณ๊ฒฝ์ ๊ตฌ๋ฒ์ ๋ฐ์ดํฐ๋ฅผ ์์ ํ ๋ฌด์ํ๋ ์ ๋ต์ผ๋ก, ์ฌ์ฉ์ ์ค์ ์ ์๊ฒ ๋ง๋ค์ด์ UX ๊ฐ ๋๋น . D ๋ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๋ ๊ฒ ์๋๋ผ ์์น๋ง ๋ฐ๊พธ๋ ๊ฑฐ์ผ. - ์ค๋ต ํผ๋๋ฐฑ: A ๋ ์ฌ์ฉ์ ๋ฐ์ดํฐ๋ฅผ ๋ ๋ฆฌ๋ ์ตํ์ ์๋จ์ด์ผ. B ๋ก ๋จผ์ ์๋ํด๋ด์ผ ํด.
- ๐ ํต์ฌ ๊ธฐ์ต๋ฒ: "localStorage ๋ ์ฌ์ฉ์๊ฐ ์์ ํ ์ ์๋ค. ํญ์ ๊ฒ์ฆํ๊ณ fallback ์ ์ค๋นํด."
๐ฃ ์์ฒ ์ด์ ํด๊ทผ ์ผ๊ธฐ
์์งํ ์ค๋ atomWithStorage ํ๋๋ก ๋คํฌ๋ชจ๋ ๋ฒ๊ทธ๋ฅผ ๊ณ ์ณค์ ๋ "์ด๊ฒ ์ด๋ ๊ฒ ๊ฐ๋จํด?" ์ถ์์ด. ๊ทธ๋ฐ๋ฐ ์ํธ ๋์ด "Next.js ์์ ์กฐ์ฌํด์ผ ํ ๊ฒ ์์ด์" ๋ผ๊ณ ํ๋ฉด์ SSR hydration mismatch ์ค๋ช
์ ์์ํ๋ ์๊ฐ ์ ์ ์ฐจ๋ ธ๋ค.
์ฒ์์ getOnInit: true ๋ก ์ค์ ํ๋ฉด ๋ค ํด๊ฒฐ๋ ์ค ์์๋๋ฐ, ์คํ๋ ค ์๋ฒ์์ ๋ ์ผ์ฐ localStorage ๋ฅผ ์ฝ์ผ๋ ค ํ๋ฉด์ ๋ฌธ์ ๊ฐ ์ฌํด์ง๋ค๋ ๊ฑฐ... ์์ ๋ฐ์ง๊ด์ ์ด์์ด. Josh W. Comeau ์์ ์จ๊ฐ ์ด๋ฏธ "์ํ(hydration)์ ์ํ์ฑ" ์ด๋ผ๋ ๊ธ์์ ์ด๊ฑธ ์ค๋ช
ํ๋ค๋ ๊ฒ ์ ๊ธฐํ์ด. ํ๋ก ํธ์๋ ์ธ๊ณ๋ ์ด๋ฏธ ๋ค ๊ฒช์ด๋ณธ ์ฌ๋๋ค์ด ํด๊ฒฐ์ฑ
์ ์ ๋ฆฌํด๋๊ณ ์๊ตฌ๋.
๐ก ์ค๋์ ๊ตํ: "localStorage ๋ ์๋ฒ๊ฐ ๋ชจ๋ฅด๋ ์ฐฝ๊ณ ๋ค. SSR ์์ ๊ทธ ์ฐฝ๊ณ ๋ฅผ ์ด๋ ค๋ฉด ๋ฐ๋์ ๋ง์ดํธ ํ์๋ง ์ด์ด์ผ ํ๋ค."
๊ทธ๋ฆฌ๊ณ Zod validation storage ํจํด... ์ฒ์์ ๊ณผํ๋ค๊ณ ์๊ฐํ๋๋ฐ, ์์ ๋์ด "์คํค๋ง ๋ฐ๋๋ฉด ์ด์ ๋ฐ์ดํฐ๊ฐ ์ฑ ํฐ๋จ๋ฆด ์ ์๋ค" ๊ณ ํ๋ ์๊ฐ ๋ฐ๋ก ๋ฉ๋ํ์ด. localStorage ๋ ์ฌ์ฉ์๊ฐ ๊ฐ๋ฐ์ ๋๊ตฌ๋ก ์ธ์ ๋ ์ง ์์ ํ ์ ์์ผ๋๊น, ๋ฏฟ๊ณ ์ฐ๋ฉด ์ ๋๋ค๋ ๊ฑธ ์ด์ ์๊ฒ ์ด.
์ค๋ ํด๊ทผํ๊ณ ๋ทํ๋ฆญ์ค ๋ณด๋ฉด์ ์ฌ์ด์ผ์ง. ๋คํฌ๋ชจ๋ ์ผ๋๊ณ ๋ด์ผ๊ฒ ๋ค. ๋ด๊ฐ ๋ง๋ ๋คํฌ๋ชจ๋ ๊ธฐ๋ฅ์ด ์ค๋๋ถํฐ ์๋ก๊ณ ์นจํด๋ ์ด์์์ผ๋๊น.