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

수요일

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의 핵심 동작 방식이다.

일요일

BeanCurrentlyInCreationException 해결 — Spring Boot 순환 의존성 에러 완전 정복

🔍 검색 키워드: BeanCurrentlyInCreationException 해결, Spring Boot 순환 의존성, circular dependency 에러, Spring Boot 빈 생성 실패, allow-circular-references, Spring Boot 시작 실패

Spring Boot 프로젝트를 띄우다가 서버가 뜨지도 않고 이런 에러를 만난 적 있을 것이다.

The dependencies of some of the beans in the application context form a cycle:
userService -> orderService -> userService

혹은 더 직접적으로:

org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'userService':
Requested bean is currently in creation: Is there an unresolvable circular reference?

서버 자체가 뜨지 않으니 당황스럽다. 이 글에서는 원인부터 실무에서 자주 쓰는 해결책까지 정리한다.

순환 의존성이란 뭔가

ServiceA가 ServiceB를 주입받고, ServiceB가 다시 ServiceA를 주입받는 상황이다. Spring이 A를 만들려면 B가 필요하고, B를 만들려면 A가 필요하다. 닭이 먼저냐 달걀이 먼저냐 — Spring은 이 상황에서 예외를 던진다.

@Service
public class UserService {
    private final OrderService orderService;

    public UserService(OrderService orderService) { // OrderService 주입
        this.orderService = orderService;
    }
}

@Service
public class OrderService {
    private final UserService userService;

    public OrderService(UserService userService) { // UserService 주입
        this.userService = userService;
    }
}

위처럼 생성자 주입(Constructor Injection)으로 서로를 참조하면 Spring Boot 2.6 이후부터는 기본적으로 예외가 발생한다.

왜 Spring Boot 2.6부터 더 자주 보이나

Spring Boot 2.6에서 기본 순환 의존성 감지가 강화됐다. 이전 버전에서는 필드 주입(@Autowired)의 경우 특별한 설정 없이도 넘어가는 경우가 있었는데, 2.6부터는 훨씬 엄격하게 잡아낸다. 기존에 잘 돌아가던 프로젝트를 Spring Boot 버전 올리다가 갑자기 이 에러를 만나는 이유다.

에러 발생 상황 체크리스트

상황가능성
Service 간 서로 참조★★★★★
Service → Repository → Service 체인★★★★☆
@Configuration 클래스 간 참조★★★☆☆
Spring Boot 버전 업그레이드 직후★★★★☆
이벤트 리스너와 서비스 간 참조★★★☆☆

해결 방법 1 — @Lazy 어노테이션 (빠른 임시 해결)

둘 중 하나의 의존성에 @Lazy를 붙인다. Spring이 실제로 해당 빈이 필요한 시점까지 초기화를 미룬다.

@Service
public class UserService {
    private final OrderService orderService;

    public UserService(@Lazy OrderService orderService) { // @Lazy 추가
        this.orderService = orderService;
    }
}

당장 급하게 해결해야 할 때 쓰는 방법이다. 하지만 근본적인 구조 문제를 가리는 것이라 장기적으로는 좋지 않다.

해결 방법 2 — 세터/필드 주입으로 전환

생성자 주입 대신 세터 주입(Setter Injection)으로 바꾸면 Spring이 빈을 먼저 만든 후 의존성을 주입하기 때문에 순환 참조 문제가 해소된다.

@Service
public class OrderService {
    private UserService userService;

    @Autowired
    public void setUserService(UserService userService) {
        this.userService = userService;
    }
}

해결 방법 3 — 구조 리팩토링 (가장 올바른 해결)

순환 의존성이 생겼다는 건 클래스의 책임이 잘못 분리됐다는 신호다. 보통 아래 패턴 중 하나로 해결된다.

공통 로직을 별도 서비스로 분리:

// UserService와 OrderService 모두 참조하던 공통 로직
@Service
public class UserOrderBridgeService {
    private final UserRepository userRepository;
    private final OrderRepository orderRepository;

    public void processUserOrder(Long userId, Long orderId) {
        // 공통 로직 처리
    }
}

@Service
public class UserService {
    private final UserRepository userRepository;
    private final UserOrderBridgeService bridgeService; // 공통 서비스만 참조
}

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final UserOrderBridgeService bridgeService;
}

이벤트 기반으로 분리:

@Service
public class UserService {
    private final ApplicationEventPublisher eventPublisher;

