Spring Model/05.Service

@Transactional의 속성 1

  • -

이번 포스트에서는 @Transactional의 속성들에 대해서 좀 깊게 살펴보자.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
	Propagation propagation() default Propagation.REQUIRED;
	Isolation isolation() default Isolation.DEFAULT;
	int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
	boolean readOnly() default false;
	Class<? extends Throwable>[] rollbackFor() default {};
	String[] rollbackForClassName() default {};
	Class<? extends Throwable>[] noRollbackFor() default {};
	String[] noRollbackForClassName() default {};
}

 

전파(Propagation) 속성

 

T.X의 전파와 예외의 전파는 다르다!

우선 전파 속성을 알아보기 위해 먼저 정리할 점은 T.X의 전파 속성이란 서로 다른 빈에 속한 @Transactional 메서드들이 호출될 때 T.X 속성이 어떻게 전파되는가?를 나타내며 예외의 전파와는 무관하다는 점이다. 일반적으로 @Transactional은 RuntimeException이 발생했을 때 rollback 하고 나머지 상황에서 commit 하는데 간혹 예외와 T.X를 같이 생각해서 헷갈리는 경우가 많다. 

위의 경우에서 BeanB의 method에서 BeanA의 method를 호출하는데 BeanA.method는 ArithmeticException이 발생하고 있고 BeanB의 method에서는 BeanA.method를 호출하면서 try~catch로 예외를 잡고 있다. 

이 상황에서 BeanA.method는 예외가 있으므로 당연히 rollback 된다. 그럼 BeanB.method는 어떻까? 예외가 없어졌으므로 commit 될까? 아니면 여전히 rollback 될까? 이런 질문에 대한 답이 바로 전파 속성이다.

 

전파 속성(Propagation)

전파속성은 어떤 @Transactional 메서드에서 다른 @Transactional 메서드를 호출하는 경우 어떻게 트랜젝션이 전파될 것인지를 결정하는 속성이다.

다음의 관계에서 Controller 1이 BeanA.method()를 호출하면 T.X 1이 시작한다.  역시 Controller 2가 BeanB.method()를 호출하면 T.X 2가 시작된다. 그런데 BeanB.method()에서 다시 BeanA.method()을 호출한다면 T.X2와 T.X1의 관계는 어떻게 될까?

스프링에서는 이런 상황을 처리하기 위해서 7가지 전파 속성을 지원한다.

전파 속성
(BeanA.method)
Controller 1 > BeanA.method
Controller 2 > BeanB.method>
BeanA.method
Propagation.REQUIRED 새로운 Tx 시작 BeanB.methodTx에 참여
Propagation.REQUIRES_NEW 새로운 Tx 시작 새로운 Tx 시작
Propagation.SUPPORTS Tx 시작하지 않음 BeanB.methodTx에 참여
Propagation.MANDATORY 예외 발생: 호출할 때 이미 TX에 포함되어야 함 BeanB.methodTx에 참여
Propagation.NESTED Tx 시작 내부에 새로운 Tx 시작
Propagation.NEVER Tx 처리하지 않음 예외 발생
Propagation.NOT_SUPPORTED Tx 처리하지 않음 Tx 처리하지 않음

이제 BeanA.method에 다양한 전파 속성이 설정된 상태에서 발생한 T.X1과 T.X2가 어떻게 동작하는지 살펴보자.

 

속성별 동작

 

가장 많이 사용되는 것인 PROPAGATION_REQUIRED와 그나마 PROPAGATION_REQUIRED_NEW가 사용되므로 이 둘에 촛점을 두고 공부해보자.

먼저 2개의 빈을 준비해보자.

@Service
public class TodoService1 {
    @NonNull
    private TodoRepository repo;
    @NonNull
    private TodoService2 service2;

    @Transactional
    public Todo regist(Todo todo, Propagation mode) {
        repo.save(todo);
        log.debug("service2 호출--------------------------------");
        try {
            if (mode.equals(Propagation.REQUIRED)) {
                service2.deletePropagationRequired(1L);
            } else if (mode.equals(Propagation.REQUIRES_NEW)) {
                service2.deletePropagationRequiresNew(1L);
            }
        } catch (ArithmeticException e) {
            log.error("예외 처리: {}", e.getMessage());
        }
        log.debug("service2 종료--------------------------------");
        return todo;
    }
}
@Service
public class TodoService2 {

