DB/redis

[Redis] 분산락

개바리여리 2025. 12. 15. 11:52

https://jhzlo.tistory.com/85 해당 블로그 글을 참조하여 정리한 글입니다.

개인 공부 용도로 정리한 글이니 본 포스트에 들어가시면 더욱 정돈되고 자세한 정보를 얻을 수 있습니다.

 

 

분산시스템에서 하나의 자원에 여러 프로세스나 쓰레드 or 서버 들이 접근할 수 있는데

이 과정에서 순서가 잘못되어 경쟁조건이 발생 할 수 있는데 이를 위한 것이 분산락이다.

 

단일 서버 환경에서는 synchronized, ReentrantLock, DB 트랜잭션만으로도
동시성 제어가 가능하다.

하지만 다음과 같은 상황에서는 기존 방식이 무력해진다.

  • 서버가 여러 대로 확장된 경우
  • 동일한 자원(재고, 주문, 프린터 등)에 대해
  • 여러 인스턴스에서 동시에 접근하는 경우

이때 JVM 내부 락은 프로세스 내부에서만 유효하기 때문에
서버 간 동시성 문제를 막을 수 없다.

 

Redis 분산락은 레디스를 중앙 락 저장소로 사용하여 여러 서버가 동일한 락 상태를 공유하도록 만드는 방식이다.

핵심은 Redis에 특정 키를 선점한 서버만 임계구역에 들어갈 수 있다는 것.

 

또한 DB 인스턴스가 여러개로 나뉘어진 경우 

 

출처 : https://jhzlo.tistory.com/85

 

그림과 같이 서로 다른 노드를 바라보아서 DB 수준에서의 비관적 락이 유효하지 않을 수 있다.

특정 노드에는 락이 걸려있지만, 다른 노드에서는 락이 적용되지 않아 데이터 불일치 발생.

 

- 레디스에서 분산락 기본 명령어

set test_lock unique_id NX PX 3000 :  NX는 키가 존재하지 않을 때만 설정, PX는 락 만료시간이며 3초이다.

누군가가 test_lock 이라는 이름으로 키를 가지고 있으면 키를 생성할 수 없고, 누군가가 키를 가지지 않았다면 test_lock 이라는 이름으로 키를 생성

 

- 락을 푸는 명령어

DEL test_lock

삭제 시에는 아무나 삭제 할 수 없도록 키가 존재하며 값이 일치하는 인스턴스만 삭제하다록 제어가 필요하다.

 

 Redis 인스턴스가 멀티 리소스 방식일 경우

 

위 방식은 아래와 같은 문제가 발생 할 수 있다.

  1. Client A가 Master에게서 mylock을 획득하였다.
  2. 키에 대한 쓰기가 복제본으로 전송되기 전 Master가 다운된다.
  3. 해당 키를 받지 못한 Slave가 Master로 승격한다.
  4. Client B가 Master에게 mylock key를 요청하면 획득할 수 있다.

이러한 문제를 위해 RedLock 이 등장하게 된다.

RedLock 

N대의 레디스 서버가 있을때, 과반수 이상의 노드에서 락을 획득하면 락을 획득한 것으로 간주하는 구조이다.

자바에서는 Redisson 을 통해 RedLock 기능을 제공한다.

 

- 동작원리

 

1. 락 획득 전 현재 시각을 밀리초(ms) 단위로 측정한다

2. 여러대의 레디스 서버에 락을 요청한다.

여기서 중요한 것은 각 요청에 대해 짧은 타임아웃을 설정한다. (  특정 Redis 서버가 장애 상태일 경우 해당 서버와의 통신으로 과도한 시간이 지체되는 것을 방지 )

3. 과반수 이상의 락을 서버로부터 확보

* 락 획득에 소요된 시간은 전체 TTL 보다 작아야 한다.

(TTL 5초인데 총 락획득에 8초가 걸렸다면 이미 락이 만료되었을 수 있어, 락 획득에 실패한 것으로 간주된다.)

4. 락 획득에 실패했다면 모든 서버에 락 해제를 요청한다. 

 

** 과반수 이상 락 획득했는데 락을 획득하지 못한 노드는 어떻게 하나?

락 획득에 실패한 노드는 그대로 방치한다.

나중에 들어오는 요청에 대해서도 같은 방식으로 동작하기에 과반수를 넘지 않기때문이다.

 

Client 1이 락을 점유하고 있는 동안 Client 2가 락을 요청한다고 가정해보자.
그렇다면 이미 점유되어있는 Node 1, Node 2, Node 3에 대해서는 락을 획득하지 못하고 Node 2와 Node 4에 대해서만 락을 획득할 수 있다. 하지만 두 개의 노드는 과반수가 아니므로 락을 획득하지 않는 것으로 간주된다. 

 

 

CAP 이론

분산시스템에서 세 가지 특정 중 동시에 두 가지만 만족할 수 있다는 이론이다.

 

