악명 높은 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');
  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 handler = (req, res) => {
  // localhost:8080 출처를 허용
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080');
  static(path.join(__dirname, 'public'))(req, res);
};js

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

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

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

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

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


단순 요청(Simple Request)

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

단순 요청의 조건

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

1. 허용된 HTTP 메소드 사용

2. 안전한 헤더 사용

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

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

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

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

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

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

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

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

안전한 헤더 목록

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

안전한 헤더 목록:

참고 자료: 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 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);
};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 요청 분석

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

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

PUT 메소드 허용하기

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

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);
};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 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);
};js

서버를 재시작한 후 테스트해본다:

첫 번째 요청

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

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

10초 이내 재요청

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

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

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


CORS를 사용하는 요청들

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

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

주요 CORS 사용 요청

참고 자료: 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는 웹 보안의 핵심 메커니즘으로, 브라우저가 다른 출처의 리소스를 안전하게 사용할 수 있도록 한다.

핵심 정리

  • 출처(Origin): 프로토콜, 호스트, 포트 세 가지를 조합한 것
  • 단순 요청: GET, POST, HEAD 메소드와 안전한 헤더만 사용하는 요청
  • 사전 요청: PUT, PATCH, DELETE 같은 메소드나 커스텀 헤더를 사용할 때 먼저 확인하는 OPTIONS 요청
  • CORS 헤더:
    • Access-Control-Allow-Origin: 허용할 출처 지정
    • Access-Control-Allow-Methods: 허용할 메소드 지정
    • Access-Control-Allow-Headers: 허용할 헤더 지정
    • Access-Control-Max-Age: 사전 요청 결과 캐시 시간
  • 보안과 편의성의 균형: CORS는 보안을 위해 필요하지만, 개발 시 불편함을 초래할 수 있다. 개발 환경과 프로덕션 환경에서 적절한 CORS 정책을 설정하는 것이 중요하다.