[Basic-3] DB 병렬 작업과 Lock 전략

2025. 7. 3. 09:27·Database/Redis

1. Redis와 DB 병렬 작업

앞서 우리는 MySQL과 Redis를 동기적으로 읽는 구조로 테스트를 진행했다. 즉, MySQL에서 데이터를 먼저 읽고 나서 Redis에 접근하는 방식이었다. 하지만 실제 운영 환경에서는 상황이 다르다. 대부분의 시스템은 성능을 위해 비동기 혹은 병렬 처리를 사용한다. 그렇다면 Redis와 DB를 병렬로 조회하거나 업데이트하면 어떤 일이 벌어질까? 이번 실습을 통해 동시성 이슈, 데이터 불일치 문제, 그리고 그 원인 및 해결 방안을 함께 알아보자.

1.1. 실습 준비

1.1.1. Entity 작성

더보기
더보기
@Entity
@Table(name = "inventory")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Inventory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String productId;

    @Column(nullable = false)
    private Integer quantity;

}

1.1.2. Repository 작성

더보기
더보기
@Repository
public interface InventoryRepository extends JpaRepository<Inventory, Long> {

    Optional<Inventory> findByProductId(String productId);

}

1.1.3. Service 작성

더보기
더보기
@Service
@RequiredArgsConstructor
@Slf4j
public class StockService {

    private final InventoryRepository inventoryRepository;
    private final ProductRepository productRepository;
    private final RedisTemplate<String, String> stringRedisTemplate;
    private final RedisTemplate<String, Object> objectRedisTemplate;
    private final RestTemplate restTemplate = new RestTemplate();

    // MySQL + Redis용 부하 테스트 데이터 초기화
    public void initializeLoadTestData() {
        // MySQL에 Product 저장
        productRepository.save(Product.builder().name("test-product").description("for load test")
                .price(BigDecimal.valueOf(1000)).stock(1000).build());

        // Redis에 캐시 데이터 저장
        objectRedisTemplate.opsForValue().set("product:1", "test-product-cache");
    }

    // MySQL, Redis 동시 부하 테스트
    public Map<String, Object> concurrentLoadTest(int threads, int operationsPerThread) throws InterruptedException {
        Map<String, Object> results = new HashMap<>();

        // MySQL 부하 테스트 스레드 풀
        ExecutorService executor = Executors.newFixedThreadPool(threads);
        CountDownLatch mysqlLatch = new CountDownLatch(threads);
        AtomicLong mysqlTotalTime = new AtomicLong(0);
        AtomicInteger mysqlErrors = new AtomicInteger(0);

        long mysqlStart = System.currentTimeMillis();

        // 각 스레드가 operationsPerThread 만큼 MySQL read 실행
        for (int i = 0; i < threads; i++) {
            executor.submit(() -> {
                try {
                    for (int j = 0; j < operationsPerThread; j++) {
                        long start = System.nanoTime();
                        productRepository.findById(1L); // DB 조회
                        mysqlTotalTime.addAndGet(System.nanoTime() - start);
                    }
                } catch (Exception e) {
                    mysqlErrors.incrementAndGet();
                    log.error("MySQL error", e);
                } finally {
                    mysqlLatch.countDown();
                }
            });
        }

        mysqlLatch.await();
        long mysqlDuration = System.currentTimeMillis() - mysqlStart;

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES); // MySQL 작업 풀 안전 종료 대기

        // Redis 부하 테스트용 새 스레드 풀 생성
        executor = Executors.newFixedThreadPool(threads);
        CountDownLatch redisLatch = new CountDownLatch(threads);
        AtomicLong redisTotalTime = new AtomicLong(0);
        AtomicInteger redisErrors = new AtomicInteger(0);

        long redisStart = System.currentTimeMillis();

        // 각 스레드가 operationsPerThread 만큼 Redis read 실행
        for (int i = 0; i < threads; i++) {
            executor.submit(() -> {
                try {
                    for (int j = 0; j < operationsPerThread; j++) {
                        long start = System.nanoTime();
                        objectRedisTemplate.opsForValue().get("product:1");
                        redisTotalTime.addAndGet(System.nanoTime() - start);
                    }
                } catch (Exception e) {
                    redisErrors.incrementAndGet();
                    log.error("Redis error", e);
                } finally {
                    redisLatch.countDown();
                }
            });
        }

