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

수요일

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이 가장 명확하고 안정적입니다.

금요일

Spring @Transactional self-invocation 완벽 해결 — 내부 메서드 호출 시 트랜잭션이 무시되는 이유

🔍 검색 키워드: Spring Transactional self-invocation, 스프링 자기 호출 트랜잭션 문제, @Transactional 내부 메서드 호출 무시, Spring AOP self-call, 스프링 트랜잭션 프록시 우회

증상: @Transactional인데 트랜잭션이 적용 안 된다

같은 클래스 내부에서 다른 메서드를 호출할 때 @Transactional이 무시되고, DB 에러가 발생해도 롤백이 되지 않는 현상이 발생한다.

@Service
public class OrderService {

    public void processOrder(Long orderId) {
        saveOrder(orderId);   // 트랜잭션이 적용되지 않음!
    }

    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
        throw new RuntimeException("에러 발생");  // 롤백 안 됨!
    }
}

원인: Spring AOP 프록시 메커니즘

Spring의 @Transactional은 AOP 프록시 기반으로 동작한다. 외부에서 빈을 호출하면 프록시 객체를 거쳐 트랜잭션 처리가 이루어지지만, 같은 클래스 내에서 this.saveOrder()로 직접 호출하면 프록시를 우회하여 실제 객체를 직접 호출하게 된다. 결과적으로 트랜잭션이 시작되지 않는다.

// 외부 호출: 프록시 → 실제 메서드 (트랜잭션 O)
orderService.saveOrder(id);

// 내부 self-invocation: 실제 객체.saveOrder() (트랜잭션 X)
this.saveOrder(id);

해결 방법 1 (권장): 클래스 분리

가장 깔끔한 해결책이다. 트랜잭션이 필요한 메서드를 별도 클래스로 분리하면 외부 호출이 되어 프록시가 정상 동작한다.

@Service
public class OrderService {
    private final OrderSaveService orderSaveService;

    public OrderService(OrderSaveService orderSaveService) {
        this.orderSaveService = orderSaveService;
    }

    public void processOrder(Long orderId) {
        orderSaveService.saveOrder(orderId);  // 프록시 통과 → 트랜잭션 O
    }
}

@Service
public class OrderSaveService {
    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

해결 방법 2: ApplicationContext에서 자기 자신 빈 주입

@Service
public class OrderService {
    @Autowired
    private ApplicationContext applicationContext;

    public void processOrder(Long orderId) {
        OrderService self = applicationContext.getBean(OrderService.class);
        self.saveOrder(orderId);  // 프록시 통과
    }

    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

해결 방법 3: @Lazy Self-injection

@Service
public class OrderService {
    @Autowired
    @Lazy
    private OrderService self;

    public void processOrder(Long orderId) {
        self.saveOrder(orderId);  // 프록시 통과
    }

    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

Spring Boot 2.6 이상에서는 순환 참조가 기본으로 금지되므로 spring.main.allow-circular-references=true 설정이 필요하다. 권장하지 않는다.

해결 방법 비교표

방법권장도비고
클래스 분리★★★★★가장 깔끔, 단일 책임 원칙 준수
ApplicationContext 주입★★★코드는 다소 복잡해짐
@Lazy Self-injection★★순환 참조 이슈, 비권장
AspectJ 컴파일 타임 위빙★★★빌드 설정 복잡

Spring @Transactional self-invocation 문제는 AOP 프록시 원리를 이해하면 자연스럽게 해결 방향이 보인다. 가장 좋은 방법은 처음부터 단일 책임 원칙을 지켜 클래스를 적절히 분리하는 것이다. 이 문제는 Spring을 사용하는 개발자라면 반드시 숙지해야 할 AOP의 핵심 동작 방식이다.