[Spring] 트랜잭션 옵션과 전파에 대하여, 그리고 트랜잭션 주의사항까지

728x90

 

트랜잭션 옵션

value, transactionManager

기본으로 등록된 트랜잭션 매니저를 사용할 때는 생략하면 되지만,

사용하는 트랜잭션 매니저가 둘 이상이라면 트랜잭션 매니저의 이름을 지정하여 구분해야 한다.

public class TxService {

  @Transactional(value = "memberTxManager")
  public void member() {...}
  
  @Transactional("orderTxManager")
  public void order() {...}
}

 

rollbackFor / noRollbackFor

체크 예외가 발생하면 커밋되지만, 추가로 예외를 지정하면 해당 예외를 롤백할 수 있다.

@Transactional(rollbackFor = Exception.class)

Exception 과 하위 예외들은 체크 예외지만 커밋되지 않고, 롤백된다.

 

`noRollbackFor` 는 반대로 롤백하면 안 되는 예외를 지정하여 해당 예외를 롤백할 수 있다.

 

isolation

`DEFAULT` : 데이터베이스에서 설정한 격리 수준을 따른다.

`READ_UNCOMMITTED` : 커밋되지 않은 읽기

`READ_COMMITTED` : 커밋된 읽기

`REPEATABLE_READ` : 반복 가능한 읽기

`SERIALIZABLE` : 직렬화 가능

 

timeout

트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정한다.

`timeoutString` 은 숫자 대신 문자 값으로 지정할 수 있다.

 

label

트랜잭션 애노테이션에 있는 값을 직접 읽어서 어떤 동작을 하고 싶을 대 사용할 수 있다.

 

readOnly

트랜잭션은 기본적으로 읽고 쓰기가 모두 가능한 트랜잭션이 생성된다.

하지만 `readOnly=true` 옵션을 사용하면, 데이터의 변경이 불가능하고 읽기만 가능하다.

또한 `readOnly` 옵션을 통해 성능 최적화가 발생할 수 있다.

 

- readOnly 적용

프레임워크

  : Jdbc Template 은 읽기 전용 트랜잭션에서 변경 기능을 사용하면 예외를 발생시킨다.

  : JPA 의 경우 커밋 시점에 플러시를 호출하지 않고, 변경 감지를 위한 스냅샷 객체도 생성하지 않아 최적화가 발생한다.

JDBC 드라이버

  : 읽기 전용트랜잭션에서 변경 쿼리가 발생하면 예외를 발생시킨다.

  : 읽기, 쓰기(마스터, 슬레이브) 데이터베이스를 구분해서 요청하고, 읽기 전용 트랜잭션의 경우 읽기(슬레이브) 데이터베이스의 커넥션을 획득하여 사용한다.

데이터베이스
  : 읽기 전용 트랜잭션의 경우 읽기만 하면 되므로, 내부에서 성능 최적화가 발생한다.

 

트랜잭션 전파

트랜잭션인 로직 1에서 트랜잭션인 로직 2를 호출할 때, 로직 1을 `외부 트랜잭션`, 로직 2를 `내부 트랜잭션` 이라고 한다.

 

여기서 외부 트랜잭션과 내부 트랜잭션을 묶어 하나의 `물리 트랜잭션` 이라고 표현하고, 각각의 트랜잭션을 `논리 트랜잭션` 이라고 한다.

기본 동작 방식은 내부 트랜잭션은 외부 트랜잭션에 참여하는 것이다.

    물리 트랜잭션
    외부 트랜잭션
(논리 트랜잭션)
  내부 트랜잭션
(논리 트랜잭션)
클라이언트    로직 1    로직 2

`물리 트랜잭션` 은 실제 데이터베이스에 적용되는 트랜잭션 단위이다. (커넥션 획득 > 트랜잭션 시작 > commit / rollback) 

`논리 트랜잭션` 은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위이다.

 

`외부 트랜잭션` 은 처음 수행된 트랜잭션으로 신규 트랜잭션(`isNewTransaction=true`) 가 된다.

이때, 스프링은 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다.

 

