분산락이란?

distributedLock1

여러 요청들이 한 자원에 대해서 공유할 때 각 분산 DB의 동기화가 여러 요청의 동기화 속도를 못따라가는 상황이 발생한다.

이에 대해 데이터 정합성은 깨지고 데이터 동시성 문제가 발생한다.

distributedLock2

이를 해결하는 방안은 공유 자원을 레디스에 올려놓고 분산락(Distributed Lock)을 활용해서 데이터 동시성 문제를 해결할 수 있다.

여러 요청마다 락을 점유하고 데이터 업데이트 하기 때문에 각 서버는 각 DB의 동기화를 기다리지 않아도 되고 동시성 문제도 해결할 수 있다.

스핀락이란?

스핀락

락을 걸지 못하면 무한 루프를 돌면서 계속 락을 얻으려고 시도하는 동기화 기법이다. 만일 락을 얻지 못하는 경우 쉬지 않고 락을 얻으려고 시도하기 때문에 다른 lock이 하는일이 많아 대기가 길어진다면 시간낭비가 엄청나게 생길수 있다. 또한 분산락과 다르게 락을 획득하지 못했을 경우 1회성 요청을 하며 락을 얻을때까지 계속 요청을 보내면서 대기하기 때문에 서버에 많은 부하를 준다. 부하를 낮추기위해 락 요청시간을 길게한다면 락을 얻을 수 있는 시간임에도 불구하고 시스템에서 설정해 놓은 시간만큼 더 기다려야한다. 이 문제를 해결하기 위해 분산락을 사용한다.

스핀락을 사용하지 않고 구현한 락

스핀락을 사용하지 않는 레디스 클라이언트 Redisson은 서버 측에서 subscribe 한 클라이언트에게 “락을 사용해도 된다”라고 알람을 주어서 락의 획득 가능 여부를 일일이 클라이언트가 요청해서 확인하지 않아도 된다. 따라서 레디스 서버에 스핀락보다 훨씬 적은 부담을 준다.

락을 사용해야 하는 상황

분산 락은 데이터베이스 등 공통된 저장소를 이용해서 자원이 사용중인지를 체크하고 그렇기 때문에 전체 서버에 동기화된 처리가 가능하다. 분산락을 사용해야 하는 상황은 다음과 같다.

  • 선착순 이벤트가 있는 경우
  • 한 사람이 여러번의 클릭으로 동일한 상태를 방지하고 싶은 경우(한번 클릭한 기록이 있으면 더이상 클릭 해도 서버측에서 무언가를 처리하지 않도록 하고 싶은 경우)

간단한 분산락 구현

레디스 클라이언트로는 Lettuce를 사용한다. 아래 코드에 일반적인 로컬 스핀락을 구현하는 것과 유사하가게 분산락을 구현한다.

void doProcess() {
  String lockKey = "lock";
  
  try {
    while(!tryLock(lockKey)) { // try 구문 안에서 락을 획득할때 까지 계속 락을 획득한다. 레디스에 너무 많은 요청이 가지 않도록 sleep을 걸어준다.
      try {
        Thread.sleep(50);
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }
    }
    
    // 락을 획득한 이후 연산을 수행한다.
  } finally {
    unlock(lockKey); // 락을 사용한 후에는 해제하도록 finally 에서 락을 해제해준다.
  }
}

boolean tryLock(String key) {
  /* 락이 존재하는지 확인하고 존재하지 않으면 락을 획득하는 연산이 atomic하게 이루어져야한다. 레디스는 setnx 명령어로 값이 존재하지 않으면 세팅한다.
  * 이 setnx를 이용하여 레디스에 값이 존재하지 않으면 세팅하게 되고 값이 세팅되었는지 여부를 리턴값으로 받아 락을 획득하는데 성공한다.
  */
  return command.setnx(key, "1");
}

void unlock(String key) {
  command.del(key);
}

위 코드의 문제점

  1. Lock의 타임아웃이 지정되어있지 않다.

    위와 같이 스핀락을 구현했을 경우 락을 획득하지 못하면 무한 루프를 돌게 된다. 특정 어플리케이션에서 tryLock을 성공했는데 불운하게도 오류 때문에 애플리케이션이 종료된다면 다른 어플리케이션들은 영원히 락을 획득하지 못하게 된다.

    그렇기 때문에 일반적인 로컬 스핀락과 다르게 일정 시간이 지나면 락이 만료되도록 구현해야한다. 그럴려면 expire time을 설정해야한다.

    또한 무한적으로 락의 획득을 시도한다면 문제가 될 수 있다. 만일 연산이 오래 걸릴 경우 대부분의 스레드가 락을 대기하는 상태가 되어 클라이언트에 응답을 하는 속도가 늦어지고 동시에 레디스에 엄청난 트래픽을 보낼 수 있다. 그래서 락을 획득하는 최대 허용시간을 정해주거나 허용 횟수를 정해주는 것이 좋다.

  2. tryLock 로직은 try-finally 구문 밖에서 수행한다.

    1번을 해결했다고 가정하면 특정 횟수 혹은 횟수 내에 락을 획득하지 못하면 Exception이 발생한다. Exception이 발생한다면 finally 구문의 unlock이 실행되어 락을 해제할 타이밍이 아닌데도 락을 해제시키기 때문에 수행중 이더라도 다른 곳에서 연산을 수행할 수 있게 되어 동기화를 보장할 수 없게 된다. 이문제는 try-finally 구문 밖에서 락 획득을 시도함으로써 해결할 수 있다.

  3. 레디스에 많은 부하를 가하게 된다.

    위 코드는 스핀락을 사용했지만 스핀 락을 사용하면 레디스에 엄청난 부하를 주게된다. 스핀 락은 지속적인 락의 획득을 시도하는 작업이기 때문에 레디스에 계속 요청을 보내게되고 레디스는 트래픽 처리에 부담을 느끼게 된다.

    이에 레디스 클라이언트인 Redisson이 분산 락을 사용하는 방법을 알아보자

