** 본 글은 인프런 <재고시스템으로 알아보는 동시성이슈 해결방법> 을 수강한 후 작성한 글입니다.
동시성 문제 해결 방법 3. Redis
- Lettuce
- setnx 명령어를 활용하여 분산락 구현
- spin lock 방식: 락을 획득하지 못한 경우 락을 획득하기 위해 계속 요청을 보낸다.
- 별도의 retry 로직 작성이 필요하다.
- Redisson
- pub/sub 방식: 채널을 하나 만들고 락을 점유 중인 스레드가 락을 획득하려고 대기 중인 스레드에게 해제를 알려주면, 안내를 받은 스레드가 락 획득을 시도한다.
- 별도의 retry 로직 작성이 필요하지 않다.
Redis 설치
docker pull redis
docker run --name myredis -d -p 6379:6379 redis
Lettuce를 이용하여 재고 감소 로직 작성하기
redis 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
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(); } }
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); } } }
테스트
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개의 재고를 감소시켰고, 예상값과 결과값이 일치했다.
- 장단점
- 장점
- 구현이 쉽다.
- 단점
- lock 획득을 계속 시도하기 때문에 서버에 부하를 줄 수 있다.
Redisson을 이용하여 재고 감소 로직 작성하기
redisson 의존성 추가
implementation 'org.redisson:redisson-spring-boot-starter:3.33.0'
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(); } } }
테스트
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개의 재고를 감소시켰고, 예상값과 결과값이 일치했다.
장단점
- 장점
- 서버 부하가 적다.
- 단점
- 구현이 복잡하다.
- 별도의 라이브러리를 사용해야 한다.
- 장점
'* > Spring' 카테고리의 다른 글
[Spring Boot] 동시성 제어 (3) - Database (1) | 2024.11.11 |
---|---|
[Spring Boot] 동시성 제어 (2) - Synchronized (0) | 2024.11.10 |
[Spring Boot] 동시성 제어 (1) - 동시성 문제, 재고 감소 시스템 구현 및 테스트 (1) | 2024.11.09 |
[JPA] 엔티티 equals 메서드 구현 시 주의할 점 (0) | 2024.11.08 |
[JPA] Join Fetch 시 MultipleBagFetchException (0) | 2024.11.07 |