로딩 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를 작성합니다.
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 내용 작성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가 감싸는 범위
같은 폴더의 레이아웃에서 먼저 기다리는 데이터는 위 자동 경계로 가려지지 않습니다. 해당 작업을 페이지로 옮기거나, 대기하는 하위 컴포넌트 주위에 명시적인 <Suspense>를 둘 수 있습니다. 중첩 경계에서는 대기한 컴포넌트에서 가장 가까운 경계가 처리하며, 대체 화면 자체도 대기하면 그 바깥 경계가 필요합니다.
클라이언트 훅 사용 시 주의점
loading.tsx 컴포넌트는 기본적으로 서버 컴포넌트입니다.
useSearchParams를 직접 호출하는 컴포넌트는 클라이언트 컴포넌트여야 합니다. 정적으로 렌더링되는 경로에서는 이 훅을 호출하는 하위 컴포넌트를 별도의 <Suspense>로 감싸야 production 빌드에서 필요한 경계를 확보할 수 있습니다. 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>
);
}