본문으로 건너뛰기

안동민 개발노트

본문 시작

로딩 UI 구현

loading.tsx와 Suspense 스트리밍으로 비동기 세그먼트의 대기 화면을 만들고 적용 범위와 유지 영역을 제어합니다.

현대 웹 애플리케이션에서는 데이터 로딩이나 비동기 작업이 발생하는 동안 사용자에게 적절한 피드백을 제공하는 것이 매우 중요합니다.

아무런 반응이 없는 화면은 사용자에게 혼란과 불편함을 줄 수 있고, 이는 애플리케이션 이탈로 이어질 수 있습니다.

Next.js App Router는 이러한 사용자 경험을 개선하기 위해 데이터가 로드되는 동안 보여줄 로딩 UI(Loading UI)를 쉽게 구현할 수 있는 기능을 제공합니다.

이 절에서는 loading.tsx 파일을 사용하여 로딩 UI를 구현하는 방법, 그리고 이 기능이 React의 Suspense와 어떻게 연동되는지 자세히 알아보겠습니다.

먼저 사용자가 라우트를 이동했을 때 어떤 영역이 유지되고, 어떤 영역이 loading.tsx로 임시 대체되는지 확인합니다.

loading.tsx는 빈 화면 대신 가까운 세그먼트의 대기 상태를 보여준다

사용자가 새 route로 이동하면 layout은 먼저 유지되고, 느린 page 자리는 loading.tsx fallback이 임시로 채운다.

  1. 1
    핵심 1

    loading.tsx는 빈 화면 대신 가까운 세그먼트의 대기 상태를 보여준다

  2. 2
    핵심 2

    loading.tsx는 빈 화면 대신 가까운 세그먼트의 대기 상태를 보여준다

  3. 3
    핵심 3

    사용자가 새 route로 이동하면 layout은 먼저 유지되고, 느린 page 자리는 loading.tsx fallback이 임시로 채운다.

  4. 4
    핵심 4

    이동 시작 사용자가 /posts 또는 /posts/42로 이동한다.


로딩 UI의 필요성과 loading.tsx의 역할

사용자가 페이지에 접속하거나 특정 작업을 수행할 때, 백엔드에서 데이터를 가져오는 데는 시간이 소요될 수 있습니다.

이 짧은 시간 동안 사용자에게 무언가 진행 중이라는 시각적인 단서를 제공하는 것이 로딩 UI의 역할입니다.

Next.js App Router는 특정 라우트 세그먼트 내에서 데이터 로딩이 발생할 때 자동으로 활성화되는 loading.tsx 파일을 통해 로딩 UI를 구현합니다.

loading.tsx의 주요 특징
  • 동일한 라우트 세그먼트에 위치: loading.tsx 파일은 로딩 상태를 표시하고자 하는 page.tsx 파일과 동일한 폴더(라우트 세그먼트)에 위치해야 합니다.
  • Suspense와 연동: loading.tsx는 React의 Suspense 경계(Boundary) 역할의 일부로 작동합니다. 데이터 페칭과 같은 비동기 작업이 시작되면 loading.tsx가 렌더링되고, 데이터 로딩이 완료되면 실제 페이지 (page.tsx)가 그 자리를 대체합니다.
  • 스트리밍(Streaming): Next.js는 서버에서 HTML을 스트리밍하는 기능을 지원합니다. 이는 페이지의 일부가 먼저 사용자에게 표시된 후, 데이터 로딩이 완료되는 대로 나머지 부분이 점진적으로 나타나게 하여 사용자 경험을 향상시킵니다. loading.tsx는 이 스트리밍의 초기 폴백(fallback) 콘텐츠로 사용됩니다.
  • 기본적으로 서버 컴포넌트: loading.tsx 파일도 기본적으로 서버 컴포넌트로 동작합니다. 물론 클라이언트 컴포넌트로 만들 수도 있습니다.

loading.tsx 구현 실습

이전 절에서 만들었던 게시물 목록 페이지(src/app/posts/page.tsx)와 상세 페이지(src/app/posts/[id]/page.tsx)에 로딩 UI를 추가하여 데이터 로딩 시 사용자에게 피드백을 제공해 봅시다.

src/app/posts/loading.tsx 파일 생성: src/app/posts 폴더 안에 loading.tsx 파일을 생성합니다.

