[Spring Database] 트랜잭션 전파
하나의 메서드 안에서 트랜잭션이 둘 이상 사용되거나, 트랜잭션 내부에서 또 트랜잭션을 거는 경우는 어떻게 처리될까?
하나하나 살펴보자.
@Test
void double_commit() {
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);
}
트랜잭션 둘을 실행하는 예시이다.
스프링은 Hikari 커넥션 풀을 기본값으로 사용하고, 트랜잭션 1이 반납한 커넥션을 트랜잭션 2가 받아와서 사용한다.
트랜잭션1: Acquired Connection [HikariProxyConnection@1000000 wrapping conn0]
트랜잭션2: Acquired Connection [HikariProxyConnection@2000000 wrapping conn0]
로그를 확인 해 보면 같은 커넥션을 사용함을 알 수 있다.
Hikari커넥션 풀은 바로 실체 객체로 연결하지 않고 프록시를 통해 연결된다.
역시 로그를 통해 히카리 커넥션풀의 객체를 출력해보면 각각 다른 프록시로부터 커넥션을 넘겨받은걸 확인할 수 있다.
트랜잭션1과 트랜잭션2는 독립적으로 동작한다.
순서대로 커넥션을 반환받고 사용하는 예시라서.. 쉽게 이해 할 수 있다.
좀 더 복잡한 예시를 살펴보자.
트랜잭션 내부에서 트랜잭션이 발생하면 어떻게 될까?
이런 경우를 트랜잭션 전파라고 부른다.
처음 시작한 트랜잭션이 아직 끝나지 않았는데 두 번째 트랜잭션이 시작되면 두 번째 트랜잭션이 첫 번째 트랜잭션에 묶여서 작동한다.
스프링은 트랜잭션 전파의 이해를 돕기 위해 물리 트랜잭션과 논리 트랜잭션이라는 개념을 제시한다.
물리 트랜잭션은 실제 데이터베이스에 적용되는 트랜잭션을 의미한다. (커밋 / 롤백의 단위)
논리 트랜잭션은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위이다.
즉, 논리 트랜잭션은 먼저 시작한 트랜잭션을 뒤따라서 시작한 후발주자가 있을 때 생성된다.
이제 이것만 기억하자.
<모든 논리 트랜잭션들이 커밋돼야 물리 트랜잭션이 커밋된다>
트랜잭션 1 2 3 4 가 물리 트랜잭션으로 묶였을때, 1 2 3 이 커밋되고 4가 롤백되면 물리 트랜잭션은 롤백된다.
@Test
void inner_commit() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction()={}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("inner.isNewTransaction()={}", inner.isNewTransaction());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
isNewTransaction() 메서드를 사용해 해당 트랜잭션이 후발주자인지 확인할 수 있다.
후발주자인 트랜잭션이 첫 번째 트랜잭션에 참여한다고 생각하자.
트랜잭션을 종료 할 때는 항상 역순으로 종료해야 한다!!!!!!
처음 트랜잭션을 시작한 외부 트랜잭션이 물리 트랜잭션을 관리한다고 생각하자.
실행 과정을 정리해보자.
1. 먼저 외부 트랜잭션이 시작된다. 트랜잭션 매니저가 데이터소스를 통해 커넥션을 생성한다.
2. 내부 트랜잭션이 시작된다. 이미 트랜잭션이 진행 중이기 때문에 트랜잭션에 참여한다.
3. 외부 트랜잭션에서 setAutoCommit(false)로 설정할 때 물리 트랜잭션이 시작됐다. 내부 트랜잭션은 외부 트랜잭션이 만들어 놓은 커넥션을 사용한다. (트랜잭션이 하나로 묶인다)
4. 내부 트랜잭션이 커밋됐다. 맨 처음에 시작한 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않는다.
5. 외부 트랜잭션이 커밋됐다. 맨 처음에 시작된 트랜잭션이기 때문에 데이터베이스에 커밋을 호출하고 물리 트랜잭션도 종료된다.
논리 트랜잭션이 커밋된다고 해서 무조건 실제 커밋이 호출되지 않지만, 물리 트랜잭션이 커밋되면 실제 커밋이 호출된다.
트랜잭션이 롤백되는 경우도 생각해보자.
내부 커밋 - 외부 롤백
간단하다. 내부가 커밋돼도 아무 일도 일어나지 않고, 외부가 롤백 될 때 물리 트랜잭션이 롤백된다.
내부 롤백 - 외부 커밋
모든 논리 트랜잭션들이 커밋돼야 물리 트랜잭션이 커밋된다.
내부 트랜잭션이 롤백될때, 물리 트랜잭션을 바로 롤백하지는 않지만 기존 트랜잭션을 롤백 전용으로 표시한다.
이후 외부 트랜잭션이 커밋된다. 트랜잭션 매니저는 처음에 시작한 트랜잭션인지 확인함과 동시에 트랜잭션 동기화 매니저에 롤백 전용 표시가 있는지 확인한다.
롤백 전용 표시가 있으면 외부 트랜잭션이 커밋된다고 해도 물리 트랜잭션을 롤백한다.
외부 트랜잭션은 커밋을 요청했는데 내부 트랜잭션이 롤백됐기 때문에 물리 트랜잭션이 롤백됐다.
스프링은 커밋을 호출했지만 실제로는 롤백됐다는 사실을 알려주기 위해 UnexpectedRollbackException 런타임 예외를 던진다.
이 부분이 아니였다면 (true || false) 를 해석할 때 앞의 true만 보고 바로 true를 반환하는 것 처럼 내부 트랜잭션이 롤백됐으면 바로 물리 트랜잭션을 롤백하는 방식으로 최적화하는 작업을 기대할 수 있을텐데..
외부 트랜잭션은 커밋 / 실제로는 롤백 이 사실을 알려주기 위해 이런 방향으로 최적화하는건 힘들다.
최적화보다 모호함을 먼저 제거하자.
외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 사용하는 방법도 있다.
원칙대로라면 두 개의 논리 트랜잭션이 하나의 물리 트랜잭션으로 묶여야 하는데, 각각의 논리 트랜잭션을 물리 트랜잭션으로 사용할 수 있다.
내부 트랜잭션을 시작 할 때 REQUIRES_NEW 옵션을 사용하면 된다.
각각의 트랜잭션은 서로 다른 데이터베이스 커넥션을 사용해 독립적으로 동작한다.
@Test
void inner_rollback_requires_new() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction()={}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus inner = txManager.getTransaction(definition);
log.info("inner.isNewTransaction()={}", inner.isNewTransaction());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner); //롤백
log.info("외부 트랜잭션 커밋");
txManager.commit(outer); //커밋
}
PROPAGATION_REQUIRES_NEW 옵션을 설정하면 내부 트랜잭션을 시작 할 때 외부 트랜잭션에 참여하는 대신 새로운 물리 트랜잭션을 만들어서 시작한다.
getTransaction 메서드로 트랜잭션을 시작하고, setAutoCommit(false) 로 물리 트랜잭션을 시작한다.
내부 트랜잭션이 시작 될 때 REQUIRES_NEW 옵션이 붙어있어 새로운 트랜잭션을 시작한다.
두 개의 트랜잭션이 물리 트랜잭션으로 묶이지 않아 각자 다른 데이터베이스 커넥션을 사용한다.
데이터베이스 커넥션이 여러 개 사용되는 부분에 집중하자.
이제 트랜잭션이 몇 개가 중첩되든 트랜잭션 전파를 사용해서 처리할 수 있는데...
전파를 사용하면 데이터베이스 커넥션이 여러 개 사용될 수 있어 성능이 저하될 수 있다.
전파 대신 애플리케이션 로직 구조를 바꿔서 문제를 해결하는 방법이 있다면 그 방법을 선택해도 괜찮다.
이런 방식으로 계층을 하나 추가하는 방법도 있다.
더 깔끔한 방법을 선택하는 편이 합리적이니, 각각의 장단점을 이해하자.
REQUIRES_NEW 처럼 스프링은 다양한 트랜잭션 전파 옵션을 제공한다.
어떤 옵션이 있는지 간단하게 살펴보자.
REQUIRED : 기본값이자 가장 많이 사용되는 옵션이다. 없으면 생성하고 있으면 참여하는 방식으로 작동한다.
REQUIRES_NEW : 두 번째로 많이 사용되는 옵션이다. 항상 새로 생성한다.
SUPPORT : 기존 트랜잭션이 없으면 트랜잭션 없이 진행하고, 있으면 참여한다.
NOT_SUPPORT : 기본적으로 트랜잭션 없이 진행하고, 기존 트랜잭션은 보류한다.
MANDATORY : 트랜잭션이 없으면 예외를 뱉고, 있으면 참여한다.
NEVER : 트랜잭션이 없으면 없이 진행하고, 있으면 예외를 뱉는다.
NESTED : 없으면 새로 만들고, 있으면 중첩 트랜잭션을 만든다.
스프링에서 다루는 트랜잭션과 MySQL같은 데이터베이스 서버에서 다루는 트랜잭션의 기본적인 개념은 동일하지만, 깊은 이해를 위해 사용하는 데이터베이스 서버의 트랜잭션에 대해서도 알아두자.
스프링의 트랜잭션 관리는 데이터베이스 서버의 트랜잭션을 좀 더 쉽게 다루기 위한 추상화 계층을 제공한다.
내부적으로는 데이터베이스 서버의 트랜잭션을 사용하지만 개발자는 내부 작동을 추상화해 트랜잭션을 편하게 다룰 수 있다.
'Spring > Spring Database' 카테고리의 다른 글
[Spring Database] 트랜잭션 (0) | 2022.09.13 |
---|---|
[Spring Database] QueryDSL (1) | 2022.09.11 |
[Spring Database] Spring Data JPA (0) | 2022.09.11 |
[Spring Database] JPA 적용 (0) | 2022.09.10 |
[Spring Database] JPA (1) | 2022.09.09 |
댓글
이 글 공유하기
다른 글
-
[Spring Database] 트랜잭션
[Spring Database] 트랜잭션
2022.09.13 -
[Spring Database] QueryDSL
[Spring Database] QueryDSL
2022.09.11 -
[Spring Database] Spring Data JPA
[Spring Database] Spring Data JPA
2022.09.11 -
[Spring Database] JPA 적용
[Spring Database] JPA 적용
2022.09.10