Spring 트랜잭션 전파의 함정: catch해도 롤백되는 이유
글 수정이 안 돼요
포인트/레벨 시스템을 배포한 직후, 글 수정에서 500 에러가 터졌습니다.
PUT /api/posts/11667 → 500 Internal Server Error
서버 로그를 열어보니 이런 예외가 찍혀 있었습니다.
UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
"트랜잭션이 rollback-only로 마킹되어 있어서 조용히 롤백됐다"는 뜻인데, 처음 보면 당황스럽습니다. 분명 try-catch로 예외를 잡았는데 왜 롤백이 되는 건지. 이 글에서 그 원리를 설명합니다.
문제의 코드
글 수정(updatePost) 메서드의 흐름은 이렇습니다.
updatePost() @Transactional │ ├─ 글 내용 수정 ├─ 태그 수정 │ └─ DRAFT → PUBLISHED 전환 시: └─ grantPostCreateExp() ← private 메서드 ├─ grantExp(POST_CREATE, +50) ├─ grantExp(FIRST_POST, +100) ← 여기서 문제! └─ catch (Exception e) { return null; }
FIRST_POST는 평생 한 번만 받는 보너스입니다. 이미 받은 사용자가 다시 글을 발행하면, DB의 유니크 제약(grant_key = "FIRST_POST")에 걸려서 DataIntegrityViolationException이 발생합니다.
코드에서는 이걸 try-catch로 잡고 return null을 합니다. "경험치 적립 실패해도 글 수정은 성공해야 하니까"라는 의도였죠. 그런데 잡아도 소용이 없었습니다.
원인: Spring 트랜잭션 전파(Propagation)
이 문제를 이해하려면 Spring의 트랜잭션 전파가 어떻게 동작하는지 알아야 합니다.
기본 전파: REQUIRED
Spring에서 @Transactional의 기본 전파 전략은 REQUIRED입니다. 이건 "이미 트랜잭션이 있으면 그 트랜잭션에 참여하고, 없으면 새로 만들어라"라는 뜻입니다.
updatePost() → 트랜잭션 시작 (TX-1) │ └─ grantExp() → @Transactional(REQUIRED) → TX-1이 이미 있으니까 "참여" → 같은 트랜잭션, 같은 Hibernate Session
같은 트랜잭션을 공유한다는 게 핵심입니다.
Hibernate Session의 특성
Hibernate(JPA 구현체)에는 중요한 규칙이 하나 있습니다.
DB 예외가 한 번이라도 발생하면, 그 Session은 더 이상 신뢰할 수 없다.
DataIntegrityViolationException이 발생한 순간, Hibernate는 해당 Session을 "오염됨" 상태로 마킹합니다. 이 Session에서 수행한 다른 변경사항들도 일관성을 보장할 수 없으므로, 트랜잭션 자체를 rollback-only로 설정합니다.
왜 catch해도 소용이 없나
문제의 전체 흐름을 다이어그램으로 그려보면 이렇습니다.
┌──────────────────────────────────────────────────────────┐ │ TX-1 (updatePost의 트랜잭션) │ │ │ │ 1. 글 내용 수정 ✅ 정상 │ │ 2. 태그 수정 ✅ 정상 │ │ │ │ 3. grantExp(FIRST_POST) │ │ └─ DB INSERT 시도 │ │ └─ 유니크 키 충돌! DataIntegrityViolationException │ │ └─ ★ Hibernate가 TX-1을 rollback-only로 마킹 ★ │ │ └─ catch에서 잡고 return null │ │ │ │ 4. return response; ← 메서드는 정상 종료 │ │ │ │ ─────── 트랜잭션 커밋 시도 ─────── │ │ ★ rollback-only 상태 발견 → UnexpectedRollbackException │ └──────────────────────────────────────────────────────────┘
Java 코드에서는 예외를 잡았지만, Hibernate Session과 DB 트랜잭션 레벨에서는 이미 손상된 상태입니다. Spring이 커밋을 시도할 때 "이 트랜잭션은 rollback-only인데 왜 커밋하려고 하세요?"라며 UnexpectedRollbackException을 던집니다.
정리하면:
| 레벨 | 상태 |
|---|---|
| Java try-catch | 예외를 잡음 (코드는 계속 진행) |
| Hibernate Session | "오염됨" → rollback-only 마킹 |
| DB 트랜잭션 | 커밋 불가 → 강제 롤백 |
세 레벨이 각각 다르게 동작하기 때문에 발생하는 문제입니다.
해결: REQUIRES_NEW로 트랜잭션 분리
해결 방법은 간단합니다. 경험치 적립 메서드가 자기만의 트랜잭션을 갖도록 하면 됩니다.
// 변경 전 @Transactional public ExpGrantResult grantExp(...) { ... } // 변경 후 @Transactional(propagation = Propagation.REQUIRES_NEW) public ExpGrantResult grantExp(...) { ... }
REQUIRES_NEW는 "무조건 새 트랜잭션을 만들어라"라는 뜻입니다.
updatePost() → TX-1 시작 │ └─ grantExp() → TX-2 시작 (TX-1은 일시 중단) │ ├─ 성공 시: TX-2 커밋 → TX-1 재개 │ └─ 실패 시: TX-2 롤백 → TX-1 재개 (TX-1은 깨끗한 상태)
grantExp 안에서 DataIntegrityViolationException이 발생해도, 그건 TX-2의 Hibernate Session이 오염되는 것이지 TX-1과는 무관합니다. TX-2가 롤백되고 나면, TX-1은 아무 일도 없었다는 듯 계속 진행할 수 있습니다.
Spring 트랜잭션 전파 옵션 정리
참고로 Spring이 제공하는 전파 옵션은 이렇습니다.
┌───────────────┬──────────────────────────────────────┐ │ 전파 옵션 │ 동작 │ ├───────────────┼──────────────────────────────────────┤ │ REQUIRED │ 있으면 참여, 없으면 생성 (기본값) │ │ REQUIRES_NEW │ 무조건 새 트랜잭션 생성 │ │ NESTED │ 세이브포인트 생성 (부분 롤백 가능) │ │ SUPPORTS │ 있으면 참여, 없으면 트랜잭션 없이 실행 │ │ NOT_SUPPORTED │ 트랜잭션 없이 실행 (있으면 일시 중단) │ │ MANDATORY │ 반드시 기존 트랜잭션 필요 (없으면 예외) │ │ NEVER │ 트랜잭션 있으면 예외 │ └───────────────┴──────────────────────────────────────┘
실무에서 가장 자주 쓰이는 건 REQUIRED와 REQUIRES_NEW 두 가지입니다. 나머지는 특수한 상황에서 간혹 사용합니다.
같은 패턴, 다른 맥락: 출석 체크와 트랜스코딩
grantExp 문제를 수정하고 나서, "혹시 비슷한 패턴이 다른 곳에도 있지 않을까?" 하고 전체 코드베이스를 점검했습니다. 2건이 더 나왔습니다.
출석 체크: 순서를 바꿨더니 데드락
출석 체크(AttendanceService.checkIn)의 원래 코드 흐름입니다.
checkIn() @Transactional │ ├─ 1. 이미 출석했나 확인 (SELECT) ├─ 2. grantExp(출석 +20) ← REQUIRES_NEW, 별도 TX로 커밋됨! ├─ 3. grantExp(연속 출석 보너스) ← REQUIRES_NEW, 별도 TX로 커밋됨! │ └─ 4. try { attendanceRepository.saveAndFlush(attendance); } catch (DataIntegrityViolationException e) { return "이미 출석"; ← ★ 같은 문제! }
동시에 두 탭에서 출석 버튼을 누르면, 둘 다 1번을 통과하고 2~3번에서 경험치를 적립한 뒤, 4번에서 한쪽이 실패합니다. saveAndFlush의 DataIntegrityViolationException을 catch해도 트랜잭션은 이미 rollback-only. 앞에서 설명한 것과 동일한 문제입니다.
처음 시도한 해결책: 순서를 바꾸자. 출석 기록을 먼저 저장하고, 성공한 경우에만 grantExp를 호출하면 되지 않을까?
checkIn() @Transactional (첫 번째 수정 - 실패!) │ ├─ 1. 이미 출석했나 확인 (SELECT) │ ├─ 2. 출석 기록 먼저 저장 (saveAndFlush) │ → INSERT user_attendance (FK → users) │ → InnoDB가 users 행에 shared lock 획득 │ └─ 3. grantExp(REQUIRES_NEW → TX-2 시작) → findByIdForUpdate(userId) → users 행에 exclusive lock 요청 → TX-1의 shared lock 대기... 영원히!
데드락이 걸렸습니다. 출석 기록을 INSERT하면, FK 참조 무결성을 위해 InnoDB가 users 행에 shared lock을 겁니다. 이건 TX-1이 끝날 때까지 유지됩니다. 그런데 이어서 호출되는 grantExp(REQUIRES_NEW)는 별도 트랜잭션(TX-2)에서 같은 users 행에 SELECT FOR UPDATE(exclusive lock)를 시도합니다.
TX-1은 grantExp가 끝나기를 기다리고, TX-2는 TX-1의 lock이 풀리기를 기다립니다. MySQL의 데드락 탐지기는 이걸 잡아내지 못합니다. 두 트랜잭션이 DB 안에서 서로 기다리는 게 아니라, 애플리케이션 레벨에서 대기하고 있기 때문입니다. 결과는 무한 대기(hang).
┌──────────────────────────────────────────┐ │ TX-1 (checkIn) │ │ saveAndFlush → users에 shared lock │ │ grantExp() 호출 → TX-1 일시 중단 │ │ ★ TX-2 완료 대기 중... │ └───────────────────────┬──────────────────┘ │ 서로 대기 ┌───────────────────────┴──────────────────┐ │ TX-2 (grantExp, REQUIRES_NEW) │ │ findByIdForUpdate → exclusive lock 요청 │ │ ★ TX-1의 shared lock 해제 대기 중... │ └──────────────────────────────────────────┘
최종 해결: 원래 순서를 유지하되, catch에서 정상 반환 대신 예외를 던진다.
checkIn() @Transactional (최종 수정) │ ├─ 1. 이미 출석했나 확인 (SELECT) ├─ 2. grantExp(출석 +20) ← REQUIRES_NEW, lock 획득/해제 완료 ├─ 3. grantExp(연속 출석 보너스) ← 마찬가지 │ └─ 4. try { attendanceRepository.saveAndFlush(attendance); } catch (Exception e) { throw new ConflictException("이미 출석"); ← 정상 반환 대신 예외! }
핵심은 catch 블록에서 정상 반환(return)이 아닌 예외(throw)를 던지는 것입니다. 예외를 던지면 Spring이 트랜잭션을 롤백하는데, rollback-only 상태의 트랜잭션을 롤백하는 건 아무 문제가 없습니다. 문제는 rollback-only를 커밋하려 할 때만 발생하니까요.
grantExp는 REQUIRES_NEW로 이미 별도 커밋된 상태이지만, ATTENDANCE 타입은 일일 한도가 1회라서 동시 요청의 두 번째 grantExp는 이미 한도 초과로 0 EXP를 반환합니다. 중복 지급은 일어나지 않습니다.
트랜스코딩의 중복 이벤트
동영상 트랜스코딩 서비스에서도 비슷한 패턴이 있었습니다.
// 변경 전: save 후 catch try { transcodeJobRepository.save(job); } catch (DataIntegrityViolationException e) { return; // 중복 이벤트 무시 } // ... 이후 로직 계속 // 변경 후: 사전 조회로 중복 확인 if (transcodeJobRepository.existsBy...(bucket, name, gen)) { return; // 중복 이벤트 무시 } transcodeJobRepository.save(job); // ... 이후 로직 계속
try-catch로 예외를 잡는 대신, 저장하기 전에 먼저 물어보는 방식으로 변경했습니다. SELECT 쿼리 하나가 추가되지만, Hibernate Session이 오염될 일 자체가 사라집니다.
세 가지 패턴 정리
같은 "트랜잭션 안에서 예외가 Session을 오염시키는" 문제였지만, 맥락에 따라 해결 방법이 달랐습니다.
┌────────────────────┬─────────────────────────────────────┐ │ 상황 │ 해결 방법 │ ├────────────────────┼─────────────────────────────────────┤ │ 다른 서비스의 │ REQUIRES_NEW로 트랜잭션 분리 │ │ @Transactional │ → 실패해도 호출자 트랜잭션에 영향 없음 │ │ 메서드 호출 │ │ ├────────────────────┼─────────────────────────────────────┤ │ 같은 트랜잭션 안의 │ catch에서 return 대신 throw │ │ Repository.save() │ → rollback-only TX를 커밋 대신 롤백 │ │ + 이후 로직 있음 │ → 롤백은 rollback-only여도 정상 동작 │ ├────────────────────┼─────────────────────────────────────┤ │ 같은 트랜잭션 안의 │ 사전 조회(exists)로 대체 │ │ Repository.save() │ → 예외 자체를 발생시키지 않음 │ │ 단순 중복 방지 │ │ └────────────────────┴─────────────────────────────────────┘
놓치기 쉬운 포인트들
이번 경험에서 배운 것들을 정리합니다.
1. try-catch != 트랜잭션 안전
Java의 예외 처리와 트랜잭션의 상태는 별개입니다. 코드에서 예외를 잡았다고 해서 DB 트랜잭션이 정상이라는 보장은 없습니다. 특히 DataIntegrityViolationException처럼 DB 레벨에서 발생하는 예외는 Hibernate Session 자체를 오염시킵니다.
2. REQUIRES_NEW의 트레이드오프
REQUIRES_NEW는 만능이 아닙니다. 두 가지 함정이 있습니다.
첫째, 외부 트랜잭션이 롤백되어도 내부 트랜잭션은 이미 커밋된 상태입니다. 데이터 불일치가 생길 수 있으므로 "어떤 작업이 먼저 커밋되어야 안전한가?"를 항상 생각해야 합니다.
둘째, InnoDB의 FK lock과 만나면 데드락이 됩니다. 외부 트랜잭션이 FK가 있는 자식 테이블에 INSERT하면, 부모 테이블 행에 shared lock이 잡힙니다. 이 상태에서 REQUIRES_NEW로 새 트랜잭션을 열어 같은 부모 행에 exclusive lock을 요청하면, 서로 대기하는 교착 상태에 빠집니다. 이건 MySQL 데드락 탐지기로도 잡히지 않는 애플리케이션 레벨 데드락입니다.
3. 기능 추가의 연쇄 효과
이 버그는 포인트 시스템을 기존 글쓰기 로직에 끼워 넣으면서 발생했습니다. 기존에 잘 돌아가던 updatePost에 grantExp 호출을 추가했을 뿐인데, 트랜잭션 경계가 달라지면서 문제가 생겼습니다. 기존 메서드에 새 기능을 붙일 때는, 그 메서드의 트랜잭션 범위와 새 기능의 실패 시나리오를 반드시 함께 검토해야 합니다.
마무리
Spring + JPA를 쓰면서 @Transactional을 붙이면 "알아서 잘 되겠지" 하고 넘어가기 쉽습니다. 대부분의 경우 실제로 잘 됩니다.
하지만 서비스 A가 서비스 B를 호출하고, B 안에서 예외가 발생하는 순간, 기본 전파(REQUIRED)는 "같은 트랜잭션"이라는 사실이 문제가 됩니다. 예외를 잡아도 Session은 이미 오염되어 있고, 트랜잭션은 커밋할 수 없는 상태입니다.
이런 문제는 단위 테스트에서 잡기 어렵습니다. 테스트 환경에서는 H2 인메모리 DB를 쓰고, 대부분 정상 케이스만 테스트하니까요. 유니크 키 충돌 같은 건 실제 데이터가 쌓인 운영 환경에서야 비로소 드러납니다.
결국 중요한 건 트랜잭션 경계를 의식적으로 설계하는 것입니다. @Transactional을 붙이는 건 쉽지만, "이 메서드가 어떤 트랜잭션 안에서 실행될 수 있는가?", "이 메서드 안에서 예외가 발생하면 호출자에게 무슨 영향을 주는가?"를 한 번 더 생각해보는 습관이 필요합니다.

댓글
댓글을 작성하려면 이 필요합니다.