...

src/app/posts/loading.tsx 내용 작성: 데이터를 로드하는 동안 사용자에게 표시될 간단한 UI를 작성합니다.

src/app/posts/loading.tsx
import React from 'react';

export default function PostsLoading() {
  return (
    <div style={{
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      minHeight: '200px',
      backgroundColor: '#f8f8f8',
      border: '1px solid #ddd',
      borderRadius: '8px',
      padding: '20px',
      boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
    }}>
      <div className="spinner" style={{
        border: '4px solid rgba(0, 0, 0, 0.1)',
        width: '36px',
        height: '36px',
        borderRadius: '50%',
        borderLeftColor: '#09f',
        animation: 'spin 1s ease infinite'
      }}></div>
      <p style={{ marginTop: '15px', fontSize: '1.1em', color: '#555' }}>게시물 목록을 불러오는 중입니다...</p>

      {/* CSS 애니메이션을 위한 스타일 태그 (실제로는 globals.css에 넣는 것이 더 좋습니다) */}
      <style jsx>{`
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
      `}</style>
    </div>
  );
}
설명
  • 간단한 로딩 스피너와 메시지를 포함합니다.
  • CSS 애니메이션을 인라인으로 추가했지만, 실제 프로젝트에서는 globals.css 파일이나 모듈 CSS 파일에 정의하는 것이 좋습니다.

src/app/posts/[id]/loading.tsx 파일 생성 (동적 라우트용): 마찬가지로, 게시물 상세 페이지를 위한 로딩 UI도 추가합니다.

[id] 폴더 안에 loading.tsx를 생성해야 합니다.

...
src/app/posts/[id]/loading.tsx 내용 작성
src/app/posts/[id]/loading.tsx
import React from 'react';

export default function PostDetailLoading() {
  return (
    <div style={{
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      minHeight: '150px',
      backgroundColor: '#f0faff',
      border: '1px dashed #09f',
      borderRadius: '5px',
      padding: '15px',
      marginTop: '20px'
    }}>
      <p style={{ fontSize: '1.2em', color: '#09f' }}>게시물 내용을 불러오는 중입니다...</p>
      <div className="dot-spinner" style={{ display: 'flex', gap: '5px', marginTop: '10px' }}>
        <div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#09f', animation: 'blink 1s infinite' }}></div>
        <div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#09f', animation: 'blink 1s infinite 0.2s' }}></div>
        <div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#09f', animation: 'blink 1s infinite 0.4s' }}></div>
      </div>

      <style jsx>{`
        @keyframes blink {
          0%, 100% { opacity: 0.2; }
          50% { opacity: 1; }
        }
      `}</style>
    </div>
  );
}

실습 확인: 개발 서버(npm run dev)를 실행한 후,

  • http://localhost:3000/posts로 접속합니다. 네트워크 지연이 시뮬레이션되지 않으면 로딩 UI가 너무 빨리 사라질 수 있습니다. 개발자 도구(F12)의 Network 탭에서 Fast 3G 또는 Slow 3G로 네트워크 속도를 조절해 보세요. 게시물 목록이 나타나기 전에 게시물 목록을 불러오는 중입니다... 메시지가 잠시 표시될 것입니다.
  • 게시물 목록에서 더 보기 링크를 클릭하여 상세 페이지로 이동합니다. 마찬가지로 상세 페이지가 로드되기 전에 게시물 내용을 불러오는 중입니다... 메시지가 표시될 것입니다.

로딩 UI의 작동 원리: Suspense와 스트리밍

다음 흐름은 loading.tsx가 단순한 스피너 파일이 아니라, Suspense fallback과 서버 스트리밍의 첫 화면을 담당한다는 점을 보여줍니다.

Suspense와 스트리밍은 먼저 보여줄 껍데기와 나중에 채울 본문을 나눈다

loading.tsx는 React Suspense fallback으로 동작하고, 서버는 준비된 HTML부터 보내 사용자가 빈 화면을 보지 않게 한다.

순서서버에서 일어나는 일브라우저에 보이는 것사용자 효과
요청route segment 렌더링 시작기존 layout 또는 새 layout 껍데기화면이 즉시 반응함
대기page 데이터 fetch가 pendingloading.tsx fallback진행 중임을 알 수 있음
스트리밍준비된 HTML chunk 전송스켈레톤 위에 부분 콘텐츠 추가 가능체감 대기 시간이 줄어듦
교체page.tsx 결과 완성fallback 제거 후 실제 콘텐츠대기 화면이 자연스럽게 사라짐

