⚛️ 04. Suspense와 React Query의 완벽한 조화

2026년 3월 4일 수정됨

📋 개요

React 19+ 시대의 표준인 useSuspenseQuery를 활용하여 로딩 상태(Spinner)를 컴포넌트 외부로 선언적으로 던지고(Throw), 워터폴을 우회하는 병렬 페칭 전략을 배웁니다.

📋 목차

"영철 님, 저희 앱은 화면 하나 열면 스피너가 왜 5개나 각자 춤을 추고 있나요? 게다가 로딩만 3초가 넘게 걸려요."

☕️ 영철이의 고민: "로딩 스피너도 관리하기 너무 버거워요!"

(금요일 오전, 영숙 디자이너에게 UX 지적을 받고 시무룩한 영철)

🐣 영철: 아 리드 님, 메인 대시보드 화면에 위젯을 3개 달았거든요. 추천 친구 리스트, 오늘 내 할 일, 날씨 정보요. 근데 이 useQuery 3개가 각각 로딩 시간이 다르니까, 화면 열리자마자 동글뺑이 스피너 3개가 미친 듯이 제각각 돌아가요. 영숙 님이 "제발 스피너 하나로 묶어서 예쁘게 통일성 있게 보여달라" 고 하시는데... 저 컴포넌트 3개가 다 쪼개져 있단 말이죠. 이거 어떻게 합치나요?

🦁 영호: 영철 님, 전형적인 명령형 파편화(Imperative Fragmentation)네요. 모든 위젯 안쪽에서 if (isLoading) return <Spinner /> 를 도배하고 계시죠?
데이터가 있는지 없는지 매번 if 문으로 검사하는 방식은 이제 구시대의 유물입니다. React의 가장 위대한 발명품인 Suspense 와, React Query v5에 탄생한 useSuspenseQuery 를 결합할 때가 왔습니다.


🤔 왜 알아야 하는가: 선언적 렌더링(Declarative UI)의 정점

앞선 Advanced 03강에서 우리는 에러가 터진 상태를 컴포넌트 밖의 ErrorBoundary로 던지는(throwOnError) 법을 배웠습니다.

Suspense도 똑같습니다! 데이터가 로딩 중(가져오지 못함)인 상태 자체를 로컬 타이머(isLoading)로 들고 있지 않고, 밖으로 탁! 던져버립니다.
이를 낚아챈 부모 컴포넌트인 <Suspense fallback={<GlobalSpinner />}> 가 그 우산 밑에 있는 모든 스피너들을 빨아들여 통합 로딩 UI 하나로 예쁘게 그려주게 됩니다.

가장 완벽한 '클린 페칭 뷰' 컴포넌트의 종착역, Suspense 세계관을 부숴봅시다.


1. useQuery vs useSuspenseQuery (v5+ 표준)

React Query 초기에는 훅 옵션에 suspense: true 를 달아 썼지만, v5부터는 아예 타입 추론이 완벽하게 분리된 전용 훅 을 써야 합니다.

❌ 영철이의 과거 (타입 안전성 0%)

const { data, isLoading } = useQuery(['user'], fetchUser);
 
if (isLoading) return <Spinner />;
 
// TypeScript가 멍청해집니다. 아래 data가 undefined 일지 모른다고 에러를 띄우죠.
// 분명 위에서 로딩 처리를 다 했는데도 말입니다!
return <div>{data?.name}</div>; 

✅ 미래지향적 Suspense 훅 (타입 100% 보장)

import { useSuspenseQuery } from '@tanstack/react-query';
 
function UserProfile() {
  // 🚀 마법 1: isLoading 같은 자질구레한 상태 코드가 아예 존재하지 않습니다.
  const { data } = useSuspenseQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  });
 
  // 🚀 마법 2: TypeScript가 data를 "무조건 100% 값이 있는 상태(상수)" 로 확정(Narrowing)합니다.
  // 옵셔널 체이닝(?.)을 안 붙여도 타입 에러가 전혀 나지 않습니다!
  return <div>{data.name}</div>; 
}

