개발일지

Next.js SSR 전환기 - Server to Server 통신 라이브러리 비교

무딘붓 2025. 3. 16. 22:44

 

1. 서론

Next.js 13 버전을 사용하고 있는 프로젝트에서, CSR 페이지를 SSR로 전환하게 되었습니다.

 

클라이언트가 API 서버에서 데이터를 가져오는 CSR 방식과 달리, SSR 방식은 서버에서 API 서버의 데이터를 가져와 페이지를 만든 후, 클라이언트에 완성된 페이지를 전달합니다.

 

그림을 통해 간단히 통신 과정을 살펴봅시다.

 

CSR에서의 API 호출

 

SSR에서의 API 호출

 

그렇다면 Next.js 서버에서 API 서버의 데이터를 가져오기 위해선 어떤 패칭 라이브러리를 사용해야 할까요?

 

Server to Server 통신에서 자주 사용하는 4가지 패칭 라이브러리를 간단히 살펴보겠습니다.

 

2. 주요 패칭 라이브러리 비교하기

 

2.1. fetch (내장 API)

  • 소개
    • 브라우저 및 Node.js 환경에 내장된 네트워크 요청 API
    • JavaScript의 Promise 기반 비동기 HTTP 요청을 간편하게 처리할 수 있음
    • MDN Fetch API
  • 장점
    • 브라우저와 Node.js(18+) 환경에서 기본적으로 제공됨 → 별도 설치 불필요
    • 가볍고 속도가 빠르다.
    • next.js에서는 fetch에 서버 캐싱 설정을 할 수 있다.
  • 단점
    • 요청 인터셉터, 요청 취소, 자동 JSON 변환 등 고급 기능이 기본 제공되지 않음
    • 기본적인 에러 핸들링이 부족 (HTTP 4xx/5xx에러도 reject 되지 않음)

 

2.2. Axios

  • 소개
    • Promise 기반의 HTTP 클라이언트로, fetch보다 사용이 간편한 API를 제공
    • 브라우저와 Node.js 환경에서 동일한 API를 사용할 수 있도록 설계됨
    • Axios 공식 문서
  • 장점
    • fetch보다 간결한 API (baseURL, timeout, transformRequest/Response 지원)
    • request 및 response 인터셉터 기능 제공
    • 자동 JSON 변환 및 직렬화 지원 (data 속성 제공)
    • timeout 설정 가능
    • Node.js와 브라우저 환경에서 동일한 API 제공
  • 단점
    • 추가 라이브러리 설치 필요 (axios 패키지)
    • fetch 대비 무거움
    • 데이터 캐싱 및 상태 관리가 불편

 

2.3. React Query (TanStack Query)

  • 소개
    • React에서 서버 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트하는 작업을 도와주는 라이브러리
    • 기존 상태 관리 라이브러리와의 차이점 → 서버 상태에 특화됨
    • https://tech.kakaopay.com/post/react-query-1/
  • 장점
    • 캐싱 및 백그라운드 데이터 동기화 기능 제공
    • stale-while-revalidate와 유사한 데이터 패칭 전략 가능
    • retry, refetch, pagination 등 강력한 API 제공
    • SWR과 비슷한 방식으로 사용할 수 있음
  • 단점
    • 단순한 SSR API 호출에는 불필요한 복잡성이 증가할 수 있음
    • Next.js 서버 컴포넌트에서 직접 사용할 수 없음

 

2.4. SWR (Stale-While-Revalidate)

  • 소개
    • Next.js 공식 팀에서 만든 React Hook 기반 데이터 패칭 라이브러리
    • stale-while-revalidate 전략을 기본으로 사용하여 빠른 응답과 데이터 최신화를 동시에 제공
    • SWR 공식 문서
  • 장점
    • Next.js 환경과 잘 맞으며, 클라이언트 캐싱 및 백그라운드 동기화 지원
    • API가 React Query보다 가볍고 단순함
  • 단점
    • 추가 라이브러리 설치가 필요함, React Query 대비 사용자 적음
    • React Query와 동일하게 서버 컴포넌트에서 직접 사용 불가능

 

