포스트

@Transactional 동작 원리와 자주 빠지는 함정

@Transactional 동작 원리와 자주 빠지는 함정

“분명 @Transactional 붙였는데 롤백이 안 돼요”

트랜잭션은 @Transactional 한 줄이면 된다고 배우지만, 실무에선 “붙였는데 안 먹는다”를 자주 만납니다. 거의 전부 이 애너테이션이 어떻게 동작하는지를 모르면 못 피하는 함정입니다.

이 글의 목표는 하나입니다. @Transactional을 “DB를 묶어주는 마법”이 아니라 “TransactionInterceptor가 프록시에서 가로채 PlatformTransactionManager로 경계를 긋는 메커니즘”으로 바꾸는 것. 이 한 문장이 몸에 붙으면 함정의 90%는 애초에 안 밟습니다.

동작 원리: 프록시 안에서 벌어지는 일

@Transactional은 AOP 프록시로 동작합니다. Spring이 해당 Bean을 감싼 프록시를 만들고, 메서드 호출이 프록시를 거칠 때 TransactionInterceptor가 끼어들어 트랜잭션을 시작·커밋·롤백합니다.

sequenceDiagram
    participant C as 호출자
    participant P as 프록시 (TransactionInterceptor)
    participant TM as PlatformTransactionManager
    participant T as 실제 Bean
    C->>P: save() 호출
    P->>TM: getTransaction() — 트랜잭션 시작
    P->>T: 실제 save() 위임
    alt 정상 반환
        T-->>P: return
        P->>TM: commit()
    else 언체크 예외
        T-->>P: throw RuntimeException
        P->>TM: rollback()
    end
    P-->>C: 결과/예외 전달

말보다 흐름으로 보면 이렇습니다 — 정상이면 커밋 경로로, 예외면 롤백 경로로 분기합니다.

호출자 프록시 tx 시작 실제 메서드 비즈니스 로직 commit() 정상 반환 rollback() 언체크 예외

이 “프록시를 거쳐야 한다”는 전제 하나에서 거의 모든 함정이 파생됩니다.

함정 1: self-invocation (내부 호출)

같은 클래스 안에서 메서드가 다른 @Transactional 메서드를 직접 호출하면, 그 호출은 프록시가 아니라 this를 통하므로 인터셉터가 끼어들 자리가 없습니다. 트랜잭션이 통째로 무시됩니다.

1
2
3
4
5
6
7
8
9
10
@Service
public class OrderService {

    public void process() {
        save();   // ❌ this.save() → 프록시를 안 거침 → 트랜잭션 X
    }

    @Transactional
    public void save() { ... }
}

해결책은 결국 “프록시를 거치게 만들기”입니다. 상황별로 셋 중 하나를 씁니다.

해법방법평가
구조 분리save()를 다른 Bean으로 빼서 주입받아 호출가장 깔끔, 1순위
자기 주입자신을 @Lazy로 주입받아 self.save() 호출빠르지만 순환참조 냄새
AopContext((OrderService) AopContext.currentProxy()).save()exposeProxy = true 필요, 최후의 수단

TransactionTemplate(프로그래밍 방식)으로 메서드 내부에서 직접 경계를 그어도 프록시 문제 자체가 사라집니다.

함정 2: 체크 예외는 기본 롤백이 안 된다

@Transactional은 기본적으로 언체크 예외(RuntimeException)와 Error 에서만 롤백합니다. 체크 예외는 던져도 그대로 커밋됩니다. (EJB 시절의 관례를 물려받은 기본값입니다.)

1
2
3
4
5
@Transactional
public void save() throws IOException {
    repo.save(entity);
    throw new IOException();   // ❌ 체크 예외 → 롤백 안 됨, 커밋됨!
}

해결: 롤백 대상을 명시합니다.

1
2
@Transactional(rollbackFor = Exception.class)
public void save() throws IOException { ... }

더 무서운 변형: 예외를 잡아서 로그만 찍고 삼키면(catch) 프록시는 “정상 반환”으로 보고 커밋합니다. 롤백시키려면 다시 던지거나 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()를 호출해야 합니다.

함정 3: public 아니면 안 먹는다

프록시 방식(기본)에서는 public 메서드에만 트랜잭션이 적용됩니다. private·protected·package-private에 붙이면 조용히 무시됩니다. final 메서드/클래스도 CGLIB가 서브클래싱을 못 해 프록시 생성이 막힙니다.

  • JDK 동적 프록시: 인터페이스 기반. 인터페이스에 없는 메서드는 프록시가 못 가로챕니다.
  • CGLIB 프록시(Boot 기본): 클래스 상속 기반. 그래서 final/private이 문제.

정말 내부 호출·비-public까지 다 걸어야 한다면 프록시가 아닌 AspectJ 로드/컴파일 타임 위빙(mode = ASPECTJ)이 답이지만, 빌드가 복잡해지므로 보통은 구조를 바꾸는 게 낫습니다.

함정 4: 전파(propagation)를 모르고 쓰면 생기는 일

전파는 “이미 트랜잭션이 진행 중일 때 이 메서드는 어떻게 행동할까”를 정합니다.