    @NonNull
    private TodoRepository repo;


    @Transactional(propagation = Propagation.REQUIRED)
    public void deletePropagationRequired(Long id) {
        int i = 1 / 0;
        repo.deleteById(id);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deletePropagationRequiresNew(Long id) {
        int i = 1 / 0;
        repo.deleteById(id);
    }
}

 

Propagation.REQUIRED

이 속성은 직접 호출되는 @Transactional에서는 새로운 T.X가 시작되고 다른 T.X에서 호출되는 경우는 기존 T.X에 참여한다. 결국 하나의 T.X가 구성되어 전체적으로 commit/rollback 된다.

@Autowired
TodoService1 service;

@Test
public void propagationRequiredTest() {
    log.debug("start※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※");
    Todo sample = Todo.builder().content("test todo").build();
    // service1은 service2의 rollback에 의해 rollback 됨
    assertThrows(UnexpectedRollbackException.class, () 
                   -> service.regist(sample, Propagation.REQUIRED));
    log.debug("end※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※");
    // 다시 조회 - 조회 결과는 없음
    assertThrows(NoSuchElementException.class, () -> service.select(sample.getId()));
}

위 테스트는 TodoService2의 Propatation.REQUIRED 속성이 설정된 메서드를 테스트해본 내용이다. TX2에서 TX1을 호출하고 TX1이 Rollback 되면서 TX2도 Rollback 했기 때문에 TX2에서 등록했던 sample은 존재하지 않는다.

start※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※
# TodoService1.regist에서 새로운 T.X를 생성
Creating new transaction with name [com.quietjun.example.todo.service.TodoService1.regist]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

service2 호출--------------------------------
# Service2의 T.X는 기존 T.X에 참여 - 새로운 T.X를 시작하지 않음
# i=1/0에 의해 예외 발생 - Rollback 해야 한다고 표시함
Participating transaction failed - marking existing transaction as rollback-only
예외 처리: / by zero
service2 종료--------------------------------
end※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※

 

Propagation.REQUIRES_NEW

이 속성은 기존 T.X에 참여하지 않고 일시 정지 시킨 후 새로운 T.X를 시작한다. 즉 별도의 T.X를 구성하기 때문에 하나가 Rollback 되어도 다른 하나에 영향을 주지 않는다.

 

@Test
public void propagationRequiredNewTest() {
    log.debug("start※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※");
    Todo sample = Todo.builder().content("test todo").build();
    // service2와 service1의 T.X는 다르므로 service1은 rollback 되지 않음
    service.regist(sample, Propagation.REQUIRES_NEW);
    log.debug("end※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※");
    // 다시 조회 - 첫 번째 TX는 잘 조회됨
    log.debug("조회 결과: {}", service.select(3L));   
    assertNotNull(service.select(3L));
}

위 테스트는 TodoService2의 Propatation.REQUIRES_NEW 속성이 설정된 메서드를 테스트해본 내용이다. TX2에서 TX1을 호출하고 TX1이 Rollback 되지만 어차피 별개의 T.X이기 때문에 TX2는 Rollback 되지 않는다. 따라서 TX2에서 등록했던 sample는 잘 살아있다.

start※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※
# TodoService1.regist에서 새로운 T.X 시작
Creating new transaction with name [com.quietjun.example.todo.service.TodoService1.regist]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

service2 호출--------------------------------
# 기존 T.X 일시 정지 및 TodoService2.registPropagationRequiresNew에서 새로운 T.X 시작
Suspending current transaction, creating new transaction with name [com.quietjun.example.todo.service.TodoService2.registPropagationRequiresNew]
# Rollback
Rolling back JPA transaction on EntityManager [SessionImpl(181083292<open>)]
# 일시 정지했던 T.X 다시 시작
Resuming suspended transaction after completion of inner transaction
예외 처리: / by zero
service2 종료--------------------------------
end※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※※

 

 

Propagation.NESTED

이 속성은 기존의 T.X 내에 독립적인 새로운 T.X를 구성한다.

이 기능은 JDBC의 SavePoint를 이용하는데 사용하는 JDBC Driver가 이를 지원하는지 확인해야 하고 결정적으로 JPA에서는 지원되지 않는다. 따라서 많이 사용되지는 않지만 좀 특이해서 따로 정리해본다.

Propagation.NESTED에서는 만약 자식 T.X가 롤백 되더라도 부모 T.X에는 영향을 주지 않지만 부모 T.X가 롤백 되면 자식 T.X는 따라서 롤백 된다. 

 

참고로 mysql에서 mybatis를 이용할 때는 과정이 잘 테스트 된다.

@Transactional
public void regist(Book book, int flag) {
    bookDao.insert(book);                     // 메인책을 한권 넣어본다.
    try {
        book.setIsbn(book.getIsbn()+"-1");
        temp.regist(book, flag);              // 두 번째 T.X 호출
    }catch(RuntimeException e) {
        e.printStackTrace();
    }
    if(flag==0) {
        throw new RuntimeException("첫 번째 T.X에서 예외");
    }
}
@Transactional(propagation = Propagation.NESTED)
public void regist(Book book, int flag) {
    bookDao.insert(book);                           // 서브 책을 넣어본다.
    if(flag==1) {
        throw new RuntimeException("중첩 T.X 예외 발생");
    }
}

먼저 첫 번째 T.X에서 예외가 발생하는 경우를 살펴보자.

@Test
@DisplayName("첫 번째 T.X에서 예외가 발생하는 경우: 외부 T.X가 롤백 되므로 책은 증가되지 않음")
public void nestedTest1() {
    int size = dao.search().size();
    Book book = Book.builder().isbn("1").title("2").author("3").price(100).build();
    assertThrows(RuntimeException.class, () -> service.regist(book, 0));
    assertEquals(size, dao.search().size());
}

이 경우는 외부의 T.X에서 오류가 발생했으므로 최종적인 데이터의 개수는 변화가 없다.

Creating new transaction with name [com.ssafy.book.model.service.BookServiceImpl.regist]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Creating nested transaction with name [com.ssafy.book.model.service.TempService.regist]
Rolling back JDBC transaction on Connection [201804812]
Releasing JDBC Connection [201804812]

다음으로 두 번째 T.X에서 예외가 발생하는 경우이다.

@Test
@DisplayName("두 번째 T.X에서 예외가 발생하는 경우: 내부 T.X만 롤백 되므로 외부 T.X에서 1권 증가")
public void nestedTest2() {
    int size = dao.search().size();
    Book book = Book.builder().isbn("1").title("2").author("3").price(100).build();
    service.regist(book, 1); // 두 번째 T.X 예외 발생
    assertEquals(size + 1, dao.search().size());
}

이 경우 내부의 T.X만 rollback 되기 때문에 외부 T.X가 추가한 데이터는 잘 유지된다.

Creating new transaction with name [com.ssafy.book.model.service.BookServiceImpl.regist]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Creating nested transaction with name [com.ssafy.book.model.service.TempService.regist]
Rolling back transaction to savepoint
java.lang.RuntimeException: 중첩 T.X 예외 발생
Initiating transaction commit # 외부 T.X는 commit
Committing JDBC transaction on Connection [560465923]
Releasing JDBC Connection [560465923]

마지막으로 아무런 예외가 발생하지 않았을 때이다.

@Test
@DisplayName("예외가 발생하지 않는 경우: 2권의 도서 추가 확인")
public void nestedTest3() {
    int size = dao.search().size();
    Book book = Book.builder().isbn("1").title("2").author("3").price(100).build();
    service.regist(book, 3); // 정상
    assertEquals(size + 2, dao.search().size());
}

당연히 rollback도 없다.

Creating new transaction with name [com.ssafy.book.model.service.BookServiceImpl.regist]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Creating nested transaction with name [com.ssafy.book.model.service.TempService.regist]
Committing JDBC transaction on Connection [560465923]
Releasing JDBC Connection [560465923]

 

지금까지 스프링의 @Transactional이 가지는 Propagation 속성에 대해서 살펴보았다. 나머지 SUPPORTS, MANDATORY, NEVER, NOT_SUPPORTED는 쉽게 의미를 파악할 수 있으므로 상세 설명은 생략한다.

'Spring Model > 05.Service' 카테고리의 다른 글

@Transactional의 속성 2  (0) 2023.10.23
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.