개발 slecs

파트너 관리자 페이지에 IP 블랙리스트와 봇 차단 필터 도입

목차

며칠 전 파트너 관리자 페이지 로그를 뒤지다가 이상한 패턴을 발견했다. 동일 IP 대역에서 짧은 간격으로 로그인 시도가 반복됐고, 그 사이사이에 상세 조회 요청이 섞여 있었다. UA는 최신 Chrome인 척 위장했는데, 막상 헤더 조합을 뜯어보니 Accept-Language가 없거나 Accept-Encoding이 비어있는 식으로 어딘가 어색했다. Referer도 대부분 빈값. 사람이 쓰는 브라우저라면 거의 항상 채워져야 할 필드들이었다.

파트너 어드민 페이지가 표적이 됐다는 게 신경 쓰였다. 일반 사용자 페이지보다 훨씬 민감한 데이터가 노출돼있는 구조이고, 파트너 계정 한 개만 털려도 연결된 거래 정보가 줄줄이 딸려나올 수 있다. 느긋하게 볼 상황이 아니었다.

처음엔 그냥 rate limit 하나로 끝낼까 싶었다. 미들웨어 몇 줄이면 되니까. 근데 파트너 어드민 특성상 정상 파트너도 단시간에 요청을 몰아서 보낼 수 있다. 대시보드 새로고침 몇 번 했다가 429 떨어지면 운영팀 CS가 쌓인다. 그래서 rate limit만으로는 안 되겠다 싶어서 IP 단위 블랙리스트 + 봇 차단 필터 두 레이어로 설계를 나눴다.

차단 레이어 구조

레이어 판정 기준 대응
IP 블랙리스트 운영팀이 등록한 IP / CIDR 즉시 차단
봇 필터 UA·헤더 조합·요청 패턴 401 반환 + 로그 기록
정상 트래픽 위 두 레이어 모두 통과 컨트롤러로

어떤 레이어를 앞에 세우느냐도 고민이 있었다. IP 블랙리스트는 DB 조회가 들어가는데, 매 요청마다 쿼리를 날리면 부하가 생긴다. 이건 캐싱으로 처리했다. 블랙리스트 목록은 실시간으로 바뀌는 데이터가 아니니까, 짧은 TTL로 메모리에 들고 있으면 DB 부하 없이 앞단에서 빠르게 떨굴 수 있다.

CIDR 지원은 처음부터 넣었다. /24 없이 IP 단위로만 관리하면, 같은 대역에서 IP를 바꿔가며 들어올 때 등록이 끝없이 쌓인다. 나중에 CIDR 컬럼을 추가하면 기존 IP 단위 레코드와 정합성 검증도 새로 해야 하고, 마이그레이션 자체가 일이 된다. 처음부터 prefix 매칭 로직 하나 넣어두는 게 훨씬 낫다.

입력: 203.0.113.45  → /24 prefix 매칭 → block
입력: 198.51.100.7  → 매칭 없음       → pass

DB 테이블은 단순하게 ip_or_cidr, reason, created_by, created_at 정도면 충분하다. reason과 created_by는 나중에 "이거 왜 막혀있지?" 할 때 반드시 필요하다. 운영팀이 돌아가며 IP를 등록하다 보면 출처 없는 레코드가 쌓이고, 결국 아무도 못 지운다.

봇 필터 짤 때 시행착오

봇 필터는 처음에 UA 기반으로만 시작했다. bot|crawler|spider 정규식 하나면 될 것 같았는데, 적용하자마자 운영팀이 쓰는 외부 모니터링 도구 두 개가 같이 막혔다. 해당 도구 UA에 bot이 그대로 박혀있었던 거다. 헬스체크가 401을 뱉으니 알림이 쌓이고, 운영팀 연락이 왔다.

