CORS (Cross-Origin Resource Sharing) 완벽 가이드
웹 개발자라면 한 번쯤은 CORS 문제 때문에 골치아팠던 적이 있으시죠? Cross-Origin Resource Sharing, 줄여서 CORS는 웹 페이지가 다른 도메인의 리소스에 안전하게 접근할 수 도와주는 브라우저의 기능입니다.
이번 포스팅에서는 CORS의 기본 개념부터 작동 원리, 요청 흐름 그리고 실제 구현 방법까지 자세히 다루도록 하겠습니다.
동일 출처 정책
CORS를 제대로 이해하려면 우선 브라우저의 기본 보안 기능인 Same-Origin Policy, 즉 동일 출저 정책에 대해서 알고 있어야 합니다. 브라우저는 현재 자바스크립트가 실행되고 있는 웹페이지의 출처와 동일하지 않은 출처의 리소스에 접근하는 걸 원칙적으로 금지합니다.
여기서 출처(Origin)이란 다음 세 가지 요소로 구성이 되며, 이 세 요소가 모두 동일해야 브라우저는 같은 동일 출처(Same Origin)로 판단합니다.
- 프로토콜: http 또는 https
- 도메인: www.test.com, api.test.com, localhost, 127.0.0.1, 등
- 포트: 80, 443, 3000, 8080, 등
브라우저는 기본 보안 정책인 Same-Origin Policy를 완화하여 필요한 경우 교차 출처, 즉 Cross-Origin 접근을 허용합니다. 서로 다른 출처에서 리소스를 공유하게 하는 메커니즘이기 때문에, Cross-Origin Resource Sharing, 즉 여기서 CORS가 시작됩니다.
CORS 문제는 왜 발생하는가?
CORS 문제는 쉽게 말해서 웹페이지에서 자바스크립트를 통해 다른 출처에 있는 리소스에 접근하려고 할 때 브라우저가 차단하는 현상을 말합니다.
과거에는 대부분의 웹 애플리케이션이 단일 서버에서 모든 것을 처리했기 때문에 Same-Origin Policy이 큰 문제가 되지 않았습니다. 하지만 현대에는 마이크로서비스 아키텍처가 일반화되면서 여러 도메인 간에 통신을 해야하는 경우가 많아졌고 동일 출처 정책을 위반하는 경우가 늘어났습니다.
SPA(Single Page Application)이나 모바일 앱 등 다양한 클라이언트가 서로 다른 출처에서 동작하고, 이미지나 폰트와 같은 정적 리소스의 로딩 지연을 최소화하기 위해서 CDN 사용도 보편화되었고, 프론트엔드에서 제3자 서비스와 통합하는 일도 아주 흔하게 발생합니다. 이 모든 경우 프로토콜이나 도메인이 달라질 수 있기 때문에 동일 출처 정책을 위반할 가능성이 매우 높아집니다.
비단 이러한 상용 환경뿐만 아니라 개발 환경에서도 CORS 문제를 어렵지 않게 접할 수 있는데요.
프론트엔드는 3000 포트에 띄우고 백엔드는 8000 포트에 띄우면, http://localhost:3000
가 출처인 웹 페이지가 http://localhost:8080
가 출처의 API를 호출하므로 동일 출처 정책을 위반하게 됩니다.
동일 출처 정책을 위반할 경우 브라우저는 Origin
헤더를 현재 페이지의 출처, 즉 프로토콜://도메인:포트
설정하여 요청에 자동으로 추가합니다.
그리고 서버에 Preflight, 즉 사전 요청을 보내어 현재 페이지가 해당 리소스에 접근할 수 있는지를 확인합니다.
서버가 접근을 허용하면 본 요청을 보내고, 거부하면 우리가 흔히 겪는 CORS 문제가 발생하게 되는 것입니다.
CORS 오류의 다양한 유형
CORS 문제는 브라우저의 콘솔이나 개발자 도구의 네트워크 탭을 통해서 쉽게 탐지할 수 있습니다.
가장 흔하게 볼 수 있는 오류는 역시 클라이언트의 Origin을 허용하지 않는 것입니다.
클라이언트 측에서 보내는 Origin
요청 헤더를 서버에서 허락하지 않는다는 뜻입니다.
No 'Access-Control-Allow-Origin' header is present on the requested resource.
다음으로 자주 볼 수 있는 오류는 서버가 특정 HTTP Method를 허용하지 않을 때입니다.
Method POST is not allowed by 'Access-Control-Allow-Methods' header in preflight response.
서버가 특정 헤더를 허용하지 않을 때도 CORS 오류가 발생할 수 있습니다.
Request header field X-Custom-Header is not allowed by 'Access-Control-Allow-Headers' header.
마지막으로 쿠키를 포함한 요청에서 CORS 오류가 발생할 수 있습니다.
The value of 'Access-Control-Allow-Origin' header must not be '*' when credentials mode is 'include'.
오류 메시지를 보면 공통적으로 Access-Control-Allow
로 시작하는 헤더가 많이 나옵니다.
이를 통해 우리는 브라우저가 다른 출처의 리소스에 접근하기 위해서는 서버에서 돌려주는 응답에 설정된 헤더 값들이 중요하다는 것을 알 수 있습니다.
CORS 프로토콜 심층 분석
이제 중요한 것은 브라우저가 다른 출처에 있는 리소스에 접근할 수 있는지 없는지 판단하는 방법을 아는 것인데요. 이 것을 바로 CORS 프로토콜이라고 하며 다양한 벤더의 브라우저가 동일하게 따르고 있는 웹 표준입니다.
위에서 간단히 설명드렸던 것처럼 브라우저는 다른 출처에 있는 리소스에 접근하기 위해서 우선 서버에 Preflight, 즉 사전 요청을 자동으로 보냅니다.
이 사전 요청은 HTTP Method 중에서 좀 생소할 수도 있는 OPTIONS을 사용하며, Origin
을 포함한 아래 2개의 헤더를 요청에 추가됩니다.
Access-Control-Request-Method
: 어떤 HTTP Method를 사용하고 싶은지Access-Control-Request-Headers
: 어떤 요청 헤더를 사용하고 싶은지
즉, 본 요청에 대한 상세 정보를 사전 요청을 통해서 서버에 전달해주는 것입니다.
그러면 서버는 아래와 같은 헤더를 통해서 자신의 허용하는 요청한 대해서 응답합니다. 위에서 살펴본 CORS 오류 메시지에 들어있던 헤더들입니다.
Access-Control-Allow-Origin
: 허용하는 OriginAccess-Control-Allow-Methods
: 허용하는 HTTP 메서드Access-Control-Allow-Headers
: 허용하는 요청 헤더Access-Control-Allow-Credentials
: 쿠키 허용 여부Access-Control-Max-Age
:Preflight 캐시 시간(초)Access-Control-Expose-Header
: 클라이언트에서 접근 가능한 응답 헤더
브라우저는 Preflight 응답을 통해 요청 헤더와 응답 헤더를 다음과 같이 비교합니다.
- 송신한
Origin
헤더가 수신된Access-Control-Allow-Origin
헤더와 일치하는가? - 송신한
Access-Control-Request-Method
가 수신된Access-Control-Allow-Methods
헤더에 포함되어 있는가? - 송신한
Access-Control-Request-Headers
가 수신된Access-Control-Allow-Headers
헤더에 포함되어 있는가? - 요청에 쿠키를 포함할 거라면
Access-Control-Allow-Credentials
값이true
인가?
이 모든 것이 만족한다면 서버가 해당 요청을 접근하는 것으로 판단하고 실제 요청을 보냅니다. 이 이후는 같은 Origin에 있는 리소스에 접근하는 것과 동일한 과정을 거치게 됩니다.
서버가 리소스의 접근을 허용한 경우에는 브라우저는 Access-Control-Max-Age
헤더에 설정된 기간동안 동일한 Preflight 요청없이 다른 Origin에 있는 리소스를 바로 접근합니다.
그리고 리소스를 요청한 자바스크립트는 본 응답에서 Access-Control-Expose-Header
에 명시된 헤더에만 접근할 수 있습니다.
참고로 동일 출저 정책을 위반했다고 해서 브라우저가 항상 CORS 프로토콜을 따라 Preflight 요청을 거치는 것은 아닙니다. 다음과 같은 조건을 만족하면 Simple Request, 즉 단순 요청으로 간주하여 사전 단계없이 다른 Origin에 있는 리소스에 바로 접근할 수 있습니다.
- 메서드:
GET
,HEAD
,POST
중 하나 - 헤더: 브라우저 기본 헤더와
Accept
,Accept-Language
,Content-Language
,Content-Type
만 사용 - Content-Type:
application/x-www-form-urlencoded
,multipart/form-data
,text/plain
중 하나
실제 요청 흐름 시나리오
React 앱 https://app.example.com
에서 API https://api.example.com
에 사용자 생성 요청한다고 가정해보겠습니다.
브라우저가 자동으로 사전 요청을 보냅니다.
Origin
, Access-Control-Request-Method
, Access-Control-Request-Headers
헤더를 본 요청에 대한 정보를 서버에 알려줍니다.
OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
서버가 204
상태 코드로 바디없이 CORS 헤더만 응답합니다.
Access-Control-Allow-Methods
헤더에 POST
가 들어있고,
Access-Control-Allow-Headers
헤더에 Content-Type
과 Authorization
이 모두 들어 있습니다.
따라서 본 요청을 허용한다는 것을 알 수 있습니다.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600
이제 브라우저는 안전하다가 판단하고 사용자 생성을 위한 본래 요청을 보냅니다.
POST /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer token123
{"name":"Dale","email":"dale@example.com"}
서버는 201
상태 코드와 함께 사용자된 사용자의 데이터를 응답을 합니다.
httpHTTP/1.1 201 Created
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
{"id":123,"name":"Dale","email":"dale@example.com"}
본 응답에도 Access-Control-Allow-Origin
헤더가 설정되어 있는 것을 유심하게 보셔야합니다. 👀
Cors의 응답 헤더 중에서 Access-Control-Allow-Origin
헤더와 Access-Control-Allow-Credentials
헤더는 Preflight 뿐만 아니라 실제 응답에도 포함되야 합니다.
클라이언트에서 CORS 요청하기
클라이언트에서 CORS 요청을 할 때는 브라우저가 자동으로 CORS 프로토콜을 처리해주므로, 개발자는 일반적인 HTTP 요청과 거의 동일하게 코드를 작성할 수 있습니다. 다만 CORS 에러가 네트워크 에러로 잡히므로, 이를 적절히 처리해주면 좋습니다.
GET 요청은 일반적으로 Simple Request 조건을 만족하므로 Preflight 요청 없이 바로 실행됩니다.
fetch("https://api.test.com/data")
.then((data) => console.log("Data:", data))
.catch((error) => {
console.error("Request failed:", error);
});
자바스크립트의 Promise에 대한 자세한 설명은 관련 포스팅을 참고 바랍니다.
POST 요청에 Content-Type: application/json
을 포함하면 Simple Request 조건을 벗어나므로 브라우저가 자동으로 Preflight 요청을 보냅니다.
const postData = async () => {
try {
const response = await fetch("https://api.test.com/users", {
method: "POST",
headers: {
Authorization: `Bearer token123`
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Dale",
email: "dale@test.com",
}),
});
const data = await response.json();
console.log("Created user:", data);
} catch (error) {
console.error("Request failed:", error);
}
};
자바스크립트의 async/await에 대한 자세한 설명은 관련 포스팅을 참고 바랍니다.
쿠키나 HTTP 인증 정보를 포함해야 하는 경우 credentials
옵션을 설정해야 합니다.
이 경우 서버에서 사전 요청 때 Access-Control-Allow-Credentials: true
를 응답해야 합니다.
fetch("https://api.test.com/profile", {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((data) => console.log("Profile:", data))
.catch((error) => console.error("Error:", error));
CORS 에러는 사용자가 이해하기 어려운 기술적인 문제이므로, 적절한 에러 처리와 사용자 친화적인 메시지 제공이 중요하겠습니다.
자바스크립트의 fetch() 함수에 대해서는 관련 포스팅을 참고 바랍니다.
서버에서 CORS 구현하기
CORS 프로토콜에서 다른 출처로부터 들어온 접근을 허용하거나 거부하는지는 전적으로 서버 측의 구현에 달려있습니다.
직접 구현하려면 OPTIONS 요청을 처리하는 핸들링 로직을 작성해야하는데요. 결국은 응답 헤더에 CORS 응답 헤더를 설정해주는 작업입니다.
대부분의 웹 서버 프레임워크에서 CORS 기능을 내장하거나 플러그인을 통해서 지원하고 있어서 많은 코드 작성없이 쉽게 구현할 수 있습니다. 예를 들어, 가장 대중적인 Express.js에서는 CORS 미들웨어를 사용하면 됩니다.
const express = require("express");
const cors = require("cors");
const app = express();
const allowedOrigins = ["https://www.example.com", "https://app.example.com"];
app.use(
cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
return callback(null, origin);
} else {
return callback(new Error("Not allowed by CORS"));
}
},
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Custom-Header"],
credentials: true,
maxAge: 86400,
exposedHeaders: ["X-Total-Count", "X-Page-Number"],
})
);
app.post("/users", (req, res) => {
res.json({ id: 1, name: "Dale" });
});
app.listen(3001, () => console.log("API listening on port 3001"));
보안을 위해서 개발 환경이나 외부에 공개된 리소스가 아니라면 가급적 Whitelist, 즉 제한된 리스트 내에서 동적으로 Access-Control-Allow-Origin
헤더를 설정해주는 것이 좋습니다.
그리고 CORS 프로토콜 규격상 credentials: include
옵션을 사용하면서 Access-Control-Allow-Origin: *
를 사용하는 건 허용되지 않으며 반드시 특정 출처를 명시해야 합니다.
마무리
CORS는 브라우저가 출처가 다른 리소스를 가져올 수 있는가 없는가에 대한 명시적 계약입니다. 잘못 이해하거나 설정하면 서비스가 안 되거나, 보안 구멍이 될 수 있고, 정확히 이해하고 설정하면 사용자 경험도 깔끔해지고 서비스도 안전해집니다. 본 가이드를 통해 CORS의 동작 원리를 정확히 이해하고, 보안을 고려한 올바른 구현 방법을 익히셨기를 바랍니다.