Published on

Spring Boot Redis 분산락 데드락·타임아웃 해결

Authors

Redis 분산락은 "동시에 하나만"을 보장하기 위한 흔한 선택이지만, 운영에서 자주 터지는 문제는 따로 있습니다. 바로 데드락처럼 보이는 무한 대기, 락이 풀리지 않는 락 누수, 그리고 락 대기 타임아웃으로 인한 장애 전파입니다.

이 글은 Spring Boot 기반 서비스에서 Redis 분산락을 사용할 때 데드락·타임아웃이 발생하는 대표 원인과, 재현 가능한 진단 절차, 그리고 실전 해결 패턴을 코드 중심으로 정리합니다.

또한 분산락은 대개 DB 트랜잭션과 함께 쓰이므로 트랜잭션 경계 문제도 같이 다룹니다. 필요하다면 Spring Boot 3에서 @Transactional 먹통 원인 7가지도 함께 참고하면 원인 분리가 빨라집니다.

1) 증상부터 분류하기: 진짜 데드락 vs 느린 락

운영에서 "데드락"이라고 부르는 것 중 상당수는 엄밀한 의미의 데드락이 아니라 아래 중 하나입니다.

  • 락이 만료되지 않음(락 누수): 락 키에 TTL이 없거나, 갱신(워치독) 로직이 멈춰 TTL이 사실상 무한이 됨
  • 락 대기 시간이 너무 김: tryLock(waitTime, leaseTime)에서 waitTime이 과도하거나, 스핀락처럼 계속 재시도
  • 락을 잡은 뒤 작업이 너무 김: DB 쿼리 지연, 외부 API 지연, GC pause로 인해 임계영역이 길어져 후행 요청이 줄줄이 대기
  • 락 해제 실패: 락 소유자 검증 없이 DEL해서 다른 스레드 락을 풀거나, 반대로 소유자 검증 때문에 해제가 안 되는 버그
  • 락 순서 교착(진짜 데드락에 가까움): 두 개 이상의 리소스 락을 서로 다른 순서로 획득

이 분류를 먼저 해야 해결책이 정확해집니다. 특히 "락 대기 타임아웃"과 "락 누수"는 대응이 완전히 다릅니다.

2) Redis 분산락의 최소 안전 조건

Redis로 락을 구현할 때 최소한 아래 조건을 만족해야 합니다.

  1. 획득은 원자적으로: SET key value NX PX ttl
  2. 해제는 소유자 검증 후 원자적으로: Lua 스크립트로 GET 비교 후 DEL
  3. TTL(leaseTime)은 반드시 존재: 예외/프로세스 다운에도 언젠가 풀리게
  4. 임계영역은 짧게: 락은 동시성 제어지, 작업 큐가 아님

Spring Boot에서 Redisson을 쓰면 1~3을 라이브러리 수준에서 보장해 주지만, 4는 설계 문제라 사람이 해결해야 합니다.

3) 데드락·타임아웃을 만드는 대표 원인 8가지

3.1 TTL 없이 락을 잡는 구현

직접 구현 시 SETNX만 쓰고 TTL을 누락하면, 프로세스가 죽는 순간 락이 영구히 남습니다. 운영에서 가장 흔한 락 누수 패턴입니다.

반드시 SETNXPX를 함께 써야 합니다.

3.2 leaseTime이 너무 짧아 "락이 중간에 풀림"

leaseTime을 짧게 잡으면 임계영역이 끝나기 전에 락이 만료되어 다른 요청이 락을 재획득할 수 있습니다. 그러면 임계영역이 겹치며 데이터가 깨지고, 그 뒤에 보정 로직/재시도가 폭발해 결과적으로 타임아웃이 늘어납니다.

3.3 leaseTime이 너무 길어 "실패 시 대기열이 얼어붙음"

반대로 TTL을 과도하게 길게 잡으면, 락 소유자가 장애로 죽었을 때 후행 요청이 긴 시간 대기합니다. 이때 API 타임아웃과 결합하면 장애가 연쇄 전파됩니다.

3.4 락 안에서 DB 트랜잭션을 오래 잡는 구조

락 획득 후 DB 트랜잭션을 열고, 그 안에서 외부 API 호출까지 하면 임계영역이 길어집니다. DB 커넥션 점유 시간도 늘어 커넥션 풀이 고갈되고, 결국 락 대기와 DB 대기가 서로를 증폭시킵니다.

JPA 성능 문제로 임계영역이 길어지는 경우도 많습니다. 조회가 느려지는 원인이 N+1이라면 Spring Boot JPA N+1 최적화 - Fetch Join·BatchSize처럼 쿼리부터 줄여야 락 타임아웃도 같이 줄어듭니다.

3.5 다중 락 순서 불일치

예: 주문 락과 쿠폰 락을 동시에 잡아야 하는데

  • A 요청: 주문 락 order:1 후 쿠폰 락 coupon:9
  • B 요청: 쿠폰 락 coupon:9 후 주문 락 order:1

