본문 바로가기
Redis

Redis로 동시성 제어하기

by sangyunpark99 2025. 2. 24.
"동시성 제어를 redis로 관리할 수 있을까?"

 

 

이번 글은 레디스를 사용한 락 관리 방법인 Lettuce와 Redisson에 대해 알아보겠습니다.

아래 내용은 "재고시스템으로 알아보는 동시성 이슈 해결방법" 강의를 참고해서 작성했습니다.

 

아래와 같은 상황을 가정하겠습니다.

독거미 키보드가  1000개를 50%할인 이벤트를 열었습니다.
이벤트가 시작되자마자 엄청난 트래픽이 몰리면서 1000개의 구매 요청이 들어왔습니다.
그러나, 1000개의 구매 요청이 완료됬음에도 재고가 전부 소진되지 않는 문제가 발생했습니다.
신입 개발자 민수씨는 이 상황에 어리둥절 합니다.

 

신입 개발자 민수씨는 다음과 같은 로직으로 재고 소진 기능을 구현했습니다.

package com.example.redis_stock_management.domain;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Stock {

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

    private Long productId;

    private Long quantity;

    protected Stock() {

    }

    public void decrease(Long quantity) {
        if(this.quantity - quantity < 0) {
            throw new RuntimeException("재고가 부족합니다.");
        }

        this.quantity -= quantity;
    }

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

    public Long getQuantity() {
        return quantity;
    }
}

 

package com.example.redis_stock_management.service;

import com.example.redis_stock_management.domain.Stock;
import com.example.redis_stock_management.repository.StockRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class StockService {

    private final StockRepository stockRepository;

    public StockService(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}

 

민수씨는 자신이 짠 로직에 이상이 있는지 동시성 테스트를 위해 다음과 같이 테스트 코드를 짜고 결과를 확인했습니다.

@SpringBootTest
class LettuceLockStockFacadeTest {

    @Autowired
    private LettuceLockStockFacade lettuceLockStockFacade;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    void init(){
        Stock stock = new Stock(1L, 1000L);
        stockRepository.saveAndFlush(stock);
    }

    @AfterEach
    public void delete() {
        stockRepository.deleteAll();
    }

    @Test
    public void 주문으로_인한_재고_소진() throws Exception {
        //given
        int threadCount = 1000;

        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        //when
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
               try {
                   lettuceLockStockFacade.decrease(1L, 1L);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               } finally {
                   latch.countDown();
               }
            });
        }

        //then
        latch.await();

        Stock stock = stockRepository.findById(1L).orElseThrow();

        assertThat(stock.getQuantity()).isEqualTo(0L);
    }
}

 

테스트를 돌리고, 결과를 확인하는 순간 민수씨는 멘붕에 빠집니다.

 

 

1000개의 주문의 재고 감소 처리를 했음에도 DB에 남아있는 재고 수는 968개가 남아있었습니다.

 

왜 이런 결과가 나올까요?

 

멀티 스레드 환경에서 발생하는 동시성 문제가 발생했기 때문입니다.

동시에 실행된 여러 트랜잭션이 같은 데이터를 읽고, 그 데이터를 기반으로 갱신하면서 이전 값이 덮어씌워지는 문제인 갱신 분실이 발생한 것입니다. MySQL의 격리 수준은 Repeatable Read이고, 이는 Lost Update가 발생할 수 있습니다.

 

갱신 분실을 이미지로 표현한 것은 다음과 같습니다.

  • TransactionA에서 재고를 조회 합니다. (조회 시점의 재고 : 1000개)
  • TransactionB에서도 재고를 조회 합니다. (조회 시점의 재고 : 1000개)
  • TransactionA에서 재고를 1 감소시킨 후 업데이트 합니다. (업데이트 후 재고 : 999개)
  • TransactionB에서도 재고를 1 감소시킨 후 업데이트 합니다. (업데이트 후 재고 : 999개)

이 과정에서 원래는 두 트랜잭션이 완료된 후 재고가 998개가 되어야 하지만, TransactionB의 업데이트가 TransactionA의 변경 사항을 덮어써버렸습니다. 결과적으로 TransactionA가 감소시킨 1개가 유실되어 재고는 999로 남게 되었습니다.

 

 

이 문제를 어떻게 해결 할 수 있을까요?

 

JPA에서 제공하는 낙관적 락과 비관적 락을 사용하여 Lost Update 문제를 해결할 수도 있지만, 이번에는 Redis를 활용해서 문제를 해결해 보겠습니다.

 

 

Redis에서 락을 사용해서 해결하는 대표적인 방법인 Lettuce방식과 Redisson 방식에대해 알아보겠습니다.

 

Lettuce의 SpinLock 방식

 

Lettuce 방식은 기본적으로 SET NX (set if not exists) 명령어를 사용해서 락을 획득하고,

