악명 높은 CORS(교차 출처 리소스 공유) 쉽게 이해하기

CORS란?

CORS(Cross-Origin Resource Sharing)브라우저에서 다른 출처에 있는 리소스를 사용할 수 있는 규칙입니다.

CORS
CORS

지난 장에서 살펴본 것처럼 브라우저의 동일 출처 정책(Same-Origin Policy)으로 인해 출처가 다른 리소스에 접근할 수 없는 상황에 직면하게 됩니다.

이번 장에서는 CORS의 원리문제 상황, 그리고 이를 해결할 수 있는 방법을 배워보겠습니다.

핵심 용어 정리

1. 출처(Origin)

URL의 프로토콜, 호스트, 포트 세 가지를 조합한 것을 출처라고 합니다.

  • 이 세 가지가 모두 같으면 출처가 같다고 표현합니다
  • 하나라도 다르면 출처가 다르다고 표현합니다

2. 교차 출처 요청(Cross-Origin Request)

다른 출처의 자원을 사용하기 위해 네트워크 요청을 만드는 것을 말합니다.

브라우저는 실행 중인 애플리케이션의 출처와 다른 출처의 자원을 요청하면 이를 차단합니다. 앞서 공부한 동일 출처 정책이 동작하기 때문입니다.

이를 허용하려면 서버와 특별한 약속을 지켜야 하는데, 그것이 바로 CORS입니다.


CORS의 동작 원리

간단하게 DOM이 생성되면 스크립트를 실행시켜보겠습니다.

const init = async () => {
  // DOM이 만들어지면 init 함수를 실행한다
  const response = await fetch('/resource.json');
  // text로 요청 본문을 읽음
  const data = await response.text();
  const jsonElement = document.createElement('pre');
  jsonElement.textContent = data;
  document.body.appendChild(jsonElement);
};

document.addEventListener('DOMContentLoaded', init);ts
const http = require('http');
const path = require('path');
const static = require('../shared/serve-static');

const handler = (req, res) => {
  static(path.join(__dirname, 'public'))(req, res);
};

const server = http.createServer(handler);
server.listen(8080, () => console.log('server is running ::8080'));js

제대로 데이터를 받아오는 사진
제대로 데이터를 받아오는 사진

실제로 서버를 실행하고 localhost:8080으로 접속하면 GET http://localhost:8080/resource.json이라는 네트워크 요청을 만들고, 응답값인 JSON 문자열을 화면에 출력합니다.

교차 출처 요청 테스트

이제는 교차 출처 요청을 만들 차례입니다. 브라우저에서 실행되는 애플리케이션은 8080번 포트에서 자원을 가져옵니다. 이번에는 8081번 포트를 사용해서 다른 출처로 서버를 하나 만들어보겠습니다.

먼저 서버 코드에서 포트를 외부 인자로 받도록 수정합니다:

const http = require('http');
const path = require('path');
const static = require('../shared/serve-static');

const handler = (req, res) => {
  static(path.join(__dirname, 'public'))(req, res);
};

const server = http.createServer(handler);
const port = process.env.PORT || 8080;
server.listen(port, () => console.log(`server is running ::${port}`));js

이제 서버를 두 번 실행시켜줍니다:

node server             # 8080번 포트로 실행
PORT=8081 node server  # 8081번 포트로 실행bash

브라우저에서 실행되는 스크립트를 수정하여 localhost:8081로 다른 출처에 요청을 보내보겠습니다:

const init = async () => {
  // 8081번 포트로 요청을 보냄
  const response = await fetch('http://localhost:8081/resource.json');
  const data = await response.text();
  const jsonElement = document.createElement('pre');
  jsonElement.textContent = data;
  document.body.appendChild(jsonElement);
};
document.addEventListener('DOMContentLoaded', init);ts

이제 localhost:8080에 접속해보겠습니다.

요청은 보냈고, 응답도 받았지만, 스크립트에는 응답 본문을 사용할 수 없음이라고 표기
요청은 보냈고, 응답도 받았지만, 스크립트에는 응답 본문을 사용할 수 없음이라고 표기

8081번 포트로 요청을 보내고 응답도 받았지만, 스크립트에서 응답 본문을 사용할 수 없다는 메시지가 표시됩니다.

콘솔에서도 교차 출처 요청 차단이라는 경고 문구 표시
콘솔에서도 교차 출처 요청 차단이라는 경고 문구 표시

CORS 에러 분석

콘솔을 확인하면 "교차 출처 요청 차단"이라는 경고가 표시됩니다. 동일 출처 정책으로 인해 8080번 포트가 아닌 8081번 포트를 사용하는 서버의 리소스가 차단된 것입니다.

