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. 성공 조건
락 획득이 성공으로 간주되려면 두 조건을 모두 만족해야 한다.
- 과반수 합의: N/2 + 1 개 이상의 인스턴스에서 락 획득 성공
- 유효 시간: 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]); // ❌ 단일 연결!
왜 동작하지 않는가?
- Headless Service 한계: DNS는 여러 IP를 반환하지만, 대부분의 Redis 클라이언트는 첫 번째 IP만 사용
- 독립성 부족: 같은 클러스터 내 Pod들은 동일한 네트워크 환경을 공유
- 클라이언트 인식: 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
- Redis Redlock 공식 문서
- Redlock 논란에 대한 Martin Kleppmann의 분석
- Kubernetes StatefulSet 공식 가이드
- Redis 클러스터링 베스트 프랙티스
'Container Orchestration > Kubernetes' 카테고리의 다른 글
| Kubernetes Local Storage Solutions: OpenEBS vs Longhorn vs Rook Ceph 완전 비교 가이드 (0) | 2025.12.17 |
|---|---|
| Containerd v3에서 Insecure Registry 설정 방식 변경 (0) | 2025.11.26 |
| Kubernetes 클러스터 구축하기(kubespray 2025v.) (4) | 2025.07.31 |
| Helmfile 완전 정복: 실무에서 Helm을 선언적으로 관리하는 방법 (0) | 2025.06.16 |
| Helm values.schema.json 완벽 정리 (0) | 2025.05.29 |