개발자로 후회없는 삶 살기

spring PART.트랜잭션 전파 기본 본문

[백엔드]/[spring | 학습기록]

spring PART.트랜잭션 전파 기본

몽이장쥰 2023. 5. 22. 10:55

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com:443

 

본론

- 스프링 트랜잭션 전파

트랜잭션이 둘 이상이 있을 때 어떻게 동작하는지 알아보고 전파를 알아봅니다. 스프링 동작 원리도 더 깊이있게 이해할 수 있습니다.

 

> 트랜잭션을 사용 중인데 그 안에서 또 트랜잭션을 쓰는 복잡한 경우에 스프링이 이를 어떻게 해결하는지 알아봅니다. 이를 이해하면 스프링의 트랜잭션을 완성할 수 있습니다.

 

- 구현

@Autowired
private  PlatformTransactionManager txManager;

@TestConfiguration
static class config {
    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

// 자동 주입
@Autowired
private PlatformTransactionManager platformTransactionManager;

플랫폼 매니저를 주입받고 config에 데이터 소스 트랜잭션 매니저를 직접 등록합니다. 원래는 스프링이 자동 등록해주지만 직접 등록한 상황입니다.

 

@Test
void commit() {
    TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
    txManager.commit(status);
}

@Test
void roll() {
    TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
    txManager.rollback(status);
}

1. 커밋 테스트

트랜잭션을 시작하여 status를 가져옵니다. 커밋합니다. 스프링 트랜잭션이 이렇게 커밋 동작을 했었습니다. 이를 통해서 트랜잭션 시작할 때 커낵션을 가져오고 커밋이 끝나면 되돌려주는 것이었습니다.

2. 롤백 테스트

똑같이 동작합니다. 커낵션을 가져오고 롤백하고 커낵션을 반납합니다. 지금까지 한 내용 그대로입니다. 여기서 중요한 것은 커밋이나 롤백을 하면 커낵션을 반납하고 끝나는 것입니다.

 

- 트랜잭션 두 번 사용

여기에 트랜잭션을 하나 더 추가해봅니다. 이번엔 트랜잭션이 각각 따로 사용되는 경우를 확인해봅니다. 이 예제는 트랜잭션 1이 완전히 끝나고 2가 수행되는 예제입니다.

 

-> 구현

@Test
void double_commit_rollback() {
    log.info("트랜잭션1 시작");
    TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션1 커밋");
    txManager.commit(tx1);

    log.info("트랜잭션2 시작");
    TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션2 롤백");
    txManager.commit(tx2);
}

그냥 2번 사용하는 것입니다. 트랜잭션 1을 시작해서 커밋하고 2를 시작하고 커밋합니다. 실행해보면 기대했던 대로 그냥 1 시작하고 커밋하고 커낵션 돌려주고 2 시작하고 커낵션 획득하고 커밋하고 종료합니다.

 

- 정리

로그를 보면 트랜잭션 1, 2가 같은 conn0를 사용했습니다. 커낵션 풀이 아니었으면 0, 1을 썼을 것입니다. 히카리 풀을 썼기에 커낵션 1이 커낵션을 사용하고 완전히 풀에 반납하고 반납한 0번을 커낵션 2가 사용해서 그렇습니다. 완전히 다른 커낵션이라서 그렇습니다.

 

> conn0를 구분하는 방법이 있습니다. 히카리 풀에서 커낵션을 획득하면 실제 커낵션을 그대로 반환하는 것이 아니라 내부 관리를 위해서 히카리 프록시 커낵션을 생성하고 반환합니다. 그 안에 실제 커낵션 conn0가 포함되어있는데 이때 생성할 때 객체의 주소가 다릅니다. 내부 물리 커낵션은 같은데 프록시 객체가 다른 것으로 이 주소로 각각의 커낵션에서 새로 조회한 커낵션이라는 것을 구분할 수 있습니다.

 

-> 그림

클라가 트랜잭션 1, 2를 순서대로 사용합니다. 클라가 트랜잭션 코드를 호출하면 내부에서 conn을 획득합니다. 그리고 로직에서 이 커낵션을 사용하고 커밋하고 반납합니다.

+ 끝나면 트랜잭션 2로 동일하게 동작합니다. 트랜잭션이 각각 사용되면서 당연히 세션, 커낵션이 달라서 conn이 다릅니다. 풀을 쓰기에 썼다가 반납하고 반납한 것을 쓴 것입니다. 커밋하고 롤백해보면 당연히 다른 데이터로 커밋하고 롤백합니다. 중점은 결국 트랜잭션 1, 2이 다른 커낵션을 쓰는 것입니다.

 

- 전파

지금까지는 2개의 트랜잭션을 썼지만 각각 사용해서 둘이 사용이 따로 되어서 전혀 관련이 없습니다. 그러면 트랜잭션을 이미 하고 있는데 그 안에서 또 트랜잭션을 하면 어떻게 될까요? 

 

> 기존 트랜잭션과 별도의 트랜잭션을 진행할까 아니면 기존 트랜잭션을 이어받아서 할 지 애매합니다. 트랜잭션이 이미 되고 있고 커밋을 하지 않았는데 그 안에서 또 트랜잭션이 중첩해서 시작해버린 것입니다. 이런 경우 어떻게 동작할지 결정하는 것을 트랜잭션 전파라고 합니다.

 

-> 그림

예제를 통해 알아봅니다. 외부 트랜잭션이 수행중인데 내부에서 또 트랜잭션이 추가 수행된 경우입니다. 외부 트랜잭션에서 커밋이랑 롤백을 안했는데 (아직 안 끝난 상황) 또 발생한 것입니다. 이럴 때 어떻게 해야할까요? 외부가 먼저 시작한 트랜잭션, 내부가 도중에 시작한 트랜입니다.

 

> 스프링은 이 경우 외부와 내부를 묶어서 하나로 만들고 내부가 외부에 참여합니다. 내부에서 외부의 트랜잭션을 그대로 참여해서 동작하여 하나의 트랜잭션으로 묶여서 사용됩니다. 이게 기본이고 옵션으로 다르게 동작할 수 있습니다.

 

-> 물리, 논리 트랜잭션

이해를 돕기 위해 물리와 논리를 나눕니다. 논리 트랜잭션들은 하나의 물리 트랜잭션으로 묶입니다. 하나의 물리 트랜잭션이 있고 그 안에 여러 논리 트랜잭션이 있는 것입니다. 물리 트랜잭션은 우리가 실제 db에 적용하는 트랜잭션을 뜻하고 실제 커낵션을 통해서 트랜잭션을 시작(set auto commit(false))하고 실제 커낵션을 커밋, 롤백하는 단위인 우리가 일반적으로 생각하는 것이 물리 트랜잭션입니다.

 

> 논리는 트랜잭션 매니저를 통해 관리되고 사용하는 단위로 계속 있는 개념이 아니고 트랜잭션이 진행되는 중에 내부에 추가로 트랜잭션이 사용되는 경우에 이런 개념이 나타납니다. 단순히 트랜잭션이 하나인 경우 물리와 논리를 구분하지 않고 그냥 알고 있는 트랜잭션처럼 생각하면 됩니다. 위에 2개를 각각 사용하는 경우에도 물리, 논리를 구분하지 않습니다.

 

+ 지금처럼 외부와 내부가 있는 경우 전체가 물리 트랜잭션, 안에 하나하나를 논리 트랜잭션라고 말합니다.

 

-> 왜 나눌까요?

트랜잭션이 사용중일 때 또 다른 트랜잭션이 발생하면 여러가지 복잡한 상황이 생깁니다. 무슨 얘기냐면 내부에서 커밋하면 외부에서도 커밋하거나 롤백해야하나? 각 논리 트랜잭션이 다 따로따로 커밋이나 롤백을 하면 어디까지 커밋, 롤백해야하나 등등 복잡한 상황이 너무 많습니다.

 

> 그래서 이렇게 일단 하나의 물리를 일반적인 트랜잭션으로 생각하고 각각의 트랜잭션을 다양한 상황에서 커밋, 롤백하는 단위라고 구분합니다. 다양한 상황에서는 논리 트랜잭션을 다뤄야할 것입니다. 논리 트랜잭션을 도입하면 엄청 단순한 원칙을 만들 수 있습니다.

 

-> 대원칙

모든 논리 트랜이 커밋되어야 물리 트랜이 커밋된다.
하나라도 논리 트랜이 롤백되면 물리 전체가 롤백된다. 

모든 트랜잭션이 커밋되어야 전체가 다 커밋되고 하나라도 롤백하면 전체가 다 롤백됩니다. 이렇게 간단한 대원칙을 만들 수 있습니다. ★ 이게 기본 원칙으로 트랜잭션안에서 트랜잭션이 시작하면 전체가 물리로 묶이지만 내부는 논리 트랜잭션이 생깁니다. 그런데 이 중에서 이게 커밋이되려면 전부 다 커밋이 되어야하고 하나라도 롤백되면 전체가 다 롤백됩니다.

 

모든 논리 트랜잭션이 커밋해야 전체가 커밋되고

 

하나라도 롤백되면 전체가 롤백됩니다.

 

- 전파 예제

예제 코드로 자세히 알아봅니다. 클라가 요청하는데 외부 트랜잭션이 시작했는데 내부에서 또 트랜잭션이 만들어지고 둘 다 커밋 하여 전체가 커밋되는 상황을 알아보겠습니다.

 

-> 테스트

@Test
void inner_commit() {
    log.info("외부 트랜 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNes = {}", outer.isNewTransaction());

    log.info("내부 트랜 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("inner.isNes = {}", inner.isNewTransaction());

    log.info("내부 트랜 커밋");
    txManager.commit(inner);

    log.info("외부 트랜 커밋");
    txManager.commit(outer);
}

외부 트랜잭션을 먼저 시작하고 outer라고 표현하겠습니다. outer에 isNew 개념이 있는데 이게 처음 수행된 트랜잭션인지 물어봐서 외부는 처음이니 맞습니다.

 

> 내부 트랜잭션을 만듭니다. 이러면 트랜잭션을 하고 있는데 또 트랜잭션이 시작된 것입니다. 내부와 외부라고 해서 진짜 안에서 일어나야 하는 것 아니가 싶은데 아닙니다. 외부를 서비스, 내부를 레포처럼 계층별로 외부 내부일 수도 있지만 그냥 트랜잭션이 하고 있는데 커밋 또는 롤백 전에 또 트랜잭션을 시작하면 처음 시작한 게 외부와 나중에 시작한게 내부 트랜잭션입니다.

> inner는 isNew가 처음이 아니라서 F가 나올 것입니다. 이제 커밋합니다. 당연히 내부를 먼저 커밋해야하고 외부를 후에 커밋해야 합니다. 지금 모든 커밋을 다 해서 물리 전체가 커밋되게 만든 상황입니다.

 

-> 참여란?

이 경우 내부는 외부가 하고 있는데 시작되어서 내부가 외부에 참여합니다. 참여한다는 것은 내부가 외부 트랜잭션을 받아서 따른다는 것입니다. 다른 관점으로 보면 외부 트랜잭션의 범위가 내부까지 넓어진다는 것입니다. 정리하면 참여한다는 것은 외부 트랜잭션과 내부 트랜이 하나의 물리 트랜잭션으로 묶이는 것입니다.

 

> 그런데 코드를 잘 보면 커밋을 두 번합니다. 트랜잭션을 생각하면 하나의 트랜잭션이 한번 만 커밋하고 커밋이나 롤백을 하면 해당 트랜잭션이 끝난다고 했습니다. 어떻게 커밋을 두번이나 하는 걸까요?

 

- 실행 결과

실행해보면 외부가 시작하고 isNew가 T입니다. 내부 트랜잭션이 시작하고 Participating으로 참여합니다. 현재 존재하는 트랜잭션에 참여했다고 합니다. isNew가 F가 나오고 내부를 커밋합니다. 그러면 DB 커낵션에 커밋하고 끝나야하는것이 아닌가 싶은데 commit이라는 로그가 안 나옵니다.

 

원래는 커밋하면 init commit이라는 로그가 남았습니다. 여기에서는 아무일도 안하고 외부에서 트랜잭션하면 물리 DB에 커밋해서 끝나는 것입니다. 커밋이나 롤백하면 아예 커낵션을 끊어버리기 때문에 내부 트랜잭션인 외부에 참여하는 트랜잭션은 물리 커낵션에 대한 작업을 아예 하지 못하고 넘어가버립니다. 시작할 때도 외부를 시작할 때는 로그가 jdbc로부터 커낵션 얻고 이런게 많은데 내부를 할 때는 전혀 없습니다.

> 외부 커밋을 하면 실제 커밋이 되고 커낵션을 되돌려줍니다. 외부 트랜잭션만 시작하고, 커밋하는 것입니다. 내부 트랜잭션은 DB 커낵션을 커밋하면 안 되는 것이고 스프링은 이렇게 여러 트랜잭션을 함께 사용할 경우 처음 트랜잭션을 시작한 외부 트랜이 실제 물리 트랜잭션을 관리하도록 합니다.

 

- 동작 과정

-> 요청 흐름

 


1. 외부 요청 흐름 

처음에 외부 트랜잭션을 getTransaction으로 시작합니다. 트랜잭션 매니저가 데이터 소스를 통해서 커낵션 생성하고 수동 커밋 모드로 바꾸고 물리 트랜잭션을 시작합니다. 트랜잭션 매니저는 동기화 매니저에 커낵션을 보관하고 있습니다. 그리고 생성결과를 status(outer)에 담아서 보관하는데 여기에 isNew 여부가 담겨있고 트랜잭션을 처음 시작한 트랜잭션이라서 신규 트랜잭션입니다. 그리고 비즈 로직을 할 텐데 동기화 매니저의 커낵션을 통해서 사용하고 이때 트랜잭션은 계속 유지가 되고 있을 것입니다.

 

2. 내부 요청 흐름

외부를 하는 도중 커밋, 롤백 안하고 내부가 getTransaction으로 시작합니다. 트랜잭션 매니저는 getTransaction할 때  동기화 매니저를 통해 이미 기존 트랜잭션이 있는지 확인을 합니다. 항상 이런 과정을 거치는 것이고 외부일 때도 이런 과정을 거치는데 동기화에 가보니 커낵션이 없어서 새로 만드는 것입니다.

 

> 동기화에 보니 커낵션이 담겨있으면 트랜잭션이 이미 진행중인 것으로 기존 트랜잭션이 존재하기에 기존 트랜잭션에 참여합니다. 사실 기존 트랜잭션에 참여한다는 것은 아무것도 하지 않겠다는 의미입니다. 커낵션 획득도 못하고 커밋도 못합니다. 커낵션을 새로 만들면 아예 다른 세션으로 동작하는 것이기에 새로 만들면 안됩니다. 그래서 "Participating in existing transaction"라는 로그 하나만 남깁니다. 

+ 이미 물리 트랜잭션이 진행 중이므로 그냥 두면 이후 로직이 기존에 시작된 트랜잭션을 자연스럽게 사용하는 것이고 커낵션도 이미 있는 동기화 매니저의 커낵션을 가져도 씁니다. 그러면 이게 전체 하나의 물리 트랜잭션으로 묶이게 됩니다. 어떻게 보면 내부 트랜잭션 코드가 없다고 봐도 됩니다. 내부가 커낵션이고 뭐고 다 외부가 한 것을 가져다 쓰고 종료할 때도 커밋 못하고 꺼져서 그렇습니다.

 

-> 응답 흐름

 

1. 내부 응답 흐름

로직 2가 끝나고 내부 트랜잭션으로 끝났다고 응답이 옵니다. 그러면 내부 트랜잭션이 commit()을 호출하면 트랜잭션 매니저가 커밋 시점에 신규 트랜잭션 여부에 따라서 다르게 동작하고 이 경우 신규가 아니니 실제 커밋 호출을 안 합니다. 실제 커낵션에 커밋이나 롤백하면 물리 트랜잭션이 끝나버리기에 아직 트랜잭션이 안 끝났으니 절대로 실제 커밋을 호출하면 안 되고 물리 트랜잭션은 외부 트랜이 종료할 때까지 이어져야합니다.

 

> 그래서 대원칙에서 모든 트랜잭션이 커밋해야 물리도 커밋되는 것입니다. 내부는 아무것도 안하고 외부 커밋되고 합니다. 내부는 아무것도 안 하면 되어서 로그도 남지 않습니다.

 

2. 외부 응답 흐름

로직1이 끝나고 외부 트랜잭션을 커밋합니다. 트랜잭션 매니저는 커밋 시점에 신규 여부에 따라 다르게 동작하는데 outer는 T라서 물리 트랜잭션이라고 생각하여 DB 커낵션에 실제 커밋을 호출합니다. 실제 DB에 커밋이 반영되고 물리 커낵션도 끝납니다. 이렇게 동작하기에 commit() 코드를 두 번 작성해도 commit이 안되기 때문에 가능하고 참여가 가능한 것입니다.

 

- 핵심 정리

여기서 핵심은 트랜잭션 매니저에 커밋을 호출한다고 해서 항상 실제 커낵션에 물리 커밋이 발생하는 것이 아니라는 것입니다. 외부 커낵션만 물리 트랜잭션을 관리하고 커밋할 수 있습니다. 신규 트랜잭션이 외부 트랜잭션으로 신규 트랜잭션인 경우에만 실제 커낵션을 사용해서 물리 커밋과 롤백을 수행할 수 있고 신규 트랜이 아니면 실제 물리 커낵션을 사용하지 않습니다. 

 

> 트랜잭션이 내부에서 추가로 사용되면 트랜잭션 매니저가 신규인지 아닌지를 통해 논리 트랜잭션을 관리하고 모든 논리 트랜잭션이 커밋되면 물리 전체 트랜잭션이 커밋된다고 이해하면 됩니다.

 

- 외부 롤백

그러면 롤백은 어떻게 되나 보겠습니다. 내부는 커밋하는데 외부만 롤백하면 어떻게 되나 알아봅니다. 논리 트랜잭션이 하나라도 롤백되면 전체가 다 롤백된다고 했습니다. 내부 트랜잭션이 커밋해도 이 전체가 다 롤백됩니다.

 

- 테스트

@Test
void outer_rollback() {
    log.info("외부 트랜 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNes = {}", outer.isNewTransaction());

    log.info("내부 트랜 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("inner.isNes = {}", inner.isNewTransaction());

    log.info("내부 트랜 커밋");
    txManager.commit(inner);

    log.info("외부 트랜 롤백");
    txManager.rollback(outer);
}

외부트랜잭션을 시작하고 내부 트랜잭션을 시작하는데 내부트랜잭션 커밋하고 외부는 롤백합니다. 내부에서 커밋하고 나오는데 외부에서 롤백하는 상황이고 이러면 전체가 롤백되는데 어떻게 롤백되나 봅니다.

 

로그를 보면 외부 시작하고 커낵션 만들고 트랜잭션 시작하고 내부 트랜잭션이 시작되고 참여하고 트랜잭션 잭션 매니저가 관리를 해보니 트랜잭션 동기화 매니저에 외부 트랜잭션의 커낵션이 있어서 아무것도 안합니다. 내부는 그대로 외부가 수행한 것을 가져다 씁니다. 이게 참여라고 했습니다.

> 그 다음에 내부가 커밋해도 아무것도 안 한다고 했습니다. 물리 커낵션을 손대면 안된다고 했습니다. 그리고 외부 트랜잭션이 롤백되는데 실제 DB 트랜잭션을 롤백합니다. 내부는 외부에 어차피 참여한 것이라서 외부가 롤백하면 내부도 롤백됩니다. 내부에서 commit을 하든 insert를 하든 내부와 외부가 다 롤백됩니다. 절대로 내무 트랜잭션은 관여를 하지 않습니다.

 

-> 그림

1) 내부 트랜잭션 응답

요청 흐름은 앞서 배운 것과 같이 getconnection 시점에 매니저를 보는 것이고 이번에 응답 흐름을 봅니다. 내부에서 commit을 해서 트랜잭션 매니저에게 응답이 온 상황입니다. 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작하고 이 경우 신규가 아니라서 커밋을 하지 않습니다. 물리 트랜잭션은 외부 트랜잭션이 종료할 때 까지 이어집니다.

 

2) 외부 트랜잭션 응답

외부 로직에서 문제가 발생해서 롤백합니다. 트랜잭션 매니저에게 rollback()을 호출하면 롤백 시점에 신규 트랜잭션 여부에 따라 다르게 동작하는데 신규여서 실제 DB 커낵션의 롤백이 호출됩니다. 물리 롤백이 동작하고 실제로 DB에 반영이 됩니다. 위에서 배운 것과 비슷합니다.

 

- 내부 롤백

근데 내부 롤백은 다르게 동작합니다. 외부에서 커밋되는 상황입니다. 이 상황은 겉으로 보기에는 단순하지만, 실제로는 단순하지 않습니다. 내부는 롤백했지만 영향을 주지 않습니다. 그런데 외부는 커밋을 합니다. 지금까지 학습한 것을 보면 외부만 물리 트랜잭션에 영향을 줘서 롤백을 할 일이 생겼는데 그걸 무시하고 커밋해야할 것 같은 생각이 듭니다.

 

하지만, 대원칙에서 모든 논리 트랜잭션이 커밋되어야 커밋되고 하나라도 롤백되면 전체 롤백이 되니 지금 전체를 롤백해야합니다.

 

- 테스트

@Test
void inner_rollback() {
    log.info("외부 트랜 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNes = {}", outer.isNewTransaction());

    log.info("내부 트랜 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("inner.isNes = {}", inner.isNewTransaction());

    log.info("내부 트랜 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜 커밋");
    txManager.commit(outer);
}

이번에는 외부는 커밋하는데 내부는 롤백하는 시나리오입니다.

 

-> 실행 및 로그 설명

예외가 터집니다. rollback-only라는 게 마크가 되어있다고 합니다.

 

외부 시작하고 내부 시작하고 내부가 참여하는 것 같습니다. 내부 트랜잭션을 롤백하는데 참여한 트랜잭션이 실패했다고 합니다. 내부 트랜잭션에서 롤백할 때 rollback only라는 것을 현재 참여 중이 외부 트랜잭션에 마킹한답니다.

 

> 그러면서 setting으로 roll back 이라고 샛팅을 해둡니다. "롤백이 되어야 해"라는 의미입니다. 내부에서는 마크를 하고 외부 트랜잭션을 커밋합니다. 그랬더니 전체 트랜잭션은 roll back을 요구했는데 현재 트랜잭션이 커밋을 요구했다고 합니다. 외부에서 커밋을 했는데 어디선가 롤백을 해야한다고 표시를 해놔서 커밋을 하니 예외가 발생하고 결론적으로 표시 때문에 트랜잭션 매니저가 롤백을 해버립니다.

 

※ 정리

내부에서 롤백해서 다 롤백해야 하는데 그래서 표시를 했고 외부에서 커밋해서 예외가 발생했지만 결국 다 롤백했습니다. 이 부분을 실무에서 만날 수 있고 어려운 부분이니 이 개념을 잘 이해해야 합니다. 외부와 내부가 시작하는 것은 알겠습니다. 그리고 내부를 롤백했습니다.

 

해석해보면 내부 트랜잭션을 롤백하면 얘는 관여를 할 수 없어서 실제 물리 트랜잭션을 롤백하지 못합니다. 물리 롤백은 외부 트랜잭션만 롤백할 수 있기 때문입니다. 대신에 기존 트랜을 롤백 전용으로 표시합니다. "현재 트랜잭션이 나 때문에 끝이 났어, 롤백만 되야 해"라고 롤백 전용이라고 표시를 하는 것입니다.

> 외부 트랜잭션을 커밋하면 전체 트랜잭션이 롤백만 되어야한다고 예외가 뜨고 이건 커밋 못한다고 합니다. 어딘가 rollback only라고 표시를 한 겁니다. 그래서 외부가 커밋을 호출하지만, 전체 트랜잭션이 롤백 전용이라고 표시되어있어서 롤백이 되어버립니다.

 

-> 그림

1. 내부 롤백 응답

내부에서 롤백이 일어나고 외부에서 커밋한 시나리오 입니다. 내부에서 트랜잭션을 롤백합니다. 트랜잭션 매니저는 롤백 시점에 신규 여부를 보고 신규가 아니라서 실제 물리 트랜잭션에 롤백을 못합니다. 절대로 내부 트랜잭션은 실제 커낵션에 커밋이나 롤백을 못하고 하면 실제 커낵션이 끝나버리는 것을 알아야 합니다. 물리 트랜잭션은 외부 트랜이 종료할 때까지 이어져야 합니다. 

 

> 내부는 "나 롤백해야 하는데 물리 트랜잭션을 손을 못대"인 상황인데 롤백하는 대신에 트랜잭션 동기화 매니저에 rollbackOnly = True라고 표시를 합니다. 이 커낵션에 관련되어서 롤백 전용이라고 마크한 것입니다. "나 롤백해야 하는데 물리에 손을 못대네"하고 동기화 매니저에 마크를 담아둔 것입니다.

 

2. 외부 커밋 응답

그러면 롤백을 한게 아니고 내부가 영향을 준게 아니라서 외부 트랜잭션 로직으로 갑니다. 로직 1이 끝나고 트랜잭션 매니저가 외부 트랜잭션을 커밋합니다. 또한 신규 여부를 보는데 신규가 맞아서 실제 DB에 커밋을 호출합니다. 외부 트랜잭션이 실제 물리 트랜잭션이어서 가능했습니다.

> 근데 이때 매니저가 신규인 것을 확인하고 트랜잭션 동기화 매니저의 마크를 확인합니다. 트랜잭션 매니저가 동기화 매니저에 마크가 있나 확인하고 마크가 있으니 매니저는 "내부 트랜잭션에서 뭐 하나 터졌구나"하고 물리 트랜잭션을 커밋하면 안되겠구나 하고 롤백합니다. 대원칙을 봤을 때 모든 논리 트랜잭션이 다 커밋해야 커밋된다고 했고 하나라도 롤백이 있으면 롤백이라고 했기에 내부에서 일어난 롤백 때문에 롤백합니다. 외부에서 롤백이 일어나도 롤백했었습니다.

 

다만 외부는 물리 트랜을 관리하니 직접 rollback()으로 가능했었습니다. 내부에서 일어난 롤백은 외부까지 영향을 못 주며 외부까지 전달하기 위해 마킹을 하고 외부의 커밋 시점에 트랜 매니저가 신규 확인을 하면서 동기화 매니저를 봐서 결정하게 되는 것입니다.

 

ex) 예를 들어서 개발자가 이렇게 설계를 해놔서 고객은 주문이 성공했다고 생각했는데 실제로는 롤백이 되어서 주문이 생성되지 않은 상황에 외부에서 커밋을 하면 스프링이 이 경우 UnexpectedRollbackException를 런타임으로 던져서 커밋을 시도했지만 기대하지 않은 롤백이 일어났다고 명확하게 알려줍니다. @트랜잭션안에서 런타임 예외는 롤백한다는 것과 똑같이 됩니다.

 

- 정리

논리 트랜잭션이 하나라도 롤백하면 전체가 롤백되고 내부 논리 트랜잭션이 롤백하면 롤백 전용 마크를 남깁니다. 하나라도 표시가 있으면 그 트랜잭션은 끝난 것입니다. 외부 트랜잭션을 커밋할 때 동기화 매니저의 롤백 전용 마크를 확인하고 마크가 표시되어 있으면 예외를 던지고 전체 물리 트랜잭션을 롤백합니다. 대원칙이 정확히 성립합니다. 논리와 물리 트랜잭션 개념을 정의하면 대원칙을 이해하기가 쉬워집니다. 이렇게 기대한 결과가 다른 경우 예외를 발생시켜서 명확하게 문제를 알려주는게 좋은 개발과 설계입니다.

 

- REQUIRES_NEW

내부에서 롤백했지만 외부 트랜잭션을 롤백하지 않고 내부만 롤백하고 외부는 커밋하는 내부가 물리 트랜잭션에 영향을 줄 수 있는 방법, 둘이 마치 다른 트랜잭션처럼 동작하는 법을 알아봅니다.

외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 각각 별도의 물리 트랜잭션을 사용하게 하는 방법입니다. 그래서 커밋과 롤백도 각각 별도로 이뤄집니다. 이는 내부 트랜잭션에 문제가 발생해서 롤백해도  외부에 영향을 주지 않고 반대로 외부 트랜잭션에 문제가 발생해도 내부에 영향을 주지 않습니다.

 

-> 그림

작동 원리를 알아봅니다. 물리 트랜잭션을 분리하려면 내부 트랜잭션을 getconnection할 때 REQUIRES_NEW 라는 옵션을 사용하면 됩니다. 외부 트랜잭션과 각각 별도의 물리 트랜잭션을 가지게 되고 DB 커낵션을 따로 사용한다는 뜻으로 커낵션 1, 2 다른 커낵션을 사용하며 내부 트랜이 롤백되더라도 로직 1에 대한 것은 영향을 주지 않고 커밋됩니다. 그냥 대원칙을 따르지 않는 다는 것이라고 생각하면 됩니다.

 

- 테스트

@Test
void inner_rollback_new() {
    log.info("외부 트랜 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNes = {}", outer.isNewTransaction());

    log.info("내부 트랜 시작");
    DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
    definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    TransactionStatus inner = txManager.getTransaction(definition);
    log.info("inner.isNes = {}", inner.isNewTransaction());

    log.info("내부 트랜 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜 커밋");
    txManager.commit(outer);
}

외부 시작하고 내부 시작하는데 definition에 REQUIRES_NEW 옵션을 줄 수 있습니다. 기본이 REQUIRED로 기존 트랜잭션이 있으면 내부가 외부에 참여하는 것인데 New는 기존 트랜잭션이 있어도 무시하고 신규 트랜잭션을 만들어버려서 새로운 영역이 만들어집니다. 따라서 inner의 isNew가 True가 나옵니다.

 

내부를 롤백하고 외부는 커밋합니다. 원래는 이렇게 되면 마킹이 남아서 커밋 안된다고 예외가 터지고 전체 물리 트랜잭션이 롤백되었습니다. > 이제는 내부 트랜잭션 로직만 롤백이 되고 밖에는 손대지 않고 외부 트랜잭션 로직은 다 커밋됩니다. 

 

-> 실행 로그

외부는 당연히 isNew가 T이고 새로운 커낵션을 받습니다. 내부 트랜잭션을 시작하면 Suspending current transaction하여 현재 트랜잭션을(외부) 잠깐 미뤄두고 새 트랜잭션을 만들고 새 커낵션을 획득합니다. 기존 커낵션이 conn0이었는데 conn1이 됩니다. isNew도 True입니다. 이 내부 트랜잭션 영역은 완전히 새로운 영역으로 이게 동작하는 동안은 기존 트랜잭션은 잠깐 미뤄두고 새로운 커낵션에서 동작하는 것입니다.

 

> 외부 트랜잭션은 미루고 진행합니다. 그러다가 내부가 롤백을 하면 실제 롤백이 일어납니다. 원래는 내부 트랜잭션은 절대로 물리 영향을 주지 못했었습니다. > 이제는 내부 트랜잭션이 별개의 물리 트랜잭션으로 생기는 것이라서 실제로 롤백하고 커낵션을 반납하고 미뤄뒀던 외부 트랜잭션을 다시 Resume하여 외부 트랜잭션을 커밋합니다.

 

 

- 그림

-> 요청 흐름
1. 외부 트랜잭션

트랜잭션 시작하고 트랜잭션 매니저가 getconnection 할 때 신규 확인하고 커낵션 생성하고 트랜잭션 매니저에 커낵션 보관합니다. 로직 1에서 쿼리를 날리면 동기화 매니저에 저장한 conn1을 사용합니다.

2. 내부 트랜잭션

트랜잭션을 시작하고 매니저를 호출하면 NEW 옵션과 함께 getconnection을 하면 항상 새로운 트랜잭션이 시작됩니다. 따라서 데이터 소스에서 커낵션 만들고 conn2가 만들어져서 동기화 매니저에 보관합니다. 이때 conn1은 잠깐 보류하고 안쓰고 2번만 사용합니다. 또한 새로운 트랜재션이라서 isNew가 T입니다. 로직 2는 새롭게 만든 커낵션을 동기화 매니저에서 가져다 씁니다.

 

-> 응답 흐름

1. 내부 트랜잭션

로직2에서 롤백을 하면 트랜잭션이 롤백을 하고  매니저에 롤백을 요청합니다. 그러면 매니저가 신규인지 확인하는데 신규라서 롤백을 합니다. 실제 물리 트랜잭션에 롤백을 합니다. 근데 여기서 물리 트랜잭션은 전체가 아니고 내부 트랜잭션 공간만이 물리 트랜입니다. 외부 트랜잭션의 영역은 건드리지 않습니다.

2. 외부 트랜잭션

그리고 미뤄뒀던 con1의 보류가 끝나고 con1을 사용합니다. 로직1이 끝나고 외부 트랜잭션 코드에서 커밋을 요청하고 그러면 트랜잭션 매니저에 커밋을 요청하고 신규 커밋인지 확인하는데 신규라서 물리 트랜잭션을 커밋합니다. 물론 이때 롤백 only를 확인하는데 마킹이 없고 애초에 동기화 매니저에 다른 커낵션이 있습니다. 그래서 con1에 대해 물리 커밋을 합니다. 이렇게 하면 내부에서 트랜잭션을 쓰더라도 내부 영역의 트랜잭션은 구분이 가능합니다. 커밋 하든 롤백하든 따로 작동이 됩니다.

 

- 정리

NEW를 쓰면 물리 트랜잭션이 명확하게 분리되고 DB 커낵션이 동시에 2개가 사용됩니다. 기존 커낵션이 보류되긴 하지만 2개를 사용하게 됩니다.

 

- 다양한 전파 옵션

NEW는 getConn할 때 무조건 신규 트랜잭션을 하는 옵션이었습니다. 다양한 전파 옵션을 알아봅니다. 별도로 지정하지 않으면 REQUIRED가 사용되고 이건 기본으로 내부가 외부에 참여하는 것입니다. 실무에서는 거의 대부분 REQUIRED를 사용하고 가끔 NEW를 사용하고 나머지는 잘 사용하지 않습니다.

1. REQUIRED

기본 설정으로 기존 트랜잭션이 없으면 생성하고 있으면 참여합니다. 

2. REQUIRES_NEW

항상 새로운 트랜잭션을 생성합니다.

3. SUPPORT

트랜잭션을 지원한다는 뜻으로 기존 트랜잭션이 없으면, 없는대로 진행하고 있으면 참여합니다. 

4. NOT_SUPPORT

트랜잭션을 지원하지 않는다는 의미로 기존 트랜잭션이 있으면 없이 진행하고 기존 트랜잭션이 있으면 없이 진행하는데 보류해서 없이 진행합니다.

5. MANDATORY

굉장히 강하게 꼭 있어야하는 의무사항입니다. 기존에 트랜잭션이 없으면 예외가 발생합니다. 기존 트랜잭션이 있으면 참여합니다.

6. NEVER

트랜잭션을 사용하지 않는다는 의미로 기존 트랜잭션이 있으면 예외가 발생하고 없으면 그냥 없이 진행합니다.

7. NESTED

중첩이란 뜻으로 기존 트랜잭션이 없으면 생성하고 있으면 중첩 트랜잭션을 만듭니다. 중첩트랜은 외부 트랜잭션에 영향을 받지만, 외부에 영향을 주지 않습니다. 내부 트랜잭션이 중첩이면 롤백되어도 외부는 커밋할 수 있습니다. 하지만 반대로 외부 트랜잭션이 롤백되면 중첩까지 함께 롤백됩니다.

 

> 내부 트랜잭션이 롤백해도 외부는 커밋해도 되는 것에서 외부에 영향을 주지 않는 것이고 외부에서 롤백이 일어나면 대원칙에 따라서 하나라도 롤백되면 전부 다 롤백이 되는 것에서 영향을 받습니다.  > 보면 대부분 내부 트랜잭션에 사용할 옵션입니다.

※ 참고

isolation , timeout , readOnly는 트랜잭션이 신규인 경우에만 적용되고 참여하는 경우는 적용되지 않습니다.

Comments