@Transactional 롤백이 안 될 때 — 자기 호출과 프록시

트랜잭션 애너테이션을 붙였는데 예외가 나도 롤백이 안 돼요. 오타도 없고 임포트도 맞는데요. 트랜잭션이 아예 시작되지 않는 이유와, 같은 클래스 안에서 부를 때 생기는 일을 정리했어요.

트랜잭션 애너테이션을 붙여 뒀는데 롤백이 안 돼요. 예외가 났는데 앞의 저장이 그대로 커밋돼 있어요.

코드를 봐도 애너테이션은 분명히 붙어 있어요. 오타도 아니고 임포트도 맞아요. 그런데 동작하지 않아요.

로그를 켜서 트랜잭션 시작을 찍어 보면 아예 시작되지 않은 걸 알게 돼요. 애너테이션이 무시된 게 아니라, 애초에 적용되는 경로를 안 타고 있었던 거예요.

이 사실을 모르면 며칠을 헤맵니다. 애너테이션은 눈에 보이는데 그게 작동하는 조건은 눈에 안 보이거든요.

그리고 더 나쁜 건, 롤백이 안 되는 정도로 끝나지 않는다는 거예요. 저장 하나하나가 각자 커밋되면서 데이터가 반쪽 상태로 굳습니다.

왜 그런가

선언적 트랜잭션은 대개 프록시로 구현돼요. 애너테이션이 붙은 클래스를 감싸는 대리 객체를 만들고, 그 대리 객체가 트랜잭션을 열고 원본 메서드를 호출한 뒤 커밋하거나 롤백해요.

여기서 핵심은 "프록시를 거쳐야 동작한다"예요. 프록시를 안 거치는 호출은 애너테이션이 없는 것과 같아요.

가장 흔한 경우가 같은 클래스 안에서의 자기 호출이에요. A 메서드가 같은 클래스의 B 메서드를 부르면, 그건 자기 자신에 대한 직접 호출이라 프록시를 안 거쳐요. B 에 애너테이션이 아무리 잘 붙어 있어도 무시돼요.

접근 제어자도 영향을 줘요. 프록시 방식에 따라 private 이나 final 메서드는 감쌀 수 없어요. 감싸지 못하니 애너테이션이 작동하지 않고요. 컴파일도 되고 실행도 되는데 조용히 아무 일도 안 일어나요.

그리고 예외 종류에 따라 롤백 규칙이 달라요. 많은 프레임워크가 확인된 예외에는 롤백하지 않고 실행 시 예외에만 롤백하는 걸 기본값으로 둬요. 예외를 잡아서 로깅만 하고 삼키면 당연히 롤백도 안 되고요.

전부 조용히 실패한다는 게 공통점이에요. 에러가 안 나니 테스트도 통과합니다.

코드로 보면

프록시를 안 거치면 없는 것과 같다:

코드
 class OrderService {
 void handle() {
 this.save();     // 자기 호출 — 프록시를 안 거친다
 }
 @Transactional void save() { ... }   // 무시된다
 }

· 1) 다른 빈으로 분리해 프록시를 거치게 한다
 class OrderService { void handle() { writer.save(); } }
 class OrderWriter { @Transactional void save() { ... } }

· 2) 예외를 삼키면 롤백도 없다
 try { ... } catch (Exception e) { log.error(e); }
 catch (Exception e) { log.error(e); throw e; }

· 3) 확인된 예외에도 롤백하려면 명시한다
 @Transactional(rollbackFor = Exception.class)

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

무시되는 경로를 정리하면 이렇습니다.

자기 호출. 같은 객체 안에서 부르면 프록시를 안 거쳐요. 가장 흔하고 가장 안 보이는 경우예요.

접근 제어자와 final. 프록시 방식에 따라 감쌀 수 없는 메서드가 있어요. 감싸지 못한다는 경고가 뜨는 경우도 있지만 대개 조용합니다.

객체를 직접 만든 경우. new 로 만든 객체는 프록시가 아니에요. 테스트에서 직접 생성해 쓰면 트랜잭션이 안 걸리고, 그래서 테스트는 통과하는데 운영에서 다르게 동작해요.

