[Lock] DB Lock & JPA Lock 코드로 살펴보자 (MySQL)

DataBase는 데이터를 영속적으로 저장하고 있는 시스템이다. 이런 시스템은 같은 자원(데이터)에 대해서 동시에 접근하는 경우가 생길 수 밖에 없다. 이럴 경우 데이터가 오염 될 수 있는데 그렇게 되지 않도록 데이터의 일관성과 무결성을 유지해야할 필요가 있다.

 

예를 들어 수강신청 시스템에서 1명만이 정원으로 남게되었다. 여기서 2사람이 거의 동시에 버튼을 눌렀다. 성공은 1명만 되야한다. 이런 상황에서 DBMS(DataBase Management System)가 사용하는 공통적인 방법이 Lock이라는 것다.

 

1. Lock 이란?

Lock이란 데이터베이스에서 동시성과 데이터 일관성을 보장하기 위해 사용되는 메커니즘

혹은 트랜잭션 처리의 순차성을 보장하기 위한 방법

 

 

여러 커넥션이 하나의 Data에 대해 접근하려고 할 때 데이터의 일관성이 보장되지 못 하는 문제가 있다. 

 

이때 하나의 커넥션A가 Data에 접근하면서 Lock을 걸어버리면 다른 커넥션(B,C,D)는 Data에 접근할 수 없게 된다.

 

이렇게 자물쇠를 걸고 푸는 행위를 Lock이라고 한다. 

 

 

2. Lock과 Transaction ?

Lock과 Transaction은 같은 역할을 하는걸까??

 

Lock은 동시에 발생하는 수정 요청에 대한 데이터 일관성을 지키기 위한 메커니즘 중 하나이다. 

 

트랜잭션은 여러 트랜잭션에 대해 각 트랜잭션을 어떻게 처리할지에 대한 전략이다. 

 

정리하면 트랜잭션들을 어떻게 처리할지에 대한 전략 중 구현 방법 중 하나가 Lock인 것이다. 

즉, 트랜잭션의 격리수준을 구현할 방법 중 하나가 Lock이다. 

 

이 둘의 관계는 이처럼 나타낼 수 있다. 



3. Lock 전략

- 낙관적 Lock

트랜잭션이 애초에 충돌이 발생하지 않는다 라고 가정하고 사용하는 전략

 

내가 먼저 이 값을 수정했다고 명시하여 다른 사람이 동일한 조건으로 값을 수정할 수 없게 하는 것이다. 그런데 잘 보면 이 특징은 DB에서 제공해주는 특징을 이용하는 것이 아닌 Application Level에서 잡아주는 Lock이다. 

 

 

 

- 비관적 Lock

트랜잭션이 매번 충돌이 발생한다 라고 가정하고 사용하는 전략

 

비관적 락이란 트랜잭션이 시작될 때 Shared Lock 또는 Exclusive Lock을 걸고 시작하는 방법이다.

 

 

Shared Lock을 걸게 되면 write를 하기위해서는 Exclucive Lock을 얻어야하는데 Shared Lock이 다른 트랜잭션에 의해서 걸려 있으면 해당 Lock을 얻지 못해서 업데이트를 할 수 없다. 수정을 하기 위해서는 해당 트랜잭션을 제외한 모든 트랜잭션이 종료(commit) 되어야한다.

 

 

위의 도식도를 보면서 비관적 락에 대해서 제대로 이해해보자. 

  1. Transaction_1 에서 table의 Id 2번을 읽음 ( name = Karol )
  2. Transaction_2 에서 table의 Id 2번을 읽음 ( name = Karol )
  3. Transaction_2 에서 table의 Id 2번의 name을 Karol2로 변경 요청 ( name = Karol )
    • 하지만 Transaction 1에서 이미 shared Lock을 잡고 있기 때문에 Blocking
  4. Transaction_1 에서 트랜잭션 해제 (commit)
  5. Blocking 되어있었던 Transaction_2의 update 요청 정상 처리

 

 

4. JPA에서의 낙관적 & 비관적 Lock

가정) 쿠폰이 5장이 있고, 20명의 사용자가 동시에 쿠폰을 발급받을 때를 가정

 

테스트를 위해 Coupon 엔티티와 Service 로직을 작성해주었다. 

 

