현재 운영 중인 이커머스 서비스 모니터링에서 Error Tx 200건 이상 발생 등 다수의 알림이 연속적으로 발생했고, 모니터링과 로그를 확인한 결과 특정 API 호출로 인해 문제가 발생하고 있음을 확인했습니다. API 호출 패턴 자체에서는 명확한 규칙이 보이지 않았지만, 정상일 때(12월 2일)와 문제가 발생한 날(12월 9일)의 트래픽을 비교해 보면 비정상 요청임을 바로 알 수 있을 정도로 증가가 나타났습니다. 해당 내용으로 보안팀에 문의했을 때 WAF에서는 해당 API를 대상으로 탐지된 패턴이나 이벤트가 전혀 없어 감지되지 않았으며, 웹 공격으로 보이지는 않지만 URL 기반의 서비스 스캔 형태로 추정된다는 의견을 받았습니다. 결국 이 문제는 서비스 단에서 직접 대응해야 한다는 결론에 이르렀고, 이에 따라 서비스 레벨에서 적용할 수 있는 대응 방안을 찾기 시작했습니다.
목적: 특정 API를 호출해 데이터 수집이 주 목적일 가능성이 높음 / 스크래핑
현상: API 호출 속도 제한을 무시하고 무리하게 반복 요청하여 오류를 유발하고 있음
판단: 기술적으로는 악의적인 공격이 아닐 수 있으나, 운영적으로는 영향을 미치고 있으므로 차단이 필요한 대상임.
비교 항목
특정 API 트래픽 변화
1번
| 비교 항목 | 12/02 | 12/09 | 증가 배율 |
| 건수 | 8,397,416 | 19,751,893 | 약 2.3배 증가 |
| 에러 건수 | 54,205 | 9,670,573 | 약 178배 증가 |
2번
| 비교 항목 | 12/02 | 12/09 | 증가 배율 |
| 건수 | 1,455,423 | 7,757,738 | 약 5.3배 증가 |
| 에러 건수 | 7 | 556 | 약 79배 증가 |
12월 9일의 트래픽은 평소 대비 최소 2배에서 많게는 5배까지 증가했고, 에러는 178배 가까이 폭증했습니다. TPS/SQL 호출량/CPU 사용량까지 함께 증가하며 시스템 부하가 상승하였지만 큰 장애로 이어지지는 않았습니다. 다만 이벤트 시, 정상적인 사용자 요청 처리에 영향을 줄 가능성이 충분한 수준의 공격성 크롤링으로 판단했고 해결방법을 검토해 봤습니다.
특정 API의 TPS가 급증하고 에러도 크게 증가했을 때, 이것이 단순한 봇 크롤링/사용자 접근이 아니라 의도적 트래픽 유발이라고 판단할 수 있는 근거를 어떻게 찾을 수 있을까?
비정상적인 접근인지 여부는 단순히 호출량만으로 판단할 수 없다.
일반적인 검색엔진 크롤러나 정상적인 스크래퍼와는 명확히 다른 비정상 패턴이 존재하며, 다음 기준을 종합적으로 분석해 식별한다.
1. 트래픽 패턴의 비정상성 분석
비정상적인 트래픽은 정상적인 사용자나 일반적인 크롤러와는 명확히 구분되는 통계적, 시간적 패턴을 보인다.
| 구분 | 비정상 트래픽의 특징 | 정상 트래픽/일반 크롤러의 특징 |
| 시간당 호출 빈도 | 특정 시간대 급격히 치솟는 비정상적인 패턴 | 시간대별, 요일별 일정한 패턴을 따르며 점진적으로 증가 |
| 호출 간격 | 매우 짧거나 균일하여 기계적 패턴이 명확함 | 호출 간격이 불규칙하며 사람의 활동 패턴을 따름. |
| 호출 API/URL 집중도 | 특정 소수의 API 엔드포인트에 트래픽이 극단적으로 집중됨 | 다양한 API의 엔드포인트에 분산되어 호출됨 |
| 에러 응답 패턴 | 특정 에러 코드(429, 5xx)의 비율이 정상 범위를 초과함 | 주로 200, 3xx 가 주를 이루며, 에러 비율이 낮고 분산됨 |
2. 접근 주체 분석
소스 IP 주소, 자율 시스템, 지리적 위치 등을 분석하여 의도적인 공격 징후를 포착함.
- IP 주소 다양성 및 분석
- 비정상: 단일 IP 주소 또는 제한된 IP 대역에서 집중적으로 발생하거나, 특정 클라우드/프록시 서버의 IP를 대량으로 활용하여 분산됨
- 정상: 광범위한 지역과 다양한 인터넷 서비스 제공업체의 IP에서 발생
- 지리적 불일치
- 비정상: 서비스 주 타겟 지역과 전혀 관련 없는 국가/지역에서 대량 트래픽이 유입
- IP 블랙리스트 확인
- 비정상: 이미 알려진 공격용 IP 대역, 악성 봇 리스트, 이상 징후 IP에 해당되는지 확인
3. HTTP 헤더 및 세션 정보 분석
정상적인 브라우저나 크롤러는 특정 HTTP 헤더 값을 따르는 반면, 악의적인 봇은 이를 무시하거나 조작함.
- User-Agent(UA) 필드
- 비정상: 비표준적이거나, 존재하지 않거나, 자주 변경되는 UA 문자열 사용. 혹은 하나의 UA로 수천수만 건의 요청 발생. 정상적인 검색 엔진 크롤러의 UA를 사칭하는 경우
- 정상: 일반적인 브라우저 또는 명시적인 크롤러 UA 사용
- Referer 헤더 부재
- 비정상: 웹 페이지를 통한 정상적인 접근이 아님에도 Referer 헤더가 일관되게 비어 있음
- 쿠키/세션 처리
- 비정상: 정상적인 사용 과정에서 생성되는 세션 쿠키/토큰을 전혀 사용하지 않거나, 모든 요청에서 새로운 세션을 생성하려고 시도
4. 요청 내용 분석
요청 내용 자체에서 자동화된 스크립트의 징후를 포착.
- 파라미터/입력값의 비정상성
- 비정상: 요청 파라미터 값이 순차적이거나, 반복적이거나, 무의미한 더미 값인 경우.
- 정상: 다양한 값을 가짐
- 반복적인 동일 요청
- 비정상: 완벽하게 동일한 요청(URI, 파라미터 포함)이 짧은 시간 내에 대량으로 반복되는 경우
비정상적인 트래픽으로 판단하는 핵심 근거는 호출 간격의 비정상적인 균일성 + 특정 API에 집중 + 비표준적인 헤더 정보 + IP 다양성의 부족/이상 징후 IP 활용의 복합적인 발생이다. 이러한 패턴을 종합적으로 분석하여 악의적인 접근으로 판단되는 경우, 해당 IP 대역 차단, Rate Limiting 강화, 캡챠 도입 등의 방어 조치를 시행해야 한다.
해당 비정상 트래픽을 막는 방법은 어떤 게 있을까?
비정상 트래픽에 대한 대응은 인프라 단과 애플리케이션(서비스) 단으로 나눌 수 있다.
두 영역은 역할이 다르며, 단일 방법으로는 한계가 있기 때문에 복합적으로 적용하는 것이 중요하다.
인프라단에서 막는 방법
애플리케이션에 도달하기 전에 트래픽을 차단하는 1차 방어선
1. DDOS 보호 서비스 사용
- 클라우드 제공자의 DDOS 보호 서비스 활용 (예: AWS Shield, GCP Cloud Armor, Azure DDoS Protection)
- 대량의 L3/L4 공격(SYN Flood, UDP Flood 등)을 자동으로 감지 및 차단
- 서비스 장애 및 인프라 비용 폭증 방지
2. 방화벽 / ACL / 보안 게이트웨이 설정
- 특정 IP, CIDR 대역 차단 또는 허용
- 국가 단위 접근 제한(Geo Blocking)
- 내부 관리용 API 접근 제어
- 적합한 사례 (예: 관리자 페이지 보호, 내부 시스템 접근 제한, 알려진 공격 IP 차단)
3. 인프라 레벨 Rate Limiting / Throttling
- 로드밸런서, API Gateway, Ingress 레벨에서 요청 수 제한
- IP 또는 엔드포인트 기준 트래픽 제한
- 효과 (예: 특정 API로의 트래픽 집중 방지, 서버 리소스 고갈 방지)
서비스(애플리케이션) 단에서 막는 방법
요청의 맥락과 사용자 행위를 기준으로 정밀하게 차단
1. 웹 방화벽 (WAF)
- HTTP/HTTPS 요청 분석을 통한 공격 차단
- SQL Injection, XSS, Path Traversal 등 웹 공격 탐지
- IP 차단, URI 기반 정책, 시그니처 룰 적용
- 특징 (예 인프라와 서비스의 중간 계층, 비교적 빠르고 범용적인 방어 가능)
2. 속도 제한 (Rate Limiting)
- IP, 사용자 ID, API Key, Token 기준 요청 수 제한
- Redis 등의 외부 저장소를 활용한 분산 환경 대응
- 적용 (예: 로그인 시도 횟수 제한, 검색/조회 API 과도 호출 방지)
3. CAPTCHA 및 봇 감지 (Bot Detection)
- 사람이 아닌 자동화된 요청을 식별
- Rate Limit 초과 또는 비정상 패턴 발생 시 단계적으로 적용
- 효과 (예: 크롤러, 봇 트래픽 차단, 정상 사용자 영향 최소화)
4. 입력 값 검증 및 인코딩
- 요청 파라미터에 대한 길이, 타입, 포맷 검증
- 특수 문자 인코딩 처리
- 방지 대상 SQL Injection/XSS/명령어 삽입 공격
5. 세션 관리 강화
- 세션 타임아웃 설정
- 세션 고정(Session Fixation) 방지
- 토큰 기반 인증(JWT 등)과 재발급 정책 적용
- 효과 (예: 세션 탈취 및 재사용 공격 방지, 인증 우회 차단)
위와 같은 정보를 기반으로 차단 대상 트래픽이라고 식별하였고, 이에 대한 속도 제한(Rate Limiting)과 입력값 검증 및 인코딩 방법을 적용했습니다.
2차 적용 이후에는 에러가 발생하지 않도록 처리되었으며, 입력값 검증과 비즈니스 로직 단계에서 비정상 요청을 사전에 걸러 최소한의 요청만 서비스 로직에서 처리되도록 개선하였습니다.
1차 대응
1. 비정상 파라미터 Validation 추가
- 정상적인 API 사용 패턴과 맞지 않는 요청을 사전에 차단하기 위해 유효성 검증 로직을 배포.
2. IP 기반 Rate Limiting 적용
- Redis 기반으로 60초 동안 동일 IP가 100회 이상 호출할 경우 1분간 차단 처리.
- 두 API에 대해 각각 별도 키를 부여해 호출 횟수를 관리.
3. 차단된 IP 규모
- 약 600건 IP가 Rate Limit 정책에 의해 자동 차단
{
"keys": [
"rate_limit:item_uitem_api:99.2.64.126",
"rate_limit:item_uitem_api:118.235.50.99",
"rate_limit:item_uitem_api:61.43.124.67",
"rate_limit:item_uitem_api:100.2.183.65",
"rate_limit:item_uitem_api:172.59.66.129",
...
],
"total_keys": 587
}
배포 후 TPS가 많이 줄어듦

