๐Ÿ’ก 04. ๋น„๋™๊ธฐ atom + Suspense โ€” ๋กœ๋”ฉ ์ง€์˜ฅ ํƒˆ์ถœ

๐Ÿ“‹ ๊ฐœ์š”

async read atom, AbortController(signal), Suspense+ErrorBoundary ์—ฐ๋™, loadable() ์œ ํ‹ธ์„ ํ†ตํ•ด ๋น„๋™๊ธฐ ์ƒํƒœ๋ฅผ ์„ ์–ธ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฒ•์„ ๋ฐฐ์›๋‹ˆ๋‹ค.

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

  • atom(async (get) => {...}) ํŒจํ„ด์œผ๋กœ ๋น„๋™๊ธฐ ์ƒํƒœ๋ฅผ ์„ ์–ธ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.
  • signal ์„ ํ†ตํ•œ AbortController ์—ฐ๋™์œผ๋กœ ์ด์ „ ์š”์ฒญ์„ ์ž๋™ ์ทจ์†Œํ•˜๋Š” ๋ฒ•์„ ์•ˆ๋‹ค.
  • loadable() ์œ ํ‹ธ๋กœ Suspense ์—†์ด ๋กœ๋”ฉ/์—๋Ÿฌ ์ƒํƒœ๋ฅผ ์ง์ ‘ ๋‹ค๋ฃจ๋Š” ๋ฒ•์„ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ๋‹ค.

๐Ÿ“‹ ๋ชฉ์ฐจ


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

โฑ๏ธ ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„: 22๋ถ„ (์ „์ฒด) / ํ•ต์‹ฌ ํŒŒํŠธ๋งŒ: 13๋ถ„

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ํ๋ฆ„

[๋ช…๋ น์  isLoading ์ง€์˜ฅ] โ†’ [์„ ์–ธ์  async atom] โ†’ [AbortController signal] โ†’ [Suspense + ErrorBoundary] โ†’ [loadable() ๋Œ€์•ˆ] โ†’ [TanStack Query ์—ญํ•  ๋ถ„๋ฆฌ]

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ๋ฐฐ๊ฒฝ ์„ธ๊ณ„๊ด€: '์˜์ˆ˜๋„ค ์ปค๋ฎค๋‹ˆํ‹ฐ'

์˜ค๋Š˜์€ ์˜์ฒ ์ด๊ฐ€ ์Šคํ„ฐ๋”” ๋ชฉ๋ก API ์—ฐ๋™ ์ž‘์—…์„ ํ•˜๋Š” ๋‚ ์ด์•ผ.

๐Ÿฃ ์˜์ฒ : (์ž์‹ ๋งŒ๋งŒํ•˜๊ฒŒ) "์˜ํ˜ธ ๋‹˜! ์Šคํ„ฐ๋”” ๋ชฉ๋ก API ์—ฐ๋™ ์™„๋ฃŒํ–ˆ์–ด์š”. ํ•œ๋ฒˆ ๋ด์ฃผ์„ธ์š”."

๐Ÿฆ ์˜ํ˜ธ ๋‹˜: (์ฝ”๋“œ ๋ฆฌ๋ทฐ ํ™”๋ฉด์„ ์—ด๋ฉฐ) "์˜์ฒ  ๋‹˜, isLoading, isError, data useState 3๊ฐœ์— useEffect ํ•˜๋‚˜... ์ปดํฌ๋„ŒํŠธ ํ•œ ๊ฐœ์— ๋น„๋™๊ธฐ ๋กœ์ง์ด ์ด๋ ‡๊ฒŒ ๋งŽ์ด ๋“ค์–ด๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ๊ฒŒ ๋ฐ”๋กœ '๋กœ๋”ฉ ์ง€์˜ฅ'์ด์—์š”."

๐Ÿฃ ์˜์ฒ : "๊ทผ๋ฐ ์ด๊ฒŒ ๋‹ค ํ•„์š”ํ•œ ๊ฑฐ ์•„๋‹Œ๊ฐ€์š”...? ๋กœ๋”ฉ ์ƒํƒœ ๋ณด์—ฌ์ค˜์•ผ ํ•˜๊ณ , ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋„ ํ•ด์•ผ ํ•˜๊ณ ..."

๐Ÿฆ ์˜ํ˜ธ ๋‹˜: "๋งž์•„์š”. ๊ทผ๋ฐ ๊ทธ ์ฑ…์ž„์„ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ํ˜ผ์ž ๋‹ค ์งŠ์–ด์งˆ ํ•„์š”๋Š” ์—†์–ด์š”. async atom ํ•˜๋‚˜๊ฐ€ ๊ทธ ์ง์„ ๋Œ€์‹  ์งˆ ์ˆ˜ ์žˆ๊ฑฐ๋“ ์š”. ๊ทธ๋ฆฌ๊ณ  Suspense ๊ฐ€ ๋กœ๋”ฉ UI ๋ฅผ ์„ ์–ธ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•ด์ค˜์š”."

๐Ÿฃ ์˜์ฒ : (์ง„์งœ๋กœ ๋ฏฟ๊ธฐ์ง€ ์•Š๋Š” ํ‘œ์ •) "๊ทธ๊ฒŒ ๋ง์ด ๋ผ์š”...?"


๐Ÿค” ์™œ ๋น„๋™๊ธฐ atom + Suspense ์ธ๊ฐ€? ๐ŸŸข

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

  • ๋ช…๋ น์ (Imperative) ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ์˜ ๋ฌธ์ œ์ ์„ ๊ตฌ์ฒด์ ์œผ๋กœ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ๋‹ค
  • ์„ ์–ธ์ (Declarative) ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ๊ฐ€ ์™œ ์ฝ”๋“œ ํ’ˆ์งˆ์„ ๋†’์ด๋Š”์ง€ ์ดํ•ดํ•œ๋‹ค

๐Ÿค” ์ž ๊น, ๋จผ์ € ์ƒ๊ฐํ•ด๋ด
isLoading, isError, data ๋ฅผ useState 3๊ฐœ๋กœ ๊ด€๋ฆฌํ•˜๋ฉด ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ์ƒ๊ธธ๊นŒ?
"๊ฐ ์ƒํƒœ์˜ ์กฐํ•ฉ์ด ๋ง์ด ์•ˆ ๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์ƒ๊ธธ ์ˆ˜๋„ ์žˆ๊ฒ ๋‹ค" ๊นŒ์ง€ ๋– ์˜ฌ๋ ธ์œผ๋ฉด ์ถฉ๋ถ„ํ•ด.

๋ช…๋ น์  ์ ‘๊ทผ โ€” ์˜์ฒ ์ด์˜ "๋กœ๋”ฉ ์ง€์˜ฅ"

์˜์ฒ ์ด๊ฐ€ ์ฒ˜์Œ ์ง  ์Šคํ„ฐ๋”” ๋ชฉ๋ก ์ปดํฌ๋„ŒํŠธ์•ผ:

// โŒ ๐Ÿฃ ์˜์ฒ : "์ผ๋‹จ ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋˜์ง€ ์•Š์„๊นŒ์š”? ๋‹ค ๋„ฃ์œผ๋ฉด ๋˜์ž–์•„์š”"
const StudyList = () => {
  const [studies, setStudies] = useState<Study[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [isError, setIsError] = useState(false)
  const [error, setError] = useState<Error | null>(null)
  // ๐Ÿฃ ์˜์ฒ : "๋ฒŒ์จ useState 4๊ฐœ... ๊ทผ๋ฐ ์ด๊ฒŒ ๋งž๋Š” ๊ฒƒ ๊ฐ™์€๋ฐ์š”?"
 
  useEffect(() => {
    setIsLoading(true)
    setIsError(false)  // ๐Ÿฃ ์˜์ฒ : "์ด๊ฑฐ ์ดˆ๊ธฐํ™”๋„ ํ•ด์ค˜์•ผ ํ•˜๊ณ ..."
 
    fetch('/api/studies')
      .then((res) => res.json())
      .then((data) => {
        setStudies(data)
        setIsLoading(false)
      })
      .catch((err) => {
        setError(err)
        setIsError(true)
        setIsLoading(false) // ๐Ÿฃ ์˜์ฒ : "์—๋Ÿฌ์ผ ๋•Œ๋„ isLoading ๋„๋Š” ๊ฑฐ ์žŠ์œผ๋ฉด ์•ˆ ๋˜๊ณ ..."
      })
  }, [])
 
  if (isLoading) return <Spinner />
  if (isError) return <ErrorMessage error={error} />
  return <ul>{studies.map((s) => <StudyCard key={s.id} study={s} />)}</ul>
}

์˜ํ˜ธ ๋‹˜์˜ ์ฝ”๋“œ ๋ฆฌ๋ทฐ:

๐Ÿฆ ์˜ํ˜ธ: "์˜์ฒ  ๋‹˜, ์ด ์ฝ”๋“œ์— ์ˆจ์–ด์žˆ๋Š” ํญํƒ„์„ ์ฐพ์•„๋ณผ๊นŒ์š”?
         1. isLoading=true, isError=true ๊ฐ€ ๋™์‹œ์— ์„ฑ๋ฆฝํ•  ์ˆ˜ ์žˆ์–ด์š”. ๋…ผ๋ฆฌ์ ์œผ๋กœ ๋ถˆ๊ฐ€๋Šฅํ•œ ์ƒํƒœ์˜ˆ์š”.
         2. ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ์ด useEffect ๋ฅผ ๋‹ค ๋ณต์‚ฌํ•ด์„œ ์จ์•ผ ํ•ด์š”.
         3. ์ด ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋งˆ์šดํŠธ ํ•ด์ œ๋˜๋ฉด fetch ๊ฐ€ ์ทจ์†Œ๋˜์ง€ ์•Š์•„์š” โ€” ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜์˜ˆ์š”."

์ด๊ฒŒ ๋ฐ”๋กœ ๋ช…๋ น์ (Imperative) ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ ์˜ ๋ฌธ์ œ์•ผ. ์ƒํƒœ๋ฅผ ์ง์ ‘ ํ•˜๋‚˜ํ•˜๋‚˜ ๋ช…๋ นํ•ด์„œ ๊ด€๋ฆฌํ•˜๋‹ค ๋ณด๋ฉด, ์ƒํƒœ๋“ค ์‚ฌ์ด์˜ ๋…ผ๋ฆฌ์  ๋ถˆ์ผ์น˜๊ฐ€ ์ƒ๊ธฐ๊ณ  ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ ์  ๋šฑ๋šฑํ•ด์ ธ.

์„ ์–ธ์  ์ ‘๊ทผ โ€” "์–ด๋–ค ์ƒํƒœ์ธ์ง€"๋ฅผ ์„ ์–ธํ•œ๋‹ค

// โœ… ๐Ÿฆ ์˜ํ˜ธ: "async atom ํ•˜๋‚˜๋ฉด ์ด ์ง์„ ์ „๋ถ€ ๋Œ€์‹  ์งˆ ์ˆ˜ ์žˆ์–ด์š”"
const studyListAtom = atom(async (get) => {
  const res = await fetch('/api/studies')
  if (!res.ok) throw new Error('์Šคํ„ฐ๋”” ๋ชฉ๋ก ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์‹คํŒจ')
  return res.json() as Promise<Study[]>
})
 
// ์ปดํฌ๋„ŒํŠธ๋Š” ์ด๊ฒŒ ์ „๋ถ€์•ผ โ€” ๋กœ๋”ฉ? Suspense ๊ฐ€ ์ฒ˜๋ฆฌ. ์—๋Ÿฌ? ErrorBoundary ๊ฐ€ ์ฒ˜๋ฆฌ.
const StudyList = () => {
  const studies = useAtomValue(studyListAtom)
  // ๐Ÿฆ ์˜ํ˜ธ: "studies ๋Š” ํ•ญ์ƒ Study[] ํƒ€์ž…์ด์—์š”. undefined, null ์ฒดํฌ ํ•„์š” ์—†์–ด์š”."
  return <ul>{studies.map((s) => <StudyCard key={s.id} study={s} />)}</ul>
}

๐Ÿ’ก ํ•œ ์ค„๋กœ ๊ธฐ์–ตํ•˜๊ธฐ
๋ช…๋ น์  ์ฒ˜๋ฆฌ๋Š” "๋กœ๋”ฉ์ด๋ฉด ์Šคํ”ผ๋„ˆ ๋ณด์—ฌ์ค˜, ์—๋Ÿฌ๋ฉด ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋ณด์—ฌ์ค˜, ์•„๋‹ˆ๋ฉด ๋ชฉ๋ก ๋ณด์—ฌ์ค˜" ๋ผ๊ณ  ์ปดํฌ๋„ŒํŠธ์— ์„ธ์„ธํ•˜๊ฒŒ ์ง€์‹œํ•ด. ์„ ์–ธ์  ์ฒ˜๋ฆฌ๋Š” "๋ฐ์ดํ„ฐ๊ฐ€ ์ค€๋น„๋์„ ๋•Œ๋งŒ ์ด๊ฑธ ๊ทธ๋ ค์ค˜" ๋ผ๊ณ  ์˜๋„๋งŒ ์„ ์–ธํ•ด. Suspense ์™€ ErrorBoundary ๊ฐ€ ๋‚˜๋จธ์ง€๋ฅผ ๋งก์•„.


๐Ÿ”ฎ async read atom ๊ธฐ๋ณธ ๐ŸŸข

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

  • atom(async (get) => {...}) ๋ฌธ๋ฒ•์„ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค
  • async atom ์„ ์“ธ ๋•Œ Suspense ๊ฐ€ ๋ฐ˜๋“œ์‹œ ํ•„์š”ํ•œ ์ด์œ ๋ฅผ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ๋‹ค

๊ธฐ๋ณธ ๋ฌธ๋ฒ•

import { atom } from 'jotai'
 
// ๐Ÿฆ ์˜ํ˜ธ: "read ํ•จ์ˆ˜์— async ๋ฅผ ๋ถ™์ด๋Š” ๊ฒƒ๋งŒ์œผ๋กœ async atom ์ด ๋ผ์š”"
const studyListAtom = atom(async (get) => {
  const response = await fetch('/api/studies')
  if (!response.ok) {
    throw new Error(`API ์˜ค๋ฅ˜: ${response.status}`)
  }
  return response.json() as Promise<Study[]>
})

๋‹ค๋ฅธ atom ์— ์˜์กดํ•˜๋Š” async atom

derived atom ์ฒ˜๋Ÿผ, async atom ๋„ ๋‹ค๋ฅธ atom ์˜ ๊ฐ’์— ์˜์กดํ•  ์ˆ˜ ์žˆ์–ด:

const searchKeywordAtom = atom('')
 
// ๐Ÿฆ ์˜ํ˜ธ: "searchKeyword ๊ฐ€ ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ์ด atom ์ด ์ž๋™์œผ๋กœ ์žฌ์‹คํ–‰๋ผ์š”"
const filteredStudyListAtom = atom(async (get) => {
  const keyword = get(searchKeywordAtom) // ๋™๊ธฐ atom ๊ตฌ๋…
 
  const response = await fetch(`/api/studies?q=${encodeURIComponent(keyword)}`)
  if (!response.ok) throw new Error('๊ฒ€์ƒ‰ ์‹คํŒจ')
  return response.json() as Promise<Study[]>
})

๐Ÿ”— ์—ฐ๊ฒฐ ๊ณ ๋ฆฌ
์ด ํŒจํ„ด์ด derived atom ์˜ async ๋ฒ„์ „์ด์•ผ. 03. derived atom ๊ณผ ํ•จ๊ป˜ ์ฝ์œผ๋ฉด ๋” ๋ช…ํ™•ํ•ด.

Suspense ๊ฐ€ ๋ฐ˜๋“œ์‹œ ํ•„์š”ํ•œ ์ด์œ 

async atom ์„ ์‚ฌ์šฉํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ๋Š” Promise ๊ฐ€ resolve ๋˜๊ธฐ ์ „๊นŒ์ง€ ๋ Œ๋”๋ง์„ "์ค‘๋‹จ(suspend)" ํ•ด. React ๋Š” ์ด ์ค‘๋‹จ ์‹ ํ˜ธ๋ฅผ ๋ฐ›์•„์„œ ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์ƒ์œ„ <Suspense> ์˜ fallback ์„ ๋ณด์—ฌ์ค˜.

// โŒ ๐Ÿฃ ์˜์ฒ : "์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋˜์ง€ ์•Š๋‚˜์š”?"
const App = () => (
  // Suspense ์—†์ด async atom ์“ฐ๋Š” ์ปดํฌ๋„ŒํŠธ ๋ Œ๋”๋ง ์‹œ๋„
  <StudyList />  // React ๊ฐ€ "์ค‘๋‹จ ์‹ ํ˜ธ๋ฅผ ๋ฐ›์„ Suspense ๊ฐ€ ์—†์–ด์š”!" ์—๋Ÿฌ๋ฅผ ๋˜์ ธ
)
// โœ… ๐Ÿฆ ์˜ํ˜ธ: "Suspense ๋กœ ๋ฐ˜๋“œ์‹œ ๊ฐ์‹ธ์•ผ ํ•ด์š”"
const App = () => (
  <Suspense fallback={<StudyListSkeleton />}>
    <StudyList />
    {/* Promise ๊ฐ€ pending ์ด๋ฉด โ†’ <StudyListSkeleton /> ํ‘œ์‹œ */}
    {/* Promise ๊ฐ€ resolve ๋˜๋ฉด โ†’ <StudyList /> ๋ Œ๋”๋ง */}
  </Suspense>
)

๐Ÿ’ก ํŒ Jotai ๋ ˆํผ๋Ÿฐ์Šค์—๋Š” "Provider ๊ฐ€ ์žˆ์„ ๊ฒฝ์šฐ, <Suspense> ๋ฅผ Provider ๋‚ด๋ถ€์— ๋ฐฐ์น˜ํ•˜๋ผ" ๊ณ  ๋ช…์‹œ๋ผ ์žˆ์–ด. Provider ๋ฐ”๊นฅ์— Suspense ๋ฅผ ๋‘๋ฉด ๋ฌดํ•œ ๋ Œ๋”๋ง ๋ฃจํ”„๊ฐ€ ์ƒ๊ธธ ์ˆ˜ ์žˆ์–ด.

// โœ… ์˜ฌ๋ฐ”๋ฅธ ์ˆœ์„œ
<Provider>
  <Suspense fallback="๋กœ๋”ฉ ์ค‘...">
    <Layout />
  </Suspense>
</Provider>

async atom ์ด ๋˜ ๋‹ค๋ฅธ async atom ์— ์˜์กดํ•  ๋•Œ

const rawStudyListAtom = atom(async (get) => {
  const res = await fetch('/api/studies')
  return res.json() as Promise<Study[]>
})
 
// ๐Ÿฆ ์˜ํ˜ธ: "async atom ์„ get ์œผ๋กœ ๊ฐ€์ ธ์˜ฌ ๋•Œ๋Š” await ๋ฅผ ๋ถ™์—ฌ์š”"
const enrichedStudyListAtom = atom(async (get) => {
  const studies = await get(rawStudyListAtom) // await ํ•„์ˆ˜!
  return studies.filter((s) => s.isActive).map((s) => ({
    ...s,
    memberCount: s.members.length,
  }))
})

๐Ÿ›‘ options.signal โ€” AbortController ์ž๋™ ์—ฐ๋™ ๐ŸŸก

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

  • signal ์˜ต์…˜์œผ๋กœ ์ด์ „ ์š”์ฒญ์„ ์ž๋™ ์ทจ์†Œํ•˜๋Š” ๋ฒ•์„ ์•Œ ์ˆ˜ ์žˆ๋‹ค
  • ์ž๋™์™„์„ฑ(debounce) ๊ฒ€์ƒ‰์—์„œ signal ์ด ์™œ ํ•„์ˆ˜์ธ์ง€ ์ดํ•ดํ•œ๋‹ค

๐Ÿค” ์ž ๊น, ๋จผ์ € ์ƒ๊ฐํ•ด๋ด
์‚ฌ์šฉ์ž๊ฐ€ ๊ฒ€์ƒ‰์ฐฝ์— "R", "Re", "Rea", "Reac", "React" ๋ฅผ ๋น ๋ฅด๊ฒŒ ์ž…๋ ฅํ•˜๋ฉด API ์š”์ฒญ์ด 5๋ฒˆ ๋‚˜๊ฐ€. "React" ๊ฒฐ๊ณผ๋ณด๋‹ค "R" ๊ฒฐ๊ณผ๊ฐ€ ๋Šฆ๊ฒŒ ๋Œ์•„์˜ค๋ฉด ์–ด๋–ค ์ผ์ด ์ƒ๊ธธ๊นŒ?

Race Condition โ€” ์š”์ฒญ ์ˆœ์„œ์™€ ์‘๋‹ต ์ˆœ์„œ๊ฐ€ ๋’ค๋ฐ”๋€Œ๋Š” ์žฌ์•™

์š”์ฒญ 1: "R"     โ†’ ์‘๋‹ต 2์ดˆ ํ›„ ๋„์ฐฉ (๋А๋ฆฐ ์„œ๋ฒ„)
์š”์ฒญ 2: "Re"    โ†’ ์‘๋‹ต 1.8์ดˆ ํ›„ ๋„์ฐฉ
์š”์ฒญ 3: "Rea"   โ†’ ์‘๋‹ต 1.5์ดˆ ํ›„ ๋„์ฐฉ
์š”์ฒญ 4: "Reac"  โ†’ ์‘๋‹ต 1.2์ดˆ ํ›„ ๋„์ฐฉ
์š”์ฒญ 5: "React" โ†’ ์‘๋‹ต 1์ดˆ ํ›„ ๋„์ฐฉ (๋จผ์ € ๋„์ฐฉ!)

ํ™”๋ฉด: "React" ๊ฒฐ๊ณผ ํ‘œ์‹œ
โ†’ 1.2์ดˆ ํ›„: "Reac" ๊ฒฐ๊ณผ๋กœ ๋ฎ์–ด์”€
โ†’ 1.5์ดˆ ํ›„: "Rea" ๊ฒฐ๊ณผ๋กœ ๋ฎ์–ด์”€
โ†’ ์ตœ์ข… ํ™”๋ฉด: "R" ์— ๋Œ€ํ•œ ๊ฒฐ๊ณผ๊ฐ€ ๋ณด์ž„ ๐Ÿ˜ฑ

Jotai ์˜ signal ์œผ๋กœ ์ž๋™ ํ•ด๊ฒฐ

Jotai ๋Š” async read atom ์˜ ๋‘ ๋ฒˆ์งธ ์ธ์ž๋กœ { signal } ์„ ์ œ๊ณตํ•ด. ์ด signal ์€ atom ์ด ์žฌ์‹คํ–‰๋˜๊ธฐ ์ง์ „์— ์ž๋™์œผ๋กœ abort ๋ผ:

// โœ… ๐Ÿฆ ์˜ํ˜ธ: "signal ํ•˜๋‚˜๋กœ Race Condition ์„ ์™„์ „ํžˆ ์ฐจ๋‹จํ•  ์ˆ˜ ์žˆ์–ด์š”"
const searchKeywordAtom = atom('')
 
const searchResultAtom = atom(async (get, { signal }) => {
  // ๐Ÿฆ ์˜ํ˜ธ: "searchKeyword ๊ฐ€ ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ์ด์ „ ์š”์ฒญ์˜ signal ์ด abort ๋ผ์š”"
  const keyword = get(searchKeywordAtom)
 
  if (!keyword.trim()) return [] // ๋นˆ ๊ฒ€์ƒ‰์–ด๋Š” ๋นˆ ๋ฐฐ์—ด ๋ฐ˜ํ™˜
 
  const response = await fetch(
    `/api/studies/search?q=${encodeURIComponent(keyword)}`,
    { signal } // ๐Ÿฆ ์˜ํ˜ธ: "fetch ์— signal ์„ ๋„˜๊ธฐ๋ฉด ์ด์ „ ์š”์ฒญ์ด ์ž๋™ ์ทจ์†Œ๋ผ์š”"
  )
 
  if (!response.ok) throw new Error('๊ฒ€์ƒ‰ ์‹คํŒจ')
  return response.json() as Promise<Study[]>
  // ๐Ÿฃ ์˜์ฒ : "์ด๊ฒŒ ์ง„์งœ์˜ˆ์š”? signal ํ•˜๋‚˜๋กœ race condition ์ด ํ•ด๊ฒฐ๋œ๋‹ค๊ณ ์š”?"
})
// ๊ฒ€์ƒ‰ ์ปดํฌ๋„ŒํŠธ โ€” ์ž๋™์™„์„ฑ UI
const StudySearchBar = () => {
  const [keyword, setKeyword] = useAtom(searchKeywordAtom)
 
  return (
    <input
      value={keyword}
      onChange={(e) => setKeyword(e.target.value)}
      placeholder="์Šคํ„ฐ๋”” ๊ฒ€์ƒ‰..."
    />
  )
}
 
const StudySearchResults = () => {
  // ๐Ÿฆ ์˜ํ˜ธ: "์ด ์ปดํฌ๋„ŒํŠธ๋Š” ํ•ญ์ƒ ์ตœ์‹  ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋งŒ ๋ณด์—ฌ์ค˜์š”"
  const results = useAtomValue(searchResultAtom)
  return (
    <ul>
      {results.map((study) => (
        <li key={study.id}>{study.title}</li>
      ))}
    </ul>
  )
}
 
// ๋ ˆ์ด์•„์›ƒ
const SearchPage = () => (
  <div>
    <StudySearchBar />
    <Suspense fallback={<SearchSkeleton />}>
      <StudySearchResults />
    </Suspense>
  </div>
)

signal ์ด abort ๋  ๋•Œ ๋‚ด๋ถ€์—์„œ ์ผ์–ด๋‚˜๋Š” ์ผ

์‚ฌ์šฉ์ž: "Re" ์ž…๋ ฅ
  โ†“
searchKeyword ๋ณ€๊ฒฝ โ†’ searchResultAtom ์žฌ์‹คํ–‰ ์˜ˆ์•ฝ
  โ†“
์ด์ „ "R" ์š”์ฒญ์˜ signal.abort() ์ž๋™ ํ˜ธ์ถœ
  โ†“
fetch('/api/studies/search?q=R', { signal }) โ†’ AbortError ๋ฐœ์ƒ
  โ†“
Jotai ๊ฐ€ AbortError ๋ฅผ ์กฐ์šฉํžˆ ๋ฌด์‹œ (ํ™”๋ฉด์— ํ‘œ์‹œ ์•ˆ ๋จ)
  โ†“
"Re" ์— ๋Œ€ํ•œ ์ƒˆ ์š”์ฒญ ์‹œ์ž‘

๐Ÿ’ก ํ•œ ์ค„๋กœ ๊ธฐ์–ตํ•˜๊ธฐ
signal ์€ "๊ฒ€์ƒ‰์ฐฝ์— ๋ญ”๊ฐ€ ์ƒˆ๋กœ ์น  ๋•Œ๋งˆ๋‹ค ์ด์ „ ํƒ์‹œ๋ฅผ ์บ”์Šฌํ•˜๋Š” ๋ฒ„ํŠผ" ์ด์•ผ. Jotai ๊ฐ€ ์•Œ์•„์„œ ๋ˆŒ๋Ÿฌ์ค˜.


๐Ÿงฑ Suspense + ErrorBoundary ์—ฐ๋™ ๐ŸŸก

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

  • <Suspense> ์™€ <ErrorBoundary> ๋ฅผ ์กฐํ•ฉํ•˜๋Š” ์˜ฌ๋ฐ”๋ฅธ ํŒจํ„ด์„ ์•Œ ์ˆ˜ ์žˆ๋‹ค
  • Next.js App Router ์—์„œ ์ด ํŒจํ„ด์„ ์ ์šฉํ•˜๋Š” ๋ฒ•์„ ์ดํ•ดํ•œ๋‹ค

์™„์ „ํ•œ ์„ ์–ธ์  ๋น„๋™๊ธฐ UI ํŒจํ„ด

// ์—๋Ÿฌ ๋ฐ”์šด๋”๋ฆฌ (react-error-boundary ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ)
import { ErrorBoundary } from 'react-error-boundary'
 
// ๐Ÿฆ ์˜ํ˜ธ: "์ด 3๊ฐœ์˜ ๋ ˆ์ด์–ด๊ฐ€ ๊ฐ์ž ํ•  ์ผ์„ ๋ช…ํ™•ํžˆ ๋ถ„๋‹ดํ•ด์š”"
const StudySection = () => (
  <ErrorBoundary
    fallback={<StudyErrorFallback />}
    // ์—๋Ÿฌ๊ฐ€ throw ๋˜๋ฉด ErrorBoundary ๊ฐ€ ์žก์•„์„œ fallback ํ‘œ์‹œ
  >
    <Suspense fallback={<StudyListSkeleton />}>
      {/* Promise pending ์ด๋ฉด Skeleton ํ‘œ์‹œ, resolve ๋˜๋ฉด StudyList ํ‘œ์‹œ */}
      <StudyList />
    </Suspense>
  </ErrorBoundary>
)
 
// ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ํ‘œ์‹œํ•  ์ปดํฌ๋„ŒํŠธ
const StudyErrorFallback = () => (
  <div className="error-box">
    <p>์Šคํ„ฐ๋”” ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์–ด์š” ๐Ÿ˜ข</p>
    <button onClick={() => window.location.reload()}>๋‹ค์‹œ ์‹œ๋„</button>
  </div>
)

Next.js App Router ์—์„œ์˜ ํŒจํ„ด

Next.js App Router ์—์„œ๋Š” loading.tsx ์™€ error.tsx ํŒŒ์ผ์ด ๊ฐ๊ฐ Suspense ์™€ ErrorBoundary ์—ญํ• ์„ ํ•ด:

// app/studies/loading.tsx โ€” ์ž๋™์œผ๋กœ Suspense fallback ์—ญํ• 
export default function StudiesLoading() {
  return <StudyListSkeleton />
}
 
// app/studies/error.tsx โ€” ์ž๋™์œผ๋กœ ErrorBoundary fallback ์—ญํ• 
'use client'
export default function StudiesError({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div>
      <p>์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์–ด์š”: {error.message}</p>
      <button onClick={reset}>๋‹ค์‹œ ์‹œ๋„</button>
    </div>
  )
}
 
// app/studies/page.tsx โ€” Server Component
// ๐Ÿฆ ์˜ํ˜ธ: "ํŽ˜์ด์ง€ ์ž์ฒด๋Š” Server Component ๋กœ ์œ ์ง€ํ•˜๊ณ ,
//          atom ์„ ์“ฐ๋Š” ๋ถ€๋ถ„์€ Client Component ๋กœ ๋ถ„๋ฆฌํ•ด์š”"
import { Suspense } from 'react'
import { StudyListClient } from './StudyListClient'
 
export default function StudiesPage() {
  return (
    <main>
      <h1>์Šคํ„ฐ๋”” ๋ชฉ๋ก</h1>
      <Suspense fallback={<StudyListSkeleton />}>
        <StudyListClient />
      </Suspense>
    </main>
  )
}
// app/studies/StudyListClient.tsx โ€” Client Component
'use client'
import { useAtomValue } from 'jotai'
import { studyListAtom } from '@/atoms/study'
 
export const StudyListClient = () => {
  // ๐Ÿฃ ์˜์ฒ : "์ปดํฌ๋„ŒํŠธ ์ฝ”๋“œ๊ฐ€ ์ด๊ฒŒ ๋‹ค์˜ˆ์š”? ๋กœ๋”ฉ ๋ถ„๊ธฐ ํ•˜๋‚˜๋„ ์—†๊ณ ?"
  const studies = useAtomValue(studyListAtom)
  return (
    <ul>
      {studies.map((study) => (
        <StudyCard key={study.id} study={study} />
      ))}
    </ul>
  )
}

์ค‘์ฒฉ Suspense โ€” ์„ธ๋ฐ€ํ•œ ๋กœ๋”ฉ UI ์ œ์–ด

์ปดํฌ๋„ŒํŠธ ํŠธ๋ฆฌ์˜ ์—ฌ๋Ÿฌ ๊ณณ์— Suspense ๋ฅผ ๋ฐฐ์น˜ํ•˜๋ฉด, ๊ฐ ์˜์—ญ์˜ ๋กœ๋”ฉ ์ƒํƒœ๋ฅผ ๋…๋ฆฝ์ ์œผ๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด:

// ๐Ÿฆ ์˜ํ˜ธ: "Suspense ๋ฅผ ์„ธ๋ถ„ํ™”ํ•˜๋ฉด ์ผ๋ถ€๋งŒ ๋กœ๋”ฉ ์ค‘์—๋„ ๋‚˜๋จธ์ง€๋Š” ๋ณด์—ฌ์ค„ ์ˆ˜ ์žˆ์–ด์š”"
const StudyPage = () => (
  <div>
    {/* ํ—ค๋”๋Š” ํ•ญ์ƒ ํ‘œ์‹œ */}
    <StudyPageHeader />
 
    <div className="layout">
      {/* ํ•„ํ„ฐ๋Š” ๋…๋ฆฝ์ ์œผ๋กœ ๋กœ๋”ฉ */}
      <Suspense fallback={<FilterSkeleton />}>
        <StudyFilterPanel />
      </Suspense>
 
      {/* ๋ชฉ๋ก์€ ๋…๋ฆฝ์ ์œผ๋กœ ๋กœ๋”ฉ */}
      <Suspense fallback={<StudyListSkeleton />}>
        <StudyList />
      </Suspense>
    </div>
  </div>
)

๐Ÿ”„ loadable() ์œ ํ‹ธ โ€” Suspense ์—†์ด ๋กœ๋”ฉ ๋‹ค๋ฃจ๊ธฐ ๐ŸŸก

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

  • loadable() ์ด ์–ด๋–ค ์ƒํ™ฉ์—์„œ ์œ ์šฉํ•œ์ง€ ์•Œ ์ˆ˜ ์žˆ๋‹ค
  • loadable() ์˜ ๋ฐ˜ํ™˜ ํƒ€์ž…(loading | hasData | hasError)์„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค

loadable() ์ด ํ•„์š”ํ•œ ์ƒํ™ฉ

Suspense ๋ฅผ ์“ธ ์ˆ˜ ์—†๊ฑฐ๋‚˜ ์›ํ•˜์ง€ ์•Š๋Š” ์ƒํ™ฉ์ด ์žˆ์–ด:

  • ์„œ๋“œํŒŒํ‹ฐ ์ปดํฌ๋„ŒํŠธ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ Suspense ์™€ ํ˜ธํ™˜๋˜์ง€ ์•Š์„ ๋•Œ
  • ๋กœ๋”ฉ/์—๋Ÿฌ/๋ฐ์ดํ„ฐ ์ƒํƒœ๋ฅผ ํ•œ ์ปดํฌ๋„ŒํŠธ ์•ˆ์—์„œ ์ง์ ‘ ์ฒ˜๋ฆฌํ•˜๊ณ  ์‹ถ์„ ๋•Œ
  • ๊ธฐ์กด ์ฝ”๋“œ๋ฒ ์ด์Šค์— ์ ์ง„์ ์œผ๋กœ ๋„์ž…ํ•  ๋•Œ
import { loadable } from 'jotai/utils'
 
// ๐Ÿฆ ์˜ํ˜ธ: "loadable ๋กœ ๊ฐ์‹ธ๋ฉด Suspense ์—†์ด ์ƒํƒœ๋ฅผ ์ง์ ‘ ์ฝ์„ ์ˆ˜ ์žˆ์–ด์š”"
const loadableStudyListAtom = loadable(studyListAtom)
 
const StudyListWithLoadable = () => {
  // ๐Ÿฃ ์˜์ฒ : "ํƒ€์ž…์ด ๋ญ๊ฐ€ ๋‚˜์˜ค๋Š” ๊ฑฐ์˜ˆ์š”?"
  const studiesLoadable = useAtomValue(loadableStudyListAtom)
  // studiesLoadable ํƒ€์ž…:
  // { state: 'loading' }
  // { state: 'hasData'; data: Study[] }
  // { state: 'hasError'; error: unknown }
 
  if (studiesLoadable.state === 'loading') {
    return <StudyListSkeleton />
  }
 
  if (studiesLoadable.state === 'hasError') {
    return (
      <div>
        ์—๋Ÿฌ: {studiesLoadable.error instanceof Error
          ? studiesLoadable.error.message
          : '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'}
      </div>
    )
  }
 
  // state === 'hasData' ์ด๋ฉด data ์ ‘๊ทผ ๊ฐ€๋Šฅ
  return (
    <ul>
      {studiesLoadable.data.map((study) => (
        <StudyCard key={study.id} study={study} />
      ))}
    </ul>
  )
}

Suspense vs loadable() ์„ ํƒ ๊ธฐ์ค€

์ƒํ™ฉ์ถ”์ฒœ
์ƒˆ ํ”„๋กœ์ ํŠธ, ์ปดํฌ๋„ŒํŠธ ํŠธ๋ฆฌ ์„ค๊ณ„ ์ž์œ ๋กœ์›€โœ… Suspense + ErrorBoundary
๋กœ๋”ฉ/์—๋Ÿฌ/๋ฐ์ดํ„ฐ๋ฅผ ํ•œ ์ปดํฌ๋„ŒํŠธ์—์„œ ์„ธ๋ฐ€ํžˆ ์ œ์–ดโœ… loadable()
์„œ๋“œํŒŒํ‹ฐ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ Suspense ๋ฏธ์ง€์›โœ… loadable()
Next.js App Router ์˜ loading.tsx ํ™œ์šฉโœ… Suspense (์ž๋™ ํ†ตํ•ฉ)
๊ธฐ์กด isLoading/isError ํŒจํ„ด์„ ์ ์ง„์ ์œผ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜โœ… loadable() (์ค‘๊ฐ„ ๋‹จ๊ณ„)

๐Ÿ’ก ํŒ loadable() ์€ Suspense ๋ฅผ ์™„์ „ํžˆ ๋Œ€์ฒดํ•˜๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ ๋ณด์™„ ํ•˜๋Š” ๋„๊ตฌ์•ผ. ์‹œ์ž‘์€ Suspense ๋กœ, ์„ธ๋ฐ€ํ•œ ์ œ์–ด๊ฐ€ ํ•„์š”ํ•œ ๊ณณ๋งŒ loadable() ๋กœ ์ „ํ™˜ํ•˜๋Š” ์ „๋žต์ด ์ข‹์•„.


โš–๏ธ TanStack Query ์™€์˜ ์—ญํ•  ๋น„๊ต ๐ŸŸก

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

  • "์„œ๋ฒ„ ์ƒํƒœ๋Š” Query, ํŒŒ์ƒ/ํด๋ผ์ด์–ธํŠธ ์ƒํƒœ๋Š” Jotai" ์›์น™์˜ ์˜๋ฏธ๋ฅผ ์ดํ•ดํ•œ๋‹ค
  • ๋‘ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ•จ๊ป˜ ์“ธ ๋•Œ ๊ฐ์ž์˜ ์—ญํ• ์„ ๋ช…ํ™•ํžˆ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ๋‹ค

์˜์ˆ˜๋„ค ํŒ€์€ TanStack Query ์™€ Jotai ๋ฅผ ํ•จ๊ป˜ ์“ฐ๊ณ  ์žˆ์–ด. ์ด ๋‘ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์˜ ์—ญํ• ์„ ํ—ท๊ฐˆ๋ฆฌ๋ฉด ์ค‘๋ณต ์ฝ”๋“œ๊ฐ€ ์ƒ๊ฒจ:

์—ญํ•  ๋ถ„๋ฆฌ ์›์น™

TanStack Query ๊ฐ€ ๋‹ด๋‹น
  โ””โ”€โ”€ ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ ํŽ˜์นญ (์บ์‹ฑ, ์žฌ์‹œ๋„, invalidation, stale-while-revalidate)
  โ””โ”€โ”€ ์„œ๋ฒ„ ์ƒํƒœ์™€ ํด๋ผ์ด์–ธํŠธ ์ƒํƒœ ๋™๊ธฐํ™”

Jotai ๊ฐ€ ๋‹ด๋‹น
  โ””โ”€โ”€ ํด๋ผ์ด์–ธํŠธ UI ์ƒํƒœ (๋ชจ๋‹ฌ ์—ด๋ฆผ ์—ฌ๋ถ€, ์„ ํƒ๋œ ํƒญ, ํ•„ํ„ฐ ๊ฐ’)
  โ””โ”€โ”€ ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๋กœ๋ถ€ํ„ฐ ํŒŒ์ƒ๋œ ํด๋ผ์ด์–ธํŠธ ๊ณ„์‚ฐ ๊ฒฐ๊ณผ
  โ””โ”€โ”€ ์ปดํฌ๋„ŒํŠธ ๊ฐ„ ๊ณต์œ ๋˜์–ด์•ผ ํ•˜๋Š” UI ์ƒํƒœ
// โœ… ์—ญํ• ์„ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌํ•œ ์˜์ˆ˜๋„ค ํŒ€์˜ ์ฝ”๋“œ
// ๐Ÿฆ ์˜ํ˜ธ: "์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๋Š” Query, UI ์ƒํƒœ์™€ ํŒŒ์ƒ ์ƒํƒœ๋Š” Jotai"
 
// --- TanStack Query: ์„œ๋ฒ„ ์ƒํƒœ ๊ด€๋ฆฌ ---
const useStudiesQuery = () =>
  useQuery({
    queryKey: ['studies'],
    queryFn: () => fetch('/api/studies').then((r) => r.json()),
    staleTime: 60 * 1000, // 1๋ถ„๊ฐ„ fresh ์œ ์ง€
  })
 
// --- Jotai: ํด๋ผ์ด์–ธํŠธ UI ์ƒํƒœ ---
const searchKeywordAtom = atom('')
const selectedTagAtom = atom<string | null>(null)
const isFilterOpenAtom = atom(false)
 
// --- Jotai: ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๋ฅผ ํŒŒ์ƒ์‹œํ‚จ ํด๋ผ์ด์–ธํŠธ ๊ณ„์‚ฐ ---
// Query ๊ฒฐ๊ณผ๋ฅผ ์ง์ ‘ atom ์— ์ฃผ์ž…ํ•˜๋Š” ํŒจํ„ด
const rawStudiesAtom = atom<Study[]>([])  // Query ๊ฒฐ๊ณผ๋ฅผ ์ €์žฅ
 
const filteredStudiesAtom = atom((get) => {
  const studies = get(rawStudiesAtom)
  const keyword = get(searchKeywordAtom)
  const tag = get(selectedTagAtom)
 
  return studies
    .filter((s) => s.title.includes(keyword))
    .filter((s) => !tag || s.tags.includes(tag))
})
 
// Query ๊ฒฐ๊ณผ๋ฅผ atom ์— ๋™๊ธฐํ™”ํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ
const StudyDataSync = () => {
  const { data: studies = [] } = useStudiesQuery()
  const setRawStudies = useSetAtom(rawStudiesAtom)
 
  useEffect(() => {
    setRawStudies(studies)
  }, [studies, setRawStudies])
 
  return null
}

๐Ÿ”— ๋” ๊นŠ์ด ํŒŒ๊ณ ๋“ค๊ธฐ
TanStack Query ์™€ Jotai ์˜ ํ˜‘์—… ํŒจํ„ด์€ 12. TanStack Query + Jotai ์‹ค์ „ ์กฐํ•ฉ ์—์„œ ์•„ํ‚คํ…์ฒ˜ ์ˆ˜์ค€์œผ๋กœ ๋‹ค๋ค„.


๐Ÿ’ฅ ์—๋Ÿฌ ํ•ด๊ฒฐ ์นดํƒˆ๋กœ๊ทธ

์—๋Ÿฌ ๋ฉ”์‹œ์ง€๊ฐ€ ๋œจ๋ฉด Ctrl+F ๋กœ ๊ฒ€์ƒ‰ํ•ด๋ด. ๋Œ€๋ถ€๋ถ„ ์—ฌ๊ธฐ ์žˆ์–ด.


โŒ Error: A component suspended while responding to synchronous input

์ฆ์ƒ: ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ ์‹œ React ๊ฐ€ ์—๋Ÿฌ๋ฅผ ๋˜์ง

์›์ธ:

// ๐Ÿฃ ์˜์ฒ : "์™œ ์—๋Ÿฌ๊ฐ€ ๋‚˜์ง€? Suspense ๋กœ ๊ฐ์‹ธ๋†จ๋Š”๋ฐ..."
const SearchResults = () => {
  const [keyword, setKeyword] = useAtom(searchKeywordAtom)
  // searchKeywordAtom ๋ณ€๊ฒฝ โ†’ searchResultAtom ์žฌ์‹คํ–‰ โ†’ suspend
  // React 18 ์ดํ•˜์—์„œ ๋™๊ธฐ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ๋‚ด์—์„œ suspend ์‹œ ์—๋Ÿฌ
  const results = useAtomValue(searchResultAtom)
  return <ul>{...}</ul>
}

ํ•ด๊ฒฐ์ฑ…:

// โœ… ์ฝ๊ธฐ/์“ฐ๊ธฐ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ suspend ๋ฅผ ๊ฒฉ๋ฆฌ
const SearchBar = () => {
  const [keyword, setKeyword] = useAtom(searchKeywordAtom)
  return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
}
 
// ์ด ์ปดํฌ๋„ŒํŠธ๋งŒ Suspense ๋‚ด๋ถ€์— ๋ฐฐ์น˜
const SearchResults = () => {
  const results = useAtomValue(searchResultAtom) // ์ด ์ปดํฌ๋„ŒํŠธ๋งŒ suspend
  return <ul>{results.map(...)}</ul>
}
 
const SearchPage = () => (
  <>
    <SearchBar />
    <Suspense fallback={<SearchSkeleton />}>
      <SearchResults />
    </Suspense>
  </>
)

โŒ fetch ์ทจ์†Œ ํ›„ AbortError ๊ฐ€ ErrorBoundary ์— ์žกํž˜

์ฆ์ƒ: ๊ฒ€์ƒ‰ํ•  ๋•Œ๋งˆ๋‹ค ErrorBoundary ์˜ ์—๋Ÿฌ fallback ์ด ๋ณด์ž„

์›์ธ: AbortError ๋ฅผ ๋”ฐ๋กœ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์•„์„œ ErrorBoundary ๋กœ ์ „ํŒŒ๋จ

ํ•ด๊ฒฐ์ฑ…:

const searchResultAtom = atom(async (get, { signal }) => {
  const keyword = get(searchKeywordAtom)
 
  try {
    const response = await fetch(`/api/search?q=${keyword}`, { signal })
    return response.json()
  } catch (error) {
    // โœ… AbortError ๋Š” ์ •์ƒ ์ทจ์†Œ์ด๋ฏ€๋กœ ๋นˆ ๋ฐฐ์—ด ๋ฐ˜ํ™˜ or rethrow ์•ˆ ํ•จ
    if (error instanceof Error && error.name === 'AbortError') {
      return [] // ๋˜๋Š” ์ด์ „ ๊ฒฐ๊ณผ ์œ ์ง€
    }
    throw error // ์‹ค์ œ ์—๋Ÿฌ๋งŒ throw
  }
})

โŒ Suspense ๋‚ด๋ถ€์— Jotai Provider ๋ฐฐ์น˜ ์‹œ ๋ฌดํ•œ ๋ฃจํ”„

์ฆ์ƒ: ํŽ˜์ด์ง€๊ฐ€ ์˜์›ํžˆ ๋กœ๋”ฉ ์ค‘ ์ƒํƒœ

์›์ธ: Provider ๋ฐ”๊นฅ์— Suspense ๋ฅผ ๋ฐฐ์น˜ํ•˜๋ฉด Jotai ๊ฐ€ ๋ฌดํ•œ ๋ Œ๋”๋ง

ํ•ด๊ฒฐ์ฑ…:

// โŒ ์ž˜๋ชป๋œ ์ˆœ์„œ
<Suspense fallback="๋กœ๋”ฉ...">
  <Provider>
    <App />
  </Provider>
</Suspense>
 
// โœ… ์˜ฌ๋ฐ”๋ฅธ ์ˆœ์„œ โ€” Provider ๊ฐ€ Suspense ๋ฅผ ๊ฐ์‹ธ์•ผ ํ•จ
<Provider>
  <Suspense fallback="๋กœ๋”ฉ...">
    <App />
  </Suspense>
</Provider>

๐Ÿ ์ด๋ฒˆ์— ๋ฐฐ์šด ๋‚ด์šฉ ์ด์ •๋ฆฌ

๐Ÿ“‹ ๋น„๋™๊ธฐ atom ์ฒ˜๋ฆฌ ๋ฐฉ์‹ ์š”์•ฝ

๋ฐฉ์‹์ฝ”๋“œ ๋ณต์žก๋„์ปดํฌ๋„ŒํŠธ ์ฑ…์ž„์ถ”์ฒœ ์ƒํ™ฉ
isLoading useState๋†’์Œ (ํญํƒ„ 4๊ฐœ)์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ „๋ถ€๋ ˆ๊ฑฐ์‹œ ์ฝ”๋“œ ์œ ์ง€๋ณด์ˆ˜
async atom + Suspense๋‚ฎ์Œ์„ ์–ธ๋งŒ์ƒˆ ํ”„๋กœ์ ํŠธ, ํ‘œ์ค€ ํŒจํ„ด
async atom + loadable()์ค‘๊ฐ„์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ƒํƒœ ๋ถ„๊ธฐ์„ธ๋ฐ€ํ•œ ์ œ์–ด ํ•„์š” ์‹œ

โš ๏ธ ์ ˆ๋Œ€ ํ•˜์ง€ ๋ง ๊ฒƒ

์ƒํ™ฉโŒ ๋‚˜์œ ์˜ˆโœ… ์ข‹์€ ์˜ˆ
Provider ์œ„์น˜Suspense ์•ˆ์— Provider ๋ฐฐ์น˜Provider ์•ˆ์— Suspense ๋ฐฐ์น˜
Race Conditionsignal ์—†์ด fetch{ signal } ๋„˜๊ฒจ์„œ ์ž๋™ ์ทจ์†Œ
๋น„๋™๊ธฐ ์—๋Ÿฌ ์ฒ˜๋ฆฌAbortError ๋ฌด์กฐ๊ฑด throwAbortError ๋Š” ์กฐ์šฉํžˆ ์ฒ˜๋ฆฌ

๐Ÿ“ ๋งˆ๋ฌด๋ฆฌ ํ€ด์ฆˆ

Q1. ๐Ÿ”ด ์‹œ๋‹ˆ์–ด ๋ฉด์ ‘ ์งˆ๋ฌธ (์˜์ฒ ์˜ ๋ฉด์ ‘ ๋„์ „)

์˜์ฒ ์ด๊ฐ€ ์ด์ง ๋ฉด์ ‘์—์„œ ๋ฌผ์—ˆ์–ด:
"Jotai ์˜ async atom ์—์„œ signal ์˜ต์…˜์„ ์‚ฌ์šฉํ•˜๋ฉด ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ํ•ด๊ฒฐ๋˜๋‚˜์š”?"

  • A) ๋น„๋™๊ธฐ ์š”์ฒญ์˜ ์‘๋‹ต ์†๋„๊ฐ€ ๋นจ๋ผ์ง„๋‹ค
  • B) ์ด์ „ ๋น„๋™๊ธฐ ์š”์ฒญ์ด ์ž๋™์œผ๋กœ ์ทจ์†Œ๋˜์–ด Race Condition ์ด ๋ฐฉ์ง€๋œ๋‹ค
  • C) Suspense ์—†์ด๋„ ๋น„๋™๊ธฐ atom ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค
  • D) ErrorBoundary ๊ฐ€ ํ•„์š” ์—†์–ด์ง„๋‹ค