이 컴포넌트 코드는 그 자체로 예술입니다. "이 화면이 불릴 때는, 데이터가 무조건 존재한다. 만약 로딩 중이라면 이 파일 안의 렌더 코드는 아예 실행조차 안 되고 밖으로 튕겨 나간다!" 라는 무적의 전제가 성립하기 때문입니다.


2. Suspense 워터폴(Waterfall)의 덫과 우회 전략

🐣 영철: 대박! 코드 완전 깔끔하네요. 그럼 영숙 님 피드백대로 위젯 3개를 Suspense 하나로 확 묶어버리면 끝나겠네요!

// ❌ 영철이가 신나서 짠 워터폴 트랩
function Dashboard() {
  return (
    // "애들아, 너네 3명 중에 젤 늦게 끝나는 애까지 기다려줄 테니 스피너 하나만 띄워라!"
    <Suspense fallback={<BigSpinner />}>
      <FriendWidget />   {/* 1초 걸림 (useSuspenseQuery 사용) */}
      <TodoWidget />     {/* 1.5초 걸림 (useSuspenseQuery 사용) */}
      <WeatherWidget />  {/* 0.5초 걸림 (useSuspenseQuery 사용) */}
    </Suspense>
  )
}

🦁 영호: 잠깐!!! 영철 님, 방금 앱을 완전 고물 자동차로 만들어버렸네요. 저렇게 쓰면 스피너 띄우는 것까진 잘 되는데, 위젯 3개가 병렬이 아니라 순차적(워터폴)으로 실행됩니다!! 데이터 다운로드가 1초 + 1.5초 + 0.5초 = 총 3초가 걸립니다.

왜 워터폴이 터질까?

useSuspenseQuery는 데이터가 없으면 즉시 컴포넌트 렌더링을 멈추고 밖으로(throw) 튕겨 나갑니다.

  1. FriendWidget 이 읽힙니다. 데이터가 없습니다! Promise를 부모 Suspense로 툭 던지고 렌더를 중지(Suspend)합니다.
  2. 🚨 중요! 위 줄이 중지되었으므로, 그 아래에 있는 TodoWidget이나 WeatherWidget은 마운트(실행)조차 되지 못하고 숨을 죽입니다!
  3. 1초 뒤 FriendWidget 데이터 도착. 다시 렌더 재개.
  4. 이제서야 아래 TodoWidget 이 읽힙니다. 얘도 데이터가 없어서 튕기고 Suspend!
  5. 또 1.5초 대기... (무한 반복)

🚀 병렬(Parallel) 페칭으로 극복하라! (해결책)

Suspense 바운더리 밑에서 여러 쿼리를 동시에 터트리려면, 부모 단에서 미리 프리페칭(Prefetch)을 동시에 쏴주거나, 새로 나온 useSuspenseQueries (병렬 전용 훅)을 사용 해야 합니다.

가장 깔끔하게 자식 컴포넌트들의 독립성을 유지하면서 쏘는 우아한 스펙은 부모가 배열로 동시에 방아쇠(Trigger)만 당겨주는 것입니다.

import { useQueryClient } from '@tanstack/react-query';
 
function Dashboard() {
  const queryClient = useQueryClient();
 
  // 1️⃣ 부모 컴포넌트가 렌더링 될 때, 3개 API 동시 출발!! (병렬 폭발)
  // prefetchQuery는 캐시에 미리 데이터를 밀어 넣어둘 뿐, 에러/로딩으로 화면을 막지 않습니다.
  queryClient.prefetchQuery(friendQueryOptions);
  queryClient.prefetchQuery(todoQueryOptions);
  queryClient.prefetchQuery(weatherQueryOptions);
 
  return (
    <Suspense fallback={<BigSpinner />}>
      {/* 2️⃣ 자식 위젯 내부에서는 useSuspenseQuery 가 돌고 있음.
          하지만 부모가 이미 0초 시점에 백그라운드 네트워크 대포 3발을 쏴 둔 상태라, 
          각 위젯이 순차 파면(Suspend)되더라도 네트워크 다운로드 자체는 3개가 동시에 이루어지고 있다! 
          결과적으로 총 소요시간 = 젤 느린 애 1.5초! (성능 2배 펌핑) */}
      <FriendWidget />   
      <TodoWidget />     
      <WeatherWidget />  
    </Suspense>
  )
}

