트랜잭션 처리와 AOP(feat.CheckedException)

EunBeen Noh·2025년 5월 15일

SpringAdvanced

목록 보기
15/16

우리는 서비스 로직을 작성할 때 여러 작업이 함께 묶여 처리되어야 하는 경우가 있다.
예를 들어, 회원이 탈퇴하면
회원 상태를 비활성화하고 관련 데이터도 모두 삭제해야 한다.

이런 상황에서는 하나라도 실패하면 전체 작업을 되돌려야 한다.
이때 사용하는 것이 바로 트랜잭션(Transaction) 이다.

그리고 스프링에서는 이 트랜잭션을 AOP 기반으로 적용할 수 있다.

0. 트랜잭션(Transaction)

  • 여러 작업을 하나의 작업 단위로 묶는 것

    트랜잭션의 4가지 속성 (ACID)

    속성설명
    Atomicity (원자성)모든 작업은 전부 성공하거나 전부 실패해야 한다
    Consistency (일관성)트랜잭션 실행 전과 후의 데이터 일관성이 유지되어야 한다
    Isolation (격리성)동시에 여러 트랜잭션이 실행될 때 서로 간섭하면 안 된다
    Durability (지속성)성공한 트랜잭션의 결과는 영구적으로 반영되어야 한다
  • 이는 대부분 DB가 제공하고, 스프링은 이를 프로그래밍적으로 제어할 수 있게 도와준다.

1. 스프링에서의 트랜잭션 처리 방식

@Transactional

  • 코드에서 예외가 발생하면, 트랜잭션은 자동으로 rollback 되고
    아무 작업도 실제로 반영되지 않는다.
@Transactional
public void placeOrder(Long itemId) {
    inventory.decrease(itemId);
    paymentService.pay(itemId);
    deliveryService.schedule(itemId);
}

@Transactional은 스프링 AOP를 통해 동작한다.

  • 동작 구조
  1. @Transactional이 붙은 메서드를 스프링이 감지
  2. 해당 메서드를 감싼 프록시 객체를 생성
  3. 프록시가 메서드 실행 전 → 트랜잭션 시작
  4. 메서드 정상 종료 → 커밋
  5. 예외 발생 → 롤백

2. @Transactional 사용 시 주의할 점

1) private 메서드에는 트랜잭션이 적용되지 않는다.

스프링 AOP는 프록시 객체가 대상 객체의 메서드를 호출하는 방식으로 동작한다.
하지만 private 메서드는 프록시 객체가 호출할 수 없기 때문에,
트랜잭션이 적용되지 않는다.

@Transactional
private void saveMember() {
    // 트랜잭션 적용되지 않는다.
}

2) 자기 자신의 내부 메서드 호출에는 트랜잭션이 적용되지 않는다.

트랜잭션은 외부에서 진입한 첫 메서드를 기준으로 프록시가 트랜잭션을 적용한다.
따라서 같은 클래스 내에서 this.메서드()로 호출하는 내부 호출에는 트랜잭션이 동작하지 않는다.

@Service
public class MemberService {

    @Transactional
    public void register(String name) {
        // 회원 등록 전 로직
        saveMember(name);  // 내부 호출 → 트랜잭션 적용 안 됨
    }

    @Transactional
    public void saveMember(String name) {
        memberRepository.save(new Member(name));
    }
}

register()는 외부에서 프록시 객체를 통해 호출되므로 트랜잭션이 적용된다.
하지만 saveMember()는 this.saveMember() 식의 내부 호출이라
프록시를 거치지 않기 때문에 트랜잭션이 적용되지 않는다.

해결방법 1. 트랜잭션 메서드를 다른 클래스로 분리 (추천)

@Service
@RequiredArgsConstructor
public class MemberFacade {

    private final MemberService memberService;

    public void register(String name) {
        // 회원 등록 전 로직
        memberService.saveMember(name);  // 다른 Bean → 트랜잭션 정상 작동
    }
}

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public void saveMember(String name) {
        memberRepository.save(new Member(name));
    }
}

해결방법 2. 자기 자신의 프록시를 ApplicationContext로 가져와 호출

  • 프레임워크에 의존적이고, 자기 자신을 호출하는 구조이기 때문에 비추천
@Service
@RequiredArgsConstructor
public class MemberService {

    private final ApplicationContext applicationContext;
    private final MemberRepository memberRepository;

    public void register(String name) {
        // 프록시 객체로 자기 자신을 꺼내서 호출
        MemberService proxy = applicationContext.getBean(MemberService.class);
        proxy.saveMember(name);  // 프록시 경유 → 트랜잭션 정상 적용
    }

    @Transactional
    public void saveMember(String name) {
        memberRepository.save(new Member(name));
    }
}

3) 예외가 롤백되지 않는 경우가 있다.

스프링에서 @Transactional은 다음과 같은 기본 동작 규칙을 가진다.

  • UnCheckedException(RuntimeException 또는 그 하위 클래스) → 자동으로 롤백
  • Checked Exception → 롤백되지 않고 커밋됨
    • 체크 예외: 컴파일러가 명시적으로 처리하라고 요구하는 예외
    • ex) IOException, SQLException

체크 예외는 기본적으로 롤백되지 않는다.

@Transactional
public void doSomething() throws IOException {
    throw new IOException(); // // 롤백되지 않고 트랜잭션이 커밋됨
}

이 경우, 트랜잭션이 커밋되어버린다.
예외가 발생했는데도 DB에 반영되는 문제가 생길 수 있다.

해결: @Transactional에 rollbackFor 속성을 명시

rollbackFor에 롤백하고 싶은 예외 클래스를 지정하면
그 예외가 발생할 때도 롤백된다.

@Transactional(rollbackFor = Exception.class)
public void doSomething() throws IOException {
    throw new IOException(); // 이 경우에는 롤백이 된다.
}

특정 예외만 커밋하고 싶을 경우 → noRollbackFor 속성 사용

@Transactional(noRollbackFor = CustomCheckedException.class)
public void updateMember() throws CustomCheckedException {
    // 예외가 발생해도 롤백되지 않고 커밋됨
    throw new CustomCheckedException();
}
  • 위 방법들 외에, try-catch를 통해 예외처리도 가능하다.

0개의 댓글