Redis 분산락 구현하기: 원리부터 적용까지 단계별 가이드

결론부터 말하면, Redis 분산락은 마지막에 와서야 제대로 동작했고 그 전에 세 번 틀렸다. 사내 굿즈 선착순 이벤트에서 한정 수량 100개가 -37개까지 팔린 사고를 수습하며, synchronized → DB 비관적 락 → 직접 짠 SET NX → Redisson 순서로 옮겨 갔고, Redisson을 붙인 뒤에도 트랜잭션 순서 때문에 한 번 더 뚫렸다. 이 글은 그 8일의 기록이고, 끝에 방식별 부하 테스트 결과와 “다시 한다면” 어떻게 할지를 적었다.

목요일 — 오픈 10분 만에 재고가 -37

이벤트는 점심 12시에 열렸다. 12시 10분에 운영팀 채팅에 “재고 화면이 마이너스로 보여요”라는 메시지가 올라왔다. DB를 열어 보니 stock.quantity가 -37이었다. 주문은 137건. 100개짜리 이벤트에서 37명에게는 보낼 물건이 없었다.

당황스러웠던 건 코드에 이미 락이 있었다는 점이다. 재고 차감 서비스 메서드에 synchronized가 붙어 있었고, 로컬에서 스레드 200개로 돌린 테스트도 통과했었다. 문제는 운영 환경이었다. 지난달 무중단 배포를 도입하면서 애플리케이션 인스턴스가 1대에서 2대가 됐다. synchronized는 JVM 하나 안에서만 유효하다. 인스턴스 A와 B가 각자 “재고 1개 남음”을 읽고 각자 차감해 저장했으니, 두 대가 동시에 같은 값을 기준으로 쓰는 갱신 분실(Lost Update)이 초당 수십 번 일어난 것이다.

// 사고 당시 코드. 인스턴스가 1대일 때만 안전하다
public synchronized void decrease(Long itemId, int quantity) {
    Stock stock = stockRepository.findByItemId(itemId);   // A, B가 같은 값을 읽는다
    stock.decrease(quantity);
    stockRepository.save(stock);                           // 나중에 저장한 쪽이 이긴다
}

그날은 이벤트를 내리고 초과 주문 37건을 취소·환불했다. 그리고 다음 주 화요일에 2차 이벤트가 잡혀 있었다. 남은 시간은 영업일로 3일이었다.

금요일 — DB 비관적 락으로 막았더니 커넥션이 마른다

가장 빨리 고칠 수 있는 방법부터 갔다. 재고 조회 쿼리에 SELECT ... FOR UPDATE를 붙여 DB 행 락을 걸었다. 인스턴스가 몇 대든 결국 같은 MySQL 행을 두고 줄을 서니 정확성은 보장된다. 실제로 로컬 부하 테스트에서 재고는 정확히 0에서 멈췄다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Stock s WHERE s.itemId = :itemId")
Stock findByItemIdForUpdate(@Param("itemId") Long itemId);

문제는 동시 사용자를 500명으로 올렸을 때 나왔다. 응답 p95가 4.8초까지 치솟더니 Connection is not available, request timed out after 30000ms가 쏟아졌다. 행 락을 기다리는 동안 요청마다 DB 커넥션을 하나씩 물고 있으니, HikariCP 풀 10개가 락 대기 큐로 변해 버린 것이다. 재고 차감과 무관한 상품 조회 API까지 커넥션을 못 받아 같이 죽었다. 이 증상은 HikariCP 커넥션 풀 고갈 글에서 따로 정리했는데, 락 대기가 풀 고갈로 번지는 전형적인 경로였다.

정확성은 얻었지만 이벤트 트래픽에서는 못 쓴다는 결론이 났다. 락 대기를 DB 밖으로, 그것도 커넥션을 점유하지 않는 곳으로 빼야 했다. 모든 인스턴스가 이미 공유하고 있는 Redis가 그 자리였다.

월요일 — SET NX EX를 직접 짰다가 두 번 데인 것

Redis 분산락의 원리 자체는 단순하다. 락 키를 NX(없을 때만 설정)로 선점하고, EX로 만료를 걸어 락을 잡은 인스턴스가 죽어도 자동 해제되게 한다.

SET lock:stock:1001 {uuid} NX EX 3

Lettuce로 이걸 감싸서 락 획득에 실패하면 50ms 쉬고 재시도하는 스핀락을 만들었다. 오전에 완성했고 부하 테스트 재고는 0에서 정확히 멈췄다. 여기서 끝났으면 좋았겠지만 오후에 두 가지가 터졌다.

