Next.js loading.tsx 알아보기

질문Bot·2026년 2월 28일

Next.js

목록 보기
13/13
post-thumbnail

Next.js App Router를 사용하다 보면 자연스럽게 loading.tsx를 사용하게 됩니다.

저 역시 처음에는 “페이지 로딩 상태를 보여주는 파일” 정도로 이해하고 사용했습니다.
그런데 실제로 프로젝트를 진행하다 보니 loading.tsx의 동작이 직관적이지 않다는 느낌을 받았습니다.

어느 순간 이런 생각이 들었습니다.
“나는 이걸 정확히 이해하고 쓰고 있는 걸까?”

🤔 나의 궁금증

  • loading.tsx는 정확히 어떤 타이밍에 나타날까?
  • 왜 어떤 페이지에서는 잘 보이고, 어떤 페이지에서는 거의 보이지 않을까?
  • 클라이언트에서 useEffect로 데이터를 가져오면 왜 loading.tsx가 동작하지 않는 것처럼 보일까?
  • loading.tsx는 내부적으로 어떻게 구현되어 있길래 이런 차이가 생길까?

이번 글의 목표는 단 하나입니다.
그냥 사용하는 loading.tsx가 아니라, 동작을 예측할 수 있는 loading.tsx를 사용하는 것.


1. loading.tsx는 Suspense를 선언하는 파일이다

공식 문서 기준으로 loading.tsx는

  • 특정 라우트 세그먼트에 대한 즉시 로딩 상태를 만드는 파일
  • 내부적으로는 해당 세그먼트를 Suspense로 감싸고 fallback UI로 사용
  • 기본은 서버 컴포넌트지만 "use client"를 붙이면 클라이언트 컴포넌트도 가능

여기서 중요한 포인트는 “로딩 UI를 만드는 파일”이 아니라는 것입니다.

loading.tsx는 단순한 UI 파일이라기보다
해당 세그먼트에 Suspense 경계를 선언하는 파일에 가깝습니다.

쉽게 말하자면!
폴더 하나를 통째로 감싸는 Suspense fallback을 파일 하나로 선언하는 Next.js 컨벤션

예시

// app/dashboard/loading.tsx

export default function Loading() {
  return (
    <div className="p-6">
      <div className="h-6 w-40 rounded bg-gray-200 mb-4" />

      <div className="h-4 w-full rounded bg-gray-100 mb-2" />

      <div className="h-4 w-3/4 rounded bg-gray-100" />
    </div>
  );
}

Next.js는 내부적으로 대략 아래와 같은 구조를 만듭니다.

<DashboardLayout>
  <Suspense fallback={<DashboardLoading />}>
    <DashboardPage />
  </Suspense>
</DashboardLayout>

loading.tsx = 세그먼트 Suspense fallback 이다.
이걸 이해하면 이후 loading.tsx의 동작을 대부분 예측할 수 있다고 생각합니다.


2. loading.tsx의 범위는 어디까지인가?

App Router에서 가장 중요한 규칙 하나가 있습니다.

폴더 = 라우트 세그먼트

아래와 같은 폴더 구조가 있다고 가정해봅시다.

app
 └── dashboard
      ├── layout.tsx
      ├── loading.tsx
      ├──page.tsx
      └── settings
           └──page.tsx

이 구조에서 /dashboard/dashboard/settings
모두 dashboard/loading.tsx의 영향을 받습니다.

즉, 모두 dashboard/loading.tsx 관할입니다.

조금 더 정확히 말하면

  • 같은 세그먼트 아래 page / layout
  • 하위 세그먼트까지 포함
  • 더 가까운 loading.tsx가 있으면 그게 우선

그래서 저는 이렇게 기억을 합니다.

💡 loading.tsx는 이 파일이 있는 폴더 아래 트리를 통째로 감싼다

“왜 어떤 페이지에서는 loading이 보이고, 어떤 페이지에서는 안 보이는지”를 설명할 수 있게 됩니다.


3. Next.js는 언제 loading.tsx를 보여줄까?

이건 제가 가장 궁금했던 부분이었습니다.
공식 문서를 참고하면 이렇게 작성되어있습니다.

loading.tsx는 해당 세그먼트의 서버 컴포넌트 트리가 Suspense pending 상태가 되는 순간부터
UI가 준비될 때까지 표시됩니다.

여기서 핵심은 딱 두 가지
1. 세그먼트 단위
2. Suspense pending 상태

