Spring

트랜잭션 프록시와 예외

Chan Dev 2025. 4. 8. 16:22
Transaction Proxy, Exception 에 대해 공부하면서 정리합니다.

 

실무에서 @Transactional 을 붙인 메서드를 실행했는데 정상적으로 동작하지 않아 원인을 찾고 공부한 내용을 정리했습니다.

트랜잭션 프록시를 만드는 이유

왜 굳이 프록시를 만들어서 트랜잭션을 처리해야 할까요?

@Transactional 이 붙은 메서드가 호출될 때 자동으로 트랜잭션을 시작하고, 정상 종료되면 commit, 예외가 나면 rollback 을 해주기 위해서입니다.

 

구체적으로 왜 프록시가 필요한지 코드를 보면 트랜잭션을 처리하려면 메서드 실행 전후로 아래와 같은 코드가 필요합니다.

beginTransaction();   // 트랜잭션 시작
try {
    targetMethod();   // 실제 로직 실행
    commit();         // 성공 시 커밋
} catch (Exception e) {
    rollback();       // 예외 시 롤백
}

 

하지만 개발을 하다 보면 우리는 이런 트랜잭션 제어 코드를 일일이 메서드마다 넣고 싶지 않습니다.

너무 번거롭고, 코드 중복도 심하고, 관리도 어렵기 때문이죠.

 

그래서 등장한 히어로가 바로 트랜잭션 프록시 입니다.

스프링은 @Transactional 이 붙은 메서드를 감싸는 프록시 객체를 만들어서 메서드를 호출하기 전에 트랜잭션을 시작하고, 메서드가 끝나면 자동으로 커밋 또는 롤백을 해줍니다.

클라이언트 코드
   ↓
프록시 (트랜잭션 시작/종료 담당)
   ↓
실제 객체 (비즈니스 로직만 있음)

 

이렇게 하면 개발자는 핵심 비즈니스 로직에만 집중하고, 부가적인 트랜잭션 처리는 프록시가 알아서 해주는 거죠.

 

트랜잭션 프록시 동작 순서

@Transactional 이 붙은 메서드를 호출하면

1. 프록시가 먼저 호출

2. 프록시가 트랜잭션 시작

3. 프록시가 진짜 save() 메서드 호출

4. save() 실행이 문제없이 끝나면 commit

5. save() 도중에 예외가 발생하면 rollback

@Service
public class UserService {

    @Transactional
    public void save() {
        userRepository.save(new User(...)); // DB 저장
    }
}

 

예를 들어 위와 같은 코드가 있을 때 아래와 같은 실행 순서가 진행됩니다.

UserService.save() 호출
   ↓
스프링이 만든 프록시가 가로챔
   ↓
트랜잭션 시작
   ↓
진짜 UserService.save() 호출
   ↓
정상 종료? commit!
예외 발생? rollback!

 

이러한 과정은 @Service, @Transactional 같은 어노테이션을 붙인 클래스를 만들고 빈으로 등록하면

스프링은 직접 UserService 인스턴스를 사용하지 않고, UserService 를 감싸는 프록시 객체를 대신 만들어서 빈으로 등록합니다. 즉, 빈으로 등록되는 건 원본이 아니라 프록시 객체라는 말입니다.

그 프록시는 내부에 아래와 같은 형태로 생겼다고 보면 됩니다.

class UserServiceProxy {
    private final UserService target;

    public void save() {
        try {
            beginTransaction();
            target.save(); // 진짜 메서드 실행
            commit();
        } catch(Exception e) {
            rollback();
        }
    }
}

 

그래서 UserService.save() 를 호출하면 실제로는 프록시 객체의 save() 가 먼저 실행되고, 그 안에서 진짜

UserService.save() 가 호출되는 구조입니다.

 

주의할 점 

@Transactional 메서드는 public 으로 만들기

스프링은 @Transactional 을 쓰면 아래와 같은 프록시를 만듭니다.

Client
   ↓
[Proxy]  ← 트랜잭션 처리 담당
   ↓
Real Object (내부 로직)

 

즉, 프록시는 외부에서 메서드가 호출될 때만 개입할 수 있습니다.

