본문 바로가기

서버 개발

레이스 컨디션 (Race Condition) -03. Lettuce, Redisson

일반적으로 분산 서버 환경에서 하나의 데이터베이스를 사용하더라도 여러 대의 서버가 해당 데이터베이스에 동시에 접근하고 데이터를 수정하려고 할 때는 여전히 동시성 문제가 발생할 수 있다.

이러한 상황에서 낙관적 락(Optimistic Locking)과 비관적 락(Pessimistic Locking)이 물론 유용할 수 있지만,

분산 락(Distributed Locking)이나 다른 분산 동시성 제어 메커니즘을 고려해야 하는 경우가 생길 수 있다.

그 이유는 다음과 같다.

 

  1. 데이터베이스 트랜잭션 범위: 여러 서버에서 하나의 데이터베이스에 접근할 때, 트랜잭션의 범위가 여러 서버에 걸칠 수 있다. 이 경우, 낙관적 락과 비관적 락만으로는 트랜잭션 일관성을 보장하기 어려울 수 있다.
  2. 동시성 제어의 복잡성: 분산 환경에서 여러 서버 간에 동시성을 관리하려면 각 서버에서의 락을 획득하고 해제하는 방법을 동기화해야 한다. 이러한 동기화는 분산 락 또는 다른 분산 동시성 제어 메커니즘이 필요한 이유 중 하나가 될 수 있다.
  3. 데이터 일관성 유지: 여러 서버 간의 데이터 일관성을 유지하려면 데이터의 동기화와 동시성 제어가 필요한데, 이러한 사항을 만족시키기 위해서는 분산 락 또는 데이터 일관성을 보장하는 다른 메커니즘이 필요할 수 있다.
  4. 복제 및 샤딩: 분산 데이터베이스 시스템에서 데이터의 복제와 샤딩을 사용하는 경우, 데이터 접근 패턴에 따라 다양한 복제 및 샤딩 전략을 고려해야 한다. 이러한 전략과 관련된 동시성 문제를 관리하기 위해서 분산 동시성 제어가 필요할 수 있다.

 

위에서 든 이유들이 큰 틀에서는 비슷한 이유이지만, 결론적으로 분산 서버 환경에서 여러 서버가 하나의 데이터베이스에 접근하고 데이터 일관성과 동시성을 보장하기 위해서는 분산 락 또는 다른 분산 동시성 제어 메커니즘을 고려해야 한다.

그리고 이를 비교적 손쉽게 도와주는 LettuceRedisson 를 사용할 수 있다. 둘 모두 Redis를 사용하기 위한 클라이언트 라이브러리인데 Lettuce와 Redisson의 특징은 무엇이고 어떻게 사용될 수 있는지, 그리고 차이는 무엇인지 등 자세히 알아보는 시간을 갖으려고 한다.


1 . Lettuce

Lettuce란 Redis 클라이언트 라이브러리이다. 특징은 다음과 같다.

  • Netty를 기반으로 구축되어 있다. 즉 비동기 이벤트 기반 네트워크 프레임워크를 사용하여 구현되어 있으므로, 네트워크 통신에서 발생할 수 있는 블로킹 문제없이 빠른 I/O 처리가 가능하다.
  • 동기, 비동기, 리액티브 패턴을 모두 지원한다.
  • Redis 클라이언트이므로 당연하게 Redis의 모든 기능을 지원한다.
  • 여러 Redis 서버와의 안정적인 통신을 관리하기 위한 광범위한 연결 및 스레딩 옵션을 제공한다.
    (Redis 서버를 구축할 때 적절한 장애대응을 위해 한대의 Master와 한대 이상의 Replica 서버로 구성)
  • Lettuce 클라이언트 인스턴스는 여러 쓰레드에서 공유될 수 있고, 연결 인스턴스가 안전하게 공유된다.

이러한 특징을 갖고 있는 Lettuce를 이용하여 레이스 컨디션을 해결할 수 있다.'SETNX' (Set if not exists) 명령어를 이용하는 방법이 그중 하나이다.Redis에서 'SETNX' 명령어를 사용해 본 결과이다.