โœ… ์ •๋‹ต: B

๐Ÿ’ก ์ƒ์„ธ ํ•ด์„ค:

  • ์›๋ฆฌ ์„ค๋ช…: signal ์€ AbortController.signal ์ด์•ผ. Jotai ๋Š” async atom ์ด ์žฌ์‹คํ–‰๋  ๋•Œ ์ด์ „ ์‹คํ–‰์˜ signal ์„ abort ํ•ด. fetch(url, { signal }) ์— ๋„˜๊ธฐ๋ฉด, atom ์ด ์žฌ์‹คํ–‰๋˜๋Š” ์ˆœ๊ฐ„ ์ด์ „ fetch ์š”์ฒญ์ด ์ž๋™์œผ๋กœ ์ทจ์†Œ๋ผ. ์ด๋ฅผ ํ†ตํ•ด "์ด์ „์— ๋ณด๋‚ธ ์š”์ฒญ์˜ ์‘๋‹ต์ด ๋‚˜์ค‘์— ์˜จ ์‘๋‹ต์„ ๋ฎ์–ด์“ฐ๋Š”" Race Condition ์ด ์›์ฒœ ์ฐจ๋‹จ๋ผ.
  • ์˜ค๋‹ต ํ”ผ๋“œ๋ฐฑ: A ๋Š” ํ‹€๋ ธ์–ด โ€” signal ์€ ์†๋„์™€ ๋ฌด๊ด€ํ•ด. C ๋Š” ํ‹€๋ ธ์–ด โ€” loadable() ์ด ๊ทธ ์—ญํ• ์ด์•ผ. D ๋Š” ํ‹€๋ ธ์–ด โ€” signal ์€ AbortError ์ทจ์†Œ์šฉ์ด๊ณ , ์‹ค์ œ ์„œ๋ฒ„ ์—๋Ÿฌ๋Š” ์—ฌ์ „ํžˆ ErrorBoundary ๊ฐ€ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•ด.
  • ๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: "signal ์€ ๊ฒ€์ƒ‰ํ•  ๋•Œ๋งˆ๋‹ค ์ด์ „ ํƒ์‹œ๋ฅผ ์บ”์Šฌํ•˜๋Š” ๋ฒ„ํŠผ. Jotai ๊ฐ€ ์ž๋™์œผ๋กœ ๋ˆŒ๋Ÿฌ์ค˜."