        redisLatch.await();
        long redisDuration = System.currentTimeMillis() - redisStart;

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES); // Redis 작업 풀 안전 종료 대기

        int totalOps = threads * operationsPerThread;

        // 결과 정리
        results.put("mysql",
                Map.of("totalDuration", mysqlDuration, "avgLatency", mysqlTotalTime.get() / totalOps / 1_000, // 마이크로초
                        // 단위
                        "throughput", totalOps * 1000.0 / mysqlDuration, // 초당 ops
                        "errors", mysqlErrors.get()));

        results.put("redis",
                Map.of("totalDuration", redisDuration, "avgLatency", redisTotalTime.get() / totalOps / 1_000, // 마이크로초
                        // 단위
                        "throughput", totalOps * 1000.0 / redisDuration, "errors", redisErrors.get()));

        return results;
    }

    // Stock (DB + Redis) 초기화
    public String initializeStock() {
        String productId = "test-product";
        int initialQuantity = 1000;

        // DB 초기화
        inventoryRepository.deleteAll();
        inventoryRepository.save(Inventory.builder().productId(productId).quantity(initialQuantity).build());

        // Redis 초기화
        stringRedisTemplate.opsForValue().set("stock:" + productId, String.valueOf(initialQuantity));

        return String.format("Initialized stock for %s: %d (DB + Redis)", productId, initialQuantity);
    }

    // 단일 재고 감소 처리
    public Map<String, Object> decreaseStock() {
        String productId = "test-product";
        int decreaseAmount = 1;

        // Redis 재고 감소
        Long redisStock = stringRedisTemplate.opsForValue().decrement("stock:" + productId, decreaseAmount);

        if (redisStock == null) {
            return Map.of("error", "Redis error");
        }

        if (redisStock >= 0) {
            // DB 재고 감소
            Inventory inventory = inventoryRepository.findByProductId(productId).orElseThrow();
            inventory.setQuantity(inventory.getQuantity() - decreaseAmount);
            inventoryRepository.save(inventory);

            // Redis와 DB 재고 값 일치 여부
            boolean inconsistent = (redisStock.intValue() != inventory.getQuantity());
            if (inconsistent) {
                log.warn("재고 불일치 발생! Redis: {} / DB: {}", redisStock, inventory.getQuantity());
            }

            return Map.of("redisStock", redisStock, "dbStock", inventory.getQuantity(), "inconsistent", inconsistent);
        } else {
            // 재고 부족
            return Map.of("message", "Out of stock", "redisStock", redisStock);
        }
    }

    // 재고 감소 부하 테스트 (RestTemplate + multi-thread)
    public Map<String, Object> bulkDecreaseTest(int threads, int operationsPerThread) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(threads);
        CountDownLatch latch = new CountDownLatch(threads);
        List<Map<String, Object>> results = Collections.synchronizedList(new ArrayList<>());

        String url = "http://localhost:8082/api/concurrent/stock/decrease";

        // 다수의 스레드가 decrease API를 호출
        for (int i = 0; i < threads; i++) {
            executor.submit(() -> {
                try {
                    for (int j = 0; j < operationsPerThread; j++) {
                        try {
                            ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class);
                            results.add(response.getBody());
                        } catch (Exception e) {
                            log.error("호출 실패: {}", e.getMessage());
                        }
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);

        // 최종 상태 조회
        Long redisStock = Long.valueOf(stringRedisTemplate.opsForValue().get("stock:test-product"));
        Inventory inventory = inventoryRepository.findByProductId("test-product").orElse(null);
        int dbStock = (inventory != null) ? inventory.getQuantity() : -1;

        boolean inconsistent = (redisStock.intValue() != dbStock);

        return Map.of("threads", threads, "operationsPerThread", operationsPerThread, "totalOperations",
                threads * operationsPerThread, "finalRedisStock", redisStock, "finalDbStock", dbStock, "inconsistent",
                inconsistent, "results", results);
    }
}

1.1.4. Controller 작성

더보기
더보기
@RestController
@RequestMapping("/api/concurrent")
@RequiredArgsConstructor
public class StockController {

    private final StockService stockService;

    @PostMapping("/load-init")
    public ResponseEntity<?> initLoadTestData() {
        stockService.initializeLoadTestData();
        return ResponseEntity.ok("Load test data initialized");
    }

    @GetMapping("/load-test")
    public ResponseEntity<?> loadTest(
            @RequestParam(defaultValue = "1") int threads,
            @RequestParam(defaultValue = "1") int operationsPerThread) throws InterruptedException {
        return ResponseEntity.ok(stockService.concurrentLoadTest(threads, operationsPerThread));
    }

    @PostMapping("/stock/init")
    public ResponseEntity<?> initStock() {
        String result = stockService.initializeStock();
        return ResponseEntity.ok(result);
    }

    @PostMapping("/stock/decrease")
    public ResponseEntity<?> decreaseStock() {
        return ResponseEntity.ok(stockService.decreaseStock());
    }

    @PostMapping("/stock/decrease-bulk")
    public ResponseEntity<?> bulkDecrease(
            @RequestParam int threads,
            @RequestParam int operationsPerThread) throws InterruptedException {
        return ResponseEntity.ok(stockService.bulkDecreaseTest(threads, operationsPerThread));
    }
}

1.2. 테스트

1.2.1. 단일 스레드 환경에서의 Redis & MySQL 요청 테스트

1) 초기 데이터 세팅 (Product 데이터)

POST |  http://localhost:8083/api/concurrent/load-init

 

2) thread:5, operationsPerThread:5 요청

GET |  http://localhost:8083/api/concurrent/load-test?threads=5&operationsPerThread=5

 

3) thread:10, operationsPerThread:10 요청

GET | http://localhost:8083/api/concurrent/load-test?threads=10&operationsPerThread=10

 

 처음에는 5,5로 설정해서 요청을 보냈고 다음에는 10,10으로 설정해서 요청을 보냈다. 결과를 확인해보면 이전 결과에 비해 크게 달라지지 않는다는것을 확인할 수 있다. 즉, 스레드 수나 작업 수를 늘린다고 해서 처리량(throughput)이 선형적으로 증가하지는 않는다.

 

 처음에는 스레드를 늘릴수록 성능이 좋아지지만, 어느 시점(= 시스템의 최대 처리 능력치)을 지나면 성능이 정체되거나 오히려 나빠질 수 있다. 이런 이유로, 실무에서는 다양한 스레드 수/작업량 조합으로 테스트하며, 어디서 성능이 한계에 도달하는지를 통계치로 분석해서 시스템을 최적화해야 한다.

📌 redis가 MySQL에 비해 totalDuration이 더 높은것을 볼 수 있는데, 이는 Redis가 메모리 기반이라 단일 스레드 환경에서는 MySQL보다 훨씬 빠른것이다.

 

1.2.2. Stock량 감소 테스트

1) 초기 데이터 세팅

POST | http://localhost:8083/api/concurrent/stock/init

 

2) stock량 감소 - 단일 스레드 환경

지금은 단일이라서 내가 요청할 때 마다 하나씩 줄어든다.

 

POST | http://localhost:8083/api/concurrent/stock/decrease

 

 

3) stock량 감소 - 멀티 스레드 환경

총 50번(5 X 10) 작업 수행하기 때문에 993-50 = 943 나올까? 해보면 Redis는 잘 줄었는데, DB는 제대로 줄어들지 않았다. 


