Spring + DB

[Spring/Spring Data JPA] @Transactional

cwchoiit 2023. 11. 12. 00:03
728x90
반응형
SMALL
728x90
SMALL

 

Spring과 Database를 같이 사용할 때 무조건 사용하게 되는 애노테이션 @Transactional. 물론 반드시 사용해야만 하는 건 아니지만 다른 방법을 쓸 필요가 없이 너무나 편리하게 트랜잭션을 시작하고 끝낼 수 있기에 안 사용할 이유가 없다. 

 

이렇게 애노테이션 하나로 트랜잭션을 시작하는 것을 '선언적 트랜잭션'이라고 한다. 

 

이 애노테이션은 클래스, 메소드 단위로 붙일 수 있고 더 나아가 인터페이스에도 붙일 수 있다. 그러나 인터페이스에 붙이는 건 공식 매뉴얼에서도 권장하지 않는 방식이고 클래스 또는 메소드 단위로 사용하자.

 

우선 데이터베이스에 어떤 작업을 하려면 반드시 트랜잭션이 필요하다. 트랜잭션은 일반적으로 다 아는 어지간한 데이터베이스에서 기본값으로 모든 행위에 대해 트랜잭션이 열린 상태에서 시작하고 작업이 끝나면 닫힌다. 그리고 단 한건의 SQL문이라도 실행하면 트랜잭션이 닫히면서 오토 커밋을 수행한다.

 

그럼 왜 트랜잭션을 알아야 할까? 만약 결제 관련 작업을 수행하는 애플리케이션에서 이런 결제에 대해서도 모든 쿼리가 오토 커밋 상태라면 문제가 발생할 수 있다. 만약 A라는 사람이 B라는 사람에게 돈을 송금한다. 송금하면 A의 잔고는 송금한 금액만큼 차감되는 쿼리가 나가야하고 B의 잔고는 송금한 금액만큼 증액이 되어야 한다. 근데 차감은 성공적으로 수행이 됐는데 증액을 하는 과정에서 문제가 발생해서 쿼리가 성공적으로 수행되지 않으면 이만한 문제가 없다. 이 애플리케이션은 망할것이다. 그렇기에 오토 커밋을 비활성화하고 모든 작업이 정상적으로 수행된 후 커밋이 진행되어야 한다. 이에 따라 모든 작업에 대한 정보를 담고 있는 '트랜잭션'이 필요한 것이다. 트랜잭션 내에서 수행된 작업을 전부 커밋하거나 하나라도 실패하면 롤백을 하여 애플리케이션의 문제가 없도록 하기 위해 트랜잭션을 열어둔 상태에서 모든 작업을 수행하고 커밋 또는 롤백으로 트랜잭션을 종료한다. 

 

이렇게 복잡하게 트랜잭션을 시작하고 트랜잭션의 오토 커밋을 비활성화하고 트랜잭션 내 모든 작업이 끝나면 자동으로 커밋 또는 롤백을 해야하는 이 모든 과정을 @Transactional 애노테이션 하나로 모든것을 자동화할 수 있다.

 

그럼 이 @Transactional이 어떤 과정으로 동작하는걸까?

 

트랜잭션 프록시 객체

 

스프링은 최초 실행되는 시점에 @Transactional 애노테이션이 붙은 모든곳을 찾는다. 찾으면 클래스 전체에 붙어있던 클래스 내 특정 메소드에만 붙어있던 해당 클래스를 프록시 객체로 생성하여 스프링 컨테이너에 빈으로 등록한다. 이 프록시 객체를 편히 트랜잭션 프록시 객체라고 한다.

 

아래 그림을 보자.

 

Service라는 클래스 내 특정 메소드에 @Transactional 애노테이션이 붙어있었기에 스프링이 해당 객체를 프록시 객체로 생성한다. 그리고 그 프록시 객체는 실제 Service 객체를 상속한다. 즉 부모가 Service, 자식이 프록시 객체가 되는 것.

 

그리고 이 프록시 객체를 스프링 컨테이너에 등록을 한다. 앞으로 어디서든 Service를 주입받을 때는 저 프록시 객체를 주입받는 것.

