1. 테스트 코드 작성하기
- Service
@Transactional
public void bookorder(Long bookid, Integer quantity, Member member){
Book book = entityManager.find(Book.class,bookid,LockModeType.PESSIMISTIC_FORCE_INCREMENT);
Long inven = book.getInventory()-quantity;
if (inven<0){
throw new CustomException(ErrorCode.INVALIDATION_NOT_ENOUGH);
return;
}else {
book.orderbook(inven);
jumoonRepository.save(new Jumoon(member,book,quantity));
}
}
- testcode
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class LockTest {
@Autowired
MemberRepository memberRepository;
@Autowired
BookRepository bookRepository;
@Autowired
JumoonRepository jumoonRepository;
@Autowired
JumoonService jumoonService;
@Autowired
EntityManager entityManager;
@RepeatedTest(10)
void testConnection() throws InterruptedException {
Book book = new Book("큰카테","작은카테","제목스",9999,9
,"작가스","출판스",null,1993,5,20L);
bookRepository.save(book);
List<Member> members = new ArrayList<>();
for (int i = 0; i < 20; i++) {
String email = "testman"+i+"@ntest.com";
Member member = new Member(email);
memberRepository.save(member);
members.add(member);
}
CountDownLatch countDownLatch = new CountDownLatch(20);
List<JumoonWorker> workers = Stream.
generate(()-> new JumoonWorker(members.get(0),book,3,countDownLatch))
.limit(20)
.collect(Collectors.toList());
workers.forEach(worker -> new Thread(worker).start());
countDownLatch.await(1, TimeUnit.SECONDS);
Book updatebook = bookRepository.findById(book.getId()).orElseThrow(
()-> new CustomException(ErrorCode.BOOK_NOT_FOUND)
);
List<Jumoon> jumoons = jumoonRepository.findAllByBook_Id(updatebook.getId());
System.out.println("jumoons.size() = " + jumoons.size());
assertEquals(6,jumoons.size());
assertEquals(2,updatebook.getInventory());
}
private class JumoonWorker implements Runnable{
private Member member;
private Book book;
private int quantity;
private CountDownLatch countDownLatch;
public JumoonWorker(Member member, Book book, int quantity, CountDownLatch countDownLatch) {
this.member = member;
this.book = book;
this.quantity = quantity;
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
jumoonService.bookorder(book.getId(), quantity,member);
countDownLatch.countDown();
}
}
}
- 코드설명
- service에서 bookid를 이용해 book을 불러오면서 lock을 건다.
- 20개의 쓰레드를 통해 동시성 문제를 발생시키는 테스트를 10번 진행한다.
- 그리고 해당 책의 재고량과 총 주문 개수를 파악하여 동시성 제어가 잘 되었는지 파악한다.
2. Lock 옵션에 따른 실험 결과
1)Lock 옵션 종류와 설명
ㄱ. Optimistic
- NONE : 별도의 옵션을 사용하지 않아도 Entity에 @Version이 적용된 필드만 있으면 낙관적 잠금이 적용됩니다.
- OPTIMISTIC(read) : Entity 수정시에만 발생하는 낙관적 잠금이 읽기 시에도 발생하도록 설정합니다.
읽기시에도 버전을 체크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장합니다.- 이를 통해 diry read와 non-repeatable read를 방지
- OPTIMISTIC_FORCE_INCREMENT(write) : 낙관적 잠금을 사용하면서 버전 정보를 강제로 증가시키는 옵션
ㄴ. Pessimistic
- PESSIMISTIC_READ : dirty read가 발생하지 않을 때마다 공유 잠금(Shared Lock)을 획득하고 데이터가 UPDATE, DELETE 되는 것을 방지 할 수 있습니다.
- PESSIMISTIC_WRITE : 배타적 잠금(Exclusive Lock)을 획득하고 데이터를 다른 트랜잭션에서 READ, UPDATE, DELETE 하는것을 방지 할 수 있습니다.
- PESSIMISTIC_FORCE_INCREMENT : 이 잠금은 PESSIMISTIC_WRITE와 유사하게 작동 하지만 @Version이 지정된 Entity와 협력하기 위해 도입되어 PESSIMISTIC_FORCE_INCREMENT 잠금을 획득할 시 버전이 업데이트 됩니다.
2)실험 결과 요약
- 현재 서비스에서는 PESSIMISTIC_WRITE만 제대로 작동한다.
- Optimistic은 성능은 좋지만 주문이 폭주하는 경우 트랜잭션을 시작할 때 저장한 버전 값과 트랜잭션이 끝나고 난 후 체크한 버전값이 다른 경우가 많은데 이 때 잘 작동하지 않는다. 따라서 진행하는 서비스에는 적합하지 않다.
- 또한 Force_Increment 옵션의 경우 지원하는 DB의 종류가 정해져있는데, 현재 사용중인 mysql에서는 사용이 불가능하다.
- PESSIMISTIC_READ는 어떤 트랜잭션이 수정중일 때 다른 트랜잭션에서 읽기는 가능하다. 하지만 현재 실험에서는 모든 트랜잭션들이 수정하고자 하기 때문에 적합하지 않다.
- PESSIMISTIC_WRITE를 사용한 결과
3. Lock을 거는 방법
- 주로 저장된 entity를 불러올 때 사용된다. 따라서 jpa단위에서 걸어주거나 entityManager를 이용해 걸어주는 방법이 있다.
- @Transaction과 함께 @Lock을 걸어줘도 된다고 알고있었으나 잘못된 정보를 알고 있던 거였다.
4. Redisson Distribution Lock
- Transaction이 Lock을 점거하고 있다는 정보를 redis 서버(캐시 서버)에 올려서, 분산된 서버에서 하나의 DB를 조회하고 수정할 때 사용하기에 적절한 방법이다.
- 아래 자세히 설명하겠지만, 요청 Thread를 순서대로 처리한다는 보장이 없다는 단점이 있다.
- 서비스 로직만 아래와 같이 바꿔준다.
- redis 서버에 lockname을 key로 가진 데이터가 없으면 만들고 lock을 건다
- 이미 lock이 있다면, 기다리거나 포기한다. 코드 블럭 아래에 정확히 설명하겠다.
- 트랜잭션을 성공적으로 수행했다면 lock을 해제한다.
private final RedissonClient redissonClient;
@Transactional
public void bookorderredis(Long bookid, Integer quantity, Member member){
final String lockname = bookid + ":lock";
final RLock lock = redissonClient.getLock(lockname);
final String worker = Thread.currentThread().getName();
try{
if(!lock.tryLock(1,3,TimeUnit.SECONDS)){
System.out.println("키 점거 실패");
return;
}
Book book = entityManager.find(Book.class, bookid);
Long inven = book.getInventory() - quantity;
if (inven<0){
System.out.println("재고 없는데용");
}else {
book.orderbook(inven);
jumoonRepository.save(new Jumoon(member,book,quantity));
}
}catch (Exception e){
e.printStackTrace();
}finally {
if (lock != null && lock.isLocked()){
lock.unlock();
}
}
}
- 코드 설명
- trylock(long waitTime, long leaseTime, TimeUnit unit)
- waitTime : 락을 사용할 수 있을 때 까지 waitTime만큼 기다린다.
- leaseTime : leaseTime이 지나면 자동으로 락이 해제된다.
- timeunit : 시간의 단위를 설정해준다.
- unlock : 가지고 있던 락을 해제한다.
- trylock(long waitTime, long leaseTime, TimeUnit unit)
1)실험 결과 요약
- 잘 작동하지만 아래와 같이 주문 id가 순서대로 되어있지 않다. 즉 요청된 Thread를 순서대로 처리하지 않는다.
- 이러한 단점을 해결하기 위해선 Redisson Distribution Lock 이 아니라 Redisson Fair Lock을 사용해한다.
- 하지만 이 경우에는 DeadLock 문제가 발생할 가능성이 있다.
- 순서대로 처리하지 않는 이유는 먼저 Thread가 lock을 선점한 동안 여러개의 Thread가 대기중일 때, 먼저 선점한 Thread가 트랜잭션을 완료 한 이후 어떤 Thread가 lock을 점유할지 모르기 때문이다.
- Thread1번이 3초가 걸리는 Transaction을 진행중이다.
- 이때 Thread 2번 부터 10번까지 대기를 한다.
- 2~10번까지 Thread1 이 끝날때 까지 계속 lock 점거를 요청한다.
- 2~10번이 계속 요청하기 때문에 1번 종료 시점에 누가 점유하게 될지 장담할 수 없다.
2. Lock을 거는 방법
- 주로 저장된 entity를 불러올 때 사용된다. 따라서 jpa단위에서 걸어주거나 entityManager를 이용해 걸어주는 방법이 있다.
- @Transaction과 함께 @Lock을 걸어줘도 된다고 알고있었으나 잘못된 정보를 알고 있던 거였다.