프론트엔드에서 API를 호출했는데 "CORS policy" 에러가 뜹니다 — 서버에서 응답은 잘 보냈는데 왜 브라우저가 막을까요?

CORS는 서버가 "아니오"라고 한 게 아닙니다. 브라우저가 사용자를 보호하기 위해 걸어놓은 안전장치입니다. 서버는 정상 응답을 보냈지만, 브라우저가 "이 응답을 JavaScript에 넘겨줘도 되는지" 확인하는 과정에서 차단하는 겁니다.


Same-Origin Policy — 모든 것의 시작

Origin(출처)은 프로토콜 + 호스트 + 포트의 조합입니다.

PLAINTEXT
https://example.com:443/path
└─프로토콜─┘ └─호스트─┘ └포트┘

같은 Origin:
  https://example.com/page1  ↔  https://example.com/page2  ✅

다른 Origin:
  https://example.com  ↔  http://example.com     ← 프로토콜 다름 ❌
  https://example.com  ↔  https://api.example.com ← 호스트 다름 ❌
  https://example.com  ↔  https://example.com:8080 ← 포트 다름 ❌

Same-Origin Policy(SOP) 는 브라우저가 다른 Origin으로부터 받은 응답의 데이터를 JavaScript에서 읽지 못하게 차단하는 보안 정책입니다.

왜 브라우저만 차단하는가?

  • 브라우저는 사용자의 쿠키, 세션, 인증 토큰을 자동으로 보냅니다
  • 악성 사이트가 이를 이용해 fetch('https://bank.com/transfer') 같은 요청을 보낼 수 있습니다
  • SOP는 응답을 차단하여 악성 사이트가 결과를 읽지 못하게 합니다
  • 서버 간 통신(RestTemplate, curl 등)은 사용자 쿠키가 자동으로 붙지 않으므로 CORS가 적용되지 않습니다

CORS — 제어된 교차 출처 허용

CORS(Cross-Origin Resource Sharing)는 SOP를 완화하는 메커니즘입니다. 서버가 "이 Origin의 요청은 허용한다"고 응답 헤더로 알려주면, 브라우저가 응답을 JavaScript에 전달합니다.


Simple Request vs Preflight Request

Simple Request (단순 요청)

다음 조건을 모두 만족하면 Preflight 없이 바로 요청이 전송됩니다:

  • 메서드: GET, HEAD, POST 중 하나
  • Content-Type: text/plain, multipart/form-data, application/x-www-form-urlencoded 중 하나
  • 커스텀 헤더 없음 (Accept, Accept-Language 등 기본 헤더만)
PLAINTEXT
// 1. 브라우저가 요청 전송
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com    ← 브라우저가 자동 추가

// 2. 서버 응답
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com

// 3. 브라우저가 Origin 확인 후 JavaScript에 응답 전달

Preflight Request (사전 요청)

Simple Request 조건에 맞지 않으면 브라우저가 먼저 OPTIONS 요청을 보냅니다.

PLAINTEXT
// 1. Preflight (브라우저가 자동으로 보냄)
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

// 2. 서버의 Preflight 응답
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600    ← 1시간 동안 Preflight 캐시

// 3. Preflight 통과 → 실제 요청 전송
PUT /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer eyJ...

application/json은 Simple Request 조건에 해당하지 않습니다. 그래서 대부분의 REST API 호출은 Preflight가 발생합니다.


CORS 관련 헤더 정리

응답 헤더 (서버 → 브라우저)

헤더역할
Access-Control-Allow-Origin허용할 Origin (또는 *)
Access-Control-Allow-Methods허용할 HTTP 메서드
Access-Control-Allow-Headers허용할 커스텀 헤더
Access-Control-Allow-Credentials쿠키/인증 헤더 허용 여부
Access-Control-Max-AgePreflight 캐시 시간 (초)
Access-Control-Expose-HeadersJS에서 읽을 수 있는 응답 헤더

요청 헤더 (브라우저가 자동 추가)

