spring, 트랜잭션(aop)의 유의사항 - 프록시와 트랜잭션 미적용 문제, checked exception과 롤백, 전파(propagation)의 동작
proxy와 this간 불일치로 인한 aop 미적용
- 스프링 트랜잭션은 스프링 aop를 사용한다. 스프링 aop는 프록시 패턴을 사용한다. 스프링 어플리케이션이 로딩할 때, @Transactional을 선언한 객체의 프록시 객체를 생성하고, 프록시 객체를 빈에 등록한다.
- 우리는 스프링 컨텍스트에서 해당 객체를 호출한다고 생각하지만 사실은 프록시 객체가 호출되고 프록시 객체가 해당 객체를 호출한다. 그리고 프록시 객체에 트랜잭션과 관련한 코드가 작성된다. 이 말은 프록시 객체를 거쳐야지만 트랜잭션이 동작한다.
- 이와 같은 이해를 바탕으로 아래의 코드를 보자. 다음과 같은 상태에서 트랜잭션은 동작하지 않는다.
public class Service{
public void external() {
internal();
}
@Transactional
public void internal() {
}
}
external()
이 트랜잭션이 선언된internal()
메서드를 호출할 때, 트랜잭션은 동작하지 않는다.internal();
은 암묵적으로 this가 누락되어this.internal();
을 호출하는 것과 동일하다. 이는 프록시가 아닌 원래 객체를 호출하며 원래 객체에는 트랜잭션과 관련한 어떤 코드도 존재하지 않는다.- 해당 문제를 해소하기 위해서는
this.doSomething();
이 되지 않도록 만들어야 한다. internal() 메서드를 외부 클래스로 분리하여somebean.doSomething();
의 형태로 만들어야 한다. 그리고 해당 객체에 트랜잭션이 동작하도록 코드를 작성한다.
checked exception은 롤백되지 않는다.
- checked exception은 롤백되지 않는다. 신경쓰지 않았다면 몰랐을 정보이다.
- 다음과 같은 코드가 그러하다.
@Transactional
public void order(Vo vo) throws MyCheckedException {
if(checkSomething(vo)){
vo.setStatus("오류 발생");
throw new MyCheckedException("....")
}
}
- 롤백이 되지 않는 이유는 비지니스 예외라고 해서 그렇다는데 사실 와닿지는 않았다.
- 더티체크가 가능한 JPA의 경우 vo는 “오류 발생”이란 값을 가지고 DB에 저장한다.
- checked 예외에 대해서도 롤백을 하고 싶으면 rollbackFor=”Exception” 의 형태로 코드를 작성해야 한다.
트랜잭션 전파
- 서로 다르게 선언한 트랜잭션을 관리하기 위하여 스프링은 트랜잭션 전파를 제공한다.
- 트랜잭션 전파는 외부 트랜잭션과 내부 트랜잭션으로 크게 분리하여 고려된다.
- 외부 트랜잭션은 트랜잭션이 시작된 로직이다.
- 내부 트랜잭션은 외부가 만든 트랜잭션을 사용하는 로직 나머지를 의미한다.
- 트랜잭션을 병합하는 행위는 같은 커넥션을 사용한다는 의미와 같다.
- 트랜잭션의 전파는 기본적으로 REQUIRE를 사용하고 아주 가끔 REQUIRED_NEW를 사용한다. 전자는 커넥션을 하나만 사용하고 후자는 두 개 이상 사용한다.
- 외부 트랜잭션과 내부 트랜잭션 등 여기서 말하는 트랜잭션은 논리 트랜잭션이다. 실제로 DB와 통신하는 커넥션을 물리 트랜잭션이라 한다. 하나의 물리 트랜잭션은 여러 개의 논리 트랜잭션을 가질 수 있다.
트랜잭션 전파의 커밋과 롤백
- 기본적으로 트랜잭션의 커밋과 롤백은 외부 트랜잭션이 관리한다. 왜냐하면 내부 트랜잭션이 몇 개가 발생할지 모르기 때문이다. 내부 트랜잭션이 마음대로 커밋하여 트랜잭션을 종료해서는 안된다.
- 반대로 롤백의 경우 내부 트랜잭션에서 발생할 수 있어야 한다. 내부 트랜잭션에서 예외가 발생할 수 있기 때문이다.
- 커밋과 롤백의 주체가 언제나 같지 않다. 이러한 불일치를 해결하기 위해서 rollbackOnly 란 상태를 가진다. 내부 트랜잭션이 롤백을 선언할 때 rollbackOnly=true로 변경한다. 외부 트랜잭션이 이를 감지하면 롤백 처리한다.
내부 논리 트랜잭션의 예외 처리
public SomeService{
@Transactional
public void execute(Vo vo){
repository1.save(vo); // 반드시 예외 발생
try{
repository2.save(vo);
}catch(RuntimeException e){
log.info(e);
}
}
}
- 만약 위와 같은 코드가 있다고 가정하자. repository1과 repository2가 insert 쿼리를 수행한다. 두 논리 트랜잭션은 @Transactional에 의하여 병합된 상태이다(전파 수준 : Required).
- repository1#save에서 예외가 발생한다. 이 경우 repository2.save()는 저장될까?
- 저장되지 않는다. repository1에 의하여 트랜잭션의 상태값 rollbackOnly은 true가 된다. 이후 외부 트랜잭션에서 아무리 catch를 하더라도 rollbackOnly=ture이기 때문에 롤백 처리 된다.
Required를 권장
- Required_new의 경우 트랜잭션을 분리하기 위한 효과적인 방법이다.
- 하지만 선호되지 않는다. 그보다는 트랜잭션이 선언되지 않은 외부에서 별도의 트랜잭션으로 관리하는 것이 코드 상 더 분명하다.
// @Transctional // 트랜잭션 처리하지 않는다.
class SomeService{
// @Transctional // 트랜잭션 처리하지 않는다.
void execute(Vo vo){
// 트랜잭션이 선언되지 않았으므로 importantRepository 의 로직만 성공하면 커밋된다.
importantRepository.save(vo);
// 아래 트랜잭션이 실패하더라도 importantRepository 에 영향을 미치지 않는다.
attemptRepository.save(vo);
}
}
출처 : 김영한 개발자님의 인프런 강의! 강추!