127.0.0.1:6379> setnx 1 lock //키1 에 값 lock 설정 시도
(integer) 1 //1이라는 키가 성공적으로 설정되어 성공을 나타내는 integer 1값 반환
127.0.0.1:6379> setnx 1 lock //키1 에 값 lock 설정 다시 시도
(integer) 0 //1이라는 키가 이미 있으므로 설정 실패를 나타내는 integer 0값 반환
127.0.0.1:6379> del 1 //키 1 삭제
(integer) 1 //성공을 나타내는 1값 반환
127.0.0.1:6379> setnx 1 lock //키1 에 값 lock 설정 재시도
(integer) 1 //이전단계에서 키1 을 삭제하였으므로, 1이라는 키가 성공적으로 설정됨

 

이러한 과정은 레이스 컨디션을 해결하는 시뮬레이션이 될 수 있다. 이 아이디어를 다음과 같이 다시 쓸 수 있다.

  1. 레이스 컨디션을 해결해야 하는 공유 자원(예: Redis의 키)에 대해 SETNX 명령어를 사용한다.
  2. 이때, 각 프로세스나 스레드는 자신이 사용하려는 공유 자원의 키를 가지고 SETNX 명령어를 실행한다.
  3. SETNX 명령어는 해당 키가 존재하지 않을 때만 값을 설정하므로, 여러 프로세스나 스레드가 동시에 SETNX를 실행해도 최종적으로는 한 번만 성공하게 된다.
  4. SETNX 명령어가 성공하면 해당 프로세스나 스레드는 공유 자원을 안전하게 사용할 수 있다. 그렇지 않은 경우에는 이전에 값을 설정한 다른 프로세스나 스레드가 이미 자원을 확보한 것이므로, 현재 프로세스나 스레드는 다른 처리 방식을 선택할 수 있다.

4번 단계에서와 같이 자원을 사용할 수 없는 경우, 락 획득을 시도하는 프로세스나 스레드가 락을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 Spin Lock방식을 사용하여 레이스 컨디션을 해결한다. 이러한 Spin Lock방식에서는 개발자가 retry, timeout 과 같은 기능을 직접 구현해 주어야 한다.

예시코드는 아래와 같다.

 

Lettuce를 사용한 분산 락 구현

import io.lettuce.core.RedisClient;
import io.lettuce.core.SetArgs;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;

public class RedisLock {
    private RedisClient redisClient;
    private StatefulRedisConnection<String, String> connection;

    public RedisLock(String redisUrl) {
        this.redisClient = RedisClient.create(redisUrl);
        this.connection = redisClient.connect();
    }

    public boolean acquireLock(String lockKey, int timeoutSec, int retryDelay) {
        RedisCommands<String, String> commands = connection.sync();
        long timeoutMillis = System.currentTimeMillis() + timeoutSec * 1000;
        //정해진 타임아웃 시간동안 락 획득 시도
        while (System.currentTimeMillis() < timeoutMillis) {
            //키가 존재하지 않을 때(nx()) 키 만료시간을 10초로(ex(10)) 설정한 락 획득 시도
            String result = commands.set(lockKey, "locked", SetArgs.Builder.nx().ex(10));
            if ("OK".equals(result)) {
                return true;
            }
            try {
                Thread.sleep(retryDelay);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }
        }
        return false;
    }

    public void releaseLock(String lockKey) {
        connection.sync().del(lockKey);
    }

    public void closeConnection() {
        connection.close();
        redisClient.shutdown();
    }
}

 

사용 코드

public class Application {
    public static void main(String[] args) {
        RedisLock lock = new RedisLock("redis://localhost:6379");

        if (lock.acquireLock("myLock", 5, 500)) { // 5초 동안 최대 500ms 간격으로 재시도
            try {
                // 락 획득 성공
                System.out.println("Lock acquired");
                // 수행할 작업
            } finally {
                lock.releaseLock("myLock");
                System.out.println("Lock released");
            }
        } else {
            System.out.println("Failed to acquire lock");
        }

        lock.closeConnection();
    }
}

 