Redisson은 분산락을 어떻게 구현했는가

Redisson은 자바 레디스 클라이언트다. Lettuce와 비슷하게 Netty를 사용하여 non-blocking I/O를 사용한다. Redisson의 특이한 점은 직접 레디스의 명령어를 사용하지 않고, Bucket이나 Map 같은 자료구조나 Lock 같은 특정한 구현체의 형태로 제공한다는 점이다.

Lock에 타임아웃이 구현되어 있다.

Redisson은 tryLock 메소드에 타임아웃을 명시하도록 되어있다. 첫 번째 파라미터는 락 획득을 대기할 타임아웃이고 두번째 파라미터는 락이 만료되는 시간이다.

첫번째 파라미터 만큼 시간이 지나면 false가 반환되면서 락 획득에 실패했닥 알려준다. 두번째 파라미터만큼의 시간이 지나면 락이 만료되어 사라지기 때문에 애플리케이션이 락을 해제해주지 않더라도 다른 스레드 혹은 어플리케이션에서 락으 획득할 수 있다.

이로써 위 1번 문제를 해결할 수 있다.

SpinLock을 사용하지 않는다.

Redisson은 pubsub 기능을 사용하여 스핀 락이 레디스에 주는 부담을 줄였다. 락이 해제될때 subscribe하는 클라이언트들에게 락 획득을 시도해도 된다라는 알림을 주어서 일일이 레디스에 요청을 보내 락의 획득가능여부를 체크하지 않아도 되도록 개선했다.

다음은 Redisson의 Lock 획득 프로세스이다.

  1. 대기없는 tryLock 오퍼레이션을 하여 락 획득에 성공하면 true를 반환한다.
  2. pubsub을 이용하여 메시지가 올 때까지 대기하다가 락이 해제되었다는 메시지가 오면 대기를 풀고 다시 락 획득을 시도한다. 락 획득에 실패하면 다시 락 해제 메시지를 기다린다. 이 프로세스는 타임아웃까지 반복한다.
  3. 타임아웃이 지나면 최종적으로 false를 반환하고 락 획득에 실패했음을 알린다. 대기가 풀릴 때 타임아웃 여부를 체크하므로 타임아웃이 발생하는 순간은 파라미터로 넘긴 타임아웃시간과 약간의 차이가 있을 수 있다.

Lua 스크립트를 사용한다.

위와 같이 락의 기능을 제공하더라도 락에 사용되는 여러 연산은 atomic 해야한다. 실행순서가 엮일 수 있어 예상과 다른 결과가 나올 수 있기 때문이다.

  • 락의 획득가능 여부 확인과 획득은 atomic해야한다. 그렇지 않으면 락 획득이 가능하다고 응답받은 다음, 락 획득시도를 했는데 그 사이에 이미 다른 스레드에서 락을 획득해버려서 락 획득을 실패하는 경우가 있다.
  • 락의 해제와 pubsub알림은 atomic해야한다. 그렇지 않으면 락이 해제되고 바로 다른 스레드에서 락을 획득했을 때에도 락 획득을 시도해도 된다는 알림이 갈 수 있다.

레디스는 싱글 스레드 기반으로 연산하기 때문에 atomic 연산을 비교적 쉽게 구현할 수 있다. 레디스는 트랜잭션 Lua 스크립트로 atomic 연산을 지원한다.

트랜잭션은 명령어를 트랜잭션으로 묶는 기능이기에 명령어의 결과를 받아서 다른 연산에 활용하는 atomic한 연산을 구현하기 어렵다. lua 스크립트를 사용하면 atomic을 보장하는 스크립트를 구현할 수 있다.

redisson은 이러한 lua 스크립트를 많이 사용하고 있다.

출처

Redisson 분산락을 이용한 동시성 제어 (velog.io)

[레디스와 분산 락(1/2) - 레디스를 활용한 분산 락과 안전하고 빠른 락의 구현 Hyperconnect Tech Blog](https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html)