Published on

Spring Boot 3 @Transactional 전파·격리 함정 7가지

Authors

서버 사이드에서 장애를 가장 빨리 만드는 코드는 의외로 복잡한 알고리즘이 아니라 @Transactional 한 줄인 경우가 많습니다. Spring Boot 3(= Spring Framework 6, Jakarta EE 기반)에서도 트랜잭션 모델 자체는 크게 바뀌지 않았지만, 전파(Propagation)와 격리(Isolation)를 “정확히 이해하지 못한 채” 조합하면 다음과 같은 문제가 반복됩니다.

  • 롤백이 안 된다(혹은 반대로 예상치 못한 롤백)
  • 데이터가 “가끔” 중복 생성된다
  • 읽기 일관성이 깨져서 통계/정산이 틀린다
  • 락 경합/데드락이 급증한다
  • 성능이 갑자기 떨어진다

아래 7가지는 실무에서 특히 자주 밟는 함정들입니다. 각 항목마다 왜 발생하는지, 어떻게 재현되는지, 어떻게 고칠지를 Spring Boot 3 기준으로 정리합니다.

> 데드락 자체의 분석/튜닝은 별도 글에서 더 깊게 다뤘습니다: MySQL·PostgreSQL 데드락 분석과 트랜잭션·인덱스 튜닝

1) 같은 클래스 내부 호출(Self-invocation)로 트랜잭션이 아예 안 걸림

Spring의 @Transactional은 기본적으로 프록시(AOP) 기반입니다. 즉, 같은 빈 내부에서 this.method()로 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않습니다.

문제 코드

@Service
@RequiredArgsConstructor
public class OrderService {
  private final OrderRepository orderRepository;

  public void placeOrder(OrderCommand cmd) {
    // 같은 클래스의 메서드를 호출 -> 프록시 우회
    saveOrder(cmd);
  }

  @Transactional
  public void saveOrder(OrderCommand cmd) {
    orderRepository.save(new Order(cmd));
  }
}

겉으로는 saveOrder()에 트랜잭션이 걸린 것처럼 보이지만, 실제로는 트랜잭션 없이 실행될 수 있습니다(특히 예외/롤백 시나리오에서 증상이 크게 납니다).

해결

  • 트랜잭션 경계를 외부에서 호출되는 public 메서드로 올리기
  • 혹은 분리된 서비스로 위임하기(권장)
@Service
@RequiredArgsConstructor
public class OrderFacade {
  private final OrderTxService orderTxService;

  public void placeOrder(OrderCommand cmd) {
    orderTxService.saveOrder(cmd);
  }
}

@Service
@RequiredArgsConstructor
class OrderTxService {
  private final OrderRepository orderRepository;

  @Transactional
  public void saveOrder(OrderCommand cmd) {
    orderRepository.save(new Order(cmd));
  }
}

2) REQUIRES_NEW로 “부분 커밋”이 발생해 정합성이 깨짐

Propagation.REQUIRES_NEW기존 트랜잭션을 suspend하고, 완전히 독립된 새 트랜잭션을 시작합니다. 따라서 바깥 트랜잭션이 롤백되더라도 안쪽 REQUIRES_NEW는 이미 커밋되어 부분 커밋(Partial commit) 이 발생할 수 있습니다.

흔한 실수: 감사 로그/포인트 차감 등을 REQUIRES_NEW로 빼기

@Service
@RequiredArgsConstructor
public class PaymentService {
  private final AuditService auditService;
  private final PaymentRepository paymentRepository;

  @Transactional
  public void pay(Long orderId) {
    paymentRepository.save(new Payment(orderId));

    // 실패해도 로그는 남기자? -> REQUIRES_NEW
    auditService.write("PAYMENT_CREATED:" + orderId);

    // 이후 예외 발생
    if (true) throw new RuntimeException("fail");
  }
}

@Service
class AuditService {
  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void write(String msg) {
    // audit 테이블 insert
  }
}

