개발 slecs

블랙리스트 자동 등록과 Rate Limit으로 공격 실시간 차단

목차

AiController 내부 클래스에서 Model import가 빠져 있던 버그를 잡았다. 컴파일 에러라 운영 배포 전에 걸렸고, 수정 자체는 한 줄이었지만 왜 빠졌는지 추적하는 데 더 시간이 걸렸다. IDE 자동 import가 리팩터링 과정에서 지워진 케이스. 이런 건 리뷰 단계에서 잡아야 하는데 놓쳤다.

버그 패턴 자체는 늘 비슷하게 반복된다.

패턴 증상
null 체크 순서 오류 NPE (NullPointerException)
경로 예외 처리 누락 필터에서 정상 요청 차단
YAML 공백 혼용 설정값 파싱 실패
import 누락 컴파일 에러

이번 건 마지막 케이스였다. 패턴이 정해져 있으니 로그 보고 원인 찾고 수정하는 루틴이 어느 정도 잡혀가고 있긴 한데, 루틴이 잡힌다는 게 꼭 좋은 게 아니라 같은 실수를 반복하고 있다는 신호이기도 하다. 이번 수정을 계기로 import 관련 정적 분석 룰 하나 추가했다.

블랙리스트 자동 등록 구조

공격성 요청을 감지했을 때 수동으로 IP 블랙리스트에 추가하는 건 대응 속도가 너무 느리다. 새벽 2시에 봇 트래픽이 터지면 아무도 슬랙을 안 보고 있고, 아침에 출근해서 확인하면 이미 수백~수천 건이 들어온 다음이다. 그래서 감지 즉시 DB에 자동 등록하고 다음 요청부터 차단하는 구조가 필요했다.

기본 흐름은 이렇다. 요청이 들어오면 필터 단에서 블랙리스트 여부를 먼저 확인하고, 블랙리스트에 없으면 Rate Limit 카운터를 증가시킨다. 임계치를 초과하면 블랙리스트에 자동 등록하고 현재 요청도 차단한다.

// 개념 수준 의사코드
public class AttackDetectionFilter implements Filter {

    public void doFilter(request, response, chain) {
        String ip = extractIp(request);

        if (blacklistService.isBlocked(ip)) {
            response.sendError(429);
            return;
        }

        long count = rateLimitService.increment(ip, windowSeconds);
        if (count > threshold) {
            blacklistService.register(ip, ttlSeconds);
            notifySlack(ip, count);
            response.sendError(429);
            return;
        }

        chain.doFilter(request, response);
    }
}

구조 자체는 단순한데, 운영에서 신경 써야 할 부분이 몇 가지 있다.

  • 오탐 예외 처리: 로드 테스트나 배치 작업이 공격으로 오해받는 경우가 생긴다. 내부 IP 대역이나 특정 User-Agent는 화이트리스트로 빼두는 게 맞다. 오탐으로 내부 서비스가 차단되면 장애가 된다.
  • TTL 설정: 영구 차단 대신 일정 시간 후 자동 해제되도록 TTL을 건다. 오탐 복구도 되고, 공격자 IP가 바뀌면 이전 IP가 정상 사용자에게 재할당될 수 있기 때문에 영구 차단은 부작용이 있다.
  • 알림 연동: 자동 차단이 발생할 때 슬랙/디스코드로 알림을 보내야 모니터링이 된다. 자동화했다고 신경을 꺼버리면 오탐이 쌓이는 걸 모른다.

블랙리스트 저장소는 Redis로 잡았다. DB에 넣으면 매 요청마다 쿼리가 나가는 부담이 있고, Redis에 캐싱 레이어를 두는 게 응답 속도 면에서 훨씬 낫다. 단, Redis가 죽었을 때 블랙리스트 체크를 건너뛸지 차단할지 정책을 미리 정해야 한다. Fail-open(통과)이냐 Fail-closed(차단)냐의 선택인데, 보안 요구 수준에 따라 다르다.

운영 관점에서 본 Rate Limit

Rate Limit에서 가장 자주 빠지는 함정은 임계치 설정이다. "얼마나 많이 막을 것인가"를 먼저 생각하게 되는데, 사실 "정상 사용자를 막지 않으면서 공격을 차단하는가"가 더 핵심 질문이다. 임계치가 너무 타이트하면 정상 사용자가 피해를 보고 CS가 들어온다. 너무 느슨하면 공격을 막지 못한다.

정상 트래픽 패턴을 먼저 측정하고 거기서 임계치를 잡아야 한다. 추측으로 숫자를 정하면 나중에 반드시 사고가 난다. 최소 1~2주치 로그를 보고 피크 타임 정상 요청량의 2~3배 수준에서 시작하는 게 무난하다.

알고리즘 선택도 중요하다. 고정 윈도우 방식은 구현이 단순하지만 경계 구간 버스팅에 취약하다. 윈도우 끝에 몰아서 요청을 보내면 실제로는 임계치의 2배 요청이 통과할 수 있다. Redis의 슬라이딩 윈도우는 이 문제를 해결하는데, ZRANGEBYSCORE로 현재 윈도우 내 요청 수를 정확히 계산한다.

# Redis 슬라이딩 윈도우 개념
ZADD rate:{ip} {now_ms} {request_id}
ZREMRANGEBYSCORE rate:{ip} 0 {now_ms - window_ms}
ZCARD rate:{ip}
EXPIRE rate:{ip} {window_seconds}

다만 슬라이딩 윈도우는 요청마다 Redis 명령이 여러 번 나가니 Lua 스크립트로 묶어서 원자적으로 처리하는 게 좋다. 여러 명령을 따로 보내면 경쟁 조건이 생길 수 있다.

Redis가 단일 장애점이 되지 않도록 연결 풀과 타임아웃 설정도 함께 챙겨야 한다. 타임아웃을 너무 길게 잡으면 Redis가 느려질 때 전체 요청이 지연된다. 타임아웃 안에 응답이 안 오면 Rate Limit 체크를 건너뛰고 통과시키는 Fail-open 정책이 사용자 경험 면에서는 나은 선택인 경우가 많다.

작업 자체는 크지 않았지만 놓치면 운영에서 직접 맞는 류의 변경이었다. 자동화된 방어 구조가 하나씩 쌓이면서 야간 장애 대응 빈도가 줄어드는 게 체감된다. 설계보다 임계치 튜닝이 더 오래 걸렸다.

댓글 0

첫 댓글 달아줘.