글 작성자: Key Ryung

개요

지금까지 데이터의 무결성을 위해 해결을 시도한 영역은 백엔드를 구성하는 부분 중 Database였습니다. 데이터 베이스에서 Transaction들을 Serial하게 수행하여 무결성을 확보하고 성능과 Trade-Off 했습니다. 하지만 E-Commerce 분야에서 하나의 상품을 많은 사람들이 구매하는 상황은 빈번하게 발생한다고 생각했었을 때 성능 또한 향상시킬 필요성이 있습니다. 왜냐하면 구매를 하기 위해 오랜 시간 대기하였지만 실패되는 경험은 유저에게 서비스의 신뢰도를 떨어뜨린다고 판단했습니다.

 

 

그렇다면 어떻게 재고 데이터의 무결성을 보장하면서 성능 또한 올릴 수 있을지 방안을 고안해보았습니다.

 

구매 주문 과정 분해하기

현재는 '재고'라는 상태를 모두 데이터 베이스에 저장하기 때문에 발생하는 문제라고 생각했습니다. 특히 재고의 차감이라는 수정을 위해서는 Lock을 수행하기 때문에 병목이 발생한다는 것을 이전 테스트에서 알 수 있었습니다. 이런 문제의 해결책을 생각해보기 위해 구매 주문 과정을 더 작은 단위로 분해해보았습니다.

구매 주문과정을 더 작은 단위로 분해한 모습

현재 재고를 읽고 구매자가 구매한 만큼의 충분한 재고가 있는지 평가하고 만약 충분하다면 구매 주문 접수가 시작되어 최종적으로 재고가 차감되는 방식으로 구성되어 있습니다. 한순간에 한 명의 사용자만 상품을 구매한다면 문제가 없지만 여러 명의 사용자가 상품 구매를 할 때 발생하는 동시성 문제를 해결하기 위하여 재고를 읽는 순간부터 하나의 요청만 순차적으로 처리되도록 한 것이 이전 포스팅까지의 과정이였습니다.

 

분해된 과정을 지켜보면 스스로 질문을 던져보았습니다.

Q1. 재고는 항상 데이터 베이스에서만 읽어와야 할까?

A1. 재고라는 상태를 관리하는 다른 무언가가 있다면 데이터 베이스에서만 읽을 필요는 없을 것이다.

 

Q2. 구매 주문하기라는 동작에서 만약 재고가 무한정 존재하여 재고의 검증이 없다면 병목이 발생할까?

A2. 재고 차감에서는 항상 Lock이 수행되므로 발생할 것이다.

 

Cache?

처음 접근 방식은 Database를 1차 저장소, Cache Layer를 2차 저장소로 만드는 것이었습니다. Local Cache와 Global Cache를 검토했었을 때 트래픽에 따른 확장을 고려하면 Local Cache를 적용할 경우 Application간의 데이터 불일치가 예상되므로 추가적인 Replication을 적용해야 하므로 제외하여 Global Cache를 선택했습니다.

 

다음으로 Cache Layer와 Database간의 관계에 대해서 고민했습니다. 대표적으로 Cache Aside, Write Back, Read Through, Write Through 전략이 있습니다.

 

Cache 전략 Diagram

초기에 고려했던 설계는 Write Back과 Write Through 였습니다. Cache에서 재고들을 수정하고 Database에 전달해기 전 Buffer로써 설계한다면 현재 생기고 있는 병목을 막을 수 있을 것이라 생각했습니다.

 

Redisson을 활용한 방식

다음으로 그럼 구현은 어떻게 해야될지 고민했습니다. 직접 구현하는 방법도 있었지만 Redis Client 중 Redisson은 해당 전략들의 구현체를 제공하고 있다는 점에서 적용해 보기 위해 검토를 했습니다. 검토 해본 결과 Cache에 저장되어 있는 재고 데이터를 Database로 Write Back, Thorough 하는 것에는 문제가 없었으나 재고 검증이라는 과정이 해결되지 않았습니다. 추가적인 문제로 Database로 Sync하는 과정 중 예외가 발생했을 때 예외를 처리할 수 없었습니다.

 

