본문으로 건너뛰기

loading.tsx에서 영역별 Suspense까지, Next.js 스트리밍 렌더링 개선기

Published2026.11.14
Read Time15 min read
Related Project: CodeMate

loading.tsx에서 영역별 Suspense까지, Next.js 스트리밍 렌더링 개선기

CodeMate의 AI 리뷰 이력 페이지(/reviews)는 리뷰 통계, 연결된 저장소를 선택하는 필터, AI 리뷰 목록으로 구성되어 있다. 처음에는 세 데이터를 Promise.all()로 조회했다. 요청은 병렬로 실행됐지만, 화면에서 실제 내용을 볼 수 있는 시점은 가장 늦게 끝나는 리뷰 목록 조회에 맞춰졌다.

통계나 저장소 필터는 목록이 없어도 보여줄 수 있다. 그런데 이 페이지에서는 목록을 기다리는 동안 두 영역까지 스켈레톤으로 남아 있었다. loading.tsx도 이미 있었는데, 왜 이런 일이 생기는지부터 살펴봤다.

이번 작업에서 새로 도입한 것은 스트리밍 자체가 아니다. 기존의 페이지 단위 로딩 경계를 통계·필터·목록 단위로 더 나눈 것이다.

1. 병렬 조회와 화면 표시 시점은 별개였다

변경 전에는 인증과 검색 조건 처리를 마친 뒤 데이터를 한꺼번에 기다렸다. 아래 코드는 당시 구조를 설명하기 위해 단순화한 예시다.

const user = await requireCurrentUser()
const filters = parseReviewHistoryFilters(await searchParams)

const [stats, repositories, history] = await Promise.all([
  getReviewHistoryStats(user.id),
  getConnectedRepositoriesForUser(user.id),
  getReviewHistory(user.id, filters),
])

return (
  <PageContainer>
    <PageHeader title="리뷰" description="AI 분석 결과와 작성된 코드 리뷰를 한곳에서 확인하세요." />
    <ReviewTabs active="ai" />
    <ReviewStats stats={stats} />
    <ReviewHistoryFilters filters={filters} repositories={repositories} />
    <ReviewHistoryList filters={filters} history={history} />
  </PageContainer>
)

Promise.all()을 쓴 이유는 세 조회가 서로의 결과를 기다릴 필요가 없었기 때문이다. 실제로 요청도 함께 시작된다. 하지만 await Promise.all()은 모든 결과가 준비될 때까지 반환되지 않는다. 통계 조회가 먼저 끝나도 페이지 컴포넌트는 아직 return에 도달하지 못한다.

여기서 구분해야 할 것은 데이터 조회를 병렬로 실행하는 일과 준비된 UI를 먼저 표시하는 일이다. 전자는 Promise.all()로 처리했지만, 후자는 페이지를 하나의 완료 지점으로 묶어 둔 상태였다.

2. loading.tsx가 있는데도 전체를 기다린 이유

/reviews에는 이미 loading.tsx가 있었다. 페이지 제목·탭, 통계 카드, 필터, 목록을 대신하는 스켈레톤을 반환하는 파일이다.

// app/(protected)/reviews/loading.tsx — 일부 생략
export default function ReviewsLoading() {
  return (
    <PageContainer>
      {/* 제목과 탭의 스켈레톤 */}
      <ReviewStatsSkeleton />
      <ReviewFiltersSkeleton />
      <ReviewHistoryListSkeleton />
    </PageContainer>
  )
}

App Router의 loading.tsx는 해당 라우트 세그먼트에 자동으로 Suspense 경계를 구성하고, 페이지 콘텐츠가 준비되지 않았을 때 보여줄 fallback을 지정한다. 따라서 변경 전에도 페이지 단위로 로딩 화면을 먼저 보낼 수 있었다.

문제는 그 다음이었다. 페이지의 실제 JSX가 Promise.all() 뒤에 있으니, 통계·필터·리뷰 목록의 실제 콘텐츠는 함께 도착하는 구조였다. loading.tsx가 제공한 것은 페이지 단위 대기 화면이지, 내부 세 영역의 완료 시점을 개별적으로 나눠 주는 기능은 아니었다.

Suspense와 Streaming Rendering은 같은 개념일까?

React의 Suspense는 렌더링 중 준비되지 않은 UI를 어디까지 기다릴지 정하는 경계다. 경계 안의 콘텐츠가 아직 준비되지 않았다면 fallback을 보여주고, 준비되면 실제 콘텐츠로 바꾼다. 단, 일반적인 useEffect의 비동기 요청까지 자동으로 감지하는 것은 아니다.

