[학습 기록] 비관적 락과 분산 락 사이에서 성능 최적점 찾아보기

최기웅·2025년 8월 29일
4
post-thumbnail

1. 들어가며

개인적으로 동시성 제어에 대해 공부하던 중, 'DB 부하를 줄이기 위해 분산 락을 도입하는 것이 항상 최적의 성능을 보장할까?' 라는 호기심이 생겼고, 이 질문에 대한 답을 데이터 기반으로 찾고자 테스트를 설계하고 진행했습니다.

2. 시나리오 설정: 한정 재고와 몰려드는 주문

먼저, 동시성 문제를 테스트하기 위해 JPA의 비관적 락(Pessimistic Lock)을 사용한 기본적인 재고 차감 로직을 작성했습니다.

// 비관적 락 + DB 재고 확인
@Transactional
public void decreaseStock_DB_LOCK_DB_CHECK(Long productId, int quantity) {
    long start = System.currentTimeMillis();

    try {
		// 상품 ROW에 비관적 락 적용
        Product product = productRepository.findByIdForUpdate(productId)
                .orElseThrow(() -> new IllegalArgumentException("상품이 존재하지 않습니다."));

        long orderedCount = orderRepository.countByProduct(product);
        if (orderedCount >= product.getStock()) {
            throw new IllegalStateException("재고 부족");
        }

        Order order = new Order(product);
        orderRepository.save(order);

        product.decreaseStock(quantity);
    } finally {
        long end = System.currentTimeMillis();
        System.out.printf("조합1 실행 시간: %d ms%n", (end - start));
    }
}

이 방식은 데이터 정합성을 완벽하게 보장하지만, 실제 서비스 환경에서는 DB에 큰 부하를 주어 병목 현상을 일으킬 수 있습니다. 여기서 “DB 부하를 줄이면서 동시성을 제어할 방법은 없을까?”라는 궁금증이 생겼고, 해결책으로 Redis를 떠올렸습니다.

3. 왜 비관적 락과 분산 락을 비교했는가?

테스트를 설계하기 전, 다른 동시성 제어 방식들도 검토했습니다. 하지만 이번 시나리오에 가장 적합하다고 판단한 두 가지를 선택하고 집중하기로 했습니다.

  • synchronized를 제외한 이유: synchronized는 하나의 서버에서 단일 JVM 프로세스 내에서만 스레드 안전성을 보장합니다. 현대 웹 서비스는 대부분 여러 대의 서버로 운영되는 분산 환경이므로, synchronized만으로는 동시성 문제를 해결할 수 없어 비교 대상에서 제외했습니다.

  • 낙관적 락(Optimistic Lock)을 제외한 이유: 낙관적 락은 "충돌이 거의 발생하지 않을 것"이라고 가정하는 방식입니다. 하지만 '타임딜'처럼 충돌이 매우 빈번할 것으로 예상되는 상황에서는 수많은 요청이 실패하고 재시도하는 과정에서 오히려 성능이 저하될 수 있어, 이번 시나리오와는 맞지 않다고 판단했습니다.

  • 네임드 락(Named Lock)을 제외한 이유: MySQL의 GET_LOCK과 같은 네임드 락은 DB를 이용해 분산 락을 구현하는 좋은 방법입니다. 하지만 이번 테스트의 핵심 목표는 "DB 중심의 제어"와 "DB 외부 솔루션(Redis)을 이용한 제어"의 성능을 비교하는 것이었습니다. 네임드 락은 결국 DB에 의존하므로, 대표성을 가진 두 방식을 비교하는 것이 더 의미 있다고 생각했습니다.

결론적으로, 분산 환경에서 가장 현실적인 두 대안인 비관적 락과 Redis 분산 락을 비교하는 것이 이번 학습 목표에 가장 부합했습니다.

4. 4가지 해결책, 그리고 가설

