In-memory DB인 redis를 활용하여 갑작스럽게 주문이 몰릴 때 유입속도를 늦출 수 있는 방법을 공유하려고합니다.
저는 쇼핑몰 개발자입니다.
Django와 Restframework를 활용하여 API 서버를 개발합니다.
제가 일하는 곳은 상대적으로 고가상품이 많은 셀렉트 샵입니다
따라서, 타임세일을 하는 오픈마켓이나 좌석 매표를 하는 서비스들처럼 주문요청이 갑작스럽게 몰리는 경우는 많지 않습니다. 따라서 트래픽이 몰려올 때마다 대비가 잘 되어있지 않아 재고 수량보다 더 팔리거나 주문결제까지 완료했지만 재고 부족으로 주문이 취소되는 현상도 자주 발생하였습니다.
발생 문제
재고보다 더 판매되는 것은 동시성 문제로 해결방법은 해당 포스트의 작업으로 진행하였습니다.
본 포스트에서는 결제 완료까지 되었지만 재고 부족으로 주문이 취소되는 문제를 해결한 방법을 소개합니다.
주문서비스는 총 4단계로 이뤄집니다.
주문버튼 Click
→ 주문요청서 생성API
→ PG사 결제진행
→ 주문완료API
보통 두번째
단계에서 재고 여유를 확인합니다. 하지만 분초를 다투는 주문요청이 들어올 때 세번째
에서 결제완료가 된 후 네번째
로 넘어갈 때 발생합니다. 상식적으로 결제가 됐는데 주문이 취소가 되는 것은 이상합니다. 따라서 이런 취소는 모두 고객센터 CS로 접수됩니다.
유입되는 모든 주문 요청을 받아 처리하는것이 문제라고 생각했습니다.따라서 주문요청은 재고 만큼만 받고 나머지 요청은 일시품절메시지를 통해 유입을 일시적으로 막는 것으로 방안을 세웠습니다.
구매 가능한 재고= 실제 재고 +현재 결제중인 상품 수량
주문요청서 생성API
에서 위의 판단을 하여 Fail-fast하게 주문이 불가능하다고 응답하고 싶었습니다.
기술적 해결
결제중인 상품 수량
결제중 상품 수량을 계산하기 위해 주문 요청시 Redis
에 재고 수량을 저장하였습니다.
KEY = "item:${item_no}"
VALUE = [
{
"value":"user:${user no}:seq:${sequence no}",
"score":"${unixtimestamp} + ${TTL}"
}
]
데이터 타입은 Sorted Set을 사용하였습니다.
왜냐하면 Sorted Set의 score에 현재시간+TTL로 사용할 시간을 저장하여 Value들에 TTL을 지정하는 효과를 보기 위해서입니다. Redis의 자료구조중 Sequantial한 데이터는 List, Set, Hash가 있습니다. 하지만 모두 각각의 element에 TTL을 줄 수 없습니다. TTL이 중요한 이유는 고객이 주문도중 이탈시 일정 시간이 지난 이후 자동으로 주문중 수량에서 제외가 되야하기 때문입니다.
주문(결제)도중 이탈율이 낮을 것으로 판단했지만, 실제로 서비스 지표추출시 두자리수나 되었습니다.
주문중인 수량계산은 ZCOUNT
명령어를 통해 log(n)
의 시간 복잡도로 구할 수 있습니다. 이 명령어를 사용하기 위하여 value들을 sequence no
를 붙여 구분할 수 있도록 하였습니다.
주문수량이 세 개일 경우 아래처럼 됩니다
VALUE = [
{"value":"user:1:seq:1", score...},
{"value":"user:1:seq:2", score...},
{"value":"user:1:seq:3", score...},
]
아래 명령어로 특정 상품의 주문중인 상품 수량을 구할 수 있습니다.
ZCOUNT "item:${item_no}" -inf inf
아래 명령어로 특정상품에 주문중인 상품수량을 추가할 수 있습니다.
ZADD('item:${item_no}', {'user:${user_no}:seq:${sequence no}: ${unixtimestamp} + TTL })
주문중인 수량 삭제 위하여 ZSCAN
명령어로 대상 검색 → ZREM
으로 삭제하도록 하였습니다.
ZSCAN("item:${item_no}", "user:${user_no}*", 0)
ZCOUNT를 사용하기 위해 seq를 추가했기 때문에 패턴으로 검색합니다.
VALUE = [
{"value":"user:1:seq:1", score...},
{"value":"user:1:seq:2", score...},
{"value":"user:2:seq:1", score...},
]
1번 고객의 주문수량을 삭제하기 위해서 user:${user_no}*
패턴을 검색하여 삭제하면됩니다.
ZSCAN("item:${item_no}", "user:1*", 0)
ZSCAN은 데이터가 많을 경우 cursor를 사용하여 반복해서 조회합니다. 따라서 python 코드로 아래 처럼 cursor가 0일 때 까지 조회하도록 하였습니다.
from django_redis import get_redis_connectiondef scan(key, pattern):
conn = get_redis_connection()
target = []
cursor = 0
while True:
ret = conn.zscan(key, match=pattern, cursor=cursor)
# ret = (0, [(b'value, score), (b'value', score)] 형태 cursor = ret[0]
target += [value[0] for value in ret[1]]
# check no more data
if cursor == 0:
break
return target
위 코드에서 조회된 값들을 ZREM을 이용하여 삭제합니다
key = "item:${item_no}"
target = scan(key, "user:${user_no}*")
zrem(key, *target)
해당 로직은 결제도중 취소시 호출하는 로직
과 주문완료시 호출하는 로직
에 추가하면됩니다.
주문중 이탈된 수량 삭제는 value에 설정한 TTL이 지난 value를 삭제하면됩니다. ZREMRANGEBYSCORE
명령어로 삭제할 score 범위를 지정할 수 있습니다. TTL이 지난 상품을 삭제하는 방법은 다음과 같습니다
ZREMRANGEBYSCORE("item:${item_no}", '-inf', now unixtimestamp)
처음 주문수량데이터를 설정할 때 현재시간에 TTL을 더하였기 때문에 score가 현재보다 낮으면 만료된 데이터라고 판단할 수 있습니다.
비지니스적 해결
위의 Flow로 개발을 하면 유입된 주문을 1열로 세우는 것과 유사하게 됩니다.
여기서 중요한 것은 고객 중 일부는 주문 도중 이탈하거나 결제실패가 발생한다는 것입니다. 따라서 이러한 고객을 고려하여 주문을 재고보다 조금 더 받아야 재고 수량에 맞게 주문을 받게 되는 것입니다. 따라서 지난 1년 치 주문데이터를 분석하여 미결제율과 결제실패율을 계산하였습니다.
아래가 기존의 주문여부를 판단하는 로직이라면,
재고 ≥ 결제중 상품수량(redis) + 결제하려는 상품수량
위의 비율을 추가하면 아래와 같습니다
재고 * (1 + 미결제율 + 결제실패율) ≥ 결제중 상품수량(redis) + 결제하려는 상품수량
위의 수식이 참이 되면 주문을 받을 수 있고, 거짓이면 일시품절 Alert를 반환하도록 하였습니다. 이 값은 서비스마다 다르기 때문에 꼭 평균(중앙)값을 계산하여 반영해야 합니다.
위의 접근에 따라서 평균 결제 시간 값도 계산하였고 이 값을 Key의 TTL로 지정하였습니다. 그리고 추가주문 될 때마다 Key의 TTL을 재설정하였습니다. 그 이유는 Key에 TTL을 주지 않으면 추가 주문이 없는 상품은 Key를 가지고 영원히 메모리를 잡아먹기 때문입니다.
도입 이후
며칠 전 코로나로 인하여 인기가 매우 많은 게임타이틀을 판매하였습니다.
그날은 평소와는 다르게 사전 트래픽 증가에 대한 예상 공지가 없었습니다. 그러다 갑자기 Request count alert이 울렸고 한 순간에 평소대비 8배이상의 요청이 들어왔습니다.
아주 잠깐 사이트가 버벅대긴 했지만, 상품은 정상적으로 판매되었습니다.주문서요청서 생성API
와 주문완료API
에는 로직이 많고 transaction이 길게 잡혀있기 때문에 모든 주문요청을 그대로 받아들였다면 테이블락으로 인한 서버 장애가 발생했을 것입니다.
운 좋게 기능이 미리 배포되고 판매되어 순탄하게 서비스를 하였습니다.
어느 서비스이든 서버 요청이 몰리 때 응답은 느려지고 동시성 문제가 발생할 수 있습니다. 이와 비슷한 문제로 고민을 가지고 계신 분들께 조금이나마 도움이 되었으면 좋겠습니다.
도움이 되셨다면 👏🏻 주세요. 감사합니다.