[UMC] 트랜잭션, 상태, 전파 ⚡

Backend·2025-09-17·by 1000hyehyang

UMC 1주차 두 번째 시니어 미션은 스프링 트랜잭션을 차근차근 정리하는 것!

단순히 @Transactional을 붙이는 법을 넘어, 스프링이 말하는 트랜잭션의 상태, 전파(Propagation), 롤백 규칙, 프록시 동작 방식, 동기화 콜백, 프로그래매틱 API까지 공식 문서 기준으로 하나씩 짚어보려고 한다.

 

그 전에, 트랜잭션과 @Transactional 개념을 먼저 짚어보자

트랜잭션(Transaction) 이란 쉽게 말하면 데이터베이스에서 “업무 단위”를 하나로 묶는 것이다.

예를 들어, 은행 계좌 이체를 생각해보자.

A 계좌에서 돈을 빼고

B 계좌에 돈을 넣는다

이 두 작업은 항상 같이 성공하거나 같이 실패해야 한다. A 계좌에서 돈만 빠지고 B 계좌에 안 들어가면 이런 XX!이라며 화를 낼지도 모른다.

그래서 DB는 이런 작업을 하나로 묶어서 처리하는데, 이걸 트랜잭션이라고 부른다.

 

스프링에서는 @Transactional이라는 애노테이션을 붙이면, 이 메서드 안에서 일어나는 DB 작업을 하나의 트랜잭션으로 묶어준다.

@Transactional
public void transferMoney(Long fromId, Long toId, int amount) {
    accountRepository.withdraw(fromId, amount);
    accountRepository.deposit(toId, amount);
}

여기서 예외가 나면? 스프링이 알아서 롤백해주고, 문제가 없으면 커밋해버린다.

 

그런데 중요한 건, 스프링은 @Transactional을 프록시(Proxy) 객체를 만들어서 감싼다는 것이다!

프록시가 뭐냐면, 진짜 객체 대신 앞에 서 있는 대리인 같은 개념이다.

만약 transferMoney()를 호출한다고 해보자. 그럼 이때 사실 프록시가 먼저 받는다.

프록시는 “아 이건 트랜잭션을 시작해야겠네” 하고 DB 연결을 열고 트랜잭션을 시작한다.

코드가 실행된 다음 문제가 없으면 커밋, 에러 나면 롤백.

즉, 트랜잭션의 시작/종료는 프록시가 담당한다는 것!

 

선언적 트랜잭션의 기본: @Transactional이 하는 일과 기본값

위에서 본 것처럼 스프링의 선언적 트랜잭션은 AOP로 구현된다. 기본 advice 모드가 proxy라서, 프록시를 통해 들어오는 외부 메서드 호출만 트랜잭션 경계에 걸린다. 같은 클래스 안에서 자기 자신 메서드를 직접 호출하면(일명 self-invocation) 프록시를 거치지 않기 때문에 트랜잭션이 적용되지 않는다. → 이런 경우엔 아예 AspectJ 모드로 전환하거나, 호출 대상을 다른 빈으로 분리해야 한다.

@Transactional의 기본 설정도 명확하다. 전파는 REQUIRED, 격리는 DEFAULT, 읽기/쓰기 트랜잭션, 타임아웃은 기본값.

기본 롤백 규칙은 RuntimeExceptionError일 때 롤백, 체크 예외는 커밋이다. 스프링 6.2부터는 전역 기본 롤백 정책을 ALL_EXCEPTIONS로 바꿔 체크 예외도 기본 롤백으로 만들 수 있다.

예를 들어 이렇게 쓸 수 있다.

@EnableTransactionManagement(rollbackOn = AdviceModeRollback.ALL_EXCEPTIONS)
@SpringBootApplication
public class App { }

또는 개별 메서드 단위로 속성을 뒤집을 수도 있다.

@Transactional(
  propagation = Propagation.REQUIRES_NEW,
  isolation   = Isolation.READ_COMMITTED,
  timeout     = 5,
  readOnly    = false,
  rollbackFor = Exception.class
)
public void doWork() { ... }

 

전파(Propagation)의 의미: 물리 트랜잭션 vs. 논리 트랜잭션

스프링에서 트랜잭션 전파(Propagation)라는 건 **"이미 진행 중인 트랜잭션이 있을 때, 새로 들어온 메서드는 그 트랜잭션에 합류할까? 아니면 따로 새 트랜잭션을 열까?"**를 결정하는 규칙이다.

