넥스트JS + 탠스택쿼리 캐시전략: 데이터 전달 및 초기 렌더링 최적화

김현준·2025년 4월 3일
0

넥스트JS 이모저모

목록 보기
13/23

Next.js 15에서는 서버 컴포넌트에서 확장된 fetch를 통해 데이터를 가져올 수 있다.
덕분에 SEO를 챙기며 초기 렌더링 성능까지 확보할 수 있는데,
여기서 문득 이런 생각이 들었다.

"서버에서 데이터를 먼저 패칭한 뒤, 그 데이터를 탠스택 쿼리로 넘겨서 클라이언트에서 상태를 관리하면 어떨까?"

이 방식은 서버와 클라이언트의 장점을 모두 활용할 수 있는 구조다.
SEO도 챙기고, 클라이언트 상태 관리도 쉽게 할 수 있기 때문이다.

이 글에서는 대표적인 전략인 전략 3전략 4를 중심


전략 1: SSR + initialData 방식

전체 흐름 요약

  1. 서버 컴포넌트에서 게시글을 fetch (SSR, SEO용)
  2. 클라이언트 컴포넌트로 데이터를 넘김
  3. **useQuery**의 initialData로 상태 관리
  4. 이후에는 클라이언트 단에서 자동 관리

폴더 구조 예시

/app
  /posts
    page.tsx           ← 서버 컴포넌트 (SSR fetch)
    ClientProfile.tsx  ← 클라이언트 컴포넌트 (useQuery)

1-1. 서버 컴포넌트 (SSR에서 초기 데이터 fetch)

// app/posts/page.tsx
import { getUser } from '@/lib/api';
import ClientProfile from './ClientProfile';

export default async function Profile() {
  const userData = await getUser();
  return <ClientProfile userData={userData} />;
}

1-2. 클라이언트 컴포넌트 (useQuery로 상태 관리)

'use client';
import { useQuery } from '@tanstack/react-query';
import { getUser } from '@/lib/api';

export default function ClientProfile({ userData }) {
  const { data } = useQuery({
    queryKey: ['user'],
    queryFn: getUser,
    initialData: userData,
  });

  return <span>{data.nickname}</span>;
}

initialData는 초기 로딩을 없애고, 서버에서 받아온 값을 곧바로 클라이언트에 반영해준다.

장점

  • SEO 대응 (서버에서 미리 fetch)
  • 로딩 스피너 없이 빠른 렌더링
  • 클라이언트 상태 관리 자동화

단점

  • 민감 정보 노출 우려 있음
  • 인증 데이터 처리에 불리함

전략 2: /api + 클라이언트 fetch (보안성 우선)

로그인 유저 정보는 다르게 다뤄야 한다.
민감한 데이터라 SEO도 필요 없고, API에서만 처리하는 게 보안상 안전하다.

/api/me

// app/api/me/route.ts
import { getUser } from '@/lib/user/getUser';
import { NextResponse } from 'next/server';

export async function GET() {
  const user = await getUser();
  return NextResponse.json({ user });
}

useUser 훅

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

export function useUser() {
  return useQuery({
    queryKey: ['user'],
    queryFn: async () => {
      const res = await fetch('/api/me');
      const data = await res.json();
      return data.user;
    },
  });
}

장점

  • 민감 정보 보안 처리 가능
  • API 내부 캐싱 가능 (unstable_cache)
  • CSR 상황에서만 동작 가능

단점

  • SEO 미적용
  • 초기 렌더링 시 느릴 수 있음

전략3, 전략4

Next.js 15에서 탠스택쿼리를 함께 사용할 때 가장 많이 쓰는 방식은 크게 두 가지다.

  • 전략 3: 서버에서 데이터를 먼저 캐시(getLogs)한 뒤, 클라이언트에서 그 캐시를 그대로 재사용하는 방식

  • 전략 4: 서버 초기 렌더링에서는 캐시를 사용하되, 클라이언트에서는 항상 최신 데이터를 다시 가져오는 방식

두 전략 모두 SSR과 CSR의 장점을 동시에 살릴 수 있지만, 핵심적인 차이는 다음과 같다

“fallback 시 사용하는 라우트 핸들러(/api/logs)에서 SSR 캐시를 그대로 쓸지(getLogs),
아니면 최신 DB를 직접 가져올지(fetchLogs)”
에 달려 있다.

먼저, 두 가지 데이터 함수의 역할 이해하기

fetchLogs – DB에서 최신 데이터를 직접 가져오는 순수 fetch 함수

// 실제로 Prisma, Supabase 등을 통해 DB에서 조회
export async function fetchLogs(params) {
  return db.logs.findMany({ where: { ...params } });
}

getLogsfetchLogs를 감싸 SSR 캐시(unstable_cache)를 적용한 서버 함수

export const getLogs = unstable_cache(
  () => fetchLogs(params),
  ['logs', params],
  { revalidate: 300, tags: ['logs'] }
);

요약: fetchLogs()항상 최신 DB, getLogs()SSR 캐싱용 래퍼 함수


전략 3: SSR 캐시 기반 (getLogs 사용)

구조 요약

  • 서버에서 getLogs()로 데이터를 미리 가져오고 → React Query로 dehydrate()
  • 클라이언트에서는 useQuery()를 쓰되, 캐시가 있으면 queryFn은 실행 안 됨
// 서버 컴포넌트
await queryClient.prefetchQuery({
  queryKey: ['logs'],
  queryFn: () => getLogs(params), // 캐시된 서버 fetch
});

return (
  <HydrationBoundary state={dehydrate(queryClient)}>
    <ClientComponent />
  </HydrationBoundary>
);
// 클라이언트 컴포넌트
useQuery({
  queryKey: ['logs'],
  queryFn: () => fetch('/api/logs').then(res => res.json()), // fallback 용
});