    public void deleteUser(Long userId) {
        eventPublisher.publishEvent(new UserDeletedEvent(userId));
    }
}

@Component
public class OrderEventListener {
    private final OrderService orderService;

    @EventListener
    public void handleUserDeleted(UserDeletedEvent event) {
        orderService.cancelOrdersByUserId(event.getUserId());
    }
}

해결 방법 4 — application.properties 설정 (비추천)

spring.main.allow-circular-references=true

이 설정은 에러만 안 보이게 하는 것이다. 실제로는 Spring Boot가 내부적으로 의존성 순서를 휴리스틱하게 결정하게 되어, 예측하기 어려운 초기화 순서 문제가 생길 수 있다. 근본 원인을 분석할 시간이 없는 긴급 상황에서 임시로만 쓰자.

정리

  • BeanCurrentlyInCreationException은 두 빈이 서로 의존하는 구조에서 발생한다
  • Spring Boot 2.6+에서 기본적으로 더 엄격하게 감지한다
  • 빠른 해결은 @Lazy, 올바른 해결은 공통 로직 분리 또는 이벤트 기반 구조 변경
  • allow-circular-references=true는 임시방편일 뿐, 운영 환경에서는 근본 해결 필요

순환 의존성 에러는 코드 냄새(Code Smell)다. 에러를 꺼주는 것보다 왜 두 서비스가 서로를 참조해야 하는지를 먼저 물어보는 게 맞다.

목요일

Spring Boot UnexpectedRollbackException 완전 해결 가이드

🔍 검색 키워드: Spring Boot UnexpectedRollbackException, 트랜잭션 롤백 에러, Spring @Transactional 에러, rollback-only 에러, Spring 트랜잭션 전파

이 에러, 왜 뜨는 건가

운영 중에 갑자기 이런 에러를 마주하는 경우가 있다.

org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only

겉으로 보면 "조용히 롤백됐다"는 건데, 왜 롤백됐는지 이유가 안 보인다. 내 코드엔 예외처리도 했고, try-catch도 했는데 왜?

원인: Spring 트랜잭션 전파(Propagation) 구조 이해

핵심 개념

Spring의 기본 트랜잭션 전파 방식은 REQUIRED다. 즉, 이미 트랜잭션이 있으면 그 트랜잭션에 참여(join)한다.

문제는 여기서 발생한다.

[외부 트랜잭션 시작]
  └── 내부 서비스 호출 (REQUIRED → 외부 트랜잭션에 참여)
        └── 내부에서 예외 발생 → 트랜잭션에 rollback-only 마킹
  내부 예외를 try-catch로 잡음
  외부 트랜잭션 커밋 시도
      → BOOM: UnexpectedRollbackException

내부 메서드에서 예외가 발생해서 rollback-only로 마킹됐는데, 외부에서 예외를 잡아버리면 Spring은 "아 괜찮은 거구나"하고 커밋을 시도한다. 하지만 이미 롤백 마킹이 됐기 때문에 UnexpectedRollbackException이 터진다.

레벨별 해결 방법

Level 1 — 기초 (원인 파악부터)

상황 재현 코드:

@Service
@RequiredArgsConstructor
public class OrderService {
    private final PaymentService paymentService;

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 주문 저장 로직...

        try {
            paymentService.processPayment(request.getPaymentInfo()); // 내부 트랜잭션 참여
        } catch (Exception e) {
            log.error("결제 실패: {}", e.getMessage()); // 예외 잡음
            // → 이 시점에 트랜잭션은 이미 rollback-only
        }

        // 커밋 시도 → UnexpectedRollbackException 발생!
    }
}

@Service
public class PaymentService {
    @Transactional // REQUIRED (기본값) → 외부 트랜잭션에 참여
    public void processPayment(PaymentInfo info) {
        // 내부 예외 발생
        throw new PaymentException("카드 한도 초과");
    }
}

트랜잭션 상태 디버깅:

@Transactional
public void placeOrder(OrderRequest request) {
    log.info("트랜잭션 활성: {}", TransactionSynchronizationManager.isActualTransactionActive());
    log.info("롤백 마킹: {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());
}

Level 2 — 실무 해결책 (전파 방식 변경)

방법 1: REQUIRES_NEW로 별도 트랜잭션 분리

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    // ↑ 기존 트랜잭션과 완전히 분리된 새 트랜잭션 시작
    public void processPayment(PaymentInfo info) {
        // 이 트랜잭션이 롤백돼도 외부 트랜잭션에 영향 없음
        paymentRepository.save(/* ... */);
    }
}
⚠️ 주의: REQUIRES_NEW는 별도 DB 커넥션을 사용한다. 커넥션 풀 고갈 위험이 있으니 남발하면 안 된다.

