CORS란?
CORS(Cross-Origin Resource Sharing)는 브라우저에서 다른 출처에 있는 리소스를 사용할 수 있는 규칙이다.
지난 장에서 살펴본 것처럼 브라우저의 동일 출처 정책(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);tsconst 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 헤더가 없기 때문이다.
브라우저는 다른 출처로 요청을 보낼 때:
- 응답 헤더 중
Access-Control-Allow-Origin을 확인한다 - 이 헤더에 현재 출처가 포함되어 있는지 검사한다
- 서버가 해당 출처를 허용했는지 판단한다
서버 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이 담겨있는 것을 확인할 수 있다. 이것이 바로 현재 출처다.
브라우저가 서버에게 “나의 출처는 이곳이니, 당신의 자원을 사용해도 되나요?”라고 묻는 것이다.
서버는 자원을 제공하겠다는 의미로 응답 헤더에 Access-Control-Allow-Origin을 포함시킨다:
응답을 받은 브라우저는 이 헤더에 자신의 출처가 포함되어 있는지 확인한다. 포함되어 있다면 “서버가 자원 사용을 허락했다”고 판단하여 응답을 사용한다.
안전한 헤더 목록
안전한 헤더 목록:
참고 자료: 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브라우저에서 다음과 같은 오류가 발생한다:
동일 출처 정책으로 인해 리소스가 차단되었으며, Access-Control-Allow-Methods에 PUT 메소드가 없다고 표시된다.
네트워크 탭을 확인하면 더 자세한 정보를 볼 수 있다:
Preflight 요청 분석
사전 요청은 다음과 같은 특징을 가진다:
- 메소드:
OPTIONS를 사용 - URL: 실제 요청과 동일한 주소 (
resource.json) - 요청 헤더:
Access-Control-Request-Method에 사용하려는 메소드(PUT)를 명시
브라우저는 “교차 출처로 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서버를 재시작하면 정상적으로 동작한다:
사전 요청과 실제 요청의 흐름
-
사전 요청(OPTIONS):
- 요청:
Access-Control-Request-Method: PUT - 응답:
Access-Control-Allow-Methods: 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 사전 요청이 발생한다.
10초 이내 재요청
10초 이내에 다시 요청하면 OPTIONS 요청이 생략된다. 캐시된 사전 요청 결과를 사용하기 때문이다.
10초가 지나면 캐시가 만료되어 다시 사전 요청이 발생한다.
CORS를 사용하는 요청들
CORS를 사용하는 요청은 특정 API와 리소스에 한정된다.
주요 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는 웹 보안의 핵심 메커니즘으로, 브라우저가 다른 출처의 리소스를 안전하게 사용할 수 있도록 한다.
핵심 정리
- 출처(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 정책을 설정하는 것이 중요하다.