자바 백엔드 입문 32편. 프론트와 백엔드가 분리된 시대 거의 필수가 된 CORS의 정체와 Spring Boot 설정 표준 패턴을 출입증 비유로 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 32편이에요. 프론트엔드(React·Vue)와 백엔드(Spring)가 분리된 시대 — 첫 API 호출에서 가장 자주 만나는 "CORS 에러" 와 그 해결법.
CORS가 어렵게 들리는 이유
처음 프론트에서 백엔드 API 호출하면 — 브라우저 콘솔에 빨간 글씨로 "blocked by CORS policy" 가 떠요. "백엔드가 응답을 줬는데 왜 막혀?" 가 안 잡혀요.
이 글에서는 회사 출입증 비유로 풀어요. CORS = "외부 회사 직원이 우리 사옥에 들어오려면 사전 출입증 발급". 끝까지 따라오시면 "빨간 글씨" 가 사라지는 표준 설정이 한 그림에 들어와요.
CORS — 한 줄 정의
CORS(Cross-Origin Resource Sharing) = "브라우저가 다른 도메인의 API를 호출할 때 적용되는 보안 정책". 브라우저가 자체적으로 "이거 보안 위험" 이라고 차단.
핵심 — CORS는 브라우저 정책이지 백엔드 거부가 아니에요. 백엔드는 응답을 보냈는데 — 브라우저가 "다른 도메인이라 위험" 판단해 자바스크립트 코드에 응답을 전달하지 않는 거예요.
프론트 http://localhost:3000 ──→ 백엔드 http://localhost:8080
↓
응답 전송 OK
↓
[브라우저] ❌ "출처가 달라요. JS 코드에 못 줘요"
Origin이 다르다 = 무엇이 다르다?
"Origin" 의 정의가 명확.
- 프로토콜 (
httpvshttps) - 호스트 (
localhostvsapi.myshop.com) - 포트 (
:3000vs:8080)
이 세 가지 중 하나라도 다르면 다른 Origin. 매우 빈번하게 발생.
| Origin A | Origin B | CORS? |
|---|---|---|
http://localhost:3000 |
http://localhost:8080 |
✓ 발생 (포트) |
https://app.com |
https://api.app.com |
✓ 발생 (호스트) |
http://app.com |
https://app.com |
✓ 발생 (프로토콜) |
https://app.com/login |
https://app.com/api |
✗ 같은 Origin |
Preflight — OPTIONS 사전 요청
CORS의 가장 헷갈리는 부분 — Preflight 요청. 브라우저가 "진짜 요청 보내기 전에 OPTIONS로 미리 물어봐" 하는 절차.
1. 브라우저 → 백엔드: OPTIONS /api/orders (사전 질문)
"POST 보내도 되나요? Content-Type=application/json 헤더 박아도 되나요?"
2. 백엔드 → 브라우저: 200 OK + 허용 헤더 응답
"Access-Control-Allow-Origin: *"
"Access-Control-Allow-Methods: POST, GET, PUT, DELETE"
3. 브라우저 → 백엔드: POST /api/orders (진짜 요청)
4. 백엔드 → 브라우저: 정상 응답
OPTIONS 요청은 "단순 요청" 이 아닐 때 (POST + JSON, PUT, DELETE, 커스텀 헤더 등) 자동 발생. GET 단순 요청은 Preflight 없이 바로 진행.
Spring Boot에서 CORS 설정 3가지 방식
1. @CrossOrigin — 컨트롤러별
가장 단순. 컨트롤러 메서드 또는 클래스에 박기.
@RestController
@RequestMapping("/api/orders")
@CrossOrigin(origins = "http://localhost:3000") // 클래스 레벨
public class OrderController {
@GetMapping("/{id}")
@CrossOrigin(origins = "*") // 메서드 레벨 (이 메서드만)
public Order get(@PathVariable Long id) { ... }
}
옵션:
@CrossOrigin(
origins = {"http://localhost:3000", "https://app.myshop.com"},
methods = {RequestMethod.GET, RequestMethod.POST},
allowedHeaders = "*",
maxAge = 3600 // Preflight 결과 캐싱 시간
)
2. WebMvcConfigurer — 전역 설정 (권장)
모든 컨트롤러에 한 번에 적용. 회사 시스템 표준.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000", "https://app.myshop.com")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
/api/** 경로 전체에 한 줄 설정. 새 컨트롤러를 추가해도 자동 적용.
3. CorsFilter Bean — Spring Security와 함께
Spring Security가 박혀 있으면 위 두 방식이 잘 안 먹혀요. SecurityFilterChain보다 먼저 동작하는 CorsFilter 박기.
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("*"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
Spring Security 박힌 회사 시스템 표준.
자주 만나는 CORS 함정 4가지
1. allowedOrigins("*") + allowCredentials(true) 충돌
쿠키·토큰 같은 인증 정보를 보내려면 allowCredentials(true) 필수. 근데 이 옵션이 켜져 있으면 — "*" 와일드카드 사용 불가. 정확한 도메인 명시해야.
// ❌ 안 됨
.allowedOrigins("*").allowCredentials(true)
// ✓ 됨
.allowedOrigins("http://localhost:3000").allowCredentials(true)
2. 여러 환경 도메인 자동 매칭
개발·스테이징·운영 도메인이 다 다를 때 — allowedOriginPatterns 가 답.
.allowedOriginPatterns(
"http://localhost:[*]", // 모든 localhost 포트
"https://*.myshop.com" // 모든 서브도메인
)
.allowCredentials(true)
allowedOrigins 와 달리 allowedOriginPatterns 는 * 와일드카드를 — allowCredentials(true) 와도 함께 사용 OK.
3. 프록시·로드밸런서 환경
서비스 앞에 nginx·CloudFront 같은 프록시가 있으면 — CORS 헤더를 프록시에서 박는 게 표준. Spring에서 박으면 이중 처리·충돌 가능.
4. preflight 차단
OPTIONS 요청에 응답 안 하면 — 모든 진짜 요청이 차단. Spring Boot 기본 설정으로 거의 자동 처리되지만, 가끔 Security 필터가 OPTIONS를 막아 — 명시적 허용 필요.
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // Preflight 허용
.anyRequest().authenticated());
개발 = allowedOriginPatterns("http://localhost:[*]"). 운영 = 정확한 도메인 명시 + allowCredentials(true). 프록시 환경 = nginx에서 처리. Spring Security 박혔으면 CorsFilter Bean 우선.
한 줄 정리 — CORS = 브라우저 보안 정책. 백엔드는 "허용 헤더" 응답으로 풀어줘야. Spring Boot = WebMvcConfigurer.addCorsMappings 또는 CorsFilter Bean 전역 설정.
시험 직전 한 번 더 — CORS 입문자가 매번 헷갈리는 것
- CORS = Cross-Origin Resource Sharing
- 브라우저 보안 정책 — 백엔드 거부 아님
- Origin 정의 = 프로토콜 + 호스트 + 포트
- 셋 중 하나라도 다르면 다른 Origin
- 가장 흔한 시나리오 =
localhost:3000(프론트) ↔localhost:8080(백엔드) - Preflight = OPTIONS 사전 요청 (POST·PUT·DELETE·커스텀 헤더 시)
- GET 단순 요청 = Preflight 없이 직행
- Spring Boot 설정 3가지 =
@CrossOrigin/WebMvcConfigurer/CorsFilter - 권장 =
WebMvcConfigurer.addCorsMappings전역 설정 - Spring Security 사용 시 =
CorsFilterBean 추가 allowedOrigins("*")+allowCredentials(true)충돌- 해결 =
allowedOriginPatterns사용 (와일드카드 OK) - 쿠키·토큰 전송 =
allowCredentials(true)필수 maxAge= Preflight 결과 캐싱 시간 (브라우저)- 프록시·CDN 환경 = 프록시에서 CORS 처리 표준
- Spring Security가 OPTIONS 차단 시 =
permitAll()명시 - Origin ≠ Referer — Origin은 프로토콜+호스트+포트만
- 응답 헤더 =
Access-Control-Allow-Origin·-Methods·-Headers·-Credentials - 한국 회사 표준 = 개발 와일드카드 + 운영 정확 도메인
- 모바일 앱 = CORS 없음 (브라우저 정책이라)
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 27편 — @Controller @RequestMapping
- 28편 — @RestController와 JSON 응답
- 29편 — RequestParam PathVariable RequestBody
- 30편 — ArgumentResolver와 @LoginUser
- 31편 — 파일 업로드 @RequestPart MultipartFile
다음 글: