🚀 Next.js 7장: Suspense와 Streaming — 체감 속도의 마술

📋 개요

Suspense와 Streaming을 활용해 체감 로딩 속도를 개선하는 방법을 알아봅니다.

📋 목차


📌 이 문서를 읽기 전에

⏱️ 예상 읽기 시간: 15분(전체) / 핵심 파트만: 8분

🗺️ 이 문서의 흐름
RSC의 치명적 단점(병목) → Streaming 해결책 → Suspense 기반 선 렌더링 마법 → 실전 배치

🎯 이 문서를 다 읽으면 할 수 있는 것

  • API 응답이 고의로 5초나 지연되는 악조건 속에서도, 사이트의 뼈대(Header, Layout) 를 0.1초 만에 띄워 체감 로딩을 줄이는 최적화를 할 수 있다.
  • 예약어 파일인 loading.tsx 와 수동 <Suspense> 경계선의 차이를 알고 상황에 맞게 버무릴 수 있다.

🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'

  • 영철(새로 온 주니어): "요즘 서버 컴포넌트 정말 좋네요! 데이터도 직접 부르고... 아, 그런데 사용자가 접속하면 한 5초 동안 하얀 화면만 나오다가 갑자기 한꺼번에 퉁! 하고 다 떠요. 로딩 스피너라도 보여주고 싶은데 방법이 없을까요? 😭"
  • 영호(FE 리드): "영철 님... 모든 await 가 다 끝날 때까지 HTML 전송을 멈추고 기다렸잖아요! 사용자는 그동안 하얀 화면(TTFB 폭발) 만 보게 됩니다. 덩치 큰 녀석들은 <Suspense> 로 감싸서, 되는 놈부터 먼저 브라우저로 흘려보내야지(Streaming)!"

🤔 왜 알아야 하는가

과거 React(CSR)에서는 하얀 화면이 뜨는 동안 자바스크립트가 로딩 빙글빙글 스피너를 직접 그려줬어.
하지만 Next.js App Router 생태계에서는 모든 게 서버에서 그려져.

서버 컴포넌트(Page)가 DB에서 수만 건의 데이터를 끌어오는 await DB.find() 코드를 만났다고 치자.
이 쿼리가 3초가 걸린다면? Next.js 서버는 이 3초간 그 어떤 HTML 조각도 유저 브라우저로 내려보내지 않아. (All or Nothing 원칙).
그럼 유저는 브라우저 탭에 동그라미만 도는 허연 화면과 마주하고 사이트를 이탈하겠지.

이를 극복하기 위해 등장한 혁명적인 아키텍처가 바로 Streaming(스트리밍) 과 그 문을 열어주는 열쇠인 Suspense 야. 이 둘을 모르면 SSR의 늪에 빠져 오히려 CSR보다 사용자 경험(UX)을 훨씬 파괴하게 돼.


🏗️ 비유로 먼저 이해하기

🧒 5살에게 설명한다면? (영수네 국밥집의 코스 서빙)

기존 SSR (주문하고 15분간 물도 안 줌)
주방장이 국밥 10분, 수육 5분 총 15분을 요리해. 그리고 15분이 완벽하게 지날 때까지 손님은 빈 테이블에서 굶어야 해. 다 되면 거대한 쟁반 하나에 음식을 몽땅 들고 와서 상에 퉁! 놓지. (All or Nothing)

Streaming + Suspense (나오는 대로 즉시 서빙)
주방장이 조리가 필요 없는 물과 김치(기초 레이아웃) 부터 10초 만에 갖다 줘. (Streaming!)
그리고 국밥이 놓일 빈 자리에는 "맛있게 끓이는 중...🥘" 이라는 팻말(스켈레톤 UI, Suspense Fallback) 을 올려둬.
국밥이 다 끓으면 그 빈칸에만 쏙! 끼워 넣어주고. 손님은 계속 뭔가를 먹고 있어서 지루할 틈이 없어. 이게 진정한 체감 속도 최적화야.


