서론
상품의 주문 처리 속도는 고객 만족도에 직접적인 영향을 미친다.
데이터베이스로 재고를 관리하는 방법은 데이터의 일관성을 유지하는 데는 효과적이지만, 높은 트래픽이 발생하면 데이터베이스에 과부하가 걸려 사용자 경험에 부정적인 영향을 미친다.
이 문제를 해결하기 위해, 데이터베이스 대신 Redis 캐시를 사용하여 상품 재고를 관리하는 방식으로 시스템을 개선하기로 결정하였다. Redis는 고성능 키-값 저장소로, 빠른 읽기 및 쓰기 속도를 제공한다.
성능 개선 과정은 크게 세 단계로 나누어 진행했다.
- Write-Through 쓰기 전략: Redis와 데이터베이스 모두 데이터를 쓰는 방식
- Write-Back 쓰기 전략: Redis에만 데이터를 쓰고 특정 조건에서 데이터베이스에 데이터를 업데이트하는 방식
- Redis의 Lua 스크립트 기능 활용: 한 번의 트랜잭션 내에서 여러 작업을 처리하는 방식
소개
테스트 환경
- 테스트 툴: JMeter
- Test Plan
- User Defined Variables: 사용자 ID 사용자 정의 변수 설정
- jp@gc - Transactions per Seconds: 초당 요청 수에 대한 리포트 생성
- Thread Group: 가상 사용자의 수, 테스트 지속 시간 등 설정
- HTTP Request: REST API 호출
- Summary Report: 테스트 결과 요약 리스너
- View Results Trees: 테스트 실행 결과를 상세히 보여주는 리스너
- JSR223 PreProcessor: 테스트 실행 전 스크립트 실행 프로세서
- HTTP Header Manager: HTTP 요청에 사용될 헤더 정보 관리
- HTTP Request: REST API 호출
User Defined Variables
- 상품 주문 요청에 사용될 USER_ID 변수 생성
Thread Group
- Number of Threads (users): 1000
- Ramp-up period (seconds): 1
- Loop count: 1
HTTP Request
JSR223 PreProcessor
- int userId = Integer.parseInt(vars.get("USER_ID")) + (ctx.getThreadNum());
- USER_ID 값에 현재 쓰레드 번호를 더해 새로운 userId 값 계산
- vars.put("USER_ID", String.valueOf(userId));
- 계산된 userId 값을 문자열로 변환 후, vars 객체를 통해 USER_ID라는 이름으로 지정
HTTP Header Manager
테스트 시나리오
결제 프로세스
- 상품 화면 - 상태(초기 상태)
- 결제 화면 진입 - 행위
- 결제 화면 - 상태
- 결제 시도 - 행위
- 결제 중 - 상태
- 결제 완료 - 상태
※ 결제 실패 상태는 다루지 않는다.
- [테스트 시나리오] 결제 프로세스 중 고객 이탈율
- [결제 시도] 행위 중 고객 이탈율: 20%
- 결제 화면까지 왔지만, '결제'를 하지 않음.
- [결제 중 → 결제 완료] 이탈율: 20%
- '결제' 이후 PG 사의 처리를 기다리는 중 이었지만, 고객의 상황으로 인해 최종 결제 되지 않음. ex. 카드한도 초과
- 단, 고객사유가 아닌 결제 실패의 경우(ex. 카드사 장애)는 없는 것으로 가정한다.
- [결제 시도] 행위 중 고객 이탈율: 20%
중요도
- [최종 결제 수 > 전체 상품 수] 발생으로 인한 결제 취소
- 상품 수보다 많은 결제는 절대 이루어지지 않도록 한다.
- 상품페이지 재고 수가 정확하지 않음
- 재고가 있음에도 결제 화면 도입 불가
- 재고가 없다 하여 포기했는데 나보다 늦게 시도한 사람이 오히려 성공
- 같은 시각 상품페이지에 진입한 두 사람의 화면에 표기된 재고 수량이 다름
- 결제 화면 도입 이후 결제 시도 불가
- 서비스 불가
- 트래픽 몰림으로 인해 서버 다운
- 예약 구매 상품과 상관 없는 고객들이 불편을 겪음
- 오픈 시간 이전 구매 가능(어뷰징)
write-through 방식
Write-Through 쓰기 전략은 캐시 시스템에서 데이터의 일관성을 유지하기 위해 사용되는 방법이다.
핵심은 캐시(Cache)와 백엔드 저장소(예: 데이터베이스)가 항상 동기화되도록 하는 것이다.
작동 방식
- 데이터를 업데이트 시, 먼저 캐시에 해당 데이터를 쓴다.
- 캐시에 데이터를 성공적으로 쓴 후, 동일한 데이터를 백엔드 저장소(데이터베이스)에도 쓴다.
- 읽기 요청이 발생하면 빠른 응답을 위해 캐시에서 읽는다.
장점
- 데이터 일관성: 캐시와 데이터베이스 간의 데이터 일관성을 유지할 수 있다.
- 읽기 성능 개선: 자주 접근하는 데이터는 캐시에 저장되어 있기 때문에, 데이터베이스에 직접 접근하는 것보다 빠르게 데이터를 읽을 수 있다.
단점
- 쓰기 지연: 모든 쓰기 연산이 두 저장소에 모두 적용되어야 하므로, 쓰기 연산의 성능이 느려질 수 있다. 특히, 대량의 쓰기 연산이 발생하는 환경에서는 성능 저하의 원인이 될 수 있다.
- 복잡한 구현: 캐시와 데이터베이스 간의 동기화를 관리해야 하므로, 시스템의 복잡도가 증가한다.
구현
// Redisson Lock
private <T> T executeWithLock(Long goodsId, LockCallback<T> callback) {
RLock lock = redissonClient.getLock(GOODS_LOCK_PREFIX + goodsId);
try {
if (!lock.tryLock(5, 1, TimeUnit.SECONDS)) {
log.error("상품 재고 관리 LOCK 획득 실패");
throw new CustomApiException("요청이 많아 처리에 실패하였습니다.");
}
log.debug("락 획득 성공");
return callback.doInLock();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("처리 중 에러가 발생했습니다.");
throw new CustomApiException("상품 재고 처리 중 에러가 발생했습니다.");
} finally {
lock.unlock();
log.debug("락 반납");
}
}
public void processOrderForGoods(Long userId, OrderGoodsReqDto reqDto) {
executeWithLock(reqDto.getGoodsId(), () -> {
goodsService.processOrderForGoods(userId, reqDto);
return null;
});
}
@Override
@Transactional
public void processOrderForGoods(Long userId, OrderGoodsReqDto reqDto) {
Long goodsId = reqDto.getGoodsId();
Integer quantity = reqDto.getQuantity();
Goods goods = validatedGoods(goodsId, quantity);
reduceStocks(goods, quantity);
applicationEventPublisher.publishEvent(new GoodsToOrderEvent(this, userId, reqDto, goods.getGoodsPrice()));
}
public void decreaseGoodsStock(Long goodsId, int quantity) {
valueOperations.increment(GOODS_STOCK_KEY_NAMESPACE + goodsId, -quantity);
}
- 동시성 처리를 위해 Redisson 라이브러리를 사용해 락(Lock)을 걸어 메서드를 호출하였다.
- Redis 캐시와 데이터베이스 모두 상품 재고 값을 갱신하였다.
- 트랜잭션 커밋이 완료되면 카프카 이벤트를 호출하기 위해 TransactionalEventListener를 활용해 이벤트를 발행하였다.
테스트
테스트 결과
- TPS: 164.5/sec
상품 재고 정확도 검증
- 테스트 수행전 재고: 1,000
- 테스트 후
- 데이터베이스: 372
- 캐시: 372
- 생성된 상품, 결제 수: 1,000
- 완료된 상품, 결제 수: 628
- 요청 횟수(1,000) - 완료된 횟수(628) = 남은 상품 재고 수(372)
select count(*) from order_details;
select count(*) from payments;
select count(*) from order_details od where od.order_status='COMPLETED';
select count(*) from payments p where p.payment_status='PAYMENT_COMPLETED';
분석
Write-Through 쓰기 전략 방식을 사용하여 데이터베이스와 캐시 간에 데이터 일관성을 유지할 수 있었다.
하지만 주문 요청을 처리할 때마다 데이터베이스와 캐시 모두 상품 재고를 업데이트해야 하므로 쓰기 지연이 발생하였다.
상품 재고를 캐시에 저장함으로써 읽기의 경우, 성능 개선이 되겠지만 이것은 내가 원하는 바가 아니다. 현재 목표는 상품 주문 처리 속도를 향상시키는 것이다. 따라서 Write-Through 쓰기 전략에 비해 데이터베이스와 캐시 간에 데이터 일관성 유지가 어렵지만 쓰기 지연이 발생하지 않는 Write-Back 쓰기 전략 방식을 적용해 성능을 개선해 보았다.
Write-Back 방식
Write-Back 쓰기 전략은 캐싱 전략 중 하나로, 대량의 데이터 쓰기 작업이 발생하는 시스템에서 효율적인 처리를 가능하게 한다. 핵심은 데이터를 처음에 Redis와 같은 캐시 시스템에만 쓰고, 실제 데이터베이스에는 나중에 일괄적으로 업데이트하는 방식이다. 즉각적인 데이터 쓰기 요구에 대해 빠르게 응답할 수 있게 해주며, 데이터베이스에 가해지는 부하를 줄여 전체 시스템의 성능을 향상시킨다.
작동 방식
- 초기 데이터 쓰기: 데이터 쓰기 요청 시, 먼저 Redis와 같은 캐시에 저장된다. 이때 사용자에게는 데이터 쓰기가 완료된 것처럼 응답이 이루어진다.
- 배치 처리: 캐시에 저장된 데이터는 설정된 조건(예: 시간 간격, 데이터의 양)에 따라 정해진 시점에 데이터베이스로 일괄적으로 업데이트된다. 이 과정은 백그라운드에서 비동기적으로 수행되므로 사용자 작업의 대기 시간에 영향을 주지 않는다.
장점
- 성능 향상: 데이터베이스에 대한 쓰기 요청이 직접적으로 발생하지 않으므로, 쓰기 연산으로 인한 데이터베이스의 부하가 줄어들고, 전체적인 응답 속도가 향상된다.
- 스케일 아웃 용이: 캐시 시스템을 활용함으로써, 데이터베이스 시스템의 스케일 아웃(수평 확장)이 용이해진다. 대량의 데이터 쓰기 요구에도 캐시를 통해 부하를 분산시킬 수 있다.
단점
- 데이터 일관성: 캐시와 데이터베이스 간의 실시간 동기화가 이루어지지 않으므로, 일정 시간 동안 데이터 일관성에 차이가 발생할 수 있다.
- 복잡성 증가: 캐시 관리, 데이터 동기화 로직 구현 등 Write-Back 쓰기 전략을 구현하고 유지보수하기 위해서는 추가적인 개발 필요하다.
구현
private void reduceStocks(Goods goods, int quantity) {
try{
// write-back: redis에서만 상품 재고 관리
// goods.decreaseStock(quantity);
redisStockService.decreaseGoodsStock(goods.getId(), quantity);
} catch (Exception e) {
log.error("레디스 상품 재고 감소 중 오류가 발생했습니다. goodsId={}, quantity={}", goods.getId(), quantity, e);
throw new CustomApiException("레디스 상품 재고 증가 처리 중 예외 발생");
}
}
- goods.decreaseStock(quantity); 라인을 주석 처리함으로써 캐시에만 상품 재고가 관리되도록 하였다.
@Scheduled(fixedRate = 1800000) // 30분마다 수행
public void updateStocksInDatabase() {
log.info("레디스 상품 재고 정보를 데이터베이스로 동기화 시작");
try {
Map<Long, Integer> allStocks = redisStockService.getAllStocks();
goodsInternalService.updateStocksInDatabase(allStocks);
log.info("모든 상품 재고가 데이터베이스에 성공적으로 업데이트되었습니다.");
} catch (Exception e) {
log.error("데이터베이스 업데이트 중 오류 발생", e);
}
log.info("레디스 상품 재고 정보를 데이터베이스로 동기화 완료");
}
- 스케줄러를 도입하여 30분마다 캐시된 상품 재고를 데이터베이스에 업데이트한다.
※ 캐시에서 상품 재고 조회 후, 상품 재고 감소 과정이 각각 이루어지므로 Redisson 라이브러리를 통한 락(Lock)을 그대로 유지하였다.
테스트
테스트 결과
- TPS: 209.7/sec
상품 재고 정확도 검증
- 테스트 수행전 재고: 1,000
- 테스트 후
- 데이터베이스: 1,000
- 캐시: 365
- 생성된 상품, 결제 수: 1,000
- 완료된 상품, 결제 수: 635
- 요청 횟수(1,000) - 완료된 횟수(635) = 남은 상품 재고 수(365)
select count(*) from order_details;
select count(*) from payments;
select count(*) from order_details od where od.order_status='COMPLETED';
select count(*) from payments p where p.payment_status='PAYMENT_COMPLETED';
- 스케줄러도 정상적으로 동작한다!!
분석
성능 비교
- Write-Through: 164.5/sec -> Write-Back: 209.7/sec (약 27.48% 성능 개선)
Write-Back 쓰기 전략을 사용함으로써, 쓰기 성능이 향상되었다.
그러나 내가 생각했던 만큼 엄청난 성능 향상을 이루지는 못하였다. 이러한 이유는 여전히 락(Lock)을 걸어 메서드를 호출하기 때문이다. 1,000명의 사용자가 동시에 서버에 요청을 하면 각 요청에 대한 처리에 대해 락(Lock) 획득을 위한 대기 시간이 발생한다. 이로인해 내가 원하는 만큼의 성능 향상이 이루어지지 않고 있다.
락을 걸지 않으면서 동시성을 유지하기 위해서는 현재 사용하고 있는 레디스 캐시의 원자성(Atomicity)을 잘 사용해야 한다. 레디스의 원자성을 이용해 레디스에 대한 여러 명령을 하나의 레디스 트랜잭션 단위로 묶어 실행한다면 락(Lock)을 걸지 않더라도 동시성을 유지할 수 있을 것이다. 이를 위해 Lua Script를 레디스에 적용해 성능 개선을 하였다.
Lua Script 적용
Redis의 Lua Script 기능
Lua Script를 활용하면, 단일 트랜잭션 내에서 여러 데이터 조작 작업을 순차적이고 원자적으로 처리할 수 있다. 복잡한 로직을 서버 측에서 처리할 수 있게 하며, 애플리케이션의 성능과 안정성을 향상시킬 수 있다.
원자성과 효율성
Redis의 Lua Script는 원자성을 보장한다. 스크립트에 포함된 모든 명령은 중단 없이 전체가 실행되거나, 아니면 실행되지 않는다. 또한, 네트워크 지연을 최소화하기 위해 여러 데이터베이스 호출을 한 번의 스크립트 실행으로 줄일 수 있어, 애플리케이션의 전체적인 효율성을 높일 수 있다.
복잡한 로직의 서버 측 처리
Lua Script를 사용하면, 클라이언트 측에서 여러 번의 호출을 수행하는 대신, 서버 측에서 복잡한 로직을 한 번에 처리할 수 있다. 예를 들어, 특정 조건에 따라 다른 데이터 처리 작업을 수행하거나, 여러 키의 값을 동시에 업데이트하는 등의 작업을 스크립트를 통해 구현할 수 있다.
구현
@Override
public void processOrderForGoods(Long userId, int goodsPrice, OrderGoodsReqDto reqDto) {
Long goodsId = reqDto.getGoodsId();
Integer quantity = reqDto.getQuantity();
decreaseStockSafely(goodsId, quantity);
sendOrderRequestToKafka(userId, goodsId, quantity, goodsPrice, reqDto.getAddress());
}
public void decreaseGoodsStockWithLua(Long goodsId, int quantity) {
String luaScript =
"local stock = redis.call('get', KEYS[1])\n" +
"if stock == false then\n" +
" return -2\n" +
"elseif tonumber(stock) >= tonumber(ARGV[1]) then\n" +
" local newStock = tonumber(stock) - tonumber(ARGV[1])\n" +
" redis.call('decrby', KEYS[1], ARGV[1])\n" +
" redis.call('sadd', 'dirty_goods', KEYS[1])\n" + // 'dirty' 목록에 추가
" return newStock\n" +
"else\n" +
" return -1\n" +
"end";
// 스크립트 실행
Long result = redisTemplate.execute(
new DefaultRedisScript<Long>(luaScript, Long.class),
Collections.singletonList("goods_stock:" + goodsId),
quantity);
if (result == -1) {
log.info("상품 재고가 부족합니다. goodsId={}, requestedQuantity={}", goodsId, quantity);
throw new StockUnavailableException();
} else if (result == -2) {
log.info("상품 재고 정보가 존재하지 않습니다. goodsId={}", goodsId);
throw new StockNotFoundException();
}
log.info("상품 재고 감소 성공. goodsId={}, remainingStock={}", goodsId, result);
}
public void increaseGoodsStockWithLua(Long goodsId, int quantity) {
String luaScript =
"local stock = redis.call('get', KEYS[1])\n" +
"if stock == false then\n" +
" return nil\n" +
"else\n" +
" local newStock = redis.call('incrby', KEYS[1], ARGV[1])\n" +
" redis.call('sadd', 'dirty_goods', KEYS[1])\n" + // 'dirty' 목록에 추가
" return newStock\n" +
"end";
// 스크립트 실행
Long result = redisTemplate.execute(
new DefaultRedisScript<Long>(luaScript, Long.class),
Collections.singletonList("goods_stock:" + goodsId),
quantity);
if (result == null) {
log.info("상품 재고 정보가 존재하지 않습니다. goodsId={}", goodsId);
throw new StockNotFoundException();
}
log.info("상품 재고 증가 성공. goodsId={}, newStock={}", goodsId, result);
}
- Lua Script를 통해 락(Lock)을 해제하고, Write-Back 쓰기 전략을 적용해 TransactionalEventListener도 해제하였다.
테스트
테스트 결과
- TPS: 431.2/sec
- 테스트 수행전 재고: 1,000
- 테스트 후
- 데이터베이스: 1,000
- 캐시: 342
- 생성된 상품, 결제 수: 1,000
- 완료된 상품, 결제 수: 658
- 요청 횟수(1,000) - 완료된 횟수(658) = 남은 상품 재고 수(342)
select count(*) from order_details;
select count(*) from payments;
select count(*) from order_details od where od.order_status='COMPLETED';
select count(*) from payments p where p.payment_status='PAYMENT_COMPLETED';
분석
Lua 스크립트를 통해 락을 해제하고, Write-Back 쓰기 전략을 적용해 TransactionalEventListener도 해제한 결과, TPS가 431.2/sec로 나타났다. 이는 Lua 스크립트를 활용하여 데이터 처리 과정에서 발생할 수 있는 락 경합을 최소화하고, 필요한 시점에만 데이터베이스에 쓰기를 수행함으로써 성능을 획기적으로 개선할 수 있었음을 의미한다.
특히, 테스트 후 데이터베이스의 재고가 초기값인 1,000을 유지하면서 캐시의 재고가 342로 남아있는 것으로 보아, 실제로 처리되지 않은 작업들은 캐시에 임시 저장되어 있으며, 이를 통해 데이터베이스에 대한 부하를 줄이고 성능을 개선할 수 있었다.
Lua 스크립트를 활용해 Write-Through 쓰기 전략에 비해 성능 면에서 큰 이점을 보이며, Write-Back 쓰기 전략의 데이터 일관성 문제를 해결할 수 있었다.
결과
성능 비교
쓰기 전략 | TPS | 성능 개선 비율 |
Write-Through | 164.5 | 기준 |
Write-Back | 209.7 | 27.5% |
Lua 스크립트 적용 | 431.2 | Write-Through 대비 162.2%, Write-Back 대비 105.6% |
'기술 블로그 > MiriMiri' 카테고리의 다른 글
비동기 통신을 통한 마이페이지 성능 개선 (0) | 2024.05.20 |
---|---|
MSA 환경에서 Kafka 이벤트 기반 주문 처리와 트랜잭션 관리 (0) | 2024.05.14 |
Redis 복제(Replication)를 사용한 상품 재고 관리 (0) | 2024.05.14 |
Kafka에서 동일한 토픽을 여러 서비스에서 소비하는 문제 해결하기 (0) | 2024.05.14 |
Redis를 사용한 MSA 환경에서 상품 재고 관리 (0) | 2024.05.05 |