글 작성자: Key Ryung

개요

이전 포스팅에서는 Redis의 Transaction을 사용하여 Cache Layer에서의 데이터 일관성을 보장하려고 시도했지만 Redis의 Transaction과 관련된 동작에서 Read-Write 패턴의 사용은 지원하지 않는 한계점이 있었습니다. 이런 한계점을 다시 해결해보기 위해서 Redis와 관련된 동작을 분석해보았습니다.

 

재고 관리와 관련된 Redis의 동작을 정리하면 크게 3가지로 요약할 수 있습니다.

 

- Redis에 해당 제품의 재고가 없으면 Database에서 재고를 갖고 온 뒤 검증한다.

- Redis에 해당 제품의 재고가 있다면 재고 수에 대해 검증한다.

- 재고수를 차감한 뒤 Database에도 차감된 재고를 동기화한다.

 

Redis에서 지원하지 않는 동작은 수행하지 못하는 것일까?

앞선 문제들을 해결하기 위해서 생각해보았습니다.

 

내가 겪었던 문제들을 다른 개발자분들도 겪지 않았을까? 당연히 YES

 

그리고 Redisson이라는 Redis Client의 코드들을 찬찬히 보다 의문점이 생긴 부분도 해결 방향을 정하는 데 도움을 주었습니다. Redisson의 Code를 살펴보면 정말 신기하게도 TTL(Time-To-Live)를 지원하지 않는 자료구조에 대해서도 만료 시간을 설정할 수 있습니다.

 

RedissonMapCache의 코드 일부

 

위 코드와 같이 Map이라는 자료 구조에서 지원하지 않는 TTL 기능을 구현한 것을 볼 수 있습니다. `evalWriteAsync` 메서드 마지막에 script라고 표시된 부분을 보면 Code인 듯 보이는 문자열로 if, then 등 예약어들을 볼 수 있습니다. 이 Script를 Lua Script라고 말하며 프로그래밍적인 문법과 더불어 하나의 Script는 Atomic 하다는 것을 보장해줍니다.

 

재고 관리 시스템에 Lua Script 적용하기

Script를 작성하기에 앞서 어떻게 Script를 유지 보수하기 쉽게 적용할지 고민했습니다. Redisson 처럼 문자열 형태로 작성하는 것도 채택될 수도 있었지만 개행, Indent, 공백(Space)의 관리를 생각했을 때 적합하지 않다고 판단했습니다. 이런 점을 Spring에서도 인지하고 Lua Script를 하나의 객체로 관리할 수 있는 `RedisScript<T>`를 제공하고 있었습니다. 

 

Spring에서 Resource를 불러오듯 Script 또한 ClassPath를 통해 불러와 등록할 수 있었습니다. 그리고 Redis와의 재고만 처리하는 하나의 Template으로 Component로 관리할 수 있도록 `ProductStockManageTemplate`을 정의했습니다.

 

LuaScript를 사용하는 RedisTemplate 정의

 

ProductStockManageTemplate의 manageStockWithCache 메서드의 parameter를 통해 Script에 필요한 값들을 전달하도록 설계를 진행했습니다.

 

Lua Script 작성하기

Script를 작성하기에 앞서 Redis의 특징 중 하나인 다양한 자료구조들을 어떻게 적용할지 고민이 필요했습니다. 그리고 현재는 수량을 차감하는 구매 과정만 있었으나 추후 반품, 취소와 같이 수량을 다시 더해줘야 되는 기능까지 확장되는 상황을 고려했습니다.

 

// Sorted Set
key [zproduct:option:{productOptionId}]
value [purchase_order_id:{purchaseOrderId}]
score [{current_time} + {ttl}]

// Map
key [hproduct:option:{productOptionId}]
field [stock_count]
value [{stock_count}]

 