첫 번째, 남의 락을 풀었다. 부하를 올리자 GC가 길어졌고 어떤 요청은 임계 영역에 3초 넘게 머물렀다. 그 사이 락이 만료돼 다른 인스턴스가 새 락을 잡았는데, 원래 요청이 작업을 마치고 DEL lock:stock:1001을 날려 다른 인스턴스가 방금 잡은 락을 지워 버렸다. 그 순간 세 번째 요청이 들어와 재고가 다시 -1이 됐다. 해제는 “내 uuid가 맞을 때만 지운다”를 원자적으로 해야 하고, 그러려면 GET과 DEL 사이가 끊기지 않는 Lua 스크립트가 필요했다.

-- 내 값일 때만 삭제. GET과 DEL을 한 번에 실행해야 사이에 끼어들 틈이 없다
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

두 번째, Redis CPU가 올라갔다. 락을 못 잡은 요청 수백 개가 50ms마다 SET NX를 다시 던지니 Redis 명령 수가 초당 8천 건을 넘었다. 재고 하나를 두고 벌이는 경쟁치고는 너무 비쌌다. 재시도 간격을 늘리면 응답이 느려지고, 줄이면 Redis가 힘들어지는 줄다리기였다.

Lua 해제, 재시도 백오프, 만료 연장까지 손으로 다 짜다 보니 저녁이 됐고, 이 정도면 이미 검증된 라이브러리가 있을 거라는 생각이 그제야 들었다. Redisson이었다.

화요일 오전 — Redisson으로 갈아타다

Redisson의 RLock은 자바 표준 Lock처럼 쓰인다. 내가 전날 직접 짠 것들을 전부 갖고 있었다. 해제 시 소유자 검증은 Lua로 되어 있고, 락 대기는 폴링이 아니라 pub/sub으로 해제 신호를 구독하므로 Redis 명령 수가 급감한다. 락을 잡은 스레드가 살아 있는 동안 만료를 자동 연장하는 워치독도 있다.

public void decrease(Long itemId, int quantity) {
    RLock lock = redissonClient.getLock("lock:stock:" + itemId);
    try {
        // waitTime 3초: 못 잡으면 포기. leaseTime 3초: 잡은 쪽이 죽어도 3초 뒤 해제
        boolean acquired = lock.tryLock(3, 3, TimeUnit.SECONDS);
        if (!acquired) throw new StockLockException("잠시 후 다시 시도해 주세요.");

        stockService.decreaseInTransaction(itemId, quantity);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        if (lock.isHeldByCurrentThread()) lock.unlock();   // 내 락일 때만 푼다
    }
}

같은 500명 부하에서 Redis 명령 수는 초당 8천 건에서 400건 아래로 떨어졌고, p95는 310ms였다. DB 커넥션은 실제 차감 쿼리를 실행하는 짧은 순간에만 쓰였다. 재고도 0에서 멈췄다. 오전 11시, 이대로 배포하려고 했다.

화요일 오후 — 락은 맞는데 트랜잭션이 늦었다

배포 직전에 팀 동료가 부하 테스트를 한 번 더 돌렸고, 20번 중 1번꼴로 재고가 -1이 나왔다. 락은 분명히 걸려 있었다. 코드를 다시 보니 처음 버전에서는 decrease 메서드 자체에 @Transactional이 붙어 있었다. 흐름이 이렇게 된다.

락 획득 → 재고 조회·차감 → finally에서 락 해제 → 메서드가 끝나고 나서야 트랜잭션 커밋. 락이 풀린 시점과 커밋된 시점 사이에 수 ms의 틈이 있고, 그 틈에 다음 요청이 락을 잡고 재고를 읽으면 아직 커밋되지 않은 옛날 값을 본다. 락은 코드를 직렬화했지만 데이터 반영은 직렬화하지 못한 것이다.

해결은 구조다. 락을 잡는 메서드에서는 트랜잭션을 떼고, 트랜잭션은 별도 빈의 메서드로 분리해 락 안에서 완전히 커밋되게 만들었다. 위 코드의 stockService.decreaseInTransaction()이 그 부분이다. 같은 클래스 안의 메서드 호출은 스프링 프록시를 타지 않아 @Transactional이 무시되므로 반드시 다른 빈이어야 한다. 이걸 고친 뒤 200번을 돌려도 -1은 나오지 않았다.

@Service
public class StockService {
    @Transactional   // 이 메서드가 return되는 순간 커밋. 그 다음에야 락이 풀린다
    public void decreaseInTransaction(Long itemId, int quantity) {
        Stock stock = stockRepository.findByItemId(itemId);
        stock.decrease(quantity);
    }
}

