Devin.KR

동시 재시도 검증

95분 안팎

학습 목표

동시 요청의 성공 수·이력 수를 검증합니다.

개념

재시도 테스트의 성공 기준을 바꿉니다

한 요청이 201을 반환하는 검사만으로 중복 요청 방지가 완성되지 않습니다. 동일 키의 두 호출이 동시에 진행하면 결과 ID가 같고 이력이 1행이어야 합니다. 서로 다른 회원의 같은 책 경쟁에서는 결과가 성공 1개와 충돌 1개여야 합니다. 이 두 시나리오의 기대값은 다릅니다. 모든 충돌을 재시도로 바꾸거나 모든 중복을 409로 바꾸면 한쪽 계약이 깨집니다. 이번 목표는 응답 유실, 동시 재생, 본문 변경을 서로 다른 테스트로 검증하는 것입니다.

두 요청이 기존 기록을 못 본 순간을 만듭니다

sameKeyConcurrentReturnsSameId는 두 Callable을 시작 latch로 열고 RequestLoans의 after-request-read 지점에서 다시 기다립니다. 두 호출이 모두 saved에서 기록 없음으로 판단한 뒤에만 예약 INSERT를 진행합니다. 먼저 완료한 요청을 나중에 조회하는 순차 재생으로 우연히 통과하는 검사를 피합니다. 테스트용 LoanFault는 이 지점을 관찰할 뿐 업무 데이터를 변경하지 않습니다. 이력 이후 latch와 요청 조회 이후 latch를 구분하여 서로 다른 경쟁을 재현합니다.

같은 키에서는 대여 뒤에 기다리면 안 됩니다

요청 키 PK는 같은 키의 대여 실행을 직렬화합니다. 첫 요청이 키를 예약한 채 이력 이후에서 두 번째 이력을 기다리면 두 번째는 예약 INSERT에서 막혀 이력 지점에 올 수 없습니다. 그러면 테스트 자체가 원형 대기를 만듭니다. 같은 키 검사는 예약 전 조회 지점에서 기다리고, 다른 회원 검사만 이력 뒤 지점에서 기다립니다. 경쟁을 더 강하게 만들겠다는 이유로 모든 대기 지점을 켜면 시스템 문제와 실험 문제를 구별할 수 없습니다.

패배 예약은 실패한 트랜잭션입니다

starter의 충돌 catch는 승자 조회를 null로 둡니다. 이미 승자가 커밋했어도 패배 호출은 같은 예약을 반복하다 Busy로 끝납니다. TODO에서 tx.execute(status -> saved(member,key,hash))를 복원합니다. execute가 던진 중복키 예외를 밖에서 받은 뒤 조회해야 하며, 실패한 작업의 람다 안에서 예외를 삼키고 SELECT하는 구조로 옮기지 않습니다. 일부 DB는 제약 실패 이후 해당 트랜잭션의 다음 명령을 거절할 수 있으므로 정상 경계로 나가는 것이 설계 의도입니다.

횟수 상한과 시간 상한은 다른 보호입니다

RequestLoans의 예약 시도는 최대 3회입니다. 동일 키 중복이 발생했는데 새 조회에서 아직 결과를 찾지 못한 경우만 다음 시도를 합니다. KeyConflict, 대여 Conflict, 검증 오류, 주입 실패는 이 반복문이 자동 재시도하지 않습니다. 횟수 제한만 있어도 한 번의 DB 잠금 대기가 무한하면 전체 시간이 길어질 수 있습니다. 테스트 DB는 LOCK_TIMEOUT=2000을 사용하고 Future.get과 JUnit Timeout에도 제한을 둡니다. 실제 배포에서는 연결과 쿼리 제한 시간을 별도로 설정하고 검증해야 합니다.

모든 예외를 재시도하지 않습니다

catch의 대상은 요청 예약 DuplicateKeyException입니다. 본문 불일치 409는 동일 키의 의미를 바꾸려는 오류이므로 새 키를 발급하기 전에 사용자 의도를 확인해야 합니다. 현재 대여 409는 이미 점유된 자원입니다. 재시도 횟수 소진은 503 RETRY_EXHAUSTED로 구분합니다. HTTP 클라이언트가 503을 받더라도 같은 업무 의도를 재전송할 때는 같은 키를 유지합니다. 내부 장애와 권한 실패까지 성공처럼 재생하는 정책을 추가하지 않습니다.

실제로 반복 경계를 검사합니다

재생 결과만 확인하면 반복문이 최대 몇 번 도는지 놓칠 수 있습니다. 별도의 단위 검사에서는 JdbcTemplate을 모의하여 조회는 계속 빈 결과, 예약은 계속 DuplicateKeyException이 되도록 합니다. 같은 TransactionTemplate 경계가 실패마다 롤백하고 결국 Busy를 던지는지, INSERT 시도가 3회인지 확인합니다. 이는 H2에서 실제로 생긴 잠금 순서를 흉내 낸 통합 검사가 아니라 상한 분기의 결정적 검사입니다. 실제 경합과 조정자의 제어 흐름을 다른 근거로 검증합니다.