헤더역할
Origin요청을 보내는 Origin
Access-Control-Request-Method실제 요청의 메서드 (Preflight에서)
Access-Control-Request-Headers실제 요청의 커스텀 헤더 (Preflight에서)

Spring Boot에서 CORS 설정

방법 1: WebMvcConfigurer (전역 설정)

JAVA
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://app.example.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600); // Preflight 캐시 1시간
    }
}

방법 2: @CrossOrigin (컨트롤러/메서드 단위)

JAVA
@RestController
@CrossOrigin(origins = "https://app.example.com")
public class UserController {

    @GetMapping("/api/users")
    public List<User> getUsers() {
        return userService.findAll();
    }
}

방법 3: CorsFilter (필터 기반 — Spring Security와 함께 사용 시 권장)

JAVA
@Bean
public CorsFilter corsFilter() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);
    config.setMaxAge(3600L);

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

Spring Security를 사용할 때는 Security 필터 체인에서 CORS를 설정해야 합니다. http.cors()를 호출하지 않으면 Security가 CORS 응답을 가로챌 수 있습니다.


6가지 흔한 실수와 해결법

1. OPTIONS Preflight를 처리하지 않음

  • ** 증상 **: PUT, DELETE 같은 요청이 모두 실패
  • ** 원인 **: 서버가 OPTIONS 메서드를 처리하지 않아 404/405 반환
  • ** 해결 **: Spring Boot의 CORS 설정을 사용하면 자동으로 OPTIONS를 처리합니다

2. Preflight 응답에만 CORS 헤더를 넣고, 실제 응답에는 빠뜨림

  • ** 증상 **: Preflight는 통과하는데 실제 요청에서 CORS 에러
  • ** 원인 **: OPTIONS에만 Access-Control-Allow-Origin을 설정하고 실제 응답에는 넣지 않음
  • ** 해결 **: 모든 응답에 CORS 헤더가 포함되도록 필터 레벨에서 설정

3. 와일드카드(*)와 credentials 함께 사용

  • **증상 **: Access-Control-Allow-Credentials: true인데 Allow-Origin: *
  • **원인 **: 보안 위험 때문에 브라우저가 이 조합을 거부
  • ** 해결 **: 구체적인 Origin을 지정 (https://app.example.com)
JAVA
// 잘못된 설정
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true); // 브라우저가 거부

// 올바른 설정
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowCredentials(true);

4. Preflight가 비2xx 응답을 반환

  • **증상 **: 모든 교차 출처 요청이 실패
  • ** 원인 **: 인증 필터가 OPTIONS 요청까지 차단 (401 반환)
  • ** 해결 **: OPTIONS 요청은 인증 없이 통과시킴
JAVA
// Spring Security 설정
http.authorizeHttpRequests(auth -> auth
    .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
    // ...
);

5. Access-Control-Max-Age를 설정하지 않음

  • ** 증상 **: 성능 저하 (매 요청마다 Preflight 발생)
  • ** 원인 **: 캐시가 없으면 브라우저가 매번 OPTIONS를 보냄
  • ** 해결 **: maxAge(3600) 같이 적절한 캐시 시간 설정

6. CDN/로드밸런서가 CORS 헤더를 제거

  • ** 증상 **: 로컬에서는 되는데 프로덕션에서만 CORS 에러
  • ** 원인 **: CDN이나 리버스 프록시가 응답 헤더를 변경/제거
  • ** 해결 **: CDN 설정에서 CORS 헤더를 패스스루하도록 구성 (CloudFront: Origin 헤더를 캐시 키에 포함)

정리

  • CORS는 브라우저의 보안 정책(SOP)을 제어된 방식으로 완화하는 메커니즘
  • application/json을 쓰는 순간 Preflight가 발생 — 대부분의 REST API 호출이 해당
  • Spring Boot에서는 WebMvcConfigurer, @CrossOrigin, CorsFilter 세 가지 방법 제공
  • credentials와 와일드카드 *는 함께 쓸 수 없음 — 가장 흔한 실수
  • Preflight 캐시(Max-Age)를 설정하지 않으면 매 요청마다 OPTIONS가 추가로 발생
댓글 로딩 중...