상품 구매와 같은 경우는 1번의 요청에서 n개 이상의 트랜잭션이 발생할 수 있다.
계좌에서는 금액이 빠져나갈 것이고, 재고는 줄어들 것이다.
이렇게 여러 개의 복합적인 트랜잭션을 관리하는 방법을 알아보자.
커밋과 롤백
우선 하나의 트랜잭션에서 어떻게 동작하는 지 확인해보자.
과정 자체는 단순하다.
- 트랜잭션 매니저를 통해 트랜잭션을 획득한다.
- 커밋 또는 롤백을 진행한다.
환경설정 (로그)
우선 자세한 로그를 확인하기 위해 application.properties
에 설정을 추가해주자.
#Transaction log
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
#JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG
#JPA SQL
logging.level.org.hibernate.SQL=DEBUG
환경설정 (트랜잭션 매니저)
이제 트랜잭션 매니저에 대한 테스트 환경을 구축하자.
@Autowired
PlatformTransactionManager txManager;
@TestConfiguration
static class Config {
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
커밋하는 경우
단순히 트랜잭션 매니저를 통해 트랜잭션을 획득하고, 커밋을 진행하였다.
@Test
void commit() {
log.info("트랜잭션 시작");
TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션 커밋 시작");
txManager.commit(status);
log.info("트랜잭션 커밋 완료");
}
실행해서 로그를 확인해보면 conn0을 획득하고,
Committing JDBC transaction on Connection
라는 메시지를 통해
커밋이 진행된 것을 알 수 있다.
롤백하는 경우
단순히 트랜잭션 매니저를 통해 트랜잭션을 획득하고, 롤백을 진행하였다.
@Test
void rollback() {
log.info("트랜잭션 시작");
TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션 롤백 시작");
txManager.rollback(status);
log.info("트랜잭션 롤백 완료");
}
실행해서 로그를 확인해보면 conn0을 획득하고,
Rolling back JDBC transaction on Connection
라는 메시지를 통해
롤백이 진행된 것을 알 수 있다.
트랜잭션 두 번 사용
이번에는 실제로 트랜잭션을 순서대로 2번 사용해보자.
1번 트랜잭션이 완전히 종료되고,
그 다음에 2번 트랜잭션이 수행되는 방식이다.
커밋만 2번하는 경우
@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);
}
실행해서 로그를 확인해보면 1번 트랜잭션이 conn0을 획득하고 커밋을 진행한 뒤,
2번 트랜잭션이 다시 conn0을 획득하고 커밋을 진행하는 것을 확인할 수 있다.
커밋 1번에 롤백 1번을 진행하는 경우
@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.rollback(tx2);
}
실행해서 로그를 확인해보면 1번 트랜잭션이 conn0을 획득하고 커밋을 진행한 뒤,
2번 트랜잭션이 다시 conn0을 획득하고 롤백을 진행하는 것을 확인할 수 있다.
결과가 분리되어 있는 이유
메소드 자체가 하나의 요청이라고 가정했을 때,
메소드 하나에서 getTransaction을 통해 트랜잭션을 2번 획득했더라도
순서대로 진행하였기 때문에 중간에 커밋이나 롤백을 통해 트랜잭션을 종료시키기도 했고,
각 트랜잭션을 별도로 묶는 과정이 없었기 때문에 다른 트랜잭션이 커밋이 되든 롤백이 되든
영향이 없다.
트랜잭션 전파 - 기본
트랜잭션 전파 (Transaction Propagation)
방금은 트랜잭션을 각각 사용하는 방식이었다.
그렇다면 트랜잭션 도중에 다른 트랜잭션을 수행하게 되면 어떻게 될까?
기존 트랜잭션과 별도의 트랜잭션이 될 수도 있고,
또는 기존 트랜잭션을 이어 받아서 연장선이 될 수도 있다.
이러한 동작 방식을 결정하는 것을 트랜잭션 전파(Transaction Propagation)
라고 한다.
스프링에서는 다양한 트랜잭션 전파 옵션을 제공한다.
트랜잭션 전파의 원리
트랜잭션 전파에는 다양한 방식이 존재하는 데
그 중 기본이 되는 REQUIRED
방식을 기준으로 이해해보자.
기본적으로 트랜잭션의 전파는 위 사진과 같이 동작한다.
클라이언트가 애플리케이션에 요청을 보내서 특정 서비스에 있는 비즈니스 로직을 실행하려고 하면
트랜잭션 매니저가 트랜잭션을 획득하고 해당 비즈니스 로직이 실행될 것이다.
그리고 해당 비즈니스 로직 중에서 다른 서비스에 있는 비즈니스 로직을 호출하게 되면
다른 트랜잭션이 생성되면서 별도의 비즈니스 로직이 추가로 실행될 것이다.
이 때 발생되는 2개의 트랜잭션에서
클라이언트 쪽에 가까운 트랜잭션을 외부 트랜잭션
,
외부 트랜잭션에서 호출된 추가 트랜잭션을 내부 트랜잭션
이라고 한다.
가장 처음 호출된 트랜잭션을 외부 트랜잭션이라고 생각하면 된다.
이 때 스프링에서는 위 사진처럼 외부 트랜잭션과 내부 트랜잭션을 묶어서 관리한다.
즉, 여러 개의 트랜잭션이 하나의 트랜잭션이 되는 것이다.
이 방식이 REQUIRED
방식이다.
논리 트랜잭션과 물리 트랜잭션
외부 트랜잭션과 내부 트랜잭션으로 구분하면 트랜잭션의 개수가 많아질 수록 헷갈리기 때문에
스프링에서는 논리 트랜잭션
과 물리 트랜잭션
이라는 개념을 도입했다.
논리 트랜잭션
은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위다.
논리 트랜잭션에서 다른 비즈니스 로직을 호출하면
새로운 논리 트랜잭션이 발생한다.
물리 트랜잭션
은 모든 논리 트랜잭션을 묶는 단위이다.
논리 트랜잭션이 몇 개든 간에 하나의 물리 트랜잭션으로 묶는다.
물리 트랜잭션은 실제 DB에 적용하는 트랜잭션을 뜻하며,
실제 커넥션을 통해서 트랜잭션을 시작하고
실제 커넥션을 통해서 커밋 또는 롤백을 진행하는 단위다.
참고로 트랜잭션이 하나만 있을 경우에는
굳이 구분할 필요가 없다보니
논리 트랜잭션인지 물리 트랜잭션인지 구분하지는 않는다.
논리 트랜잭션과 물리 트랜잭션을 나누는 이유
논리 트랜잭션과 물리 트랜잭션을 나누는 이유는 데이터의 일관성때문이다.
3개의 트랜잭션이 실행된다고 생각해보자.
이 때 1개는 성공해서 커밋되야하고, 나머지 2개는 실패해서 롤백한다면
이는 데이터의 일관성이 없을 것이다.
만약에 어떠한 상품을 구매한다고 가정해보자.
약간 간략화 해본다면 아래와 같은 과정이 발생할 것이다.
- 상품의 재고가 줄어든다.
- 주문내역이 생성된다.
- 주문자의 계좌에서 출금이 발생한다.
위의 3가지 과정을 각각의 트랜잭션이라고 가정해보자.
이 때 1번은 성공해서 커밋을 했고, 2번과 3번은 실패해서 롤백한다면
상품의 재고만 줄어들고 주문 내역도 결제 내역도 없는 이상한 상황이 될 것이다.
(물론 실제 서비스에서는 재고를 요청한 요청자 ID를 저장하긴 하겠지만 예시를 든 것이다.)
이러한 상황을 방지하기 위해
n개의 트랜잭션이 있을 때 하나라도 롤백한다면 모두 롤백하게 하고, 모든 트랜잭션이 커밋 가능한 상황일 때만 실제 커밋하게 하는 것이다.
이러한 실제 비즈니스 로직이 실행되는 n개의 트랜잭션과
일괄적으로 커밋 또는 롤백을 수행하는 트랜잭션을 구분하기 위해
논리 트랜잭션과 물리 트랜잭션이라는 개념이 필요한 것이다.
트랜잭션 전파 - 예제
실제로 여러 개의 트랜잭션을 사용해서 전파의 원리를 익혀보자.
TransactionStatus의 isNewTransaction 메소드를 사용하면
신규 트랜잭션을 사용하는지 기존 트랜잭션을 사용하는지 알 수 있다.
내부 트랜잭션과 외부 트랜잭션이 모두 커밋되는 경우를 통해
기본 원리를 파악해보자.
@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);
}
실행해서 로그를 확인해보면 원리를 파악할 수 있다.
기존에는 개별의 트랜잭션이 실행되면 각각의 트랜잭션 매니저가 트랜잭션을 획득하고 반납했지만,
이번에는 Participating in existing transaction
라는 메시지를 통해
진행 중인 외부 트랜잭션에 내부 트랜잭션에 참여한다는 것을 알 수 있다.
이는 하나의 트랜잭션이 연장된다는 것을 의미한다.
왜냐하면 외부 트랜잭션의 범위가 내부 트랜잭션의 범위만큼 넓어지기 때문이다.
- 외부 트랜잭션을 시작한다.
- 데이터소스를 통해 커넥션을 생성한다.
- 생성된 커넥션을 수동 커밋 모드로 설정해서 물리 트랜잭션을 시작한다.
- 트랜잭션 매니저는 트랜잭션 동기화 매니저에 커넥션을 보관한다.
- 트랜잭션 매니저를 통해 신규 트랜잭션을 가져온다.
- 비즈니스 로직을 실행한다.
- 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 커넥션을 획득한다.
- 내부 트랜잭션을 시작한다.
- 트랜잭션 매니저를 통해 기존 트랜잭션이 존재하는지 확인한다.
- 기존 트랜잭션이 존재하기 때문에 기존 트랜잭션에 참여한다고 판단한다.
- 기존 트랜잭션에 참여하기로 했으니 기존 트랜잭션을 가져온다.
- 비즈니스 로직을 실행한다.
- 커넥션이 필요한 경우 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 커넥션을 획득해서 사용한다.
- 트랜잭션 매니저에 내부 트랜잭션을 커밋할 것을 요청한다.
- 트랜잭션 매니저는 기존 트랜잭션임을 파악하고 실제 커밋을 호출하지 않는다.
- 왜냐하면 아직 모든 트랜잭션이 종료된 것이 아니기 때문이다.
- 실제 물리적인 커밋은 일어나지는 않지만 커밋 요청은 했기에
논리적인 커밋
이다.
- 트랜잭션 매니저에 외부 트랜잭션을 커밋할 것을 요청한다.
- 트랜잭션 매니저는 신규 트랜잭션임을 파악하고 실제 커밋을 호출한다.
- DB 커넥션을 통해 실제 DB에 반영되며, 물리 트랜잭션이 종료된다.
- 실제 DB에 반영되기에
물리적인 커밋
이다.
- 실제 DB에 반영되기에
외부 롤백
이번에는 내부 트랜잭션은 커밋되는데, 외부 트랜잭션은 롤백되는 경우를 확인해보자.
@Test
void outer_rollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 롤백");
txManager.rollback(outer);
}
내부 비즈니스 로직인 로직2가 끝나서
트랜잭션 매니저에 커밋을 요청할 것이다.
하지만 기존 트랜잭션이기 때문에 실제 커밋은 호출되지 않는다.
그런데 외부 비즈니스 로직인 로직1이 도중에 문제가 발생해서
트랜잭션 매니저에 롤백을 요청할 것이다.
신규 트랜잭션이기 때문에 DB 커넥션을 통해 실제 롤백이 호출될 것이다.
결과적으로는 DB에 물리적인 롤백이 실행될 것이다.
내부 롤백
이번에는 내부 트랜잭션은 롤백되고, 외부 트랜잭션은 커밋되는 경우를 확인해보자.
@Test
void inner_rollback() {
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.rollback(inner);
log.info("외부 트랜잭션 커밋");
assertThatThrownBy(() -> txManager.commit(outer))
.isInstanceOf(UnexpectedRollbackException.class);
}
내부 롤백은 직전의 외부 롤백과는 다르게 동작한다.
외부 롤백의 경우에는 그냥 최종적으로 롤백하는 것이기에 단순하다.
내부 비즈니스 로직인 로직2를 수행하는 도중에 문제가 발생한다고
트랜잭션 매니저에 롤백을 요청하지는 않는다.
그렇다고 바로 물리적인 롤백을 수행하는 것도 아니다.
대신에 트랜잭션 동기화 매니저에 있는 rollbackOnly라는 옵션을 true로 변경한다.
일종의 표시만 해두는 것이다.
그런 다음에 외부 비즈니스 로직인 로직1을 마저 수행하게 되는데,
이 때 로직1 자체에는 문제가 없어서 커밋을 시도하게 된다면
트랜잭션 매니저에 외부 트랜잭션에 대한 커밋을 요청하게 될 것이다.
하지만 이 때 트랜잭션 매니저는 DB 커넥션을 통해 커밋해야 하기 때문에
트랜잭션 동기화 매니저를 확인하는데,
트랜잭션 동기화 매니저에 rollbackOnly라는 옵션이 true인 것을 보고,
물리적인 커밋이 아니라 물리적인 롤백을 요청하게 된다.
물리적인 롤백이 반영되며 물리 트랜잭션도 종료된다.
하지만 단순히 물리 트랜잭션이 종료만 되면 안 되고
시스템 상 문제가 있는 것임을 알려야 한다.
그래서 스프링에서는 UnexpectedRollbackException
예외를 던진다.
REQUIRES_NEW
기존 REQUIRES
의 경우에는 요약하자면 일괄 커밋, 일괄 롤백이었다.
외부 트랜잭션과 내부 트랜잭션이 항상 하나의 물리 트랜잭션으로 묶여서 관리되는 방식이다.
하지만 경우에 따라서는 외부 트랜잭션과 내부 트랜잭션을 각각의 물리 트랜잭션으로
관리하고 싶을 수도 있을 것이다.
그럴 때 사용하는 것이 REQUIRES_NEW
방식이다.
예시
트랜잭션 2개를 사용하되, 하나는 커밋되고 하나는 롤백되는 경우를 알아보자.
@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);
}
실제로 실행해보면 REQUIRED
와의 차이점을 알 수 있다.
REQUIRED
의 경우 기존 트랜잭션에 참여하는 방식이었기 때문에
같은 DB 커넥션을 사용했다.
하지만 REQUIRES_NEW
의 경우에는 각각의 트랜잭션을 물리적으로 분리했기 때문에
각각의 트랜잭션이 다른 DB 커넥션을 사용하는 것을 알 수 있다.
또한 isNewTransaction 메소드의 결과를 보면 각각의 트랜잭션이 신규 트랜잭션임을 알 수 있다.
그래서 실제로 내부 트랜잭션은 롤백하고 외부 트랜잭션은 커밋했는데
REQUIRED
방식과는 다르게 UnexpectedRollbackException
예외가 발생하지 않고
각각의 트랜잭션이 별도로 커밋과 롤백이 진행되고 커넥션이 반납되는 것을 확인할 수 있다.
다양한 전파 옵션
스프링에서는 다양한 트랜잭션 전파 옵션을 제공한다.
기본적으로는 REQUIRED
가 설정되어 있는데
실제로도 해당 방식을 제일 많이 사용한다.
REQUIRES_NEW
방식만 가끔 사용되고, 그 외의 방식은 거의 사용되자 않는다.
종류
REQUIRED
- 기본 설정
- 가장 많이 사용하는 방식
- 기존 트랜잭션 유무별 동작방식
- 기존 트랜잭션이 없다. => 새로운 트랜잭션을 생성한다.
- 기존 트랜잭션이 있다. => 기존 트랜잭션에 참여한다.
REQUIRES_NEW
- 항상 새로운 트랜잭션을 생성한다.
- 기존 트랜잭션 유무별 동작방식
- 기존 트랜잭션이 없다. => 새로운 트랜잭션을 생성한다.
- 기존 트랜잭션이 있다. => 새로운 트랜잭션을 생성한다.
SUPPORT
- 트랜잭션을 지원한다.
- 기존 트랜잭션이 없으면, 없는대로 진행하고, 있으면 참여한다.
- 기존 트랜잭션 유무별 동작방식
- 기존 트랜잭션이 없다. => 트랜잭션 없이 진행한다.
- 기존 트랜잭션이 있다. => 기존 트랜잭션에 참여한다.
NOT_SUPPORT
- 트랜잭션을 지원하지 않는다.
- 항상 트랜잭션을 사용하지 않는다고 이해하면 된다.
- 기존 트랜잭션 유무별 동작방식
- 기존 트랜잭션이 없다. => 트랜잭션 없이 진행한다.
- 기존 트랜잭션이 있다. => 트랜잭션 없이 진행한다.
- 기존 트랜잭션은 참여하지 않고 보류한다.
- 트랜잭션을 지원하지 않는다.
MANDATORY
- 트랜잭션이 반드시 있어야 한다.
- 기존 트랜잭션이 없으면 예외가 발생한다.
- 기존 트랜잭션 유무별 동작방식
- 기존 트랜잭션이 없다. =>
IllegalTransactionStateException
예외가 발생한다. - 기존 트랜잭션이 있다. => 기존 트랜잭션에 참여한다.
- 기존 트랜잭션이 없다. =>
- 트랜잭션이 반드시 있어야 한다.
NEVER
- 트랜잭션을 사용하지 않는다.
- 기존 트랜잭션이 있으면 예외가 발생한다.
NOT_SUPPORT
의 경우에는 기존 트랜잭션이 있든 말든 사용하지 않는 방식이지만,
NEVER
는 그냥 기존 트랜잭션 자체가 있으면 안 된다.- 기존 트랜잭션 유무별 동작방식
- 기존 트랜잭션이 없다. => 트랜잭션 없이 진행한다.
- 기존 트랜잭션이 있다. =>
IllegalTransactionStateException
예외가 발생한다.
- 트랜잭션을 사용하지 않는다.
NESTED
- 트랜잭션을 중첩해서 사용한다.
- 중첩 트랜잭션은 외부 트랜잭션의 영향을 받지만, 중첩 트랜잭션은 외부에 영향을 주지 않는다.
- 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋할 수 있다.
- 외부 트랜잭션이 롤백 되면 중첩 트랜잭션도 함께 롤백된다.
- 기존 트랜잭션 유무별 동작방식
- 기존 트랜잭션이 없다. => 새로운 트랜잭션을 생성한다.
- 기존 트랜잭션이 있다. => 중첩 트랜잭션을 만든다.
- 참고
- JDBC savepoint 기능을 사용한다.
- DB 드라이버에서 해당 기능을 지원하는지 확인이 필요하다.
- 중첩 트랜잭션은 JPA에서는 사용할 수 없다.
- JDBC savepoint 기능을 사용한다.
트랜잭션 전파와 옵션
isolation
, timeout
, readOnly
같은 옵션은 트랜잭션이 처음 시작될 때만 적용된다.
트랜잭션에 참여하는 경우에는 적용되지 않는다.
REQUIRED
방식이나 REQUIRES_NEW
방식을 통한 트랜잭션 시작 시점에만 적용된다.