동시성을 고려한 비즈니스 로직짜기

이성준·2023년 1월 13일
1

프로젝트

목록 보기
3/5

문제

내가 한 프로젝트는 알다시피 서로 책을 빌려주고 빌리는 그런 서비스이다. 그리고 당연하겠지만 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);
	}
  1. 책과 그 책을 빌리고싶어 하는 사람을 db에서 뽑아온다.
  2. 책 주인이랑 빌리고싶어하는 책이 같으면 안된다.
  3. 책의 상태를 대여중으로 바꾼다.
  4. 대여내역을 db에 저장한다.
  5. 책 주인에게 대여가 됐다고 알람을 보낸다.

테스트

	@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에서 발생시킨 예외를 한번 감싸서 던져주면 해결

0개의 댓글