Redis를 활용한 분산락 구현하기

개발자 팀·2026년 1월 23일

self-study-series

목록 보기
6/16
post-thumbnail

분산락의 구현 방법론 중 가장 많이 사용되는 Redis를 통해 분산락을 구현하는 방법에 대해 알아보겠습니다.

왜 Redis인가?

Redis는 기본적으로 인메모리 DB이기 때문에 매우 빠릅니다. 컴퓨터 구조상 멀리에 위치해있는 하드디스크보다 바로 옆에 위치해있고 I/O 비용 없이 CPU가 바로 접근을 할 수 있기 때문이죠.
기본적으로 분산락의 조건은 다음과 같습니다.

  1. 모든 서버에서 접근이 가능해야 합니다.
  2. 빠른 응답 속도는 필수입니다. 락 획득에 1초 이상 걸려버린다면 고객은 기다려주지 않을테니까요.
  3. 원자적인 연산을 지원해야 합니다. 즉, 동시에 두 서버가 락을 획득해서는 안됩니다.
  4. 자동 만료를 통하여 서버 장애 시 자연스럽게 락이 해제될 수 있도록 해야합니다.

이 조건들을 모두 만족하는 것, 그것이 바로 Redis이지요!

Redis 분산락의 기본 원리

Redis에서 분산락을 사용하기 위해선 SET NX EX명령어를 사용해야 합니다. 이 명령어로 키가 없을 때만 값을 설정하고, 자동 만료 시간도 함께 지정할 수 있습니다.
명령어의 구조는 아래와 같습니다.

SET key value NX EX seconds
│   │   │     │  │  └── 만료 시간 (초 단위)
│   │   │     │  └── EX = Expire (만료 시간 설정)
│   │   │     └── NX = Not eXists (키가 없을 때만)
│   │   └── 저장할 값 (보통 UUID)
│   └── 키 이름 (락 식별자)
└── Redis SET 명령

그럼 왜 이 명령어를 사용해야 할까요?
이는 다음 두 가지 조건을 동시에, 원자적으로 만족해야 하기 때문입니다.

조건이유해결 옵션
1. 키가 없을 때만 설정이미 락이 있으면 획득하면 안 됨NX
2. 자동 만료 설정서버 장애 시 락이 영원히 남으면 안 됨EX

여기서 원자적이라는 말의 의미는 무엇일까요? 그것은 더 이상 쪼갤 수 없음을 뜻합니다.
즉, SET NX EX 명령은 존재확인 + 값 설정 + 만료시간 설정이 모두 하나의 동작으로 실행되게 됩니다.
그렇기 때문에 다른 명령들이 중간에 끼어들 수 없게되어 안전해집니다.
해당 명령어의 기본 문법은 아래와 같습니다.

# 기본 문법
SET key value NX EX seconds

# NX: 키가 존재하지 않을 때만 설정 (Not eXists)
# EX: 만료 시간(초) 설정

# 예시: user:123:point:lock 키를 30초 TTL로 설정
SET user:123:point:lock "server-a-uuid" NX EX 30

# 응답:
# - OK: 락 획득 성공 (키가 없었음)
# - nil: 락 획득 실패 (키가 이미 존재)

여기서 각 부분들의 역할을 자세히 보겠습니다.

부분값 예시역할
keyuser:123:point:lock락을 식별하는 이름. 같은 키로 요청하면 같은 락을 경쟁
valueuuid-a1b2c3...락 소유자 식별. 나중에 "내가 획득한 락인지" 확인할 때 사용
NX-Not eXists. 키가 없을 때만 설정. 이미 있으면 실패
EX30EXpire. 30초 후 자동 삭제. 서버 장애 시 안전장치

다음은 락 획득 과정을 도식화한 자료입니다. 핵심원리는 Redis의 SET NX 명령이 원자적으로 동작한다는 것입니다.
이는 키가 없는지 확인하고 값을 설정하는 두 동작이 끊기지 않고 이어진다는 것을 의미하며 덕분에 두 서버가 동시에 같은 락을 획득하려해도 반드시 하나만 성공하게 됩니다.