서버 스트리밍 렌더링은 HTML이 전부 완성될 때까지 응답을 붙잡아 두지 않고, 준비된 부분부터 나누어 전송하는 방식이다. Next.js App Router에서는 서버 렌더링 중 Suspense 경계를 만나면 해당 영역의 fallback을 먼저 보내고, 나중에 준비된 콘텐츠를 이어서 보낼 수 있다.

둘의 역할을 나눠 보면 이해하기 쉽다.

구성역할
Suspense어느 부분을 별도의 대기 단위로 둘지 결정
fallback / 스켈레톤그 부분이 준비되지 않았을 때 보여줄 UI
서버 스트리밍준비된 HTML을 먼저 전송하고 나머지를 이어서 전송

스켈레톤이 먼저 보인다고 무조건 서버 스트리밍이 일어났다는 뜻은 아니다. 클라이언트에서도 로딩 상태에 따라 스켈레톤을 그릴 수 있다. 실제로 서버 콘텐츠가 나뉘어 도착했는지는 응답 전송 기록을 따로 확인해야 한다.

3. 통계·필터·목록을 별도 경계로 나눈 이유

이 페이지의 데이터는 서로 관련은 있지만, 화면에 나타나는 순서까지 함께 묶을 필요는 없었다.

통계 카드는 리뷰 통계만 있으면 그릴 수 있다. 저장소 필터는 연결된 저장소 목록과 현재 URL의 검색 조건이 있으면 표시할 수 있다. 필터가 선택한 조건을 리뷰 목록 조회에도 전달하지만, 리뷰 목록 응답이 완료되어야 필터 폼을 보여줄 수 있는 것은 아니다.

그래서 페이지 상단에서 세 조회를 모두 기다리는 대신, 각각의 Server Component가 자기 데이터를 조회하도록 바꿨다.

// app/(protected)/reviews/page.tsx — 핵심 부분
export default async function ReviewsPage({ searchParams }: PageProps) {
  const user = await requireCurrentUser()
  const filters = parseReviewHistoryFilters(await searchParams)

  return (
    <PageContainer>
      <PageHeader
        title="리뷰"
        description="AI 분석 결과와 작성된 코드 리뷰를 한곳에서 확인하세요."
        icon={<BotMessageSquare size={18} />}
      />
      <ReviewTabs active="ai" />

      <Suspense fallback={<ReviewStatsSkeleton />}>
        <ReviewStatsSection userId={user.id} />
      </Suspense>
      <Suspense fallback={<ReviewFiltersSkeleton />}>
        <ReviewFiltersSection userId={user.id} filters={filters} />
      </Suspense>
      <Suspense fallback={<ReviewHistoryListSkeleton />}>
        <ReviewHistorySection userId={user.id} filters={filters} />
      </Suspense>
    </PageContainer>
  )
}

ReviewHistorySection은 목록 조회가 끝날 때까지 자기 경계 안에서 대기한다.

// components/reviews/ReviewHistorySections.tsx
export async function ReviewHistorySection({
  userId,
  filters,
}: FilteredReviewSectionProps) {
  const history = await getReviewHistory(userId, filters)

  return (
    <>
      {history.hasActiveReview ? <ReviewHistoryAutoRefresh /> : null}
      <ReviewHistoryList filters={filters} history={history} />
    </>
  )
}

이 변경의 핵심은 <Suspense> 태그를 세 개 추가한 데 있지 않다. await하는 위치를 페이지 바깥쪽에서 각 경계 안쪽으로 옮겼다는 점에 있다. 기존처럼 페이지에서 세 결과를 모두 기다린 뒤 하위 컴포넌트에 전달했다면, 경계를 나누어도 이미 발생한 대기를 되돌릴 수 없다.

물론 requireCurrentUser()와 searchParams 파싱은 여전히 페이지가 JSX를 반환하기 전에 실행한다. 이번에 분리한 것은 인증 이후의 데이터 조회다. 그리고 세 영역이 항상 분리되어야 한다는 뜻도 아니다. 한쪽 결과가 있어야 다른 쪽을 올바르게 해석할 수 있는 UI라면 같은 경계로 묶는 편이 나을 수 있다.

4. 리뷰 목록만 3초 늦춰 비교했다

