임도현의 성장
Spring-Boot @Transactional 트랜잭션 전파 본문
🙀 트랜잭션 예시
회원이 쇼핑물에서 상품을 구매하고 결제를 할때 회원의 금액이 감소하지 않을 수 있고 회원이 금액이 감소는 하였는데 쇼핑물 측에서는 입금 처리가 되지 않는 문제 발생시 데이터를 전부 롤백 시켜 회원의 차감된 금액을 다시 돌려주게 한다.
- 회원이 결제 버튼을 누름 => 트랜잭션 시작
- 회원의 계좌에서 금액이 차감
- 쇼핑물에서 해당 금액을 수신 및 주문 완료 처리 => 트랜잭션 종료
🙉 Transaction 기본 방법
Transaction은 2개 이상의 쿼리를 하나의 커넥션으로 묶어 DB에 전송하고, 이 과정에서 에러가 발생할 경우 자동으로 모든 과정을 원래대로 되돌려 놓습니다. 이러한 과정을 구현하기 위해 Transaction은 하나 이상의 쿼리를 처리할 때 동일한 Connection 객체를 공유하도록 합니다.
💽 Spring에서 Transaction사용 방법
1. 선언적 트랜잭션 관리
- @Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭 션 관리라 한다.
- 선언적 트랜잭션 관리는 과거 XML에 설정하기도 했다.
- 이름 그대로 해당 로직에 트랜잭션을 적용하겠다 라고 클래스나 메서드에 선언하기만 하면 트랜잭션이 적용되는 방식 이다.
2. 프로그래밍 방식의 트랜잭션 관리
- 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래 밍 방식의 트랜잭션 관리라 한다.
- 선언적 트랜잭션 관리가 프로그래밍 방식에 비해서 훨씬 간편하고 실용적이기 때문에 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다.
🐇 간단한 트랜잭션 동작 원리
- 스프링은 PlatformTransactionManager 라는 인터페이스를 통해 트랜잭션을 추상화한다.
- txManager.getTransaction(new DefaultTransactionAttribute()) : 트랜잭션 매니저를 통해 트랜잭션을 시작 커넥션을 획득 한다.
- txManager.commit(status) : 트랜잭션 커밋 한 후 커넥션을 커넥션 풀에 커넥션을 돌려 준다.
@Test
void commit() {
log.info("트랜잭션 시작");
TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션 커밋 시작");
txManager.commit(status);
log.info("트랜잭션 커밋 완료");
}
💫 트랜잭션 두 번 사용
@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);
}
실행 로그
- Acquired Connection [HikariProxyConnection@1064414847 wrapping conn0] for JDBC transaction : 트랜잭션 시작후 커넥션 풀에서 conn0 커넥션을 획득
- Initiating transaction commit : 트랜잭션1 커밋
- Releasing JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0] after transaction : 위에서 트랜잭션을 커밋한 후 커넥션 풀에 conn0 커넥션을 반납
- 트랜잭션2도 위와 동일하게 동작
간단한 설명 정리
트랜잭션1을 실행시키고 커넥션을 획득 커밋을 하고 커넥션을 반납 트랜잭션2을 실행 커넥션을 획득 커밋을 하고 커넥션을 반납 트랜젝션1이 완전히 끝나고 트랜잭션2가 시작된다.
트랜잭션1 시작
Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@1064414847 wrapping conn0] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0] to manual commit
트랜잭션1 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@1064414847 wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@1064414847 wrapping conn0] after transaction
트랜잭션2 시작
Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT Acquired Connection [HikariProxyConnection@778350106 wrapping conn0] for JDBC transaction
Switching JDBC Connection [HikariProxyConnection@778350106 wrapping conn0] to manual commit
트랜잭션2 커밋
Initiating transaction commit
Committing JDBC transaction on Connection [HikariProxyConnection@778350106 wrapping conn0]
Releasing JDBC Connection [HikariProxyConnection@778350106 wrapping conn0] after transaction
😽 트랜잭션 전파
어떤 트랜잭션이 동작중인 과정에서 다른 트랜잭션을 실행할 경우 어떻게 처리하고 결정하는 것을 트랜잭션 전파 (propagation)라 한다.
외부 트랜잭션이 수행중인데 내부 트랜잭션이 추가로 수행됨
- 외부 트랜잭션이 수행중이고 아직 끝나지 않았는데 내부 트랜잭션이 수행된다.
- 먼저 실행되는 외부 트랜잭션이 수행되고 있는 도중 호출되기 때문에 마치 내부에있는것 처럼 보여 내부 트랜잭션이라고 부른다.
물리 트랜잭션, 논리 트랜잭션
- 스프링에서는 트랜잭션을 논리 트랜잭션과 물리 트랜잭션으로 나눈다.
- 물리 트랜잭션은 실제 DB에 적용되는 트랜잭션을 말한다.
- 논리 트랜잭션은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위를 말한다.
🧐 스프링 트랜잭션의 원칙
1. 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
2. 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.
모든 트랜잭션이 커밋되었을 때
외부 트랜잭션이 롤백 되었을 때
내부 트랜잭션이 롤백 되었을 때
이처럼 하나의 트랜잭션이라도 롤백이 되면 물리 트랜잭션은 롤백이 되고 모든 작업이 커밋될 때만 물리 트랜잭션이 커밋된다.
🤧 간단한 예제 코드
비즈니스 요규사항
Member테이블에 회원을 저장하고 회원에 대한 변경 이력을 추적할 수 있도록 회원 데이터가 변경될 때 변경 이력을 Log테이블에 남겨라
@Entity
@Getter @Setter
public class Member {
@Id
@GeneratedValue
private Long id;
private String username;
public Member() {
}
public Member(String username) {
this.username = username;
}
}
@Entity
@Getter @Setter
public class Log {
@Id @GeneratedValue
private Long id;
private String message;
public Log() {
}
public Log(String message) {
this.message = message;
}
}
@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {
private final EntityManager em;
@Transactional
public void save(Member member){
log.info("member 저장");
em.persist(member);
}
}
@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {
private final EntityManager em;
@Transactional
public void save(Log logMessage) {
log.info("log 저장");
em.persist(logMessage);
if (logMessage.getMessage().contains("로그예외")) {
log.info("log 저장시 예외 발생");
throw new RuntimeException("예외 발생");
}
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final LogRepository logRepository;
@Transactional
public void joinV1(String username) {
Member member = new Member(username);
Log logMessage = new Log(username);
log.info("== memberRepository 호출 시작 ==");
memberRepository.save(member);
log.info("== memberRepository 호출 종료 ==");
log.info("== logRepository 호출 시작 ==");
logRepository.save(logMessage);
log.info("== logRepository 호출 종료 ==");
}
}
스프링은 @Transactional 이 적용되어 있으면 기본으로 REQUIRED 라는 전파 옵션을 사용한다. 이 옵션은 기존 트랜잭션이 없으면 트랜잭션을 생성하고, 기존 트랜잭션이 있으면 기존 트랜잭션에 참여한다. 참여한다 는 뜻은 해당 트랜잭션을 그대로 따른다는 뜻이고, 동시에 같은 동기화 커넥션을 사용한다는 뜻이다.
👾 트랜잭션 참여 흐름
외부 트랜잭션은 처음 수행된 트랜잭션이다. 위 사진 처럼 논리 트랜잭션A(외부 트랜잭션)가 실행되는 순간 커넥션 회득 외부 트랜잭션이 진행중인 상태이다. 이 경우 내부 트랜잭션B C(논리 트랜잭션)은 외부 트랜잭션에 참여한다.
쉽게 말하면 내부 트랜잭션이 커넥션이 있는지 확인을 하고 있으면 참여(따르고) 없으면 생성을 한다.
- 클라이언트A가 MemberService 를 호출하면서 트랜잭션 AOP가 호출된다.
- 여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작한다.
- MemberRepository를 호출하면서 트랜잭션 AOP가 호출된다.
- 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
- 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
- LogRepository 를 호출하면서 트랜잭션 AOP가 호출된다.
- 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
- 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋(물리 커밋)을 호출하지 않는다.
- MemberService 의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
- 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이므로 물리 커밋을 호출한다.
😤 내 생각 정리
트랜잭션 시작 외부 트랜잭션이 커넥션을 획득
내부 트랜잭션 실행하면서 기존 커넥션이있는지 확인을 한다. 있을시 참여 (*없을시 생성한다.)
아무런 문제 없이 로직이 성공하면 신규 트랜잭션이 아니기에 실제 커밋을 호출 하지 않음
그대로 다음 내부 트랜잭션 실행 똑같이 아무런 문제가 없을시 신규 트랜잭션이 아니기에 실제 커밋을 호출하지 않고 다시 외부 트랜잭션으로 보낸다 모든 로직 호출이 끝나면 트랜잭션 매니저에 커밋을 요청 이 경우 신규 트랜잭션이므로 물리 트랜잭션에 커밋 요청을 한다.
만약에 내부 트랜잭션에 하나라도 롤백이 발생시 물리 트랜잭션에 롤백을 요청하여 전체 롤백을 한다.
🙊 트랜잭션 분리 REQUIRES_NEW
외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 사용하는 방법이다. 커밋과 롤백도 각각 별도로 이루어지게 된다. 이 방법은 내부 트랜잭션에 문제가 발생해서 롤백해도 외부 트랜잭션에는 영향을 주지 않는다. 반대로 외부 트랜잭션에 문제가 발생해도 내부 트랜잭션에 영향을 주지 않는다. 한마디로 트랜잭션을 각각 관리한다.
- 이렇게 물리 트랜잭션을 분리하려면 내부 트랜잭션을 시작할 때 REQUIRES_NEW 옵션을 사용하면 된다.
- 외부 트랜잭션과 내부 트랜잭션이 각각 별도의 물리 트랜잭션을 가진다.
- 별도의 물리 트랜잭션을 가진다는 뜻은 DB 커넥션을 따로 사용한다는 뜻이다.
- 이 경우 내부 트랜잭션이 롤백되면서 로직 2가 롤백되어도 로직 1에서 저장한 데이터에는 영향을 주지 않는다
- 최종적으로 로직2는 롤백되고, 로직1은 커밋된다.
🤧간단한 예제 코드
비즈니스 요규사항
Member테이블에 회원을 저장하고 내부 트랜잭션인 Log테이블에 예외가 발생하여 전부 롤백이 된다. 회원 가입이 성공하고 Log테이블에 로그를 남기는데 실패하더라도 회원 가입은 유지되게 하여라
현제 외부트랜잭션인 Member테이블에 저장은 잘되는데 내부 트랜잭션인 Log테이블에 로그 정보들이 저장이 잘 안되어 전체 롤백이 되어 회원 가입이 안돼는 거 같다. 그럴때 외부 트랜잭션과 내부 트랜잭션을 나누는 방법이있다.
💀 REQUIRES_NEW
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage) {
log.info("log 저장");
em.persist(logMessage);
if (logMessage.getMessage().contains("로그예외")) {
log.info("log 저장시 예외 발생");
throw new RuntimeException("예외 발생");
}
}
진짜 간단하다 내부 트랜잭션을 시작할 때 REQUIRES_NEW 옵션을 사용하면 된다. 위에 코드 처럼 메서드 위에@Transactional(propagation = Propagation.REQUIRES_NEW) 라고 작성을 해주면 끝이다.
🤪 내 생각 정리 REQUIRES_NEW 란?
- 외부 트랜잭션이 시작되면 커넥션을 획득하고 내부 트랜잭션은 커넥션이 있으면 참여를 한다. (기존 방법)
- 하지만 REQUIRES_NEW를 사용하면 내부 트랜잭션이 실행할때 신규 트랜잭션을 생성 하여 외부 트랜잭션을 참여하지 않기에 내부 트랜잭션이 롤백이 되어도 외부 트랜잭션에는 아무런 영향을 주지 않는다.
- REQUIRES_NEW 옵션을 확인하고 기존 트랜잭션을 참여하지 않고 새로운 트랜잭션을 시작한다.
- REQUIRES_NEW 옵션을 사용하면 물리 트랜잭션이 명확하게 분리된다.
- REQUIRES_NEW 를 사용하면 데이터베이스 커넥션이 동시에 2개 사용된다는 점을 주의해야 한다.
참고자료
스프링 DB 2편 - 데이터 접근 활용 기술
게으른 행동에 대해 하늘이 주는 벌은 두가지다.
하나는 자신의 실패이고
또 다른 하나는 내가 하지 않은 일을 해낸 옆 사람의 성공이다.
'Spring Boot' 카테고리의 다른 글
Spring-Boot Spring Security 스프링 시큐리티 (0) | 2024.10.28 |
---|---|
Spring-Boot AOP개념 @Aspect Advisor (0) | 2024.10.03 |
Spring Data JPA + Query Dsl JPA (2) | 2024.09.07 |
Spring-Boot H2 데이터베이스 설정과 연결 (1) | 2024.09.02 |
Spring-Boot MyBatis (0) | 2024.09.01 |