catsridingCATSRIDING|OCEANWAVES
Dev

Redis 분산락을 활용한 동시성 이슈 해결하기

jynn@catsriding.com
Mar 21, 2024
Published byJynn
999
Redis 분산락을 활용한 동시성 이슈 해결하기

Resolve Concurrency Issues with Redis

동시성 이슈(Concurrency issues)는 여러 작업이 동시에 실행될 때 발생하는 문제를 의미합니다. 이로 인해 시스템의 일관성이 깨지거나 예기치 않은 오류가 발생하는 경우가 있습니다.

Discovering Concurrency Issues

먼저, 여러 작업이 공유 리소스에 동시에 접근할 때 발생하는 동시성 이슈에 대해 알아보겠습니다.

StockServiceImpl.java
@Slf4j
@Service
@RequiredArgsConstructor
public class StockServiceImpl implements StockService {

    private final StockRepository repository;

    @Override
    @Transactional
    public void placeOrder(NewOrder newOrder) {
        Stock stock = repository.findBy(newOrder.toCond());
        stock = stock.order(newOrder);
        repository.save(stock.toEntity());
    }
}

이 서비스는 주문 요청이 접수되면, 우선 데이터베이스로부터 현재 재고를 확인하고, 주문 수량에 해당하는 만큼 재고를 감소시킨 후, 변동된 재고 수량을 저장하는 기능을 가지고 있습니다. 다시 말해, 주문이 발생하면 현재 재고를 업데이트하고 그 결과를 데이터베이스에 저장합니다.

이 로직을 검증하기 위해 단일 요청을 처리하는 테스트 케이스를 작성하겠습니다:

StockServiceTest.java
@SpringBootTest
class StockServiceTest {

    @Autowired private StockRepository stockRepository;
    @Autowired private StockService stockService;

    @BeforeEach
    public void setup() {
        Stock stock = Stock.builder()
                .productId(1L)
                .quantity(100L)
                .build();
        stockRepository.save(stock.toEntity());
    }

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

    @Test
    @DisplayName("decrease stock quantity")
    void shouldDecreaseStockQuantity() throws Exception {

        //  Given
        NewOrder newOrder = NewOrder.builder()
                .id(1L)
                .quantity(1L)
                .build();

        //  When
        stockService.placeOrder(newOrder);

        //  Then
        Stock stock = stockRepository.findBy(newOrder.toCond());
        assertThat(stock.getQuantity()).isEqualTo(99);
    }
}

이 테스트는 주문 이후의 재고량이 정확하게 1개만 감소했음을 검증하고 있으며, 검증 결과 테스트는 성공적으로 통과하였습니다. 즉, 초기 재고량이 100개일 때 1개의 주문이 이루어지면 잔여 재고량은 99개가 되어야 하는데, 이 부분이 정확하게 작동했습니다.

그러나, 만약 다수의 주문이 동시에 이루어진다면, 이 로직은 예상한 방식대로 동작하지 않을 가능성이 있습니다. 이런 시나리오를 직접 테스트하기 위해, 다중 쓰레드에서 동시 요청을 보내는 테스트 케이스를 작성해보겠습니다.

StockServiceTest.java
@SpringBootTest
class StockServiceTest {

    @Autowired private StockRepository stockRepository;
    @Autowired private StockService stockService;

    @BeforeEach public void setup() {...}
    @AfterEach public void cleanup() {...}
    
    @Test
    @DisplayName("decrease stock by 100")
    void shouldDecreaseStockBy100() throws Exception {

        //  Given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);
        NewOrder newOrder = NewOrder.builder()
                .id(1L)
                .quantity(1L)
                .build();

        //  When
        IntStream.range(0, threadCount).forEach(index -> processOrder(executorService, newOrder, latch));
        latch.await();

        //  Then
        Stock stock = stockRepository.findBy(newOrder.toCond());
        assertThat(stock.getQuantity()).isEqualTo(0);

    }