위 도식화 자료를 순서대로 보자면 아래와 같습니다.

  1. 서버A의 락 획득 시도 : 서버A가 SET user:123:lock "uuid-a" NX EX 30 명령을 수행합니다.
    • NX : 키가 존재하지 않을 때만(Not eXists) 설정하라는 옵션입니다.
    • EX 30 : 30초 후 자동 만료되도록 설정합니다.
    • uuid-a : 서버A가 생성한 고유 식별자로 나중에 자신의 락인지 확인할 때 사용합니다.
  2. Redis의 처리 : 현재 user:123:lock키가 없으므로 값을 설정하고 OK(락획득)을 반환합니다.
  3. 서버B의 락 획득 시도 : 서버B도 같은 키로 락 획득을 시도합니다.
  4. Redis의 거부 : 하지만 이미 같은 키가 존재하므로 NX조건을 만족할 수가 없어 아무것도 설정하지 않고 nil을 반환합니다.

락 해제 키 포인트

락 획득 못지 않게 락 해제도 정말 중요합니다. 사용한 자원을 당연히 잘 반납해야하니까요!
하지만 해제를 하다가 남의 락(다른 서버의 락)을 해제하면 안되겠죠. 그러니 자신이 획득한 락만을 해제해야 합니다.

# 단순 DELETE는 위험!
DEL user:123:point:lock

위와 같이 단순히 DEL 명령으로 락을 삭제하면 내가 획득한 락이 아닌 다른 서버의 락을 삭제해버릴 수도 있습니다.
이 상황은 락의 TTL이 지나 다른 서버가 락을 획득한 후 발생하곤 합니다.
문제상황을 도식화해보죠.

시간서버 A서버 B서버 CRedis 상태
T1락 획득 (uuid-a)--🔒 uuid-a
T2작업 중... (오래 걸림)--🔒 uuid-a
T2.5(TTL 만료)--🔓 락 없음
T3작업 중...락 획득 (uuid-b)-🔒 uuid-b
T4DEL lock 실행!작업 중...-🔓 락 없음
T5-작업 중...락 획득 (uuid-c)🔒 uuid-c
T6-작업 중!작업 중!💥 동시 접근!

서버A는 자신이 가진 자신의락(uuid-a)를 삭제하려했지만 실제로는 서버B의 락(uuid-b)를 삭제해버렸습니다.
그 결과로 서버B와 서버C가 동시에 같은 자원에 접근해버리는 현상이 나타나게 되었지요.
이를 해결하기 위해선 원자성을 보장하는 방식으로 해결을 해야 합니다. 즉, 올바른 락 해제를 위해서 Lua 스크립트를 사용해야 한다는 말이죠.
단순히 Java 코드로 값을 확인하고 삭제를 하게 되면 다음과 같은 문제들이 생기게 됩니다.

// ❌ 위험한 방식 - 원자적이지 않음!
String currentValue = redisTemplate.opsForValue().get(lockKey);  // 1단계: 값 확인
if (lockValue.equals(currentValue)) {
    redisTemplate.delete(lockKey);  // 2단계: 삭제
}
시간서버 A서버 BRedis 상태
T1GET lock → "uuid-a" ✓-🔒 uuid-a
T2(값 일치 확인 완료)-🔒 uuid-a
T3(아직 DEL 실행 전...)(TTL 만료)🔓 락 없음
T4-SET lock "uuid-b"🔒 uuid-b
T5DEL lock 실행!-🔓 락 없음 💥 uuid-b 삭제됨!

결국 Lua 스크립트를 활용하게 되면 위의 작업들이 하나의 원자적 실행으로 묶이게 되어 하나의 명령처럼 동작하게 되기 때문이죠.

