SSR 캐시 + CSR 캐시 전략 에러

김현준·2025년 7월 4일

배경

Next.js 15 (App Router)와 TanStack Query를 활용해 로그 상세 페이지를 만들고 있었다.
서버 컴포넌트에서 데이터를 미리 불러오고, 클라이언트에서는 캐시를 재사용하는 방식인
전략 3: SSR 캐시 + HydrationBoundary를 적용했다.

전략 3이란?
서버에서 데이터를 fetch하고, TanStack Query의 prefetchQuery + dehydrate()를 통해 캐시로 넘긴 뒤,
클라이언트가 HydrationBoundary를 통해 이 캐시를 재사용하는 방식이다.


문제 발생

  • 어떤 로그는 잘 보이는데, 어떤 로그는 notFound()로 넘어감
  • 콘솔에는 CORS 에러 발생
  • 브라우저에서 /api/log?logId=... 요청이 실패
  • SSR로 데이터를 불러왔는데도 왜 클라이언트에서 다시 요청이 가는지 의문

원인 분석: SSR 캐시 미사용 → fallback fetch → CORS 에러

1. TanStack Query의 기본 동작

useQuery({
  queryKey: ['log', logId],
  queryFn: () => fetch(`/api/log?logId=${logId}`),
});
  • 원래는 서버에서 prefetchQuery로 데이터를 미리 가져오고, dehydrate()로 넘긴 캐시를 클라이언트가 그대로 사용한다.
  • 하지만 캐시를 못 찾는 경우 클라이언트는 queryFn()을 다시 실행하게 되는데, 이를 fallback fetch라고 부른다.

2. fallback fetch가 발생하는 대표적인 이유

원인설명
queryKey가 다름서버와 클라이언트의 key 배열이 정확히 일치해야 캐시 재사용 가능
logId가 undefined클라이언트가 먼저 실행되면 key가 유실됨
queryFn 실행이 hydration보다 빠름비동기 렌더링 타이밍 이슈
dev 모드 또는 Fast RefreshSSR 캐시가 무시되기도 함

예시:

// 서버
queryClient.prefetchQuery({ queryKey: ['log', 'detail', logId] });

// 클라이언트
useQuery({ queryKey: ['log', 'detail', logId] });
  • queryKey가 완전히 일치하지 않으면 캐시를 못 써서 fallback fetch가 발생한다.

3. 핵심 문제: 브라우저 fetch → CORS 에러

fallback fetch가 실행되면 클라이언트는 다음과 같이 브라우저에서 API 요청을 보내게 된다:

fetch('/api/log?logId=abc123');
  • 이 API는 서버 내부에서 호출될 것을 전제로 만든 것이고,
  • 브라우저에서 직접 호출되면 CORS 허용 헤더가 없기 때문에 브라우저가 응답을 차단한다.

브라우저는 보안상 Access-Control-Allow-Origin 헤더가 없으면 요청 결과를 막는다.


전략 3의 흐름 다시 정리

서버 측 흐름

  1. 서버 컴포넌트에서 queryClient.prefetchQuery(...) 호출
  2. 내부적으로 getLogs() 실행 → unstable_cache(() => fetchLogs(...))
  3. fetchLogs()Prisma로 DB 직접 조회

클라이언트 측 흐름

  1. 클라이언트에서 useLogs()로 같은 queryKey를 사용
  2. dehydrate()된 캐시를 재사용

Supabase를 사용했다면?

fetchLogs() 내부에서 Supabase REST API를 호출했다면 다음과 같은 코드가 됐을 것이다:

await fetch('https://your-supabase.supabase.co/rest/v1/log', {
  headers: { apikey: SUPABASE_ANON_KEY },
});
  • 이때 fallback fetch가 발생하면 브라우저가 Supabase로 직접 요청을 보내게 되고,
  • Supabase에서 CORS 허용이 안 되어 있으면 CORS 에러가 발생했을 것이다.

하지만 내 경우는?

const [logs, totalCount] = await Promise.all([
  prisma.log.findMany(...),
  prisma.log.count(...),
]);
  • Supabase REST API 호출이 아니라, 서버에서 Prisma로 직접 DB를 조회하고 있음
  • 따라서 브라우저에서 외부 요청이 발생하지 않음 → CORS 설정도 필요 없음

