// Developer · Debugging Reports

뉴스도 디버깅이 필요해

마주친 오류를 재현하고, 원인을 추적하고, 기록합니다. Java · Spring · DB · 실무 트러블슈팅.

debug — today.log
$ debug --trace "오늘의 뉴스"
> 헤드라인 파싱 중...
> 원인 추적: 근거 누락 · 출처 불명
> 패치 적용: 사실 확인 + 출처 보강
✓ RESOLVED
TOTAL
TODAY
YESTERDAY

Categories

웹개발/Spring

Spring @Transactional 기본 정리 (propagation, isolation, rollback)

자바를잡아2026. 7. 3. 08:00
반응형

@Transactional이 하는 일

@Transactional은 메서드 실행을 하나의 트랜잭션으로 묶어, 전부 성공하면 커밋, 중간에 실패하면 롤백되게 한다. 예를 들어 주문을 저장하고 재고를 차감하는 두 작업이 한 메서드 안에 있다면, 둘 다 성공해야 반영되고 하나라도 실패하면 전체가 취소돼야 한다. 이 "모 아니면 도"를 보장하는 것이 트랜잭션이다. Spring @Transactional 사용법의 출발점은 이 메서드 단위 묶음을 이해하는 것이다.

사용은 간단하다. 보통 서비스 계층 메서드에 붙인다. 문제는 옵션(propagation, isolation, rollback)의 의미와, "분명 붙였는데 롤백이 안 되는" 함정이다. 운영 데이터 정합성이 걸리는 부분이라 동작 원리를 알고 써야 한다.

@Service
public class OrderService {

    @Transactional // 이 메서드 전체가 하나의 트랜잭션
    public void order(OrderVO order) {
        orderMapper.insertOrder(order);     // (1) 주문 저장
        stockMapper.decrease(order.getItemId(), order.getQty()); // (2) 재고 차감
        // (2)에서 예외가 나면 (1)도 롤백
    }
}

주요 옵션 한눈에 보기

옵션 역할 자주 쓰는 값
propagation 트랜잭션 전파 방식 REQUIRED(기본), REQUIRES_NEW
isolation 격리 수준(동시성 처리) DEFAULT(DB 기본), READ_COMMITTED
rollbackFor 롤백할 예외 지정 Exception.class 등
readOnly 읽기 전용 힌트 조회 전용 메서드에 true
timeout 제한 시간(초) 장기 트랜잭션 방지

propagation: 트랜잭션 전파

1. REQUIRED (기본값)

propagation은 이미 진행 중인 트랜잭션이 있을 때 어떻게 할지를 정한다. 기본값인 REQUIRED는 "진행 중인 트랜잭션이 있으면 거기 참여하고, 없으면 새로 만든다"이다. 대부분의 경우 이 기본값으로 충분하다.

@Transactional // propagation = REQUIRED (기본)
public void order(OrderVO order) {
    orderMapper.insertOrder(order);
    paymentService.pay(order); // 이 안의 @Transactional도 같은 트랜잭션에 참여
}

위에서 paymentService.pay()도 @Transactional이라면, 새 트랜잭션을 만들지 않고 바깥 트랜잭션에 합류한다. 그래서 어느 한쪽이 실패하면 전체가 함께 롤백된다.

2. REQUIRES_NEW

REQUIRES_NEW는 항상 새 트랜잭션을 만든다. 바깥 트랜잭션이 있어도 잠시 멈추고 독립된 트랜잭션으로 처리한다. 바깥이 롤백돼도 이쪽은 별도로 커밋될 수 있다. 로그 기록처럼 "본 작업과 무관하게 남겨야 하는" 경우에 쓴다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeLog(LogVO log) {
    logMapper.insert(log); // 바깥이 롤백돼도 로그는 남기고 싶을 때
}

다만 REQUIRES_NEW는 트랜잭션을 분리하므로 동작을 정확히 이해하고 써야 한다. 무심코 쓰면 의도와 달리 일부만 커밋돼 데이터가 어긋날 수 있다.

isolation: 격리 수준

isolation은 동시에 여러 트랜잭션이 같은 데이터를 다룰 때 서로의 변경을 어디까지 보게 할지를 정한다. 격리 수준이 높을수록 데이터 일관성은 올라가지만 동시성(성능)은 떨어진다. 기본값은 DEFAULT로, DB가 정한 기본 격리 수준을 따른다. Oracle은 보통 READ COMMITTED가 기본이다.

// 대부분은 DEFAULT(DB 기본)로 두고, 특별한 경우만 조정
@Transactional(isolation = Isolation.READ_COMMITTED)
public void process() { ... }

실무에서는 격리 수준을 직접 바꾸는 일이 많지 않다. DB 기본값으로 두는 경우가 대부분이고, 동시성 이슈(같은 데이터 동시 수정 등)가 명확할 때만 신중히 조정한다. 격리 수준을 높이면 락 경합이나 성능 저하로 이어질 수 있어, 운영에서는 영향 범위를 충분히 검토하고 적용한다.

rollback: 언제 롤백되는가 (가장 중요한 함정)

1. 기본은 RuntimeException만 롤백

