콘텐츠로 이동

@Transactional 롤백 정책 — 왜 RuntimeException에만 롤백할까

정의

Spring의 @Transactional 기본 동작은 RuntimeException(언체크 예외)과 Error에서만 자동 롤백합니다. IOException·SQLException같은 체크 예외에서는 트랜잭션이 그대로 commit됩니다. 이는 버그가 아니라 1990년대 Java 예외 철학을 충실히 반영한 설계입니다.

어노테이션 한 줄로 우리는 매일 30년 전 Java 설계자의 결정을 제어하고 있습니다.

함정 케이스

@Transactional
public void saveAndUpload(Order order) throws IOException {
    orderRepository.save(order);              // DB INSERT 성공
    fileStorage.upload(order.getReceipt());   // ❌ IOException 발생
    // 컨트롤러까지 IOException이 전파됨
}

기대: 예외가 났으니 order도 롤백될 것입니다. 실제: order는 그대로 DB에 남아있습니다. IOException은 체크 예외라 Spring이 commit을 진행합니다.

자바의 예외 2분류 철학

종류 의미 (Java 설계자의 의도) 예시
Checked Exception 외부 요인에 의한 실패, 복구 가능·예측 가능 IOException, SQLException, InterruptedException
Unchecked Exception (RuntimeException) 코드가 잘못된 결과 (프로그래밍 에러) — 진행 불가 NullPointerException, IllegalStateException, IllegalArgumentException
Error JVM·시스템 수준의 치명적 문제 OutOfMemoryError, StackOverflowError

Spring은 이 철학을 그대로 수용: - 체크 예외 = 복구 가능 → 트랜잭션 유지 (commit) - 언체크 예외/Error = 복구 불가 → 자동 롤백

근거 — Spring 공식 문서 인용:

"any unchecked exceptions (RuntimeException and Error) will trigger a rollback. Checked exceptions will not."

실무와의 괴리

현대 실무에서는 이 가정이 거의 맞지 않습니다:

상황 Java 철학상 실무 기대
IOException (파일 업로드 실패) 복구 가능 → commit 롤백 원함
SQLException (DB 통신 실패) 복구 가능 → commit 롤백 원함
외부 API IOException 복구 가능 → commit 롤백 원함

→ 99%의 경우 모든 예외에서 롤백이 정답입니다.

해결책 — rollbackFor

방법 1: 메서드별 명시

@Transactional(rollbackFor = Exception.class)
public void saveAndUpload(Order order) throws IOException {
    orderRepository.save(order);
    fileStorage.upload(order.getReceipt());
}

rollbackFor = Exception.class는 "체크·언체크 가리지 않고 모든 Exception에서 롤백"을 의미하며, 실무 표준 패턴입니다.

방법 2: 메타 어노테이션 (프로젝트 전역 표준)

매번 적기 번거롭다면 사내 어노테이션을 만들어 표준화:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface Tx { }

사용:

@Tx
public void saveAndUpload(Order order) throws IOException { ... }

방법 3: AspectJ로 클래스/패키지 단위 적용

대규모 프로젝트에서는 AOP로 일괄 적용합니다. CLAUDE.md 같은 코딩 헌법에 표준을 명시합니다.

noRollbackFor — 반대 옵션

특정 비즈니스 예외는 롤백을 막고 싶을 때:

@Transactional(
    rollbackFor = Exception.class,
    noRollbackFor = { ItemNotFoundException.class }
)

예: "재고 부족 알림은 던지지만, 이 알림 자체는 트랜잭션과 무관" 같은 케이스입니다.

현대 트렌드 — 체크 예외 회의

언어/패러다임 체크 예외 입장
Java (1995) 체크/언체크 분리 도입
Spring (2003~) Java 철학 그대로 수용
C# (.NET) 체크 예외 개념 없음
Kotlin (2011) 체크 예외 폐지
Scala 체크 예외 무력화
모던 Java 코드 체크 예외를 RuntimeException으로 wrap 후 throw가 관행