방법 2: noRollbackFor 설정

@Transactional(noRollbackFor = PaymentException.class)
public void processPayment(PaymentInfo info) {
    // PaymentException이 발생해도 롤백하지 않음
}

방법 3: 예외를 잡지 말고 던지기

@Transactional
public void placeOrder(OrderRequest request) {
    try {
        paymentService.processPayment(request.getPaymentInfo());
    } catch (PaymentException e) {
        // 잡지 말고 그냥 던진다
        throw e; // 혹은 새 예외로 래핑
        // → 외부 호출자가 트랜잭션 롤백 처리
    }
}

Level 3 — 고급 (아키텍처 관점에서 설계)

이벤트 기반 분리 (트랜잭션 완료 후 처리)

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void placeOrder(OrderRequest request) {
        Order order = orderRepository.save(Order.of(request));

        // 트랜잭션 커밋 후 이벤트 발행
        eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
    }
}

@Component
public class PaymentEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    // ↑ 외부 트랜잭션 커밋 후에 실행 → 트랜잭션 오염 없음
    public void handleOrderPlaced(OrderPlacedEvent event) {
        paymentService.processPayment(event.getOrderId());
    }
}

Facade 패턴으로 트랜잭션 경계 명확히

@Service
public class OrderFacade {
    // @Transactional 없음 → 트랜잭션 경계 없음

    public void placeOrder(OrderRequest request) {
        orderService.saveOrder(request);        // 각각 독립 트랜잭션
        paymentService.processPayment(request); // 각각 독립 트랜잭션
    }
}

@Service
public class OrderService {
    @Transactional // 이 메서드 범위만 트랜잭션
    public void saveOrder(OrderRequest request) { /* ... */ }
}

상황별 해결 체크리스트

상황권장 방법주의사항
내부 예외가 외부와 독립적으로 처리돼야 할 때REQUIRES_NEW커넥션 풀 사용량 증가
특정 예외는 롤백 안 해도 될 때noRollbackFor데이터 정합성 검토 필요
결제 같은 외부 I/O가 있을 때@TransactionalEventListenerAFTER_COMMIT 타이밍 주의
서비스 레이어 설계를 바꿀 수 있을 때Facade 패턴트랜잭션 경계 재설계 필요
빠르게 임시 수정이 필요할 때예외 재던지기호출부에서 처리 필요

자주 하는 실수

실수 1: try-catch에서 예외를 먹어버리기

// ❌ 잘못된 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    // 예외를 삼켜버림 → UnexpectedRollbackException 확정
}

// ✅ 올바른 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    throw new BusinessException("처리 실패", e); // 반드시 다시 던지기
}

실수 2: Self-invocation (같은 클래스 내 메서드 호출)

@Service
public class MyService {
    @Transactional
    public void outer() {
        inner(); // 프록시를 거치지 않음 → @Transactional 무시됨
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
    // → REQUIRES_NEW가 적용되지 않아서 여전히 같은 트랜잭션 사용
}

Self-invocation í•´ê²°:

@Service
@RequiredArgsConstructor
public class MyService {
    private final ApplicationContext context;

    public void outer() {
        MyService proxy = context.getBean(MyService.class); // 프록시 직접 가져오기
        proxy.inner();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
}

정리

UnexpectedRollbackException은 트랜잭션 전파를 모르면 반드시 한 번은 만나는 에러다. 핵심은 하나다.

💡 내부 트랜잭션이 롤백 마킹되면, 같은 트랜잭션에 참여한 외부 트랜잭션도 롤백될 수밖에 없다.

해결은 ‫글 두 방향이다.

  • 트랜잭션을 분리한다 (REQUIRES_NEW, Facade 패턴, 이벤트 기반)
  • 예외를 제대로 다룬다 (먹지 말고 던지기, noRollbackFor)

실무에서는 REQUIRES_NEW를 무분별하게 쓰기보다 @TransactionalEventListener나 Facade 패턴으로 설계 단계에서 트랜잭션 경계를 명확히 하는 게 장기적으로 낫다.