Articles

Redis 한 줄로 요청 횟수를 제한하고, 세마포어로 동시 호출 수를 제한하기

WhoIsLoafing 서버는 들어오는 요청은 IP별로 횟수를 세고, 나가는 GitHub 호출은 동시에 몇 개까지만 보내요. Redis의 숫자 증가 명령으로 만든 요청 제한과, 가상 스레드와 세마포어로 만든 동시 호출 제한을 정리했어요.

WhoIsLoafing은 로그인 없이도 분석을 할 수 있어요. 문턱이 낮은 만큼, 누군가 분석 요청을 수백 번 보내면 GitHub 요청 한도와 AI 비용이 순식간에 바닥나요. 반대 방향도 문제예요. 분석 한 번에 GitHub에 커밋 수백 개를 물어야 하는데, 한꺼번에 다 보내면 GitHub가 막아요.

그래서 두 가지 제한을 뒀어요. 들어오는 요청의 횟수 제한과, 나가는 요청의 동시 실행 제한이에요.

들어오는 요청: IP별로 세요

Express 서버를 Workers로 옮기던 시절에는 요청 횟수를 메모리에 셌어요. 인스턴스가 바뀌면 숫자가 사라졌죠. 지금 서버는 Redis에 세요. 서버를 다시 띄우거나 여러 대로 늘려도 숫자가 유지돼요.

코드는 짧아요.

fun allow(name: String, identifier: String, limit: Long, window: Duration): Boolean {
    return try {
        redisService.increment("$KEY_PREFIX$name:$identifier", window) <= limit
    } catch (e: Exception) {
        log.warn("[allow] 요청 횟수 확인 실패: {} ({})", name, e.message)
        true
    }
}
fun increment(key: String, duration: Duration): Long {
    val count = values.increment(key) ?: 0L
    if (count == 1L) {
        redisTemplate.expire(key, duration)
    }
    return count
}

Redis의 INCR는 키의 숫자를 1 올리고 올린 값을 돌려줘요. 키가 없으면 0에서 시작해서 1을 돌려줘요. 그래서 돌려받은 값이 1이면 이번 구간의 첫 요청이라는 뜻이고, 그때만 만료 시간을 걸어요. 만료되면 키가 사라지고, 다음 요청은 다시 1부터 세요.

이 방식을 고정 구간 방식이라고 해요. 10분 구간이 시작된 뒤 60번까지 허용하고, 10분이 지나면 처음부터 다시 세요. 구간 경계에서 짧게 두 배까지 허용될 수 있다는 약점이 있지만, 남용을 막는 목적에는 충분했어요.

INCR는 Redis 안에서 한 번에 일어나는 연산이라, 요청 두 개가 동시에 와도 숫자가 꼬이지 않아요. 읽고, 더하고, 쓰는 세 단계를 직접 하면 그 사이에 다른 요청이 끼어들 수 있어요.

기능마다 다른 한도

한도는 기능마다 이름을 붙여 따로 세요. 키가 rate:이름:IP라서, 분석을 많이 했다고 피드백을 못 보내지는 않아요.

기능 한도
분석 10분에 60번
심층 분석 10분에 60번
피드백 1시간에 20번

사람이 정상적으로 쓰면 닿지 않을 만큼 넉넉하게 잡았어요. 제한의 목적은 평범한 사용자를 막는 게 아니라, 프로그램으로 마구 보내는 요청을 막는 것이에요.

분석은 채팅처럼 결과를 흘려보내는 방식이라, 한도를 넘으면 오류 응답 대신 채팅 말풍선으로 "요청이 너무 많아요. 잠시 뒤에 다시 시도해 주세요."를 보내고, 다시 시도 버튼을 붙여요.

Redis가 죽으면 통과시켜요

위 코드의 catch가 중요해요. Redis에 연결할 수 없으면 요청을 막지 않고 통과시켜요.

반대로 막는 쪽을 고를 수도 있었어요. 하지만 그러면 Redis가 잠깐 멈출 때 서비스 전체가 멈춰요. 요청 제한은 남용을 막는 안전장치이지 서비스의 핵심 기능이 아니에요. 안전장치가 고장 났다고 서비스가 멈추는 것보다, 잠깐 제한 없이 도는 편이 나아요. 대신 경고 로그를 남겨서 알 수 있게 했어요.