1.2.3. 결론

 실시간으로 많은 트래픽이 유입되는 환경에서는 Redis와 데이터베이스(DB)를 함께 사용하여 성능을 높이는 경우가 많다. 특히 Redis는 메모리 기반으로 빠른 응답 속도를 제공하기 때문에, 재고 관리와 같은 처리 속도가 중요한 상황에서 캐시 용도로 자주 활용된다.

 

 하지만 이렇게 Redis와 DB를 병행하여 사용할 때 종종 발생하는 문제가 있다. 위의 실습에서 겪었던 데이터의 불일치이다. 이 문제는 단순한 실수나 버그가 아니라, 구조적으로 병렬 처리와 비동기 요청의 특성에서 기인한다.

[ Redis와 DB간의 데이터 불일치 발생 이유 ]

[1] 비동기 및 병렬 요청에 의한 충돌

Redis는 단일 명령 실행 단위를 기준으로 작업을 수행한다. 반면에 DB는 각 스레드가 개별 트랜잭션을 통해 처리하게 된다. 이 과정에서 Redis는 데이터 변경이 있지만, 정작 DB는 DB는 업데이트가 늦거나 실패하는 경우가 생길 수 있다. 즉, Redis는 성공한 것처럼 보여도, 실제 DB에는 반영되지 못해 두 시스템 간의 재고 값이 어긋나게 되는 것이다.

[2] 네트워크 지연 및 스레드 간 경쟁 상태

멀티스레드 환경에서 내부적으로 HTTP 요청이 병렬로 발생할 경우, 한 요청의 응답을 기다리기 전에 다음 요청이 Redis에 접근하게 된다. 이때 Redis는 빠르게 응답하므로 여러 요청이 거의 동시에 처리되지만, DB는 상대적으로 느리기 때문에 순차성 보장이 어렵고, 결과적으로 일부 요청은 DB에 제대로 반영되지 않을 수도 있다.

 

이런 데이터 불일치를 해결하기 위해서는 1)원자적 처리 혹은 2)락/보상 전략을 사용하면 된다. 우리는 그 중에서 락 기반 처리에 대해 깊이 학습하도록 하겠다.


 

2. DB(MySQL) Lock

동시에 여러 사용자가 같은 데이터를 읽고/수정하려고 할 때,DB의 무결성과 일관성을 보장하기 위해 Lock이 필요하다. 다음은 DB Lock을 사용하는 대표적인 서비스의 예시와 문제 상황이다. 이런 문제들을 해결하려면 트랜잭션 제어와 Lock 전략이 중요하다.

[1] 송금 시스템: 같은 계좌의 금액을 여러 사용자가 동시에 변경한 우
[2] 수강 신청 시스템:같은 수업에 동시에 등록한 경우
[3] 쇼핑몰: 재고 1개 남았는데 2명이 동시에 주문한 경우

2.1. DB Lock이란?

트 랜잭션 중 순차적 처리를 보장하기 위한 직렬화 장치이다. Row/테이블 수준의 접근을 제어하며 동시에 발생하는 충돌을 막고 데이터 정합성 유지시키는 역할을 한다.

2.2. MySQL의 주요 Lock 종류 (InnoDB 기준)

종류  설명  예시
배타 잠금 (X Lock) 쓰기 작업 시 락 UPDATE, DELETE, SELECT ... FOR UPDATE
공유 잠금 (S Lock) 읽기 작업 시 락 SELECT ... LOCK IN SHARE MODE
레코드 락 (Record Lock) 인덱스 기반 row-level lock 특정 index 기반 row에만 락
📌 DeadLock이란?
서로 자원이 풀리기를 기다리면서 무한정 대기 상태에 빠지는 현상을 말한다. 우리는 데이터 무결성과 정합성을 보장하기 위해 위와 같은 Lock을 도입하였는데, 이 Lock을 잘못 쓰면 발생한다.
✅ 예시
1. 트랜잭션 A 레코드 1에 대해 X Lock을 걸고 수정 중
2. 트랜잭션 B 레코드 2에 대해 X Lock을 걸고 수정 중
3. 이후, A가 레코드 2에 접근 → B가 점유 중이라 대기 B가 레코드 1에 접근 → A가 점유 중이라 대기
→ A와 B가 서로를 기다리는 상태, 즉 데드락 발생

2.3. 낙관적 락 vs 비관적 락

2.3.1. 낙관적 락 (Optimistic Lock)

충돌이 발생하지 않을것으로 기대하며, 나중에 확인하는 방식이다. 즉, 기본적으로 Lock을 걸지 않고, 충돌 시점에만 검증해서 처리한다. 버전을 통한 점유 여부 상태를 파악하는데, @Entity의 @Version 필드를 통해 관리한다.

@Entity
public class Student {
    @Id private Long id;
    private String name;
    @Version private Integer version;
}

 

낙관적 락은 락을 사용하지 않기 때문에 성능은 좋지만, 충돌 시 재시도를 위한 복잡한 로직을 작성해야 하며 실시간 정합성이 보장되지 않는다.

2.3.2. 비관적 락 (Pessimistic Lock)

충돌이 발생할 것으로 기대하며, 먼저 자원을 선점하는 방식이다. 즉, 조회 시점부터 Lock을 사용하며, 트랜잭션 종료 전까지 다른 외부로 부의 쓰기나 읽기를 모두 차단한다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Entity> findById(Long id);
SELECT * FROM table WHERE id = 1 FOR UPDATE;

 

비관적 락은 Lock을 기본으로 사용하기 때문에 데이터 정합성을 강력히 보장해주고 충돌 자체를 원천 차단해준다. 하지만 락을 걸어놓고 수행하기 때문에 트랜잭션이 길면 성능 저하가 발생할 수 있고, 데드락이 발생할 가능성이 있다.