먼저 Sorted Set의 경우 해당 상품이 어떤 주문에 포함되어 있는지를 포함하고 TTL을 설정할 수 있도록 설계를 진행했습니다. Sorted Set의 경우 score를 기준으로 정렬되니 만료가 빠른 순서부터 느린 순서로 정렬될 것이고 ZREMRANGEBYSCORE를 사용해서 현재 시간까지의 값들을 삭제한다면 EXPIRE 명령어를 사용하지 않아도 TTL 기능을 구현할 수 있습니다. TTL 기능은 Cache의 불일치, 즉 Cache Pollution을 고려했습니다.

 

만약 Redis에 있는 재고 수량이 Database에 있는 수량과 불일치되는 문제가 발생한 뒤 지속적으로 서비스가 운영된다면 결국 서비스 장애로 이루어질 것이라 생각했습니다. 그래서 Redis의 Map 자료 구조에 등록되어 있는 key들을 지속적으로 만료시켜(Cache Eviction) Database와의 동기화를 주기적으로 맞춰 순간적인 불일치가 발생하더라도 추후에는 다시 정합성이 맞게 되는 Eventually Consistency 방식을 고려했습니다.

 

local key = KEYS[1]

local purchase_order_id = ARGV[1]
local current_stock = tonumber(ARGV[2])
local purchase_amount = tonumber(ARGV[3])
local current_time = tonumber(ARGV[4])
local ttl = tonumber(ARGV[5])

local remained_stock = 0

local stock_count_field = "stock_count"
local purchase_order_set_key = "z"..key
local product_stock_hash_key = "h"..key

local is_stock_exist = redis.call("hexists", product_stock_hash_key, stock_count_field)

if is_stock_exist == 0 then
    local deductStock = current_stock - purchase_amount
    if deductStock >= 0 then
        remained_stock = deductStock
    else
        return nil
    end

    local value = "purchase_order_id:"..purchase_order_id
    redis.call("zadd", purchase_order_set_key, current_time + ttl, value)
    redis.call("hset", product_stock_hash_key, stock_count_field, remained_stock)
else
    local recent_stock = redis.call("hget", product_stock_hash_key, stock_count_field)
    remained_stock = recent_stock - purchase_amount

    local value = "purchase_order_id:"..purchase_order_id
    redis.call("zadd", purchase_order_set_key, current_time + ttl, value)
    redis.call("hset", product_stock_hash_key, stock_count_field, remained_stock)
end

return remained_stock

 

전반적인 로직은 처음 개요에서 말했던 부분과 일맥 상통합니다. Redis에 재고 데이터가 있는지 파악하고 그에 따라 재고와 구매 수량을 비교하여 검증한 뒤 차감된 재고를 기록합니다.

 

Lua Script를 작성하면서 고려했던 부분은 각 명령, 연산(hget, hexist, hset, zadd) 들의 시간 복잡도였습니다. In-Memory DB 이므로 같은 O(N)이라도 File I/O에 비해 속도가 더 빠를 수는 있으나 Redis의 특성을 고려했습니다. Lua Script 자체는 하나의 연산으로 Atomic 함을 보장합니다.

 

어떻게 Atomic함을 보장하면서 처리 속도를 올렸는지 추가적인 조사를 진행했습니다.

 

Redis가 Atomic한 연산을 보장하는 Single Thread? Multi-Thread?

이전 포스팅에서 Redis와 Hazelcast의 Performance를 비교하는 아래 그림을 보여드린 적이 있습니다. 선정 시에는 의문점으로만 남겼지만 직접 사용해보면서 의문점을 파보았습니다.

 

Redis는 Single Thread로 작동하는데 Multi Thread 환경에서 실행시킨다면 의미가 없는 것 아닌가?

 

왼쪽 - Redis 공식 비교, 오른쪽 - Hazelcast 공식 비교

질문에 대한 답은 여러 관점, 레디스 버전에 따라 YES가 될 수도 NO가 될 수도 있습니다.

 

먼저 `Single Thread로 동작하여 Atomic 한 연산을 보장한다`의 의미를 Redis의 구조와 연관시켜 살펴보면 다음과 같이 볼 수 있습니다.

Redis Event Loop 구조

Event Loop에서 IO Multiplexing을 이용해서 Read/Write 이벤트를 받아오고, Read 이벤트가 발생하면 네트워크를 통해 패킷을 읽고, Command가 완성되면 실행되는 구조입니다.

