로딩 UI 구현
loading.tsx와 Suspense 스트리밍으로 비동기 세그먼트의 대기 화면을 만들고 적용 범위와 유지 영역을 제어합니다.
현대 웹 애플리케이션에서는 데이터 로딩이나 비동기 작업이 발생하는 동안 사용자에게 적절한 피드백을 제공하는 것이 매우 중요합니다.
아무런 반응이 없는 화면은 사용자에게 혼란과 불편함을 줄 수 있고, 이는 애플리케이션 이탈로 이어질 수 있습니다.
Next.js App Router는 이러한 사용자 경험을 개선하기 위해 데이터가 로드되는 동안 보여줄 로딩 UI(Loading UI)를 쉽게 구현할 수 있는 기능을 제공합니다.
이 절에서는 loading.tsx 파일을 사용하여 로딩 UI를 구현하는 방법, 그리고 이 기능이 React의 Suspense와 어떻게 연동되는지 자세히 알아보겠습니다.
먼저 사용자가 라우트를 이동했을 때 어떤 영역이 유지되고, 어떤 영역이 loading.tsx로 임시 대체되는지 확인합니다.
사용자가 새 route로 이동하면 layout은 먼저 유지되고, 느린 page 자리는 loading.tsx fallback이 임시로 채운다.
- 1핵심 1
loading.tsx는 빈 화면 대신 가까운 세그먼트의 대기 상태를 보여준다
- 2핵심 2
loading.tsx는 빈 화면 대신 가까운 세그먼트의 대기 상태를 보여준다
- 3핵심 3
사용자가 새 route로 이동하면 layout은 먼저 유지되고, 느린 page 자리는 loading.tsx fallback이 임시로 채운다.
- 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를 작성합니다.
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 내용 작성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과 서버 스트리밍의 첫 화면을 담당한다는 점을 보여줍니다.
loading.tsx는 React Suspense fallback으로 동작하고, 서버는 준비된 HTML부터 보내 사용자가 빈 화면을 보지 않게 한다.
| 순서 | 서버에서 일어나는 일 | 브라우저에 보이는 것 | 사용자 효과 |
|---|---|---|---|
| 요청 | route segment 렌더링 시작 | 기존 layout 또는 새 layout 껍데기 | 화면이 즉시 반응함 |
| 대기 | page 데이터 fetch가 pending | loading.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은 넓은 범위를 받치고, 하위 loading은 더 구체적인 화면을 보여준다.
- 1핵심 1
가장 가까운 loading.tsx가 해당 세그먼트의 대기 화면을 맡는다
- 2핵심 2
가장 가까운 loading.tsx가 해당 세그먼트의 대기 화면을 맡는다
- 3핵심 3
상위 loading은 넓은 범위를 받치고, 하위 loading은 더 구체적인 화면을 보여준다.
- 4핵심 4
app/ posts/ loading.tsx 목록 fallback page.tsx 목록 본문 [id]/ loading.tsx 상세 fallback page.…
좋은 로딩 UI는 단순히 “기다려 주세요”를 보여주는 데서 끝나지 않고, 유지되는 레이아웃과 바뀌는 콘텐츠 영역을 분리해 사용자가 현재 위치를 잃지 않게 만듭니다.
스켈레톤의 크기, 가까운 fallback의 우선순위, 스트리밍 이후 대체되는 영역을 함께 점검하면 체감 품질이 안정됩니다.
로딩 UI는 가까운 세그먼트의 fallback, 유지되는 레이아웃, 대체될 콘텐츠 영역을 나누어 설계하면 사용자 위치감과 체감 속도를 함께 지킬 수 있습니다.
loading.tsx를 추가할 때는 파일 위치만 정하지 말고, 어떤 콘텐츠를 가리고 어떤 layout을 남길지 같이 결정해야 한다.
| 점검 항목 | 좋은 기준 | 나쁜 기준 | 수정 방향 |
|---|---|---|---|
| 파일 위치 | 대기할 page와 같은 세그먼트 | 너무 상위에 하나만 둠 | 목록/상세 fallback 분리 |
| UI 형태 | 최종 콘텐츠와 비슷한 skeleton | 의미 없는 spinner만 표시 | 대체될 영역의 구조 반영 |
| 범위 | layout은 유지, page 영역만 대기 | 전체 화면을 지움 | layout과 page 책임 분리 |
| 우선순위 | 가장 가까운 loading이 담당 | 상위 fallback이 모든 화면을 덮음 | 하위 세그먼트에 구체적 loading 추가 |
| 클라이언트 훅 | 필요할 때만 use client | loading 전체를 습관적으로 client로 만듦 | 브라우저 API 사용 여부로 결정 |
클라이언트 훅 사용 시 주의점
loading.tsx 컴포넌트는 기본적으로 서버 컴포넌트입니다.
만약 로딩 UI 내에서 useSearchParams와 같은 클라이언트 전용 React 훅을 사용해야 한다면, 해당 loading.tsx 파일 상단에 반드시 "use client" 지시어를 추가하여 클라이언트 컴포넌트로 만들어야 합니다.
"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를 추가할지 판단할 때는 느린 구간이 실제로 있는지, 어느 세그먼트가 기다리는지, 브라우저 훅이 필요한지를 순서대로 봐야 합니다.
로딩 UI가 필요한지부터 확인하고, 필요한 경우 가장 좁은 route 세그먼트와 서버/클라이언트 경계를 정한다.
| 결정 단계 | 질문 | 예 | 아니오 |
|---|---|---|---|
| 1. 느림 | 사용자가 빈 화면을 볼 만큼 대기가 있는가 | loading.tsx 추가 | 기본 렌더링 유지 |
| 2. 범위 | 목록과 상세의 대기 모양이 다른가 | 각 세그먼트에 분리 | 상위 loading 공유 |
| 3. 형태 | 최종 콘텐츠 구조를 예측할 수 있는가 | skeleton 사용 | 명확한 문장 fallback |
| 4. 훅 | useSearchParams 같은 브라우저 훅이 필요한가 | use client 추가 | 서버 컴포넌트 유지 |
| 5. 검증 | 느린 네트워크에서 확인했는가 | Network throttling으로 검증 | 로딩이 너무 빨라 놓칠 수 있음 |
마지막으로 loading.tsx, Suspense, 스트리밍이 느린 데이터 화면의 사용자 경험을 어떻게 보완하는지 역할별로 정리합니다.
느린 데이터 화면을 만들 때 loading.tsx, page.tsx, layout.tsx의 책임을 나누면 빈 화면 없이 전환할 수 있다.
| 파일/개념 | 맡는 일 | 사용자가 보는 것 | 설계 기준 |
|---|---|---|---|
| layout.tsx | 공통 껍데기와 위치감 유지 | 내비게이션, 사이드바 | 이동 중 남아야 하는 UI |
| loading.tsx | Suspense fallback 제공 | 스켈레톤 또는 대기 문장 | 느린 page 영역과 같은 모양 |
| page.tsx | 실제 데이터와 최종 화면 렌더 | 완료된 콘텐츠 | fallback이 사라질 자리 |
| Streaming | 준비된 HTML을 먼저 전송 | 부분적으로 빠르게 표시 | 서버 작업을 기다리는 동안 반응 유지 |
| Client Hook | 브라우저 값이 필요한 fallback | 검색어 포함 로딩 문구 | 필요한 경우에만 client |