
다른 학교와 협업하여 진행 중인 일정 공유 프로젝트 [beacon](https://github.com/cholog-project/beacon)에서 React 프론트엔드와 Spring Boot 백엔드 API를 연결하는 과정 중에 CORS 에러가 발생했습니다.
이전 프로젝트에서는 이미 CORS 설정이 되어 있었기 때문에 특별히 신경 쓰지 않았지만, 이번 기회에 정리하며 CORS(Cross-Origin Resource Sharing)의 개념과 원리를 이해하고자 합니다.
CORS를 이해하기 전에 SOP이라는 것을 알아야 합니다. 브라우저는 기본적으로 SOP(Same-Origin Policy), 즉 동일 출처 정책을 따릅니다.
동일 출처란 프로토콜, 호스트, 포트가 모두 같을 때를 의미합니다.
이 정책에 따라 동일 출처에 있는 리소스는 자유롭게 접근할 수 있지만, 다른 출처의 리소스는 제한적으로만 접근할 수 있습니다.

출처
그런데 개발 과정에서는 다른 출처의 자원과 상호작용해야 하는 경우가 종종 발생합니다.
예를 들어, 프론트엔드와 백엔드가 서로 다른 서버에 배포된 경우가 이에 해당합니다. 이때 CORS가 이러한 교차 출처 간 요청을 제한적으로 허용하는 역할을 합니다.
그렇다면 동일 출처가 아닌 경우 접근을 차단하는 이유는 무엇일까요?
만약 이 SOP 정책이 없다면 해커가 CSRF(Cross-Site Request Forgery)나 XSS(Cross-Site Scripting) 같은 보안 공격을 통해 사용자의 개인정보를 탈취할 위험이 있습니다. 다음은 SOP 정책이 없는 상황에서 발생할 수 있는 공격 시나리오입니다.

출처
1. 사용자가 악성 사이트에 접속합니다.
2. 악성 사이트에 심어진 자바스크립트 코드가 실행되어 사용자가 모르는 사이에 외부 사이트로 요청을 보냅니다.
3. 외부 사이트는 브라우저의 쿠키나 인증 정보를 이용해 요청을 처리하고 응답을 반환합니다.
4. 반환된 응답 데이터는 해커의 서버(hacker.example.com)로 전송됩니다.
이처럼 SOP는 브라우저가 외부 출처와 직접 상호작용하는 것을 제한하여 사용자의 보안을 강화합니다.
CORS는 교차 출처 자원 공유를 의미하며, 한 출처의 자원이 다른 출처의 자원에 제한적으로 접근할 수 있도록 허용하는 메커니즘입니다. 이를 교차 출처 요청이라고 합니다.
예를 들어, 프론트엔드 서버(React)와 백엔드 서버(Spring Boot)가 서로 다른 포트를 사용한다면 이 두 서버는 다른 출처로 간주됩니다.
프론트엔드: http://localhost:3000
백엔드: http://localhost:8080
이 경우 브라우저는 CORS 위반으로 인해 요청을 차단합니다.

출처
위의 구성요소 중에서 Protocol + Host + Port 3가지가 같으면 동일 출처(Origin)라고 합니다.
| Protocol이 다른 경우 | Host가 다른 경우 | 80, 8080으로 포트가 다른 경우 |
|---|---|---|
| http://example.com/app1 https://example.com/app2 | http://example.com http://www.example.com http://myapp.example.com | http://example.com http://example.com:8080 |
이제 기본 다른 출처에 요청을 하는 세가지 방법을 알아보겠습니다.
웹 클라이언트 어플리케이션이 다른 출처의 리소스를 요청할 때는 HTTP 프로토콜을 사용하여 요청을 보냅니다. 이 과정에서 브라우저는 요청 헤더에 Origin 필드를 포함하여 요청을 보낸 출처 정보를 함께 전달합니다.
Origin: https://evan-moon.github.io
서버는 이 요청을 처리한 후 응답 헤더에 Access-Control-Allow-Origin 값을 포함시켜 허용된 출처 정보를 브라우저에 전달합니다.
브라우저는 자신이 보낸 요청의 Origin과 서버가 응답으로 보낸 Access-Control-Allow-Origin 값을 비교하여 응답이 유효한지 판단합니다.
하지만 모든 요청이 동일한 방식으로 처리되지는 않습니다. 요청의 특성에 따라 CORS는 세 가지 시나리오로 나뉘며, 이를 이해하면 CORS 에러를 효과적으로 해결할 수 있습니다.
단순 요청은 브라우저가 별도의 사전 검증 없이 즉시 요청을 보내는 방식입니다. 특정 조건을 만족하는 경우에만 단순 요청으로 처리됩니다.
단순 요청의 조건
예시
const xhr = new XMLHttpRequest();
const url = 'https://www.api.com?q=test';
xhr.open('GET', url);
xhr.onreadystatechange = requestHandler;
xhr.send();

1. 브라우저는 요청 헤더에 Origin 값을 포함해 서버에 요청합니다.
2. 서버는 응답 헤더에 허용된 출처 정보를 포함해 반환합니다.
만약 서버의 응답 헤더에 Access-Control-Allow-Origin이 포함되지 않거나 요청 출처와 다른 경우, 브라우저는 요청을 차단합니다.
예비 요청은 민감한 정보나 사용자 데이터에 영향을 줄 수 있는 복잡한 요청을 안전하게 처리하기 위한 사전 검증 과정입니다. 브라우저는 OPTIONS 메서드를 사용해 요청의 허용 여부를 미리 확인합니다.
요청 헤더에는 다음 값이 포함됩니다.
예시
const xhr = new XMLHttpRequest();
const url = 'https://www.api.com?q=test';
xhr.open(‘GET', url);
xhr.setRequestHeader(‘custom-header', ’test')
xhr.onreadystatechange = requestHandler;
xhr.send();

1. 브라우저는 예비 요청을 보냅니다.
2. 서버는 다음과 같은 응답 헤더를 반환합니다:
신용 요청은 쿠키, 인증 헤더, TLS 클라이언트 인증서 등 인증 정보를 포함한 요청입니다.
기본적으로 브라우저는 다른 출처의 요청에 인증 정보를 포함하지 않지만, 특정 조건을 만족하면 허용할 수 있습니다.
신용 요청의 조건
예시
const xhr = new XMLHttpRequest();
const url = 'https://www.api.com?q=test';
xhr.open('GET', url);
xhr.withCredentials = true;
xhr.send();

만약 서버 응답에 Access-Control-Allow-Credentials: true가 없거나, 허용된 출처가 아닌 경우 브라우저는 요청을 차단합니다.

복잡한 요청일수록 예비 요청(Preflight Request)을 사용하는 것이 안전합니다.
예비 요청을 통해 서버가 허용 여부를 사전에 결정할 수 있기 때문에, 예상치 못한 요청 실패나 보안 취약성을 줄일 수 있습니다.
스프링 부트에서는 @CrossOrigin 어노테이션이나 전역 설정(WebMvcConfigurer)을 사용해 간편하게 CORS 정책을 적용할 수 있습니다. 여기서는 메서드별, 컨트롤러 단위, 그리고 전역 설정으로 나누어 설정하는 방법을 알아보겠습니다.
특정 메서드에만 CORS 정책을 적용하려면 @CrossOrigin 애너테이션을 사용합니다. 아래는 GET 메서드에만 CORS를 허용하는 예제입니다.
@RestController
@RequestMapping("/users")
public class UserController {
@CrossOrigin(origins = "http://example.com")
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
// 사용자 정보 반환
return new User(id, "John Doe");
}
@DeleteMapping("/{id}")
public void deleteUser(@PathVariable Long id) {
// 사용자 삭제
}
}
@CrossOrigin을 적용한 메서드(getUser)
deleteUser 메서드
컨트롤러 전체에 CORS 정책을 적용할 수도 있습니다. 이 경우 해당 컨트롤러의 모든 엔드포인트가 설정한 CORS 정책을 따릅니다.
@CrossOrigin(origins = "http://example.com", maxAge = 3600)
@RestController
@RequestMapping("/products")
public class ProductController {
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
return new Product(id, "Laptop", 1200.0);
}
@DeleteMapping("/{id}")
public void deleteProduct(@PathVariable Long id) {
// 상품 삭제
}
}
@CrossOrigin(maxAge = 3600)
@RestController
@RequestMapping("/orders")
public class OrderController {
@CrossOrigin(origins = "http://example.com")
@GetMapping("/{id}")
public Order getOrder(@PathVariable Long id) {
return new Order(id, "Electronics", 2);
}
@DeleteMapping("/{id}")
public void deleteOrder(@PathVariable Long id) {
// 주문 삭제
}
}
전역적으로 모든 엔드포인트에 CORS 정책을 적용하려면 WebMvcConfigurer를 구현합니다. 이 방식은 필터 기반으로 작동하며 모든 요청에 대해 CORS 검사가 수행됩니다.
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 경로 허용
.allowedOrigins("http://example.com") // 허용된 출처
.allowedMethods("GET", "POST", "PUT", "DELETE") // 허용된 HTTP 메서드
.allowCredentials(true) // 인증 정보 포함 허용
.maxAge(3600); // 프리플라이트 응답 캐시 시간 (1시간)
}
}