그럼 이 주입받은 프록시 객체를 클라이언트는 사용할텐데 그 사용하는 메소드가 트랜잭션이 붙어있는지 아닌지 어떻게 알까?

 

예를 들어, Service`a()`, `b()`라는 메소드가 있을 때 `a()` 메소드는 @Transactional이 있고 `b()`는 없다. 클라이언트는 `a()`를 호출한다. 이 때 어떻게 동작할까?

 

클라이언트가 a()를 호출하면 실제 호출은 프록시 객체의 a()를 호출한다. 그리고 이 때 프록시는 본인이 참조하고 있는 실제 객체를 바라본다. 프록시 객체가 실제 객체를 바라보면서 이 메소드가 @Transactional 애노테이션이 붙어있는지 확인하는 것이다. a() 메소드는 애노테이션이 있기 때문에 프록시 객체는 해당 메소드의 전체 로직의 가장 상위와 가장 하위단에 트랜잭션을 도입한다. 

 

아래 그림은 위 설명에 대한 도식이다.

 

 

이런 흐름으로 @Transactional 애노테이션은 동작한다. 

 

 

@Transactional@PostConstruct 사용 시 주의

 

@PostConstruct는 특정 객체가 생성된 후 곧바로 실행되는 애노테이션이다. 다시 말해 어떤 객체의 메소드에 @PostConstruct가 달려있으면 그 객체가 생성된 직후에 바로 그 메소드가 실행되게 해주는 애노테이션인데, 이 녀석과 @Transactional이 같이 달려있을 땐 해당메서드가 @PostConstruct를 통해서 호출될 때 @Transactional이 동작하지 않는다. 이를 조심해야 한다.

 

다음 코드를 보자.

package com.example.springtx.apply;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@SpringBootTest
public class InitTxTest {

    @Autowired Hello hello;

    @Test
    void go() {

    }

    @TestConfiguration
    static class InitTxTestConfig {
        @Bean
        Hello hello() {
            return new Hello();
        }
    }

    static class Hello {

        @PostConstruct
        @Transactional
        public void initV1() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("@PostConstruct tx active = {}", isActive);
        }
    }

}

 

Hello 클래스의 initV1() 메소드는 @PostConstruct@Transactional이 같이 달려있다. 이 HelloInitTxTest 클래스가 주입받은 상태에서 InitTxTest 클래스의 go() 메소드의 테스트를 실행할 때 Hello 객체가 생성되고, 생성된 직후 @PostConstruct가 발동되는데 트랜잭션은 발동하지 않는다. 

 

확인해보자. TransactionSynchronizationManager.isActualTransactionActive()는 현재 트랜잭션이 활성화된 상태인지 아닌지를 반환한다. 

2023-12-08T08:26:18.892+09:00  INFO 1715 --- [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2023-12-08T08:26:18.913+09:00  INFO 1715 --- [           main] com.example.springtx.apply.InitTxTest    : @PostConstruct tx active = false
2023-12-08T08:26:19.230+09:00  INFO 1715 --- [           main] com.example.springtx.apply.InitTxTest    : Started InitTxTest in 4.882 seconds (process running for 8.13)

 

결과 로그를 보면 `tx active = false`를 확인할 수 있다. 그럼 왜 그럴까?

 

이유는, @Transactional이 달린 객체를 프록시 객체로 스프링 컨테이너에 빈으로 등록하는 시점보다 @PostConstruct가 먼저 발동되기 때문이다. 즉, 스프링이 완전히 띄워져서 모든 준비가 된 상태가 아닐 때 @PostConstruct가 먼저 발동하기 때문.

 

물론, 당연히 @PostConstruct가 발동해서 실행되는게 아니라 해당 메소드를 직접 호출하면 문제없이 동작한다. @PostConstruct는 직접 프록시 객체의 initV1() 메소드를 호출하는것과 상관이 없고 해당 객체가 생성된 시점에 호출되는 것이기 때문에 아예 다른 관점이다.

그래서 이런 경우를 주의해야한다. 그럼 이런 경우에는 어떻게 해결할까?

 

@EventListener(ApplicationReadyEvent.class)를 사용하면 된다. 이 ApplicationReadyEvent는 애플리케이션이 모든 준비가 다 끝났을 때 발동되는 이벤트이다. 즉, 프록시 객체를 스프링 컨테이너에 완전히 다 등록한 후 스프링이 실제로 띄워졌을 때 호출되는 이벤트이다.

 

package com.example.springtx.apply;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@SpringBootTest
public class InitTxTest {

    @Autowired Hello hello;

    @Test
    void go() {

    }

    @TestConfiguration
    static class InitTxTestConfig {
        @Bean
        Hello hello() {
            return new Hello();
        }
    }

    static class Hello {

        /*@PostConstruct
        @Transactional
        public void initV1() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("@PostConstruct tx active = {}", isActive);
        }*/

        @EventListener(ApplicationReadyEvent.class)
        @Transactional
        public void initV2() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("@PostConstruct tx active = {}", isActive);
        }
    }

}

 

