팀프로젝트 중 자꾸 프런트엔드가 cors 에러가 뜬다고 연락이 왔다.
나는 나름 CORS 허용 설정을 해놨다고 생각했는데 원인이 뭔지 스프링에서 어떻게 허용을 하는지 탐구해 보기로 했다.
CORS란?
CORS란 Cross Origin Resource Sharing의 약자로 직역하자면 "교차 출처 리소스 공유"라는 뜻으로 출처를 교차에서 공유한단 뜻이다.
출처란 뭘까?
Origin(출처)이란 우리가 흔히 알고 있는 URL에서 도메인만 뜻하는 게 아니라 프로토콜과 포트까지 포함하는 개념이다.
예를 들어 http://www.google.com:80/ 이라는 URL로 보자면
첫 http는 프로토콜 -> 어떤 방식으로 소통할지,
www.google.com는 도메인 -> DNS 서버를 거쳐 어떤 네트워크 주소로 갈지,
:80 포트번호 -> 어떤 프로세스의 포트번호로 들어갈지를 결정하게 되고 이 셋을 합쳐 오리진(출처)라 부른다.

즉 CORS란 다른 오리진(도메인이 다를 수도 있고 프로토콜이 다를 수도 있고 포트가 다를 수도 있는) 오리진 간에 리소스를 공유한단 뜻이다.
우리가 백엔드에서 CORS 허용을 안해준다면 백엔드 환경과 다른 오리진인 프런트엔드와 리소스를 공유 못하는 것이 되겠다.
그렇다면 이 CORS를 왜 허용해준다는 것일까? 그것은 웹 표준이 Same-Origin-Policy(줄여서 SOP) 즉 동일 출처 정책으로 되어있단 점 때문이다!
왜 귀찮게 동일 출처간에만 리소소를 공유하게 하는 걸까? 대 HTTP 3.0의 시대에 말이다.
그 원인을 찾으려면 파이어폭스 브라우저의 전신이던 넷스케이프에 자바스크립트가 도입되던 1995년으로 돌아가야 한다.
그 시절 넷스케이프는 HTML, CSS에 더불어 자바스크립트를 도입하였는데 웹 브러우저 내에서 이미지, 플러그인등의 요소를 쉽게 접근하고 조합할 수 있게 하는 의도로 도입하였다. 따라서 자바스크립트를 이용하여 브라우저 내부의 문서 객체인 Document Object Model(이하 DOM)에 접근할 수 있도록 개발되었다.
DOM이란 HTML 문서의 객체 기반 표현 방식이다. 쉽게 설명하면 JavaScript를 통해 HTML로 표현된 객체에 접근하여 변경, 제거 등의 작업을 수행할 수 있도록 해주는 것이 DOM이다.
<!DOCTYPE html>
<html>
<head>
<title>DOM 예시</title>
</head>
<body>
<h1 id="title">원래 제목</h1>
<button onclick="changeTitle()">제목 바꾸는 버튼</button>
<script>
function changeTitle() {
const title = document.getElementById("title");
title.textContent = "바뀐 제목";
//document.getElementById("title");로 돔 객체를 추출해서 제목을 바꾼다.
}
</script>
</body>
</html>
이렇게 돔 조작으로 웹 요소를 쉽게 접근하는 만큼 단점도 생겼는데 XSS(Cross-Site Scripting)나 CSRF(Cross-Site Rquest Forgery) 공격에 매우 취약해진다는 것이다.
XSS(Cross-Site Scripting) 공격은 쿠키 세션을 다루거나 서버로 정보를 전송하는 자바스크립트의 장점을 이용하여 악성 스크립트를 게시판이나 URL 파라미터 같은 곳에 삽입하여 서버에 저장시킨 뒤 유저의 정보를 탈취한다.
CSRF(Cross-Site Rquest Forgery) 공격은 사이트 간의 요청을 위조함으로써 유저가 원하지 않은 요청을 의도하지 않은 방식으로 보내게 만드는 공격이다. 브라우저가 자동으로 쿠키나 세션을 들고 있는데 그 쿠키나 세션을 생성한 (즉 로그인한) 사이트에 요청을 날리는 스크립트를 만들어 메일/메신저/블로그등에 올려 클릭하게 만들고 만약 사용자가 그걸 클릭하면 요청을 날리게 만들어 피해를 입힌다.
정말 무시무시한 일이 아닐 수가 없다... 더불어 그 시절 웹은 Server-Side Rendering 방식, 즉 서버에서 생성한 웹페이지를 보내는 방식이었기에 다른 오리진에서 보내는 요청은 무조건 공격으로 간주하고 SOP 정책을 사용하게 된다.
그리고 2011년 국제 인터넷 표준화 기구에 의해 웹 표준으로 문서화되어서 현재까지 내려오고 있다.
하지만 무조건이라는 게 어디 있겠는가? 언제나 예외는 있는 법이다. 또 시대가 발전하여 요즘은 프런트엔드와 백엔드가 협업을 하기 때문에 다른 오리진간에 리소스를 공유할 일이 생긴다. 그래서 SOP 정책은 몇 가지 예외를 두는 방향으로 개정하게 된다.
먼저 예외 상황을 허용했다. <script> 태그로 Javascript를 실행하는 경우, 이미지를 렌더링 하는 경우, <link> 태그로 CSS 파일을 불러오는 경우, HTML 문서를 화면에 보여주는 경우엔 다른 Origin으로의 요청을 허용하였다.
그다음이 바로 위에서 설명한 CORS 정책이다.
그렇다면 이러한 CORS 정책은 어떻게 적용할 수 있는가? 바로 서버의 응답 헤드에 Access-Control-Allow-Origin: * 혹은 특정 도메인만 허용하고 싶다면 Access-Control-Allow-Origin: <허용하고 싶은 도메인>을 박아 넣으면 된다.
웹브라우저에서 CORS 정책으로 접근하는 경우 무조건 헤더에 Access-Control-Allow-Origin만 넣어서는 안 되는 경우도 있는데 함께 살펴보도록 하겠다.
1. 단순 요청일 경우
단순한 요청일 경우 클라이언트는 다른 출처로의 요청을 보낼 때 HTTP 요청 헤더에 자신의 출처(프로토콜, 도메인, 포트)를 넣어서 서버에게 보낸다.
POST /api/user HTTP/1.1
Host: api.example.com
Origin: http://localhost:3000
Content-Type: application/json
그러면 이 요청을 받은 서버는 응답 헤더에 Access-control-Allow-Origin을 실어 보낸다. 이 헤더에는 허가된 출처 정보가 담겨있다. 혹은 * 와일드카드를 사용하여 모든 출처를 허용할 수도 있다.
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: *

