이번 시간에는 프론트엔드에서 역시 중요한 개념인 SOP와 CORS에 대해 다뤄보도록 하겠습니다. 프론트엔드 개발을 하다보면 어쩔 수 없이 만나게 되는 CORS 에러가 있습니다. 저도 이 에러를 처음봤을때가 생각나네요. 요즘에는 이게 하도 유명해져서 누구나 다 아는 상식처럼 되어버렸습니다. (예전에는 안그랬던거 같은데...)
SOP와 CORS에 공통적으로 들어가는 단어가 있습니다. 바로 Origin(출처)입니다. 따라서 먼저 Origin에 대한 개념부터 이해하고 가도록 하겠습니다.
Origin(출처)는 프로토콜(예: HTTP 또는 HTTPS), 포트(지정된 경우) 및 호스트로 정의됩니다.
예를 들어, http://www.example.com/foo
는 http://www.example.com/bar
와 same-origin(동일 출처)이지만, https://www.example.com/bar
과는 다릅니다. 프로토콜이 다르기 때문이죠.
그래서 Origin이 같으면 Same-Origin(동일 출처)라고 하고, 다르면 Cross-Origin(교차 출처)라고 합니다.
참고 : https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
참고 2 : https://en.wikipedia.org/wiki/Same-origin_policy
컴퓨팅에서 Same-origin policy(SOP, 동일 출처 정책)은 웹 애플리케이션 보안 모델의 중요한 개념입니다.
Same-origin policy은 말 그대로 "같은 출처에서만 리소스를 공유할 수 있다" 라는 규칙을 가진 정책입니다. 이는 잠재적 악성 문서(사이트)를 격리하여 가능한 공격 벡터를 줄이는 데 도움이 됩니다.
예를 들어, XSS 를 통해 누군가가 악의적인 스크립트를 심었다고 가정해봅시다. 이렇게 되면 사용자가 해당 페이지를 접속할 때 브라우저에서 악의적인 JS가 실행되어 쿠키라던가 DOM을 이용해 개인 정보를 다른 사이트(서버)로 요청을 통해 정보를 탈취할 수 있습니다. 이런 위험을 사전에 방지하고자 다른 웹 페이지의 액세스하는 것을 막는 것이 SOP가 생긴 이유라고 할 수 있습니다.
다시 말해, 관련이 없는 사이트에서 제공하는 콘텐츠 간의 엄격한 분리는 데이터 기밀성 또는 무결성의 손실을 방지하기 위해 클라이언트 측에서 유지되어야 합니다.
좋은 취지로 만들어졌지만 개발과정에서 SOP는 CORS 에러를 발생시키는 원인이 됩니다. 개발을 하다보면 프론트 서버와 백엔드 서버가 다른 경우가 비일비재합니다. 이때도 마찬가지로 동일 출처가 아니기 때문에 브라우저 자체에서 요청을 막아버리게 됩니다.
콘솔에서 보여주는 에러 메시지는 다음과 같습니다. 이후에 나오는 CORS 설정을 통해 이런 요청을 허용하도록 할 수 있는데 이는 밑에 CORS 를 보면서 다루도록 하겠습니다.
근데 한가지 의구심이 있을 수 있습니다. 이미지 같은 경우는 다른 사이트의 이미지를 호출해도 문제가 없다는 사실입니다. 다른 출처인데 어떻게 이게 가능할까요? SOP 설명대로라면 이것도 막아야 되는 거 아닐까요?
이는 교차 출처 네트워크 접근이 일반적으로 세 가지 범주로 분류되며 경우에 따라서 허용이 될 수도 있고 아닐 수도 있습니다.
다음은 교차 출처로 삽입할 수 있는 리소스의 일부 예시입니다. 그니까 이런것들은 기본적으로 html에 이미 임베딩이 되어있다면 사용할 수 있다는 걸 보여주는 거 같네요.
<img>
로 표시하는 이미지. 그러나 자바스크립트를 사용하여 cross-origin image data를 읽는 것은 차단됩니다. <video>
와 <audio>
로 재생하는 미디어.<script src="…"></script>
를 사용하는 JavaScript. (ex. CDN 요청을 통해 사용하는 JS 라이브러리)<link rel="stylesheet" href="…">
로 적용된 CSS. @font-face
로 적용하는 글꼴. (ex. 구글 폰트 사용 시)<iframe>
으로 삽입하는 모든 것. 사이트는 X-Frame-Options
헤더를 사용하여 cross-origin framing을 방지할 수 있습니다.<object>
와 <embed>
로 삽입하는 외부 리소스.참고 : https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
참고 2 : https://en.wikipedia.org/wiki/Cross-origin_resource_sharing
CORS 메커니즘은 브라우저와 서버 간의 안전한 cross-origin 요청 및 데이터 전송을 지원합니다. 추가적인 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 방법이라고 볼 수 있습니다.
교차 출처 리소스 공유가 동작하는 방식을 보여주는 세 가지 시나리오를 살펴보겠습니다.
단순 요청은 CORS preflight 를 트리거하지 않습니다. 다음 요건을 모두 충족해야 사용할 수 있습니다.
예를들어, https://foo.example
의 웹 컨텐츠가 https://bar.other
도메인의 컨텐츠를 호출하길 원합니다. foo.example에 배포된 자바스크립트에는 아래와 같은 코드가 사용될 수 있습니다.
const xhr = new XMLHttpRequest();
const url = 'https://bar.other/resources/public-data/';
xhr.open('GET', url);
xhr.onreadystatechange = someHandler;
xhr.send();
요청 헤더의 Origin을 보면, https://foo.example
로부터 요청이 왔다는 것을 알 수 있습니다.
GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example ✅
서버는 이에 대한 응답으로 Access-Control-Allow-Origin 헤더를 다시 전송합니다. 이 경우 서버는 Access-Control-Allow-Origin: *
, 으로 응답해야 하며, 이는 모든 도메인에서 접근할 수 있음을 의미합니다.
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: * ✅
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml
[…XML Data…]
https://bar.other
의 리소스 소유자가 오직 https://foo.example
의 요청만 리소스에 대한 접근을 허용하려는 경우 다음을 전송할 수 있습니다.
Access-Control-Allow-Origin: https://foo.example
"preflighted" request는 위에서 논의한 "simple requests" 와는 달리, 먼저 OPTIONS 메서드를 통해 다른 도메인의 리소스로 HTTP 요청을 보내 실제 요청이 전송하기에 안전한지 확인합니다. cross-origin 요청은 유저 데이터에 영향을 줄 수 있기 때문에 이와같이 미리 전송(preflighted)합니다.
다음은 preflighted 할 요청의 예제입니다. Content-Type 이 application/xml이고, 사용자 정의 헤더가 설정되었기 때문에 이 요청은 preflighted 처리됩니다.
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://bar.other/resources/post-here/');
xhr.setRequestHeader('Ping-Other', 'pingpong');
xhr.setRequestHeader('Content-Type', 'application/xml');
xhr.onreadystatechange = handler;
xhr.send('<person><name>Arun</name></person>');
OPTIONS 요청과 함께 두 개의 다른 요청 헤더가 전송됩니다. 실제 요청을 전송할 때 POST 메서드로 전송된다는 것과 X-PINGOTHER 와 Content-Type 사용자 정의 헤더와 함께 전송된다는 것을 서버에 알려줍니다.
OPTIONS /resources/post-here/ HTTP/1.1
...
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
이제 서버는 이러한 상황에서 요청을 수락할지 결정할 수 있습니다. 아래는 서버가 요청 메서드와 (POST) 요청 헤더를 (X-PINGOTHER) 받을 수 있음을 나타내는 응답입니다.
HTTP/1.1 204 No Content
...
Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
정리해보면, OPTIONS 요청을 통해 Origin과 Access-Control-Allow-Origin을 비교해서 서버가 요청을 허용하는지 알아낸다음, 본 요청은 그 이후에 일어난다는 것을 알 수 있습니다.
자격 증명을 포함한 요청입니다. credentialed requests는 HTTP cookies 와 HTTP Authentication headers 정보를 인식합니다. 기본적으로 cross-site XMLHttpRequest 나 Fetch 호출에서 브라우저는 자격 증명을 보내지 않습니다. XMLHttpRequest 객체나 Request 생성자가 호출될 때 특정 플래그를 설정해야 합니다.
참고 : https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
예를 들어, XMLHttpRequest, axios 옵션 중에는 withCredentials이 있고, fetch에는 credential 이 있습니다.
/* XMLHttpRequest */
const xhr = new XMLHttpRequest();
xhr.open("GET", "http://example.com/", true);
xhr.withCredentials = true;
xhr.send(null);
/* axios */
axios.get("http://back-shop/api/products", {
withCredentials: false, // default
})
/* fetch API */
fetch("http://back-shop/api/products", {
credentials: "include",
})
withCredentials 라는 의미에서 알 수 있듯이 서로 다른 도메인(크로스 도메인)에 요청을 보낼 때 요청에 credential 정보를 담아서 보낼 지를 결정하는 항목입니다. 이는 동일한 오리진 요청에 영향을 미치지 않습니다.기본값은 false이기 때문에 다른 도메인의 XMLHttpRequest 응답은 요청하기 전에 자격 증명이 true로 설정되어 있지 않으면 해당 도메인의 쿠키 값을 설정할 수 없습니다.
서버에서도 적절한 응답 헤더를 넘겨줘야 합니다.
*
" 와일드카드를 지정하는 대신 Access-Control-Allow-Origin 헤더 값에 출처를 지정해야 합니다.*
" 와일드카드를 지정할 수 없습니다.위에서 설명했던 것처럼 백엔드 서버에서 Access-Control-Allow-Origin를 설정해주면 해결됩니다. 이는 프론트엔드 개발자가 백엔드 개발자에게 요청해야할 내용입니다. Spring, Express, Django와 같이 이름있는 벡엔드 프레임워크의 경우에는 모두 CORS 관련 설정을 위한 세팅이나 미들웨어 라이브러리를 제공하고 있으니 어렵지 않을 것입니다.
단, Access-Control-Allow-Origin을 "*
" 와일드카드를 지정하게 되면 모든 요청을 수용하겠다는 뜻이기도 하니 주의할 필요가 있을 거 같습니다. 뭔가 찜찜하기도 하고요...
흔히 리액트를 사용하다보면 개발 서버가 자체적으로 실행되면서 개발을 하게 됩니다. 운영에서는 서버와 합쳐지겠지만 개발할때는 프론트 서버와 백엔드 서버가 별도로 실행되기 때문에 CORS 에러가 발생합니다. 이걸 굳이 백엔드 설정을 하는 것을 불필요할 것입니다. 그래서 Dev Server를 보면 보통 Proxy를 설정하면 된다고 합니다.
실습을 위해 가장 먼저 CORS 에러를 발생할 상황을 만들어보겠습니다. 먼저 프론트엔드 프로젝트를 Vite를 통해 생성해줬습니다. 그리고 개발 서버를 띄우면 기본적으로 localhost:5173 으로 실행됩니다.
프론트엔드에서 백엔드 서버인 localhost:3000/api/users를 요청하는 코드입니다.
const fetchData = async () => {
setLoading(true);
try {
const res = await fetch("http://localhost:3000/api/users");
const json = await res.json();
setData(json);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
이제 백엔드 서버를 node.js로 간단하게 만들어줍니다.
// express app
const express = require("express");
const app = express();
const port = 3000;
app.get("/api/users", (req, res) => {
console.log("get request");
const users = [
{ id: 1, name: "John" },
{ id: 2, name: "Peter" },
{ id: 3, name: "Sally" },
];
res.json(users);
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
이제 브라우저를 보면 아래와 같이 CORS 에러를 보실 수 있습니다. 근데 잘 보면 서버 요청과 응답은 정상적으로 실행된 것을 알 수 있고, CORS 에러는 브라우저 자체에서 응답을 받을 때 Origin과 Access-Control-Allow-Origin을 비교해서 다르면 발생한다는 것을 알 수 있네요.
Access-Control-Allow-Origin를 설정해서 CORS 에러를 해결해보겠습니다. 아래와 같이 헤더 설정을 통해 전체 허용('*')을 해줬습니다.
app.get("/api/users", (req, res) => {
const users = [
{ id: 1, name: "John" },
{ id: 2, name: "Peter" },
{ id: 3, name: "Sally" },
];
// allow access from any origin
res.setHeader("Access-Control-Allow-Origin", "*");
res.json(users);
});
이렇게 되면 어떤 Origin에서 요청이 오던 브라우저에서 CORS 에러가 발생하지 않습니다.
아, 그리고 한가지 더 확인할 수 있는 것은 기본적인 GET 요청인 경우 Simple requests 인 것을 알 수 있네요. Preflighted requests라면 OPTIONS이 있었겠죠?
그러면 Preflighted requests를 확인해보겠습니다. 그러기 위해서는 메소드를 POST로 바꾸고 Content-Type을 application/json으로 설정해서 요청해보도록 합니다.
const body = {
name: "John",
};
const res = await fetch("http://localhost:3000/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
서버 측 코드입니다. 마찬가지로 Access-Control-Allow-Origin를 설정해주었습니다.
app.post("/api/users", (req, res) => {
// res body console
console.log(req.body);
// allow access from any origin
res.setHeader("Access-Control-Allow-Origin", "*");
res.json({ success: true });
});
그랬더니 결과는 CORS 에러가 발생했습니다. 에러 메시지를 보니 preflight request 라는 단어가 보이네요.
그리고 Network 요청을 확인해보니 밑에 Preflight라는 요청이 있는 걸 알 수 있네요. 그니까 Preflight 요청 전에 본 요청이 전송이 되면서 CORS 에러가 발생한 거 같군요.
Preflight 요청은 OPTIONS 인 것을 알 수 있습니다.
참고 : https://github.com/expressjs/cors
node에서는 cors 라고 하는 유명한 라이브러리가 있습니다. 지금도 무려 천만 다운로드 수를 기록하고 있네요. 사용법도 간단합니다. 여기서는 "/api/users"에 대해서만 OPTIONS에 대해 cors를 설정했습니다.
app.use(express.json()); // 요청 본문을 json 형태로 파싱
app.options("/api/users", cors()); // enable pre-flight request
app.post("/api/users", (req, res) => {
console.log(req.body);
// allow access from any origin
res.setHeader("Access-Control-Allow-Origin", "*");
res.json({ success: true });
});
이렇게 해서 preflight 이후에 본 요청이 오도록 구현이 되었습니다.
마지막으로 자격 증명을 포함한 요청을 보내보도록 하겠습니다. 여기서는 로그인 상황을 가정하여 아래와 같이 요청한다고 가정해보겠습니다.
const res = await fetch("http://localhost:3000/api/auth/login");
서버에서 응답은 다음과 같습니다. res.cookie하면 노드에서 Set-Cookie를 설정해서 넘겨주겠는 뜻입니다.
app.get("/api/auth/login", (req, res) => {
// cookie setting
res.cookie("session_id", "12345", {
httpOnly: true,
path: "/",
});
res.setHeader("Access-Control-Allow-Origin", "*");
res.json({ success: true });
});
이렇게 했더니 요청에 대한 응답은 200 OK가 된거 같은데... Application 탭에서 Cookie를 확인해보면 제대로 설정이 되어있지 않습니다. 왜냐면 fetch 요청 시에 credentials을 설정하지 않았기 때문입니다. 따라서 설정을 해주겠습니다.
const res = await fetch("http://localhost:3000/api/auth/login", {
credentials: "include",
});
그리고 실행해보니 이런 에러가 나네요. Access-Control-Allow-Origin에 와일드카드(*
)를 사용할 수 없다는 에러입니다.
따라서 구체적으로 설정해주도록 하겠습니다.
res.setHeader("Access-Control-Allow-Origin", "http://localhost:5173");
그리고 실행했더니 또 에러가 나네요. 이번에는 Access-Control-Allow-Credentials를 true로 설정하지 않았군요. 이래서 공식문서를 꼼꼼히 읽어야 하나봅니다. ㅎㅎ
따라서 이것도 설정해주도록 하겠습니다.
res.setHeader("Access-Control-Allow-Credentials", "true");
마침내 성공했습니다. 만약 CORS에 대해서 잘 모르고 있었다면 좀 헤맸을거 같네요.
이번 시간에는 웹 개발에서 기본이 되는 SOP와 CORS에 대해 알아봤습니다. 그리고 간단한 실습을 통해 구체적으로 더 알아봤습니다. 그냥 글로 읽고 이해했을때보다 훨씬 재밌고 기억에 잘 남을거 같네요. 좋은 시간이었습니다. 👍
이제 면접에서 물어본다면 자신있게 설명할 줄 알아야겠죠?