자, 이제 다른 방법인 이벤트 리스너로 실행해보자. 결과는 우리가 원하는대로 동작한다.

2023-12-08T09:36:16.992+09:00 TRACE 2338 --- [           main] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.example.springtx.apply.InitTxTest$Hello.initV2]
2023-12-08T09:36:16.993+09:00  INFO 2338 --- [           main] com.example.springtx.apply.InitTxTest    : @PostConstruct tx active = true
2023-12-08T09:36:16.993+09:00 TRACE 2338 --- [           main] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.example.springtx.apply.InitTxTest$Hello.initV2]

 

`tx active = true`라는 로그가 찍힌다. 

 

@Transactional property

이번엔 @Transactional 애노테이션이 가지고 있는 여러 속성들을 알아보자.

 

value

트랜잭션을 사용할 땐, 어떤 트랜잭션 매니저를 사용할지 지정해줘야한다. @Transactional 애노테이션도 결국 트랜잭션 매니저를 사용하는거기 때문에 트랜잭션 매니저를 지정해줘야 하는데 지정하지 않으면 기본으로 등록된 트랜잭션 매니저를 사용한다. 그러나 트랜잭션 매니저가 둘 이상이면 다음처럼 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.

@Transactional("memberTxManager")
public void member() {...}

@Transactional("orderTxManager")
public void order() {...}

 

 

rollbackFor

트랜잭션 내 로직을 수행 중 예외가 발생하면 스프링 트랜잭션의 기본 정책은 다음과 같다.

  • 언체크 예외(런타임 예외라고도 함)인 RuntimeException, Error 또는 그 하위 예외가 발생하면 롤백
  • 체크 예외인 Exception 또는 그 하위 예외들은 커밋

이 옵션을 사용해서 기본 정책에 '추가적으로' 어떤 예외가 발생했을 때 롤백을 하라고 지정할 수 있다.

@Transactional(rollbackFor = Exception.class)

이렇게 하면 Exception부터 그 하위 예외들이 발생해도 롤백한다.

rollbackForClassName이라는 속성도 있는데 이는 문자로 넣는 경우이다. 그냥 rollbackFor를 사용하면 된다.

 

 

noRollbackFor

이는 rollbackFor와 반대 개념이다. 기본 정책에 추가로 어떤 예외가 발생하면 롤백하면 안되는지 지정할 수 있다. 마찬가지로 noRollbackForClassName도 있다. 

 

propagation

이는 전파에 대한 내용이다. 우선 가능 옵션 리스트는 다음과 같다.

