Next.js 15 (App Router)와 TanStack Query를 활용해 로그 상세 페이지를 만들고 있었다.
서버 컴포넌트에서 데이터를 미리 불러오고, 클라이언트에서는 캐시를 재사용하는 방식인
전략 3: SSR 캐시 + HydrationBoundary를 적용했다.
전략 3이란?
서버에서 데이터를fetch하고, TanStack Query의prefetchQuery + dehydrate()를 통해 캐시로 넘긴 뒤,
클라이언트가HydrationBoundary를 통해 이 캐시를 재사용하는 방식이다.
notFound()로 넘어감/api/log?logId=... 요청이 실패useQuery({
queryKey: ['log', logId],
queryFn: () => fetch(`/api/log?logId=${logId}`),
});
prefetchQuery로 데이터를 미리 가져오고, dehydrate()로 넘긴 캐시를 클라이언트가 그대로 사용한다.queryFn()을 다시 실행하게 되는데, 이를 fallback fetch라고 부른다.| 원인 | 설명 |
|---|---|
queryKey가 다름 | 서버와 클라이언트의 key 배열이 정확히 일치해야 캐시 재사용 가능 |
logId가 undefined | 클라이언트가 먼저 실행되면 key가 유실됨 |
queryFn 실행이 hydration보다 빠름 | 비동기 렌더링 타이밍 이슈 |
| dev 모드 또는 Fast Refresh | SSR 캐시가 무시되기도 함 |
예시:
// 서버
queryClient.prefetchQuery({ queryKey: ['log', 'detail', logId] });
// 클라이언트
useQuery({ queryKey: ['log', 'detail', logId] });
fallback fetch가 실행되면 클라이언트는 다음과 같이 브라우저에서 API 요청을 보내게 된다:
fetch('/api/log?logId=abc123');
브라우저는 보안상
Access-Control-Allow-Origin헤더가 없으면 요청 결과를 막는다.
queryClient.prefetchQuery(...) 호출getLogs() 실행 → unstable_cache(() => fetchLogs(...))fetchLogs()는 Prisma로 DB 직접 조회useLogs()로 같은 queryKey를 사용dehydrate()된 캐시를 재사용fetchLogs() 내부에서 Supabase REST API를 호출했다면 다음과 같은 코드가 됐을 것이다:
await fetch('https://your-supabase.supabase.co/rest/v1/log', {
headers: { apikey: SUPABASE_ANON_KEY },
});
const [logs, totalCount] = await Promise.all([
prisma.log.findMany(...),
prisma.log.count(...),
]);
내가 만든 API는 서버 내부에서 Prisma로 DB를 조회할 뿐인데, 왜 CORS 에러가 발생
그런데도 CORS 에러가 났다면, "요청이 발생한 위치"와 "브라우저의 보안 정책"을 정확히 이해해야 한다.
fetch('/api/log?logId=abc123');
이 요청은 결국 브라우저가 Next.js의 API 라우트에 fetch 요청을 보내는 것과 같다.
이때 브라우저는 요청이 "Cross-Origin"이라고 판단하거나, Preflight 요청 조건에 부합하면
CORS 검사를 강제로 수행하게 된다.
| 상황 | 설명 |
|---|---|
| API 도메인이 다름 | 예: 프론트는 vercel.app, API는 supabase.co |
| 포트가 다름 | 예: 프론트는 localhost:3000, API는 localhost:4000 |
| 프로토콜이 다름 | 예: http:// ↔ https:// |
| 요청이 Preflight 대상 | POST, PUT, DELETE 또는 헤더에 Authorization 등이 포함된 경우 |
/api/log?logId=...에 요청을 보냄(ex: return NextResponse.json(...))Access-Control-Allow-Origin이 없음res.json() 호출 시 예외 발생 → useQuery에서 에러 → notFound() 호출로 이어짐개발자 도구 → Network 탭 → /api/log?... 요청 확인
Access-Control-Allow-Origin이 없는지 확인return new NextResponse(JSON.stringify(data), {
headers: {
'Access-Control-Allow-Origin': '*', // 실제 운영에선 도메인 제한 권장
'Content-Type': 'application/json',
},
});
useQuery({
queryKey,
queryFn: () => {
if (typeof window === 'undefined') return fetchUseLogs(params);
throw new Error('fallback fetch 차단');
},
});
또는:
useQuery({
queryKey,
queryFn: () => fetchUseLogs(params),
enabled: typeof window === 'undefined',
});
이번 프로젝트에서는 전략 3을 포기하고 다음처럼 단순한 SSR-only 구조로 변경했다:
getLog(logId) 실행const [bookmarkCount, setBookmarkCount] = useState(ssrData._count.log_bookmark);
| 요청 위치 | CORS 필요 여부 | 설명 |
|---|---|---|
| 서버 컴포넌트 내부 | ❌ 없음 | Node 환경 (브라우저 아님) |
| 클라이언트에서 fetch | ✅ 있음 | 브라우저 보안 정책 적용됨 |
| Supabase REST API를 브라우저에서 호출 | ✅ 있음 | 외부 Origin 요청 |
export async function GET() {
return new NextResponse(JSON.stringify(data), {
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
},
});
}
export const nextConfig = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type' },
],
},
];
},
};
NextResponse를 수동으로 반환하는 라우트 핸들러에서는 위 설정이 적용되지 않을 수 있다.| 상황 | 권장 방식 |
|---|---|
| 브라우저에서 직접 fetch 필요 | 라우트 핸들러에서 직접 CORS 헤더 설정 |
| 테스트용 프로젝트 | next.config.ts로 일괄 설정 |
| 민감한 API (로그인, 유저 정보 등) | 도메인 제한 + 라우트별 설정 |
| SSR 전용 API | CORS 설정 필요 없음 |