sitelink1 | https://blog.naver.com/jukrang/222403268969 |
---|---|
sitelink2 | |
sitelink3 | |
sitelink4 | |
extra_vars4 | |
extra_vars5 | |
extra_vars6 |
무슨 상황?
고객에게 받은 재현 영상(보안상 공개 불가)을 분석해보니 상황은 이랬다.
1) 메인 페이지에 진입을 한다.
2) 더 보기를 몇 번 누른 후에 상세 보기 버튼을 클릭해서 모달을 열면,
3) CORS 에러가 발생하며,
4) 페이지가 새로고침 된다.
고객이 Edge 브라우저를 사용하고 있기에 크로스 브라우징 이슈를 의심했지만, 나는 재현할 수 없었다. 삽질이 길어질까 팀에 상황을 공유하고 재현할 수 있는 사람을 찾았다. 다행히 한 동료의 테스트 계정으로 재현을 할 수 있었다.
재현을 하면서 네트워크 탭의 변화를 확인했다. 아래와 같이 HTTP 요청이 두 번, 시간차를 두고 발생하고 있었다. 그 중에 두 번째 요청이 실패한다.
둘 다 같은 자원을 요청하는데 요청하는 시점과 요청의 출처가 다르다.
1. 첫 번째 요청(stylesheet): 페이지를 최초 로딩할 때 CSS 청크를 link 태그로 요청
2. 두 번째 요청(fetch): 런타임에 동일한 CSS 청크를 한 번 더 fetch로 요청
재미있는 점은 2번 요청은 특정 상황에서만 발생한다는 사실. 내 테스트 환경에서는 재현을 할 수가 없었다. Next.js가 CSS 자원을 청크로 빌드하고 특정 상황일 때만 자원을 서버로 요청하는데, 동작 규칙을 아직 정확하게 파악하지 못했다. 다만 런타임에 Next.js가 동일한 CSS 청크를 한 번 더 불러오는 상황이 있고, 이때 fetch 함수를 이용한다는 사실만 확인했다.
상황을 정리해보면,
1) 브라우저는 1번 요청의 응답을 받아서 아래의 Response Header를 갖는 응답 캐시를 저장한다.
2) 클라이언트가 fetch로 2번 요청을 보낼 때 브라우저는 1번의 응답 캐시를 가지고 요청을 처리해버린다. fetch 함수를 이용하기 때문에 이 과정은 CORS 제약을 받는다.
3) 브라우저가 캐시한 1번 응답의 헤더는 CORS 관련 속성(Access-Control-Allow-*)을 갖고 있지 않기 때문에 브라우저는 2번 요청을 CORS 정책 위반으로 판단한다.
아, 그리고 이 현상은 (당연히) 브라우저 캐시를 사용할 때만 발생했다.
다른 요청인데 왜 캐시가 동작하죠?
구글링을 하다가 Server Fault에서 비슷한 상황을 질문하는 포스트를 발견했다. 포스트에 달린 답글에 설명이 매우 잘 되어 있다. 각박해져 가지만, 여전히 세상에는 좋은 사람이 많다.
참고로 우리 제품은 AWS 인프라에 호스팅되어 있다. 정적 자원(JS, CSS)을 S3에 올려놓고 CloudFront로 라우팅한다. 클라이언트의 정적 자원 접근 요청은 CloudFront → S3로 흘러들어 간다.
Server Fault의 논의를 읽고 내가 이해한 문제가 발생하는 과정은 이렇다.
1. 페이지를 렌더링 하는 시점에 브라우저는 link 태그를 처리하며 요청 헤더에 Origin이 없는 non-cors 요청을 서버로 보낸다.
2. 이 요청은 CloudFront에서 S3로 전송되는데, S3는 요청 헤더에 Origin이 없기에 응답 헤더에 Vary: Origin을 설정하지 않는다.
3. CloudFront도 응답 헤더에 Vary: Origin을 넣지 않는다.(Whitelist Header에 Origin을 설정해도 마찬가지)
5. 클라이언트는 Vary: Origin 헤더가 없는 응답을 받아서 캐시 한다.
6. 두 번째 fetch 요청을 브라우저는 캐시 히트로 판단하고 저장해 둔 non-cors 응답을 꺼내서 반환한다.
7. fetch는 cors 요청이지만 브라우저가 캐시에서 꺼낸 응답은 non-cors이므로 CORS를 위반한다.
Vary 응답 헤더?
Vary 헤더에 설정한 속성이, HTTP Request마다 변할 수 있다는 것을 표현한다. 브라우저는 요청의 유일성을 식별할 때 이 헤더 속성을 참조한다. 서버가 브라우저에게 캐시를 검토할 때 기준으로 삼을 값을 이 헤더를 이용해 알려줄 수 있다.
예) 서버가 Vary: user-agent를 응답 헤더로 보내면, 브라우저는 동일한 요청을 보내기 전에 user-agent를 확인해서 캐시 된 정보를 반환할지, 서버로 요청을 전달할지 판단함.
Fetch Standard(https://fetch.spec.whatwg.org/#cors-protocol-and-http-caches)에는 서버가 "Access-Control-Allow-Origin" 헤더에 와일드 카드(*)나 고정 Origin이 아닌 값을 설정한다면 응답 헤더에 "Vary: Origin" 을 명시해야 한다고 정의하고 있다. Origin에 따라 응답 결과가 다를 수 있기 때문이다.
크로미움 측은 이를 버그가 아닌 정상 동작이라고 판단(won’t fix)하고 있다.
내 생각에도 크로미움은 죄가 없다. 응답에 Vary: Origin이 없으니 나머지 정보만 보고 캐시 히트로 처리하는 게 맞잖아? 앗, 그러고보니 이 상황을 Fetch Standard도 언급하고 있다.
In particular, consider what happens if `Vary` is not used and a server is configured to send `Access-Control-Allow-Origin` for a certain resource only in response to a CORS request. When a user agent receives a response to a non-CORS request for that resource (for example, as the result of a navigation request), the response will lack `Access-Control-Allow-Origin` and the user agent will cache that response. Then, if the user agent subsequently encounters a CORS request for the resource, it will use that cached response from the previous non-CORS request, without `Access-Control-Allow-Origin`.
https://fetch.spec.whatwg.org/#cors-protocol-and-http-caches
S3나 CloudFront가 잘못 처리하고 있다고 보기에도 애매하다. 나도 죄가 없다. 모르는 게 죄는 아니지 않은가! Next.js가 왜 불필요하게 이미 로딩한 CSS Chunk를 요청하는지 궁금하지만 고객이 고통 받고 있으니 탐구는 잠시 미루고 문제부터 빨리 해결하기로 했다.
삽질은 길고 해결은 짧다.
브라우저가 첫 번째와 두 번째 요청을 동일한 요청으로 취급하여 캐시를 적용한다면, 첫 번째 요청의 응답 결과에 CORS 응답 헤더가 있으면 이 문제는 자연스레 해결이 된다. 두 번째 요청을 처리할 때 브라우저가 CORS 헤더가 있는 첫 번째 응답을 참조할 테니까.
link 태그에 crossorigin 속성을 추가하면 브라우저는 CSS 자원을 요청할 때 헤더에 Origin 프로퍼티를 담아서 서버로 전송한다. 그러고보니 정적 자원 도메인과 애플리케이션의 도메인이 서로 다른데 이 설정을 아직까지 안 하고 있었네?
우리 제품은 script와 css 청크를 구성하는 책임을 Next.js에게 맡기고 있다. Next.js가 제공하는 옵션을 이용해서 쉽게 이 문제를 풀 수 있었다. Next.js는 8 버전부터 crossOrigin 설정을 제공한다.
crossOrigin 설정을 적용하고 빌드를 하면 Next Router가 페이지를 빌드 할 때 script와 link 태그에 crossorigin 속성을 추가한다. 이렇게.
그렇게 문제가 사라졌다!
네트워크 탭에서 첫 번째 요청의 응답 헤더에 CORS 관련 헤더(access-control-allow-...)가 생긴 걸 볼 수 있다.
해법은 매우 간단하지만 이런 문제는 여러 도구가 상호작용하며 문제를 만들기 때문에 원인을 찾기가 어렵다. Server Fault에서 답을 찾지 못했다면 며칠 동안 이 문제를 잡고 있었을지도? 내가 Server Fault에서 도움을 얻었듯, 누군가에게 이 글이 도움이 되기를.