Spring 의 @Transactional 은 마법이 아니라 프록시입니다. 빈을 감싸는 프록시가 메서드 호출 앞뒤로 트랜잭션 시작·커밋·롤백 코드를 끼워 넣습니다. 원리는 고급 과정의 리플렉션·프록시 레슨에서 다룬 JDK 동적 프록시와 같습니다.
즉 @Transactional 이 실제로 동작하려면 그 메서드 호출이 반드시 프록시를 거쳐야 합니다. 프록시를 거치지 않은 호출에는 트랜잭션이 붙지 않습니다. 이 사실 하나가 2.6 절의 self-invocation 함정을 만듭니다.
Spring 은 전파 속성을 7가지 제공하지만, 정산·이체 업무 코드에서 실제로 보는 것은 REQUIRED 와 REQUIRES_NEW 둘뿐입니다.
| 전파 속성 | 동작 |
|---|---|
REQUIRED |
있으면 참여, 없으면 새로 시작(기본값) |
REQUIRES_NEW |
항상 새 트랜잭션, 기존은 잠시 멈춤 |
NESTED |
세이브포인트로 부분 롤백 가능 |
SUPPORTS |
있으면 참여, 없으면 트랜잭션 없이 실행 |
NOT_SUPPORTED |
기존을 멈추고 트랜잭션 없이 실행 |
MANDATORY |
기존 트랜잭션이 반드시 필요, 없으면 예외 |
NEVER |
트랜잭션이 있으면 예외 |
나머지 다섯은 드라이버·DBA 정책에 따라 세이브포인트 지원이 갈리거나(NESTED), 실무에서 의도를 설명하기 어려워서(SUPPORTS, NOT_SUPPORTED) 잘 안 씁니다. "실패해도 반드시 남겨야 하는 로그"만 REQUIRES_NEW 로 분리하고 나머지는 REQUIRED 로 충분합니다.
| 격리 수준 | 막는 문제 |
|---|---|
READ_UNCOMMITTED |
없음 |
READ_COMMITTED |
더티 리드 |
REPEATABLE_READ |
더티 리드, 반복 읽기 불일치 |
SERIALIZABLE |
더티 리드, 반복 읽기, 팬텀 리드 |
Oracle 은 기본 격리 수준이 READ COMMITTED 입니다. 커밋되지 않은 값을 읽는 더티 리드는 없지만, 같은 트랜잭션 안에서 같은 행을 두 번 읽으면 다른 값이 나올 수 있고(반복 읽기 불일치), 조건에 맞는 행 개수가 달라질 수도 있습니다(팬텀 리드). 정산 배치처럼 같은 데이터를 여러 번 읽어 합산하는 로직에서는 이 특성을 염두에 둬야 합니다.
@Transactional(readOnly = true) 는 성능 최적화 힌트이지 DB 가 강제하는 잠금이 아닙니다. MyBatis 는 이 힌트를 보고 커밋 호출을 생략할 수 있지만, Oracle 은 readOnly 플래그만으로 쓰기 SQL 실행 자체를 막아 주지 않습니다.
읽기 전용 조회 메서드에 붙여 두면 실수로 그 안에서 UPDATE 를 실행해도 여전히 실행됩니다. 다만 트랜잭션 매니저·드라이버 조합에 따라 예외가 날 수도 있으니, "안전장치"가 아니라 "의도 표시 + 약간의 성능 이득"으로 이해해야 합니다.
Spring 의 기본 롤백 규칙은 RuntimeException 과 Error 만 롤백하고, checked Exception 은 커밋합니다. 이 규칙을 모르고 checked 예외로 실패를 표현하면 트랜잭션이 조용히 커밋되어 데이터가 반쯤 반영된 채 남습니다.
checked 예외도 롤백하려면 rollbackFor = Exception.class 를 명시해야 합니다. 이 레슨의 AccountService.transferLenient 와 transferStrict 가 이 차이를 그대로 재현합니다.
같은 클래스 안에서 this.메서드() 로 다른 트랜잭션 메서드를 호출하면 프록시를 거치지 않습니다. 2.1 절에서 본 것처럼 트랜잭션은 프록시가 만들기 때문에, 이 호출에는 @Transactional 이 아예 적용되지 않습니다.
흔한 회피 방법은 세 가지입니다. 첫째, 자기 자신의 프록시를 필드로 주입받아 그 필드로 호출합니다(이 레슨의 방식). 둘째, AopContext.currentProxy() 로 현재 프록시를 꺼냅니다. 셋째, 그 메서드를 아예 다른 빈으로 분리합니다.
같은 이유로 private·final 메서드도 트랜잭션이 적용되지 않습니다. 인터페이스 기반 프록시는 인터페이스에 없는 메서드를 아예 노출하지 않고, 클래스 기반(CGLIB) 프록시도 final 메서드는 오버라이드할 수 없어 그대로 원본이 호출됩니다.
REQUIRED 로 참여한 내부 메서드가 롤백 대상 예외를 던지면, 그 트랜잭션은 즉시 롤백되지 않고 "rollback-only" 로 표시만 됩니다. 커넥션을 공유하고 있어서 바깥 트랜잭션이 아직 할 일이 남았을 수도 있기 때문입니다.
문제는 바깥 메서드가 이 예외를 catch 로 삼키고 정상 종료해 버리는 경우입니다. 바깥은 정상 흐름이니 커밋하려 하지만, 트랜잭션이 이미 rollback-only 로 표시돼 있어서 커밋할 수 없습니다. 이때 Spring 은 UnexpectedRollbackException 을 던집니다. "예외를 잡았으니 안전하다"는 생각이 오히려 새로운 예외를 만드는 셈입니다.
트랜잭션은 커넥션 하나를 붙잡고 있는 구간입니다. 이 구간이 길어질수록 커넥션 풀의 다른 요청이 커넥션을 못 받아 대기합니다.
외부 API 호출, 파일 업로드·다운로드, 긴 루프 연산을 트랜잭션 메서드 안에 넣으면 그 시간만큼 커넥션을 점유합니다. 외부 API 가 느려지면 커넥션 풀 전체가 고갈되고, 트랜잭션과 무관한 다른 화면까지 응답이 멈춥니다. 외부 호출은 트랜잭션 밖으로 빼거나, 결과를 먼저 받아 온 뒤 짧은 트랜잭션으로 저장만 하는 순서로 바꿔야 합니다.
@Transactional(timeout = 5) 처럼 초 단위 제한을 걸어 두면, 트랜잭션이 그 시간을 넘길 때 강제로 롤백돼 커넥션을 오래 붙잡는 사고를 막습니다. 배치 작업에서는 전체를 한 트랜잭션으로 묶지 않고, 1000건 같은 청크 단위로 트랜잭션을 나눠 커밋합니다.
청크 하나가 실패해도 그 안의 실패 건 로그는 남아야 하므로, 로그 저장 메서드만 REQUIRES_NEW 로 분리합니다. 이 레슨의 logFailure 가 그 역할이고, 2.7 절의 원리를 그대로 씁니다.
이체처럼 같은 행을 동시에 수정할 수 있는 업무에는 두 가지 락 전략이 있습니다.
| 전략 | 방식 | 특징 |
|---|---|---|
| 비관적 락 | SELECT ... FOR UPDATE |
조회 시점부터 잠금, 대기 발생 |
| 낙관적 락 | version 컬럼 비교 |
갱신 시점에만 충돌 감지, 재시도 필요 |
비관적 락은 충돌이 잦은 계좌 이체에 적합하고, 낙관적 락은 충돌이 드문 화면 저장에 적합합니다. 두 계좌를 동시에 잠그는 순서가 요청마다 다르면 데드락(Oracle 오류 코드 ORA-00060)이 발생하므로, 항상 계좌 ID 오름차순처럼 고정된 순서로 잠급니다.
MyBatis 는 SqlSessionTemplate 을 통해 Spring 의 트랜잭션 동기화에 참여합니다. 같은 스레드에서 열린 Spring 트랜잭션이 있으면 MyBatis 도 그 트랜잭션의 커넥션을 그대로 씁니다.
@MapperScan 으로 매퍼 인터페이스를 스캔해 빈으로 등록하면, 서비스 계층에서 매퍼를 주입받아 호출할 때마다 별도 설정 없이 같은 규칙이 적용됩니다. 이 레슨의 Tx.currentConnection() 이 SqlSessionTemplate 이 커넥션을 찾는 방식을 단순화한 것입니다.
테스트 클래스나 메서드에 @Transactional 을 붙이면, Spring 테스트 프레임워크는 테스트가 끝날 때 자동으로 롤백합니다. 테스트 데이터가 실제 DB 에 남지 않아 편리하지만, 커밋 이후 동작(캐시 갱신, 이벤트 발행)까지는 검증하지 못한다는 한계가 있습니다.
logging.level.org.springframework.transaction.interceptor=TRACE 를 설정하면 트랜잭션 시작·커밋·롤백 시점이 로그에 그대로 찍힙니다. 전파 속성이 의도대로 동작하는지 의심될 때 가장 먼저 켜 볼 로그입니다.