Spring 에서는 @Transactional 이라는 어노테이션을 통해 트랜잭션을 쉽게 사용할 수 있다.
스프링에서는 이를 사용하는 방식을 “선언적 트랜잭션을 사용한다" 라고 표현한다.
내부적으로는 AOP 를 사용하여 구현된다.
- 이미 다들 알겠지만.. AOP 는 “cross-cutting-concern” 즉, 어플리케이션 전반에 걸친 공통적인 관심사 (대표적으로, 커넥션을 가져오고 커넥션을 반환하여 트랜잭션 설정 하는 것 ) 를 비즈니스 로직 으로 부터 분리 시켜 줄 수 있다.
스프링에서 AOP 는 “메소드 레벨 단위로만 적용 가능" 하다는 특징이 있다.
여기서 @Transactional 역시 메소드 레벨로 적용되는 AOP 로 구현되어있다 보니 생기는 문제점은
트랜잭션의 범위를 좁게 유지할 수 없다.
는 것이다.
만약
@Transactional
void A() {
// stmt1
// API call
// stmt2
..
}
이와 같은 메소드가 존재하는 경우, stmt1 에서는 Connection Pool 로부터 커넥션을 하나 가져온다.
A() 메소드 전체는 하나의 트랜잭션 단위이기 때문에, API 를 호출하는 동안에서 stm1 에서 가져온 커넥션을 유지하고 있는다. 그리고 stm2 역시 이 커넥션을 사용하여 DB 에 접근한다.
따라서, Connection Pool 의 커넥션들이 빠르게 고갈될 수 있다.
Network I/O 는 같은 머신 내의 저장공간에 대한 입출력도 아닌, 네트워크를 통해 요청을 하고 (필요시에는 ) 이에 대한 응답을 받아오는 과정이다.
따라서 상당한 시간이 걸릴 수 있다.
💥 만약 A() 를 호출해야하는 요청이 동시에 여러 번 일어난다면??
--> A() 메소드에 묶여있는 커넥션들이 채 반환되기도 전에 수많은 요청이 들어오는 것이다.
따라서 커넥션 풀의 커넥션들이 빠르게 고갈될 것이다.
사실 DB I/O 를 위와 같이 다른 종류의 I/O 와 묶어둘 경우 이 문제가 일어날 위험이 높아진다고 볼 수 있다. ( 커넥션을 잡아두고는 사용도 안하니까.. idle 하게 놀고 있게 되는 커넥션들이 생기는 거임 ㅠ-ㅠ idle 한 코어 처럼 생각하면 됨 )
그래서 이번에는 선언적 트랜잭션이 아닌 programmatic transaction 을 사용해보기로 했다.
따라서 아래 클래스가 필요하다.
PlatformTransactionManager 의 TransactionalTemplate
트랜잭션을 수동으로 관리할 수 있는 일련의 callback 기반의 API 들을 제공한다.
이를 사용하려면 PlatformTransactionManager 를 통해 초기화 하는 것이 필요 하다.
TransactionTemplate 이 제공하는 exeucte() 메소드에 코드블럭을 전달하면, 이 코드 블락은 하나의 트랜잭션 내에서 수행되게 된다.
이 TransactionCallback 은 함수형 인터페이스이기에 람다를 통해 코드 블럭을 전달해줘도 된다.
또한 여기서 사용되는 트랜잭션은, PlatformTransactionManager 를 통해 TransactionTemplate 을 만들 때, isolation_level, propagation level 등을 세팅 해 줄 수 있다.
@Service
public class TestUserService {
private final TestUserRepository userRepository;
private final TransactionTemplate transactionTemplate;
public TestUserService(
TestUserRepository userRepository,
PlatformTransactionManager transactionManager) {
this.userRepository = userRepository;
this.transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
예시 상황)
updateXXX 메소드 --> updateOne 메소드
이런식으로 호출되는 상황이다.
updateXXX 메소드에서는 updateOne 을 두 번 호출하여, 각각의 user 의 월급을 업데이트 한다.
참고로 아래 코드들은 개인적인 궁금증들을 해결하기 위해 작성된 극단적인 코드들임에 유의해주면 좋겠다. ex)List 가져와서 사이즈 확인도 안하고 바로 get(index) 해와서 update 하는 코드..
예시 ) 하나의 클래스 내부에서 “트랜잭션설정 안한 메소드" → 선언적 트랜잭션으로 “REQUIRES_NEW” 의 전파레벨을 가진 메소드 호출 시
self-invocation 상황에서 스프링 AOP 는 예상한대로 적용되지 않는다.
Self-invocation 과 @Transactional
Spring AOP 는 프록시를 사용하여 구현되는데, 같은 클래스 내부의 메소드를 호출 하는 것은 묵시적으로 this 참조를 통해 self invocation 을 하는 것이다.
따라서 이 경우에는 “프록시" 가 아닌 “this 참조 객체" 에 대해 호출을 하는 것이 된다.
이로 인해, 해당 메소드는 트랜잭션이 설정되지 않는다.
따라서 “호출되는 메소드(called method)" 가 @Transactional(propagation = REQUIRED_NEW) 이었다면, self-invocation 으로 인해 " 새로운 트랜잭션 범위에서 실행되지 않고(우리가 annotate 한 @Transactional 이 적용되지가 않기 때문에)" 호출하는 메소의 범위에서 실행된다.
이 경우, 호출하는 메소드에는 어떤 트랜잭션도 설정하지 않았기 때문에, 결국 이 전체 과정은 어떤 트랜잭션 위에서도 실행되는 연산들이 아니게 된다.
따라서, 호출되는 메소드에서 DB 에 대한 update 를 하더라도 commit 되지 않는다.
public void updateWithoutTransaction(int amount) {
List<TestUser> users = userRepository.findAll();
updateOne(users.get(0).getId(), amount);
updateOne(users.get(1).getId(), amount);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
protected void updateOne(Long id, int updateAmount) {
userRepository.findById(id)
.orElseThrow(() ->
new RuntimeException("해당하는 id 의 사용자가 없습니다"))
.updateSalary(updateAmount);
}
@Test
@DisplayName("트랜잭션이 존재 하지않는 같은 클래스 메소드 -> @Transactional(propagation = REQUIRES_NEW) 에서는 업데이트가 발생하지 않는다(아예 트랜잭션이 생성되지 않는 상황인 것)")
void test1() {
userService.updateWithoutTransaction(beforeSalaryAmount);
List<TestUser> users = userRepository.findAll();
Assertions.assertThat(users.get(0).getSalary())
.isEqualTo(beforeSalaryAmount);
Assertions.assertThat(users.get(1).getSalary())
.isEqualTo(beforeSalaryAmount);
}
예시 ) 하나의 클래스 내부에서 “트랜잭션설정 안한 메소드" → programmatic transaction 으로 “REQUIRES_NEW” 의 전파레벨을 가진 메소드 호출 시
Spring AOP 를 통해 트랜잭션을 설정하지 않고, 트랜잭션 템플릿을 통해 트랜잭션을 사용하는 상황이다.
따라서, this 로 호출한 메소드 내부에서 트랜잭션 템플릿을 통해 트랜잭션 내부에서 수행되는 연산을 갖고 있다면, 이는 정상적으로 수행된다. 여기에서 설정한 트랜잭션 옵션도 모두 적용되어, 매 번 새로운 트랜잭션 범위에서 연산이 수행된다.
따라서 index 0 에 위치한 user 의 월급을 업데이트하는데 성공하고
index 1 에 위치한 user 의 월급을 업데이트 하는 과정에서는 런타임 예외가 발생하더라도
다른 트랜잭션에서 이미 커밋 완료된 Index 0 은 롤백 되지 않는다.
public void updateWithoutTransactionWithCallingTemplateMethod(int amount) {
List<TestUser> users = userRepository.findAll();
updateOneThrowingExceptionByConditionWithTemplate(false, users.get(0).getId(), amount);
updateOneThrowingExceptionByConditionWithTemplate(true, users.get(1).getId(), amount);
}
protected void updateOneThrowingExceptionByConditionWithTemplate(boolean isThrowException,
Long id, int updateAmount) {
transactionTemplate.execute(status -> {
userRepository.findById(id)
.orElseThrow(() ->
new RuntimeException("해당하는 id 의 사용자가 없습니다"))
.updateSalary(updateAmount);
if (isThrowException) {
throw new RuntimeException("THROW NEW RUNTIME_EXCEPTION");
}
return null;
});
}
@Test
@DisplayName("@Transactional -> TransactionTemplate 을 사용한 메소드 - 에서 호출된 메소드에서 런타임 예외 발생 시 별개의 트랜잭션에서 수행되어 , 이미 업데이트 된 부분은 롤백되지 않는다(즉 내부 호출된 메소드의 트랜잭션이 적용된 상황!원래는 self-invocation 으로 적용이 안되는데, 프로그래밍적 트랜잭션 사용하여 적용이 가능해진 것)")
void test4() {
Assertions.assertThatExceptionOfType(RuntimeException.class)
.isThrownBy(() -> userService.updateWithoutTransactionWithCallingTemplateMethod(
beforeSalaryAmount));
List<TestUser> users = userRepository.findAll();
Assertions.assertThat(users.get(0).getSalary())
.isEqualTo(beforeSalaryAmount * 2);
Assertions.assertThat(users.get(1).getSalary())
.isEqualTo(beforeSalaryAmount);
}
참조
https://www.baeldung.com/spring-programmatic-transaction-management
https://multifrontgarden.tistory.com/289
'Spring' 카테고리의 다른 글
@InjectMocks 는 구현체여야 하는데, 구현체 타입으로 명시하고 싶지 않은 경우 (0) | 2023.01.03 |
---|---|
Controller api 가 생각처럼 출력되고 있지 않다 (2) | 2022.09.11 |
thymeleaf 에서 객체의 메소드 출력하기 그리고 null (0) | 2022.07.23 |
Self-invocation 과 @Transactional (0) | 2022.06.06 |