→ Java 자체도 체크 예외에서 멀어지는 중입니다. Spring 트랜잭션의 디폴트는 시대에 뒤처진 측면이 있습니다.

CLAUDE.md STOP 트리거 후보

이 함정을 구조적으로 막으려면 CLAUDE.md 섹션 7에:

STOP: @Transactional을 rollbackFor 없이 사용 (체크 예외 그냥 commit 위험)
  → @Transactional(rollbackFor = Exception.class) 또는 사내 @Tx 사용

또는 lint-fix.sh / Checkstyle 룰로 강제할 수도 있습니다 (커스텀 규칙).

같은 인사이트 패턴 — "기본값과 가정의 함정"

페이지 위험한 기본값·가정 결과 실무 권장
트랜잭션 롤백 (이 페이지) @Transactional이 모든 예외를 롤백한다는 가정 (기본은 unchecked 예외·Error만 롤백) 체크 예외에서 커밋되어 데이터 오염 rollbackFor = Exception.class 또는 사내 합성 애너테이션
API 하위 호환 클라이언트 JSON 파서가 미지 필드에 관용적일 것이라는 가정 (라이브러리마다 기본값이 다름) 응답 필드 하나 추가로 앱 전체 오류 Tolerant Reader + 응답 구조 wrapping 변경 금지
API 버전 관리 버전을 나누면 변경 부담이 끝난다는 가정 강제 업데이트가 불가한 환경에서 v1 코드 영구 유지 버전은 Controller·DTO에만 + deprecation·sunset 합의
JPA enum 매핑 JPA @Enumerated 기본 ORDINAL enum 순서 변경·중간 삽입 시 조용한 데이터 오염 EnumType.STRING + @Column(length) 명시
크론잡 동시 실행 K8s CronJob concurrencyPolicy 기본 Allow 배치 중복 실행 → 정산 2배 Forbid + activeDeadlineSeconds
Keep-Alive 타임아웃 웹 서버 keep-alive 기본값이 LB idle 이하 (Gunicorn 2초·Node.js 5초·Tomcat 60초 vs ALB 60초) 서버가 먼저 끊어 새벽 간헐 502 서버 keep-alive > LB idle (+5~15초)
DB 커넥션 풀 풀의 커넥션이 계속 유효하다는 가정 (maxLifetime이 DB wait_timeout·방화벽/LB idle 제한보다 김) 이미 끊긴 커넥션 대여 → 산발적 Connection is closed maxLifetime을 가장 짧은 인프라 제한보다 몇 초 짧게 + keepaliveTime
VARCHAR 길이 관습적 VARCHAR(255) (Latin1 시대의 1바이트 프리픽스 경계) utf8mb4에서는 2바이트 프리픽스 → 의도와 다른 저장·인덱스 비용 도메인 상한 우선 + utf8mb4의 63 경계 인지
자바 직렬화 ObjectInputStream이 데이터를 그냥 읽어 줄 것이라는 신뢰 임의 클래스 코드 실행(RCE) JSON·Protobuf로 대체, 불가피하면 ObjectInputFilter 화이트리스트
애그리거트 참조 JPA 객체 참조로 애그리거트 경계 관통 트랜잭션 번짐·N+1 경계 밖은 ID 참조

→ 공통 원리: "프레임워크·인프라의 기본값은 그 시대 설계자가 정답이라 믿었던 값일 뿐입니다." 시간이 지나면 시대 가정이 깨집니다. 매번 의심해야 합니다.

빠른 진단 — 우리 프로젝트는 안전한가

# rollbackFor 없는 @Transactional 찾기
grep -rn "@Transactional" src/main/java/ \
  --include="*.java" \
  | grep -v "rollbackFor" \
  | grep -v "noRollbackFor"

결과가 많다면 메타 어노테이션(@Tx) 도입 후 일괄 치환을 검토합니다.

원본 출처

관련 페이지