특징

항목설명
SEO + 빠른 LCPSSR에서 데이터 미리 확보
CSR에서 fetch 생략SSR → CSR로 캐시 공유
최신성 부족SSR 캐시가 최대 5분 stale
서버 부하 낮음SSR 캐시로 API 트래픽 분산

전략 4: 최신 데이터 우선 (fetchLogs 사용)

구조 요약

  • SSR 초기에는 getLogs()로 빠른 렌더링 확보
  • CSR에서는 /api/logs로 fetch하여 항상 최신 DB 상태 유지
// /api/logs route.ts
export async function GET() {
  const logs = await fetchLogs(); // 항상 fresh
  return NextResponse.json(logs);
}
// 클라이언트 쿼리
useQuery({
  queryKey: ['logs'],
  queryFn: () => fetch('/api/logs').then(res => res.json()),
  placeholderData: keepPreviousData,
});

특징

항목설명
항상 최신 상태 유지SSR 캐시 무시하고 fetchLogs 직접 호출
중복 fetch 발생hydration 이후 fetch 한 번 더 일어남
서버 부하 ↑모든 CSR이 DB에 직접 hit
사용자 액션 즉시 반영 가능예: 글 작성 후 목록 자동 갱신

전략 3 vs 전략 4 핵심 비교

항목전략 3 (getLogs + SSR 캐시)전략 4 (fetchLogs + 최신 fetch)
초기 렌더 속도 (LCP)✅ 빠름✅ 동일
중복 fetch 방지✅ SSR → CSR 공유❌ CSR에서 다시 fetch
최신 데이터 보장❌ 최대 5분 지연 가능✅ 항상 fresh
API 서버 부하✅ 캐시 재사용으로 감소❌ 페이지 접근마다 DB hit
클라이언트 UX✅ 깜빡임 없음placeholderData 필요

어떤 전략을 언제 쓸까?

상황추천 전략이유
인기글 / 태그 리스트 / 검색 결과전략 3최신성보다 렌더링 속도, 서버 캐시 분산이 중요
내가 쓴 글 / 마이페이지전략 4항상 최신 데이터 필요. 캐시 stale 허용 불가
SEO + 빠른 초기 렌더전략 3SSR 캐시가 LCP 최적화에 유리
사용자 액션 직후 반영해야 하는 UI전략 4새로고침 없이 최신 상태 보여줘야 함

전략 4 사용 시 주의점: SSR과 CSR 캐시 불일치

  • CSR에서는 fetchLogs()로 항상 최신 데이터를 가져오지만,
  • SSR 초기 렌더링은 getLogs()의 캐시를 기반으로 하기 때문에
    새로고침 시 이전(캐싱된) 상태가 다시 표시될 수 있다.

즉, DB는 갱신됐는데 새로고침하면 옛 데이터를 보게 되는 현상이 발생할 수 있다.


해결 방법: SSR 캐시와 CSR 캐시 둘 다 무효화해야 한다

1) SSR 캐시 무효화 – 서버에서 getLogs()가 사용하는 SSR 캐시 제거:

// 예시: /api/logs/[id]/route.ts (글 수정/삭제 등 서버 작업 이후)
await db.logs.update(...);
revalidateTag('logs'); // SSR 캐시 무효화

2) CSR 캐시 무효화 – 클라이언트의 React Query 캐시 제거:

// 예시: 글 수정 후 클라이언트 후처리
await mutation.mutateAsync(...);
queryClient.removeQueries(['logs']); // CSR 캐시 무효화

주의: queryClient.removeQueries()는 절대 useLogs 훅 내부에 넣지 않는다.
이건 데이터를 변경하는 사용자 액션 이후에만 명시적으로 실행되어야 한다.

예외적으로 해줘야하는 경우

  • fallback 호출 후, 캐시가 오래 유지되는 구조일 때
  • 로그성 / 댓글 목록처럼 실시간성이나 최신성이 중요한 리스트

fallback (/api/logs)가 호출되는 조건

클라이언트에서 useQuery()queryFn으로 지정된 /api/logs 요청은 다음 조건에서 실행

시점/api/logs 호출 여부설명
SSR 첫 렌더링호출되지 않음getLogs() 결과가 dehydrate()로 넘어감
CSR 마운트 (hydration)호출되지 않음SSR 캐시가 그대로 사용됨
staleTime 이내 페이지 이동호출되지 않음CSR 캐시 그대로 재사용
staleTime 이후 재렌더 or 포커스호출됨React Query의 revalidation 동작
queryClient.removeQueries() 이후호출됨CSR 캐시가 제거되었으므로 새로 fetch
queryKey가 바뀐 경우호출됨hydration 실패 → 쿼리 재요청

전략 3이라도 staleTime 이후나 쿼리 무효화가 발생하면 fallback으로 /api/logs가 호출될 수 있다.

  • 이때 /api/logs 라우트 핸들러가 getLogs()를 사용하면 SSR 캐시가 계속 유지되고
  • fetchLogs()를 사용하면 최신 데이터를 무시하고 덮어씌운다.

이 차이가 바로 전략 3과 4의 본질적인 차이다.


전략 3은 여전히 유효하다

전략 3은 성능 최적화, LCP 개선, API 트래픽 분산에 강력하다.
전략 4는 최신성 확보와 실시간 UI 반영에 적합하다.

"기본은 전략 3, 최신성이 중요한 곳만 전략 4로 override"
→ 성능과 UX를 모두 잡는 가장 실용적인 조합이다.

profile
기록하자

0개의 댓글