Redis 분산락, 직접 구현하면서 이해해보자! - 1편: Spin Lock 방식

2025. 10. 31. 02:07·서버

❇️ 들어가며

안녕하세요! 분산락(Distributed Lock)은 여러 서버(인스턴스)가 동일한 자원에 동시에 접근하려 할 때 데이터의 일관성을 지키기 위해서 사용합니다. 보통 Spring 진영에서는 분산락을 사용하기 위해서 Redisson 라이브러리를 사용하곤 하는데요. 어떻게 작동하는지 궁금해서 Spin Lock 방식과 Pub/Sub 방식을 직접 구현하면서 이해해보려고 합니다. 

 

글에서 설명할 코드는 Spring, Kotlin, Spring Data Redis로 작성되어 있습니다. 테스트 코드는 Testcontainers를 사용하기 때문에 Docker를 실행하고 테스트해주시길 바랍니다. 전체 코드는 다음의 링크를 확인해 주세요.

 

spring-lab/distributed-lock-impl at main · alstn113/spring-lab

Spring에 대한 다양한 실험. Contribute to alstn113/spring-lab development by creating an account on GitHub.

github.com

 

❇️ Redis 락의 기본 원리 및 문제점

Redis를 이용해서 락을 구현하기 위해서는 보통 다음과 같은 방식을 사용합니다. 

  • SET key value NX: 락 획득을 위해 사용합니다. key가 존재하지 않는 경우 등록하고, 이미 존재하는 경우 nil를 반환합니다. 
  • DEL key: 락 해제를 위해 사용합니다. key를 삭제합니다.

하지만 이 정도로는 많이 부족합니다. 다음과 같은 문제점들이 있습니다.

  • 락 획득에 실패한 경우 다시 요청해야 합니다.
  • 특정 서버가 락을 획득하고 다운되는 경우 락이 풀리지 않습니다.
  • 락을 보유하지 않은 다른 서버나 스레드가 락을 해제할 수 있습니다.
  • 동일한 스레드가 락을 재진입할 수 없습니다.

 

❇️ Spin Lock 구현 

SET NX를 사용하는 경우 락이 점유 중일 때 즉시 실패하게 됩니다. 하지만 대부분의 경우 우리는 "잠깐 기다렸다가 다시 시도"를 원합니다. 즉, 락이 해제될 가능성이 있으니 짧은 간격으로 다시 SET NX를 시도하는 것입니다. 락 점유 상태일 때 바로 실패하지 않고 CPU를 점유하며 일정 간격으로 계속 재시도하는 방식을 Spin Lock 방식이라고 합니다. 이 글에서는 Spin Lock 방식의 구현 방법을 알아보겠습니다.

 

먼저 분산락의 인터페이스와 확장 함수를 만들어보겠습니다. 메서드로는 락 획득을 시도하는 tryLock과 락을 해제하는 unlock이 있습니다. 락 획득을 시도할 때는 waitTime과 leaseTime을 지정할 수 있습니다. waitTime은 락 획득을 위해서 기다릴 수 있는 시간입니다. leaseTime은 락을 점유할 수 있는 시간입니다. 락을 점유하는 시간을 지정함으로써 서버가 락을 획득 후 해제하지 못하고 다운되는 경우 일정 시간 이후에 자동으로 락이 해제되도록 할 수 있습니다. 확장 함수는 락을 획득하고 로직 실행 후 락을 해제하는 틀 역할을 하는 코드입니다.

package io.github.alstn113.app.distributedlock

import java.time.Duration

interface DistributedLock {

    fun tryLock(key: String, waitTime: Duration, leaseTime: Duration): Boolean
    fun unlock(key: String)
}

// 분산락을 사용할 수 있는 확장 함수
fun DistributedLock.withLock(
    key: String,
    waitTime: Duration,
    leaseTime: Duration,
    callback: () -> Unit,
) {
    val acquired = this.tryLock(key, waitTime, leaseTime)
    if (!acquired) {
        throw IllegalStateException("Failed to acquire lock for key: $key")
    }

    try {
        callback()
    } finally {
        this.unlock(key)
    }
}

 

단순히 "DEL KEY"만 사용하는 경우 락을 보유하지 않은 다른 서버나 스레드가 락을 해제할 수 있습니다. 이를 해결하기 위해서 정적 필드에 UUID로 된 INSTANCE_ID를 두어 서버를 구분하도록 하고, Thread.currentThread().threadId()를 사용해 스레드를 구분하도록 할 수 있습니다.

