*/Spring

[Spring Boot] 동시성 제어 (4) - Redis

sssbin 2024. 11. 12. 20:48

** 본 글은 인프런 <재고시스템으로 알아보는 동시성이슈 해결방법> 을 수강한 후 작성한 글입니다.

동시성 문제 해결 방법 3. Redis

  1. Lettuce
    • setnx 명령어를 활용하여 분산락 구현
    • spin lock 방식: 락을 획득하지 못한 경우 락을 획득하기 위해 계속 요청을 보낸다.
    • 별도의 retry 로직 작성이 필요하다.
  2. Redisson
    • pub/sub 방식: 채널을 하나 만들고 락을 점유 중인 스레드가 락을 획득하려고 대기 중인 스레드에게 해제를 알려주면, 안내를 받은 스레드가 락 획득을 시도한다.
    • 별도의 retry 로직 작성이 필요하지 않다.

Redis 설치

docker pull redis
docker run --name myredis -d -p 6379:6379 redis

Lettuce를 이용하여 재고 감소 로직 작성하기

  1. redis 의존성 추가

     implementation 'org.springframework.boot:spring-boot-starter-data-redis'
  2. repository → lock을 얻고 해제하는 로직 작성

     package com.example.stock.repository;
    
     import lombok.RequiredArgsConstructor;
     import org.springframework.data.redis.core.RedisTemplate;
     import org.springframework.stereotype.Component;
    
     import java.time.Duration;
    
     @Component
     @RequiredArgsConstructor
     public class RedisLockRepository {
    
         private final RedisTemplate<String, String> redisTemplate;
    
         public Boolean lock(Long key) {
             return redisTemplate
                     .opsForValue()
                     .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000)); // setnx 명령어
         }
    
         public Boolean unlock(Long key) {
             return redisTemplate.delete(generateKey(key));
         }
    
         private String generateKey(Long key) {
             return key.toString();
         }
     }
    
  3. facade → 실제 로직 전후로 lock 획득 및 해제하는 로직 작성

     package com.example.stock.facade;
    
     import com.example.stock.repository.RedisLockRepository;
     import com.example.stock.service.StockService;
     import lombok.RequiredArgsConstructor;
     import org.springframework.stereotype.Component;
    
     @Component
     @RequiredArgsConstructor
     public class LettuceLockStockFacade {
    
         private final RedisLockRepository redisLockRepository;
         private final StockService stockService;
    
         public void decrease(Long id, Long quantity) throws InterruptedException {
             while (!redisLockRepository.lock(id)) {
                 // lock 획득 실패 시 재시도
                 Thread.sleep(100);
             }
    
             try {
                 // 재고 감소
                 stockService.decrease(id, quantity);
             } finally {
                 // lock 해제
                 redisLockRepository.unlock(id);
             }
         }
     }
    
  4. 테스트

     package com.example.stock.facade;
    
     import com.example.stock.domain.Stock;
     import com.example.stock.repository.StockRepository;
     import org.junit.jupiter.api.AfterEach;
     import org.junit.jupiter.api.BeforeEach;
     import org.junit.jupiter.api.Test;
     import org.springframework.beans.factory.annotation.Autowired;
     import org.springframework.boot.test.context.SpringBootTest;
    
     import java.util.concurrent.CountDownLatch;
     import java.util.concurrent.ExecutorService;
     import java.util.concurrent.Executors;
    
     import static org.junit.jupiter.api.Assertions.*;
    
     @SpringBootTest
     class LettuceLockStockFacadeTest {
    
         @Autowired
         private LettuceLockStockFacade lettuceLockStockFacade;
    
         @Autowired
         private StockRepository stockRepository;
    
         @BeforeEach
         void before() {
             stockRepository.saveAndFlush(Stock.of(1L, 100L));
         }
    
         @AfterEach
         void after() {
             stockRepository.deleteAll();
         }
    
         @Test
         void 동시에_100개의_요청() throws InterruptedException {
             // when
             int threadCount = 100;
             ExecutorService executorService = Executors.newFixedThreadPool(32);
             CountDownLatch latch = new CountDownLatch(threadCount);
    
             for (int i=0; i<threadCount; i++) {
                 executorService.submit(() -> {
                     try {
                         lettuceLockStockFacade.decrease(1L, 1L);
                     } catch (InterruptedException e) {
                         throw new RuntimeException(e);
                     } finally {
                         latch.countDown();
                     }
                 });
             }
    
             latch.await();
    
             // then
             Stock stock = stockRepository.findById(1L).get();
             assertEquals(0, stock.getQuantity());
         }
     }

    동시에 100개의 재고를 감소시켰고, 예상값과 결과값이 일치했다.

  1. 장단점
  • 장점
    • 구현이 쉽다.
  • 단점
    • lock 획득을 계속 시도하기 때문에 서버에 부하를 줄 수 있다.

