보안 필터 강화
목차
BotBlockFilter에 /api/remote-support/ 경로 예외를 추가하면서, 겸사겸사 Rate Limit 임계값이랑 공격 패턴 감지 범위도 같이 손댔다. 단일 이슈 처리라기보다 필터 전반을 한번 점검하는 식으로 접근했음.
운영 서버에 붙어있는 이 보안 필터는 크게 세 가지를 담당한다: IP 기반 Rate Limit, 봇/공격 패턴 감지, 블랙리스트 자동 등록. 각각은 독립적이면서도 파이프라인 형태로 연결돼 있어서 앞단 판단 결과가 뒷단에 영향을 준다. 봇 UA 감지에서 걸리면 Rate Limit 체크도 건너뛰고 바로 차단으로 떨어지는 식.
처리 흐름과 이번 변경
요청 수신
→ 화이트리스트 IP 확인
→ 봇 UA / 의심 경로 감지
→ Rate Limit 체크 (Redis 슬라이딩 윈도우)
→ 차단 / 블랙리스트 등록 / 통과
이 흐름이 그림으로 보면 깔끔한데, 실제 운영에서는 "어느 단계에서 막혔나?"를 추적하는 게 꽤 번거롭다. 각 단계마다 차단 이유를 로그에 남겨두지 않으면 나중에 분석할 때 고생한다. 이번에 로그 구조도 같이 정리하려다가 범위가 커질 것 같아서 다음으로 미뤘다.
이번 변경 내용을 표로 정리하면:
| 항목 | 기존 | 변경 후 |
|---|---|---|
| Rate Limit 임계값 | 완화 상태 | 강화 (1분 기준 하향) |
| 공격 패턴 감지 | 기본 | 의심 경로 + 페이로드 확장 |
| 블랙리스트 | 수동 | DB 자동 등록 |
임계값이 완화된 채로 방치된 이유는 거의 항상 비슷하다. "정상 트래픽이 막혔다" 신고 이후 급하게 수치를 올려놓고 그대로 잊어버리는 패턴. 그 신고의 원인이 해결됐거나 트래픽 패턴이 바뀌었더라도, 한번 건드린 설정은 다시 조이기 부담스럽다. 이번에 확인해보니 완화 이유가 이미 사라진 상태였고, 그냥 정리했음.
공격 패턴 감지 쪽은 기본 패턴 외에 의심 경로와 페이로드 조합을 추가했다. 특정 경로를 탐색하는 것 자체는 봇 UA 없이도 가능하기 때문에, UA만 보는 게 아니라 "이 경로를 이 방식으로 찌르는 요청"을 패턴으로 정의하는 방향으로 확장했다.
예외 경로와 AJAX 처리
예외 경로 설계가 생각보다 꼼꼼하게 챙겨야 하는 영역이다. 헬스체크 엔드포인트나 모니터링 수집 경로가 필터에 막히면 이중으로 문제가 생긴다. 실제 서비스는 정상인데 모니터링이 다운됐다고 알람이 울리거나, 헬스체크 실패로 로드밸런서가 서버를 드롭시키는 상황. /api/remote-support/ 경로가 그런 케이스였고, 명시적으로 필터 스킵 목록에 넣었다.
예외 경로를 관리하는 방식은 크게 두 갈래다:
- 하드코딩: 코드에 직접 박아놓는다. 빠르고 단순하지만 변경마다 배포가 따라온다.
- DB/설정 파일: 런타임에 읽어서 적용한다. 유연하지만 캐싱 전략을 제대로 짜지 않으면 요청마다 DB를 찌르는 구조가 된다.
이번엔 하드코딩으로 처리했다. 예외 경로 자체가 자주 바뀌는 성격이 아니라서 굳이 동적으로 관리할 이유가 없었음. 대신 예외 목록을 한 곳에만 두는 걸 철저히 지켰다. 여러 미들웨어에 흩어지면 나중에 "이 경로가 왜 안 막히지?" 하고 전체 코드를 뒤져야 하는 일이 생긴다.
SKIP_PATHS = [
"/health",
"/metrics",
"/api/remote-support/",
]
def should_skip(path: str) -> bool:
return any(path.startswith(p) for p in SKIP_PATHS)
단순하지만 목록 관리가 한 파일 한 곳에 집중된다는 게 중요하다. 예외 처리 로직이 여러 군데로 분산되는 순간, "왜 이 경로는 안 막히지?" 라는 질문의 답을 찾는 데 불필요한 시간이 든다.
AJAX 요청을 Rate Limit 카운트에서 제외한 것도 비슷한 맥락인데, 이건 트레이드오프가 있다. 페이지 로드 시 AJAX 요청이 여러 개 동시에 나가는 구조에서 동일한 임계값을 적용하면 정상 사용자가 막힌다. 구분 기준은 보통 X-Requested-With: XMLHttpRequest 헤더인데, 이 헤더는 위조 가능하다. 그래서 "AJAX니까 무제한"이 아니라 별도의 더 넉넉한 버킷을 쓰는 방식이 맞다. 아예 카운트에서 빼버리면 헤더 하나 붙여서 대량 요청하는 스크립트를 못 막게 된다.
블랙리스트 자동 등록
수동 블랙리스트의 한계는 단순하다. 공격 감지와 대응 사이에 사람이 개입하는 구간이 반드시 존재한다. 야간이나 주말이면 그 구간이 몇 시간으로 늘어난다. 자동 등록은 이 구간을 없애는 게 목적이고, 구조 자체는 복잡하지 않다.
def on_attack_detected(ip: str, reason: str, ttl_seconds: int = 3600):
db.blacklist.upsert(
{"ip": ip},
{
"ip": ip,
"reason": reason,
"expires_at": now() + ttl_seconds,
"auto": True,
}
)
cache.invalidate("blacklist")
notify_slack(f"[자동차단] {ip} | {reason}")
세 가지가 핵심이다.
첫째, TTL 설정. 영구 차단은 오탐 복구가 안 된다. 로드 테스트, 배치 작업, CDN 공유 IP 등 합법적인 트래픽이 걸릴 수 있고, 그때 영구 차단이 걸려 있으면 복구 과정 자체가 번거롭다. TTL을 두면 최악의 경우 시간이 지나면 자동 해제된다.
둘째, 캐시 무효화. DB에 넣고 끝나면 기존 캐시에서 해당 IP를 계속 통과시킨다. 등록 직후 캐시를 날려야 다음 요청부터 바로 막힌다. 이걸 빠뜨리면 자동 등록 기능이 있으면서도 실시간 차단이 안 되는 이상한 상태가 된다.
셋째, 알림 연동. 자동 등록이 너무 조용하면 오탐이 쌓이는 걸 모른다. 차단이 발생할 때마다 슬랙/디스코드로 알림을 보내고, 담당자가 오탐이라고 판단하면 수동으로 해제하거나 화이트리스트에 올리는 흐름이 갖춰져야 한다. 자동화 자체보다 자동화를 관찰하는 구조가 더 중요하다.
오탐 관리가 자동화된 시스템에서 제일 귀찮은 부분이다. TTL을 너무 짧게 잡으면 자동 차단의 의미가 없고, 너무 길게 잡으면 정상 IP가 오래 막힌다. 이건 실제 트래픽 패턴을 보면서 조정해야 하는 값이지 코드 수준에서 정답을 낼 수 있는 문제가 아님. 처음에는 보수적으로(짧은 TTL), 오탐 빈도 확인 후 늘리는 방향이 안전하다.
이번 작업 자체는 규모가 크지 않았다. 그런데 이런 류의 작업이 미루기 쉬운 이유는, 안 해도 당장은 아무 문제가 생기지 않기 때문이다. 임계값이 완화 상태로 방치됐던 것도 그래서다. 실제로 뭔가 터지면 그때 조이는 거고, 조용하면 굳이 건드릴 이유가 없어 보인다. 그런데 그 사이에 조금씩 느슨해진 것들이 쌓이면, 어느 순간 한꺼번에 정리해야 하는 상황이 온다. 주기적으로 한번씩 훑어보는 게 필요한 이유가 거기 있다.
댓글 0
첫 댓글 달아줘.