들어가며
안녕하세요~! 오늘은 Spring 관련한 게시글로 찾아뵙게 되었네요
Spring 개발자라면 가장 많이 사용하게 되는 어노테이션인 Transactional 을 사용할 시 주의점에 대해 소개해드리고자 합니다
Spring Transactional
Spring 에서는 PlatformTransactionManager 이라는 클래스가 Transaction 들을 관리해주는 역할을 가지고 있습니다.
작성한 코드가 정상적으로 가동한다면 큰 이상 없이 commit 을 하게 되고,
만일 예외가 발생하는 사항이 발생한다면 rollback 을 mark 하여 Transaction 을 commit 하지 않게 됩니다.
그럼 어떠한 매커니즘으로 Transaction 을 관리하게 될까요?
@Transactional 어노테이션을 AOP 로 구현하여 관리하게 된답니다.
Transactional 어노테이션을 붙인 클래스 로직을 호출하기전에, Transaction Advisor(Transaction Interceptor) 클래스에 의해
Transaction 을 시작하고 관리하게 되는 것이죠.
또한 요구사항에 따라 Transaction 에 다양한 정책을 부과할 수도 있습니다.
Option | Description |
Propagation | Transaction 전파레벨. 기본은 Transcation 을 상속하는 REQUIRED |
Isolation | Transaction 격리레벨. 기본은 DB 정책을 따라가는 DEFAULT |
Timeout | Transaction 시 timeout 임계치 설정 |
RollbackFor | 특정 클래스에 대한 예외 발생시 Rollback 을 처리하게 됨. 기본은 RuntimeException or Error |
NoRollbackFor | 특정 클래스에 대한 예외 발생시 Rollback 을 처리하지 않음. 기본은 없음 |
readOnly | 읽기전용으로만 사용할 것인지 설정 |
Propagation 과 Isolation 은 다루기에는 엄~청 다양한 케이스로 분류해야되니 디테일한 설명을 생략합니다.
단 가장 많이 사용하는 설정은 기본값이기에 REQUIRED / REQUIRES_NEW 에 대해서만 간략하게 소개 드리겠습니다.
REQUIRED 설정은 그림에서 보는 것과 같이, Transaction 이 중첩되는 경우 이미 존재하는 Transaction 을 활용하게 됩니다.
REQUIRES_NEW 설정은 그림에서 보는 것과 같이, 새로운 Transaction 을 생성하여 기존 정책과는 다르게 가져갑니다.
개인적으로는 위 2가지 설정을 제일 많이 사용하는 것 같고, 다른 정책들은 상대적으로 덜 채택하게 되는 것 같습니다
이 Propgation 정책에 대해 설명드린 이유는, 바로 예외 상황에 대한 분석을 조금 더 쉽게 이해드리고자 설명드렸어요.
저희가 이번 게시글을 통해 중요하게 바라보는 것은 바로 예외 상황입니다.
Transaction 중에 예외가 발생하면 어떻게 될까?
바로 예제 코드와 함께 분석해보겠습니다.
@Service
class PostServiceWrapper(
private val postService: PostService,
) {
@Transactional
fun error(): PostsDomain {
return postService.error()
}
}
@Service
class PostService(
private val postsRepository: PostsRepository,
) {
@Transactional
fun error(): PostsDomain {
throw IllegalArgumentException("Always failed")
}
}
간단하게 항상 예외를 발생하는 Service 를 만들고 Controller 에 연결해서 호출해보았습니다.
예상대로 실패합니다.
조금 더 상황을 한번 꼬아보겠습니다.
만일 ServiceWrapper 에서 try catch 를 걸고 새로운 저장메서드를 부르면 어떻게 될까요?
@Service
class PostServiceWrapper(
private val postService: PostService,
) {
@Transactional
fun saveOrError(): PostsDomain {
return try {
postService.error()
} catch (e: Exception) {
postService.save()
}
}
}
@Service
class PostService(
private val postsRepository: PostsRepository,
) {
@Transactional
fun save(): PostsDomain {
return postsRepository.save(
Posts(
title = "test",
content = "content",
)
).toDomain()
}
@Transactional
fun error(): PostsDomain {
throw IllegalArgumentException("Always failed")
}
}
Wrapper 에서 Transaction 을 열고, Service 에서 각각 Transactional 을 달아 Transaction 을 유지하도록 설정해보았네요.
Wrapper 에서 try catch 로 exception 을 핸들링 했으니, 당연히 로직에는 큰 이상 없고 commit 될 것이라 생각되는군요
당연히 성공할 줄 알았던 Transaction 이 놀랍게도 Transaction 이 rollback-only 로 mark 되었다며 실패합니다. 왜 그럴까요?
이를 찾기 위해 TransactionAspect 클래스를 탐구하며 들어가보겠습니다.
Aspect 에서 예외가 던져졌으면 뭔가작업을 하고 다시 예외를 던지는 것을 볼 수 있습니다.
당연하게도 중첩 Transactional 을 걸었기 때문에 Aspect 는 호출처마다 발생하게 되는군요
아까 위에서 설명드렸던 rollbackOn 메서드에서 RuntimeException 인지 판단해서 true 면 rollback 을 하는 코드를 볼 수 있네요.
저 rollback 메서드를 타고 들어가다보면 아래와 같은 코드를 볼 수 있어요
즉, Transaction 설정이 Rollback 으로 mark 되어 성공적으로 commit 을 할 수 없다는 것이죠.
1번이라도 예외가 AOP 를 통해 감지가 되었으면, 그 Transaction 은 다시 재사용할 수 없게 된다는 것이네요
그럼 이 현상을 회피해서 억지로라도 Transaction 을 성공시키려면 어떻게 해야할까요?
- save / error 메서드 transactional 에 REQUIRES_NEW 로 정책을 수정해서 새로운 Transaction 을 열게 한다
- error 메서드 transactional 에 noRollback For 로 특정 Exception 을 마킹해서 rollback 되지 않도록 한다
2가지 방법이 있겠군요.
@Service
class PostServiceWrapper(
private val postService: PostService,
) {
@Transactional
fun saveOrError(): PostsDomain {
return try {
postService.error()
} catch (e: Exception) {
postService.save()
}
}
}
@Service
class PostService(
private val postsRepository: PostsRepository,
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun save(): PostsDomain {
return postsRepository.save(
Posts(
title = "test",
content = "content",
)
).toDomain()
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun error(): PostsDomain {
throw IllegalArgumentException("Always failed")
}
}
위 처럼 설정하고 다시 한번 Wrapper 의 saveOrError 를 호출해볼까요?
예상한 대로 Transaction 의 범위를 Wrapper 와 분리하여 새로운 scope 로 지정하였더니
저장에 성공하는 것을 볼 수 있었네요
예외상황 때문에 Transaction 의 정책을 수정하는 것이 좋을까?
위 예제코드처럼 Transaction 내부에서 발생한 예외를 핸들링하기 위해 Propagation 정책을 수정하거나, Rollback 정책을 수정하는 것이 과연 좋을까요?
저의 정답은 단연컨데 아니요 입니다
왜 그렇게 생각하느냐면...
- Transaction 정책을 각기 다르게 가져가는 순간 다른 동료 개발자는 이 Transaction 에 대해 심도 있게 고민해야 합니다.
- 특정 Transaction 상황에 대해 메서드를 설계했기 때문에, 재사용성이 떨어집니다.
- 예외 클래스가 1개가 반드시 1 종류의 에러 상황으로 볼 수 없습니다. 특히 RuntimeException 을 지정하면.. 사실상 모든 예외 클래스겠죠
요약해서 말씀드리면, code 전개 방식이 복잡해지고, 고려해야되는 변수들을 늘려가는 방식이기 때문에
본질적으로 클래스 구조를 개선해서 최대한 Transaction 정책은 단순하게 가져가는 것이 유지보수 및 확장성에 좋다고 생각해요.
물론 지극히 제 개인적인 견해임을 밝힙니다 ㅎㅎ
정리하며
꽤나 길고 복잡한 여정이었죠~! 간단하게 정리하며 마치겠습니다.
- Spring 에서 Transaction 관리는 AOP 기반의 PlatformTransactionManager 가 담당한다
- Transactional 에는 다양한 전파정책과 예외정책들이 있다
- Transactional 사용시 예외가 발생하면 기본적으로는 예외가 발생한 Transaction 은 더 이상 사용하려고 하지말자
'Developer > Spring' 카테고리의 다른 글
[JPA] Hibernate 에서 지원하는 Id generator 에 대해 알아보자 (1) | 2023.12.17 |
---|---|
Micrometer Tracing 에 대해 알아보자(a.k.a. spring cloud sleuth) (0) | 2023.10.28 |
Spring WebFlux 에서는 어떻게 Kotlin Coroutine 을 지원하고 있을까? ( feat. Context ) (3) | 2023.05.27 |
[JPA] Hibernate 에서 Query statement caching 은 어떻게 이루어질까? (5) | 2023.01.08 |
[Test] 효과적인 Integration Test 를 위한 Spring Test Context 를 구성해보자 (2) | 2022.09.24 |