원인은 Access-Control-Allow-Origin CORS 헤더가 없기 때문입니다.

브라우저는 다른 출처로 요청을 보낼 때:

  1. 응답 헤더 중 Access-Control-Allow-Origin을 확인합니다
  2. 이 헤더에 현재 출처가 포함되어 있는지 검사합니다
  3. 서버가 해당 출처를 허용했는지 판단합니다

서버 8081은 아직 이에 대해 명시하지 않았기 때문에, 브라우저는 자원 사용을 허용하지 않은 것으로 판단하여 네트워크 오류를 발생시킨 것입니다.

CORS 헤더 설정하기

서버 8081이 다른 출처에게 자원을 공유하려면 Access-Control-Allow-Origin 헤더를 설정해야 합니다.

서버 코드를 수정해보겠습니다:

const http = require('http');
const path = require('path');
const static = require('../shared/serve-static');

const handler = (req, res) => {
  // localhost:8080 출처를 허용
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080');
  static(path.join(__dirname, 'public'))(req, res);
};

const server = http.createServer(handler);
const port = process.env.PORT || 8080;
server.listen(port, () => console.log(`server is running ::${port}`));js

다시 서버를 두 개 실행해봅시다:

정상적으로 데이터를 서버로부터 받아오는 사진
정상적으로 데이터를 서버로부터 받아오는 사진

이번에는 정상적으로 네트워크 요청이 전송되었습니다. 서버 8081로 요청을 보냈고, 응답도 받았으며, 브라우저가 이 응답을 차단하지도 않았습니다.

그 이유는 응답 헤더의 Access-Control-Allow-Origin에 현재 애플리케이션이 실행되는 localhost:8080이 명시되어 있기 때문입니다.

브라우저는 “서버가 8080번 포트의 출처도 이 자원을 사용할 수 있다고 허락했구나”라고 판단하여 응답 사용을 허용한 것입니다.


단순 요청(Simple Request)

교차 출처 요청 중에도 특정 조건을 만족하는 요청을 단순 요청(Simple Request)이라고 부릅니다.

단순 요청의 조건

단순 요청이 되기 위해서는 두 가지 조건을 만족해야 합니다:

1. 허용된 HTTP 메소드 사용

  • GET
  • POST
  • HEAD

2. 안전한 헤더 사용

교차 출처 요청에서 사용할 수 있는 헤더를 안전한 헤더라고 합니다.

방금 우리가 사용한 GET 메소드는 단순 요청에 해당합니다. 브라우저는 다른 출처의 자원을 사용하기 위해 HTTP 요청을 만들 때 Origin 헤더에 현재 출처를 실어서 보냅니다.

요청 헤더의 Origin에 이 애플리케이션을 실행한 URL이 담겨있음을 확인할 수 있는 사진
요청 헤더의 Origin에 이 애플리케이션을 실행한 URL이 담겨있음을 확인할 수 있는 사진

요청 헤더의 Origin에 현재 애플리케이션이 실행된 URL이 담겨있는 것을 확인할 수 있습니다. 이것이 바로 현재 출처입니다.

브라우저가 서버에게 “나의 출처는 이곳이니, 당신의 자원을 사용해도 되나요?”라고 묻는 것입니다.

서버는 자원을 제공하겠다는 의미로 응답 헤더에 Access-Control-Allow-Origin을 포함시킵니다:

Access-Control-Allow-Origin 여기에 내 출처를 실어준다.
Access-Control-Allow-Origin 여기에 내 출처를 실어준다.

응답을 받은 브라우저는 이 헤더에 자신의 출처가 포함되어 있는지 확인합니다. 포함되어 있다면 “서버가 자원 사용을 허락했다”고 판단하여 응답을 사용합니다.

안전한 헤더 목록

CORS 허용 목록에 있는 요청 헤더
CORS 허용 목록에 있는 요청 헤더

안전한 헤더 목록:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type
  • Range

참고 자료: MDN - CORS 안전한 요청 헤더

브라우저에서 교차 출처 요청을 할 때 이외의 헤더를 사용하면, 출처 확인과 마찬가지로 헤더 사용 여부도 서버에게 확인받아야 합니다.

커스텀 헤더 테스트

브라우저에서 동작하는 스크립트에 커스텀 헤더를 추가해보겠습니다:

const init = async () => {
  const response = await fetch('http://localhost:8081/resource.json', {
    // 안전하지 않은 헤더를 보냄
    headers: {
      'X-Goguma': 'goguma',
    },
  });
  const data = await response.text();
  const jsonElement = document.createElement('pre');
  jsonElement.textContent = data;
  document.body.appendChild(jsonElement);
};
document.addEventListener('DOMContentLoaded', init);ts