    private void processOrder(ExecutorService executorService, NewOrder newOrder, CountDownLatch latch) {
        executorService.submit(() -> {
            try {
                stockService.placeOrder(newOrder);
            } finally {
                latch.countDown();
            }
        });
    }
}

모든 요청이 처리된 후에, 기대한 재고 수량은 0이었지만, 실제 결과는 큰 차이를 보였습니다:

console
Expected :0L
Actual   :88L

이런 상황이 바로 동시성 이슈(Concurrency Issues)의 전형적인 예입니다. 여러 쓰레드가 공유 자원에 동시에 접근하면서 충돌로 인해 시스템의 일관성이 손상되고 예기치 않은 오류가 발생한 것입니다.

Concurrency Issues vs. Race Conditions
동시성 이슈(Concurrency Issues)와 경쟁 조건(Race Conditions)은 보통 보안과 동시성 제어 문제라는 포괄적인 범주 안에서 논의되지만, 두 상황은 서로 다른 특징과 문제를 지닙니다. 동시성 이슈는 여러 여러 작업이 공유 리소스에 동시에 접근할 때 발생하는 문제를 가리키며, 반면 경쟁 조건은 시스템에서 두 개 이상의 작업이 각기 다른 순서로 실행될 때 그 결과가 달라지는 상황을 의미합니다. 즉, 작업의 경쟁 상태가 시스템의 처리 결과에 영향을 미치는 특정 동시성 이슈를 가리킵니다. 따라서, 경쟁 조건(Race Conditions)은 동시성 이슈(Concurrency Issues)의 하위 범주라고 볼 수 있으며, 보통 이 두 용어는 동시에 사용되곤 합니다.

Resolving Concurrency Issues

동시성 문제를 다루는 다양한 접근법이 존재하며, 이에는 분산락, 세마포어, 메시지 큐 등의 기법이 포함됩니다. 이번 글에서는 이러한 방법들 중 Redis의 분산락(Distributed Locks) 기능을 활용하여 문제를 해결하는 방법에 대해 살펴보겠습니다.

Distributed Lock
분산락(Distributed Lock)은 분산 시스템에서 다수의 노드가 특정 자원을 동시에 사용하려 할 때 이를 제어해 동시성 문제를 해결하는 동기화 메커니즘입니다. 단일 시스템에서는 락 메커니즘을 통해 하나의 프로세스만이 특정 자원에 접근하도록 제어하지만, 분산 시스템에서는 여러 노드간의 접근을 동기화해야 합니다. 분산락의 활용은 분산 데이터베이스, 클러스터 컴퓨팅, 마이크로서비스 아키텍처 등 다양한 분야에서 적용될 수 있습니다.

Java에서 Redis를 사용하기 위한 주요 라이브러리로는 Jedis, Lettuce, 그리고 Redisson이 있습니다. 이들 라이브러리들은 각각 다른 특성을 가지며, 사용자의 요구 사항에 따라 적절한 라이브러리를 선택할 수 있습니다.

LibraryFeatures
Jedis가장 많이 사용되는 Redis Java 클라이언트로 매우 단순하고 사용하기 쉬운 API를 지원합니다. 하지만, 이 라이브러리는 멀티스레드 환경에는 적합하지 않습니다.
LettuceNetty에 의해 관리되는 연결 풀을 자동으로 제공하고, 클러스터, Pub/Sub, 지속성 등과 같은 고급 기능을 지원합니다. 이 라이브러리는 멀티스레드 환경에서 사용하기에 적합합니다.
RedissonRedis를 위한 고성능 및 스레드-세이프한 클라이언트로, 분산데이터 구조와 분산 서비스를 지원하는 다양한 강력한 기능을 제공하며, Jedis 및 Lettuce와 비교하여 더 많은 고급 기능을 지원합니다.

