정리하게 된 이유: 아래 포스팅을 읽고 새로운 점을 알게 되어서.
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와 같은 페이지 이동은, 해당 부분 코드를 실행할 때 리소스를 불러온다.
- Next.js의 client-side navigation(새로고침없이 페이지 이동) 방식은
이 fetch API로 요청하는 경우는 CORS 요청이므로(브라우저 → CDN 서버) 브라우저가 요청 헤더에 Origin을 포함함. 그래서 서버에서도 이에 맞춰 ACAO 헤더를 포함하여 응답해야 함
현재 필자의 CDN 서버는, “요청에 Origin 헤더가 포함된 경우에만” 응답에
Vary: Origin헤더를 추가하고 있다고 함.Vary헤더는 특정 URL 요청에 대해 캐시된 응답을 사용할 수 있는지 판단할 기준 헤더를 설정하는 헤더이다. 서버가 응답에 Vary: Origin 헤더를 추가하면, 브라우저는 요청의 Origin 헤더 값에 따라 캐시된 응답을 구분하여 사용함.Vary 헤더에 설정한 속성이, HTTP Request마다 변할 수 있다는 것을 표현한다.
문제는, 브라우저에서 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로 조작할 수 없고 브라우저가 직접 제어하기 때문에 신뢰할 수 있음.
- 왜냐면 CSRF 공격을 방어하기 위함.
- 서버에서 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에 와일드카드를 사용할 수 없다.
그래서 해결 방법은,
모든 응답에
Vary: Origin헤더를 추가하는 것으로 CDN 서버 설정을 변경한다.모든 script, link 태그에
crossOrigin: ‘anonymous’옵션을 추가한다.- 관련 공식 문서
- 첫 렌더링(SSR)때 부터 ‘나는 CORS 요청이야’ 라고 명시하는 방식이다. 그러면 이후 fetch 요청하는 경우와 동일하게 동작하므로 캐시 문제가 해소된다.
- next.js에서는 next.config.ts에 crossOrigin 설정을 추가하면 된다.
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>태그를 추가함
- React.js에서는 리소스가 필요할 때 동적으로 DOM에
- CSS in JS일 경우 JS Bundle 안에 포함됨. 그러면 브라우저가 JS를 실행할 때
- 현재 상황에서 dynamic import를 사용하여 CSR 되도록 구현하면, JS가 실행되면서 필요한 순간에
<link>태그를 DOM에 직접 적용하는 방식을 사용하게 되므로 Non-CORS 요청으로 리소스를 가져옴. 최초 렌더링, 페이지 이동 후에도 동일하게 동작하므로 캐시에 의한 요청/응답의 CORS 에러가 발생하지 않는다.