Redis의 실행구조

즉, 다시 말해 우리가 흔히 Redis는 Single Thread로 동작한다는 것의 의미는 Command의 실행이 하나의 Thread에 의해 실행된 다는 것을 의미합니다. 이를 Atomic한 연산과 관련지어 생각해본다면 실행을 하는 Thread는 한 개밖에 없으므로 동시성 문제에 대해서 고민하지 않아도 되므로 프로그래밍 자체가 쉬워집니다.

 

https://redis.io/docs/getting-started/faq/#redis-is-single-threaded-how-can-i-exploit-multiple-cpu--cores

 

Redis FAQ

Commonly asked questions when getting started with Redis

redis.io

 

Redis 공식 문서에서는 Redis에서 CPU가 BottleNeck이 아니라 메모리나 네트워크 부분에서 발생한다고 소개하고 있습니다. 하지만 여기서 더 성능을 높이기 위해 v4부터 실행은 그대로 단일 스레드가 사용되고 Multi-Thread를 사용할 방법을 적용하기 시작합니다.

Threaded IO의 구조 - 관련된 부분은 더 분석하여 포스팅할 예정입니다.

이렇게 구조가 변경되어 Multi-Thread를 사용하여 Performance를 개선하면서도 Atomic 한 연산은 보장하기 위해서 실행은 Single Thread로 수행합니다. 이런 점에서 Lua Script가 실행될 때 실행 시간이 길어지면 연속적으로 뒤따르는 연산들이 느려지기 때문에 이런 문제를 막기 위해서 각각 연산들의 시간 복잡도를 O(N) 이하보다 작은 연산을 사용했습니다.

 

ZADD - O(log(N))
HEXISTS - O(1)
HGET - O(1)
HSET - O(1)

 

Lua Script는 Silver Bullet일까?

당연히 아닙니다. 그동안 갖고 있던 모든 고민들을 해결할 수 있는 해결책이라고 판단했었지만 Lua Script는 Redis Server에서 실행되는 Script이기 때문에 Unit 테스트를 할 수 없습니다. Refactoring과 기능의 확장을 고려했었을 때 Unit Test가 없다는 점 때문에 Lua Script 가 문제점이 될 것으로 예상합니다.

 

Conclusion

그동안 갖고 있던 문제점(Database IO 병목)의 해결에 초점을 두고 지속적으로 개선해 나갔습니다. 그러던 중 의문점이 하나 생겼습니다. Database의 재고는 항상 Consistency 해야 될까라는 의문이었습니다. 구매 주문 과정을 통해 풀어 설명해본다면 한 명의 유저가 10개를 구매했다면 곧바로 Database의 재고가 10개가 차감되어 일관성이 실시간으로 유지되어야 하는가로 말할 수 있습니다.

 

해당 프로젝트는 네이버 클라우드를 활용하여 진행했습니다.

 

REF

https://medium.com/29cm/redis-redis%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%98%EC%97%AC-%EC%84%9C%EB%B2%84-%EC%9A%94%EC%B2%AD-%EC%86%8D%EB%8F%84-%EC%A4%84%EC%9D%B4%EA%B8%B0-8132ea90bc39

 

Redis를 활용하여 Request 5배 더 받기

In-memory DB인 redis를 활용하여 갑작스럽게 주문이 몰릴 때 유입속도를 늦출 수 있는 방법을 공유하려고합니다.

medium.com

https://charsyam.wordpress.com/2020/05/05/%EC%9E%85-%EA%B0%9C%EB%B0%9C-redis-6-0-threadedio%EB%A5%BC-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90/

 

[입 개발] Redis 6.0 – ThreadedIO를 알아보자.

안녕하세요. 입개말만 하는 CharSyam 입니다. 이번에 Redis Version 6.0.x 가 출시되었습니다. Redis 5.0에서도 Stream 등 새로운 기능이 들어왔었는데, 이번 6.0에서도 ACL 및 여러가지 기능들이 들어왔습니다

charsyam.wordpress.com