Next.js의 loading.tsx는 내부적으로 React의 Suspense 컴포넌트를 사용하여 구현됩니다.

Next.js는 라우트 세그먼트를 렌더링할 때 그 내부 비동기 작업(예: fetch 호출)을 감지합니다.

비동기 작업이 아직 완료되지 않았다면, Next.js는 가장 가까운 상위 loading.tsx 컴포넌트를 찾아 해당 UI를 폴백(fallback)으로 렌더링합니다.

데이터 로딩이 완료되면 폴백 UI는 사라지고 실제 page.tsx 컴포넌트의 콘텐츠가 그 자리를 차지하게 됩니다.

이 과정은 서버에서부터 시작됩니다.

Next.js 서버는 먼저 레이아웃과 loading.tsx 파일을 포함한 HTML의 초기 부분을 스트리밍해 클라이언트에 보냅니다.

클라이언트는 이 초기 HTML을 받아 즉시 렌더링하므로, 사용자는 빈 화면 대신 로딩 UI를 보게 됩니다.

이후 데이터 로딩이 완료되면 나머지 실제 페이지 콘텐츠 HTML이 스트리밍되어 기존 로딩 UI를 대체합니다.

이 방식은 초기 페이지 로딩 시간을 체감상 크게 단축시키는 효과가 있습니다.


로딩 UI의 적용 범위

  • loading.tsx 파일은 자신이 위치한 라우트 세그먼트와 그 하위의 모든 라우트 세그먼트에 적용됩니다.
  • 만약 상위 폴더에 loading.tsx가 있고 하위 폴더에도 loading.tsx가 있다면, 가장 가까운 상위 loading.tsx가 활성화됩니다.
  • 레이아웃(layout.tsx) 파일 내에서 발생하는 데이터 페칭에 대해서도 loading.tsx가 작동합니다. 이는 레이아웃 자체가 비동기 컴포넌트일 때 특히 유용합니다.

loading.tsx의 위치와 사용자에게 보여줄 UI의 형태는 라우트 구조를 기준으로 정리하면 훨씬 명확해집니다.

가장 가까운 loading.tsx가 해당 세그먼트의 대기 화면을 맡는다

상위 loading은 넓은 범위를 받치고, 하위 loading은 더 구체적인 화면을 보여준다.

  1. 1
    핵심 1

    가장 가까운 loading.tsx가 해당 세그먼트의 대기 화면을 맡는다

  2. 2
    핵심 2

    가장 가까운 loading.tsx가 해당 세그먼트의 대기 화면을 맡는다

  3. 3
    핵심 3

    상위 loading은 넓은 범위를 받치고, 하위 loading은 더 구체적인 화면을 보여준다.

  4. 4
    핵심 4

    app/ posts/ loading.tsx 목록 fallback page.tsx 목록 본문 [id]/ loading.tsx 상세 fallback page.…

좋은 로딩 UI는 단순히 “기다려 주세요”를 보여주는 데서 끝나지 않고, 유지되는 레이아웃과 바뀌는 콘텐츠 영역을 분리해 사용자가 현재 위치를 잃지 않게 만듭니다.

스켈레톤의 크기, 가까운 fallback의 우선순위, 스트리밍 이후 대체되는 영역을 함께 점검하면 체감 품질이 안정됩니다.

좋은 로딩 UI는 위치감을 유지하고 바뀔 영역만 임시로 채운다

스피너 하나만 중앙에 띄우기보다 최종 콘텐츠의 크기와 구조를 닮은 fallback이 사용자의 예측을 돕는다.

  1. 스피너 하나만 중앙에 띄우기보다
  2. 최종 콘텐츠의 크기

    구조를 닮은 fallback이 사용자의 예측을 돕는다.


로딩 UI는 가까운 세그먼트의 fallback, 유지되는 레이아웃, 대체될 콘텐츠 영역을 나누어 설계하면 사용자 위치감과 체감 속도를 함께 지킬 수 있습니다.

로딩 화면은 route 범위, fallback 모양, 대체 시점을 함께 설계한다

