Home Spring DB - 스프링 트랜잭션의 이해
Post
Cancel

Spring DB - 스프링 트랜잭션의 이해

트랜잭션 적용 확인

@Transactional을 통해 선언적 트랜잭션 방식을 사용하면 다눈히 어노테이션 하나로 트랜잭션을 적용할 수 있음 제대로 적용 되었는지 아래와 같이 확인 해 볼 수 있음

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class TxBasicTest {

  @Autowired
  BasicService basicService;

  @Test
  void proxyCheck() {
      //BasicService$$EnhancerBySpringCGLIB...
      log.info("aop class={}", basicService.getClass());
      assertThat(AopUtils.isAopProxy(basicService)).isTrue();
  }

  ...

  static class BasicService {
      @Transactional
      public void tx() {
          log.info("call tx");
          boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
          log.info("tx active={}", txActive);
      }
      public void nonTx() {
          log.info("call nonTx");
          boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
          log.info("tx active={}", txActive);
      }
  }

  ...

}
1
TxBasicTest : aop class=class ..$BasicService$$EnhancerBySpringCGLIB$$xxxxxx

위 테스트의 결과를 확인해보면 클래스 이름이 프록시 클래스의 이름으로 출력되는 것을 확인 할 수 있음

스프링 컨테이너에 트랜잭션 프록시 등록

Alt text

  • @Transactional 어노테이션이 특정 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록
    • 실제 baseService 객체 대신에 프록시인 basicService$$CGLIB 을 스프링 빈에 등록
    • 프록시 내부는 실제 baseService 객체를 참조
    • 핵심은 실제 객체 대신에 프록시가 스프링 컨테이너에 등록되었다는 것
  • 클라이언트인 txBasicTest는 스프링 컨테이너에 @Autowired로 의존관계 주입 요청할때, 스프링 컨테이너는 실제 객체 대신에 프록시가 스프링 빈으로 등록되어있기 때문에 프록시를 주입
    • 프록시는 BasicService를 상속하여 만들어지기 때문에 다형성을 활용할 수 있어 BasicService 대신에 프록시인 BasicService$$CGILB를 주입할 수 있음

Alt text

  • 클라이언트가 주입받은 BasicService$$CGILB은 트랜잭션을 적용하는 프록시 임

TransactionSynchronizationManager.isActualTransactionActive() 현재 쓰레드에 트랜잭션이 적용되었는지 확인할 수 있는 기능 결과가 true 이면 트랜잭션이 적용되어 있는 상태. 트랜잭션의 적용 여부를 가장 확실히 확인할 수 있음

트랜잭션 적용 위치

스프링에서의 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가짐

스프링의 @Transactional은 다음과 같은 두가지 규칙이 있음

  • 우선순위 규칙
  • 클래스에 적용하면 메서트는 자동 적용

우선순위

트랜잭션을 사용할때는 다양한 옵션을 사용할 수 있음.

1
2
3
4
5
6
7
8
9
10
11
12
@Transactional(readOnly = true)
static class LevelService {

    @Transactional(readOnly = false)
    public void write() {
        log.info("call write");
        printInfo();
    }

    ...

}

위와 같이 클래스에는 읽기 전용 트랜잭션옵션(@Transactional(readOnly = true)) 이 붙어 있고, 메소드에는 아닌경우, 클래스 보다는 메소드가 더 구체적이므로 메소드에 있는 @Transactional(readOnly = false)가 적용되어 짐

클래스에 적용되면 메소드는 자동 적용

1
2
3
4
5
6
7
8
9
10
11
@Transactional(readOnly = true)
static class LevelService {

    public void read() {
        log.info("call read");
        printInfo();
    }

    ...

}

위 테스트 코드를 확인해 보면, 메소드에 @Transactional이 없기 떄문에 더 상위 클래스를 확인하게 됨 클래스에는 @Transactional(readOnly = true)이 적용되어 있으므로 메소드도 적용됨

readOnly = true 는 기본옵션이므로 보통 생략 함

인터페이스에 @Transactional 적용

인터페이스에서도 @Transactional를 적용할 수 있음. 다만 이경우에도 구체적인 것이 더 우선순위가 높으므로 아래와 같은 우선순위가 적용됨

  • 클래스의 메소드
  • 클래스 타입
  • 인터페이스 메소드
  • 인터페이스 타입

다만 인터페이스에 @Transactional 을 사용하는 것은, 스프링 5.0 이전 버전에서는 AOP가 정상적으로 적용되지 않을 수도 있기 때문에 스프링 공식 메뉴얼에서 권장하지 않는 방법

public 메소드에만 트랜잭션 적용

스프링 트랜잭션 AOP 기능은 public 메소드에만 트랜잭션을 적용하도록 기본 설정이 되어 있음. 그러므로 private, protected, package-visible 에는 트랜잭션이 적용되지 않음.

1
2
3
4
5
6
7
@Transactional
public class Hello {
  public method1();
  method2():
  protected method3();
  private method4();
}