결과:

  • Payment는 롤백
  • Audit은 커밋
  • 운영에서는 “결제 실패인데 감사 로그만 남는” 상태가 누적

안전한 대안

  • 같은 트랜잭션에 묶을 데이터REQUIRES_NEW로 빼지 말기
  • “실패해도 남겨야 하는 로그”는 DB 트랜잭션이 아니라 비동기 이벤트/외부 로그 시스템으로 분리
  • 정말 DB에 남겨야 한다면 “부분 커밋”을 설계적으로 수용하고, 조회/리포팅에서 명확히 구분

3) NESTED는 만능이 아니다: DB/드라이버/플랫폼에 따라 동작이 다름

Propagation.NESTED는 Savepoint 기반으로 부분 롤백을 제공합니다. 하지만 다음 제약이 큽니다.

  • JPA/Hibernate 환경에서 항상 기대대로 동작하지 않을 수 있음
  • 데이터소스/드라이버가 Savepoint를 제대로 지원해야 함
  • 플랫폼 트랜잭션 매니저(DataSourceTransactionManager) vs JPA 트랜잭션 매니저(JpaTransactionManager)에 따라 체감이 다름

함정 포인트

  • “안쪽만 롤백되고 바깥은 커밋될 것”이라고 가정했다가, 실제로는 전체 롤백/예외 전파로 이어지는 케이스

권장 접근

  • NESTED정말 Savepoint가 필요한 경우에만 제한적으로 사용
  • 대부분의 경우는 업무 단위를 더 작게 쪼개고, 실패 허용/복구 전략을 명시적으로 설계(예: 보상 트랜잭션, 이벤트 기반)

4) 격리 레벨을 올리면 “정합성”이 아니라 “락/데드락”이 먼저 온다

Isolation.SERIALIZABLE 또는 REPEATABLE_READ를 무심코 적용하면, 눈에 띄는 효과는 “정합성 향상”이 아니라 락 경합 증가, 트랜잭션 대기, 데드락 증가로 나타나는 경우가 많습니다.

특히 다음 조합이 위험합니다.

  • 긴 트랜잭션(외부 API 호출, 파일 업로드, 네트워크 대기 포함)
  • 범위 조건 조회 후 업데이트(팬텀/갭락 관련)
  • 인덱스가 부정확하거나 범위를 넓게 잡는 쿼리

예시: 통계 집계에서 SERIALIZABLE을 걸어버리기

@Transactional(isolation = Isolation.SERIALIZABLE)
public void recalcDailyStats(LocalDate day) {
  // day 범위 전체를 읽고, 집계 테이블 업데이트
}

권장 접근

  • 격리 레벨을 올리기 전에:
    • 필요한 일관성이 정말 “직렬화” 수준인지 정의
    • 쿼리 범위를 줄이고 인덱스를 정교하게
    • 동시성 제어는 격리 레벨보다 명시적 락(PESSIMISTIC_WRITE) 또는 낙관적 락(@Version) 이 더 예측 가능한 경우가 많음

데드락이 의심되면 원인/패턴 분석부터 하세요: MySQL·PostgreSQL 데드락 분석과 트랜잭션·인덱스 튜닝

5) READ_COMMITTED에서도 “같은 트랜잭션 안에서” 읽기 결과가 바뀐다(Non-repeatable read)

많은 팀이 기본 격리 레벨(대부분 READ_COMMITTED)을 “한 번 읽은 값은 트랜잭션 끝까지 유지”로 오해합니다. 하지만 READ_COMMITTED는 말 그대로 커밋된 것만 읽을 수 있을 뿐, 같은 트랜잭션 내에서도 재조회 시 값이 바뀔 수 있습니다.

재현 시나리오

  • Tx A: 사용자 잔액 조회(100)
  • Tx B: 잔액 업데이트 후 커밋(50)
  • Tx A: 같은 사용자 잔액 재조회 → 50

함정

  • “중간에 값이 바뀌면 안 되는” 계산/검증 로직이 READ_COMMITTED에서 흔들림