Q2. ๐Ÿ”ฅ ๊ธด๊ธ‰ ๋””๋ฒ„๊น… (์˜์ˆ™์˜ UX ํ”ผ๋“œ๋ฐฑ)

์˜์ˆ™ ๋‹˜์ด ํ”ผ๋“œ๋ฐฑ์„ ๋‚จ๊ฒผ์–ด: "๊ฒ€์ƒ‰์ฐฝ์— ํƒ€์ดํ•‘ํ•˜๋ฉด ํ™”๋ฉด์— ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๊ฐ€ ๊นœ๋นก์˜€๋‹ค ์—†์–ด์กŒ๋‹ค ํ•ด์š”. ๋ฌด์–ธ๊ฐ€ ๋ฌธ์ œ๊ฐ€ ์žˆ์–ด ๋ณด์—ฌ์š”."
์˜์ฒ ์ด๊ฐ€ ์ฝ”๋“œ๋ฅผ ์—ด์–ด๋ดค๋”๋‹ˆ:

const searchResultAtom = atom(async (get, { signal }) => {
  const keyword = get(searchKeywordAtom)
  const response = await fetch(`/api/search?q=${keyword}`, { signal })
  return response.json()
  // ๐Ÿฃ ์˜์ฒ : "signal ๋„ ๋„ฃ์—ˆ๋Š”๋ฐ ์™œ ์—๋Ÿฌ๊ฐ€ ๋‚˜์ง€?"
})

