Devin.KR

부분 실패와 롤백

90분 안팎

학습 목표

예외 전파와 Spring 프록시 경계를 확인합니다.

개념

성공 경로만으로 경계를 확인할 수 없습니다

대여가 정상적으로 두 행을 저장하는 것을 확인해도 그 두 변경이 하나의 트랜잭션이라는 증거는 부족합니다. 자동 커밋 상태에서도 성공 경로는 같은 모양으로 끝납니다. 차이가 드러나는 순간은 첫 번째 변경 뒤 두 번째 변경 전에 실패할 때입니다. 이 레슨은 이력 INSERT 직후 RuntimeException을 던지고 호출 종료 뒤 loans와 active_loans를 다시 조회합니다. 기대값은 둘 다 0이며 이력만 1이면 부분 변경이 커밋된 결함입니다.

starter는 borrow의 @Transactional을 제거한 상태입니다. 코드에는 두 INSERT와 실패 주입 지점이 모두 있어 정상 대여는 동작합니다. 먼저 실패 테스트 이름과 실제 loans 행 수를 확인한 다음 애노테이션을 복구합니다. 실패 없이 동작하는 코드에 경계를 추가하는 것보다 실패가 남긴 저장 사실을 읽는 편이 원자성의 효과를 명확히 보여 줍니다. 같은 결과가 왜 나오는지 연결 자원과 프록시 경로까지 설명해야 수정이 끝납니다.

실패 주입을 HTTP 입력에서 분리합니다

LoanFault는 check(String point) 하나를 가진 인터페이스입니다. 기본 빈은 아무 작업도 하지 않고 테스트의 @Primary 빈만 지정된 지점에서 예외를 던집니다. after-history는 이력 저장 후 현재 대여 저장 전, after-return-time은 반납 시각 갱신 후 현재 행 삭제 전입니다. 클라이언트가 fail=true를 보내 장애를 만들 수 있는 API는 제공하지 않습니다. 테스트를 위해 만든 통로가 실제 서비스의 실패 제어 권한으로 노출되지 않도록 빈의 교체 범위를 제한합니다.

실패를 주입하면 예외의 종류와 발생 위치를 고정할 수 있습니다. 네트워크를 끊거나 실행 중인 프로세스를 종료하는 방법은 필요하지 않습니다. assertThrows는 service.borrow 호출만 감싸고 준비 SQL은 바깥에서 수행합니다. fixture를 넣다가 같은 예외가 발생한 것을 서비스 실패로 오인하지 않게 하기 위해서입니다. 실패 직후 두 테이블의 행 수를 조회하고 같은 회원이 나중에 다시 정상 대여할 수 있는지도 생각해 봅니다. 테스트의 fault.point는 매번 빈 문자열로 초기화합니다.

Spring이 보는 호출 경로를 따라갑니다

애노테이션은 Java 메서드 본문을 스스로 바꾸지 않습니다. 기본 프록시 방식에서 Spring은 빈을 감싼 프록시를 만들고 외부 호출을 가로채 트랜잭션을 시작합니다. 컨트롤러가 주입받은 LoanTransactions.borrow를 호출하면 이 경계를 지납니다. 테스트도 @Autowired로 받은 서비스 빈을 사용합니다. new LoanTransactions로 객체를 만들고 같은 메서드를 실행하면 Spring 프록시를 통과하지 않아 애노테이션만으로 트랜잭션이 시작되지 않습니다.

같은 객체 안에서 this.borrow를 호출하는 자기 호출도 기본 프록시 경계를 우회합니다. 그래서 private 저장 도우미에 애노테이션을 붙이고 public 대여 메서드에서 호출하는 형태로 해결하지 않습니다. 업무 진입점인 public borrow에 경계를 두고 컨트롤러가 그 빈을 호출하게 합니다. serviceIsSpringProxy 테스트는 프록시 존재를 확인하지만 그것만으로 충분하지 않습니다. returnBook에만 애노테이션이 있어도 객체는 프록시가 될 수 있으므로 실제 borrow 실패 후 DB 검사와 함께 읽습니다.

어떤 예외가 경계를 나가는지 봅니다

이 실습의 기본 롤백 규칙은 RuntimeException과 Error입니다. 업무 충돌인 Conflict도 RuntimeException을 상속합니다. checked exception은 기본 설정에서 같은 롤백 규칙이 아니므로 필요한 경우 rollbackFor로 구체적인 타입을 지정하거나 업무 실패를 일관된 런타임 예외로 정의합니다. “모든 예외면 자동 롤백”이라고 외우면 실제 체크 예외 경로에서 틀릴 수 있습니다. 여기서는 실패 주입의 IllegalStateException으로 동작을 검증하고 checked exception 실험을 통과했다고 주장하지 않습니다. 기본 규칙은 Spring 6.0.13 Transactional API 문서에서 확인할 수 있습니다.

서비스가 IllegalStateException을 잡고 아무 일 없던 것처럼 정상 반환하면 프록시는 실패를 보지 못합니다. 그 경우 첫 변경을 취소해야 한다는 신호를 잃을 수 있습니다. 응답 계약으로 바꿀 예외는 서비스 밖의 ApiErrors에서 처리합니다. 프록시가 먼저 롤백을 마친 뒤 웹 계층이 500 INTERNAL_ERROR 응답을 만듭니다. 실패 사실과 사용자에게 보일 메시지는 다른 책임입니다. 로그에는 예외 클래스만 남기며 SQL 메시지·비밀번호·세션 토큰은 출력하지 않습니다.