브라우저를 새로고침하면 다음과 같은 에러가 발생합니다:

커스텀 헤더를 보냈지만, 동일 출처 정책으로 인해 응답을 받을 수 없음
커스텀 헤더를 보냈지만, 동일 출처 정책으로 인해 응답을 받을 수 없음

동일 출처 정책으로 인해 원격 리소스가 차단되었습니다. Access-Control-Allow-Headers에 헤더 X-Goguma가 허용되지 않았다고 표시됩니다.

커스텀 헤더 허용하기

안전하지 않은 헤더를 사용하려면 서버가 Access-Control-Allow-Headers 응답 헤더에 허용할 헤더 이름을 명시해야 합니다:

const http = require('http');
const path = require('path');
const static = require('../shared/serve-static');

const handler = (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080');
  // X-Goguma 헤더를 허용
  res.setHeader('Access-Control-Allow-Headers', 'X-Goguma');
  static(path.join(__dirname, 'public'))(req, res);
};

const server = http.createServer(handler);
const port = process.env.PORT || 8080;
server.listen(port, () => console.log(`server is running ::${port}`));js

서버를 재시작하면 이제 정상적으로 요청이 처리됩니다:

제대로 응답을 받을 수 있는 사진
제대로 응답을 받을 수 있는 사진

요청 헤더에 X-Goguma가 정상적으로 포함되어 전송된 것을 확인할 수 있습니다.


사전 요청(Preflight Request)

단순한 요청에 해당하는 GET, POST, HEAD 메소드가 아닌 PUT, PATCH, DELETE 메소드를 사용하는 경우가 있습니다.

이런 경우 서버는 “요청을 보낸 측이 브라우저가 아닐 수도 있다”고 판단합니다. 따라서 브라우저와 서버는 서로를 확인하기 위한 사전 요청을 먼저 주고받습니다.

이를 사전 요청(Preflight Request)이라고 부릅니다.

PUT 메소드로 사전 요청 테스트

브라우저에서 동작하는 스크립트의 메소드를 PUT으로 변경해보겠습니다:

const init = async () => {
  // PUT 메소드로 요청
  const response = await fetch('http://localhost:8081/resource.json', {
    method: 'PUT',
  });
  const data = await response.text();
  const jsonElement = document.createElement('pre');
  jsonElement.textContent = data;
  document.body.appendChild(jsonElement);
};

document.addEventListener('DOMContentLoaded', init);ts

브라우저에서 다음과 같은 오류가 발생합니다:

동일 출처 정책으로 인해 8080에 있는 리소스를 차단했습니다. Access-Control-Allow-Methods에 PUT 메소드가 없다고 출력한다.
동일 출처 정책으로 인해 8080에 있는 리소스를 차단했습니다. Access-Control-Allow-Methods에 PUT 메소드가 없다고 출력한다.

동일 출처 정책으로 인해 리소스가 차단되었으며, Access-Control-Allow-MethodsPUT 메소드가 없다고 표시됩니다.

네트워크 탭을 확인하면 더 자세한 정보를 볼 수 있습니다:

네트워크 탭에도 오류가 발생함
네트워크 탭에도 오류가 발생함

Preflight 요청 분석

사전 요청은 다음과 같은 특징을 가집니다:

  • 메소드: OPTIONS를 사용
  • URL: 실제 요청과 동일한 주소 (resource.json)
  • 요청 헤더: Access-Control-Request-Method에 사용하려는 메소드(PUT)를 명시

브라우저는 “교차 출처로 PUT 메소드를 사용해도 되나요?”라고 서버에게 먼저 확인하는 것입니다.

PUT 메소드 허용하기

서버가 다른 출처에서 PUT 메소드를 허용하려면 Access-Control-Allow-Methods 응답 헤더를 설정해야 합니다:

const http = require('http');
const path = require('path');
const static = require('../shared/serve-static');

const handler = (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080');
  res.setHeader('Access-Control-Allow-Headers', 'X-Goguma');
  // PUT 메소드를 허용
  res.setHeader('Access-Control-Allow-Methods', 'PUT');
  static(path.join(__dirname, 'public'))(req, res);
};

const server = http.createServer(handler);
const port = process.env.PORT || 8080;
server.listen(port, () => console.log(`server is running ::${port}`));js

서버를 재시작하면 정상적으로 동작합니다:

PUT 요청 응답을 브라우저에서 허용해줌
PUT 요청 응답을 브라우저에서 허용해줌

