Redis 기반 분산 락과 Redlock 알고리즘

분산 락(distributed lock)은 여러 프로세스나 노드가 동시에 접근할 수 있는 공유 자원을, 분산 환경에서 안전하게 다루기 위한 잠금 메커니즘이다.

이 글은 다음 질문들에 대한 답을 찾아가는 과정이다.

분산 시스템에서 생기는 문제들

경쟁 상태 (Race Condition)

중복 작업

데이터 일관성 붕괴

리더 선출 문제

락의 목적

효율성 (Efficiency)

여러 노드가 같은 일을 반복하면서 생기는 불필요한 비용을 막기 위해 락을 쓸 수 있다.

정확성 (Correctness)

동시에 실행되는 프로세스들이 같은 자원에 접근해 수정하려 할 때 발생하는 데이터 손상과 일관성 위반을 막기 위해 락을 쓸 수 있다.

Designing Data-Intensive Applications의 저자 Martin Kleppmann에 따르면, 분산 락을 도입할 때 그것이 효율성 문제를 풀기 위한 것인지 정확성 문제를 풀기 위한 것인지를 분명히 이해하는 것이 중요하다.

예컨대 효율성을 목적으로 분산 락을 도입한다면 Redlock 알고리즘은 맞지 않을 수 있다. 중복 작업을 줄이자고 Redis 마스터 5대를 띄우고 그중 과반에서 락을 얻어내는 비용이, 중복 작업 자체의 비용보다 클 가능성이 높기 때문이다.

Redis로 분산 락 구현하기

Redis는 싱글 스레드로 동작하는 인메모리 저장소로, 읽기와 쓰기가 빠르다. 단일 스레드에서 명령을 하나씩 순차적으로 처리하기 때문에 락 구현에 자주 쓰인다.

예제: 분산 환경의 크론 잡

여러 노드가 이중화되어 있는 환경에서 크론 잡이 실행 주기마다 한 번씩만 돌도록 하는 예제를 구현해 본다.

크론 작업 정의

10초간 동작하는 작업을 흉내 낸다.

func runCronTask() {
	// 실제로 실행할 크론 작업 (여기서는 10초 sleep으로 흉내 낸다)
	fmt.Println("Working... ⏳")
	time.Sleep(10 * time.Second)
	fmt.Println("Task complete! 🎉")
}

메인 구조

이미 락이 잡혀 있으면 작업을 실행하지 않고, 락이 없으면 실행한다.

func main() {
	ctx := context.Background()

	// Redis 클라이언트 생성
	client := newRedisClient("localhost:6379", "", 0)

	// 분산 락 획득 시도
	acquired, err := client.acquireLock(ctx, lockKey, lockTTL)
	if err != nil {
		log.Fatalf("Redis connection error: %v", err)
	}

	if !acquired {
		fmt.Println("Cron job already running on another server! 🚫")
		return
	}

	fmt.Println("Starting cron job... ✅")

	// 크론 작업 실행 (예: 데이터 백업)
	runCronTask()

	// 락 해제
	client.releaseLock(ctx, lockKey)
	fmt.Println("Cron job complete, lock released 🔓")
}

newRedisClientacquireLockreleaseLock 메서드를 갖는 Redis 클라이언트를 만드는 생성자다. 구현은 아래에서 설명한다.

락 구현

import "github.com/redis/go-redis/v9"

// 락 관련 상수
const lockKey = "cron:job:my-task"
const lockTTL = 30 * time.Second // 락 유지 시간

// 분산 락에 사용할 Redis 클라이언트
type redisClient struct {
	client *redis.Client
}

func newRedisClient(addr, password string, db int) *redisClient {
	return &redisClient{
		client: redis.NewClient(&redis.Options{
			Addr:     addr,     // Redis 주소
			Password: password, // 비밀번호 (없으면 빈 문자열)
			DB:       db,       // 기본 DB
		}),
	}
}

// 락을 획득하는 acquireLock 메서드
func (r *redisClient) acquireLock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
	ok, err := r.client.SetNX(ctx, key, "locked", ttl).Result()
	if err != nil {
		return false, err
	}
	return ok, nil
}

// 락을 해제하는 releaseLock 메서드
func (r *redisClient) releaseLock(ctx context.Context, key string) {
	r.client.Del(ctx, key)
}