항목 비관적 락 낙관적 락
락 시점 조회 시 바로 락 수정 시 충돌 검증
충돌 처리 충돌 자체 차단 충돌 후 예외 처리
성능 경쟁 많으면 유리 경쟁 적으면 빠름
Deadlock 위험 존재 없음
재시도 로직 필요 거의 없음 있음
사용 예 주문, 송금, 수강신청 등 게시글 수정, 마이페이지 등
 

2.4. DB(MySQL) Lock 환경 구축

2.4.1. Entity 수정

io/redis/performance/entity/Inventory.java

더보기
더보기
@Entity
@Table(name = "inventory")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Inventory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String productId;

    @Column(nullable = false)
    private Integer quantity;

    @Version
    private Long version;

}

2.4.2. Repository 수정

io/redis/performance/repository/InventoryRepository.java

더보기
더보기
@Repository
public interface InventoryRepository extends JpaRepository<Inventory, Long> {

    Optional<Inventory> findByProductId(String productId);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT i FROM Inventory i WHERE i.productId = :productId")
    Optional<Inventory> findByProductIdWithLock(@Param(value = "productId") String productId);
}

2.4.3. Service 작성

[1]  io/redis/performance/service/MySQLLockService.java

더보기
더보기
@Service
@RequiredArgsConstructor
@Slf4j
public class MySQLLockService {

    private final InventoryRepository inventoryRepository;

    // 비관적 락
    @Transactional
    public boolean decreaseStockWithPessimisticLock(String productId, int quantity) {
        try {
            Inventory inventory = inventoryRepository.findByProductIdWithLock(productId)
                .orElse(null);

            if (inventory == null) {
                log.warn("Pessimistic Lock: Product {} not found", productId);
                return false;
            }

            if (inventory.getQuantity() >= quantity) {
                inventory.setQuantity(inventory.getQuantity() - quantity);
                inventoryRepository.save(inventory);
                log.info("Pessimistic Lock: Stock decreased for product {}: new quantity {}", productId, inventory.getQuantity());
                return true;
            } else {
                log.warn("Pessimistic Lock: Insufficient stock for product {}", productId);
                return false;
            }

        } catch (Exception e) {
            log.error("Error in pessimistic lock: ", e);
            return false;
        }
    }

    // 낙관적 락
    @Transactional(propagation = Propagation.REQUIRES_NEW)
	@Retryable(value = { OptimisticLockingFailureException.class }, maxAttempts = 10, backoff = @Backoff(delay = 100))
	public boolean decreaseStockWithOptimisticLock(String productId, int quantity) {
		try {
			Inventory inventory = inventoryRepository.findByProductId(productId).orElse(null);

			if (inventory == null) {
				log.warn("Optimistic Lock: Product {} not found", productId);
				return false;
			}

			if (inventory.getQuantity() >= quantity) {
				inventory.setQuantity(inventory.getQuantity() - quantity);
				inventoryRepository.save(inventory);
				log.info("Optimistic Lock: Stock decreased for product {}: new quantity {}", productId,
						inventory.getQuantity());
				return true;
			} else {
				log.warn("Optimistic Lock: Insufficient stock for product {}", productId);
				return false;
			}

		} catch (OptimisticLockingFailureException e) {
			log.warn("Optimistic lock conflict for product {}, retrying...", productId);
			throw e; 
		} catch (Exception e) {
			log.error("Error in optimistic lock: ", e);
			return false;
		}
	}
}

[2] io/redis/performance/service/ConcurrencyControlTestService.java

더보기
더보기
@Service
@RequiredArgsConstructor
@Slf4j
public class ConcurrencyControlTestService {

    private final InventoryRepository inventoryRepository;
    private final RedisTemplate<String, String> stringRedisTemplate;

    private final MySQLLockService mySQLLockService;

    public String initializeStock() {
        String productId = "test-product";
        int initialQuantity = 1000;

        // DB 초기화
        inventoryRepository.deleteAll();
        inventoryRepository.save(Inventory.builder().productId(productId).quantity(initialQuantity).build());

        // Redis 초기화
        stringRedisTemplate.opsForValue().set("stock:" + productId, String.valueOf(initialQuantity));

        return String.format("Initialized stock for %s: %d (DB + Redis)", productId, initialQuantity);
    }

    public Map<String, Object> compareLockPerformance(int threads, int operationsPerThread) throws InterruptedException {
        Map<String, Object> result = new HashMap<>();

        // 낙관적
        result.put("mysql_optimistic", runTest("MySQL Optimistic", threads, operationsPerThread,
                (productId, qty) -> mySQLLockService.decreaseStockWithOptimisticLock(productId, qty)));

        // 비관적
        result.put("mysql_pessimistic", runTest("MySQL Pessimistic", threads, operationsPerThread,
                (productId, qty) -> mySQLLockService.decreaseStockWithPessimisticLock(productId, qty)));

        return result;
    }

    private Map<String, Object> runTest(String testName, int threads, int opsPerThread, LockAction action) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(threads);
        CountDownLatch latch = new CountDownLatch(threads);
        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failureCount = new AtomicInteger();

        long start = System.nanoTime();

        for (int t = 0; t < threads; t++) {
            executor.submit(() -> {
                for (int i = 0; i < opsPerThread; i++) {
                    boolean success = action.execute("test-product", 1);
                    if (success) {
                        successCount.incrementAndGet();
                    } else {
                        failureCount.incrementAndGet();
                    }
                }
                latch.countDown();
            });
        }

        latch.await();
        long durationNs = System.nanoTime() - start;
        long durationMs = durationNs / 1_000_000;
        long avgLatency = successCount.get() + failureCount.get() > 0 ?
                durationNs / (successCount.get() + failureCount.get()) / 1_000 : 0;

        executor.shutdown();

        Map<String, Object> result = new HashMap<>();
        result.put("testName", testName);
        result.put("successCount", successCount.get());
        result.put("failureCount", failureCount.get());
        result.put("successRate", (successCount.get() * 100.0) / (successCount.get() + failureCount.get()));
        result.put("throughput", (successCount.get() + failureCount.get()) / (durationNs / 1_000_000_000.0));
        result.put("avgLatency", avgLatency);
        result.put("totalDuration", durationMs);
        return result;
    }

    @FunctionalInterface
    private interface LockAction {
        boolean execute(String productId, int quantity);
    }
}

