Devin.KR

반납과 시간 테스트

90분 안팎

학습 목표

반납 권한·반납 시각·중복 반납의 정책을 검증합니다.

개념

반납은 이력을 지우는 동작이 아닙니다

대여를 끝내면 같은 도서는 다시 대여할 수 있어야 하지만 누가 언제 빌렸는지는 남아야 합니다. 그래서 반납은 loans.returned_at을 채우고 해당 active_loans 행을 지우는 두 변경입니다. loans 자체를 DELETE하면 도서 이용 기록과 메모의 연결을 잃습니다. 이 두 변경도 함께 성공해야 합니다. 반납 시각만 저장되고 현재 대여 행이 남으면 다른 회원이 빌릴 수 없고, 현재 행만 지우면 기록에는 아직 미반납으로 보입니다.

이번 starter는 현재 대여 삭제는 수행하지만 이력의 반납 시각 갱신을 빠뜨렸습니다. returnCommitsTimeAndKeepsHistory와 duplicateReturnIs409AndTimeUnchanged가 이 결함을 보여 줍니다. 이미 주어진 returnBook 서비스 트랜잭션 안에서 시각 UPDATE를 복원하고 영향 행 수를 검사합니다. 반납은 대여 당시 생성된 ID로 요청합니다. 도서 ID만으로 반납 요청을 받으면 그 책의 오래된 대여와 새 대여를 구별하기 어려워집니다.

소유권은 변경 전에 검사합니다

조회는 WHERE id=? AND member_id=?로 대여 ID와 세션 회원을 함께 제한합니다. 결과가 없으면 존재하지 않는 대여와 타인의 대여를 같은 RESOURCE_NOT_FOUND 404로 처리합니다. ADMIN 역할은 도서 목록 관리 권한이며 개인 대여를 대신 반납할 권한으로 확장하지 않습니다. 본문이나 쿼리의 memberId를 사용해 소유자 검사를 통과시키지 않습니다. 클라이언트가 선택한 자원 ID는 요청 대상일 뿐 요청자의 신원을 증명하는 값이 아닙니다.

소유자의 미반납 이력만 다음 변경으로 넘어갑니다. returned_at이 이미 있으면 LOAN_CONFLICT 409이며 원래 반납 시각을 유지합니다. 이 프로젝트는 중복 반납을 성공 204로 다시 반환하는 정책을 선택하지 않았습니다. 응답 유실 뒤 재시도를 어떻게 구별할지는 다음 모듈의 요청 식별자와 함께 논의합니다. 현재 정책에서 타인의 반납과 중복 반납은 다른 조건이며 각각 404와 409가 됩니다. 둘을 한 예외로 합치기 전에 공개할 정보 범위를 생각합니다.

시각을 주입하면 기다림이 사라집니다

서버 안에서 현재 시각을 직접 얻으면 테스트 기대값을 실행 순간에 맞추기 어렵습니다. LoanTransactions는 생성자로 Clock을 받으며 실행 빈은 Clock.systemUTC, 테스트 빈은 Clock.fixed를 사용합니다. 테스트 시각은 2026-10-09T03:00:00Z입니다. 대여와 반납을 같은 고정 시각에 실행하므로 loaned_at과 returned_at이 같아도 시간 제약을 만족해야 합니다. 테스트를 통과시키려고 sleep을 추가하면 실행 시간과 환경에 의존하는 검사가 됩니다.

TIMESTAMP 컬럼은 시간대 오프셋을 저장하지 않는 실습 타입입니다. 그래서 Clock의 Instant를 UTC LocalDateTime으로 변환하고 Timestamp.valueOf로 저장하는 규약을 정했습니다. 조회 값도 같은 규약으로 해석합니다. 로컬 맥의 시간대가 서울이라고 DB 값에 자동으로 9시간을 더하지 않습니다. 화면에서 사용자 시간대로 표시하는 일은 별도의 표현 책임입니다. DB에 UTC를 저장한다는 문장만 쓰고 변환 코드는 시스템 기본 시간대를 사용하는 실수를 피합니다.

UPDATE와 DELETE의 영향 행 수를 봅니다

반납 시각 UPDATE에는 id·member_id·returned_at IS NULL 조건을 모두 넣습니다. 영향 행 수가 1일 때만 현재 행 삭제로 넘어갑니다. 0행이면 앞선 조회 이후 상태가 달라졌거나 이미 반납된 것이므로 성공으로 응답하지 않습니다. 이 조건은 순차 처리의 중복 반납을 분명히 표현하며 겹친 요청에 대한 마지막 방어에도 도움이 됩니다. 그러나 이 테스트를 동시 실행한 것은 아니므로 모든 DB의 동시 요청 결과까지 확정했다고 설명하지 않습니다.

