Container Orchestration/Kubernetes

Kubernetes 환경에서 Redis Redlock 구현하기: 분산 락의 완전한 이해

Somaz 2025. 12. 24. 07:48
728x90
반응형

Overview

분산 시스템에서 동시성 제어는 항상 까다로운 문제다. 여러 인스턴스가 동시에 같은 리소스에 접근하려 할 때, 데이터 일관성을 보장하고 race condition을 방지하기 위해서는 적절한 락(Lock) 메커니즘이 필요하다.

 

Redis를 사용한 분산 락 구현 시, 단순한 SET NX 명령어만으로는 네트워크 파티션이나 Redis 인스턴스 장애 상황에서 안전성을 보장하기 어렵다. 이런 문제를 해결하기 위해 Redis 창시자 Salvatore Sanfilippo가 제안한 것이 바로 Redlock 알고리즘이다.

 

이 글에서는 Redlock의 동작 원리부터 Kubernetes 환경에서의 실제 구현 방법, 그리고 운영 시 주의사항까지 상세히 알아본다. 특히 많은 개발자들이 놓치기 쉬운 Kubernetes StatefulSet 환경에서의 함정과 이를 해결하는 방법을 중점적으로 다룬다.

 

 

 

 

 

📅 관련 글

2022.09.26 - [Open Source Software] - Redis(Remote Dictionary Server)란?

2025.04.02 - [CS 지식] - [CS 지식20.] OS 캐시와 디스크 I/O: MySQL, Redis 퍼포먼스 분석

 

 

 

 


 

Redlock 알고리즘이란?

 

 

기본 개념

Redlock은 여러 독립적인 Redis 인스턴스를 사용하여 분산 환경에서 안전하고 신뢰할 수 있는 락을 구현하는 알고리즘이다. 핵심 아이디어는 간단하다.

클라이언트가 락을 획득하려면 N개의 독립적인 Redis 인스턴스 중 과반수(N/2 + 1) 이상에서 락을 성공적으로 획득해야 한다.

 

 

왜 여러 인스턴스가 필요한가?

단일 Redis 인스턴스를 사용할 경우 발생할 수 있는 문제들

  • Single Point of Failure: Redis 서버 장애 시 전체 락 시스템 마비
  • 네트워크 파티션: 클라이언트와 Redis 간 연결 문제 시 잘못된 락 상태
  • 클럭 드리프트: 서버 시간 차이로 인한 TTL 오차

Redlock은 이런 문제들을 분산과 과반수 합의로 해결한다.

 

 

 

 


 

 

 

 

Redlock 동작 원리 심화

 

1. 락 획득 과정

Redlock의 락 획득 과정을 단계별로 살펴보자

def acquire_lock(resource_name, ttl):
    start_time = current_time()
    successful_locks = 0
    unique_value = generate_unique_id()
    
    # 모든 Redis 인스턴스에 동시에 락 요청
    for redis_instance in redis_instances:
        if redis_instance.set(resource_name, unique_value, px=ttl, nx=True):
            successful_locks += 1
    
    elapsed_time = current_time() - start_time
    drift = (ttl * 0.01) + 2  # 클럭 드리프트 보정
    
    # 성공 조건: 과반수 + 유효 시간 남음
    if successful_locks >= (len(redis_instances) / 2 + 1):
        validity_time = ttl - elapsed_time - drift
        if validity_time > 0:
            return True, validity_time
    
    # 실패 시 모든 인스턴스에서 락 해제
    release_lock(resource_name, unique_value)
    return False, 0

 

 

 

 

2. 성공 조건

락 획득이 성공으로 간주되려면 두 조건을 모두 만족해야 한다.

  1. 과반수 합의: N/2 + 1 개 이상의 인스턴스에서 락 획득 성공
  2. 유효 시간: TTL에서 소요 시간과 클럭 드리프트를 뺀 값이 양수

 

 

3. 락 해제 과정

락 해제는 원자적(Atomic)으로 수행되어야 한다.