테스트 자체의 트랜잭션을 피합니다

이 검증에는 테스트 메서드에 @Transactional을 붙이지 않습니다. 테스트가 이미 트랜잭션을 시작하면 서비스의 기본 전파가 그 안에 참여할 수 있어 메서드 종료 시점이 실제 요청과 달라집니다. 서비스의 경계가 빠져도 테스트의 경계가 대신 변경을 묶어 결함을 숨길 수 있습니다. 테스트 호출이 끝난 뒤 주입된 JDBC로 남은 데이터를 읽고 BeforeEach에서 직접 초기화합니다. 삭제 순서는 현재 대여, 메모, 이력, 계정, 회원, 도서처럼 외래키의 자식부터 정리합니다.

예외가 발생했다는 검사와 저장이 취소되었다는 검사는 각각 필요합니다. assertThrows 하나만 있으면 RuntimeException을 던지고 앞 행을 남기는 구현도 통과합니다. 반대로 행 수만 0인지 확인하면 서비스가 아예 아무 작업도 하지 않은 결함을 놓칠 수 있습니다. 정상 대여가 두 행을 남긴다는 테스트와 실패 대여가 두 행을 남기지 않는 테스트를 짝으로 둡니다. HTTP 실패에서도 응답이 500이고 부분 행이 없는지 검사해 서비스와 예외 처리 연결을 함께 관찰합니다.

롤백이 되돌리는 범위를 구분합니다

이번 트랜잭션은 같은 DataSource의 DB 변경을 취소합니다. 번호 발급의 시퀀스 값, 이미 보낸 메일, 다른 서버로 전송한 HTTP 요청까지 이전 상태로 되돌린다고 약속하지 않습니다. 그래서 성공 메일 전송을 이력 INSERT 직후에 끼워 넣고 롤백으로 안전해진다고 설명하지 않습니다. 이번 실습에는 외부 부수 효과가 없으며 다른 자원의 보상 처리나 이벤트 전달 설계는 범위 밖입니다. 번호에 빈칸이 생겨도 loans 행 수 0이라는 검증은 그대로 유효합니다.

Expected 0 but was 1이 loans 검사에서 나오면 실패 발생 지점, public 메서드의 애노테이션, 주입 빈 호출 여부, 트랜잭션 관리자의 DataSource를 순서대로 확인합니다. No qualifying bean 오류는 롤백 결과가 아니라 실행 준비 실패이므로 테스트를 실행했다고 집계하지 않습니다. 원자성을 고친 뒤에도 동시 요청의 조회 결과가 같을 가능성은 남습니다. 원자성과 고립성을 섞어 설명하지 않고, 이번 증거는 단일 호출의 부분 실패 취소라고 제한합니다. 격리 수준 비교는 더 읽기 장에서 살펴봅니다.

따라하기

starter의 실패를 읽습니다

롤백 starter에서 아래 명령을 실행합니다. borrowFailureRollsBackHistory가 기대한 이력 0행과 실제 1행을 대조합니다. 정상 대여가 통과해도 중간 실패는 남을 수 있습니다.

./mvnw test > test.log 2>&1

서비스 TODO를 수정합니다

borrow 위의 TODO를 애노테이션으로 교체합니다. 아래 메서드 표기는 위치 안내이며 기존 본문을 삭제하지 않습니다. import된 Spring Transactional을 사용합니다.

@Transactional("loanTransactionManager")
public int borrow(int bookId,int memberId) {
    // 기존 본문과 실패 주입 지점은 그대로 둡니다.
}

수정한 프로젝트를 다시 실행합니다

서비스 경계를 복원한 뒤 재실행합니다. 주입 빈 호출에서 예외가 전파되고 실패 뒤 이력과 현재 행이 모두 0인지 확인합니다. HTTP 500 검사도 부분 저장 여부와 함께 읽습니다.

./mvnw test > test.log 2>&1

실행 보고서를 집계합니다

롤백 solution의 실제 실행 합계입니다. 142개 중 예외 전파·프록시 존재·부분 저장 취소 검사가 포함됩니다. 실패와 오류가 모두 0인지 보고서로 검증하고, 예외 발생만 검사한 결과와 구분합니다.

python3 report.py

실행 결과

Tests=142
Failures=0
Errors=0
Skipped=0

확인 문제

실습

LoanTransactions.java의 이 레슨 TODO를 본문의 저장 규칙에 맞게 완성합니다. borrow 위의 TODO를 애노테이션으로 교체합니다. 아래 메서드 표기는 위치 안내이며 기존 본문을 삭제하지 않습니다. import된 Spring Transactional을 사용합니다. 정상·거절·중간 실패 경로와 기존 회귀 검사를 모두 보존합니다. 완료 후 ./mvnw test와 python3 report.py를 실행합니다.

시작 코드·테스트 내려받기

실행 명령

./mvnw test

기대 결과

누적 142개 검사, Failures=0, Errors=0, Skipped=0.

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 대여 처리의 트랜잭션 범위를 설명합니다.