CATEGORY

카테고리 (612)
AI (17)
Language & Specs (259)
FrameWork (34)
Library (19)
App (41)
Git (8)
Build & Dependency (2)
AWS (15)
DataBase (45)
OS (33)
Tool (16)
IT (120)
반응형
SEEMINGLY ONLINE

Seemingly
Online

이모저모 방방곡곡 두루두루 개발지식 저장소

RECENT POSTS

FrameWork/Spring

Spring Boot CORS 설정 — 에러 해결과 안 먹히는 원인 3가지

반응형

Spring Boot에서 CORS 에러가 나는 이유는 서버가 응답에 Access-Control-Allow-Origin 헤더를 붙여 주지 않기 때문입니다. 해결은 WebMvcConfigurer.addCorsMappings()로 전역 설정하거나 @CrossOrigin을 붙이면 됩니다. 그런데 "설정했는데도 안 된다"는 경우가 많고, 원인은 대개 셋 중 하나입니다: ① 기본 허용 메서드가 GET·HEAD·POST뿐이라 PUT·DELETE가 막히고, ② allowedOrigins("*")allowCredentials(true)를 같이 쓰면 500 에러가 나고, ③ Spring Security를 쓰면 CORS를 따로 걸어 줘야 합니다. 아래 내용은 Spring Boot 3.3.5(Spring Framework 6.1.14, Java 21)로 앱을 띄워 응답 헤더를 직접 확인한 결과입니다.

CORS 에러는 왜 나나

브라우저는 다른 출처(origin = 프로토콜 + 호스트 + 포트)로 보내는 요청을 막습니다. 프런트가 http://localhost:3000, 백엔드가 http://localhost:8080이면 포트가 달라 다른 출처입니다. 이때 서버가 "이 출처는 허용한다"는 뜻으로 Access-Control-Allow-Origin 헤더를 돌려줘야 브라우저가 응답을 넘겨줍니다.

💡 CORS는 브라우저가 막는 것입니다. 그래서 Postman이나 curl로는 잘 되는데 브라우저에서만 실패하는 일이 흔합니다. 서버 코드가 아니라 응답 헤더를 봐야 합니다.

1. 전역 설정 — WebMvcConfigurer.addCorsMappings

가장 많이 쓰는 방식입니다. WebMvcConfigurer를 구현하고 addCorsMappings()를 오버라이드합니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:3000");
    }
}

이 설정으로 앱을 띄우고 실제로 요청해 본 결과입니다.

# 허용한 출처 → 200 + 허용 헤더가 붙는다
$ curl -i -H "Origin: http://localhost:3000" http://localhost:8080/api/hello
HTTP/1.1 200
Vary: Origin
Access-Control-Allow-Origin: http://localhost:3000

# 허용하지 않은 출처 → 403 (스프링이 서버에서 막는다)
$ curl -i -H "Origin: http://evil.com" http://localhost:8080/api/hello
HTTP/1.1 403

# Origin 헤더가 없으면(같은 출처·서버 간 호출) 그냥 200
$ curl -i http://localhost:8080/api/hello
HTTP/1.1 200

허용하지 않은 출처는 헤더만 빠지는 게 아니라 403이 떨어집니다. 로그에 403이 찍히면 CORS 설정을 의심해 보세요.

2. 함정 ① — 기본 허용 메서드는 GET·HEAD·POST뿐

가장 자주 걸리는 함정입니다. allowedMethods()를 생략하면 기본값은 GET, HEAD, POST입니다. PUT이나 DELETE는 설정을 했더라도 preflight에서 막힙니다.

# POST preflight → 200, 허용 메서드가 GET,HEAD,POST로 나온다
$ curl -i -X OPTIONS -H "Origin: http://localhost:3000" \
       -H "Access-Control-Request-Method: POST" http://localhost:8080/api/hello
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET,HEAD,POST
Access-Control-Max-Age: 1800

# DELETE preflight → 403 (기본 허용 목록에 없다)
$ curl -i -X OPTIONS -H "Origin: http://localhost:3000" \
       -H "Access-Control-Request-Method: DELETE" http://localhost:8080/api/hello
HTTP/1.1 403

해결은 간단합니다. 쓰는 메서드를 명시하면 됩니다.

registry.addMapping("/api/**")
        .allowedOrigins("http://localhost:3000")
        .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS");

3. 함정 ② — allowedOrigins("*") + allowCredentials(true)는 500

쿠키나 인증 헤더를 같이 보내려고 allowCredentials(true)를 켜면서, 편하려고 allowedOrigins("*")를 그대로 두는 경우입니다. 이 조합은 요청 시점에 예외가 나며 500이 떨어집니다.

HTTP/1.1 500

java.lang.IllegalArgumentException: When allowCredentials is true,
allowedOrigins cannot contain the special value "*" since that cannot be set
on the "Access-Control-Allow-Origin" response header. To allow credentials to
a set of origins, list them explicitly or consider using
"allowedOriginPatterns" instead.

