분산 락이란 무엇인가?
서버 한 대만 돌리는 환경이라면 synchronized, ReentrantLock, DB 트랜잭션 등으로도 충분히 동시성 제어가 가능합니다.
하지만 대부분의 서비스는 트래픽 처리량과 가용성을 위해 여러 대의 서버(프로세스)로 구성된 분산 환경을 운영합니다.
문제는 여기서부터 시작됩니다.
서버 A, B가 동시에 같은 데이터를 수정한다면, 서버 간 메모리나 JVM이 독립되어 있기 때문에 기존의 synchronized 락은 전혀 통하지 않습니다.
이런 상황에서 자원의 일관성을 보장하기 위해 등장한 것이 바로 분산 락(Distributed Lock) 입니다.
분산 락의 핵심 개념 : “여러 서버(혹은 프로세스)가 하나의 자원에 동시에 접근할 때, 중앙화된 스토리지(예: Redis) 를 통해 락 상태를 공유하여 한 시점에 단 하나의 프로세스만 자원을 사용할 수 있도록 보장하는 것”
즉, 서버 수가 늘어나도 단일 서버처럼 일관된 동시성 제어를 가능하게 해주는 메커니즘이죠.
Redis를 활용한 분산 락
분산 락을 구현하는 방식은 여러 가지가 있지만, 가장 널리 쓰이는 세 가지는 다음과 같습니다.
| Zookeeper | 강력한 일관성과 리더 선출 기능 제공 | 설정 복잡, 성능 느림 |
| MySQL | GET_LOCK() 등 DB 트랜잭션 기반 | 디스크 I/O 기반으로 느림 |
| Redis | 인메모리 기반의 빠른 락 처리 | 기본적으로 단일 노드 안전성만 보장 |
Redis가 분산 락 구현체 중에서도 압도적으로 많이 쓰이는 이유는 단순히 “빠르기 때문”이 아닙니다.
그 속에는 인메모리 구조, 단순한 명령 조합, 일관성 높은 처리 모델이라는 세 가지 강점이 있습니다.
각각 조금 더 자세히 살펴볼게요.
1. 빠르다 – 인메모리(In-Memory) 구조 덕분
Redis가 빠른 이유의 핵심은 바로 모든 데이터를 메모리(RAM) 에 저장하고 처리하기 때문입니다.
이를 인메모리 데이터 저장소(In-Memory Data Store) 라고 부릅니다.
보통 데이터베이스(MySQL, PostgreSQL 등)는 데이터를 디스크(HDD, SSD) 에 저장하고, 요청이 들어올 때마다 디스크에서 데이터를 읽어야 합니다. 디스크 접근 속도는 메모리보다 수천 배 느리기 때문에, 읽기/쓰기 요청이 많아질수록 응답 속도에 병목이 생깁니다.
하지만 Redis는 디스크를 거치지 않고, 모든 데이터를 OS의 메모리 공간(RAM)에 상주시키고 바로 연산합니다.
즉, 디스크 I/O 없이 CPU ↔ RAM 간 접근만으로 처리되기 때문에 응답 속도가 마이크로초(μs) 단위로 매우 빠릅니다. 이런 특성 덕분에 락을 걸고 해제하는 작업(SETNX, DEL)처럼 짧고 자주 호출되는 연산에서는 거의 체감되지 않을 정도로 빠르게 작동합니다.
💡 예시 : 일반적인 RDBMS에서의 단일 쓰기 연산 속도: 수 ms (millisecond, 1/1000초)
Redis에서의 단일 쓰기 연산 속도: 수 μs (microsecond, 1/1,000,000초)
그래서 분산 락처럼 “짧은 시간 동안 수천 번 호출되는 연산”에는 디스크 기반보다 RAM 기반의 Redis가 훨씬 효율적입니다. 물론 메모리에 저장하기 때문에 데이터 영속성(persistence) 이 낮아질 수 있지만, 락 정보는 “임시적” 성격이 강하기 때문에 이 문제는 큰 단점이 되지 않습니다. 오히려 속도와 실시간성이 중요하기 때문에 인메모리 방식이 완벽하게 어울립니다.
2. 간단하다 – 단 세 개의 명령어로 구현 가능
Redis에서 분산 락을 구현하는 데 필요한 명령은 단 세 가지뿐입니다.
- SETNX: 존재하지 않을 때만 설정 → 락 획득
- EXPIRE: 유효 시간 설정 → 장애 시 자동 해제
- DEL: 작업 완료 후 삭제 → 락 해제
이 세 가지를 조합하면, “락 생성 → TTL 설정 → 해제”라는 분산 락의 전 과정을 단순한 구조로 처리할 수 있습니다. Zookeeper처럼 복잡한 트리 노드 구조나 MySQL의 트랜잭션 기반 락처럼 무거운 트랜잭션이 필요하지 않습니다.
그만큼 개발과 유지보수가 단순하고 직관적입니다.
3. 일관성이 높다 – 싱글 스레드 기반 처리 구조
Redis는 기본적으로 싱글 스레드(single-threaded) 로 동작합니다. 즉, 한 번에 하나의 명령만 처리합니다.
이게 단점처럼 들릴 수도 있지만, 락 같은 상호 배제(Mutual Exclusion) 연산에서는 오히려 장점이 됩니다.
여러 명령이 동시에 들어와도 Redis는 내부적으로 FIFO(First-In-First-Out) 방식으로 순차적으로 처리하기 때문에, 동시에 SETNX 명령을 보내더라도 정확히 한 명만 락을 획득하도록 자연스럽게 동시성을 제어해줍니다.
이건 다중 스레드 환경에서 생길 수 있는 데드락(Deadlock), 레이스 컨디션(Race Condition), 스케줄링 지연 문제를 Redis 수준에서 근본적으로 예방하는 효과가 있습니다.
즉, Redis 자체가 락 구현에 유리한 구조적 특성을 이미 가지고 있는 셈입니다.
💡 “싱글 스레드인데 왜 느리지 않나요?”
“싱글 스레드 = 동시 처리가 안 된다”라고 생각할 수 있지만, Redis에서는 이 말이 성능 저하를 의미하지 않습니다. 그 이유는 Redis의 내부 동작 구조 때문이에요.
Redis는 CPU를 효율적으로 활용하는 단일 이벤트 루프(Event Loop) 구조로 동작합니다. 즉, 여러 명령을 동시에 처리하지는 않지만, 매우 짧은 시간 안에 요청을 순차적으로 처리합니다. 한 명령이 끝나야 다음 명령이 실행되지만, 각 명령의 수행 시간이 보통 마이크로초(μs) 단위로 매우 짧습니다.
결과적으로 초당 수십만 건의 요청(ops/sec) 을 처리할 수 있습니다.
📈 실제 벤치마크 : Redis는 단일 코어 기준으로 초당 10만~20만 개의 요청을 처리할 수 있습니다. 즉, 수천 개의 클라이언트가 동시에 접근해도 “차례대로 빠르게 처리”되기 때문에 사용자는 병목을 거의 체감하지 못합니다.
💡 “과연 지금 redis는 모두 싱글 스레드로 이루어져 있을까?”
Redis 6.0 부터 클라이언트로 부터 전송된 네트워크를 읽는 부분과 전송하는 I/O 부분은 멀티 스레드를 지원합니다. 하지만 실행하는 부분은 싱글 스레드로 동작하기 때문에 기존과 같이 Atomic을 보장합니다.
정리하자면
| 빠르다 | 인메모리(RAM) 기반 구조 | 락 획득/해제가 μs 단위로 가능 |
| 간단하다 | SETNX + EXPIRE + DEL 단순 명령 조합 | 구현 난이도 낮고 유지보수 쉬움 |
| 일관성이 높다 | 싱글 스레드 명령 처리 구조 | 동시성 충돌 최소화 |
결국 Redis는 “속도, 단순성, 일관성” 세 가지를 모두 잡은 분산 락 구현체입니다.
락의 목적이 “서로 부딪히지 않게 빠르고 정확히 제어하는 것”임을 생각하면,
Redis는 이 조건을 가장 완벽하게 만족시키는 스토리지라고 할 수 있습니다.
Redis 분산 락의 기본 원리
Redis에서 분산 락을 구현할 때 가장 핵심이 되는 명령어 조합은 SETNX + EXPIRE 입니다.
이 두 명령어가 함께 동작하면서 “락을 획득하고 자동으로 해제되는 구조”를 만들어줍니다.
(1) 락 설정 – SETNX
SETNX는 SET if Not eXists의 약자로, Redis에 실제로 존재하는 기본 내장 명령어입니다.
SETNX lock_key "1"
이 명령은 “해당 key가 존재하지 않을 때만 값을 설정”합니다.
즉, 동일한 key를 사용하는 여러 클라이언트가 동시에 SETNX 명령을 보내더라도, Redis는 내부적으로 싱글 스레드 기반의 순차 처리 방식이기 때문에 가장 먼저 도착한 요청 하나만 성공(true 반환) 하고 나머지는 실패하게 됩니다.
이 덕분에 동시에 여러 서버가 접근하더라도 한 시점에는 단 하나의 프로세스만 자원을 점유할 수 있게 되어, 분산 환경에서도 상호 배제(Mutual Exclusion) 가 보장됩니다.
SETNX는 Redis의 기본 키 명령 중 하나로,키가 존재하지 않을 때만 값을 설정하는 연산입니다.
(즉, 단순히 “존재 확인 → 삽입”을 하나의 원자적 명령으로 수행합니다.)
(2) TTL 설정 – EXPIRE
락을 획득한 프로세스가 정상적으로 해제하지 못하고 중간에 장애로 죽어버리면 어떻게 될까요?
이 경우 락이 영구적으로 남아버려, 다른 프로세스가 자원에 접근하지 못하는 교착 상태가 발생합니다.
이 문제를 해결하기 위해 Redis는 TTL(Time To Live) 기능을 제공합니다. EXPIRE 명령으로 특정 키에 유효 기간(초 단위) 을 설정할 수 있습니다.
EXPIRE lock_key 5 # 5초 후 락 자동 해제
이렇게 설정해두면 5초가 지나면 Redis가 알아서 해당 락을 삭제합니다.
즉, 락을 걸었던 프로세스가 죽더라도 자동으로 복구 가능한 구조(Fault Tolerant Design) 가 되는 것이죠.
왜 TTL이 꼭 필요할까? 락은 “한시적으로 자원을 점유하기 위한 권한”이기 때문에 무한정 유지되면 다른 요청이 모두 차단되어 시스템 전체가 정지할 수 있습니다. TTL을 설정하면, 락이 일정 시간 후 자동으로 해제되어 시스템이 멈추지 않고 자연스럽게 회복됩니다.
(3) 락 생성 + TTL 설정을 한 번에 – SET NX EX
SETNX와 EXPIRE를 따로 호출하는 대신, Redis 2.6 버전 이후에는 이 두 기능을 하나의 명령으로 결합한 고급 SET 명령을 제공합니다.
SET lock_key "1" NX EX 5
이 명령은 다음의 의미를 한 번에 수행합니다.
- NX: key가 없을 때만 설정 (SETNX와 동일한 역할)
- EX 5: key에 TTL 5초 설정 (EXPIRE 역할)
즉, 한 번의 명령으로 락 획득과 TTL 설정을 모두 처리할 수 있습니다.
이 방식의 장점은 명령이 하나로 합쳐져 있기 때문에, 도중에 장애가 생겨 SETNX는 성공했지만 EXPIRE가 실행되지 않는 레이스 컨디션(Race Condition) 을 원천적으로 방지할 수 있다는 점입니다.
SETNX와 EXPIRE은 Redis에 원래 존재하는 별개의 명령어이지만,이후 Redis가 자주 쓰이는 이 조합을 위해 단일 명령(SET ... NX EX)으로 통합 지원하게 되었습니다.
(4) 락 해제 – DEL
락을 걸었다면, 반드시 해제해야 합니다.
그렇지 않으면 다른 프로세스가 락을 얻지 못해 시스템이 멈추게 됩니다.
DEL lock_key
DEL은 Redis의 가장 기본적인 삭제 명령어로, 해당 key를 즉시 삭제하여 락을 해제합니다. 하지만 여기서 주의해야 할 부분이 있습니다.
만약 락을 보유한 프로세스 A의 TTL이 만료되어 락이 자동 해제된 후, 다른 프로세스 B가 새로운 락을 얻었는데, A가 늦게 도착해서 DEL을 실행한다면 어떻게 될까요?
➡️ B의 락이 실수로 삭제되는 심각한 문제가 발생합니다.
여기서 중요한 점은, Redis는 “같은 이름의 키”를 덮어써서 사용하는 구조이기 때문에, A가 DEL lock_key를 실행하면, Redis는 “그냥 이름이 lock_key인 데이터를 삭제하라”는 요청으로 이해합니다.
Redis는 A, B를 구분하지 않습니다. 그냥 key만 보고 지워요.
그래서 락을 걸 때 UUID나 스레드 ID 같은 고유 식별자(value) 를 함께 저장하고, 해제할 때 “현재 Redis에 저장된 값이 내가 건 락의 ID와 동일한지”를 확인한 뒤 삭제해야합니다.
이렇게 하면 남의 락을 실수로 지우는 일을 방지할 수 있습니다.
전체 흐름 정리
| 1 | SETNX | 락 설정 | 이미 락이 걸려있으면 실패 |
| 2 | EXPIRE | TTL 설정 | 장애로 인한 영구 락 방지 |
| 3 | DEL | 락 해제 | 다른 요청이 다시 접근 가능 |
| 💡 | SET NX EX | 원자적 실행 | SETNX + EXPIRE 중간 장애 방지 |
이 명령어들은 모두 Redis에 기본적으로 내장되어 있는 정식 명령어(Official Command) 들입니다.
즉, 별도의 플러그인이나 라이브러리를 추가하지 않아도
Redis만 설치되어 있다면 바로 CLI나 클라이언트에서 실행할 수 있습니다.
이 세 단계만으로도 Redis를 이용한 간단하지만 강력한 분산 락 구조를 구현할 수 있습니다.
Lettuce란?
이제 이 락을 Java에서 어떻게 구현할지 살펴봅시다.
Java에서 Redis와 통신할 수 있는 대표적인 클라이언트는 Jedis와 Lettuce, 그리고 Redisson입니다.
- Jedis: 오래된 동기식 클라이언트 (요청마다 스레드가 블로킹)
- Lettuce: Netty 기반의 비동기/논블로킹 클라이언트
- Redisson: 고수준의 락, 세마포어, 큐 등 다양한 추상화를 제공하는 라이브러리
💡 Lettuce의 장점
Spring Boot 2.x 이상에서는 기본적으로 Lettuce가 Redis 클라이언트로 설정되어 있습니다.
이전에는 Jedis가 기본이었지만, 최근에는 대부분의 프로젝트가 Lettuce로 전환되었습니다.
그 이유는 Lettuce가 더 높은 성능과 안정성, 그리고 현대적인 구조를 갖추고 있기 때문입니다.
1️⃣ Netty 기반의 고성능 I/O
Lettuce는 내부적으로 Netty라는 네트워크 프레임워크 위에서 동작합니다.
Netty는 비동기 이벤트 기반 구조로, 요청마다 스레드를 새로 생성하지 않고 하나의 이벤트 루프(Event Loop) 가 수많은 네트워크 요청을 효율적으로 처리합니다.
즉, 네트워크 I/O를 기다리며 스레드가 블로킹되는 일이 없고, CPU 사용 효율이 극대화되며 TPS(초당 처리량) 이 크게 향상됩니다.
이 덕분에 Lettuce는 단일 서버 환경에서도 높은 성능을 내고, 대규모 트래픽이 몰리는 환경에서도 안정적으로 Redis 요청을 처리할 수 있습니다.
📌 참고 Netty는 Spring WebFlux, Elasticsearch, gRPC 등고성능 프레임워크의 핵심 네트워크 엔진으로 사용될 만큼 검증된 기술입니다.
2️⃣ 비동기(Async) · 논블로킹(Non-blocking) 구조
Lettuce는 비동기 통신 방식을 지원합니다.
요청을 보낸 뒤 응답을 기다리지 않고 다음 작업을 수행할 수 있으며, 결과는 나중에 CompletableFuture나 Reactive Streams 형태로 비동기적으로 받을 수 있습니다.
즉, 하나의 스레드로 여러 요청을 병렬적으로 처리할 수 있어, 자원을 효율적으로 사용하고, 대규모 요청을 빠르게 처리할 수 있습니다.
반면 Jedis는 동기(Blocking) 방식이기 때문에 요청을 보낼 때마다 응답을 기다려야 했고,
이로 인해 스레드 수가 많아질수록 성능 저하가 발생했습니다.
Lettuce는 이런 구조적 한계를 해결하면서 멀티 스레드 환경에서도 스레드 블로킹 없이 높은 처리량을 유지합니다.
3️⃣ Thread-safe한 커넥션 구조
Lettuce는 내부 커넥션이 Thread-safe하게 설계되어 있습니다. 하나의 커넥션을 여러 스레드가 안전하게 공유할 수 있으며, 동시에 여러 요청이 들어와도 충돌 없이 안정적으로 처리됩니다.
이 구조 덕분에
- 별도의 JedisPool 같은 커넥션 풀을 직접 구성할 필요가 없고,
- 커넥션 수를 최소화하면서도 안정적인 동시 처리가 가능합니다.
즉, Lettuce는 멀티스레드 환경에서도 안전하게 동작하는 클라이언트입니다.
Lettuce를 이용한 Redis 분산 락 구현
Lettuce는 Redis와 비동기(Async)·논블로킹(Non-blocking) 으로 통신할 수 있는 자바 클라이언트입니다.
Spring Boot 2.x 이상에서는 기본 Redis 클라이언트가 Lettuce이기 때문에,
별도 설정 없이 spring-boot-starter-data-redis 의존성만 추가해도 바로 사용할 수 있습니다.
(1) Redis 설정
docker pull redis
docker run --name redis -d -p 6379:6379 redis
로컬 혹은 테스트 환경에서 Redis를 실행합니다.
(2) Gradle 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Spring이 자동으로 Lettuce 커넥션을 구성해주기 때문에,
추가 설정 없이 바로 RedisTemplate을 주입받아 사용할 수 있습니다.
(3) RedisLockRepository 구현
아래는 Lettuce를 통해 직접 분산 락을 구현하는 기본 예시입니다.
핵심은 SETNX + EXPIRE 조합을 이용해 락을 한 번에 생성하고,
Lua Script를 이용해 락을 안전하게 해제하는 것입니다.
@RequiredArgsConstructor
@Repository
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
/**
* 락 획득 시도 (SETNX + TTL)
* SET key value NX EX <expireTime>
* → 키가 없을 때만 값을 설정하고, 동시에 TTL(만료시간)을 부여.
*/
public boolean acquireLock(String lockKey, String requestId, long expireTime) {
String result = redisTemplate.execute((RedisCallback<String>) connection ->
connection.set(
lockKey.getBytes(),
requestId.getBytes(),
Expiration.from(expireTime, TimeUnit.MILLISECONDS),
RedisStringCommands.SetOption.SET_IF_ABSENT
)
);
return "OK".equals(result);
}
/**
* 락 해제 (Lua Script 사용)
* "내가 설정한 락"일 때만 삭제하도록 원자적으로 처리.
*/
public boolean releaseLock(String lockKey, String requestId) {
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute((RedisCallback<Long>) connection ->
connection.eval(
luaScript.getBytes(),
ReturnType.INTEGER,
1,
lockKey.getBytes(),
requestId.getBytes()
)
);
return result != null && result > 0;
}
}
💡 여기서 중요한 포인트
- SETNX + EXPIRE를 하나의 명령으로 묶은 이유
- → 락 생성과 TTL 설정을 분리하면, 두 명령 사이 장애로 영구 락(dead lock) 이 발생할 수 있습니다. 따라서 SET key value NX EX 형태로 원자적(atomic) 으로 실행해야 합니다.
- Lua Script를 사용하는 이유
하지만 이 경우 두 명령이 분리되어 실행되므로, Redis는 여러 명령을 연달아 실행하면 명령 사이에 다른 클라이언트의 요청이 끼어들 수 있습니다.public boolean releaseLock(String lockKey, String requestId) { String currentValue = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(currentValue)) { redisTemplate.delete(lockKey); return true; } return false; }
따라서 Lua 스크립트와 eval 명령어 (명령 전체가 하나의 트랜잭션 단위로 처리되는)를 함께 사용해 하나의 명령으로 Lua 스크립트를 전달해 스크립트 안의 Redis 호출(redis.call())들은 원자적으로(atomic) 실행하도록 해야합니다.
→ 즉, eval 명령어와 Lua 스크립트를 사용해 GET과 DEL을 서버 내부에서 한 번에 실행(원자성 보장) 해야 합니다.
(4) Lettuce 기반 스핀락 구현
락 획득이 실패하면 잠시 대기 후 재시도하는 스핀락(Spin-lock) 방식입니다.
간단하면서도 즉시성이 높아, 짧은 임계 구역(예: 재고 감소)에 적합합니다.
@RequiredArgsConstructor
@Service
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private final StockService stockService;
public void decrease(Long id) {
String lockKey = "lock:stock:" + id;
String requestId = UUID.randomUUID().toString();
long expireTime = 3000L;
// 1) 락 획득 시도
while (!redisLockRepository.acquireLock(lockKey, requestId, expireTime)) {
try {
Thread.sleep(100); // 100ms 후 재시도
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
try {
// 2) 임계 구역
stockService.decrease(id);
} finally {
// 3) 락 해제
redisLockRepository.releaseLock(lockKey, requestId);
}
}
}
스핀락은 구현이 단순하지만, 경쟁이 심한 환경에서는 Redis에 폴링 요청이 과도하게 발생할 수 있습니다.
이를 보완하려면 다음과 같은 전략을 함께 사용하는 것이 좋습니다.
- 지수 백오프(Exponential Backoff): 재시도 간격을 점진적으로 늘리기
- 타임아웃 설정: 일정 시간 이후 락 요청 포기
- Pub/Sub 기반 이벤트 대기: 락 해제 이벤트를 구독해 효율적으로 재시도
Lettuce 분산 락의 장단점
| 장점 | - Spring 기본 클라이언트로 별도 설정 불필요- 비동기·논블로킹으로 높은 처리량- 구현이 단순해 빠르게 적용 가능 |
| 단점 | - 스핀락 방식이라 Redis 요청이 많으면 부하 발생- EXPIRE 이전 장애 발생 시 락이 해제되지 않을 수 있음- 클러스터 환경에서는 RedLock과 같은 추가 알고리즘 필요 |
정리
- Redis는 빠르고 단순한 구조 덕분에 가장 널리 쓰이는 분산 락 솔루션이다.
- Lettuce는 Spring 기본 클라이언트이자,
- Netty 기반의 고성능 비동기 클라이언트로 간단하게 락 구현이 가능하다.
'Spring > 동시성 제어' 카테고리의 다른 글
| ReentrantLock의 이해와 활용 (0) | 2025.12.03 |
|---|---|
| CAS(Compare-And-Swap) 이해하기 (0) | 2025.11.06 |
| MySQL Named 락과 분산 락 이해하기 (0) | 2025.10.11 |
| Spring 낙관적 락 (Optimistic Lock) (0) | 2025.09.08 |
| Spring 비관적 락 (Pessimistic Lock) (0) | 2025.09.08 |