이 경우 서로가 가진 락을 기다리며 교착 상태에 가까운 상황이 발생합니다(분산락은 TTL이 있어 영구 교착은 피할 수 있어도, 타임아웃과 재시도로 시스템이 흔들립니다).

3.6 스핀락/과도한 재시도

waitTime을 길게 주거나, tryLock 실패 시 즉시 재시도하면 Redis와 애플리케이션 CPU가 같이 올라가고, 결국 더 느려져 타임아웃이 악화됩니다.

3.7 Redis 네트워크 지연, 커넥션 풀 고갈

락은 Redis round-trip에 민감합니다. 네트워크 지연이나 Redisson 커넥션 풀이 부족하면 락 획득/해제 자체가 느려지고, 그 느림이 임계영역을 늘리는 것처럼 보이기도 합니다.

3.8 GC pause, STW로 인한 워치독 중단

Redisson의 watchdog(자동 TTL 연장)은 애플리케이션 스레드에서 동작합니다. 긴 GC pause가 발생하면 연장이 늦어져 락이 만료될 수 있고, 반대로 pause 동안 요청이 쌓이며 타임아웃이 증가합니다.

4) Spring Boot에서 권장하는 Redisson 설정

아래는 단일 Redis(또는 Sentinel/Cluster) 환경에서 흔히 쓰는 기본 설정 예시입니다. 핵심은 타임아웃과 커넥션 풀, 재시도 정책을 서비스 특성에 맞게 "측정하면서" 잡는 것입니다.

spring:
  data:
    redis:
      host: localhost
      port: 6379

redisson:
  threads: 16
  nettyThreads: 32
  singleServerConfig:
    address: redis://localhost:6379
    timeout: 3000
    connectTimeout: 3000
    retryAttempts: 3
    retryInterval: 1500
    connectionPoolSize: 64
    connectionMinimumIdleSize: 16

주의할 점:

  • timeout은 Redis 응답 타임아웃입니다. 너무 짧으면 순간 지연에 취약하고, 너무 길면 장애 감지가 늦습니다.
  • connectionPoolSize가 작으면 락 요청이 몰릴 때 풀에서 대기하며 타임아웃이 발생합니다.

5) 락 획득 패턴: 짧게 기다리고 빠르게 실패하기

락은 가능한 한 "짧게 기다리고" 실패를 상위로 전파하거나, 큐/비동기로 우회하는 것이 안정적입니다.

아래는 Redisson RLock을 이용한 전형적인 패턴입니다.

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;

import java.util.concurrent.TimeUnit;

public class StockService {

    private final RedissonClient redissonClient;
    private final StockRepository stockRepository;

    public StockService(RedissonClient redissonClient, StockRepository stockRepository) {
        this.redissonClient = redissonClient;
        this.stockRepository = stockRepository;
    }

