Valkey로 구현하는 분산 락: 원리부터 성능·커넥션·장애 대응까지
여러 서버 인스턴스가 동일한 데이터를 동시에 변경하는 환경에서는 애플리케이션 내부의 synchronized나 ReentrantLock만으로 동시성을 제어할 수 없다.
예를 들어 주문 서버가 세 대 실행 중이고, 동일한 쿠폰을 동시에 발급하려는 요청이 각각 다른 서버로 들어온다고 가정해 보자.
Client A → Application 1
Client B → Application 2
Client C → Application 3
각 애플리케이션의 JVM 내부 락은 자신의 프로세스 안에서만 유효하다. 따라서 Application 1이 락을 획득해도 Application 2와 Application 3은 이를 알 수 없다.
이처럼 여러 프로세스나 서버가 공유하는 자원에 대해 상호 배제를 보장하기 위해 사용하는 것이 분산 락이다.
Valkey는 메모리 기반의 빠른 명령 처리와 원자적 연산을 제공하기 때문에 분산 락 저장소로 자주 활용된다. 그러나 단순히 SETNX를 호출하는 것만으로 안전한 분산 락이 완성되는 것은 아니다.
실무에서는 다음 문제를 함께 고려해야 한다.
- 락 소유자 식별
- 락 만료 시간
- 락 해제의 원자성
- 작업 시간이 TTL을 초과하는 상황
- 재시도와 락 경합
- Valkey 장애와 Failover
- 커넥션 풀과 명령 지연
- 락을 획득했지만 DB 작업이 중복되는 상황
이 글에서는 Valkey 기반 분산 락의 동작 원리부터 Spring 환경의 구현, 성능, 커넥션 처리 및 운영 시 주의사항까지 정리한다.
1. 분산 락이 필요한 상황
분산 락은 여러 인스턴스가 동일한 자원을 동시에 변경할 가능성이 있을 때 필요하다.
대표적인 사례는 다음과 같다.
한정 수량 쿠폰 발급
재고 차감
중복 결제 방지
정산 배치 중복 실행 방지
스케줄러 중복 실행 방지
동일 사용자의 상태 변경
외부 시스템 중복 요청 제한
예를 들어 재고가 한 개 남은 상황에서 두 요청이 동시에 처리되면 다음과 같은 문제가 발생할 수 있다.
요청 A: 재고 조회 → 1
요청 B: 재고 조회 → 1
요청 A: 재고 차감 → 0
요청 B: 재고 차감 → 0
두 요청 모두 재고가 존재한다고 판단했기 때문에 실제 재고보다 많은 주문이 승인될 수 있다.
이를 방지하려면 동일한 상품에 대한 변경 작업이 동시에 실행되지 않도록 임계 구역을 만들어야 한다.
lock:product:100
먼저 락을 획득한 요청만 재고를 변경하고, 나머지 요청은 대기하거나 실패하도록 처리한다.
2. Valkey 분산 락의 기본 원리
Valkey에서는 다음 명령으로 락을 획득할 수 있다.
SET lock:product:100 request-uuid NX PX 3000
각 옵션의 의미는 다음과 같다.
| 옵션 | 의미 |
|---|---|
lock:product:100 |
락을 식별하는 키 |
request-uuid |
락 소유자를 식별하는 고유 값 |
NX |
키가 존재하지 않을 때만 저장 |
PX 3000 |
3초 후 자동 만료 |
SET 명령은 NX와 PX 옵션을 함께 사용할 수 있으며, 조건 확인과 키 저장, TTL 설정을 하나의 명령으로 처리한다. 명령의 시간 복잡도도 O(1)이다.
락 키가 존재하지 않으면 다음과 같이 생성된다.
SET lock:product:100 request-a NX PX 3000
→ OK
이미 다른 요청이 락을 획득했다면 키가 존재하므로 저장되지 않는다.
SET lock:product:100 request-b NX PX 3000
→ nil
이 연산이 원자적으로 실행되기 때문에 여러 애플리케이션 인스턴스가 동시에 요청하더라도 한 요청만 락을 획득할 수 있다.
3. 왜 SETNX와 EXPIRE를 따로 실행하면 안 되는가
다음과 같이 락 획득과 TTL 설정을 분리해서는 안 된다.
SETNX lock:product:100 request-a
EXPIRE lock:product:100 3
두 명령 사이에서 애플리케이션이 종료될 수 있기 때문이다.
1. SETNX 성공
2. 애플리케이션 장애
3. EXPIRE 실행 실패
이 경우 락 키에 TTL이 설정되지 않아 영구적으로 남을 수 있다.
lock:product:100 → 영구 락
따라서 락 생성과 TTL 설정은 반드시 하나의 SET NX PX 명령으로 처리해야 한다.
SET lock:product:100 request-a NX PX 3000
4. 락 값에는 반드시 소유자 식별자가 필요하다
락 값으로 "LOCKED"와 같은 고정 문자열을 사용하면 안 된다.
SET lock:product:100 LOCKED NX PX 3000
다음과 같은 상황을 생각해 보자.
1. 요청 A가 락 획득
2. 요청 A의 작업이 지연
3. 락 TTL 만료
4. 요청 B가 동일한 락 획득
5. 요청 A의 작업 완료
6. 요청 A가 락 삭제
요청 A가 단순히 DEL을 실행하면 요청 B가 새로 획득한 락까지 삭제해 버린다.
DEL lock:product:100
따라서 락을 획득할 때 요청마다 고유한 값을 저장해야 한다.
String lockValue = UUID.randomUUID().toString();
SET lock:product:100 9352146e-... NX PX 3000
락을 해제할 때는 현재 저장된 값이 자신이 저장한 값과 같은 경우에만 삭제해야 한다.
5. GET과 DEL을 분리하면 발생하는 경쟁 조건
다음 구현도 안전하지 않다.
String currentValue = redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
redisTemplate.delete(lockKey);
}
GET과 DEL 사이에 락이 만료될 수 있기 때문이다.
1. 요청 A가 GET으로 자신의 값 확인
2. A의 락 TTL 만료
3. 요청 B가 락 획득
4. 요청 A가 DEL 실행
5. 요청 B의 락이 삭제됨
따라서 값 비교와 삭제가 반드시 원자적으로 실행되어야 한다.
이를 위해 Lua 스크립트를 사용할 수 있다.
if valkey.call('GET', KEYS[1]) == ARGV[1] then
return valkey.call('DEL', KEYS[1])
else
return 0
end
Valkey의 Lua 스크립트는 서버 내부에서 실행되므로 값 확인과 삭제를 하나의 원자적 작업으로 처리할 수 있다. 스크립트가 접근하는 키는 KEYS 인자로 명시적으로 전달해야 하며, 이는 Cluster 환경에서 올바른 키 라우팅을 위해서도 필요하다.
6. Spring Boot에서 구현하기
다음은 StringRedisTemplate을 이용한 기본적인 분산 락 구현이다. Spring Data Redis의 API 명칭에는 Redis가 포함되어 있지만, Valkey가 제공하는 호환 프로토콜과 명령을 통해 사용할 수 있다.
락 획득
@Component
@RequiredArgsConstructor
public class ValkeyDistributedLock {
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT =
new DefaultRedisScript<>(
"""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""",
Long.class
);
private final StringRedisTemplate redisTemplate;
public boolean tryLock(
String lockKey,
String lockValue,
Duration leaseTime
) {
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(
lockKey,
lockValue,
leaseTime
);
return Boolean.TRUE.equals(acquired);
}
public boolean unlock(
String lockKey,
String lockValue
) {
Long result = redisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(lockKey),
lockValue
);
return result != null && result == 1L;
}
}
서비스 적용
@Service
@RequiredArgsConstructor
public class CouponService {
private final ValkeyDistributedLock distributedLock;
private final CouponRepository couponRepository;
@Transactional
public void issueCoupon(
Long couponId,
Long userId
) {
String lockKey = "lock:coupon:" + couponId;
String lockValue = UUID.randomUUID().toString();
boolean acquired = distributedLock.tryLock(
lockKey,
lockValue,
Duration.ofSeconds(3)
);
if (!acquired) {
throw new IllegalStateException(
"현재 다른 요청에서 쿠폰을 처리하고 있습니다."
);
}
try {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow();
coupon.issueTo(userId);
} finally {
distributedLock.unlock(lockKey, lockValue);
}
}
}
락 해제는 반드시 finally에서 실행해야 한다.
try {
// 임계 구역
} finally {
// 락 해제
}
그러나 이 구현만으로 모든 문제가 해결되는 것은 아니다.
7. 트랜잭션 종료 전에 락을 해제하면 안 된다
Spring의 @Transactional은 프록시를 통해 메서드 호출 이후 트랜잭션을 커밋한다.
다음 구조를 사용하면 코드상 임계 구역이 끝났더라도 실제 DB 커밋은 아직 완료되지 않았을 수 있다.
@Transactional
public void process() {
try {
updateDatabase();
} finally {
unlock();
}
}
실행 순서는 다음과 같이 될 수 있다.
1. 락 획득
2. DB 데이터 변경
3. finally에서 락 해제
4. 메서드 반환
5. Spring 트랜잭션 커밋
3번과 5번 사이에 다른 요청이 락을 획득하면 아직 커밋되지 않은 데이터에 접근하거나, 동일한 업무를 다시 처리할 수 있다.
따라서 분산 락의 범위가 DB 트랜잭션 전체를 감싸도록 구성하는 것이 안전하다.
public void issueCoupon(Long couponId, Long userId) {
String lockKey = "lock:coupon:" + couponId;
String lockValue = UUID.randomUUID().toString();
if (!distributedLock.tryLock(
lockKey,
lockValue,
Duration.ofSeconds(3)
)) {
throw new LockAcquisitionException();
}
try {
transactionTemplate.executeWithoutResult(status ->
issueCouponInTransaction(couponId, userId)
);
} finally {
distributedLock.unlock(lockKey, lockValue);
}
}
private void issueCouponInTransaction(
Long couponId,
Long userId
) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow();
coupon.issueTo(userId);
}
실행 순서는 다음과 같다.
1. 락 획득
2. 트랜잭션 시작
3. DB 변경
4. 트랜잭션 커밋
5. 락 해제
락을 획득하는 메서드와 트랜잭션 메서드를 별도 빈으로 분리하는 방법도 사용할 수 있다.
8. TTL은 얼마나 설정해야 하는가
TTL이 너무 짧으면 작업 도중 락이 만료될 수 있다.
락 TTL: 3초
업무 처리: 5초
0초: 요청 A 락 획득
3초: A의 락 만료
3.1초: 요청 B 락 획득
5초: 요청 A 작업 완료
3.1초부터 A와 B가 동시에 임계 구역을 실행하게 된다.
반대로 TTL이 지나치게 길면 애플리케이션 장애 시 다른 요청이 오랜 시간 동안 자원에 접근하지 못한다.
따라서 TTL은 감으로 정하기보다 실제 처리 시간의 분포를 기준으로 정해야 한다.
예를 들어 다음 값을 모니터링한다.
업무 처리 시간 P50: 80ms
업무 처리 시간 P95: 250ms
업무 처리 시간 P99: 800ms
최대 관측 시간: 1.8초
이 경우 100ms나 300ms를 TTL로 설정하는 것은 위험하다.
일반적으로 다음 요소를 포함해야 한다.
TTL >
업무 처리 P99
+ DB 지연 여유
+ 네트워크 지연
+ GC Pause 여유
+ 장애 감지 여유
다만 TTL을 길게 설정하는 것만으로는 긴 작업을 안전하게 처리할 수 없다. 작업 시간이 유동적이라면 락 연장 기능을 고려해야 한다.
9. 락 자동 연장과 Watchdog
업무 처리 시간이 일정하지 않은 경우 락을 획득한 클라이언트가 주기적으로 TTL을 연장할 수 있다.
락 TTL: 9초
3초마다 TTL 연장
단, TTL 연장 역시 현재 락 소유자가 자신인지 확인한 뒤 실행해야 한다.
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end
락 소유권을 확인하지 않고 PEXPIRE를 호출하면 다른 요청이 획득한 락을 연장할 수 있다.
Watchdog를 사용할 때는 다음 문제도 고려해야 한다.
- 애플리케이션의 Stop-the-World GC
- 스레드 풀 고갈
- 네트워크 단절
- Valkey 응답 지연
- 연장 스케줄러 중단
- 애플리케이션 프로세스 정지
따라서 Watchdog는 락 만료 문제를 완전히 제거하는 장치가 아니라, 정상적인 처리 시간이 길어지는 상황에서 락을 연장하는 보조 장치로 이해해야 한다.
10. 재시도는 즉시 반복하면 안 된다
락 획득에 실패했을 때 다음과 같이 즉시 반복하면 Valkey에 불필요한 부하를 발생시킨다.
while (!tryLock()) {
// 즉시 재시도
}
락 경합이 심할수록 모든 요청이 같은 키를 향해 SET NX를 계속 호출한다.
100개 요청
× 초당 1,000회 재시도
= 초당 100,000회 락 획득 시도
실제 업무는 하나의 요청만 처리하면서 Valkey에는 대량의 실패 명령이 발생하는 구조가 된다.
다음과 같이 최대 대기 시간과 재시도 간격을 제한해야 한다.
public boolean tryLockWithRetry(
String lockKey,
String lockValue,
Duration waitTime,
Duration leaseTime
) {
long deadline = System.nanoTime() + waitTime.toNanos();
while (System.nanoTime() < deadline) {
if (tryLock(lockKey, lockValue, leaseTime)) {
return true;
}
sleepWithJitter();
}
return false;
}
private void sleepWithJitter() {
long sleepMillis = ThreadLocalRandom.current()
.nextLong(30, 80);
try {
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"락 대기 중 인터럽트가 발생했습니다.",
e
);
}
}
고정된 재시도 주기보다 무작위 지연인 Jitter를 추가하는 것이 좋다.
고정 주기:
요청 A → 50ms
요청 B → 50ms
요청 C → 50ms
Jitter:
요청 A → 37ms
요청 B → 58ms
요청 C → 71ms
모든 요청이 같은 시점에 다시 경쟁하는 Thundering Herd 현상을 완화할 수 있다.
11. 락의 성능은 명령 속도만 보면 안 된다
Valkey의 SET과 DEL은 단순한 키 연산이므로 자체 명령 비용은 작다. 하지만 분산 락의 실제 지연 시간은 서버 내부 명령 실행 시간보다 네트워크 왕복과 경합의 영향을 더 크게 받는다.
락을 한 번 처리할 때 최소 다음 통신이 발생한다.
락 획득: SET NX PX
락 해제: Lua Script 또는 조건부 삭제
따라서 락이 없는 경우와 비교하면 최소 두 번의 Valkey 요청이 추가된다.
요청 처리 시간
= 락 획득 RTT
+ 업무 처리 시간
+ 락 해제 RTT
락 획득에 실패해 재시도까지 발생하면 요청 수는 더 증가한다.
총 Valkey 명령 수
= 최초 획득 시도
+ 재시도 횟수
+ 락 해제
+ TTL 연장 횟수
때문에 다음과 같은 구조는 성능상 좋지 않다.
모든 주문 요청
→ 전역 락 lock:order
모든 주문이 하나의 키를 경합하기 때문에 전체 처리가 직렬화된다.
요청 1 → 처리
요청 2 → 대기
요청 3 → 대기
요청 4 → 대기
락 키는 가능한 한 업무 자원 단위로 세분화해야 한다.
lock:order:{orderId}
lock:product:{productId}
lock:user:{userId}
lock:coupon:{couponId}
예를 들어 상품별 락을 사용하면 서로 다른 상품의 주문은 병렬 처리할 수 있다.
lock:product:100 → 요청 A
lock:product:200 → 요청 B
lock:product:300 → 요청 C
하지만 락 키를 지나치게 세분화하면 하나의 업무가 여러 자원을 잠가야 하는 문제가 생길 수 있다. 이 경우 교착 상태를 방지하기 위해 락 획득 순서를 고정해야 한다.
List<Long> productIds = request.productIds().stream()
.sorted()
.toList();
항상 작은 ID부터 락 획득
100 → 200 → 300
12. 임계 구역을 짧게 유지해야 한다
분산 락 내부에 느린 작업을 포함하면 락 점유 시간이 증가한다.
try {
callExternalApi();
uploadFile();
sendEmail();
updateDatabase();
} finally {
unlock();
}
외부 API 호출이나 파일 업로드는 지연 시간의 편차가 크고 장애 시 타임아웃까지 발생할 수 있다.
가능하다면 락 내부에는 동시성 제어가 반드시 필요한 작업만 포함해야 한다.
ExternalResult result = callExternalApi();
try {
updateSharedState(result);
} finally {
unlock();
}
또는 DB 상태 변경만 락 내부에서 수행하고, 알림이나 후속 작업은 Outbox 또는 MQ를 통해 분리할 수 있다.
분산 락 획득
→ DB 상태 변경
→ Outbox 저장
→ 트랜잭션 커밋
→ 락 해제
→ MQ 발행
→ 알림 처리
락 점유 시간이 짧아질수록 처리량은 증가하고 락 만료 가능성도 낮아진다.
13. 커넥션을 요청마다 생성하면 안 된다
분산 락은 요청마다 최소 두 번 이상의 Valkey 명령을 실행한다. 이때 매번 TCP 커넥션을 생성하면 다음 비용이 추가된다.
TCP 연결
TLS Handshake
인증
명령 전송
연결 종료
따라서 애플리케이션은 장기 연결 또는 커넥션 풀을 사용해야 한다.
Valkey 서버는 클라이언트 연결을 non-blocking 소켓으로 처리하며 TCP_NODELAY를 설정한다. 동시에 연결 가능한 클라이언트 수는 maxclients 설정에 의해 제한되며, 지나치게 많은 연결은 클라이언트 버퍼 메모리와 서버 리소스를 소비할 수 있다. Valkey는 전체 클라이언트 메모리를 제한하기 위한 maxmemory-clients와 클라이언트 축출 기능도 제공한다.
Spring Data Redis에서 Lettuce를 사용할 경우 일반 명령은 기본적으로 thread-safe한 native connection을 공유할 수 있다. 반면 Spring Data Redis의 LettuceConnection 객체 자체는 thread-safe하지 않으므로 직접 보관해 여러 스레드에서 공유해서는 안 된다. Blocking 명령과 트랜잭션 명령은 별도의 연결 또는 풀을 사용할 수 있다.
일반적인 분산 락 명령은 다음과 같은 특성을 가진다.
SET NX PX
GET
DEL
EVALSHA
PEXPIRE
이 명령들은 BLPOP과 같은 blocking 명령이 아니므로 일반적인 Lettuce 환경에서는 공유 native connection을 활용할 수 있다.
따라서 단순히 처리량을 높이기 위해 풀 크기부터 무조건 늘리는 것은 적절하지 않다.
14. 커넥션 풀이 필요한 경우
다음과 같은 경우에는 전용 커넥션 또는 커넥션 풀을 검토할 수 있다.
- Blocking 명령을 함께 사용하는 경우
MULTI/EXEC트랜잭션을 사용하는 경우- 매우 큰 Value를 읽거나 쓰는 경우
- Pub/Sub 트래픽이 매우 높은 경우
- 하나의 연결에서 Head-of-line Blocking이 관측되는 경우
- 일반 캐시 트래픽과 락 트래픽을 격리해야 하는 경우
Valkey GLIDE는 비동기 multiplex 연결을 기반으로 하며 대부분의 일반 워크로드에서 하나의 클라이언트 인스턴스를 재사용하도록 권장한다. 다만 blocking 명령, 큰 값, 높은 Pub/Sub 처리량, connection state에 영향을 미치는 명령은 별도 클라이언트를 사용하는 것이 권장된다. Cluster에서는 클라이언트 하나가 각 노드에 연결을 유지하므로 클라이언트 인스턴스를 무분별하게 늘리면 전체 연결 수가 빠르게 증가할 수 있다.
예를 들어 애플리케이션 인스턴스가 20개이고 Valkey Cluster가 6개 노드로 구성되어 있다고 가정하자.
애플리케이션 인스턴스: 20
클라이언트 인스턴스: 애플리케이션당 10
Cluster 노드: 6
예상 연결 수:
20 × 10 × 6 = 1,200
클라이언트 수를 늘리는 것이 곧바로 처리량 증가를 의미하지는 않는다. 오히려 서버 연결 수와 메모리 사용량만 증가할 수 있다.
15. 커넥션 풀 크기는 어떻게 산정하는가
동기식 커넥션 풀을 사용한다면 풀 크기는 동시 요청 수가 아니라 동시에 Valkey 커넥션을 점유하는 명령 수를 기준으로 판단해야 한다.
대략적인 출발점은 다음과 같이 생각할 수 있다.
필요 커넥션 수
≈ 초당 Valkey 요청 수 × 평균 커넥션 점유 시간
예를 들어 다음 조건이라면:
Valkey 요청 수: 5,000 TPS
평균 왕복 및 처리 시간: 2ms
5,000 × 0.002 = 10
평균적으로 약 10개의 동시 점유가 발생한다.
다만 실제 설정에서는 다음 요소를 추가로 고려해야 한다.
- P95/P99 지연 시간
- 순간 트래픽
- 장애 시 재시도 증가
- Cluster 리다이렉션
- TLS 사용 여부
- 서버 Failover
- GC Pause
- 락 경합률
풀 크기를 지나치게 크게 잡으면 애플리케이션이 Valkey 장애 시 많은 요청을 동시에 밀어 넣어 오히려 장애를 확대할 수 있다.
따라서 풀 크기와 함께 다음 설정이 중요하다.
connection timeout
command timeout
max total
max idle
min idle
max wait
validation policy
특히 max wait를 무제한으로 두면 Valkey 장애 시 요청 스레드가 커넥션을 기다리며 계속 쌓일 수 있다.
Valkey 장애
→ 커넥션 대기
→ 요청 스레드 고갈
→ HTTP 요청 적체
→ 전체 서비스 장애
락 저장소 장애가 전체 서비스 장애로 전파되지 않도록 짧고 명확한 타임아웃과 실패 정책을 설정해야 한다.
16. 캐시와 분산 락 커넥션을 분리해야 하는가
규모가 작다면 동일한 RedisConnectionFactory를 사용해도 문제가 없는 경우가 많다.
그러나 다음 상황에서는 락 전용 클라이언트나 커넥션 구성을 분리할 수 있다.
대용량 캐시 GET/SET
높은 Pub/Sub 트래픽
대형 Value 조회
Blocking 명령
중요한 결제·정산 락
예를 들어 캐시에서 수 MB의 값을 대량 조회하면 하나의 multiplex 연결에서 작은 락 명령이 뒤로 밀릴 수 있다.
대형 GET 응답
→ 락 SET NX 대기
→ 락 획득 지연
→ 요청 타임아웃
이 경우 다음처럼 역할을 분리할 수 있다.
Cache Valkey Client
Distributed Lock Valkey Client
Pub/Sub Valkey Client
반드시 Valkey 서버 자체를 분리해야 한다는 의미는 아니다. 우선 클라이언트와 커넥션을 분리하고, 실제 지연과 장애 격리 요구가 크다면 별도 Valkey 인스턴스 또는 Cluster 분리를 검토한다.
17. Lua Script 성능과 EVALSHA
락 해제나 연장에는 Lua 스크립트가 사용된다.
매번 EVAL로 전체 스크립트 문자열을 전송하는 것보다 스크립트를 미리 로드하고 SHA 값으로 실행하는 EVALSHA를 사용할 수 있다.
SCRIPT LOAD "if redis.call(...) ..."
EVALSHA <sha1> 1 lock:product:100 request-uuid
Valkey 공식 문서에서도 동일한 스크립트를 반복해서 EVAL로 전송하면 네트워크 대역폭과 처리 오버헤드가 발생하므로 스크립트 캐시 사용을 설명하고 있다. 스크립트는 동적으로 문자열을 생성하기보다 인자를 통해 값을 전달해야 하며, 동적 스크립트를 계속 생성하면 스크립트 캐시 메모리가 증가할 수 있다.
일반적으로 Spring Data Redis의 DefaultRedisScript는 스크립트 SHA를 이용한 실행을 우선 시도하고, 서버에 스크립트가 없으면 다시 로드하는 방식으로 사용할 수 있다.
스크립트 자체도 짧게 유지해야 한다.
GET
값 비교
DEL
Lua 내부에서 반복문이나 다량의 키 조회를 수행하면 Valkey의 명령 처리 지연을 유발할 수 있다.
18. 분산 락과 Valkey Failover의 한계
Valkey Primary와 Replica는 일반적으로 비동기 복제를 사용한다.
다음 상황이 발생할 수 있다.
1. Client A가 Primary에서 락 획득
2. 락 정보가 Replica로 복제되기 전 Primary 장애
3. Replica가 새로운 Primary로 승격
4. 새로운 Primary에는 락 정보가 없음
5. Client B가 동일한 락 획득
결과적으로 A와 B가 동시에 락을 보유했다고 판단할 수 있다.
Valkey Cluster 역시 비동기 복제를 사용하며, Failover 또는 네트워크 Partition 상황에서는 이미 승인된 쓰기가 손실될 수 있는 작은 시간 구간이 존재한다고 명시한다. 따라서 단일 Primary 기반 락은 정상 상태에서 상호 배제에는 유용하지만, 모든 장애 상황에서 절대적인 락 안전성을 보장하는 합의 시스템은 아니다.
이 점은 결제, 정산, 재고와 같이 정확성이 중요한 도메인에서 매우 중요하다.
분산 락만 믿지 말고 DB 제약 조건과 멱등 처리를 함께 적용해야 한다.
Valkey 분산 락
+ DB Unique Constraint
+ 낙관적 락
+ 상태 전이 조건
+ 멱등 키
19. Fencing Token이 필요한 이유
락 TTL과 네트워크 지연을 고려하면 이전 락 소유자가 뒤늦게 작업을 완료하는 문제를 완전히 제거하기 어렵다.
1. 요청 A가 락 획득
2. A가 장시간 정지
3. A의 락 만료
4. 요청 B가 락 획득
5. B가 데이터 변경
6. A가 다시 실행되어 이전 데이터를 기록
락이 만료됐더라도 요청 A는 자신이 락을 잃었다는 사실을 모를 수 있다.
이 문제를 방지하는 방법 중 하나가 Fencing Token이다.
락을 획득할 때 증가하는 순번을 함께 발급한다.
INCR lock:product:100:token
요청 A → token 41
요청 B → token 42
데이터를 저장하는 시스템은 이전 토큰보다 큰 토큰만 허용한다.
현재 저장 토큰: 42
요청 A의 토큰: 41
41 < 42
→ 요청 거부
Fencing Token은 락을 잃은 오래된 클라이언트가 뒤늦게 데이터를 덮어쓰는 것을 방지한다.
단, 최종 저장소가 토큰을 검증할 수 있어야 한다.
UPDATE inventory
SET quantity = quantity - 1,
fencing_token = :newToken
WHERE product_id = :productId
AND fencing_token < :newToken;
영향받은 행이 0건이면 오래된 토큰의 요청으로 판단한다.
20. 분산 락이 필요하지 않을 수도 있다
분산 락은 편리하지만 시스템 복잡도와 네트워크 의존성을 높인다.
DB 하나에서 데이터 변경이 끝나는 경우에는 DB 동시성 제어가 더 단순하고 강한 보장을 제공할 수 있다.
비관적 락
SELECT *
FROM inventory
WHERE product_id = 100
FOR UPDATE;
낙관적 락
UPDATE inventory
SET quantity = quantity - 1,
version = version + 1
WHERE product_id = 100
AND version = 7;
조건부 갱신
UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 100
AND quantity > 0;
Unique Constraint
ALTER TABLE coupon_issue
ADD CONSTRAINT uk_coupon_user
UNIQUE (coupon_id, user_id);
다음과 같이 판단할 수 있다.
| 상황 | 우선 고려 |
|---|---|
| 단일 DB row 변경 | 조건부 UPDATE, 비관적·낙관적 락 |
| 중복 데이터 생성 방지 | Unique Constraint |
| 여러 애플리케이션의 동일 작업 제어 | 분산 락 |
| 스케줄러 중복 실행 방지 | 분산 락 또는 DB 리더 선출 |
| 외부 API 포함 장시간 작업 | 멱등 키, 상태 머신, Outbox |
| 장애 상황에서도 강한 합의 필요 | 합의 기반 시스템 검토 |
분산 락은 DB의 정합성 제약을 대체하는 수단이 아니라 애플리케이션 간 동시 실행을 줄이는 수단으로 사용하는 것이 안전하다.
21. 락 실패 정책을 먼저 정해야 한다
락 획득 실패 시 무조건 재시도하는 것이 정답은 아니다.
업무 특성에 따라 정책이 달라야 한다.
즉시 실패
중복 실행을 피하는 것이 중요하고 사용자가 다시 요청할 수 있는 경우다.
HTTP 409 Conflict
현재 처리 중입니다.
일정 시간 대기
짧은 임계 구역이며 잠시 대기하면 처리가 가능한 경우다.
최대 대기: 500ms
재시도 간격: 30~80ms
비동기 전환
요청을 즉시 처리할 필요가 없다면 MQ로 전달할 수 있다.
요청
→ MQ 적재
→ Consumer가 자원 단위로 순차 처리
기존 결과 반환
동일한 멱등 키로 이미 처리 중이거나 완료된 경우 기존 처리 결과를 반환할 수 있다.
idempotencyKey
→ PROCESSING
→ COMPLETED
락 획득 실패를 단순한 시스템 오류로 처리하기보다 업무 상태로 설계하는 것이 좋다.
22. 모니터링해야 할 지표
분산 락은 정상 동작 여부뿐만 아니라 경합과 지연을 관찰해야 한다.
애플리케이션 지표
lock.acquire.success
lock.acquire.failure
lock.acquire.timeout
lock.acquire.latency
lock.hold.duration
lock.retry.count
lock.unlock.failure
lock.renew.failure
Valkey 지표
connected_clients
blocked_clients
rejected_connections
instantaneous_ops_per_sec
used_memory_clients
evicted_clients
keyspace_hits
keyspace_misses
latency
commandstats
특히 다음 상황은 경고 대상으로 삼을 수 있다.
락 획득 실패율 급증
락 대기 시간 P99 증가
락 점유 시간 TTL 근접
Unlock 실패 발생
TTL 연장 실패
Valkey 커넥션 대기 증가
Command Timeout 증가
락 획득률만 보면 안 된다.
예를 들어 락 성공률이 100%라도 평균 락 점유 시간이 계속 증가하면 시스템 처리량은 감소하고 있을 수 있다.
23. 운영 체크리스트
Valkey 분산 락을 운영에 적용할 때는 최소한 다음 항목을 확인해야 한다.
락 구현
SET NX PX를 한 번에 실행한다.- 락 값에는 요청별 고유 식별자를 저장한다.
- 락 해제는 Lua Script로 원자적으로 처리한다.
- 락 해제는 반드시
finally에서 수행한다. - 트랜잭션 커밋 이후 락이 해제되도록 한다.
TTL
- 실제 처리 시간의 P99를 측정한다.
- GC와 네트워크 지연 여유를 포함한다.
- 장시간 작업에는 TTL 연장을 검토한다.
- TTL 연장 시 락 소유권을 확인한다.
성능
- 전역 락보다 자원 단위 락을 사용한다.
- 임계 구역을 짧게 유지한다.
- 외부 API와 파일 처리를 락 밖으로 분리한다.
- 재시도에 Backoff와 Jitter를 적용한다.
- 락 경합률과 점유 시간을 측정한다.
커넥션
- 요청마다 커넥션을 생성하지 않는다.
- 공유 connection 또는 pool을 재사용한다.
- Blocking, Pub/Sub, 대용량 조회는 별도 연결을 검토한다.
- Cluster 노드 수를 고려해 전체 연결 수를 계산한다.
- Connection Timeout과 Command Timeout을 제한한다.
- 커넥션 풀 대기를 무제한으로 설정하지 않는다.
정합성
- DB Unique Constraint를 함께 적용한다.
- 중요한 상태 변경은 조건부 UPDATE를 사용한다.
- 멱등 키를 적용한다.
- 필요하다면 Fencing Token을 사용한다.
- Failover 시 락 중복 가능성을 고려한다.
24. 결론
Valkey를 이용한 분산 락의 기본 구현은 단순하다.
SET lock-key unique-value NX PX ttl
하지만 운영 가능한 분산 락을 만들기 위해서는 그 이후가 더 중요하다.
고유한 락 소유자 값
원자적인 락 해제
적절한 TTL
락 자동 연장
제한된 재시도
경합 최소화
커넥션 재사용
명확한 타임아웃
Failover 한계 인지
DB 제약 조건과 멱등 처리
특히 분산 락을 사용했다고 해서 비즈니스 데이터의 정합성이 자동으로 보장되는 것은 아니다.
Valkey 장애, 네트워크 Partition, TTL 만료, GC Pause, DB 트랜잭션 지연과 같은 상황에서는 하나의 작업이 두 번 실행될 가능성을 완전히 배제하기 어렵다.
따라서 실무에서는 다음과 같이 계층적으로 보호하는 것이 안전하다.
1차: Valkey 분산 락으로 동시 실행 억제
2차: DB 조건부 갱신과 Unique Constraint로 정합성 보장
3차: 멱등 키로 중복 요청 방지
4차: Fencing Token 또는 상태 머신으로 오래된 요청 차단
Valkey 분산 락은 모든 동시성 문제를 해결하는 만능 도구가 아니다.
그러나 락의 범위를 작게 유지하고, 커넥션과 재시도를 통제하며, DB 정합성 장치와 함께 사용한다면 여러 애플리케이션 인스턴스 사이의 중복 실행을 효과적으로 제어할 수 있다.
'나의 주니어 개발 일기 > Redis' 카테고리의 다른 글
| Valkey 장애 복구의 내부 원리: RDB·AOF·Replication·Sentinel·Cluster Failover (0) | 2026.07.29 |
|---|---|
| Redis 자주 사용하는 명령어 (0) | 2024.02.19 |
