Propagation.REQUIRES_NEW란?
REQUIRES_NEW는 Spring의 트랜잭션 전파 속성 중 하나로, 독립적인 새로운 트랜잭션을 시작할 때 사용한다.
현재 트랜잭션이 존재하든 말든, 무조건 새로운 트랜잭션을 시작한다.
기존 트랜잭션이 존재할 경우 잠시 중단되고, 새로운 트랜잭션이 완전히 별도로 실행된다.
사용 시기
로깅, 알림, 감사 로그 등 독립적 저장이 필요한 작업
부모 트랜잭션의 롤백 여부와 상관 없이 저장되어야 하는 작업을 할 때 사용한다.
트랜잭션 범위 최소화
락 범위나 예외 영향을 줄이고 싶을 때 사용한다.
경우에 따른 롤백
하위 메소드에서 예외 발생
아래 예제를 보면 상위 메소드의 결과까지 롤백이 된 것을 볼 수 있다.
분명 REQUIRES_NEW를 사용하면 독립적인 트랜잭션이라 했는데 왜 이런 결과가 나올까?
REQUIRES_NEW는 트랜잭션을 분리하지만 예외는 트랜잭션과 별개로 상위 메소드로 던져진다.
즉, 하위 메소드에서 터진 예외가 부모로 전달되면서 스택이 전파되고, 그 예외가 부모 메소드 바깥으로 던져졌기 때문에 Spring이 이걸 예외로 간주하고 부모 트랜잭션도 롤백한 것이다.
@Service
class ParentService(
private val childService: ChildService,
private val productRepository: ProductRepository
) {
@Transactional
fun parent() {
val product = Product(
name = "parent",
stockQuantity = 1
)
productRepository.save(product)
// 하위 메소드 호출
childService.child()
}
}
@Service
class ChildService(
private val productRepository: ProductRepository
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun child() {
val product = Product(
name = "child",
stockQuantity = 1
)
productRepository.save(product)
// 오류 발생
if (true) {
throw IllegalArgumentException("오류 발생")
}
}
}
@SpringBootTest
class RequiresNewTest(
private val parentService: ParentService,
private val productRepository: ProductRepository
) : FunSpec({
test("하위 메소드에서 예외 발생 시 상위 메소드의 트랜잭션은 롤백되지 않는다") {
try {
parentService.parent()
} catch (e: Exception) { }
val products = productRepository.findAll()
products.size shouldBe 1
}
})
하위 메소드에서 발생한 예외를 처리하는 경우
하위 메소드에서 발생한 예외에 대해 처리를 해주면 스택이 전파되지 않기 때문에 부모 메소드는 롤백되지 않는다.
@Service
class ParentService(
private val childService: ChildService,
private val productRepository: ProductRepository
) {
@Transactional
fun parent() {
val product = Product(
name = "parent",
stockQuantity = 1
)
productRepository.save(product)
// 하위 메소드 호출
try {
childService.child() // 예외가 발생하도록 코드 작성
} catch (e: Exception) {
println("예외 처리")
}
}
}
하위 메소드 완료 후 부모 메소드에서 예외 발생
하위 메소드에서 저장 완료 후 부모 메소드에서 예외가 발생하면 부모 메소드는 롤백되고 하위 메소드는 롤백되지 않는다.
@Service
class ParentService(
private val childService: ChildService,
private val productRepository: ProductRepository
) {
@Transactional
fun parent() {
val product = Product(
name = "parent",
stockQuantity = 1
)
productRepository.save(product)
// 하위 메소드 호출
try {
childService.child() // 정상적으로 완료
} catch (e: Exception) {
println("예외 처리")
}
// 임의로 오류 발생
if (true) {
throw IllegalArgumentException("오류 발생")
}
}
}
보상 트랜잭션
보상 트랜잭션은 분산 시스템에서 트랜잭션의 원자성을 완벽히 보장할 수 없을 때, 실패한 작업을 되돌리기 위해 수행하는 작업이다.
즉, 이미 실행된 작업을 취소하거나 무효화하기 위한 되돌리기 작업이다.
왜 필요할까?
일반적인 데이터베이스에서는 트랜잭션이 실패하면 롤백으로 상태를 원상 복구할 수 있다. 하지만 마이크로서비스나 분산 시스템에서는 서로 다른 시스템이 독립적으로 트랜잭션을 처리하기 때문에 롤백이 불가능하거나 어려운 경우가 많다. 그래서 보상 트랜잭션을 별도로 정의해서 트랜잭션 실패 시, 이미 실행된 작업들을 수동으로 취소하는 방식으로 문제를 해결한다.
그런데 이런 보상 트랜잭션이 왜 갑자기 REQUIRES_NEW와 함께 등장할까?
보통 REQUIRES_NEW는 독립적인 트랜잭션을 생성해서 부모 트랜잭션의 성공/실패와 관계없이 특정 작업을 수행하고 싶을 때 사용한다.
하지만 MySQL Named Lock이나 Redis 분산락 등을 사용할 때, 락의 범위를 최소화하기 위해 REQUIRES_NEW를 사용하는 경우도 많다.
예를 들어, 아래와 같은 상품 주문 로직이 있다고 해보자.
fun createOrder() {
// 전처리
// 재고 감소 with Lock (REQUIRES_NEW)
productService.decreaseQuantityWithLock();
// 후처리
}
이 로직에서 productService.decreaseQuantityWithLock()은 별도의 트랜잭션(REQUIRES_NEW)으로 처리되며, 락을 잡고 재고를 감소시킨다. 문제는 그 다음 후처리 과정에서 예외가 발생했을 때다.
이 경우, createOrder() 트랜잭션은 롤백되지만, 이미 커밋된 decreaseQuantityWithLock()는 롤백되지 않는다.
즉, 재고는 줄었는데 주문은 실패한 이상한 상태가 발생할 수 있다.
바로 이럴 때 필요한 것이 보상 트랜잭션이다.
후처리 실패 시, 이미 수행된 decreaseQuantity()의 작업을 되돌리기 위한 보상 로직을 명시적으로 구현해야 한다.
아래 예시 코드는 주문 생성 시 Redis 분산락을 이용해 재고를 감소시킨다.
만약 후처리 중에 예외가 발생하면, 보상 트랜잭션을 통해 재고를 다시 증가시키는데, 이때도 동일하게 Redis 분산락을 활용하여 재고 조정의 정합성을 유지한다.
또한, 보상 트랜잭션 자체도 실패할 수 있기 때문에, @Retryable을 이용해 재시도 로직을 적용하고, 최종적으로 모든 시도가 실패한 경우에는 로깅이나 모니터링을 통해 적절한 후속 조치를 할 수 있도록 설계한다.
@Service
class OrderService(
private val productService: ProductService,
) {
@Transactional
fun createOrderWithCompensatingTransaction() {
// 전처리
// 재고 감소
productService.decreaseStockQuantity(2, 1)
// 후처리
try {
if (true) throw IllegalArgumentException()
} catch (e: Exception) {
productService.rollbackStockQuantity(2, 1) // 보상 트랜잭션
}
}
}
@Service
class ProductService(
private val productRepository: ProductRepository
) {
private val log = KotlinLogging.logger { }
@RedisRLock(
key = DynamicKey(prefix = "product", key = "#productId"),
waitTime = 10L,
leaseTime = 10L
)
fun decreaseStockQuantity(
productId: Long,
quantity: Int,
) {
// 상품 조회
val product = productRepository.findByIdOrNull(productId)
?: throw IllegalArgumentException("Product not found with id: $productId")
// 재고 감소
product.decreaseStockQuantity(quantity)
}
@Retryable(
value = [Exception::class],
maxAttempts = 3,
backoff = Backoff(delay = 2000)
)
@RedisRLock(
key = DynamicKey(prefix = "product", key = "#productId"),
waitTime = 10L,
leaseTime = 10L
)
fun rollbackStockQuantity(
productId: Long,
quantity: Int
) {
// 상품 조회
val product = productRepository.findByIdOrNull(productId)
?: throw IllegalArgumentException("Product not found with id: $productId")
// 재고 증가
product.increaseStockQuantity(quantity)
}
@Recover
fun allFailure() {
log.error { "보상 트랜잭션 실패" }
// TODO : 로깅 및 모니터링 추가
}
}
Connection Pool
Connection Pool이란?
커넥션 풀은 데이터베이스와의 연결을 미리 여러 개 만들어 놓고, 애플리케이션에서 필요할 때마다 꺼내서 쓰고, 다 쓰면 다시 반납하는 구조이다.
왜 필요할까?
DB 커넥션을 생성하는 작업은 시간도 오래 걸리고 많은 자원을 사용한다. 그래서 매번 요청할 때마다 DB 커넥션을 새로 만들고 닫으면 성능 저하가 발생한다. 그래서 아래와 같은 방식을 사용한다.
- 애플리케이션 시작 시 커넥션을 일정 개수만큼 생성
- 요청이 들어오면 커넥션 풀에서 기존 커넥션을 꺼내어 사용
- 사용이 끝나면 닫지 않고 풀에 반납
- 풀에 남은 커넥션이 없으면, 기다리거나 새로 생성
이와 같은 구조 덕분에 DB 연결 속도가 빨라지고, 자원 사용이 효율적이며, 동시에 많은 요청을 처리할 수 있다.
HikariCP란?
HikariCP는 자바 진영에서 가장 많이 쓰이는 고성능 JDBC Connection Pool 라이브러리다.
Spring Boot에서 별다른 설정을 하지 않으면 기본적으로 HikariCP를 사용한다.
HikariCP vs 다른 커넥션 풀
항목 | HikariCP | Tomcat JDBC | C3P0 |
속도 | 가장 빠름 | 중간 | 느림 |
안정성 | 매우 높음 | 괜찮음 | 가끔 이슈 |
설정 난이도 | 낮음 | 낮음 | 비교적 복잡 |
REQUIRES_NEW를 사용할 때 커넥션 풀을 주의할 것
REQUIRES_NEW는 기존 트랜잭션과 별개로 새로운 트랜잭션을 생성하기 때문에, 내부적으로 새로운 DB 커넥션을 필요로 한다. 위에서 살펴본 상품 주문 로직을 실행하면 상위 메소드 트랜잭션용 커넥션 1개, 하위 메소드(REQUIRES_NEW) 트랜잭션용 커넥션 1개가 필요하다.
Spring Boot 기본 설정에서는 HikariCP의 maximumPoolSize가 10으로 설정되어 있다. 이는 애플리케이션에서 동시에 사용할 수 있는 최대 커넥션 수가 10개라는 의미이다.
그렇다면 어떤 점을 주의해야 할까?
바로, 동시에 다수의 사용자가 접속하는 상황에서 문제가 발생할 수 있다는 점이다.
예를 들어, 10명의 사용자가 동시에 주문을 시도한다고 가정해보자.
각 사용자의 요청은 부모 메소드 트랜잭션용 커넥션 1개씩을 가져가게 된다.
이 시점에서 이미 10개의 커넥션이 모두 사용중이기 때문에 하위 메소드가 커넥션을 획득하지 못해 무한 대기하다가 실패하게 된다.
그럼, 만약 maximumPoolSize가 11이면 어떻게 될까?
부모 메소드가 10개의 커넥션을 획득해도 1개의 커넥션이 남아 하위 메소드가 커넥션을 획득할 수 있어 메소드가 정상적으로 실행된다.
즉, REQUIRES_NEW를 사용할 때 적절한 Connection Pool 설정이 필요하다.
Connection Pool Size = Tn * (Cn - 1) + 1
Tn : 전체 Thread 개수
Cn : 하나의 요청에서 동시에 필요한 Connection 개수
위 공식은 가장 흔하게 사용할 수 있는 공식이다. 하지만 모니터링을 통해 상황에 맞게 유연하게 설정하는 편이 좋다.
'Programming > SpringBoot' 카테고리의 다른 글
Transactional Outbox Pattern(2) - Debezium (0) | 2025.07.06 |
---|---|
Transactional Outbox Pattern(1) - Polling (3) | 2025.07.06 |
Spring Boot 동시성 제어 (4) - MySQL Named 락 (0) | 2025.04.09 |
Spring Boot 동시성 제어 (3) - 비관적 락 (0) | 2025.04.08 |
Spring Boot 동시성 제어 (2) - 낙관적 락 (0) | 2025.04.08 |