2.4.4. Controller 작성

io/redis/performance/controller/LockController.java

더보기
더보기
@RestController
@RequestMapping("/api/lock")
@RequiredArgsConstructor
@Slf4j
public class LockController {

    private final ConcurrencyControlTestService testService;

    /**
     * 락 성능 테스트 실행
     * @param threads 스레드 수
     * @param opsPerThread 각 스레드당 작업 수
     * @return 테스트 결과 (성공률, 처리량, 평균 지연시간 등)
     */

    @PostMapping("/stock-init")
    public ResponseEntity<?> initStock() {
        String result = testService.initializeStock();
        return ResponseEntity.ok(result);
    }

    @GetMapping
    public Map<String, Object> testLockPerformance(
            @RequestParam(defaultValue = "3") int threads,
            @RequestParam(defaultValue = "5") int opsPerThread) {
        try {
            log.info("Lock 테스트 시작: threads={}, opsPerThread={}", threads, opsPerThread);
            return testService.compareLockPerformance(threads, opsPerThread);
        } catch (InterruptedException e) {
            log.error("Lock 테스트 중 인터럽트 발생", e);
            throw new RuntimeException("Lock 테스트 실패", e);
        }
    }
}

2.4.5. Configuration 작성

io/redis/performance/config/RedisConfig.java

더보기
더보기
@Configuration
@EnableRetry
public class RetryConfig {
}

2.5. 실습: MySQL Lock

2.5.1. 낙관적 락 테스트

1) 낙관적 락 활성화

비관적 락은 주석처리하고, 낙관적 락의 주석은 해제한다.

 

2) 초기 데이터 세팅

POST | http://localhost:8083/api/concurrent/stock/init

 

3) 낙관적 락 run 호출

GET | http://localhost:8083/api/lock?threads=3&operationsPerThread=10

 

수행을 해보면 successRate가 100으로 30번(3x10) 수행되어서 970(1000-30)번이 수행될 것으로 예상되지만, 아래 사진과 같이 실제로는 15번만 수행이 되었다. 여기서 말하는 100은 15번 중 15번 모두 성공했다는 뜻이었던 것이다.

 

왜 최종 결과는 30이 아니라 15만 줄어들었을까? 15번에 대한 버전은 15까지 오른거 보니까 버전 관리는 잘 수행되고 있다. 역시나 낙관적 락은 충돌 시, 재실행 하느라 30이 아닌 15번만 작동한것이다. 동기화에 대한 문제가 발생한 것이다. 이 동기화를 좀 더 원활하게 해결할 수 있는 방법은 없을까?

 

4) 낙관적 락의 재시도 로직 수정

MySQLLockService의 maxAttempts와 delay값을 변경해보자. (10, 100) -> (200, 10)

변경 전

 

변경 후

 

5) 재시도

GET |  http://localhost:8083/api/lock?threads=3&opsPerThread=10

 

이제 정상적으로 30번 모두 실행되고, 970(1000-30)인것을 확인할 수 있다.

 

 

2.5.2. 비관적 락 테스트

1) 비관적 락 활성화

낙관적 락은 주석처리하고, 비관적 락의 주석은 해제한다.

 

2) 초기 데이터 세팅

POST | http://localhost:8083/api/concurrent/stock/init

 

3) 비관적 락 run 호출

GET | http://localhost:8083/api/lock?threads=3&operationsPerThread=10

 

비관적 락이 오히려 낙관적 락의 실행시간보다 빠르다. 낙관적 락의 경우 예외가 발생하면 재시도하기 때문에 시간이 더 오래 걸린것이다. 물론 이 표본만으로는 어떤게 실무 환경에서 빠르고 좋은거라고 판단할 수 없다. 개발 환경에 따라, 서비스 요구사항에 따라 어떤 락을 사용할지 선택해야 한다.

2.5.3. 정리

비관적 락이 평균 지연 시간(latency)은 더 낮고, 처리량(throughput)은 더 높게 나오는 경향을 보였다.

 

 비관적 락은 조회 시점에서부터 락을 걸어둠으로써 다른 트랜잭션의 접근을 차단하기 때문에, 실제 수정 작업 중에 충돌이 발생하지 않고 빠르게 처리된다. 즉, 경쟁을 원천 차단하는 방식이므로 충돌이 아예 발생하지 않아 상대적으로 안정적이고 빠르게 처리된다. 반면, 낙관적 락은 충돌이 발생하지 않을 것이라는 전제 하에 먼저 작업을 진행하고, 마지막 단계에서 버전 정보 등을 통해 충돌 여부를 확인한다.  이 과정에서 충돌이 발생하면 OptimisticLockingFailureException과 같은 예외가 발생하고 재시도가 필요하므로, 전체 처리 속도가 다소 느려질 수 있다.

.

threads = N, operationsPerThread = M 형태로 테스트를 진행했을 때, N과 M의 값이 작을 경우에는 낙관적 락과 비관적 락 모두 성능 차이가 크지 않았다. 그러나 N과 M을 증가시켜 경쟁 강도를 높이면 다음과 같은 현상이 나타났다.

[1] 낙관적 락의 경우, 충돌이 빈번하게 발생하며 재시도 횟수 증가 → 성능 저하
[2] 비관적 락의 경우, 락 대기로 인한 병목이나 데드락(deadlock) 발생 위험 → 주의 필요

 

항목  비관적 락 낙관적 락
락 시점 조회 시점 수정 시점(커밋 직전)
충돌 처리 방식 충돌 자체 차단 충돌 발생 시 예외 처리 및 재시도 필요
성능 경향 경쟁 심한 경우에 유리 경쟁 적은 경우에 더 효율적
주요 이슈 데드락 발생 가능성 충돌로 인한 재시도 비용 증가

 


