ZZOL의 효율적인 서버 자기보호 전략

@MJ · 16 min read
Created Date · 2026년 02월 18일 12:02
Updated Date· 2026년 02월 18일 19:02

ZZOL 서비스에 Rate Limiting을 적용했다. 단순히 "요청을 제한하자"가 아니라, 같은 서버 보호인데 HTTP와 WebSocket에서 왜 다른 전략을 써야 하는지, 각 설정값을 왜 그렇게 잡았는지에 대한 판단 과정을 기록한다.

왜 서버 자기보호가 필요했는가

ZZOL은 실시간 멀티플레이어 게임 서비스다. 점심시간에 한 팀이 모여서 미니게임을 하고, 룰렛으로 당첨자를 뽑는다. 사용 패턴이 극단적으로 몰리는 서비스인데, 12시 50분~1시 10분 사이에 전사 동시 접속이 발생하고, 이 20분 동안 방 생성 + 입장 + 게임 시작이 한꺼번에 일어난다.

지금까지 구현한 것들은 "서버가 정상일 때 잘 돌아가게 하는 것"이었다. 스레드풀 튜닝으로 처리량을 최적화하고, 서킷 브레이커로 외부 의존성 장애를 격리하고, Graceful Shutdown으로 배포 시 세션을 보호했다. 하지만 한 가지 시나리오에 대한 답이 없었다.

서버 자체가 감당 못할 트래픽이 들어오면 어떻게 할 것인가.

방 생성 API(POST /rooms) 하나를 호출하면 Redis에 방 데이터를 저장하고, DB에 insert하고, QR 코드를 비동기로 생성하고, Redis Stream에 이벤트를 발행한다. 비용이 가벼운 API가 아니다. 이 API를 초당 100번 호출하면 서버 리소스가 빠르게 고갈된다. WebSocket도 마찬가지다. 한 세션에서 초당 수백 건의 STOMP 메시지를 쏘면, inbound 채널의 스레드풀이 점유되어 정상 사용자의 메시지 처리가 밀린다.

첫 번째 판단: 어디서 제한할 것인가

Rate Limiting을 구현할 때 가장 먼저 내린 결정은 제한 위치였다.

처음에는 애플리케이션에서 전부 처리하려고 했다. Resilience4j에 RateLimiter 모듈이 이미 있으니까, HTTP API에도 WebSocket에도 일관되게 적용할 수 있을 것 같았다. 그런데 한 가지 문제가 있었다. 애플리케이션에서 Rate Limiting을 한다는 건, 요청이 이미 Tomcat 스레드를 점유한 상태에서 거절한다는 뜻이다.

POST /rooms를 예로 들면, 애플리케이션까지 도달한 요청은 이미 서블릿 스레드를 잡고 있다. Rate Limiter가 거절하더라도, Spring의 필터 체인을 타고, 요청 파싱을 하고, 429 응답을 만들어 돌려보내는 비용이 발생한다. 악의적 요청 1000건이 들어오면, 1000개의 스레드가 "거절하기 위해" 점유된다. 서버 보호가 목적인데, 보호하는 과정 자체가 서버에 부하를 준다.

Nginx에서 먼저 자르면 이 비용이 0이다. 앱 서버까지 트래픽이 아예 도달하지 않는다.

그렇다면 WebSocket 메시지도 Nginx에서 처리할 수 있을까? 결론은 불가능하다. Nginx가 제한할 수 있는 건 WebSocket 핸드셰이크(HTTP Upgrade 요청)까지다. 핸드셰이크 이후에는 TCP 스트림이 흐를 뿐이고, 그 위에서 오가는 STOMP 프레임의 내용을 Nginx는 파싱하지 못한다. "이 메시지가 게임 액션인지, 초당 몇 건 보내고 있는지"는 STOMP을 이해하는 애플리케이션만 판단할 수 있다.

결론은 이렇게 정리됐다.

Client → Nginx (HTTP Rate Limit) → Spring Boot (WebSocket Msg Rate Limit)
           |                              |
           +-- IP 기반                     +-- 세션 기반
           +-- 요청 단위 제한                +-- 메시지 단위 제한
           +-- 차단 비용: 거의 0             +-- 비즈니스 컨텍스트 필요
제한 대상 Nginx 애플리케이션
HTTP API 요청 빈도 가능, 여기서 처리 불필요
WebSocket 핸드셰이크 빈도 가능, 여기서 처리 불필요
WebSocket 메시지 빈도 불가능 여기서만 가능

Nginx Rate Limiting: 설정값을 어떻게 잡았는가