락이 없는 경우 일정 시간  동안 반복적으로 다시 요청하는 방식인 SpinLock 방식을 사용합니다.

 

SpinLock 방식이 무엇인가요?

 

SpinLock 방식은 락을 획득할 때까지 계속해서 재시도를 수행하는 방식입니다.

이 방식에서는 스레드가 락을 얻을 때까지 반복적으로 락을 시도하며 대기하기 때문에 CPU를 계속 사용하게 됩니다.

그로 인해 CPU 리소스가 낭비될 가능성이 있으며, 락이 오래 유지될 경우 시스템 성능에 부정적인 영향을 줄 수 있습니다.

하지만, 락이 빠르게 해제될 것으로 예상되는 경우에는 별도의 대기 없이 락을 바로 획득할 수 있어, 대기 오버헤드가 적고 빠르게 동작할 수 있다는 장점이 있습니다.

 

Lettuce의 SpinLock 방식을 구현한 코드는 다음과 같습니다.

 

RedisLockRepository

package com.example.redis_stock_management.repository;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;

@Component
public class RedisLockRepository {

    private RedisTemplate<String, String> redisTemplate;

    public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Boolean lock(Long key) {
        return redisTemplate.opsForValue().setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3000));
    }

    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key) {
        return key.toString();
    }
}

 

LettuceLockStockFacade

@Component
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

    public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
        this.redisLockRepository = redisLockRepository;
        this.stockService = stockService;
    }


    public void decrease(Long id, Long quantity) throws InterruptedException {
        while(!redisLockRepository.lock(id)) {
            Thread.sleep(100);
        }

        try {
            stockService.decrease(id, quantity);
        } finally {
            redisLockRepository.unlock(id);
        }
    }

 

로직에서 while문을 사용해서 락을 획득할 때까지 반복하고 있습니다.

try - finally에서 락을 획득하고 나서 로직 실행을 완료한 경우, 사용한 락을 해제해주는 작업을 진행합니다.

 

Thread.sleep은 왜 해준건가요?

 

Thread.sleep으로 약간의 딜레이를 주어 Redis의 부하를 줄여줍니다.

 

 

테스트 코드로 동시성 제어가 잘 되었는지 확인합니다.

@SpringBootTest
class LettuceLockStockFacadeTest {

    @Autowired
    private LettuceLockStockFacade lettuceLockStockFacade;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    void init(){
        Stock stock = new Stock(1L, 1000L);
        stockRepository.saveAndFlush(stock);
    }

    @AfterEach
    public void delete() {
        stockRepository.deleteAll();
    }

    @Test
    public void 주문으로_인한_재고_소진() throws Exception {
        //given
        int threadCount = 1000;

        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        //when
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
               try {
                   lettuceLockStockFacade.decrease(1L, 1L);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               } finally {
                   latch.countDown();
               }
            });
        }

        //then
        latch.await();

        Stock stock = stockRepository.findById(1L).orElseThrow();

        assertThat(stock.getQuantity()).isEqualTo(0L);
    }
}

 

테스트 실행 결과는 다음과 같습니다.

 

락을 통해 동시성 제어가 잘 되었습니다.

 

SpinLock 방식의 흐름을 그림으로 표현하면 다음과 같습니다.

(실제 재고에 대한 데이터는 DB에 저장되어 있습니다. 그림에선 락을 기준으로 설명하다보니 redis만 존재합니다.)

 

이제, Redisson 방식에 대해 알아보겠습니다.

 

Redisson 방식

 

Redisson은 여러 서버(멀티 노드)환경에서도 안정적으로 동작할 수 있도록 락을 관리하는 기능을 제공합니다.

Lettuce의 Spin Lock 방식과는 다르게, 대기 방식(Blocking)으로 동작합니다.

또한  Redisson은 락을 해제할 때 Redis의 Pub/Sub 기능을 사용합니다. 즉, 락을 해제하는 순간, 대기 중이던 다른 스레드(or 프로세스)가 이를 감지하고 즉시 락을 획득할 수 있게 합니다.

 

Pub/Sub 방식이 방식이 뭘까요?

 

발행자(Publisher)와 구독자(Subscriber)간의 메시지를 전달하기 위한 비동기 메시징 시스템입니다.

하나의 클라이언트가 메시지를 발행하면, 구독하고 있는 모든 클라이언트에게 동시에 전달되는 방식입니다.

 

redis-cli로 메시지가 전달되는지 확인해 보겠습니다.

(테스트는 두가지의 터미널을 사용했고, 설명하기 쉽게 하나는 A터미널, 다른 하나는 B터미널로 부르겠습니다.)

 

1. A 터미널에서 channel1번을 구독합니다.

A 터미널

 

2. B 터미널에서 channel1번으로 "hello redis"라는 메시지를 publish 합니다.

 

B 터미널

 

3. A 터미널에 hello redis라는 메시지가 전달됩니다.

 