    public void decreaseStock(Long productId, int quantity) throws InterruptedException {
        String lockKey = "lock:stock:" + productId;
        RLock lock = redissonClient.getLock(lockKey);

        boolean locked = lock.tryLock(200, 3, TimeUnit.SECONDS);
        if (!locked) {
            // 빠른 실패: 429, 409, 혹은 재시도 가능한 에러로 변환
            throw new IllegalStateException("LOCK_TIMEOUT");
        }

        try {
            // 임계영역은 최대한 짧게
            stockRepository.decrease(productId, quantity);
        } finally {
            // 락을 잡은 스레드만 unlock
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

포인트:

  • waitTime은 짧게(예: 50~300ms) 두고, 실패를 빨리 반환하는 것이 시스템 안정성에 유리합니다.
  • leaseTime을 명시하면 watchdog가 꺼집니다. 작업 시간이 예측 가능할 때만 명시하는 편이 안전합니다.

6) "락 안에서 트랜잭션"을 줄이는 설계

6.1 가장 흔한 실수: 락 범위가 트랜잭션 범위보다 큼

예를 들어 락 획득 후 @Transactional 메서드를 호출하는 구조는 흔하지만, 트랜잭션이 길어지면 락 대기열이 급격히 늘어납니다.

가능한 대안:

  • 락 안에서 하는 일을 "DB의 단일 업데이트" 수준으로 줄이기
  • 외부 API 호출은 락 밖으로 빼고, 결과 반영만 락 안에서 처리
  • DB에서 원자적 업데이트로 해결 가능한 문제는 "락 없이" SQL로 해결

6.2 DB 원자 업데이트로 락 자체를 제거

재고 차감 같은 케이스는 아래처럼 조건부 업데이트로 동시성 문제를 DB에서 해결할 수 있습니다.

UPDATE stock
SET quantity = quantity - :q
WHERE product_id = :id
  AND quantity >= :q;

영향 받은 행 수가 1이면 성공, 0이면 실패입니다. 이 접근은 Redis 락보다 단순하고 빠르며, 락 타임아웃 자체가 사라집니다.

7) 다중 락이 필요하면 "정렬된 순서"로만 획득

여러 리소스에 락이 필요하면 키를 정렬한 뒤 항상 같은 순서로 획득하세요.

import java.util.*;
import java.util.concurrent.TimeUnit;

public void lockAllOrFail(List<String> resourceKeys) throws InterruptedException {
    List<String> sorted = new ArrayList<>(resourceKeys);
    Collections.sort(sorted);

    List<RLock> acquired = new ArrayList<>();

    try {
        for (String key : sorted) {
            RLock lock = redissonClient.getLock("lock:" + key);
            boolean ok = lock.tryLock(100, 2, TimeUnit.SECONDS);
            if (!ok) {
                throw new IllegalStateException("LOCK_TIMEOUT: " + key);
            }
            acquired.add(lock);
        }

        // do work

    } finally {
        Collections.reverse(acquired);
        for (RLock lock : acquired) {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

이 패턴은 "락 순서 교착"을 구조적으로 차단합니다.

8) 락 타임아웃을 "문제"가 아니라 "신호"로 다루기

락 대기 타임아웃이 발생했다는 건 보통 아래 중 하나입니다.

  • 임계영역이 너무 길다
  • 트래픽이 임계영역 처리량을 초과한다
  • Redis/네트워크가 느리다

따라서 단순히 waitTime을 늘려 해결하려고 하면, 장애가 늦게 터질 뿐 더 크게 터집니다.

권장 대응:

  • API 레벨: 409(경합), 429(과부하) 등으로 빠르게 실패시키고 클라이언트 재시도 정책을 둠
  • 서버 레벨: 백오프(지수 백오프 + 지터) 적용
  • 시스템 레벨: 임계영역을 줄이거나(쿼리 최적화), 처리량이 필요하면 큐 기반으로 변경

장애 진단 관점에서는 "재시도 폭증"이 다른 시스템도 같이 무너뜨리는 경우가 많습니다. 비슷한 형태의 진단 프레임은 Kafka Exactly-Once 깨질 때 진단 7단계처럼 단계적으로 원인을 좁히는 방식이 도움이 됩니다.

9) 운영 진단 체크리스트: 어디서 막히는지 30분 내 찾기

9.1 애플리케이션 메트릭

  • 락 획득 성공/실패 카운트
  • 락 획득 대기 시간 분포(p50/p95/p99)
  • 임계영역 수행 시간 분포
  • 동일 키에 대한 경합률(특정 키에만 몰리는지)

Micrometer로 타이머를 박아두면 원인 분리가 빨라집니다.

Timer lockWaitTimer = Timer.builder("redis_lock_wait")
        .publishPercentileHistogram()
        .register(meterRegistry);

long start = System.nanoTime();
boolean locked = lock.tryLock(200, 3, TimeUnit.SECONDS);
lockWaitTimer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS);

9.2 Redis 관측

  • Redis CPU 사용률, instantaneous_ops_per_sec
  • slowlog에서 락 관련 명령이 느린지
  • 네트워크 RTT

락은 명령 자체가 단순해서, Redis가 느리다면 보통 네트워크/커넥션/과도한 트래픽이 원인입니다.

9.3 스레드 덤프/프로파일링

  • 락 대기 중인 스레드가 어디서 막히는지
  • 임계영역 안에서 외부 호출을 하는지
  • GC 로그에서 STW가 긴지

10) 직접 구현이 필요할 때: 안전한 unlock Lua

라이브러리를 쓰지 않고 직접 구현해야 한다면, 해제는 반드시 Lua로 소유자 검증을 포함해 원자적으로 처리하세요.

-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end

Java에서 호출할 때는 아래처럼 사용합니다.

String key = "lock:stock:" + productId;
String token = UUID.randomUUID().toString();

// acquire: SET key token NX PX ttlMillis

DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(
    "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
    "  return redis.call('DEL', KEYS[1]) " +
    "else " +
    "  return 0 " +
    "end"
);
script.setResultType(Long.class);

Long result = redisTemplate.execute(script, List.of(key), token);

여기서도 주의: 본문에 부등호 문자가 직접 노출되면 MDX에서 문제가 될 수 있으니, 문서나 로그 예시에 부등호가 포함될 경우 반드시 인라인 코드로 감싸는 습관이 안전합니다.

11) 결론: 해결 우선순위

Redis 분산락 데드락·타임아웃을 줄이는 가장 현실적인 우선순위는 다음과 같습니다.

  1. 락 키에 TTL이 있는지(락 누수 제거)
  2. 임계영역을 줄이기(쿼리/외부 호출/트랜잭션 경계 재설계)
  3. 빠른 실패 + 백오프로 재시도 폭증 방지
  4. 다중 락은 정렬된 순서로만 획득
  5. 메트릭으로 락 대기/임계영역 시간을 가시화

분산락은 만능이 아니라, 병목을 "한 곳"으로 모으는 도구입니다. 따라서 락이 느리면 락을 더 기다리게 만드는 대신, 왜 임계영역이 길어졌는지부터 줄이는 것이 정답인 경우가 대부분입니다.