- C (Consistency) : 모든 노드가 같은 데이터를 갖는다는 일관성

- A (Availability) : 모든 요청에 대해 응답을 반환할 수 있는 가용성

- P (Partition tolerance) : 네트워크 분할 상황에서도 시스템이 지속적인 동작을 할 수 있음

분산 환경에서는 P는 기본 전제로 받아들여야 하므로, 실제 선택지는 C와 A 중 하나를 선택하게 된다.

 

RedLock은 CAP 이론상 AP 시스템에 더 가깝다. 일관성은 최소한은 유지하지만 강한 일관성 보장은 하지 못한다.

하지만 분산락은 본질적으로 동시셍 제어, 무결성 보장이 원칙인데 일반적인 데이터 저장소보다 일관성 확보에 중점을

두어야 하는데 모순이 발생한다.

 

RedLock의 주요 한계

 

- 강한 일관성을 보장하지 못한다

 

[Clock Drift로 이한 일관성 붕괴]

각 노드의 로컬 시간을 기준으로 TTL을 계산하므로 각 노드 간의 시간이 동기화 되지 않는다.

실제 환경에서는 시계가 동일하지 않고 동일한 속도가 아닌데 이것을 "Clock Drift" 라고 한다.

 

  1. 클라이언트 1이 Redis 노드 A, B, C에서 락을 획득한다.
  2. 네트워크 문제로 D, E에서는 락을 획득하지 못한다.
  3. 이때 노드 C의 시계가 비정상적으로 빨라진다 → TTL이 빨리 끝났다고 잘못 판단
  4. 클라이언트 2가 뒤이어 노드 C, D, E에서 락을 획득한다. (C는 TTL 만료로 인해 락이 없다고 판단)
  5. A와 B에서는 여전히 클라이언트 1의 락이 살아있다.
  6. 결과적으로 클라이언트 1과 2가 동시에 락을 소유하고 있다고 착각하게 된다.
  7. 두 클라이언트가 동일 자원에 동시 작업을 수행하면서 일관성 붕괴

[Application 중단]

RedLock은 락을 잡고 일정 시간 안에 작업이 완료될 것이라 가정한다.
하지만 현실에서는 클라이언트 측에서 예상치 못한 중단, GC(Garbage Collection), 프로세스 정지, 네트워크 지연 등의 이유로 작업이 지연될 수 있다.

 

 

  1. 클라이언트 1이 락을 획득하고 파일을 읽는다.
  2. 이때 stop-the-world GC 또는 프로세스 중단으로 클라이언트 1이 멈춘다.
  3. 락은 TTL 만료로 인해 Redis에서 자동으로 해제된다.
  4. 클라이언트 2가 새로운 락을 획득하고 파일을 수정한 뒤 저장한다.
  5. 클라이언트 1이 복구되어 멈춘 시점 이후의 파일 상태를 모른 채 다시 저장한다.
  6. 결과적으로 클라이언트 2의 변경사항이 클라이언트 1에 의해 덮여써진다.

이러한 과정으로 LOST-UPDATE 가 발생 할 수 있다.

 

 

아래는 단일서버에서 간단한 레디스 분산락 예시 코드

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

    String key();
    long waitTime () default 2;
    long leaseTime() default 5;
}

@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {

    private final RedissonClient redissonClient;

    @Around("@annotation(lock)")
    public Object around(ProceedingJoinPoint pjp, DistributedLock lock) throws Throwable {

        /*String lockKey = lock.key();
        RLock rLock = redissonClient.getLock(lockKey);

        boolean acquired = false;
        */

        RLock rLock = redissonClient.getLock(lock.key());
        boolean acquired = rLock.tryLock(
                lock.waitTime(),
                lock.leaseTime(),
                TimeUnit.SECONDS
        );

        if(!acquired){
            throw new IllegalStateException("락 획득 실패");
        }

        Object result;

        try{
            //비지니스 로직 실행
            result = pjp.proceed();
        }catch (Throwable t) {
            rLock.unlock();
            throw t;
        }

        //트랜잭션 커밋 후 락 해제
        //TransactionSynchronizationManager : Spring 트랜잭션의 "생명주기 이벤트 훅"을 등록하는 관리자이다.
        //@Transactional 로 시작된 트랜재겨션에 "이 시점에 해당 코드를 실행하게 등록" 하는 것.
        //해당 클래스는 "트랜잭션 시작", "커밋", "롤백" 같은 트랜잭션 경계 이벤트를 감지할 수 있게 해준다.
        //분산락에서 필요한 이유 : 락은 풀렸는데 DB 트랜잭션 커밋이 안될 수 있기 때문에 => 락 해제 시점을 트랜잭션 이후로 잡아줌.
        TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {

                    //DB 트랜잭션이 정상적으로 커밋된 직후 호출
                    @Override
                    public void afterCommit(){
                        if(rLock.isHeldByCurrentThread()){
                            rLock.unlock();
                        }
                    }

                    //커밋이든 롤백이든 트랜잭션이 종료될때
                    @Override
                    public void afterCompletion(int status) {
                        // rollback 시 안전장치
                        if (status != STATUS_COMMITTED && rLock.isHeldByCurrentThread()) {
                            rLock.unlock();
                        }
                    }
                }
        );
        return result;
    }
}

 

