포스트

Spring

spring-internals-lab로 다시 읽는 Spring 7 - @Transactional의 실체

시리즈 spring-internals-lab로 다시 읽는 Spring 9편 중 7편 spring-internals-lab로 다시 읽는 Spring
  1. 1 spring-internals-lab로 다시 읽는 Spring 1 - 이 프로젝트는 무엇을 검증하려는가
  2. 2 spring-internals-lab로 다시 읽는 Spring 2 - BeanDefinition과 등록 단계
  3. 3 spring-internals-lab로 다시 읽는 Spring 3 - refresh()와 Bean 생성 파이프라인
  4. 4 spring-internals-lab로 다시 읽는 Spring 4 - 의존성 주입과 후보 선택
  5. 5 spring-internals-lab로 다시 읽는 Spring 5 - Bean lifecycle과 후처리기
  6. 6 spring-internals-lab로 다시 읽는 Spring 6 - 프록시와 AOP
  7. 7 spring-internals-lab로 다시 읽는 Spring 7 - @Transactional의 실체
  8. 8 spring-internals-lab로 다시 읽는 Spring 8 - DispatcherServlet과 MVC 요청 흐름
  9. 9 spring-internals-lab로 다시 읽는 Spring 9 - Spring Boot는 무엇을 자동으로 조립하는가

GitHub 저장소

@Transactional은 애노테이션이 아니라 인터셉터다

Spring을 오래 써도 @Transactional은 자꾸 마법처럼 느껴진다. 하지만 앞선 글에서 프록시와 AOP 구조를 먼저 정리해 두면 이 기능도 훨씬 덜 추상적으로 보인다.

이번 글의 질문은 다음과 같다.

  • @Transactional은 실제로 어디서 트랜잭션을 시작하는가
  • 어떤 예외에서 롤백하고 어떤 예외에서는 커밋하는가
  • readOnly는 실제로 무엇을 보장하는가
  • self-invocation과 private 메서드에 붙은 @Transactional은 왜 조용히 무시되는가

결론부터 말하면, @Transactional은 결국 트랜잭션 인터셉터 하나다.

호출 흐름은 생각보다 작다

실제 구조를 단순화하면 이렇다.

flowchart TD
    A["프록시 호출"] --> B["TransactionInterceptor"]
    B --> C["트랜잭션 시작"]
    C --> D["target method 실행"]
    D --> E{"예외 발생?"}
    E -- no --> F["commit"]
    E -- yes --> G{"rollbackOn(ex)?"}
    G -- yes --> H["rollback"]
    G -- no --> I["commit"]

그림에서 갈림길은 rollbackOn(Throwable) 판정 하나다. 예외가 났다는 사실만으로 롤백이 결정되지 않는다.

checked 예외는 기본적으로 롤백되지 않는다

실무에서 자주 착각하는 부분이다. transaction-propagation-playground 실험이 가장 분명하게 보여준 결과도 이것이다.

1
2
3
4
5
@Transactional
public void noRollbackOnCheckedExceptionByDefault(...) throws Exception {
    adjustBalance(...);
    throw new IOException("checked failure");
}

직관적으로는 예외가 났으니 롤백할 것 같지만, 실제로는 커밋된다.

기본 규칙은 다음과 같다(Spring 문서).

예외기본 동작
RuntimeException롤백
Error롤백
checked Exception커밋

그래서 @Transactional은 “예외면 롤백”이 아니라 “특정 종류의 예외면 롤백”으로 읽어야 한다.

rollbackFor는 규칙 테이블을 바꾸는 장치다

이 기본 규칙을 뒤집는 방법이 rollbackFor다.

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

이제 checked 예외도 롤백한다.

rollbackFor는 transaction manager의 특별 옵션이 아니라, rollbackOn(Throwable) 판정 테이블을 더 넓히는 선언으로 이해하면 된다.

readOnly=true는 생각보다 약한 계약이다

readOnly=true를 붙이면 쓰기 쿼리가 막힐 것 같지만, 기본 DataSourceTransactionManager에서는 그렇지 않을 수 있다.

실험 결과도 그랬다.

  • TransactionSynchronizationManager.isCurrentTransactionReadOnly()는 true
  • 그런데 실제 쓰기 쿼리는 성공

readOnly는 기본적으로 힌트이기 때문이다. @Transactional Javadoc은 이 속성을 이렇게 설명한다.

“This just serves as a hint for the actual transaction subsystem; it will not necessarily cause failure of write access attempts.”

문서 Transactional (Spring Framework API) 이 출처의 인용 모아 보기

하위 시스템에 주는 힌트일 뿐이고, 쓰기 시도를 반드시 실패시키지는 않는다는 뜻이다. 그래서 다음과 같이 읽어야 한다.

  • transaction attribute에는 read-only 정보가 있다
  • 하지만 실제 JDBC 드라이버/매니저가 강제하지 않을 수 있다
  • ORM 최적화나 flush 전략 쪽에서 더 유용하게 쓰일 수 있다

