트랜잭션 프록시와 예외
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
스프링은 그럼 왜 체크 예외는 커밋하고, 언체크(런타임) 예외는 롤백할까요?
기본적으로 스프링은 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임(언체크) 예외는 복구 불가능한 예외로 가정합니다. 그런데 비즈니스 의미가 있는 비즈니스 예외라는 것이 무슨 말일까요?
주문시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를 대기로 처리해야 하는 경우가 생깁니다.
이 경우 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내해야 하죠
즉 고객의 잔고가 부족한 것은 시스템에 문제가 있는 것이 아닙니다. 오히려 시스템에는 문제없이 동작한 것이고, 비즈니스 상황이 예외인 것입니다. 이런 예외를 비즈니스 예외라고 합니다.