전파타입 설명
REQUIRED 기본값으로 설정되는 전파 타입입니다. 기존에 활성화된 트랜잭션에 자식 트랜잭션이 합류하여 하나의 트랜잭션으로 취급하는 타입으로, 둘 중 하나의 트랜잭션에서 언체크 예외가 발생하면 모두 롤백이 수행됩니다.
REQUIRED_NEW 기존에 활성화된 트랜잭션이 있더라도 합류하지 않고 별개의 트랜잭션으로 취급하여 수행되는 전파 타입입니다. 언체크 예외가 발생한 트랜잭션에서만 롤백이 수행됩니다.
SUPPORTS 기존에 활성화된 트랜잭션이 있다면 합류를 하고, 활성화된 트랜잭션이 없다면 합류하지 않고 트랜잭션 없이 그대로 작업을 수행합니다. 트랜잭션이 그다지 필요없는 SELECT 쿼리에 적용하면 성능 향상을 기대할 수 있다고도 합니다.
NOT_SUPPORTED 기존에 활성화된 트랜잭션 유무에 상관없이 트랜잭션 없이 작업을 수행합니다. 활성화된 트랜잭션이 존재한다면 일시정지 후 작업을 완료하고 재시작을 하는 동작을 거치게 됩니다.
MANDATORY 기존에 활성화된 트랜잭션이 존재할 경우 해당 트랜잭션에 합류하며, 존재하지 않을 경우 예외를 발생시킵니다.
NEVER 기존에 활성화된 트랜잭션이 존재할 경우 예외를 발생시키며, 활성화된 트랜잭션이 없을 경우 작업을 수행합니다.
NESTED 기존 트랜잭션이 없으면 새로운 트랜잭션을 만들고 기존 트랜잭션이 있으면 중첩 트랜잭션을 만듭니다. 중첩 트랜잭션은 외부(기존) 트랜잭션에 영향을 받지만, 중첩 트랜잭션은 외부 트랜잭션에 영향을 주지 않습니다. 즉, 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋할 수 있지만 외부 트랜잭션이 롤백되면 중첩 트랜잭션도 같이 롤백됩니다. (중첩 트랜잭션은 JPA에서는 사용할 수 없는 옵션입니다)

 

참고로, 트랜잭션 옵션 중 isolation, timeout, readOnly는 트랜잭션이 처음 시작될 때만 적용된다. 즉, 트랜잭션에 참여하는 경우에는 적용되지 않는다. 예를 들어, REQUIRED를 통한 트랜잭션 시작이나 REQUIRED_NEW를 통한 트랜잭션 시작 시점에만 적용되고 트랜잭션에 참여하는 트랜잭션은 기존 트랜잭션을 시작할 때 적용했던 옵션을 따라 사용된다. 

 

 

REQUIRED

기본값은 REQUIRED인데, 이 옵션은 위에 설명한 그대로 기존에 활성화된 트랜잭션이 있으면 그 트랜잭션에 합류한다. 좀 더 정확히 말하면 '물리 트랜잭션''논리 트랜잭션'으로 나뉘어지는데 다음 그림을 보자.

1. 클라이언트에서 트랜잭션을 가지는 서비스를 호출하면 트랜잭션이 시작된다. 

2. 하나의 트랜잭션에서 실행되던 로직에서 새로운 트랜잭션을 가지는 또다른 서비스를 호출하여 트랜잭션 두개가 생성된다.

3. 이 두개의 트랜잭션은 논리 트랜잭션으로 나뉘어지고 이 두개의 논리 트랜잭션을 크게 묶어 하나의 물리 트랜잭션으로 합류된다.

 

이런 흐름이 REQUIRED 옵션이다. 이렇게 하나의 물리 트랜잭션으로 합류가 된다. 그리고 이 물리 트랜잭션이 실제 데이터베이스와 통신하는 트랜잭션이고 논리 트랜잭션들은 애플리케이션 레벨에서 트랜잭션을 시작하고 종료(커밋 또는 롤백)하는 트랜잭션이다. 

 

 

이 경우, 중요한 두가지 규칙이 있다.

  • 두 개의 논리 트랜잭션이 모두 커밋되어야 물리 트랜잭션이 커밋된다.
  • 두 개의 논리 트랜잭션 중 하나라도 롤백이라면 물리 트랜잭션은 롤백된다. 

 

다음 그림을 보고 이와 같이 정하자.

최초 시작되는 트랜잭션을 외부 트랜잭션, 내부 로직에서 또다른 트랜잭션을 만들려 하는 곳을 내부 트랜잭션이라고 칭하자.

 

둘 다 논리 트랜잭션이고 이 두개의 논리 트랜잭션이 모두 커밋되어야 하나의 물리 트랜잭션(실제 데이터베이스에 커밋 또는 롤백을 날리는)이 커밋된다고 했다.

 

여기서 그럼, 둘 중 하나가 롤백을 한다면 결국 물리 트랜잭션이 롤백이 되는데 이 때 외부 트랜잭션과 내부 트랜잭션이 롤백을 할 때 처리되는 방식이 다르다. 이것을 이해해야 한다.

 

