우당탕탕

[동시성제어 4-1편] Redis + Lua Script를 활용한 분산락 본문

Tech/Spring

[동시성제어 4-1편] Redis + Lua Script를 활용한 분산락

모찌모찝 2025. 8. 8. 08:55
Redis + Lua Script를 활용한 분산락


안녕하세요!
이번 글에서는 대규모 분산 환경에서 안전하게 “한 번에 한 명만” 작업이 이루어지게 하는 Redis + Lua Script 기반 분산 락을 알아보고 예시코드를 통해 개발하는 방법을 알아보도록 하겠습니다. 

이전 글 보러가기

[동시성 제어 1편] 동시성 제어란? - 데이터가 꼬이지 않는 백엔드의 첫걸음

[동시성 제어 2편] 비관적 락(Pessimistic Lock) - JPA 스프링으로 경험해보는 실전 가이드

[동시성 제어 3편] 낙관적 락(Optimistic Lock) - @Version 어노테이션을 활용한 락

동시성문제 : 출처:https://blog.naver.com/chgy2131/223374232041

1. 분산락이란? 왜 Redis에서 구현할까

• 서버가 2대 이상(마이크로서비스, 스케일아웃 인프라 등)인 환경
→ DB 락/코드 수준 락으로는 “내부 인스턴스 한정”
• 한정 상품 주문, 이벤트 처리, 배치 스케줄러 중복 실행 제한 등
→ 
모든 서버에서 “단 1명”만 성공해야 하는 시나리오에서 필수

Redis는
네트워크로 공개된 중앙 캐시/키밸류 스토어로, TTL(만료), 키 단위 락, 빠른 응답 등 분산 환경에 락을 쉽게 도입할 수 있게 해 줍니다.

2. Redis 락/Unlock 기본 구조와 흐름

2.1 락 획득: SET NX PX

Boolean isLocked = 
    redisTemplate.opsForValue().setIfAbsent(
         lockKey, 
         myValue, 
         Duration.ofMillis(timeout));


• lockKey: 락의 이름 (ex. “order:lock:123”)
• myValue: 락 소유자 식별용(보통 UUID)
• timeout: 락 만료 시간 (작업 예상 시간보다 약간 길게)

2.2 락 해제 문제점: Race Condition

만약 Redis의 del key로만 락 해제를 시도 시 다른 서버가 락을 이미 가져갔는데(내 value 아님)
내가 del 명령을 호출하면 “남의 락”까지 해제되는 심각한 문제가 발생합니다. 그래서 “내가 잡은 락만” 해제할 원자적(atomic) 동작이 추가적으로 필요하며, 이를 해결하기 위해 Lua스크립트를 같이 사용하게됩니다.

3. Lua 스크립트란? 실제로 왜 써야 하나

3.1 Lua란?

• 경량의 스크립트 언어 (C 기반, 쉽고 빠름)
• Redis 2.6 이상에서 서버 사이드 원자 실행을 위해 내장
• if/else, for, return, 여러 Redis 명령어 조합 가능

3.2 Lua Script가 꼭 필요한 이유

• get → del로 락 해제하면 두 명령 사이 race condition 위험 존재.
• Lua 스크립트는 “내 락 맞으면 해제, 아니면 무시” 동작을 1회 서버 트랜잭션으로 보내 100% 원자성을 보장.

4. *.lua 파일 외부화 – 관리와 협업의 실전 습관

4.1 unlock.lua 예제 (상세 주석/문서화) /resources 아래 저장

--[[
  unlock.lua - Redis 분산 락 해제용 Lua Script
  @KEYS[1]: 락의 Redis Key (ex: "item:lock:1004")
  @ARGV[1]: 현재 락 소유자(획득 시 UUID or 서비스 ID)
  동작:
    - (1) 해당 키의 값이 내 값이라면 delete(락 해제)
    - (2) 아니라면 아무 것도 하지 않고 0 반환(안전하게 무시)
    - return: 1(락 해제됨), 0(실패/소유자 아님)
]]
-- 락의 값이 내 값인지 확인
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1]) -- 내 락이면 해제
else
    return 0 -- 락 소유자가 아님
end

• 실제 복잡 로직(만료/증분/키 생성 등)도 자유롭게 조합 가능.
• 주석 친절히 달아두면 협업 및 유지/보수에도 탁월.

4.2 Spring Boot에서 Lua 파일 활용 예시

// 1. resources/scripts/unlock.lua 파일 준비
Resource script = new ClassPathResource("scripts/unlock.lua");