하지만 단순한 요청이 되기가 쉽지 않다. 단순 요청이란 헤더에 커스텀 헤더가 없거나 안전한 요청을 말하는데 정리해 보자면 다음과 같다.
- GET, POST, HEAD 메서드만 허용된다.
- Accept, Accept-Language, Content-Language, Content-Type 헤더만 허용된다.
- Content-Type는 application/x-www-form-urlencoded, multipart/form-data, text/plain 이 세가지 값만 허용된다.
- 요청에 사용된 XMLHttpRequestUpload 객체에는 이벤트 리스너가 등록되어 있지 않다. 이들은 XMLHttpRequest.upload 프로퍼티를 사용하여 접근한다.
- 요청에 ReadableStream 객체가 사용되지 않는다.
백엔드나 프런트엔드 개발을 해보면 알겠지만 GET POST 메서드만 사용하는 것도 아니고 커스텀 헤더 쓸 일도 많고 Json으로 통신하니 단순 요청은 쉽지 않다고 생각된다. 그래서 우리의 요청은 두 번째 시나리오로 넘어간다.
2. 프리플라이트 요청(Preflight Request)

위에서 말했듯이 단순 요청이 아닌 경우 모든 요청은 프리플라이트 요청 즉 사전준비 요청으로 넘어간다.
브라우저는 이 요청이 안전한 요청인지 물어보는 절차를 거치게 되는데 '이거 좀 위험한데 맞아요?' 하면서 내가 설정하지 않아도 브라우저가 OPTION 메서드를 서버에 먼저 날린다.
OPTIONS /data HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type
그러면 서버는 단순 요청과 마찬가지로 Access-Control-Allow-Origin 헤더를 전송하고 거기에 더해 허용하는 메서드, 헤더, 캐시기간, 쿠기 사용이 되는지 등을 응답해 주게 된다.
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
만약 본 요청 (클라이언트가 보내고자 하는 요청) 전에 Preflight로 응답받은 요청에서 빠진 설정이 있으면 '아 이건 서버에서 설정되지 않았는데?'라고 판단한 뒤 본 요청은 서버로 가지도 않고 바로 브라우저 콘솔에서 에러를 내버린다!
여담으로 Postman 같은 도구로 테스트해봤을 때는 잘되지만 브라우저 환경에선 CORS 정책문제로 막히는 경우를 만나는 경우가 매우 많은데 Postman은 브라우저가 아니기 때문에 Preflight 요청을 보내지 않아 에러가 안 뜬다고 한다.
3. 인증 정보를 포함한 요청 (Credentuialed Request)
3번째 시나리오는 헤더에 인증정보 (쿠키, 토큰) 등을 담아서 보내는 인증된 요청을 사용하는 방법이다.
우리가 로그인이나 JWT 토큰을 사용할 때 클라이언트 입장에서 헤더에 토큰을 담거나 쿠키를 들고 서버에 요청을 보내게 되는데 이게 다 Credential Request라 할 수 있겠다.
이 요청을 보내려면 위에서도 봤겠지만 Preilght 응답받는 헤더에 Access-Control-Allow-Credentials: true가 포함되어 있어야 한다. 이 응답 헤더가 없다면 역시나 브라우저에서 에러를 띄우게 된다.
거기에 더불어 요청에 인증정보가 담겨있는 상태에서 다른 출처의 리소스를 요청하게 되면 브라우저는 몇 가지 조건을 더 검사하게 되는데
1. Access-Control-Allow-Origin에는 * (와일드카드)를 사용할 수 없으며, 명시적인 URL 이어야 한다. (https://foo.com과 같이 구체적인 origin을 지정해주어야 한다.)
즉 Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true 이 둘을 이렇게 같이 사용을 못한다는 뜻이다.
명시적으로 Access-Control-Allow-Origin: localhost:3000 Access-Control-Allow-Credentials: true 이런 식으로 지정해주어야 한다.
2. Access-Control-Allow-Credentials: true가 응답헤더에 반드시 존재하여야 한다.
자 그럼 처음으로 돌아와서 자바와 스프링 시큐리티에서 CORS 설정하는 것을 먼저 살펴보자. 위에서 탐구하고 보면 어떤 메서드가 어떤 역할을 하는지 쉽게 보일 것이다.
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry corsRegistry){
corsRegistry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600)
;
}
}
@Bean //cors 설정 빈
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
config.setExposedHeaders(List.of("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
위가 기본 자바의 Cors 설정이고 밑이 스프링 시큐리티를 사용할 경우 Cors를 설정해 주는 메서드이다. 둘이 형태는 달라도 OPTION Preflight 요청이 날아오면 필터에 등록된 저 메서드들을 통해 요청을 가로채고 헤더를 만들어 응답을 하게 된다.
그렇다면 내 코드에선 뭐가 문제였을까? 공부를 해보니 쉽게 답이 나왔었다.
@Bean //cors 설정 빈
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
config.setExposedHeaders(List.of("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean //cors 설정 빈
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
config.setExposedHeaders(List.of("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
첫 번째 코드는 허용하는 오리진에 로컬호스트를 박아버린 것이다. 내 서버는 현재 배포상태에 있는데 로컬호스트 오리진만 허용해 버리니 서버와 같은 프로토콜과 주소에서 포트만 다른 오리진을 허용한다는 격이 되어서 에러를 띄워버렸다.
두 번째 코드는 Access-Control-Allow-Origin에 와일드카드를 사용하고 Access-Control-Allow-Credentials: true 옵션을 쓰는 인증정보를 포함한 요청 정책 위반이다.
두 번째 경우에서 조금 당황하였는데 노트북으로 개발할 경우 계속 오리진이 달라지는데 서버 입장에서 계속 설정에 오리진을 추가해줘야 하나?라는 고민을 하였는데 스프링 5.3 이상 버전부터는 그 해결책이 있다. 바로 setAllowedOriginPatterns() 메서드를 사용하면 되었고 나는 Cors 문제를 결국 해결하였다.

마치면서
우리가 흔히 설정만 하고 지나치는 CORS 문제도 깊게 파헤치면서 과거에 어떠한 문제 때문에 발전해 왔고 어떠한 원리가 있는지를 알아보게 되었다. 백엔드 개발자를 하려면 HTTP와 네트워크도 정말 깊게 공부해야겠다는 걸 느끼는 트러블 슈팅이었다.
'탐구' 카테고리의 다른 글
| 노가다에서 벗어나기: ModelMapper보다 MapStruct를 선택한 이유 (1) | 2025.12.30 |
|---|---|
| Copy on Write로 알아본 계층을 관통하는 철학 (1) | 2025.12.26 |
| 영속성 컨테이너의 플러시 타이밍 문제에 관하여... (0) | 2025.07.01 |