분산 락 — SETNX, Redlock, Redisson
분산 락이 필요한 이유
여러 서버 인스턴스가 동시에 같은 작업 처리:
서버 1: 주문 재고 차감 (재고 100개)
서버 2: 동시에 같은 주문 재고 차감
결과: 재고가 중복으로 차감됨 (레이스 컨디션)
분산 락: 한 번에 하나의 서버만 작업 처리
SETNX 기반 간단한 락
# 락 획득 (원자적: NX=없을 때만, EX=자동 만료)
SET lock:order-123 "process-1" NX EX 30
# OK → 락 획득 성공
# nil → 이미 다른 프로세스가 락 보유class SimpleRedisLock(private val redis: StringRedisTemplate) {
fun tryLock(lockKey: String, owner: String, ttlSeconds: Long): Boolean {
val result = redis.opsForValue().setIfAbsent(
lockKey, owner, Duration.ofSeconds(ttlSeconds)
)
return result == true
}
fun unlock(lockKey: String, owner: String): Boolean {
// 내가 가진 락인지 확인 후 삭제 (원자적 Lua)
val script = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""".trimIndent()
val result = redis.execute(
RedisScript.of(script, Long::class.java),
listOf(lockKey),
owner,
)
return result == 1L
}
}락 연장 (Watch Dog)
class ExtendableLock(private val redis: StringRedisTemplate) {
private val watchdogExecutor = Executors.newScheduledThreadPool(1)
fun acquireWithWatchdog(
lockKey: String,
owner: String,
ttlSeconds: Long,
): ScheduledFuture<*>? {
val acquired = tryLock(lockKey, owner, ttlSeconds)
if (!acquired) return null
// 만료 전에 자동 갱신 (TTL의 1/3 간격)
return watchdogExecutor.scheduleAtFixedRate({
extendLock(lockKey, owner, ttlSeconds)
}, ttlSeconds / 3, ttlSeconds / 3, TimeUnit.SECONDS)
}
private fun extendLock(lockKey: String, owner: String, ttlSeconds: Long) {
val script = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('EXPIRE', KEYS[1], ARGV[2])
else
return 0
end
""".trimIndent()
redis.execute(
RedisScript.of(script, Long::class.java),
listOf(lockKey),
owner, ttlSeconds.toString(),
)
}
}Redlock 알고리즘
단일 Redis 노드의 한계: Redis 장애 시 락 유실.
Redlock = 여러 독립 Redis 노드에 과반수 락 획득.
Redis 노드 5개 (독립적, 복제 없음):
Node 1, Node 2, Node 3, Node 4, Node 5
락 획득:
동시에 5개 노드에 SET NX 시도
3개(과반수) 이상 성공 → 락 획득
3개 미만 성공 → 락 실패 → 획득한 노드들 모두 해제
락 유효 시간:
설정 TTL - (획득 소요 시간) - 드리프트
Redisson — 프로덕션 권장
Redisson은 Redis 기반의 분산 자료구조와 락을 제공하는 라이브러리.
의존성
implementation("org.redisson:redisson-spring-boot-starter:3.27.0")설정
@Bean
fun redissonClient(): RedissonClient {
val config = Config()
config.useSingleServer()
.setAddress("redis://localhost:6379")
.setPassword("your-password")
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(5)
return Redisson.create(config)
}
// Sentinel 설정
@Bean
fun redissonClientSentinel(): RedissonClient {
val config = Config()
config.useSentinelServers()
.setMasterName("mymaster")
.addSentinelAddress(
"redis://sentinel1:26379",
"redis://sentinel2:26379",
"redis://sentinel3:26379",
)
return Redisson.create(config)
}
// Cluster 설정
@Bean
fun redissonClientCluster(): RedissonClient {
val config = Config()
config.useClusterServers()
.addNodeAddress(
"redis://node1:7001",
"redis://node2:7002",
"redis://node3:7003",
)
return Redisson.create(config)
}RLock — 분산 락
@Service
class OrderService(private val redisson: RedissonClient) {
fun processOrder(orderId: Long) {
val lock = redisson.getLock("lock:order:$orderId")
// 락 획득 시도 (10초 대기, 30초 TTL)
val acquired = lock.tryLock(10, 30, TimeUnit.SECONDS)
if (!acquired) {
throw LockAcquisitionException("락 획득 실패: order-$orderId")
}
try {
// 임계 영역
processOrderInternal(orderId)
} finally {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
}Kotlin with 블록으로 깔끔하게
inline fun <T> RLock.withLock(
waitTime: Long = 10,
leaseTime: Long = 30,
unit: TimeUnit = TimeUnit.SECONDS,
block: () -> T,
): T {
val acquired = tryLock(waitTime, leaseTime, unit)
if (!acquired) throw LockAcquisitionException("Failed to acquire lock: $name")
try {
return block()
} finally {
if (isHeldByCurrentThread) unlock()
}
}
// 사용
val lock = redisson.getLock("lock:order:$orderId")
lock.withLock {
processOrderInternal(orderId)
}Watch Dog (자동 갱신)
// leaseTime = -1 → Watch Dog 활성화 (기본 30초마다 자동 갱신)
val lock = redisson.getLock("lock:order:$orderId")
lock.lock() // leaseTime -1 = Watch Dog 모드
try {
// Watch Dog이 락이 해제될 때까지 자동으로 TTL 갱신
longRunningProcess()
} finally {
lock.unlock()
}Redisson 분산 자료구조
// RMap — 분산 Map
val map: RMap<String, User> = redisson.getMap("users")
map["user:1001"] = user
val user = map["user:1001"]
// RSet — 분산 Set
val set: RSet<String> = redisson.getSet("online-users")
set.add("user-1001")
set.contains("user-1001")
// RList — 분산 List
val list: RList<String> = redisson.getList("notifications:user-1001")
list.add("새 메시지가 있습니다")
// RSortedSet — 분산 Sorted Set
val sortedSet: RSortedSet<String> = redisson.getSortedSet("leaderboard")
// RScoredSortedSet — score 포함
val scoredSet: RScoredSortedSet<String> = redisson.getScoredSortedSet("leaderboard")
scoredSet.add(9500.0, "alice")
scoredSet.addScore("alice", 100.0) // score 증가
// RQueue — 분산 큐
val queue: RQueue<String> = redisson.getQueue("task-queue")
queue.offer("task-1")
queue.poll() // 꺼내기
// RBlockingQueue — Blocking Queue
val blockingQueue: RBlockingQueue<String> = redisson.getBlockingQueue("tasks")
blockingQueue.put("task-1")
val task = blockingQueue.take() // blocking
// RDeque — 분산 Deque
val deque: RDeque<String> = redisson.getDeque("deque")Redisson 고급 기능
RReadWriteLock — 읽기/쓰기 락
val rwLock = redisson.getReadWriteLock("rwLock:config")
// 여러 스레드가 동시에 읽기 락 획득 가능
fun readConfig(): Config {
val readLock = rwLock.readLock()
readLock.lock()
try {
return loadConfig()
} finally {
readLock.unlock()
}
}
// 쓰기 락 - 배타적
fun updateConfig(config: Config) {
val writeLock = rwLock.writeLock()
writeLock.lock()
try {
saveConfig(config)
} finally {
writeLock.unlock()
}
}RSemaphore — 분산 세마포어
// 동시 처리 스레드 수 제한
val semaphore = redisson.getSemaphore("semaphore:api-calls")
semaphore.trySetPermits(10) // 최대 10개 동시 처리
fun processRequest() {
if (!semaphore.tryAcquire(5, TimeUnit.SECONDS)) {
throw TooManyRequestsException("서버 과부하")
}
try {
doProcess()
} finally {
semaphore.release()
}
}RRateLimiter — 분산 Rate Limiter
val rateLimiter = redisson.getRateLimiter("rate-limiter:api")
// 초당 10회 제한
rateLimiter.trySetRate(RateType.OVERALL, 10, 1, RateIntervalUnit.SECONDS)
fun callApi(): Boolean {
return rateLimiter.tryAcquire() // 허용이면 true
}RScheduledExecutorService — 분산 스케줄러
val executor = redisson.getExecutorService("distributed-scheduler")
// 한 번만 실행 (클러스터에서 하나의 노드만 실행)
executor.scheduleAtFixedRate(
MyTask(), // Serializable + Callable
0, 1, TimeUnit.MINUTES
)정리
- SETNX + EX: 간단한 분산 락 (단일 Redis 노드)
- Lua 스크립트: 확인 후 삭제 원자적 실행
- Watch Dog: 작업 중 락 만료 방지 자동 갱신
- Redlock: 여러 독립 노드에 과반수 락 (높은 신뢰성)
- Redisson: 프로덕션 권장 — Watch Dog, RReadWriteLock, RSemaphore, RRateLimiter 제공