Engineer Information Processing study materials
Engineer Information Processing study resources

Spring 트랜잭션 self-invocation과 감사 로그

Engineer Information Processing · Put your knowledge to work in context.

All case studies

Spring 트랜잭션 self-invocation과 감사 로그

주문 서비스는 Spring Boot 3, Java 17, Spring AOP 기반 @Transactional을 사용한다. OrderService.createOrder()는 @Transactional이고, 같은 클래스의 private 메서드 saveAuditLog()를 호출한다. saveAuditLog()에는 @Transactional(propagation = REQUIRES_NEW)를 붙여 결제 실패 시에도 감사 로그를 별도 트랜잭션으로 남기려 했다. 그런데 결제 승인 API가 RuntimeException을 던지면 주문과 재고는 롤백되지만 감사 로그도 함께 사라진다. 하루 5,000건 중 실패 12건에서 감사 로그가 누락되었고, 팀은 private 메서드에 @Transactional이 적용되지 않는 이유를 이해하지 못한 채 메서드를 public으로 바꾸고 self-invocation을 그대로 두었다. 또한 감사 로그 저장이 외부 API 300ms를 기다리는 동안 주문 트랜잭션이 DB 커넥션을 오래 잡아 커넥션 풀 20개가 고갈되는 현상도 관측되었다. 요구사항은 주문 실패와 무관하게 감사 로그를 반드시 남기되, 주문 응답 시간을 500ms 이내로 유지하는 것이다.

1. private 메서드와 self-invocation에서 @Transactional(REQUIRES_NEW)이 적용되지 않는 이유를 Spring AOP 프록시 관점에서 설명하시오.

Read the worked answer

Spring의 선언적 트랜잭션은 빈을 프록시로 감싸고, 외부에서 프록시를 통해 호출될 때 트랜잭션 인터셉터를 적용한다. OrderService 내부에서 this.saveAuditLog()를 호출하면 프록시를 거치지 않고 실제 객체의 메서드를 직접 호출하므로 인터셉터가 실행되지 않는다. private 메서드는 CGLIB/JDK 프록시의 대상이 되기 어려워 @Transactional이 붙어도 적용되지 않는다.

따라서 public으로 바꿔도 self-invocation이면 REQUIRES_NEW는 발동하지 않고 기존 주문 트랜잭션에 참여한다. 결제 예외로 주문 트랜잭션이 롤백되면 감사 로그 INSERT도 같은 트랜잭션에서 함께 롤백된다. 해결의 핵심은 private/public 변경이 아니라 프록시를 통과하는 별도 빈으로 분리하는 것이다.

2. 감사 로그를 주문 실패와 무관하게 남기고 주문 응답 500ms를 지키는 리팩터링 방안을 2가지 제시하고, 각각의 트랜잭션 경계와 실패 시나리오를 설명하시오.

Read the worked answer

방안 1은 AuditService를 별도 빈으로 분리하고 @Transactional(propagation=REQUIRES_NEW) 메서드를 외부 호출하는 것이다. OrderService가 결제 예외를 잡거나 다시 던지기 전에 auditService.record()를 호출하면, REQUIRES_NEW가 기존 트랜잭션을 suspend하고 새 트랜잭션을 연다. 감사 로그는 독립 커밋되므로 주문 롤백과 무관하다. 단, 외부 API 300ms를 이 안에서 기다리면 커넥션을 오래 잡으므로 감사 테이블 INSERT만 하고 외부 전송은 비동기로 분리한다.

방안 2는 transactional outbox를 사용한다. 별도 트랜잭션으로 audit_outbox에 주문 실패 이벤트를 빠르게 INSERT하고, 별도 워커가 외부 API로 전송한다. 또는 @TransactionalEventListener를 별도 빈에 두고 AFTER_ROLLBACK 단계에서 REQUIRES_NEW로 기록할 수 있다. 이때도 self-invocation이 아니어야 하며, 이벤트 리스너가 예외를 삼키면 감사 누락을 막을 수 없다. 감사 저장 자체가 실패할 때 fail-open인지 fail-closed인지 요구사항으로 정해야 한다.

3. 이 수정을 검증하는 통합 테스트를 어떻게 작성해야 하는가? 단위 테스트와의 차이, 확인할 DB 상태를 포함하시오.

Read the worked answer

단위 테스트에서 OrderService를 직접 new 하거나 Mockito mock으로 호출하면 프록시가 없어 self-invocation 문제를 재현하지 못한다. @SpringBootTest로 실제 빈과 프록시를 주입하고, 결제 API가 RuntimeException을 던하도록 stub한 뒤 orderService.createOrder()를 호출한다.

검증할 상태는 ① 주문 테이블에 행이 없음, ② 재고가 원복됨, ③ 감사 로그 테이블에 실패 이벤트가 1건 존재함이다. 테스트 자체가 @Transactional로 묶이면 REQUIRES_NEW가 테스트 트랜잭션을 suspend하므로 커밋 결과를 보기 어렵다. 테스트는 NOT_SUPPORTED로 실행하거나 TransactionTemplate으로 호출하고, 별도 트랜잭션에서 감사 로그를 조회해 커밋 여부를 확인한다. 외부 API가 동기 호출되지 않아 주문 응답이 500ms 이내인지도 함께 측정한다.