그래서 readOnly=true를 “DB 쓰기 차단 스위치”로 보면 오해가 생긴다.

self-invocation이 안 되는 이유는 여기서도 같다

앞선 AOP 글에서 본 self-invocation 문제는 @Transactional에서도 그대로 재현된다.

1
2
3
public void outer() {
    this.innerTransactionalMethod();
}

this로 부른 innerTransactionalMethod() 호출은 프록시로 되돌아가지 않는다. 그래서 TransactionInterceptor를 아예 통과하지 못한다. 공식 문서도 기본 proxy 모드에서는 프록시를 거친 외부 호출만 가로챈다고 적고 있다(Using @Transactional).

sequenceDiagram
    participant Caller
    participant Proxy
    participant Target

    Caller->>Proxy: outer()
    Proxy->>Target: outer()
    Target->>Target: this.innerTransactional()
    Note right of Target: TransactionInterceptor 미통과

이 문제는 트랜잭션 기능의 특수성이라기보다 프록시 기반 AOP의 일반 성질이다.

private 메서드가 안 되는 이유도 동일하다

private 메서드는 프록시가 오버라이드할 수 없으므로 가로챌 수 없다. 그래서 여기에 @Transactional을 붙여도 트랜잭션 경계가 만들어지지 않는다. 같은 문서는 6.0부터 클래스 기반 프록시에서 protected와 package-visible 메서드까지 허용한다고 적는데, private은 여기에 없다.

“지원 안 하는 문법”이라기보다, 프록시 구현 전략상 개입 지점이 존재하지 않는 경우다.

커넥션 공유는 어떻게 되는가

트랜잭션이 실제로 의미 있으려면 같은 작업 단위 안에서 같은 JDBC Connection을 써야 한다. mini-transaction이 이 점을 잘 드러낸다. 흐름은 다음과 같다.

  • 트랜잭션 시작 시 커넥션을 얻는다.
  • 그 커넥션을 현재 스레드에 바인딩한다.
  • 같은 스레드의 하위 작업은 새 커넥션이 아니라 기존 커넥션에 참여한다.
flowchart LR
    A["begin()"] --> B["Connection 획득"]
    B --> C["ThreadLocal 바인딩"]
    C --> D["하위 호출들"]
    D --> E["같은 Connection 재사용"]
    E --> F["commit / rollback"]

커넥션이 스레드에 묶여 있어서 하위 호출이 같은 트랜잭션에 참여한다. 그래서 트랜잭션은 begin/commit 로그만이 아니라 스레드 범위 자원 바인딩과 함께 봐야 한다. 여러 메서드가 이 커넥션을 공유하거나 새로 여는 규칙은 트랜잭션 전파에서 다룬다.

mini-spring이 보여주는 핵심

mini-transaction의 MiniTransactionInterceptor는 뼈대를 거의 그대로 드러낸다.

1
2
3
4
5
6
7
8
try {
    Object result = invocation.proceed();
    transactionManager.commit(status);
    return result;
} catch (Throwable ex) {
    transactionManager.rollback(status);
    throw ex;
}

물론 실제 Spring보다 단순하다.

  • 예외 종류별 rollback 규칙 없음
  • readOnly, timeout 없음
  • rollback-only 전파 단순화

대신 이 단순함 덕분에 구조가 잘 보인다.

  1. 인터셉터가 있다.
  2. transaction manager가 있다.
  3. 현재 스레드 커넥션 바인딩이 있다.

@Transactional의 최소 구조는 이 세 가지로 이미 다 들어 있다.

왜 checked 예외는 기본적으로 커밋일까

이건 설계 관례의 문제다. checked 예외는 종종 “호출자가 처리 가능한 비즈니스적 예외”로 쓰이고, unchecked 예외는 시스템 오류로 쓰인다는 오래된 관례가 있다.

물론 현실의 코드베이스가 이 규칙을 항상 잘 따르진 않는다. 그래서 Spring은 기본값은 주되 rollbackFor로 뒤집을 수 있게 한다.

Spring은 “무조건 한 가지 규칙”보다 관례 기반 기본값과 명시적 override를 함께 두는 쪽을 택했다.

정리

앞의 질문 네 개는 모두 같은 구조로 답할 수 있다. 트랜잭션 시작 위치, 롤백 판정, self-invocation과 private의 한계는 프록시 앞의 인터셉터 하나에서 나오고, readOnly는 그 인터셉터가 하위 시스템에 넘기는 힌트일 뿐이다. 결국 @Transactional은 별도 마법이 아니라, 앞 글에서 본 프록시와 인터셉터 체인을 데이터 접근과 연결한 응용 사례다.

다음 글에서는 같은 방식으로 MVC 쪽을 본다. DispatcherServlet도 하나의 거대한 마법보다는, 여러 작은 책임을 분리해 조립한 결과에 가깝다.

참고

데이터베이스 내부와 트랜잭션 Spring과 JVM 백엔드
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

변경이력

1번 수정

  1. docs(notes): cite rollback and readOnly docs in the transactional post

댓글

아직 댓글이 없습니다