Nginx의 limit_req_zone으로 API별 Rate Limiting을 설정했다. 여기서 중요한 건 설정 문법이 아니라 각 값을 왜 그렇게 잡았는가다.

방 생성 API — rate=2r/s, burst=4

ZZOL의 사용 패턴은 "점심시간에 한 팀이 방 하나를 만들어서 게임 한 판 하는 것"이다. 한 사람이 방을 초당 2개 이상 만들 이유가 없다. burst=4는 네트워크 지연으로 클라이언트가 버튼을 연타하는 경우를 허용한다.

방 참여 API — rate=5r/s, burst=10

방 생성보다 여유 있게 잡았다. 이유가 있다. 같은 회사 Wi-Fi를 사용하면 팀원 여러 명이 같은 IP로 잡힌다. burst=10은 한 방의 최대 인원(9명)이 거의 동시에 입장하는 케이스를 허용하기 위한 값이다.

WebSocket 핸드셰이크 — rate=3r/s, burst=5

WebSocket 연결은 한 번 맺으면 유지된다. 초당 3회 이상 핸드셰이크를 시도하는 건 비정상이거나 SockJS fallback 재연결 폭풍이다.

응답 코드: 503 대신 429

Nginx의 기본 Rate Limit 초과 응답은 503(Service Unavailable)이다. 이걸 429(Too Many Requests)로 변경했다. 503은 "서버가 죽었다"는 의미이고, 429는 "네가 너무 많이 보냈다"는 의미다. 클라이언트가 429를 받으면 자신의 요청 빈도가 문제라는 것을 알고 재시도 간격을 늘릴 수 있다. 의미적으로 정확한 응답 코드를 써야 클라이언트 쪽 에러 핸들링도 제대로 동작한다.

두 번째 판단: WebSocket Rate Limiter 알고리즘 선택

왜 Fixed Window인가

Rate Limiting 알고리즘에는 Fixed Window, Sliding Window Log, Token Bucket 등이 있다. 각각 트레이드오프가 다르다.

방식 장점 단점
Fixed Window 구현 단순, 메모리 O(1)/세션 윈도우 경계에서 2×limit 버스트 가능
Sliding Window Log 버스트 완전 차단 요청 히스토리 저장, 메모리 O(N)
Token Bucket 버스트 완화 + 평균 속도 보장 구현 복잡도

Fixed Window를 선택했다. 알려진 한계가 있다. 윈도우 경계(예: t=0.999s와 t=1.000s 사이)에서 순간적으로 2×limit 메시지가 통과할 수 있다. 하지만 이 서비스에서 이게 실질적 위협인지를 따져봐야 한다.

ZZOL의 부하 테스트 실측 데이터에서 정상 사용자 1명의 최대 초당 요청은 1.3건이다. Rate Limit을 20으로 잡았을 때, 경계에서 40건이 통과하는 것과 20건이 통과하는 것의 서버 부하 차이는 무시할 수준이다. 반면 Sliding Window를 적용하면 메시지마다 타임스탬프를 저장해야 하고, 레이싱 게임 중 초당 수십 건의 메시지에 대해 매번 이 비용을 지불해야 한다. 이 서비스 규모에서 Sliding Window의 정밀도는 과잉이라고 판단했다.

왜 Lazy Reset인가

일반적인 Fixed Window 구현은 1초마다 스케줄러를 돌려서 모든 세션의 카운터를 리셋한다. 동시 접속 250명이면 250개를 매초 순회하는데, 당장은 비용이 크지 않지만 설계적으로 깔끔하지 않다. 연결이 0개일 때도 스케줄러가 돌고, 메시지를 보내지 않는 세션까지 매초 건드린다.

대신 Lazy Reset 방식을 사용했다. tryAcquire() 호출 시점에 "마지막 윈도우 시작 시간으로부터 1초가 지났는가?"만 체크하고, 지났으면 그때 리셋한다. 메시지가 오지 않는 세션은 비용이 0이다. 리셋 타이밍이 메시지 도착에 의해 결정되므로 별도 스케줄러가 필요 없다.

synchronized boolean tryAcquire(int limit, long now) {
    lastAccessTime = now;

    // 윈도우 만료 시 리셋 (Lazy Reset)
    if (now - windowStart >= 1000) {
        count = 0;
        windowStart = now;
    }

    return ++count <= limit;
}

세 번째 판단: 레이스 컨디션과 synchronized

처음 구현에서는 AtomicIntegerAtomicLong을 사용했다. 각 필드의 연산은 원자적이니까 동시성 문제가 없을 거라고 생각했다. 하지만 코드 리뷰에서 문제가 발견됐다.