그런데 왜 CORS 에러가 났을까?

내가 만든 API는 서버 내부에서 Prisma로 DB를 조회할 뿐인데, 왜 CORS 에러가 발생

  • 실제로 라우트 핸들러 내부 코드는 Supabase도, 외부 서버도 호출하지 않고 내부에서만 Prisma로 동작하는데 왜?

그런데도 CORS 에러가 났다면, "요청이 발생한 위치"와 "브라우저의 보안 정책"을 정확히 이해해야 한다.

문제의 본질: 클라이언트가 브라우저에서 요청을 보냈기 때문

클라이언트에서 useQuery()를 통해 다음과 같은 요청이 실행되면

fetch('/api/log?logId=abc123');

이 요청은 결국 브라우저가 Next.js의 API 라우트에 fetch 요청을 보내는 것과 같다.
이때 브라우저는 요청이 "Cross-Origin"이라고 판단하거나, Preflight 요청 조건에 부합하면
CORS 검사를 강제로 수행하게 된다.

브라우저가 CORS 검사를 하게 되는 대표적인 상황

상황설명
API 도메인이 다름예: 프론트는 vercel.app, API는 supabase.co
포트가 다름예: 프론트는 localhost:3000, API는 localhost:4000
프로토콜이 다름예: http://https://
요청이 Preflight 대상POST, PUT, DELETE 또는 헤더에 Authorization 등이 포함된 경우

그러면 어떤 일이 벌어지나?

  1. 브라우저가 /api/log?logId=...에 요청을 보냄
  2. Next.js 라우트 핸들러가 응답을 줌 (ex: return NextResponse.json(...))
  3. 그런데 응답 헤더에 Access-Control-Allow-Origin이 없음
  4. 브라우저는 보안 정책상 응답을 거부
  5. res.json() 호출 시 예외 발생 → useQuery에서 에러 → notFound() 호출로 이어짐

확인 팁

개발자 도구 → Network 탭 → /api/log?... 요청 확인

  • Request URL과 Origin이 다르면 CORS 가능성 있음
  • 응답 헤더에 Access-Control-Allow-Origin이 없는지 확인
  • OPTIONS 요청이 먼저 보내졌다면 Preflight가 실행된 상황

해결 방법

방법 1. 라우트 핸들러에 CORS 허용 헤더 직접 추가

return new NextResponse(JSON.stringify(data), {
  headers: {
    'Access-Control-Allow-Origin': '*', // 실제 운영에선 도메인 제한 권장
    'Content-Type': 'application/json',
  },
});

방법 2. 클라이언트에서 fallback fetch 자체 방지

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로 전환

이번 프로젝트에서는 전략 3을 포기하고 다음처럼 단순한 SSR-only 구조로 변경했다:

  • 서버 컴포넌트에서 getLog(logId) 실행
  • 데이터를 props로 클라이언트에 전달
  • 클라이언트는 TanStack Query 없이 렌더링
  • fallback fetch가 발생하지 않으므로 CORS 문제도 발생하지 않음

북마크 수 같은 값은 useState로 처리

const [bookmarkCount, setBookmarkCount] = useState(ssrData._count.log_bookmark);
  • 북마크 버튼을 클릭할 때만 수치를 변경
  • TanStack Query 없이도 실시간 반응 처리 가능

CORS 설정은 언제 필요할까?

요청 위치CORS 필요 여부설명
서버 컴포넌트 내부❌ 없음Node 환경 (브라우저 아님)
클라이언트에서 fetch✅ 있음브라우저 보안 정책 적용됨
Supabase REST API를 브라우저에서 호출✅ 있음외부 Origin 요청

CORS 설정 방법

1. 라우트 핸들러에서 직접 설정

export async function GET() {
  return new NextResponse(JSON.stringify(data), {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Content-Type': 'application/json',
    },
  });
}

참고: Supabase CORS 설정 문서


2. next.config.ts에서 전역 설정

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 전용 APICORS 설정 필요 없음
profile
기록하자

0개의 댓글