loading.tsx와 Suspense는 같지 않다

App Router에서 loading.tsx와 Suspense의 Navigation 동작 차이2026-04-04
#Next.js#React#Suspense

loading.tsx도 결국 Suspense로 감싸지는 거니까 동작이 똑같을 거라고 생각했는데, 그건 오해였습니다.

문제 상황

페이지 간 Navigation 시 이전 화면이 유지되다가 새 페이지가 준비되면 자연스럽게 전환되길 원했습니다. 하지만 loading.tsx를 사용하고 있던 상황에서는 Navigation이 발생할 때마다 즉시 fallback UI(로딩 화면)가 노출되고 있었습니다.

기존 콘텐츠를 유지하면서 새 페이지가 로드된 후 전환되는, 좀 더 부드러운 UX를 원했기에 이 동작을 개선하기로 했습니다.

먼저 알아야 할 것: useTransition과 startTransition

본격적으로 들어가기 전에 React의 useTransitionstartTransition에 대해 간단히 짚고 넘어가겠습니다.

startTransition은 상태 업데이트를 non-blocking Transition으로 표시하는 API입니다. 이 안에서 발생하는 상태 변경은 "Transition"으로 처리되며, UI를 blocking하지 않고 백그라운드에서 렌더링을 준비합니다.

import { startTransition } from "react";
 
// 상태 업데이트를 non-blocking Transition으로 표시
startTransition(() => {
  setPage("/new-page");
});

useTransition은 이 startTransition에 로딩 상태(isPending)를 추가로 제공하는 Hook입니다.

const [isPending, startTransition] = useTransition();
 
startTransition(() => {
  setPage("/new-page");
});
 
// isPending으로 전환 중 상태를 표현할 수 있음
return <div style={{ opacity: isPending ? 0.7 : 1 }}>...</div>;

여기서 중요한 점은 Transition 중에는 Suspense의 fallback이 트리거되지 않는다는 것입니다. 이미 화면에 보여지고 있는 콘텐츠가 있다면, React는 그 콘텐츠를 유지하면서 새 콘텐츠가 준비될 때까지 기다립니다.

App Router의 Navigation은 useTransition 기반이다

App Router에서 모든 클라이언트 사이드 Navigation은 내부적으로 startTransition을 기반으로 동작합니다.

// Next.js 내부적으로 Navigation을 이렇게 처리합니다
startTransition(() => {
  // 라우트 변경
});

위에서 설명했듯이 startTransition 내에서 상태 업데이트가 발생하면, Suspense 경계가 감지되더라도 이미 보여지고 있는 콘텐츠에 대해서는 fallback을 트리거하지 않습니다.

React 공식문서에서는 이렇게 설명하고 있습니다.

During a Transition, React will avoid hiding already revealed content. However, if you navigate to a route with different parameters, you might want to tell React it is different content. You can express this with a key.

전환 중에 React는 이미 공개된 콘텐츠를 숨기지 않습니다. 그러나 다른 매개변수를 사용하여 경로로 이동하는 경우 React에 다른 콘텐츠임을 알리고 싶을 수도 있습니다. 이를 key로 표현할 수 있습니다.

즉, Transition 중에 React는 이미 보여지고 있는 콘텐츠를 숨기지 않습니다. 이것이 핵심입니다.

그런데 loading.tsx는 다르다

여기서 제가 오해했던 부분입니다. loading.tsx도 내부적으로 Suspense로 감싸지는 것은 맞습니다. Next.js가 loading.tsx를 발견하면 아래와 같은 구조로 변환합니다.

<Suspense fallback={<Loading />}>
  <Page />
</Suspense>

그래서 저는 직접 Suspense를 선언하는 것과 loading.tsx를 사용하는 것이 완전히 동일하게 동작할 거라고 생각했습니다. 하지만 loading.tsxInstant Loading State라는 특성을 가지고 있습니다.

Next.js 공식문서에 따르면, loading.tsx는 Navigation 시 즉시 fallback UI를 보여주도록 설계되어 있습니다. 이것은 일반적인 Suspense의 Transition 동작과는 다른, Next.js가 의도적으로 구현한 동작입니다.

정리하면

Navigation 시 동작
loading.tsx즉시 fallback UI를 보여줌 (Instant Loading State)
Suspense (직접 선언)Transition 중이므로 이전 콘텐츠를 유지하고, 새 콘텐츠가 준비되면 전환

같은 Suspense 기반이지만, loading.tsx는 Navigation에 대해 즉시 반응하도록 특별히 처리되어 있는 것입니다.

해결 방법

root 레벨의 layout.tsx에 직접 Suspense를 적용하고, fallback을 두지 않은 채 loading.tsx를 삭제했습니다.

// layout.tsx
import { Suspense } from "react";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Suspense>
          {children}
        </Suspense>
      </body>
    </html>
  );
}

이렇게 하면 Navigation 시 startTransition 내부에서 Suspense가 감지되지만, 이미 보여지고 있는 콘텐츠는 유지됩니다. 새 페이지의 데이터가 모두 준비된 후에야 화면이 전환되므로, 사용자는 로딩 화면 없이 부드러운 전환을 경험할 수 있습니다.

변경 전

  1. 현재 페이지에서 다른 페이지로 이동 클릭
  2. 즉시 loading fallback UI 노출
  3. 새 페이지 렌더링

변경 후

  1. 현재 페이지에서 다른 페이지로 이동 클릭
  2. 이전 페이지가 유지됨 (새 페이지 로드 중)
  3. 새 페이지가 준비되면 자연스럽게 전환

마무리

loading.tsx가 Suspense를 기반으로 한다는 사실만 알고 있으면 둘이 동일하게 동작할 거라고 쉽게 오해할 수 있습니다. 하지만 Next.js의 loading.tsx는 Instant Loading State라는 고유한 특성이 있어서, Navigation 시 Transition의 "이전 콘텐츠 유지" 동작을 무시하고 즉시 fallback을 보여줍니다.

둘 중 어떤 방식이 정답이라고 할 수는 없고, 프로젝트의 특성에 따라 선택하면 됩니다. 페이지 로드가 오래 걸리는 경우라면 loading.tsx로 즉시 로딩 UI를 보여주는 것이 사용자에게 더 나은 피드백이 될 수 있고, 반대로 페이지 전환이 빠르거나 이전 콘텐츠를 유지하는 것이 자연스러운 경우라면 직접 Suspense를 선언하는 것이 더 부드러운 UX를 만들어 줄 수 있습니다.