레이블이 cors인 게시물을 표시합니다. 모든 게시물 표시
레이블이 cors인 게시물을 표시합니다. 모든 게시물 표시

수요일

Spring Security CORS 완벽 해결 — @CrossOrigin 설정해도 403이 사라지지 않는 이유

🔍 검색 키워드: Spring Security CORS 에러, Spring Boot CORS 설정, Access-Control-Allow-Origin 403, CORS preflight 차단, Spring Security WebMvcConfigurer CORS

증상 — 에러 메시지

프론트엔드에서 API를 호출하면 다음과 같은 CORS 에러가 콘솔에 출력됩니다:

Access to XMLHttpRequest at 'https://api.example.com/api/v1/users' from origin
'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

특히 @CrossOrigin 어노테이션이나 WebMvcConfigurer로 CORS를 설정했는데도 에러가 사라지지 않는 경우, Spring Security가 원인일 가능성이 높습니다.

원인

Spring Boot 프로젝트에 Spring Security가 추가되면 CORS 처리 순서가 달라집니다. Spring Security의 필터 체인이 Spring MVC의 CORS 처리보다 먼저 실행되기 때문에, @CrossOrigin 이나 WebMvcConfigurer를 통한 CORS 설정이 Security 레이어에서 막혀버립니다.

Preflight 요청(OPTIONS)은 인증 헤더가 없기 때문에 Spring Security가 401/403으로 거부하고, 브라우저는 CORS 에러로 인식합니다. 즉, CORS 설정이 잘못된 게 아니라 Security 필터가 CORS 처리 전에 먼저 요청을 차단하는 것이 핵심 원인입니다.

해결방법

방법 1: SecurityFilterChain에 CORS 직접 등록 (Spring Boot 3.x 권장)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
                http
                            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                                        .csrf(csrf -> csrf.disable())
                                                    .authorizeHttpRequests(auth -> auth
                                                                    .requestMatchers("/api/public/**").permitAll()
                                                                                    .anyRequest().authenticated()
                                                                                                );
                                                                                                        return http.build();
                                                                                                            }
                                                                                                            
                                                                                                                @Bean
                                                                                                                    public CorsConfigurationSource corsConfigurationSource() {
                                                                                                                            CorsConfiguration config = new CorsConfiguration();
                                                                                                                                    config.setAllowedOrigins(List.of("https://app.example.com"));
                                                                                                                                            config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
                                                                                                                                                    config.setAllowedHeaders(List.of("*"));
                                                                                                                                                            config.setAllowCredentials(true);
                                                                                                                                                                    config.setMaxAge(3600L);
                                                                                                                                                                    
                                                                                                                                                                            UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
                                                                                                                                                                                    source.registerCorsConfiguration("/**", config);
                                                                                                                                                                                            return source;
                                                                                                                                                                                                }
                                                                                                                                                                                                }

방법 2: WebMvcConfigurer와 Security 통합

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
        public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")
                            .allowedOrigins("https://app.example.com")
                                        .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                                                    .allowedHeaders("*")
                                                                .allowCredentials(true)
                                                                            .maxAge(3600);
                                                                                }
                                                                                }
                                                                                
                                                                                // Security 설정에서 반드시 cors() 활성화
                                                                                @Bean
                                                                                public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
                                                                                    http
                                                                                            .cors(Customizer.withDefaults())  // WebMvcConfigurer 설정 사용
                                                                                                    .csrf(csrf -> csrf.disable())
                                                                                                            // ...
                                                                                                                return http.build();
                                                                                                                }

자주 하는 실수 3가지

1. Security 설정에 .cors() 없이 WebMvcConfigurer만 설정 — Security 필터가 OPTIONS 요청을 차단함

2. allowedOrigins에 trailing slash 포함 — "https://app.example.com/" 처럼 끝에 슬래시 금지

3. allowedMethods에 OPTIONS 누락 — Preflight 통과를 위해 OPTIONS 반드시 포함

원인별 정리표

증상원인해결
OPTIONS 요청 401/403Security가 Preflight 차단cors() 활성화 + permitAll
@CrossOrigin 무시됨Security 필터 우선 실행SecurityFilterChain에 cors() 추가
쿠키/세션 전송 안 됨allowCredentials 미설정setAllowCredentials(true)
특정 헤더만 에러allowedHeaders 미포함setAllowedHeaders(List.of("*"))
로컬에서만 동작origin 목록에 배포 URL 없음allowedOrigins에 배포 도메인 추가

핵심 정리

Spring Security + CORS 문제의 90%는 SecurityFilterChain에 .cors()를 추가하지 않아서 발생합니다. @CrossOrigin이나 WebMvcConfigurer만으로는 Security 레이어를 통과할 수 없습니다. Spring Boot 3.x 환경에서는 corsConfigurationSource Bean을 별도로 등록하는 방법 1이 가장 명확하고 안정적입니다.