Redisson을 이용하여 재고 감소 로직 작성하기

  1. redisson 의존성 추가

     implementation 'org.redisson:redisson-spring-boot-starter:3.33.0'
  2. facade → 실제 로직 전후로 lock을 획득 및 해제하는 로직 작성

     package com.example.stock.facade;
    
     import com.example.stock.service.StockService;
     import lombok.RequiredArgsConstructor;
     import org.redisson.api.RLock;
     import org.redisson.api.RedissonClient;
     import org.springframework.stereotype.Component;
    
     import java.util.concurrent.TimeUnit;
    
     @Component
     @RequiredArgsConstructor
     public class RedissonLockStockFacade {
    
         private final RedissonClient redissonClient;
         private final StockService stockService;
    
         public void decrease(Long id, Long quantity) {
             // RedissonClient를 이용해서 lock 객체를 가져온다.
             RLock lock = redissonClient.getLock(id.toString());
    
             try {
                 // lock 획득
                 boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS); // 획득 시간, 점유 시간
    
                 // lock 획득 실패
                 if (!available) {
                     System.out.println("lock 획득 실패");
                     return;
                 }
    
                 // 재고 감소
                 stockService.decrease(id, quantity);
             } catch (InterruptedException e) {
                 throw new RuntimeException(e);
             } finally {
                 // lock 해제
                 lock.unlock();
             }
         }
     }
  3. 테스트

     package com.example.stock.facade;
    
     import com.example.stock.domain.Stock;
     import com.example.stock.repository.StockRepository;
     import org.junit.jupiter.api.AfterEach;
     import org.junit.jupiter.api.BeforeEach;
     import org.junit.jupiter.api.Test;
     import org.springframework.beans.factory.annotation.Autowired;
     import org.springframework.boot.test.context.SpringBootTest;
    
     import java.util.concurrent.CountDownLatch;
     import java.util.concurrent.ExecutorService;
     import java.util.concurrent.Executors;
    
     import static org.junit.jupiter.api.Assertions.*;
    
     @SpringBootTest
     class RedissonLockStockFacadeTest {
    
         @Autowired
         private RedissonLockStockFacade redissonLockStockFacade;
    
         @Autowired
         private StockRepository stockRepository;
    
         @BeforeEach
         void before() {
             stockRepository.saveAndFlush(Stock.of(1L, 100L));
         }
    
         @AfterEach
         void after() {
             stockRepository.deleteAll();
         }
    
         @Test
         void 동시에_100개의_요청() throws InterruptedException {
             // when
             int threadCount = 100;
             ExecutorService executorService = Executors.newFixedThreadPool(32);
             CountDownLatch latch = new CountDownLatch(threadCount);
    
             for (int i=0; i<threadCount; i++) {
                 executorService.submit(() -> {
                     try {
                         redissonLockStockFacade.decrease(1L, 1L);
                     } finally {
                         latch.countDown();
                     }
                 });
             }
    
             latch.await();
    
             // then
             Stock stock = stockRepository.findById(1L).get();
             assertEquals(0, stock.getQuantity());
         }
     }

    동시에 100개의 재고를 감소시켰고, 예상값과 결과값이 일치했다.

  4. 장단점

    • 장점
      • 서버 부하가 적다.
    • 단점
      • 구현이 복잡하다.
      • 별도의 라이브러리를 사용해야 한다.