Devin.KR

원자적인 대여

90분 안팎

학습 목표

대여 이력과 active_loans 변경의 트랜잭션 범위를 정합니다.

개념

한 번의 대여가 두 행을 바꾸는 이유

대여 이력만 저장하면 과거에 누가 빌렸는지는 알 수 있지만 현재 대여를 제약으로 막기 어렵습니다. 현재 대여만 저장하면 반납 시 행을 지울 때 과거 기록까지 사라집니다. loans는 이력을 보존하고 active_loans는 지금 책을 점유한 대여만 가리킵니다. 회원이 도서 7번을 빌리는 성공 결과는 loans 한 행과 active_loans 한 행입니다. 둘 중 하나만 남는 상태는 성공으로 반환하지 않습니다. 업무 한 건과 SQL 한 문장의 개수가 다르다는 점이 트랜잭션을 정하는 출발점입니다.

이번 로컬 실습의 결함은 이력 저장 뒤 현재 대여 저장이 빠져 있다는 것입니다. HTTP 201을 받았다는 사실만으로 완료라고 판단하면 대여 가능한 도서처럼 다시 보일 수 있습니다. LoanTransactions.borrow에 두 번째 INSERT를 추가하고 성공 결과의 행 수와 연결 ID를 확인합니다. 이미 제공된 public 서비스 메서드의 트랜잭션 경계는 유지합니다. 다음 레슨에서는 이 경계를 일부러 제거한 starter로 부분 실패를 재현합니다.

두 테이블의 책임과 제약을 읽습니다

loans.id는 대여 한 번의 식별자이고 book_id·member_id·loaned_at·returned_at을 보관합니다. 반납 전에는 returned_at이 NULL입니다. active_loans.book_id는 기본키여서 실물 한 권에 현재 대여 행을 둘 수 있는 수가 최대 한 건입니다. loan_id에는 UNIQUE를 두어 한 대여가 여러 책의 현재 대여로 재사용되지 않게 합니다. 두 테이블의 관계는 단순히 이름이 비슷하다는 뜻이 아니라 같은 대여 ID와 같은 도서 ID가 연결되어야 한다는 뜻입니다.

스키마는 loans의 (id,book_id)에 UNIQUE를 추가하고 active_loans의 (loan_id,book_id)에 복합 외래키를 둡니다. 그래서 도서 7의 대여를 도서 8의 현재 대여라고 저장할 수 없습니다. 기본키만으로 returned_at이 NULL인지까지 보장하지는 않습니다. 반납 시각과 현재 대여 행의 생명주기는 서비스에서 함께 바꿉니다. 데이터베이스 제약이 보장하는 범위와 서비스가 지켜야 할 규칙을 나누어 설명하면 “외래키가 있으니 모든 상태가 안전하다”는 오해를 줄입니다.

트랜잭션 범위는 업무의 저장 경계입니다

대여를 시작하면 도서 존재와 현재 대여를 확인하고 새 이력을 넣은 뒤 현재 대여를 연결합니다. 이 작업을 서비스의 borrow 메서드 하나에서 묶습니다. 컨트롤러는 JSON 입력 검증과 세션 신원 전달, 201 응답 구성만 담당합니다. 조회·INSERT를 각자 독립적으로 커밋하면 뒤 작업이 실패해도 앞 행은 남습니다. 저장소 메서드마다 따로 트랜잭션을 붙이는 대신 이력과 현재 상태가 함께 성공해야 하는 서비스 진입점에 경계를 둡니다.

실습은 @Transactional("loanTransactionManager")를 사용합니다. 이 관리자는 accessJdbc.getDataSource로 얻은 같은 DataSource 객체로 만들어집니다. 같은 URL을 가진 새 DataSource를 따로 만들어 연결해도 Spring이 관리하는 자원 인스턴스가 다르면 두 작업을 같은 트랜잭션에 묶었다고 볼 수 없습니다. 서비스 내부에서 새 JdbcTemplate이나 직접 DriverManager 연결을 만들지 않습니다. 주입된 accessJdbc로 모든 변경을 실행하고 프레임워크가 연결의 커밋·롤백을 관리하게 합니다.

대여 번호와 회원 신원을 섞지 않습니다

기존 대여 ID는 테스트 fixture에 직접 주어져 있으므로 새 API의 번호는 loan_ids 시퀀스로 발급합니다. 실습 시퀀스는 1000부터 시작하고 앞 모듈의 고정 번호는 그보다 작습니다. MAX(id)+1은 동시 요청이 같은 최대값을 읽을 수 있어 번호 발급 수단으로 쓰지 않습니다. 번호는 식별자이지 성공 건수의 증거가 아닙니다. 롤백 뒤 번호에 빈칸이 있어도 실제 저장 행 수와 관계를 검사해야 하며 다음 번호를 수작업으로 되돌리지 않습니다.

