CORS 다시 정리하기(Next.js App Router에서 발생한 'CORS 캐시' 트러블슈팅)

2026. 1. 23. 01:14

정리하게 된 이유: 아래 포스팅을 읽고 새로운 점을 알게 되어서.

https://tech.kakaopay.com/post/nextjs-troubleshooting-cors-version-skew/

  • Next.js App Router로 구현 중인 프로젝트이며, 배포 시 정적 파일이 CDN에 업로드 되는 상황.

  • 최초 렌더링(SSR) 때는 <script> 혹은 <link rel="stylesheet"> 태그를 읽어서 리소스를 다운로드 받고, 이는 CORS 요청이 아니기 때문에 요청에 Origin 헤더를 포함하지 않음. 서버에서도 Access-Control-Allow-Origin(이하 ACAO) 헤더를 응답에 포함하지 않음.

  • 이후 client-side navigation으로 페이지 이동이 발생할 경우, Next.js는 페이지 전환에 필요한 리소스를 미리 가져오거나(prefetch), 동적으로 로드하기 위해 Fetch API를 사용함.

    • Next.js의 client-side navigation(새로고침없이 페이지 이동) 방식은 <Link>router.push(), router.replace()
    • Next.js는 Link 태그로 연결된, 페이지의 리소스를 서버에서 미리 불러온다 = prefetch
      • router.push와 같은 페이지 이동은, 해당 부분 코드를 실행할 때 리소스를 불러온다.
  • 이 fetch API로 요청하는 경우는 CORS 요청이므로(브라우저 → CDN 서버) 브라우저가 요청 헤더에 Origin을 포함함. 그래서 서버에서도 이에 맞춰 ACAO 헤더를 포함하여 응답해야 함

    • 현재 필자의 CDN 서버는, “요청에 Origin 헤더가 포함된 경우에만” 응답에 Vary: Origin 헤더를 추가하고 있다고 함.

    • Vary 헤더는 특정 URL 요청에 대해 캐시된 응답을 사용할 수 있는지 판단할 기준 헤더를 설정하는 헤더이다. 서버가 응답에 Vary: Origin 헤더를 추가하면, 브라우저는 요청의 Origin 헤더 값에 따라 캐시된 응답을 구분하여 사용함.

    • Vary 헤더에 설정한 속성이, HTTP Request마다 변할 수 있다는 것을 표현한다.

      [출처] 고객님은 왜 CORS 에러를 만나 새로고침을 당했을까?(feat. Vary 헤더와 브라우저 캐시)

  • 문제는, 브라우저에서 ACAO 헤더가 없는 응답을 캐싱하기 때문에 발생한다. 이런 캐싱이 존재하는 상태에서 동일한 CSS 리소스를 요청하면 브라우저는 캐시된 응답을 사용하려 하는데, 이 요청이 Origin 헤더가 포함된 fetch 요청이라면, 캐시된 응답에는 ACAO 헤더가 없으므로 CORS 에러가 발생한다.

  • 즉, 브라우저가 fetch 타입 요청의 응답으로, 이미 캐싱되어 있는 link 태그 stylesheet 타입 요청에 대한 응답을 사용하기 때문에 발생

(→ 부끄럽지만 나는 <script>, <link rel=”stylesheet”> 가 Non-CORS로 취급되는 것을 처음 알게 되었고, CORS 요청일 경우 Origin 헤더를 브라우저가 알아서 포함시키는 것도 정확히 알지 못했다. Origin 헤더는 JavaScript에서 제어할 수 없다는 것도 몰랐다.)


