안녕하세요, 하이퍼커넥트 Azar API팀의 Fitz 입니다.

아자르 API는 전세계에서 오는 많은 트래픽을 받고 있기 때문에 수평적인 확장에 용이한 구조를 지향하고 있고, 실제로 여러 대의 서버로 API를 운영하고 있습니다.

서비스 특성상 트랜잭션이 많이 일어나는 서비스를 운영하다보니 동기화된 처리가 필요했고, 여러 서버에 공통된 락을 적용해야했기에 레디스를 활용하여 분산 락을 사용하게 되었습니다.

일반적으로 자바에서는 공유된 자원에 여러 스레드가 접근할 때 락을 사용합니다. 하지만 여러 서버를 운영하는 분산환경에서는 한 서버에 락이 걸려있더라도 다른 서버로 동일한 요청이 가게 된다면 동기화를 보장할 수 없습니다.

분산 락은 데이터베이스 등 공통된 저장소를 이용하여 자원이 사용 중인지를 체크합니다. 그래서 전체 서버에서 동기화된 처리가 가능해집니다.

간단한 분산 락 구현

아래의 코드에 일반적인 로컬 스핀 락을 구현하는 것과 유사하게 아주 간단한 분산 락을 구현해보았습니다. 레디스 클라이언트로는 Lettuce를 사용하였습니다.

코드를 보시면서 읽으면 더 수월하게 읽으실 수 있습니다. 코드에 주석으로 달려있는 번호는 아래의 번호와 같습니다.

  1. 락을 획득한다는 것은 “락이 존재하는지 확인한다”, “존재하지 않는다면 락을 획득한다” 두 연산이 atomic하게 이루어져야합니다. 레디스는 “값이 존재하지 않으면 세팅한다” 라는 setnx 명령어를 지원합니다. 이 setnx를 이용하여 레디스에 값이 존재하지 않으면 세팅하게 하고, 값이 세팅되었는지 여부를 리턴 값으로 받아 락을 획득하는데에 성공했는지 확인합니다.
  2. try 구문 안에서 락을 획득할때까지 계속 락 획득을 시도합니다. 그리고 혹시라도 레디스에 너무 많은 요청이 가지 않도록 약간의 sleep을 걸어줬습니다.
  3. 락을 획득한 후에 연산을 수행합니다.
  4. 락을 사용 후에는 꼭 해제하도록 finally에서 락을 해제해줍니다.
