본문 바로가기

Computer Science/NetWork

CORS 동작 원리와 Spring Boot 설정 방법

추천캐릭터 2026. 7. 3. 15:21
728x90

CORS 동작 원리와 Spring Boot 설정 방법

 

 

Postman은 되는데 브라우저만 안 될 때

프론트엔드와 API 서버를 분리해 개발하다 보면 반드시 한 번은 만나는 에러가 있다.

Access to fetch at 'https://api.kraft.io.kr/api/posts' from origin
'https://kraft.io.kr' has been blocked by CORS policy

이상한 점은 같은 API가 Postman이나 curl로는 멀쩡히 응답한다는 것이다. 여기에 CORS의 본질이 담겨 있다. CORS는 서버 장애나 방화벽이 아니라 브라우저가 스스로 지키는 보안 정책이다. 서버는 정상 동작 중이고, 브라우저가 "이 응답을 저 스크립트에게 보여줘도 된다"는 허락 표시를 찾지 못해 차단했을 뿐이다.

가령 Kraft처럼 화면과 API 서버를 서로 다른 오리진으로 나누는 순간, 모든 API 호출이 이 정책의 심사 대상이 된다. 이 글에서는 그 심사가 어떻게 이뤄지는지, Spring Boot에서는 무엇을 설정해야 하는지 정리한다.

출발점: Same-Origin Policy와 오리진

브라우저는 기본적으로 같은 오리진(origin) 사이의 요청만 자유롭게 허용한다(Same-Origin Policy). 오리진은 스킴 + 호스트 + 포트 세 가지의 조합이며, 하나라도 다르면 다른 오리진이다.

  • https://kraft.io.kr vs https://api.kraft.io.kr → 호스트가 다름 → 교차 오리진
  • http://localhost:3000 vs http://localhost:8080 → 포트가 다름 → 교차 오리진 (로컬 개발에서 CORS를 처음 만나는 이유)

CORS(Cross-Origin Resource Sharing)는 이 제한을 서버가 허락한 범위 안에서 풀어주는 표준이다. 즉 "누구에게 열어줄지"를 정하는 주체는 서버이고, 그 결정을 집행하는 주체는 브라우저다.

프리플라이트: 본 요청 전에 허락부터 받는다

CORS 요청은 두 갈래로 나뉜다. GET이나 단순한 폼 전송처럼 조건이 까다롭지 않은 단순 요청(simple request)은 바로 전송되고 응답 헤더만 검사받는다. 반면 Content-Type: application/json인 POST나 Authorization 헤더가 붙은 요청 — 사실상 요즘 API 호출의 대부분 — 은 브라우저가 본 요청 전에 OPTIONS 메서드로 사전 확인(preflight)을 먼저 보낸다.

OPTIONS /api/posts HTTP/1.1
Origin: https://kraft.io.kr
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

서버가 허락한다면 이렇게 응답한다.

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://kraft.io.kr
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 3600

이 허락이 떨어져야 브라우저가 실제 POST를 전송한다. 개발자 도구 네트워크 탭에서 API 하나에 OPTIONS 요청이 먼저 찍히는 게 바로 이 과정이다. CORS 에러를 디버깅할 때는 본 요청이 아니라 이 OPTIONS 응답의 상태 코드와 헤더부터 확인하는 것이 지름길이다.

Spring Boot 전역 설정

컨트롤러마다 @CrossOrigin을 붙일 수도 있지만, 설정이 흩어지면 관리가 어려워진다. WebMvcConfigurer로 전역에서 한 번에 선언하는 편이 낫다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://kraft.io.kr")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("Authorization", "Content-Type")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

maxAge는 프리플라이트 결과를 브라우저가 캐시하는 시간이다. 지정해 두면 요청마다 OPTIONS 왕복이 반복되는 것을 줄일 수 있다.

Spring Security를 쓰고 있다면 한 단계가 더 필요하다. 프리플라이트 OPTIONS에는 인증 정보가 실리지 않기 때문에, 시큐리티 필터가 먼저 401로 끊어버리면 MVC의 CORS 설정까지 도달하지 못한다. CorsConfigurationSource 빈을 등록하고 필터 체인에 알려주자.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.cors(Customizer.withDefaults());
    // ... 나머지 보안 설정
    return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://kraft.io.kr"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setAllowCredentials(true);
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

"MVC에는 설정했는데 프리플라이트가 401/403으로 떨어진다"면 십중팔구 이 지점이다.

실무에서 자주 걸리는 지점

자격 증명과 와일드카드는 함께 못 쓴다. 쿠키나 인증 헤더를 주고받는 allowCredentials(true) 상태에서는 allowedOrigins("*")가 스펙상 금지이며, Spring도 예외를 던진다. 여러 오리진을 패턴으로 허용해야 한다면 allowedOriginPatterns("https://*.kraft.io.kr")를 사용한다.

오리진 표기는 정확히. 허용 오리진에는 경로나 끝의 슬래시를 쓰지 않는다. https://kraft.io.kr/처럼 슬래시 하나만 붙어도 다른 값으로 취급된다.

CORS는 보안 장치가 아니다. 이 정책은 브라우저 사용자만 보호한다. 서버 간 호출이나 curl에는 아예 적용되지 않으므로, 인증·인가는 CORS와 별개로 갖춰야 한다.

마무리

CORS 에러는 서버가 고장 났다는 뜻이 아니라 "허락 헤더가 없다"는 브라우저의 신호다. SOP → 프리플라이트 → 허용 헤더 응답이라는 흐름만 잡고 있으면 대부분 설정 몇 줄로 해결된다. 그리고 CORS는 자체 API 서버에서 끝나지 않는다. 브라우저가 S3 같은 외부 스토리지에 직접 업로드하는 구조에서도 똑같이 등장하는데, 다음 글(S3 Presigned URL 업로드)에서 이어서 다룬다.

728x90