구조 변경이 실제 화면에도 반영되는지 확인하기 위해, 리뷰 목록 조회에만 동일한 3초 지연을 주입했다. 통계와 연결 저장소 조회에는 지연을 추가하지 않았다.

두 버전 모두 로컬 프로덕션 빌드에서 Chrome Performance Trace를 15회씩 측정했다. 이번에는 LCP뿐 아니라 Performance Trace의 Screenshots에서 실제 통계 숫자와 저장소 필터 폼이 처음 보이는 프레임을 같은 기준으로 찾아 집계했다. 스켈레톤이 보이는 시점은 제외했다.

실제 콘텐츠 첫 표시 시점변경 전 중앙값변경 후 중앙값앞당겨진 시간
통계 숫자4.522초1.555초2.967초
저장소 필터 폼4.522초1.260초3.262초

p75로 비교해도 같은 경향이었다.

실제 콘텐츠 첫 표시 시점 (p75)변경 전변경 후
통계 숫자4.578초1.710초
저장소 필터 폼4.578초1.316초

변경 전에는 두 영역 모두 리뷰 목록을 기다리다가 비슷한 시점에 나타났다. 변경 후 대표 프레임에서는 저장소 필터 폼이 먼저 보이고, 이어 통계 카드에 실제 숫자가 표시됐다. 두 영역을 독립적인 경계로 나눈 의도가 화면에서도 확인된 셈이다.

여기서 측정한 것은 실제 콘텐츠가 화면에 처음 보인 시점이다. 스크린샷 프레임을 기준으로 읽은 값이므로 캡처 간격만큼의 오차가 있을 수 있고, 필터를 클릭해 결과를 받아보는 데 걸리는 시간을 측정한 것도 아니다.

5. 먼저 보이는 화면과 마지막에 끝나는 요청은 다르다

반대로 느린 리뷰 목록 자체가 빨라진 것은 아니다. 지연된 목록을 포함한 후속 HTML chunk는 변경 후에도 약 4.17초에 도착했다. 목록 제목 역시 변경 후 약 4초대 후반에 나타났다. 첫 번째 리뷰 카드는 측정 뷰포트 아래에 있어서 이번 스크린샷만으로는 첫 표시 시점을 확인할 수 없었다.

보조 지표로 기록한 LCP는 p75 기준 4.791초에서 1.622초로 줄었지만, 이 숫자만으로 리뷰 목록을 더 빨리 읽을 수 있게 됐다고 말할 수는 없다. 문서 전송 완료는 4.244초에서 4.369초, Load는 4.286초에서 4.397초로 비슷했다.

이번 결과에서 더 직접적인 근거는 통계 숫자와 저장소 필터 폼이 약 3초 먼저 화면에 나타났다는 사실이다. 리뷰 목록 요청의 완료 시점은 거의 그대로 두면서, 다른 영역이 그 요청을 기다리지 않도록 만든 효과를 확인한 것이다.

다만 3초 지연을 인위적으로 넣은 로컬 실험이고, 변경 전후 각각 15회라는 제한이 있다. 이 결과를 그대로 운영 사용자 전체의 개선 시간으로 일반화할 수는 없다. 실제 환경에서 목록 조회가 빠르게 끝난다면 차이도 작아질 수 있다.

6. 마무리

처음에는 세 요청을 Promise.all()로 병렬 처리했으니 화면도 효율적으로 표시될 거라고 생각했다. 하지만 페이지 컴포넌트가 모든 결과를 기다린 뒤 JSX를 반환하는 한, 먼저 끝난 통계 조회도 화면에는 반영되지 못했다.

loading.tsx는 이미 페이지가 준비될 때까지의 대기 화면을 보여주고 있었다. 이번에는 통계·필터·목록이 서로를 기다릴 필요가 없다는 점에 맞춰, 데이터 조회 위치와 Suspense 경계를 함께 나눴다.

그 결과 목록 조회 시간은 줄지 않았지만 통계와 필터의 실제 표시 시점은 앞당겨졌다. 다음에 서버에서 여러 데이터를 가져오는 페이지를 만든다면, 단순히 요청을 병렬로 묶는 것에서 끝내지 않고 어떤 영역이 다른 영역을 기다려야 하는지도 함께 살펴보려고 한다.


참고 문서

코드 예시는 핵심 구조를 중심으로 일부 생략하거나 단순화했다. 수치는 CodeMate /reviews 페이지에서 수행한 로컬 통제 실험 결과다.