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');
// 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 헤더가 없기 때문입니다.
브라우저는 다른 출처로 요청을 보낼 때:
- 응답 헤더 중
Access-Control-Allow-Origin
을 확인합니다 - 이 헤더에 현재 출처가 포함되어 있는지 검사합니다
- 서버가 해당 출처를 허용했는지 판단합니다
서버 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이 담겨있는 것을 확인할 수 있습니다. 이것이 바로 현재 출처입니다.
브라우저가 서버에게 “나의 출처는 이곳이니, 당신의 자원을 사용해도 되나요?”라고 묻는 것입니다.
서버는 자원을 제공하겠다는 의미로 응답 헤더에 Access-Control-Allow-Origin
을 포함시킵니다:
응답을 받은 브라우저는 이 헤더에 자신의 출처가 포함되어 있는지 확인합니다. 포함되어 있다면 “서버가 자원 사용을 허락했다”고 판단하여 응답을 사용합니다.
안전한 헤더 목록
안전한 헤더 목록:
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
브라우저에서 다음과 같은 오류가 발생합니다:
동일 출처 정책
으로 인해 리소스가 차단되었으며, Access-Control-Allow-Methods
에 PUT
메소드가 없다고 표시됩니다.
네트워크 탭을 확인하면 더 자세한 정보를 볼 수 있습니다:
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
서버를 재시작하면 정상적으로 동작합니다:
사전 요청과 실제 요청의 흐름
-
사전 요청(OPTIONS):
- 요청:
Access-Control-Request-Method: PUT
- 응답:
Access-Control-Allow-Methods: 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
사전 요청이 발생합니다.
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는 웹 보안의 핵심 메커니즘으로, 브라우저가 다른 출처의 리소스를 안전하게 사용할 수 있도록 합니다.
핵심 포인트 정리
동일 출처 정책
은 기본적으로 다른 출처의 리소스 접근을 차단합니다CORS
는 서버가 명시적으로 허용한 출처에서만 리소스를 사용할 수 있게 합니다단순 요청
과사전 요청
의 차이를 이해하고 적절히 대응해야 합니다Access-Control-Max-Age
를 통해 네트워크 비용을 최적화할 수 있습니다
보안과 편의성의 균형: CORS는 보안을 위해 필요하지만, 개발 시 불편함을 초래할 수 있습니다. 개발 환경과 프로덕션 환경에서 적절한 CORS 정책을 설정하는 것이 중요합니다.