스프링에서 제공하는 트랜잭션은 기본적으로 물리적 트랜잭션과 논리적 트랜잭션 개념으로 동작합니다.
이를 통해 트랜잭션의 전파(Propagation)를 효과적으로 관리할 수 있습니다.
트랜잭션이 시작될 때, 히카리(Hikari)와 같은 커넥션 풀은 실제 물리 커넥션을 반환하지 않고 프록시 커넥션을 제공합니다.
TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
txManager.commit(tx1);
TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
txManager.commit(tx2);
위 코드 실행 결과
트랜잭션 1: HikariProxyConnection@1000000 wrapping conn0
트랜잭션 2: HikariProxyConnection@2000000 wrapping conn0
실제 물리적 커넥션(conn0
)은 같지만, 프록시 객체가 서로 달라 별개의 트랜잭션으로 동작합니다.
커넥션 풀이 존재한다고 가정하면 위와같이 쓰고 반납, 쓰고 반납을 반복할 수 있습니다.
하지만 커넥션풀이 없다면 아래 그림과 같은 상황이 발생 할 것입니다.
트랜잭션 로직 중에 또 다른 트랜잭션이 발생하면 이를 아래 그림과 같이 물리 트랜잭션 / 논리 트랜잭션 개념으로 설명하면 쉽게 이해할 수 있습니다
트랜잭션의 커밋/롤백의 절대 규칙은 다음과 같습니다.
두 개의 논리 트랜잭션이 모두 커밋되어야 전체 물리 트랜잭션이 커밋된다.
스프링은 어떻게 외부 트랜잭션 과 내부 트랜잭션 을 묶어서 하나의 물리 트랜잭션으로 묶어서 동작하게 하는지 알아보겠습니다.
@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
: 외부에서 시작된 트랜잭션이 존재하므로 기존에 생성된 외부 물리 트랜잭션에 참여할 것입니다.
inner.isNewTransaction()=false
: 따라서 신규 트랜잭션인지의 여부는 false
입니다.
내부 트랜잭션이 커밋을 호출해도 물리 커밋이 발생하지 않습니다.
왜냐하면, 내부 트랜잭션이 커밋을 진행하게 되면 트랜잭션이 끝나버리기 때문에 외부 트랜잭션 까지 결과가 이어질 수 없기때문입니다.
따라서 내부 트랜잭션은 커밋을 진행하면 안됩니다
해당 그림을 바탕으로 여러가지 경우에 대해 생각해보겠습니다.
내부 트랜잭션이 커밋 or 롤백을 하게되면 물리 트랜잭션이 아예 종료되어버리기 때문에 내부 트랜잭션은 관여하면 안됩니다 → 내부/외부 트랜잭션을 어떻게 구분할까 ?
⇒ 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작합니다.
내부 트랜잭션은 신규 트랜잭션이 아니기 때문에 실제 커밋을 호출하지 않습니다.
내부 트랜잭션이 커밋을 한다 해도, 외부 트랜잭션을 롤백하면 커밋한 내부 트랜잭션도 모두 롤백됩니다.
내부 트랜잭션 롤백시 신규 트랜잭션인지 판단을 할까.. ?
→ 판단합니다. 트랜잭션 롤백/커밋시 무조건 트랜잭션 매니저에서 신규 트랜잭션인지 확인하는 과정을 거치게 됩니다.
예상 결과)내부 트랜잭션 내용만 롤백되고, 외부 트랜잭션은 커밋 처리
→ 대원칙을 생각하자
🗣️ 논리 트랜잭션 중 하나라도 롤백이 되면, 전체 트랜잭션은 롤백된다.
제 생각에는 내부 트랜잭션 커밋 → 외부 트랜잭션 롤백인 상황보다 반대의 경우가 더 치명적일 것 같습니다.
개발자는 분명히 전체 트랜잭션의 커밋을 기대했을텐데, 내부 로직에서 롤백이 발생하여 해당 경우를 확실히 체크해야 하기 때문입니다.
그렇다면, 외부 트랜잭션에서 커밋을 할 때 "롤백을 해라!" 라는 판단을 어떻게 할 수 있을까요?
내부 트랜잭션에서 롤백을 하게되면, 신규 트랜잭션이 아니기에 롤백/커밋이 적용되지 않습니다.
하지만, 롤백인 경우에는 트랜잭션 동기화 매니저에 rollbackOnly=true
라는 값을 남기게 됩니다.
논리 트랜잭션에서 rollback
이 발생한 후, 외부 트랜잭션에서 커밋을 진행하게 된다면,
rollbackOnly=true
마크가 있는지 확인 → true
→ 트랜잭션 롤백여기서 핵심은 위 과정처럼 롤백이 발생하게 되면, UnexpectedRollbackException
런타임 예외를 던진다.
트랜잭션 전파 옵션 중에서 가장 많이 사용하는 두 가지 전파 옵션을 알아보겠습니다.
REQUIRED
REQUIRES_NEW
위 그림과 같은 상황에서, 아래와 같은 요구사항이 발생했다고 가정하겠습니다.
MemberService
부터 MemberRepository
, LogRepository
를 모두 하나의 트랜잭션으로 묶고 싶다.MemberRepository
만 호출하고 여기에만 트랜잭션을 사용하고 싶다.LogRepository
만 호출하고 여기에만 트랜잭션을 사용하고 싶다.클라이언트 A의 요구사항은 만족시키지만 클라이언트 B,C 는 각각 트랜잭션을 레포지토리 메서드에 적용시키지 않았기 때문에 만족시킬 수 없게 됩니다.
만약 트랜잭션 전파 옵션 이라는 기능이 없었더라면, 각 요구사항에 맞는 메서드를 각각 @Transactional
의 유무를 나누어 구현해야 할 것입니다.
하지만 @Transactional
을 사용하여 기본값인 Required
옵션을 통해 각각의 레포지토리에서 트랜잭션을 시작할 수 있고, 이는 외부 서비스에서 실행한 트랜잭션과 동일한 커넥션인 con1
을 공유하여 사용하게 될 것입니다.
con1
커넥션을 서비스와 각각의 레포지토리에서 공유하는 상황에서, 즉 레포지토리의 트랜잭션 전파 옵션이 default
인 Required
인 상태에서, 하나의 논리 트랜잭션이 롤백예외를 던지는 아래 그림과 같은 상황을 생각해 보겠습니다.
논리 트랜잭션C에서 던진 예외를 트랜잭션 AOP가 예외를 인지하고 롤백을 호출한다.
하지만 신규 트랜잭션이 아니므로 물리 롤백을 호출하지 않고, rollbackOnly
를 설정한다.
해당 로직에서는 서비스 계층에서도 예외를 처리하지 않고 밖으로 던지게 되고, 트랜잭션 AOP가 롤백을 호출할 것이고, 신규 트랜잭션 이므로 롤백이 될 것이다. (이 상황에서는 rollbackOnly
설정을 참고하지 않습니다)
해당 상황으로 알 수 있듯이, 하나의 논리 트랜잭션에서 롤백이 발생하면 예외를 던지고, 트랜잭션 AOP를 통해 롤백을 호출하고 최종적으로는 클라이언트가 예외를 받게 될 것이다.
따라서 데이터 정합성에는 문제가 발생하지 않지만, 이런 의문이 들 수 있습니다.
💡로그 저장이 안되어서 발생한 예외 때문에 회원가입도 롤백되어 영향을 받는게 올바를까 ?
❗️이 경우가 실무에서 가장 많이 범하는 실수라고합니다
자 그렇다면 서비스 계층에서 예외를 처리하는 경우를 생각해보겠습니다.
논리 트랜잭션 C에서 던진 예외를 신규 트랜잭션의 시작점인 서비스 레이어에서 잡아서 처리를 한다면 과연 논리 트랜잭션 B의 멤버 저장은 제대로 커밋이 될까?
트랜잭션 커밋 절대 규칙
논리 트랜잭션이 모두 커밋되어야 물리 트랜잭션이 커밋된다.
즉, 서비스계층에서 예외를 잡았다 해도, rollbackOnly
설정값이 true
이기 때문에 롤백을 해야하는것이 불가피합니다.
신규 트랜잭션에서 커밋을 요청하였지만, rollbackOnly
를 체크한 후, true
로 마킹이 되어있기 때문에, UnexpectedRollbackException
이 발생할 것입니다.
따라서 트랜잭션 매니저는 해당 예외를 던지고, 트랜잭션 AOP 또한 전달 받은 예외를 클라이언트에게 던집니다.
그렇다면, 회원가입 트랜잭션과 로그 트랜잭션을 분리하는 방법을 적용해 보겠습니다
로그가 안찍힌다는 이유로 회원가입이 되지 않는것은 클라이언트에게 충분히 불만을 남길 수 있는 상황일 것입니다.
위 구조처럼, 로그를 실행하는 레포지토리에 기존 default
값인 required
에서 new
로 바꾸면, 트랜잭션 매니저에서 신규 트랜잭션을 생성할 것입니다.
새로운 커넥션인 con2
를 트랜잭션 동기화 매니저에 보관하고, 회원가입과 로그저장 로직에서 물리 트랜잭션을 분리할 수 있습니다.
따라서, 예외가 발생한다 해도 con1
에는 rollbackOnly
표시가 되지 않기 때문에, 서비스 계층에서 커밋을 진행하여 회원가입은 가능해질 것입니다.
하지만, 이제는 하나의 http 요청에서 동시에 2개의 DB 커넥션을 사용하게 되기 때문에 유의하여 사용해야 합니다.
facade
계층을 두어 트랜잭션 전파의 default
옵션인 required
만을 사용하여 물리 트랜잭션을 분리할 수도 있겠지만, 구조를 조정하는것과 requiresNew
옵션을 사용하는 것은 trade-off 가 있다고 생각하기 때문에, 때에 따라서 유연하게 사용하면 될 것 같습니다.
아래는 프로젝트 코드의 예시입니다.
프로젝트를 진행하며, facade
계층에서 commandService
를 주입받아 트랜잭션을 처리하는 과정에서 의문이 생겼었습니다.
@Service
@RequiredArgumentConstructor
public class PostFacade {
private final PostQueryService postQueryService;
private final PostCommandService postCommandService;
// 하나의 트랜잭션으로 포스트 조회와 조회수 증가를 처리합니다.
@Transactional
public PostDto getPostAndIncreaseViewCount(Long postId) {
// 포스트 조회
PostDto post = postQueryService.getPost(postId);
// 조회수 증가
postCommandService.increaseViewCount(postId);
return post;
}
}
facade 계층의 메서드에 @transactional
을 붙이고 해당 facade
계층에서 호출한 commandService
의 메서드에도 어노테이션을 붙여야 할 것 같았습니다.
JPA 에서 데이터 계층에 접근하기 위해서는 트랜잭션이 필수적이기 때문입니다.
@Service
public class PostCommandService {
private final PostRepository postRepository;
public PostCommandService(PostRepository postRepository) {
this.postRepository = postRepository;
}
//@Transactional
public void increaseViewCount(Long postId) {
// DB에 직접 업데이트 쿼리를 실행하여 조회수를 증가시키는 로직
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ResourceNotFoundException("Post not found"));
post.incrementViewCount();
postRepository.save(post);
}
}
서로 다른 물리 트랜잭션을 구분할 것이 아니고 외부에서 트랜잭션이 시작된 상태로 넘어오게 된다면,
트랜잭션의 전파 옵션 기본값인 required
때문에 해당 트랜잭션에 참여하게 되어 해당 어노테이션은 불필요하게 되는 것이였습니다.
Reference
김영한님 - 스프링 DB 2편 - 데이터 접근 활용 기술