이런 내용을 정리하면서 구멍난 지식을 정리

  • 매일메일 - CORS(Cross-Origin Resource Sharing)는 무엇이며 왜 필요한가요?
  • 매일메일 - CORS란 무엇인가요?
  • 직역하자면, 교차 출처 리소스 공유 정책
    • 서로 다른 출처(Origin)에서 리소스 공유하는 것에 대한 정책
    • 서로 다른 출처가 안전하게 데이터를 주고받을 수 있도록 규정한 '상호 합의된 공유 통제 정책'
  • CORS 요청이란, 브라우저에서 실행 중인 스크립트가 자신이 속한 출처(origin)와 다른 출처(origin)의 리소스에 접근하려고 할 때 발생하는 요청
  • “브라우저가 요청의 Origin 헤더와 응답 메시지의 Access-Control-Allow-Origin(이하 ACAO) 헤더를 비교해서 CORS를 위반하는지 확인함”
  • 브라우저의 SOP(Same Origin Policy) 동일 출처 정책
    • 동일 출처의 리소스만 상호작용할 수 있도록 제한하는 브라우저의 정책
    • CSRF(Cross-Site Request Forgery, 크로스사이트요청변조), XSS(Cross-Site Scripting) 등의 보안 취약점을 노린 공격을 방어하기 위함
  • Origin = Protocol + Host + Port
  • 브라우저는 모든 Cross-Origin 요청데이터를 변경할 수 있는 요청(POST, PUT, DELETE)에는 반드시 Origin 헤더를 포함함
    • 왜냐면 CSRF 공격을 방어하기 위함.
      • Origin은 서버 입장에서 ‘이 요청이 어디서 실제로 시작됐는가?’ 에 대한 정보를 제공하기 때문.
      • 공격 상황: 공격 사이트(https://evil.com)에서 타겟 서버(https://bank.com)로 POST 요청을 보냅니다.
      • 브라우저의 행동: 브라우저는 이 요청의 출처가 https://evil.com임을 알고 있으므로, 요청 헤더에 Origin: https://evil.com을 강제로 붙입니다.
      • 서버의 방어: 서버는 요청을 받았을 때 Origin 헤더를 확인합니다. "어? 우리 사이트(bank.com)에서 온 요청이 아니라 evil.com에서 왔네?"라고 판단하여 해당 요청을 거부(403 Forbidden 등)할 수 있습니다.
    • 참고로, Origin 헤더는 JavaScript로 조작할 수 없고 브라우저가 직접 제어하기 때문에 신뢰할 수 있음.
  • 서버에서 ACAO 응답 헤더를 포함해서 내려주면, 브라우저가 확인하고 요청했던 Origin과 해당 값이 같거나, ACAO가 와일드카드일 때, 응답 받은 데이터를 스크립트에 전달함
    • 그렇지 않을 경우 브라우저에서 이 응답을 차단하고 콘솔에 에러를 띄움.
  • <script>, <link rel="stylesheet">, <img> 태그는 단순 임베딩(Non-CORS)이라고 하며, 항상 CORS 허용함.
    • 이들은 HTML 파서가 직접 호출한다고 함.
    • 단, 단순 화면 표시하는 렌더링용으로만 허용할 뿐. 브라우저가 내부 내용을 읽을 순 없다.
    • 브라우저가 위 내용을 읽고 접근하려면 당연히 CORS 허용을 해야 함.
    • 다양한 오픈소스 CDN(jQuery, Google Font 등)을 단순히 임베딩하여 화면에 그리는 것을 허용하는 것이라고 함.
  • Simple Request(단순 요청)
    • “이 정도는 위험하지 않으니 예비 요청없이 바로 보내도 되겠다”
    • cross-origin 상황에서, 다음 조건 모두를 만족해야 simple request
    • 다음 중 하나라도 만족하지 못하면, preflight 발생!
    • Method: GET, HEAD, POST
    • Header: Accept, Accept-Language, Content-Language, Content-Type 등 기본적인 헤더만 사용해야 함.(Authorization 같은 커스텀 헤더 안 됨!)
    • Content-Type: application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나여야 함. (즉, application/json 안 됨!)
      • 왜냐면, 전통적인 HTML form은 json 형식을 보낼 수 없었음. 그래서 json 요청이 들어오는 것을 예상하지 않고 설계된 경우가 많음.
      • 만약 브라우저가 갑자기 application/json으로 POST를 보내버리면, 서버가 이를 잘못 해석해서 보안 사고가 날 수 있었기에 "생소한 방식(JSON 등)은 미리 물어보고(Preflight) 보내라"는 규칙이 생긴 것
      • 즉, "기존 서버들을 보호하기 위해서"
  • Preflight Request(예비 요청, 사전 요청, OPTIONS 메서드를 사용함)
    • CORS 상황에서(cross-origin일 가능성이 있는 환경에서) 보안을 확인하기 위해 브라우저가 제공하는 기능.
    • 브라우저가 “이 요청이 cross-origin일 가능성이 있다”고 판단하고, non-simple request 조건을 만족하면 발생함.
    • https://developer.mozilla.org/ko/docs/Glossary/Preflight_request
    • CORS Preflight, CORS 요청이라고 부르기도 함
    • 서버와 브라우저가 통신하기 전에, 이 요청이 안전한지 확인하는 CORS 요청
    • 브라우저가 스스로 자동으로 보내는 예비 요청.
    • 모든 preflight는 options를 사용하지만, 모든 options가 preflight는 아님.
      • Preflight: "안전한지 미리 확인 좀 할게"라는 보안 규칙
      • OPTIONS: 그 확인을 위해 사용하는 통신 수단
  • Credential Request
    • CORS 상황이라면 preflight 발생
    • 쿠키나 토큰과 같은 인증 정보를 포함하는 인증된 요청
    • 이때는 Access-Control-Allow-Credentials를 true로 설정해야 하고,
    • ACAO에 와일드카드를 사용할 수 없다.

그래서 해결 방법은,

  1. 모든 응답에 Vary: Origin 헤더를 추가하는 것으로 CDN 서버 설정을 변경한다.

  2. 모든 script, link 태그에 crossOrigin: ‘anonymous’ 옵션을 추가한다.

    • 관련 공식 문서
    • 첫 렌더링(SSR)때 부터 ‘나는 CORS 요청이야’ 라고 명시하는 방식이다. 그러면 이후 fetch 요청하는 경우와 동일하게 동작하므로 캐시 문제가 해소된다.
    • next.js에서는 next.config.ts에 crossOrigin 설정을 추가하면 된다.
  3. dynamic import

     import dynamic from 'next/dynamic';
    
     const ChargeSuccess = dynamic(() => import('pages/charge/success'), {
       ssr: false,
     });
    
     const Page = () => {
       return <ChargeSuccess />;
     };
    
     export default Page;
    • 문제가 되는 페이지 부분을 별도 컴포넌트로 분리하고, dynamic import 를 적용하여 CSR 되도록 구현
    • dynamic import를 하면 Next.js의 Build Manifest에서 해당 페이지의 필수 리소스 목록으로부터 이 컴포넌트가 제외됨. 그래서 서버에서 자동으로 미리 prefetch를 하지 않게 됨.
    • CSR 방식은 브라우저가 런타임에 직접 <link> 태그를 삽입해서 가져옴(Non-CORS).
    • Next.js는 초기엔 <link> 태그로 가져오고, 이후 prefetch를 위해 페이지별로 코드스플리팅된 chunk를 서버로부터 fetch 요청하거나, 동적으로 fetch 요청하기 때문에 발생했던 문제.
    • 이때 ssr: false 설정까지 필요한 이유는, Next.js는 dynamic에 의해 코드스플리팅을 하면서도 그 조각을 기본적으로 SSR(HTML 결과물)에 포함시키기 때문. 절대 SSR이 되지 않게 옵션을 준 것이다.
    • 스타일 코드는 기본적으로 다음과 같이 로드된다.
      • CSS in JS일 경우 JS Bundle 안에 포함됨. 그러면 브라우저가 JS를 실행할 때 <style> 태그를 만들어서 <head> 에 주입함으로써 화면에 적용됨
      • 일반 CSS(zero runtime css)는 .css 파일로 추출하여 로드됨. 최초 렌더링 때는 <link rel="stylesheet"> 태그를 통해 가져옴. 페이지 이동 시 Next.js 최적화를 위해 해당 페이지 렌더링에 필요한 CSS 파일을 미리 fetch하거나 동적 CORS 요청하여 다운로드함 → 그래서 현재 이 트러블 슈팅이 발생한 것
        • React.js에서는 리소스가 필요할 때 동적으로 DOM에 <link> 태그를 추가함
    • 현재 상황에서 dynamic import를 사용하여 CSR 되도록 구현하면, JS가 실행되면서 필요한 순간에 <link> 태그를 DOM에 직접 적용하는 방식을 사용하게 되므로 Non-CORS 요청으로 리소스를 가져옴. 최초 렌더링, 페이지 이동 후에도 동일하게 동작하므로 캐시에 의한 요청/응답의 CORS 에러가 발생하지 않는다.