Redis Transaction을 이용한 직접 구현

라이브러리를 사용하므로써 예외 처리의 부재 같은 문제들을 직접 해결해보고자 Redis Transaction을 사용하여 직접 구현해보고자 했습니다.

/** 
/* ProductOptionId를 key로 operation 진행
**/
BoundListOperations<String, String> listOps = redisTemplate.boundListOps(key);

Long size = listOps.size();
if (size != null && size <= 0) {
	// Redis에 재고 정보가 없다면 Database의 재고 정보를 조회 후
	// 재고 차감
	// Redis에 차감 재고 기록 
} else {
	// --Redis Transaction 범위 시작--
	// Redis에 재고 정보가 있다면 Redis의 재고 정보를 조회 후
	// 재고 차감
	// Redis에 차감 재고 기록
}

return remainedStock[0];
// --상위 트랜잭션 종료 후 Redis Transaction 실행--

재고의 차감되는 히스토리들을 확인하고 싶었기 때문에 초기에는 List 자료구조를 사용했습니다. List 자료구조와 Redis Transaction을 사용했을 때 원하는 바대로 동작한다면 Sorted Set 구조로 변경하여 TTL을 주어 Cache를 주기적으로 만료시킬 계획이었습니다. 하지만 위 로직에는 문제점이 있었습니다.

실제로 들어가 있는 Stock History와 이상적인 Stock History 모식도

위의 나타낸 코드가 모두 Redis Transaction 내에 포함되어 있지 않으면 다음과 같이 같은 재고를 바탕으로 Cache 재고를 차감하게 됩니다. 그렇다면 Redis Transaction 내에 모두 포함하면 되지 않을까라는 생각이 들었습니다.

Redis Transaction 내의 Read 연산은 Null 반환

하지만 재고 검증이라는 단계에서 Read 과정은 Redis Transaction 내에서 항상 Null을 반환합니다. 결론적으로 Redis의 Transaction은 Read-Write 패턴의 형태로 사용할 수 없습니다. 이유는 RedisTemplate의 Transaction은 Transaction의 Atomic하도록 만들기 위해 multi - exec까지의 코드를 다음과 같이 수행합니다.

 

 

코드 레벨에서 들여다 보면 Redis Template에서 Database Transaction의 commit 쿼리와 같이 exec()를 수행하면 한 번에 Redis Transaction 내의 operation을 한 번에 Redis에게 요청합니다. 따라서 Redis의 특성에 따라 한 순간에 전송된 연산들은 순차적으로 처리되어 Atomic하게 동작하게 되는 방식으로 동작합니다. (이런 이유로 Read를 Transaction 내에서 수행하고 싶어도 exec()을 호출후 Redis에 요청됨으로 Transaction 내에서 값을 읽는 부분은 필요없게 되므로 null 을 반환하도록 처리되어 있습니다.)

 

고민을 위한 여지

  • Atomic 하면서 재고 검증을 할 수 있기 위해서는 어떻게 해야될까?

 

REF

https://waspro.tistory.com/697

 

Redis5 설계하기 총정리

개요 이번 포스팅에서는 Redis를 효과적으로 구축/운영하기 위한 설계방법에 대해 알아보도록 하자. Redis는 대표적인 In-memory DB로 세션, 캐시, 큐 등으로 활용된다. 단일 환경으로 가볍게 구성이

waspro.tistory.com

https://techblog.gccompany.co.kr/redis-kafka%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%BF%A0%ED%8F%B0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B0%9C%EB%B0%9C%EA%B8%B0-feat-%EB%84%A4%EA%B3%A0%EC%99%95-ec6682e39731

 

Redis&Kafka를 활용한 선착순 쿠폰 이벤트 개발기 (feat. 네고왕)

안녕하세요. 유저혜택개발팀 쿠폰 백앤드 개발자 페이든입니다.

techblog.gccompany.co.kr