DB 부하를 줄이는 목표를 가지고, 동시성 제어 주체(Lock)와 재고 확인 주체(Check)를 다르게 조합하여 총 4가지 시나리오를 설계하고 성능을 테스트했습니다.

[조합 1] 비관적 락 + DB 재고 확인

  • 설명: 기본 로직. 성능 비교의 기준점입니다. (코드는 위 '2. 시나리오 설정' 참고)

[조합 2] 비관적 락 + Redis 재고 확인

  • 설명: 데이터 정합성은 DB 락으로 보장하되, 부하가 심한 재고 확인 로직만 Redis의 원자적 연산으로 대체합니다.
  • 코드:
    @Transactional
    public void decreaseStock_DB_LOCK_REDIS_CHECK(Long productId, int quantity) {
        long start = System.currentTimeMillis();
    
        try {
    		// 상품 ROW에 비관적 락 적용
            Product product = productRepository.findByIdForUpdate(productId)
                    .orElseThrow(() -> new IllegalArgumentException("상품이 존재하지 않습니다."));
    
            Long currentStock = decreaseStockInRedis(productId, quantity);
    
            Order order = new Order(product);
            orderRepository.save(order);
    
            product.updateStock(currentStock);
        } finally {
            long end = System.currentTimeMillis();
            System.out.printf("조합2 실행 시간: %d ms%n", (end - start));
        }
    }
    
    private Long decreaseStockInRedis(Long productId, int quantity) {
        String key = "product:stock:" + productId;
        Long currentStock = redisTemplate.opsForValue().decrement(key, quantity);
    
        if (currentStock < 0) {
            redisTemplate.opsForValue().increment(key, quantity);
            throw new SoldOutException("재고가 부족합니다.");
        }
        return currentStock;
    }

