결론부터 말씀드리면, 컴포넌트의 독립성을 높이다 보면 필연적으로 ‘N+1 데이터 페칭’ 문제가 생기기 쉬운데요.
이 문제는 ‘데이터로더(DataLoader)‘의 배치 처리 기능을 이용해 해결하는 것이 정답입니다.
미리 알아두실 점#
시작하기 전에 한 가지 중요한 전제가 있는데요.
이 글의 내용은 백엔드 API가 여러 개의 ID를 한 번에 조회하는 기능을 이미 지원하고 있다는 가정하에 진행됩니다.
N+1 문제는 왜 발생할까#
앞선 글에서 데이터 페칭 ‘코로케이션’이나 ‘병렬 처리’를 위해 컴포넌트를 잘게 쪼개는 것을 추천했는데요.
이렇게 컴포넌트를 잘게 나누다 보면 페이지 전체에서 발생하는 데이터 페칭을 관리하기가 점점 어려워지거든요.
이때 발생하는 두 가지 문제가 있는데, 첫 번째는 ‘중복 데이터 페칭’입니다.
하지만 이 문제는 Next.js의 ‘리퀘스트 메모이제이션(Request Memoization)’ 기능이 해결해주므로, 데이터 페칭 레이어만 잘 분리해두면 크게 걱정할 필요가 없습니다.
문제는 바로 두 번째, ‘N+1 데이터 페칭’인데요.
데이터 페칭 단위를 잘게 나눌수록 이 N+1 문제가 발생할 가능성은 기하급수적으로 높아집니다.
아래 코드는 게시물 목록을 가져온 뒤, 각 목록의 자식 컴포넌트에서 작성자 정보를 개별적으로 조회하는 예시인데요.
바로 이런 구조에서 N+1 문제가 발생합니다.
page.tsx
import { type Post, getPosts, getUser } from "./fetcher";
export const dynamic = "force-dynamic";
export default async function Page() {
const { posts } = await getPosts();
return (
<>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<PostItem post={post} />
</li>
))}
</ul>
</>
);
}
async function PostItem({ post }: { post: Post }) {
const user = await getUser(post.userId);
return (
<>
<h3>{post.title}</h3>
<dl>
<dt>author</dt>
<dd>{user?.username ?? "[unknown author]"}</dd>
</dl>
<p>{post.body}</p>
</>
);
}
fetcher.ts
export async function getPosts() {
const res = await fetch("https://dummyjson.com/posts");
return (await res.json()) as {
posts: Post[];
};
}
// ... Post, User 타입 정의
export async function getUser(id: number) {
const res = await fetch(`https://dummyjson.com/users/${id}`);
return (await res.json()) as User;
}
이 페이지가 렌더링될 때 getPosts()가 1번, 그리고 게시물 개수(N)만큼 getUser()가 N번 호출되는데요.
결과적으로 아래와 같이 총 ‘N+1’번의 데이터 페칭이 발생하게 됩니다.
…
DataLoader를 이용한 해결책#
이런 N+1 문제를 피하기 위해 API 쪽에서는 보통 https://dummyjson.com/users/?id=1&id=2&id=3처럼 여러 ID를 한 번에 조회하는 기능을 만들어두거든요.
Next.js에서는 바로 이 API와 ‘데이터로더(DataLoader)’ 라이브러리를 함께 사용해서 N+1 문제를 해결할 수 있습니다.
‘데이터로더’는 원래 ‘그래프큐엘(GraphQL)’ 서버에서 널리 쓰이는 라이브러리로, 데이터 요청을 모아서 한 번에 처리(배치 처리)하고 캐싱하는 기능을 제공하는데요.
아주 짧은 시간 동안 여러 번 호출된 dataLoader.load(id)를 하나로 모아, ID 배열을 배치 함수에 넘겨주는 방식으로 동작합니다.
Next.js에서 DataLoader 사용하기#
서버 컴포넌트들은 서로 병렬로 렌더링되기 때문에, 각 컴포넌트에서 await myLoader.load(id)를 호출해도 데이터로더가 이 요청들을 똑똑하게 모아서 한 번에 처리해주는데요.
앞서 보여드린 예시 코드의 getUser() 함수를 데이터로더를 사용해서 다시 작성해 보겠습니다.
fetcher.ts (개선된 버전)
import DataLoader from "dataloader";
import * as React from "react";
// ...
const getUserLoader = React.cache(
() => new DataLoader((keys: readonly number[]) => batchGetUser(keys)),
);
export async function getUser(id: number) {
const userLoader = getUserLoader();
return userLoader.load(id);
}
async function batchGetUser(keys: readonly number[]) {
// 실제 dummyjson은 이 기능을 지원하지 않지만, 예시를 위한 코드입니다.
const res = await fetch(
`https://dummyjson.com/users/?${keys.map((key) => `id=${key}`).join("&")}`,
);
const { users } = (await res.json()) as { users: User[] };
return users;
}
// ...
여기서 가장 중요한 포인트는 데이터로더 인스턴스를 생성하는 부분을 React.cache()로 감싸준 것인데요.
데이터로더 자체에 캐시 기능이 있기 때문에, 모든 유저 요청에 걸쳐 동일한 인스턴스를 공유하면 다른 유저의 데이터가 노출될 수 있습니다.
React.cache()는 요청(request) 단위로 값을 캐싱해주므로, 각 유저의 요청마다 새로운 데이터로더 인스턴스가 생성되도록 보장해주는 아주 중요한 역할을 합니다.
이렇게 구현하면 기존의 getUser() 함수 인터페이스는 전혀 바꾸지 않으면서도, N+1 데이터 페칭 문제를 깔끔하게 해결할 수 있습니다.
다른 대안은 없을까#
여기서 소개한 패턴은 일종의 ‘지연 로딩(Lazy Loading)’ 방식인데요.
만약 백엔드 API의 구현이나 성능상의 이유로 이 방식이 적합하지 않다면, ‘즉시 로딩(Eager Loading)’ 패턴을 고려해볼 수 있습니다.
즉, N+1의 첫 번째 요청(‘1’)에서 관련된 모든 정보(N)를 한꺼번에 다 가져오는 방식인데요.
하지만 이 방식은 자칫 잘못하면 책임이 너무 커다란 ‘갓 에이피아이(God API)‘를 만들게 될 위험이 있습니다.