`내부 트랜잭션` 은 이미 진행 중인 외부 트랜잭션에 참여하며, 신규 트랜잭션이 아니다.(`isNewTransaction=false`)

 

트랜잭션 전파의 원칙

- 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.

- 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.

 

외부 트랜잭션 커밋 > 내부 트랜잭션 커밋 > 물리 트랜잭션 전체 커밋

- 외부 트랜잭션 : 요청

1. `txManager.getTransaction()` 을 호출하여 외부 트랜잭션을 시작한다.

2. 트랜잭션 매니저는 DataSource 를 통해 커넥션을 생성하고, 커넥션을 수동 커밋 모드로 설정하여 물리 트랜잭션을 시작한다.

3. 트랜잭션 매니저는 동기화 매니저에 커넥션을 보관한다.

4. 트랜잭션 매니저는 트랜잭션 생성한 결과를 `TransactionStatus` 에 담아 반환하는데 여기에 `isNewTransaction=true` 의 정보가 들어있다.

5. 로직 1이 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 커넥션을 획득해서 사용한다.

- 내부 트랜잭션 : 요청

6. `txManager.getTransaction()` 을 호출하여 내부 트랜잭션을 시작한다.

7. 트랜잭션 매니저는 트랜잭션 동기화 매니저를 통해 기존 트랜잭션이 존재하는지 확인하고, 존재하므로 기존 트랜잭션에 참여한다.

8. 기존 트랜잭션이 사용하던 커넥션이 보관된 트랜잭션 동기화 매니저에서 해당 커넥션을 꺼내 사용하게 된다.

9. 트랜잭션 매니저는 트랜잭션을 생성한 결과를 `TransactionStatus` 에 담아 반환하는데 여기에 `isNewTransaction=false` 의 정보가 들어있다.

10. 로직 2가 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 외부 트랜잭션이 보관한 커넥션을 획득해서 사용한다.

- 내부 트랜잭션 : 응답

11. 로직 2가 끝나고, 트랜잭션 매니저를 통해 내부 트랜잭션을 커밋한다.

12. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부를 확인하고, 신규 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않고, 트랜잭션 매니저에 논리 커밋을 한다.

- 외부 트랜잭션 : 응답

13. 로직 1이 끝나고, 트랜잭션 매니저를 통해 외부 트랜잭션을 커밋한다.

14. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부를 확인하고, 신규 트랜잭션이기 때문에 DB 커넥션에 실제 커밋을 호출하여, 실제 커넥션에 물리 커밋을 한다.

 

외부 트랜잭션 롤백 > 내부 트랜잭션 커밋 > 물리 트랜잭션 전체 롤백

외부 트랜잭션 요청과 내부 트랜잭션 요청은 위와 동일하다.

- 내부 트랜잭션 : 응답

1. 로직 2가 끝나고, 트랜잭션 매니저를 통해 내부 트랜잭션을 커밋한다.

2. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부를 확인하고, 신규 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않고, 트랜잭션 매니저에 논리 커밋을 한다.

- 외부 트랜잭션 : 응답

3. 로직 1이 끝나고, 트랜잭션 매니저를 통해 외부 트랜잭션을 롤백한다.

4. 트랜잭션 매니저는 롤백 시점에 신규 트랜잭션 여부를 확인하고, 신규 트랜잭션이기 때문에 DB 커넥션에 실제 롤백을 호출하여, 실제 커넥션에 물리 롤백을 한다.

 

외부 트랜잭션 커밋 > 내부 트랜잭션 롤백 > 물리 트랜잭션 전체 롤백

외부 트랜잭션 요청과 내부 트랜잭션 요청은 위와 동일하다.

- 내부 트랜잭션 : 응답

1. 로직 2가 끝나고, 트랜잭션 매니저를 통해 내부 트랜잭션을 롤백한다.

2. 트랜잭션 매니저는 롤백 시점에 신규 트랜잭션 여부를 확인하고, 신규 트랜잭션이 아니기 때문에 실제 롤백을 호출하지 않고, 트랜잭션 매니저에 논리 롤백을 한다.

3. 트랜잭션 동기화 매니저에 `rollbackOnly=true` 라는 표시를 해둔다.

- 외부 트랜잭션 : 응답

