Lock 이란?

하나의 자원에 여러 요청이 들어올 때 데이터의 일관성과 무결성을 보장하기 위해 하나의 커넥션에게만 변경할 수 있는 권한을 주는 기능으로 동시성을 제어하기 위한 목적으로 사용됩니다.  간단한 예시를 보면서 이해해 보도록 하겠습니다.

사용자에게 송금하는 메소드 transfer 가 있습니다. 순서는 다음과 같습니다. 잔고조회 -> 잔고 + amount

 

사용자A사용자B가 동시에 사용자C에게 송금한다고 했을 때, 사용자A, 사용자B는 1000 씩 송금을 했으니 사용자C의 잔고가 4000이 될 것으로 예상했으나 최종잔고는 3000이 되었습니다. 이는 동시성 문제로 데이터의 일관성과 무결성이 보장되지 않아서 생기는 문제입니다. 오늘은 이 문제를 해결하는 Lock 에 대해서 알아보도록 하겠습니다.

 

 

Lock의 종류

1. Shared Lock (공유 락)

  • 읽기 작업을 위한 Lock
  • 여러 트랜잭션이 동시에 데이터를 읽을 수 있음
  • 다른 Shared Lock과는 호환되지만 Exclusive Lock과는 호환되지 않음
    • 읽기 작업 중일 때, 새로운 읽기 작업이 들어온 경우 -> 가능
    • 읽기 작업 중일 때, 새로운 쓰기 작업이 들어온 경우 -> 불가능 (쓰기 작업으로 멱등성 보장x)

2. Exclusive Lock (배타적 락)

  • 쓰기 작업을 위한 Lock
  • 한 번에 하나의 트랜잭션만 데이터를 수정할 수 있음
  • 다른 어떤 Lock과도 호환되지 않음
    • 쓰기 작업 중일 때, 새로운 읽기 작업이 들어온 경우 -> 불가능
    • 쓰기 작업 중일 때, 새로운 쓰기 작업이 들어온 경우 -> 불가능

 

그렇다면 호환되지 않은 Lock의 요청이 들어올 때 불가능하다고 했는데 어떤 상황이 벌어질까?

 

 

Blocking

호환되지 않은 Lock 이 들어올 경우 블로킹(Blocking)이 발생합니다. 새로운 Lock의 요청이 들어올 때, 이미 Lock이 선행되고있으면 선행되고있는 Lock이 종료될떄까지 대기하고있다가 종료가 되면 새로운 Lock을 획득해 진행하게됩니다.

 

위의 예시에 Lock을 적용한다면 순서는 다음과 같습니다.

사용자A -> transfer(사용자A, 사용자C, 1000) -> Exclusive Lock 획득 -> 사용자B -> transfer(사용자B, 사용자C, 1000) -> Blocking (사용자A의 Lock이 해제될 때까지 대기) -> 송금 -> Blocking 해제, Exclusive Lock 획득 -> 송금 

 

위에서 Shared Lock은 여러 Shared Lock과 호환된다고했습니다. 만약 100개의 트랜잭션에서 Shared Lock을 획득하고 있을때 1개의 Exclusive Lock 요청이 들어온다면 100개의 Shared Lock이 모두 해제될때까지 대기하게 됩니다. 또는 끊기지 않고 Shared Lock이 들어온다면 Exclusive Lock은 계속 획득하지 못하게 됩니다. 따라서 Lock을 설정하는 범위를 알맞게 설정하는 것이 중요합니다.

 

 

그 다음으로 두개의 자원에 대한 Lock을 다룰때는 어떤 문제가 생길 수 있을까요?

 

 

Dead Lock

두 개 이상의 자원에서 서로 Blocking 되어 영구적으로 접근할 수 없는 상태(교착상태)입니다.

 

위의 transfer 메소드에서 사용자의 잔고를 줄이는 로직추가하여 예시로 들어보겠습니다. 로직은 다음과 같습니다. 그림그리는 것보다 코드가 더 읽기 쉽고 이해하기 편하니 코드로 작성하겠습니다.

    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        // 잔액 확인 및 업데이트를 위해 Exclusive Lock
        User fromUser = userRepository.findByIdWithLock(fromId);

        // 수신자의 계좌에 Exclusive Lock
        User toUser = userRepository.findByIdWithLock(toId);

        // 송금 처리
        fromUser.withdraw(amount);
        toUser.deposit(amount);
    }

 

시간 사용자A
(사용자A -> 사용자B에게 100원 송금)
사용자B
(사용자B -> 사용자A에게 100원 송금)
1 사용자A 락 획득 사용자B 락 획득
2 사용자B 락 획득시도 (Blocking) 사용자A 락 획득시도 (Blocking)
3 대기 대기
4 Dead Lock

 

그러면 위의 코드에서 Dead Lock이 발생하지 않게 하기 위해서는 어떻게 해아할까요?

락 획득의 순서에서 일과성을 유지하는 것입니다.

    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        User fromUser, toUser;
        if (fromId < toId) {
            fromUser = userRepository.findByIdWithLock(fromId);
            toUser = userRepository.findByIdWithLock(toId);
        } else {
            toUser = userRepository.findByIdWithLock(toId);
            fromUser = userRepository.findByIdWithLock(fromId);
        }
        fromUser.withdraw(amount);
        toUser.deposit(amount);
    }

 

사용자A ID = 1

사용자B ID = 2 라고 했을 때,

시간 사용자A
(사용자A -> 사용자B에게 100원 송금)
사용자B
(사용자B -> 사용자A에게 100원 송금)
1 사용자A 락 획득 사용자A 락 획득시도 Blocking
2 사용자B 락 획득 대기
3 송금 (락 반환) 사용자A 락 획득
4 - 사용자B 락 획득
5 - 송금 (락 반환)

 

 

결론

동시성 문제와 대용량 트래픽을 공부하기 위해 한정 수량 쿠폰과 대기열을 만들어보는 와중에 DeadLock와 데이터 무결성에 문제가 생겼고, 이를 해결하는 과정에서 Lock에 대해서 공부하게되었습니다. 다음 글은 JPA Lock 전략과 JAVA synchrozied, 그리고 JMeter 에 대해서 작성하려고합니다.

+ Recent posts