공격 자동 차단과 레이트 리밋으로 운영 보안 강화
목차
WAS2 서버의 공인 IP가 바뀌면서 내부망 통신이 끊겼다. 메트릭 수집, 트레이싱 파이프라인이 조용히 멈춰 있었고, 모니터링 대시보드가 데이터 공백으로 채워지기 시작했을 때 처음 인지했음.
수정 대상은 docker-compose.prod.yml, prometheus-prod.yml, tempo-config.yml 세 파일. 작업 자체는 IP 한 줄 교체지만 이 세 파일이 연결 고리로 엮여 있어서, 하나만 바꾸면 나머지가 계속 구버전 주소를 바라보는 상황이었음.
운영 중 설정 변경이 까다로운 이유가 이거다. 코드는 단순한데 의존 관계가 숨어 있어서 반쪽만 바꾸면 오히려 디버깅이 더 힘들어진다. 이번엔 세 파일을 한꺼번에 수정하고, 배포 전 변경분을 diff로 한 번 더 확인하고 올렸음. 이 루틴이 잡히면 실수는 줄어들지만 완전히 없애려면 결국 설정 값을 중앙에서 관리하거나 서비스 디스커버리를 붙이는 수밖에 없음.
자주 나오는 버그 패턴
| 패턴 | 증상 |
|---|---|
| null 체크 순서 오류 | NPE (NullPointerException) |
| 경로 예외 처리 누락 | 필터에서 정상 요청 차단 |
| YAML 공백 혼용 | 설정값 파싱 실패 |
| import 누락 | 컴파일 에러 |
이번 케이스는 YAML 설정값 문제에 가까웠음. 공백이 섞이거나 앵커 참조가 어긋나면 파싱은 성공해도 엉뚱한 값이 들어가고, 그게 IP 주소라면 연결 오류는 한참 뒤에야 나타난다. 로그 레벨을 올려서 보면 금방 보이는데, 평소엔 INFO 이하로 내려두다 보니 놓치는 경우가 생김.
재현 → 원인 파악 → 최소 범위 수정 → 배포 순서가 루틴으로 잡혀가고 있는데, "최소 범위"가 사실 제일 어렵다. 고치다 보면 주변 코드가 눈에 걸려서 같이 건드리고 싶어지는데, 그러면 배포 후 이슈가 생겼을 때 원인 추적이 복잡해진다. 이번엔 딱 IP 변경 범위만 잡고 나머지는 별도 이슈로 분리했음.
블랙리스트 자동 등록 구조
공격성 요청을 감지했을 때 수동 대응은 속도가 느리다. 담당자가 로그를 보고 판단해서 IP를 직접 막는 사이에 이미 수천 건이 들어온다. DB에 자동 등록하고 다음 요청부터 즉시 차단하는 구조를 만들어두면 사람이 개입하기 전에 이미 막혀 있음.
구조 자체는 단순하다.
def handle_request(ip: str):
if is_blacklisted(ip): # Redis 조회
raise HTTPException(status_code=403)
if is_suspicious(request):
register_blacklist(ip, ttl=3600) # TTL 설정
notify_slack(f"자동 차단: {ip}")
raise HTTPException(status_code=429)
process_request()
실제 운영에서 주의할 점이 몇 가지 있음.
- 오탐 가능성: 내부 로드 테스트, 배치 잡, 파트너사 크롤러가 봇으로 오해받지 않도록 화이트리스트 선처리가 필요하다. 화이트리스트 없이 올렸다가 내부 배치가 통째로 차단된 경험을 가진 팀이 꽤 있음.
- 만료 설정: 영구 차단보다 TTL을 걸어두는 게 맞다. 오탐 복구가 가능하고, 공격자 입장에서도 IP 로테이션을 하기 때문에 영구 차단의 실익이 생각보다 크지 않음.
- 알림 연동: 자동 차단 발생 시 슬랙·디스코드로 알림을 붙여야 한다. 차단이 너무 많이 발생하거나 반대로 전혀 발생하지 않을 때 둘 다 이상 신호임.
is_suspicious 판단 로직이 블랙박스가 되지 않도록 기준을 명시적으로 관리하는 것도 중요하다. 단순 임계치 외에 경로 패턴, User-Agent 이상치, 페이로드 크기를 조합하면 정확도가 높아지지만 관리 비용도 같이 올라간다. 초기엔 단순하게 시작하고, 오탐·미탐 케이스를 쌓아가면서 기준을 다듬는 게 현실적임.
운영 관점에서 본 Rate Limit
Rate Limit을 설정할 때 "얼마나 많이 막을 것인가"보다 핵심은 "정상 사용자를 막지 않으면서 공격을 차단하는가"다. 너무 타이트하면 정상 사용자가 피해를 보고, 너무 느슨하면 의미가 없다.
고정 윈도우(fixed window)는 구현이 단순하지만 경계 구간 버스팅 공격에 취약하다. 윈도우가 바뀌는 순간 직전과 직후에 각각 한도를 꽉 채워서 보내면 실제로는 두 배 트래픽이 통과한다.
고정 윈도우 예시 (1분/100req):
00:59에 100req → 01:00에 100req → 1초 사이 200req 통과
슬라이딩 윈도우(sliding window)는 이 문제를 해결하는데, Redis에서 정렬 집합(ZSET)으로 구현하면 메모리를 더 쓰고 연산도 무거워진다.
def sliding_window_rate_limit(key: str, limit: int, window: int) -> bool:
now = time.time()
pipe = redis.pipeline()
pipe.zremrangebyscore(key, 0, now - window)
pipe.zadd(key, {str(now): now})
pipe.zcard(key)
pipe.expire(key, window)
_, _, count, _ = pipe.execute()
return count <= limit
Redis가 단일 장애점이 되면 Rate Limit이 통째로 무너지기 때문에, 연결 풀과 타임아웃 설정을 함께 챙겨야 한다. 그리고 Redis 장애 시 fallback 전략, 즉 차단할지 허용할지를 미리 정해두는 것도 빠뜨리기 쉬운 부분임. 기본적으로 fail-open(허용)이 서비스 가용성에는 유리하지만, 보안 민감도가 높은 엔드포인트라면 fail-closed(차단)를 선택할 수도 있다. 어느 쪽이든 결정을 명시적으로 남겨두지 않으면 나중에 혼선이 생김.
엔드포인트별로 한도를 다르게 가져가는 것도 실무에서 자주 쓰는 패턴이다. 로그인 경로는 훨씬 타이트하게, 일반 API는 여유 있게 잡는 식으로 경로 기준 정책을 분리하면 공격 표면을 좁히면서 정상 사용자 경험은 유지할 수 있음.
작업 규모는 크지 않았지만 운영 안정성에 직접 닿는 변경들이었음. IP 한 줄이 바뀌어서 모니터링이 멈추는 경험을 한 번 하고 나면, 설정 파일 의존 관계를 문서화하는 습관이 생긴다. 자동 차단이나 Rate Limit도 마찬가지다. 한 번 설정하고 잊는 게 아니라 오탐 로그를 주기적으로 리뷰하면서 기준을 조정해야 살아있는 보안 레이어가 됨. 작은 수정들이 쌓여서 전체 시스템 신뢰도가 올라가는 건 결국 이런 반복에서 온다.
댓글 0
첫 댓글 달아줘.