외부 트랜잭션이 최초의 트랜잭션이기 때문에 결국 이 외부 트랜잭션이 커밋 또는 롤백을 해야 물리 트랜잭션이 커밋 또는 롤백을 한다.

내부 트랜잭션은 커밋이나 롤백을 해도 물리 트랜잭션은 아무런 작업을 하지 않는다. 왜냐? 외부 트랜잭션이 아직 남아있기 때문에.

 

1.내부 트랜잭션이 커밋되고 외부 트랜잭션이 롤백이 되면 그냥 롤백 처리가 된다.
2.내부 트랜잭션이 롤백되고 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 터진다.

저 부분이 중요하다. 내부 트랜잭션이 롤백을 하면 물리 트랜잭션이 롤백을 바로 하지 않지만(물리 트랜잭션이 롤백 또는 커밋이 되면 데이터베이스에 실제 커밋 또는 롤백을 날리는데 아직 트랜잭션이 끝난게 아니니까) 물리 트랜잭션에 Rollback-Only 라는 마크를 달게 된다.

 

여기서 외부 트랜잭션이 커밋이 된다면 물리 트랜잭션에 커밋을 날리는데 커밋을 할 수 없는것이다. 왜냐? 내부 트랜잭션이 롤백을 했으니까. 그러나 개발자는 외부 트랜잭션을 커밋을 했는데 커밋이 되면 안되니까 UnexpectedRollbackException이 발생하는 것이다. 즉, 스프링 입장에서는 "너가 지금 물리 트랜잭션을 커밋하려고 시도했지만 내부 트랜잭션에서 롤백을 날렸기 때문에 넌 커밋을 할 수 없어"라고 친절하게 알려주는 것.

 

이게 기본 전파 속성 'REQUIRED'의 동작 흐름이다. 

 

그래서 진짜 진짜 중요하게 이해해야 하는 부분이 있는데, 아래 그림을 보자.

 

위 그림처럼 트랜잭션에서 또 다른 트랜잭션을 가지는 내부 로직이 있고 이 세 개의 트랜잭션이 모두 REQUIRED 전파 타입인 경우가 있다고 가정하자.

 

REQUIRED 전파 속성은 모두 커밋이 되어야 물리 트랜잭션이 커밋되고 하나라도 롤백이라면 물리 트랜잭션은 롤백된다고 했다. 

근데 만약, 런타임 예외가 발생한 지점에서 예외 처리를 하지 못하고 자신을 호출한 곳으로 예외를 던졌다고 가정하자. 당연히 이 로직의 트랜잭션은 롤백을 할 것이다. 그러나 이 트랜잭션이 가장 최초에 시작된 트랜잭션이 아니라면 롤백을 바로 데이터베이스에 날리는 게 아니라 Rollback Only 마킹을 하고 끝나는데, 그럼 이 예외를 넘겨받은 바깥쪽 트랜잭션 로직에서 이 예외를 복구했다면? 즉 예외를 잡아서 처리했다면? 이 전체 물리 트랜잭션은 커밋이 성공적으로 될까? 아니다. 그렇지 않다. 왜냐하면 결국 이 REQUIRED 전파 타입은 사용중인 트랜잭션에 참여하기 때문에 내부의 논리 트랜잭션에서 하나라도 rollback only에 마킹을 했다면 무조건 물리 트랜잭션은 롤백을 한다. 근데 예외를 잡아버리니까 정상 흐름을 유지하고 트랜잭션을 커밋을 할 거라고 기대하는 경우가 종종 있을 수 있다. 충분히 그럴수 있다는 생각이든다. 하지만 아니라는 것을 꼭 염두하자.

 

 

 

REQUIRED_NEW

REQUIRED_NEW는 위에서 설명한 REQUIRED와 다르게 새 트랜잭션을 사용하는 내부 로직이 있으면, 이전 트랜잭션과 완전히 분리된 새로운 트랜잭션에서 동작하는 방식이다. 즉, 또다른 커넥션이 사용된다.

 

기존 트랜잭션(A)이 있는 경우 해당 트랜잭션을 잠시 보류한 뒤 새로운 트랜잭션(B)을 만들어서 그 트랜잭션(B)을 사용하고 내부 로직이 모두 종료되는 시점에 트랜잭션(B)을 커밋 또는 롤백한 후 기존 트랜잭션(A)이 다시 사용된다.

 