void doProcess() {     String lockKey = "lock";      try {         while (!tryLock(lockKey)) { // (2)             try {                 Thread.sleep(50);             } catch (InterruptedException e) {                 throw new RuntimeException(e);             }         }                  // (3) do process     } finally {         unlock(lockKey); // (4)     } }  boolean tryLock(String key) {     return command.setnx(key, "1"); // (1) }  void unlock(String key) {     command.del(key); } 

분산 락 개발시 나타날 수 있는 문제점과 해결 방법

위의 코드에는 여러가지 문제점이 있습니다. 이 문제점들을 짚어보면서 분산 락을 구현할 때 주의할 점을 알아보겠습니다.

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

위의 코드와 같이 스핀 락을 구현했을시에 락을 획득하지 못하면 무한 루프를 돌게 됩니다. 만약 특정한 어플리케이션에서 tryLock을 성공했는데 불운하게도 어떤 오류때문에 어플리케이션이 종료되어버리면 어떻게 될까요? 다른 모든 어플리케이션까지 영원히 락을 획득하지 못한 채 락이 해제되기만을 기다리는 무한정 대기상태가 되어 전체 서비스의 장애가 발생하게 될 것입니다.

그래서 일반적인 로컬 스핀 락과는 다르게 일정 시간이 지나면 락이 만료되도록 구현해야 합니다. 그럴려면 expire time을 설정해주어야합니다. 하지만 위의 코드에서는 “락을 사용중인지 확인”, “락을 획득” 연산을 하나로 묶기 위해 setnx 명령어를 사용했습니다. 이 명령어는 expire time을 지정할 수 없기에 이 문제를 해결하기가 힘듭니다.

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

2. tryLock 로직은 try-finally 구문 밖에서 수행해야합니다.

1번을 해결했다고 가정한다면 특정 시간 혹은 횟수 내에 락을 획득하지 못하면 Exception이 발생하게 됩니다.

Exception이 발생한다면 finally 구문의 unlock()이 실행되어 락을 해제할 타이밍이 아닌데도 락을 해제시키기 때문에 작업이 수행중이더라도 다른 곳에서도 연산을 수행할 수 있게되어 동기화를 보장할 수 없게됩니다.

이 문제는 단순히 try-finally 구문 밖에서 락 획득을 시도함으로써 해결할 수 있습니다.

int maxRetry = 3; int retry = 0;  while (!tryLock(lockKey)) {     if (++retry == maxRetry) {         throw new LockAcquisitionFailureException();     }          try {         Thread.sleep(50);     } catch (InterruptedException e) {         throw new RuntimeException(e);     } }  try {     // do process } finally {     unlock(lockKey); } 


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

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

스핀 락을 사용하면서 레디스에 부담을 덜 주기위해 50ms만큼 sleep하면서 tryLock을 수행하도록 했지만, 이 또한 50ms마다 계속 레디스에 요청을 보내는 것이므로 작업이 오래 걸릴수록, 요청 수가 많을 수록 더 큰 부하를 가하게 됩니다.

만약 300ms가 걸리는 동기화된 작업에 동시에 100개의 요청이 왔다고 가정해보겠습니다. (분산 락이므로 서버의 대수는 무관합니다.)

처음으로 락을 획득하는데 성공한 1개의 요청을 제외하고, 나머지 99개의 요청은 작업이 완료되는 300ms 동안 무려 레디스에 594회의 락 획득 요청을 하게 됩니다. 즉 1초 동안 약 2000회라는 많은 요청을 레디스에 보내게 됩니다.

또한 일회성이 아니라 모든 작업이 완료될때까지 지속적으로 레디스에 부하를 가하기 때문에 요청이 지속적으로 들어오는 환경이라면 이러한 비효율성은 더욱 커집니다.

만약 레디스에 부담을 덜 주기 위해 sleep 시간을 300ms로 늘린다면 어떨까요? 50ms가 걸리는 작업에 이 동기화를 적용하면 락을 획득하지 못할 경우 50ms 걸리는 작업을 하기 위해 300ms를 대기해야하는 다른 비효율적인 상황이 생기게 됩니다.

이제는 오픈소스 레디스 클라이언트인 Redisson이 분산 락을 어떻게 설계했는지 소개하며 어떻게 이 문제점들을 해결했고, 보다 빠른 성능을 내게 되었는지 설명하겠습니다.

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

RedissonJedis, Lettuce 같은 자바 레디스 클라이언트입니다.

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


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

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

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

이로 인해 락이 해제되지 않는 문제로 무한 루프에 빠질 위험이 사라졌기 때문에 위의 1번 문제를 해결할 수 있습니다.

// RedissonLock의 tryLock 메소드 시그니쳐 public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException 


2. 스핀 락을 사용하지 않습니다.

Redisson은 기본적으로 스핀 락을 사용하지 않기 때문에 레디스에 부담을 주지 않습니다. 그럼 어떻게 락의 획득 가능여부를 판단할까요?

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

또한 Redisson은 최대한 레디스와 어플리케이션에 부하를 주지 않도록 신경쓴 모습이 보입니다. 아래는 Redisson의 Lock 획득 프로세스입니다.

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

3. Lua 스크립트를 사용합니다.

위와 같이 락의 기능을 제공하더라도 락에 사용되는 여러 연산은 atomic해야 합니다. 그 이유는 각 명령어를 따로 보내게 되면 두 연산이 atomic하지 않게 수행되기 때문에 명령어의 실행 순서가 섞일 수 있어 예상과 다른 결과가 나올 수 있기 때문입니다.

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

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

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

Redisson은 이러한 Lua 스크립트를 많이 활용하고 있습니다. RedissonLock에서도 Lua 스크립트를 사용하여 연산의 atomic을 보장하면서도, 레디스에 보내는 요청 수를 현저하게 줄여 성능을 높이고 있습니다.

// in RedissonLock.java <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {     internalLockLeaseTime = unit.toMillis(leaseTime);      return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,               "if (redis.call('exists', KEYS[1]) == 0) then " +                   "redis.call('hset', KEYS[1], ARGV[2], 1); " +                   "redis.call('pexpire', KEYS[1], ARGV[1]); " +                   "return nil; " +               "end; " +               "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +                   "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +                   "redis.call('pexpire', KEYS[1], ARGV[1]); " +                   "return nil; " +               "end; " +               "return redis.call('pttl', KEYS[1]);",                 Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); }  

마무리

이번 글에서는 레디스를 이용한 분산 락의 구현에 대해 알아보았습니다.

위의 3번과 같이 Lua 스크립트를 사용하면 여러 atomic을 보장하는 연산을 쉽고 빠른 성능으로 구현할 수 있습니다. 스핀 락을 어플리케이션 레벨에서 직접 구현하여 레디스에 많은 트래픽을 보내기 전에, 먼저 레디스에서 제공하는 트랜잭션이나 Lua 스크립트로 문제를 해결할 수 있는지를 판단해보면 더 좋은 성능의 어플리케이션을 만들어낼 수 있을 겁니다.

또한 Redisson은 정말 좋은 라이브러리이긴 하지만 아주 많은 구현체들을 지원하기 때문에 각 구현체들이 많은 트래픽에도 효율적으로 동작할 수 있는지 사용하기 전에 내부의 구현 내용을 확인해보고 사용하시는 것을 추천드립니다.

다음 글에서는 스프링에서 분산 락의 문제점을 발견하고, 해당 문제점을 수정한 경험에 대해 이야기할 예정입니다.

감사합니다.