๊ฐ€์žฅ ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์€ ์›์ธ๊ณผ ํ•ด๊ฒฐ์ฑ…์€?

  • A) signal ๋Œ€์‹  AbortController ๋ฅผ ์ง์ ‘ ๋งŒ๋“ค์–ด์•ผ ํ•œ๋‹ค
  • B) AbortError ๋ฅผ catch ํ•˜์ง€ ์•Š์•„์„œ ErrorBoundary ๋กœ ์ „ํŒŒ๋˜๊ณ  ์žˆ๋‹ค
  • C) Suspense ๋ฅผ ์—ฌ๋Ÿฌ ๊ฐœ ์ค‘์ฒฉํ•ด์•ผ ํ•œ๋‹ค
  • D) loadable() ๋กœ ๋ณ€๊ฒฝํ•ด์•ผ ํ•œ๋‹ค

โœ… ์ •๋‹ต: B

๐Ÿ’ก ์ƒ์„ธ ํ•ด์„ค:

  • ์›๋ฆฌ ์„ค๋ช…: ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ์ด์ „ atom ์‹คํ–‰์˜ signal ์ด abort ๋ผ. ๊ทธ๋Ÿฌ๋ฉด fetch ์—์„œ AbortError ๊ฐ€ throw ๋˜๋Š”๋ฐ, ์ด๊ฑธ ์žก์ง€ ์•Š์œผ๋ฉด ErrorBoundary ๋กœ ์ „ํŒŒ๋ผ. AbortError ๋Š” ์ •์ƒ์ ์ธ ์ทจ์†Œ ์‹ ํ˜ธ์ด๋ฏ€๋กœ catch ํ•ด์„œ ๋ฌด์‹œํ•ด์•ผ ํ•ด.
  • ํ•ด๊ฒฐ์ฑ…: try/catch ๋ธ”๋ก์—์„œ error.name === 'AbortError' ๋ฅผ ์ฒดํฌํ•ด์„œ ์กฐ์šฉํžˆ ์ฒ˜๋ฆฌํ•˜๋ฉด ๋ผ.
  • ๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: "AbortError ๋Š” ์—๋Ÿฌ๊ฐ€ ์•„๋‹ˆ๋ผ '์•ผ, ๊ทธ๋งŒํ•ด๋„ ๋ผ' ์‹ ํ˜ธ์•ผ. ErrorBoundary ์—๊ฒŒ ์ „๋‹ฌํ•  ํ•„์š” ์—†์–ด."

