ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 인프런) 재고시스템으로 알아보는 동시성 이슈 해결 (2)
    JAVA 2024. 6. 5. 00:18

     

     

     

     

     

    지난 글에 이어서 다음의 인프런 강의를 실습해본다. 

    https://www.inflearn.com/course/%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%B4%EC%8A%88-%EC%9E%AC%EA%B3%A0%EC%8B%9C%EC%8A%A4%ED%85%9C/dashboard

     

    재고시스템으로 알아보는 동시성이슈 해결방법 | 최상용 - 인프런

    최상용 | 동시성 이슈란 무엇인지 알아보고 처리하는 방법들을 학습합니다., 동시성 이슈 처리도 자신있게! 간단한 재고 시스템으로 차근차근 배워보세요. 백엔드 개발자라면 꼭 알아야 할 동

    www.inflearn.com

     

    지난글 

    https://dodop-blog.tistory.com/463

     

    인프런) 재고시스템으로 알아보는 동시성 이슈 해결 (1)

    동시성 이슈 해결을 위한 인프런 강의를 듣고 실습해보았다. https://www.inflearn.com/course/%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%B4%EC%8A%88-%EC%9E%AC%EA%B3%A0%EC%8B%9C%EC%8A%A4%ED%85%9C/dashboard 재고시스템으로 알아보는 동

    dodop-blog.tistory.com

     

     

     

     

     

     

    5. Redis - lettuce

    이번에는 레디스를 이용하여 동시성 이슈를 해결해보자. 레디스를 사용해서 분산락을 만들기 위한 대표 사용 라이브러리는 lettuce와 Redisson 이 있다. 

    • lettuce
      • setnx 명령어를 활용하여 분산락을 구현
        • set if not exist의 줄임말
        • key/value를 set할 때 기존 값이 없을때만 set 
      • spin lock 방식
      • retry 로직을 개발자가 구현해야함
      • 반복문을 돌면서 지속적으로 lock이 있는지 확인하고 획득을 시도하는 방식 

    • Redisson
      • pub sub 기반의 lock 구현 제공
      • 채널을 만들고 락을 점유중인 스레드가 락을 획들하려는 스레드에게 해제를 알려주면 안내받은 스레드가 락 획득을 시도하는 로직으로 개발자가 retry 로직 구현하지 않아도 됨 

     

     

     

     

    먼저 실습을 위해 docker로 redis를 켜준다. 

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

     

     

    spring data redis 의존성을 추가해준다. 

    https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis/3.2.4

    implementation("org.springframework.boot:spring-boot-starter-data-redis:3.2.4")

     

     

    프로젝트 실행이 잘 되는지 확인하고 정상적으로 실행되면 터미널에서 redis cli를 실행시켜준다. 

    % docker exec -it 2d81724d6cc4 redis-cli

     

    이때 컨테이너 아이디는 docker ps를 통해서 실행중인 redis의 컨테이너 아이디를 활용한다. 

     

     

    key가 1인 lock을 넣어준다. 

    setnx 1 lock

     

     

    다시한번더 락을 넣으려고하면 키가 중복되어 오류가 발새한다 

     

    del 명령어로 키를 삭제하고 다시 넣으면 성공한다. 

     

     

    mysql의 네임드락과 거의 유사하며 레디스를 이용한다는 점과, 세션관리에 신경쓰지 않아도 된다는 차이가 있다. 

     

     

    lock repository를 만들어준다. 

    락을 생성하고 해제하는 메서드를 만든다. 

    @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(3_000));
        }
    
        public Boolean unlock(Long key) {
            return redisTemplate.delete(generateKey(key));
        }
    
        private String generateKey(Long key) {
            return key.toString();
        }
    }

     

     

     

     

    파사드를 만들어서 락을 획득하고 재고를 감소시키도록 구현한다. 

     

    import com.yunhalee.stock.repository.LockRepository;
    import com.yunhalee.stock.repository.RedisLockRepository;
    import com.yunhalee.stock.service.StockService;
    import org.springframework.stereotype.Component;
    
    @Component
    public class RedisLockStockFacade {
    
    
        private RedisLockRepository redisLockRepository;
    
        private final StockService stockService;
    
        public RedisLockStockFacade(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);
            }
        }
    }

     

     

     

    테스트를 작성한다. 

    import com.yunhalee.stock.domain.StockEntity;
    import com.yunhalee.stock.facade.RedisLockStockFacade;
    import com.yunhalee.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.autoconfigure.cache.CacheProperties;
    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.assertEquals;
    
    @SpringBootTest
    class RedisLockStockFacadeTest {
    
        @Autowired
        private RedisLockStockFacade redisLockStockFacade;
    
        @Autowired
        private StockRepository stockRepository;
    
        @BeforeEach
        public void setUp() {
            stockRepository.saveAndFlush(new StockEntity(1L, 100L));
        }
    
        @AfterEach
        public void clear() {
            stockRepository.deleteAll();
        }
    
    
        @Test
        public void 동시에_100개의_요청() throws InterruptedException {
            Long stockId = 1L;
            int threadCount = 100;
            // 비동기 적으로 요청을 실행할 수 있는 thread service
            ExecutorService service = Executors.newFixedThreadPool(threadCount);
            // 모든 요청이 완료될 떄까지 기다리게 할 수 있는 기능
            CountDownLatch countDownLatch = new CountDownLatch(threadCount);
            for (int i=0;  i< threadCount; i++) {
                service.submit(() -> {
                    try {
                        redisLockStockFacade.decrease(stockId, 1L);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    } finally {
                        countDownLatch.countDown();
                    }
                });
            }
            countDownLatch.await();
            StockEntity stock = stockRepository.findById(stockId).orElseThrow();
            assertEquals(0, stock.getQuantity());
        }
    
    }

     

     

    정상적으로 성공하는 것을 알 수 있다. 

    구현 방법이 간단하지만, spinlock 방식으로 레디스에 부하를 줄 수 있다. 

    그래서 Thread slepp을 통해서 락 획득 방식에 재시도 텀을 두어야 한다. 

     

     

     

     

     

     

     

    6. Redis - redisson

    이번에는 Redisson 활용 방식을 구현해보자. 

     

    Redisson 의존성을 추가해야 한다. 

    https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter/3.27.2

    implementation("org.redisson:redisson-spring-boot-starter:3.27.2")

     

     

     

    redisson pubsub을 실습해보자. 

    redis cli창을 두개 켜준다. 

     

    한 창에서는 ch1을 subscribe 하고, 

    다른 창에서는 메세지를 보내본다. 

     

     

    subscribe 하는 쪽에서 메세지를 수신하는 것을 알 수 있다. 

     

     

    redisson을 자신이 점유하고 있는 락을 해제할 때, 채널에 메세지를 보내면서 락 획득을 대기하는 스레드들에게 안내를 보낸다

    락 획득을 대기하는 스레드는 메세지를 받았을 때, 락획득을 시도한다. 

    lettuce는 계속해서 락 획득을 시도하는 반면에, redisson은 한번 혹은 몇번만 락 획득을 시도하기 때문에 레디스 부하를 낮춰준다. 

    레디슨의 경우 락 관련된 클래스를 라이브러리에서 제공해주므로 별도의 클래스를 만들지 않아도 되기 때문에 로직 실행 후 락을 해제하는 퍼사드만 만든다. 

    락을 획득하는데 대기하는 시간과 해제 시간을 설정할 수 있다. 

     

    redisson 클라이언트를 이용해서 락을 획득하도록 하고 획득에 실패하는 경우 로그를 남기도록 처리하였다. 

    import com.yunhalee.stock.repository.RedisLockRepository;
    import com.yunhalee.stock.service.StockService;
    import org.redisson.api.RLock;
    import org.redisson.api.RedissonClient;
    import org.springframework.stereotype.Component;
    
    import java.util.concurrent.TimeUnit;
    
    @Component
    public class RedissonLockStockFacade {
    
    
        private RedissonClient redissonClient;
    
        private final 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();
            } finally {
                lock.unlock();
            }
        }
    }

     

     

    테스트 코드를 작성한다. 

    import com.yunhalee.stock.domain.StockEntity;
    import com.yunhalee.stock.facade.RedissonLockStockFacade;
    import com.yunhalee.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.assertEquals;
    
    @SpringBootTest
    class RedissonLockStockFacadeTest {
    
        @Autowired
        private RedissonLockStockFacade redissonLockStockFacade;
    
        @Autowired
        private StockRepository stockRepository;
    
        @BeforeEach
        public void setUp() {
            stockRepository.saveAndFlush(new StockEntity(1L, 100L));
        }
    
        @AfterEach
        public void clear() {
            stockRepository.deleteAll();
        }
    
    
        @Test
        public void 동시에_100개의_요청() throws InterruptedException {
            Long stockId = 1L;
            int threadCount = 100;
            // 비동기 적으로 요청을 실행할 수 있는 thread service
            ExecutorService service = Executors.newFixedThreadPool(threadCount);
            // 모든 요청이 완료될 떄까지 기다리게 할 수 있는 기능
            CountDownLatch countDownLatch = new CountDownLatch(threadCount);
            for (int i=0;  i< threadCount; i++) {
                service.submit(() -> {
                    try {
                        redissonLockStockFacade.decrease(stockId, 1L);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    } finally {
                        countDownLatch.countDown();
                    }
                });
            }
            countDownLatch.await();
            StockEntity stock = stockRepository.findById(stockId).orElseThrow();
            assertEquals(0, stock.getQuantity());
        }
    
    }

     

     

    현재 재시도를 하지 않고 로그로만 남겼기 떄문에 테스트 케이스가 실패한다면 대기 시간을 늘려서 테스트를 실행한다. 

    테스트 코드가 성공하는 것을 알 수 있다. 

     

     

    레디스 부하를 줄여준다는 장점이 있지만, 구현이 조금 복잡하고 별도의 라이브러리를 사용해야한다는 단점이 있다. 

     

     

     

     

     

     

    Lettuce와 Redisson 방식 비교 
    • Lettuce
      • 구현이 간단
      • spring data redis 를 이용하면 lettuce 가 기본이기때문에 별도의 라이브러리를 사용하지 않아도 됨
      • spin lock 방식이기때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis 에 부하가 갈 수 있음 
    • Redisson 
      • 락 획득 재시도를 기본으로 제공
      • pub-sub 방식으로 구현이 되어있기 때문에 lettuce 와 비교했을 때 redis 에 부하가 적음 
      • 별도의 라이브러리를 사용해야 함 
      • lock 을 라이브러리 차원에서 제공해주기 떄문에 사용법을 공부해야 함 

    따라서 재시도가 필요하지 않은 lock 은 lettuce 활용하고, 재시도가 필요한 경우에는 redisson 를 활용하자 

     

     

     

     

     

    Mysql(데이터베이스 레벨)과 Redis 방식 비교 

     

    • Mysql
      • 이미 Mysql 을 사용한다면 별도의 비용없이 사용가능
      • Redis 보다는 성능이 좋지않지만, 어느정도의 트래픽까지는 문제없이 활용이 가능
    • Redis
      • 활용중인 Redis 가 없다면 별도의 구축비용과 인프라 관리비용이 발생
      • Mysql 보다 성능이 좋다.

    따라서, 실무에서 비용적 여유가 없고 mysql 로 처리가 가능할 정도의 트래픽이라면 mysql, 아니라면 redis를 활용한다. 

     

     

     

     

Designed by Tistory.