OTTER-LOG

cors 오류 해결기

cors 오류 해결기
by otter2023년 2월 11일에 최종수정되었습니다.
잘못된 내용이 있으면 댓글을 달아주세요.

문제가 발생했던 상황

notion API 의 데이터를 로컬 데브 서버에서, useEffect 훅을 통해 불러오고자 했을때 아래와 같은 오류가 발생했습니다.

지금까지는 백엔드와의 협업 과정에서는 백엔드 분들이 대부분 처리해 주셨기에 큰 문제 없이 문제를 해결하고 진행할 수 있었는데 이번에는 협업하는 백엔드 분이 없고, notionAPI 를 사용하고 있었으므로 프론트 차원에서 제가 직접 이 문제를 해결해야만 했습니다.

SOP (Same Origin Policy)

CORS에 대해 이해하기 전에, SOP를 먼저 이해해야 합니다. SOP는 동일 출처 정책의 준말로 대부분의 웹 브라우저에서 채택하고 있는 보안정책입니다. 구체적으로 SOP는 어떤 출처에서 불러온 문서나 스크립트가 다른 출처에서 가져온 리소스와 상호작용하는 것을 제한합니다. 이를 통해, 보안상으로 해로울 수 있는 문제점을 줄일 수 있습니다.

위의 내용을 확인해보면 SOP는 출처를 기반으로 작동하는 것 같습니다. 그러면 출처는 어떻게 확인될까요?

( Ref : 동일 출처 정책 | MDN )

출처 (origin)

웹 컨텐츠의 출처는 URL의 스킴(프로토콜), 호스트, 포트로 정의됩니다.

https://otter-log.world:443/post?filterBy=javascript < -- > < ----------- > < - > < -- > < ------------ > 프로토콜 호스트 port path query string

예를 들어, 위의 주소를 통해 이를 파악한다면 위와 같이 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 에러는 브라우저가 응답을 분석해 오류를 만들어 내므로, 서버사이드에서 요청한다면 문제가 해결 될 것입니다.

그리고 검색을 통해 한가지 방법을 더 찾아낼 수 있었습니다.

  • proxy 사용하기

    proxy 서버를 사용해, 브라우저가 원하는 몇 개의 HTTP 헤더를 미리 설정합니다. ( Access-Control-Allow-Origin : http://localhost:3000 ) 만 설정해 놔도 될 것 같습니다.

    그리고 이 proxy 서버를 통해 요청을 진행하고, proxy 서버로 요청을 받습니다.

제가 선택한 방법은 두번째 방법이었습니다. next 환경으로 진행하고 있었기 때문에 api 를 만들어 이용하면 쉽게 해결이 가능하다고 판단했기 때문입니다.

또, 첫번째 방법은 외부의 api를 사용하는 제 입장에서는 적용할 수 없엇고 세번째 방법은 검색을 통해서 쉽게 적용할 수 있는 방법을 찾을 순 있었지만 단순히 우회해서 사용한다는 것 이상의 내용을 아직 이해하고 있지 못해, 사용하기 어렵다고 판단했습니다.

최종적으로 아래와 같은 api를 작성해서 진행했습니다.

import type { NextApiRequest, NextApiResponse } from "next"; export default async function handler( req: NextApiRequest, res: NextApiResponse<any>, ) { const { filter } = req.query; let filtered = []; try { const response = await fetch(`https://api.notion.com/v1/search/`, { method: "POST", headers: { Authorization: `Bearer ${process.env.NOTION_API}`, "Notion-Version": "2022-06-28", "Content-Type": "application/json", }, body: JSON.stringify({ query: filter?.toString() }), }); const data = await response.json(); filtered = data.results; } catch (e) { filtered = []; } res.status(200).json({ filtered }); }

그리고 useEffect 에서 이 api 를 불러오는 방식을 통해 우회적으로 cors 문제를 해결할 수 있었습니다.

useEffect(() => { fetch(`api/notion`).then( ... ) })

Ref