가장 많이 걸리는 함정이다. @Transactional은 기본적으로 RuntimeException(언체크 예외)과 Error에서만 롤백한다. 즉 IOException 같은 checked exception은 기본 설정으로는 롤백하지 않는다. 예외가 났는데도 데이터가 커밋돼 버리는 황당한 상황이 여기서 나온다.

// checked exception은 기본적으로 롤백되지 않는다
@Transactional
public void process() throws Exception {
    orderMapper.insertOrder(order);
    if (somethingWrong) {
        throw new Exception("실패"); // checked → 롤백 안 됨, insert는 커밋됨
    }
}

2. rollbackFor로 명시하기

checked exception에서도 롤백하려면 rollbackFor로 명시한다. 실무에서는 모든 예외에 롤백을 거는 경우가 많다.

// 모든 Exception에 대해 롤백
@Transactional(rollbackFor = Exception.class)
public void process() throws Exception {
    orderMapper.insertOrder(order);
    if (somethingWrong) {
        throw new Exception("실패"); // 이제 롤백됨
    }
}

또 하나 흔한 실수는 메서드 안에서 try-catch로 예외를 잡아 삼키는 것이다. 예외를 잡아서 밖으로 안 던지면 스프링은 정상 종료로 보고 커밋한다. 롤백이 필요하면 예외를 다시 던지거나, 명시적으로 롤백 처리를 해야 한다.

@Transactional이 아예 동작 안 할 때

옵션 이전에, @Transactional 자체가 안 먹는 경우가 있다. 원리를 모르면 한참 헤맨다.

  • 같은 클래스 내부 호출(self-invocation) — 같은 클래스의 메서드를 this.method()로 직접 부르면 프록시를 거치지 않아 @Transactional이 적용되지 않는다. 별도 빈으로 분리하거나 구조를 바꿔야 한다.
  • private 메서드 — 프록시 기반 AOP라 private에는 적용되지 않는다. public 메서드에 붙인다.
  • 예외를 try-catch로 삼킴 — 예외가 밖으로 안 나가면 롤백되지 않는다.
  • 트랜잭션 매니저 미설정 — DataSourceTransactionManager 등 트랜잭션 매니저 빈이 등록돼 있고 <tx:annotation-driven>(또는 @EnableTransactionManagement)이 있는지 확인한다.
<!-- XML 설정 예: 트랜잭션 매니저 + 어노테이션 활성화 -->
<bean id="txManager"
      class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource" />
</bean>
<tx:annotation-driven transaction-manager="txManager" />

실무에서 자주 하는 실수

  • checked exception에서 롤백을 기대하지만 rollbackFor를 안 줘서 커밋되는 경우.
  • 메서드 안에서 예외를 try-catch로 잡아 삼켜 롤백이 안 되는 경우.
  • 같은 클래스 내부 호출이라 @Transactional이 적용 안 되는 경우.
  • 조회 전용 메서드에 readOnly를 안 줘서 불필요한 트랜잭션 오버헤드가 생기는 경우(성능 측면).
  • 운영에서 격리 수준을 검증 없이 높여 락 경합·성능 저하가 발생하는 경우.

FAQ

Q1. 예외가 났는데 롤백이 안 됩니다.

checked exception은 기본적으로 롤백되지 않는다. @Transactional(rollbackFor = Exception.class)로 명시하거나, 예외를 try-catch로 삼키지 않았는지 확인한다.

Q2. propagation 기본값은 무엇인가요?

REQUIRED다. 진행 중인 트랜잭션이 있으면 참여하고, 없으면 새로 만든다. 대부분 이 기본값으로 충분하다.

Q3. 같은 클래스의 메서드를 호출했더니 트랜잭션이 안 먹습니다.

같은 클래스 내부 호출은 프록시를 거치지 않아 @Transactional이 적용되지 않는다. 해당 메서드를 다른 빈으로 분리하는 방식을 검토한다.

Q4. readOnly=true는 무슨 의미인가요?

읽기 전용 트랜잭션이라는 힌트다. 조회 전용 메서드에 주면 일부 최적화가 가능하다. 다만 동작은 DB·드라이버에 따라 다를 수 있다.

Q5. 격리 수준은 보통 어떻게 두나요?

대부분 DEFAULT(DB 기본값)로 둔다. Oracle은 보통 READ COMMITTED다. 동시성 문제가 명확할 때만 신중히 조정한다.

정리

Spring @Transactional 사용법의 핵심은 "메서드를 하나의 트랜잭션으로 묶고, 옵션으로 전파·격리·롤백을 제어하는 것"이다. propagation 기본값 REQUIRED는 진행 중 트랜잭션에 참여하고, isolation은 보통 DB 기본값으로 둔다. 가장 주의할 점은 rollback이다. 기본적으로 RuntimeException만 롤백되므로 checked exception까지 롤백하려면 rollbackFor를 명시하고, 예외를 try-catch로 삼키지 않아야 한다. 또 같은 클래스 내부 호출이나 private 메서드에서는 @Transactional이 적용되지 않는다는 점도 기억한다. 운영 데이터 정합성이 걸린 만큼, 격리 수준 조정이나 트랜잭션 구조 변경은 테스트 환경에서 충분히 확인한 뒤 반영하는 것이 안전하다.

반응형