cors 오류 해결기
문제가 발생했던 상황
얼마전 마켓컬리 해커톤을 진행하던 중 cors
문제를 만났습니다. 짧은 개발기간 중, 해결하는 시간이 부족했지만 브라우저
에서 일어나는 문제다. 라는 부분만을 기억하고 있었던 저는 우선 getServerSideProps
를 통해 해당 문제를 해결하고 진행했었습니다.
SOP (Same Origin Policy)
CORS에 대해 이해하기 전에, SOP를 먼저 이해해야 합니다. SOP는 동일 출처 정책의 준말로 대부분의 웹 브라우저에서 채택하고 있는 보안정책입니다. 구체적으로 SOP는 어떤 출처에서 불러온 문서나 스크립트가 다른 출처에서 가져온 리소스와 상호작용하는 것을 제한합니다. 이를 통해, 보안상으로 해로울 수 있는 문제점을 줄일 수 있습니다.
위의 내용을 확인해보면 SOP는 출처를 기반으로 작동하는 것 같습니다. 그러면 출처는 어떻게 확인될까요?
( Ref : 동일 출처 정책 | MDN )
출처 (origin)
웹 컨텐츠의 출처는 URL의 스킴(프로토콜), 호스트, 포트로 정의됩니다.
https://otter-log.world:443/post?filterBy=javascript
예를 들어, 위의 주소를 통해 이를 파악한다면 위와 같이 URL의 구성요소를 파악할 수 있습니다.
- https : 프로토콜이 됩니다
- otter-log.world : 호스트가 됩니다.
- :443 : port number가 되고, http의 경우 80 https의 경우 443이 적용됩니다.
- post : path 가 됩니다.
- ?filterBy : query string이 됩니다.
이 중에, 출처는 URL의 스킴과 호스트, 포트로 구성된다고 했습니다. 즉 여기서 출처로 여겨 지는 부분은 다음 부분만이 될 것입니다.
https://otter-log.world:443
SOP의 동일 출처 비교
다시 SOP로 돌아가 봅시다**.** SOP는 출처와 출처를 비교하는데 출처는 스킴, 호스트, 포트넘버로 구성되어져 있다는 것을 위를 통해 알 수 있습니다. 즉 쉽게 말해 스킴, 호스트, 포트 넘버만 같다면 동일 출처가 됩니다.
| URL | 동일 출처 | 이유 | | ----------------------------------------------- | ----- | --------------- | | https://otter-log.world/about | O | 스킴, 호스트, 포트가 동일 | | https://otter-log.world/post?filterBy=react | O | 스킴, 호스트, 포트가 동일 | | http://otter-log.world | X | 스킴이 다름 | | **https://api.github.**world | X | 호스트가 다름 |
- port 넘버와 관련된 부분은 브라우저에 따라 다를 수 있다고 합니다. 아래 레퍼런스에 더 자세한 설명이 있습니다.
( Ref : CORS는 왜 이렇게 우리를 힘들게 하는걸까? )
그런데, 이상하게도 우리는 같은 출처가 아님에도 불구하고 api 요청을 통해 데이터를 전달받기도 하고 또는 데이터를 전달하기도 합니다. 이는 분명 SOP에 위반됩니다. 같은 출처가 아니니까요.
CORS (Cross-Origin Resource Sharing)
CORS는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다.
(Ref : 교차 출처 리소스 공유 (CORS) | MDN )
MDN의 문서에 따르면, CORS는 위와 같이 정의되어 있습니다. 이를 하나하나 살펴보면 다음과 같이 이해할 수 있습니다.
- CORS는 추가적인 HTTP 헤더를 사용합니다.
- 이 HTTP 헤더를 통해, 출처가 다르더라도 (SOP를 위반하더라도) 접근할 수 있는 권한을 부여합니다.
- 이를 브라우저에 알려줍니다.
CORS의 동작
위의 정의에서 우리는 CORS가 추가적인 HTTP 헤더를 사용함으로써 동작한다는 것을 알 수 있었습니다.
기본적으로 웹 클라이언트 어플리케이션이 다른 출처의 리소스를 요청할 때에는 HTTP 프로토콜을 사용하여 요청을 보내고, 이때 브라우저의 요청 헤더에는 Origin
이라는 필드에 요청하는 출처를 담아 보냅니다.
- 브라우저의 요청 —> 서버
Origin: http://foo.example.com
그러면 서버는 이 요청에 응답할때 Access-Control-Allow-Origin
헤더를 내려 보내줍니다. 이 헤더는 해당 리소스를 접근하는 것이 허용된 출처라는 의미를 가집니다.
Access-Control-Allow-Origin: http://foo.example.com // 브라우저는 이를 받고, 브라우저는 자신이 요청을 보냈던 Origin과 비교합니다. // 문제가 없으므로, 이 경우에는 cors error가 발생하지 않습니다.
(Ref : Cross-origin resource sharing | Wikipedia )
그런데 이는 가장 심플한 시나리오입니다. 일반적인 CORS 요청은 이렇게 간단히만 확인하지 않습니다.
Preflight Request
프리플라이트 방식은 가장 기본적인 시나리오입니다. 이 시나리오에서 브라우저는 요청을 한번에 보내지 않고 예비요청과 본 요청으로 나누어 보냅니다. 이 때에 OPTIONS
메서드가 사용됩니다.
프리플라이트 요청에서는 다음
우리가, fetch api를 통해 서버로 요청을 보내면 브라우저는 우전 프리플라이트, 예비 요청을 먼저 진행합니다.
**// POST 요청을 보내고, Context-Type 헤더를 담아 요청하는 예제** OPTIONS /resources/post-here/ HTTP/1.1 Host: bar.other User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-us,en;q=0.5 Accept-Encoding: gzip,deflate Connection: keep-alive **Origin: http://foo.example // Origin 출처를 표기해서 요청을 보냅니다. Access-Control-Request-Method: POST // 실제로 서버에 요청할 때에 POST 요청을 할 것이라는 정보를 포함합니다. Access-Control-Request-Headers: Content-Type // Content-type 헤더를 사용할 것이라는 것을 알려줍니다. // ----------------------------------------------------------** HTTP/1.1 204 No Content Date: Mon, 01 Dec 2008 01:15:39 GMT Server: Apache/2 **Access-Control-Allow-Origin: https://foo.example // 이 리소스에 접근이 가능한 출처를 명시해줍니다. // 만약 출처가 다르다면 리소스 접근이 불가능하고, CORS 에러가 일어나는 원인이 됩니다. Access-Control-Allow-Methods: POST, GET, OPTIONS // 요청 메서드를 받을 수 있다는 것을 알려줍니다. Access-Control-Allow-Headers: Content-Type // Content-Type 헤더랄 받을 수 있음을 알려줍니다.** Access-Control-Max-Age: 86400 Vary: Accept-Encoding, Origin Keep-Alive: timeout=2, max=100 Connection: Keep-Alive
( Ref : 교차 출처 리소스 공유 (CORS) | MDN )
위의 예시에서는 우리가 보낸 헤더를 서버가 수락해주었습니다.
- Origin으로 보낸 출처와 Access-Control-Allow-Origin의 출처가 동일합니다.
- Access-Control-Request-Method가 허용되는 메서드에 존재합니다.
- Access-Control-Request-Headers의 헤더 타입이, 허용되는 헤더에 존재합니다.
위의 사전 요청을 서버가 수락해주었으므로, 이 응답을 받고 본 요청일 진행하게 됩니다. 이를 통해 출처가 다르더라도 우리는 해당 출처에서 리소스를 받아올 수 있게 됩니다.
그런데 이 떄에, Orgin으로 보낸 출처와 Access-Control-Allow-Origin의 출처가 다르다면, 브라우저는 이 요청이 CORS 정책을 위반했다고 판단하고 에러를 내뿜게 되는 것입니다. 당연히 우리가 보낸 Orgin과 서버가 허용해주는 Allow-Orgin이 다르기 때문입니다.
인증 정보를 포함한 요청
Crendentialed Request는, 다른 출처 간의 보안을 조금 더 강화하고 싶을때 사용합니다. 브라우저가 사용하는 fetch
API는 별도의 옵션 없이 브라우저의 쿠키 정보나 인증 관련 헤더를 요청에 담지 않습니다. 다만, 이 때에 요청에 인증과 관련된 정보를 담을 수 있게 하는 옵션이 있는데 이것이 credentials 옵션입니다.
fetch('https://foo.example', { credentials: 'include', // Credentials 옵션 포함 }); // 인증정보를 포함한다는 fetch 요청을 보내는 상황
이때 서버는 credentials: 'include'
의 헤더를 읽고 가능한 상황이라면 Access-Control-Arrow-Credentials : true
로 응답합니다. 만약, 불가능한 상황이라면 헤당 헤더가 없는 응답이 올 것이고 이러한 상황에서는 CORS오류가 발생할 것입니다.
그런데 이부분에 한가지 주의할 점이 있습니다. 인증정보를 포함하는 요청을 하고자 할 때에는 Access-Control-Allow-Origin : *
를 사용할 수 없고 꼭 명시적인 URL
을 작성해야 한다는 것입니다.
Access-Control-Allow-Origin : * // 모든 URL에 접근을 허용한다는 의미 --- 보안상에 문제가 있을 수 있으니 // 인증정보를 포함하는 요청에, 해당 헤더라면 거절됩니다. Access-Control-Allow-Origin : https://foo.example // 명시적인 URL을 작성해 주어야 합니다.
문제 파악하기
이제 CORS에 대해 공부를 해보았으니 제가 한 fetch 요청이 왜 실패했는지 유추해볼 수 있습니다. 콘솔에 찍힌 오류 메세지를 다시 한번 확인해 봅시다.
Access to fetch at 'https://api.notion.com/v1/pages/a57a676843ff424c941308ec7cd97c24' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
- preflight 요청을 보냈지만, 응답 헤더에
Access-Control-Allow-Origin
과 관련한 문제가 생겼습니다. - 서버가 응답해준 헤더에
localhost:3000
이 존재하지 않는다고도 볼 수 있습니다.
문제 해결하기
그렇다면 이 문제를 어떻게 해결할까요? 제가 생각한 해결 방법은 다음과 같았습니다.
-
서버의 응답 헤더에
localhost:3000
명시하기, 또는 임시적으로*
사용하기(둘 다 보안상에 좋지 않은 방법입니다.
*
는 말할것도 없고localhost:3000
또한 명시적인 URL은 아니니까요) -
서버사이드에서 요청하기
CORS 에러는 브라우저가 응답을 분석해 오류를 만들어 내므로, 서버사이드에서 요청한다면 문제가 해결 될 것입니다.
React에서 해결하기
useEffect(() => { const baseURL = "https://sampleapis.com/futurama/api/characters"; fetch(baseURL) .then((resp) => resp.json()) .then((data) => console.log(data)); }); // 일반적인 상황에서 위와 같이 데이터를 불러올 것입니다.
http-proxy-middlewar
를 사용해 프록시 서버를 사용하는 방법이 있습니다.
// src/setupProxy.js const proxy = require('http-proxy-middleware') module.exports = (app) => { app.use( proxy('/api', { target: 'https://sampleapis.com/futurama/api/characters', changeOrigin: true, }), ) } // package.json에서 설정해주는 방법도 존재합니다.
Next에서 해결하기
그리고 Next 환경에서는 next config의 rewrites
를 통해 해결할 수 있습니다.
const nextConfig = { async rewrites() { return [ { source: "/api/test", destination: "https://sampleapis.com/futurama/api/characters", // cors 문제가 발생한 url }, ]; }, }; // useEffect useEffect(() => { const baseURL = "https://sampleapis.com/futurama/api/characters"; fetch("api/test") // url을 사용하지 않고, api/test로 요청 .then((resp) => resp.json()) .then((data) => console.log(data)); });
그런데 이러한 방법은 왜 가능한걸까요? rewrites
는 request URL
에 숨기고 싶은 정보가 있을때 사용하는 부분이라고 생각해서 CORS 문제 해결에 도움이 된다고 생각하지 않았었습니다. 그런데 이 부분 또한, Next의 서버가 일종의 프록시 서버처럼 사용되어져 문제가 해결되는 것이었습니다.
프록시 서버를 사용하면 왜 문제가 해결될까?
위키피디아에 따르면, 프록시 서버는 클라이언트가 자신을 통해서 다른 네트워크 서비스에 간접적으로 접속할 수 있게 해 주는 컴퓨터 시스템이나 응용 프로그램을 가리킨다. 라고 설명되어 있습니다.
즉 클라이언트와 서버를 중계해주는 서버라고 이해할 수 있었습니다. CORS
는 보안상의 이유로 브라우저가 다른 도메인의 리소스에 접근하는 것을 제한하는 정책이라는 점을 다시 생각해 봅시다.
프록시 서버를 사용하게 되면, 브라우저는 프록시 서버로 요청을 보내고 프록시 서버에서 다시 원래의 서버 (우리가 처음에 접근하고자 했던 도메인)으로 요청을 보내는 방식으로 문제가 해결되는 것입니다.