3. Redis Lock

 실무에서 서비스를 운영하다 보면, 하나의 서버가 아닌 여러 서버가 동시에 동작하는 분산 환경(distributed environment)에서 하나의 공유 자원(예: 재고, 쿠폰, 주문번호 등)에 접근하는 상황이 종종 발생한다. 이럴 때 자칫 잘못하면 Race Condition(경쟁 조건)이 발생하여 데이터 충돌이나 중복 처리가 발생할 수 있다. 이러한 문제를 해결하기 위해 필요한 것이 바로 분산 락(Distributed Lock)이다.

 

3.1. 분산 락이란?

 분산 락은 여러 서버 또는 프로세스가 동일한 자원에 동시에 접근하지 못하도록 제어하는 메커니즘이다. 흔히 사용하는 데이터베이스의 락은 특정 자원(Row나 Table)에 직접 락을 거는 방식이지만, Redis에서 사용하는 분산 락은 그보다는 임계 영역(Critical Section)에 락을 거는 방식에 가깝다. 즉, 자원 자체에 락을 거는 것이 아니라, 해당 자원을 처리하는 로직의 진입 자체를 제어하는 구조이다.

 

3.2.  Redis로 구현하는 분산 락 방식

Redis를 이용한 분산 락 구현에는 여러 클라이언트 방식이 존재하지만, 대표적으로 두 가지 클라이언트가 많이 사용된다. 바로 Lettuce와 Redisson이다. 각 클라이언트는 락을 처리하는 방식과 특징에 차이가 있으므로 목적에 따라 적절히 선택하는 것이 중요하다.

3.2.1. Lettuce

Lettuce는 Spring Data Redis의 기본 클라이언트로 널리 사용되는 가볍고 빠른 Redis 클라이언트이다.

✅ 주요 특징
[1] Netty 기반의 비동기/논블로킹 I/O를 지원하여 성능이 우수하다.
[2] 싱글 스레드 환경에 안전하며, 멀티 스레드 환경에서도 연결을 공유할 수 있다.
[3] CompletableFuture나 Reactive Streams 기반의 비동기 API를 제공한다.
[4] 기본적인 Redis 명령어 수준에서만 동작하며, 고수준의 락 기능은 직접 구현해야 한다.
🔄 Spin Lock 방식
Lettuce로 분산 락을 구현하는 경우, 일반적으로 Spin Lock 방식을 사용한다. 이 방식은 락을 얻기 위해 Redis에 반복적으로 접근하며, 락이 풀릴 때까지 짧은 시간 간격으로 재시도한다.
📌 예) while (lock이 안 걸리면) 계속 Redis에 lock 시도 요청
 → 이 방식은 단순하지만 락 획득까지 Redis에 많은 요청을 발생시킬 수 있고, 락 해제 타이밍이 중요한 단점도 있다.
→ 우리는 Spin Lock을 사용하지 않고 Lua Script를 사용한다

3.2.1. Redisson

Redisson은 Lettuce보다 무겁고 기능이 많은 Redis 클라이언트로, Redis를 In-memory Data Grid처럼 사용할 수 있도록 설계된 고수준 클라이언트이다.

[주요 특징]

[1] 분산 락, 분산 컬렉션, 분산 캐시 등 다양한 고수준 분산 객체 제공

[2] 내부적으로 Lua 스크립트 기반으로 락 처리를 수행하여 안전성과 원자성을 높임

[3] Redis의 클러스터, Sentinel, AWS/Azure 환경 등 다양한 모드 지원

[4] 객체 캐싱을 위한 JCache, Spring Cache, Hibernate Cache 등과 연동 가능

[5] 재시도, 락 타임아웃, Pub/Sub, 비동기 API 등 다양한 기능 제공

🔐 RedLock 알고리즘 기반 락
Redisson은 RedLock 알고리즘을 통해 락을 안전하게 관리한다. 이 알고리즘은 여러 Redis 인스턴스에 락을 분산해서 거는 방식으로, 하나의 Redis 노드가 장애가 나더라도 전체 시스템이 락을 관리할 수 있게 도와준다. 락의 획득과 해제는 Pub/Sub 방식으로 수행되어, 효율적이고 안정적이다.

📌 요약

항목 Lettuce Redisso
성능 지향 ✅ 비동기 I/O (Netty 기반) ❌ 다소 무거움
고수준 API ❌ 직접 구현 필요 (Spin Lock) ✅ 다양한 분산 객체 지원
락 방식 Spin Lock RedLock 알고리즘 + Pub/Sub
사용 난이도 쉬움 (가볍고 빠름) 복잡하지만 기능 많음
활용 환경 단순 락이 필요한 상황 클러스터 환경, 고신뢰 락이 필요한 상황

3.3. Redis Lock 환경 구축

3.3.1. Component 작성 (Lock)

io/redis/performance/lock/RedisDistributedLock.java

더보기
더보기
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisDistributedLock {

    private final StringRedisTemplate redisTemplate;
    private static final String LOCK_PREFIX = "lock:";

    public boolean acquireLock(String key, String value, long expireTime, TimeUnit unit) {
        String lockKey = LOCK_PREFIX + key;
        Boolean result = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, value, expireTime, unit);
        return Boolean.TRUE.equals(result);
    }

    public void releaseLock(String key, String value) {
        String lockKey = LOCK_PREFIX + key;
        String currentValue = redisTemplate.opsForValue().get(lockKey);

        if (value.equals(currentValue)) {
            redisTemplate.delete(lockKey);
        }
    }

    // Lua 스크립트를 사용한 안전한 락 해제
    public boolean releaseLockSafely(String key, String value) {
        String lockKey = LOCK_PREFIX + key;
        String script =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "   return redis.call('del', KEYS[1]) " +
                        "else " +
                        "   return 0 " +
                        "end";

        Long result = redisTemplate.execute(
                new DefaultRedisScript<>(script, Long.class),
                Collections.singletonList(lockKey),
                value
        );

        return result != null && result > 0;
    }
}

