이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Rate limit는 단순히 초당 요청 수를 줄이는 기능을 넘어 어떤 abuse를 막아야 하는가?
- IP 기준 제한만으로 부족한 이유는 무엇인가?
- Spring API에서 429, Retry-After, 보안 로그를 어떻게 설계해야 하는가?
개요
Rate Limit은 일정 시간 안에 허용할 요청량을 제한하는 방어다. 하지만 API 보안에서는 “서버 부하 방지”만이 아니라 credential stuffing, 인증번호 발송 남용, 비밀번호 재설정 폭주, 검색 scraping, 쿠폰 대입, 외부 API 비용 폭증 같은 업무 흐름 abuse까지 다룬다.
IP 하나만 기준으로 제한하면 NAT 환경의 정상 사용자를 함께 막거나, 공격자가 VPN과 프록시를 나눠 우회할 수 있다. 계정, IP, device, session, endpoint, 실패 횟수, 비용 단위를 함께 봐야 한다.
429 응답은 클라이언트에게 속도를 줄이라는 신호다. 가능하면 Retry-After를 제공하되, 공격자에게 정확한 내부 quota를 모두 공개하지 않도록 응답 상세와 로그 상세를 분리한다.
이 문서의 핵심은 “트래픽 양”이 아니라 “업무 기능의 소모와 자동화 가능성”을 제한하는 것이다.
공격 시나리오
- 공격자는 로그인 endpoint에 여러 계정과 비밀번호 조합을 낮은 속도로 반복한다.
- 인증번호 발송 API를 자동 호출해 SMS 비용을 발생시키거나 피해자에게 스팸을 보낸다.
- 비밀번호 재설정 API를 반복 호출해 계정 존재 여부와 이메일 발송 여부를 관찰한다.
- 검색 API를 많은 keyword와 page 조합으로 호출해 데이터를 scraping한다.
- GraphQL이나 report API처럼 한 요청의 비용이 큰 endpoint에 복잡한 query를 보낸다.
- IP 제한이 있으면 프록시, 모바일망, 봇넷, 여러 계정을 섞어 우회한다.
취약한 요청/응답 예시
POST /api/auth/otp/send HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"phoneNumber":"+82-10-1234-5678"}HTTP/1.1 200 OK
Content-Type: application/json
{"message":"인증번호를 발송했습니다."}같은 번호, 같은 device, 같은 IP에서 수백 번 호출해도 항상 발송하면 비용과 사용자 피해가 커진다. 응답이 계정 존재 여부나 발송 상태를 자세히 말하면 enumeration에도 쓰인다.
주제별 핵심 판단
- rate limit key는 endpoint 성격에 따라 다르게 잡는다.
- 로그인은 계정, IP, device, 실패 횟수를 함께 본다.
- OTP 발송은 전화번호, 계정, IP, device, 일일 비용 한도를 함께 본다.
- 검색과 report는 요청 수뿐 아니라 page size, query complexity, 처리 시간, export 크기를 본다.
- 429는 클라이언트에게 재시도 시점을 알려 줄 수 있지만 내부 정책 전체를 설명하지 않는다.
- 정상 사용자가 회복할 수 있는 unlock, cooldown, 고객지원 경로를 설계한다.
- rate limit 우회 시도 자체를 보안 이벤트로 남긴다.
개선된 요청/응답 예시
POST /api/auth/otp/send HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"phoneNumber":"+82-10-1234-5678"}HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{"code":"TOO_MANY_REQUESTS","message":"잠시 후 다시 시도해 주세요.","traceId":"b8d44a10"}응답은 사용자가 다시 시도할 수 있는 최소 정보만 준다. 내부적으로는 어떤 key가 어떤 한도에 걸렸는지 로그에 남긴다.
방어 설계
- endpoint별 abuse 시나리오를 먼저 정의한다.
- key는 IP 하나가 아니라 account, subject, device, session, endpoint, cost bucket을 조합한다.
- 인증 전 요청은 계정 식별자가 없을 수 있으므로 IP, device fingerprint, username hash를 함께 본다.
- 인증 후 요청은 principal과 resource owner 기준 제한을 추가한다.
- 외부 비용이 드는 기능은 일일/월간 quota와 예산 알림을 둔다.
- 고비용 query는 요청 수 외에 page size, depth, sort, export row count를 제한한다.
- 제한 초과는 429와
Retry-After로 응답하되, 민감한 정책값은 숨긴다. - 우회 탐지는 rate limit 저장소, access log, 인증 실패 로그를 함께 분석해야 한다.
Spring/HTTP 예시
public record RateLimitKey(
String endpoint,
String principalKey,
String subjectKey,
String ipBucket,
String deviceId
) {}@PostMapping("/api/auth/otp/send")
public ResponseEntity<ApiResponse> sendOtp(@Valid @RequestBody OtpSendRequest request,
HttpServletRequest servletRequest) {
RateLimitKey key = RateLimitKey.of(
"otp.send",
"anonymous",
hash(request.phoneNumber()),
ipBucket(servletRequest),
deviceId(servletRequest)
);
RateLimitDecision decision = otpRateLimiter.check(key);
if (decision.blocked()) {
audit.warn("otp_rate_limited", key, decision.reason());
return ResponseEntity.status(429)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(decision.retryAfterSeconds()))
.body(ApiResponse.error("TOO_MANY_REQUESTS", "잠시 후 다시 시도해 주세요."));
}
otpService.send(request.phoneNumber());
return ResponseEntity.accepted().body(ApiResponse.ok());
}예시는 개념 코드다. 실제 구현에서는 Redis, gateway, service mesh, application filter 중 어디에서 제한할지 장애 영향과 일관성을 보고 정한다.
운영 로그와 감사 지점
level=WARN event=rate_limit_exceeded result=blocked endpoint=otp.send keyType=phone+ip+device subjectHash=8421 ipBucket=203.0.113.0/24 retryAfter=60 reason=burst_limit traceId=b8d44a10level=INFO event=abuse_pattern_detected result=review_required pattern=credential_stuffing usernames=120 ipBuckets=18 window=10m traceId=b8d44a10- 전화번호, 이메일, username은 원문보다 hash나 masked value로 남긴다.
- limit key, window, reason, retryAfter, endpoint를 구조화된 필드로 남긴다.
- 단일 차단보다 여러 key를 넘나드는 분산 패턴을 볼 수 있어야 한다.
- 정상 사용자 영향 분석을 위해 허용, 지연, 차단 비율을 metric으로 본다.
- 외부 SMS, email, payment API 비용 한도와 rate limit 이벤트를 연결한다.
실전 판단 기준
- IP 기준만 있으면 분산 공격에 약하고 NAT 정상 사용자에게 과하게 적용될 수 있다.
- 로그인과 OTP는 같은 rate limit 정책을 쓰면 안 된다.
- 429 없이 조용히 성공처럼 응답하면 클라이언트가 무한 재시도할 수 있다.
- 정확한 quota를 모두 응답하면 공격자가 그 아래 속도로 조정한다.
- limit 저장소 장애 시 fail-open인지 fail-closed인지 endpoint별로 결정해야 한다.
- rate limit는 인증, 권한, validation을 대체하지 않는다.
테스트 포인트
- 같은 계정으로 로그인 실패가 반복될 때 계정 기준 제한이 동작하는지 확인한다.
- 여러 계정으로 같은 IP에서 실패할 때 IP 또는 network 기준 제한이 동작하는지 확인한다.
- 여러 IP에서 같은 전화번호로 OTP 발송을 반복할 때 subject 기준 제한이 동작하는지 확인한다.
- 검색 API에서 page size, page depth, 정렬 조합으로 고비용 요청을 제한하는지 확인한다.
- 429 응답에
Retry-After가 포함되고 오류 schema가 일관적인지 확인한다. - rate limit 저장소 장애 시 정책대로 동작하고 경보가 발생하는지 확인한다.
위험 신호!
- 모든 API가 같은 초당 요청 수 제한만 가진다.
- IP 하나만 key로 사용한다.
- OTP, email, 외부 결제 검증처럼 비용이 드는 API에 별도 quota가 없다.
- 429 응답이 없거나 retry 지침이 없다.
- 제한 초과 로그에 endpoint와 key reason이 없다.
- 공격자가 정확한 quota와 reset 시간을 응답으로 쉽게 알 수 있다.
- rate limit를 권한 검사나 입력 검증의 대체재로 설명한다.
확인 질문
확인 질문
- rate limit key를 IP 하나로만 잡으면 왜 부족한가?
- 공격자는 IP를 분산할 수 있고, 정상 사용자는 NAT나 모바일망 때문에 같은 IP를 공유할 수 있기 때문이다.
- API abuse 방어에서 비용 단위란 무엇인가?
- SMS, email, 외부 API 호출, CPU, DB query, export row, storage처럼 요청이 소비하는 자원이다.
- 429 응답과 보안 로그는 어떻게 달라야 하는가?
- 응답은 재시도 안내를 제공하고, 로그는 어떤 key와 reason으로 제한했는지 분석 가능한 상세 필드를 남긴다.