- Coupon

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Coupon {
    @Id
    @GeneratedValue
    @Column(name = "coupon_id")
    private Long id;

    private int count;

    @Builder
    public Coupon(int count) {
        this.count = count;
    }

    public void issue() {
        if (count <= 0) {
            throw new IllegalArgumentException("수량 부족");
        }
        count -= 1;
    }
}

 

DB엔 다음과 같이 데이터가 들어가있다.

 

 

- case1: Lock을 걸지 않았을 때

    @Test
    @DisplayName("동시에 쿠폰을 발급할 경우 - Lock 적용 x")
    void test_not_lock() throws InterruptedException {
        final int executeNumber = 20;

        ExecutorService executorService = Executors.newFixedThreadPool(10);

        // 스레드의 실행이 끝날 때까지 대기
        CountDownLatch countDownLatch = new CountDownLatch(executeNumber);

        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failCount = new AtomicInteger();

        for (int i = 0; i < executeNumber; i++) {
            executorService.execute(() -> {
                try {
                    couponService.issueCoupon(1);
                    successCount.getAndIncrement();
                    System.out.println("쿠폰 발급");
                } catch (Exception e) {
                    failCount.getAndIncrement();
                    System.out.println(e.getMessage());
                }
                countDownLatch.countDown();
            });
        }

        countDownLatch.await();

        System.out.println("발급된 쿠폰의 개수 = " + successCount.get());
        System.out.println("실패한 횟수 = " + failCount.get());

        assertEquals(failCount.get() + successCount.get(), executeNumber);
    }

 

 

위 코드의 결과는 다음과 같이 나타난다.

 

DB엔 분명 쿠폰이 5개 밖에 없지만 발급된 쿠폰의 개수가 20개로 나타난다. 이런 문제는 매우 크리티컬한 문제이다. 

이 문제를 먼저 낙관적 Lock을 사용해서 해결해보자. 

 

 

 - case2: 낙관적 Lock을 걸었을 때

Integer version 부분을 제외하곤 위에서 작성한 entity와 동일하다.

 

version은 엔티티에 접근해서 어떠한 값이 변경될 때 이 version이 같이 올라간다. 

즉, 현재 버전이 맞는지 아닌지 식별하기 위한 숫자이다. 

따라서 update 쿼리가 나갈 때 version을 확인하는 조건문이 함께 나간다. 

 

 

아까 작성한 Test 코드를 돌리면 결과는 다음과 같이 나온다. 

 

이번엔 왜 3장인 것일까? 심지어 5장도 아니다. 그 이유는 낙관적 Lock은 최초의 요청만 commit하기 때문이다. 자세하게 살펴보자. 

 

다음과 같이 트랜잭션 4개가 동시에 count의 값을 바꾸려고 할 때, 이때의 version은 1이다. 

 

 

 

트랜잭션 A가 update문을 수행할 때 version을 확인하고 현재의 version과 동일하기 때문에 version을 2로 업데이트 시켜준다. 나머지 트랜잭션 B,C,D는 본인들의 version은 1인 반면 데이터의 version은 2가 되기 때문에 실패하게 되는 것이다. 

 

 

 

위와 같은 매커니즘으로 동작하기 때문에 매번 발급되는 티켓의 개수가 다르게 나타날 수도 있다. 

하지만 여기서 확실한 점은 절대 5개를 넘지는 않는다는 것이다. 

 

 

- case3: 비관적 Lock을 걸었을 때

JPA에선 repository의 조회 함수에 Lock 어노테이션을 걸어주면 비관적 Lock을 사용할 수 있다. 

 

 

 

비관적 Lock같은 경우 정확히 5개만 쿠폰이 발급된다. 그 이유는 비관적 Lock의 특징에 있다. 

비관적 Lock은 데이터에 접근함과 동시에 Lock을 걸어버리기 때문에 한 개의 트랜잭션이 데이터에 접근하는 순간 나머지 트랜잭션은 전부 대기하게 된다. 

 

 

 

쿼리를 살펴보면 select for update 쿼리가 나가는걸 볼 수 있다. 이 쿼리가 DB에 접근함에 따라 즉시 Lock을 거는 쿼리이다. 

 

 

비관적 Lock의 동작 매커니즘을 그림으로 살펴보면 다음과 같다.

트랜잭션 A가 데이터에 접근했을 땐 접근하자마자 Lock을 걸어버리기 때문에 어떤 트랜잭션도 동일한 데이터에 접근할 수 없다. 트랜잭션 A가 볼일을 다 보고 Lock을 푼다면 그때 대기하던 트랜잭션이 접근할 수 있다.