제목 그대로 RDB에서 update를 통해 재고감소를 하던 로직을 Redis에서 재고 수량을 관리하여 update 해주는 방식으로 변경하였습니다.
이번 글에서는 재고에 대한 데이터를 Redis에서 관리하는 방식으로 변경하여 성능 개선한 경험을 공유하겠습니다.
애플리케이션에서 재고 수량을 관리할 때, RDB (관계형 데이터베이스)와 Redis (인메모리 데이터베이스)를 사용하는 방법에는 차이점이 있습니다.
데이터 저장소:
동시성 처리:
데이터 저장소:
동시성 처리:
INCRBY
와 DECRBY
명령어는 재고 수량을 원자적으로 증가하거나 감소시킵니다.고속 성능:
높은 동시성 처리:
유연한 데이터 구조:
확장성:
저는 코드 레벨에 Redis의 원자적 명령어 (Atomic Operations)을 적용했습니다.
Redis의 원자적 명령어는 여러 클라이언트가 동시에 데이터를 수정할 때 데이터 일관성을 보장합니다. decrement와 increment 명령어는 Redis의 원자적 계산 명령어 중 일부입니다.
Redis는 다양한 원자적 명령어를 제공하여 동시성 문제를 해결하고 데이터를 안전하게 수정할 수 있도록 합니다. 여기서는 decrement
와 increment
명령어를 중심으로 Redis의 원자적 계산 기능을 소개하겠습니다.
INCR
명령어INCR
명령어는 지정된 키의 값을 원자적으로 증가시킵니다. 만약 키가 존재하지 않으면, 키를 0으로 초기화하고 1을 더합니다.
INCR key
DECR
명령어DECR
명령어는 지정된 키의 값을 원자적으로 감소시킵니다. 만약 키가 존재하지 않으면, 키를 0으로 초기화하고 1을 뺍니다.
DECR key
INCRBY
명령어INCRBY
명령어는 지정된 키의 값을 주어진 만큼 원자적으로 증가시킵니다.
INCRBY key increment
DECRBY
명령어DECRBY
명령어는 지정된 키의 값을 주어진 만큼 원자적으로 감소시킵니다.
DECRBY key decrement
코드는 GitHub에서 확인할 수 있습니다.
https://github.com/f-lab-edu/joy-mall
JoyMall
은 쿠버네티스 기반 컨테이너 환경에서 4개의 팟으로 구성되어 있습니다.
이런 Redis의 원자적 명령어를 사용하고 분산락 코드를 제거하여 락 획득으로 인한 병목 현상을 해결할 수 있었습니다.
이렇게 원자적 명령어를 통해 해결할 수 있다는 점을 알게되었지만 고민되는 문제가 또 발생했습니다.
이거에 대한 방법을 찾아보니 Spring에서 제공하는 Scheduling 기능을 사용하기로 하였습니다.
하지만 여러 개의 팟으로 구성된 제 환경에서 4개의 스케줄이 동시에 실행되면 동시성 문제가 또 발생할 수 있다고 판단하였습니다.
그래서 분산 환경에서 리더 선출 메커니즘 구현을 통해 해결하였습니다.
@Component
@RequiredArgsConstructor
public class SchedulerLeader {
private final RedisTemplate<String, String> redisTemplate;
private static final String LEADER_KEY = "scheduler:leader";
private static final long LEADER_TIMEOUT = 60000; // 60 seconds
private String instanceId = UUID.randomUUID().toString();
public boolean isLeader() {
Boolean isLeader = redisTemplate.opsForValue().setIfAbsent(LEADER_KEY, instanceId, LEADER_TIMEOUT, TimeUnit.MILLISECONDS);
if (Boolean.TRUE.equals(isLeader) || instanceId.equals(redisTemplate.opsForValue().get(LEADER_KEY))) {
redisTemplate.expire(LEADER_KEY, LEADER_TIMEOUT, TimeUnit.MILLISECONDS);
return true;
}
return false;
}
}
이 SchedulerLeader
클래스는 분산 환경에서 리더 선출 메커니즘을 구현합니다. UUID를 사용하여 각 인스턴스마다 고유한 Id를 가지도록 합니다.
Redis를 이용한 리더 선출: LEADER_KEY
라는 Redis 키를 사용하여 현재 리더를 표시합니다.setIfAbsent
메소드로 이 키가 없을 때만 값을 설정합니다. 이는 여러 인스턴스 중 하나만 리더가 될 수 있게 합니다.
리더십 유지: 리더로 선출되면 키의 만료 시간을 갱신합니다. (LEADER_TIMEOUT
) 이는 리더 인스턴스가 활성 상태임을 주기적으로 알립니다.
리더 확인: 키 설정에 성공하거나, 현재 키의 값이 자신의 instanceId
와 일치하면 리더로 인정됩니다.
이를 통해 분산 환경에서 스케줄러의 중복 실행을 방지하고, 단일 리더에 의한 일관된 작업 수행을 보장하도록 구성하였습니다.
@Component("salesProductFacadeRedis")
@RequiredArgsConstructor
public class SalesProductFacadeRedis implements SalesProductFacade {
private final RedisTemplate<String, String> redisTemplate;
private static final String STOCK_KEY_PREFIX = "salesProduct_stock:";
private static final String CHANGE_LOG_KEY = "salesProduct_stock_change_log";
@Override
public void decreaseStock(Set<OrderItem> orderItems) {
for (OrderItem orderItem : orderItems) {
String stockKey = STOCK_KEY_PREFIX + orderItem.getSalesProductId();
Long remainStock = redisTemplate.opsForValue().decrement(stockKey, orderItem.getQuantity());
if (remainStock == null || remainStock < 0) {
redisTemplate.opsForValue().increment(stockKey, orderItem.getQuantity());
throw new RuntimeException("판매 수량이 부족합니다.");
}
redisTemplate.opsForSet().add(CHANGE_LOG_KEY, stockKey);
}
}
}
위 코드에선 Redis에 재고 수량을 가져와 주문 개수 만큼 차감해주고 수량이 부족하면 예외를 발생시키고 변경된 판매 상품에 대한 변경을 위해 Redis Sets
을 사용하여 재고가 변경된 상품의 키를 저장합니다.
@Component
@RequiredArgsConstructor
public class SalesProductSyncScheduler {
private final SalesProductRepository salesProductRepository;
private final RedisTemplate<String, String> redisTemplate;
private final SchedulerLeader schedulerLeader;
private static final String CHANGE_LOG_KEY = "salesProduct_stock_change_log";
@Scheduled(fixedRate = 60000)
public void syncStockToDB() {
if (!schedulerLeader.isLeader()) {
return;
}
Set<String> stockKeys = redisTemplate.opsForSet().members(CHANGE_LOG_KEY);
if (stockKeys == null || stockKeys.isEmpty()) return;
for (String stockKey : stockKeys) {
if (stockKey.isEmpty()) {
return;
}
Long salesProductId = Long.valueOf(stockKey.replace("salesProduct_stock:", ""));
String stockInRedis = redisTemplate.opsForValue().get(stockKey);
if (stockInRedis != null) {
int stock = Integer.parseInt(stockInRedis);
SalesProduct salesProduct = salesProductRepository.findById(salesProductId)
.orElseThrow(NoSuchElementException::new);
if (salesProduct != null) {
salesProduct.decreaseStock(salesProduct.getSalesStock() - stock);
salesProductRepository.save(salesProduct);
}
}
}
}
}
이 SalesProductSyncScheduler
클래스는 Redis에 임시 저장된 재고 정보를 주기적으로 RDB(관계형 데이터베이스)와 동기화하는 역할을 합니다.
이 구현을 통해 아래의 문제들을 해결하였습니다.
테스트 조건은 Vuser 약 300명, 10분 동안 진행하였습니다.
이전 테스트에선
평균 TPS: 118
최고 TPS: 171
평균 테스트 시간 2,476 ms
의 결과가 나왔는데
Redis에서 재고 수량 관리 적용 후 테스트는
평균 TPS: 207
최고 TPS: 322
평균 테스트 시간 1,422 ms
으로 2배 가까이 성능이 개선된걸 확인할 수 있습니다.
오류를 보니 테스트 막바지에 400건 정도 발생하였습니다.
이전 테스트의 결과는
응답시간이 평균 2.36초, 최대 22.64초 였습니다.
Redis에서 재고 수량 관리 적용 후 테스트는
응답시간이 평균 1.15초, 최대 60.06초 인것을 확인할 수 있습니다.
성능이 확실히 개선되었습니다!!
에러가 난 부분들을 확인해보겠습니다.
아마 부하때문에 생긴 에러 같습니다.
Heap 메모리 사용량은 매우 안정적이고 CPU 사용량도 절반 정도 안정화 되었습니다.
아무래도 스케줄링을 통해 1개의 애플리케이션에서 계속 RDB 동기화를 진행시켜줘서 그런거 같습니다.
이렇게 Redis에서 재고 수량을 관리하고 RDB에 동기화까지 하는 코드를 적용해보고 부하 테스트도 진행해보았습니다. MSA 아키텍쳐를 적용해서 다른 서버에서는 RDB에 동기화만 하는 작업을 해주었으면 훨씬 성능이 좋았을거 같은데 아쉽습니다.