clientInboundChannel은 스레드 풀 기반으로 동작한다. 동일 세션의 SEND 메시지 두 건이 서로 다른 스레드에서 동시에 tryAcquire를 실행할 수 있다. "윈도우 만료 체크 → 리셋 → 카운트 증가"가 세 단계로 분리되어 있기 때문에, AtomicInteger/AtomicLong이 각 연산을 개별로 보호하더라도 세 단계를 하나의 임계 구역으로 묶지 못한다.

Thread A: 윈도우 만료 체크 → false (리셋 스킵)
Thread B: 윈도우 만료 체크 → true  (막 만료됨)
Thread B: count = 0, windowStart = now
Thread A: ++count → 1  ← 이전 윈도우에서 거절되어야 할 요청이 새 윈도우로 스며듦

이전 윈도우에서 거절되어야 할 요청이 새 윈도우의 슬롯을 소비하면서 Rate Limit을 우회하게 된다.

해결 방법으로 synchronized + primitive 필드를 선택했다. SessionCounter는 세션 단위 인스턴스이므로, synchronized를 사용해도 락 경합 범위가 해당 세션에 한정된다. 세션 A의 메시지를 처리할 때 세션 B의 처리가 블로킹되는 일은 없다. CAS(Compare-And-Swap) 기반 lock-free 방식도 고려했지만, 세션 단위 락에서 경합이 거의 발생하지 않는 상황에서 코드 복잡도를 올릴 이유가 없었다.

네 번째 판단: 초과 시 에러를 보낼 것인가

Rate Limit을 초과한 메시지를 어떻게 처리할지 두 가지 선택지가 있었다.

첫째, 클라이언트에 에러 메시지를 보내는 방식. "Rate Limit 초과"라는 에러를 STOMP 에러 프레임으로 전송하면, 클라이언트가 상황을 인지하고 전송 빈도를 줄일 수 있다.

둘째, 조용히 메시지를 드롭하는 방식. 인터셉터에서 null을 반환하면 메시지가 폐기되고, 클라이언트는 아무 응답도 받지 못한다.

두 번째를 선택했다. 이유는 서버 보호라는 목적에 있다. 초당 수십~수백 건의 비정상 메시지가 오는 상황에서, 매 건마다 에러 응답을 보내면 outbound 스레드풀이 에러 응답 처리에 점유된다. Rate Limiting이 서버를 보호하기 위한 장치인데, 그 장치가 서버를 추가로 부하시키면 본말전도다.

정상 사용자가 Rate Limit에 걸릴 가능성은 현실적으로 없다. 초당 1.3건이 최대인 사용 패턴에서 20건 제한에 걸리려면 스크립트를 써야 한다. 스크립트에 에러 메시지를 정중하게 보내줄 필요는 없다.

인터셉터 체인 설계

Rate Limiter는 기존 메트릭 수집 인터셉터와 분리해서 별도 인터셉터로 구현했다. 메트릭 수집과 Rate Limiting은 책임이 다르고, 실행 순서에 의미가 있기 때문이다.

StompPrincipalInterceptor → WebSocketRateLimitInterceptor → WebSocketInboundMetricInterceptor

Principal 설정이 먼저 되어야 Rate Limiter에서 세션을 식별할 수 있고, Rate Limit에서 걸러진 메시지가 메트릭에 잡히면 안 된다. 차단된 메시지까지 메트릭에 포함되면 실제 처리량 수치가 왜곡된다. Rate Limiter가 메트릭 수집보다 앞에 있어야 하는 이유가 이것이다.

정리

서버 자기보호를 위한 Rate Limiting을 두 레이어로 나눠 구현했다.

구분 HTTP API WebSocket 메시지
제한 위치 Nginx Spring 인터셉터
제한 기준 IP 기반 세션 기반
차단 비용 거의 0 (앱 도달 전 차단) 인터셉터 실행 비용
초과 시 동작 429 응답 메시지 드롭 (무응답)

같은 "서버 보호"라는 목적이지만, HTTP와 WebSocket의 프로토콜 특성 때문에 전략이 달라야 했다. HTTP는 요청-응답 기반이니까 앞단에서 자르는 게 효율적이고, WebSocket은 지속 연결 위의 메시지 스트림이니까 메시지 내용을 파싱할 수 있는 애플리케이션에서 처리해야 한다.

설정값은 전부 도메인 사용 패턴과 부하 테스트 데이터에서 도출했다. "점심시간에 한 팀이 방 하나 만들어서 게임 한 판 하는 것"이 정상 사용이고, 그 범위를 벗어나면 비정상이다. Rate Limiting의 핵심은 이 경계를 데이터 기반으로 정의하는 것이었다.