응답 유실은 커밋 뒤 재요청으로 표현합니다

테스트에서 실제 네트워크를 끊지 않아도 업무 결과 재생 계약은 확인할 수 있습니다. 첫 borrow가 완료한 ID를 기록하고 응답을 소비하지 않았다고 가정하여 같은 입력으로 다시 호출합니다. 두 ID가 같고 이력 수가 늘지 않으면 커밋된 요청의 재생 경로를 검증한 것입니다. 네트워크 프록시나 패킷 손실 전체를 시험했다는 뜻은 아닙니다. 브라우저에서 버튼을 두 번 클릭하는 것과 서버 커밋 뒤 응답 유실은 발생 위치가 다르므로 테스트 이름에도 그 가정을 적습니다.

틀린 입력은 저장 불변으로 확인합니다

같은 회원과 키로 책 7을 빌린 뒤 책 8을 보내면 KeyConflict입니다. 이력과 현재 행은 기존 한 건을 유지하고 책 8의 현재 대여를 만들지 않습니다. 키의 길이 경계에서는 빈 문자열, 공백, 65자를 거절하고 허용된 64자는 성공할 수 있어야 합니다. 다른 회원이 같은 키로 다른 책을 빌리는 것은 두 행이 되어야 합니다. 이들을 하나의 정상 사례로 합치지 않고 실패·경계·범위 사례로 나누면 어느 계약을 깨뜨렸는지 읽기 쉽습니다.

일부 실패의 정리까지 확인합니다

failedAttemptDoesNotPoisonKey에서는 대여 이력 직후의 실패가 요청 예약까지 취소했는지 확인합니다. 테스트 후 seed는 자식인 현재 대여와 메모를 먼저 지우고 이력과 회원, 도서를 정리합니다. 요청 결과 테이블도 독립적으로 초기화합니다. 이전 검사에서 만든 키가 다음 검사에 남으면 동시 요청이 기존 결과를 읽어 우연히 통과할 수 있습니다. 모든 검사가 자기 입력을 준비하고 종료 시 테스트 대기 장치를 해제하는 것이 재현 가능성의 조건입니다.

제한 시간 오류도 정보입니다

ExecutionException의 원인이 Busy이면 충돌 후 승자 조회 TODO를 먼저 봅니다. request gate timeout은 두 작업이 기록 없음 지점까지 함께 왔는지 확인합니다. JUnit Timeout은 그보다 넓은 검사 전체의 실행 제한입니다. 이를 없애거나 시간을 계속 늘리기 전에 어떤 자원을 기다리는지 살핍니다. 3회 시도 상한 검사에서 expected 3이 다른 값이면 반복문과 catch 범위가 달라졌는지 확인합니다. 로그 양을 늘리는 행동보다 실패한 경계와 DB 잔여 행을 연결하는 것이 더 직접적인 조사입니다.

미션은 누적 계약으로 마무리합니다

미션 starter는 앞 모듈의 로그인, 자원 권한, CSRF, 도서 CRUD, 대여·반납 코드를 보존하고 요청 식별 조정자에 TODO를 둡니다. 요청 키를 추가하면서 기존 무키 대여의 상태 코드나 반납 정책을 바꾸지 않습니다. report.py로 누적 테스트의 실패·오류·건너뜀을 확인하고 새 HTTP 검사에서 헤더, 201, Location, 409 응답을 검증합니다. 제출 설명에는 두 종류의 경쟁, 새 트랜잭션에서 결과를 읽는 이유, 키 보존 기간의 보장 범위를 적습니다. 일반 동시성의 안전성과 진행성은 더 읽기에서 이어갑니다.

따라하기

실패한 검사를 찾습니다

starter에서 실행합니다. sameKeyConcurrentReturnsSameId의 원인 예외와 충돌 후 조회 경로를 읽습니다.

./mvnw test

TODO의 경계를 복원합니다

RequestLoans의 충돌 catch 안 TODO를 Integer existing=tx.execute(status -> saved(member,key,hash));로 바꿉니다.

수정 후 전체 검사를 실행합니다

충돌 후 새 조회를 구현하고 누적 검사를 실행합니다. 동일 키 동시 호출의 같은 ID와 3회 예약 상한을 함께 확인합니다.

./mvnw test

누적 결과를 집계합니다

동시 재시도 실습의 전체 테스트가 끝난 뒤 보고서를 집계합니다. Tests=156, Failures=0, Errors=0, Skipped=0인지 본인의 결과를 확인합니다.

python3 report.py

확인 문제

실습

RequestLoans의 충돌 catch 안 TODO를 Integer existing=tx.execute(status -> saved(member,key,hash));로 바꿉니다. 테스트는 삭제하거나 완화하지 않습니다. 정상·경계·실패 결과와 작업 종료를 확인하여 수정 코드와 실행 보고서를 제출합니다.

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

실행 명령

./mvnw test

기대 결과

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

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

더 읽기

면접 질문

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