📎

CORS 이해하기 - 웹 크로스 도메인 통신 가이드

1. CORS는 왜 탄생했는가?

1995년 넷스케이프 2.0이 JavaScript를 세상에 내놓았을 때, 웹은 아직 단순한 문서 공유 도구였습니다. 하지만 JavaScript가 DOM을 조작하고 사용자와 상호작용할 수 있게 되면서, 동시에 보안 위험도 드러났습니다. 한 사이트의 JavaScript가 다른 사이트의 데이터를 마음대로 읽을 수 있다면? 악의적인 웹사이트가 사용자 모르게 은행 계좌에 접근할 수도 있었습니다.
그래서 브라우저 제작사들은 동일 출처 정책(Same-Origin Policy)이라는 보안 정책을 도입했습니다. "같은 도메인, 같은 포트, 같은 프로토콜"이라는 엄격한 규칙을 만든 것이죠. 이 정책은 웹을 안전하게 만들었지만, 동시에 제약이 되기도 했습니다.
2000년대 중반, 웹 2.0 시대가 열리면서 상황이 바뀌었습니다. 구글 맵스는 여러 서버에서 지도 타일을 가져와야 했고, 페이스북은 다양한 API와 연동해야 했습니다. 하지만 동일 출처 정책이 이 모든 것을 막고 있었습니다. 합법적인 용도임에도 불구하고 말이죠.

1.1 JSONP이 SOP를 우회할 수 있다?

개발자들은 동일 출처 정책의 예외를 찾기 시작했습니다. 그들은 <script> 태그는 어떤 도메인의 JavaScript 파일이든 가져올 수 있다는 점을 발견했습니다. jQuery를 CDN에서 불러오고, Google Analytics를 사용하는 것처럼 말이죠.

1.1.1 JSONP가 뭔데?

JSONP는 <script> 태그의 특성을 이용한 우회 기법이었습니다. <script src="...">로 JavaScript 파일을 요청하면 동일 출처 정책의 제약을 받지 않는다는 점을 활용한 것이죠.
동작 방식은 다음과 같습니다.
// 1. 클라이언트에서 콜백 함수를 미리 정의 function handleData(data) { console.log('환율:', data.rate); } // 2. script 태그로 서버에 요청 (콜백 함수명을 파라미터로 전달) var script = document.createElement('script'); script.src = 'https://api.exchange.com/rate?callback=handleData'; document.head.appendChild(script); // 3. 서버는 JSON 데이터가 아니라 JavaScript 코드를 응답 // 응답 내용: handleData({"rate": 1350, "currency": "KRW"});
서버가 순수한 JSON 데이터를 보내는 게 아니라, 클라이언트가 지정한 함수명으로 데이터를 감싼 JavaScript 코드를 보내는 것입니다. 브라우저는 이를 JavaScript 파일로 인식해서 즉시 실행하고, 결과적으로 미리 정의해둔 handleData 함수가 호출되면서 데이터를 받을 수 있었습니다.
이 방법은 실제로 작동했습니다. 하지만 근본적인 문제가 있었습니다. GET 요청만 가능했고, 서버를 완전히 신뢰해야 했습니다. 악의적인 서버가 handleData(data) 대신 deleteAllFiles()를 보낸다면? 브라우저는 그대로 실행했습니다.

1.2 AJAX와 CORS의 탄생의 연관관계?

2005년 제시 제임스 가렛이 "AJAX"라는 용어를 만들어냈을 때, 웹 개발 방식이 크게 바뀌기 시작했습니다. 구글 지메일과 구글 맵스가 보여준 페이지 새로고침 없는 사용자 경험은 새로운 가능성을 제시했습니다.
하지만 XMLHttpRequest는 동일 출처 정책의 엄격한 통제를 받았습니다. 같은 도메인 내에서만 데이터를 주고받을 수 있었죠. 이는 마치 전화는 걸 수 있지만 같은 건물 안에서만 가능한 것과 같았습니다.
웹 서비스들이 복잡해지면서 이 제약은 더욱 답답해졌습니다. 아마존은 결제 시스템을, 트위터는 소셜 로그인을, 구글은 지도 서비스를 제공했지만, 다른 사이트에서 이를 안전하게 사용할 방법이 없었습니다. JSONP는 임시방편일 뿐이었고, POST 요청이나 커스텀 헤더가 필요한 경우에는 무용지물이었습니다.
이런 현실적 필요가 쌓이면서, 2009년 W3C는 CORS 명세 작업을 시작했습니다. 보안은 유지하면서도 합법적인 크로스 도메인 통신을 가능하게 하는 정교한 메커니즘을 만들어낸 것입니다. 서버가 명시적으로 "이 요청은 안전하다"고 승인한 경우에만 브라우저가 통신을 허용하는 방식이었죠.

2. Server에서 고려해야 될 사항들

2.1 CORS-Safelisted method와 request-header

특정 HTTP 메서드와 헤더는 CORS에서 안전한 것으로 간주되어 프리플라이트 요청 없이 전송될 수 있습니다.
Safe Methods
  • GET
  • HEAD
  • POST (다음 조건들을 모두 만족할 때만)
    • Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나
    • 커스텀 헤더가 없어야 함 (Accept, Accept-Language, Content-Language, Content-Type만 허용)
    • XMLHttpRequest.upload에 이벤트 리스너가 등록되어 있지 않아야 함
