RSS듀오랩스
Next.js

Next.js 데이터 가져오기 순서: 순차와 병렬, 그리고 Suspense 위치

작성자
듀오랩스 대표·7분 읽기

서버 컴포넌트에서 데이터를 읽는 코드는 이렇게 생깁니다.

export default async function ArtistPage({ params }) {
  const { username } = await params;
  const artist = await getArtist(username);
  const albums = await getAlbums(username);
  return <Profile artist={artist} albums={albums} />;
}

await 을 두 번 쓴 것뿐인데, 두 조회가 각각 300ms 라면 이 페이지는 600ms 를 기다립니다. 두 조회는 서로를 필요로 하지 않는데도 줄을 섭니다.

서버 컴포넌트니까 빠르다는 생각

서버 컴포넌트에서 데이터베이스를 직접 읽으면 API 왕복이 사라지므로 빨라진다고 이해하기 쉽습니다. 실제로 왕복 하나는 사라집니다. 하지만 한 컴포넌트 안에서 await 을 연달아 쓰면, 사라진 왕복보다 큰 대기가 그 자리에 생깁니다.

데이터 가져오기 문서는 이 구분을 순차와 병렬로 나눠 설명합니다. 그리고 한 가지를 먼저 짚습니다. 레이아웃과 페이지는 기본적으로 병렬로 렌더링되므로 각 세그먼트는 가능한 한 빨리 데이터를 가져오기 시작합니다. 문제는 세그먼트 사이가 아니라 한 컴포넌트 안입니다.

However, within any component, multiple async/await requests can still be sequential if placed after the other.

서로 필요 없는 조회는 함께 시작한다

앞의 코드에서 앨범 조회는 아티스트 정보를 필요로 하지 않습니다. 그렇다면 두 요청을 먼저 시작해 두고 함께 기다리면 됩니다.

const artistData = getArtist(username);   // await 하지 않는다
const albumsData = getAlbums(username);
const [artist, albums] = await Promise.all([artistData, albumsData]);

문서의 설명대로 요청은 함수를 부르는 순간 시작됩니다. await 은 결과를 기다리는 시점일 뿐입니다. 이 차이를 알고 나면 코드를 읽는 방식이 바뀝니다. await 이 연달아 있으면 「여기서 줄을 서는가」를 먼저 봅니다.

Promise.all 은 하나가 실패하면 전체가 실패한다는 점도 함께 생각해야 합니다. 일부가 실패해도 나머지를 보여 주고 싶다면 Promise.allSettled 를 쓰거나, 아래의 스트리밍으로 나눕니다.

의존하는 조회는 순차가 맞다

모든 조회를 병렬로 만들 수는 없습니다. 플레이리스트를 가져오려면 아티스트 id 가 먼저 필요한 경우처럼, 뒤의 요청이 앞의 결과를 쓰는 구조가 있습니다.

문서는 이때 뒤의 조회를 별도 컴포넌트로 빼고 <Suspense> 로 감싸는 방법을 보여 줍니다.

const artist = await getArtist(username);
return (
  <>
    <h1>{artist.name}</h1>
    <Suspense fallback={<div>불러오는 중</div>}>
      <Playlists artistID={artist.id} />
    </Suspense>
  </>
);

아티스트 이름은 먼저 보이고, 플레이리스트는 준비되는 대로 채워집니다. 다만 문서는 한계도 적습니다. 이 구조에서도 페이지는 아티스트 조회를 기다린 뒤에야 무언가를 보여 줍니다. 첫 화면을 더 빨리 내보내려면 페이지 전체를 감싸는 경계, 즉 loading.js 를 함께 둡니다.

그리고 순차 구조에서는 첫 요청이 전부를 막는다는 점을 문서가 경고합니다. 첫 조회가 느리면 그 뒤의 스트리밍도 늦게 시작됩니다. 더 줄일 수 없다면 그 결과를 캐시하는 쪽을 검토하라고 안내합니다.

경계를 데이터 가까이 두기

loading.js 만으로 충분하지 않은 이유도 같은 문서에 있습니다.

while loading.js works well for streaming route segments, using <Suspense> closer to the runtime or uncached data access is recommended.

loading.js 는 페이지 전체를 한 덩어리로 감쌉니다. 페이지의 90% 가 캐시된 내용이고 10% 만 매번 새로 읽어야 한다면, 그 10% 때문에 전체가 스켈레톤이 됩니다. 느린 조회를 하는 컴포넌트 가까이에 <Suspense> 를 두면 나머지는 즉시 보입니다.

이 판단은 Suspense 글에서 본 「무엇을 함께 보여 줄 것인가」와 같은 결정입니다. 다만 Next.js 에서는 경계의 위치가 응답을 언제 보내기 시작하는지까지 정합니다.

클라이언트로 값을 흘려보낼 때

문서는 서버에서 만든 Promise 를 클라이언트 컴포넌트에 props 로 넘기고 React 의 use API 로 읽는 방법도 안내합니다. 서버가 응답을 시작한 뒤에도 데이터가 이어서 흘러가는 구조입니다.

// 서버 컴포넌트
const posts = getPosts();           // await 하지 않고 Promise 를 넘긴다
return (
  <Suspense fallback={<Skeleton />}>
    <Posts posts={posts} />
  </Suspense>
);

클라이언트 컴포넌트에서 use(posts) 로 읽으면 준비될 때까지 가장 가까운 Suspense 경계가 fallback 을 보여 줍니다. 클라이언트에서 직접 데이터를 가져와야 한다면 SWR 이나 React Query 같은 라이브러리를 쓰라고 문서는 덧붙입니다. 그 라이브러리들은 캐시와 재검증 규칙을 따로 갖고 있습니다.

순서를 점검하는 세 질문

질문 그렇다면
이 조회가 앞의 결과를 쓰는가 아니면 함께 시작하고 Promise.all 로 기다린다
느린 조회가 화면 전체를 막는가 그 컴포넌트만 <Suspense> 로 감싼다
첫 조회가 전부를 막는가 캐시하거나, 그 조회 없이 그릴 수 있는 부분을 분리한다

느린 화면을 만났을 때 저는 먼저 await 이 몇 번 줄을 서는지 셉니다. 대개 라이브러리나 데이터베이스가 아니라 await 의 위치가 원인입니다.

여기까지가 확실한 부분

레이아웃과 페이지가 기본적으로 병렬로 렌더링된다는 점, 한 컴포넌트 안의 연속된 await 이 순차가 된다는 점, 요청이 함수 호출 시점에 시작된다는 점, 순차 구조에서 첫 요청이 전체를 막는다는 경고, loading.js 보다 데이터 가까이에 <Suspense> 를 두라는 권고, Promise 를 클라이언트로 넘겨 use 로 읽는 방법은 Next.js 16 문서 기준입니다. 300ms 같은 숫자는 설명을 위한 가정이고 실제 값은 데이터 소스에 따라 다릅니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.