위 구현 코드 중

String result = commands.set(lockKey, "locked", SetArgs.Builder.nx().ex(10)); 

는 락 획득 시도에 대한 결과를 나타내는 코드이다. SetArgs.Builder.nx().ex(10))를 좀더 자세히 봐보자면

  • lockKey: 락을 설정할 키
  • "locked": 키에 설정할 값.
  • SetArgs.Builder.nx().ex(10): SET 명령의 옵션으로, nx()는 키가 존재하지 않을 때만 값을 설정하도록 지정하고, ex(10)는 설정된 키의 만료 시간을 10초로 설정한다.

ex(10) 코드의 키의 만료 시간 10초 부여는 분산락을 구현할 때 분산 락 메커니즘의 안정성 및 효율성을 향상시키는 중요한 역할을 한다. 자세한 내용은 아래와 같다.

  1. 데드락 방지 : 분산 시스템에서 데드락은 여러 프로세스가 서로의 자원을 기다리면서 발생할 수 있는 문제이다. ex(10)으로 설정된 만료 시간은 락이 자동으로 해제되도록 함으로써 이러한 데드락 상황을 방지할 수 있다.
  2. 락의 자동 해제 : 만약 락을 획득한 프로세스가 어떤 이유로든 중단되거나 크래시가 발생해 정상적으로 락을 해제할 수 없는 경우, 설정된 만료 시간이 지나면 락이 자동으로 해제된다. 이는 시스템의 복원력을 높이고, 자원을 영구적으로 점유하는 문제를 예방한다.
  3. 시스템 안정성 유지 : 분산 시스템의 다양한 컴포넌트 간에 발생할 수 있는 네트워크 지연이나 장애에 대응하기 위해, 만료 시간은 락의 수명을 제어하여 시스템의 안정성을 유지하는 데 도움을 준다. 이는 예상치 못한 상황에서도 시스템이 계속해서 잘 작동할 수 있도록 보장한다.

 

 

2. Redisson

Redisson또한 Java 객체를 Redis에 저장하고 쉽게 조작할 수 있게 해주는 Redis 클라이언트이다. 주요 특징은 다음과 같다.

  • Map, Set, List, Queue 등 자바 컬렉션 인터페이스를 구현하여, Redis를 백엔드로 사용하는 분산 데이터 구조를 제공한다.
  • Lock, Semaphore, CountDownLatch, AtomicLong 등 다양한 동기화 도구를 제공해 분산 환경에서 데이터 동시성을 관리할 수 있다.
  • Redis에 저장되는 자바 객체의 직렬화 및 역직렬화 작업을 자동화해준다.
  • 비동기, 동기, 리액티브 프로그래밍을 모두 지원한다.
  • Redis의 Pub/Sub 기능(발행/구독 모델)을 활용해 이벤트 기반 시스템을 구축할 수 있다.

 

여러가지 특징이 있지만, 레이스 컨디션 해결을 위해 마지막으로 언급된 Pub/Sub 기능을 이용하여 분산락을 구현해 레이스 컨디션을 해결할 수 있다.

Pub/Sub 을 이용한 분산락 구현의 아이디어는 아래와 같다.

  1. 프로세스가 락을 요청하면 Redisson은 우선 Redis에 락이 설정되어 있는지 확인한다.
  2. 락이 이미 획득되어 있는 경우, 요청한 프로세스는 해당 락 해제 이벤트를 기다리기 위해 Pub/Sub 채널을 구독한다.
  3. 한편 락을 보유한 프로세스가 작업을 마치고 락을 해제하면, Redisson은 Pub/Sub 채널을 통해 이 이벤트를 전달한다.
  4. 대기 중이던 프로세스가 이 이벤트를 받으면, 락 획득 시도를 재개한다.

구현 코드는 다음과 같다.

 