3.3.2. Service 작성

[1] RedisLockService

더보기
더보기
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisLockService {

    private final RedisDistributedLock distributedLock;
    private final StringRedisTemplate stringRedisTemplate;

    // 성능 최적화 설정 (락을 흭득하지 못했을 때 수행하는 수치[설정] 값)
    private static final int MAX_RETRY = 3; 			// 재시도 횟수 감소
    private static final long RETRY_DELAY_MS = 5; 		// 재시도 간격 단축
    private static final int LOCK_TIMEOUT_SECONDS = 3;  // 락 타임아웃 단축

    public boolean decreaseStockWithRedisLock(String productId, int quantity) {
        String lockKey = "inventory:" + productId;
        String lockValue = UUID.randomUUID().toString();

        boolean lockAcquired = false;

        try {
            // 빠른 락 획득 시도
            for (int i = 0; i < MAX_RETRY; i++) {
                if (distributedLock.acquireLock(lockKey, lockValue, LOCK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
                    lockAcquired = true;
                    break;
                }

                if (i < MAX_RETRY - 1) { // 마지막 시도가 아니면 대기
                    Thread.sleep(RETRY_DELAY_MS);
                }
            }

            if (!lockAcquired) {
                log.debug("Failed to acquire lock for product {} after {} retries", productId, MAX_RETRY);
                return false;
            }

            // Redis 파이프라인 사용으로 성능 최적화
            return executeStockDecrement(productId, quantity);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("Interrupted during lock retry for product {}", productId);
            return false;
        } catch (Exception e) {
            log.error("Error in Redis lock operation for product {}: {}", productId, e.getMessage());
            return false;
        } finally {
            if (lockAcquired) {
                distributedLock.releaseLockSafely(lockKey, lockValue);
            }
        }
    }

    private boolean executeStockDecrement(String productId, int quantity) {
        String stockKey = "stock:" + productId;

        try {
            // 원자적 연산 사용
            Long result = stringRedisTemplate.opsForValue().decrement(stockKey, quantity);

            if (result == null) {
                log.warn("Stock key {} not found", stockKey);
                // 롤백
                stringRedisTemplate.opsForValue().increment(stockKey, quantity);
                return false;
            }

            if (result >= 0) {
                log.debug("Stock decreased for product {}: new quantity {}", productId, result);
                return true;
            } else {
                log.debug("Insufficient stock for product {}: attempted {}, current {}",
                        productId, quantity, result + quantity);
                // 롤백
                stringRedisTemplate.opsForValue().increment(stockKey, quantity);
                return false;
            }

        } catch (Exception e) {
            log.error("Error during stock decrement for product {}: {}", productId, e.getMessage());
            return false;
        }
    }
}

[2] RedissonLockService

더보기
더보기
@Service
@RequiredArgsConstructor
@Slf4j
public class RedissonLockService {

    private final RedissonClient redissonClient;
    private final RedisTemplate<String, Object> redisTemplate;

    public boolean decreaseStockWithRedisson(String productId, int quantity) {
        RLock lock = redissonClient.getLock("lock:inventory:" + productId);

        try {
            // [1] Lock을 얻기 위해 최대 10초 동안 대기
            // [2] Lock을 획득하면 30초 동안 유지됨 (자동 TTL 적용)
            //     -> 이 30초는 락을 나타내는 Redis 키(lock:inventory:...)의 TTL이며,
            //        30초가 지나면 명시적으로 unlock하지 않아도 Redis에서 자동으로 만료되어 락이 풀림
            //     -> Redisson이 이를 자동으로 관리하므로, 별도로 Lua 스크립트를 사용하지 않아도 됨
            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                try {
                    String stockKey = "stock:" + productId;
                    Object value = redisTemplate.opsForValue().get(stockKey);

                    if (value == null) {
                        log.warn("Redisson Lock: Stock key {} not found", stockKey);
                        return false;
                    }

                    int currentStock;
                    try {
                        currentStock = Integer.parseInt(value.toString());
                    } catch (NumberFormatException e) {
                        log.error("Redisson Lock: Invalid stock value for {}: {}", stockKey, value);
                        return false;
                    }

                    if (currentStock >= quantity) {
                        redisTemplate.opsForValue().set(stockKey, String.valueOf(currentStock - quantity));
                        log.info("Redisson Lock: Stock decreased for product {}: new quantity {}", productId, currentStock - quantity);
                        return true;
                    } else {
                        log.warn("Redisson Lock: Insufficient stock for product {}", productId);
                        return false;
                    }

                } finally {
                    // Lock 해제
                    // -> 30초 동안 유지된 후 자동으로 release(unlock) 되지만, 30초가 지나기 전에, 작업이 끝났다면 즉시 락을 반환
                    if (lock.isHeldByCurrentThread()) {
                        lock.unlock();
                    }
                }
            } else {
                log.warn("Redisson Lock: Failed to acquire lock for product {}", productId);
                return false;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("Redisson Lock: Interrupted while waiting for lock", e);
            return false;
        } catch (Exception e) {
            log.error("Redisson Lock: Unexpected error", e);
            return false;
        }
    }
}

[3] ConcurrencyControlTestService (수정)

더보기
더보기
@Service
@RequiredArgsConstructor
@Slf4j
public class ConcurrencyControlTestService {

    private final InventoryRepository inventoryRepository;
    private final RedisTemplate<String, String> stringRedisTemplate;

    private final MySQLLockService mySQLLockService;

    // ✅ 의존성 추가 
    private final RedisLockService redisLockService;
    private final RedissonLockService redissonLockService;

    public String initializeStock() {
        String productId = "test-product";
        int initialQuantity = 1000;

        // DB 초기화
        inventoryRepository.deleteAll();
        inventoryRepository.save(Inventory.builder().productId(productId).quantity(initialQuantity).build());

        // Redis 초기화
        stringRedisTemplate.opsForValue().set("stock:" + productId, String.valueOf(initialQuantity));

        return String.format("Initialized stock for %s: %d (DB + Redis)", productId, initialQuantity);
    }

    public Map<String, Object> compareLockPerformance(int threads, int operationsPerThread) throws InterruptedException {
        Map<String, Object> result = new HashMap<>();

        // 낙관적
//        result.put("mysql_optimistic", runTest("MySQL Optimistic", threads, operationsPerThread,
//                (productId, qty) -> mySQLLockService.decreaseStockWithOptimisticLock(productId, qty)));

        // 비관적
//        result.put("mysql_pessimistic", runTest("MySQL Pessimistic", threads, operationsPerThread,
//                (productId, qty) -> mySQLLockService.decreaseStockWithPessimisticLock(productId, qty)));

        // ✅ redis basic
        result.put("redis_basic", runTest("Redis Basic Lock", threads, operationsPerThread,
                (productId, qty) -> redisLockService.decreaseStockWithRedisLock(productId, qty)));

        // ✅ redisson
//        result.put("redisson", runTest("Redisson Lock", threads, operationsPerThread,
//                (productId, qty) -> redissonLockService.decreaseStockWithRedisson(productId, qty)));

        return result;
    }

    private Map<String, Object> runTest(String testName, int threads, int opsPerThread, LockAction action) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(threads);
        CountDownLatch latch = new CountDownLatch(threads);
        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failureCount = new AtomicInteger();

        long start = System.nanoTime();

        for (int t = 0; t < threads; t++) {
            executor.submit(() -> {
                for (int i = 0; i < opsPerThread; i++) {
                    boolean success = action.execute("test-product", 1);
                    if (success) {
                        successCount.incrementAndGet();
                    } else {
                        failureCount.incrementAndGet();
                    }
                }
                latch.countDown();
            });
        }

        latch.await();
        long durationNs = System.nanoTime() - start;
        long durationMs = durationNs / 1_000_000;
        long avgLatency = successCount.get() + failureCount.get() > 0 ?
                durationNs / (successCount.get() + failureCount.get()) / 1_000 : 0;

        executor.shutdown();

        Map<String, Object> result = new HashMap<>();
        result.put("testName", testName);
        result.put("successCount", successCount.get());
        result.put("failureCount", failureCount.get());
        result.put("successRate", (successCount.get() * 100.0) / (successCount.get() + failureCount.get()));
        result.put("throughput", (successCount.get() + failureCount.get()) / (durationNs / 1_000_000_000.0));
        result.put("avgLatency", avgLatency);
        result.put("totalDuration", durationMs);
        return result;
    }

    @FunctionalInterface
    private interface LockAction {
        boolean execute(String productId, int quantity);
    }
}