그림으로 보면 다음으로 이해할 수 있다.

즉, 완전히 분리된 물리 트랜잭션을 가지며 어느 한쪽이 다른 한쪽에게 영향을 주지 않는다. 다시 말해 기존 물리 트랜잭션이 커밋이나 롤백을 하던, 새로운 물리 트랜잭션이 커밋이나 롤백을 하던 아무런 영향을 서로에게 주지 않는다. 자기 할 것을 한다.

 

 

isolation

트랜잭션 격리 수준을 지정할 수 있다. 기본값은 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용하는 'DEFAULT'이다. 대부분 데이터베이스에서 설정한 기준을 따른다. 애플리케이션 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드물다. 

  • DEFAULT: 데이터베이스에서 설정한 격리 수준을 따른다.
  • READ_UNCOMMITTED: 커밋되지 않은 읽기 (특정 레코드를 변경 후 커밋하지 않았을 시점에서도 변경된 값을 적용해서 데이터베이스를 통해 읽어올 수 있음을 의미)
  • READ_COMMITED: 커밋된 읽기 (특정 레코드를 변경 후 커밋하지 않으면 변경된 값이 아닌 직전 커밋 데이터를 읽음)
  • REPEATABLE_READ: 반복 가능한 읽기
  • SERIALIZABLE: 직렬화 가능

 

timeout

트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정. 기본값은 트랜잭션 시스템의 타임아웃을 사용한다. 운영 환경에 따라 동작하는 경우도 있고 그렇지 않은 경우도 있기 때문에 꼭 확인하고 사용해야 한다.

 

label

트랜잭션 애노테이션에 있는 값을 직접 읽어서 어떤 동작을 하고 싶을 때 사용할 수 있다. 일반적으로 사용하지 않는다.

 

readOnly

트랜잭션은 기본적으로 읽기 쓰기가 모두 가능한 트랜잭션이 생성된다. `readOnly=true` 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제가 안되고 읽기 기능만 동작한다. (드라이버나 데이터베이스에 따라 정상 동작하지 않는 경우도 있다) 그리고 이 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있다.

 

readOnly 옵션은 크게 3가지에 적용된다.

  • 프레임워크
    • JdbcTemplate은 읽기 전용 트랜잭션 안에서 변경 기능을 실행하면 예외를 던진다.
    • JPA는 읽기 전용 트랜잭션의 경우 커밋 시점에 `flush()`를 호출하지 않는다. 읽기 전용이니 변경에 사용되는 플러시를 호출할 필요가 없다. 추가로 변경이 필요 없으니 변경 감지를 위한 스냅샷 객체도 생성하지 않는다. 이렇게 JPA에서는 다양한 최적화가 발생
  • JDBC 드라이버 (DB와 드라이버 버전에 따라 다르게 동작하기 때문에 사전 확인 필요)
    • 읽기 전용 트랜잭션에서 변경 쿼리가 발생하면 예외를 던진다.
    • 읽기, 쓰기 데이터베이스를 구분해서 요청한다. 읽기 전용 트랜잭션의 경우 읽기 데이터베이스의 커넥션을 획득해서 사용한다. 읽기 전용 트랜잭션의 경우 읽기 데이터베이스의 커넥션을 획득해서 사용한다.
  • 데이터베이스
    • 데이터베이스에 따라 읽기 전용 트랜잭션의 경우 읽기만 하면 되므로, 내부에서 성능 최적화가 발생한다.

 

 

체크 예외 / 언체크 예외(런타임 예외) 발생 시 커밋 또는 롤백

위 속성을 설명하면서 스프링 트랜잭션의 기본 설정으로 체크 예외가 발생하면 커밋, 언체크 예외가 발생하면 롤백한다고 했다.

실제 그렇게 동작하는지 확인해보자.

 

package com.example.springtx.exception;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.*;

@Slf4j
@SpringBootTest
public class RollbackTest {

    @Autowired RollbackService rollbackService;

