개발/cache

분산락 - redis 사용

방푸린 2024. 11. 8. 14:43
반응형

분산락 (Distributed Lock)

분산락은 분산 시스템에서 여러 인스턴스가 동시에 동일한 리소스를 수정하거나 접근할 때, 경쟁 조건(race condition)을 방지하기 위해 사용하는 메커니즘입니다. 주로 여러 서버에서 동시에 동일한 데이터나 자원에 접근할 때, 하나의 서버만이 리소스를 수정하거나 작업을 진행할 수 있도록 동기화(synchronization)합니다.

분산락은 시스템의 일관성을 보장하며, 여러 서비스가 동시에 동일한 작업을 수행하지 않도록 하여 데이터의 무결성을 유지합니다.

분산락 구현 방법

분산락을 구현하는 방법은 여러 가지가 있으며, 각각의 방식은 특정 상황에 맞춰 사용할 수 있습니다. 주요 분산락 구현 방법은 다음과 같습니다:

  1. 데이터베이스 기반 락 (Database Locking)
    • 비관락 쓰기락 베타락
      • 구현 방식: 데이터베이스에서 특정 레코드를 업데이트하거나 SELECT FOR UPDATE와 같은 쿼리를 사용하여 락을 걸고, 이를 통해 락을 구현합니다. 
      • 장점: 간단하게 구현할 수 있으며, 대부분의 관계형 데이터베이스가 지원합니다.
      • 단점: 성능 문제, 락 경합, 교착 상태(deadlock) 등이 발생할 수 있습니다.
    • 네임드락 (Named Lock)
      • 구현 방식: 네임드락은 일반적으로 데이터베이스가 제공하는 GET_LOCK(), RELEASE_LOCK() 등의 함수를 사용하여 이름이 지정된 락을 설정합니다. 이 락은 데이터베이스의 특정 리소스가 아니라, 지정된 이름을 가진 락을 사용하여 락을 설정합니다. 트랙젝션 단위가 아닌 세션 단위의 락
  2. Redis 기반 락 (Redis Lock)
    • 구현 방식: Redis의 SETNX (SET if Not Exists)와 EXPIRE 명령어를 사용하여 락을 구현합니다. 이 방식은 Redis 서버를 통해 분산 환경에서 빠르고 효율적으로 락을 관리할 수 있습니다.
    • 장점: 빠르고, TTL(시간 만료)을 지원하여 자동으로 락을 해제할 수 있습니다. 분산 환경에 적합합니다.
    • 단점: Redis 서버가 다운될 경우 락이 풀리지 않는 문제가 발생할 수 있습니다.
  3. Zookeeper 기반 락 (Zookeeper Lock)
    • 구현 방식: Zookeeper를 활용하여 분산 락을 구현합니다. Zookeeper의 Ephemeral Node와 Watchers 기능을 사용하여 락을 관리합니다.
    • 장점: 강력한 일관성과 고가용성을 제공합니다. 락이 해제되면 자동으로 다른 노드가 락을 획득할 수 있습니다.
    • 단점: 설정이 복잡하고, 성능 이슈가 발생할 수 있습니다.
  4. Consul 기반 락 (Consul Lock)
    • 구현 방식: Consul은 Session과 Key/Value Store를 사용하여 분산 락을 구현할 수 있습니다. 락은 세션을 기반으로 하며, 세션이 만료되면 락이 자동으로 해제됩니다.
    • 장점: Consul의 분산 시스템과 고가용성을 활용할 수 있습니다.
    • 단점: Consul을 설정하고 관리하는 복잡성이 있습니다.
  1.  

Redis 기반 분산락 구현 예시

Redis를 사용하여 분산락을 구현하는 방법을 소개합니다. Redis의 SETNX 명령어와 EXPIRE 옵션을 활용하여 락을 구현할 수 있습니다.

  1. 분산 락을 획득하고, 획득되지 않으면 예외를 던지는 방식 (Throwing)
  2. 스핀락 방식 (Spinlock)

1. Redis 분산락 구현(예외를 던지는 방식)