3.4. 실습: Redis Lock 테스트

3.4.1. Basic Redis (실무에서는 잘 안쓰고 Redisson을 사용을 권장)

[1] basic redis 활성화

redis basic의 주석을 해제하고, redisson은 주석 처리한다.

 

[2] 초기 데이터 세팅

POST | http://localhost:8083/api/concurrent/stock/init

 

[3] Basic Redis run (stock 감소)

GET | http://localhost:8083/api/lock?threads=5&opsPerThread=10

 

결과를 확인해보면, 성공률이 약 35%이다. 총 17번의 성공, 33번의 실패가 나타난것을 확인할 수 있다. 왜 이렇게 실패 확률이 높을까? 원인을 분석해보면 Lock을 흭득하는 과정에서 충돌이 발생했을 때, Retry를 하는 성능 최적화 수치를 너무 낮게 설정했기 때문이다.

RedisLockService

 

이번에는 재시도 횟수를 3에서 5로 증가시키고, 락 타임아웃을 3에서 10으로 늘려보자. 이후 다시 한번 stock 감소 API를 호출해보면 이전과 다르게 성공확률이 58%까지 올라간것을 확인할 수 있다.

RedisLockService


3.4.2. Reddison

 방금 기본적인 Redis를 사용하여 락을 흭득하고 재고를 감소시키는 API를 호출해보았다. 그 과정에서 Redis의 Retry와 관련된 값을 수동으로 조정해가면서 성공 확률을 높일 수 있었지만, 동시에 throughput이 그만큼 감소하는것을 확인할 수 있었다.

 

 즉, Redis의 경우에는 MySQL의 락 기능을 기본적으로 제공하지 않기 때문에 위와 같이 직접 커스터마이징을 하면서 최적화를 수행해야 했다. 분산락을 자체적으로 지원하는 Reddison을 사용하면 이런 고민을 해결할 수 있다. Reddison을 사용해보자.

 

[1] redisson 활성화

기존의 basic redis 부분을 주석처리하고, redisson 부분은 주석해제한다.

 

[2] 초기 데이터 세팅

POST | http://localhost:8083/api/concurrent/stock/init

 

[3] Redisson run (stock 감소)

http://localhost:8083/api/lock?threads=5&opsPerThread=10

 

Redisson의 특징인 안정성에 맞게 성공률이 100%인것을 확인할 수 있다.

'Database > Redis' 카테고리의 다른 글

[Optimization-2] 캐싱 개념 (2)  (0) 2025.12.28
[Optimization-1] 캐싱 개념(1)  (0) 2025.12.28
[Basic-4] 세션 관리  (0) 2025.07.03
[Basic-2] 캐싱 전략  (0) 2025.07.02
[Basic-1] 기초 이론 및 구축 실습  (0) 2025.07.01
'Database/Redis' 카테고리의 다른 글
  • [Optimization-1] 캐싱 개념(1)
  • [Basic-4] 세션 관리
  • [Basic-2] 캐싱 전략
  • [Basic-1] 기초 이론 및 구축 실습
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Basic-3] DB 병렬 작업과 Lock 전략
상단으로

티스토리툴바