단일 시그널이 오탐을 만든다는 건 이론으론 알고 있었는데, 직접 겪으니 다르게 느껴졌다. 결국 조건을 세 가지 AND로 바꿨다.

  • UA 패턴이 의심스럽고
  • Referer가 비어있으며
  • /login POST 요청 빈도가 임계값을 초과하는 경우

세 조건이 동시에 충족돼야 차단. 하나라도 빠지면 통과. 화이트리스트 IP는 봇 필터 자체를 건너뛰도록 처리했다.

AND / OR 선택이 왜 중요한지 간단히 정리하면 이렇다.

조합 방식 오탐(정상→차단) 미탐(악성→통과)
UA만 단독 판정 높음 낮음
UA OR Referer 빔 매우 높음 매우 낮음
UA AND Referer 빔 AND 빈도 초과 낮음 중간

미탐을 줄이려면 조건을 OR로 넓혀야 하지만 오탐도 같이 늘어난다. 파트너 어드민처럼 정상 사용자가 분명히 있는 서비스에선 오탐 비용이 더 크다. 정상 파트너가 막히면 CS로 이어지고, CS가 반복되면 운영팀이 필터 자체를 신뢰하지 않게 된다. 필터를 신뢰하지 않으면 결국 아무도 안 쓴다.

3-AND로 바꾸고 나니 오탐이 거의 없어졌다. 정상 모니터링 도구는 /login POST를 때리지 않으니 세 번째 조건에서 걸리지 않는다. 악성 봇은 UA를 위장해도 Referer를 비우고 로그인을 반복하니까 세 조건이 다 채워진다. 처음부터 이렇게 설계했으면 됐는데, 단일 시그널부터 시작한 게 아깝긴 하다.

운영팀이 쓸 화면까지가 한 세트

블랙리스트 로직 자체는 금방 만들었다. 문제는 등록/해제를 누가 어떻게 하느냐였다. API만 뚫어두고 "등록하려면 요청 주세요"로 운영하면 어떻게 되는지 이미 알고 있다. 결국 개발자가 IP 등록 대행을 하게 된다.

그래서 파트너 상세 화면에 운영 인터페이스를 붙였다.

  • 파트너별 최근 차단 시도 N건 표시
  • 마지막 로그인 IP와 국가
  • 최근 24시간 의심 행동 카운터
  • 블랙리스트 등록/해제 버튼

운영팀이 특정 파트너 계정에서 이상한 낌새를 느끼면, 상세 화면 들어가서 IP 이력 보고 바로 차단을 넣을 수 있도록. 로그 서버 뒤지고 IP 추려내는 작업을 운영팀 혼자 하기엔 무리가 있었고, 솔직히 그 작업을 매번 개발자한테 넘기는 구조도 말이 안 됐다.

보안 기능은 백엔드 로직보다 운영 인터페이스가 실제 사용 여부를 결정한다. 차단 기능이 있어도 등록 동선이 불편하면 결국 안 쓴다. 그리고 안 쓰는 보안 기능은 없는 거랑 같다.

부록으로, CLAUDE 가이드에 "보안 변경은 운영팀 승인 후" 한 줄 추가했고, .gitignore에 로컬 차단 룰 dump 파일 패턴도 넣었다. IP 리스트가 실수로 커밋되면 어떤 대역이 차단돼있는지 공격자에게 힌트를 주는 셈이다.


이번 작업에서 건진 것 세 가지.

첫째, 차단 조건은 AND로. OR로 묶으면 오탐이 쌓이고, 오탐이 쌓이면 운영팀이 필터를 꺼버린다.

둘째, CIDR은 나중에 추가하면 마이그레이션 작업이 따라온다. 처음 테이블 만들 때 넣어두는 게 공짜다.

셋째, 보안 기능은 운영팀이 직접 다룰 수 있는 화면까지가 완성이다. 백엔드만 만들고 던지면 결국 개발자가 운영 업무를 나눠 갖는다. 나는 그걸 이번에 또 확인했다.

댓글 0

첫 댓글 달아줘.