이전 포스트에서 트랜잭션이 무엇인지, 스프링에서 트랜잭션을 어떤 방식으로 추상화했었는지 정리했고, 스프링에서의 트랜잭션은 @Transactional annotation을 통해 간단하게 사용할 수 있다는 것을 알 수 있었다.
이번 글에서는 스프링의 트랜잭션과 추가적인 내용에 대해 알아보고, @Transactional을 가능하게 하는 스프링 트랜잭션 AOP에 대해 좀 더 자세히 알아보자.
참고.
스프링 트랜잭션 추상화의 핵심은 PlatformTransactionManager 인터페이스였었다. 궁금하면 이전 글 참고
@Transactional을 이용한 트랜잭션 관리를 선언적 트랜잭션 관리라고 부른다.
스프링에서는 @Transactional을 사용하게 되면 기본적으로 프록시 방식의 AOP가 적용되어 트랜잭션을 사용할 수 있다.
원래는 트랜잭션을 사용하려면 비즈니스 로직의 앞 뒤로 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료하는 코드를 작성해야 했지만, 프록시 방식의 AOP 적용 시 프록시에서 트랜잭션 시작/종료를 수행하는 코드를 전부 가져가므로 트랜잭션 처리 로직과 비즈니스 로직을 명확하게 분리할 수 있다. 아래의 코드처럼 말이다.
public class TransactionProxy {
private MemberService target;
public void logic() { //트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
//실제 대상 호출
target.logic();
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
}
public class Service {
public void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
}
이렇게 스프링은 프록시 방식의 AOP를 통해 트랜잭션을 처리하고, 내부적으로는 트랜잭션 매니저와 트랜잭션 동기화 매니저를 통해 트랜잭션이 수행되는 동안 동일한 DB 커넥션을 사용하는 것을 보장한다.
트랜잭션은 누구나 다 사용하는 기능이기 때문에, 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공하고, 스프링 부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈 및 설정들도 전부 자동으로 등록해준다. 개발자가 할 일은 단순히 트랜잭션 처리가 필요한 곳에 @Transactional annotation만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 annotation을 인식해서 트랜잭션을 처리하는 프록시를 적용해준다.
중요.
위의 코드에서 target.logic()에 해당하는 부분이 프록시 객체가 실제 객체의 메서드를 호출하는 부분이다.
즉, 클라이언트가 logic() 메서드를 호출하면 프록시의 logic()이 호출되지만, 프록시는 실제 객체의 logic() 메서드가 트랜잭션 적용 대상인지 판단한 후에, 필요하다면 트랜잭션을 적용한 후에 실제 객체의 logic()을 호출하는 것이다. 트랜잭션이 적용되던 되지않던 호출되는 logic() 메서드는 실제 객체의 것이라는 것을 명심해야 한다.
스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다. 그러므로 @Transactional을 클래스 레벨에 붙이는 것보다 메서드 레벨에 붙이는 것이 우선순위가 더 높다. 또, 인터페이스와 해당 인터페이스를 구현한 클래스 중에는 구체적인 클래스가 더 높은 우선순위를 가진다. 정리하면 아래의 순서와 같은 우선순위를 가진다.
주의.
인터페이스에 @Transactional을 사용하는 것은 스프링 공식 매뉴얼에서 권장하지 않는 방법이다. 스프링 5.0에서 많은 부분 개선되었다고 하지만, 여전히 AOP에 의해 적용되지 않을 가능성이 있으므로 지양하자.
앞서 정리한 것처럼 @Transactional을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고, 내부에서 실제 객체를 호출해준다. 그런데 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는다. 이게 정확하게 무슨 말일까? 그림으로 보면 더 이해하기 쉽다.
먼저 정상적인 프록시 호출의 흐름이다.
클라이언트가 internal() 메서드를 호출하면, 실제 객체의 메서드가 아닌 프록시 객체의 메서드가 호출된다. 이 메서드에는 @Transactional이 적용되어 있기 때문에 트랜잭션이 적용되고 난후 실제 객체의 internal() 메서드가 호출된다.
자 이번에는 프록시를 거치지 않고 대상 객체를 직접 호출하는 정상적이지 못한 흐름이다.
클라이언트가 external() 메서드를 호출하면, 실제 객체의 메서드가 아닌 프록시 객체의 메서드가 호출되는 건 맞지만, 이 메서드에는 @Transactional이 적용되어 있지 않다. 그러므로, 트랜잭션이 적용되지 않고 실제 객체의 external() 메서드가 호출된다. 여기까지는 문제가 없다.
문제는 호출된 external() 메서드 내부에서 internal() 메서드가 호출된다는 점이다. internal() 메서드는 클라이언트 입장에서는 트랜잭션이 적용되기를 바라는 메서드지만, 위의 사진 같은 경우에는 프록시 객체의 internal() 메서드가 호출되는 것이 아닌 실제 객체의 internal()이 호출되는 것이기 때문에 트랜잭션이 적용되지 않는다. 트랜잭션을 적용시키는 주체는 실제 객체가 아니라 프록시 객체이기 때문이다.
즉, external() 메서드의 내부에서 호출되는 internal() 메서드는 프록시를 거치지 않고 직접 호출되었기 때문에 AOP가 적용되지 않은 것이고, 트랜잭션도 적용되지 않는다.
참고.
위에서 발생한 문제를 Self-Invocation 이라고 부른다고 하는데, 정확한 내용은 더 찾아봐야할 것 같다.
위의 문제를 해결하는 가장 간단한 방법은 트랜잭션이 적용된 메서드를 별도의 클래스로 분리하는 것이다.
internal() 메서드를 별도의 클래스로 분리하여 external() 메서드 내부에서 분리된 internal() 메서드를 호출하게하면 된다. 아래와 같이 말이다.
별도의 클래스로 분리했기 때문에, external() 에서는 트랜잭션이 적용되지 않지만, internal()을 호출할 때, 실제 객체가 아닌 프록시 객체가 호출되고 결과적으로 internal() 메서드에는 트랜잭션이 적용된다.
그 밖에도 다른 해결방안들이 있지만, 실무에서는 별도의 클래스로 분리하는 방법을 주로 사용한다.
참고.
최근 프로젝트 진행중 OAuth2 로그인을 구현하던 중 동일한 문제를 만났던 적이 있다.
https://yejun-the-developer.tistory.com/13 를 참고했고, 나같은 경우는 내부적으로 @Transactional이 붙은 함수를 호출하는 외부하는 함수에도 @Transactional을 붙여 트랜잭션이 전파되도록 코드를 수정했다.
위의 예시에 적용해보자면, internal()과 external()을 같은 클래스에 두고, external()에도 @Transactional을 적용한 것이다. 하지만 내 프로젝트에도 내부적으로 호출하는 함수를 별도의 클래스로 분리하여 해결할 수도 있다.
스프링의 트랜잭션 AOP 기능은 원래 public 메서드에만 트랜잭션을 적용하도록 기본 설정이 되어있었다. 스프링 부트 3.0부터는 protected, package-visible(default 접근제한자)에도 트랜잭션이 적용되는 것으로 변경되었지만, private 메서드에는 여전히 적용되지 않는다.
이는 스프링에서 막아둔 것인데, 트랜잭션이 의도하지 않은 곳까지 과도하게 적용되는 것을 막기 위해서이다. @Transactional은 클래스 레벨에 붙일 수 있기 때문에, 모든 접근제어자에 모두 트랜잭션이 적용되도록 허용하면 무분별하게 트랜잭션이 적용될 수 있다. 그러므로, private 메서드에는 @Transactional이 붙더라도 트랜잭션 적용이 무시된다.
스프링 초기화 시점에는 트랜잭션 AOP가 적용되지 않을 수 있다.
스프링 빈을 스프링 컨테이너에 등록할 때, @PostConstruct 를 이용하여 스프링 빈에 대한 초기화 동작을 수행할 수 있었다는 것을 기억하는가? 기억이 안난다면 스프링 핵심원리의 내용을 다시 보고 오면 좋을 것이다.
만약에 이 스프링 빈을 초기화하는 과정에 트랜잭션을 사용해야한다면 어떻게 해야할까? @PostConstruct와 @Transactional을 동시에 사용하는 경우를 생각해보자. @Transactional을 이용해 트랜잭션이 적용되려면 스프링이 제공하는 트랜잭션 AOP가 스프링 컨테이너에 등록되어야한다. 그런데 @PostConstruct는 해당 클래스가 스프링 빈으로 등록되는 순간에 호출되어 초기화를 수행한다. 따라서, @PostConstruct가 붙은 초기화 코드가 먼저 호출되고, 그 다음에 트랜잭션 AOP가 적용된다. 따라서, 초기화 시점에는 해당 메서드에서 트랜잭션을 획득할 수 없다.
이를 해결하기 위해서는 ApplicationReadyEvent를 사용하는 것이다. 아래의 코드 처럼 말이다.
public class Hello {
@PostConstruct
@Transactional
public void initV1() {
boolean isActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init @PostConstruct tx active={}", isActive);
}
@EventListener(value = ApplicationReadyEvent.class)
@Transactional
public void init2() {
boolean isActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init ApplicationReadyEvent tx active={}",
}
}
@EventListener(value = ApplicationReadyEvent.class) 를 사용하면, 트랜잭션 AOP를 포함한 스프링 컨테이너가 완전히 생성되고 난 후에 이벤트가 붙은 메서드를 호출해준다. 따라서, 트랜잭션 AOP가 완전히 준비된 후에 초기화 코드를 실행하기 때문에, 초기화 코드에도 트랜잭션을 적용할 수 있다.
스프링 트랜잭션은 다양한 옵션을 제공한다.
@Transactional 의 소스코드를 보면 상당히 다양한 옵션들이 존재하는 것을 확인할 수 있다.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Reflective
public @interface Transactional {
@AliasFor("transactionManager")
String value() default "";
@AliasFor("value")
String transactionManager() default "";
String[] label() default {};
Propagation propagation() default Propagation.REQUIRED;
Isolation isolation() default Isolation.DEFAULT;
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
String timeoutString() default "";
boolean readOnly() default false;
String[] rollbackForClassName() default {};
String[] noRollbackForClassName() default {};
Class<? extends Throwable>[] rollbackFor() default {};
Class<? extends Throwable>[] noRollbackFor() default {};
}
하나씩 천천히 살펴보자.
어떤 트랜잭션 매니저를 사용할지를 지정해주는 옵션이다. 보통은 경우 이를 생략하고, 생략할 경우 기본으로 등록된 트랜잭션 매니저를 사용한다. 만약 사용하는 트랜잭션 매니저가 둘 이상이라면 이 옵션을 통해 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.
예외 발생시 스프링 트랜잭션의 기본정책은 다음과 같다.
참고.
언체크 예외와 체크 예외는 자바 예외를 나누는 기준이다. 스프링에서 사용하는 트랜잭션에서 각 종류의 예외별로 어떻게 대처할지에 대한 기본정책을 위와 같이 정한 것이다. 스프링에서 그렇게 처리하는 것이라는 걸 잊지말 것.
이 옵션을 사용하면 스프링 트랜잭션의 기본정책과 더불어 사용자가 추가로 특정 예외가 발생할 때 롤백 여부를 지정할 수 있다.
트랜잭션 전파에 대한 옵션이다. 트랜잭션 전파와 관련된 내용에서 추가적으로 설명한다.
DB 상에서 사용하는 트랜잭션 격리수준을 지정할 수 있다. 기본 값은 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용한다. 애플리케이션 개발자가 트랜잭션 격리 수준을 직접 설정하는 경우는 드물다. 옵션으로 설정할 수 있는 값은 아래와 같다.
각각의 옵션에 대한 자세한 설명은 필요하면 추가적으로 학습바란다.
트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정한다. 기본 값은 트랜잭션 시스템의 타임아웃을 사용한다. 운영 환경에 따라 동작하는 경우도 있고, 그렇지 않은 경우도 있기 때문에 확인 후에 사용해야한다.
트랜잭션 annotation에 있는 값을 직접 읽어서 어떤 동작을 하고 싶을 때 사용할 수 있다. 일반적으로 사용하지 않는다.
트랜잭션은 기본적으로 읽기, 쓰기가 모두 가능한 트랜잭션이 생성된다. 만약 readOnly=true 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우에는 읽기 기능만 작동한다. 다만 드라이버나 데이터베이스에 따라 동작하지 않을 수도 있고, 만약 읽기 전용 트랜잭션을 사용한다면 다양한 성능 최적화가 발생할 수 있다.
readOnly 옵션은 크게 3군데에서 적용될 수 있다. (드라이버나 DB에 따라 동작하지 않을 수도 있음, 사용 전 확인필요)
스프링 트랜잭션을 적용하여 비즈니스 로직을 처리하던중 예외가 발생하면 어떻게 처리해야할까?
비즈니스 로직 내부에서 예외를 잡아서 처리한다면 문제가 없겠지만, 예외는 밖으로 던져질수도 있다. 즉, 예외는 트랜잭션을 적용하는 프록시 객체로 던져질 수 있다. 이 경우에 스프링 트랜잭션은 기본정책을 기준으로 예외를 처리한다.
참고.
프록시 객체(트랜잭션 AOP)가 예외를 처리한다는 말이 예외를 잡아서 정상흐름으로 돌린다는게 아니다. 예외의 종류에 따라서 트랜잭션을 commit할지, rollback할지만 처리하고, 예외는 동일하게 잡아서 처리하는 로직이 없다면 밖으로 던진다.
스프링은 어떤 기준에 근거하여 위와 같은 기본정책을 갖고 있는 걸까?
스프링은 기본적으로 체크 예외는 비즈니스적 의미가 있을 때 사용하고, 언체크(런타임) 예외는 복구가 불가능한 예외로 가정하기 때문이다.
비즈니스적 의미가 있는 예외는 매우 중요하고, 반드시 처리해야하는 경우가 많다. 반드시 예외를 처리해야하기 때문에 체크 예외를 사용하는 것을 고려해야한다. 그리고 만약 예외를 잡아서 처리했다면, 트랜잭션 내에서 조작한 데이터를 롤백하지 않고 커밋하여 비즈니스 상황에 맞게 사용할 수 있다.
반면에, 시스템 자체가 정상적으로 동작하지 않아서 발생하는 예외는 비즈니스 의미와는 관계가 없는 복구할 수 없는 예외이기 때문에 언체크(런타임) 예외를 사용하고, 발생 시에 트랜잭션을 롤백하는 것이 바람직하다.
하지만 명심해야할 것은 트랜잭션 옵션 설정을 통해 비즈니스 상황에 맞게 정책을 변경할 수 있다는 것이다. 선택적으로 체크 예외를 롤백할 수도 있고, 언체크 예외를 커밋할수도 있는 것이다.