Q3. ์นœ๊ตฌ์—๊ฒŒ ์„ค๋ช…ํ•œ๋‹ค๋ฉด?

Suspense + async atom ์กฐํ•ฉ๊ณผ isLoading useState ์กฐํ•ฉ์˜ ์ฐจ์ด๋ฅผ ๊ฐœ๋ฐœ์ž ์นœ๊ตฌ์—๊ฒŒ ํ•œ ๋ฌธ์žฅ์œผ๋กœ ์„ค๋ช…ํ•ด๋ด.

์˜ˆ์‹œ ๋‹ต๋ณ€:

"isLoading ํŒจํ„ด์€ '๋ฐฐ๋‹ฌ ์ฃผ๋ฌธํ•˜๊ณ  30๋ถ„๋งˆ๋‹ค ์ „ํ™”ํ•ด์„œ ์–ด๋”จ๋ƒ๊ณ  ๋ฌผ์–ด๋ณด๋Š” ๊ฒƒ'์ด๊ณ , Suspense + async atom ์€ '๋ฐฐ๋‹ฌ ์•ฑ์—์„œ ๋„์ฐฉํ•˜๋ฉด ์•Œ๋ฆผ ์˜ค๋„๋ก ์„ค์ •ํ•ด๋†“๋Š” ๊ฒƒ'์ด์•ผ. ์ „์ž๋Š” ๋‚ด๊ฐ€ ์ง์ ‘ ์ฑ™๊ฒจ์•ผ ํ•˜๊ณ , ํ›„์ž๋Š” ์„ ์–ธ๋งŒ ํ•˜๋ฉด ์•Œ์•„์„œ ์ฒ˜๋ฆฌ๋ผ."