사전 요청과 실제 요청의 흐름

  1. 사전 요청(OPTIONS):

    • 요청: Access-Control-Request-Method: PUT
    • 응답: Access-Control-Allow-Methods: PUT
    • 서버가 PUT 메소드 사용을 허용함을 알림
  2. 실제 요청(PUT):

실제로 PUT 요청이 보내지고, 응답 본문이 실려옴
실제로 PUT 요청이 보내지고, 응답 본문이 실려옴

실제 PUT 요청이 전송되고, 응답 본문도 정상적으로 수신되었습니다.

브라우저는 “서버가 PUT 메소드를 허용했구나”라고 판단하여 애플리케이션에서 응답을 사용할 수 있도록 허용한 것입니다.

사전 요청 캐싱

매번 OPTIONS 메소드를 사용하는 사전 요청을 보내는 것은 네트워크 비용이 추가로 발생합니다.

이를 최적화하기 위해 브라우저는 사전 요청의 응답을 캐시할 수 있습니다. Access-Control-Max-Age 헤더를 설정하면 됩니다:

const http = require('http');
const path = require('path');
const static = require('../shared/serve-static');

const handler = (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080');
  res.setHeader('Access-Control-Allow-Headers', 'X-Goguma');
  res.setHeader('Access-Control-Allow-Methods', 'PUT');
  // 10초 동안 사전 요청 결과를 캐시
  res.setHeader('Access-Control-Max-Age', '10');
  static(path.join(__dirname, 'public'))(req, res);
};

const server = http.createServer(handler);
const port = process.env.PORT || 8080;
server.listen(port, () => console.log(`server is running ::${port}`));js

서버를 재시작한 후 테스트해보겠습니다:

첫 번째 요청

첫 요청은 당연히 캐싱한 데이터가 없으므로 OPTIONS 요청이 날라감
첫 요청은 당연히 캐싱한 데이터가 없으므로 OPTIONS 요청이 날라감

처음에는 OPTIONS 사전 요청이 발생합니다.

10초 이내 재요청

10초 이내의 요청은 당연히 캐싱한 데이터가 있으므로 OPTIONS 요청이 날라가지 않음

10초 이내에 다시 요청하면 OPTIONS 요청이 생략됩니다. 캐시된 사전 요청 결과를 사용하기 때문입니다.

10초가 지나면 캐시가 만료되어 다시 사전 요청이 발생합니다.


CORS를 사용하는 요청들

CORS를 사용하는 요청은 특정 API와 리소스에 한정됩니다.

CORS를 사용하는 요청
CORS를 사용하는 요청

주요 CORS 사용 요청

  • Fetch API: 우리가 다룬 fetch 함수
  • XMLHttpRequest(XHR): 비동기 HTTP 요청
  • @font-face: CSS에서 웹 폰트를 로드할 때
  • WebGL 텍스처: 다른 도메인의 이미지를 텍스처로 사용할 때
  • Canvas drawImage: 다른 출처의 이미지를 캔버스에 그릴 때

참고 자료: MDN - 어떤 요청이 CORS를 사용합니까?

웹 폰트 CORS 테스트

웹 폰트를 통해 CORS가 어떻게 동작하는지 확인해보겠습니다:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script src="script.js"></script>
    <style>
      @font-face {
        font-family: 'MyFont';
        /* 다른 출처(8081 포트)의 폰트 자원을 요청 */
        src: url('http://localhost:8081/myfont.otf');
      }
    </style>
  </head>

  <body style="font-family: 'MyFont';"></body>
</html>html

실행 결과, 동일 출처 정책으로 인해 http://localhost:8081/myfont.otf가 차단됩니다.

웹 폰트도 CORS를 사용하는 자원이므로, 다른 출처에서 폰트를 로드하려면 서버에서 적절한 CORS 헤더를 설정해야 합니다.

마치며

CORS는 웹 보안의 핵심 메커니즘으로, 브라우저가 다른 출처의 리소스를 안전하게 사용할 수 있도록 합니다.

핵심 포인트 정리

  1. 동일 출처 정책은 기본적으로 다른 출처의 리소스 접근을 차단합니다
  2. CORS는 서버가 명시적으로 허용한 출처에서만 리소스를 사용할 수 있게 합니다
  3. 단순 요청사전 요청의 차이를 이해하고 적절히 대응해야 합니다
  4. Access-Control-Max-Age를 통해 네트워크 비용을 최적화할 수 있습니다

보안과 편의성의 균형: CORS는 보안을 위해 필요하지만, 개발 시 불편함을 초래할 수 있습니다. 개발 환경과 프로덕션 환경에서 적절한 CORS 정책을 설정하는 것이 중요합니다.