[RateLimit] 1분에 20번만 조회할 수 있다 — Redis + Lua로 구현한 봇 차단

y001·2025년 4월 19일
0
post-thumbnail

1. 문제 제기: 무분별한 조회 시도, 서버는 어떻게 막을 수 있을까?

상품 리스트 API, 게시글 목록, 영화 목록 조회와 같이 누구나 접근할 수 있는 API는 봇에 의해 무차별적으로 호출될 수 있다. 이때 다음과 같은 문제가 발생한다:

  • 과도한 요청은 서비스 전체에 부하를 줄 수 있다
  • Redis의 INCR 방식은 TTL을 별도로 설정해야 하고, INCR → EXPIRE 흐름은 원자성이 보장되지 않아 race condition이 발생할 수 있다

따라서 "1분에 최대 20회"만 요청을 허용하는 Rate Limit 정책이 필요하다.

단순한 카운터보다는 시간 기준 sliding window 구조가 필요하며, Redis의 ZSET을 활용한 접근이 적합하다. 여기에 원자성을 더하기 위해 Lua 스크립트를 결합한다.


2. Redis + Lua로 해결하는 Rate Limit 구조

Lua 스크립트로 요청 시점을 ZSET에 저장하고, 1분 이전 데이터를 삭제한 후 최근 1분간의 요청 수를 계산하는 구조이다.

  1. ZADD — 현재 timestamp를 ZSET에 추가
  2. ZREMRANGEBYSCORE — 1분(60000ms)보다 오래된 항목 삭제
  3. ZCARD — 남아 있는 데이터 수 체크
  4. 초과되면 block key(SET + EXPIRE) 저장
  5. 전체 연산을 Lua로 묶어 원자성 확보

3. Lua 스크립트 코드 예시 및 설명

-- KEYS[1] = 요청 카운트용 ZSET
-- KEYS[2] = 차단 키 (rate limit에 걸렸을 경우 표시용)
-- ARGV[1] = 허용 횟수 (20)
-- ARGV[2] = 현재 시간 (timestamp)
-- ARGV[3] = 제한 시간 (예: 60000ms = 1분)

redis.call('ZADD', KEYS[1], ARGV[2], tostring(ARGV[2]))
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[2] - ARGV[3])

local count = redis.call('ZCARD', KEYS[1])
if count > tonumber(ARGV[1]) then
    redis.call('SET', KEYS[2], '1')
    redis.call('EXPIRE', KEYS[2], math.ceil(ARGV[3] / 1000))
    return 0
else
    redis.call('EXPIRE', KEYS[1], math.ceil(ARGV[3] / 1000))
    return 1
end

✔ 핵심 포인트

  • ZSET에 시간 정보를 넣으면 자연스럽게 슬라이딩 윈도우가 구현된다
  • TTL과 삭제 기준이 동일하기 때문에 불필요한 데이터가 남지 않는다
  • EXPIRE는 초 단위이므로 ms를 1000으로 나눠준다

4. 실전 테스트: K6로 봇 시뮬레이션

import http from 'k6/http';
import { sleep } from 'k6';

export default function () {
    const res = http.get('http://localhost:8080/v4/movies');
    console.log(`응답 코드: ${res.status}`);
    sleep(2); // 1분당 약 30회 요청 발생하도록 설정
}

✅ 테스트 환경

  • Redis에 ratelimit:ip:<주소>:minute 키가 생성됨
  • 최초 20회까지는 응답 코드 200 OK
  • 이후에는 429 Too Many Requests 발생
  • block 키(ratelimit:ip:<주소>:blocked)도 함께 생성됨

Redis 내 실제 키 조회 예시

127.0.0.1:6379> ZRANGE ratelimit:ip:0_0_0_0_0_0_0_1:minute 0 -1
"1745056542645"
"1745056544645"
...
127.0.0.1:6379> GET ratelimit:ip:0_0_0_0_0_0_0_1:blocked
"1"

5. 코드 레벨로 본 구현 흐름

@Service
class RedisRateLimitAdapter(private val redissonClient: RedissonClient) : RateLimitPort {

    override fun checkIpRequestLimit(ip: String?) {
        val normalizedIp = (ip ?: "unknown").replace(":", "_")
        val key = "ratelimit:ip:$normalizedIp:minute"
        val blockedKey = "ratelimit:ip:$normalizedIp:blocked"
        val now = System.currentTimeMillis()
        val limit = 20
        val expireMillis = 60_000L

        val luaScript = loadLua("ip_ratelimit.lua")
        val result = redissonClient
            .getScript(StringCodec.INSTANCE)
            .eval<Long>(
                RScript.Mode.READ_WRITE,
                luaScript,
                RScript.ReturnType.INTEGER,
                listOf(key, blockedKey),
                limit.toString(),
                now.toString(),
                expireMillis.toString()
            )

        if (result == null || result == 0L) {
            throw RateLimitException("요청이 너무 많습니다. 1분 후 다시 시도해주세요.")
        }
    }

    private fun loadLua(filename: String): String {
        val path = "/lua/$filename"
        val url = javaClass.getResource(path)
        return url?.readText(StandardCharsets.UTF_8)
            ?: throw IllegalArgumentException("Lua 스크립트를 찾을 수 없습니다: $filename")
    }
}

Redisson의 RScript.eval()은 Lua 기반 스크립트를 실행할 수 있는 기능을 제공한다.
여기서 중요한 점은 eval 자체가 Redis에서 원자적으로 실행된다는 것이다.

0개의 댓글