4가지의 주요 라이브러리를 살펴봤습니다. 이 중에서 어떤 라이브러리를 사용해야 할까요?

 

각각의 장단점이 있지만, 저희는 이미 React Query를 사용하고 있는 CSR 페이지를 SSR로 전환한다는 점을 고려해야 했습니다. 따라서, 첫 페이지 렌더링은 SSR로 구현하되, 이후 동작은 React Query를 이용한 CSR로 구성하게 되었습니다.

 

따라서, 추가 설치가 필요한 SWR, 캐싱이 어려운 Axios를 제외하고, fetch로 첫 데이터를 호출해 넘기는 방법과, React Query로 첫 데이터를 가져온 후 Hydration을 통해 클라이언트에서 이어서 사용하는 방법 2가지를 고려하게 되었습니다.

 

이제 이 두 가지 방법으로 SSR에서 어떻게 API 서버와 통신하는지 살펴보겠습니다.

 

3. fetch + initialData 방식으로 데이터 넘겨주기

 

fetch를 사용하는 방법은 간단합니다. Next.js의 서버 컴포넌트에서는 fetch를 직접 사용할 수 있으므로, fetch로 API 서버에서 원하는 값을 가져온 다음, 클라이언트 컴포넌트에 initialData로 전달할 수 있습니다.

 

예시 코드를 살펴보겠습니다.

 

3.1 기본 사용법

import ClientComponent from "./client-component";

async function getData() {
  const res = await fetch('<https://api.example.com/data>');
  
  if (!res.ok) {
    throw new Error('데이터를 불러오지 못했습니다.');
  } 
  return res.json();
}

// (서버 컴포넌트)
export default async function Page() {
  const data = await getData();

  return ;
}

 

위 코드에서 서버 컴포넌트는 fetch를 이용해 데이터를 가져오고, 이를 클라이언트 컴포넌트인 ClientComponent에 initialData로 전달합니다.

 

"use client";

import { useQuery } from "@tanstack/react-query";
import axios from "axios";

// (클라이언트 컴포넌트)
export default function ClientComponent({ initialData }: { initialData: any }) {
  const { data } = useQuery({
    queryKey: ["data"],
    queryFn: async () => {
      const res = await axios.get("<https://api.example.com/data>");
      return res.data;
    },
    initialData, // 서버에서 전달한 초기 데이터 사용
  });

  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </div>
  );
}
 

 

이제 클라이언트 컴포넌트에서 initialData로 받아 첫 페이지를 빠르게 로딩할 수 있습니다. 이후에는 useQuery로 데이터를 갱신해서 계속 사용할 수 있습니다.

 

3.2 병렬 불러오기

데이터를 병렬로 가져오는 방법도 있습니다.

import Albums from './albums';

async function getArtist(username) {
  const res = await fetch(`https://api.example.com/artist/${username}`);
  return res.json();
}

async function getArtistAlbums(username) {
  const res = await fetch(`https://api.example.com/artist/${username}/albums`);
  return res.json();
}

export default async function Page({ params: { username } }) {
  // 두 요청을 병렬로 시작
  const artistData = getArtist(username);
  const albumsData = getArtistAlbums(username);

  // 모든 프로미스가 해결될 때까지 대기
  const [artist, albums] = await Promise.all([artistData, albumsData]);

  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums}></Albums>
    </>
  );
}

 

위 코드에서는 fetch 호출을 await 하기 전에 시작하여 두 요청을 병렬로 수행함으로써 시간을 절약할 수 있습니다. 그러나 두 프로미스가 모두 해결될 때까지 렌더링 된 결과를 볼 수 없으므로, 필요에 따라 서스펜스 바운더리를 사용해야 할 수 있습니다.

 