4. 로직 1이 끝나고, 트랜잭션 매니저를 통해 외부 트랜잭션을 커밋한다.

5. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부를 확인하고, 신규 트랜잭션이기 때문에 DB 커넥션에 실제 커밋을 호출하기 전에, 트랜잭션 동기화 매니저에 롤백 전용(`rollbackOnly=true`) 표시가 있는지 확인한다.

6. 롤백 전용 표시가 있으므로 물리 트랜잭션을 커밋하는 것이 아니라 롤백하여, 실제 데이터베이스에 물리 롤백이 반영된다.

7. 시스템에는 커밋을 호출했지만, 롤백이 되었기 때문에 스프링은 `UnexpectedRollbackException` 런타임 예외를 던져, 기대하지 않은 롤백이 발생했다고 알려준다.

(`rollbackOnly` 인 상황에서 커밋이 발생하면 `UnexpectedRollbackException` 예외가 발생한다.)

 

REQUIRES_NEW - 외부 트랜잭션 커밋 > 내부 트랜잭션 롤백 > 물리 트랜잭션 롤백 > 물리 트랜잭션 커밋

외부 트랜잭션과 내부 트랜잭션을 분리하여 별도의 물리 트랜잭션을 사용하는 방법이다.

따라서 내부 트랜잭션에 문제가 발생해서 롤백해도, 외부 트랜잭션에 영향을 주지 않는다. (반대도 마찬가지이다.)

 

하지만 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 되므로 성능상 주의하여 사용해야 한다.

 

- 외부 트랜잭션 : 요청

1. `txManager.getTransaction()` 을 호출하여 외부 트랜잭션을 시작한다.

2. 트랜잭션 매니저는 DataSource 를 통해 커넥션을 생성하고, 커넥션을 수동 커밋 모드로 설정하여 물리 트랜잭션을 시작한다.

3. 트랜잭션 매니저는 동기화 매니저에 커넥션을 보관한다.

4. 트랜잭션 매니저는 트랜잭션 생성한 결과를 `TransactionStatus` 에 담아 반환하는데 여기에 `isNewTransaction=true` 의 정보가 들어있다.

5. 로직 1이 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 커넥션을 획득해서 사용한다.

- 내부 트랜잭션 : 요청

6. `REQUIRES_NEW` 옵션과 함께 `txManager.getTransaction()` 을 호출하여 내부 트랜잭션을 시작한다.

7. 트랜잭션 매니저는 `REQUIRES_NEW` 옵션을 확인하고, 기존 트랜잭션에 참여하는 것이 아니라 새로운 트랜잭션을 시작한다.

8. 트랜잭션 매니저는 DataSource 를 통해 커넥션을 생성하고, 커넥션을 수동 커밋 모드로 설정하여 물리 트랜잭션을 시작한다.

9. 트랜잭션 매니저는 동기화 매니저에 커넥션을 보관한다. 이때는 `con1` 은 보류되고, 내부 트랜잭션을 완료할 때까지 `con2` 가 사용된다.

10. 트랜잭션 매니저는 트랜잭션 생성한 결과를 `TransactionStatus` 에 담아 반환하는데 여기에 `isNewTransaction=true` 의 정보가 들어있다.

11. 로직 2가 사용되고, 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 `con2` 커넥션을 획득해서 사용한다.

- 내부 트랜잭션 : 응답

12. 로직 2가 끝나고, 트랜잭션 매니저를 통해 내부 트랜잭션을 롤백한다.

13. 트랜잭션 매니저는 롤백 시점에 신규 트랜잭션 여부를 확인하고, 신규 트랜잭션이기 때문에 DB 커넥션에 실제 롤백을 호출한다.

14. 내부 트랜잭션이 `con2` 커넥션의 물리 트랜잭션을 롤백하여 트랜잭션이 종료되며, `con2` 커넥션은 종료되거나 커넥션 풀에 반납된다.

15. 이후 `con1` 의 보류가 끝나고 다시 `con1` 을 사용한다.

- 외부 트랜잭션 : 응답

16. 로직 1이 끝나고, 트랜잭션 매니저를 통해 외부 트랜잭션을 커밋한다.

17. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부를 확인하고, 신규 트랜잭션이기 때문에 DB 커넥션에 실제 커밋을 호출하기 전에, 트랜잭션 동기화 매니저에 롤백 전용(`rollbackOnly=true`) 표시가 있는지 확인한다.

18. 롤백 전용 표시가 없으므로 `con1` 커넥션을 통해 물리 트랜잭션을 커밋하여 트랜잭션이 종료되며, `con1` 커넥션은 종료되거나, 커넥션 풀에 반납된다. 

 

전파 옵션

`REQUIRED` : 기본 설정으로, 트랜잭션이 없으면 생성하고 있으면 참여한다.

`REQUIRED_NEW` : 항상 새로운 트랜잭션을 생성한다.

`SUPPORT` : 트랜잭션을 지원하는 뜻으로, 트랜잭션이 없으면 없는대로 하고 있으면 있는 대로 참여한다.

`NOT_SUPPORT` : 트랜잭션을 지원하지 않는다는 뜻으로, 트랜잭션 없이 진행한다. (기존 트랜잭션이 있으면 보류한다.)

`MANDATORY` : 의무사항으로, 트랜잭션이 없으면 `IllegalTransactionStateException` 예외가 발생한다.

`NEVER` : 트랜잭션을 사용하지 않는다는 의미로, 트랜잭션이 있으면 `IllegalTransactionStateException` 예외가 발생한다.

`NESTED` : 트랜잭션이 없으면 새로운 트랜잭션을 생성하고, 있으면 중첩 트랜잭션을 만든다.

(중첩 트랜잭션은 외부 트랜잭션의 영향을 받지만, 외부에 영향을 주지 않는다.

중첩 트랜잭션이 롤백되어도 외부 트랜잭션은 커밋할 수 있고, 외부 트랜잭션이 롤백되면 중첩 트랜잭션도 함께 롤백된다.)

(JDBC + DataSourceTransactionManager 조합에서만 지원되며, JPA 환경에서는 지원하지 않는다.)

 

트랜잭션 전파에서 `isolation`, `timeout`, `readOnly` 옵션은 트랜잭션 시작 시점에만 적용되고, 참여하는 경우에 적용되지 않는다.

 

트랜잭션 주의사항 1

@Slf4j
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);
  }
}

`external()` 은 트랜잭션이 없고, `internal()` 은 @Transactional 을 통해 트랜잭션을 적용한다.

 

`external()` 실행

1. 트랜잭션 없이 시작하고, 내부에서 @Transactional 이 있는 `internal()` 을 호출한다.

2. 이때 `this.internal()` 을 호출하는 것이기 때문에, 여기서 this 는 실제 대상 객체(`target`) 의 인스턴스를 뜻한다.

3. 결과적으로 `target.internal()` 을 호출한 것이기 때문에, 트랜잭션 프록시가 적용되지 않아 결과적으로 @Transactional 이 있어도 트랜잭션이 적용되지 않는다.

 

정리하자면,

대상 객체의 내부에서 메서드 호출이 발생하면, 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생하는 것이다.

 

이러한 문제를 해결하는 가장 간단한 방법은 트랜잭션을 적용하는 메서드를 별도의 클래스로 분리하는 것이다.

 

트랜잭션 주의사항 2

@Slf4j
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={}", isActive);
  }
}

 

`initV1()` 은 @PostConstruct 로 초기화를 하며 @Transactional 을 통해 트랜잭션을 실행한다.

 

여기서,

@Transactional 을 @PostConstruct 과 사용할 경우,

트랜잭션 AOP 같은 부분이 다 처리 되지 않은 시점에 호출되어 트랜잭션이 적용되지 않는 문제가 발생한다.

 

따라서 이러한 경우 @PostConstruct 대신 `@EventListener(ApplicationReadyEvent.class)` 를 사용하여

AOP 를 포함한 스프링 컨테이너가 완전히 초기화된 이후 호출될 수 있도록 하면 트랜잭션이 잘 적용된다.

 

 

 

 

 

 

 

 

 

출처 | 스프링 DB 2(김영한) - 인프런

 
728x90