-- Lua 스크립트로 원자적 해제
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

 

  • 이 스크립트는 락의 소유권을 확인한 후에만 해제하여, 다른 클라이언트의 락을 실수로 해제하는 것을 방지한다.

 

 

 

Kubernetes 환경에서 흔한 실수

많은 개발자들이 Kubernetes에서 Redis를 StatefulSet으로 배포하면서 Redlock이 자동으로 동작할 것이라고 생각한다. 하지만 현실은 다르다.

 

 

문제 상황

# 이런 구성으로는 Redlock이 제대로 동작하지 않는다
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis-cluster
spec:
  replicas: 3  # 3개 Pod가 생성되지만...

---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
spec:
  clusterIP: None   # Headless Service
  selector:
    app: redis-cluster

 

// 이 코드는 실제로는 1개 연결만 생성한다!
const redis = new Redis({host: 'redis-service', port: 6379});
const redlock = new Redlock([redis]);  // ❌ 단일 연결!

 

 

 

 

왜 동작하지 않는가?

  1. Headless Service 한계: DNS는 여러 IP를 반환하지만, 대부분의 Redis 클라이언트는 첫 번째 IP만 사용
  2. 독립성 부족: 같은 클러스터 내 Pod들은 동일한 네트워크 환경을 공유
  3. 클라이언트 인식: Redis 클라이언트가 여러 인스턴스를 개별적으로 인식하지 못함

 

 

 

 

올바른 해결 방법들

 

방법 1: 개별 Service 생성 (권장)

가장 확실한 방법은 각 Redis Pod에 대해 개별 Service를 생성하는 것이다.

# Pod별 개별 Service
apiVersion: v1
kind: Service
metadata:
  name: redis-0
spec:
  selector:
    app: redis-cluster
    statefulset.kubernetes.io/pod-name: redis-cluster-0
  ports:
    - port: 6379
      targetPort: 6379

---
apiVersion: v1
kind: Service
metadata:
  name: redis-1
spec:
  selector:
    app: redis-cluster
    statefulset.kubernetes.io/pod-name: redis-cluster-1
  ports:
    - port: 6379
      targetPort: 6379

---
apiVersion: v1
kind: Service
metadata:
  name: redis-2
spec:
  selector:
    app: redis-cluster
    statefulset.kubernetes.io/pod-name: redis-cluster-2
  ports:
    - port: 6379
      targetPort: 6379

 

 

 

방법 2: Pod Anti-Affinity 적용

서로 다른 노드에 Pod를 배치하여 진정한 독립성을 확보한다

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis-cluster
spec:
  template:
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - redis-cluster
            topologyKey: kubernetes.io/hostname

 

 

 

 

방법 3: DNS 기반 동적 접근

더 고급 방법으로는 DNS SRV 레코드를 활용한 동적 인스턴스 발견이 있다.

const dns = require('dns').promises;

async function getRedisInstances() {
  const addresses = await dns.lookup('redis-service', { all: true });
  return addresses.map(addr => ({
    host: addr.address,
    port: 6379
  }));
}

async function createRedlock() {
  const instances = await getRedisInstances();
  return new Redlock(instances.map(config => new Redis(config)));
}

 

 

 

 

 


 

 

 

 

 

실제 구현 예시

 

Node.js 구현

const Redis = require('ioredis');
const Redlock = require('redlock');

class RedlockService {
  constructor() {
    // 개별 Service들을 사용한 연결
    this.redisClients = [
      new Redis({
        host: 'redis-0.default.svc.cluster.local',
        port: 6379,
        retryDelayOnFailover: 100,
        maxRetriesPerRequest: 3
      }),
      new Redis({
        host: 'redis-1.default.svc.cluster.local',
        port: 6379,
        retryDelayOnFailover: 100,
        maxRetriesPerRequest: 3
      }),
      new Redis({
        host: 'redis-2.default.svc.cluster.local',
        port: 6379,
        retryDelayOnFailover: 100,
        maxRetriesPerRequest: 3
      })
    ];

    this.redlock = new Redlock(this.redisClients, {
      retryCount: 3,
      retryDelay: 200,
      retryJitter: 200,
      driftFactor: 0.01,
      clockDriftMs: 2
    });

    this.setupEventHandlers();
  }

