ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 동시성 문제 해결하기(1) - DB Lock
    실전 개발해보기 2024. 1. 23. 16:05

     

     

    팀프로젝트 때 내가 맡았던 쿠폰 도메인...

     

    쿠폰 도메인에서 가장 중요한 파트라하면 단연 쿠폰을 다운로드 하는 기능이 되겠다.

     

    플젝 당시에는 락 관련 테스트 코드짜다가 에러가 너무 많이 나서 일단 Pessimistic Lock으로 구현해놓고 

     

    플젝이 끝난 지금,  다시 테스트 코드를 짜서 이것저것 비교해보며 공부해보려고 한다.

     

    내 개발환경은

     

    언어 : Java

    프레임워크 : Springboot

    DB: Mysql

     

    Jpa(hibernate) 정도를 사용하고 있다. 


     

     

    DB에서 테이블/레코드에 쓰는 락으로 대표적으로 Exclusive lock /Shared lock  가 있다. (여기서는 간단하게 정리)

     

    Exclusive lock (배타적 잠금)

    쓰기 잠금(Write lock)이라고도 불린다.
    어떤 트랜잭션에서 데이터를 변경하고자 할 때(ex . 쓰고자 할 때) 해당 트랜잭션이 완료될 때까지 해당 테이블 혹은 레코드(row)를 다른 트랜잭션에서 읽거나 쓰지 못하게 하기 위해 Exclusive Lock을 걸고 트랜잭션을 진행시키는  것이다.
    Shared lock (공유 잠금)

    읽기 잠금(Read lock)이라고도 불린다.
    어떤 트랜잭션에서 데이터를 읽고자 할 때 Shared Lock이 걸려있어도 다른 Shared Lock은 허용이 되지만 Exclusive Lock은 불가하다.
    쉽게 말해 리소스를 다른 사용자가 동시에 읽을 수 있게 하되 변경은 불가하게 하는 것이다.

     

     

    그리고 이 두가지 락을 우리가 직접 쿼리에 거는게 아니라 전략적으로 사용할 수 있는 방법 2가지가 있다

     

    1. Pessimistic Lock(비관적 락)

    2. Optimistic Lock(낙관적 락)

     

    (named Lock은 분산락 관련해서 추후에 정리해보겠다)

    각각 구현해보고 테스트 결과를 확인해고, 어떤 상황에 어떤 전략을 가져가는게 좋을 지 정리해보자

     


     

     

    1. Pessimistic Lock(비관적 락)

     

    비관적 락은 내가 select 문으로 어떤 데이터를 가져오고자 할 때

     

    데이터에 아예 잠금을 걸어서, 해당 트랜잭션이 끝날 때 까지

     

    다른 트랜잭션에서 잠금이 걸린 데이터를 어쩌지 못하게 만드는 전략이다. 

     

    물론 비관적 락 안에 또 세가지로 나눌 수 있다.

     

    1. @Lock(LockModeType.PESSIMISTIC_WRITE) 
      위에서 보았던  Exclusive Lock이라 생각하면 된다. 다른 트랜잭션이 잠금 걸린 데이터를 읽는 것,쓰는 것 모두 불가능하게 만든다
    2. @Lock(LockModeType.PESSIMISTIC_READ)위에서 보았던 Shared Lock이라 생각하면 된다. 다른 트랜잭션이 잠금 걸린 데이터를 읽는 것은 가능하지만 쓰는 것은 불가능하다.
    3. @Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENTExclusive Lock에 기반하지만 낙관적 락처럼 @Version 을 사용한다.

     

    코드를 따라서 흐름을 이해해보자.

     

    PessimisticCouponTest
    
            Coupon savedCoupon = couponJpaRepository.save(coupon);
            System.out.println(savedCoupon.getCouponQuantity().getValue());
    
            //when&&then
            int threadCount = 10_000;
            ExecutorService executorService = Executors.newFixedThreadPool(32);
            CountDownLatch latch = new CountDownLatch(threadCount);
            for (Long i = 1L; i <= 10_000; i++) {
                Long k = i;
                executorService.execute(() -> {
                            try {
                                productCouponService.download(new SaveCouponRequest(savedCoupon.getId()), k);
                            } finally {
                                latch.countDown();
                            }
                        }
                );
            }
            latch.await();

     

     

    1만개의 사람(쓰레드)이 선착순 쿠폰을 얻기 위해 경쟁해야 하는 상황이다.(물론 쓰레드풀의 크기는 32로 작게 설정했다.)

     

    --  productCouponService 코드 일부 --
       	@Transactional 
     	CouponQuantity couponQuantity = couponJpaRepository.findCouponQuantity(couponId)
                    .orElseThrow(() -> new CouponException(ErrorCode.COUPON_NOT_FOUND));
     	couponQuantity.decrease(1);
        
      
      
     -- couponJpaRepository 코드 일부 --
     
        @Lock(LockModeType.PESSIMISTIC_READ)
        @Query("SELECT cq FROM CouponQuantity cq where cq.id = :couponId")
        Optional<CouponQuantity> findCouponQuantity(@Param("couponId") Long couponId);

     

     

    중요한건 레포지토리에서 Lock을 통해 가져오는 것이다. 

     

    이제 가져오는 CouponQuantity는 다른 트랜잭션에서 Read 목적으로만 접근 가능하고, Write는 불가능해진다.

     

    본인의 로직에 따라 전략을 설정하면 될 것 같다.

     

     


     

     

    2. Optimistic Lock(낙관적 락)

    낙관적 락은 비관적 락처럼 테이블/레코드에 잠금을 거는 개념이 아니다.

    정확히는 잠금이라는 개념이 없이 @Version으로 관리하는 방식이다.

     

    public class CouponQuantity {
    
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @Column(nullable = false)
        private Integer value;
    
        @Version
        private Long version;
     }

     

    낙관적 락을 사용하려는 엔티티에 @Version 필드(Long/Integer) 를 생성하면 값은 Jpa에서 DB로 알아서 넣어준다.

     

    이 version이 사용되는 원리는 다음 그림과 같다.

     

    상황) 락이라는 개념이 없기 때문에 특정 데이터를 Alice/Bob 2명이 동시에 가져갈 수 있다. 

    Bob이 먼저 값을 수정하고 커밋하면 맨 처음 가져왔을때의 version에서 1만큼 증가한다.

    그리고 Alice도 값을 수정하려하면 한번 예외가 발생한다. 왜냐?
    Alice가 가진 version은 1이고 DB에 있는 version은 2 로 다른 상태다.

     

    version이라는 공유자원을 통해 데이터의 정합성을 맞추는 방식이다. 

     

    그러나 보았듯이 version 값이 맞지 않을 경우 예외가 발생한다.

     

    이 예외 처리를 바로 우리 애플리케이션 단에서 코드로 작성해줘야한다. 

     

    productCouponService 일부
    
    	 while (true){
                try {
                    couponQuantityFacade.decreaseWithOptimistic(couponId);
                    break;
                } catch (Exception e) {
                    Thread.sleep(50);
                }
            }

     

    Service 와 Repository 사이에 Facade 패턴을 적용한다.

     

    couponFacade 일부
    
        public void decreaseWithOptimistic(Long couponId) {
            CouponQuantity couponQuantity = couponJpaRepository.findCouponQuantity(couponId)
                    .orElseThrow(() -> new CouponException(ErrorCode.COUPON_NOT_FOUND));
            couponQuantity.decrease(1);
        }
        
        
     repository 일부
     
       @Lock(LockModeType.OPTIMISTIC)
        @Query("SELECT cq FROM CouponQuantity cq where cq.id = :couponId")
        Optional<CouponQuantity> findCouponQuantity(@Param("couponId") Long couponId);

     

     

    Optimistic이 걸려있는 데이터를 가져와서 수정시 version이 맞지 않으면 예외가 발생하는데

     

    while문과 Thread.sleep을 이용해서 시간을 두고 계속해서 재시도를 한다. 

     


     

    3. 비교해보기 

     

    Optimistic Lock
    Pessimistic Lock

     

    둘다 같은 환경에서 약 1만개의 동시 요청에 대한 테스트 결과다.

    확실히 Pessimistic Lock이 더 빠른 결과를 보여줬다. 

    다만 여기있는 이 수치는 정해진 환경이기도하고, 큰 차이를 보이진 않아서 두 전략에 대해 평가하긴 어렵다.

     

    고려해볼점 

     

    1. Optimistic Lock은 결국 실행한 로직(트랜잭션)을 커밋할 때 예외가 발생한다. 

         -> 변경사항을 되돌려야 하는 롤백 비용 발생 

     

    2. Pessimistic Lock은 락을 얻으려고 무한으로 시도하는 작업이다.

        -> 얻으려고 하는 작업 비용이 계속해서 발생

     

     

    나의 결론 

     

    충돌이 자주 일어나지 않는 환경(레이스 컨디션이 적은)이라고 한다면 

    Optimistic Lock이 최선이라고 생각한다.

    다만 충돌이 너무 자주 일어난다면 갱신 손실에 대한 비용이 커지므로 Pessimistic Lock을 고려할 것이다.

    2편에서 쓸 Redis를 이용한 Lock에 대해서도 고려해볼 것 같다.

     



    Reference

     

    https://www.baeldung.com/jpa-pessimistic-locking

    https://stackoverflow.com/questions/129329/optimistic-vs-pessimistic-locking

    https://medium.com/nerd-for-tech/optimistic-vs-pessimistic-locking-strategies-b5d7f4925910

    https://jeong-pro.tistory.com/94

    https://www.inflearn.com/course/%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%B4%EC%8A%88-%EC%9E%AC%EA%B3%A0%EC%8B%9C%EC%8A%A4%ED%85%9C

Designed by Tistory.