근데 만약 protected 나 private, default 같은 메서드가 있다면 외부에서는 호출할 수 없기 때문에

그러면 프록시도 개입할 수 없는 것이죠.

스프링 부트 3.0 부터는 protected, pacakge-visible( default 접근제어자 ) 에도 트랜잭션이 적용됩니다.

 

내부 메서드 호출은 프록시를 건너뛴다

 

예를 들어 아래처럼 되어 있다고 해봅시다.

@Service
public class MyService {

    @Transactional  // 적용 안 됨!
    private void internalLogic() {
        // DB 작업
    }

    public void externalCall() {
        internalLogic(); // 이건 프록시를 안 거치고, 자기 자신을 직접 호출함!
    }
}

 

코드만 보면 정상 작동될 것 같지만 아쉽게도 @Transactional 은 정상적으로 동작하지 않습니다.

자바 언어에서는 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킵니다.

결과적으로 자기 자신의 내부 메서드를 호출하는 this.internalLogic() 이 되는데, 여기서 this 는 자기 자신을 가리키므로, 실제 대상 객체(target) 의 인스턴스를 뜻합니다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않습니다.

따라서 트랜잭션을 적용할 수 없는 것입니다. 즉, target 에 있는 internalLogic() 을 직접 호출하는 것입니다.

이러한 문제는 내부 호출을 피하기 위해 internalLogic() 메서드를 별도의 클래스로 분리하시면 됩니다.

 

또한 부가적으로 스프링 초기화 시점에는 트랜잭션이 적용되지 않을 수 있습니다.

예를 들어 @PostConstruct 로 만든 메서드에 @Transactional 을 사용하면 적용되지 않습니다.

 

왜냐하면 초기화 코드가 먼저 호출되고, 그 다음에 트랜잭션이 적용되기 때문입니다.

따라서 초기화 시점에는 해당메서드에서 트랜잭션을 획득할 수 없습니다.

 

트랜잭션 예외

만약 예외가 발생했는데, 내부에서 예외를 처리하지 못하고, 트랜잭션 범위(@Transactional 가 적용된 부분 ) 밖으로 예외를 던지면 어떻게 될까요?

 

미리 말하자면 예외 발생시 스프링 트랜잭션은 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백합니다.

언체크 예외인 RuntimeException, Error 와 그 하위 예외가 발생하면 트랜잭션을 롤백하며,

체크 예외인 Exception 과 그 하위 예외가 발생한다면 트랜잭션을 커밋하게 됩니다.

물론 정상 응답하면 트랜잭션을 커밋합니다.

@Slf4j
static class RollbackService {
	
    //런타임 예외 발생: 롤백
    @Transactional
    public void runtimeException() {
    	log.info("call runtimeException");
        throw new RuntimeException();
    }
    
    //체크 예외 발생: 커밋
    @Transactional
    public void checkedException() throws MyException {
    log.info("call checkedException");
    throw new MyException();
    }
 
    //체크 예외 rollbackFor 지정: 롤백 <- 이 경우 Exeption 도 rollback 됩니다.
    @Transactional(rollbackFor = MyException.class)
    public void rollbackFor() throws MyException {
    log.info("call rollbackFor");
    throw new MyException();
    }
 }
 
 static class MyException extends Exception {}

 

실행하기 전에 아래와 같은 코드를 추가해야 트랜잭션이 커밋되었는지 롤백 되었는지 로그를 확일 할 수 있습니다.

 

application.properties

logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
 #JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG

 

스프링은 그럼 왜 체크 예외는 커밋하고, 언체크(런타임) 예외는 롤백할까요?

 

기본적으로 스프링은 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임(언체크) 예외는 복구 불가능한 예외로 가정합니다. 그런데 비즈니스 의미가 있는 비즈니스 예외라는 것이 무슨 말일까요?

 

주문시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를 대기로 처리해야 하는 경우가 생깁니다.

이 경우 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내해야 하죠

즉 고객의 잔고가 부족한 것은 시스템에 문제가 있는 것이 아닙니다. 오히려  시스템에는 문제없이 동작한 것이고, 비즈니스 상황이 예외인 것입니다. 이런 예외를 비즈니스 예외라고 합니다.