๐Ÿ’ก ์ด ๋น„์œ ๋ฅผ ์ง์ ‘ ๋งŒ๋“ค์—ˆ๋‹ค๋ฉด: ์„ ์–ธ์  vs ๋ช…๋ น์  ํŒจ๋Ÿฌ๋‹ค์ž„์„ ์™„์ „ํžˆ ์ดํ•ดํ•œ ๊ฑฐ์•ผ. ๋‹ค์Œ ๊ฐ€์ด๋“œ๋กœ ๋„˜์–ด๊ฐ€๋„ ์ถฉ๋ถ„ํ•ด!


๐Ÿฃ ์˜์ฒ ์ด์˜ ํ‡ด๊ทผ ์ผ๊ธฐ

์˜ค๋Š˜๋„ ์ง€๊ฐํ•  ๋ป”ํ–ˆ๋‹ค๊ฐ€ ๊ฒจ์šฐ ์‚ด์•„๋‚จ์€ ํ•˜๋ฃจ์˜€๋‹ค.

์•„์นจ์— ์ž์‹  ์žˆ๊ฒŒ PR ์˜ฌ๋ ธ๋‹ค๊ฐ€ ์˜ํ˜ธ ๋‹˜ํ•œํ…Œ ์ฝ”๋“œ ๋ฆฌ๋ทฐ ํญํƒ„์„ ๋งž์•˜์–ด. isLoading, isError, data useState 3๊ฐœ์— useEffect ๊นŒ์ง€ โ€” ๋‚ด๊ฐ€ ๋ด๋„ ๋ญ”๊ฐ€ ๋ณต์žกํ•˜๋‹ค ์‹ถ์—ˆ๋Š”๋ฐ, ์—ญ์‹œ ๋“ค์ผฐ๋‹ค.

