> [!abstract] Introduction > 웹 개발을 하다 보면 한 번쯤은 마주치게 되는 에러가 있다. 바로 CORS(*Cross-Origin Resource Sharing*) 에러다. 브라우저 콘솔에 빨간 글씨로 뜨는 이 에러는 프론트엔드 개발자를 당혹스럽게 만들곤 하는데, 이 글에서는 CORS가 무엇이고, 왜 존재하며, 어떻게 해결할 수 있는지를 실제 경험을 바탕으로 살펴본다. 사건의 발단은 약 2년 전, 동아리 디스코드 채널에서 사용하던 봇을 앱과 웹 서비스로 개발하기 위해 플러터로 서비스를 한창 개발하던 때, 계획대로라면 나타나야 할 이미지가 뜨지 않으면서 시작된다. ![[CORSError.png]] ## 문제 상황 원인을 찾기 위해 로그를 살펴보던 중 문제의 로그를 발견하게 된다. 터미널의 로그는 이렇게 나왔고, ```text ══╡ EXCEPTION CAUGHT BY IMAGE RESOURCE SERVICE ╞════════════════════════════════════════════════════ The following ProgressEvent object was thrown resolving an image codec: [object ProgressEvent] When the exception was thrown, this was the stack Image provider: NetworkImage("https://cdn.discordapp.com/attachments/1046418973954158612/1090166423290126366/2021-10-17.jpg", scale: 1.0) Image key: NetworkImage("https://cdn.discordapp.com/attachments/1046418973954158612/1090166423290126366/2021-10-17.jpg", scale: 1.0) ════════════════════════════════════════════════════════════════════════════════════════════════════ ``` 웹 브라우저의 콘솔 로그에는 이런 오류가 떴다. ```text Access to XMLHttpRequest at 'https://cdn.discordapp.com/attachments/1046418973954158612/1090166423290126366/2021-10-17.jpg' from origin 'http://localhost:56614' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Failed to load resource: net::ERR_FAILED ``` 코드에는 문제가 없어 보였는데, 이상하게 이미지를 가져올 때면 같은 오류가 발생했다. 당시 웹에 대해 아무것도 모른 채 무작정 웹 개발에 뛰어든 2023년의 나는 지금 보면 별것 아닌 이 오류를 이해하고 해결하기 위해 먼 길을 돌아가야 했다. ## CORS 이해하기 ### Step 1. OSI 7계층과 TCP/IP 프로토콜 스택 ![[OSILayerFunctions.png]] 네트워크를 바라보는 두 가지 관점이 있다. 하나는 네트워크에서 통신이 발생하는 과정을 7개의 수준으로 나눈 OSI(Open System Interconnection) 참조 모델로, OSI 7계층이라고 한다. 또 하나의 관점은 실제로 통신이 이루어지는 과정을 나타내는 TCP/IP 프로토콜 스택이다. OSI 7계층은 다시 두 가지 계층으로 나눌 수 있는데, 1~4계층, 즉 피지컬-데이터 링크-네트워크-트랜스포트 계층을 합쳐 데이터 플로 계층*Data Flow Layer* 혹은 하위 계층*Lower Layer*이라 부르고, 나머지 세션-프레젠테이션-애플리케이션 계층을 합쳐 애플리케이션 계층(Application Layer) 혹은 상위 계층(Upper Layer)이라 부른다. 데이터 플로 계층은 데이터를 상대방에게 잘 전달하는 역할을 가지고 있으며, 애플리케이션 계층은 데이터 플로 계층을 고려하지 않고 데이터를 표현하는 데 초점을 맞춘다. CORS는 이 계층 구조에서 최상위인 애플리케이션 계층(L7[^l7-protocol])의 프로토콜인 HTTP 위에서 동작하는 정책이다. 즉, TCP 연결[^tcp-connection]이 성립된 이후 HTTP 헤더를 통해 제어되는 메커니즘이라는 점을 먼저 기억해두자. ### Step 2. Encapsulation & Decapsulation ![[OsiReferenceModel.png]] 현대 네트워크는 데이터를 패킷(*packet*)이라는 단위로 쪼개어 보내는데, 이런 기법으로 하나의 통신이 회선 전체를 점유하지 않고 동시에 여러 단말이 통신할 수 있도록 해준다. 그리고 데이터를 다시 사용하기 위해 데이터를 받는 쪽에서는 패킷을 다시 큰 데이터 형태로 결합해 사용한다. 애플리케이션에서 패킷을 데이터 플로 계층으로 내려보내면서 패킷에 헤더(*header*)라 불리는 정보를 덧붙이게 되는데, 이 과정을 인캡슐레이션(*Encapsulation*)이라 부른다. 데이터를 받는 측에서는 이렇게 인캡슐레이션 과정을 거친 데이터를 역순으로 해체하게 되는데, 이 과정을 디캡슐레이션(*Decapsulation*)이라 부른다. 디캡슐레이션 과정에서는 데이터에 붙어있는 헤더를 해체하며 헤더에 적힌 정보를 토대로 패킷을 재조합한다. ### Step 3. HTTP와 HTTP 헤더 ![[HttpLayers.svg]] HTTP와 HTTP 헤더가 여기서 등장한다. HTTP(*HyperText Transfer Protocol*)는 TCP/IP 프로토콜 스택 중 애플리케이션 계층에서 사용하는 프로토콜 중 하나이며, 통신 과정에서 메소드(*method*)라는 방법을 통해 단순한 웹페이지 요청 그 이상을 처리할 수 있다. | 메소드 | 세부 설명 | | --------- | ----------------------- | | `GET` | 웹페이지 읽기 | | `HEAD` | 웹페이지의 헤더 읽기 | | `POST` | 웹페이지에 추가하기 | | `PUT` | 웹페이지 저장하기 | | `DELETE` | 웹페이지 지우기 | | `TRACE` | 들어오는 요청을 그대로 반환 | | `CONNECT` | 프록시를 통하여 연결 | | `OPTIONS` | 페이지의 옵션 찾기 | HTTP의 통신 과정은 비교적 단순한데, 사용자가 웹사이트를 방문하면 브라우저(클라이언트)는 웹서버에 리소스를 요청한다. 요청을 받은 웹서버는 HTML, CSS와 같은 리소스를 응답으로 반환한다. ![[HttpRequest.svg]] HTTP 요청은 일반적으로 다음과 같은 형식을 가진다. 첫 줄에는 HTTP 요청 메서드(`GET`, `POST` 등), URL 경로, HTTP 프로토콜 버전 정보가 담기고, 이후 줄에는 키(*key*)-값(*value*) 쌍 형태의 HTTP 헤더(호스트, 언어, 브라우저 정보 등)가 온다. 이후 요청 본문이 있을 수도 있다. ![[HttpResponse.svg]] 서버의 응답 또한 유사한 구조를 가진다. 첫 줄에는 HTTP 프로토콜 버전 정보와 HTTP 상태 코드가, 이후 줄에는 키-값 쌍 형태의 HTTP 헤더가, 그리고 마지막으로 응답 본문(요청한 데이터)이 온다. ![[FetchingAPage.svg]] HTTP 헤더는 콜론(`:`)으로 구분된 이름-값 쌍(*name-value pair*)으로 구성되는데, 예를 들어 `Content-Type: application/json` 형태에서 `Content-Type`이 필드 이름, `application/json`이 값에 해당한다. 요청-응답 주기에서 헤더는 다음과 같은 단계를 거쳐 처리된다. 1. 클라이언트가 요청 메소드(`GET`, `POST` 등)와 URI를 지정한 요청 라인 생성 2. 호스트 정보, 사용자 에이전트, 허용 콘텐츠 유형 등 메타데이터를 헤더에 추가 3. 서버가 상태 코드(`200 OK` 등)와 함께 응답 헤더 구성 4. 콘텐츠 유형, 캐싱 정책, 보안 관련 지시자를 포함한 응답 전송 ### Step 4. Origin #### URL URL(*Uniform Resource Locator*)은 웹 페이지, 이미지, 동영상 등 인터넷에 있는 자원(*Resource*)의 위치를 알려주는 고유한 주소로, 다음과 같은 구조로 이루어져 있다. ```http [프로토콜]://[도메인]:[포트번호]/[경로]?[쿼리] ``` 예를 들어 한 유튜브 영상을 가리키는 URL을 살펴보자. ```http https://www.youtube.com/watch?v=abcdefghijk ``` 각각의 부분에 대하여 간단히 설명하자면 아래와 같다. | 부분 | 의미 | 예시 | | ------------------- | -------------------------------------- | ---------------- | | 프로토콜 | 접속 방법 (웹사이트로 접근하는 방법) | `https` | | 도메인(호스트명) | 웹 사이트의 고유한 이름 | `youtube.com` | | 포트 번호 (생략 가능) | 서버의 특정 문 번호 (기본은 `80` 또는 `443`) | `:80`, `:8080` | | 경로(*Path*) | 사이트 내부의 특정 위치 (방 번호, 상세 경로) | `/watch` | | 쿼리(*Query*) (생략 가능) | 추가 정보나 질문(조건)을 전달할 때 사용 | `?v=abcdefghijk` | 쉽게 말하자면, > "이 주소로 가서 (도메인), > **영상을 볼 수 있는 페이지**를 열고 (경로), > **동영상 ID가 abcdefghijk**인 영상을 가져와주세요!(쿼리)" 라는 의미가 되는 것이다. #### Origin 여기서 웹 콘텐츠의 출처(*origin*)를 정의할 수 있는데, 접근할 때 사용하는 URL의 프로토콜(*protocol*), 도메인(*domain*), 포트(*port*)로 정의된다. 두 URL의 프로토콜, 도메인, 포트가 모두 일치하는 경우 같은 출처를 가졌다고 말하는데, 이 세 요소 중 하나라도 다르면 다른 출처로 간주된다. 예를 들어 `https://example.com`을 기준으로 살펴보면 다음과 같다. | URL | 같은 출처? | 이유 | | -------------------------- | ------ | ------------ | | `https://example.com/page` | O | 경로만 다름 | | `http://example.com` | X | 프로토콜이 다름 | | `https://api.example.com` | X | 도메인(호스트)이 다름 | | `https://example.com:8080` | X | 포트가 다름 | ### Step 5. SOP와 CORS 정책 #### 동일 출처 정책이 필요한 이유 이때 웹 브라우저는 다른 사이트의 스크립트가 사용자를 대신하여 권한 없이 다른 사이트의 리소스에 액세스하는 것을 방지하기 위해 같은 출처를 가진 자원에만 접근할 수 있도록 하는데, 이를 동일 출처 정책(*Same-Origin Policy*, SOP)이라 부른다. 1995년 넷스케이프 내비게이터 2.0에서 처음 도입된 이 정책은 모든 현대 브라우저의 보안 모델의 초석이 되어왔다. SOP가 왜 필요한지 이해하려면, 이 정책이 없을 때 어떤 공격이 가능한지를 살펴봐야 한다. **XSS***Cross-Site Scripting*[^xss]는 공격자가 신뢰할 수 있는 웹사이트에 악의적인 스크립트를 주입하는 공격이다. 예를 들어 게시판의 댓글에 `<script>` 태그를 삽입하면, 그 페이지를 방문한 다른 사용자의 브라우저에서 공격자의 스크립트가 실행된다. 이 스크립트는 해당 사이트의 출처에서 실행되므로 SOP를 우회하여 쿠키, 세션 토큰 등 민감한 정보를 탈취할 수 있다. **CSRF***Cross-Site Request Forgery*[^csrf]는 사용자가 이미 인증된 상태를 악용하는 공격이다. 사용자가 `bank.com`에 로그인한 상태에서 악성 사이트 `evil.com`을 방문하면, `evil.com`의 스크립트가 사용자의 브라우저를 통해 `bank.com`에 송금 요청을 보낼 수 있다. 브라우저는 `bank.com`에 대한 쿠키를 자동으로 포함시키기 때문에, 서버 입장에서는 정상적인 사용자의 요청처럼 보인다. SOP는 바로 이 지점에서 방어선 역할을 한다. `evil.com`의 스크립트가 `bank.com`으로 요청을 보낼 수는 있지만[^sop-write-allowed], 응답 데이터를 자바스크립트로 읽는 것은 차단한다. 즉 SOP는 교차 출처 쓰기*Cross-Origin Writes*와 임베딩*Cross-Origin Embedding*은 대체로 허용하되, 교차 출처 읽기*Cross-Origin Reads*를 엄격히 제한하는 방식으로 동작한다. | 상호작용 유형 | 설명 | 허용 여부 | 예시 | | ---------------------- | ---------------------------- | ----- | ---------------------------------------------------- | | Cross-Origin Writes | 다른 출처로 데이터를 보내는 행위 | 통상 허용 | 링크(`<a>`), 리다이렉트, Form 제출 | | Cross-Origin Embedding | 다른 출처의 리소스를 화면에 표시하거나 실행 | 통상 허용 | `<img>`, `<script>`, `<link rel="stylesheet">`, `<iframe>` | | Cross-Origin Reads | 다른 출처의 리소스 내용을 자바스크립트로 읽는 행위 | 엄격히 제한 | `fetch()`, `XMLHttpRequest`를 통한 JSON 응답 읽기 | #### SOP에서 CORS로 교차 출처 자원 공유*Cross-Origin Resource Sharing, CORS* 정책은 이러한 SOP의 제한을 안전하게 완화하기 위한 방법으로, 서버가 교차 출처 요청을 허용하는 특정 출처를 명시적으로 지정할 수 있게 한다. CORS는 SOP를 대체하는 것이 아니라, 제어된 방식으로 그 제한을 완화하는 메커니즘이다. ![[FetchingPageCors.svg]] 조금 더 깊게 들어가면, SOP와 CORS는 웹 브라우저와 서버의 통신 과정에서 적용되는 정책이다. 이제 실제로 통신 과정에서 SOP와 CORS가 어떻게 다루어지는지 살펴보자. CORS 정책의 핵심은 HTTP 헤더를 사용한 브라우저와 서버 간의 통신 관리인데, 서버는 특정 HTTP 헤더를 응답에 포함시켜 브라우저에게 어떤 출처의 요청을 허용할지 알려준다. #### Simple Request ![[SimpleRequest.svg]] 웹 브라우저와 서버 사이의 가장 단순한 통신은 위와 같이 클라이언트와 서버가 한 번씩 요청과 응답을 주고받는 단순한 모델을 가지고 있다. 이 통신을 유도한 코드는 아래와 같다. ```js const fetchPromise = fetch("https://bar.other"); fetchPromise .then((response) => response.json()) .then((data) => { console.log(data); }); ``` 단순 요청*Simple Request*이 되려면 다음 조건을 모두 만족해야 한다. 1. 메서드가 `GET`, `HEAD`, `POST` 중 하나여야 한다. 2. 브라우저가 자동으로 설정하는 헤더 외에, `Accept`, `Accept-Language`, `Content-Language`, `Content-Type` 등 소수의 CORS-safelisted 헤더만 허용된다. 3. `Content-Type`이 `application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain` 중 하나여야 한다. 이때 브라우저가 서버에 전송하는 요청은 다음과 같다. 자바스크립트의 `fetch()` API와 CORS 프로토콜을 정의하고 있는 Fetch Standard[^fetch-standard]에서는 `Origin` 헤더를 포함한 요청을 CORS 요청으로 정의하고 있는데, `Origin` 헤더에는 요청을 보내는 출처가 담겨 있다. ```http GET /resources/public-data/ 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: https://foo.example ``` 브라우저의 요청을 확인한 서버는 아래와 같이 응답한다. 가장 기본적인 CORS 헤더는 `Access-Control-Allow-Origin`인데, 이 헤더는 서버가 허용할 요청의 출처를 지정하여 해당 출처에서 오는 요청만 받아들인다. > [!note] > 서버의 응답을 확인한 결과 출처와 관계없이 리소스에 접근할 수 있게 해주는 `Access-Control-Allow-Origin: *`을 볼 수 있는데, 이때 사용된 `*`를 와일드카드(*wildcard*)라 부른다. 이렇게 응답이 도착하면, 브라우저가 CORS 정책 위반 여부를 검사한다. ```http HTTP/1.1 200 OK Date: Mon, 01 Dec 2008 00:23:53 GMT Server: Apache/2 Access-Control-Allow-Origin: * Keep-Alive: timeout=2, max=100 Connection: Keep-Alive Transfer-Encoding: chunked Content-Type: application/xml […XML Data…] ``` #### Preflight Request 그러나 `Origin` 헤더를 포함한 요청을 하고 있다고 해서 모든 요청이 CORS 프로토콜을 준수하는 것은 아니다. 기존의 요청 방식에서는 브라우저가 서버로부터 응답을 받은 뒤 CORS 정책 위반 여부를 검사하기 때문에, CORS 프로토콜을 준수하지 않는 요청을 서버에서 처리하는 것을 막을 수 없다. > [!note] > `GET`과 `HEAD`가 아닌 메소드를 사용하는 모든 요청은 `Origin` 헤더를 포함하고 있는데, 그렇다고 해서 CORS 프로토콜이 지켜지는 것이 아니다. 이 문제를 해결하기 위해 탄생한 것이 바로 예비 요청*Preflight Request*이다. 현대적인 API 통신에서 널리 쓰이는 `application/json` 형식이나 `Authorization` 헤더를 통한 토큰 인증은 단순 요청의 조건을 만족하지 못하므로, 대부분의 API 요청은 예비 요청을 거치게 된다. 브라우저는 실제 요청 이전에 `OPTIONS` 메소드를 사용하는 요청을 미리 보내 이 다음으로 보낼 요청(본 요청*Main Request*)이 안전한지 확인한 다음, 안전하다는 것이 확인되면 실제 요청을 보낸다. 다음의 자바스크립트 코드는 실제 요청을 하기에 앞서 예비 요청을 하고 있다. ```js const fetchPromise = fetch("https://bar.other/doc", { method: "POST", mode: "cors", headers: { "Content-Type": "text/xml", "X-PINGOTHER": "pingpong", }, body: "<person><name>Arun</name></person>", }); fetchPromise.then((response) => { console.log(response.status); }); ``` 브라우저/클라이언트와 서버 사이에 일어나는 통신 과정을 단순화하면 아래와 같다. ![[PreflightCorrect.svg]] > [!note] > 이때 예비 요청과 달리 실제 요청은 `Access-Control-Request-*` 헤더를 포함하고 있지 않다. 실제 예비 요청에서 어떤 정보가 오고 가는지 살펴보자. 예비 요청과 이에 대한 응답은 아래와 같다. 예비 요청에 대한 응답으로 본 요청에서 접근이 허용된 출처인 `Access-Control-Allow-Origin`과 사용 가능한 메소드의 목록인 `Access-Control-Allow-Methods`, 그리고 사용 가능한 헤더의 목록인 `Access-Control-Allow-Headers` 등 다른 출처에서 웹 콘텐츠에 접근하기 위한 여러 헤더를 명시하고 있다. ```http OPTIONS /doc 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: https://foo.example Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type,x-pingother 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 Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: X-PINGOTHER, Content-Type Access-Control-Max-Age: 86400 Vary: Accept-Encoding, Origin Keep-Alive: timeout=2, max=100 Connection: Keep-Alive ``` 이렇게 예비 요청이 끝나면, 본 요청을 보내고 실제로 필요했던 응답을 받게 된다. ```http POST /doc 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 X-PINGOTHER: pingpong Content-Type: text/xml; charset=UTF-8 Referer: https://foo.example/examples/preflightInvocation.html Content-Length: 55 Origin: https://foo.example Pragma: no-cache Cache-Control: no-cache <person><name>Arun</name></person> HTTP/1.1 200 OK Date: Mon, 01 Dec 2008 01:15:40 GMT Server: Apache/2 Access-Control-Allow-Origin: https://foo.example Vary: Accept-Encoding, Origin Content-Encoding: gzip Content-Length: 235 Keep-Alive: timeout=2, max=99 Connection: Keep-Alive Content-Type: text/plain [Some XML content] ``` ##### Preflight의 성능 비용 여기서 주목할 점은 Preflight 메커니즘의 성능 비용이다. 하나의 API 호출을 위해 `OPTIONS` 요청과 본 요청, 총 두 번의 HTTP 왕복*round-trip*이 발생한다. TCP 위에서 동작하는 HTTP의 특성상 각 요청마다 최소 1 RTT[^rtt]가 소요되므로, Preflight가 붙으면 체감 지연이 2배가 된다. 네트워크 지연이 큰 모바일 환경이나 해외 서버와의 통신에서는 이 비용이 특히 두드러진다. 이를 완화하기 위해 서버는 `Access-Control-Max-Age` 헤더를 사용할 수 있다. 이 헤더는 브라우저가 Preflight 응답을 캐시할 수 있는 시간(초 단위)을 지정한다. 위의 예시에서 `Access-Control-Max-Age: 86400`은 24시간 동안 동일한 엔드포인트에 대해 `OPTIONS` 요청을 생략하고 바로 본 요청을 보낼 수 있음을 의미한다. 다만 브라우저마다 최대 캐시 시간에 상한이 있어서 Firefox는 24시간(86400초), Chrome 계열은 2시간(7200초)을 넘을 수 없다. #### Credentialed Request 쿠키나 `Authorization` 헤더 같은 자격 증명을 포함하는 요청에서는 CORS 정책이 한층 더 엄격해진다. 클라이언트 측에서는 `fetch()` 호출 시 `credentials: 'include'` 옵션을 명시적으로 설정해야 쿠키가 전송된다. ```js fetch("https://api.example.com/data", { credentials: "include", }); ``` 서버 측에서는 반드시 다음 두 가지 조건을 동시에 만족해야 한다. 1. `Access-Control-Allow-Origin`에 와일드카드(`*`)를 사용할 수 없고, 요청을 보낸 구체적인 출처(예: `https://my-app.com`)를 명시해야 한다. 2. `Access-Control-Allow-Credentials: true` 헤더를 반환해야 한다. 만약 `Access-Control-Allow-Origin: *`과 `Access-Control-Allow-Credentials: true`가 동시에 존재하면, 브라우저는 보안 위반으로 간주하여 응답을 차단한다. 이는 인증 정보가 임의의 출처로 노출되는 것을 막기 위한 안전장치이다. ## 해결 방법 ### CORS는 언제 사용되는가 공식 문서에 의하면 CORS가 사용되는 상황은 다음과 같다. - `fetch()` 또는 `XMLHttpRequest`의 호출 - 웹 폰트(CSS 내 `@font-face`에서 교차 도메인 폰트 사용 시) - WebGL 텍스쳐 - `drawImage()`를 사용해 캔버스에 그린 이미지/비디오 프레임 - 이미지로부터 추출하는 CSS Shapes 웹 브라우저의 콘솔에 남아있는 로그를 다시 보면 이 문제는 첫 번째 케이스, 즉 `XMLHttpRequest`의 호출이 문제가 됐다는 것을 알 수 있다. ```text Access to XMLHttpRequest at 'https://cdn.discordapp.com/attachments/...' from origin 'http://localhost:56614' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. ``` 그리고 이 함수는 플러터의 웹 렌더러인 CanvasKit이 내부에서 사용하는 함수였다. CanvasKit이 지정된 URL에서 이미지를 가져오는 과정에서 CORS 정책을 위반하면서 이 오류가 발생했던 것이다. ### 서버 측에서 처리하기 CORS 문제의 근본적인 해결은 서버 측에서 이루어진다. 서버가 응답 헤더에 적절한 CORS 헤더를 포함시키면 된다. #### 정적 출처 허용 당시 나의 프로젝트는 Firebase를 백엔드로 사용하고 있었기 때문에 아래와 같은 `cors.json` 파일을 만들어 Google Cloud SDK의 `gsutil`을 통해 서버 측의 CORS 설정을 바꿔 해결했다. ```json [ { "origin": ["*"], "method": ["GET"], "responseHeader": ["Access-Control-Allow-Origin"], "maxAgeSeconds": 3600 } ] ``` ```bash gsutil cors set cors.json gs://[BUCKET_NAME] ``` 이 설정은 모든 출처(`*`)에서 `GET` 요청을 허용하고, Preflight 결과를 1시간(3600초) 동안 캐시하도록 한다. 공개적으로 접근 가능한 이미지 스토리지였기 때문에 와일드카드 사용이 적절했다. #### 동적 출처 반사 그러나 프로덕션 환경에서 인증이 필요한 API 서버라면 와일드카드 대신 동적 출처 반사*Dynamic Origin Reflection* 전략을 사용해야 한다. 서버가 요청의 `Origin` 헤더 값을 읽어 허용 목록(화이트리스트)과 대조한 뒤, 일치하는 경우에만 해당 출처를 `Access-Control-Allow-Origin`에 동적으로 반영하는 방식이다. ```python # Flask 예시 ALLOWED_ORIGINS = [ "https://myapp.com", "https://admin.myapp.com", ] @app.after_request def add_cors_headers(response): origin = request.headers.get("Origin") if origin in ALLOWED_ORIGINS: response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Vary"] = "Origin" return response ``` 여기서 `Vary: Origin` 헤더가 중요한데, 이 헤더는 CDN이나 브라우저 캐시에게 "이 응답은 `Origin` 헤더 값에 따라 내용이 달라질 수 있으니 출처별로 별도로 캐싱하라"는 지시를 내린다. 이 헤더가 없으면 CDN이 특정 출처에 대한 응답을 캐시한 뒤 다른 출처의 요청에도 같은 응답을 반환하여 CORS 에러를 유발할 수 있다. ### 프록시 서버를 통한 우회 CORS는 브라우저의 보안 정책이다. 서버 간 통신에는 SOP가 적용되지 않는다. 이 특성을 활용하면 프록시 서버를 중간에 두어 CORS 제약을 우회할 수 있다. #### 개발 환경: 개발 서버 프록시 로컬 개발 환경에서는 프론트엔드(`localhost:3000`)와 백엔드(`localhost:8080`)의 포트가 달라 무조건 교차 출처가 된다. React, Vue, Vite 등 현대적인 프론트엔드 도구들은 이를 위한 내장 프록시 기능을 제공한다. ```mermaid sequenceDiagram participant B as 브라우저 participant D as 개발 서버<br/>(localhost:3000) participant A as API 서버<br/>(localhost:8080) B->>D: GET /api/data (동일 출처) Note over B,D: SOP 위반 없음 D->>A: GET /api/data (서버 간 통신) Note over D,A: 브라우저 밖 → CORS 무관 A-->>D: 200 OK + JSON D-->>B: 200 OK + JSON ``` 브라우저는 API 요청을 프론트엔드 개발 서버(`localhost:3000/api`)로 보낸다. 이는 동일 출처 요청이므로 CORS가 발생하지 않는다. 개발 서버(Node.js 기반)가 이 요청을 받아 실제 백엔드로 중계*proxy*하고, 서버 간 통신은 브라우저의 SOP 제약을 받지 않으므로 자유롭게 통신이 가능하다. ```js // vite.config.js export default { server: { proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ""), }, }, }, }; ``` 이 설정을 통해 개발자는 코드에서 `/api/data`로 요청을 보내면 실제로는 `http://localhost:8080/data`로 전달되며, CORS 설정을 신경 쓸 필요가 없다. #### 프로덕션 환경: 리버스 프록시 프로덕션 환경에서는 Nginx 같은 웹 서버를 리버스 프록시로 사용하여, 클라이언트 정적 파일과 API 서버를 동일한 도메인의 다른 경로로 라우팅하는 전략이 가장 깔끔하다. ```mermaid graph LR B["브라우저"] -->|"https://app.com/*"| N["Nginx"] N -->|"/"| S["정적 파일<br/>(React/Vue)"] N -->|"/api/"| A["API 서버<br/>(백엔드)"] ``` ```nginx server { listen 443 ssl; server_name app.com; # 프론트엔드 정적 파일 location / { root /var/www/html; try_files $uri $uri/ /index.html; } # API 요청을 백엔드로 프록시 location /api/ { proxy_pass http://backend:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } ``` 브라우저 입장에서는 두 요청 모두 `https://app.com`이라는 동일 출처로 향하는 것이므로 CORS 정책 자체가 적용되지 않는다. Preflight 요청도 발생하지 않으니 RTT 비용도 절감된다. 이 방식은 SOP 제약을 아키텍처적으로 우회하는 가장 근본적인 해결책이다. AWS 환경이라면 API Gateway가 이 역할을 대신할 수 있다. `OPTIONS` 요청에 대해 Lambda나 백엔드를 호출하지 않고 게이트웨이 자체적으로 CORS 헤더를 반환하는 Mock 통합을 설정하면, 여러 마이크로서비스의 CORS 정책을 중앙에서 일괄 관리할 수 있다. ### 서드파티 미디어 스토리지와 CDN 나의 경우처럼 Discord CDN, AWS S3, Google Cloud Storage 등 서드파티 스토리지에서 이미지를 가져오는 경우, 해당 스토리지 서버가 CORS 헤더를 반환하도록 설정해야 한다. 직접 운영하는 서버가 아니기 때문에 리버스 프록시 전략을 적용할 수 없고, 스토리지 서비스가 제공하는 CORS 설정 기능에 의존해야 한다. 특히 CDN을 사용할 때는 두 가지를 주의해야 한다. 첫째, `Vary: Origin` 헤더를 반드시 설정해야 한다. 일반적인 `<img>` 태그 요청(비-CORS)으로 이미지가 CDN에 캐시되면, 이 캐시된 응답에는 `Access-Control-Allow-Origin` 헤더가 없을 수 있다. 이후 자바스크립트가 동일한 이미지를 `fetch()`로 요청하면 CDN이 헤더 없는 캐시 응답을 반환하여 CORS 에러가 발생한다. 둘째, HTML에서 `<img>` 태그로 같은 리소스를 사용하면서 자바스크립트에서도 `fetch()`로 접근해야 하는 경우, `<img>` 태그에 `crossorigin` 속성을 추가하여 브라우저가 처음부터 CORS 요청으로 이미지를 가져오도록 해야 캐시 충돌을 방지할 수 있다. ```html <img src="https://cdn.example.com/image.png" crossorigin="anonymous" /> ``` ## 보안 고려사항 CORS를 단순히 "해결해야 할 에러"로 접근하여 무분별하게 허용하면 심각한 보안 취약점이 발생할 수 있다. `Access-Control-Allow-Origin: *`는 공개 데이터(공공 API, 정적 이미지 등)에만 사용해야 한다. 민감한 사용자 정보를 다루는 API에 와일드카드를 적용하면, 공격자가 악성 사이트를 통해 사용자의 인증 세션을 이용해 데이터를 탈취할 수 있다. 특히 인트라넷 내부의 서버가 와일드카드를 허용하면, 외부 공격자가 사용자의 브라우저를 프록시처럼 활용하여 내부망을 스캔하고 데이터를 빼낼 수 있다. `Access-Control-Allow-Origin: null` 설정도 피해야 한다. 로컬 파일(`file://`)이나 샌드박스된 iframe의 요청을 허용하기 위해 사용하는 경우가 있지만, 공격자가 샌드박스 iframe을 이용해 `null` 출처를 의도적으로 생성하여 보안 검사를 우회할 수 있다. 결국 CORS는 사용자를 보호하는 보안 경계선이다. 개발 과정에서 마주치는 CORS 에러가 성가시게 느껴질 수 있지만, 그 에러는 누군가의 데이터를 지키고 있다는 신호이기도 하다. 편의를 위해 보안을 희생하는 설정(`*` 남용, `null` 허용)을 지양하고, 이 글에서 살펴본 표준화된 전략을 통해 보안과 개발 편의성의 균형을 맞추는 것이 중요하다. --- ## 출처 - Mozilla Developer Network (2024) *Cross-Origin Resource Sharing (CORS) - HTTP*. Available at: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS. - Mozilla Developer Network (2024) *Same-origin policy - Security*. Available at: https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Same-origin_policy. - WHATWG (2024) *Fetch Standard*. Available at: https://fetch.spec.whatwg.org/. - Mozilla Developer Network (2024) *Preflight request - Glossary*. Available at: https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request. - Mozilla Developer Network (2024) *Access-Control-Max-Age header - HTTP*. Available at: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age. - PortSwigger (2024) *CORS and the Access-Control-Allow-Origin response header*. Available at: https://portswigger.net/web-security/cors/access-control-allow-origin. - Vite (2024) *Server Options*. Available at: https://vite.dev/config/server-options. - Outpost24 (2024) *Exploiting trust: Weaponizing permissive CORS configurations*. Available at: https://outpost24.com/blog/exploiting-permissive-cors-configurations/. [^l7-protocol]: L7은 OSI 모델의 7번째 계층인 애플리케이션 계층(*Application Layer*)을 지칭하는 약어로, HTTP, HTTPS, FTP 등의 프로토콜이 이 계층에서 동작한다. [^tcp-connection]: TCP(*Transmission Control Protocol*) 연결은 3-way 핸드셰이크(`SYN` → `SYN-ACK` → `ACK`)를 통해 수립되며, 이 과정 자체가 1.5 RTT를 소요한다. [^xss]: XSS(*Cross-Site Scripting*)는 OWASP Top 10에 포함되는 대표적인 웹 보안 취약점으로, 공격자가 웹 애플리케이션에 악성 스크립트를 주입하여 다른 사용자의 브라우저에서 실행시키는 공격이다. 참고: https://owasp.org/www-community/attacks/xss/ [^csrf]: CSRF(*Cross-Site Request Forgery*)는 사용자가 자신의 의지와 무관하게 공격자가 의도한 행위(데이터 변경, 송금 등)를 수행하게 만드는 공격이다. SOP는 응답 읽기를 차단하지만 요청 전송 자체는 막지 않으므로, CSRF 방어에는 별도의 토큰 검증이 필요하다. [^sop-write-allowed]: SOP는 교차 출처 *쓰기*(요청 전송)는 대체로 허용한다. 이는 `<form>` 태그의 기본 동작과의 하위 호환성 때문이다. SOP가 주로 차단하는 것은 교차 출처 *읽기*(응답 데이터를 자바스크립트로 접근하는 행위)이다. [^fetch-standard]: The Fetch Standard(https://fetch.spec.whatwg.org/)는 WHATWG에서 관리하는 웹 표준으로, `fetch()` API와 CORS 프로토콜의 정확한 동작을 정의한다. [^rtt]: RTT(*Round-Trip Time*)는 패킷이 클라이언트에서 서버까지 갔다가 응답이 돌아오기까지 걸리는 시간이다. 일반적인 국내 서버 기준 10~50ms, 해외 서버 기준 100~300ms 정도가 소요된다.