Next.js 15에서는 서버 컴포넌트에서 확장된 fetch를 통해 데이터를 가져올 수 있다.
덕분에 SEO를 챙기며 초기 렌더링 성능까지 확보할 수 있는데,
여기서 문득 이런 생각이 들었다.
"서버에서 데이터를 먼저 패칭한 뒤, 그 데이터를 탠스택 쿼리로 넘겨서 클라이언트에서 상태를 관리하면 어떨까?"
이 방식은 서버와 클라이언트의 장점을 모두 활용할 수 있는 구조다.
SEO도 챙기고, 클라이언트 상태 관리도 쉽게 할 수 있기 때문이다.
이 글에서는 대표적인 전략인 전략 3과 전략 4를 중심
useQuery**의 initialData로 상태 관리/app
/posts
page.tsx ← 서버 컴포넌트 (SSR fetch)
ClientProfile.tsx ← 클라이언트 컴포넌트 (useQuery)
// 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} />;
}
'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는 초기 로딩을 없애고, 서버에서 받아온 값을 곧바로 클라이언트에 반영해준다.
/api + 클라이언트 fetch (보안성 우선)로그인 유저 정보는 다르게 다뤄야 한다.
민감한 데이터라 SEO도 필요 없고, API에서만 처리하는 게 보안상 안전하다.
// 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 });
}
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;
},
});
}
unstable_cache)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 } });
}
getLogs – fetchLogs를 감싸 SSR 캐시(unstable_cache)를 적용한 서버 함수export const getLogs = unstable_cache(
() => fetchLogs(params),
['logs', params],
{ revalidate: 300, tags: ['logs'] }
);
요약:
fetchLogs()는 항상 최신 DB,getLogs()는 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 + 빠른 LCP | SSR에서 데이터 미리 확보 |
| CSR에서 fetch 생략 | SSR → CSR로 캐시 공유 |
| 최신성 부족 | SSR 캐시가 최대 5분 stale |
| 서버 부하 낮음 | SSR 캐시로 API 트래픽 분산 |
fetchLogs 사용)getLogs()로 빠른 렌더링 확보/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 (getLogs + SSR 캐시) | 전략 4 (fetchLogs + 최신 fetch) |
|---|---|---|
| 초기 렌더 속도 (LCP) | ✅ 빠름 | ✅ 동일 |
| 중복 fetch 방지 | ✅ SSR → CSR 공유 | ❌ CSR에서 다시 fetch |
| 최신 데이터 보장 | ❌ 최대 5분 지연 가능 | ✅ 항상 fresh |
| API 서버 부하 | ✅ 캐시 재사용으로 감소 | ❌ 페이지 접근마다 DB hit |
| 클라이언트 UX | ✅ 깜빡임 없음 | ✅ placeholderData 필요 |
| 상황 | 추천 전략 | 이유 |
|---|---|---|
| 인기글 / 태그 리스트 / 검색 결과 | 전략 3 | 최신성보다 렌더링 속도, 서버 캐시 분산이 중요 |
| 내가 쓴 글 / 마이페이지 | 전략 4 | 항상 최신 데이터 필요. 캐시 stale 허용 불가 |
| SEO + 빠른 초기 렌더 | 전략 3 | SSR 캐시가 LCP 최적화에 유리 |
| 사용자 액션 직후 반영해야 하는 UI | 전략 4 | 새로고침 없이 최신 상태 보여줘야 함 |
fetchLogs()로 항상 최신 데이터를 가져오지만,getLogs()의 캐시를 기반으로 하기 때문에즉, DB는 갱신됐는데 새로고침하면 옛 데이터를 보게 되는 현상이 발생할 수 있다.
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 호출 후, 캐시가 오래 유지되는 구조일 때
- 로그성 / 댓글 목록처럼 실시간성이나 최신성이 중요한 리스트
클라이언트에서 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은 성능 최적화, LCP 개선, API 트래픽 분산에 강력하다.
전략 4는 최신성 확보와 실시간 UI 반영에 적합하다.
"기본은 전략 3, 최신성이 중요한 곳만 전략 4로 override"
→ 성능과 UX를 모두 잡는 가장 실용적인 조합이다.