본문 바로가기
Framework/Spring Boot

[Spring Boot] @Transactional의 함정: 롤백(Rollback)과 전파 속성의 모든 것

반응형

은행 앱에서 A가 B에게 10만 원을 송금하는 상황을 상상해 봅시다. 서버는 두 가지 쿼리를 실행합니다. 1. A의 통장에서 10만 원 차감 (UPDATE), 2. B의 통장에 10만 원 증가 (UPDATE). 그런데 1번 쿼리가 성공한 직후, 갑자기 데이터베이스 서버의 전원이 나갔습니다! A의 돈은 10만 원이 증발했는데, B는 돈을 받지 못했습니다. 뉴스에 대서특필될 만한 엄청난 사고입니다. 이런 대형 사고를 막기 위해 데이터베이스는 "1번과 2번 쿼리를 하나로 묶어라. 둘 다 성공할 때만 최종 반영(Commit)하고, 하나라도 실패하면 1번 쿼리도 아예 없었던 일로 롤백(Rollback) 시켜라!"라는 논리적 작업 단위를 제공하는데, 이것이 바로 트랜잭션(Transaction)입니다. 스프링 부트(Spring Boot)에서는 이 복잡한 개념을 @Transactional 어노테이션 단 하나로 우아하게 해결합니다. 하지만 실무에서 이 어노테이션의 함정(예외 처리 방식, 프록시 원리)을 모르고 쓰면 데이터가 롤백되지 않고 그대로 DB에 꽂히는 대참사가 발생합니다.

이번 포스팅에서는 "데이터 무결성의 최후의 보루!" @Transactional의 숨겨진 원리와 실무 전파(Propagation) 전략에 대하여 다뤄보겠습니다.

👻 1. 롤백의 배신: Unchecked Exception vs Checked Exception

실무 개발자들이 가장 많이 하는 실수 1위입니다. @Transactional을 붙여놓고 예외(Exception)가 터졌는데도 롤백이 안 되어 데이터가 꼬이는 현상입니다.

스프링의 트랜잭션은 RuntimeException(Unchecked Exception)Error가 발생했을 때만 자동 롤백을 수행합니다. 만약 파일 처리 중 발생하는 IOException이나 SQLException 같은 Checked Exception이 터지면, 스프링은 "어? 개발자가 의도한 에러인가 보네?" 하고 그냥 커밋(Commit)을 갈겨버립니다!

@Service
public class TransferService {

    // ❌ 최악의 코드: Checked Exception 발생 시 롤백 안 됨! A의 돈만 날아감!
    @Transactional
    public void transferMoney(Long from, Long to, int amount) throws IOException {
        accountRepo.decrease(from, amount); // 1. A 돈 차감 성공 (DB 커밋 예약)
        if (true) throw new IOException("네트워크 오류 발생!"); // 2. 에러 터짐! 
        // 😱 하지만 IOException은 Checked 예외라 롤백되지 않고 1번 작업이 그대로 DB에 커밋됨!!
    }

    // 🌟 실무 표준 코드: 모든 예외(Exception.class)에 대해 무조건 롤백하라고 강제 명시!
    @Transactional(rollbackFor = Exception.class)
    public void transferMoneySafe(Long from, Long to, int amount) throws IOException {
        accountRepo.decrease(from, amount);
        if (true) throw new IOException("네트워크 오류 발생!"); // 👉 Exception.class 하위이므로 안전하게 롤백됨!
    }
}

🪞 2. AOP 프록시의 한계: 내부 호출(Self-Invocation)의 덫

스프링의 @Transactional은 객체를 감싸는 프록시(Proxy)라는 가짜 객체가 동작하는 원리입니다. 외부(Controller)에서 Service를 호출할 때 프록시가 문지기 역할을 하며 트랜잭션을 열어줍니다.