DefaultRedisScript<Long> unlockScript = new DefaultRedisScript<>();
unlockScript.setLocation(script);
unlockScript.setResultType(Long.class);

// 2. Unlock 실행 (내 락일 때만 삭제!)
Long result = redisTemplate.execute(
    unlockScript,
    Collections.singletonList(lockKey), // KEYS[1]
    lockValue // ARGV[1]: lock owner
);
if (result != null && result == 1L) {
    // 락 성공 해제
} else {
    // 이미 해제됐거나 내 락이 아님 (파악/로깅/예외처리)
}

5. 락 획득 & 해제 전체 플로우 예제

5.1 락 Acquire (SET NX + TTL)

public boolean tryLock(String key, String value, long timeoutMs) {
    Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, Duration.ofMillis(timeoutMs));
    return Boolean.TRUE.equals(success);
}

5.2 락 해제 (Lua Script)

public boolean releaseLock(String key, String value) {
    // unlock.lua는 위와 동일
    Resource luaScript = new ClassPathResource("scripts/unlock.lua");
    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setLocation(luaScript);
    script.setResultType(Long.class);

    Long result = redisTemplate.execute(script, Collections.singletonList(key), value);
    return result != null && result > 0;
}

5.3 실제 비즈니스 코드에서 락 적용 (예: 재고 감소 API)

@Service
public class StockService {
    @Autowired private RedisLockService redisLockService;
    @Autowired private StockRepository stockRepository;

    public void decreaseStock(Long stockId, int amount) {
        String lockKey = "stock:LOCK:" + stockId;
        String lockVal = UUID.randomUUID().toString();
        boolean locked = redisLockService.tryLock(lockKey, lockVal, 3000);

        if (!locked) throw new RuntimeException("동일 재고 작업 대기 중입니다");

        try {
            // 재고 감소 비즈니스 로직
            Stock stock = stockRepository.findById(stockId).orElseThrow();
            if (stock.getQuantity() < amount) throw new RuntimeException("재고 부족!");
            stock.setQuantity(stock.getQuantity() - amount);
            stockRepository.save(stock);
        } finally {
            // 반드시 finally에서 unlock! (에러 발생해도 해제)
            redisLockService.releaseLock(lockKey, lockVal);
        }
    }
}

6. 실무 트러블슈팅 및 관리 팁

• TTL(만료) 관리:
    -> 락 유지 시간은 실제 비즈니스 예상 처리 시간보다 약간 넉넉하게!
    -> 너무 짧으면 작업 도중 락 풀리고 중복 실행(!!!)
    -> 너무 길면 필요 없는 대기/경합 발생
• 락 점유 실패/재시도:
    -> 실패 시 백오프(backoff) + 재시도 로직 권장
    -> 락 획득 대기열은 대부분 직접 구현 필요

for (int i = 0; i < 5; i++) {
    if (tryLock(...)) break;
    Thread.sleep(100); // 100ms 후 재시도
}

• 고가용성:
    -> Redis 싱글 노드 락은 장애 시 락 풀림 문제 → 실전에서는 Redisson+RedLock(다수 Redis 노드)으로 보완 가능
• 모니터링/로깅:
    -> 락 획득, 해제, 실패 케이스 logging 필수 (운영 장애 대응)

7. 주요 패턴/확장 사례

• 락 획득 시 만료 갱신(renew)
• 동일 자원/다수 작업 처리(큐잉, 순서 관리)
Expired Key 이벤트 활용
Lua로 복잡 분기(증감, 랭킹 갱신, 멱등성 등) 전체 구현

8. 마치며 – 한눈에 핵심 정리

• Redis + Lua Script 락은 분산 서버 환경에서 원자적 락/해제 보장을 위해 반드시 필요한 실무 패턴!
“*.lua 외부 파일 관리 + 주석 정리”는 유지보수/협업의 표준!
• 코드-주석-관리-예외처리 묶음으로 실전에 자주 활용
• TTL/재시도/장애/경합 상황 모두 미리 설계/테스트하는 태도는 필수!
• 더 복잡한 패턴은 Redisson, RedLock 알고리즘과 연동해 확장

이상으로 Redis + Lua Script 방식 분산락의 개념, 원리, 구체 코드, 관리팁, 실무 노하우까지 작성해 보았습니다.
의문이 남는 부분, 실전에서 부딪힌 트러블 케이스, 다른 Lua 활용 예시(락 획득/갱신, 랭킹, 상태머신 등) 궁금하다면 언제든 댓글 혹은 추가 질문 남겨주세요!

4-2편에서는 Redisson 기반 실전 분산락을 이어갈 예정입니다. 

Comments