1. RLock을 사용한 구현

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

import java.util.concurrent.TimeUnit;

public class RedissonLockExample {
    public static void main(String[] args) throws InterruptedException {
        // Redisson 클라이언트 설정
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        RedissonClient redisson = Redisson.create(config);

        // 분산 락 획득(Rlock)
        RLock lock = redisson.getLock("myLock");
        if (lock.tryLock(10, 5, TimeUnit.SECONDS)) {
            try {
                System.out.println("lock acquired, performing task...");
            } finally {
                lock.unlock();
                System.out.println("lock released.");
            }
        } else {
            System.out.println("unable to acquire lock, task in progress.");
        }

        redisson.shutdown();
    }
}

 

분산 락을 사용하기 위해 Rlock객체를 사용했으며, 이때 파라미터는 (대기시간, 보유시간, 시간단위) 이다.

즉, 락을 시도하는 대기시간을 설정하여 해당시간 내에 락을 얻을 수 있는지 확인하고

락을 획득했다면 보유시간동안 보유할 수 있게 된다. 

이러한 tryLock 메서드는 다음과 같은 장점을 갖는다.

  • 무한정 기다리지 않고, 지정된 시간만큼만 대기하기 때문에 데드락 상황을 피할 수 있다.
  • leaseTime을 사용하여 락을 자동으로 해제할 수 있어, 예상치 못한 크래시나 오류 상황에서도 다른 프로세스가 락을 얻을 수 있다.

 

2. RReadWriteLock을 이용한 구현도 가능해 보인다(이 글에서는 생략)

...


 

위에서 스핀 락(Lettuce)과 Pub/Sub 패턴(Redisson)을 사용하여 레이스 컨디션을 해결할 수 있는 예시 및 특징을 알아보았다. 위 내용을 간략하게 정리한 후 어떤 경우에 어떤 방법을 사용하는 것이 좋은지에 대해 정리하려고 한다.

 

스핀 락 (Spin Lock)Pub/Sub

특성 스핀락 Pub/Sub
작동 방식 락이 사용 중일 때, 획득할 수 있을 때까지 지속적으로 상태 확인 락이 해제될 때까지 이벤트를 구독하여 대기
CPU 부하 높음, 지속적으로 상태를 확인하므로 CPU 사용률 증가 낮음, 이벤트 기반 대기로 불필요한 CPU 사용 없음
잠재적 지연 없음, 즉시 락을 획득할 수 있음 있을 수 있음, 이벤트 수신 및 락 획득에 지연 가능
확장성 낮음, 여러 스레드가 동시에 상태 확인 시 성능 저하 가능 높음, 다수의 스레드가 대기해도 효율적으로 처리 가능
복잡성 낮음, 구현이 비교적 단순함 높음, Pub/Sub 메커니즘 구현이 복잡할 수 있음
적합한 사용 사례 락이 짧은 시간 내에 해제될 가능성이 높은 경우 긴 시간 동안 락이 필요하고, 다수의 스레드가 대기할 경우
효율성 낮음, 불필요한 CPU 사용 발생 높음, 락 해제 이벤트 대기를 통한 효율적 처리

 

  • 작업 성격에 따른 선택: 스핀 락은 짧은 시간 내에 락이 해제될 가능성이 높은 경우에 유리하지만, 긴 작업이 필요한 경우는 비효율적이다. Pub/Sub은 작업의 길이에 상관없이 스레드의 자원 낭비를 줄인다.
  • 시스템 특성: 스핀 락은 CPU 자원이 충분한 환경에서 더 효과적이지만, CPU 자원이 제한된 환경에서는 Pub/Sub이 더 나은 성능을 제공할 수 있어 보인다.
  • 복잡성 및 유지보수: 스핀 락은 구현이 단순하지만 고성능 시스템에서는 Pub/Sub의 효율성을 감안해 더 복잡한 시스템을 선택하는 것이 좋다.

 

spin lock과 pub/sub을 나타내는 이미지 그려줘