그런데, 같은 클래스 내부에 있는 다른 메서드를 직접 호출(this.method())하면 어떻게 될까요? 프록시 문지기를 거치지 않고 클래스 내부에서 직통으로 넘어가버리기 때문에 트랜잭션이 아예 작동하지 않습니다! (이것도 실무 면접 단골 질문입니다.)

@Service
public class OrderService {

    // 1. 컨트롤러가 호출하는 시작점 (트랜잭션 없음)
    public void placeOrder() {
        // 내부 메서드를 호출합니다 (this.saveOrder() 와 동일)
        saveOrder(); 
    }

    // 2. 트랜잭션을 걸어둔 핵심 메서드
    @Transactional
    public void saveOrder() {
        // 😱 큰일 났습니다! 외부에서 프록시를 타고 들어온 게 아니라 내부에서 직통으로 호출했으므로,
        // @Transactional 어노테이션이 완벽하게 무시되고 트랜잭션 없이 쿼리가 실행됩니다!
        orderRepo.save(new Order());
    }
}

해결책: 트랜잭션이 필요한 메서드는 반드시 다른 클래스로 분리하거나, 부득이하다면 자기 자신(Self)을 의존성 주입받아서 호출해야 프록시를 정상적으로 탈 수 있습니다.


🚂 3. 트랜잭션 전파(Propagation) 속성: 롤백의 연대 책임

트랜잭션 A가 진행 중인데, 그 안에서 트랜잭션 B를 또 호출하면 어떻게 될까요? "기존 트랜잭션에 합류할 것인가, 아니면 아예 새로운 독립된 트랜잭션을 팔 것인가?"를 결정하는 것이 전파(Propagation) 속성입니다.

  • REQUIRED (기본값): 실무의 99%를 차지합니다. 이미 진행 중인 트랜잭션이 있으면 거기에 '합류'하고, 없으면 새로 만듭니다. 합류한 경우 B에서 에러가 터지면 A까지 전체가 다 롤백되는 연대 책임을 집니다.
  • REQUIRES_NEW: 항상 무조건 새로운 '독립된' 트랜잭션을 새로 엽니다. A가 돌다가 B를 만나면 A를 멈춰두고 B 트랜잭션을 엽니다. B에서 에러가 터져도 B만 롤백되고 A는 무사히 진행(커밋)됩니다. (예: 결제가 실패하더라도, '결제 실패 로그'는 반드시 DB에 저장(커밋)해야 할 때 로그 저장 메서드에 이 속성을 줍니다.)

🎯 4. 마무리 및 다음 단계

지금까지 데이터베이스의 무결성을 지키는 수호신이지만, Checked 예외와 내부 호출(Self-Invocation)이라는 무시무시한 함정을 숨기고 있는 @Transactional의 프록시 원리와 전파(Propagation) 속성 제어법에 대해 다루어 보았습니다. 이제 여러분은 돈이 날아가고 데이터가 꼬이는 최악의 대참사를 완벽히 방어할 수 있습니다.

트랜잭션은 철저하게 동기식(Synchronous)으로 동작합니다. 로직 하나가 끝날 때까지 유저는 화면 앞에서 멍하니 기다려야 하죠. 만약 회원가입이 완료된 직후 '축하 이메일 보내기' 기능이 추가되었는데, 메일 서버가 느려서 전송에 5초가 걸린다면? 유저는 회원가입 버튼을 누르고 5초 동안 멈춘 브라우저를 보며 이탈해버릴 것입니다. 이런 무거운 작업은 "일단 가입 완료 띄워주고, 메일 발송은 뒷단에서 조용히 몰래 처리해라!"라고 지시해야 합니다. 이어지는 18단계 포스팅에서는 "유저를 기다리게 하지 마라! 메인 쓰레드의 짐을 덜어주는 비동기 프로그래밍의 마법!" Spring Boot @Async 설정과 스케줄링(@Scheduled) 백그라운드 처리 실무 완벽 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!

반응형