[조합 3] Redis 분산 락 + DB 재고 확인

  • 설명: DB 락 대신 Redisson 분산 락을 사용하여 DB 부하를 줄입니다. 락 획득 후 재고 확인 로직은 조합 1과 유사합니다.
  • 코드:
    @Transactional
    public void decreaseStock_DIST_LOCK_DB_CHECK(Long productId, int quantity) {
        long start = System.currentTimeMillis();
    
        try {
            String lockKey = "lock:product:" + productId;
            RLock lock = redissonClient.getLock(lockKey);
    
            try {
                if (!lock.tryLock(10, 5, TimeUnit.SECONDS)) {
                    throw new RuntimeException("락 획득 실패");
                }
    
                Product product = productRepository.findById(productId)
                        .orElseThrow(() -> new IllegalArgumentException("상품이 존재하지 않습니다."));
    
                long orderedCount = orderRepository.countByProduct(product);
                if (orderedCount >= product.getStock()) {
                    throw new IllegalStateException("재고 부족");
                }
    
                Order order = new Order(product);
                orderRepository.save(order);
    
                product.decreaseStock(quantity);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("인터럽트", e);
            } finally {
                if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        } finally {
            long end = System.currentTimeMillis();
            System.out.printf("조합3 실행 시간: %d ms%n", (end - start));
        }
    }

[조합 4] Redis 분산 락 + Redis 재고 확인

  • 설명: 락과 재고 확인을 모두 Redis에서 처리하여 DB 의존성을 최소화합니다.
  • 코드:
    @Transactional
    public void decreaseStock_DIST_LOCK_REDIS_CHECK(Long productId, int quantity) {
        long start = System.currentTimeMillis();
    
        try {
            String lockKey = "lock:product:" + productId;
            RLock lock = redissonClient.getLock(lockKey);
    
            try {
                if (!lock.tryLock(10, 5, TimeUnit.SECONDS)) {
                    throw new RuntimeException("락 획득 실패");
                }
    
                Product product = productRepository.findById(productId)
                        .orElseThrow(() -> new IllegalArgumentException("상품이 존재하지 않습니다."));
    
                Long currentStock = decreaseStockInRedis(productId, quantity);
    
                Order order = new Order(product);
                orderRepository.save(order);
    
                product.updateStock(currentStock);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("인터럽트", e);
            } finally {
                if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        } finally {
            long end = System.currentTimeMillis();
            System.out.printf("조합4 실행 시간: %d ms%n", (end - start));
        }
    }
    
    private Long decreaseStockInRedis(Long productId, int quantity) {
        String key = "product:stock:" + productId;
        Long currentStock = redisTemplate.opsForValue().decrement(key, quantity);
    
        if (currentStock < 0) {
            redisTemplate.opsForValue().increment(key, quantity);
            throw new IllegalStateException("재고가 부족합니다.");
        }
    
        return currentStock;
    }

5. 예상과 다른 테스트 결과

동시 요청 상황을 가정하여, ExecutorService로 스레드를 동시에 실행하고 각 로직의 순수 실행 시간을 측정하는 간단한 테스트 코드를 작성했습니다.

@Test
@DisplayName("50개의 재고에 100개의 동시 주문 요청 테스트")
void concurrent_order_test() throws InterruptedException {
    Product product = productRepository.save(new Product("상품", 50L));
    String key = "product:stock:" + product.getId();
    redisTemplate.opsForValue().set(key, String.valueOf(product.getStock()));

    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);
    
    long start = System.currentTimeMillis();
    
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                // 각 조합에 맞는 서비스 메소드 호출
                stockService.decreaseStock_... // 여기에 각 조합 메소드 이름 기입
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    
    long end = System.currentTimeMillis();
    System.out.printf("=== 실행 시간: %d ms ===", (end - start));

    // ... 결과 검증 로직 ...
}

그리고 제 예상은 완전히 빗나갔습니다.

[단일 요청 평균 실행 시간]

테스트를 여러 번 실행한 결과, 한 가지 흥미로운 점을 발견했습니다. 2번이 가장 빠르고 3번이 가장 느린 것은 일관되었지만, 1번과 4번의 순위는 테스트 환경의 미세한 차이에 따라 뒤바뀌곤 했습니다.

  • 어떨 때는 2번 < 1번 < 4번 < 3번
  • 또 어떨 때는 2번 < 4번 < 1번 < 3번
1번 (비관적 락 + DB 재고 확인)2번 (비관적 락 + Redis 재고 확인)3번 (Redis 분산 락 + DB 재고 확인)4번 (Redis 분산 락 + Redis 재고 확인)
조합 1번조합 2번조합 3번조합 4번

🚨 이 테스트의 의미는?

전문 부하 테스트 툴(k6, nGrinder 등)은 사용자 관점에서 네트워크 지연까지 포함한 전체 응답 시간을 측정하지만, 이번 테스트는 서버 내부에서 로직 자체가 얼마나 걸리는지에 집중했습니다. 이 방식만으로도 각 조합의 상대적인 성능을 비교하고 가장 큰 병목 지점을 찾는 데는 충분했습니다.

가장 빠를 거라 예상했던 4번(Redis 분산 락 + Redis 재고 확인)이 기준점인 1번보다 느릴 때도 있었고, 전혀 기대하지 않았던 2번(비관적 락 + Redis 재고 확인)이 1위를 차지했습니다. 대체 왜 이런 결과가 나온 걸까요?

6. 결과 분석

각 작업의 숨겨진 비용을 생각하며 결과를 다시 해석해 보겠습니다.

  • DB 재고 확인 (SELECT + COUNT): 디스크를 직접 읽는 매우 비싼 I/O 작업입니다.
  • Redis 재고 확인 (DECR): 메모리에서 바로 처리되는 매우 저렴한 작업입니다.
  • 비관적 락: DB 커넥션을 점유하지만, 일단 연결되면 작업은 DB 내부에서만 이루어집니다
  • 분산 락: Redis 서버와 락 획득/해제를 위해 네트워크 통신(RTT)이 반드시 발생합니다.

이 관점으로 각 조합을 다시 봅시다.

  • 1위: 2번 (비관적 락 + Redis 재고 확인)
    • 비용: DB Lock (비쌈) + Redis Check (저렴)
    • 분석: 가장 비싼 'DB 재고 확인'을 가장 저렴한 'Redis 재고 확인'으로 대체한 효과가 결정적이었습니다. DB Lock의 부하는 여전하지만, 가장 큰 병목이 사라지면서 최고의 효율을 보여줬습니다.
  • 2, 3위 경쟁: 1번(비관적 락 + DB 재고 확인) vs 4번(Redis 분산 락 + Redis 재고 확인)
    • 1번 비용: DB Lock (비쌈) + DB Check (매우 비쌈)
    • 4번 비용: Redis Lock (네트워크 비용) + Redis Check (저렴)
    • 분석: 여기서 순위가 뒤바뀌는 현상이 발생합니다. 이는 DB ‘내부 처리 비용'과 '네트워크 왕복 비용' 사이의 미세한 줄다리기 때문입니다. 테스트 환경의 네트워크 상태나 DB 컨디션에 따라 어떤 날은 4번이, 어떤 날은 1번이 더 빠르게 측정될 수 있습니다. 이는 분산 락이 항상 비관적 락보다 빠르지 않다는 증거가 됩니다.
  • 4위: 3번 (Redis 분산 락 + DB 재고 확인)
    • 비용: Redis Lock (네트워크 비용) + DB Check (매우 비쌈)
    • 분석: 가장 느린 조합이었습니다. Redis와 통신하는 네트워크 비용을 지불하고, DB에 접속해서 가장 비싼 I/O 작업까지 수행했습니다. 두 시스템의 단점만 합쳐진 결과입니다.

7. 결론

이번 테스트를 통해 몇 가지 중요한 교훈을 얻었습니다.

  1. 가장 큰 병목부터 해결하라: 이번 테스트에서는 Lock보다 SELECT와 COUNT로 인한 DB I/O가 훨씬 큰 성능 저하의 원인이었습니다.
  2. 분산 락은 만능이 아니다: 분산 락은 DB 부하를 줄여주지만, ‘네트워크 지연’이라는 새로운 비용을 청구합니다. 테스트 결과에서 보았듯, 서비스 환경에 따라 비관적 락이 더 빠를 수도 있습니다.
  3. 추측하지 말고 측정하라: 막연한 기대 대신 실제 데이터를 기반으로 판단하는 것이 얼마나 중요한지 다시 한번 깨달았습니다.

결론적으로 이번 테스트 환경에서는 2번, 즉 비관적 락으로 데이터 정합성을 보장하되, 부하가 심한 재고 확인만 Redis로 대체하는 하이브리드 방식이 가장 효율적이었습니다.

기술을 선택할 때 단순히 ‘새롭다’, ‘좋아 보인다’가 아니라, 그 기술의 동작 원리와 트레이드오프를 명확히 이해하고 시스템에 맞는 최적의 조합을 찾아가는 과정이 중요하다는 것을 배운 값진 경험이었습니다.

💡 마지막으로, 이번 테스트는 단일 서버 환경에서 서버 내부 로직 자체가 얼마나 걸리는지에 초점을 맞췄습니다.
멀티 서버 환경이나 실제 트래픽 기반 부하 테스트를 진행한다면 네트워크 지연, 락 경쟁 양상 등이 달라져서 성능 순서가 달라질 수 있습니다.
그렇기 때문에 이번 결과는 특정 조건에서의 상대적 비교일 뿐, 절대적인 성능 지표라기보다는 “트레이드오프를 이해하고 적합한 방식을 선택하는 과정”에 참고 자료로 활용하는 것이 좋을 것 같습니다.

profile
https://giwoong01.tistory.com/

0개의 댓글