Lettuce에서는 분산락 기능을 직접 구현해야 합니다. 이 때 스핀락(Spinlock) 방식이 적용되므로, 부하가 클 경우 문제가 발생할 수 있습니다. 반면에, Redisson은 분산락 인터페이스를 지원합니다. 또한, 락(Lock)을 관리하기 위해 Pub/Sub 방식을 적용하여 더 낮은 부하와 보다 안정적인 환경을 제공합니다.

이러한 이점들로 인해, Redisson 라이브러리를 통해 현재 직면한 상황을 해결해보도록 하겠습니다.

Spinlock
스핀락(Spinlock)은 고성능 컴퓨팅에서 사용되는 동기화 방식 중 하나입니다. 쓰레드가 임계 구역에 진입하려 할 때 해당 임계 구역이 락(Lock) 상태인 경우, 스핀락을 사용하는 스레드는 락이 해제될 때까지 반복적으로 락의 상태를 체크하면서 대기합니다. 이는 즉시 실행이 가능해질 때까지 계속해서 루프를 도는 것으로, 이를 스핀이라고 표현합니다. 따라서 스핀락은 락이 해제되는 시간이 짧을 경우 효과적인 방법이지만, 락 해제 시간이 길어지는 경우 CPU 리소스를 낭비하게 되며, 그 결과로 부하가 커질 수 있습니다. 따라서 환경 및 요구 사항에 따라 적절한 락 방식을 선택하는 것이 중요합니다.

먼저, Spring Boot 프로젝트에서 Redisson 라이브러리를 사용하기 위해  build.gradle에 다음의 의존성을 추가합니다.