수요일 — 타임아웃 값을 숫자로 정하다

남은 건 waitTime과 leaseTime을 감이 아니라 근거로 정하는 일이었다. 재고 차감 트랜잭션은 부하 상태에서도 최대 120ms였다. leaseTime은 그보다 넉넉하게 3초로 뒀다. 너무 짧으면 월요일에 겪은 것처럼 작업 중에 락이 풀리고, 너무 길면 인스턴스가 죽었을 때 3초 이상 아무도 재고를 못 건드린다.

waitTime은 오래 고민했다. 처음엔 3초였는데, 선착순 이벤트에서는 어차피 100번째 이후 요청은 실패해야 정상이다. 그 요청들을 5초씩 붙잡아 두면 톰캣 스레드 200개가 대기 중인 요청으로 꽉 차서 다른 API가 멈춘다. 그래서 waitTime을 1초로 줄이고, 못 잡으면 즉시 “잠시 후 다시 시도” 응답을 주기로 했다. 대기 대신 빠른 실패를 택한 셈이다. 아래는 500명 동시 요청 기준으로 정리한 결과다.

방식재고 정확성p95 응답DB 커넥션 대기Redis 명령/초
synchronized (2대)-37까지 초과90ms없음–
DB FOR UPDATE정확4.8초, 타임아웃 다수풀 고갈–
SET NX 직접 구현해제 버그로 -1420ms없음약 8,000
Redisson (트랜잭션 분리 전)간헐적 -1310ms없음약 400
Redisson + 트랜잭션 분리 + waitTime 1초정확180ms없음약 400

2차 이벤트는 이 구성으로 열었고 100개가 100개에서 끝났다. 초과 주문 0건, 커넥션 타임아웃 0건이었다.

덤 — 같은 락으로 캐시 스탬피드도 막았다

이벤트가 끝나고 한 가지를 더 손봤다. 상품 상세 캐시의 TTL이 만료되는 순간 수백 요청이 동시에 캐시 미스를 겪고 DB로 몰리는 캐시 스탬피드(Cache Stampede)가 이벤트 중에 두 번 관측됐기 때문이다. 캐시 재생성 구간에 같은 Redisson 락을 걸어 한 요청만 DB를 조회하고 나머지는 잠깐 기다렸다가 새 캐시를 읽게 했다. 여기에 TTL에 0~30초 무작위 값(jitter)을 더해 동시 만료 자체를 흩어 놓았다. 분산락 하나 익혀 두니 다른 동시성 문제에도 바로 꺼내 쓸 수 있었다.

다시 한다면

분산락이 정말 필요한지 먼저 물었을 것이다. 이번 문제의 본질은 “재고를 원자적으로 1 줄인다”였다. 이건 락 없이도 풀린다. UPDATE stock SET quantity = quantity - 1 WHERE item_id = ? AND quantity >= 1 한 줄이면 DB가 원자성을 보장하고, 영향받은 행이 0이면 품절이다. 재고를 Redis에 두고 DECR로 처리하는 방법도 있다. 우리 경우 차감과 함께 주문 생성·쿠폰 소진 등 여러 테이블을 한 트랜잭션으로 묶어야 해서 결국 락이 필요했지만, 그 판단을 사고 첫날에 했어야 했다. 단순 카운터라면 분산락은 과한 도구다.

직접 짜는 단계는 건너뛰었을 것이다. 월요일 하루를 SET NX 구현에 썼고 배운 건 많았지만, 배운 내용 전부가 Redisson 문서 첫 페이지에 있었다. 원리를 이해하는 것과 운영 코드를 직접 짜는 것은 다른 문제다.

Redis 자체의 가용성도 봤을 것이다. 지금 구성은 Redis가 단일 노드라 Redis가 죽으면 재고 차감이 전부 실패한다. 이벤트 트래픽에서 그 실패는 “안전한 실패”라 받아들였지만, 상시 기능에 쓸 거라면 Sentinel이나 Cluster를 붙이고, 노드 장애 중 락이 두 곳에서 잡힐 수 있다는 Redlock 논쟁도 알고 결정해야 한다. 락 키는 lock:stock:{itemId}처럼 자원 단위로 잘게 나눠 다른 상품끼리는 경합하지 않게 하는 것도 처음부터 지켰어야 할 원칙이다.

참고: Redisson 공식 위키, Redis 분산락 문서

“Redis 분산락 구현하기: 원리부터 적용까지 단계별 가이드”에 대한 4개의 생각

댓글 남기기