๐Ÿงฉ 10. atomWithStorage โ€” ์ƒˆ๋กœ๊ณ ์นจํ•ด๋„ ์‚ด์•„์žˆ๋Š” ์ƒํƒœ

๐Ÿ“‹ ๊ฐœ์š”

atomWithStorage ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•, getOnInit ์˜ต์…˜, RESET ์‹ฌ๋ณผ, Zod validation, SSR hydration mismatch ํ•ด๊ฒฐ์ฑ…์„ ๋ฐฐ์›๋‹ˆ๋‹ค.

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • atomWithStorage ๋กœ localStorage ์— ์ƒํƒœ๋ฅผ ์ž๋™ ๋™๊ธฐํ™”ํ•  ์ˆ˜ ์žˆ๋‹ค.
  • getOnInit, RESET, createJSONStorage, Zod validation ๋“ฑ ๊ณ ๊ธ‰ ์˜ต์…˜์„ ์‹ค์ „์—์„œ ์“ธ ์ˆ˜ ์žˆ๋‹ค.
  • SSR(Next.js)์—์„œ ๋ฐœ์ƒํ•˜๋Š” hydration mismatch ๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค.

๐Ÿ“‹ ๋ชฉ์ฐจ


๐Ÿ“Œ ์ด ๋ฌธ์„œ๋ฅผ ์ฝ๊ธฐ ์ „์—

โฑ๏ธ ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„: 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 (ํ•„์ˆ˜)-์ €์žฅ๊ฐ’ ์—†์„ ๋•Œ ์‚ฌ์šฉํ•  ๊ธฐ๋ณธ๊ฐ’
storagelocalStorage JSON์ปค์Šคํ…€ storage ๊ตฌํ˜„์ฒด
getOnInitfalsetrue ์ด๋ฉด ๋งˆ์šดํŠธ ์‹œ ์ฆ‰์‹œ ์ €์žฅ๊ฐ’ ๋ฐ˜ํ™˜

โš ๏ธ Next.js ์—์„œ์˜ ์ฃผ์˜์‚ฌํ•ญ

์ƒํ™ฉโŒ ๋‚˜์œ ์˜ˆโœ… ์ข‹์€ ์˜ˆ
localStorage ์˜์กด UI์„œ๋ฒ„์—์„œ ์ง์ ‘ ๋ Œ๋”ClientOnly ๋ž˜ํผ๋กœ ๊ฐ์‹ธ๊ธฐ
hydration mismatchsuppressHydrationWarning ์œผ๋กœ ์–ต์ œ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 ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ๊ฐœ๋ฐœ์ž ๋„๊ตฌ๋กœ ์–ธ์ œ๋“ ์ง€ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์œผ๋‹ˆ๊นŒ, ๋ฏฟ๊ณ  ์“ฐ๋ฉด ์•ˆ ๋œ๋‹ค๋Š” ๊ฑธ ์ด์ œ ์•Œ๊ฒ ์–ด.

์˜ค๋Š˜ ํ‡ด๊ทผํ•˜๊ณ  ๋„ทํ”Œ๋ฆญ์Šค ๋ณด๋ฉด์„œ ์‰ฌ์–ด์•ผ์ง€. ๋‹คํฌ๋ชจ๋“œ ์ผœ๋†“๊ณ  ๋ด์•ผ๊ฒ ๋‹ค. ๋‚ด๊ฐ€ ๋งŒ๋“  ๋‹คํฌ๋ชจ๋“œ ๊ธฐ๋Šฅ์ด ์˜ค๋Š˜๋ถ€ํ„ฐ ์ƒˆ๋กœ๊ณ ์นจํ•ด๋„ ์‚ด์•„์žˆ์œผ๋‹ˆ๊นŒ.


๐Ÿ”— ๋” ์•Œ์•„๋ณด๊ธฐ