해결 옵션

  • 비즈니스적으로 “반복 가능한 읽기”가 필요하면 REPEATABLE_READ를 고려하되 락/성능 영향 검토
  • 더 흔한 해법은 명시적 락 또는 버전 기반 낙관적 락
public interface AccountRepository extends JpaRepository<Account, Long> {

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Query("select a from Account a where a.id = :id")
  Account findByIdForUpdate(@Param("id") Long id);
}

@Transactional
public void withdraw(Long accountId, long amount) {
  Account a = accountRepository.findByIdForUpdate(accountId);
  a.withdraw(amount);
}

6) readOnly = true는 “쓰기 방지”가 아니라 “힌트”다

@Transactional(readOnly = true)를 붙이면 쓰기가 막힐 거라고 기대하지만, 실제로는 다음 성격이 큽니다.

  • Spring/JPA/Hibernate에 “읽기 전용 최적화 힌트”를 주는 것에 가깝다
  • 구현/DB에 따라 쓰기가 그대로 반영될 수도 있다
  • Hibernate는 flush 전략을 바꾸거나 dirty checking 비용을 줄일 수 있지만, 절대적 보장은 아님

흔한 사고

  • 조회 서비스에 readOnly=true를 붙여두고, 내부에서 실수로 엔티티를 변경
  • 테스트에서는 안 보이다가 운영에서 flush 타이밍에 따라 반영되거나, 반대로 반영되지 않아 더 혼란

권장 접근

  • 읽기 전용 메서드에서는 엔티티를 수정하지 않는 규율을 지키고, DTO 매핑을 선호
  • 쓰기 가능성이 있으면 readOnly를 빼고 명확히 분리
@Transactional(readOnly = true)
public OrderView getOrder(Long id) {
  Order o = orderRepository.findById(id).orElseThrow();
  return new OrderView(o.getId(), o.getStatus(), o.getTotalPrice());
}

7) 롤백 규칙 오해: 체크 예외(Checked Exception)는 기본적으로 롤백되지 않는다

Spring의 기본 롤백 규칙은:

  • RuntimeExceptionError → 롤백
  • Checked Exception(예: Exception) → 기본적으로 롤백 안 함

이 때문에 “예외가 났는데 데이터가 커밋됨” 같은 사고가 발생합니다.

문제 코드

@Transactional
public void register(UserCommand cmd) throws Exception {
  userRepository.save(new User(cmd));
  // 체크 예외 발생
  throw new Exception("duplicated");
}

해결

  • 정말 롤백이 필요하면 rollbackFor를 명시
@Transactional(rollbackFor = Exception.class)
public void register(UserCommand cmd) throws Exception {
  userRepository.save(new User(cmd));
  throw new Exception("duplicated");
}
  • 또는 비즈니스 예외를 RuntimeException 계열로 설계(예: BusinessException extends RuntimeException)

마무리: 전파·격리는 “기능”이 아니라 “계약”이다

@Transactional의 전파/격리 옵션은 문제를 해결해주는 스위치라기보다, 동시성/정합성/성능에 대한 계약을 코드로 선언하는 도구입니다. 특히 아래 두 가지는 항상 같이 점검해야 합니다.

  • 트랜잭션 경계가 어디서 시작/종료되는지(프록시 우회, 전파 옵션)
  • DB가 실제로 제공하는 일관성/락 동작(격리 레벨, 인덱스, 쿼리 패턴)

실무적으로는 다음 순서가 안전합니다.

  1. 트랜잭션을 가능한 짧게 유지(외부 I/O를 트랜잭션 밖으로)
  2. 기본 격리에서 요구사항을 만족하는지 검증
  3. 부족하면 격리 레벨 상향보다 명시적 락/버전 락을 먼저 고려
  4. REQUIRES_NEW는 “부분 커밋을 허용한다”는 설계를 동반할 때만 사용

관련해서 Spring Boot 3에서 외부 호출이 섞일 때의 장애 패턴도 함께 보면 좋습니다: Spring Boot 3 Feign 타임아웃·재시도 함정 9가지