private fun getLockOwnerId(): String {
    val threadId = Thread.currentThread().threadId()
    return "$INSTANCE_ID:$threadId"
}

companion object {
    private val INSTANCE_ID = UUID.randomUUID().toString()
}

 

같은 락을 공유하는 두 메서드를 실행하는 경우, 락의 재진입을 허용하지 않는 경우 자기 자신과의 교착 상태에 빠질 수 있습니다. 이 문제를 해결하기 위해서 락 획득 시 진입 카운트를 1로 두고 재진입 시 증가하는 방식을 사용해야 합니다. 락 해제 시에는 진입 카운트를 감소시키고 0인 경우 락을 해제하도록 해야 합니다.

 

Redis 단일 명령어들은 원자적으로 처리되지만 여러 명령어를 사용하는 경우 명령어 사이에 다른 명령어들이 들어갈 수 있어 원자적으로 처리되지 않습니다. 그러므로 Lua Script를 사용하여 전체 명령을 원자적으로 처리하게 할 수 있습니다. 락 획득을 시도하는 Lua Script를 보겠습니다. 

 

쉽게 생각해서 락이 존재하지 않거나, 내가 소유한 락을 존재하는 경우에(재진입하는 경우) lockKey의 lockOwnerId 값이 없으면 1로 지정하고, 있으면 1을 증가시킵니다. 그리고 락의 임대 시간(leaseMillis)만큼 만료 시간을 지정합니다. 락을 획득하거나 재진입하는 경우 -1을 반환하고, 다른 이가 락을 보유해서 락을 획득할 수 없는 경우 남은 만료 시간을 반환합니다.

-- try-acquire.lua

-- 반환값:
--  -1 -> 락 획득 성공 (새로 획득 또는 재진입)
--   n -> 락 획득 실패, 남은 TTL (ms)

local lockKey = KEYS[1]
local leaseMillis = tonumber(ARGV[1])
local lockOwnerId = ARGV[2]

-- 락이 존재하지 않거나, 동일한 소유자가 재진입하는 경우 락 획득 성공
if ((redis.call('exists', lockKey) == 0) or (redis.call('hexists', lockKey, lockOwnerId) == 1)) then
    redis.call('hincrby', lockKey, lockOwnerId, 1);
    redis.call('pexpire', lockKey, leaseMillis);
    return -1;
end;

return redis.call('pttl', lockKey);

 

다음은 락 해제를 위한 Lua Script입니다. 락을 획득한 서버의 스레드만 해제할 수 있도록 하기 위해서 lockOwnerId를 검증합니다. 락을 소유하지 않는 경우 -1을 반환하고, 락을 정상 해제하는 경우 1을 반환합니다.

-- unlock.lua

-- 반환값:
--   1 -> 정상 해제
--   0 -> 재진입 카운트 감소 (아직 락 유지)
--  -1 -> unlock 불가 (다른 스레드가 락 소유 중)

local lockKey = KEYS[1]
local lockOwnerId = ARGV[1]

local ownerExists = redis.call('hexists', lockKey, lockOwnerId)

if ownerExists == 0 then
  return -1
end

local counter = redis.call('hincrby', lockKey, lockOwnerId, -1)
if counter > 0 then
  return 0
else
  redis.call('del', lockKey)
  return 1
end

 

DistributedLock을 구현한 SpinLock 코드입니다. tryAcquire과 unlock 메서드는 각각 락 획득과 락 해제를 위한 Lua Script를 실행하는 코드입니다. 지정한 waitTime만큼 while문을 반복하면서 락 획득을 시도하게 됩니다. 락 획득을 즉시 재시도하는 경우 높은 부하를 주기 때문에 재시도 간의 간격을 지수적으로 늘려갑니다. 이 예시에서는 1ms, 2ms, ... 64ms, 128ms로 지수적으로 늘려가며 최대 128ms만큼의 재시도 간격을 가지게 됩니다. 

package io.github.alstn113.app.distributedlock.spinlock

import io.github.alstn113.app.distributedlock.DistributedLock
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.core.script.RedisScript
import org.springframework.stereotype.Component
import java.time.Duration
import java.util.*

