본문 바로가기
Dev/Article

Throttling / Rate limiting / Circuit Breaker

by Luigi.yoon 2023. 12. 7.

분산 시스템에서 고가용성, 안정성, 자원 보호를 위해서 사용하는 방법들입니다.


✅ 1. 각 개념 설명

🔹 Rate Limiting (속도 제한)

  • 정의: 단위 시간 동안 허용되는 최대 요청 수를 제한
  • 목적: API 남용 방지, 공정한 자원 사용 보장
  • 예시: “사용자는 1분에 100건 요청 가능, 초과 시 429 오류 반환”

🔹 Throttling (스로틀링, 속도 조절)

  • 정의: 사용량이 많을수록 요청을 일시적으로 지연하거나 속도를 제한
  • 목적: 시스템 과부하 방지, 리소스 보호
  • 예시: “요청이 몰리면 응답을 500ms 지연하거나, 초당 처리 수 제한”

🔹 Circuit Breaker (서킷 브레이커)

  • 정의: 외부 시스템 호출 실패율이 높으면 해당 호출을 **일시적으로 차단(Open)**하고, 일정 시간 후 회복 상태를 **테스트(Half-Open)**하여 정상화되면 다시 연결(Closed)
  • 목적: 장애 전파 방지, 전체 시스템 안정성 확보
  • 예시: “DB 응답 실패율이 50% 이상이면 1분간 DB 호출을 차단”

✅ 2. 비교 분석 표

항목 Rate Limiting Throttling Circuit Breaker
🔧 핵심 기능 요청 수 제한 속도 조절, 지연 장애 서비스 호출 차단
🎯 주요 목적 남용 방지, 자원 보호 과부하 방지 장애 확산 방지
🧠 적용 방식 고정 제한값 (예: 100req/min) 실시간 리소스 기반 동적 조절 상태 기반 차단(Open/Closed)
⚠️ 오류 응답 429 Too Many Requests 없음 or 지연된 응답 fallback 응답 or 즉시 실패
🧪 상태 관리 없음 없음 있음 (Closed/Open/Half-Open)
🧩 적용 대상 API 요청, 사용자별 트래픽 네트워크, DB, API 요청 외부 시스템 호출 (DB, API)
👥 사용자 경험 갑작스러운 실패 가능 지연되지만 성공 가능 빠른 실패 or 대체 응답
📦 기술 예시 NGINX, AWS API Gateway Istio, Envoy, Redis 기반 큐 Resilience4j, Hystrix
 

✅ 3. 비유로 이해하기

개념 비유
Rate Limiting 놀이공원에서 “1시간에 100명만 입장 가능” 규칙
Throttling 입장 대기줄이 길면 천천히 입장시키는 것
Circuit Breaker 놀이기구가 고장 났을 때 일시적으로 폐쇄하고 점검 후 재개장
 

✅ 4. 함께 사용하는 경우

복잡한 시스템에서는 아래처럼 조합하여 사용하는 것이 일반적입니다:

  • Rate Limiting으로 API 남용을 방지
  • Throttling으로 시스템 부하를 조절
  • Circuit Breaker로 불안정한 외부 서비스 차단

예시 시나리오:

“사용자 요청이 초당 100건 넘으면 Throttle.
1분에 500건 넘으면 429 응답(Rate Limit).
외부 DB 오류율 60% 넘으면 1분간 호출 차단(Circuit Breaker).”


✅ 5. 결론 요약

항목 Rate Limiting Throttling Circuit Breaker
✅ 핵심 역할 요청 수 제한 처리 속도 조절 장애 확산 방지
🧠 똑똑한 동작 ❌ 정적 ✅ 동적 ✅ 상태 기반
🧩 사용 시기 사용량 제어 자원 보호 외부 시스템 불안정 시
🤝 보완 관계 함께 사용 가능 함께 사용 가능 함께 사용 권장
 

 

✅ Redis 성능 기준

연산 종류 처리 속도
단순 GET/SET 10만~30만 QPS (싱글 인스턴스 기준)
INCR / EXPIRE 8만~15만 QPS
Lua Script 실행 복잡도에 따라 수천~수십만 QPS 가능
List/ZSet 삽입/삭제 수만 QPS 이상 가능 (데이터 길이에 따라 다름)
 

Redis는 싱글 스레드 기반의 인메모리 DB로서, 단순 연산에 매우 빨라, 수만 ~ 수십만 대기열 관리용도로 충분합니다.

 

Throttling / Queueing / Rate Limiting 를 사용한 분산 환경에서의 사용자 대기열 시스템 예제

  •  

✅ 목표 재정의

기능 목적
컨테이너 간 접속 상태 공유 수평 확장 대응
사용자 요청을 대기열 방식으로 제어 동시 접속자 제한
TTL 사용 중복 삽입 방지, 유효 시간 관리 (단 너무 짧게 설정 ❌)
대기 중 사용자에게 정보 제공 대기 순번, 예상 시간
초당 허용량 제한 급격한 요청 폭주 방어
사용자 대기 지속 시간 보장 TTL 만료 시점이 지나도 처리가 보장되도록 함

✅ Redis 설계