🧩 Streaming (점진적 렌더링) 🟢

스트리밍은 특별한 코드가 아니야. HTML의 전송 방식을 쪼개는 기술이지.
"빨리 만들어지는 HTML 덩어리부터 청크(Chunk) 단위로 쪼개서 브라우저로 흘려보내겠다!"

이 방식의 최대 장점은 TTFB(Time To First Byte, 맨 첫 번째 데이터가 브라우저에 도달하는 시간)가 극단적으로 단축된다는 거야. 사용자 눈에는 0.1초 만에 최상단 헤더(GNB)와 사이드바가 뜨기 때문에, 사이트가 "진짜 엄청나게 빠르다!"고 착각하게 돼. (실제 데이터 로딩은 오래 걸리더라도)

이 마법의 발동 조건

Next.js 서버가 아무 곳에서나 이 스트림을 뿜어내게 할 순 없어.
Next.js 엔진에게 "지금 이 구역은 오래 걸릴 테니까, 일단 이 스켈레톤 뼈대나 로딩 글씨를 먼저 갖다 주고 나중에 데이터 오면 끼워 넣어줘!" 라고 명시적으로 명령을 내려야 해.

그 명령어가 바로 리액트의 <Suspense /> 모듈이야!


🛡️ Suspense의 진정한 의미 (선 렌더링, 후 데이터) 🟡

영철이의 끔찍한 병목 코드부터 팩트 폭격을 해보자.

❌ 병목의 시작 (All or Nothing)

// app/dashboard/page.tsx
import Header from './Header' // 0.1초
import HeavyChart from './HeavyChart' // 💥 무려 5초짜리 무거운 DB 쿼리
import Footer from './Footer' // 0.1초
 
// 이 페이지 전체는 HeavyChart가 5초 동안 끙끙대는 동안 HTML 전송을 '멈추고 대기'한다!
export default async function DashboardPage() {
  return (
    <div>
      <Header /> {/* 불쌍하게도 나까지 화면에 나타나지 못하고 5초를 같이 굶어야 해... */}
      <HeavyChart />
      <Footer />
    </div>
  )
}

✅ 우아한 선 렌더링 (Streaming 쪼개기)

영호 리드가 HeavyChart 주변에 부적을 감았어.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import Header from './Header'
import HeavyChart from './HeavyChart' 
import Footer from './Footer'
 
export default function DashboardPage() {
  // 🌟 이 페이지 컴포넌트 자체에서는 async/await (블로킹)을 완전히 제거해버렸어!
  return (
    <div>
      {/* 1차 배달 (Streaming): Header와 Footer는 서버에서 즉시 0.1초 만에 조립되어 브라우저로 쏴진다! */}
      <Header /> 
      
      {/* 2차 배달 구역: Suspense 안쪽에 가둬둔 HeavyChart 녀석만 서버에 남겨두고, 
          일단 fallback에 적힌 "차트 로딩 중..." 글씨를 1차 배달 때 끼워 보낸다. */}
      <Suspense fallback={<p className="text-gray-500 animate-pulse">차트 로딩 중...📊</p>}>
        <HeavyChart /> 
      </Suspense>
 
      <Footer />
    </div>
  )
}
// app/dashboard/HeavyChart.tsx
// ⚠️ 반드시 데이터를 가져오는 녀석(async)을 별도의 "서버 컴포넌트"로 오려내야(분리) 한다!!
export default async function HeavyChart() {
  const data = await fetch('https://api.my.com/heavy', { cache: 'no-store' }); // 5초 걸림
  return <div>눈부신 5초짜리 차트 결과물: {data.result}</div>
}

💡 핵심 설계 기법
Suspense를 발동시키려면 렌더링을 가로막는 무거운 로직(await fetch) 을 페이지 최상단 트리에 두지 말고, 쪼개어 만든 하위 자식 컴포넌트 내부에 밀어 넣어야 해. 그래야 부모 트리는 멈추지 않고 렌더링을 이어갈 수 있으니까!


