1. Suspense의 개념
1-1. Suspense란
Suspense는 자식 컴포넌트가 현재 바로 렌더링될 수 없을 때, 기다리는 동안 대신 보여줄 UI를 지정하는 기능이다.
<Suspense fallback={<Loading />}> <UserProfile /> </Suspense>
UserProfile이 아직 렌더링될 수 없는 상태라면 React는 렌더링을 잠시 보류하고 fallback을 보여준다.
1-2. Suspense가 동작하는 조건
단순히 Suspense로 컴포넌트를 감싸기만 한다고 fallback이 표시되는 것은 아니다..!
데이터를 불러오는 경우에는, Promise가 아직 완료되지 않아 현재 렌더링을 끝낼 수 없는 상태가 되어야 suspend가 발생하고, 그때 Suspense의 fallback이 표시된다.
렌더링 시작 ↓ Promise를 기다림 ↓ Promise가 pending 상태 ↓ 컴포넌트 suspend ↓ Suspense fallback 표시 ↓ Promise 완료 ↓ 다시 렌더링
2. React에서의 Suspense
2-1. useEffect + fetch는 Suspense와 연결되지 않는다
다음 코드에서도 fetch가 Promise를 반환하지만
다음과 같이 Suspense로 감싸도 fallback은 표시되지 않는다.
이유는 useEffect가 렌더링이 완료된 이후 실행되기 때문이다.
function User() { const [user, setUser] = useState(null); useEffect(() => { fetch("/api/user") .then((res) => res.json()) .then(setUser); }, []); return <div>{user?.name}</div>; }
<Suspense fallback={<Loading />}> <User /> </Suspense>
React 입장에서는 User 컴포넌트가 렌더링을 정상적으로 완료했기 때문에 suspend가 발생하지 않는것이다.
2-2. Suspense를 발생시키는 방법
앞서 살펴봤듯이 Suspense가 동작하려면 렌더링 중인 컴포넌트가 아직 준비되지 않아 suspend되는 상황이 필요하다.
React에서는 이 상황을 만드는 방법이 여러 가지 있으며, use()와 React.lazy()가 대표적이다.
use()는 Promise의 결과를 렌더링 중에 직접 읽는 방법이다.
function User({ userPromise }) { const user = use(userPromise); return <div>{user.name}</div>; }
userPromise가 이미 완료되었다면 바로 값을 반환하고 렌더링을 계속한다.
반대로 Promise가 아직 pending 상태라면, use()는 값을 바로 반환할 수 없기 때문에 User 컴포넌트의 렌더링이 중단된다. 이 상태가 suspend이다.
<Suspense fallback={<Loading />}> <User userPromise={userPromise} /> </Suspense>
다만 Suspense를 사용하기 위해 반드시 use()를 직접 작성해야 하는 것은 아니다.
React.lazy()처럼 React가 내부적으로 Promise를 처리하면서 자동으로 suspend시키는 기능도 있다.
const Page = React.lazy(() => import("./Page")); <Suspense fallback={<Loading />}> <Page /> </Suspense>
React.lazy()의 import()는 해당 컴포넌트 파일을 비동기로 불러오기 때문에 Promise를 반환한다.
여기서 Page 파일이 아직 다운로드되지 않았다면 React는 Page를 렌더링할 수 없으므로 자동으로 suspend시킨다.
3. Next.js에서의 Suspense
Next.js App Router에서는 Server Component가 async/await을 사용할 수 있기 때문에 Promise 대기 상태를 Suspense와 자연스럽게 연결할 수 있다.
3-1. 페이지 단위 로딩은 loading.tsx를 사용해보자!
페이지나 Route Segment 전체의 로딩 상태를 처리하고 싶다면 해당 페이지 폴더에 단순하게 loading.tsx를 두어 로딩 UI를 보여줄 수 있다.
(loading.tsx가 suspense와 관련있는지 모르고 그냥 쓰고 있었다고 한다..)
app/ └─ users/ ├─ page.tsx └─ loading.tsx
// page.tsx export default async function UsersPage() { const users = await fetchUsers(); return <UserList users={users} />; }
// loading.tsx export default function Loading() { return <div>로딩 중...</div>; }
위의 코드처럼 작성하면 UsersPage가 fetchUsers()의 Promise를 기다리는 동안 Next.js는 loading.tsx를 보여주게 된다.
이렇게 가능한 이유는 Next.js가 해당 Route Segment에 Suspense Boundary를 자동으로 만들어 주기 떄문인데, Next.js가 내부적으로 해당 경계에 Suspense를 만들어 주어 내부적으로는 다음과 비슷하게 동작한다.
<Suspense fallback={<Loading />}> <UsersPage /> </Suspense>
3-2. 페이지 일부를 Suspense로 나누고 Streaming하기
페이지 전체가 아니라 특정 영역만 데이터를 기다리는 경우에는 해당 영역을 Suspense로 감싼다.
export default function DashboardPage() { return ( <> <Header /> <Suspense fallback={<UserSkeleton />}> <UserInfo /> </Suspense> </> ); }
async function UserInfo() { const user = await fetchUser(); return <div>{user.name}</div>; }
이때 UserInfo는 Server Component이므로 use()를 사용하지 않고 await으로 Promise를 기다릴 수 있다.
Promise가 아직 완료되지 않았다면 UserInfo의 렌더링도 끝나지 않으므로 suspend 상태가 되고, 가장 가까운 Suspense가 fallback을 보여준다.
즉 Next.js에서는 비동기 Server Component 자체가 Suspense 안에서 기다리는 대상이 될 수 있다.
여기서 Next.js가 준비된 UI 부터 보여줄 수 있는 이유는 Streaming 기술을 제공하기 때문이다.
여기서 Streaming은 페이지의 모든 내용이 준비될 때까지 기다리는 것이 아니라, 먼저 렌더링할 수 있는 부분부터 사용자에게 전달하는 방식이다.
이런 async Server Component + Suspense + Streaming 조합은 Next.js App Router에서 Suspense를 활용하는 대표적인 방식이라고 할 수 있다.
3-3. await의 위치가 중요한 이유
주의할점은 Suspense를 사용하더라도 Promise를 Suspense 바깥에서 먼저 await하면 바깥의 UI도 함께 기다리게 된다. 는 것이다.
export default async function DashboardPage() { const user = await fetchUser(); return ( <> <Header /> <Suspense fallback={<UserSkeleton />}> <UserInfo user={user} /> </Suspense> </> ); }
이 경우 DashboardPage는 fetchUser()가 완료되어야 return할 수 있다.
따라서 데이터가 필요하지 않은 Header까지 fetchUser()가 끝날 때까지 렌더링되지 않는다. Suspense가 렌더링되는 시점에는 이미 user 데이터가 준비되어 있기 때문에 UserSkeleton을 보여줄 상황도 없다.
따라서 먼저 보여주고 싶은 UI가 있다면 느린 비동기 작업을 Suspense 내부의 컴포넌트로 분리하는 것이 중요하다!
3-4. Suspense와 Error 처리
그렇다면 Promise의 Pending을 Suspeense가 처리한다면 rejected되면 누가 처리할까? 바로 Error Boundary이다. (이제야 명확해지는 사실..!)
Promise가 pending → 아직 준비되지 않음 → suspend → Suspense가 fallback 표시 Error 발생 → 작업 실패 → Error Boundary가 에러 UI 표시
정리하면서 코드를 보니 Suspense가 동작도 안하는데 그냥 써놓은 곳이 종종 있었다...ㅎ
빨리 고쳐봐야 겠다!