build.gradle
dependencies {
    implementation 'org.redisson:redisson-spring-boot-starter:3.27.2'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

Spring Data Redis는 기본적으로 Lettuce를 사용하고 있지만, 추가적으로 적용한 이유는 자동 구성을 활용해 Redis 설정을 더욱 간편하게 처리하기 위한 것입니다.

Redisson 라이브러리를 프로젝트에 추가한 후, 분산락을 쉽게 적용하기 위해 파사드 패턴을 활용합니다. 파사드 패턴은 복잡한 시스템에 대한 단순화된 인터페이스를 제공하는 디자인 패턴입니다. 이를 통해 일관성을 유지하면서 코드 복잡성을 줄일 수 있습니다. 이렇게 구현된 StockServiceFacade 클래스는 다음과 같습니다:

StockServiceFacade.java
@Slf4j
@Component
@RequiredArgsConstructor
public class StockServiceFacade {

    private final StockService stockService;
    private final RedissonClient redissonClient;

    public void placeOrder(NewOrder newOrder) {
        RLock rlock = redissonClient.getLock(newOrder.getId().toString());
        try {
            if (!rlock.tryLock(10, 1, TimeUnit.SECONDS)) return;
            stockService.placeOrder(newOrder);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            rlock.unlock();
        }
    }
}
  • RedissonClient: 이는 Redisson 라이브러리의 주요 클래스로, Redisson 객체들을 구성하고 관리합니다. 이를 사용하여 락을 생성하고 관리를 합니다.
  • redissonClient.getLock(): 특정 키에 대한 락을 가져옵니다. 이 메소드는 RLock 객체를 반환합니다.
  • RLock: Redisson에서 제공하는 인터페이스로, 싱글 노드나 클러스터 등의 분산 환경에서 사용할 수 있는 분산락 기능을 제공합니다. RLock은 자바의 java.util.concurrent.locks.Lock 인터페이스를 확장하므로, Lock 인터페이스의 모든 메서드를 사용할 수 있습니다.
  • rlock.tryLock(): 이 메소드는 분산락을 얻으려는 시도를 하는데 사용됩니다.
    • waitTime: 이 매개변수는 락 획득을 위해 대기하는 최대 시간을 설정합니다. 이 시간 동안 락이 사용 가능해지지 않으면 메소드는 false를 반환하고 실행을 중지합니다. 여기서 설정하는 시간 간격 동안 스레드는 락이 해제될 때까지 블로킹됩니다.
    • leaseTime: 이 매개변수는 락이 획득된 후에 락을 자동으로 해제할때까지의 지연시간을 설정합니다. 이 시간이 경과하면, 락은 자동으로 해제됩니다. 이 메커니즘은 락을 수동으로 해제하는 것을 잊었을 때의 안전장치 역할을 합니다.
    • TimeUnit: 이 매개변수는 waitTimeleaseTime의 시간 단위를 설정합니다. 보통 경과 시간을 표현하기 위해, 명확성과 편의를 위해 TimeUnit.SECONDSTimeUnit.MILLISECONDS가 사용됩니다.

이렇게 구성된 파사드 객체를 통해 주문이 처리되는 과정은 다음과 같습니다:

  1. 우선, redissonClient.getLock()를 사용하여 Redisson 라이브러리를 통해 해당 주문 ID에 대한 분산락을 생성하거나 가져옵니다. 이것은 RLock 객체로 표현되며, 고유한 주문 ID에 대해 전체 분산 환경에서 공유되는 락(Lock)입니다.
  2. 그 다음, rlock.tryLock()를 통해 이 락을 획득하려고 시도합니다. 여기서는 최대 10초 동안 락 획득을 기다리고, 성공적으로 락을 획득한 경우에는 1초 동안 락을 유지한 후 자동으로 해제합니다. 만약 10초 동안 락을 획득하지 못하면 메소드는 false를 반환하고, 이 경우 메소드는 더 이상 진행되지 않고 바로 종료됩니다.
  3. 락 획득에 성공한 경우, 기존 stockService.placeOrder()를 호출하여 실제 주문 처리를 진행합니다.
  4. 락을 해제하는 것은 매우 중요합니다. 그래야 다음 쓰레드에서 락을 획득하여 다음 단계로 넘어갈 수 있기 때문입니다. 그래서 finally 블록에서 rlock.unlock()를 호출하여 락을 해제합니다.

이제, 이전에 발생했던 동시성 이슈에 대해 다시 살펴보고, 파사드 객체를 이용함으로써 문제가 해결되었는지 확인해 보겠습니다. 이를 위해, 서비스 객체를 직접 호출하던 부분을 파사드 객체를 이용하여 호출하도록 테스트 코드를 수정하였습니다. 이렇게 변경된 테스트 코드를 다시 실행해보면, 파사드 객체의 효과를 확인할 수 있게 됩니다.

StockServiceTest.java
@SpringBootTest
class StockServiceTest {

    @Autowired private StockRepository stockRepository;
-   @Autowired private StockService stockService;
+   @Autowired private StockServiceFacade stockServiceFacade;

    @BeforeEach public void setup() {...}
    @AfterEach public void cleanup() {...}
    
    @Test
    @DisplayName("decrease stock by 100")
    void shouldDecreaseStockBy100() throws Exception {...}

    private void processOrder(ExecutorService executorService, NewOrder newOrder, CountDownLatch latch) {
        executorService.submit(() -> {
            try {
-               stockService.placeOrder(newOrder);
+               stockServiceFacade.placeOrder(newOrder);
            } finally {
                latch.countDown();
            }
        });
    }
}

수정된 테스트 코드를 실행해보면, 이전에 실패하던 테스트 케이스가 이번엔 성공적으로 통과했습니다. ✅

동시성 이슈는 백엔드 개발에서 중요한 관심사이며, 심지어 어떤 사람들은 그것을 백엔드의 꽃이라고 묘사하기도 합니다. 🌹 이를 해결하기 위한 다양한 설계와 방법론을 알아두는 것은 모든 백엔드 개발자에게 중요한 역량이 될 것입니다. 제대로된 도구와 전략을 선택할 수 있도록 지속적인 학습과 경험의 중요성을 다시 한번 깨달았습니다.


  • Spring
  • Redis