Redis에서 분산락을 구현할 때 사용할 수 있는 핵심 명령어는 SETNX와 EXPIRE입니다.

  • SETNX: Key가 존재하지 않으면 값을 설정하고, 존재하면 아무 작업도 하지 않습니다. 락을 구현하는 데 유용합니다.
  • EXPIRE: Key에 대한 TTL(시간 제한)을 설정하여 일정 시간 후 락을 자동으로 해제할 수 있습니다.
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisDistributedLock {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String LOCK_PREFIX = "lock:";

    public RedisDistributedLock(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // 락을 얻고, 성공하면 block을 실행, 실패하면 예외를 던지는 방식
    public <T> T lockAndRun(String key, long timeout, TimeUnit timeUnit, Runnable block) {
        String lockKey = LOCK_PREFIX + key;
        boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", timeout, timeUnit);

        if (lockAcquired) {
            try {
                block.run();
            } finally {
                releaseLock(lockKey);
            }
        } else {
            throw new RuntimeException("Unable to acquire lock for key: " + key);
        }

        return null;
    }

    // 락 해제
    private void releaseLock(String key) {
        redisTemplate.delete(key);
    }
}
  • setIfAbsent: Redis의 SETNX 명령어를 사용하여 락을 획득하려고 시도합니다. 락이 이미 있을 경우 false를 반환하고, 없으면 true를 반환하여 락을 획득합니다.
  • timeout과 timeUnit: 락을 획득할 수 있는 시간 제한을 설정합니다. 이 시간이 지나면 자동으로 락이 풀립니다.
  • 락을 성공적으로 획득하면 block.run()이 실행되고, 작업이 끝난 후 락을 해제합니다.
  • 락을 획득하지 못하면 예외를 던집니다.

장점:

  • 직관적: 락을 획득하지 못했을 때 바로 예외를 던지므로 호출하는 쪽에서 락 실패에 대한 처리를 명확히 할 수 있습니다.
  • 성공적인 락 획득 후 작업 실행 보장: 락을 획득한 후 작업이 실행되므로, 동시에 여러 프로세스에서 동일한 리소스를 수정하지 않게 됩니다.

단점:

  • 락 획득 실패시 예외 처리 필요: 락을 획득하지 못했을 경우 예외가 발생하므로 호출자 측에서 이를 처리해야 합니다. 예외가 자주 발생할 경우 성능에 영향을 줄 수 있습니다.
  • 락 획득 실패에 대한 대처가 복잡할 수 있음: 예외를 던지면 호출자가 반드시 예외를 처리해야 하므로, 이 부분에서 코드가 복잡해질 수 있습니다.

2. Redis 분산 락 구현 (스핀락 방식)

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisSpinlock {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String LOCK_PREFIX = "lock:";

    public RedisSpinlock(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // 스핀락을 이용한 락 획득 및 실행
    public <T> T spinLockAndRun(String key, long timeout, TimeUnit timeUnit, Runnable block) {
        String lockKey = LOCK_PREFIX + key;

        long start = System.currentTimeMillis();
        boolean lockAcquired = false;

        while (System.currentTimeMillis() - start < timeUnit.toMillis(timeout)) {
            lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", timeout, timeUnit);
            if (lockAcquired) {
                try {
                    block.run();
                    break;  // 성공적으로 작업을 마쳤으면 루프 종료
                } finally {
                    releaseLock(lockKey);
                }
            }

            try {
                // 락을 획득하지 못하면 일정 시간 대기 후 재시도 (스핀락)
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }

        if (!lockAcquired) {
            throw new RuntimeException("Unable to acquire lock for key: " + key);
        }

        return null;
    }

    // 락 해제
    private void releaseLock(String key) {
        redisTemplate.delete(key);
    }
}

설명:

  • 스핀락(Spinlock): 락을 획득하지 못하면 일정 시간 동안 계속해서 락을 시도하며 기다립니다. setIfAbsent로 락을 시도하고, 락을 획득하지 못하면 sleep을 이용해 일정 시간 대기한 후 다시 시도합니다.
  • timeout과 timeUnit: 락을 시도할 최대 시간을 설정하고, 락을 획득한 후 해당 시간 동안 작업을 실행합니다.
  • 락을 성공적으로 획득한 후 작업을 실행하고, 완료 후 락을 해제합니다.

장점:

  • 낮은 대기 시간: 예외를 던지지 않고 계속해서 락을 시도하므로 예외 처리보다 더 부드러운 흐름을 유지할 수 있습니다.
  • 재시도 방식: 락을 획득하지 못하면 일정 시간 대기 후 재시도하므로, 락 경합이 많을 때 유용할 수 있습니다.

단점:

  • CPU 자원 낭비: 락을 시도하며 대기하는 동안 CPU를 계속 소모하게 되어, 시스템 부하가 증가할 수 있습니다.
  • 무한 루프 문제: 락을 계속해서 시도하지만, 특정 상황에서는 결국 락을 얻지 못하고 무한 루프에 빠질 수 있습니다. 이를 해결하려면 재시도 횟수를 제한해야 합니다.
  • 성능 저하: 락이 자주 경합되면, 스핀락을 반복하면서 성능 저하가 발생할 수 있습니다.
728x90
반응형