IP는 프록시 뒤에서 제대로 읽어야 해요

IP별로 세려면 요청한 사람의 IP를 정확히 알아야 해요. 서버 앞에 리버스 프록시를 두면, 그대로 읽을 때 모든 요청이 프록시의 IP로 보여요. 그러면 모든 사용자가 한도 하나를 나눠 쓰게 돼요.

운영 설정에서 스프링이 프록시가 붙여 준 헤더를 읽도록 했어요.

forward-headers-strategy: framework

그리고 컨트롤러에서는 @ClientIp라는 어노테이션 하나로 IP를 받아요. 헤더를 어떻게 읽는지는 한 곳에서만 처리하고, 각 컨트롤러는 신경 쓰지 않아요.

나가는 요청: 동시에 몇 개까지만

이제 반대 방향이에요. 분석하다 보면 커밋 수백 개의 상세 정보를 GitHub에 물어야 해요. 하나씩 차례로 물으면 너무 느리고, 한꺼번에 다 보내면 GitHub가 "너무 많은 요청을 동시에 보냈다"며 막아요.

그래서 "목록을 받아서, 동시에 최대 몇 개까지만 실행하는" 도구를 만들었어요.

fun <T, R> mapLimit(items: List<T>, limit: Int, task: (T) -> R): List<R> {
    val semaphore = Semaphore(limit, true)
    val futures = items.map { item ->
        async {
            semaphore.acquire()
            try {
                task(item)
            } finally {
                semaphore.release()
            }
        }
    }
    return futures.map { it.await() }
}

세마포어는 허가증 묶음이에요. limit장의 허가증을 만들어 두고, 작업은 허가증을 받아야 시작할 수 있어요. 끝나면 돌려줘요. 허가증이 다 나가 있으면 다음 작업은 하나가 돌아올 때까지 기다려요.

생성자의 두 번째 인자 true는 공정 모드예요. 먼저 기다린 작업이 먼저 허가증을 받아서, 뒤에 온 작업이 계속 끼어드는 바람에 오래 굶는 작업이 생기지 않아요. 돌려주는 결과는 실행 순서와 상관없이 처음 받은 목록 순서 그대로예요.

finally에서 돌려주는 것도 중요해요. 작업이 실패해도 허가증은 꼭 돌아와야 해요. 그렇지 않으면 실패할 때마다 허가증이 한 장씩 사라져서, 결국 아무 작업도 시작하지 못해요.

기다리는 스레드가 많아도 괜찮은 이유

위 코드는 작업마다 스레드를 하나씩 만들고, 대부분은 허가증을 기다리며 멈춰 있어요. 커밋이 500개면 스레드 500개가 생기는 셈이에요. 보통의 스레드라면 부담스러운 숫자예요.

하지만 이 실행기는 가상 스레드를 써요.

private val executor = Executors.newVirtualThreadPerTaskExecutor()

가상 스레드는 기다리는 동안 운영체제 스레드를 붙잡고 있지 않아요. 수백, 수천 개를 만들어도 실제로 일하는 것은 몇 개뿐이에요. 덕분에 "작업마다 스레드 하나, 동시 실행은 세마포어로 제한"이라는 단순한 구조를 그대로 쓸 수 있었어요.

같은 도구를 탐색 화면에서도 써요. 여러 동아리의 GitHub 조직에서 레포 목록을 모을 때, 조직 열두 곳씩 동시에 물어요.

정리

  • 요청 횟수는 Redis의 INCR와 첫 요청에만 거는 만료 시간으로 셀 수 있어요.
  • 기능마다 이름을 나눠 세고, 한도는 정상 사용에 닿지 않게 넉넉히 잡아요.
  • 제한 장치가 고장 나면 서비스를 막지 말고 통과시키고 기록을 남겨요.
  • 프록시 뒤의 서버는 전달된 헤더로 실제 IP를 읽어야 해요.
  • 나가는 요청은 세마포어로 동시 실행 수를 제한하고, 허가증은 finally에서 꼭 돌려줘요.
  • 가상 스레드를 쓰면 기다리는 스레드가 많아도 부담이 적어요.
다음 글 팀 프로젝트 기여도를 대화로 풀어 주는 서비스를 만든 이유