A 터미널

 

 

Redisson에서 락을 점유한 스레드가 락을 해제할 때, Pub/Sub을 통해 락 해제 이벤트를 전송합니다.

이 이벤트를 받은 대기 중인 스레드는 즉시 락을 획득할 수 있습니다.

 

즉, 기존 Lettuce의 Spin Lock방식처럼 계속해서 락을 확인하면서 Redis에 요청을 보내는 방식이 아니라,

락이 해제될 때만 락을 획득하는 방식으로 동작하며, 불필요한 CPU 및 네트워크 리소스 낭비를 줄입니다.

 

Redisson의 Lock 방식을 구현한 코드는 다음과 같습니다.

(이전 Lettuce 방식처럼 별도의 Repository를 구현해줄 필요는 없습니다.)

@Component
public class RedissonLockStockFacade {

    private RedissonClient redissonClient;

    private StockService stockService;

    public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {
        this.redissonClient = redissonClient;
        this.stockService = stockService;
    }

    public void decrease(Long id, Long quantity) throws InterruptedException {
        RLock lock = redissonClient.getLock(id.toString());

        try {
            boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);

            if(!available) {
                System.out.println("Lock 획득 실패");
                return;
            }

            stockService.decrease(id, quantity);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}

 

락을 획득한 후, 로직 수행이 완료되면 락을 해제해주는 방식입니다.

 

lock.tryLock(10, 1, TimeUnit.SECONDS)
// tryLock(락을 얻기 위해 대기하는 시간, 락을 획득한 후 유지할 시간, 시간의 단위)

 

이 코드는 스레드가 10초 동안 락을 얻기 위해 대기하고, 락을 획득한 후 1초동안 유지합니다.

 

 

 

 

테스트 코드로 동시성 제어가 잘 되었는지 확인합니다.

@SpringBootTest
class RedissonLockStockFacadeTest {

    @Autowired
    private RedissonLockStockFacade redissonLockStockFacade;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    void init(){
        Stock stock = new Stock(1L, 1000L);
        stockRepository.saveAndFlush(stock);
    }

    @AfterEach
    public void delete() {
        stockRepository.deleteAll();
    }

    @Test
    public void 주문으로_인한_재고_소진() throws Exception {
        //given
        int threadCount = 1000;

        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        //when
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    redissonLockStockFacade.decrease(1L, 1L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            });
        }

        //then
        latch.await();

        Stock stock = stockRepository.findById(1L).orElseThrow();

        assertThat(stock.getQuantity()).isEqualTo(0L);
    }
}

 

테스트 실행 결과는 다음과 같습니다.

 

 

Redisson의 Pub/Sub Lock 방식의 흐름을 그림으로 표현하면 다음과 같습니다.

 

실무에선 어떻게 사용이 될까요?

 

실무에서는 재시도가 필요하지 않은 락은 Lettuce를 활용하고, 재시도가 필요한 경우에는 Redisson을 활용합니다.

 

왜 그럴까요?

 

Lettuce는 SET NX(set if not exists) 방식을 사용해서, 락을 한번만 시도하고 실패하면 바로 종료합니다.

즉, 락을 얻지 못하는 경우 기다리지 않고 즉시 실패하여 빠르게 반환하는 것이 기본 동작이기 때문에 재시도가 필요하지 않은 락에 사용이 됩니다.

 

Redisson은 앞서 봤던 tryLock메서드를 사용해서 락을 획득할 때까지 기다릴 수 있게 해주기 때문에 재시도가 필요한 경우에는 Redisson을 활용합니다.

 

Lettuce는 SpinLock 방식으로 락을 획득할 때까지 계속해서 재시도한다고 하지 않았나요?

 

Lettuce는 기본적으로 SET NX(set if not exists) 방식을 사용하여 락을 한 번만 시도하고, 실패하면 즉시 종료합니다.
다만, Spin Lock처럼 동작하게 만들려면 while문을 사용하여 락을 획득할 때까지 반복 재시도하는 로직을 직접 구현해야 합니다.
즉, Lettuce 자체가 Spin Lock을 제공하는 것이 아니라, Spin Lock 방식으로 동작하도록 구현하는 것입니다.

 

 

정리

  • Redis를 활용한 동시성 제어 방법으로 Lettue의 Spin Lock 방식과 Redisson의 Pub/Sub 기반 Lock 방식을 사용할 수 있습니다.
  • 실무에서는 재시도가 필요하지 않은 락은 Lettuce를 활용하고, 재시도가 필요한 경우에는 Redisson을 활용합니다.

 

'Redis' 카테고리의 다른 글

Redis 다중 명령 원자성 보장 방법  (0) 2025.04.23
Redis SortedSet 정말 빠른가?  (0) 2024.10.17
Redis, 캐싱하면 정말 응답 속도가 빨라?  (0) 2024.10.01