군만두의 IT 개발 일지

[스터디9] 06. 처리율 제한 장치의 설계 - 실습 본문

학습일지/시스템 설계

[스터디9] 06. 처리율 제한 장치의 설계 - 실습

mandus 2025. 8. 3. 16:33

목차

    4장. 처리율 제한 장치의 설계

    처리율 제한 장치의 사용 예시

    • 초당 게시글 작성 횟수 제한: 사용자가 초당 2회 이상 게시글을 등록할 수 없도록 설정
    • IP별 계정 생성 횟수 제한: 같은 IP 주소에서 하루에 10개 이상의 계정 생성 시도를 제한
    • 디바이스별 리워드 요청 제한: 동일한 디바이스에서 주당 5회 이상 리워드 요청이 불가하도록 설계
    • API 호출 횟수 제한: 예를 들어, 트위터 API는 3시간 동안 300개 트윗만 올릴 수 있도록 제한함
    • 분당 읽기 요청 제한: 구글 Docs API는 사용자당 분당 300번의 읽기 요청만 허용함

    이외에도 일상 생활에서의 예시가 있을지 고민하다가 티켓팅 시스템의 처리율 제한 장치를 간단히 구현해보기로 했다.

    1단계 문제 이해 및 설계 범위 확정

    Q: 초당 수만 건의 티켓 구매 요청이 몰리는 상황에서 어떻게 시스템을 보호할까?
    A: Rate Limiter를 통한 트래픽 제어, 적절한 알고리즘 선택, Redis 기반 분산 처리, Fail-safe 전략

    비즈니스 요구사항

    • 티켓팅 서비스: IU 콘서트처럼 인기 티켓 오픈 시 트래픽 급증
    • 공정성: 동일한 사용자가 시스템을 독점하지 못하도록 제한
    • 가용성: Rate Limiter 장애가 전체 서비스를 다운시키면 안 됨
    • 확장성: 여러 서버에서 일관된 Rate Limiting 적용

    기술적 요구사항

    • 응답시간: Rate Limit 검사는 50ms 이하
    • 메모리 효율: 사용자 당 최소 메모리 사용
    • 정확성: 분산 환경에서도 정확한 카운팅
    • 유연성: 다양한 제한 규칙 지원

    2단계 개략적 설계안 제시 및 동의 구하기

    ▲ 티켓팅 서비스 아키텍처
    Q: Rate Limiter를 어디에 배치할까?
    A:
    - 클라이언트측: 신뢰할 수 없음 (조작 가능)
    - 서버측: ✅ 안전하고 정확한 제어
    - 미들웨어/게이트웨이: 좋은 선택지이지만 지연시간 증가

    Rate Limiter 알고리즘 비교

    알고리즘 메모리 효율 정확도 버스트 허용 구현 복잡도 티켓팅 적합도
    Token Bucket ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ 최적
    Leaky Bucket ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐
    Fixed Window ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐
    Sliding Window Log ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐
    🎫 Token Bucket을 선택한 이유
    - 티켓 오픈 순간의 버스트 트래픽을 자연스럽게 처리 가능
    - 사용자에게 "여러 번의 기회"를 주는 직관적인 모델
    - Redis와 Lua 스크립트로 효율적 구현 가능

    3단계 상세 설계

    Token Bucket 동작 원리

    1. 토큰 저장소: 각 사용자별로 토큰 버킷 생성
    2. 토큰 보충: 일정한 속도(refill rate)로 토큰 추가
    3. 요청 처리: 토큰이 있으면 소모하고 요청 허용
    4. 요청 거부: 토큰이 없으면 Rate Limit 발생
    1) 티켓 구매: 용량 3개, 초당 1개 보충
       초기: [🪙🪙🪙] → 1번째 구매 → [🪙🪙] → 2번째 구매 → [🪙] → 3번째 구매 → []
       4번째 구매 시도 → RATE_LIMITED
       60초 후: [🪙🪙🪙]
    
    2) 일반 조회: 용량 10개, 초당 2개 보충
    3) IP 기반: 용량 20개, 초당 5개 보충 (익명 사용자)

    애플리케이션 설정 (application.yml)

    # Rate Limiter 설정
    rate-limiter:
      ticket-purchase:
        capacity: 3         # 3개 용량
        refill-rate: 1      # 초당 1개 보충
        window-seconds: 60
      user-request:
        capacity: 10        # 10개 용량
        refill-rate: 2      # 초당 2개 보충
        window-seconds: 60
    
    spring:
      data:
        redis:
          url: redis://localhost:6379
          timeout: 2000ms
          lettuce:
            pool:
              max-active: 10
              max-idle: 10
              min-idle: 1

    4단계 마무리

    API 엔드포인트

    Method Endpoint 설명 Rate Limit
    GET /api/tickets 티켓 목록 조회 사용자 요청 (10개/분)
    POST /api/tickets/purchase 티켓 구매 티켓 구매 (3개/분)
    GET /api/tickets/rate-limit-status Rate Limit 상태 사용자 요청 (10개/분)
    GET /api/test/rate-limit 테스트용 엔드포인트 사용자 요청

    Token Bucket Rate Limiter 서비스

    @Service
    public class TokenBucketRateLimiter {
        
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
        
        @Autowired
        private RedisScript<Long> tokenBucketScript;
        
        public boolean isAllowed(String key, int capacity, int refillRate, int requestedTokens) {
            String redisKey = "token_bucket:" + key;
            long currentTime = System.currentTimeMillis() / 1000;
            
            try {
                Long result = redisTemplate.execute(
                    tokenBucketScript,
                    List.of(redisKey),
                    String.valueOf(capacity),
                    String.valueOf(refillRate),
                    String.valueOf(currentTime),
                    String.valueOf(requestedTokens)
                );
                
                if (result == null) {
                    logger.error("Redis script returned null for key: {}", key);
                    return false; // Fail Closed
                }
                
                return result == 1;
            } catch (Exception e) {
                logger.error("Rate limiter error for key: " + key, e);
                return simpleRateLimit(key, capacity);
            }
        }
        
        // 기본 1개 토큰 요청
        public boolean isAllowed(String key, int capacity, int refillRate) {
            return isAllowed(key, capacity, refillRate, 1);
        }
    }

    Lua 스크립트 (Redis 원자적 연산)

    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refillRate = tonumber(ARGV[2])
    local currentTime = tonumber(ARGV[3])
    local requestedTokens = tonumber(ARGV[4])
    
    -- 현재 토큰 정보 가져오기
    local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
    local tokens = tonumber(bucket[1]) or capacity
    local lastRefill = tonumber(bucket[2]) or currentTime
    
    -- 토큰 보충 계산
    local tokensToAdd = math.floor((currentTime - lastRefill) * refillRate)
    tokens = math.min(capacity, tokens + tokensToAdd)
    
    -- 요청된 토큰 수만큼 사용 가능한지 확인
    if tokens >= requestedTokens then
        -- 토큰 소모
        tokens = tokens - requestedTokens
        
        -- Redis에 업데이트
        redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', currentTime)
        redis.call('EXPIRE', key, 3600) -- 1시간 TTL
        
        return 1 -- 허용
    else
        -- 현재 상태만 업데이트 (토큰은 소모하지 않음)
        redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', currentTime)
        redis.call('EXPIRE', key, 3600)
        
        return 0 -- 거부
    end

    Spring Interceptor

    @Component
    public class RateLimitInterceptor implements HandlerInterceptor {
        
        @Autowired
        private RateLimitService rateLimitService;
        
        @Override
        public boolean preHandle(HttpServletRequest request, 
                               HttpServletResponse response, 
                               Object handler) throws Exception {
            
            String userId = extractUserId(request);
            String requestURI = request.getRequestURI();
            String clientIp = rateLimitService.getClientIpAddress(request);
            boolean allowed = true;
            String limitType = "";
            
            try {
                // 티켓 구매 API - 엄격한 제한
                if (requestURI.contains("/tickets/purchase")) {
                    limitType = "ticket_purchase";
                    if (userId != null) {
                        allowed = rateLimitService.isTicketPurchaseAllowed(userId);
                    } else {
                        allowed = rateLimitService.isIpRequestAllowed(clientIp);
                    }
                }
                // 일반 API 요청
                else {
                    limitType = "user_request";
                    if (userId != null) {
                        allowed = rateLimitService.isUserRequestAllowed(userId);
                    } else {
                        allowed = rateLimitService.isIpRequestAllowed(clientIp);
                    }
                }
            } catch (Exception e) {
                logger.error("Rate limit check failed - blocking for safety", e);
                allowed = false; // Fail Closed
            }
            
            if (!allowed) {
                sendRateLimitErrorResponse(response, limitType);
                return false;
            }
            
            return true;
        }
        
        private String extractUserId(HttpServletRequest request) {
            // 헤더에서 사용자 ID 추출
            return request.getHeader("X-User-Id");
        }
    } 

    Rate Limit 서비스

    @Service
    public class RateLimitService {
        
        @Autowired
        private TokenBucketRateLimiter tokenBucketLimiter;
        
        @Autowired
        private RateLimitConfig rateLimitConfig;
        
        /**
         * 티켓 구매 요청 제한 확인
         */
        public boolean isTicketPurchaseAllowed(String userId) {
            RateLimitConfig.TicketPurchase config = rateLimitConfig.getTicketPurchase();
            String key = "ticket_purchase:" + userId;
    
            return tokenBucketLimiter.isAllowed(
                    key,
                    config.getCapacity(),
                    config.getRefillRate()
            );
        }
    
        /**
         * 일반 사용자 요청 제한 확인
         */
        public boolean isUserRequestAllowed(String userId) {
            RateLimitConfig.UserRequest config = rateLimitConfig.getUserRequest();
            String key = "user_request:" + userId;
    
            return tokenBucketLimiter.isAllowed(
                    key,
                    config.getCapacity(),
                    config.getRefillRate()
            );
        }
    
        /**
         * IP 기반 요청 제한 (익명 사용자)
         */
        public boolean isIpRequestAllowed(String ipAddress) {
            String key = "ip_request:" + ipAddress;
            return tokenBucketLimiter.isAllowed(key, 20, 5); // 20개 용량, 초당 5개 보충
        }
    }

    Docker 구성

    version: '3.8'
    
    services:
      redis:
        image: redis:7-alpine
        container_name: ticketing-redis
        ports:
          - "6379:6379"
        command: redis-server --appendonly yes
        volumes:
          - redis_data:/data
        networks:
          - ticketing-network
    
      ticketing-app:
        build: .
        container_name: ticketing-service
        ports:
          - "8080:8080"
        environment:
          - SPRING_PROFILES_ACTIVE=docker
          - SPRING_REDIS_HOST=redis
          - SPRING_REDIS_PORT=6379
        depends_on:
          - redis
        networks:
          - ticketing-network
        restart: unless-stopped
    
    volumes:
      redis_data:
    
    networks:
      ticketing-network:
        driver: bridge

    성능 최적화 전략

    • Connection Pooling: 최대 10개 연결로 효율적 관리
    • Lua 스크립트 캐싱: Redis에서 스크립트 재사용으로 성능 향상
    • TTL 자동 정리: 1시간 후 자동으로 메모리 해제
    • Fail-Closed 전략: Redis 장애 시 안전한 차단으로 시스템 보호

    예상 성능 지표

    • 처리량: 초당 1,000+ TPS
    • 응답시간: 평균 30ms, 99%ile 50ms
    • 메모리: 사용자당 약 100B
    • 동시 사용자: 10,000+ 명

    자동화 테스트 스크립트

    # 티켓 구매 Rate Limit 테스트
    $purchaseSuccess = 0
    $purchaseFailed = 0
    
    for ($i = 1; $i -le 6; $i++) {
        Write-Host "Purchase Request $i" -NoNewline
        try {
            $headers = @{
                "X-User-Id" = "test-user"
                "Content-Type" = "application/json"
            }
            $body = @{
                ticketId = "TICKET_001"
                quantity = 1
            } | ConvertTo-Json
    
            $response = Invoke-RestMethod -Uri "$BASE_URL/api/tickets/purchase" -Method POST -Headers $headers -Body $body
            Write-Host " - SUCCESS" -ForegroundColor Green
            $purchaseSuccess++
        } catch {
            $statusCode = $_.Exception.Response.StatusCode.value__
            if ($statusCode -eq 429) {
                Write-Host " - RATE LIMITED" -ForegroundColor Red
                $purchaseFailed++
            }
        }
        Start-Sleep -Milliseconds 300
    }
    # 예상 결과: 1-3번 SUCCESS, 4-6번 RATE_LIMITED

    Fail-Safe 전략

    Q: Rate Limiter가 실패하면 어떻게 할까?
    AFail-Safe 전략 사용
    - Fail-Closed: Redis 장애 시 모든 요청 차단
    - Circuit Breaker: 연속 실패 시 자동으로 우회 (미구현)
    - Local Fallback: 간단한 in-memory 카운터로 대체
    - 모니터링 알림: 즉시 장애 알림으로 빠른 복구 (미구현)

    분산 환경 대응

    Q: 여러 데이터센터에서 Rate Limiter를 운영한다면?
    A: 분산 전략 사용
    - Redis Cluster: 샤딩을 통한 수평 확장
    - Eventually Consistent: 약간의 불일치는 허용하되 전체적인 제한 유지 (미구현)
    - Regional Limit: 지역별로 독립적인 Rate Limit 적용 (미구현)
    - Global + Local: 글로벌 한도와 로컬 한도를 조합 (미구현)

    실제 테스트 결과

    티켓 구매 Rate Limit 테스트

    ▲ 티켓 구매 테스트 결과

    일반 요청 Rate Limit 테스트

    ▲ 일반 요청 테스트 결과

    Token Recovery 확인

    ▲ 토큰 복구 테스트

    최종 결과

    ▲ 성공적인 테스트 요약

    면접 예상 질문 및 답변

    Q1: Rate Limiter 알고리즘을 어떻게 선택할까?
    A: 비즈니스 요구사항에 따라 선택한다.
    - Token Bucket: 일반적인 웹 서비스 (버스트 허용 중요)
    - Leaky Bucket: 안정적인 처리율이 중요한 결제 시스템
    - Fixed Window: 대용량 트래픽, 단순한 구현이 필요한 경우
    - Sliding Window: 정확한 제한이 필요한 SLA 보장 서비스
    Q2: 분산 환경에서 정확한 카운팅을 어떻게 보장할까?
    A: Redis + Lua 스크립트로 원자적 연산을 보장하고, Eventually Consistent 전략으로 약간의 불일치는 허용하되 전체적인 제한은 유지합니다.
    Q3: Redis가 다운되면 어떻게 할까?
    A: Fail-Closed 전략을 채택하여 안전을 우선시하고, 간단한 local cache로 fallback하며 Circuit Breaker 패턴으로 자동 복구를 지원한다.

    참고 자료

    1. Vagelis Bisbikis, "Fan-Out and Fan-In Patterns: Building a Personalized Feed in Laravel", 2025.02.10, https://medium.com/@vagelisbisbikis/fan-out-and-fan-in-patterns-building-a-personalized-feed-in-laravel-676515f65e03
    2. dewble, "[Architecture]처리율 제한 장치 설계(Rate limiting)", 2023.07.08, https://dewble.tistory.com/entry/designing-a-rate-limiter
    3. 박종훈, "API 제한 설정하기 - 처리율 제한 장치의 설계", 2023.05.16, https://jonghoonpark.com/2023/05/17/처리율-제한-장치-설계
    4. 민동현, "처리율 제한 장치의 설계", 2022.03.18, https://donghyeon.dev/인프라/2022/03/18/처리율-제한-장치의-설계/
    5. aal2525, "가상 면접 사례로 배우는 대규모 시스템 설계 기초 – 처리율 제한 장치", 2025.02.16, https://velog.io/@aal2525/가상-면접-사례로-배우는-대규모-시스템-설계-기초-처리율-제한-장치의-설계

     

    이 글은 『 가상 면접 사례로 배우는 대규모 시스템 설계 기초』 책을 학습한 내용을 정리한 것입니다.

    Comments