티스토리 뷰
[시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (3/3) - 레디스 분산 락(Lock)으로 해결하기
nooblette 2024. 8. 17. 18:55💡 최상용님의 재고시스템으로 알아보는 동시성이슈 해결방법 강의를 듣고 정리한 내용입니다.
목차
글 목록
- [시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (1/3) - 동시성 이슈와 Application Level로 해결하기
- [시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (2/3) - 데이터베이스 락(Lock)으로 해결하기
- [시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (3/3) - 레디스 분산 락(Lock)으로 해결하기
배경
지난 [시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (2/3) - 데이터베이스 락(Lock)으로 해결하기에 이어 이번에는 레디스(Redis)를 활용하여 동시성 문제를 해결해본다. 동시성 문제를 해결하기 위해서 레디스에서는 대표적으로 Lettuce와 Redisson을 활용한다. (레디스에 대한 설명은 이번 글에서는 생략한다.)
Lettuce
레디스의 자바 클라이언트이다. Lettuce로 분산 락(Distributed lock)을 구현하는 경우 setnx 명령어를 활용할 수 있다. 이때 Spin lock 방식으로 구현하는데, 주의할 점으로 retry 로직을 개발자가 직접 작성해주어야한다.
- setnx : set if not exists의 줄임말
- key와 value를 기준으로 기존의 값이 없는 경우 set을 한다.
Spin Lock
스레드가 Lock을 획득하기 위해 반복적으로 시도하는 기법이다. Spin Lock으로 락을 획득하는 과정을 그림으로 나타내면 다음과 같다.
먼저 Thread-1이 setnx 명령어로 key = 1, value = lock으로 레디스에 set을 시도한다. 현재 레디스에는 key가 1인 값이 없으므로 정상적으로 적재하고 성공을 반환한다.
이후 Thread-2가 동일한 key와 value로 set을 시도한다. 하지만 이때는 이미 {1: lock}으로 값이 포함되어 있으므로 Thread-2의 set은 실패한다. Thread-2는 Lock 획득에 실패하였으므로 일정시간(e.g. 100ms) 이후 Lock 획득을 재시도 한다.
Redisson
Lettuce와 같은 자바 레디스 클라이언트이다. Redisson을 사용하는 경우 pub-sub 기반으로 Lock을 구현한다.
pub-sub
Lock을 관리하기 위한 채널을 생성한다. Lock을 점유중인 쓰레드가 작업이 끝나면 Lock을 획득하려고 대기중인 쓰레드에게 해제를 알려준다. 이후 안내를 받은 쓰레드가 Lock을 획득하는 시도를 한다. Lettuce와 달리 별도의 Retry 로직을 작성하지 않아도 된다.
Thread-1이 Lock을 점유하고 있고 Thread-2가 Lock 획득을 위해 대기하고 있다고 가정한다. Thread-1이 작업이 끝나면 Channel로 작업이 끝났다는 메시지를 보낸다. Channel은 대기중이던 Thread-2에게 Lock 획득을 시도하라고 메시지를 보낸다. Thread-2는 Channel로부터 메시지를 받고 Lock 획득을 시도한다.
레디스로 분산 락을 구현하는 대표적인 2가지 방법은 이와 같다. 이제 본격적으로 레디스로 락을 구현해보자
Lettuce를 활용하여 재고감소 로직 작성하기
Lettuce를 활용하여 재고감소 로직을 작성해본다. Lettuce를 활용하여 Lock을 관리하는 방식은 앞서 [시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (2/3) - 데이터베이스 락(Lock)으로 해결하기에서 소개한 Mysql의 Named Lock과 거의 유사하다.
하지만 Mysql이 아닌 Redis를 활용하므로 별도의 데이터베이스 세션을 관리해줄 필요는 없다는 점이 있다.
build.gradle
스프링 부트 프로젝트에서 레디스를 사용하기 위해 spring-boot-starter-data-redis 의존성을 추가해준다.
Spring Data Redis는 Lettuce를 기본으로 제공하여 별도의 라이브러리를 사용하지 않아도 된다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
...
}
gradle을 reload하고 나면 좌측 External Libraries 목록에서spring-boot-starter-data-redis 라이브러리가 추가된 것을 볼 수 있다.
RedisLockRepository
레디스에 접근하여 데이터에 CRUD하기 위한 클래스. Lock 획득과 해제 메서드를 구현한다.
redisTemplate의 setIfAbsent 메서드를 호출하여 레디스의 setnx 명령어를 호출한다. 이때 타임아웃은 3000ms로 지정하였다.
package com.example.stock.repository;
import java.time.Duration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Component
public class RedisLockRepository {
// 레디스를 사용하기 위해 RedisTemplate 클래스를 변수로 추가한다.
private RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
// Lock 메서드를 구현한다.
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
// setnx 명령어 호출
.setIfAbsent(
generateKey(key), // key
"lock", // value
Duration.ofMillis(3_000) // lock timeout 지정(e.g. 3000ms(3sec))
);
}
// Lock을 해제하기 위한 메서드
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
LettuceLockStockFacade
비즈니스 로직 수행 전후로 레디스에 Lock을 추가, 해제하기 위한 클래스이다. Lock 획득에 실패했다면 다시 레디스에 락을 획득할 수 있는지 재시도한다.(Spin Lock 방식) 이때 100ms 텀을 두어 레디스에 부하를 줄인다.
package com.example.stock.facade;
import org.springframework.stereotype.Component;
import com.example.stock.repository.RedisLockRepository;
import com.example.stock.service.StockService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class LettuceLockStockFacade {
private final RedisLockRepository repository;
private final StockService stockService;
public LettuceLockStockFacade(RedisLockRepository repository, StockService stockService) {
this.repository = repository;
this.stockService = stockService;
}
public void decrease(Long id, Long quantity) throws InterruptedException {
// Lock 획득
while(!repository.lock(id)) {
log.info("lock 획득 실패");
Thread.sleep(100);
}
// Lock 획득에 성공했다면 재고 감소 로직 실행
log.info("lock 획득");
try {
stockService.decrease(id, quantity);
} finally {
// 로직이 모두 수행되었다면 Lock 해제
repository.unlock(id);
log.info("lock 해제");
}
}
}
LettuceLockStockFacadeTest
LettuceLockStockFacade 클래스의 decrease() 메서드를 호출하여 성공적으로 동시성 이슈를 해결하는지 테스트한다.
의존관계만 LettuceLockStockFacade로 변경하여 기존의 테스트 코드를 재활용해도 된다.
package com.example.stock.facade;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
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 com.example.stock.domain.Stock;
import com.example.stock.repository.StockRepository;
@SpringBootTest
class LettuceLockStockFacadeTest {
@Autowired
private LettuceLockStockFacade lettuceLockStockFacade;
@Autowired
private StockRepository stockRepository;
// 각 테스트가 실행되기 전에 데이터베이스에 테스트 데이터 생성
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
// 각 테스트를 실행한 후에 데이터베이스에 테스트 데이터 삭제
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
// when
// 100개의 쓰레드 사용(멀티스레드)
int threadCount = 100;
// ExecutorService : 비동기로 실행하는 작업을 간단하게 실행할 수 있도록 자바에서 제공하는 API
ExecutorService executorService = Executors.newFixedThreadPool(32);
// CountDownLatch : 작업을 진행중인 다른 스레드가 작업을 완료할때까지 대기할 수 있도록 도와주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
// 100개의 작업 요청
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).orElseThrow();
assertEquals(0, stock.getQuantity());
}
}
테스트가 성공하는 것을 볼 수 있다.
실행 로그를 확인하여 동작 방식을 추적해보자.
작업 스레드 중 하나인 pool-3-thread-10 스레드가 lock 획득에 성공한다. 이후 모든 스레드는 pool-3-thread-10이 Lock을 반납할기 전까지 획득에 실패하는 것을 볼 수 있다.
pool-3-thread-10 스레드가 비즈니스 로직(재고 차감 로직)을 모두 수행하고나서 Lock을 해제하면 다른 스레드(pool-3-thread-14)가 Lock을 획득한다. 이후 모든 스레드는 pool-3-thread-14가 Lock을 반납할기 전까지 획득에 실패하고 다시 대기하는 것을 볼 수 있다.
Lettuce를 활용한 방식의 장점으로는 구현이 간단하고, 쉽게 Lock에 타임아웃을 걸 수 있다는 점이 있다.
하지만 Spin lock 방식이므로 스레드가 Lock을 획득하기 위해 계속 레디스에 요청을 보내면서 레디스 장비에 부하를 줄 수 있다. 따라서 예시와 같이 Thread.sleep()을 통해 Lock을 시도하는 중간에 텀을 두어야한다.
Redisson를 활용하여 재고감소 로직 작성하기
이번에는 Redisson를 활용하여 pub-sub 기반으로 Lock을 구현하는데, 그 동작 원리는 다음과 같다.
1. 한 스레드가 자신이 점유하고 있던 Lock을 해제할 때 위와 같이 Channel에 작업이 끝났다는 메시지를 보낸다.
2. 이를 수신받은 Channel은 락을 획득하려하는 스레드에게 Lock을 획득하라고 알려준다.
3. Lock을 획득하려하는 스레드들은 메시지를 받으면 Lock 획득을 시도한다.
Redisson은 Spin Lock을 사용하여 계속 Lock 획득을 시도하던 Lettuce와 달리 Lock 해제가 되었을 때 한번(혹은 n번만) Lock 획득을 시도하기 때문에 레디스에 부하를 줄일 수 있다.
build.gradle
Redisson을 스프링부트 프로젝트에 사용하기 위해 redisson-spring-boot-starter 라이브러리 의존성을 추가한다.
Lettuce를 기본으로 제공하는 Spring Data Redis와 달리 별도의 라이브러리를 사용해야한다. 따라서 Lock을 라이브러리 차원에서 제공하고 있어 별도의 사용법을 익혀야 한다. (러닝커브가 있다.)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.34.1'
...
}
RedissonLockStockFacade
Redisson을 활용하여 Lock을 획득하고 해제하는 클래스를 작성한다.
Redisson은 동시성 제어를 위한 Lock 관련 클래스를 라이브러리 차원에서 제공하고 있으므로 개발자가 별도로 Repository를 작성할 필요는 없다. 하지만 비즈니스 로직 수행 전후로 Lock을 획득하고 해제하는 로직은 작성해야하므로 Facade 클래스를 작성해준다.
package com.example.stock.facade;
import java.util.concurrent.TimeUnit;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import com.example.stock.service.StockService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class RedissonLockStockFacade {
// Lock 획득/해제를 위한 RedissonClient 추가
private final 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) {
// RedissonClient을 활용하여 Lock 객체 조회
RLock lock = redissonClient.getLock(id.toString());
try {
// Lock 획득 시도
boolean available = lock.tryLock(
10, // lock 획득을 위한 대기 시간
5, // lock 점유 시간
TimeUnit.SECONDS
);
// Lock 획득에 실패한 경우
if(!available) {
log.info("lock 획득 실패");
return;
}
// Lock 획득에 성공한 경우 - 비즈니스 로직 수행
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 로직이 모두 정상적으로 수행되었다면 Lock을 해제한다.
lock.unlock();
}
}
}
RedissonLockStockFacadeTest
RedissonLockStockFacade의 decrease() 메서드를 테스트한다. 전체적인 로직은 LettuceLockStockFacadeTest와 유사하다.
package com.example.stock.facade;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
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 com.example.stock.domain.Stock;
import com.example.stock.repository.StockRepository;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@SpringBootTest
class RedissonLockStockFacadeTest {
@Autowired
private RedissonLockStockFacade redissonLockStockFacade;
@Autowired
private StockRepository stockRepository;
// 각 테스트가 실행되기 전에 데이터베이스에 테스트 데이터 생성
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 32L));
}
// 각 테스트를 실행한 후에 데이터베이스에 테스트 데이터 삭제
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
// when
// 100개의 쓰레드 사용(멀티스레드)
int threadCount = 100;
// ExecutorService : 비동기로 실행하는 작업을 간단하게 실행할 수 있도록 자바에서 제공하는 API
ExecutorService executorService = Executors.newFixedThreadPool(100);
// CountDownLatch : 작업을 진행중인 다른 스레드가 작업을 완료할때까지 대기할 수 있도록 도와주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
// 100개의 작업 요청
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).orElseThrow();
assertEquals(0, stock.getQuantity());
}
}
테스트가 성공하는 것을 볼 수 있다.
Lock을 획득한 스레드(pool-3-thread-24) 비즈니스 로직을 수행하고나서 해제하고나면 다른 쓰레드(pool-3-thread-30)가 다시 Lock을 획득하여 로직을 수행하는 것을 볼 수 있다.
Spin lock 방식의 Lettuce와 달리 pub/sub 기반의 구현으로 레디스에 부하를 줄일 수 있다.
하지만 구현 로직이 복잡하고 별도의 외부 라이브러리를 사용하므로 그 사용법을 익혀야한다는 부담이 있다.
정리
정리해보면 Redis로 분산 락을 구현하는 방법으로는 Lettuce를 사용하는 방식과 Redisson을 사용하는 방식이 있다.
Lettuce는 구현이 간단하고 Spring Data Redis가 기본적으로 Lettuce를 제공하고 있어 별도의 라이브러리를 사용하지 않아도 된다는 장점이 있다. 하지만 Spin lock 방식으로 Lock을 획득하고, 획득하기 위해 대기하므로 Redis에 부하를 줄 수 있다.
반면 Redisson을 pub/sub 모델로 Lettuce에 비해 Redis에 부하를 덜 준다. 또한 Lock 획득 재시도를 라이브러리 차원에서 제공하고 있어 개발자가 별도로 작성해줄 필요가 없다. 하지만 별도의 라이브러리를 사용하고 이 라이브러리의 사용법을 따로 익혀야한다는 점에서 러닝커브가 있다.
실무에서는 재시도가 필요하지 않다면 Lettuce를, 별도의 재시도 로직이 필요하다면 Redisson을 활용할 수 있다.
'시스템 디자인' 카테고리의 다른 글
[시스템 디자인] 실습으로 배우는 선착순 이벤트 시스템 (3/3) - 요구사항 변경과 쿠폰 발급 실패 예외처리 (0) | 2024.09.06 |
---|---|
[시스템 디자인] 실습으로 배우는 선착순 이벤트 시스템 (2/3) - Kafka로 시스템 안정성 향상하기 (4) | 2024.09.05 |
[시스템 디자인] 실습으로 배우는 선착순 이벤트 시스템 (1/3) - 동시성 이슈와 Redis로 해결하기 (5) | 2024.09.01 |
[시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (2/3) - 데이터베이스 락(Lock)으로 해결하기 (0) | 2024.08.17 |
[시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (1/3) - 동시성 이슈와 Application Level로 해결하기 (0) | 2024.08.16 |