    @Test
    void runtimeException() {
        assertThatThrownBy(rollbackService::runtimeException)
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void checkedException() {
        assertThatThrownBy(rollbackService::checkedException)
                .isInstanceOf(MyException.class);
    }

    @Test
    void checkedRollbackFor() {
        assertThatThrownBy(rollbackService::rollbackFor)
                .isInstanceOf(MyException.class);
    }

    @TestConfiguration
    static class RollbackTestConfig {

        @Bean
        RollbackService rollbackService() {
            return new RollbackService();
        }
    }

    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 지정 시 롤백 확인
         * */
        @Transactional(rollbackFor = MyException.class)
        public void rollbackFor() throws MyException {
            log.info("call checkedException rollbackFor");
            throw new MyException();
        }
    }

    static class MyException extends Exception {

    }
}

 

RollbackService 클래스에는 세 개의 메소드가 있다. runtimeException(), checkedException(), rollbackFor().

 

runtimeException() 메소드를 실행하면 언체크 예외를 발생시키고 이는 롤백을 하게 된다.

그 결과를 확인하는 테스트 코드는 RollbackTest.runtimeException()이다. 

 

그리고 실제 커밋을 하는지 롤백을 하는지 로그로 확인해보기 위해서는 다음 설정이 application.yml 파일에 필요하다.

logging:
  level:
    org.springframework.transaction.interceptor: TRACE
    org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
    org.springframework.orm.jpa.JpaTransactionManager: DEBUG
    org.hibernate.resource.transaction: DEBUG

 

이제 실행해보자.

RollbackTest.runtimeException() 결과

로그를 살펴보면 rollback이 실행됐음을 알 수 있다.

 

 

그 다음, 체크 예외를 발생시키면 커밋을 하는 것을 확인하는 테스트 코드는 RollbackTest.checkedException()이다.

RollbackTest.checkedException() 결과

커밋 로그가 찍힌다.

 

이제 체크 예외를 발생시켰지만 rollbackFor로 해당 체크예외를 지정해 놓으면 커밋이 아닌 롤백을 하는지 확인해보는 테스트 코드는 RollbackTest.checkedRollbackFor()이다.

 

RollbackTest.checkedRollbackFor()결과

롤백 로그가 찍힌다.

 

그럼 왜 체크 예외는 커밋이고 언체크 예외는 롤백일까?

 

 

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

스프링은 왜 체크 예외는 커밋, 언체크 예외는 롤백할까?

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

 

체크 예외 자체를 개발자가 이 예외가 터지는 것 자체가 비즈니스적으로 의미가 있다라고 판단한 예외라고 스프링은 가정하는 것.

 

비즈니스적으로 의미가 있다라고 판단한 예외란 또 어떤걸까? 아래와 같은 비즈니스 요구사항이 있다고 가정해보자.

 

비즈니스 요구사항

  • 정상 처리: 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 '완료'로 처리한다.
  • 시스템 예외: 주문시 내부 복구 불가능한 예외(SQL 에러, 네트워크 에러, 시스템 장애 등)가 발생하면 전체 데이터를 롤백한다.
  • 비즈니스 예외: 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고 결제 상태를 '대기'로 처리한다. 이 경우, "고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다."

이 때, 결제 잔고가 부족하면 NotEnoughMoneyException 이라는 체크 예외가 발생한다고 가정해보면, 이 예외는 시스템에 문제가 있어서 발생하는 예외가 아니다. 시스템은 정상 동작해도 비즈니스 상황적으로 문제가 되기 때문에 발생한 예외이다. 이런 예외를 비즈니스 예외라고 한다. 위 요구사항에서 결제 상태를 대기로 처리하는 것 자체가 롤백될 이유가 없다는 소리다.

 

제일 중요한 건 예외는 시스템 예외와 비즈니스 예외로 항상 분기되고 비즈니스 예외는 비즈니스적으로 문제가 생긴게 아닌것임을 인지하는것이 중요하다.

 

728x90
반응형
LIST

'Spring + DB' 카테고리의 다른 글

Index란? (DB)  (0) 2024.04.05
선언적 트랜잭션(@Transactional) 내부 호출 주의  (2) 2023.12.07
MyBatis  (4) 2023.12.06
JdbcTemplate  (0) 2023.12.06
Transaction, Auto Commit, Rollback, Lock  (0) 2023.11.30