@Transactional은 어노테이션 한 줄로 트랜잭션이 자동 적용되는 듯 보이지만, 실제 운영에서는 롤백이 동작하지 않거나, 트랜잭션 자체가 적용되지 않는 함정이 적지 않습니다. 코드 리뷰에서는 통과하고 단위 테스트에서는 잘 돌아가는데, 운영 환경에서만 데이터가 일관성 없이 남는 경우가 대부분입니다.
이 글에서는 @Transactional 자주 틀리는 5가지 함정을 가상의 실무 시나리오와 함께 정리합니다. Self-invocation, 체크 예외, try-catch로 인한 롤백 누락, AOP 미적용 메서드, Propagation 오용까지 한 번씩 점검해 보시기 바랍니다.
@Transactional이 자주 틀리는 근본 이유
@Transactional은 Spring AOP 기반으로 동작합니다. Spring이 빈을 등록할 때 해당 클래스를 감싸는 프록시 객체를 만들고, 외부에서 메서드를 호출할 때 프록시가 트랜잭션을 열고 닫는 구조입니다.
이 구조 때문에 다음 조건이 충족되지 않으면 트랜잭션이 적용되지 않습니다.
| 조건 | 충족되지 않을 때 발생하는 문제 |
|---|---|
| 외부에서 호출되어야 함 | Self-invocation은 프록시를 우회 |
RuntimeException/Error 계열 예외 | 체크 예외는 기본적으로 롤백되지 않음 |
| 예외가 메서드 밖으로 나가야 함 | try-catch로 잡으면 롤백 마커 안 찍힘 |
public 메서드여야 함 | private·final·static은 프록시 대상 아님 |
| 적절한 Propagation 선택 | REQUIRES_NEW 등 잘못 쓰면 데드락 |
이 다섯 가지가 바로 이어서 다룰 5가지 함정입니다.
함정 1: Self-invocation 같은 클래스 메서드 호출은 프록시를 우회한다
회원가입 서비스에서 다음과 같은 코드가 있다고 가정합니다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final EmailService emailService;
public void register(MemberRequest request) {
Member saved = memberRepository.save(Member.from(request));
// 같은 클래스의 메서드를 직접 호출
this.sendWelcomeEmail(saved);
}
@Transactional
public void sendWelcomeEmail(Member member) {
emailService.send(member.getEmail(), "환영합니다");
memberRepository.updateEmailSentFlag(member.getId(), true);
}
}
겉으로는 sendWelcomeEmail()에 @Transactional이 붙어 있으니 트랜잭션이 적용될 것처럼 보입니다. 그러나 실제로는 적용되지 않습니다. this.sendWelcomeEmail()은 Spring 프록시가 아니라 객체 자신의 메서드를 직접 호출하기 때문입니다.
증상
이메일 발송 중간에 예외가 발생해도 updateEmailSentFlag가 호출된 부분만 부분적으로 커밋되거나, 반대로 전혀 트랜잭션 경계 없이 실행되어 데이터 정합성이 깨집니다. 단위 테스트에서는 별 문제 없어 보이지만, 운영에서 “이메일 보냈다고 표시되어 있는데 실제로 안 보낸 사용자”가 발생합니다.
해결법
가장 깔끔한 해결법은 트랜잭션이 필요한 메서드를 별도 클래스로 분리하는 것입니다.
@Service
@RequiredArgsConstructor
public class WelcomeEmailService {
private final EmailService emailService;
private final MemberRepository memberRepository;
@Transactional
public void send(Member member) {
emailService.send(member.getEmail(), "환영합니다");
memberRepository.updateEmailSentFlag(member.getId(), true);
}
}
이렇게 하면 외부에서 프록시를 통한 호출이 보장되어 트랜잭션이 정상 적용됩니다. 같은 클래스에서 해결해야 한다면 AopContext.currentProxy()나 자기 자신 주입(self-injection)을 쓰는 방법도 있지만, 클래스 분리가 가독성과 유지보수성 모두에서 가장 우수합니다.
함정 2: 체크 예외는 기본 롤백 대상이 아니다
파일을 받아 저장한 뒤 메타 정보를 DB에 기록하는 다음 코드를 가정합니다.
@Service
@RequiredArgsConstructor
public class FileUploadService {
private final FileRepository fileRepository;
@Transactional
public void upload(MultipartFile file) throws IOException {
FileMeta meta = fileRepository.save(FileMeta.of(file));
Path target = Paths.get("/data/uploads", meta.getStoredName());
Files.copy(file.getInputStream(), target); // IOException 가능
}
}
Files.copy에서 디스크 가득 참, 권한 부족 등으로 IOException이 발생하면 DB에는 FileMeta가 남고 실제 파일은 없는 상태가 됩니다. 직관과 다르게 트랜잭션이 롤백되지 않습니다.
증상
DB에는 파일 메타 정보가 정상적으로 들어가 있지만, 실제 파일 시스템에는 파일이 존재하지 않는 고아 데이터(orphan record) 가 누적됩니다. 며칠 뒤 다운로드 요청에서 404가 발생하고 나서야 원인이 드러납니다.
원인
Spring의 @Transactional은 기본적으로 RuntimeException과 Error만 롤백 대상으로 삼습니다. IOException 같은 체크 예외는 메서드 시그니처에 선언되어 있더라도 롤백 대상이 아닙니다.
해결법
rollbackFor 속성에 명시적으로 체크 예외를 지정하거나, 더 안전하게 모든 예외(Exception.class)에 대해 롤백하도록 설정합니다.
@Transactional(rollbackFor = Exception.class)
public void upload(MultipartFile file) throws IOException {
// ... 같은 코드
}
팀 차원에서 정책을 통일하고 싶다면, 공통 메타 어노테이션을 만들어 사용하는 것도 좋은 방법입니다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Transactional(rollbackFor = Exception.class)
public @interface SafeTransactional {}
함정 3: try-catch로 예외를 삼키면 롤백이 사라진다
주문 등록과 동시에 재고를 차감하는 다음 코드를 가정합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final StockService stockService;
@Transactional
public void placeOrder(OrderRequest request) {
Order order = orderRepository.save(Order.from(request));
try {
stockService.decrease(request.getProductId(), request.getQuantity());
} catch (Exception e) {
log.warn("재고 차감 실패, 주문은 진행", e);
}
}
}
“재고 차감이 실패해도 주문은 진행하자”는 의도로 try-catch를 둔 코드입니다. 그러나 결과는 주문은 들어갔는데 재고는 차감되지 않은 데이터 정합성 깨짐 상태입니다.
원인
Spring의 트랜잭션 롤백은 메서드 밖으로 빠져나가는 예외를 감지해 동작합니다. try-catch로 예외를 잡아 흡수하면 프록시는 “정상 종료”로 간주하고 커밋합니다.
해결법
선택지는 두 가지입니다.
첫째, 부분 실패를 허용하지 않을 거라면 catch를 제거하고 예외가 밖으로 나가도록 둡니다.
@Transactional
public void placeOrder(OrderRequest request) {
Order order = orderRepository.save(Order.from(request));
stockService.decrease(request.getProductId(), request.getQuantity());
}
둘째, catch는 유지하되 명시적으로 롤백 마커를 찍습니다.
try {
stockService.decrease(request.getProductId(), request.getQuantity());
} catch (Exception e) {
log.warn("재고 차감 실패", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
진짜로 “주문은 살리고 재고만 별도로 처리”하고 싶다면, 재고 차감을 별도 트랜잭션(REQUIRES_NEW)으로 분리하는 설계로 가야 합니다. 다음 함정에서 이어 설명합니다.
함정 4: private·final 메서드에는 @Transactional이 적용되지 않는다
내부 헬퍼 메서드를 만들면서 무심코 @Transactional을 붙이는 경우가 있습니다.
@Service
public class PaymentService {
public void pay(PaymentRequest request) {
validatePayment(request);
processPayment(request);
}
@Transactional
private void processPayment(PaymentRequest request) {
// 결제 처리 + DB 기록
}
}
processPayment는 컴파일도 잘 되고 빌드도 통과합니다. 그러나 트랜잭션은 적용되지 않습니다.
원인
Spring AOP 프록시는 기본적으로 CGLIB 또는 JDK 동적 프록시로 생성되며, private·final·static 메서드는 오버라이드가 불가능해 프록시가 가로챌 수 없습니다. 따라서 이런 메서드에 붙은 @Transactional은 조용히 무시됩니다.
해결법
세 가지 원칙을 지킵니다. 트랜잭션이 필요한 메서드는 public으로 선언하고, final 키워드를 붙이지 않으며, 외부에서 호출되도록 클래스 경계를 통해 노출합니다.
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentProcessor paymentProcessor;
public void pay(PaymentRequest request) {
validatePayment(request);
paymentProcessor.process(request); // 다른 빈의 public 메서드 호출
}
}
@Service
public class PaymentProcessor {
@Transactional
public void process(PaymentRequest request) {
// 트랜잭션 적용됨
}
}
IDE에서 private 메서드 위에 @Transactional이 붙어 있다면 경고가 표시되도록 정적 분석 도구(SonarQube 등)를 켜 두는 것도 예방 효과가 큽니다.
함정 5: 트랜잭션 분리 판단을 잘못하면 양쪽 모두 무너진다
Propagation은 트랜잭션 함정 중에서도 가장 까다로운 영역입니다. 분리하지 않아야 할 것을 분리하면 데드락이 나고, 분리해야 할 것을 분리하지 않으면 부수 로직이 본 API를 죽입니다. 두 사례를 함께 봐야 균형 잡힌 판단이 가능합니다.
5-A. 분리하지 않아서 로그가 본 API를 죽인 사례
실제 운영 중 다음과 같은 구성을 가정합니다. 모든 컨트롤러 호출을 AOP로 가로채어 요청·응답을 DB의 공통 로그 테이블에 INSERT해 두는 구조입니다.
@Aspect
@Component
@RequiredArgsConstructor
public class ApiAccessLogAspect {
private final ApiLogRepository apiLogRepository;
@Around("@within(org.springframework.web.bind.annotation.RestController)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
String request = serialize(joinPoint.getArgs());
Object result = joinPoint.proceed();
String response = truncate(serialize(result), 60_000); // 응답은 절단
apiLogRepository.save(ApiLog.of(request, response)); // 요청은 무절단
return result;
}
}
응답은 얼마나 커질지 예측이 어려워 60,000자 절단(truncate) 을 걸어 두었습니다. 반면 요청은 클라이언트가 합리적인 크기로 보낼 것이라 가정하고 별도 제한을 두지 않았습니다.
증상
평소에는 문제없이 작동하다가, 어느 날 외부 시스템이 수 MB짜리 요청 본문을 통째로 밀어 넣었습니다. 그 순간 다음이 발생했습니다.
Value length(4549587) exceeds the maximum size(65534) 같은 메시지와 함께 로그 INSERT가 실패했고, AOP가 본 메서드와 동일한 트랜잭션 안에서 동작했기 때문에 부모 트랜잭션까지 롤백되어 본 API도 함께 실패했습니다. 본 비즈니스 로직 자체는 아무 문제가 없었는데도 말입니다. 결국 “로그를 남기려다 로그가 본 서비스를 죽인” 사고가 된 것입니다.
원인
두 가지 실수가 맞물렸습니다.
첫째, 요청 길이에 대한 절단 정책이 없었습니다. 응답에만 truncate를 적용했는데, 실제로는 요청도 얼마든지 커질 수 있습니다. 외부 시스템·클라이언트·악의적 호출 모두 큰 페이로드를 보낼 수 있습니다.
둘째, 로그 저장이 본 트랜잭션과 같은 경계 안에 있었습니다. AOP가 본 메서드를 감싸고 있고, 본 메서드가 @Transactional을 갖고 있으면, AOP에서 던지는 예외도 같은 트랜잭션을 흔듭니다. 부가 기능인 로그가 본 기능의 성공·실패를 결정하는 구조입니다.
해결법
해결은 두 축으로 동시에 진행해야 합니다.
(1) 요청·응답 양쪽 모두 길이 제한을 적용합니다.
String request = truncate(serialize(joinPoint.getArgs()), 60_000);
String response = truncate(serialize(result), 60_000);
DB 컬럼 최대 길이보다 작은 임계치로 절단하고, 잘렸음을 표시하는 마커(예: "...[truncated]")를 함께 남겨 추적 가능성을 유지합니다.
(2) 로그 저장을 본 트랜잭션과 분리합니다. 이 분리에는 두 가지 안전한 방법이 있습니다.
@Service
@RequiredArgsConstructor
public class ApiLogService {
private final ApiLogRepository apiLogRepository;
// 본 트랜잭션과 분리된 별도 트랜잭션
@Transactional(propagation = Propagation.REQUIRES_NEW,
noRollbackFor = Exception.class)
public void saveLog(ApiLog logEntry) {
try {
apiLogRepository.save(logEntry);
} catch (Exception e) {
// 로그 저장 실패는 본 비즈니스에 영향 없음
log.warn("API 로그 저장 실패", e);
}
}
}
REQUIRES_NEW로 분리하면 로그 INSERT가 실패해도 본 트랜잭션은 영향을 받지 않습니다. 단, 5-B에서 설명할 커넥션 풀 부담이 동반되므로 트래픽이 높은 환경이라면 다음 비동기 방식이 더 안전합니다.
@Component
@RequiredArgsConstructor
public class ApiLogListener {
private final ApiLogRepository apiLogRepository;
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onApiCall(ApiLogEvent event) {
try {
apiLogRepository.save(event.toEntity());
} catch (Exception e) {
log.warn("API 로그 저장 실패", e);
}
}
}
@TransactionalEventListener(AFTER_COMMIT)은 본 트랜잭션이 정상 커밋된 후에만 로그를 저장합니다. 로그 실패가 본 트랜잭션에 영향을 줄 수 없는 구조가 됩니다. @Async까지 함께 적용하면 본 API의 응답 시간에도 영향을 주지 않습니다.
정리
이 사례의 교훈은 두 가지입니다. 부가 기능은 본 기능의 성공을 결정해선 안 되며, 외부 입력에 대한 길이 가정은 절대 신뢰하지 말아야 합니다. 둘 다 갖춰지지 않으면 어느 날 갑자기 운영 사고로 돌아옵니다.
5-B. 반대로, 분리하지 말아야 할 것을 분리해서 데드락이 난 사례
REQUIRES_NEW는 안전해 보이지만 함정도 있습니다. 다음 코드는 결제 처리 한 건마다 결제 로그를 별도 트랜잭션으로 남기려는 의도입니다.
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentLogService paymentLogService;
@Transactional
public void pay(PaymentRequest request) {
// 결제 처리 (커넥션 1번 점유 중)
paymentLogService.writeLog(request);
}
}
@Service
public class PaymentLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeLog(PaymentRequest request) {
// INSERT INTO payment_log (커넥션 2번 추가 점유)
}
}
증상
평소에는 정상 동작합니다. 그러나 트래픽이 급증하는 시간대에 커넥션 풀 고갈로 인한 응답 지연 또는 데드락이 발생합니다. HikariCP 기본 풀 크기가 10이라면, 동시에 결제 5건만 진행돼도 커넥션 10개가 모두 점유되어 다음 요청은 대기 상태가 됩니다.
원인
REQUIRES_NEW는 부모 트랜잭션의 커넥션을 반납하지 않은 채 새 커넥션을 빌립니다. 같은 스레드가 동시에 커넥션 두 개를 점유하므로, 동시 요청 수가 풀 크기의 절반을 넘는 순간부터 데드락에 가까운 상태가 됩니다.
해결법
판단 기준은 단순합니다.
| 상황 | 권장 |
|---|---|
| 로그가 부모와 같이 커밋·롤백돼도 무방 | Propagation.REQUIRED(기본값) 유지 |
| 로그가 부모 성공·실패와 무관해야 함 | @TransactionalEventListener(AFTER_COMMIT) + @Async |
| 부모 트랜잭션 안에서 즉시 별도 커밋 필요 | REQUIRES_NEW + 풀 크기 충분히 확보 |
| 인프라 비용 감수 가능 | Kafka·RabbitMQ로 결합도 분리 |
REQUIRES_NEW는 꼭 필요할 때만, 사용 범위를 최소화하시기 바랍니다.
두 사례에서 얻는 결론
함정 5의 본질은 “분리할 것과 분리하지 않을 것을 판단하는 능력” 입니다. 부가 기능(로그·감사·알림 등)은 본 트랜잭션과 분리하고, 비즈니스 정합성이 중요한 작업(결제·재고·포인트)은 같은 트랜잭션 안에 두는 것이 일반 원칙입니다.
운영 체크리스트
신규 코드 작성과 코드 리뷰 단계에서 다음을 점검하시기 바랍니다.
첫째, 같은 클래스 내에서 this.method() 형태로 @Transactional 메서드를 호출하지 않는지 확인합니다.
둘째, 체크 예외를 던지는 메서드에는 rollbackFor = Exception.class 또는 공통 메타 어노테이션이 적용되어 있는지 확인합니다.
셋째, 트랜잭션 메서드 내부의 try-catch가 예외를 삼키는지 확인하고, 필요 시 setRollbackOnly()를 명시합니다.
넷째, @Transactional이 private·final 메서드에 붙어 있지 않은지 확인합니다. 정적 분석 도구로 자동 검출이 가능합니다.
다섯째, 부가 기능(로그·감사·알림)이 본 트랜잭션과 같은 경계 안에 있는지 확인합니다. 같이 묶여 있다면 부가 기능 실패가 본 API 실패로 이어집니다. @TransactionalEventListener(AFTER_COMMIT) 또는 REQUIRES_NEW로 분리하시기 바랍니다.
여섯째, 로그·감사 테이블에 저장하는 모든 외부 입력은 길이 절단(truncate) 정책이 적용되어 있는지 확인합니다. 요청과 응답 양쪽 모두 점검 대상이며, “request는 클 일 없겠지”라는 가정은 운영 사고의 출발점입니다.
일곱째, REQUIRES_NEW 사용 시 커넥션 풀 크기와 동시 요청 수가 안전한지 검증합니다. 가능하면 비동기 이벤트나 메시지 큐로 대체합니다.
여덟째, AOP 프록시 동작 확인을 위한 통합 테스트를 작성합니다. 단위 테스트는 @Transactional 함정을 잘 잡지 못하므로, @SpringBootTest 기반의 검증이 필요합니다.
자주 묻는 질문 (FAQ)
Q1. 단위 테스트는 통과하는데 운영에서만 트랜잭션이 깨지는 이유는?
단위 테스트는 보통 Mock 기반이라 실제 AOP 프록시가 동작하지 않습니다. Self-invocation, private 메서드 적용 같은 함정은 단위 테스트에서 검출되지 않습니다. @SpringBootTest로 실제 컨텍스트를 띄우는 통합 테스트에서만 잡힙니다.
Q2. 모든 메서드에 @Transactional(rollbackFor = Exception.class) 붙이는 건 안전한가요?
대부분의 경우 안전합니다. 단, 재시도 가능한 비즈니스 예외를 체크 예외로 모델링한 경우(드물지만 있음) 의도치 않은 롤백이 발생할 수 있습니다. 팀의 예외 설계 규약을 함께 검토하시기 바랍니다.
Q3. @Async 메서드 안에서 @Transactional이 동작하지 않는 이유는?
@Async는 별도 스레드에서 메서드를 실행하는데, 트랜잭션 컨텍스트는 ThreadLocal에 저장되므로 새 스레드로 전파되지 않습니다. 비동기 메서드에 @Transactional을 별도로 붙이면 새 트랜잭션이 시작되지만, 이는 호출 측 트랜잭션과 분리된 별개 트랜잭션입니다.
Q4. AOP 로깅에서 length 절단은 어느 정도가 적정한가요?
로그 테이블 컬럼 한계의 80~90% 수준을 권장합니다. 예를 들어 VARCHAR(65534) 컬럼이라면 60,000자 정도가 안전합니다. 절단된 데이터는 "...[truncated, total=NNNN]" 형태의 마커를 함께 남겨, 원본 크기를 추적할 수 있도록 합니다. 본문 자체가 자주 큰 경우라면 DB 대신 파일 시스템 또는 S3 같은 객체 스토리지를 활용하는 편이 안전합니다.
Q5. CGLIB 프록시와 JDK 동적 프록시 중 어떤 게 기본인가요?
Spring Boot 2.x부터는 CGLIB 프록시가 기본입니다. 인터페이스가 없는 클래스도 프록시화할 수 있어 편리하지만, final 클래스나 final 메서드는 여전히 프록시화 불가입니다. Kotlin 사용 시 클래스·메서드가 기본 final이므로 all-open 플러그인 적용이 필요합니다.
마무리
@Transactional 자주 틀리는 5가지 함정은 단순한 문법 실수가 아니라 Spring AOP 프록시 동작 원리에서 비롯되는 구조적 함정입니다. 다섯 가지 모두 컴파일러는 잡아주지 못하고, 단위 테스트도 대부분 통과시키며, 운영에 들어가서야 데이터 정합성이 깨지면서 드러납니다.
핵심을 다시 정리하면 다음과 같습니다. Self-invocation은 클래스 분리로 해결하고, 체크 예외는 rollbackFor로 명시하며, try-catch는 setRollbackOnly()와 함께 사용합니다. @Transactional은 public 메서드에만 의미가 있고, 로그·감사 같은 부가 기능은 본 트랜잭션과 반드시 분리하되 REQUIRES_NEW 남용은 피합니다. 이 다섯 가지만 의식해도 운영 환경의 데이터 정합성 사고는 크게 줄어듭니다.
핵심 요약
- Self-invocation: 같은 클래스 메서드 호출은 프록시를 우회하므로 클래스 분리가 정답입니다.
- 체크 예외 미롤백: 기본 롤백은
RuntimeException만 대상이므로rollbackFor = Exception.class가 안전합니다.- try-catch 삼킴: catch가 예외를 흡수하면 롤백이 사라지므로
setRollbackOnly()를 명시합니다.- AOP 미적용 메서드:
@Transactional은public메서드에만 동작하며private·final은 무시됩니다.- 트랜잭션 분리 판단: 부가 기능(로그·감사·알림)은 본 트랜잭션과 반드시 분리하고, 외부 입력은 길이 절단을 적용합니다. 반대로
REQUIRES_NEW를 남용하면 풀 고갈과 데드락이 발생합니다.
“@Transactional 자주 틀리는 5가지 함정”에 대한 2개의 생각