propagation트랜잭션 있을 때없을 때비고
REQUIRED(기본)참여새로 시작가장 흔함
REQUIRES_NEW중단(suspend) 후 새 트랜잭션새로 시작별도 커넥션 사용
NESTED세이브포인트 생성새로 시작JDBC 세이브포인트, 부분 롤백
SUPPORTS참여트랜잭션 없이 실행 
MANDATORY참여예외반드시 누가 열어둬야
NEVER예외트랜잭션 없이 실행 
NOT_SUPPORTED중단 후 비트랜잭션 실행비트랜잭션 실행 

가장 자주 쓰는 REQUIRES_NEW의 함정 두 가지:

  1. 커넥션을 하나 더 점유합니다. 부모 트랜잭션이 커넥션을 쥔 채 자식이 또 빌리므로, 루프 안에서 남발하면 커넥션 풀이 말라 데드락에 빠집니다.
  2. 부모와 격리됩니다. 부모가 방금 쓴(아직 커밋 안 된) 데이터를 자식은 못 봅니다.

NESTED는 새 트랜잭션이 아니라 세이브포인트입니다. 자식만 롤백하고 부모는 계속 갈 수 있지만, JDBC 세이브포인트를 지원하는 드라이버에서만 동작합니다.

1
2
3
// 본 작업이 실패해도 감사 로그는 반드시 남겨야 할 때
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAuditLog(...) { ... }

함정 5: readOnly와 격리 수준 — “성능 옵션” 그 이상

@Transactional(readOnly = true)는 단순한 주석이 아닙니다.

  • Hibernate 기준 FlushMode를 MANUAL로 낮춰 더티 체킹/플러시를 건너뜁니다 → 조회 성능 향상, 의도치 않은 UPDATE 방지.
  • DB·드라이버에 따라 읽기 전용 커넥션으로 라우팅되기도 합니다(리플리카 분기의 토대).

격리 수준(isolation)은 기본적으로 DB 기본값(보통 READ_COMMITTED)을 따르며, @Transactional(isolation = ...)로 메서드별 조정이 가능합니다. 단, 한 트랜잭션 도중에는 바꿀 수 없습니다.

가장 자주 터지는 운영 이슈: OSIV와 LazyInitializationException

Boot는 기본적으로 OSIV(Open Session In View) 가 켜져 있어, 영속성 컨텍스트를 뷰 렌더링까지 열어 둡니다. 덕분에 컨트롤러에서 지연 로딩이 “되는 것처럼” 보이지만, 트래픽이 몰리면 DB 커넥션을 요청 끝까지 붙잡고 있어 커넥션 풀이 고갈됩니다.

1
2
3
spring:
  jpa:
    open-in-view: false   # 운영에선 끄고, 트랜잭션 안에서 필요한 연관을 명시적으로 로딩

끄면 트랜잭션 경계 밖에서 지연 로딩 시 LazyInitializationException이 나는데, 이건 버그가 아니라 “경계를 명확히 하라”는 신호입니다.

디버깅: 트랜잭션이 실제로 열렸는지 보는 법

추측 대신 로그로 확인합니다.

1
2
3
4
5
logging:
  level:
    org.springframework.transaction.interceptor: TRACE   # 경계 시작/커밋/롤백
    org.springframework.orm.jpa: DEBUG
    org.hibernate.SQL: DEBUG

Getting transaction for [...], Completing transaction for [...], Initiating transaction rollback 로그가 메서드 단위로 찍힙니다. “안 걸린다”던 메서드에 이 로그가 아예 안 보이면 → 십중팔구 함정 1·3(프록시 우회). 로그는 보이는데 롤백이 안 되면 → 함정 2(예외 종류).

면접/리뷰 단골 질문

  • Q. @Transactional이 self-invocation에서 안 먹는 이유는? → 인터셉터는 프록시 경유 호출에서만 동작하는데, this.method()는 프록시를 우회한다.
  • Q. 체크 예외가 기본 롤백이 안 되는데 어떻게 롤백시키나? → rollbackFor 명시, 또는 삼킨 예외라면 setRollbackOnly().
  • Q. REQUIRES_NEW를 루프에서 쓰면 위험한 이유는? → 부모 커넥션을 쥔 채 자식이 별도 커넥션을 빌려 풀 고갈/데드락 위험.
  • Q. 운영에서 open-in-view를 끄는 이유는? → 요청 끝까지 커넥션을 점유하는 것을 막기 위해. 대신 트랜잭션 경계 안에서 연관을 로딩.

정리

  • @Transactional은 TransactionInterceptor가 AOP 프록시에서 가로채 PlatformTransactionManager로 경계를 긋는다. 이 한 줄이 핵심.
  • self-invocation(함정 1)·비-public/final(함정 3)은 프록시를 우회·차단 → 트랜잭션 자체가 안 걸린다.
  • 체크 예외는 기본 커밋(함정 2) → rollbackFor, 삼킨 예외는 setRollbackOnly().
  • 전파는 표로 외우되 REQUIRES_NEW는 커넥션 2개·격리, NESTED는 세이브포인트임을 기억.
  • 운영에선 open-in-view=false + 트랜잭션 인터셉터 TRACE 로그로 경계를 눈으로 확인하자.

관련 글: 프록시·인터셉터가 어떻게 Bean에 끼어드는지는 IoC/DI와 Bean 생명주기에서, 영속성 컨텍스트·지연 로딩의 토대는 Spring Data JPA 기초에서 다룹니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.