내가 한 프로젝트는 알다시피 서로 책을 빌려주고 빌리는 그런 서비스이다. 그리고 당연하겠지만 A가 올린 책을 B와 C가 동시에 빌릴 수는 없다. 하지만 테스트 해본결과 동시에 빌려지고 있었다.
@Transactional
public void createRental(Long bookId, Long customerId) {
//1
Member customer = getMemberById(customerId);
Book book = bookService.getWithMerchantByBookId(bookId);
//2
blockRentMyBook(customerId, book);
//3
book.changeBookStateFromTo(BookState.RENTABLE, BookState.TRADING);
//4
rentalRepository.save(Rental.create(book, customer));
//5
alarmService.sendAlarm(book.getMember(), book, AlarmType.RENTAL);
}
@Test
void createRental() throws InterruptedException {
int num = 4;
List<Member> members = makeMembers(num);
makeBooks(num, members);
ExecutorService executorService = Executors.newFixedThreadPool(num);
CountDownLatch countDownLatch = new CountDownLatch(num);
for (int i = 2; i < num; i++) {
int finalI = i;
executorService.execute(
() -> {
rentalController.postRental(1L, (long)finalI);
countDownLatch.countDown();
});
}
countDownLatch.await(4, TimeUnit.SECONDS);
List<Rental> all = rentalRepository.findAll();
int count = (int)all.stream().filter(rental -> rental.getBook().getId() == 1L).count();
Assertions.assertEquals(2L,count);
}
일단 동시요청을 테스트해야하는데 자바에서는 ExecutorService
라는 쓰레드풀을 통해 간단하게 병렬처리를 할 수 있는 인터페이스를 제공한다.
그리고 Executors
에 있는 팩토리 메소드를 통해서 ExecutorService
를 구현하는 객체를 반환하는 쓰레드풀을 얻을 수 있다.
CountDownLatch
는 다른 스레드들의 작업이 완료 될때까지 대기하는 것을 도와주는 클래스인데, 동작방식은 처음 초기화된 정수값이 있고 각각 쓰레드에서 countDown
을 호출하면서 정수값을 하나씩 감소시키다가 다른스레드를 기다리는 쓰레드에서는 await
메소드를 호출하고 정수값이 0이될때까지 기다린다, 그리고 0이 되면 다음 라인을 실행한다.
쓰레드풀에 newFixedThreadPool
로 4개의 쓰레드를 생성하고, 각각 member1이 올린 1번책을 member2,3이 "동시에" 대여한다. 우리가 원하는건 대여가 한사람만 할 수 있게하는것이지만 결과는 1번책을 두명이 동시에 빌리고 있다고 나온다.
내가 시도한 해결방법은 JPA의 낙관적 락을 사용한 방법이다. 낙관적 락은 트랜잭션에서 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법인데, JPA가 제공하는 버전 관리 기능을 사용한다. 비관적 락과 낙관적 락 중에 낙관적 락을 선택한 이유는 한책을 두명이상이 대여한다는 상황 자체가 거의 일어날것 같지 않기 때문이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "book", indexes = @Index(name = "idx_book", columnList = "latitude,longitude,book_state"))
public class Book extends BaseTimeEntity implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Version
@Column(name = "book_version")
private Long version;
...
@Version컬럼을 추가해 JPA가 버전관리를 하게 해주자.
그 다음에 테스트로 동시요청을 보내게되면
OptimisticLockFailException이 발생한다.
@PostMapping("/{bookId}")
public ResponseEntity<Void> postRental(@PathVariable Long bookId, @Login Long memberId) {
try {
rentalService.createRental(bookId, memberId);
} catch (
ObjectOptimisticLockingFailureException e) {
log.warn("이미 누군가 대여한 책입니다.");
throw new NotRentableException();
}
return new ResponseEntity<>(HttpStatus.CREATED);
}
이 JPA에서 발생시킨 예외를 한번 감싸서 던져주면 해결