application/x-www-form-urlencoded, multipart/form-data 의 차이를 쉽게 알 수 있는 글 공유
Safe Headers
  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type (application/x-www-form-urlencoded, multipart/form-data, text/plain만)

2.1.1 Access-Control-Allow-Origin

이 헤더는 어떤 출처에서 오는 요청을 허용할지 지정합니다.
Access-Control-Allow-Origin: https://example.com Access-Control-Allow-Origin: *
보안상 이유로 와일드카드(*)를 사용할 때는 자격 증명이 포함된 요청은 허용되지 않습니다.

2.1.2 Access-Control-Allow-Methods

허용되는 HTTP 메서드를 지정합니다.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE

2.1.3 Access-Control-Allow-Headers

클라이언트가 사용할 수 있는 요청 헤더를 지정합니다.
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

2.1.4 Access-Control-Max-Age

프리플라이트 요청의 결과를 캐시할 시간을 초 단위로 지정합니다.
Access-Control-Max-Age: 86400

2.2 request-header 옵션들

서버에서 처리해야 하는 기타 CORS 헤더들입니다.
  • Access-Control-Allow-Credentials: 자격 증명이 포함된 요청을 허용할지 결정합니다
  • Access-Control-Expose-Headers: 클라이언트가 접근할 수 있는 응답 헤더를 지정합니다
  • Vary: 캐싱을 위해 Origin 헤더를 고려하도록 합니다

2.3 Preflight Request (OPTIONS 메서드)

2.3.1 서버 리소스를 변경하는 PUT, DELETE와 같은 메서드는 안전하지 않다

브라우저는 안전하지 않은 요청을 보내기 전에 OPTIONS 메서드로 프리플라이트 요청을 먼저 전송합니다. 서버는 이 요청에 대해 적절한 CORS 헤더로 응답해야 합니다.
// 프리플라이트 요청 예시 OPTIONS /api/users/123 HTTP/1.1 Origin: https://example.com Access-Control-Request-Method: DELETE Access-Control-Request-Headers: authorization // 서버 응답 HTTP/1.1 200 OK Access-Control-Allow-Origin: https://example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: authorization Access-Control-Max-Age: 86400

3. Client에서 고려해야 될 사항들

3.1 fetch의 다양한 옵션들

3.1.1 쿠키를 포함하는 요청 전송. credentials

자격 증명(쿠키, 인증 헤더 등)을 포함한 요청을 보내려면 credentials 옵션을 설정해야 합니다.
fetch('https://api.example.com/data', { method: 'GET', credentials: 'include' // 'same-origin', 'omit', 'include' })
  • same-origin: 같은 출처일 때만 자격 증명 포함
  • include: 항상 자격 증명 포함
  • omit: 자격 증명 포함하지 않음

3.1.2 mode

CORS 처리 방식을 지정합니다.
fetch('https://api.example.com/data', { mode: 'cors' // 'cors', 'no-cors', 'same-origin' })
  • cors: CORS 프로토콜을 사용하여 요청
  • no-cors: CORS 없이 요청 (응답 내용에 접근 불가)
  • same-origin: 같은 출처에만 요청

3.2 img, script의 crossorigin (동일 출처에 의해 접근제한 되지 않는 요소들)

3.2.1 crossorigin 속성을 사용할 수 있는 요소 종류

다음 HTML 요소들은 crossorigin 속성을 지원합니다.
  • <img>
  • <script>
  • <link>
  • <audio>
  • <video>
<img src="https://example.com/image.jpg" crossorigin="anonymous"> <script src="https://cdn.example.com/script.js" crossorigin="use-credentials"></script>

3.2.2 fetch의 credentials과의 비교

crossorigin 속성값과 fetch의 credentials 옵션은 유사한 역할을 합니다.
  • anonymous: fetch의 'omit'과 유사
  • use-credentials: fetch의 'include'와 유사

3.2.3 iframe으로 데이터 전송하기. postMessage

iframe을 통해 다른 도메인과 안전하게 통신할 수 있습니다.
// 부모 창에서 iframe으로 메시지 전송 const iframe = document.getElementById('myIframe'); iframe.contentWindow.postMessage('Hello', 'https://example.com'); // iframe에서 메시지 수신 window.addEventListener('message', function(event) { if (event.origin !== 'https://parent-domain.com') return; console.log('받은 메시지:', event.data); }); // 응답 전송 event.source.postMessage('응답 메시지', event.origin);
postMessage를 사용할 때는 반드시 origin을 검증하여 보안을 유지해야 합니다.
 

CORS는 웹 보안과 개발 편의성 사이의 균형점입니다. 동일 출처 정책의 보안 기능을 유지하면서도, 현대 웹 애플리케이션이 필요로 하는 크로스 도메인 통신을 가능하게 만듭니다.
서버에서는 어떤 출처를 허용할지 명시적으로 선언하고, 클라이언트에서는 적절한 요청 방식을 선택하는 것. 이 두 가지가 함께 작동할 때 안전하고 효과적인 웹 서비스가 만들어집니다. JSONP 같은 우회 기법의 시대는 지났고, 이제는 표준화된 CORS로 크로스 도메인 통신을 할 수 있습니다.