Redis Lettuce로 구현하는 안전한 레디스 분산락 — 실시간 정합성 보장

--

룰렛 프로모션에서 동시성 문제를 해결하고
중복 당첨의 위험에서 자유로워지는 방법에 대해 소개합니다.

INDEX

  1. 들어가며
  2. Lettuce와 Redisson 비교
  3. Lettuce 를 선정한 이유
  4. 완전한 분산 락의 기준
  5. HOW TO USE IT
  6. 룰렛 프로모션 시나리오
  7. 마치며

들어가며

안녕하세요. 토니모리 개발운영팀 장준환🫣입니다.

실시간 이벤트를 구현하다보면 다양한 문제상황이 발생합니다. 최근 신년 룰렛 프로모션을 진행하면서 겪은 Redis 분산 락 구현 경험을 공유하고자 합니다.

저희와 비슷한 고민을 가진 분들께 도움이 되었으면 합니다.

룰렛 프로모션 이벤트에는 아래와 같은 동시성 문제들이 발생할 수 있습니다.

  • 여러 고객이 동일한 시점에 룰렛 이벤트에 참여한 경우, 트랜잭션이 중복으로 발생하여 최대 당첨 항목 갯수 이상의 당첨자가 발생하는 문제
  • 동일한 고객여러 번 참여를 시도하여 시스템 오류 또는 불공정한 결과가 발생할 가능성

위와 같은 위와 같은 상황은 매우 크리티컬한 이슈로 이어질 수 있으므로, 이를 확실히 제어할 수 있는 방법이 필요했습니다.

그렇기에 Redis 를 통한 분산 락을 고민하게 되었습니다.

2. Lettuce와 Redisson 비교

Lettuce와 Redisson은 Redis 클라이언트 라이브러리로, 각 다른 장단점을 가지고 있습니다.

  • 설계 기반 : Lettuce는 Netty 기반으로 설계되어 경량 클라이언트로 동작하며, 높은 성능을 제공합니다. 반면 Redisson은 고급 기능(분산 객체, 세션 관리 등)을 포함하여 더 무겁지만 풍부한 기능을 제공합니다.
  • 성능 : Lettuce는 높은 성능을 자랑하며, 기본적인 락 구현에 적합합니다. Redisson은 부가 기능이 많아 Lettuce보다는 리소스를 조금 더 사용합니다.
  • 락 기능 : Lettuce는 기본적인 락 메커니즘 구현에 적합합니다. Redisson은 RedLock 알고리즘을 기본적으로 지원하며 복잡한 락 시나리오에서도 유용합니다.
  • 복잡도 : Lettuce는 직접 구현이 필요하지만, Redisson은 간단한 API를 제공하여 락 기능을 더 쉽게 사용할 수 있습니다.
  • 사용 사례 : Lettuce는 단순 락 메커니즘이 필요한 경우 적합하며, Redisson은 고급 기능(분산 객체, 클러스터 지원)이 필요한 경우 적합합니다.

3. Lettuce 를 선정한 이유

현재 프로모션 서버의 경우, Spring 4.x.x 를 사용하고 있습니다. 해당 버전과 Redisson 라이브러리는 호환성이 매우 낮아 직접적인 추가설정이 필요한 상황이라 불가피하게 Lettuce를 선정하였습니다.

4. 완전한 분산 락의 기준

분산 락(Distributed Lock)은 분산 시스템에서 여러 프로세스가 동시에 공유 리소스(예: 데이터베이스, 캐시, 파일 등)에 접근할 때 동기화를 보장하기 위해 사용되는 메커니즘입니다. 단일 서버 환경에서는 프로세스 락을 사용하면 충분하지만, 여러 서버나 프로세스가 병렬로 동작하는 분산 환경에서는 다음과 같은 문제가 발생할 수 있습니다.

  • 동일한 리소스에 대해 중복 작업이 수행될 위험
  • 데이터 불일치 발생 가능성
  • 리소스의 비효율적 사용