DELETE는 book_id와 loan_id를 함께 조건으로 사용합니다. 과거 대여를 반납하는 요청이 도서 ID만 조건으로 현재 행을 지우면 재대여한 다른 회원의 행까지 삭제할 수 있습니다. 먼저 이미 반납 여부를 검사하고 삭제에서도 정확한 대여 관계를 제한합니다. 삭제 영향 행 수가 0이면 이력과 현재 상태가 불일치하므로 Conflict를 던집니다. 서비스 트랜잭션이 방금 기록한 returned_at도 취소하여 부분 성공을 남기지 않습니다. 불일치를 숨기려고 현재 행이 없어도 204로 반환하지 않습니다.

시각만 바뀐 실패를 재현합니다

after-return-time에서 테스트용 예외를 던지면 UPDATE는 실행됐지만 DELETE는 아직 실행되지 않은 상태입니다. 호출 종료 뒤 returned_at은 다시 NULL이고 active_loans는 1행이어야 합니다. 이력은 여전히 1행입니다. 반환값이 없는 void 메서드라도 트랜잭션과 예외 전파는 동일하게 중요합니다. 반납 전에 존재하던 이력을 0행으로 만드는 것은 롤백이 아니라 데이터 손실입니다. 대여 실패와 반납 실패는 이전 상태가 다르므로 기대 행 수를 각각 정합니다.

returnWithoutCsrfDoesNotChangeRows는 로그인 세션만 있고 토큰이 없는 요청이 403으로 끝나며 두 저장 상태가 유지되는지 검사합니다. otherAndAbsentReturnHaveSame404는 다른 회원과 없는 자원의 응답 본문까지 비교합니다. adminCannotReturnOthersLoan은 관리자 우회를 막습니다. 정상 204만 확인한 뒤 권한 테스트를 생략하면 외부에서 남의 대여를 종료하는 결함을 만들 수 있습니다. 상태 전이와 인가 조건은 반납 메서드의 입력 경계에서 함께 작동합니다.

재대여를 통해 관계를 확인합니다

회원 1이 빌리고 반납한 뒤 회원 2가 같은 도서를 빌리면 loans는 2행이고 active_loans는 새 대여를 가리키는 1행입니다. 오래된 대여의 반환 요청을 다시 보내도 새 현재 대여는 유지됩니다. reborrowCreatesNewHistory와 oldReturnDoesNotDeleteNewActiveLoan은 이 관계를 검사합니다. 누적 기록 수와 현재 상태 수가 다른 것은 오류가 아닙니다. 이력은 늘어날 수 있고 현재 행은 도서 한 권당 최대 하나라는 서로 다른 불변식을 가진 자료입니다.

Expected 고정 시각 but was null이면 UPDATE 누락과 파라미터 순서를 확인합니다. Expected 409 but was 204이면 returned_at 검사와 조건부 UPDATE를 봅니다. 외래키 제약 실패가 삭제 준비에서 나오면 자식 테이블을 먼저 지웠는지 확인합니다. 완료 제출에는 반납 성공, 타인 반납 거부, 중복 반납, 중간 실패, 재대여 결과를 포함합니다. Clock 주입과 테스트 경계의 일반 원리는 더 읽기로 보내고 이번 설명에서는 도서 대여의 저장 결과를 근거로 정책을 설명합니다.

따라하기

starter의 실패를 읽습니다

반납 starter에서 아래 명령을 실행하고 returnCommitsTimeAndKeepsHistory의 시각 비교 실패를 찾습니다. 현재 대여 삭제 뒤 이력의 returned_at이 NULL로 남는 결함을 확인합니다.

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

서비스 TODO를 수정합니다

returnBook의 UPDATE TODO에 아래 줄을 넣습니다. now()는 주입 Clock을 UTC Timestamp로 변환하며 이후 실패 주입과 DELETE는 유지합니다.

if(jdbc.update("UPDATE loans SET returned_at=? WHERE id=? AND member_id=? AND returned_at IS NULL",now(),id,memberId)!=1) throw new Conflict();

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

조건부 시각 UPDATE를 추가한 뒤 재실행합니다. 정확한 Clock 시각, 반납 이력 보존, 타인 거절, 재대여 후 현재 행 연결을 각각 확인합니다. UPDATE 영향 행 수와 DELETE 조건은 테스트 기대값에 맞춰 구현합니다.

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

실행 보고서를 집계합니다

반납 solution에서 실행한 누적 검사 합계입니다. 반납 시각과 중복 요청·권한·실패 취소를 포함해 142개를 실행했습니다. 건너뜀 없이 모두 통과했는지 본인의 수정 결과와 비교합니다.

python3 report.py

실행 결과

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

확인 문제

실습

LoanTransactions.java의 이 레슨 TODO를 본문의 저장 규칙에 맞게 완성합니다. returnBook의 UPDATE TODO에 아래 줄을 넣습니다. now()는 주입 Clock을 UTC Timestamp로 변환하며 이후 실패 주입과 DELETE는 유지합니다. 정상·거절·중간 실패 경로와 기존 회귀 검사를 모두 보존합니다. 완료 후 ./mvnw test와 python3 report.py를 실행합니다.

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

실행 명령

./mvnw test

기대 결과

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

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

더 읽기

면접 질문

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