Architecture

Redis로 구현한 Rate Limiting Pattern

JeongKyun 2024. 9. 2.
반응형

서론

웹 애플리케이션에서 Rate Limiting Pattern은 제품 사용자 규모가 커질 수록 중요한 역할을 한다.
이는 서버의 과부하를 방지하고, 사용자 경험을 개선하며, 서비스의 안정성을 보장하는 데 필수적인 기능이다. Rate Limiter는 일정 시간 동안 특정 사용자나 IP의 요청 수를 제한하여, API 남용을 방지한다. 이번 글에서는 Kotlin/Spring + Redis를 사용하여 Rate Limiter 구현하는 방식에 대해서 소개해보려한다.
 

Redis를 선택한 이유

Rate Limiting 구현 시 Redis를 사용하는 이유는 다음과 같다:

  1. 빠른 성능: Redis는 인메모리 데이터베이스로, 읽기 및 쓰기 성능이 뛰어나다.
  2. 간편한 데이터 구조: Redis의 데이터 구조를 활용하여 간편하게 카운트와 만료 시간을 관리할 수 있다.
  3. 분산 처리: Redis는 클러스터링과 분산 환경을 지원하므로, 대규모 서비스에서도 안정적으로 사용할 수 있다.

 

Rate Limiter 구현

Redis를 사용하여 Rate Limiter를 구현하는 과정은 다음과 같다:

  1. 클래스 정의: Rate Limiter 클래스는 RedisTemplate을 통해 Redis와 상호작용한다.
  2. 요청 카운트 및 만료 시간 관리: 특정 클라이언트의 요청 수를 카운트하고, 일정 시간이 지나면 카운트를 리셋한다.

 
아래는 Redis를 활용한 Rate Limiter의 구현 예시이다:

import org.springframework.data.redis.core.RedisTemplate
import java.time.Duration

class RateLimiter(
    private val redisTemplate: RedisTemplate<String, String>,
) {
    private val RATE_LIMIT_PREFIX = "RATE_LIMIT"

    // 허용되는 최대 요청 수
    private val REQUEST_LIMIT = 5L

    // 제한 시간 윈도우
    private val TIME_WINDOW = Duration.ofMinutes(1)    

    /**
     * @param key 제한을 적용할 식별자 (예: 사용자 ID, IP 주소)
     * @return 요청 허용 여부
     */
    fun isAllowed(key: String): Boolean {
        val rateLimitKey = "$RATE_LIMIT_PREFIX:$key"
        val current =
            redisTemplate.opsForValue().increment(rateLimitKey, 1) ?: 0L

        if (current == 1L) {
            redisTemplate.expire(rateLimitkey, TIME_WINDOW)
        }

        return current <= REQUEST_LIMIT
    }
}

 

구현 세부 사항

  • REQUEST_LIMIT: 이 값은 클라이언트가 특정 시간 동안 보낼 수 있는 최대 요청 수를 정의한다.
  • TIME_WINDOW: 요청 제한을 적용할 시간 윈도우를 정의한다.
  • RATE_LIMIT_PREFIX: Redis의 키에 붙일 접두사로, 키를 구분하기위해 위해 사용한다.

isAllowed 메서드는 클라이언트의 요청 수를 증가시키고, 첫 번째 요청 시에만 만료 시간을 설정한다. 이 메서드는 현재 요청 수가 제한을 초과하지 않으면 true를 반환하고, 초과하면 false를 반환한다.
 
이를 통해 회사 정책에 따라 제한사항을 설정하여 Key를 userId, request ip 로 잡을지 설정하여 제한을 둘 수 있다.
 
 

테스트

위 isAllowed 메서드가 정상적으로 동작하는지 API Controller를 구현하여 통합 테스팅을 진행했다.
진행 코드는 아래와 같다.
 

RateLimitTestController 구현

@RestController
class RateLimitTestController(
    private val rateLimiterService: RateLimiter,
) {
    @PostMapping("/rate-limit-test")
    fun rateLimitTest(
        @RequestHeader(value = "X-Forwarded-For", required = false) ip: String?,
    ): ResponseEntity<String> {
        val clientIp = ip ?: "unknown"

        return if (rateLimiterService.isAllowed(clientIp)) {
            ResponseEntity.ok("OK")
        } else {
            ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Too many requests. Please try again later.")
        }
    }
}

 

SpecsForRateLimitTestController (Test)

@SpringBootTest
@AutoConfigureMockMvc
class SpecsForRateLimitTestController {
    @Autowired
    private lateinit var mockMvc: MockMvc

    @Test
    fun `should limit requests when called in parallel`() {
        // Arrange
        val executorService = Executors.newFixedThreadPool(10)
        val latch = CountDownLatch(1)

        // Act
        val tasks =
            List(10) {
                Callable {
                    latch.await()

                    val result =
                        mockMvc
                            .perform(
                                post("/rate-limit-test")
                                    .header("X-Forwarded-For", "192.168.0.1"),
                            ).andReturn()

                    result.response.status
                }
            }

        latch.countDown()
        val futures = executorService.invokeAll(tasks, 10, java.util.concurrent.TimeUnit.SECONDS)
        executorService.shutdown()

        // Assert
        val responseStatuses = futures.map { it.get() }
        val successCount = responseStatuses.count { it == HttpStatus.OK.value() }
        val tooManyRequestsCount = responseStatuses.count { it == HttpStatus.TOO_MANY_REQUESTS.value() }

        assertEquals(5, successCount)
        assertEquals(5, tooManyRequestsCount)
    }
}

테스트는 java의 concurrent 도구를 사용하여 병렬로 요청하여 정확히 5번 요청하고 5번 실패하는지에 대한 검증을 하도록했다.

 
 

매뉴얼 테스트는 아래와 같이 Curl로 진행했다.

 
 

마치며

Redis를 활용한 Rate Limiter의 구현은 효율적이고 확장성이 뛰어나며, 병렬 요청 시 발생할 수 있는 문제를 해결하기 위해 Redis의 데이터 구조와 테스트 전략을 잘 이해하고 적용하는 것이 중요하다. 이와 같은 접근 방식은 대규모 시스템에서의 안정성과 성능을 보장하는 데 큰 도움이 된다. 현재 회사에선 B2B 제품을 만들고있다보니 스파이크성 트래픽 or 스크롤러 등을 방지하기 위한 처리율 제한 로직등을 Gateway에 두는것이 필요한 상황은 아니나 최근 Rate Limit에 대한 글들이 많이 보여 학습 겸 직접 구현하며 공부해보았다.
 
다음 글에서는 Redis 외에도 Rate Limiting 기능을 제공하는 오픈소스들이 꽤 많은데, 그 중 대표적인 bucket4j를 사용하여 rate limit을 구현하는 과정을 소개해보려한다.
 
위 구현사항들은 GitHub에 올려두었다.
(해당 Github Repository에는 앞으로 유량제어 관련 기법들을 구현할 예정이다.)

댓글

💲 많이 본 글