🚦 우아한 로딩 패턴: loading.tsx vs <Suspense> 🟡

Next.js는 이 Suspense 작성을 자동화해주는 꿀 단축기 파일도 지원해. 바로 예약어 loading.tsx 지.

1) 폴더 단위 자동 래핑: loading.tsx

app/dashboard/ 폴더 트리에 loading.tsx 라는 파일을 툭 던져두면,
Next.js가 몰래 뒷단에서 page.tsx 껍데기 전체를 거대한 <Suspense>로 통째로 감싸버려.

app/
 └─ dashboard/
     ├─ layout.tsx  (1차로 즉시 Streaming 되어 렌더링 완료됨)
     ├─ loading.tsx (2차로 page.tsx가 준비될 때까지 화면 가운데 표시됨)
     └─ page.tsx    (3차로 5초 뒤에 렌더링 결괏값으로 쾅! 교체됨)
  • 장점: 파일 하나 만들면 라우팅 이동 시 체감 속도가 우주 방어급.
  • 단점: "페이지 전체"를 통째로 묶어버림. 만약 페이지 안에 1초 걸리는 A와 10초 걸리는 B가 있다면, 10초 뒤에 A와 B가 통째로 같이 툭 튀어나옴.

2) 파편 단위 수동 래핑: <Suspense>

그래서 시니어급 5년차로 갈수록 단순한 loading.tsx 에 의존하기보다는, 화면(Page) 트리를 쪼개고 여러 개의 <Suspense> 로 개별 포장하는 고급 아키텍처를 추구해.

export default function Page() {
  return (
    <section>
      {/* 1. 리뷰 영역은 1초 만에 뜨고 스피너 종류도 다름! */}
      <Suspense fallback={<SmallSpinner />}>
        <FastReviewList /> 
      </Suspense>
 
      {/* 2. 추천 상품 영역은 5초 걸리니까 뼈대(Skeleton) UI로 따로 돌아감! */}
      <Suspense fallback={<SkeletonCard />}>
        <SlowRecommendList /> 
      </Suspense>
    </section>
  )
}

이게 바로 진정한 애플리케이션의 점진적 경험(Progressive Enhancement) 최적화야.


💥 에러 해결 카탈로그

에러 메시지가 뜨면 Ctrl+F로 검색해봐.

❌ 화면 전체 로딩이 끝날 때까지 헤더(Header)마저 안 떠요!

원인: layout.tsx나 루트 부모 깊이의 파일 최상단 영역에서 export default async function Layout() { const db = await ... } 처럼 전역 블로킹(Blocking)을 시전해버렸기 때문.
해결책: 이 거대한 await 로직이 꼭 Layout 전체 기둥을 멈추게 해야만 하는 통신인지 고민해라. 만약 그 데이터가 사이드바 프로필 그림 하나용이라면, 사이드바 컴포넌트만을 별도로 쪼개서 뽑아낸 뒤 걔한테 await 짐을 몰아주고, 부모 레이아웃에는 껍데기에 <Suspense> 하나 씌운 채 즉시 리턴하게 풀어줘라.


🏁 이번에 배운 내용 총정리

도구스코프 크기역할 및 특징
loading.tsx페이지(Page) 통째로URL 진입 시 페이지 전체를 하나의 Suspense 덩어리로 간주해 즉시 로딩 화면을 띄움. (가장 뼈대 레벨)
<Suspense>쪼개진 개별 컴포넌트화면 내부의 독립적인 느린 파편들을 각각 지연시켜 Streaming(동시 전송)을 발생시키는 세밀한 컨트롤 타워
layout.tsxURL 변경 무관 보존 뼈대이 뼈대는 라우팅과 무관하게 최초 로딩되어 Streaming 껍데기 역할을 하며, 웬만하면 이 안에서 전역 블로킹 await를 남발하지 말아야 함

📝 마무리 퀴즈