async atom ์ด Promise ๋ฅผ ๋ฐ›์•„์„œ Suspense ๊ฐ€ ์•Œ์•„์„œ ์ฒ˜๋ฆฌํ•œ๋‹ค๋Š” ๊ฒŒ ์ง„์งœ๋กœ ๋ฏฟ๊ธฐ์ง€ ์•Š์•˜๋Š”๋ฐ, ์ง์ ‘ ์ฝ”๋“œ ๋ฐ”๊พธ๊ณ  ๋‚˜๋‹ˆ๊นŒ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ ˆ๋ฐ˜ ์ดํ•˜๋กœ ์ค„์—ˆ์–ด. ๋กœ๋”ฉ ๋ถ„๊ธฐ ์ฝ”๋“œ๊ฐ€ ํ•˜๋‚˜๋„ ์—†๋Š”๋ฐ ์ œ๋Œ€๋กœ ๋™์ž‘ํ•˜๋Š” ๊ฒŒ ์‹ ๊ธฐํ•ด์„œ ํ˜ผ์ž ์ข€ ๋ฉ ๋•Œ๋ ธ๋‹ค.

๐Ÿ’ก ์˜ค๋Š˜์˜ ๊ตํ›ˆ: "์ปดํฌ๋„ŒํŠธ๋Š” '๋ฌด์—‡์„ ๋ณด์—ฌ์ค„์ง€'๋งŒ ์•Œ๋ฉด ๋ผ. '์–ธ์ œ ๋กœ๋”ฉ์ธ์ง€, ์–ธ์ œ ์—๋Ÿฌ์ธ์ง€'๋Š” Suspense ์™€ ErrorBoundary ์—๊ฒŒ ๋งก๊ฒจ."

signal ๋กœ Race Condition ์„ ๋ง‰๋Š” ๊ฒƒ๋„ ์ฒ˜์Œ ์•Œ์•˜๋Š”๋ฐ... ์†”์งํžˆ ์ด์ „์— ์ง  ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์— signal ์ด ์—†์—ˆ๋‹ค๋Š” ๊ฒŒ ์ข€ ๋ฌด์„œ์›Œ์กŒ์–ด. ๊ฒ€์ƒ‰์–ด ๋น ๋ฅด๊ฒŒ ์น˜๋ฉด ์—‰๋šฑํ•œ ๊ฒฐ๊ณผ๊ฐ€ ๋ณด์ผ ์ˆ˜ ์žˆ์—ˆ๋˜ ๊ฑฐ์ž–์•„. ์˜ค๋Š˜ ๊ณ ์ณ์„œ ๋‹คํ–‰์ด๋‹ค.

๋ฐฐ๋Š” ์—„์ฒญ ๊ณ ํ”„๋„ค. ํ‡ด๊ทผํ•˜๊ณ  ์นผ๊ตญ์ˆ˜ ๋จน๊ณ  ์‹ถ๋‹ค. ์ง‘ ๊ทผ์ฒ˜ ์นผ๊ตญ์ˆ˜ ์ง‘์ด 9์‹œ์— ๋‹ซ๋Š”๋ฐ ๋”ฑ ๋งž์ถฐ๊ฐˆ ์ˆ˜ ์žˆ์„ ๊ฒƒ ๊ฐ™์•„. ๋นจ๋ฆฌ ๊ฐ€์ž.


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