로딩 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를 그 Suspense의 대체 화면(fallback)으로 씁니다.
loading.tsx는 파일의 위치로 적용 범위를 정합니다. 같은 폴더의 page.tsx를 위한 로딩 화면을 만들 수 있고, 더 가까운 로딩 화면이 없는 하위 페이지에도 적용됩니다. Suspense가 감싼 범위 안의 렌더링이 기다리면 로딩 UI를 보여 주고, 내용이 준비되면 그것으로 바꾸지만, Effect나 이벤트 처리 함수에서 시작한 아무 비동기 작업이나 저절로 알아채는 기능은 아닙니다. 서버는 준비된 UI와 대체 화면을 먼저 보내고 나머지 내용을 이어서 보낼 수 있는데(스트리밍), 미리 가져온 데이터나 응답의 버퍼링 등에 따라 로딩 화면이 실제로 보이는 시점은 달라집니다. loading.tsx는 기본으로 서버 컴포넌트이며, 필요하면 클라이언트 컴포넌트로 만들 수도 있습니다.
loading.tsx 구현 실습
5장 1절에서 만든 게시물 목록 페이지(src/app/posts/page.tsx)와 상세 페이지(src/app/posts/[id]/page.tsx)에 로딩 UI를 더해, 데이터를 불러오는 동안 사용자에게 피드백을 주어 봅시다. 5장 1절에서 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 모듈로 나눌 수 있습니다.
상세 페이지 전용 대체 화면을 따로 두려면 [id] 폴더 안에도 loading.tsx를 만듭니다. 이 파일이 없으면 상위의 posts/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에 들어가 목록의 대체 화면이 나타나는지 확인합니다. 개발자 도구의 네트워크 속도 제한(Chrome의 Slow 4G나 3G 같은 설정)은 브라우저와 Next.js 서버 사이의 통신을 늦출 뿐, 서버가 로컬 json-server에 보내는 요청 자체를 늦추지는 않습니다. “더 보기”로 상세 페이지에 이동해 상세의 대체 화면도 확인하며, 미리 가져온 페이지가 준비되어 있거나 응답이 빠르면 두 화면 모두 눈에 보이지 않을 수 있습니다. 기다리는 상태를 따로 실험하려면 개발용 API에 통제된 지연을 넣고 실제로 보이는지 확인합니다.
로딩 UI의 작동 원리: Suspense와 스트리밍
Next.js는 loading.tsx를 자동으로 만든 React Suspense의 fallback으로 씁니다. 프레임워크가 지원하는 서버 데이터 읽기나 use처럼 렌더링을 기다리게 하는 작업이 이 Suspense 안에서 끝나지 않으면 대체 화면을 보여 줍니다. 서버는 이미 준비된 바깥 UI와 대체 화면을 먼저 보내고, Suspense 안의 내용이 준비되면 이어서 보낼 수 있으며, 클라이언트는 도착한 내용으로 대체 화면을 바꿉니다. 준비가 끝난 이동에서는 대체 화면 없이 바로 내용이 나타날 수도 있습니다(Next.js 문서의 loading).
더 깊이: 스트리밍을 시작한 뒤에는 상태 코드를 바꿀 수 없습니다
HTTP 응답은 헤더를 먼저 보내고 본문을 이어서 보내며, 상태 코드는 그 헤더에 들어 있습니다. 그래서 Next.js는 스트리밍 응답을 200 상태 코드로 시작하고, 본문의 스트리밍이 시작되는 순간 헤더와 상태 코드가 확정됩니다. 스트리밍이 시작되는 때는 loading.tsx 같은 Suspense의 대체 화면이 렌더링되거나, <Suspense> 안의 서버 컴포넌트가 기다리기 시작할 때입니다. 그보다 먼저 notFound(), forbidden(), unauthorized()를 부르면 404, 403, 401 상태가 그대로 유지됩니다.
스트리밍이 시작된 뒤에 notFound()가 불리면 상태 코드는 이미 200으로 나간 뒤입니다. 이때 Next.js는 스트리밍하는 HTML에 <meta name="robots" content="noindex">를 넣어 검색 엔진이 그 URL을 색인하지 않게 하며, 일부 크롤러는 이런 응답을 소프트 404(soft 404)로 분류하기도 합니다. 분석이나 규정 때문에 실제 404 상태가 꼭 필요하다면, 본문을 보내기 전에 판단해야 합니다. 공식 문서는 Proxy에서 슬러그를 확인한 뒤 찾을 수 없음 라우트로 rewrite하거나 404 응답을 직접 돌려주는 방법을 제시하며, 이때 Proxy의 검사는 빠르게 유지하고 본문 전체를 조회하지 않도록 권합니다.
로딩 UI가 적용되는 범위
NEXT · CH5-4
같은 layout의 대기는 이 loading 경계 밖입니다
loading.tsx의 자동 Suspense는 같은 폴더 layout의 children 쪽에 놓입니다. page와 하위 라우트가 렌더링 중 대기할 때 대체 화면을 제공합니다.
가장 가까운 하위 경계가 대기를 처리합니다. Effect·이벤트의 임의 fetch를 자동 감지하지 않으며, 콘텐츠가 이미 준비되면 로딩 화면이 안 보일 수 있습니다.
위 그림은 loading.tsx의 Suspense가 감싸는 범위를 보여 줍니다. 그림의 요약처럼 loading.tsx의 자동 Suspense는 같은 폴더의 layout이 렌더링하는 children 쪽에 놓이며, page와 하위 라우트가 렌더링 중에 기다릴 때 대체 화면을 제공합니다. 같은 폴더의 layout.tsx 안에 자동 Suspense가 있고, 그 안에 page.tsx와 하위 라우트(하위 layout.tsx 포함)가 들어가므로, 같은 폴더의 layout.tsx 자신이 기다리는 작업은 이 Suspense 바깥에 있습니다. 그림의 마지막 설명처럼 가장 가까운 하위 Suspense가 기다림을 처리하며, Effect나 이벤트의 아무 fetch를 저절로 알아채지 않고, 내용이 이미 준비되면 로딩 화면이 보이지 않을 수 있습니다.
그래서 같은 폴더의 레이아웃에서 먼저 기다리는 데이터는 이 자동 Suspense로 가려지지 않습니다. 그 작업을 페이지로 옮기거나, 기다리는 하위 컴포넌트를 직접 <Suspense>로 감쌀 수 있습니다. Suspense가 중첩되어 있으면 기다리는 컴포넌트에서 가장 가까운 Suspense가 처리하며, 대체 화면 자체도 기다린다면 그 바깥에 또 다른 Suspense가 필요합니다.
예를 들어 posts/layout.tsx 자체에서 사용자 정보를 기다리는 경우와 posts/page.tsx에서 게시물 목록을 기다리는 경우를 비교해 봅시다. 아래는 그 조회가 렌더링을 실제로 기다리게 하고 아직 끝나지 않았다고 가정한 판단의 예이며, 지연값이나 표시 시간을 측정한 결과가 아닙니다. 첫 경우의 사용자 정보 대기는 posts/loading.tsx의 자동 Suspense보다 바깥에 있으므로, 로딩 파일을 만들었다는 것만으로 이 레이아웃 자체의 대기가 처리되지 않습니다. 기다리는 작업을 하위 컴포넌트로 옮겨 그 컴포넌트를 직접 <Suspense>로 감싸거나, 필요한 바깥의 Suspense를 따로 설계해야 합니다. 두 번째 경우의 페이지 조회는 자동 Suspense 안에 있으므로, 내용이 준비되지 않은 동안 목록의 대체 화면을 쓰고 준비되면 목록으로 바꿉니다. 이미 준비된 응답이나 미리 불러오기, 브라우저의 버퍼링 때문에 이 화면이 눈에 보이지 않을 수 있으므로, 스피너를 보지 못했다는 것만으로 Suspense가 없다고 결론 내리지 않습니다.
클라이언트 훅을 쓸 때 주의할 점
loading.tsx는 기본으로 서버 컴포넌트입니다. useSearchParams를 직접 부르는 컴포넌트는 클라이언트 컴포넌트여야 하며, 정적으로 렌더링되는 경로에서는 이 훅을 부르는 하위 컴포넌트를 따로 <Suspense>로 감싸야 프로덕션 빌드에서 필요한 대기 처리가 갖춰집니다. loading.tsx 자신은 바깥 Suspense의 대체 화면이므로, 그 안의 훅을 바깥의 Suspense만으로 처리할 수 있다고 가정하면 안 됩니다.
"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>
);
}useSearchParams를 부르는 부분만 QueryLoadingContent로 떼어 내 직접 <Suspense>로 감쌌다는 점이 핵심입니다. 그래서 검색 매개변수를 아직 읽을 수 없는 동안에는 검색어가 없는 짧은 문구가 먼저 보이고, 값을 읽을 수 있게 되면 q가 들어간 문구로 바뀝니다.
정리
loading.tsx는 같은 폴더의 레이아웃 안에 자동으로 만들어지는 Suspense의 대체 화면으로, 그 폴더의 page와 하위 라우트가 렌더링 중에 기다릴 때 보입니다.- 하위 폴더에
loading.tsx가 없으면 상위의 로딩 화면이 하위 페이지까지 감싸며, 가장 가까운 것이 기다림을 처리합니다. - 같은 폴더의 레이아웃 자신이 기다리는 작업은 자동 Suspense 바깥이므로, 페이지로 옮기거나 직접
<Suspense>로 감쌉니다. - 로딩 UI는 렌더링이 기다리는 동안에만 보이며, 이벤트나 Effect에서 시작한 요청의 진행 상태는 그 컴포넌트의 상태로 관리합니다.
useSearchParams를 쓰는 컴포넌트는 클라이언트 컴포넌트로 만들고 따로<Suspense>로 감쌉니다.
연습 문제
1. 가장 가까운 로딩 화면
posts/loading.tsx와 posts/[id]/loading.tsx가 모두 있고, 상세 페이지의 서버 렌더링이 기다린다고 가정합니다. 어느 대체 화면이 가장 가까울까요? 상세의 로딩 파일을 지우면 어떻게 될까요? 렌더링은 곧바로 끝나고 클라이언트 컴포넌트의 버튼 이벤트가 따로 fetch를 시작했다면, 로딩 파일만으로 버튼의 진행 상태가 저절로 보일까요?
해설 보기
상세 페이지에 더 가까운 [id]/loading.tsx가 기다림을 처리합니다. 이 파일을 지우면 상위의 posts/loading.tsx가 감싸는 범위에서 상세 페이지의 기다림을 처리할 수 있습니다. 같은 폴더의 레이아웃 자신이 먼저 기다리는 경우라면 앞에서 본 것처럼 자동 Suspense의 위치를 다시 확인해야 합니다.
버튼 이벤트에서 시작한 요청은 이미 끝난 렌더링을 기다리게 하는 작업이 아니므로 자동 로딩 화면의 대상이 아닙니다. 버튼의 요청 시작과 성공, 실패는 그 클라이언트 컴포넌트의 상태로 관리해야 합니다. 문제를 살펴볼 때는 먼저 파일의 위치와 기다리는 작업이 있는 컴포넌트를 표시하고, 그다음 실제 응답이 언제 준비되는지 확인하며, 네트워크가 느리다는 것만으로 서버의 모든 조회가 똑같이 늦어진다고 가정하지 않습니다.
2. 레이아웃의 대기와 로딩 화면
posts/layout.tsx가 3초 걸리는 사용자 정보 조회를 기다린 뒤 렌더링합니다. posts/loading.tsx가 있는데도 3초 동안 로딩 화면이 보이지 않는 이유는 무엇이고, 어떻게 고칠 수 있을까요?
해설 보기
posts/loading.tsx의 자동 Suspense는 posts/layout.tsx가 렌더링하는 children 쪽에 놓이므로, 레이아웃 자신이 기다리는 3초는 그 Suspense 바깥에 있어 대체 화면으로 가려지지 않습니다.
사용자 정보를 보여 주는 부분을 별도의 비동기 서버 컴포넌트로 떼어 내 레이아웃에서 <Suspense fallback={...}>로 감싸면, 레이아웃의 나머지는 먼저 보이고 그 부분만 대체 화면을 보여 줄 수 있습니다. 또는 상위 폴더에 loading.tsx를 두어 이 레이아웃까지 감싸게 하는 방법도 있습니다.
3. 보이지 않은 로딩 화면
posts/page.tsx가 게시물 목록을 기다리고 posts/loading.tsx도 있는데, 로컬 개발 서버에서 목록으로 이동해 보니 로딩 화면을 한 번도 보지 못했습니다. 이것만으로 Suspense가 동작하지 않는다고 결론 낼 수 있을까요? 무엇을 먼저 확인해야 할까요?
해설 보기
결론 낼 수 없습니다. 로컬의 응답이 빨리 준비되었거나, 이미 받아 둔 결과나 미리 불러오기 때문에 대체 화면이 눈에 띄기 전에 목록으로 바뀌었을 수 있습니다.
먼저 기다리는 작업이 posts/page.tsx처럼 자동 Suspense 안쪽에 있는지, 같은 폴더의 레이아웃이 먼저 기다리는 것은 아닌지 파일 위치로 확인합니다. 화면으로 확인하고 싶다면 개발 중의 작은 재현에서 조회를 일부러 늦추어 대체 화면이 나타나는지 보고, 확인한 뒤에는 그 지연을 제거합니다.
loading.tsx의 동작은 loading.js API 참조에서 확인할 수 있습니다.
이것으로 5장을 마칩니다. 5장에서는 URL의 고유한 UI인 페이지 컴포넌트에서 서버 데이터를 요청하고 params를 쓰는 방법, 레이아웃을 만들고 중첩하는 방법과 레이아웃 데이터의 범위, 이동할 때마다 새로 마운트되는 템플릿, 그리고 loading.tsx와 Suspense로 로딩 UI를 만드는 방법을 익혔습니다. 다음 장의 서버 컴포넌트에서 데이터 페칭부터는 데이터 페칭과 캐싱을 본격적으로 다룹니다.