분산 락은 이러한 문제를 방지하기 위해 특정 리소스에 대해 한 번에 하나의 프로세스만 접근할 수 있도록 보장합니다. 이를 위해 Redis 와 같은 분산 시스템이 주로 활용되며, 락 생성 및 해제 과정을 원자적으로 처리해야 합니다.

Redis 기반 분산 락의 안정성을 높이려면 다음 요소를 충족해야 합니다.

1. 원자적 락 생성과 TTL 설정

  • SET 명령어를 사용하며, NX(not exists)와 EX(expire)를 함께 사용하여 락 생성과 TTL 설정을 원자적으로 수행해야 합니다.
SET {lock_key} {lock_value} NX EX TTL

2. 락 소유권 확인

  • 락을 해제할 때는 락을 설정한 클라이언트만 해제할 수 있도록 해야 합니다. 이를 위해 락 생성 시 고유한 lock_value를 사용하고, 락 해제 시 lock_value를 검증합니다.

3. TTL로 인한 Deadlock 방지

  • TTL이 설정되어 있으면 장애 상황에서도 락이 영구적으로 유지되지 않습니다.

5. HOW TO USE IT

아래 코드는 실제 운영 코드가 아닌 단순 설명을 위한 예시 코드입니다.

version : java 8, spring 4.x.x
library : spring-data-redis:1.7.10.RELEASE

1) 락 생성

락을 생성할 때는 Redis 의 SET 명령어를 사용해 Atomic하게 락을 설정하고 TTL(Time-to-Live)을 설정합니다. 이를 통해 락이 자동으로 만료되도록 보장할 수 있습니다.

import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.types.Expiration;

private static final String DISTRIBUTED_LOCK_KEY = "PROMOTION_EXAMPLE_LOCK_KEY";
private static final String DISTRIBUTED_LOCK_QUEUE_KEY = "PROMOTION_EXAMPLE_LOCK_QUEUE_KEY";
private static final long LOCK_ACQUISITION_TIMEOUT = 5000;
private static final long LOCK_RETRY_INTERVAL_MILLIS = 500;
private static final long LOCK_EXPIRATION_TIME = 3000;
private static final long LOCK_QUEUE_EXPIRATION_TIME = 3000;