인증정보를 특정 출처에만 열어야 안전하기 때문에 스프링이 막는 것입니다. 출처를 하나씩 명시하거나, 패턴이 필요하면 allowedOriginPatterns()를 씁니다.

registry.addMapping("/api/**")
        .allowedOriginPatterns("*")            // allowedOrigins 아님
        .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
        .allowCredentials(true)
        .maxAge(3600);

바꾼 뒤 실행 결과입니다. 요청한 출처가 그대로 되돌아오고 Access-Control-Allow-Credentials가 붙습니다.

HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true

# DELETE preflight도 이제 통과
HTTP/1.1 200
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Max-Age: 3600
⚠️ allowedOriginPatterns("*")는 사실상 모든 출처에 인증정보를 허용하는 설정입니다. 로컬 개발엔 편하지만 운영에서는 실제 도메인을 명시하세요.

4. 함정 ③ — Spring Security를 쓰면 따로 걸어야 한다

Spring Security가 붙으면 시큐리티 필터가 MVC보다 앞에 있습니다. preflight 요청(OPTIONS)에는 쿠키가 실리지 않아 시큐리티가 "인증 안 된 요청"으로 보고 먼저 거절해 버립니다. 그래서 CORS를 시큐리티 쪽에도 알려 줘야 합니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults());   // CorsConfigurationSource 빈을 사용
        return http.build();
    }

    @Bean
    UrlBasedCorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:3000"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

CorsConfigurationSource 빈이 있으면 http.cors(Customizer.withDefaults())가 이를 자동으로 집어 씁니다. Spring MVC 쪽에 이미 CORS를 설정해 뒀다면 그 설정을 그대로 쓰기도 합니다.

5. @CrossOrigin — 엔드포인트 단위

컨트롤러나 메서드에 직접 붙이는 방식입니다. 간단하지만 붙인 곳에만 적용된다는 점을 조심해야 합니다.

@CrossOrigin(origins = "http://localhost:3000")
@GetMapping("/api/one")
public String one() { return "one"; }

@GetMapping("/api/none")     // 어노테이션 없음
public String none() { return "none"; }

실제로 같은 앱에서 두 엔드포인트를 호출해 보면 차이가 분명합니다.

# @CrossOrigin 붙은 쪽 → 헤더가 붙는다
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:3000

# 안 붙은 쪽 → 200이지만 CORS 헤더가 없어 브라우저가 막는다
HTTP/1.1 200

엔드포인트가 늘어날수록 빠뜨리기 쉬우니, 전역 설정을 기본으로 두고 예외적인 곳에만 @CrossOrigin을 쓰는 편이 안전합니다.

참고로 파일 업로드 API에서 비동기 처리를 함께 쓴다면 Spring @Async MultipartFile NoSuchFileException 문제도 자주 겹치니 같이 보면 좋습니다.

자주 묻는 질문 (FAQ)

Q. Postman에서는 되는데 브라우저에서만 CORS 에러가 납니다.
정상입니다. CORS는 브라우저가 강제하는 규칙이라 Postman·curl에는 적용되지 않습니다. 서버 응답에 Access-Control-Allow-Origin이 붙는지 curl -i로 확인하세요.

Q. GET은 되는데 PUT·DELETE만 막힙니다.
allowedMethods()를 생략해 기본값(GET·HEAD·POST)이 적용된 경우입니다. 쓰는 메서드를 명시하면 해결됩니다.

Q. 설정을 다 했는데도 preflight에서 401·403이 납니다.
Spring Security가 붙어 있을 가능성이 큽니다. 위 4번처럼 http.cors(...)CorsConfigurationSource 빈을 함께 설정하세요.

Q. maxAge는 뭔가요?
preflight 결과를 브라우저가 캐시하는 시간(초)입니다. 생략하면 기본 1800초(30분)이며, 응답의 Access-Control-Max-Age로 확인할 수 있습니다.

마무리

정리하면 Spring Boot CORS 설정은 addCorsMappings()로 전역 설정이 기본이고, 안 먹힐 때는 ① 허용 메서드를 명시했는지, ② allowedOrigins("*")allowCredentials(true)를 같이 쓰지 않았는지, ③ Spring Security 쪽 설정을 빠뜨리지 않았는지 이 셋을 순서대로 확인하면 됩니다. 헷갈릴 때는 curl -i -H "Origin: ..."로 응답 헤더를 직접 보는 게 가장 빠릅니다. 위 결과는 모두 Spring Boot 3.3.5에서 재현해 확인한 것입니다.


📚 참고 출처 (2026년 7월 21일 확인)
· Spring Framework — CORS (Web MVC)
· Spring Security — CORS
· 동작은 Spring Boot 3.3.5 / Spring Framework 6.1.14 / Java 21에서 직접 실행해 응답 헤더 확인

반응형

COMMENTS