[최상용님의 재고 시스템으로 알아보는 동시성 이슈 해결방법을 학습 후 정리한 내용입니다.]
순서
- Redis 라이브러리 알아보기
- 작업환경 세팅
- Lettuce 사용하기
- Redisson 사용하기
- 정리
1. Redis 라이브러리 알아보기
Redis를 사용하여 동시성 문제를 해결하는 대표적인 라이브러리에는 2가지가 존재한다.
- Lettuce
- Redisson
Lettuce
- Setnx 명령어를 활용하여 분산락을 구현한다. 기존의 값이 없을 때만 Set을 하는 명령어이다.
- Setnx는 Spin Lock방식이므로 retry 로직을 개발자가 작성해야 한다.
- Spin Lock이란, Lock 을 획득하려는 스레드가 Lock을 획득할 수 있는지 확인하면서 반복적으로 시도하는 방법이다.
Redisson
- Pub-sub기반으로 Lock 구현을 제공한다.
- Pub-Sub방식이란, 채널을 하나 만들고, 락을 점유중인 스레드가, 락을 해제했음을 대기중인 스레드에게 알려주면, 대기중인 스레드가 락 점유를 시도하는 방식이다.
- Lettuce와 다르게 대부분 별도의 Retry 방식을 작성하지 않아도 된다.
2. 작업환경 세팅
먼저 도커로 Redis image를 pull 받는다.
docker pull redis
redis를 실행한다.
docker run --name myredis -d -p 6379:6379 redis
실행된 redis를 확인한다.
docker ps
build.gradle에 Redis 의존성을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
3. Lettuce 사용하기
먼저, Lettuce는 setnx을 이용하여 락을 구현하는 것이기 때문에 setnx에 대해서 간략히 살펴보자.
1) 먼저 컨테이너 id를 사용하여 cli로 접속한다.
2)setnx 명령어로 (key,value) 데이터를 저장한다.
key(1)와 value(lock)을 처음으로 입력하면 성공(1)하지만, 그 이후로 1에 value를 집어 넣으려고 하면, 실패(0)하게 된다. 이 경우, del을 통해 삭제 후, 다시 삽입해야 한다.
이제 코드로 돌아와서 예시를 살펴보자.
@Component 어노테이션이 붙은 RedisLockRepository클래스는 Redis를 이용한 락의 저장과 해제 기능을 담당한다. RedisTemplate 을 사용해 Redis서버와 통신하며, setIfAbsent()메서드로 (key,value)를 설정한다. 이미 key값이 있다면, value를 설정하지 않는다.
이렇게 락을 설정하면, 락을 획득한 클라이언트만이 특정 작업을 수행할 수 있다.
[RedisLockRepository]
@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String,String> redisTemplate;
public Boolean lock(Long key){ //lock 설정
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) //lock 해제
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key){
return key.toString();
}
}
Redis도 lock을 설정하고, 해제하는 코드가 들어가기 때문에 Facade 클래스를 별도로 정의하여 stockService의 decrease()메서드를 감싸준다.
[LettuceLockStockFacade]
@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private final StockService stockService;
public void decrease(Long key, Long quantity) throws InterruptedException {
while(!redisLockRepository.lock(key)){ //계속해서 lock 획득 시도
Thread.sleep(100); //Spinlock 방식이 redis에게 주는 부하를 줄여주기 위해 sleep
}
try{
stockService.decrease(key,quantity);
} finally{
redisLockRepository.unlock(key);
}
}
}
이제 멀티 스레드 환경에서 동시에 100번의 재고 감소 호출이 일어났을 때 Facade의 decrease()를 사용하면, 우리가 예상한 대로 재고가 0이 되면서, 테스트가 통과함을 볼 수 있다.
[Test]
@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) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
//100 -(1*100) = 0
assertEquals(0L, stock.getQuantity());
}
- Lettuce를 활용한 락 방식은 구현이 간단하다는 장점이 있다.
- 하지만, spin Lock 방식이므로 Redis에 부하를 줄 수 있다는 단점이 있다.
4. Redisson 사용하기
먼저, redisson은 Pub-sub방식으로 lock을 구현하기 때문에 Pub-sub방식에 대해 간략하게 알고 넘어가자.
pub-sub방식을 사용하개보기 위해서는 2개의 redis-cli가 필요하다.
1) 1번 cli에서 subscribe 명령어로 ch1을 구독한다.
2) 2번 cli에서 publish 명령어로 메세지를 전달한다.
3) 그러면 1번 cli는 ch1을 구독하고 있기 때문에, 2번 cli가 보낸 메시지를 확인할 수 있다.
- redisson은 자신이 점유하고 있는 락을 해제할 때 channel에 메시지를 보내줌으로써 락을 획득해야 하는 스레드들에게 락을 획득할 수 있게 한다.
- Lettuce와 다르게 redisson은 계속 락 획득을 시도하는게 아니기 때문에 Redis의 부하를 줄일 수 있다.
이제 코드로 예시를 살펴보자. 먼저, redisson라이브러리 의존성을 추가해준다.
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.19.0'
redisson은 기본적으로 redis의 락 구현체를 제공하기 때문에 별도의 레파지토리를 작성할 필요는 없다. 그래도 서비스 로직 실행 전 후로 락 획득, 해제를 해야 하므로 Facade 클래스를 정의한다.
[Facade]
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {
private RedissonClient redissonClient;
private StockService stockService;
public void decrease(Long key, Long quantity){
RLock lock = redissonClient.getLock(key.toString());
try{
boolean available = lock.tryLock(5,1, TimeUnit.SECONDS); // lock 획득
if(!available){
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(key,quantity); //서비스 로직 실
} catch(InterruptedException e){
throw new RuntimeException(e);
} finally{
lock.unlock(); //lock 해제
}
}
}
이제 멀티 스레드 환경에서 동시에 100번의 재고 감소 호출이 일어났을 때 Facade의 decrease()를 사용하면, 우리가 예상한 대로 재고가 0이 되면서, 테스트가 통과함을 볼 수 있다.
[Test]
@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());
}
5. 정리
데이터베이스 lock vs Redis lock
Mysql
- 이미 mysql을 사용하고 있다면 별도의 비용 없이 사용 가능하다.
- 어느정도의 트래픽까지는 문제 없이 활용이 가능하다.
- Redis보다는 성능이 좋지 않다.
Redis
- 활용 중인 Redis가 없다면 별도의 구축 비용과 인프라 관리 비용이 발생한다.
- Mysql보다 성능이 좋다.
Redis의 Lettuce vs Redis의 Redisson
Lettuce
- 구현이 간단하다.
- Spring data redis을 이용하면 Lettuce가 기본이기 때문에 별도의 라이브러리를 사용하지 않아도 된다.
- Spin Lock 방식이기 때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 Redis에 부하가 갈 수 있다.
Redisson
- 락 획득 재시도를 기본으로 제공한다.
- pub-sub 방식으로 구현이 되어있기 때문에 Lettuce와 비교했을 때 Redis에 부하가 덜 간다.
- 별도의 라이브러리를 사용 해야 한다.
- Lock 라이브러리 차원에서 제공해주기 때문에 사용법을 공부해야 한다.
실무에서는 재시도가 필요하지 않은 Lock은 Lettuce로, 재시도가 필요하다면 Redisson을 사용한다.
'Frameworks > Springboot' 카테고리의 다른 글
[Spring] 커넥션 풀과 DataSource에 대한 이해 (0) | 2023.05.22 |
---|---|
[Spring] JDBC에 대한 이해 (0) | 2023.05.22 |
[Springboot] Synchronized, Database로 동시성 문제 해결하기 (0) | 2023.05.09 |
[Springboot] PRG패턴 정리 (0) | 2022.08.26 |
[Springboot] HTTP요청, 응답 처리 방식 정리 (0) | 2022.08.24 |
댓글