우선 비지니스 로직과 분리하기 위해 AOP를 통해 횡단관심사 분리

 

@Component
public class LockProbe {

    //AutomicInteger : 멀티스레드 환경에서 정수 연산을 원자적으로 수행하게 해주는 클래스 (Thread-safe)
    private final AtomicInteger concurrent = new AtomicInteger(0); //임계구역에 들어와있는 스레드 수
    private final AtomicInteger maxConcurrent = new AtomicInteger(0); // 테스트 전체 동안 최대 동시 진입 수

    public void enter(){
        int current = concurrent.incrementAndGet(); //현재 값을 1증가하고 반환하는데 원자적으로 수행된다. (스레드가 임계구역에 진입했다.) , CAS 연산이 적용된다.
        maxConcurrent.updateAndGet(prev -> Math.max(prev, current));  //지금까지 기록된 최대 동시 진입 수(prev)와 현재 동시 진입 수(current) 중  더 큰 값을 다시 저장하라
    }

    public void exit(){
        concurrent.decrementAndGet();  //임계구역에서 나간다.
    }

    public int getMaxConcurrent(){
        return maxConcurrent.get();
    }
}

 

해당 클래스는 테스트 용도를 위한 클래스. 실제로 임계구역에 동시 진입을 했는가를 확인한다.

 

@Service
@RequiredArgsConstructor
public class PrintService {

    private final StockRepository stockRepository;
    private final PrinterClient printerClient;
    private final LockProbe lockProbe;

    @DistributedLock(
            key = "lock:printer:ZEBRA01",
            waitTime = 5,  //최대 얼마나 오랫동안 기다릴지 (초)
            leaseTime = 10  //락을 성공적으로 획득했을 때, 해당 락을 Redis에 최대 얼마나 오랫동안 유지할지
    )
    @Transactional
    public void printAndDecreaseStock(Long stockId, Long quantity){

        lockProbe.enter();
        try{
            Stock stock = stockRepository.findById(stockId)
                    .orElseThrow(() -> new IllegalArgumentException("재고없음"));

            stock.decrease(quantity);

            stockRepository.saveAndFlush(stock);

            printerClient.printLabel(stockId, quantity);
        }finally {
            lockProbe.exit();
        }
    }
}

@Component
public class PrinterClient {

    public void printLabel(Long stockId, Long quantity) {
        // 실제로는 ZPL 전송 / 소켓 / 스풀러 호출
        // 여기선 테스트 가능하게 분리만
    }
}

 

실제 service 호출부와 추후 처리를 할 Client 부분 (추상화) 

 

@Getter
@Entity
@Table(name = "stock")
public class Stock {


    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;

    private Long quantity;

    @Version
    private Long version;

    protected Stock(){

    }

    public Stock(final Long productId, final Long quantity){
        this.productId = productId;
        this.quantity = quantity;
    }

    public void decrease(final Long quantity){
        if(this.quantity - quantity < 0){
            throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
        }

        this.quantity -= quantity;
    }

    public Long getQuantity() {
        return quantity;
    }
}

 

Stock 엔티티 

 

@SpringBootTest
class PrintServiceTest {

    @Autowired
    PrintService printService;

    @Autowired
    StockRepository stockRepository;

    @MockitoBean
    PrinterClient printerClient;

    @Autowired
    LockProbe lockProbe;

    @Test
    void 레디스_동시요청제어() throws Exception {

        Stock stock = new Stock(100L, 100L);
        stockRepository.saveAndFlush(stock);

        int threadCount = 50;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        AtomicInteger success = new AtomicInteger();


        for(int i=0; i < threadCount; i++){
            executor.submit(() -> {
               try{
                   printService.printAndDecreaseStock(stock.getId(), 1L);
               } catch(Exception e){

               }finally {
                   latch.countDown();
               }
            });
        }

        latch.await();
        executor.shutdown();

        // then
        Stock result = stockRepository.findById(stock.getId()).orElseThrow();

        // 결과 정합성
        assertThat(result.getQuantity()).isEqualTo(50L);

        // 프린트 50회 호출
        verify(printerClient, times(50)).printLabel(stock.getId(), 1L);

        // 진짜 분산락 검증 포인트
        assertThat(lockProbe.getMaxConcurrent())
                .as("임계 구역 동시 진입 수")
                .isEqualTo(1);
    }
}

 

간단한 레디스 분산락 동시성 호출 테스트