Lua 스크립트는 다음과 같이 작성됩니다.

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
구성 요소설명예시
KEYS[1]락의 키 이름 (첫 번째 키 인자)"user:123:point:lock"
ARGV[1]비교할 값 (첫 번째 일반 인자)"uuid-a-1234-5678"
redis.call("get", ...)Redis GET 명령 실행현재 저장된 값 조회
redis.call("del", ...)Redis DEL 명령 실행키 삭제 후 1 반환
return 0값 불일치 시삭제하지 않음

Lua 스크립트를 사용하면 원자성 뿐만 아니라 네트워크 왕복도 줄여주게되죠. 왜냐하면 한 번에 전부 다 실행되니까요!
Java에서 Lua스크립트를 활용해 안전하게 락을 해제 하는 예제는 다음과 같습니다.

@Component
public class RedisLockManager {

    private static final String UNLOCK_SCRIPT =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('del', KEYS[1]) " +
        "else " +
        "    return 0 " +
        "end";

    @Autowired
    private StringRedisTemplate redisTemplate;

    public boolean unlock(String lockKey, String lockValue) {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptText(UNLOCK_SCRIPT);
        script.setResultType(Long.class);

        Long result = redisTemplate.execute(
            script,
            Collections.singletonList(lockKey),
            lockValue
        );

        return result != null && result == 1L;
    }
}

TTL의 중요성

다음은 분산락의 핵심 개념 중 하나인 TTL에 대해 알아보겠습니다.
TTL은 데드락을 방지하는 핵심 메커니즘입니다. 락이 설정된 후 일정 시간이 지나면 자동으로 삭제되도록 하는 기능입니다. 이는 서버 장애 상황시에 락이 영원히 남아있는 것을 방지하는 역할을 하죠.

갑작스러운 서버 다운으로 인하여 서버 락이 영원히 Redis에 남아있게 된다면? 그 락 때문에 해당 자원은 영원히 서비스를 제공할 수 없게 되는거죠.
만약 TTL이 설정되어있다면 어떻게 될까요?

만약 서버가 다운이 된다고 해도 Redis는 자동으로 TTL 설정 시간(예 : 30초) 후에 자동으로 락을 삭제하게 됩니다.
이후 다른 서버들이 락을 획득할 수 있게 되어 시스템이 자동으로 복구되게 됩니다.

상황TTL 없음TTL 있음
정상 동작✅ 문제없음✅ 문제없음
서버 장애 시❌ 락 영구 유지 → 서비스 중단✅ TTL 후 자동 해제 → 자동 복구
네트워크 단절 시❌ 락 해제 불가✅ TTL 후 자동 해제
애플리케이션 버그❌ unlock() 누락 시 영구 락✅ TTL이 최후의 안전망 역할

TTL 설정 가이드 라인은 아래와 같습니다.

/**
 * TTL 설정 기준
 *
 * 기본 공식: 예상 처리 시간 × 2~3 + 여유 시간
 *
 * 예시:
 * - 단순 포인트 차감: 1초 예상 → TTL 5초
 * - DB 트랜잭션 포함: 3초 예상 → TTL 10초
 * - 외부 API 호출 포함: 10초 예상 → TTL 30초
 */
public class LockTimeConstants {

    public static final long QUICK_OPERATION = 5;      // 5초
    public static final long STANDARD_OPERATION = 10;  // 10초
    public static final long LONG_OPERATION = 30;      // 30초
    public static final long EXTENDED_OPERATION = 60;  // 60초
}

마치며.

이번에는 Redis를 활용한 분산락에 대해 간단하게 알아보았습니다. 핵심은 Redis 활용 시에도 락 획득을 위해 SET key value NX EX 30의 방식을 사용하여 키의 정합성과 TTL을 설정하였고, Lua 스크립트를 활용해 원자적으로 키 값을 비교하여 자신만의 락을 삭제하도록 설정할 수 있었습니다.
다음에는 Spring Boot 환경에서의 분산락 구현 방법에 대해 스터디 해본 내용을 남기도록 하겠습니다.
감사합니다 🫡

profile
공부하고 기록하고 공유하는 개발자 팀(Tim) 입니다. 늘끄적입니다.

0개의 댓글