스프링 공식 문서는 전파를 설명하면서 물리(physical) 트랜잭션논리(logical) 트랜잭션을 구분한다.

image

REQUIRED는 상황에 따라 기존 물리 트랜잭션에 참여하거나, 없으면 새 물리 트랜잭션을 시작한다. 이때 각 진입점은 논리 트랜잭션 스코프를 만들고, 안쪽 스코프에서 rollback-only를 표시하면 결국 바깥 커밋 시점에 UnexpectedRollbackException으로 알려준다. 호출자는 “커밋된 줄 알았는데 사실 롤백되었다”라는 착시를 겪지 않도록 해야 하기 때문이다. 또한 기존 트랜잭션에 참여할 땐 바깥의 격리/타임아웃/읽기 전용 속성을 상속한다. 필요하다면 validateExistingTransactions=true속성 불일치(예: read-only 불일치)를 거부하게 만들 수도 있다.

image

REQUIRES_NEW는 이름 그대로 항상 독립된 물리 트랜잭션을 연다. 그래서 안팎이 서로의 롤백 상태에 영향을 주지 않는다. 다만 바깥 트랜잭션이 잡은 연결을 유지한 채, 새 연결을 추가로 빌리기 때문에 커넥션 풀이 부족하면 교착의 위험이 커진다. 동시에 처리하는 스레드 수보다 풀 크기를 최소 +1 이상 확보하라는 경고도 문서에 적혀 있다.

image

NESTED하나의 물리 트랜잭션에 여러 세이브포인트를 두는 방식이다. 안쪽에서 부분 롤백을 해도 바깥 물리 트랜잭션은 계속 간다. 구현상 JDBC 세이브포인트를 쓰므로, 이 모드는 JDBC 리소스 트랜잭션에서만 가능하고 스프링의 DataSourceTransactionManager와 매칭된다고 적혀 있다.

요약하면, REQUIRED는 “같은 배를 타자”, REQUIRES_NEW는 “아예 다른 배로 가자”, NESTED는 “같은 배지만 구명정을 달자”에 가깝다.

실전에서 “핵심 비즈니스는 반드시 커밋, 부가기능은 독립적으로 처리” 같은 요구가 있을 때 REQUIRES_NEW가,

벌크 처리에서 “실패한 아이템만 되돌리고 나머지는 진행”이 필요할 땐 NESTED가 깔끔하게 맞는다. (물론 커넥션 풀, 드라이버/플랫폼 지원을 먼저 확인해야 한다.)

 

격리 수준, 읽기 전용, 타임아웃

@Transactionalisolation, readOnly, timeout 속성으로 트랜잭션 세부 동작을 제어한다. 격리 수준은 DEFAULT, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE 등 자바 enum으로 제공되며, 속성 의미는 DB가 결정한다. 보통 JPA/JDBC 모두 바깥 트랜잭션에 참여할 때는 바깥의 속성을 상속한다는 점을 기억하면 된다.

격리 수준

  • READ COMMITTED: 다른 트랜잭션이 커밋한 데이터만 읽을 수 있다. (보통 가장 많이 쓰임)

  • REPEATABLE READ: 내가 SELECT 했던 행은 트랜잭션이 끝날 때까지 값이 안 바뀐다고 보장해줌.

  • SERIALIZABLE: 가장 강력한 격리, 사실상 순서대로 한 줄씩 실행하는 것처럼 보장. 대신 성능은 제일 느림.

  • DEFAULT: DB 벤더(MySQL, PostgreSQL 등)의 기본 설정을 따른다.

읽기 전용

@Transactional(readOnly = true) → 트랜잭션이 데이터를 수정하지 않고 조회만 할 때 힌트를 주는 옵션

JPA에서는 영속성 컨텍스트가 dirty checking을 안 해서 성능이 조금 더 좋아진다.

즉, “이 메서드에서는 데이터 안 건드릴 거야”라고 선언하는 용도.

타임아웃

트랜잭션이 너무 오래 걸리면 자동으로 롤백시켜버리는 장치

 

스프링의 롤백 규칙: 기본과 확장

스프링은 기본적으로 RuntimeException/Error에서만 롤백한다. 체크 예외는 기본 커밋이다.

