Overview
Next.js App Router가 제공하는 강력한 서버 캐싱 기능을 경험하면서, React Query의 필요성에 대해 다시 생각해보게 되었다. 두 기술의 핵심 차이점과 실제 프로젝트에서 어떤 상황에 무엇을 선택해야 하는지, 무한 스크롤 구현 사례를 통해 정리해본다.
새로운 선택지: Next.js App Router
최근 Next.js App Router의 캐싱 전략을 살펴보면서, Page Router에 비해 개발 경험이 많이 달라졌다는 걸 느꼈다. App Router 환경의 fetch는 서버 컴포넌트와 좋은 시너지를 보였고, 캐싱이 자동 처리되어 코드가 간결해졌다. 그러다 보니 이런 질문에 도달했다. 이 정도면 React Query가 정말 필요 없을까?
React Query의 메인테이너인 TkDodo도 비슷한 얘기를 했다. 그의 블로그 글 You Might Not Need React Query에서 그는 이렇게 말한다.
"If you're starting a new application, and you're using a mature framework like Next.js or Remix that has a good story around data fetching and mutations, you probably don't need React Query."
새로 시작하는 프로젝트이고 Next.js 같이 데이터 페칭을 잘 지원하는 프레임워크를 쓴다면, React Query가 필요 없을 수도 있다는 말이다.
핵심 차이: 캐싱의 위치
두 방식의 가장 큰 차이점은 캐시를 어디에 하느냐의 문제다.
- Next.js
fetch: 서버에 캐시를 한다. Next.js 서버가 기억해두는 '데이터 캐시'인 셈이다. - React Query: 반대로 클라이언트, 즉 사용자 브라우저에 캐시를 쌓는다.
TkDodo가 지적했듯, React Query는 근본적으로 "클라이언트의 비동기 상태를 관리하는 라이브러리"다. 데이터 페칭이 오직 서버에서만 일어난다면 클라이언트 상태 관리 라이브러리의 필요성은 줄어든다.
언제 React Query가 필요한가?
하지만 클라이언트에서의 상호작용이 중요해지면 얘기가 달라진다. 이런 지점에서 React Query는 여전히 유용하다. TkDodo 역시 이런 하이브리드 접근법을 언급한다.
"This hybrid approach can be especially beneficial if you are hitting a use-case that isn't (yet) well supported by Server Components. As an example, you might want to render an Infinite Scrolling List..."
그의 말처럼, 서버 컴포넌트가 아직 매끄럽게 지원하지 못하는 기능, 예를 들어 무한 스크롤 같은 기능을 구현할 때 React Query는 효과적이다. 사용자가 스크롤을 내릴 때마다 다음 데이터를 계속 붙여주거나, 필터링, 정렬 등의 기능들은 클라이언트 캐시를 정교하게 다룰 때 더 나은 사용자 경험을 제공할 수 있다.
실전 예시: 리스트 페이지 구현하기
이론은 알겠는데, 실제로는 어떨까? 이 두 가지 접근법이 실제 프로젝트에서 어떻게 적용되는지, '리스트 페이지'를 만드는 시나리오를 통해 단계별로 살펴보자.
1단계: 서버 캐싱으로 정적 리스트 만들기
초기 요구사항은 간단하다. 데이터베이스의 아이템 목록을 화면에 보여주는 것이다. 이는 서버 컴포넌트와 fetch 조합으로 간단히 해결된다.
// app/items/page.tsx (Server Component)
async function getItems() {
const res = await fetch('https://api.example.com/items', {
next: { tags: ['items'] }
});
return res.json();
}
export default async function ItemsPage() {
const initialItems = await getItems();
return (
<div>
{initialItems.map(item => <div key={item.id}>{item.name}</div>)}
</div>
);
}이제 아이템이 추가되거나 변경되었을 때, 특정 API를 호출해 서버 캐시를 수동으로 갱신해줄 수 있다.
여기까지는 Next.js가 제공하는 서버 캐싱만으로도 충분하다.
2단계: 클라이언트 캐싱으로 '무한 스크롤' 구현하기
하지만 스크롤을 내리면 다음 목록을 계속 보여달라는 요구사항이 추가되면 상황이 달라진다. '몇 페이지를 로드했는가', '데이터를 가져오는 중인가' 같은 상태들은 클라이언트에서 관리되어야 한다.
이때 하이브리드 방식이 효과적이다. 첫 페이지 데이터는 서버 컴포넌트가 getItems로 빠르게 전달하고, 이후의 무한 스크롤 로직은 클라이언트 컴포넌트와 React Query에 맡긴다.
// app/items/page.tsx (Server Component)
export default async function ItemsPage() {
const initialItems = await getItems();
return <ItemList initialItems={initialItems} />;
}// components/ItemList.tsx (Client Component)
'use client';
export default function ItemList({ initialItems }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 1 }) =>
fetch(`/api/items?page=${pageParam}`).then(res => res.json()),
initialData: {
pages: [{ items: initialItems, hasMore: true, nextPage: 2 }],
pageParams: [1],
},
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextPage : undefined,
});
// 스크롤 감지로 자동 로딩
useIntersectionObserver({
target: observerRef,
onIntersect: () => hasNextPage && !isFetchingNextPage && fetchNextPage(),
});
const allItems = data?.pages.flatMap(page => page.items) || [];
return (
<div>
{allItems.map(item => <li key={item.id}>{item.name}</li>)}
{isFetchingNextPage && <div>Loading more...</div>}
<div ref={observerRef} />
</div>
);
}이제 첫 페이지는 서버 덕분에 빠르게 보이고, 무한 스크롤 같은 복잡한 상호작용은 React Query가 똑똑하게 처리해준다.
만약 React Query 없이 직접 구현했다면 어땠을까? 로딩 상태, 데이터 목록, 다음 페이지 존재 여부, 에러 상태 등을 모두 useState로 만들어 관리하고, useEffect 안에서 IntersectionObserver를 설정하고, 데이터를 가져온 뒤 기존 데이터와 합치는 로직을 모두 직접 작성해야 했을 것이다. 코드는 금방 복잡해지고, 사소한 버그가 생기기도 쉽다.
useInfiniteQuery는 이 모든 과정을 깔끔하게 추상화해준다. 바로 이것이 우리가 복잡한 비동기 로직을 다룰 때 React Query 같은 상태 관리 라이브러리를 사용하는 이유다.
나름의 기준점 찾기
이 두 기술을 써보면서 느낀 건, 어느 하나가 정답이 아니라는 점이다. 각각의 장점이 뚜렷해서, 서로 부족한 점을 채워주는 보완재 같은 느낌이었다.
- 서버 캐싱: 첫 페이지 로딩 속도를 높이고 정적 콘텐츠를 보여줄 때 강력하다.
- 클라이언트 캐싱: 무한 스크롤처럼 사용자와 계속 상호작용하며 데이터가 바뀌는 상황을 다룰 때 편리하다.
결국 어떤 기술을 선택할지는 내가 지금 뭘 만들고 있는지에 달렸다. 서버에서 처리하는 게 유리한 작업은 Next.js의 fetch를, 클라이언트에서 다루는 게 더 효율적인 상태는 React Query를 쓰는 식으로, 상황에 맞게 도구를 조합하는 유연함이 중요하다.
개인적인 판단 기준
프로젝트를 진행하면서 정리해본 나만의 기준들이다. 절대적인 답은 아니지만, 비슷한 고민을 하는 누군가에게 도움이 될 수도 있을 것 같다.
Next.js fetch를 먼저 고려하는 경우:
- 블로그 글이나 제품 정보처럼 자주 바뀌지 않는 데이터
- SEO가 중요한 랜딩 페이지나 마케팅 페이지
- 사용자가 처음 접속했을 때의 로딩 속도가 중요한 경우
- 서버에서 데이터를 가공해서 보내주는 게 효율적인 경우
React Query가 더 나은 것 같은 경우:
- 사용자가 계속 클릭하고 조작하는 대시보드나 관리 페이지
- 무한 스크롤이나 실시간 알림 같은 동적인 기능
- 사용자 액션에 따라 즉시 UI가 반응해야 하는 경우
- 여러 컴포넌트가 같은 데이터를 공유해야 하는 경우
둘 다 쓰는 게 좋았던 경우:
- 첫 페이지는 서버에서 빠르게, 이후 상호작용은 클라이언트에서 부드럽게
- SEO는 중요하지만 사용자 경험도 포기하고 싶지 않은 경우
- 각각의 장점을 모두 활용하고 싶은 경우
아직도 헷갈릴 때가 많지만, 이런 기준들을 생각해보면서 조금씩 더 나은 선택을 하려고 노력 중이다.