목요일

CORS 에러 완전 정복 — 원인부터 해결까지 (초보자~실무자)

개발하다 보면 누구나 한 번쯤은 마주치는 그 빨간 에러.

Access to XMLHttpRequest at 'https://api.example.com/data' from origin 'http://localhost:3000'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

처음 보면 황당하다. 코드는 분명히 맞는데, 브라우저가 요청을 막는다. 이 글에서는 CORS가 왜 생기는지, 어떻게 해결하는지를 레벨별로 정리한다.


1. CORS가 뭔데?

CORS (Cross-Origin Resource Sharing) — 직역하면 "교차 출처 리소스 공유".

브라우저는 기본적으로 Same-Origin Policy(동일 출처 정책)를 따른다. 즉, http://localhost:3000에서 실행 중인 페이지가 https://api.example.com에 요청을 보내면, 출처(origin)가 다르기 때문에 브라우저가 막는다.

여기서 Origin = 프로토콜 + 도메인 + 포트 세 가지가 모두 같아야 동일 출처다.

비교동일 출처?
http://example.com vs https://example.com❌ 프로토콜 다름
http://example.com vs http://api.example.com❌ 도메인 다름
http://example.com:3000 vs http://example.com:8080❌ 포트 다름
http://example.com/a vs http://example.com/b✅ 동일 출처

2. 핵심 원리 — 브라우저가 하는 일

중요한 포인트: CORS는 서버가 막는 게 아니라 브라우저가 막는다.

서버는 이미 응답을 줬다. 하지만 브라우저가 그 응답에 Access-Control-Allow-Origin 헤더가 없으면 자바스크립트 코드에 넘겨주지 않고 차단한다. curl이나 Postman으로는 에러 없이 잘 되는 이유가 이것이다.

Preflight 요청

GET, POST 외의 메서드거나, 커스텀 헤더가 있거나, Content-Type이 application/json이면 브라우저는 실제 요청 전에 OPTIONS 메서드로 사전 확인 요청(Preflight)을 먼저 보낸다.


3. 해결 방법

🟢 초보자 — 로컬 개발 환경

프론트엔드 개발 서버 프록시 설정 (가장 흔한 방법)

CRA (package.json):

{
  "proxy": "https://api.example.com"
}

Vite (vite.config.ts):

export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

🟡 중급자 — 서버 사이드 CORS 헤더 설정

Node.js (Express):

const cors = require('cors');

app.use(cors({
  origin: ['https://myapp.com', 'https://www.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true
}));

Spring Boot (Java):

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://myapp.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

Nginx:

location /api/ {
    add_header 'Access-Control-Allow-Origin' 'https://myapp.com';
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
    add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Length' 0;
        return 204;
    }
    proxy_pass http://backend;
}

🔴 고급 — 자주 빠지는 함정들

① credentials: true인데 * 와일드카드 쓰면 안 된다

쿠키나 Authorization 헤더 포함 요청은 Access-Control-Allow-Origin: *이어도 브라우저가 차단한다. 정확한 출처를 명시해야 한다.

# 틀린 예
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

# 맞는 예
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true

② Preflight OPTIONS 요청 처리를 빠뜨리는 경우

수동으로 헤더를 추가할 때 OPTIONS 처리를 생략하는 실수가 많다. 204를 반환해야 본 요청이 들어온다.

③ 여러 서버에서 헤더가 중복으로 붙는 경우

Access-Control-Allow-Origin이 두 개 붙으면 브라우저가 에러를 낸다. 한 레이어에서만 처리해야 한다.

④ 동적 출처 허용

const allowedOrigins = ['https://myapp.com', 'https://app2.com'];
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }
  next();
});

4. 상황별 체크리스트

상황해결책
로컬 개발 중 API 서버 연결 안 됨프론트 dev 서버 프록시 설정
Postman은 되는데 브라우저만 에러서버에 CORS 헤더 추가
OPTIONS 요청이 405 에러Preflight 핸들러 추가
쿠키/JWT 포함 요청이 차단됨* 대신 정확한 origin + credentials: true
배포 후 갑자기 안 됨Nginx/로드밸런서 헤더 중복 확인
서드파티 API (서버 못 건드림)내 서버를 프록시로 우회

마무리

CORS는 처음엔 황당하지만 원리를 알면 단순하다. 브라우저가 보안 정책으로 막는 것이고, 서버가 헤더로 허용해줘야 풀린다. Postman은 되는데 브라우저에서만 안 된다면 거의 무조건 CORS 문제다.

운영 환경에서는 절대 * 와일드카드 쓰지 말고, 꼭 필요한 출처만 명시하는 것이 기본이다.