크롤링 차단 자동화와 브라우저 풀 타임아웃 안정성 개선
목차
PlaywrightBrowserPool.java 내부 클래스를 건드리면서 두 가지를 같이 처리했다. 하나는 page.close() 호출 누락, 다른 하나는 세마포어 획득 타임아웃 처리. 각각 따로 보면 작은 수정인데, 이 둘이 동시에 터지면 브라우저 풀이 고갈되고 이후 요청 전체가 타임아웃 루프에 빠진다.
브라우저 풀을 세마포어로 제어하는 구조는 흔하다. 동시에 열 수 있는 페이지 수를 제한해 메모리 폭발을 막는 방식. 문제는 예외 경로에서 page.close()가 빠지면, 세마포어를 반환(release)하더라도 실제 브라우저 컨텍스트는 살아있다는 것이다. GC가 회수하기 전까지 시스템 리소스를 물고 있고, 충분히 쌓이면 OS 레벨에서 프로세스 핸들 한도에 걸린다. 운영 중에 이게 터지면 로그에는 세마포어 반환이 정상처럼 보이는데 실제 메모리는 계속 오르는 상황이 연출된다. 처음 마주하면 원인 찾는 데 시간이 좀 걸린다.
수정 방향은 단순했다. try-finally 블록 안에서 page.close()를 보장하고, 세마포어 tryAcquire에 타임아웃을 명시적으로 넣는 것. 타임아웃 없이 블로킹 acquire를 쓰면 대기 스레드가 쌓여도 알 방법이 없다. 명시적 타임아웃이 있어야 "풀 고갈" 상태를 로그에 남기고 빠르게 실패 응답을 내보낼 수 있다.
boolean acquired = semaphore.tryAcquire(5, TimeUnit.SECONDS);
if (!acquired) {
log.warn("BrowserPool semaphore timeout - pool exhausted");
throw new BrowserPoolExhaustedException("No available browser slot");
}
Page page = null;
try {
page = browser.newPage();
// ... 작업
} finally {
if (page != null) {
page.close(); // 예외 경로에서도 반드시 호출
}
semaphore.release();
}
이렇게 해두면 풀이 포화 상태일 때 5초 대기 후 즉시 예외를 던지고, 호출 측에서 재시도나 에러 응답을 선택할 수 있다. 무한 대기보다 훨씬 낫고, 로그에서 원인도 바로 보인다. 타임아웃 값은 서비스 응답 목표(SLA)에 맞춰 조정하면 된다. 5초가 너무 길다면 더 줄여도 된다.
버그 재현부터 배포까지
이번처럼 운영 중 간헐적으로 터지는 문제는 재현이 제일 어렵다. 로그에 남은 스택트레이스로 원인을 추측하고, 로컬에서 비슷한 부하를 만들어 재현을 시도한다. 재현이 안 되면 코드 경로 분석으로 넘어간다. 재현 → 원인 파악 → 최소 범위 수정 → 배포 순서가 이제 루틴처럼 잡혔다.
자주 마주치는 패턴을 정리해두고 있는데, 이번 케이스도 그 안에 있었다.
| 패턴 | 증상 | 자주 보이는 위치 |
|---|---|---|
| null 체크 순서 오류 | NPE (NullPointerException) | 서비스 레이어 초기화 |
| 경로 예외 처리 누락 | 필터에서 정상 요청 차단 | 인터셉터, 필터 체인 |
| YAML 공백 혼용 | 설정값 파싱 실패 | application.yml, 환경별 설정 |
| import 누락 | 컴파일 에러 | IDE 자동 import 실수 |
| finally 누락 | 리소스 누수 | IO, DB 커넥션, 브라우저 핸들 |
마지막 행이 이번 케이스다. 패턴 목록에 추가했다.
수정 자체보다 배포 타이밍이 더 신경 쓰인다. 트래픽 낮은 시간대에 배포하고, 배포 직후 5-10분 정도 로그를 직접 보면서 이상 없는지 확인하는 루틴이 있다. 작은 수정일수록 확인 없이 넘어가기 쉬운데, 그게 나중에 발목을 잡는다. "어차피 한 줄 고친 거니까"라는 생각이 제일 위험하다.
블랙리스트 자동 등록 구조
크롤링 방어를 수동으로만 하면 속도에서 진다. 공격성 요청이 들어온 순간 이미 수십-수백 건이 쌓인 다음에 사람이 알아채는 경우가 많다. DB에 자동 등록하고 다음 요청부터 필터에서 즉시 차단하는 구조가 실시간 대응에 실질적이다.
구조를 짤 때 오탐 처리를 먼저 생각했다. 내부 배치 작업이나 로드 테스트가 봇으로 분류되면 곤란하다. IP 기반 화이트리스트와 User-Agent 예외 목록을 관리하고, 자동 등록 전에 이 목록을 먼저 통과시킨다. 배치 작업 주기와 Rate Limit 임계값이 겹치는 경우가 생각보다 많다.
TTL 설정도 중요하다. 영구 차단은 오탐 복구가 어렵고, 너무 짧으면 공격자가 잠깐 쉬다 다시 들어온다. 첫 감지는 짧게 잡고, 같은 IP가 재등록되면 TTL을 점진적으로 늘리는 방식이 합리적이다. Redis EXPIRE 갱신으로 이 로직을 구현할 수 있다. 아래처럼.
1회 감지: TTL 10분
2회 재감지: TTL 1시간
3회 이상: TTL 24시간 (또는 수동 해제 전까지)
Slack 알림도 연동해뒀다. 단, 알림이 너무 많으면 노이즈가 돼서 묻히기 때문에, 동일 IP의 중복 알림은 일정 시간 억제하도록 쿨다운을 넣었다. "자동 차단됐다"는 사실보다 "얼마나 자주 터지는지"를 파악하는 게 실제 운영에 도움이 된다.
Rate Limit을 실제로 튜닝할 때
Rate Limit 설계에서 숫자보다 중요한 건 정상 사용자를 막지 않으면서 공격을 걸러내는 임계값 찾기다. 너무 타이트하면 정상 요청이 막히고, 너무 느슨하면 의미가 없다. 초기값은 운영 로그에서 정상 트래픽 패턴을 보고 잡는 게 낫다. 감으로 넣으면 나중에 반드시 조정하게 된다.
고정 윈도우 방식은 경계 구간에서 버스팅 공격에 취약하다. 예를 들어 1분당 100건 제한이라면, 0:59에 100건, 1:00에 다시 100건을 연속으로 넣어서 실질적으로 1초 안에 200건이 처리되는 상황이 생긴다. Redis 슬라이딩 윈도우는 이 문제를 완화한다. Sorted Set에 타임스탬프를 점수로 쌓고, 만료 시간 밖의 엔트리를 ZREMRANGEBYSCORE로 정리하는 방식. 윈도우가 실시간으로 이동하기 때문에 경계 구간 버스팅이 없다.
단, Redis를 Rate Limit의 핵심 경로에 넣으면 Redis 장애가 서비스 장애로 이어질 수 있다. 연결 풀 크기와 커맨드 타임아웃 설정을 같이 챙겨야 하고, Redis가 응답하지 않을 때 Rate Limit을 통과시킬지(fail-open) 차단할지(fail-closed)도 정책으로 잡아둬야 한다. 일반적으로 가용성을 우선하면 fail-open을 선택하는데, 서비스 성격에 따라 다르다. 어느 쪽이든 정해두지 않으면 장애 상황에서 즉흥 판단하게 된다.
이번 작업은 코드 한두 줄짜리 수정이 섞여 있었지만, 각각이 없었으면 운영 중에 터질 만한 것들이었다. 작은 수정들이 쌓여서 전체 시스템의 신뢰도가 올라가는 건데, 그 과정이 눈에 잘 안 보인다는 게 항상 아쉽다. 사후 대응보다 이런 루틴 점검에서 잡히는 게 낫다.
댓글 0
첫 댓글 달아줘.