public boolean acquireDistributedLockWithSortedQueue(String clientId) {
return redisTemplate.execute((RedisCallback<Boolean>) connection -> {
byte[] queueKey = redisTemplate.getStringSerializer().serialize(DISTRIBUTED_LOCK_QUEUE_KEY);
byte[] lockKey = redisTemplate.getStringSerializer().serialize(DISTRIBUTED_LOCK_KEY);
byte[] clientValue = redisTemplate.getStringSerializer().serialize(clientId);

// 타임스탬프 기반으로 대기열 추가
long timestamp = System.currentTimeMillis();
long expirationScore = timestamp + LOCK_QUEUE_EXPIRATION_TIME;

// ZSET에 추가
connection.zAdd(queueKey, expirationScore, clientValue);

// 주기적으로 만료 항목 제거
long currentTime = System.currentTimeMillis();
connection.zRemRangeByScore(queueKey, 0, currentTime);

// 분산락 대기 시간 제한
while (System.currentTimeMillis() - timestamp < LOCK_ACQUISITION_TIMEOUT) {
// 대기열의 맨 앞 확인
Set<byte[]> queueHead = connection.zRange(queueKey, 0, 0);
if (queueHead != null && !queueHead.isEmpty() && Arrays.equals(queueHead.iterator().next(), clientValue)) {
// 맨 앞이라면 기존 락 소유자 확인
byte[] currentLockOwner = connection.get(lockKey);
if (currentLockOwner == null) {
// 락 시도
connection.set(lockKey, clientValue,
Expiration.milliseconds(LOCK_EXPIRATION_TIME),
RedisStringCommands.SetOption.SET_IF_ABSENT);

// TTL 확인 및 락 획득 여부
Long ttl = connection.ttl(lockKey);
if (ttl != null && ttl > 0) {
// 대기열에서 자신의 clientId 제거
connection.zRem(queueKey, clientValue);
return true; // 락 획득 성공
}
}
}

// Lock 취득을 위한 재시도
try {
Thread.sleep(LOCK_RETRY_INTERVAL_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
connection.zRem(queueKey, clientValue);
throw new IllegalStateException("Thread interrupted while waiting for lock", e);
}
}

// 대기열에서 자신의 clientId 제거 (시간 초과 시)
connection.zRem(queueKey, clientValue);
return false; // 락 획득 실패
});
}

1.1. Redis Sorted Set을 대기열로 사용

Redis Sorted Set은 요소별 점수(score)를 기준으로 정렬된 데이터 구조로, 요소의 우선순위를 효율적으로 관리할 수 있습니다. 이 메서드에서는 클라이언트 ID를 요소로, 타임스탬프 기반 점수를 사용하여 대기열 순서를 관리합니다.

  • 요소 : 클라이언트 ID (clientId)
  • 점수 : 현재 타임스탬프 (System.currentTimeMillis()) + 대기열 만료 시간 (LOCK_QUEUE_EXPIRATION_TIME)
connection.zAdd(queueKey, expirationScore, clientValue);

이 명령은 클라이언트의 ID(clientValue)를 expirationScore에 따라 Redis ZSET에 추가합니다. 점수를 기준으로 대기열 순서가 결정됩니다.

1.2. 만료 항목 정리

대기열에서 오래된(만료된) 항목은 다음 명령을 통해 주기적으로 제거됩니다.

connection.zRemRangeByScore(queueKey, 0, currentTime);
  • zRemRangeByScore는 지정된 점수 범위(0에서 currentTime)에 포함된 요소를 삭제합니다.
  • 이를 통해 대기열이 점점 커지는 것을 방지하고, 유효한 클라이언트 ID만 남게 합니다.

1.3. 맨 앞 클라이언트 확인

대기 중인 클라이언트는 대기열의 맨 앞 요소가 자신인지 확인하여 락을 시도합니다.

Set<byte[]> queueHead = connection.zRange(queueKey, 0, 0);
  • zRange(queueKey, 0, 0)는 점수가 가장 낮은(가장 먼저 추가된) 요소를 반환합니다.
  • 반환된 요소가 현재 클라이언트의 clientValue와 일치하면, 이 클라이언트가 락을 시도할 수 있는 권한을 가집니다.

1.4. 락 소유 여부 확인 및 설정

대기열의 맨 앞 클라이언트는 다음 단계를 통해 락을 설정하거나 락 상태를 확인합니다.

1.4.1. 현재 락 소유자 확인

byte[] currentLockOwner = connection.get(lockKey);
  • 락이 비어 있으면(currentLockOwner == null) 락을 설정할 수 있습니다.

1.4.2. 락 설정

connection.set(
lockKey,
clientValue,
Expiration.milliseconds(LOCK_EXPIRATION_TIME),
RedisStringCommands.SetOption.SET_IF_ABSENT
);
  • 락 키(lockKey)에 현재 클라이언트의 clientValue를 설정합니다.
  • TTL(LOCK_EXPIRATION_TIME)을 설정하여 Deadlock을 방지합니다.

1.4.3. TTL 확인 및 대기열 정리

connection.zRem(queueKey, clientValue);
  • 락이 성공적으로 설정되면, 해당 클라이언트는 대기열에서 자신을 제거합니다.

1.5. 대기 시간 초과 처리

클라이언트는 설정된 대기 시간(LOCK_ACQUISITION_TIMEOUT) 동안 락 획득을 시도합니다.

  • 대기 시간 초과 시 대기열에서 자신을 제거합니다.
connection.zRem(queueKey, clientValue);
  • 락 획득 실패를 반환합니다.

1.6. 대기열 순서 보장의 핵심

  • 타임스탬프 기반 점수 : 대기열 순서는 클라이언트가 대기열에 들어온 시간(System.currentTimeMillis())에 따라 결정됩니다.
  • 맨 앞 클라이언트 확인 : ZSET의 맨 앞 요소(zRange(queueKey, 0, 0))가 현재 클라이언트인지 확인하여 순서를 보장합니다.
  • 만료 항목 제거 : zRemRangeByScore를 통해 오래된 항목을 제거하여 대기열을 정리합니다.

2) 락 해제