만일 위와 같이 트랜잭션이 적용되어 있다면 모든 메소드에 트랜잭션이 걸리게 됨. 그러므로 의도하지 않은 메소드 까지 트랜잭션이 걸리게 되어 트랜잭션이 과도하게 적용되는 문제가 발생함. 트랜잭션은 주로 비지니스 로직의 시작점에 걸기 떄문에 대부분 외부에 열어준 곳을 시작점으로 사용하게 되는데 이때 의도하지 않은 메소드까지 전부 트랜잭션이 걸릴수가 있기 떄문에 public 에만 트랜잭션을 적용하도록 스프링에서 설정되어 있음.

스프링 부트 3.0 부터는 protected, package-visible(default 접근제한자)에도 트랜잭션이 적용됨

트랜잭션 AOP 주의 사항 - 프록시 내부 호출

@Transactional을 사용하면 스프링의 트랜잭션 AOP가 적용됨. 트랜잭션 AOP는 기본적으로 프록시 방식의 AOP를 사용하여 프록새 객체가 요청을 먼저 받아서 트랜잭션을 처리하고 이후 실제 객체를 호출함. 따라서 트랜잭션을 적용하려면 항상 프록시를 통하여 대상 객체(Target)를 호출해야함 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고 트랜잭션 또한 적용되지 않음.

Alt text

AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록함. 따라서 스프링은 의존관계 주입시에 항상 실제 객체 대신에 프록시 객체를 주입함. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않으나, 대상 객체의 내부에서 메소드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생 함. 이렇게 되면 AOP가 적용되지 않기 떄문에 @Transactional이 있어도 트랜잭션이 적용되지 않음

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class InternalCallV1Test {

    ...

    @Slf4j
    static class CallService {
        public void external() {
            log.info("call external");
            printTxInfo();
            internal();
        }

        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active = {}", txActive);
        }
    }

    @Test
    void internalCall() {
        callService.internal();
    }

    @Test
    void externalCall() {
        callService.external();
    }

    ...

internalCall() 실행 Alt text

  1. 클라이언트 테스트 코드는 callService.internal() 호출. 이때 callService는 트랜잭션 프록시 임
  2. callService의 트랜잭션 프록시 호출
  3. internal() 메소드에 @Transactional이 붙어있으므로 트랜잭션 프록시는 트랜잭션을 적용
  4. 트랜잭션 적용 후 실제 callService 객체 인스턴스의 internal() 을 호출
  5. 실제 callService 가 처리를 완료하면 응답이 트랜잭션 프록시로 돌아오고 트랜잭션 프록시는 트랜잭션을 완료
1
2
3
4
5
6
TransactionInterceptor           : Getting transaction for 
[..CallService.internal]
..rnalCallV1Test$CallService     : call internal
..rnalCallV1Test$CallService     : tx active=true
TransactionInterceptor           : Completing transaction for 
[..CallService.internal]

externalCall() 실행 Alt text

1
2
3
4
CallService     : call external
CallService     : tx active=false
CallService     : call internal
CallService     : tx active=false

위 경우에, 실행 결과를 보면 트랜잭션관련 코드 및 tx active=false 로 로그가 출력되는 것으로 봐 태랜잭션이 적용되지 않음을 확인 할 수 있음. 해당 내용에 대한 이유는 프록시가 아닌 실제 객체의 내부 메소드를 호출해서 발생함

프록시와 내부 호출

Alt text

  1. 클라이언트 테스트 코드는 callService.external() 호출. 이때 callService는 트랜잭션 프록시 임
  2. callService의 트랜잭션 프록시 호출
  3. internal() 메소드에 @Transactional이 없으므로 트랜잭션 프록시는 트랜잭션을 적용하지 않음
  4. 트랜잭션 적용하지 않고 실제 callService 객체 인스턴스의 external() 을 호출
  5. external()은 내부에서 internal() 메소드를 호출하지만 이는 트랜잭션 프록시 객체의 internal() 이 아닌 실제 객체의 internal() 메소드를 호출하는 것이므로 트랜잭션을 수행하지 않음

프록시 방식의 AOP 한계

위와 같은 상황의 문제 원인으로는 자바 언어에서는 메소드 앞에 별도의 참조가 없다면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킴. 결과적으로 자기자신의 내부 메소드를 호출하는 this.internal()이 되는데, 이떄 this 는 자기 자신을 가리키므로 실제 대상 객체(Target)의 인스턴스를 뜻함. 그러므로 결과적으로 이러한 내부 호출은 프록시를 커치지 못하여 트랜잭션을 적용할 수 없음.

@Transactional을 사용하는 트랜잭션 AOP는 프록시를 사용함. 프록시를 사용하면 메소드 내부 호출에는 프록시를 적용 할 수 없음.

내부 호출문제 해결방안

