DB 제약과 충돌 처리
95분 안팎
학습 목표
DB 제약을 정합성의 경계로 삼고 충돌을 변환합니다.
개념
트랜잭션만으로 겹침이 사라지지는 않습니다
앞 모듈은 이력 INSERT와 현재 대여 INSERT를 하나의 트랜잭션으로 묶었습니다. 하지만 현재 대여가 없는지 SELECT한 결과는 다음 INSERT까지 예약 권리가 되지 않습니다. 두 트랜잭션이 각각 빈 결과를 읽을 수 있습니다. 조회는 친절한 조기 거절이며 최종 정합성은 active_loans.book_id의 PRIMARY KEY가 지킵니다. 이번 목표는 두 회원이 실제로 겹쳐 진입할 때 성공 하나와 충돌 하나만 남는 것을 저장 결과로 설명하는 것입니다.
도서 한 권과 현재 행 하나를 연결합니다
active_loans는 현재 점유를 나타냅니다. book_id를 PK로 삼으면 같은 도서 ID를 가진 현재 행 두 개를 저장할 수 없습니다. loan_id의 UNIQUE는 하나의 대여 이력을 여러 현재 행이 공유하지 못하게 합니다. 이력의 (id, book_id)와 연결한 복합 외래키는 다른 책의 이력을 현재 행으로 연결하는 오류를 막습니다. 서로 다른 제약이 각각 다른 불변식을 보호하므로 충돌을 피하려고 제약을 삭제하는 것은 해결책이 아닙니다.
테스트는 두 이력을 먼저 만듭니다
distinctMembersRaceRollsBackLoser는 회원 1과 회원 2가 같은 책 7을 빌리도록 두 작업을 만듭니다. LoanFault의 after-history 지점에서 CountDownLatch를 기다리게 합니다. 두 이력 INSERT가 끝났지만 아직 어느 요청도 현재 대여를 INSERT하지 않은 상태를 만듭니다. 이 지점은 두 요청이 조기 조회를 모두 통과했다는 뜻입니다. latch를 풀면 두 INSERT가 PK를 경쟁하고 패배 요청은 충돌 예외를 받습니다. 테스트용 대기 지점은 실행 빈의 일반 요청에서는 아무 일도 하지 않습니다.
성공과 패배를 같이 수집합니다
competing은 borrow가 정상 반환하면 201, LoanTransactions.Conflict이면 409를 돌려줍니다. 결과를 정렬한 뒤 [201, 409]와 비교하여 어느 회원이 먼저 이기는지에 의존하지 않습니다. 그 다음 loans와 active_loans가 각각 1행인지 조회합니다. 성공한 요청의 ID와 현재 행의 연결은 앞 모듈의 회귀 검사도 유지합니다. 응답 두 개만 맞아도 패배자의 이력이 남아 있다면 잘못된 구현입니다. 응답 계약과 저장 불변식을 따로 검사합니다.
충돌은 변경 전체를 취소해야 합니다
LoanTransactions.borrow는 DuplicateKeyException을 잡아 Conflict라는 RuntimeException으로 경계 밖에 전파합니다. Spring의 서비스 프록시가 이 실패를 보고 해당 트랜잭션을 롤백합니다. 두 테이블 변경을 묶었기 때문에 패배 요청이 이미 넣었던 이력도 취소됩니다. PK가 현재 행을 지키는 책임과 서비스 트랜잭션이 패배 이력을 없애는 책임은 함께 필요합니다. PK 하나가 앞서 다른 테이블에 커밋한 행까지 자동으로 지우는 것은 아닙니다.
starter가 보여 주는 부분 실패
이 실습 starter는 borrow의 트랜잭션 애노테이션을 빼 놓았습니다. JDBC 호출이 각각 종료되므로 패배 요청의 이력이 남습니다. 동시에 현재 대여 INSERT를 시도하면 여전히 PK가 한쪽을 거절하지만 이력 수는 2가 될 수 있습니다. TODO에 @Transactional("loanTransactionManager")를 복원합니다. 이미 import되어 있는 Spring 애노테이션을 사용합니다. 테스트 클래스에 트랜잭션을 붙여 문제를 가리거나 catch에서 이력을 수동 삭제하는 방식으로 우회하지 않습니다.
프록시를 통과하는 호출인지 확인합니다
테스트는 컨테이너가 주입한 LoanTransactions 빈을 사용합니다. 직접 new로 만든 객체의 애노테이션은 컨테이너의 트랜잭션 가로채기를 받지 않습니다. 같은 객체 내부에서 자신을 부르는 호출 역시 프록시 경계와 다를 수 있습니다. 누적 검사 serviceIsSpringProxy는 빈이 실제 프록시인지 확인합니다. 애노테이션 문법이 맞다는 사실과 두 JDBC 호출이 같은 트랜잭션에 속한다는 사실을 구별합니다. 매니저 이름은 실습의 accessJdbc 데이터 소스를 사용하는 loanTransactionManager입니다.
변환 범위를 좁게 유지합니다
현재 대여 INSERT의 중복키는 이미 대여된 상태의 경쟁이므로 LOAN_CONFLICT 409로 응답합니다. 모든 DataIntegrityViolationException을 같은 충돌로 바꾸면 없는 회원 외래키와 스키마 오류도 사용자 충돌로 숨길 수 있습니다. 이번 코드는 DuplicateKeyException만 해당 구간에서 변환합니다. 전역 unexpected 처리는 내부 오류 계약을 유지합니다. 예외 메시지에 담길 수 있는 SQL과 연결 정보를 응답에 그대로 넣지 않습니다. 실패 원인의 종류를 보존하는 것이 다음 조사에 도움이 됩니다.
패배자를 다시 빌리게 하지 않습니다
현재 대여 PK 충돌은 업무 상태가 달라졌다는 실패입니다. 같은 책을 자동으로 반복 INSERT한다고 곧 성공하는 것은 아닙니다. 성공한 회원이 책을 점유하고 있으므로 패배 요청은 409로 끝납니다. 잠금 시간 초과나 통신 문제와 대여 충돌을 구분합니다. 이 레슨은 모든 DB의 교착상태 재시도 전략을 구현하지 않습니다. 다음 레슨의 같은 요청 재생은 패배한 새 대여 요청을 계속 실행하는 전략과 목적이 다릅니다.
숫자 빈칸을 데이터 손실로 오해하지 않습니다
loan_ids 시퀀스는 두 요청에 서로 다른 ID를 발급할 수 있습니다. 패배 트랜잭션이 롤백되어도 시퀀스 값이 연속으로 되돌아온다고 기대하지 않습니다. 합격 기준은 ID가 붙어 있는 저장 행의 관계와 개수입니다. 1000 다음 이력이 1002여도 대여 1건의 누락이라고 단정하지 않습니다. 이력 2행과 현재 행 1행이라는 실패를 발견하려면 시퀀스 최대값이 아니라 SELECT COUNT(*)로 실제 행을 셉니다.
오류가 멈춘 위치를 읽습니다
expected 1 but was 2가 loans 개수 검사에서 나오면 패배자의 INSERT가 취소되지 않은 것입니다. 애노테이션과 호출 경계를 확인합니다. gate timeout은 두 요청이 after-history까지 오지 못했다는 신호입니다. 한 작업만 호출했는지, 같은 트랜잭션이나 한 연결로 모든 작업을 묶었는지 살핍니다. DuplicateKeyException이 Future 원인으로 직접 나오면 Conflict 변환 위치를 확인합니다. 단순히 예외를 모두 삼키고 409 숫자를 반환하면 저장 결과 검사가 결함을 발견합니다.
검증 범위를 제출물에 적습니다
메모리 H2에서 실제 별도 연결과 서비스 트랜잭션을 사용하여 동시 성공 수와 롤백 결과를 관찰했습니다. 운영 DB의 잠금 대기 시간과 격리 설정까지 검증한 것은 아닙니다. 다른 DB로 옮길 때 같은 시나리오를 그 DB에서도 실행해야 합니다. 제출에는 복원한 애노테이션, 두 요청 결과, 이력과 현재 행 수, 왜 조회가 최종 보호가 아닌지의 설명을 포함합니다. 격리 수준과 회복의 전체 이론은 더 읽기로 보내고 이번 코드는 현재 대여 제약의 효력을 직접 확인합니다.
따라하기
실패한 검사를 찾습니다
starter에서 실행합니다. distinctMembersRaceRollsBackLoser의 이력 수 비교를 읽습니다.
./mvnw testTODO의 경계를 복원합니다
LoanTransactions.borrow 위 TODO를 @Transactional("loanTransactionManager")로 바꿉니다. 이력과 현재 행 INSERT 본문은 보존합니다.
수정 후 전체 검사를 실행합니다
트랜잭션 경계를 복원하고 전체 회귀를 돌립니다. 한 패배 요청이 남긴 이력까지 취소되는지 동시 검사와 중간 실패 검사를 함께 읽습니다.
./mvnw test누적 결과를 집계합니다
DB 제약 solution의 전체 실행을 report.py로 집계합니다. 패배 롤백 검사 1개와 기존 회귀 142개가 포함된 실제 실행 합계입니다.
python3 report.py실행 결과
Tests=143 Failures=0 Errors=0 Skipped=0
확인 문제
실습
LoanTransactions.borrow 위 TODO를 @Transactional("loanTransactionManager")로 바꿉니다. 이력과 현재 행 INSERT 본문은 보존합니다. 테스트는 삭제하거나 완화하지 않습니다. 정상·경계·실패 결과와 작업 종료를 확인하여 수정 코드와 실행 보고서를 제출합니다.
실행 명령
./mvnw test
기대 결과
누적 143개 검사, Failures=0, Errors=0, Skipped=0.
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 대여 처리의 트랜잭션 범위를 설명합니다.