락을 해제할 때는 소유권을 확인하여 안전하게 락을 삭제합니다. 이는 분산 환경에서 락이 다른 클라이언트에 의해 삭제되는 것을 방지합니다.

private static final String DISTRIBUTED_LOCK_KEY = "PROMOTION_EXAMPLE_LOCK_KEY";

public void releaseLock(String clientId) {
ValueOperations<String, String> ops = redisTemplate.opsForValue();
String currentAcquisitionClientId = ops.get(DISTRIBUTED_LOCK_KEY);
if (clientId.equals(currentAcquisitionClientId)) {
redisTemplate.delete(DISTRIBUTED_LOCK_KEY);
}
}

3) 사용 예시

룰렛 프로모션 당첨 항목 처리에서 Redis 락을 활용한 흐름은 아래와 같습니다.

private CustomResponse<?> executeWithRedisLock(EventTicketDto eventTicketDto) {
if (ObjectUtils.isEmpty(eventTicketDto)) {
return new CustomResponse<>(RouletteResponseType.XXXXXXXX_ERROR);
}
String clientId = UUID.randomUUID().toString();

// 레디스 락이 존재하는지 확인
if (redisLockAdapter.acquireDistributedLockWithSortedQueue(clientId)) {
try {
// Lock을 취득한 경우, 룰렛 당첨 로직을 수행한다.
return rouletteItemXXXXXXX.spinRoulette(eventTicketDto);
} catch (XxxxxxException e) {
return new CustomResponse<>(RouletteResponseType.XXXXXXXX_ERROR);
} finally {
// 작업이 완료되거나 실패한 경우, Lock을 방출한다.
redisLockAdapter.releaseLock(clientId);
}
} else {
// Lock 취득에 실패한 경우, 특정 에러를 반환한다.
return new CustomResponse<>(RouletteResponseType.XXXXXXXX_ERROR);
}
}

6. 룰렛 프로모션 시나리오

  1. 고객이 룰렛 이벤트에 참여 요청을 보냅니다.
  2. 서버는 Redis 락을 사용하여 동일한 이벤트에 대해 중복 처리가 발생하지 않도록 보호합니다.
  3. 락 획득 성공 시, 룰렛 당첨 로직을 수행하고 결과를 반환합니다.
  4. 작업 완료 후 락을 해제합니다.
  5. 락 획득 실패 시, 서버는 요청을 거절하고 오류 메시지를 반환합니다.

7. 마치며

이번 신년 룰렛 프로모션은 Redis Lettuce 기반 분산 락을 도입하여 성공적으로 운영되었습니다. 2주간 로그인 인원 대비 약 40%의 높은 참여율을 기록하였고, 안정적으로 고객들에게 서비스를 제공할 수 있었습니다. 특히 Redis 기반 분산 락은 다음과 같은 장점을 실현했습니다.

  • 참여 순서 보장 : Redis Sorted Set을 사용하여 사용자 대기열의 순서 보장
  • 시스템 안정성 강화 : 동시성 문제를 방지하여 데이터 정합성을 보장
  • 고객 경험 개선 : 중복 처리 없이 빠르고 정확한 이벤트 운영 가능
  • 유연한 확장 가능성 : 향후 다양한 이벤트나 프로모션에서 재사용할 수 있는 확장성 제공

Redis 기반 분산 락 도입 사례가 유사한 문제를 해결하려는 개발자분들께 도움이 되기를 바랍니다.

--

--

토니모리 테크블로그 : TonyTech
토니모리 테크블로그 : TonyTech

Written by 토니모리 테크블로그 : TonyTech

토니모리의 테크팀은 기술을 통해 고객 경험을 혁신하며 매일 더 나은 서비스를 만들어가고 있습니다. 우리의 도전과 성장을 기록하고, 그 여정을 함께 나누며 한 걸음씩 앞으로 나아갑니다.

No responses yet