메소드 내부 호출로 인하여 트랜잭션 프록시가 적용되지 않는 문제에 대하여, 가장 간단한 해결 방안은 internal() 메서드를 별도의 클래스로 분리하는 것

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class InternalCallV1Test {

    ...

    @Slf4j
    @RequiredArgsConstructor
    static class CallService {

        private final InternalService internalService;

        public void external() {
            log.info("call external");
            printTxInfo();
            internalService.internal();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active = {}", txActive);
        }
    }

    static class InternalService {

        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }

    ...

1
2
3
4
5
6
7
8
9
10
11
#external()
..InternalCallV2Test$CallService : call external
..InternalCallV2Test$CallService : tx active=false

#internal()
TransactionInterceptor           : Getting transaction for 
[..InternalService.internal]
..rnalCallV2Test$InternalService : call internal
..rnalCallV2Test$InternalService : tx active=true
TransactionInterceptor           : Completing transaction for 
[..InternalService.internal]

Alt text

  1. 클라이언트 테스트 코드는 callService.external() 호출.
  2. callService는 클래스 외부, 내부에 @Transactional이 없으므로 실제 객체임 임
  3. callService는 주입 받은 internalService.internal()을 호출
  4. internalService는 트랜잭션 프록시이며 internal() 메소드에 @Transactional이 붙어 있으므로 트랜잭션 프록시는 트랜잭션을 적용
  5. 트랜잭션 적용 후 실제 internalService 객체 인스턴스의 internal()을 호출

트랜잭션 AOP 주의 사항 - 초기화 시점

초기화 코드(예: @PostConstruct)와 @Transactional을 함꼐 사용하면 트랜잭션이 적용되지 않음.

1
2
3
4
5
6
@PostConstruct
@Transactional
public void init() {
  boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
  log.info("Hello init @PostConstruct tx active={}", isActive);
}
1
Hello init @PostConstruct tx active=false

이는 트랜잭션 AOP를 포함한 스프링 컨테이너가 완전히 생성되기도 전에 해당 초기화 코드가 호출되어 AOP가 적용되지 못하여 발생하는 문제.

이를 해결하기 위해 @PostConstruct 와 같은 초기화 방법이 아닌 ApplicationReadyEvent 이벤트를 사용하여 초기화에 이용하면 해결 됨

1
2
3
4
5
6
@EventListener(value = ApplicationReadyEvent.class)
@Transactional
public void init() {
  boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
  log.info("Hello init @PostConstruct tx active={}", isActive);
}
1
2
3
4
TransactionInterceptor           : Getting transaction for [Hello.init]
..ngtx.apply.InitTxTest$Hello    : Hello init ApplicationReadyEvent tx 
active=true
TransactionInterceptor           : Completing transaction for [Hello.init]

이 ApplicationReadyEvent 이벤트는 트랜잭션 AOP를 포함한 스프링 컨테이너가 완전히 생성되고 난 다음에 이벤트가 붙은 메소드를 호출하기 떄문에 트랜잭션을 적용할 수 있음.

예외와 트랜잭션 커밋, 롤백

Alt text

  • 예외 발생시 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백 함
    • 언체크 예외인 RuntimeException, Error와 그 하위 예외가 발생하면 트랜잭션을 롤백
    • 체크 예외인 Exception과 그 하위 예외가 발생하면 트랜잭션을 커밋
    • 정상 응답(리터)시 트랜잭션을 커밋

스프링은 기본적으로 체크 예외는 비지니스 의미가 있을때 사용하고, 런타임(언체크) 예외는 복구 불가능한 예외로 가정함

  • 체크 예외 : 비지니스 의미가 있을때 사용
  • 언체크 예외 : 복구 불가능한 예외

그러나 이는 필수가 아니며, 개발자가 rollbackFor라는 옵션을 통해 체크 예외에도 롤백이 가능함

비지니스 예외

비지니스 예외라는 내용에 대하여 예시를 통해 설명하면 아래와 같음

주문을 하는데 있어 비지니스 요구사항을 아래와 같이 가정함

  • 정상 : 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 완료 처리
  • 시스템 예외 : 주문시 내부에 복구 불가능한 예외가 발생하면 전체 데이터 롤백 처리
  • 비지니스 예외 : 주문시 고객의 결제 잔고가 부족하면 주문 데이터를 저장하고 결제 상태를 대기로 처리하고 고객에게 잔고 부족을 알리고 별도의 계좌로 입급하도록 안내

위와 같은 상황일떄, 결제 잔고가 부족하면 NotEnoughMoneyException 예외가 발생한다고 가정한다면, 이 예외는 시스템에 문제가 있어 발생하는 시스템 예외가 아님. 시스템은 정상 동작 했지만 비지니스 상황에서 문제가 되기 떄문에 발생한 예외. 이와 같은 상황을 비지니스 예외라고 함. 이러한 비지니스 예외는 매우 중요하고 반드시 처리해야 하는 경우가 많으므로 주로 체크 예외를 고려 할 수 있음.

예외에 대한 정리

  • 스프링의 기본값은 체크 예외일떄는 커밋, 언체크 예외일떄는 롤백을 수행 함
  • 비지니스 상황에 다라 체크 예외의 경우에도 트랜잭션을 커밋하지 않고 롤백할 수 있으며 이때는 rollbackFor 옵션 사용
  • 런타임 예외는 항상 롤백되며 체크 예외의 경우 rollbackFor 옵션을 사용하여 비지니스 상황에 따라 커밋과 롤백을 선택할 수 있음

참고

  • 스프링 DB - 데이터 접근 활용 기술(김영한)
This post is licensed under CC BY 4.0 by the author.