지난번 포스팅과 코드를 이어간다.
https://devhooney.tistory.com/110
메모리 DB인 Redis를 활용하여 데이터 정합성을 맞추는 방법을 알아보자
Lettuce
Redisson
2가지가 있다.
Lettuce
- Lettuce는 setnx 명령어를 활용하여 분산락을 구현한다.
키와 밸류를 set할 때 기존 값이 없을 때만 set하는 명령어
spin lock 방식 이므로 재시도 로직을 직접 짜야 한다.
spin lock이란 쓰레드가 lock을 사용할 수 있는지 반복적으로 확인하면서 시도하는 방식을 말한다.
- 쓰레드1이 키가 1인 데이터를 레디스에 set하면 처음엔 1이 없으므로 성공한다.
- 쓰레드2가 키가 1인 데이터를 set하려 하면 1이 이미 있으므로 실패한다.
- 성공을 위해 100ms마다 재시도하는 로직을 작성해서 성공할 때 까지 시도한다.
Redisson
- Redisson은 pub-sub 기반의 lock 구현을 제공한다.
pub-sub 기반은 채널을 하나 만들고 lock을 점유중인 쓰레드가 다음 lock을 점유하려는 쓰레드에게 점유가 끝났음을 알려주면서 lock을 주고 받는 방식이다.
Lettuce 코드
아래 라이브러리를 build.gradle에 작성하여 설치한다.(라이브러리 설치 전 레디스를 먼저 설치한다.)
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
RedisLockRepository
@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));
}
public String generateKey(Long key) {
return key.toString();
}
}
로직 실행 전 후로 Lock 획득과 해제를 수행해야 하므로 Facade 클래스를 추가한다.
@Component
public class LettuceLockStockFacade {
private RedisLockRepository redisLockRepository;
private StockService stockService;
public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
this.redisLockRepository = redisLockRepository;
this.stockService = stockService;
}
public void decrease(Long key, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(key)) {
Thread.sleep(100);
}
try {
stockService.decrease(key, quantity);
} finally {
redisLockRepository.unlock(key);
}
}
}
Test코드
@SpringBootTest
class LettuceLockStockFacadeTest {
@Autowired
private LettuceLockStockFacade lettuceLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
Stock stock = new Stock(1L, 100L);
stockRepository.saveAndFlush(stock);
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
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) {
e.printStackTrace();
} finally {
{
latch.countDown();
}
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) = 0
assertEquals(0L, stock.getQuantity());
}
}
구현이 간단하다는 장점이 있지만, SpRedis에게 부담을 줄 수 있으므로 lock획득 재시도 시 텀(위 코드에서는 thread.sleep으로 텀을 주었음)을 주어야 한다.
Redisson 코드
Redisson 라이브러리 설치
implementation 'org.redisson:redisson-spring-boot-starter:3.17.4'
redisson은 lock 관련 클래스를 제공해주기 때문에 repository는 필요없다.
하지만 lock 획득, 해제는 직접 작성해야 하므로 facade 클래스를 생성한다.
@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 key, Long quantity) {
RLock lock = redissonClient.getLock(key.toString());
try {
boolean available = lock.tryLock(20, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패!");
return;
}
stockService.decrease(key, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
Test코드
@SpringBootTest
class RedissonLockStockFacadeTest {
@Autowired
private RedissonLockStockFacade redissonLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
Stock stock = new Stock(1L, 100L);
stockRepository.saveAndFlush(stock);
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
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();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) = 0
assertEquals(0L, stock.getQuantity());
}
}
Lettuce와 Redisson의 비교
- Lettuce
- 구현이 간단하다
- spring data redis를 이용하면 lettuce가 기본이기 때문에 별도의 라이브러리를 사용하지 않아도 된다.
- spin lock 방식이기 때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis에 부하가 갈 수 있다.
- Redisson
- lock 획득 재시도를 기본으로 제공한다.
- pub-sub방식으로 구현되어 있기 때문에 lettuce 대비 redis에 부하가 덜 간다.
- 별도 라이브러리를 사용해야 한다.
- lock을 라이브러리 차원에서 제공하기 때문에 사용법을 공부해야한다.
실무에서는
- 재시도가 필요하지 않은 lock은 lettuce활용
- 재시도가 필요한 경우에는 redisson 활용
Mysql과 Redis 비교
- Mysql
- 이미 Mysql을 사용하고 있다면 별도의 비용없이 사용 가능하다.
- 어느정도의 트래픽까지는 문제 없이 활용이 가능하다.
- Redis보다 성능이 좋지 않다
- Redis
- 활용중인 Redis가 없다면 별도의 구축비용과 인프라 관리비용이 발생한다.
- Mysql보다 성능이 좋다.
- 강의 수강 후 정리한 내용입니다.
'개발 > Java&Kotlin' 카테고리의 다른 글
[Spring] RestTemplate 사용하기 (0) | 2022.10.31 |
---|---|
[JPA] fetchCount() 대체하기 (2) | 2022.10.25 |
[Spring] 동시성 이슈 해결 방법 (3) (0) | 2022.09.22 |
[Spring] 동시성 이슈 해결 방법 (2) (0) | 2022.09.21 |
[Spring] 동시성 이슈 해결 방법 (1) (0) | 2022.09.20 |