3.3 캐싱 사용하기

또한, Next.js에서 제공하는 fetch의 캐싱 기능도 사용할 수 있습니다.

fetch('https://...'); // cache: 'force-cache' is the default
fetch('https://...', { next: { revalidate: 10 } }); // 캐시 수명 10초
fetch('https://...', { cache: 'no-store' });        // 캐싱 안함

 

캐싱과 병렬 불러오기에 대한 자세한 내용은 Next.js 공식 페이지의 Data Fetching and Caching 페이지를 확인해 주세요.

 

 

3.4 인터셉터 구현하기

fetch에서 기본 제공하지 않는 인터셉터를 구현하려면, 아래와 같이 fetch를 래핑한 커스텀 유틸리티 함수를 만들어 사용할 수 있습니다.

export async function fetchWithInterceptor(url: string, options?: RequestInit) {
  // 1️⃣ 요청 인터셉터 (헤더 추가 등)
  const modifiedOptions: RequestInit = {
    ...options,
    headers: {
      ...options?.headers,
      Authorization: `Bearer ${process.env.API_TOKEN}`, // 예제: 인증 토큰 추가
      "Content-Type": "application/json",
    },
  };

  // 2️⃣ 실제 fetch 실행
  const response = await fetch(url, modifiedOptions);

  // 3️⃣ 응답 인터셉터 (에러 핸들링, 로깅 등)
  if (!response.ok) {
    console.error(`[Error] ${response.status}: ${response.statusText}`);
    throw new Error(`API 요청 실패: ${response.status}`);
  }

  return response.json(); // JSON 변환 후 반환
}

 

 

4. React Query와 Hydration을 활용하여 데이터 가져오기

 

React Query를 사용하는 방법은 조금 더 복잡합니다. 서버 컴포넌트에서 React Query를 직접 사용할 수 없기 때문입니다. 대신 서버에서 미리 데이터를 패칭 한 후, Hydration을 통해 클라이언트에서 이어서 사용하는 방법을 활용해야 합니다.

 

React Query가 낯선 분들의 이해를 위해서 몇 가지 개념을 정리해 보겠습니다.

 

  • QueryClient
    • React Query의 핵심 객체로, 쿼리 캐시와 관련된 모든 상태와 메서드를 관리합니다.
  • prefetchQuery()
    • QueryClient의 메서드로, 지정된 쿼리를 미리 패칭하여 캐시에 저장합니다.
    • 서버에서 이 메서드를 사용하면, 해당 쿼리의 데이터가 미리 로드되어 이후 클라이언트에서 중복 요청 없이 재사용할 수 있습니다.
  • dehydrate()
    • 서버에서 QueryClient의 상태를 직렬화하여 클라이언트로 전달하기 위해 사용합니다. (직렬화에 대해 간단히 설명하면, 데이터를 전달하기 위해 ‘포장’하는 과정으로 비유할 수 있습니다.)
    • 직렬화된 데이터는 일반적으로 DehydratedState 형태로 HTML에 포함되거나 페이지의 props로 전달됩니다.

 

개념을 살펴봤으니, 조금 더 구체적으로 과정을 설명해 보겠습니다.

 

  1. React Query의 prefetchQuery를 이용해 서버에서 데이터를 먼저 가져온 후, dehydrate로 React Query의 상태를 직렬화합니다.
  2. 클라이언트에서 직렬화된 상태를 Hydration과정으로 복원한 후,
  3. 복원된 데이터를 이용해 페이지를 렌더링 합니다.

 

각 과정의 코드를 자세히 살펴보겠습니다.

 

4.1 서버에서 데이터 패칭 및 직렬화

서버 컴포넌트에서는 React Query의 QueryClient를 생성한 후, prefetchQuery로 데이터를 미리 가져옵니다. 이후 dehydrate() 함수를 사용해 QueryClient의 상태를 직렬화하여 클라이언트로 전달할 준비를 합니다.

 