즉 loading.tsx는 “시간 기준”이 아니라 Suspense 상태 기준이다.

다시 말해, 서버 컴포넌트에서 데이터 fetching이나 비동기 작업이 발생해
렌더링이 지연되는 순간 Suspense가 pending 상태가 되고,
그때 loading.tsx가 fallback으로 표시됩니다.


3-1. 첫 진입 (SSR / RSC)

/dashboard에 처음 들어간다고 가정해보겠습니다.

async function getDashboard() {
  await new Promise((r) => setTimeout(r, 1500));

  const res = await fetch('https://api.example.com/dashboard', {
    cache: 'no-store',
  });

  return res.json();
}

실제 흐름

  • 서버가 DashboardPage 렌더를 시작
  • await 지점에서 렌더가 멈춤
  • 해당 세그먼트 Suspense가 pending 상태로 진입
  • Next.js는 이미 loading.tsx를 Suspense fallback으로 연결해 둔 상태
  • fallback = loading.tsx 표시

그래서 결과는
→ 서버는 loading.tsx HTML을 먼저 스트리밍하고
→ 데이터가 준비되면 실제 UI 스트림을 이어서 전송하며
→ 클라이언트에서는 자연스럽게 교체됩니다.

이 동작이 바로 Next.js가 말하는 Streaming + Loading UI입니다.
즉. Suspense는 아직 준비되지 않은 UI를 표시하고, Streaming은 준비된 UI부터 먼저 전송하게 해줍니다.


혹시 Link 컴포넌트가 익숙하지 않다면 아래 글을 참고해 보셔도 좋겠습니다.
코드 레벨까지 정리한 내용입니다.
Next Link 컴포넌트 정리한 글

<Link href="/dashboard/settings" />

여기서 중요한 개념이 prefetch입니다.

Next.js의 Link는 다음 시점에 해당 라우트의 리소스를 미리 가져옵니다.

  • viewport 진입
  • hover
  • idle

이 과정에서 서버 컴포넌트 결과(RSC payload), loading UI, 필요한 JS chunk가 사전에 준비됩니다.

그래서 클릭 순간에는 다음 두 가지 상황이 발생

  • 아직 준비되지 않았다면 → Suspense pending → loading.tsx 표시
  • 이미 준비되어 있다면 → 즉시 렌더 (loading 거의 안 보임)

즉 우리가 느끼는 Instant Loading State는 단순히 loading UI 때문이 아니라
👉 Suspense + Streaming + Prefetch 조합이기 때문에
그래서 Link 기반 네비게이션에서는 loading.tsx가 안보이는 경우가 많습니다.


3-3. useEffect fetch에서는 왜 안 뜰까

이건 진짜 많이 헷갈리는 포인트입니다.

useEffect(() => {
  fetch(...);
}, []);

이 경우:

  • fetch는 클라이언트 비동기 작업이고
  • 컴포넌트가 렌더된 이후에 실행됩니다
  • 따라서 Suspense가 관여할 수 없습니다
  • 서버 입장에서는 이미 “렌더 가능한 상태”입니다

그래서 loading.tsx는 관여할 타이밍이 없다!

정리하면:
loading.tsx는
서버 컴포넌트 + Suspense + streaming 영역
useEffect 로딩은 컴포넌트 상태 영역이여서, 이 둘은 완전히 다른 레이어입니다.

Suspense는 렌더 중 기다리는 작업을 처리하고, useEffect는 렌더 이후 작업을 처리함.


5. 내부 구조 간단하게 보기

App Router에는 이런 개념들이 있습니다.

  • AppRouter
  • LayoutRouter
  • LoadingBoundary
  • ErrorBoundary
  • NotFoundBoundary

개념적으로 보면 구조는 대략 다음과 같습니다.

<LayoutRouter>
  <LoadingBoundary fallback={<SegmentLoading />}>
    <SegmentTree />
  </LoadingBoundary>
</LayoutRouter>

여기서 SegmentLoading이 바로 loading.tsx입니다.
Boundary는 “이 영역에서 문제가 발생하거나 준비되지 않았을 때 대체 UI를 보여주는 경계”를 의미합니다.
즉 loading.tsx를 추가한다는 것은 👉 이 세그먼트에 LoadingBoundary를 하나 선언한다는 의미입니다.

profile
유용한 정보를 전달하는 사람이 되고자 노력합니다.

0개의 댓글