💡 이 글은 트랜잭션을 사용하면서 겪은 지극히 개인적인 일(초보적인 실수)에 대해 정리한 글입니다.
이론적인 설명보다는, 실무에서 부딪히고 깨지며 얻은 날것의 깨달음을 중심으로 작성했습니다.

문제의 시작: 단순했던 나의 생각
대량 등록 API를 개발하던 중, 다음과 같은 요구사항이 생겼다..
“작업의 성공 여부와, 실패한 경우 그 원인을 DB 로그로 남겨야 한다.”
나는 아주 단순하게 생각했다.
'전체 로직을 try-catch로 감싸고, 예외가 터지면 catch 블록에서 실패 로그 상태를 업데이트하면 되잖아?'
@Transactional
fun postBatchProduct(...) {
val bulkUploadLog = bulkUploadLogService.createBulkUploadLog(... PENDING ...)
try {
// 엑셀 파싱 및 DTO 생성
} catch (e: Exception) {
bulkUploadLogService.updateBulkUploadLog(... FAILED ...)
throw e
}
try {
// bulk insert
} catch (e: Exception) {
bulkUploadLogService.updateBulkUploadLog(... FAILED ...)
throw e
}
bulkUploadLogService.updateBulkUploadLog(... COMPLETED ...)
}
해결 시도 : REQUIRES_NEW의 함정과 데드락 🤯
로그는 반드시 남아야 한다!!
첫 번째 해결 방법으로 시도한 것은 트랜잭션을 분리하는 것이었다.
실제 대량 등록을 요하는 비즈니스 로직과 로그를 생성 및 수정하는 로직의 트랜잭션을 분리하고자 REQUIRES_NEW 전파 속성을 사용했다.
💡 REQUIRES_NEW 전파 속성이란?
기존에 실행 중인 부모 트랜잭션이 있든 없든 무조건 완전히 독립적인 새로운 트랜잭션을 생성하는 옵션을 말한다.
조금 더 정확하게 말하자면, 기존 트랜잭션(부모)이 존재하면 잠시 멈춤 상태(suspend)가 되고, 새로 열린 트랜잭션(자식)이 끝날 때까지 대기한다. 자식 트랜잭션은 부모 트랜잭션의 성공(커밋)/실패(롤백) 여부와 상관없이 독자적으로 동작한다.
이 때문에 완전히 다른 커밋/롤백 생명주기를 가져야 할 때 즉, 메인 로직의 성공여부와 상관없이 완료되어야 하는 작업을 다룰 때 흔히 해결책으로 떠올린다고 한다.
트랜잭션 분리하기
최상위 메서드를 부모 트랜잭션으로 두고 로그 생성 및 수정하는 하위 메서드를 자식 트랜잭션으로 설정해 트랜잭션을 분리하였다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun createBulkUploadLog(...): BulkUploadLog {
val log = bulkUploadLogRepository.save(...)
bulkUploadLogRepository.flush() // 즉시 DB 반영
return log
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun updateBulkUploadLog(logId: Long, reason: String? = null, status: UploadProcessStatus) {
bulkUploadLogRepository.findByIdOrNull(logId)?.updateProcessStatus(...)
bulkUploadLogRepository.flush() // 즉시 DB 반영
}
이론적으로도 완벽해 보이고 해당 작업을 호출했을 때도 문제없이 로그가 생성되고 업데이트되었다. 심지어 부모 트랜잭션이 롤백되는 상황에서도 로그는 Status를 업데이트하고 저장되었다!!
모든 문제가 해결되었다고 느낀 시점이야 말로 모든 문제가 시작되는 부분이었다.
데드락 발생 🤯
실운영 서버에 적용하자마자 대량등록 작업이 안된다는 CS가 들어왔다. 분명 작업이 실패했다면 작업자에게 실패 원인을 보여줬을 텐데 왜 안된다고 하지? 싶었다. 서둘러 로그 테이블에 저장된 데이터를 보기 위해 DB를 켰을 때는 어떠한 데이터도 남아있지 않았다.
분명 트랜잭션을 재정의하고 부모 트랜잭션이 실패하더라도 REQUIRES_NEW를 통해 분리한 자식 트랜잭션은 롤백되지 않고 DB에 데이터가 남아있어야 했다.
서버 로그를 확인한 결과, DB 락 경합으로 인해 트랜잭션이 강제 롤백된 것을 확인했다.
REQUIRES_NEW로 분리한 트랜잭션이 동일 Row에 접근하면서 락 대기가 발생했고, 결국 DB가 해당 요청을 실패 처리한 것이다.
💡 REQUIRES_NEW 전파 속성의 문제점
REQUIRES_NEW의 설명으로 아래와 같이 적어두었다.
기존 트랜잭션(부모)이 존재하면 잠시 멈춤 상태(suspend)가 되고, 새로 열린 트랜잭션(자식)이 끝날 때까지 대기한다.
또한, REQUIRES_NEW는 부모 트랜잭션과 자식 트랜잭션이 독립적으로 실행된다는 것이다. 이는 단순 독립적이라는 의미이지 안전하다는 의미는 아니다.
기억해야 할 REQUIRES_NEW의 문제점은 크게 2가지가 존재한다.
첫 번째, 여러 개의 DB 커넥션 점유
트랜잭션을 분리하면 여러 개의 DB커넥션을 사용한다. 부모, 자식 트랜잭션이 각각 하나의 DB 커넥션을 점유하고 트래픽이 몰리면 커넥션 풀이 순식간에 고갈될 수 있다.
두 번째, 일시정지(Suspend) 시 자원(Lock) 유지
부모 트랜잭션은 자식 트랜잭션이 진행되는 시점에 자신이 쥐고 있던 자원(Lock)을 놓지 않고 일시 정지(Suspend) 상태가 된다는 것이다. 만약 자식 트랜잭션이 부모가 Lock을 쥐고 있는 데이터에 접근하려고 하면 완벽한 교착 상태(Deadlock)에 빠지게 된다.
나의 문제점
트랜잭션이 락(Lock)을 어떻게 쥐고 있는지 정확하게 이해하지 못한 게 화근이었다. 현재 상황을 순서대로 정의하면 이렇다.
(참고: DB는 MySQL을 사용한다.)
- 상위 트랜잭션이 시작되며 createBulkUploadLog를 호출해 로그를 INSERT 한다. 이때 상위 트랜잭션은 해당 DB Row에 대한 쓰기 락(Write Lock)을 쥐게 된다.
- 엑셀 파싱 등 로직을 타다가 예외가 발생해 catch 블록으로 빠진다.
- REQUIRES_NEW가 걸린 updateBulkUploadLog를 호출한다. 스프링은 상위 트랜잭션을 락을 쥔 채로 일시 정지(Suspend) 시키고 새 트랜잭션을 연다.
- 새로 열린 자식 트랜잭션이 방금 생성된 그 똑같은 로그 Row를 찾아 상태를 FAILED로 UPDATE 하려고 시도한다.
- 💥 교착 상태 발생! 자식 트랜잭션은 상위 트랜잭션이 락을 풀어주길(커밋/롤백되길) 기다리고, 상위 트랜잭션은 자식 트랜잭션이 끝나길 기다리며 무한 대기에 빠진 것이다.
이 과정을 통해 DB 데이터 락(Lock)에 대한 나의 개념이 얼마나 부족했는지 뼈저리게 깨달았다. 결국 REQUIRES_NEW는 만능 치트키가 아니었으며, 이번 데드락은 트랜잭션 전파 속성에 대한 얕은 이해도와 DB 커넥션, 그리고 락 메커니즘에 대한 무지에서 비롯된 문제였다.
나의 오해와 진실 (고정관념 깨기)
이번 일을 겪으며, 내가 가진 고정관념 와 오해를 돌아보게 되었다.
1. "한 요청 = 한 트랜잭션"이라는 강박
그동안 CRUD API 위주로 개발하면서, API 요청 전체를 하나의 트랜잭션으로 감싸야한다는 고정관념을 스스로 만들어 버렸다.
2. 데이터 매핑 과정도 트랜잭션 안에 있어야 한다?
대량등록 API는 엑셀 파일 데이터를 파싱하고 DTO로 변화하는 작업을 필요로 했는데 해당 작업은 단순한 메모리 연산일 뿐, DB와는 아무런 상관이 없다. 굳이 이 무거운 작업들을 트랜잭션 안에 묶어두어 커넥션을 오래 물고 있을 필요가 전혀 없었던 것이다.
최종 해결: 트랜잭션 경계 분리와 Event Listener 도입
결론적으로 내가 가지고 있던 고정관념으로 인해 트랜잭션의 경계를 잘못 잡고 있었던 것이다. 이를 해결하기 위해 최상위 메서드에 트랜잭션 어노테이션을 지우고 비동기로 진행되는 Event Listener를 도입하여 로직을 완전히 분리하였다.
1. 메인 로직(트랜잭션 제거 및 이벤트 발행)
// 최상위 메서드에서 @Transactional 제거!
fun postBatchProduct(user: User, excelFile: MultipartFile) {
// 1. 초기 로그 생성 (내부적으로 개별 트랜잭션 처리)
val bulkUploadLog = bulkUploadLogService.createBulkUploadLog(
userId = user.userId.userId,
type = UploadType.PRODUCT
)
var bulkData: BulkInsertProductDao.BulkDataVer2? = null
try {
// 2. 엑셀 파싱 (순수 메모리 연산 - 트랜잭션 불필요)
val productRowData = parseExcelToProductRowDataVer(excelFile = excelFile)
bulkData = buildProductBulkVer(...)
} catch (ne: NJException) {
handleBulkUploadFailure(log = bulkUploadLog, e = ne, reason = ...)
} catch (e: Exception) {
handleBulkUploadFailure(log = bulkUploadLog, e = e, reason = "데이터 오류")
}
try {
// 3. 실제 DB 벌크 인서트 (이 안에서만 트랜잭션 동작)
bulkData?.let {
productBulkInsertRepository.bulkInsertProductsVer2(...)
}
} catch (e: Exception) {
handleBulkUploadFailure(log = bulkUploadLog, e = e, reason = "파일 업로드 실패")
}
// 4. 성공 시 비동기 이벤트 발행
eventPublisher.publishEvent(
BulkUploadLogCompleteEvent(logId = bulkUploadLog.id!!, source = this)
)
}
private fun handleBulkUploadFailure(log: BulkUploadLog, e: Exception, reason: String) {
// 실패 시 비동기 이벤트 발행 후 예외 던짐
eventPublisher.publishEvent(BulkUploadLogFailEvent(logId = log.id!!, failReason = reason, source = this))
throw e as? NJException ?: NJException(...)
}
2. 비동기 이벤트 핸들러(로그 업데이트의 독립)
@Component
class BulkUploadLogEventHandler(
private val bulkUploadLogRepository: BulkUploadLogRepository
) {
@Async // 비동기로 별도 스레드에서 동작
@EventListener
fun handleFailure(event: BulkUploadLogFailEvent) {
bulkUploadLogRepository.findByIdOrNull(event.logId)?.let { log ->
log.uploadFailReason = event.failReason
log.uploadProcessStatus = UploadProcessStatus.FAILED.code
bulkUploadLogRepository.save(log)
}
}
@Async
@EventListener
fun handleUpdateBulkUploadLog(event: BulkUploadLogCompleteEvent) {
bulkUploadLogRepository.findByIdOrNull(event.logId)?.let { log ->
log.uploadProcessStatus = UploadProcessStatus.COMPLETED.code
bulkUploadLogRepository.save(log)
}
}
}
3. 변경한 상세 내용
- 관심사 분리 : 비즈니스 로직은 데이터 파싱과 저장에만 집중하고, 로그 업데이트는 이벤트 핸들러가 행하도록 한다.
- 데드락 해결 : 메인 트랜잭션과 무관하게 별도의 스레드(@Async)에서 로그 업데이트가 진행되므로 락 경합이 사라졌다.
- 불필요한 트랜잭션 축소 : 엑셀 파싱 등 무거운 작업이 트랜잭션 밖으로 빠져나와 DB 커넥션 점유 시간이 줄었다.
4. 한 발짝 더: 비동기 이벤트의 한계와 남은 고민
이벤트 리스너를 통해 결합도를 낮추고 성능을 개선했지만, 아직 완벽한 것은 아니다. 만약 메인 로직이 성공하고 비동기 이벤트를 발행하기 직전에 서버가 다운된다면? 로그는 PENDING 상태로 남아있게 될 것이다. 이벤트의 유실을 100% 방지하기 위해서는 Outbox 패턴이나 스프링 배치(Spring Batch)를 통한 후처리 보정 작업이 필요할 수 있다. 이 부분은 다음 과제로 남겨두려 한다.
마무리
트랜잭션은 API 요청 단위가 아니라 일관성이 반드시 보장되어야 하는 DB 작업 단위에 적용하는 것이다. 나는 그래도 트랜잭션 어느 정도 알지라고 생각했는데 사실은 관성적인 CRUD 패턴에 갇혀있었을 뿐이었다.
에러를 빠르게 해결하기 위해 잘 알지도 못하는 정보를 냅다 반영하면 안 된다는 뼈아픈 교훈도 얻었다.
여기서 멈추지 않고 앞으로 조금 더 개선해야 할 사항들이 있지만 이제는 트랜잭션을 조금 안다고 말해도 되지 않을까?
'Log > 트러블슈팅' 카테고리의 다른 글
| [트러블슈팅] 504 Gateway Timeout : 15초→0.1초 성능 개선기 (0) | 2025.11.05 |
|---|