import { dehydrate, QueryClient } from "@tanstack/react-query";
import Hydrate from "./hydrate-client";
import ClientComponent from "./client-component";

export default async function Page() {
  // 1. QueryClient 생성  
  const queryClient = new QueryClient();
  
  // 2. 서버에서 데이터 가져오기 (S2S 통신)
  await queryClient.prefetchQuery({
    queryKey: ["data"],
    queryFn: async () => {
      const res = await fetch("https://api.example.com/data", {
        method: "GET",
        headers: { "Content-Type": "application/json" },
      });

      if (!res.ok) throw new Error("데이터를 가져오는 데 실패했습니다.");

      return res.json();
    },
  });

  // 3. dehydrate()로 react-query의 데이터를 직렬화
  const dehydratedState = dehydrate(queryClient);

  // 4. 직렬화된 상태와 함께 클라이언트 컴포넌트 렌더링
  return (
    <Hydrate state={dehydratedState}>
      <ClientComponent />
    </Hydrate>
  );
}

4.2 클라이언트에서 Hydration 처리

Next.js의 서버 컴포넌트는 useQueryClient()를 직접 사용할 수 없으므로, 클라이언트에서 hydrate()을 처리해 주는 Hydrate 컴포넌트를 별도로 만들어서 사용합니다.

 

Hydrate 컴포넌트에서는 HydrationBoundary를 사용하여 서버에서 직렬화된 상태를 복원합니다.

"use client";

import { Hydrate as RQHydrate, HydrationBoundary } from "@tanstack/react-query";

export default function Hydrate({ state, children }: { state: any; children: React.ReactNode }) {
  return (
    <HydrationBoundary state={state}>
      {children}
    </HydrationBoundary>
  );
}

4.3 클라이언트 컴포넌트에서 데이터 사용하기

클라이언트 컴포넌트에서는 useQuery 훅을 사용하여 데이터를 가져오고, 해당 데이터를 활용하여 UI를 구성합니다. 서버에서 미리 가져온 데이터는 Hydration을 통해 즉시 사용 가능합니다.

 

이후에는 클라이언트 컴포넌트에서 useQuery()를 사용하여 데이터를 가져오고 캐싱할 수 있습니다.

"use client";

import { useQuery } from "@tanstack/react-query";
import axios from "axios";

export default function ClientComponent() {
  // 클라이언트에서 데이터 요청 (하지만 초기 데이터는 SSR로 전달됨)
  const { data, isLoading, error } = useQuery({
    queryKey: ["data"],
    queryFn: async () => {
      const res = await axios.get("https://api.example.com/data");
      return res.data;
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading data</div>;

  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </div>
  );
}​

 

 

5. 참고자료

 

 

next.js에서 react query가 필요할까?

😋왜 필요할까?react-query가 제공해주는 SSR 환경에서 사용법을 보면서 한가지 의문이 들었습니다. react query가 제공해주는 캐싱, 리페칭 등등의 기능들이 매력적이긴한데...사실 대부분의 기능은

xionwcfm.tistory.com

 

Next.Js에서 fetch말고 React-Query 사용하기

Next.js에서 React-Query(TanStack Query)를 사용하는 방법에 대한 글을 작성하였습니다.

velog.io

 

Next) 서버 컴포넌트(React Server Component)에 대한 고찰

이번에 회사에서 신규 웹 프로젝트를 진행하기로 결정했는데, 정말 뜬금 없게도 앱 개발자인 내가 이 프로젝트를 리드하게 되었다. 사실 억지로 떠맡게 된 것은 아니고, 새로운 웹 기술 스택을

velog.io

 

React-Query를 Next.js와 함께 사용해보자 Part 1.

React-query를 보다 더 잘 사용하기 위한 스터디 결과

blog.kmong.com