  setupEventHandlers() {
    this.redisClients.forEach((client, index) => {
      client.on('connect', () => {
        console.log(`Redis-${index} 연결 성공`);
      });
      
      client.on('error', (err) => {
        console.error(`Redis-${index} 오류:`, err.message);
      });
    });

    this.redlock.on('clientError', (err) => {
      console.error('Redlock 클라이언트 오류:', err);
    });
  }

  async acquireLock(resource, ttl = 10000) {
    try {
      console.log(`락 획득 시도: ${resource}`);
      const lock = await this.redlock.acquire([resource], ttl);
      console.log(`락 획득 성공: ${resource}`);
      return lock;
    } catch (error) {
      console.error(`락 획득 실패: ${resource}`, error.message);
      throw error;
    }
  }

  async releaseLock(lock) {
    try {
      await lock.release();
      console.log(`락 해제 성공: ${lock.resources}`);
    } catch (error) {
      console.error(`락 해제 실패:`, error.message);
      throw error;
    }
  }
}

// 사용 예시
async function criticalSection() {
  const redlockService = new RedlockService();
  
  try {
    const lock = await redlockService.acquireLock('user-action-123', 15000);
    
    try {
      // 임계 영역에서 수행할 작업
      console.log('중요한 작업 수행 중...');
      await performCriticalOperation();
      console.log('작업 완료');
    } finally {
      await redlockService.releaseLock(lock);
    }
  } catch (error) {
    console.error('작업 실패:', error.message);
  }
}

 

 

 

 

Java 구현 (Redisson 사용)

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

@Service
public class RedlockService {
    
    private final RedissonClient redissonClient;
    
    public RedlockService() {
        Config config = new Config();
        config.useReplicatedServers()
            .addNodeAddress("redis://redis-0.default.svc.cluster.local:6379")
            .addNodeAddress("redis://redis-1.default.svc.cluster.local:6379")
            .addNodeAddress("redis://redis-2.default.svc.cluster.local:6379");
        
        this.redissonClient = Redisson.create(config);
    }
    