loading.tsx를 추가할 때는 파일 위치만 정하지 말고, 어떤 콘텐츠를 가리고 어떤 layout을 남길지 같이 결정해야 한다.

점검 항목좋은 기준나쁜 기준수정 방향
파일 위치대기할 page와 같은 세그먼트너무 상위에 하나만 둠목록/상세 fallback 분리
UI 형태최종 콘텐츠와 비슷한 skeleton의미 없는 spinner만 표시대체될 영역의 구조 반영
범위layout은 유지, page 영역만 대기전체 화면을 지움layout과 page 책임 분리
우선순위가장 가까운 loading이 담당상위 fallback이 모든 화면을 덮음하위 세그먼트에 구체적 loading 추가
클라이언트 훅필요할 때만 use clientloading 전체를 습관적으로 client로 만듦브라우저 API 사용 여부로 결정

클라이언트 훅 사용 시 주의점

loading.tsx 컴포넌트는 기본적으로 서버 컴포넌트입니다.

만약 로딩 UI 내에서 useSearchParams와 같은 클라이언트 전용 React 훅을 사용해야 한다면, 해당 loading.tsx 파일 상단에 반드시 "use client" 지시어를 추가하여 클라이언트 컴포넌트로 만들어야 합니다.

src/app/some-route/loading.tsx (클라이언트 훅 사용 예시)
"use client"; // 이 파일을 클라이언트 컴포넌트로 만듭니다.

import { useSearchParams } from 'next/navigation';

export default function LoadingWithParams() {
  const searchParams = useSearchParams();
  const query = searchParams.get('q');

  return (
    <div>
      <p>데이터 로딩 중입니다. {query ? `"${query}" 검색 결과` : '콘텐츠'}</p>
      {/* ... 스피너 등 */}
    </div>
  );
}

로딩 UI는 사용자 경험을 향상시키는 간단하지만 매우 효과적인 방법입니다.

Next.js의 loading.tsx를 통해 비동기 데이터 로딩 상황에서도 사용자에게 끊김 없는 피드백을 제공하고, 웹 애플리케이션의 체감 성능을 크게 개선할 수 있습니다.

마지막으로 loading.tsx를 추가할지 판단할 때는 느린 구간이 실제로 있는지, 어느 세그먼트가 기다리는지, 브라우저 훅이 필요한지를 순서대로 봐야 합니다.

loading.tsx 추가 여부는 느린 구간, 표시 범위, 구현 경계 순서로 판단한다

로딩 UI가 필요한지부터 확인하고, 필요한 경우 가장 좁은 route 세그먼트와 서버/클라이언트 경계를 정한다.

결정 단계질문아니오
1. 느림사용자가 빈 화면을 볼 만큼 대기가 있는가loading.tsx 추가기본 렌더링 유지
2. 범위목록과 상세의 대기 모양이 다른가각 세그먼트에 분리상위 loading 공유
3. 형태최종 콘텐츠 구조를 예측할 수 있는가skeleton 사용명확한 문장 fallback
4. 훅useSearchParams 같은 브라우저 훅이 필요한가use client 추가서버 컴포넌트 유지
5. 검증느린 네트워크에서 확인했는가Network throttling으로 검증로딩이 너무 빨라 놓칠 수 있음

마지막으로 loading.tsx, Suspense, 스트리밍이 느린 데이터 화면의 사용자 경험을 어떻게 보완하는지 역할별로 정리합니다.

loading.tsx는 Suspense fallback과 스트리밍을 route 파일로 연결한다

느린 데이터 화면을 만들 때 loading.tsx, page.tsx, layout.tsx의 책임을 나누면 빈 화면 없이 전환할 수 있다.

파일/개념맡는 일사용자가 보는 것설계 기준
layout.tsx공통 껍데기와 위치감 유지내비게이션, 사이드바이동 중 남아야 하는 UI
loading.tsxSuspense fallback 제공스켈레톤 또는 대기 문장느린 page 영역과 같은 모양
page.tsx실제 데이터와 최종 화면 렌더완료된 콘텐츠fallback이 사라질 자리
Streaming준비된 HTML을 먼저 전송부분적으로 빠르게 표시서버 작업을 기다리는 동안 반응 유지
Client Hook브라우저 값이 필요한 fallback검색어 포함 로딩 문구필요한 경우에만 client