서비스에서 사용자가 API 요청하면 DALL ・ E 로 이미지를 생성해주는 기능이 있었는데, 한번 요청할 때 마다 0.04 달러의 비용이 발생한다.
필요한 사용자에게 기쁜 마음으로 API를 제공하겠지만 과도한 요청을 하는 클라이언트에 대해서는 어느정도 제한이 필요해 요청 제한 기능을 도입했다.
요청 제한 기준은 서비스 요청 한도, 사용자 요청 한도 2개가 존재한다.
서비스 요청 한도 : 서비스의 요청 처리 가능 횟수
(ex) 50/T₁ = T₁ 시간 동안 50번 요청 처리(이미지 생성) 가능
사용자 요청 한도 : 사용자의 요청 가능 횟수
(ex) 5/T₂ = T₂ 시간 동안 5번 요청 가능
사용자가 이미지 생성 요청 시 서비스 처리 횟수가 서비스 한도를 넘지 않았고,
사용자 요청 횟수가 사용자 한도를 넘지 않아야 이미지를 생성해 줄 수 있다.
API를 요청할 때 마다 두 값이 조회, 변경되므로 DB가 아닌 캐시(Redis)에 저장한다.
고정 윈도 카운터 알고리즘을 사용했다. 이 방식은 타임라인을 고정된 크기의 윈도우로 분할하고 요청이 들어왔을 때 윈도우 한도를 넘지 않으면 요청을 수행하고 넘으면 요청을 버린다.
아래와 같은 이유로 해당 방식을 사용했다.
- 구현이 간단하고 직관적이다
- 따로 의존성을 추가할 필요가 없다
- 윈도우 경계에서 요청이 몰릴 수 있지만 윈도우 크기와 한도를 적절히 설정하면 크게 문제가 없다고 판단
사용자가 API 요청을 하면 2개의 윈도우를 확인한다. 서비스 윈도우 & 사용자 개인 윈도우. 사용자 윈도우는 사용자만 접근하므로 동시성 제어가 필요없지만 서비스 윈도우는 공유 자원이므로 동시성 제어가 필요하다.
하나의 API 요청에 의해 아래와 같이 여러 연산이 발생한다.
- 서비스 윈도우에 쌓인 누적 요청이 윈도우를 초과하는가? (READ)
- 사용자 윈도우에 쌓인 누적 요청이 윈도우를 초과하는가? (READ)
- 서비스 처리 횟수 + 1 (WRITE)
- 사용자 처리 횟수 + 1 (WRITE)
위 그림에서 서비스, 사용자 윈도우 둘 다 한도를 초과하지 않았다면
(빨간 요청이 생기지 않는다면) 요청을 처리한다.
위의 다중 연산은 중간에 다른 쓰레드의 간섭을 받지 않고 원자적으로 처리돼야 한다.
왜냐하면, 서비스 처리 횟수는 공유자원이기에 다중 스레드로부터 동시성 제어가 필요하다. 즉, 트랜잭션의 역할을 해줄 무언가 필요하다. Redis가 아닌 MySQL 이였다면 @Transactional이라는 좋은 어노테이션을 사용했겠지만 Redis에는 적용되지 않는다. 그렇다면 어떻게 트랜잭션을 걸까?
어떻게 트랜잭션을 거는지 알아보기 전에 레디스의 구조를 살펴보자. 레디스는 싱글 스레드 아키텍처로 동작하는 구조를 가진다. 레디스는 왜 싱글 스레드일까? 그 이유는 아래와 같다.
- 주된 연산이 조회, 저장, 삭제 등으로 매우 간단하다
(웹 애플리케이션처럼 CPU를 많이 잡아먹는 비즈니스 로직 또는 이미지 처리 연산이 없음)
- 싱글 스레드는 동기화나 동시성 제어가 필요 없는 간단한 구조이다
- 레디스 성능 저하의 주된 요인은 CPU가 아닌 메모리 공간과 네트워크 통신 속도이다
Redis는 간단한 작업을 하기 때문에 싱글 스레드(+ 논블로킹 I/O)로도 충분히 커버가 가능한 것이고, 멀티 스레드로 동작하면 오히려 동시성 제어, 동기화 등 추가적인 오버헤드가 발생하기 때문에 싱글 스레드의 간단한 구조를 선택한 것입니다.
반면, 웹 애플리케이션은 비즈니스 로직 처리나 이미지 처리 등 상대적으로 CPU 작업량이 비교적 큰 작업을 수행하며, 사용자 요청에 대한 동시 처리(실시간성)를 보장하기 위해 멀티 스레드 방식으로 동작합니다.
앞서 레디스가 싱글 스레드인 이유와 동시성 제어가 필요 없다는 것을 알았다. 이제 스프링에서 레디스의 트랜잭션을 구현하는 방법에 대해 알아보자,
이 글에서는 2가지 방법만 다룬다.
1. Multi & Exec 방법
2. Lua Script 방법
이 방식은 MULTI로 트랜잭션을 시작하고 EXEC로 커밋하는 방식이다. MULTI로 트랜잭션을 시작하면 그 이후의 명령어들은 바로 실행되지 않고 EXEC 명령어를 실행하기 전까지 큐에 쌓인다. 그리고 EXEC가 호출되면 큐에 쌓였던 명령어들이 한번에 수행된다. Atomicity를 만족하는 좋은 방식이다. 하지만 이 방식을 나의 서비스에 적용시키는데에 아주 치명적인 단점이 존재했다.
먼저 MULTI, EXEC를 쓴 간단한 코드 예시를 보자.
public void executeTransaction() {
redisTemplate.execute(new SessionCallback<Void>() {
@Override
public Void execute(RedisOperations operations) {
// 트랜잭션 시작
operations.multi();
// 예시 데이터 읽기
String currentValue = (String) operations.opsForValue().get("QQQQ");
// if 조건문으로 조건 처리
if (currentValue == null) {
System.out.println("currentValue == null");
}
// 트랜잭션 실행
operations.exec();
return null;
}
});
System.out.println("currentValue = "+redisTemplate.opsForValue().get("QQQQ"));
}
위 코드는 트랜잭션을 MULTI로 시작하고 레디스에서 “QQQQ”의 값을 조회하고 조회결과가 null이면 "currentValue == null"을 출력한 후 트랜잭션을 EXEC로 commit한다. 그 후 트랜잭션이 끝나면 다시 한번 QQQQ의 값을 조회해서 출력한다.
코드를 실행하기 전에 아래와 같이 레디스 cli에서 QQQQ의 값을 hello로 저장하자.
위 코드를 실행하면 QQQQ는 null이 아닌 hello로 저장돼 있으므로 트랜잭션 내부 조건문의 출력문은 출력되지 않고 트랜잭션 밖의 QQQQ의 값인 hello만 출력될 것이라고 기대했다. 한번 코드를 실행해보자.
기대했던 것과 달리 트랜잭션 내부에서 QQQQ의 조회 값은 null이다. 분명 레디스에 저장했는데 왜 null일까?
아래는 스프링의 API 문서인데, RedisOperations가 구현하는 인터페이스인 ValueOperations의 Get 메소드의 Return을 보면 레디스에 키가 없거나 GET이 pipeline이나 transaction에서 사용됐을 때 null을 리턴한다고 돼 있다. 왜 일까?
스프링 ValueOperations 인터페이스 API
레디스의 MULTI, EXEC 방식은 큐를 이용하여 동작하는데 MULTI가 호출되면 이후의 명령어들을 모두 큐에 쌓아놓는다.
그 후 EXEC가 실행되면 큐에 있던 모든 명령어들이 일괄적으로 실행된다. 큐를 이용해 여러 연산을 atomic하게 처리하는 것이다.
아래 cli를 통해 Multi 이후에 연산을 호출하면 연산 결과가 아닌 QUEUED를 응답받는다.
아까의 코드를 살펴보면 QQQQ의 값을 get 하는 명령어는 MULTI와 EXEC 사이에서 호출됐다. 이말은 이 명령어는 큐에 담긴다는 얘기다. 그리고 if 조건문은
get("QQQQ")
명령이 큐에 담겨 아직 실행되지 않은 상태에서currentValue
값을 따지기 때문에 무조건 null이다. 명령어 자체가 아직 실행되지 않았기 때문이다!
@Override
public Void execute(RedisOperations operations) {
// 트랜잭션 시작
operations.multi();
// 예시 데이터 읽기
String currentValue = (String) operations.opsForValue().get("QQQQ");
// if 조건문으로 조건 처리
if (currentValue == null) {
System.out.println("currentValue == null");
}
// 트랜잭션 실행
operations.exec();
return null;
}
아래 그림과 같이 명령이 큐에 얼음된 상태에서 아직 실행도 안된 명령의 결과 값인 currentValue를 if문에 쓰는 상황이다.
따라서 트랜잭션 내에서 조건문을 쓰려면 MULTI, EXEC 방식은 불가능하다.
내 프로젝트로 말하자면, 이 방식으론 트랜잭션 내에서 윈도우 한도를 초과했는지 확인 할 수 없다는 얘기이다.
MULTI, EXEC 방식은 트랜잭션 내에서 조건문을 사용할 수 없다는 것을 알았다. 다른 방법인 Lua Script를 확인해보자.
이 방식은 여러 연산을 스크립트 형식으로 작성해 스크립트 단위로 레디스에게 명령을 보내는 방식이다.
레디스는 Lua 인터프리터를 내장하기 때문에 레디스 내부에서 스크립트를 실행할 수 있다. 레디스 공식 문서를 들어가보면 Lua Script에 대해 아래와 같이 설명한다.
Redis guarantees the script's atomic execution.
While executing the script, all server activities are blocked during
its entire runtime. These semantics mean that all of the script's effects
either have yet to happen or had already happened.
Redis는 스크립트의 원자적 실행을 보장합니다. 스크립트를 실행하는 동안 서버의
모든 활동은 스크립트 실행 시간이 끝날 때까지 차단됩니다.
이말은 스크립트의 모든 효과가 아직 발생하지 않았거나,
이미 모두 발생했다는 것을 의미합니다.
여러 명령을 포함한 스크립트가 원자적으로 실행될 수 있는 것은 레디스가 싱글 스레드로 동작하기 때문이다. 여러 서버 또는 스레드가 같이 쓰는 레디스에 동시에 접근해도 먼저 요청한 스크립트를 다 처리하기 전까지 다른 요청은 대기 상태이므로 연산 도중에 방해받지 않는 atomic한 처리가 가능한 것이다. 또한 연산들이 큐에 담겼다가 일괄 실행되는 방식이 아닌 레디스 서버 내부에서 직접 실행되기 때문에 if 조건문도 사용할 수 있다.
사용자의 API 요청에 제한을 두고, 다중 연산을 통해 요청 제한의 유무를 판단하며 그것을 트랜잭션 형식으로 구현해야 한다. 트랜잭션 형식은 2가지가 있었으며 그 중 Lua Script가 적절하다고 판단했다.
이제 서비스에서 어떤 식으로 요청 제한을 하는지 알아보자.
먼저 동작 방식은 아래 그림과 같다.
- 사용자 이미지 생성 요청 발생
- AOP가 요청을 인터셉트하여 Rate Limiter를 호출
- Rate Limiter에서 Redis에게 Lua Script를 요청에 포함시켜 요청 초과 여부를 응답받음 (이때 동시성 제어가 된 트랜잭션 수행)
- 응답을 초과하지 않았을 경우 API Controller호출
- API Controller는 사용자가 입력한 이미지 태그를 기반으로 DALL・E 3 API로 이미지 생성 요청
- DALL・E 3 API는 태그에 맞는 이미지 생성 후 응답
- 응답 받은 이미지를 사용자에게 응답
local Global_Key = "Global_Key" -- 서비스 키
local clientKey = KEYS[1] -- unique key (클라이언트 유니크 키)
local clientLimit = tonumber(ARGV[1]) -- 클라이언트 요청 가능 횟수 (5)
local clientLimitTime = 60 -- 클라이언트 윈도우 사이즈 60초
local globalLimit = 50 -- 50회 요청 가능
local globalLimitTime = 60 -- 서비스 윈도우 사이즈 60초
local globalCnt = tonumber(redis.call('get',Global_Key) or '0') -- 서비스 한도 조회
local clientCnt = tonumber(redis.call('get', clientKey) or '0') -- 클라이언트 한도 조회
if clientCnt + 1 > clientLimit or globalCnt + 1 > globalLimit then -- 지정 시간 내에 호출 수 초과 시 0 반환
return 0
else
redis.call('INCRBY', clientKey, '1') -- 사용자 요청 횟수 += 1 (키 없으면 1로 초기화)
redis.call('INCRBY', Global_Key, '1') -- 서비스 처리 횟수 += 1 (키 없으면 1로 초기화)
local client_ttl = redis.call('ttl',clientKey) -- 클라이언트 윈도우 사이즈 조회
local global_ttl = redis.call('ttl',Global_Key) -- 서비스 윈도우 사이즈 조회
if client_ttl == -1 then -- 클라이언트 윈도우가 없으면 새로 생성
redis.call('expire',clientKey,clientLimitTime)
end
if global_ttl == -1 then -- 서비스 윈도우가 없으면 새로 생성
redis.call('expire',Global_Key,globalLimitTime)
end
return 1
end
위 Script는 레디스 캐시에 서비스와 클라이언트의 키로 두 값을 조회하고 둘 중 하나라도 각자의 요청 제한을 초과한 경우 0을 리턴하고 초과하지 않은 경우 1을 리턴한다. 아래 코드는 Script를 포함한 요청을 레디스에게 보내는 RateLimiter의 코드이다.
@Override
public void tryApiCall(String key, LimitRequestPerTime limitRequestPerTime, ProceedingJoinPoint joinPoint) throws Throwable {
Long callCounter = redisTemplate.execute(
luaScript, //Lua Script
Collections.singletonList(key), //사용자 키
limitRequestPerTime.count()) // 사용자 요청 가능 횟수 (윈도우 시간 당)
);
if (callCounter.intValue() != 0) { //실패가 아닌 경우만 joinPoint(API Controller) 실행
joinPoint.proceed(); // API Controller 실행
return;
}
throw new RequestPerMinuteException("1분당 호출수 초과");
}
요청을 초과 했는지 확인하는 다중 연산을 Lua Script로 작성해 레디스 안에서 atomic하게 처리되도록 구현했다. 트랜잭션과 유사하게 여러 연산을 묶는 것까지 성공했다. 그렇다면 Lua Script가 중간에 실패했을 때 롤백이 될까?
결론부터 말하면 안된다.
레디스는 롤백을 지원하는 기능이 없다. Redis는 빠른 성능과 단순한 설계를 우선시하는 인메모리 데이터 구조 저장소이다. 롤백 기능을 지원하는 것은 Redis의 컨셉에 맞지 않다. 그러나, 롤백을 지원하진 못해도 트랜잭션 도중에 연산이 실패하는 것을 최대한 회피하도록 EXECABORT 기능을 제공한다.
EXECABORT는 간단히 말해 레디스에서 트랜잭션이 실패할 것이라는 것을 미리 간파하고 트랜잭션을 버리는(discard) 것을 말한다. 아래와 같은 경우 레디스는 미리 실패 여부를 알 수 있다.
문법적으로 잘못된 경우는 따로 설정할 필요 없이 레디스에서 자동으로 알아채지만 두 번째의 메모리 초과같은 경우는 레디스의 메모리 사용량을 MaxMemory로 설정한 경우에만 가능하다. 레디스는 메모리 사용량을 모니터링하다가 다음 명령으로 인해 사용량이 MaxMemory를 초과할 것이 예상되는 경우 명령을 거절한다. 각각의 경우를 redis-cli로 확인해보자.
❌ 연산이 문법적으로 틀린 경우
MULTI // 트랜잭션 시작 GET a b c // 한번에 키 3개 조회(문법적 오류) EXEC // commit
레디스에서 EXEC를 호출하기 전에 문법적으로 틀렸다는 것을 출력하고 EXEC를 호출하면 에러로 인해 트랜잭션을 버렸다고 출력함.
🌊 연산이 MaxMemory를 초과할 것이 확실한 경우
CONFIG SET MaxMemory 8b // 레디스의 사용 가능 메모리를 8 byte로 제한 MULTI SET Large_KEY "AAAAAAAAAAAAAAAA" // 저장 요청 (8byte 넘는 문자열) EXEC
레디스에서 EXEC를 호출하기 전에 메모리 초과하게 된다는 것을 출력하고 EXEC를 호출하면 에러로 인해 트랜잭션을 버렸다고 출력함.
이미지 생성 요청에서 비용 문제로 과도한 요청을 방지하기 위해 요청 제한 기능을 도입했고 이를 레디스와 Lua Script를 통해 구현했다.
이 과정에서 레디스의 구조와 Mutli & Exec 트랜잭션은 큐를 사용한다는 것과 이 방법은 내부에서 조건문을 사용할 수 없다는 것을 배웠다. 레디스는 롤백을 지원하지 않지만 트랜잭션의 실패를 최대한 회피하기 위해 EXECABORT 기능을 제공한다는 것을 알았다.