락의 획득과 해제는 go-redis의 SetNX로 처리한다.

SETNX 명령은 deprecated 되었지만 go-redis는 여전히 이를 지원하며, 내부적으로는 키가 없을 때만 값을 설정하는 SET 명령을 사용한다.

락을 설정할 때는 데드락을 막기 위해 TTL을 함께 건다.

주의할 점

여기까지는 Redis 인스턴스가 하나라고 가정하고 락을 구현했다. 그런데 이 구현에는 큰 문제가 하나 있다.

Redis가 SPoF(Single Point of Failure)가 된다.

그러면 단일 인스턴스 대신 마스터-슬레이브 구성을 쓰면 해결되지 않을까? 답은 그리 간단하지 않다.

Redlock 알고리즘

마스터-슬레이브 구조의 허점

  1. 클라이언트 A가 마스터에서 락을 획득한다
  2. 마스터가 락 정보를 리플리카에 복제하기 전에 크래시가 나서 내려간다
  3. 리플리카가 마스터로 승격된다
  4. 클라이언트 B가 새로 승격된 마스터에서 락을 획득한다. 그런데 이 자원은 아직 A가 작업 중이다

Redlock 알고리즘이란

Redlock 알고리즘은 여러 Redis 노드에 걸쳐 락을 획득함으로써, Redis 인스턴스 하나에 장애가 생겨도 전체 시스템의 일관성신뢰성이 유지되도록 한다.

  1. 여러 Redis 인스턴스 사용: Redlock은 여러 Redis 서버에 락을 요청해서 단일 Redis 노드의 장애에 영향받지 않게 한다. 최소 5개의 Redis 노드가 필요하다.
  2. 과반수 정족수 필요: 락을 획득하려면 과반의 Redis 서버에서 획득에 성공해야 한다. 예를 들어 Redis 서버가 5대라면 최소 3대에서 락을 얻어야 유효한 것으로 본다.
  3. 락 유효성 검증: Redis 서버마다 락 획득에 걸리는 시간이 다를 수 있으므로, 획득 후 만료 시각을 검증해서 락이 여전히 유효한지 확인한다.

Redlock 알고리즘의 동작

클라이언트는 다음 순서로 동작한다.

  1. 현재 시각을 밀리초 단위로 기록한다
  2. 동일한 키와 랜덤 값으로 N개의 Redis 인스턴스 전부에 순차적으로 락 획득을 시도한다. 각 시도마다 짧은 타임아웃을 두어, 노드가 죽어 있으면 즉시 다음 인스턴스로 넘어가게 한다.
  3. 락 획득에 걸린 총 시간을 계산한다. N개 중 과반이 락을 내주었고 그 경과 시간이 락의 유효 시간보다 짧다면, 락 획득에 성공한 것으로 본다.
  4. 락을 획득했다면, 실효 유효 시간은 최초 유효 시간에서 경과 시간을 뺀 값이 된다.
  5. 락을 획득하지 못했거나 남은 유효 시간이 음수라면(획득 과정에서 이미 만료되었다는 뜻), 모든 인스턴스에서 락을 해제한다.

장점

단점

고려사항

한계

아래는 Martin Kleppmann의 블로그에 정리된 Redlock 알고리즘의 한계를 요약한 것이다. https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

분산 락 자체가 다음과 같은 실패 시나리오를 안고 있다.

Martin Kleppmann, How to do distributed locking

Martin Kleppmann, How to do distributed locking

이를 해결하기 위해 펜싱 토큰(fencing token) 을 쓸 수 있다. 펜싱 토큰은 단조 증가하는 숫자 토큰이다. 가장 최근에 성공한 쓰기의 토큰보다 낮은 번호를 들고 온 쓰기 요청은 오래된 토큰으로 간주하여 거부한다.

문제는 Redlock 알고리즘도 Redis도 이런 메커니즘을 내부적으로 제공하지 않는다는 점이다. 그리고 억지로 만든다 해도, 서로 다른 n개의 마스터가 완전히 동기화된 펜싱 토큰을 제공하도록 보장하는 문제에 부딪힌다.

타이밍 문제

각 노드가 인식하는 시간은 정확히 동기화되어 있지 않으며, 여러 이유로 몇 분씩 차이가 날 수 있다.