Redis 키 타입 설명
entry:zqueue ZSet 대기열 (score: 입장 timestamp 또는 증가값)
entry:user:{userId} String (TTL 5~10분) 중복 삽입 방지용
rate:tokens String + TTL 초당 요청 제한 토큰 관리
entry:metadata:{userId} Hash or String 사용자별 요청 정보/상태 저장 (선택적)
 

✅ 전체 로직 흐름 (사용자 1명 기준)

[1] 클라이언트 요청 → Gateway 진입
    └─ 헤더 또는 세션에서 userId 추출

[2] Redis에 이미 entry:user:{userId} TTL 키가 있다면:
    └─ 이미 대기열에 있음 → 순번 및 예상 시간 응답

[3] 없다면:
    ├─ ZADD entry:zqueue userId (score = timestamp 또는 counter)
    ├─ SETEX entry:user:{userId} "1" TTL 10분
    └─ 대기 metadata 저장 가능 (선택)

[4] ZRANK entry:zqueue userId → 대기 순번 확인
    ├─ 순번 < 허용 동시 처리 수 (ex. 100명): 통과
    └─ 순번 ≥ 허용치: 대기 메시지 응답

[5] 초당 제한: Redis Token Bucket(Lua script) → 실패 시 429

[6] 처리 후:
    ├─ ZREM entry:zqueue userId
    ├─ DEL entry:user:{userId}
    └─ metadata 정리

 


✅ TTL 전략

TTL 항목 적정 값 이유
entry:user:{userId} 5~10분 대기 중 재삽입 방지, 유실 방지
Token Bucket 1~2초 TTL or sliding window Redis 내 자동 갱신
entry:metadata 사용자 처리 완료 후 즉시 삭제 또는 10분  

✅ UX 개선 예시 응답 메시지

{
  "status": 429,
  "message": "현재 대기열 위치: 152 / 예상 대기 시간: 약 30.4초",
  "retry_after_ms": 30400,
  "userId": "abc123",
  "entry_time": "2025-06-30T11:30:21.000Z"
}

 


✅ 초당 허용량 제한 (Token Bucket)

  • Redis Lua script 기반 구현 (replenishRate, burstCapacity)
  • 전역 키 or 사용자/경로별 키 사용
  • Redis TTL로 부하 완화

📦 전체 구성요소 요약

요소 설명
QueueGatewayFilter Redis ZSet 기반 대기열 필터
RequestRateLimiter Spring Cloud Gateway 기본 제공 QPS 제한
Redis TTL 키, ZSet, Token 관리
application.yml Gateway + Redis 설정 포함

 


✅ application.yml

spring:
  redis:
    host: localhost
    port: 6379
    timeout: 2000
    lettuce:
      pool:
        max-active: 50
        max-idle: 10
        min-idle: 5

  cloud:
    gateway:
      routes:
        - id: entry-api
          uri: http://localhost:8081
          predicates:
            - Path=/entry
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 5   # 초당 5건 허용
                redis-rate-limiter.burstCapacity: 10  # 버스트 처리 허용
                key-resolver: "#{@userKeyResolver}"
            - name: RedisQueueFilter

✅ KeyResolver (userId 기준 제한)

@Component("userKeyResolver")
public class UserKeyResolver implements KeyResolver {
    public Mono<String> resolve(ServerWebExchange exchange) {
        return Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-User-Id"));
    }
}

 


✅ 3. GatewayFilter (RedisQueueFilter)

@Component("RedisQueueFilter")
public class RedisQueueFilter implements GatewayFilter {

    private static final String ZQUEUE = "entry:zqueue";
    private static final String PREFIX = "entry:user:";
    private static final int MAX_ACTIVE = 100;
    private static final int TTL_SECONDS = 600;
    private static final int AVERAGE_PROCESS_MS = 200;

    private final StringRedisTemplate redis;

    public RedisQueueFilter(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String userId = extractUserId(exchange);
        if (userId == null) return reject(exchange, "User ID 누락");

        String userKey = PREFIX + userId;

        return Mono.fromCallable(() -> {
            // TTL로 중복 삽입 방지
            Boolean alreadyQueued = redis.hasKey(userKey);
            if (Boolean.FALSE.equals(alreadyQueued)) {
                // 사용자 삽입
                long score = System.currentTimeMillis();
                redis.opsForZSet().add(ZQUEUE, userId, score);
                redis.opsForValue().set(userKey, "1", Duration.ofSeconds(TTL_SECONDS));
            }
            return true;
        }).flatMap(_ -> {
            Long position = redis.opsForZSet().rank(ZQUEUE, userId);
            if (position == null) return reject(exchange, "대기열 순위 조회 실패");

            if (position >= MAX_ACTIVE) {
                long waitMs = (position - MAX_ACTIVE + 1) * AVERAGE_PROCESS_MS;
                return reject(exchange, String.format("대기 위치: %d / 예상 시간: %.1f초", position + 1, waitMs / 1000.0));
            }

            return chain.filter(exchange).doFinally(signal -> {
                redis.opsForZSet().remove(ZQUEUE, userId);
                redis.delete(userKey);
            });
        });
    }

    private String extractUserId(ServerWebExchange exchange) {
        return exchange.getRequest().getHeaders().getFirst("X-User-Id");
    }

    private Mono<Void> reject(ServerWebExchange exchange, String message) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
        DataBuffer buffer = response.bufferFactory().wrap(message.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }
}