
개인적으로 동시성 제어에 대해 공부하던 중, 'DB 부하를 줄이기 위해 분산 락을 도입하는 것이 항상 최적의 성능을 보장할까?' 라는 호기심이 생겼고, 이 질문에 대한 답을 데이터 기반으로 찾고자 테스트를 설계하고 진행했습니다.
먼저, 동시성 문제를 테스트하기 위해 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를 떠올렸습니다.
테스트를 설계하기 전, 다른 동시성 제어 방식들도 검토했습니다. 하지만 이번 시나리오에 가장 적합하다고 판단한 두 가지를 선택하고 집중하기로 했습니다.
synchronized를 제외한 이유: synchronized는 하나의 서버에서 단일 JVM 프로세스 내에서만 스레드 안전성을 보장합니다. 현대 웹 서비스는 대부분 여러 대의 서버로 운영되는 분산 환경이므로, synchronized만으로는 동시성 문제를 해결할 수 없어 비교 대상에서 제외했습니다.
낙관적 락(Optimistic Lock)을 제외한 이유: 낙관적 락은 "충돌이 거의 발생하지 않을 것"이라고 가정하는 방식입니다. 하지만 '타임딜'처럼 충돌이 매우 빈번할 것으로 예상되는 상황에서는 수많은 요청이 실패하고 재시도하는 과정에서 오히려 성능이 저하될 수 있어, 이번 시나리오와는 맞지 않다고 판단했습니다.
네임드 락(Named Lock)을 제외한 이유: MySQL의 GET_LOCK과 같은 네임드 락은 DB를 이용해 분산 락을 구현하는 좋은 방법입니다. 하지만 이번 테스트의 핵심 목표는 "DB 중심의 제어"와 "DB 외부 솔루션(Redis)을 이용한 제어"의 성능을 비교하는 것이었습니다. 네임드 락은 결국 DB에 의존하므로, 대표성을 가진 두 방식을 비교하는 것이 더 의미 있다고 생각했습니다.
결론적으로, 분산 환경에서 가장 현실적인 두 대안인 비관적 락과 Redis 분산 락을 비교하는 것이 이번 학습 목표에 가장 부합했습니다.
DB 부하를 줄이는 목표를 가지고, 동시성 제어 주체(Lock)와 재고 확인 주체(Check)를 다르게 조합하여 총 4가지 시나리오를 설계하고 성능을 테스트했습니다.
@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;
}@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));
}
}@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;
}동시 요청 상황을 가정하여, 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번의 순위는 테스트 환경의 미세한 차이에 따라 뒤바뀌곤 했습니다.
| 1번 (비관적 락 + DB 재고 확인) | 2번 (비관적 락 + Redis 재고 확인) | 3번 (Redis 분산 락 + DB 재고 확인) | 4번 (Redis 분산 락 + Redis 재고 확인) |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
🚨 이 테스트의 의미는?
전문 부하 테스트 툴(k6, nGrinder 등)은 사용자 관점에서 네트워크 지연까지 포함한 전체 응답 시간을 측정하지만, 이번 테스트는 서버 내부에서 로직 자체가 얼마나 걸리는지에 집중했습니다. 이 방식만으로도 각 조합의 상대적인 성능을 비교하고 가장 큰 병목 지점을 찾는 데는 충분했습니다.
가장 빠를 거라 예상했던 4번(Redis 분산 락 + Redis 재고 확인)이 기준점인 1번보다 느릴 때도 있었고, 전혀 기대하지 않았던 2번(비관적 락 + Redis 재고 확인)이 1위를 차지했습니다. 대체 왜 이런 결과가 나온 걸까요?
각 작업의 숨겨진 비용을 생각하며 결과를 다시 해석해 보겠습니다.
이 관점으로 각 조합을 다시 봅시다.
이번 테스트를 통해 몇 가지 중요한 교훈을 얻었습니다.
결론적으로 이번 테스트 환경에서는 2번, 즉 비관적 락으로 데이터 정합성을 보장하되, 부하가 심한 재고 확인만 Redis로 대체하는 하이브리드 방식이 가장 효율적이었습니다.
기술을 선택할 때 단순히 ‘새롭다’, ‘좋아 보인다’가 아니라, 그 기술의 동작 원리와 트레이드오프를 명확히 이해하고 시스템에 맞는 최적의 조합을 찾아가는 과정이 중요하다는 것을 배운 값진 경험이었습니다.
💡 마지막으로, 이번 테스트는 단일 서버 환경에서 서버 내부 로직 자체가 얼마나 걸리는지에 초점을 맞췄습니다.
멀티 서버 환경이나 실제 트래픽 기반 부하 테스트를 진행한다면 네트워크 지연, 락 경쟁 양상 등이 달라져서 성능 순서가 달라질 수 있습니다.
그렇기 때문에 이번 결과는 특정 조건에서의 상대적 비교일 뿐, 절대적인 성능 지표라기보다는 “트레이드오프를 이해하고 적합한 방식을 선택하는 과정”에 참고 자료로 활용하는 것이 좋을 것 같습니다.