Blog

[Spring][258] BookRent 동시성 문제 해결

Category
Author
Tags
PinOnMain
1 more property

1. 문제

현재 진행하는 프로젝트에서 여러명이 동시에 1개의 책에 대해서 대출을 신청할 때 동시성 오류 발생
JMeter을 이용하여 100명이 동시에 1개의 책을 대출신청하도록 설정하였다.
Book과 BookRent는 일대일 관계이나, 10개의 bookRent가 생성된 모습이다.

2. 원인

책을 대출하는 로직은 아래와 같다.
@Transactional public MessageDto createRental(Long bookId, User user) { Book book = bookRepository.findById(bookId) .orElseThrow(()->new IllegalArgumentException("book을 찾을 수 없습니다.")); User savedUser = userRepository.findByIdFetchBookRent(user.getUserId()) .orElseThrow(()->new IllegalArgumentException("user를 찾을 수 없습니다.")); if (book.getBookStatus() != BookStatusEnum.POSSIBLE) { throw new IllegalArgumentException("책이 대여 가능한 상태가 아닙니다."); } book.changeStatus(BookStatusEnum.IMPOSSIBLE); BookRent bookRent = bookRentRepository.save(new BookRent(book)); book.addBookRent(bookRent); savedUser.addBookRent(bookRent); return new MessageDto("도서 대출 신청이 완료되었습니다"); }
Plain Text
복사
대출을 하면 book의 status가 IMPOSSIBLE이 된다.
IMPOSSIBLE이 되면 book을 대출할 수 없는데, IMPOSSIBLE 상태가 되기 전 다수의 요청이 들어가서 여러개의 BookRent가 만들어지는 것으로 추정된다.

3. 해결

1. 비관적 락 사용

public class BookRentService { @Transactional public MessageDto createRental(Long bookId, User user) { Book book = bookRepository.findByIdLock(bookId)// 비관적락으로 변경 .orElseThrow(()->new IllegalArgumentException("book을 찾을 수 없습니다.")); //생략return new MessageDto("도서 대출 신청이 완료되었습니다"); } } public interface BookRepository extends JpaRepository <Book,Long>, QuerydslPredicateExecutor<Book>, BookRepositoryCustom { @Lock(LockModeType.PESSIMISTIC_READ)//비관적 락 적용@Query("SELECT b from book b WHERE b.bookId = :bookId") Optional<Book> findByIdLock(@Param("bookId") Long bookId);
Plain Text
복사
Book을 조회할 때 비관적 락을 사용하여 동시성 이슈를 해결하였다. READ는 가능하도록 PESSIMISTIC_READ로 설정함
ㅈ동시성 문제는 해결됐지만, 낙관적 락에 비해 성능 이슈가 있을 것으로 예상됨

2. 낙관적 락 사용

아래와 같이 book 엔티티에 @Version 어노테이션을 사용하여 낙관적락을 사용하였다.
public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "book_id") private Long bookId; @Version//낙관적락 사용을 위한 추가private long version;
Plain Text
복사
낙관적 락을 사용할 경우에도 동시성 이슈가 잘 해결이 된다.
동시 접근시 아래와 같은 오류가 나타나는 것을 확인할 수 있다.
오류내용 : Row was updated or deleted by another transaction (or unsaved-mapping was incorrect) : [com.example.team258.common.entity.Book#8]
낙관적락은 락이 많이 발생하지 않는 상황에서 쓰는 것이 적합하다.
책 대여 기능의 경우, 1. 책의 숫자가 매우 많기도 하고, 2. 대여중인 책의 경우 예약 기능이 존재하기 때문에 실질적인 비관적락보다는 낙관적락을 사용하는 것이 적합하다고 생각됨.
+추가
낙관적락을 @Transactional 어노테이션과 같이 사용중인데 옳은 방향인지 궁금함.
@Transactional을 사용하지 않을 경우 아래와 같이 1개의 값만 들어가지만 user_id 가 null인 오류가 발생함
낙관적락에서 오류가 발생할 경우 추가로 로직을 작성해야하는지??? --> https://chaewsscode.tistory.com/181

3. @Transactional 격리수준 변경

책을 대여하는 메소드의 @Transactional 격리수준을 변경하며 테스트하였다.
참고로 Mysql의 @Transactional 격리수준은 REPEATABLE_READ 이다.
@Transactional(isolation = Isolation.SERIALIZABLE) public MessageDto createRental(Long bookId, User user) { Book book = bookRepository.findById(bookId) .orElseThrow(()->new IllegalArgumentException("book을 찾을 수 없습니다.")); User savedUser = userRepository.findByIdFetchBookRent(user.getUserId()) .orElseThrow(()->new IllegalArgumentException("user를 찾을 수 없습니다.")); if (book.getBookStatus() != BookStatusEnum.POSSIBLE) { throw new IllegalArgumentException("책이 대여 가능한 상태가 아닙니다."); } book.changeStatus(BookStatusEnum.IMPOSSIBLE); BookRent bookRent = bookRentRepository.save(new BookRent(book)); book.addBookRent(bookRent); savedUser.addBookRent(bookRent); return new MessageDto("도서 대출 신청이 완료되었습니다"); }
Plain Text
복사
@Transactional(isolation = Isolation.SERIALIZABLE) 사용시 아래와 같은 오류가 나며 동시성 이슈가 해결됨
Serializable은 여러 트랜잭션이 동일한 레코드에 동시 접근이 불가하기 때문에 부정합 문제가 발생할 수 없지만 성능이 매우 떨어진다. 극단적으로 안전한 작업이 필요한 경우가 아니면 사용해서는 안된다.

4. Syncronized 사용

아래와 같이 코드를 수정하여 Syncronized 를 사용하였다.
// @Transactionalpublic synchronized MessageDto createRental(Long bookId, User user) { Book book = bookRepository.findById(bookId) .orElseThrow(()->new IllegalArgumentException("book을 찾을 수 없습니다.")); User savedUser = userRepository.findByIdFetchBookRent(user.getUserId()) .orElseThrow(()->new IllegalArgumentException("user를 찾을 수 없습니다.")); if (book.getBookStatus() != BookStatusEnum.POSSIBLE) { throw new IllegalArgumentException("책이 대여 가능한 상태가 아닙니다."); } book.changeStatus(BookStatusEnum.IMPOSSIBLE); BookRent bookRent = bookRentRepository.save(new BookRent(book)); book.addBookRent(bookRent); savedUser.addBookRent(bookRent); return new MessageDto("도서 대출 신청이 완료되었습니다"); }
Plain Text
복사
아래와 같이 Bookrent는 1개 생성되나 Book-bookrent 연관관계가 지정되지 않는 오류 발생. 이유는 잘 모르겠다 ㅋㅋ
Syncronized는 앱을 다중서버로 구동하면 오류가 생길 수 있는데 우리 프로젝트는 2개 이상 서버를 운용할 예정으로 기각

4. 분산락

별도 문서에 정리