@Component
class SpinLock(
    private val redisTemplate: StringRedisTemplate,
    @Qualifier("tryAcquireScript") private val tryAcquireScript: RedisScript<Long>,
    @Qualifier("unlockScript") private val unlockScript: RedisScript<Long>,
) : DistributedLock {

    override fun tryLock(key: String, waitTime: Duration, leaseTime: Duration): Boolean {
        val deadlineMillis = System.currentTimeMillis() + waitTime.toMillis()

        var backOffMillis = BACKOFF_INITIAL_MILLIS

        while (true) {
            if (isDeadlineExceeded(deadlineMillis)) {
                return false
            }

            val ttl = tryAcquire(key, leaseTime)
            if (ttl == -1L) { // 성공을 의미
                return true
            }

            try {
                Thread.sleep(backOffMillis)
            } catch (e: InterruptedException) {
                Thread.currentThread().interrupt()
                return false
            }

            backOffMillis = minOf(backOffMillis * 2, BACKOFF_MAX_MILLIS)
        }
    }

    override fun unlock(key: String) {
        redisTemplate.execute(
            unlockScript,
            listOf(key),
            getLockOwnerId(),
        )
    }

    private fun tryAcquire(key: String, leaseTime: Duration): Long {
        val leaseTimeMillis = leaseTime.toMillis()

        return redisTemplate.execute(
            tryAcquireScript,
            listOf(key),
            leaseTimeMillis.toString(),
            getLockOwnerId()
        )
    }

    private fun isDeadlineExceeded(deadlineMillis: Long): Boolean {
        return System.currentTimeMillis() > deadlineMillis
    }

    private fun getLockOwnerId(): String {
        val threadId = Thread.currentThread().threadId()
        return "$INSTANCE_ID:$threadId"
    }

    companion object {
        private val INSTANCE_ID = UUID.randomUUID().toString()
        private const val BACKOFF_INITIAL_MILLIS = 1L
        private const val BACKOFF_MAX_MILLIS = 128L
    }
}

 

❇️ Spin Lock 단점

Spin Lock 방식은 구현이 쉽지만 다양한 단점들을 가지고 있습니다.

  • 짧은 주기의 재시도 요청으로 인해 서버와 Redis CPU 부하가 증가합니다.
  • 락 해제 시점과 재시도 요청 타이밍의 불일치로 락 효율이 저하됩니다.
  • 락 경쟁이 심해질수록 재시도 요청이 많아지므로 성능 저하가 더욱 심해집니다.

 

❇️ 마치며

이러한 Spin Lock 방식의 단점들을 해결하기 위해서 Pub/Sub 방식의 분산락을 사용하곤 합니다. 2편: Pub/Sub 방식에서는 Redis Pub/Sub 방식의 분산락을 직접 구현하는 방법을 알아보도록 하겠습니다. 감사합니다. 잘못된 부분이 있다면 댓글로 알려주시길 바랍니다!

 

❇️ 참고

  • jhzlo님의 분산락 정리 블로그
  • diggingcode님의 Redisson 정리 블로그 
  • Redisson 공식문서

'서버' 카테고리의 다른 글

Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 1편: 이해와 설계  (0) 2025.11.21
Redis 분산락, 직접 구현하면서 이해해보자! - 2편: Pub/Sub 방식  (0) 2025.10.31
POST 요청의 중복 처리를 막는 멱등키 헤더 구현 (Interceptor + Redis)  (0) 2025.09.17
Refresh Token과 Refresh Token Rotation에 대한 고찰  (1) 2025.09.14
@Transactional과 @Cacheable을 같이 쓰면 어떤 것이 먼저 동작할까?  (1) 2025.09.07
'서버' 카테고리의 다른 글
  • Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 1편: 이해와 설계
  • Redis 분산락, 직접 구현하면서 이해해보자! - 2편: Pub/Sub 방식
  • POST 요청의 중복 처리를 막는 멱등키 헤더 구현 (Interceptor + Redis)
  • Refresh Token과 Refresh Token Rotation에 대한 고찰
alstn113
alstn113
웹 프론트엔드, 서버 개발에 관한 이야기를 다룹니다 :D
  • alstn113
    alstn113's devlog
    alstn113
  • 전체
    오늘
    어제
    • 분류 전체보기 (62)
      • 서버 (31)
      • 웹 프론트엔드 (5)
      • 협업 (2)
      • 우아한테크코스 6기 백엔드 (12)
      • 책, 영상, 블로그 정리 (10)
      • 회고 (1)
  • 블로그 메뉴

    • 홈
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    플러피
    우아한테크코스
    글쓰기
    회고
    굿폰
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
alstn113
Redis 분산락, 직접 구현하면서 이해해보자! - 1편: Spin Lock 방식
상단으로

티스토리툴바