본문 바로가기

Programming/Spring

Spring @Transactional : 원리부터 실전 주의사항까지

반응형

Spring에서 @Transactional은 가장 많이 쓰이면서도 가장 자주 오용되는 기능 중 하나다.
겉으로는 단순해 보이지만, 내부 동작을 잘 모르고 쓰면 트랜잭션이 아예 시작되지 않거나, 롤백되지 않거나, 데이터 불일치 문제가 생길 수 있다.

이 글에서는 단순 개념이 아니라 실제로 개발하다가 부딪히는 문제들을 기준으로 @Transactional깊이 있게 정리해본다.


1. @Transactional이 작동하는 원리

핵심은 프록시와 AOP

Spring에서 @Transactional프록시 기반 AOP를 사용해 트랜잭션을 관리한다.
즉, 트랜잭션을 시작하거나 커밋/롤백하는 실제 코드는 프록시 객체에 의해 실행된다.

기본 흐름은 다음과 같다:

  1. Bean 등록 시 Spring이 트랜잭션 어노테이션이 붙은 클래스를 프록시로 감싼다.
  2. 프록시가 메서드 호출을 가로채 트랜잭션을 시작한다.
  3. 원래 메서드가 실행된다.
  4. 정상 종료되면 트랜잭션 커밋, 예외 발생 시 롤백.
Client → (Proxy)UserService → 실제 UserServiceImpl

주의: 이 구조 때문에 프록시를 거치지 않는 호출에서는 트랜잭션이 적용되지 않는다. 이게 가장 흔한 실수다.


 

2. 트랜잭션이 적용되지 않는 흔한 사례들

같은 클래스 내부에서 메서드 호출

@Service
public class OrderService {

    public void createOrder() {
        // 아래 메서드는 @Transactional이 있어도 프록시를 거치지 않음
        saveOrder();
    }

    @Transactional
    public void saveOrder() {
        // 트랜잭션 미적용됨
    }
}

설명: this.saveOrder()는 프록시를 거치지 않고 직접 호출되기 때문에 트랜잭션이 작동하지 않는다.

 

해결법:

  • saveOrder()를 별도 클래스로 분리
  • AopContext.currentProxy()를 활용해 프록시를 통해 자기 자신을 호출

 

3. 예외가 발생했는데도 롤백되지 않는 이유

Spring은 RuntimeException, Error에 대해서만 기본적으로 롤백한다.

@Transactional
public void doSomething() throws IOException {
    throw new IOException("Checked Exception"); // rollback 안 됨
}

 

해결법:

@Transactional(rollbackFor = IOException.class)
public void doSomething() throws IOException {
    throw new IOException("이제 롤백됨");
}

실무 팁: 선언부에서 rollbackFor를 명시하지 않으면, DB에는 반영된 것처럼 보이고 실제로는 반영되지 않은 데이터 불일치 상태가 발생할 수 있다.


 

4. 트랜잭션 전파(Propagation)의 진짜 의미

REQUIRED (기본값)

  • 트랜잭션이 존재하면 그대로 참여
  • 없으면 새로 시작

REQUIRES_NEW

  • 무조건 새 트랜잭션 시작
  • 기존 트랜잭션은 일시 중단
@Transactional
public void parent() {
    child(); // REQUIRED이면 같은 트랜잭션, REQUIRES_NEW면 별도
}

실무에서 자주 부딪히는 문제

@Transactional
public void parent() {
    try {
        child(); // REQUIRES_NEW
    } catch (Exception e) {
        log.warn("자식 에러 무시");
    }
    // 여기서 에러 발생하면 child 트랜잭션은 이미 커밋된 상태
}

REQUIRES_NEW는 별도 커넥션을 쓰기 때문에, 부모에서 롤백돼도 자식은 커밋됨. 신중하게 써야 한다.


 

5. readOnly = true는 어떤 효과가 있을까?

@Transactional(readOnly = true)
public List<User> findAllUsers() {
    return userRepository.findAll();
}
  • JDBC에 따라 Connection.setReadOnly(true) 호출됨
  • 쓰기 작업은 금지되지 않는다
  • Hibernate는 flush() 생략 등 내부 최적화 수행

실무 팁: 쓰기 작업이 포함될 가능성이 있으면 readOnly는 사용하지 말 것.
무의식적으로 쓰기 작업이 들어가면 의도하지 않은 부작용이 생길 수 있다.


 

6. 트랜잭션이 시작되지 않는 시점

  • @PostConstruct
  • @Scheduled
  • ApplicationListener
  • Bean 초기화 도중

이 시점들에서는 프록시가 완전히 생성되기 전이기 때문에 트랜잭션이 시작되지 않는다.

@PostConstruct
@Transactional // 작동하지 않음
public void init() {
    ...
}

 

해결법:

  • 별도 초기화 클래스를 만들고 @EventListener(ApplicationReadyEvent.class) 사용

 

7. 실무에서 많이 겪는 트랜잭션 오류들

증상 원인 해결책
트랜잭션 안 먹힘 내부 메서드 호출 클래스를 분리하거나 Proxy 호출 사용
롤백 안 됨 Checked Exception rollbackFor 사용
DB는 업데이트 됐는데 에러 readOnly 상태에서 쓰기 시도 readOnly 사용 주의
트랜잭션 중첩으로 Deadlock 전파 방식 오남용 propagation 정책 설계 필요

 

@Transactional, 정확히 이해하고 써야 한다

 

트랜잭션은 “데이터를 안전하게 유지한다”는 단순한 목적을 갖지만, Spring에서 이를 구현하는 방식은 복잡하다.
프록시, 예외 처리, 전파 속성 등을 제대로 이해하지 못하면 예상치 못한 버그로 이어진다.

 

반응형