이전에 이해하지 못했던 트랜잭션 롤백 상황을 회사에서 겪었고, 이를 정리하기 위해 글을 작성하게 되었습니다.
1. 어떤 문제가 있었을까?
회사에서 아래와 같은 에러 로그를 확인했습니다. 통계성 데이터를 저장하다가 실패했고, 그 과정에서 트랜잭션이 롤백되며 아래오 같은 로그가 남았습니다. 정확히 기억은 안 나지만 이는 어디에서 한 번 봤던 익숙한 로그였습니다.
1
2
Transaction silently rolled back because it has been marked as rollback-only.
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only\n\tat ......
곰곰이 생각해보니 이는 몇 년 전, 배달의 민족 기술 블로그에서 봤던 에러였는데요. 당시에는 내용이 어려워서 정확한 이해를 못 하고 넘어갔습니다. 다행히 이번에는 구체적인 코드와 당장 문제를 해결해야 하는 상황 이었기 때문 확실히 원인을 파악할 수 있었습니다. 왜 해당 에러가 발생했는지 이에 대해 살펴보겠습니다.
2. 왜 발생했을까?
아래 코드를 보면 정산 데이터를 저장하는 과정에서 RuntimeException이 발생했지만, 이를 try-catch 로 잡아버렸기 때문에 메서드는 정상적으로 종료됩니다. 그러나 이미 트랜잭션 내부에서 예외가 발생한 순간, 스프링은 해당 트랜잭션을 rollback-only 상태로 마크해 둡니다. 내부적으로요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
class SettlementService(
private val settlementRepository: SettlementRepository,
private val statisticsService: StatisticsService
) {
@Transactional
fun saveMonthlySettlement(userId: Long, yyyymm: String) {
// 1. 정산 데이터 저장
settlementRepository.save(
Settlement(userId, yyyymm, 1_000_000L)
)
try {
// 2. 통계 데이터 저장 (같은 트랜잭션 참여)
statisticsService.saveStatistics(userId, yyyymm)
} catch (e: Exception) {
// 예외를 삼켜버림
log.error("통계 저장 실패", e)
}
// 겉보기에는 정상 종료
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class StatisticsService(
private val statisticsRepository: StatisticsRepository
) {
@Transactional
fun saveStatistics(userId: Long, yyyymm: String) {
statisticsRepository.save(
Statistics(userId, yyyymm)
)
throw RuntimeException("통계 저장 중 예외 발생")
}
}
따라서 바깥 트랜잭션이 성공하더라도 전체 트랜잭션은 실패하게 되는 것이죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
fun saveMonthlySettlement(userId: Long, yyyymm: String) {
// 1. DB 저장 (정상)
settlementRepository.save(Settlement(userId, yyyymm, 1_000_000L))
try {
// 2. 내부 로직 (같은 트랜잭션 참여)
statisticsService.saveStatistics(userId, yyyymm)
// → 여기서 RuntimeException 발생
} catch (e: Exception) {
// 3. 예외를 잡음 (겉보기엔 정상 흐름)
}
// 4. 하지만 이미 트랜잭션은 rollback-only 상태
}
3. 예외 케이스는 없을까?
여기서 부모 트랜잭션이 없는 경우, 비동기 와 같은 몇 가지 상황이 궁금해졌는데요. 이에 대해서도 살펴보겠습니다.
- 부모 트랜잭션이 없는 경우
- 비동기
3-1. 부모 트랜잭션이 없는 경우
부모에 트랜잭션이 없으므로 아래 메소드는 예외를 만나면 롤백한 후 종료됩니다. rollback-only 마킹은 다른 트랜잭션이 참여한 경우에만 의미가 있기 때문에 UnexpectedRollbackException 은 발생하지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
class SettlementService(
private val statisticsService: StatisticsService
) {
// @Transactional 없음 → 부모 트랜잭션 없음
fun saveMonthlySettlement(userId: Long, yyyymm: String) {
// 정상 저장
settlementRepository.save(Settlement(userId, yyyymm, 1_000_000L))
try {
// saveStatistics가 자체 트랜잭션을 생성
statisticsService.saveStatistics(userId, yyyymm)
} catch (e: Exception) {
log.error("통계 저장 실패", e)
}
// RuntimeException은 발생하지만,
// UnexpectedRollbackException은 발생하지 않음
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class StatisticsService(
private val statisticsRepository: StatisticsRepository
) {
@Transactional
fun saveStatistics(userId: Long, yyyymm: String) {
statisticsRepository.save(
Statistics(userId, yyyymm)
)
throw RuntimeException("통계 저장 중 예외 발생")
}
}
3-2. @Async로 비동기 처리한 경우
스프링의 트랜잭션은 @Async 를 사용할 때, 트랜잭션은 새 스레드에서 실행되며 이는 부모 트랜잭션 컨텍스트와 완전히 분리됩니다. 따라서 내부에서 예외가 발생해도 부모 트랜잭션에는 영향이 없습니다. 다만 비동기이므로 부모 쪽에서 성공/실패 여부를 알 수 없고, 실패 시 보상 처리를 별도로 구현해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Service
class SettlementService(
private val settlementRepository: SettlementRepository,
private val statisticsService: StatisticsService
) {
@Transactional
fun saveMonthlySettlement(userId: Long, yyyymm: String) {
// 1. 정산 데이터 저장
settlementRepository.save(
Settlement(userId, yyyymm, 1_000_000L)
)
// 2. 통계 데이터 저장 (비동기 → 별도 스레드)
statisticsService.saveStatistics(userId, yyyymm)
// 비동기이므로 예외가 전파되지 않음, 정산은 정상 커밋
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
class StatisticsService(
private val statisticsRepository: StatisticsRepository
) {
@Async
@Transactional
fun saveStatistics(userId: Long, yyyymm: String) {
statisticsRepository.save(
Statistics(userId, yyyymm)
)
throw RuntimeException("통계 저장 중 예외 발생")
// 별도 스레드에서 자체 트랜잭션이 롤백될 뿐,
// 부모 트랜잭션에는 영향 없음
}
}
반면 @Async가 부모 트랜잭션에 있는 경우, 부모 메서드를 별도 스레드에서 실행시킬 뿐, 그 안에서의 트랜잭션 동작은 동일합니다. 부모가 트랜잭션을 생성하고, 자식이 트랜잭션에 참여하는 구조는 바뀌지 않습니다. 커밋 시점에 UnexpectedRollbackException이 발생합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
class SettlementService(
private val settlementRepository: SettlementRepository,
private val statisticsService: StatisticsService
) {
@Async
@Transactional
fun saveMonthlySettlement(userId: Long, yyyymm: String) {
// 1. 정산 데이터 저장
settlementRepository.save(
Settlement(userId, yyyymm, 1_000_000L)
)
try {
// 2. 통계 데이터 저장 (같은 트랜잭션 참여)
statisticsService.saveStatistics(userId, yyyymm)
} catch (e: Exception) {
log.error("통계 저장 실패", e)
}
// 여전히 UnexpectedRollbackException 발생
}
}
4. 부모 트랜잭션을 성공시키려면?
내부 메서드에 @Transactional(propagation = Propagation.REQUIRES_NEW) 를 선언하면, 부모 트랜잭션과 별개의 새로운 트랜잭션을 생성합니다. 이 경우 내부에서 예외가 발생해도 부모 트랜잭션은 영향을 받지 않습니다. 자식 트랜잭션이 새로운 문맥에서 실행되었기 때문에 트랜잭션이 실패하더라도 전체에 영향을 미치지는 않기 때문이죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
class SettlementService(
private val settlementRepository: SettlementRepository,
private val statisticsService: StatisticsService
) {
@Transactional
fun saveMonthlySettlement(userId: Long, yyyymm: String) {
// 1. 정산 데이터 저장
settlementRepository.save(
Settlement(userId, yyyymm, 1_000_000L)
)
try {
// 2. 통계 데이터 저장 (REQUIRES_NEW → 별도 트랜잭션)
statisticsService.saveStatistics(userId, yyyymm)
} catch (e: Exception) {
log.error("통계 저장 실패", e)
}
// 통계는 롤백되지만, 정산은 정상 커밋
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
class StatisticsService(
private val statisticsRepository: StatisticsRepository
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun saveStatistics(userId: Long, yyyymm: String) {
statisticsRepository.save(
Statistics(userId, yyyymm)
)
throw RuntimeException("통계 저장 중 예외 발생")
}
}
이렇게 하면 통계 저장은 롤백되지만, 부모인 정산 저장은 정상적으로 커밋됩니다. 다만 REQUIRES_NEW 는 별도 커넥션을 사용하기 때문에 커넥션 풀 고갈 가능성이 있고, 부모와 자식 간 데이터 정합성을 직접 관리해야 합니다.
PROPAGATION_REQUIRES_NEW, in contrast to PROPAGATION_REQUIRED, uses a completely independent transaction for each affected transaction scope. In that case, the underlying physical transactions are different and hence can commit or roll back independently, with an outer transaction not affected by an inner transaction’s rollback status.
5. 정리
자식 트랜잭션에서 예외가 발생할 경우, try/catch가 있더라도 자동 롤백 됩니다. 다만 예외 케이스도 있으니, 잘 체크해보세요. 여담으로 이전에 이해하지 못한 내용을 확실하게 짚고 넘어가서 기분이 좋았(?) 는데, 바빠서 잠깐 즐기고 지나갔습니다.