    public boolean executeWithLock(String lockName, int ttlSeconds, Runnable task) {
        RLock lock = redissonClient.getLock(lockName);
        
        try {
            if (lock.tryLock(10, ttlSeconds, TimeUnit.SECONDS)) {
                try {
                    log.info("락 획득 성공: {}", lockName);
                    task.run();
                    return true;
                } finally {
                    lock.unlock();
                    log.info("락 해제 완료: {}", lockName);
                }
            } else {
                log.warn("락 획득 실패: {}", lockName);
                return false;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("락 획득 중 인터럽트: {}", lockName, e);
            return false;
        }
    }
}

 

 

 

 

 


 

 

 

 

운영 시 주의사항

 

1. 클럭 동기화

Redlock의 핵심은 시간 기반 TTL이므로 서버 간 시간 동기화가 매우 중요하다.

# NTP 동기화 상태 확인
chrony sources -v

# 클럭 드리프트 최소화 설정
echo "server time.google.com iburst" >> /etc/chrony.conf
systemctl restart chronyd

 

 

 

2. 적절한 TTL 설정

TTL은 예상 작업 시간의 2-3배로 설정하는 것이 안전하다.

const expectedWorkTime = 5000;  // 5초
const safetyMargin = 2;
const ttl = expectedWorkTime * safetyMargin;  // 10초

 

 

 

 

3. 네트워크 타임아웃 최적화

const redisOptions = {
  connectTimeout: 100,    // 100ms
  commandTimeout: 200,    // 200ms
  retryDelayOnFailover: 100,
  maxRetriesPerRequest: 3
};

 

 

 

4. 모니터링 메트릭

프로덕션 환경에서는 다음 메트릭들을 모니터링해야 한다.

# Prometheus 메트릭 예시
- redlock_acquire_success_total
- redlock_acquire_failure_total  
- redlock_acquire_duration_seconds
- redlock_validity_time_remaining
- redis_connection_failures_total

 

 

 

5. 장애 시나리오 대비

// 락 연장 로직
async function extendLockIfNeeded(lock, workTimeRemaining) {
  if (lock.validity < workTimeRemaining + 1000) {  // 1초 여유
    try {
      const extendedLock = await lock.extend(10000);
      console.log('락 연장 성공');
      return extendedLock;
    } catch (error) {
      console.error('락 연장 실패:', error.message);
      throw new Error('작업 시간 부족으로 중단');
    }
  }
  return lock;
}

 

 

 

 


 

 

 

 

대안 솔루션과의 비교

 

vs. Database-based Locking

항목 Redlock Database Lock
성능 매우 빠름 상대적으로 느림
복잡성 중간 단순함
일관성 Eventually Consistent Strongly Consistent
장애 복구 자동 TTL 수동 해제 필요

 

 

 

vs. Consul/etcd Locking

항목 Redlock Database Lock
인프라 복잡성 낮음 높음
성능 매우 빠름 빠름
합의 알고리즘 과반수 투표 Raft
생태계 Redis 중심 마이크로서비스

 

 

Raft(Reliable, Replicated, Redundant, And Fault-Tolerant)는 분산 시스템에서 합의(Consensus)를 달성하기 위한 알고리즘이다. 2013년 Diego Ongaro와 John Ousterhout이 Stanford에서 개발했으며, 기존의 Paxos 알고리즘보다 이해하기 쉽고 구현하기 간단하다는 것이 가장 큰 장점이다.

 

 

 

 

 

 

언제 Redlock을 사용해야 할까?

 

Redlock이 적합한 경우

  • 높은 처리량이 필요한 환경
  • 짧은 지연시간이 중요한 실시간 시스템
  • 이미 Redis 인프라가 구축된 환경
  • 단순한 락 요구사항 (복잡한 합의 불필요)

 

Redlock을 피해야 할 경우

  • 강한 일관성이 절대적으로 필요한 금융 시스템
  • 네트워크 파티션이 빈번한 환경
  • 클럭 동기화가 어려운 환경
  • 복잡한 분산 합의가 필요한 경우

 

 

 


 

 

 

 

마무리

Redlock은 분산 환경에서 락을 구현하는 좋은 솔루션이지만, 올바른 구현이 중요하다. 특히 Kubernetes 환경에서는 단순히 StatefulSet을 배포하는 것만으로는 충분하지 않으며, 각 Redis 인스턴스에 대한 독립적인 접근 경로를 확보해야 한다.

 

핵심은 진정한 독립성을 확보하는 것이다. 서로 다른 노드에 배치된 Redis 인스턴스들에 개별적으로 접근할 수 있는 구조를 만들고, 적절한 클럭 동기화와 모니터링을 통해 안정성을 확보해야 한다.

 

또한 Redlock이 만능 해결책은 아님을 인식해야 한다. 요구사항에 따라 데이터베이스 기반 락이나 Consul/etcd 같은 대안이 더 적합할 수 있다. 중요한 것은 각 솔루션의 특성을 이해하고, 상황에 맞는 최적의 선택을 하는 것이다.

 

마지막으로, 분산 락은 시스템의 성능에 직접적인 영향을 미치므로 충분한 테스트와 모니터링이 필수다. 프로덕션 배포 전에 다양한 장애 시나리오를 테스트하고, 운영 중에는 락 획득/해제 메트릭을 지속적으로 모니터링하여 시스템의 안정성을 확보하기 바란다.

 

 

 

 

 

 

 

 

 

 

 

 


Reference

 

 

728x90
반응형