그래서 분산 환경에서 타이밍 문제는 까다로운데, Redlock 알고리즘은 타이밍에 크게 의존하기 때문에 취약할 수 있다.

However, Redlock is not like this. Its safety depends on a lot of timing assumptions: it assumes that all Redis nodes hold keys for approximately the right length of time before expiring; that the network delay is small compared to the expiry duration; and that process pauses are much shorter than the expiry duration.

이런 이유로 Martin Kleppmann은 Redlock 알고리즘이 단순하지도 않고 정확성이 보장되지도 않으므로, 분산 시스템의 락 메커니즘으로 적합하지 않다고 주장한다.

Go에서 Redlock 쉽게 쓰기

Redsync 라이브러리를 쓰면 Redlock 알고리즘을 쉽게 적용할 수 있다.

func main() {
	// Redis 커넥션 풀 생성
	redisAddrs := []string{
		"localhost:6379",
		"localhost:16379",
		"localhost:26379",
		"localhost:36379",
		"localhost:46379",
	}
	var pools []redsyncredis.Pool
	for _, addr := range redisAddrs {
		client := redis.NewClient(&redis.Options{
			Addr: addr,
		})
		pools = append(pools, goredis.NewPool(client))
	}

	// 커넥션 풀로 redsync 인스턴스 생성
	rs := redsync.New(pools...)

	mutexname := "my-global-mutex"

	// 주어진 mutexname으로 Mutex 인스턴스 생성
	mutex := rs.NewMutex(mutexname)

	// 다른 프로세스나 스레드가 획득하지 못하도록 락을 잡는다
	if err := mutex.Lock(); err != nil {
		panic(err)
	}

	// 작업 수행
	{
		// do something
		time.Sleep(1 * time.Second)
	}

	// 다른 프로세스나 스레드가 획득할 수 있도록 락을 해제한다
	if ok, err := mutex.Unlock(); !ok || err != nil {
		panic("unlock failed")
	}
}

일반적인 락을 쓰듯이, NewMutex로 만든 mutex 인스턴스의 LockUnlock 메서드를 호출해 락을 획득하고 해제하면 된다.

Redsync는 Redlock 알고리즘을 어떻게 구현했을까?

먼저 Mutex 구조체를 보자.

type Mutex struct {
	name   string
	expiry time.Duration

	tries     int
	delayFunc DelayFunc

	quorum int

	pools []redis.Pool
	
	// ...
}

내부적으로는 다음 시그니처의 lockContext 메서드가 호출된다.

func (m *Mutex) lockContext(ctx context.Context, tries int) error

주어진 시도 횟수 안에서 락 획득을 시도하고, 실패하면 재시도한다. 재시도 지연은 Redlock 알고리즘의 원칙에 따라 무작위로 정해진다.

func(tries int) time.Duration {
			return time.Duration(rand.Intn(maxRetryDelayMilliSec-minRetryDelayMilliSec)+minRetryDelayMilliSec) * time.Millisecond
		},

다시 lockContext 메서드로 돌아오면,

		n, err := func() (int, error) {
			// ...
			return m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) {
				return m.acquire(ctx, pool, value)
			})
		}()

m.actOnPoolsAsync라는 함수를 호출한다. 이 함수는 함수 하나를 받아 풀들을 순회하며 각각에 대해 실행하고, 락 획득에 성공한 마스터의 개수와 에러를 반환한다.

func (m *Mutex) actOnPoolsAsync(actFn func(redis.Pool) (bool, error)) (int, error) {
// ...
	for node, pool := range m.pools {
		go func(node int, pool redis.Pool) {
			r := result{node: node}
			r.statusOK, r.err = actFn(pool)
			ch <- r
		}(node, pool)
	}
// ...

// 락 획득에 성공한 횟수를 세어 n에 담는다
	for range m.pools {
		r := <-ch
		if r.statusOK {
			n++
			// ...
			
		// n 반환
		return n, err
}

lockContext 함수는 이 결과를 n, err로 받는다.

마지막으로 lockContext 함수는 정족수 이상에서 락을 획득했는지, 그리고 락 유효 시간이 아직 만료되지 않았는지를 검증한다.

if n >= m.quorum && now.Before(until) {
			m.value = value
			m.until = until
			return nil
		}

참고 자료

Distributed Locks with Redis

How to do distributed locking — Martin Kleppmann’s blog

Is Redlock safe? - antirez