개발 slecs

결제 콜백 필터 누락 버그 수정과 레이트리밋 운영 전략

목차

결제 콜백은 서비스에서 민감도가 가장 높은 엔드포인트 중 하나다. 외부 PG사나 서드파티에서 서버로 결과를 쏴주는 구조이기 때문에, 필터 레이어가 제대로 동작하지 않으면 잘못된 상태의 요청이 그대로 비즈니스 로직까지 흘러들어간다. 그래서 이 영역 버그는 작아 보여도 실제 파급이 크다.

이번에 수정한 건 receive-result URL에 붙어 있는 캡처 필터에서 reason 파라미터 검증이 빠져있던 문제였음. 내부 클래스를 뜯으면서 원인을 찾는 데는 그리 오래 걸리지 않았는데, 왜 이 필드만 빠져 있었는지가 조금 재밌는 포인트였다.

reason 필터가 빠진 이유

필터 자체는 있었다. status, code, message 등 주요 파라미터는 다 체크하고 있었는데 reason 하나만 빠져 있었음. 코드 리뷰 때 놓쳤을 가능성이 높고, 콜백 스펙이 나중에 업데이트되면서 reason 필드가 추가됐는데 필터 클래스가 따라가지 못했을 수도 있다. 어느 쪽이든 결과는 같다 - reason이 비정상 값이어도 필터를 통과.

실제 증상은 특정 케이스에서 콜백이 처리되면 안 되는 상황인데 로직이 그냥 실행되는 것이었다. 재현 시나리오를 만들고 로그를 뜯으면서 확인했음.

수정은 단순하다. 해당 내부 클래스의 필터 메서드에 reason 검증 분기를 추가하는 것.

// 수정 전 - reason 필터 없음
if (status == null || !ALLOWED_STATUSES.contains(status)) {
    return FilterResult.REJECT;
}

// 수정 후 - reason 도 함께 검증
if (status == null || !ALLOWED_STATUSES.contains(status)) {
    return FilterResult.REJECT;
}
if (reason != null && BLOCKED_REASONS.contains(reason)) {
    return FilterResult.REJECT;
}

코드 자체는 한 줄 추가 수준이지만, 이게 빠져 있을 때의 파급은 작지 않다. 콜백 처리 오류가 누적되거나 잘못된 상태 전이를 일으킬 수 있으니까. 그래서 수정 후 바로 배포보다는 재현 → 원인 파악 → 최소 범위 수정 → 배포 순서를 지켰음. 작은 수정일수록 검증 루틴을 생략하고 싶어지는데, 그게 더 위험하다.

아래는 이런 필터 관련 버그가 어떤 패턴으로 자주 나타나는지 정리한 것. 이번 케이스는 마지막 행에 해당했음.

패턴 증상
null 체크 순서 오류 NPE (NullPointerException)
경로 예외 처리 누락 필터에서 정상 요청 차단
YAML 공백 혼용 설정값 파싱 실패
import 누락 컴파일 에러
스펙 추가 후 필터 미동기화 이번 케이스

"스펙 추가 후 필터 미동기화"는 프로젝트가 오래될수록 더 자주 나온다. 외부 스펙은 계속 바뀌는데 내부 필터 클래스는 초기 설계 그대로인 경우. 그래서 콜백 처리 관련 클래스는 외부 스펙 변경 PR이 들어올 때 자동으로 검토 대상에 포함시키는 게 좋고, 테스트 커버리지도 해당 필드 단위까지 내려가야 이런 누락을 잡을 수 있다.

Rate Limit: 수치보다 설계가 먼저

Rate Limit을 설정할 때 단순히 "얼마나 많이 막을 것인가"보다 중요한 건 "정상 사용자를 막지 않으면서 공격을 차단하는가"임. 수치 자체는 부차적이고, 어떤 방식으로 카운팅할 건지가 핵심이다.

