수요일

🔍 검색 키워드: Spring Boot @Transactional 작동 안 함, @Transactional self-invocation 에러, Spring AOP 프록시 문제, @Transactional 같은 클래스 호출, Spring 트랜잭션 무시

Spring Boot로 개발하다가 @Transactional을 붙였는데도 롤백이 안 되거나 트랜잭션 자체가 적용이 안 되는 상황을 겪는다. 코드를 아무리 봐도 문제가 없어 보이는데 예외가 발생해도 DB에 그대로 저장된다.

이 문제의 80%는 self-invocation(자기 자신을 직접 호출)이 원인이다.


증상

  • @Transactional을 붙였는데 예외 발생 후에도 DB가 롤백되지 않음
  • 같은 클래스 안에서 @Transactional 메서드를 호출할 때만 이 현상 발생
  • 다른 빈(Bean)에서 호출하면 정상적으로 동작함
  • 로그에 트랜잭션 관련 에러는 없고 조용히 문제가 생김

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

Spring의 @Transactional은 AOP 프록시로 동작한다. 외부에서 빈을 호출하면 프록시가 먼저 받아서 트랜잭션을 시작하고 실제 메서드를 호출한 뒤 트랜잭션을 종료한다.

외부 호출 → [Spring Proxy] → 트랜잭션 시작 → [실제 객체.method()] → 트랜잭션 종료
this.method() → [실제 객체.method()] (프록시 미통과, 트랜잭션 없음)
@Service
public class OrderService {

    public void createOrder(OrderDto dto) {
        // ... 주문 생성 로직
        this.sendNotification(dto); // ❌ self-invocation — 트랜잭션 적용 안 됨
    }

    @Transactional
    public void sendNotification(OrderDto dto) {
        // 이 메서드의 @Transactional은 위에서 직접 호출하면 무시됨
        notificationRepository.save(new Notification(dto));
    }
}

해결방법

방법 1: 별도 빈으로 분리 (가장 권장)

@Service
public class OrderService {

    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void createOrder(OrderDto dto) {
        // ... 주문 생성 로직
        notificationService.sendNotification(dto); // ✅ 외부 빈 호출 → 프록시 통과
    }
}

@Service
public class NotificationService {

    @Transactional
    public void sendNotification(OrderDto dto) {
        notificationRepository.save(new Notification(dto));
    }
}

방법 2: Self-Injection

@Service
public class OrderService {

    @Autowired
    @Lazy  // 순환 참조 방지
    private OrderService self;

    public void createOrder(OrderDto dto) {
        self.sendNotification(dto); // ✅ 프록시를 통해 호출
    }

    @Transactional
    public void sendNotification(OrderDto dto) {
        notificationRepository.save(new Notification(dto));
    }
}

방법 3: AopContext.currentProxy() 사용

@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)  // 필수 설정
public class Application { ... }

@Service
public class OrderService {

    public void createOrder(OrderDto dto) {
        ((OrderService) AopContext.currentProxy()).sendNotification(dto);
    }

    @Transactional
    public void sendNotification(OrderDto dto) {
        notificationRepository.save(new Notification(dto));
    }
}

@Transactional의 다른 흔한 함정들

함정 1: private 메서드에 @Transactional

// ❌ private 메서드는 프록시 오버라이딩 불가 — @Transactional 무시됨
@Transactional
private void updateStock(Long productId, int quantity) { ... }

// ✅ public으로 변경
@Transactional
public void updateStock(Long productId, int quantity) { ... }

함정 2: Checked Exception은 기본 롤백 안 됨

// ❌ IOException(Checked Exception)은 기본 롤백 대상이 아님
@Transactional
public void processFile(String path) throws IOException {
    fileRepository.save(new FileRecord(path));
    throw new IOException("파일 처리 실패"); // 롤백 안 됨!
}

// ✅ rollbackFor로 명시
@Transactional(rollbackFor = Exception.class)
public void processFile(String path) throws IOException {
    fileRepository.save(new FileRecord(path));
    throw new IOException("파일 처리 실패"); // 롤백 됨
}

함정 3: try-catch로 예외를 삼켜버리기

// ❌ 예외를 catch하고 아무것도 안 하면 롤백 안 됨
@Transactional
public void saveData(Data data) {
    try {
        repository.save(data);
        externalApiCall();
    } catch (Exception e) {
        log.error("에러 발생", e); // 트랜잭션 매니저가 롤백 대상인지 모름
    }
}

// ✅ 롤백이 필요하면 명시적 처리
@Transactional
public void saveData(Data data) {
    try {
        repository.save(data);
        externalApiCall();
    } catch (Exception e) {
        log.error("에러 발생", e);
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
}

점검 체크리스트

항목 확인
self-invocation 여부 같은 클래스 내 직접 호출인지 확인
메서드 접근자 public 메서드인지 확인
예외 타입 Checked Exception이면 rollbackFor 설정
try-catch 위치 예외를 너무 일찍 삼키지 않는지
빈 분리 트랜잭션 경계를 별도 서비스로 분리했는지

정리

@Transactional이 작동 안 한다면 첫 번째로 확인할 것은 호출 방식이다. 같은 클래스 안에서 this.method()로 호출하면 Spring 프록시를 거치지 않아 트랜잭션이 적용되지 않는다.

가장 깔끔한 해결은 트랜잭션이 필요한 로직을 별도 서비스 빈으로 분리하는 것이다. Self-injection이나 AopContext.currentProxy() 방식은 동작은 하지만 코드가 복잡해지므로 리팩토링이 가능하다면 분리를 우선한다.

댓글 없음:

댓글 쓰기