배웠으면 한 번 비틀어서 확인해봐야 해.

Q1. 영철이가 쇼핑몰 메인 페이지에 loading.tsx 파일 하나를 루트에 생성해서 예쁜 바람개비 애니메이션을 만들었다. 메인 페이지(page.tsx)에는 '베스트셀러 호출(2초)', '신상품 호출(3초)' 두 개의 await fetch 통신이 직렬 순서로 나열되어 있다. 유저가 처음 접속했을 때, 화면에는 어떤 시퀀스로 뷰가 노출될 것인가?

정답: [순간: GNB 뼈대 보임] → [~5초까지: 바람개비(loading.tsx)만 보임] → [5초 뒤: 전체 페이지가 한 번에 그려짐]

💡 상세 해설: page.tsx 내부에서 2초 + 3초의 통신 지연(직렬 블로킹)이 발생하므로 약 5초의 총 지연 시간이 통과되어 페이지 렌더링이 100% 모조리 완료될 때까지, 영철이가 설치한 폴더 단위 통짜 <Suspense> (loading.tsx) 껍데기는 절대 fallback 스크린을 걷어내지 않는다.

Q2. 위 1번 상황의 답답한 5초 로딩을 본 영호(리드)는 영철이를 혼내며, "베스트 상품(2초)은 완성되자마자 즉시 뜨게 하고, 신상품(3초)은 좀 느려도 되니 그 자리만 나중에 채워지게 화면을 조각내세요!" 라고 미션을 줬다. 영철이는 어떤 아키텍처 리팩토링(2단계 작업)을 거쳐 이 미묘한 제어를 이룰 수 있을까?

정답: await fetch 로직을 독립된 자식 서버 컴포넌트로 분리하고, 각각을 <Suspense fallback={...}> 캡슐로 감싸는 Streaming 최적화 패턴을 적용한다.

💡 상세 해설: 아주 전형적인 Streaming 최적화 프로세스이다. 첫째, 통짜로 묶였던 무식한 loading.tsx는 과감히 삭제하거나 컴포넌트 레벨로 축소 시킨다. 둘째, page.tsx에서 모든 await fetch 로직을 전부 바깥으로 들어 올린다. 해당 통신은 <Bestseller /><NewItems />라는 두 개의 완전 독립된 자식 '서버 컴포넌트 파일' 내부 공간의 async로 각각 밀어 넣어 위임한다. 셋째, 부모인 page.tsx는 블로킹되는 요인이 0이 된 상태에서 두 자식 렌더러를 리턴하되, 각각을 <Suspense fallback={...}> 이라는 독립 캡슐로 감싸준다. 이렇게 되면 2초 뒤에 베스트셀러 캡슐만 톡 터지면서 화면을 채우고, 3초 뒤에 남은 하나의 캡슐이 톡 터지며 체감 로딩의 마술이 실현된다!


🐣 영철이의 퇴근 일기

오늘은 정말 마법 같은 경험을 했어. 5초나 걸리는 무거운 차트 때문에 사이트 전체가 하얀 화면으로 멈춰있는 걸 보면서 식은땀이 났는데, 영호 리드 님이 알려주신 '코스 서빙(Streaming)' 덕분에 0.1초 만에 뼈대를 띄워내는 작업을 성공했거든!

💡 오늘의 교훈: "느린 자식은 Suspense 경계선 안으로 격리하자. 부모(레이아웃) 는 기다리지 않고 먼저 출발해야 한다!"

<Suspense> 라는 캡슐로 느린 녀석들을 가둬두기만 했는데, 사용자 입장에서는 기다림이 전혀 지루하지 않게 변하는 게 신기했어. 오늘 정말 알찬 하루였다! 집에 가서 매콤한 떡볶이 시켜 먹으면서 스트레스 확 풀어야지. 내일은 더 빠릿빠릿한 서비스를 만드는 '최적화 킹' 영철이가 되겠어! 🐣


🔗 더 알아보기