안동민 개발노트

안동민 개발노트

페이지 컴포넌트 작성레이아웃 컴포넌트 생성 및 중첩템플릿 컴포넌트 활용로딩 UI 구현
본문 시작
  1. 홈
  2. 문서
  3. Next.js
  4. 5장 : 페이지 및 레이아웃 컴포넌트
  5. 로딩 UI 구현
  1. Next.js
  2. 로딩 UI 구현

로딩 UI 구현

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

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

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

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

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


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

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

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

Next.js App Router는 렌더링 중 대기하는 하위 UI를 위한 Suspense 경계를 만들고, loading.tsx를 그 경계의 대체 화면으로 사용합니다.

loading.tsx의 주요 특징
  • 파일 위치로 범위 지정: 같은 폴더의 page.tsx를 위한 로딩 화면을 만들 수 있고, 더 가까운 경계가 없는 하위 페이지에도 적용됩니다.
  • Suspense와 연동: 경계 안의 렌더링이 대기하면 로딩 UI를 표시하고, 준비된 콘텐츠로 바꿉니다. Effect나 이벤트 핸들러에서 시작한 임의의 비동기 작업을 자동 감지하는 기능은 아닙니다.
  • 스트리밍: 서버는 준비된 UI와 대체 화면을 먼저 보내고 나머지 콘텐츠를 이어 보낼 수 있습니다. 미리 가져온 데이터나 응답 버퍼링 등에 따라 로딩 화면이 실제로 보이는 시점은 달라집니다.
  • 기본적으로 서버 컴포넌트: 필요한 경우 클라이언트 컴포넌트로 만들 수도 있습니다.

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>{`
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
      `}</style>
    </div>
  );
}
설명
  • 간단한 로딩 스피너와 메시지를 포함합니다.
  • 일반 <style>의 키프레임은 전역 CSS 규칙입니다. 예제는 styled-jsx 설정 없이 사용할 수 있으며, 실제 프로젝트에서는 고유한 이름을 쓰고 globals.css나 CSS 모듈로 분리할 수 있습니다.

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

상세 페이지 전용 대체 화면을 분리하려면 [id] 폴더 안에 loading.tsx를 생성합니다. 이 파일이 없으면 상위 posts/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>{`
        @keyframes blink {
          0%, 100% { opacity: 0.2; }
          50% { opacity: 1; }
        }
      `}</style>
    </div>
  );
}

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

  • http://localhost:3000/posts로 접속해 목록 대체 화면이 나타나는지 확인합니다. 개발자 도구의 Fast 3G 또는 Slow 3G 설정은 브라우저와 Next.js 서버 사이의 통신을 늦추며, 서버가 로컬 JSON Server에 보내는 요청 자체를 늦추지는 않습니다.
  • 더 보기로 상세 페이지에 이동해 상세 대체 화면도 확인합니다. 미리 가져온 페이지가 준비되어 있거나 응답이 빠르면 두 화면 모두 눈에 보이지 않을 수 있습니다. 대기 상태를 따로 실험하려면 개발용 API에 통제된 지연을 넣고 실제 표시 여부를 확인합니다.

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

Next.js는 loading.tsx를 자동으로 만든 React Suspense 경계의 fallback으로 사용합니다. 프레임워크가 지원하는 서버 데이터 읽기나 use처럼 렌더링을 대기시키는 작업이 이 경계 안에서 완료되지 않으면 대체 화면을 표시합니다.

서버는 이미 준비된 바깥 UI와 대체 화면을 먼저 보내고, 경계 안의 콘텐츠가 준비되면 이어서 전송할 수 있습니다. 클라이언트는 도착한 콘텐츠로 대체 화면을 바꿉니다. 준비가 끝난 탐색에서는 대체 화면 없이 콘텐츠가 나타날 수도 있습니다.


로딩 UI의 적용 범위

loading.tsx의 Suspense가 감싸는 범위

loading.tsx의 Suspense가 감싸는 범위

로딩 경계의 포함과 제외자동 Suspense는 같은 폴더의 layout 안에 놓이며 page와 하위 layout을 감싼다. 같은 폴더 layout 자체가 기다리는 작업은 이 경계 밖이다.같은 폴더의 layout.tsx이 컴포넌트 자체의 대기는 아래 경계 밖Suspense 경계대기 화면: loading.tsxpage.tsx와 하위 라우트하위 layout.tsx도 이 경계 안
로딩 경계의 포함과 제외layout 자체 바깥이 아니라 그 children 부분에 자동 Suspense 경계가 놓인다.같은 폴더의 layout.tsx자체의 대기는 아래 경계 밖Suspense 경계대기 화면: loading.tsxpage.tsx와 하위 라우트하위 layout.tsx도 포함

같은 폴더의 레이아웃에서 먼저 기다리는 데이터는 위 자동 경계로 가려지지 않습니다. 해당 작업을 페이지로 옮기거나, 대기하는 하위 컴포넌트 주위에 명시적인 <Suspense>를 둘 수 있습니다. 중첩 경계에서는 대기한 컴포넌트에서 가장 가까운 경계가 처리하며, 대체 화면 자체도 대기하면 그 바깥 경계가 필요합니다.


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

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

useSearchParams를 직접 호출하는 컴포넌트는 클라이언트 컴포넌트여야 합니다. 정적으로 렌더링되는 경로에서는 이 훅을 호출하는 하위 컴포넌트를 별도의 <Suspense>로 감싸야 production 빌드에서 필요한 경계를 확보할 수 있습니다. loading.tsx 자신은 바깥 경계의 대체 화면이므로, 그 안의 훅을 그 바깥 경계만으로 처리한다고 가정하면 안 됩니다.

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

import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';

function QueryLoadingContent() {
  const searchParams = useSearchParams();
  const query = searchParams.get('q');

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

export default function LoadingWithParams() {
  return (
    <Suspense fallback={<p>데이터 로딩 중입니다.</p>}>
      <QueryLoadingContent />
    </Suspense>
  );
}

템플릿 컴포넌트 활용

이전 페이지

서버 컴포넌트에서 데이터 페칭

다음 페이지

이 페이지의 목차

로딩 UI의 필요성과 loading.tsx의 역할loading.tsx 구현 실습로딩 UI의 작동 원리: Suspense와 스트리밍로딩 UI의 적용 범위클라이언트 훅 사용 시 주의점