본문 바로가기
Framework/Spring Boot

[Spring Boot] Redis 분산 락(Distributed Lock): 분산 환경의 동시성 제어

반응형

여러분이 티켓팅 사이트를 만들었고, 아이유 콘서트 VVIP석이 딱 1자리 남았습니다. 사용자 A와 B가 0.001초의 오차도 없이 동시에 [예매하기] 버튼을 눌렀습니다. 두 요청은 백엔드의 checkInventory()(재고가 1개 이상인지 확인)를 동시에 통과하고, decreaseInventory()(재고 차감)를 동시에 실행해버립니다. 결과는 재고는 1개인데 2명에게 예매가 완료되어 버리는 '초과 판매(Over-selling)' 대참사입니다. 학원 프로젝트(단일 서버)에서는 메서드에 synchronized 키워드를 붙여서 쓰레드를 줄 세우면 해결되었습니다. 하지만 우리의 실무 환경은 스프링 부트 서버가 10대 떠 있는 분산 환경(Scale-Out)입니다. 1번 서버의 JVM synchronized가 2번 서버의 요청을 막아줄 수 있을까요? 절대 불가능합니다! 10대의 서버가 똑같이 재고 1개를 보고 동시에 달려드는 이 끔찍한 동시성(Concurrency) 문제를 완벽하게 제어하려면, 10대 서버 밖(외부)에 공통으로 존재하는 문지기에게 자물쇠(Lock) 관리를 맡겨야 합니다.

이번 [Spring Boot 실무 완벽 가이드] 시리즈에서는 "분산 서버 환경 동시성 제어의 절대 반지!" Redis(레디스)를 활용한 분산 락(Distributed Lock)과 Redisson 라이브러리 실무 튜닝에 대하여 다뤄보겠습니다.

🔒 1. 분산 락(Distributed Lock)의 기본 원리

분산 락의 핵심 개념은 '화장실 열쇠'와 같습니다.

Redis 분산 락 개념도

10명의 사람(10대의 스프링 부트 서버)이 1개의 화장실(재고 DB)을 쓰려고 합니다. 화장실 문 앞(Redis)에 열쇠가 걸려있습니다.

  • Lock 획득 (SETNX): 서버 A가 재빨리 달려와 Redis에 "나 상품 1번 쓸 거임!" 이라는 Key를 등록(열쇠 획득)합니다. 0.001초 뒤에 도착한 서버 B도 Key를 등록하려 하지만, 이미 Key가 존재하므로 실패하고 대기합니다.
  • 로직 실행: 열쇠를 가진 서버 A만 느긋하게 DB에 들어가서 재고를 1에서 0으로 차감합니다.
  • Lock 반납 (DEL): 볼일을 마친 서버 A가 Redis에서 Key를 삭제(열쇠 반납)합니다. 이제 대기하던 B가 열쇠를 얻고 DB에 가보지만, 이미 재고가 0이므로 "품절입니다"를 뱉어내고 안전하게 방어합니다!

⚙️ 2. Redisson: 스핀 락(Spin Lock)의 저주를 풀다

스프링에서 Redis를 쓸 때 흔히 사용하는 Lettuce 라이브러리로 락을 구현하려면, 서버 B는 "열쇠 나왔어? 열쇠 나왔어?"라며 0.1초마다 Redis를 찌르며(Polling) 물어봐야 합니다. 1만 명이 접속하면 1만 명이 동시에 0.1초마다 Redis를 공격하는 스핀 락(Spin Lock) 상태가 되어 Redis 서버가 터져버립니다.

이 문제를 완벽하게 해결한 것이 Redisson(레디슨) 라이브러리입니다. Redisson은 무식하게 계속 물어보는 대신, Redis의 Pub/Sub 기능을 사용하여 "서버 B야, 기다리고 있어봐. 서버 A가 열쇠 반납하면 내가 알람(Event) 울려줄게, 그때 와!"라는 획기적인 이벤트 구독 방식으로 Redis 부하를 0으로 만들어버립니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class TicketService {

    private final RedissonClient redissonClient;
    private final TicketRepository ticketRepository;

    public void reserveTicket(Long ticketId, Long userId) {
        String lockKey = "lock:ticket:" + ticketId;
        // 1. Redisson에서 특정 티켓에 대한 락(자물쇠) 객체를 가져옵니다.
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 2. 락 획득 시도 (최대 5초까지 열쇠를 기다려보고, 획득하면 3초 동안만 열쇠를 보유함 - 교착상태 방지)
            boolean isLocked = lock.tryLock(5, 3, TimeUnit.SECONDS);
            
            if (!isLocked) {
                log.info("락 획득 실패 (대기열 초과) - 사용자: {}", userId);
                throw new RuntimeException("현재 접속자가 많습니다. 잠시 후 다시 시도해주세요.");
            }

            // --- 3. [임계 구역 (Critical Section)] 락을 획득한 오직 단 1개의 쓰레드만 여기를 지나갑니다! ---
            Ticket ticket = ticketRepository.findById(ticketId).orElseThrow();
            if (ticket.getStock() < 1) {
                throw new RuntimeException("매진되었습니다.");
            }
            ticket.decreaseStock(); // 재고 1 차감
            ticketRepository.save(ticket); // DB 저장
            // ---------------------------------------------------------------------------------

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 4. 볼일이 끝났으면 반드시(finally) 락(열쇠)을 반납해야 다음 사람이 들어옵니다!
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock(); 
            }
        }
    }
}

🎯 3. 마무리 및 다음 단계

지금까지 분산된 여러 대의 서버가 하나의 자원(재고 DB)을 두고 싸울 때 발생하는 치명적인 초과 판매(동시성) 문제를, 외부 인프라인 Redis의 Pub/Sub 기능(Redisson)을 활용해 미친 듯이 빠르고 안전하게 줄 세우는 분산 락(Distributed Lock) 실무 튜닝 비법에 대해 상세히 다루어 보았습니다. 이제 티켓팅 오픈 당일에도 재고가 마이너스(-)로 뚫리는 악몽은 없습니다.

Redis 분산 락은 강력하지만, 외부 인프라(Redis 서버)를 띄워야 하고 네트워크를 타고 락을 얻어야 하므로 세팅이 무겁다는 단점이 있습니다. "Redis 없이, 그냥 순수하게 MySQL(데이터베이스) 기능만으로 동시성을 막을 수는 없을까?" 이어지는 41단계 포스팅에서는 "JPA가 제공하는 또 다른 동시성 제어의 마법!" 데이터 버전(Version)을 비교하여 충돌을 튕겨내는 낙관적 락(Optimistic Lock)과, DB Row 자체에 진짜 자물쇠를 걸어버리는 비관적 락(Pessimistic Lock)의 철저한 실무 비교 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!

반응형