고정 윈도우 방식은 구현이 단순하지만 경계 구간에서 버스팅 공격에 약하다. 1분당 100회 제한을 걸었을 때 00:59에 100회, 01:00에 100회를 허용해버리면 실질적으로 1분 사이에 200회가 들어오는 셈이다.

Redis의 슬라이딩 윈도우 방식은 이걸 막는다. 매 요청마다 현재 시각 기준으로 지난 N초를 계산해서 판단하기 때문에 경계 구간 버스팅이 원천 차단됨. 구현 예시는 Sorted Set으로 단순하게 잡을 수 있다.

ZADD rate:{identifier} {now_ms} {request_id}
ZREMRANGEBYSCORE rate:{identifier} 0 {now_ms - window_ms}
ZCARD rate:{identifier}
EXPIRE rate:{identifier} {window_sec}

단, Redis가 단일 장애점이 되지 않도록 연결 풀과 타임아웃 설정을 같이 챙겨야 한다. Redis가 느려지거나 연결이 끊길 때 Rate Limit 체크 자체가 블로킹이 되면 서비스 전체가 영향받는다. 타임아웃을 짧게 잡고, Redis 장애 시 Rate Limit을 pass-through 할지 fail-closed 할지 정책을 미리 결정해두는 것.

너무 타이트하게 잡으면 정상 사용자가 피해를 보고, 너무 느슨하면 의미가 없다. 이 밸런스는 실제 트래픽 데이터 없이 초기값 잡기가 어려워서, 처음엔 느슨하게 운영하면서 로그를 쌓고 점진적으로 조여가는 게 안전하다. 모니터링 없이 수치를 한 번에 확정짓는 건 거의 항상 나중에 수정하게 된다.

블랙리스트 자동 등록 구조

공격성 요청을 감지했을 때 수동 대응은 속도가 너무 느리다. 담당자가 알림 확인하고 IP 찾아서 차단하는 사이에 이미 수천 건이 들어와 있음. DB에 자동 등록하고 다음 요청부터 즉시 차단하는 구조를 만들면 실시간 대응이 가능하다.

구조 자체는 단순하다. Rate Limit을 N회 초과하면 해당 식별자를 블랙리스트 테이블에 INSERT하고, 이후 요청에서는 Rate Limit 계산 전에 블랙리스트 여부를 먼저 확인해서 차단. 체크 순서가 중요한데, 블랙리스트 조회를 먼저 해야 Rate Limit 연산 비용 자체를 절약할 수 있다.

자동 등록 시 신경 써야 할 포인트:

  • 오탐 가능성 - 로드 테스트나 배치 작업이 봇으로 오해받지 않도록 예외 IP 목록이나 헤더 기반 화이트리스트를 미리 준비해두는 것. 배포 전날 로드테스트 돌렸다가 자기네 IP 차단당하는 케이스가 실제로 나온다
  • 만료 설정 - 영구 차단 대신 TTL을 설정해서 오탐 복구 가능하게. 자동으로 풀리는 구조여야 운영 부담이 줄고, 나중에 "이 IP 왜 막혀 있지?" 상황도 줄어든다
  • 알림 연동 - 자동 차단 발생 시 슬랙/디스코드 알림으로 모니터링. "차단했다"로 끝나는 게 아니라 패턴을 보는 게 중요하기 때문. 특정 시간대에 몰리는지, 같은 대역에서 반복되는지

자동화가 잘 된 시스템일수록 오탐이 생겼을 때 복구도 빨라야 한다. 등록 → 차단 → 만료 → 복구 사이클이 설계 단계에서 명확하지 않으면 운영 중에 구멍이 난다.

작은 수정과 운영 설계가 같은 날 묶인 게 이상해 보일 수 있는데, 사실 연결된 흐름이었다. 콜백 필터를 손보면서 이 레이어가 공격 유입 경로로 악용될 수 있다는 걸 다시 확인한 거고, 그게 Rate Limit·블랙리스트 설계 검토로 자연스럽게 이어진 것. 현장에서 코드를 보다 보면 개선 포인트는 항상 거기서 나온다.

댓글 0

첫 댓글 달아줘.