이전에 스프링의 트랜잭션 내부동작을 꽤나 디테일하게 다뤘었는데 이번에 한번 슬쩍 다시 복습하는김에 복기를 해본다.
서비스 계층은 오로지 비즈니스 로직만을 다루고 있어야한다. 그렇기 때문에 트랜잭션 처리와 같은 데이터 베이스 접근 코드는 따로 분리를 해줘야한다.
트랜잭션 처리 코드를 분리하기위해 스프링에서는 AOP 기능을 지원해주는데 @Transactional 을 사용하면 해당 기능을 사용할 수 있다.
AOP란 공통 기능을 분리한다음 하나의 모듈로 묶는 작업을 의미하며 스프링에서는 에러처리, 트랜잭션, 로깅, 보안과 같은 작업을 할 때 사용된다.
AOP를 적용하기위해 스프링에서는 컨테이너에 빈을 DI 할 때 Service 가 아니라 ServiceProxy (실제 이름은 다름) 를 주입한다.
Service의 프록시 객체는 Service 를 상속 받고 있기 때문에 비즈니스 로직을 전부 사용할 수 있으며 트랜잭션에 관한 코드를 가지고 있어서 트랜잭션 또한 처리가 가능하다.
그러면 클라이언트는 Service 호출할때 사실은 서비스 프록시를 호출하게 되는 것이다. 그리고 프록시가 트랜잭션처리를 하며, 서비스로직을 수행할때는 프록시가 참조하고 있는 실제 Service 객체에게 해당 로직 수행을 위임한다.

@Transactional 는 클래스, 메서드 둘 다 등록 할 수 있다.
그럼 다음과 같이 클래스와 메서드에 둘다 등록되어있는 경우
save1()의readOnly는 true 일까 false 일까?
@Service
@Transactional(readOnly = true)
public class TestService {
@Transactional(readOnly = false)
public void save1(){
...
}
@Transactional
public void save2(){
...
}
}
[결과]
save1()->readOnly = false
save2()->readOnly = true
스프링에서 우선순위는 대부분의 경우 구체적일수록 우선순위가 높아진다.
그러므로 메서드들의 집합인 클래스보다 개별 매서드 하나가 더 구체적이므로 readOnly = false 가 적용된다.
그리고 당연하게도 save2() 의 경우 따로 지정하지 않았기 때문에 readOnly = true 가 지정된다.
클래스의 메서드
클래스
인터페이스의 메서드
인터페이스
스프링 5.0 부터는 인터페이스에도 @Transactional 등록이 지원 되었지만 그리 추천 하지 않는다.
앞서 설명했듯이 클라이언트는 Service 를 호출 할 때 사실은 프록시를 호출하게 된다. 그런데 만일 클라이언트가 프록시가 아닌 직접 Service 를 호출 하게 되면 어떻게 될까?
우선 위와 같은 상황이 언제 발생하는지 알아보자.
static class CallService {
public void external() {
log.info("call external");
printTxInfo();
internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
}
외부에서 트랜잭션 메서드를 호출 하는게 아닌, 내부에서 트랜잭션 메서드를 호출 하게 되는 상황을 생각해보자.
이 경우에도 마찬가지로 트랜잭션이 성공적으로 수행될것 같지만 그렇지 않다. 왜냐하면 여기에는 비밀이 하나 숨어있다.
사실 모든 메서드 호출 코드 앞에는 this. 이 생략이 되어있다. (다른 인스턴스로부터 호출되는 메서드 제외 ex) instance1.method1() (X) )
트랜잭션 처리를 하기 위해서는 반드시 프록시를 거쳐서 비즈니스 로직을 수행해야하는데 내부 호출을 하게되면 그냥 바로 실제 Service 객체가 바로 수행을 하게 된다.
그래서 트랜잭션 처리가 실패하게 되는것이다.
여러 방법이 있지만 실무에서는 아래와 같은 방식을 자주 사용한다. (그리고 제일 간단하다)
@TestConfiguration
static class InternalCallV2Config {
@Bean
CallService callService() {
return new CallService(innerService());
}
@Bean // 프록시 객체 주입
InternalService innerService() {
return new InternalService();
}
}
static class CallService {
private final InternalService internalService;
public void external() {
log.info("call external");
printTxInfo();
internalService.internal();
}
}
@Slf4j
static class InternalService {
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
}
internal() 메서드를 InternalService 라는 객체로 분리를 해준다. 그 다음 빈으로 등록도 해준다. (스프링은 프록시객체를 주입하게된다.)
이러면 external() 이 internal() 을 사용할 때 internalService (프록시 객체) 를 통하여 internal() 을 호출하므로 트랜잭션이 정상적으로 수행된다.
초기화 시점에 @Transactional 수행하려 할 경우 AOP가 적용 안 될 수 있다.
@PostConstruct
@Transactional
public void init(){
// 테스트 데이터 생성
}
신입 개발자 길동이는 데이터 베이스에 초기 데이터들을 생성하기위해 init() 메서드에 @PostConstruct 와 @Transactional 을 같이 사용하고 어플리케이션을 실행하였다.
하지만 계속해서 트랜잭션이 실패하였다. 그래서 길동이는 사수에게 코드를 들고가 질문하였고 친절한 김사수씨는 길동이에게 다음과 같이 코드를 짜야한다고 설명해줬다.
김사수사수: @PostConstruct 와 @Transactional 을 같이 사용할 경우 스프링은 @PostConstruct 를 먼저 실행시키기 때문에 트랜잭션이 실패한거예용.한번 대신에@EventListener(ApplicationReadyEvent.class) 를 사용해 보세요
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void init(){
// 테스트 데이터 생성
}
길동이는 사수의 조언대로 @PostConstruct 대신 @EventListener(ApplicationReadyEvent.class) 를 사용하였고 트랜잭션은 성공적으로 처리 되었다.
본 포스트는
김영한의 스프링 DB 2편 - 데이터 접근 활용 기술 강의 를 보고 정리했습니다.