이 정책은 @TransactionalrollbackFor/noRollbackFor 또는 XML의 rollback-for/no-rollback-for정밀 제어할 수 있고, 코드에서 정말 필요하면 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()명시 롤백 표식을 박을 수도 있다. 문서에는 Vavr TryCompletableFuture를 반환하는 선언적 처리에 대한 특수 규칙도 정리되어 있다(실패 상태로 완료되면 롤백 판단).

 

트랜잭션 상태와 동기화 콜백

코드가 트랜잭션 안에서 돌아갈 때는 TransactionStatus새 트랜잭션 여부, rollback-only 여부, 완료 여부 같은 상태를 확인할 수 있다.

커밋 직전/직후 혹은 완료 시점에 훅을 심고 싶다면 TransactionSynchronization를 사용한다. beforeCommit, beforeCompletion, afterCommit, afterCompletion, flush 같은 콜백이 제공되며, 커밋 성공 후에만 실행해야 하는 외부 부작용(이메일, 메시지 발행 등)을 afterCommit()에 두는 것이 안전하다.

 

프로그래매틱 트랜잭션: TransactionTemplatePlatformTransactionManager

선언적 트랜잭션이 대부분의 케이스에 적합하지만, 더 세밀한 제어가 필요하면 프로그래매틱 API가 답이다.

프로그래매틱 트랜잭션 : 직접 코드로 “트랜잭션 시작 → 비즈니스 로직 실행 → 성공이면 커밋, 실패면 롤백”을 적는 방식

TransactionTemplate은 콜백 기반으로 트랜잭션 경계를 감싸주고, status.setRollbackOnly()로 명시 롤백도 가능하다. 템플릿 자체에 전파, 격리, 타임아웃 설정을 부여할 수 있다.

@Service
@RequiredArgsConstructor
public class BillingService {

  private final TransactionTemplate tx;

  public void bill() {
    tx.executeWithoutResult(status -> {
      try {
        charge();
        record();
      } catch (IOException e) {
        status.setRollbackOnly(); // 필요 시 명시 롤백
      }
    });
  }
}

더 낮은 수준으로 가면 PlatformTransactionManager를 이용해 트랜잭션을 직접 획득/커밋/롤백할 수 있다. 스프링은 이를 위해 JDBC, JPA, JTA 등 여러 구현체를 제공하며, 대부분의 애플리케이션은 선언적 경계나 TransactionTemplate을 통해 이를 사용한다.

JPA를 쓴다면 보통 JpaTransactionManager가 맞다. 단일 EntityManagerFactory에 적합하고, 여러 리소스를 하나의 글로벌 트랜잭션에 묶어야 한다면 JTA 계열을 고려하라고 문서가 안내한다.

 

다중 트랜잭션 매니저와 선택

애플리케이션에 트랜잭션 매니저가 여러 개라면, @Transactional("order")처럼 이름으로 어떤 매니저를 쓸지 명시할 수 있다. 클래스 레벨에 타입-레벨 qualifier를 붙여 공통 기본을 줄 수도 있고, 메서드 단위에서 덮어쓰는 것도 가능하다.

 

그래서 어떻게 개발하는 게 좋은가...

문서 그대로를 옮기지는 않되, 문서의 의도를 개발 규칙으로 번역하면 이런 식이다.

트랜잭션 경계는 짧게 잡고, 컨트롤러까지 끌고 가지 않는다.

비핵심 부작용 로직은 REQUIRES_NEW분리해 본 트랜잭션을 지킨다.

벌크 처리는 NESTED부분 롤백을 설계하고, 외부 시스템과의 상호 작용은 afterCommit()에 묶어 “DB 커밋 후”에만 실행한다.

프록시 한계를 항상 염두에 두고, 자기 호출이 필요하면 구조를 분리하거나 AspectJ를 선택한다.

기본 롤백 정책은 팀 표준으로 합의하고, 필요하면 스프링 6.2의 전역 기본 롤백 옵션으로 체크 예외도 통일해 일관성을 높인다.

 

결국 선택의 문제로 돌아온다. 무엇을 같이 묶을지, 무엇을 분리할지, 그리고 실패는 어디서 되돌릴지. 전파와 상태, 콜백과 API를 이해하면 그 선택이 의식적인 설계가 된다.

댓글 로딩 중...