🐣 영철이의 퇴근 일기

와, 나 오늘 진짜 프론트엔드 레벨업 팍팍 했다.
그동안 return 문 위에 if (isLoading) 가드 치고, 옵셔널 체이닝 바리바리 묶고(data?.name || '') 난리 쳤었는데...
useSuspenseQuery 하나 딱 박으니까, 내 컴포넌트가 무조건 데이터를 100% 쥐고 있는 순백의 렌더 함수로 탈바꿈했다.

💡 오늘의 교훈: "로딩 스피너 처리는 컴포넌트 내부에서 if문으로 오염시키지 말고, 외부의 <Suspense> 한테 우산 씌우듯 짬처리해라. 단! Suspense 형제끼리 묶으면 무조건 워터폴(순차 지연)이 터지니까, 컴포넌트 밖에서 동시 병렬 발사(Prefetch)를 꼭! 같이 세팅해줘야 성능을 지킬 수 있다."

오늘 영숙 디자이너님 폰에 앱 테스트 시켜주는데, 빙글빙글 각개전투하던 스피너 3개가 거대한 은하수 로딩 UI 한 방으로 예쁘게 떴다가 1.5초 만에 뿅 하고 한 번에 화면 렌더링되니까 진짜 유니콘 스타트업 앱 같았다. 하! 내일은 웹소켓 해봐야지! 😆


📝 배운 내용 점검하기 (Quiz)

Q. Suspense 환경에서, A API 목록을 기반으로 B API 상세 정보를 불러와야 하는 의존적 쿼리 (Dependent Queries)를 처리해야 합니다 (예: 유저 토큰(A)이 로드되어야만 그 토큰으로 내 게시물(B)을 가져옴). 기존의 useQuery 에서는 enabled 옵션을 써서 순서를 맞추었습니다. 하지만 useSuspenseQuery 에서는 공식적으로 이 enabled 옵션 사용이 막혀있는데(TS 에러 발생), 어떻게 이를 우아하게 해결해야 합니까?

  • A) enabled 대신 suspense: false 옵션을 강제로 주입하여 막는다.
  • B) B API를 부르는 자식 컴포넌트(Child)를 하나 새로 파서, 부모(Parent) 컴포넌트가 A API (useSuspenseQuery) 를 모두 성공적으로 응답받은 직후에만 return <Child token={data.token} /> 형식으로 조건부 렌더링을 태운다.
  • C) useSuspenseQueries 병렬 훅의 combine 옵션을 이용하여 내부 캐시에 콜백(callback) 딜레이를 먹인다.

정답: B

💡 상세 해설:

  • 원리 설명: Suspense의 철학은 "이 컴포넌트가 불렸다는 것은 데이터를 무조건 즉시 가져오겠다는(Pending) 선언" 입니다. 따라서 "아직 조건이 안 갖춰졌으니 쉬어라(enabled: false)"라는 개념 자체가 Suspense와 정면으로 충돌합니다(TkDodo 피셜). 의존성을 띠는 쿼리는 컴포넌트 트리를 과감하게 쪼개야 합니다.
    "A 데이터 얻어오는 부모 컴포넌트 -> A 성공! -> 이어서 B 컴포넌트 호출 -> B 데이터 얻어옴!"
    마치 폭포수가 떨어지듯 물리적으로 컴포넌트 실행 타이밍을 지연시키는(Mount Delay) 기법이 가장 완벽하고 타입 안전한 Suspense 연쇄 페칭 아키텍처입니다.
  • 오답 피드백: "영철 님! Suspense를 쓰면서 enabled를 꼼수로 부리려 하면 스피너가 무한루프 돌거나 빈 화면이 얼어버립니다. 데이터가 선행되어야 한다면 컴포넌트 마운트 자체를 부모에서 락(Lock) 걸어주세요!"
  • 📌 핵심 기억법: Suspense 의존성 쿼리는 enabled 금지! 부모-자식 트리 탑다운 분리로 해결!