[Spring Boot] 트랜잭션 범위와 전파 속성(Propagation)
트랜잭션은 작업에서 예외가 발생할 경우 Rollback 처리를, 모두 성공할 경우 Commit 처리하는 실행 단위이다.
Spring에서는 트랜잭션과 관련된 기술들을 제공하며, 주로 @Transactional 어노테이션을 통해 쉽게 사용할 수 있다.
이번 포스팅에서는 Transacrtional을 Spring의 트랜잭션의 동작 특성과, 트랜잭션 전파라는 키워드를 위주로 살펴보려고 한다.
Spring이 제공하는 트랜잭션 기능
1. 트랜잭션 동기화
데이터베이스와의 작업이 트랜잭션 컨텍스트 내에서 일관되게 처리될 수 있도록 도와준다. 이는 여러 데이터베이스 작업이 하나의 트랜잭션 안에서 수행되도록 동기화하는 것을 의미한다.
예를 들어, 하나의 서비스 메서드에서 두 개의 다른 데이터베이스 테이블을 업데이트해야 한다고 가정해보자.
트랜잭션 동기화가 없다면, 첫 번째 테이블 업데이트가 성공하고 두 번째 테이블 업데이트가 실패했을 때, 첫 번째 업데이트 결과가 그대로 남을 수 있다.
하지만 트랜잭션 동기화를 사용하면 두 작업이 동일한 트랜잭션 안에서 수행되기 때문에 두 번째 업데이트가 실패하면 첫 번째 업데이트도 롤백되어 일관성이 유지된다.
2. 트랜잭션 추상화
개발자가 특정 데이터베이스나 트랜잭션 관리 기술에 종속되지 않고, 트랜잭션을 처리할 수 있도록 해준다.
스프링이 제공하는 PlatformTransactionManager 인터페이스를 통해 이루어지며 JDBC, JPA, JMS 등 다양한 트랜잭션 관리 방식을 동일한 방식으로 다룰 수 있다.
3. AOP를 이용한 트랜잭션 분리
트랜잭션 관리 로직을 비즈니스 로직과 분리하는 방법을 제공하며 코드의 가독성과 유지보수성을 높일 수 있다. 스프링에서는 @Transactional 어노테이션을 통해 AOP를 사용하여 트랜잭션 관리를 쉽게 분리할 수 있도록 한다.
기본적인 Transactional 사용법
Transactional을 적용하지 않을 경우
public void createUser(User user, Address address) {
userRepository.saveAndFlush(user);
throw new RuntimeException("예외 발생");
addressRepository.saveAndFlush(address);
}
다음 예시 코드는 User와 Address를 함께 저장하고 있다. 그런데 Address를 저장하기 이전에 예외가 발생했다고 생각해보자.
결과는 트랜잭션이 지정되어 있지 않기 때문에 User는 데이터베이스에 반영되지만 Address는 데이터베이스에 반영되지 않은 상태일 것이다.
비즈니스 로직이 두 Action이 하나의 작업으로 묶여 모두 반영되거나, 실패할거면 모두 반영되어야 하지 않는 원자성의 특징을 가진다면 절대 이렇게 사용할 수는 없을 것이다. 이 때문에 실제 개발을 할 때 비즈니스 로직들이 높은 비율로 트랜잭션을 필요로 한다.
또한 트랜잭션이 없다면 데이터베이스에 영구적으로 반영하기 위해서 flush()를 작성해주어야한다는 차이점이 있다.
Transactional을 적용할 경우
@Transactional
public void createUser(User user, Address address) {
userRepository.save(user);
throw new RuntimeException("예외 발생");
addressRepository.save(address);
}
이번에는 Transactional 어노테이션을 적용하여 작성한 비즈니스 로직이다. 즉, 두 Action이 모두 Commit되거나, 예외 발생 시 Rollback되게 된다. 따라서 작업의 원자성을 유지할 수 있다.
또한 Flush를 별도로 작성해주지 않아도 트랜잭션이 완료되어 커밋될 때 데이터베이스에 반영된다.
트랜잭션의 범위
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private AddressRepository addressRepository;
@Autowired
private ShoppingCartService shoppingCartService;
/** 실행할 메서드 **/
@Transactional
public void createUserInfo(User user, Address address, ShoppingCart cart) {
this.createUser(user); // 회원 저장
shoppingCartService.createShoppingCart(cart) // 장바구니 저장
addressRepository.save(address); // 주소 저장
}
@Transactional
public void createUser(User user) {
userRepository.save(user);
}
}
@Service
public class ShoppingCartService {
@Autowired
private ShoppingCartRepository shoppingCartRepository;
@Transactional
public void createShoppingCart(ShoppingCart cart) {
shoppingCartRepository.save(cart);
}
}
만약 위와 같은 코드에서 createUserInfo()를 호출한다면 트랜잭션의 범위는 어떻게 될까?
createUserInfo() 내부에서는 같은 클래스에 있는 createUser()와, 다른 클래스에 있는 createShoppingCart()를 호출하고 있고, 모두 @Transactional 어노테이션이 존재한다.
결론부터 말하면 위와 같은 형태에서는 각각의 메서드들이 @Transactional 처리되어 있어도 같은 트랜잭션 범위에서 작업이 일어난다.
Transactional에서 별도의 옵션을 주지 않는다면 기본적으로 트랜잭션의 전파 방식 중 Propagation.REQUIRED의 방식을 따른다.
- 메서드가 호출될 때, 호출한 쪽에서 트랜잭션이 설정되어 있지 않다면 새로운 트랜잭션을 시작한다. (새로운 트랜잭션을 생성하여 실행)
- 이미 트랜잭션이 설정된 스코프에서 하위 메서드가 호출되면 상위 메서드의 트랜잭션 내에서 실행된다. (동일한 트랜잭션 내에서 실행)
- 하위 메서드에서 예외가 발생할 경우 해당 트랜잭션은 롤백되며, 이 롤백은 호출한 쪽에도 전파된다.
물리 트랜잭션과 논리 트랜잭션
스프링에서는 트랜잭션을 관리하기 위해서 데이터베이스의 트랜잭션 단위와 별도의 트랜잭션 단위를 함께 사용하여 관리한다.
- 물리 트랜잭션: 실제 데이터베이스에 적용되는 실질적인 트랜잭션, 데이터베이스에 대한 변경 사항이 커밋(저장)되거나 롤백(취소)되는 단위.
- 논리 트랜잭션: 프로그래밍 레벨에서 관리되는 트랜잭션이다. 즉 스프링에서 트랜잭션 매니저를 통해 관리되는 단위. 여러 논리 트랜잭션이 하나의 물리 트랜잭션에 포함될 수 있으며 이는 스프링이 내부적으로 트랜잭션 경계를 관리하는 방식으로 사용된다.
- 하나 혹은 여러개의 논리 트랜잭션들은 물리 트랜잭션 내부에 포함된다.
- 논리 트랜잭션들이 각각 Commit된 이후 물리 트랜잭션 Commit되는 순간 실제 커밋이 이루어진다.
- 내부의 논리 트랜잭션들 중 1개라도 롤백처리가 되었다면 외부의 물리 트랜잭션이 롤백되며 다른 논리 트랜잭션들도 롤백된다.
위의 예시 코드를 물리 트랜잭션과 논리 트랜잭션으로 구분해보면 아래의 그림과 같다.
트랜잭션의 전파 방식 (Propagation)
트랜잭션 전파 방식은 트랜잭션 관리에서 하나의 트랜잭션이 다른 트랜잭션 내에서 호출될 때 그 트랜잭션을 어떻게 처리할지를 결정하는 방법을 의미한다.
Spring에서는 @Transactional의 propagation 옵션으로 설정할 수 있다.
Propagation.REQUIRED
- 트랜잭션이 필요하고, 기존 트랜잭션이 있으면 사용하고 없으면 새로 만든다.
- 메서드가 호출될 때, 호출한 쪽에서 트랜잭션이 설정되어 있지 않다면 새로운 트랜잭션을 시작한다. (새로운 트랜잭션을 생성하여 실행)
- 이미 트랜잭션이 설정된 스코프에서 하위 메서드가 호출되면 상위 메서드의 트랜잭션 내에서 실행된다. (동일한 트랜잭션 내에서 실행)
- 하위 메서드에서 예외가 발생할 경우 해당 트랜잭션은 롤백되며, 이 롤백은 호출한 쪽에도 전파된다.
- Spring의 각각의 메서드에 해당하는 논리 트랜잭션들을 하나의 물리 트랜잭션으로 묶어 논리 트랜잭션들이
Propagation.REQUIRES_NEW
- 항상 새로운 트랜잭션이 필요하다.
- 메서드가 호출될 때, 항상 새로운 트랜잭션이 시작된다. (기존 트랜잭션이 있더라도 일시 중지하고, 새로운 트랜잭션을 생성하여 실행)
- 호출한 쪽에 트랜잭션이 설정되어 있더라도, 하위 메서드는 항상 독립적인 트랜잭션 내에서 실행된다. (기존 트랜잭션과 별개로 실행)
- 하위 메서드에서 예외가 발생할 경우, 해당 트랜잭션은 롤백되지만, 상위 트랜잭션에는 영향을 미치지 않는다. (상위 트랜잭션은 계속 진행)
Propagation.MANDATORY
- 트랜잭션이 의무이다.
- 메서드가 호출될 때, 반드시 트랜잭션이 설정되어 있어야 한다. (트랜잭션이 없는 경우 예외 발생)
- 호출한 쪽에서 트랜잭션이 설정되어 있다면, 그 트랜잭션 내에서 실행된다. (기존 트랜잭션 내에서 실행)
- 트랜잭션이 없는 상태에서 호출될 경우, IllegalTransactionStateException이 발생한다. (예외 발생)
Propagation.SUPPORTS
- 기존 트랜잭션이 있으면 사용하고 없으면 새로 만들지는 않고 없이 진행한다.
- 메서드가 호출될 때, 호출한 쪽에 트랜잭션이 설정되어 있다면 해당 트랜잭션 내에서 실행된다. (기존 트랜잭션 내에서 실행)
- 트랜잭션이 설정되지 않은 상태에서 메서드가 호출될 경우, 트랜잭션 없이 실행된다. (트랜잭션이 없어도 실행)
- 트랜잭션이 없는 상태에서 하위 메서드에서 예외가 발생하면, 트랜잭션과 관계없이 예외가 처리된다. (트랜잭션과 무관하게 예외 처리)
Propagation.NOT_SUPPORTED
- 트랜잭션을 지원하지 않고 기존에 트랜잭션이 있었어도 없이 진행한다.
- 메서드가 호출될 때, 호출한 쪽에 트랜잭션이 설정되어 있더라도 트랜잭션 없이 실행된다. (기존 트랜잭션이 일시 중지되고 실행)
- 트랜잭션이 설정된 상태에서 하위 메서드가 호출되면, 트랜잭션이 없는 상태로 실행된다. (트랜잭션을 중지하고 실행)
- 하위 메서드에서 예외가 발생해도 롤백이 전파되지 않으며, 트랜잭션과는 무관하게 예외가 처리된다. (트랜잭션과 독립적으로 예외 처리)
Propagation.NEVER
- 트랜잭션을 지원하지 않고 상위 스코프에도 트랜잭션이 설정되있으면 안 된다.
- 메서드가 호출될 때, 트랜잭션이 설정되어 있지 않아야 한다. (트랜잭션이 있는 경우 예외 발생)
- 호출한 쪽에 트랜잭션이 설정되어 있으면, IllegalTransactionStateException이 발생한다. (예외 발생)
- 트랜잭션이 없는 상태에서만 메서드가 정상적으로 실행되며, 트랜잭션 없이 실행된다. (트랜잭션 없는 상태로 실행)
Propagation.NESTED
- 중첩 트랜잭션 (자식 트랜잭션)을 만든다.
- 메서드가 호출될 때, 호출한 쪽에 트랜잭션이 설정되어 있다면 중첩 트랜잭션을 생성하여 실행된다. (상위 트랜잭션 내에 별도 저장점을 설정)
- 트랜잭션이 설정되지 않은 상태에서 호출될 경우, 새로운 트랜잭션이 시작된다. (새로운 트랜잭션 생성)
- 하위 메서드에서 예외가 발생할 경우, 중첩된 트랜잭션만 롤백되며, 상위 트랜잭션은 영향을 받지 않는다. (상위 트랜잭션은 롤백되지 않음)
트랜잭션 전파 : 부분적인 작업을 반영해야 하는 경우
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private AddressRepository addressRepository;
@Autowired
private ShoppingCartService shoppingCartService;
/** 실행할 메서드 **/
@Transactional
public void createUserInfo(User user, Address address, ShoppingCart cart) {
this.createUser(user); // 회원 저장
shoppingCartService.createShoppingCart(cart) // 장바구니 저장
addressRepository.save(address); // 주소 저장
}
@Transactional
public void createUser(User user) {
userRepository.save(user);
}
}
@Service
public class ShoppingCartService {
@Autowired
private ShoppingCartRepository shoppingCartRepository;
@Transactional
public void createShoppingCart(ShoppingCart cart) {
shoppingCartRepository.save(cart);
}
}
만약 위에서 작성했던 로직에서, 회원이나 장바구니 Entity를 저장하는 도중에 예외가 발생하더라도 주소는 저장되도록 반영하고 싶다면 어떻게 해야 할까? 이 때 사용할 수 있는 트랜잭션 전파 옵션이 Propagation.REQUIRES_NEW이다.
해당 전파 옵션은 기존 트랜잭션과 별개로 실행되기 때문에 호출한 쪽에 트랜잭션이 설정되어 있더라도 하위 메서드는 독립적인 트랜잭션 내에서 실행된다.
Propagation.REQUIRES_NEW
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private AddressRepository addressRepository;
@Autowired
private ShoppingCartService shoppingCartService;
/** 실행할 메서드 **/
@Transactional
public void createUserInfo(User user, Address address, ShoppingCart cart) {
this.createAddress(address); // 주소 저장
this.createUser(user); // 회원 저장
shoppingCartService.createShoppingCart(cart) // 장바구니 저장
}
@Transactional
public void createUser(User user) {
userRepository.save(user);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createAddress(Address address){
addressRepository.save(address)
}
}
@Service
public class ShoppingCartService {
// ...
}
그래서 전파 옵션을 @Transactional(propagation = Propagation.REQUIRES_NEW)로 설정하여 다음과 같이 작성해보았다.
이럴 경우 우리가 원하는 대로 동작이 이루어질까? 아쉽게도 그렇지 않다. 기존처럼 하나의 트랜잭션 안에서 작업이 처리된다.
스프링에서는 AOP를 통해 해당 어노테이션을 인지하여 해당 메서드에 대한 프록시 객체를 생성한다. 이 프록시 객체가 트랜잭션의 시작, 커밋, 롤백 등의 관리를 자동으로 처리한다.
문제는 이 과정에서 같은 Bean(클래스) 내부에서 중첩되어 작성된 Transactional을 인지하지 못한다는 것이다.
같은 클래스 내부에서의 메서드 호출은 프록시 객체를 통해 이루어지지 않고 직접 호출이 되기 때문에 @Transactional을 감지하지 못하기 때문이다.
Bean 분리
@Service
public class AddressService {
@Autowired
private AddressRepository addressRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createAddress(Address address){
addressRepository.save(address);
}
}
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private AddressRepository addressRepository;
@Autowired
private ShoppingCartService shoppingCartService;
@Autowired
private AddressService addressService; // AddressService 주입
/** 실행할 메서드 **/
@Transactional
public void createUserInfo(User user, Address address, ShoppingCart cart) {
addressService.createAddress(address); // 주소 저장
this.createUser(user); // 회원 저장
shoppingCartService.createShoppingCart(cart) // 장바구니 저장
}
@Transactional
public void createUser(User user) {
userRepository.save(user);
}
}
@Service
public class ShoppingCartService {
// ...
}
따라서 트랜잭션을 별도의 클래스로 분리해주었다. 이제 우리가 기대했던 결과를 얻을 수 있을 것이다.
@Transactional(propagation = Propagation.REQUIRES_NEW)을 통해 현재 진행 중인 트랜잭션과 별도로 새로운 트랜잭션을 시작하여 createAddress 메서드는 UserService의 트랜잭션과는 독립적으로 실행되게 된다.
- 만약 회원 혹은 장바구니를 저장하는 과정에서 예외가 발생하여 롤백되더라도 별개로 주소를 저장하는 과정은 커밋되어 데이터베이스에 반영
- 만약 주소를 저장하는 과정에서 예외가 발생하더라도, 회원 저장과 장바구니 저장에 해당하는 트랜잭션은 계속해서 진행된다.
정리
지금까지 설명했던 트랜잭션의 범위 및 전파에 관한 내용에 대해서 예시 코드를 그림으로 정리하며 마무리지어보자.
Propagation.REQUIRED
Propagation.REQUIRES_NEW
<함께 보기> Spring Transactional의 격리 수준 (Isolation level)과 동시성 제어
https://sjh9708.tistory.com/67
<References>
https://jsonobject.tistory.com/467
'Backend > Spring' 카테고리의 다른 글
[Spring Boot] 테스트 코드 : 계층별 테스트 코드 작성 전략 (Controller, Service, Repository) (0) | 2024.08.05 |
---|---|
[Spring Boot] 레이어드(Layerd) 아키텍쳐 : 계층별 주요 책임 및 고려할 점 (0) | 2024.08.04 |
[Spring Boot] 좋은 테스트 코드 : 필요성 및 실천 방법론 개요 (1) | 2024.07.22 |
[Spring Boot] 테스트 : Test Double : 모듈 의존성 격리(Mocking) (0) | 2024.05.16 |
[Spring Boot] 테스트 코드 : 시작하기 (JUnit) (0) | 2024.03.05 |