예외를 삼킨 경우. 잡아서 로깅만 하면 프레임워크는 정상 종료로 봐요.

롤백 규칙 불일치. 확인된 예외는 기본적으로 롤백 대상이 아닌 프레임워크가 많아요.

비동기 경계. 별도 스레드로 넘기면 트랜잭션 컨텍스트가 안 따라가요. 스레드 로컬에 매여 있기 때문이에요.

여기서 특히 위험한 조합이 있어요. 트랜잭션이 안 걸린 상태로 여러 저장이 실행되면, 각 저장이 자기만의 짧은 트랜잭션으로 커밋돼요. 그래서 앞부분은 저장되고 뒷부분은 실패한 반쪽 상태가 남아요. 롤백이 안 되는 정도가 아니라 데이터가 중간 상태로 굳는 거예요.

또 하나, 트랜잭션이 걸렸는데 범위가 예상과 다른 경우도 있어요. 읽기 전용으로 선언해 두고 그 안에서 쓰기를 하면, 프레임워크나 DB 설정에 따라 조용히 무시되거나 에러가 나요. 그리고 컨트롤러 계층에 트랜잭션을 걸면 요청 전체가 하나의 트랜잭션이 되면서 앞서 본 커넥션 점유 문제가 생기고요.

처방

  1. 트랜잭션 경계를 별도 빈으로 분리하세요. 자기 호출 문제를 구조적으로 없애는 방법이에요. "이 클래스는 트랜잭션 경계"라고 역할을 명확히 하면 리뷰에서도 잘 보입니다.
  2. 예외를 삼키지 마세요. 잡았으면 다시 던지거나, 롤백을 명시적으로 표시하세요.
  3. 롤백 규칙을 명시하세요. 확인된 예외에도 롤백해야 한다면 그렇게 선언하세요. 기본값에 기대지 마세요.
  4. 트랜잭션이 실제로 열렸는지 확인하는 습관을 들이세요. 프레임워크가 제공하는 로그 설정을 켜면 트랜잭션 시작·커밋·롤백이 다 찍혀요. 개발 환경에서는 항상 켜 두는 게 좋아요.
  5. 테스트에서 실제 프록시를 거치게 하세요. 객체를 직접 만들어 테스트하면 트랜잭션 동작은 전혀 검증되지 않아요. 롤백을 검증하는 테스트를 최소한 몇 개는 두세요.
  6. 트랜잭션 경계를 서비스 계층에 두고, 컨트롤러에는 두지 마세요. 요청 전체를 감싸면 커넥션 점유가 길어지고 외부 호출까지 안에 들어갑니다.
  7. 비동기로 넘길 때는 트랜잭션이 따라가지 않는다는 걸 전제로 설계하세요.

지금 확인해 보세요

정말 트랜잭션 안인가:

코드
· 프레임워크의 트랜잭션 로그를 켠다 (개발 환경 상시)
 트랜잭션 시작 / 커밋 / 롤백이 다 찍히게 한다
 아무것도 안 찍히면 프록시를 안 거친 것이다

· DB 쪽에서 본다 — 한 요청이 몇 개의 트랜잭션을 만드나
SELECT count(*) FROM pg_stat_activity
 WHERE state = 'idle in transaction';

· 커밋 대비 롤백 비율 — 롤백이 0 이면 의심스럽다
SELECT datname, xact_commit, xact_rollback
  FROM pg_stat_database WHERE datname = current_database();

· 롤백을 실제로 검증하는 테스트를 둔다
 예외를 던지고, 저장이 안 됐는지 확인한다
 이 테스트 하나가 이 편의 모든 함정을 잡는다

핵심 한 줄애너테이션이 붙어 있는 것과 동작하는 것은 다르다. 로그로 확인하라.

자주 묻는 것

같은 클래스 안에서 부른 @Transactional 메서드는 왜 무시되나요?

선언적 트랜잭션은 프록시가 감싸서 동작하는데, 같은 클래스 안의 자기 호출은 프록시를 거치지 않아요. 트랜잭션 경계를 별도 빈으로 분리하면 프록시를 거치게 돼요.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

트랜잭션·락·DB 운영 글 21편 모두 보기 →