트랜잭션 롤백이 왜 안 되는지 모르는 개발자에게
by 그릿 | GROWTH_ESSAY | 2026-11-08
#백엔드 #트랜잭션 #트러블슈팅분명 코드에서 에러가 발생했는데 데이터베이스에는 엉뚱한 데이터가 버젓이 저장되어 있습니다.
주문 처리는 실패했는데 포인트는 이미 차감되었고, 결제 상태는 완료인데 배송 정보는 생성되지 않았습니다. 메서드 위에 분명히 @Transactional을 달아두었는데 왜 롤백되지 않고 그대로 커밋되었을까요?
예외를 삼키면 프레임워크도 속습니다
많은 개발자가 try-catch 블록을 습관처럼 씁니다. 문제는 catch 블록 안에서 에러 로그만 한 줄 찍고 아무런 예외를 다시 던지지 않는다는 점입니다.
스프링의 트랜잭션 AOP는 메서드 밖으로 예외가 던져지는 순간을 감지해 롤백을 지시합니다. 메서드 내부에서 예외를 잡아 삼켜버리면 프레임워크는 이 작업이 정상 종료되었다고 판단합니다.
200명이 넘는 백엔드 멘티들의 코드를 열어보면 열 중 셋은 이 실수로 데이터 정합성을 깨뜨립니다. 로그에 에러 스택트레이스가 선명하게 남아 있어도 데이터베이스는 이미 커밋을 마친 뒤입니다.
Checked 예외는 기본적으로 롤백되지 않습니다
또 하나의 치명적인 착각이 있습니다. 예외가 터지면 무조건 자동으로 롤백될 것이라는 믿음입니다.
스프링의 @Transactional은 기본 설정에서 언체크 예외인 RuntimeException과 Error만 롤백 대상으로 삼습니다. 직접 정의한 비즈니스 예외가 Exception을 상속받고 있다면 어떻게 될까요?
잔액 부족 예외가 던져져도 트랜잭션은 롤백되지 않고 그대로 커밋을 진행합니다. 이 동작 방식을 모른 채 실무에 투입된 1년 차 주니어가 롤백 누락 사고를 내고 며칠 동안 DB 데이터를 수동 복구하는 광경을 수없이 보았습니다. 체크 예외까지 롤백시키려면 rollbackFor 속성을 명시해야 합니다.
같은 클래스 내부 호출은 프록시를 건너뜁니다
같은 클래스 안에 있는 메서드를 this.updateOrder()처럼 직접 호출하는 패턴도 대표적인 함정입니다.
스프링 트랜잭션은 빈을 감싼 프록시 객체가 시작과 커밋, 롤백을 대신 처리합니다. 외부에서 빈을 통해 호출하지 않고 클래스 내부에서 메서드를 직접 호출하면 프록시를 완전히 우회합니다.
어노테이션이 붙어 있어도 트랜잭션 자체가 시작조차 되지 않습니다. 롤백이 안 되는 것이 아니라, 애초에 롤백할 트랜잭션 자체가 존재하지 않았던 셈입니다.
데이터 정합성은 어노테이션 하나 붙인다고 거저 주어지지 않습니다. 프레임워크가 트랜잭션을 어떻게 감싸고 언제 롤백 마커를 찍는지 모르면, 코드는 언젠가 프로덕션에서 거짓말을 합니다.
예외를 어떻게 전파할 것인지, 트랜잭션의 경계가 어디까지인지 선명하게 통제할 수 있어야 진짜 백엔드 엔지니어입니다.