POST /loans의 입력은 bookId입니다. memberId가 입력에 추가되어도 확인된 신원을 바꾸는 데 사용하지 않습니다. 컨트롤러가 Principal.getName을 숫자 회원 ID로 변환해 서비스에 넘깁니다. 익명 요청은 인증 필터에서 거절되며 유효한 세션도 CSRF 토큰 없이 변경 요청을 보낼 수 없습니다. 인증된 다른 회원의 번호를 본문에 넣어 대신 대여하게 만드는 코드는 앞 모듈의 신원 계약을 깨뜨립니다. 서비스에 넘기는 회원 값의 출처를 코드와 테스트에서 함께 확인합니다.

결과와 오류의 계약을 고정합니다

성공 응답은 201, Location은 /loans/생성ID이고 JSON에는 id만 넣습니다. 없는 도서는 BOOK_NOT_FOUND 404, 이미 현재 대여가 있으면 LOAN_CONFLICT 409입니다. bookId 누락·null·0·음수는 입력 검증에서 400이 됩니다. 업무 실패를 200에 오류 문자열로 넣으면 호출자는 성공 여부를 상태 코드로 판단할 수 없습니다. 반대로 원래 SQL 예외 메시지를 응답에 넣으면 테이블 이름이나 내부 구현을 노출할 수 있으므로 안정적인 코드와 일반 메시지를 반환합니다.

현재 대여 사전 조회는 설명하기 쉬운 실패 응답을 만드는 데 도움을 줍니다. 그러나 이 조회 하나가 다른 트랜잭션의 INSERT를 막지는 않습니다. 최종 중복 방어는 active_loans 기본키이며 DuplicateKeyException을 잡을 때에도 Conflict를 다시 던져 서비스 호출이 실패하도록 합니다. 예외를 잡고 대여 ID를 정상 반환하면 이력만 커밋할 위험이 있습니다. 동시성의 재현·격리 수준·응답 유실 재시도는 다음 모듈에서 다루며 이번 순차 통과를 동시 요청 검증으로 확대해 주장하지 않습니다.

테스트로 저장 사실을 읽습니다

borrowCommitsBothAndUsesSessionIdentity는 실제 폼 로그인과 CSRF 토큰을 사용합니다. 생성 응답의 ID가 active_loans.loan_id와 일치하는지, loans.member_id가 세션 회원인지 확인합니다. 단순히 각 테이블 COUNT가 1이라고 해도 서로 다른 대여를 가리키면 잘못된 결과입니다. 연결 값, 회원, 고정 시각을 함께 비교합니다. 없는 도서와 이미 대여 중인 경우에는 거절한 요청 전후 행 수가 변하지 않아야 합니다. 정상 결과와 거절 결과를 한쪽씩 빠뜨리지 않습니다.

starter에서 Expected 1 but was 0가 active_loans 검사에 나오면 이력 INSERT 다음의 현재 상태 INSERT를 확인합니다. Referential integrity constraint violation은 먼저 book_id와 loan_id가 실제 이력의 쌍인지 확인합니다. Table not found는 SQL 규칙 이전에 초기화 빈과 스키마 실행 순서를 점검할 신호입니다. 테스트 기대값을 0으로 낮춰 통과시키지 않습니다. 수정 뒤 누적 검사를 실행해 기존 도서 CRUD와 개인 대여 조회가 계속 동작하는지도 확인합니다. ACID 용어 설명은 더 읽기로 이어집니다.

따라하기

starter의 실패를 읽습니다

대여 starter에서 아래 명령을 실행한 뒤 borrowCommitsBothAndUsesSessionIdentity의 현재 대여 행 수 검사를 찾습니다. 현재 행 INSERT가 없어서 COUNT가 0으로 남는지 보고서를 읽습니다.

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

서비스 TODO를 수정합니다

borrow의 현재 대여 INSERT TODO에 아래 조각을 넣습니다. 이력에서 생성한 id를 사용하고 충돌은 실패로 전파합니다.

try { jdbc.update("INSERT INTO active_loans(book_id,loan_id) VALUES(?,?)",bookId,id); }
catch(DuplicateKeyException e) { throw new Conflict(); }

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

INSERT를 완성한 후 다시 실행합니다. 생성한 대여 ID와 active_loans.loan_id가 일치하는지, 도서와 회원 연결도 그대로인지 누적 검사로 확인합니다.

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

실행 보고서를 집계합니다

대여 solution에서 실제 실행한 보고서 합계입니다. 기존 계약 122개와 새 저장 검사 20개가 포함됩니다. 수정한 코드도 생성·거절·연결 관계 검사에 실패가 없는지 집계합니다.

python3 report.py

실행 결과

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

확인 문제

실습

LoanTransactions.java의 이 레슨 TODO를 본문의 저장 규칙에 맞게 완성합니다. borrow의 현재 대여 INSERT TODO에 아래 조각을 넣습니다. 이력에서 생성한 id를 사용하고 충돌은 실패로 전파합니다. 정상·거절·중간 실패 경로와 기존 회귀 검사를 모두 보존합니다. 완료 후 ./mvnw test와 python3 report.py를 실행합니다.

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

실행 명령

./mvnw test

기대 결과

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

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

더 읽기

면접 질문

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