2차 대응 14시 40분 더 많은 IP를 이용해 호출
1. 비즈니스 로직상 Validation 추가
- 정상적인 API 사용 패턴과 맞지 않는 요청을 사전에 차단하기 위해 유효성 검증 로직을 배포.
2. 특정 IP 차단 기능
3. 차단된 IP 규모
- 약 1,600건 IP Rate Limit 정책에 의해 자동 차단

{
"keys": [
"rate_limit:item_option_api:116.36.26.23",
"rate_limit:item_option_api:172.59.117.57",
"rate_limit:item_option_api:117.111.6.206",
"rate_limit:item_uitem_api:75.161.210.252",
"rate_limit:item_option_api:73.152.163.211",
"rate_limit:item_uitem_api:76.37.169.163",
"rate_limit:item_option_api:98.177.2.50",
..............
],
"total_keys": 1567
}
Rate Limiting 적용
Servlet Filter 기반 Redis Rate Limiter + IP 대역 차단 필터
요청 진입
↓
RateLimit 대상 Path인지 확인
↓
Client IP 추출
↓
[1] IP 대역 차단 확인
↓
[2] Rate Limit 카운트 증가 & 제한 확인
↓
통과 or 403 / 429 응답
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String path = httpRequest.getRequestURI();
// Rate Limit 적용 대상인지 Redis에서 확인
RateLimitPathDto pathConfig = rateLimitPathService.findMatchingPath(path);
if (pathConfig == null) {
// Rate Limit 미적용 경로는 바로 통과
chain.doFilter(request, response);
return;
}
String clientIp = getClientIp(httpRequest);
// 1. IP 대역 차단 확인 (최우선 체크)
if (IP_RANGE_BLOCK_ENABLED && ipRangeBlockService.isBlocked(clientIp)) {
log.warn("IP range blocked! IP: {}, Path: {}", clientIp, path);
httpResponse.setStatus(HttpStatus.FORBIDDEN.value());
httpResponse.setContentType("application/json;charset=UTF-8");
httpResponse.setHeader("X-Block-Reason", "IP_RANGE_BLOCKED");
String errorJson = "{\"error\":\"Forbidden\",\"message\":\"차단된 IP 대역입니다.\"}";
httpResponse.getWriter().write(errorJson);
return;
}
// 2. Rate Limit 확인 (Redis에서 가져온 설정 사용)
int limit = pathConfig.getLimit();
String rateLimitType = pathConfig.getType();
// Redis 키: rate_limit:{type}:{ip}
String redisKey = String.format("rate_limit:%s:%s", rateLimitType, clientIp);
try {
Long currentCount = redisTemplate.opsForValue().increment(redisKey);
if (currentCount == null) {
currentCount = 0L;
}
if (currentCount == 1) {
redisTemplate.expire(redisKey, BLOCK_DURATION_SECONDS, TimeUnit.SECONDS);
}
if (currentCount <= limit) {
long remaining = limit - currentCount;
httpResponse.setHeader("X-RateLimit-Limit", String.valueOf(limit));
httpResponse.setHeader("X-RateLimit-Remaining", String.valueOf(Math.max(0, remaining)));
httpResponse.setHeader("X-RateLimit-Type", rateLimitType);
chain.doFilter(request, response);
} else {
log.warn("Rate limit exceeded! IP: {}, Path: {}, Type: {}, Count: {}/{}", clientIp, path, rateLimitType, currentCount, limit);
Long ttl = redisTemplate.getExpire(redisKey, TimeUnit.SECONDS);
httpResponse.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
httpResponse.setContentType("application/json;charset=UTF-8");
httpResponse.setHeader("X-RateLimit-Limit", String.valueOf(limit));
httpResponse.setHeader("X-RateLimit-Remaining", "0");
httpResponse.setHeader("X-RateLimit-Type", rateLimitType);
httpResponse.setHeader("Retry-After", String.valueOf(ttl != null ? ttl : BLOCK_DURATION_SECONDS));
String errorJson = String.format("{\"error\":\"Too many requests\",\"message\":\"요청 한도 초과. %d초 후에 다시 시도해주세요.\",\"limit\":%d}", ttl != null ? ttl : BLOCK_DURATION_SECONDS,limit);
httpResponse.getWriter().write(errorJson);
}
} catch (Exception e) {
log.error("Redis error in rate limiting for IP: {}. Allowing request.", clientIp, e);
chain.doFilter(request, response);
}
}
/**
* X-Forwarded-For 헤더에서 실제 클라이언트 IP 추출
*/
private String getClientIp(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}'n년차 개발자' 카테고리의 다른 글
| Spring MVC와 Spring WebFlux, 무엇을 선택해야할까 (0) | 2025.12.02 |
|---|---|
| 개인화 추천 API 성능 개선 (0) | 2025.11.27 |
| 서버 자원 수집 및 시각화 (0) | 2025.04.28 |
| Spring Boot Logstash 연동 설정 및 작동 방식 (1) | 2025.04.16 |
| 모니터링 시스템 구축 (0) | 2025.04.07 |