서버 보안 필터 강화와 Rate Limit 임계값 조정
목차
보안 필터는 기능 개발보다 손이 덜 가는 것처럼 보이지만, 막상 건드리면 생각보다 복잡하다. 임계값 하나 잘못 잡으면 정상 사용자가 튕겨나가고, 예외 경로 하나 빠뜨리면 헬스체크가 막혀 모니터링 알람이 쏟아진다. 이번 작업이 딱 그 경계선을 다시 그어야 하는 상황이었다.
이번에 손댄 것들
주요 변경은 크게 세 가지였다.
Rate Limit 임계값 강화. 이전에 완화 상태로 두었던 1분 기준 요청 수 상한을 다시 내렸다. 완화해두었던 건 특정 플로우 테스트 때문이었는데, 그 이후 원복을 미루다 보니 그 상태가 운영 기준처럼 굳어버렸다. 이번에 재조정하면서 AJAX 요청은 카운트에서 제외하는 옵션을 별도로 달았다. 페이지 초기 로드 시 AJAX가 연달아 나가는 구조라 동일 기준을 그대로 적용하면 정상 사용자도 금방 걸린다.
공격 패턴 감지 확장. 기본 UA 필터링에 의심 경로 패턴과 페이로드 기반 감지를 추가했다. 봇이나 스캐너가 찌르는 경로들은 어느 정도 패턴이 있다. /actuator, /.env, /wp-admin, /config.json 같은 경로들, 혹은 SQL 인젝션 시그니처를 포함한 쿼리스트링. 이런 걸 잡아내는 규칙을 확장했다.
블랙리스트 자동 등록. 기존에는 의심 IP를 수동으로 등록하던 방식이었는데, 일정 조건을 충족하면 DB에 자동으로 적재되도록 바꿨다. 수동 관리는 대응 속도가 느리고 놓치는 경우가 생긴다. 자동화해두면 공격 패턴이 반복될 때 사람 개입 없이 처리된다.
요청이 들어오면 필터가 아래 순서로 처리한다.
요청 수신
→ 화이트리스트 IP 확인 # 내부 서비스, 신뢰 IP는 여기서 바로 통과
→ 블랙리스트 체크 # DB 자동 등록된 차단 IP
→ 봇 UA / 의심 경로 감지
→ Rate Limit 체크 (Redis 슬라이딩 윈도우)
→ 차단 / 블랙리스트 등록 / 통과
| 항목 | 기존 | 변경 후 |
|---|---|---|
| Rate Limit 임계값 | 완화 상태 (상향된 채로 방치) | 강화 - 1분 기준 하향 |
| AJAX 요청 처리 | 동일 기준 적용 | 카운트 제외 옵션 추가 |
| 공격 패턴 감지 | 기본 UA 필터 | 의심 경로 + 페이로드 확장 |
| 블랙리스트 | 수동 등록 | DB 자동 등록 |
예외 경로 관리가 핵심이다
이번 작업에서 가장 신경 쓴 부분이 예외 경로 처리였다. 헬스체크 엔드포인트나 모니터링 수집 경로가 필터에 걸리면 운영에 직접 영향이 간다. 로드밸런서에서 헬스체크가 실패하면 인스턴스가 아웃 처리되고, 메트릭 수집 경로가 막히면 대시보드가 끊긴다.
예외 처리 방식은 보통 두 가지 접근이 있다.
- 경로 기반 스킵: 특정 경로 패턴은 필터 자체를 통과
- IP + 경로 조합: 지정 IP에서 오는 지정 경로만 스킵
두 번째가 더 안전하지만 관리 포인트가 늘어난다. 지금 구조에서는 경로 기반으로 명시적 제외 목록을 유지하는 방식을 쓰고 있다. 중요한 건 이 목록이 코드 어딘가에 묻혀있지 않고 설정으로 빠져 있어야 한다는 점이다. 그래야 배포 없이 조정할 수 있다.
// 예시: 필터 스킵 경로 설정 로딩
private static final List<String> SKIP_PATHS = List.of(
"/actuator/health",
"/actuator/prometheus",
"/internal/ping"
);
public boolean shouldSkip(HttpServletRequest request) {
String uri = request.getRequestURI();
return SKIP_PATHS.stream().anyMatch(uri::startsWith);
}
ActuatorIpFilter 차단 로그에 allowed-ips 목록을 함께 찍도록 바꾼 것도 이번 작업에 포함됐다. 차단이 발생했을 때 로그만 보고도 "어떤 IP들이 허용 목록에 있었는데 이 IP는 없었다"는 걸 바로 파악할 수 있어야 디버깅이 빠르다. 기존에는 차단 여부만 찍혀서 허용 목록을 따로 뒤져야 했다.
Redis 슬라이딩 윈도우를 쓸 때 챙겨야 할 것
Rate Limit 구현체로 Redis 슬라이딩 윈도우를 쓰는 게 고정 윈도우보다 나은 건 경계 구간 버스팅 공격에 강하기 때문이다. 고정 윈도우는 윈도우가 리셋되는 순간 직전과 직후에 두 배 요청이 몰려도 통과시킬 수 있다. 슬라이딩 윈도우는 현재 시점 기준으로 과거 N초를 계속 보기 때문에 이 약점이 없다.
다만 Redis가 단일 장애점이 되지 않도록 처리해야 한다. 연결 풀 설정과 타임아웃을 반드시 같이 챙겨야 하고, Redis가 응답하지 않을 때 필터가 어떻게 동작할지 폴백 전략도 있어야 한다. 흔한 선택지는 두 가지다.
- Fail-open: Redis 장애 시 Rate Limit 체크를 스킵하고 통과시킴. 가용성을 우선할 때.
- Fail-closed: Redis 장애 시 요청을 거부함. 보안을 우선할 때.
어느 쪽이 맞다 할 수는 없고, 서비스 성격에 따라 다르다. 다만 명시적으로 결정해두지 않으면 예외 처리 코드에 암묵적으로 어느 한 방향이 선택돼 있는 거라 짚고 넘어가야 한다.
작업 규모 자체는 크지 않았다. 그렇지만 이런 변경이 미루어지면 느슨해진 상태가 기준처럼 굳는다. Rate Limit이 완화된 채로 방치된 것처럼. 작은 수정이지만 제때 하는 게 맞다.
댓글 0
첫 댓글 달아줘.