보안 설정을 “Spring Security에 인증 붙이고 HTTPS 쓰면 됐지”로 생각하던 시절이 있었다.
그러다 로그에서 하루에 수천 건씩 들어오는 SQL Injection 시도를 처음 봤다. 자동화 스캐너였다. 이게 Spring Boot에 도달할 때마다 JVM이 처리 비용을 쓰고 있었다.
WAF가 앞단에서 걸러줘야 한다는 걸 그때 알았다. 처음부터 파고들자.
Security Group이 있는데 WAF가 왜 필요한가?
Security Group은 L3/L4에서 동작한다. IP 주소, 포트, 프로토콜만 본다.
Security Group이 보는 것:
출발지 IP: 1.2.3.4
목적지 포트: 443
프로토콜: TCP
결정: 허용
Security Group이 모르는 것:
URI: /api/users?id=1 OR 1=1--
헤더: User-Agent: sqlmap/1.7.8
본문: username=admin'--
SQL Injection이든, 정상 요청이든, 같은 IP 같은 포트에서 온다. Security Group은 이 둘을 구분할 방법이 없다. 공격자 IP를 차단해봤자 IP를 수시로 바꾼다.
WAF는 L7에서 동작한다. HTTP 요청 전체를 — URI, 쿼리 파라미터, 헤더, 요청 본문 — 들여다보고 패턴을 매칭한다. 정상 요청은 통과시키고, 공격 패턴만 차단한다.
Security Group이 “어디서 오는가”를 보는 것이라면, WAF는 “무엇을 보내는가”를 본다.
SQL Injection이 뭔데 L4 방화벽이 못 막는가?
SQL Injection은 HTTP 파라미터 안에 SQL 구문을 삽입해서 DB 쿼리를 조작하는 공격이다.
기본 형태:
정상 요청:
GET /api/users?id=42
실행되는 DB 쿼리:
SELECT * FROM users WHERE id = 42
---
공격 요청:
GET /api/users?id=42 OR 1=1--
조작된 DB 쿼리:
SELECT * FROM users WHERE id = 42 OR 1=1--
→ 1=1이 항상 참이므로 모든 사용자 데이터 반환
UNION 기반 — 다른 테이블을 끌어다 붙인다:
GET /api/products?category=shoes' UNION SELECT username,password,null FROM users--
조작된 쿼리:
SELECT name, price, image FROM products WHERE category = 'shoes'
UNION SELECT username, password, null FROM users--
→ 상품 목록에 users 테이블 데이터가 섞여 반환됨
L4 방화벽은 이 HTTP 파라미터 내용을 볼 수 없으니 막을 방법이 없다.
JPA/Hibernate가 PreparedStatement로 파라미터 바인딩을 하면 이론적으로 막힌다. 하지만 Native Query, @Query(nativeQuery=true), 동적 쿼리 빌더에서 문자열을 직접 이어 붙이는 코드가 하나라도 있으면 뚫린다. 그 하나를 찾아내는 게 공격자다.
WAF는 그 요청이 Spring Boot에 닿기 전에 차단한다.
AWS WAF는 어디에 붙이나? CloudFront vs ALB, 위치에 따라 뭐가 달라지나?
AWS WAF의 핵심 객체는 **WebACL(Web Access Control List)**이다. 붙일 수 있는 대상:
- CloudFront (전역, us-east-1에서 생성)
- ALB (리전별)
- API Gateway (리전별)
- Cognito User Pool
인터넷
↓
CloudFront (엣지, 전 세계)
↓ [WebACL A — 엣지에서 1차 차단]
↓ 통과한 요청만 리전으로
ALB (서울 리전)
↓ [WebACL B — 리전에서 2차 차단]
Spring Boot WAS
CloudFront에 붙이면:
공격 트래픽이 리전까지 도달하지 않는다. ALB, EC2, NAT Gateway 처리 비용이 발생하지 않는다. 단, CloudFront WebACL은 반드시 us-east-1에서 생성해야 한다. 리전을 착각하면 WebACL이 안 보인다.
ALB에만 붙이면:
CloudFront를 쓰지 않는 구조거나, CloudFront를 우회하는 경로(ALB 직접 도메인)가 있을 때 필요하다. 차단 전에 이미 리전 트래픽 비용이 발생한다.
두 곳 모두 적용:
대규모 서비스에서 쓴다. CloudFront 엣지에서 1차, ALB에서 2차. ALB에 직접 접근하는 경로도 막힌다.
Managed Rule Group이 뭔가? 내가 직접 규칙을 만들지 않아도 되는 이유
AWS가 관리하는 규칙 세트다. 패턴 DB를 AWS가 지속적으로 업데이트한다. 새 취약점이 나오면 내가 아무것도 안 해도 규칙이 자동 갱신된다.
주요 Managed Rule Group:
| Rule Group | 용도 |
|---|---|
| AWSManagedRulesCommonRuleSet | OWASP Top 10 기반 일반 웹 공격 |
| AWSManagedRulesSQLiRuleSet | SQL Injection 패턴 |
| AWSManagedRulesXSSRuleSet | Cross-Site Scripting |
| AWSManagedRulesKnownBadInputsRuleSet | Log4Shell, Spring4Shell 등 알려진 익스플로잇 |
| AWSManagedRulesAmazonIpReputationList | 봇넷, 스캐너 등 악성 IP 목록 |
| AWSManagedRulesAnonymousIpList | VPN, Tor, 프록시 IP |
실제 탐지 예시:
차단 대상 요청들:
GET /api/users?name=admin'--
GET /api/products?id=1 UNION SELECT password FROM users--
POST /api/login (body: username=admin' OR '1'='1)
→ AWSManagedRulesSQLiRuleSet이 쿼리 파라미터와 요청 본문에서 패턴 매칭 후 Block
처음 도입 시 반드시 Count 모드로 시작하라:
Managed Rule Group을 바로 Block으로 설정하면 정상 요청이 차단되는 사고가 생긴다.
예를 들어 AWSManagedRulesCommonRuleSet의 SizeRestrictions_BODY는 요청 본문이 8KB를 초과하면 차단한다. 파일 업로드 API가 있다면 이게 막힌다. Count 모드로 2주 운영해서 어떤 Rule이 매칭되는지 확인한 뒤 필요한 Rule만 제외(Exclude)하고 Block으로 전환한다.
Rate-based Rule은 어떻게 동작하나? IP별로 요청 수를 어떻게 세나?
특정 기간 동안 동일한 IP(또는 다른 키)에서 오는 요청 수를 추적해서 임계값을 초과하면 차단한다.
기본 구성:
평가 윈도우: 5분 (기본값)
임계값: 2,000 요청
Action: Block
동작:
→ 같은 IP에서 평가 윈도우 동안 2,001번째 요청부터 Block
→ 슬라이딩 윈도우 방식으로 집계
→ 요청 수가 임계값 아래로 내려가면 자동 해제
평가 윈도우는 고정값이 아니다. 2023년부터 1/2/5/10분(60/120/300/600초) 중에서 선택할 수 있다(기본값 5분). 짧은 윈도우(1분)는 급격한 트래픽 폭증에 빠르게 반응하고, 긴 윈도우(10분)는 완만하게 분산된 공격을 누적해서 잡는다.
집계 키(Aggregate Key) — IP만 되는 게 아니다:
IP (기본): 같은 공인 IP에서 오는 요청 집계
Forwarded IP: 프록시/NAT 뒤에서 실제 클라이언트 IP
HTTP Header: 특정 헤더 값별로 집계 (예: User-Agent)
Query Argument: 특정 쿼리 파라미터 값별로 집계
Custom Keys: 위 항목들의 조합
로그인 엔드포인트 Brute Force 방어:
Rule 1: 전체 API Rate Limit
URI: /api/*
5분 임계값: 10,000
Action: Block
Rule 2: 로그인 엔드포인트 강화
URI: /api/auth/login
5분 임계값: 100 (5분에 100번 = 초당 약 0.33번)
Action: Block
기업 NAT 환경 주의:
회사 사무실 직원 수백 명이 하나의 NAT 뒤에서 같은 공인 IP를 쓴다. 너무 낮은 임계값을 설정하면 정상 사용자들을 단체로 차단한다. B2B 서비스라면 Forwarded IP를 집계 키로 쓰거나, 신뢰하는 IP 대역을 Allow Rule로 먼저 통과시키는 것을 고려한다.
Custom Rule은 언제 직접 만드나? — Geo match 예시
Managed Rule Group으로 안 잡히는, 서비스 고유의 정책은 직접 규칙으로 만든다. 대표적인 게 국가 단위 차단(Geo match)과 IP set 기반 차단이다.
국내 서비스인데 특정 해외 국가에서 공격 트래픽이 집중된다면, 그 국가를 통째로 막는 게 가장 단순하다.
{
"Name": "BlockNonKR",
"Priority": 2,
"Statement": {
"NotStatement": {
"Statement": {
"GeoMatchStatement": { "CountryCodes": ["KR"] }
}
}
},
"Action": { "Block": {} },
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "BlockNonKR"
}
}
NotStatement + GeoMatchStatement로 “KR이 아닌 모든 국가”를 차단한다. 국내 전용 서비스라면 이 한 줄로 해외 스캐너의 상당수를 걷어낸다.
IP set 기반은 반대로 특정 IP 목록을 명시적으로 막거나 허용한다. 미리 만들어 둔 IP set(예: 알려진 악성 IP 대역)을 참조한다.
{
"Name": "BlockBadIPSet",
"Priority": 0,
"Statement": {
"IPSetReferenceStatement": {
"ARN": "arn:aws:wafv2:ap-northeast-2:123456789012:regional/ipset/bad-actors/..."
}
},
"Action": { "Block": {} },
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "BlockBadIPSet"
}
}
IP set은 별도 리소스라 규칙을 안 건드리고 IP 목록만 갱신할 수 있다. 차단 IP가 자주 바뀌는 운영 환경에서 유용하다.
Bot Control이 봇을 어떻게 감지하나? User-Agent만 보는 건 아닌가?
User-Agent만 보는 건 초보적인 방어다. User-Agent: python-requests/2.28을 Mozilla/5.0으로 바꾸면 통과된다.
Bot Control은 훨씬 정교하다.
두 가지 보호 레벨:
Common Bot Protection — 알려진 봇 시그니처 기반:
- HTTP 헤더 패턴 (User-Agent를 포함하지만 그게 전부가 아님)
- IP 평판 데이터베이스
robots.txt를 무시하는 크롤러- 알려진 스캐너 (Shodan, Masscan 등)
Targeted Bot Protection — 동적 분석 기반:
- 첫 요청에 JS 챌린지나 CAPTCHA를 통해 브라우저 검증 토큰 발급
- 후속 요청에서 토큰 유효성 확인
- 요청 패턴, 타이밍, 상호작용 방식 행동 분석
Selenium, Puppeteer 등 브라우저 자동화 도구는 JS 실행 환경을 갖고 있어도 여러 행동 지표에서 일반 사용자와 다르다. Bot Control Targeted는 이를 감지한다.
비용이 높다:
- Common: $10/백만 요청
- Targeted: $40/백만 요청
실제로 봇 공격이 있는 서비스에만 적용하는 게 경제적이다. 먼저 WAF 로그에서 봇 트래픽 비율을 파악하고 도입 여부를 결정한다.
WAF가 정상 요청을 차단하면 어떻게 대응하나?
False Positive는 반드시 발생한다고 가정해야 한다.
도입 후에 대응하는 게 아니라 프로세스를 미리 만들어둬야 한다.
실제 False Positive 사례:
WAF 로그에서 이런 항목이 발견됐다:
{
"action": "BLOCK",
"terminatingRuleId": "AWSManagedRulesCommonRuleSet",
"terminatingRuleMatchDetails": [
{
"conditionType": "SQLI",
"location": "QUERY_STRING",
"matchedData": ["SELECT"]
}
],
"httpRequest": {
"clientIp": "203.x.x.x",
"uri": "/api/products/search",
"args": "keyword=SELECT+shirt+color"
}
}
/api/products/search?keyword=SELECT+shirt+color — “SELECT”라는 단어를 검색어로 보냈다가 SQLi Rule에 걸렸다. 정상 요청이다.
대응 방법:
방법 1: Rule 예외(Exclude Rule) 추가
- 특정 Rule ID에 대해 특정 URI 경로를 예외로 추가
- 가장 세밀한 제어
방법 2: 특정 IP/IP 대역 Allow
- 내부 QA 서버, 파트너사 IP를 Allow List에 등록
- 해당 IP는 WAF 검사를 건너뜀
방법 3: Rule Action을 Count로 변경
- Block → Count로 임시 변경해서 영향도 파악 후 판단
긴급 상황에서 WAF 비활성화는 최후의 수단이다.
실제 공격 중에 WAF를 끄면 방어막이 없어진다. Rule 단위 Count 전환을 먼저 해서 어떤 요청이 통과되는지 확인한 뒤 원인을 찾아라.
WAF를 쓰면 Spring Boot까지 오는 요청이 줄어드는 게 왜 중요한가?
WAF가 없는 상태에서 SQL Injection 스캐너가 초당 500개 요청을 보내면:
500 req/s × Spring Boot 처리
→ 500개 HTTP 파싱
→ 500개 Filter Chain 실행
→ 500개 Controller 메서드 호출 시도
→ 500개 PreparedStatement 생성 (차단되더라도 JPA 레이어까지 도달)
→ CPU, 스레드 풀, 커넥션 풀 소비
WAF가 있으면:
500 req/s × WAF
→ 495개 Block (네트워크 엣지에서 차단, JVM 처리 없음)
→ 5개만 Spring Boot에 도달 (false negative)
실제 경험한 케이스다. 보안 스캐너가 초당 수백 요청을 보내는 상황에서 WAF Rate Limiting을 적용하자 Spring Boot CPU 사용률이 35%에서 4%로 떨어졌다.
WAF는 보안 레이어이기도 하지만 비용 절감 수단이기도 하다. EC2 서버를 더 크게 올리는 대신 WAF를 붙이는 게 경제적으로 유리한 경우가 많다.
Slowloris 공격 같은 Connection 고갈 공격은 애플리케이션 레이어에 도달하는 것 자체가 문제다. 스레드 풀을 점유해 정상 요청도 처리되지 않는 상황이 만들어진다. WAF + ALB의 Idle Timeout 설정이 첫 번째 방어선이다.
비용이 얼마나 나오나? 예상보다 많이 나올 수 있는 시나리오
WAF 비용은 고정 비용 + 트래픽 연동 비용이다.
| 항목 | ap-northeast-2 기준 |
|---|---|
| WebACL | $5 / 월 |
| Rule (Custom Rule 1개) | $1 / 월 |
| Managed Rule Group (1그룹) | $1 / 월 |
| 요청 처리 | $0.60 / 백만 요청 |
| Bot Control Common | $10 / 백만 요청 |
| Bot Control Targeted | $40 / 백만 요청 |
월 1,000만 요청 서비스 기준 예시:
WebACL: $5
Custom Rule 2개: $2
Managed Rule Group 3개 (Common, SQLi, XSS): $3
요청 처리 1,000만: $6
합계: 약 $16/월 (Bot Control 제외)
비용 폭발 시나리오:
CloudFront에 붙이면 전체 트래픽이 WAF를 통과한다. 정적 파일(이미지, JS, CSS) 요청도 WAF가 검사한다. 월 10억 요청 서비스라면 요청 처리 비용만 $600이다.
CloudFront에서 정적 파일 캐시 히트율을 높여 WAF를 통과하는 요청 수 자체를 줄이는 것이 비용 관리의 핵심이다.
Bot Control Targeted 비용 주의:
월 1억 요청에 Targeted를 붙이면 $4,000다. 차단하는 트래픽 양과 방어 효과를 측정하고, 비용보다 클 때만 적용한다.
WAF와 Spring Security는 어떻게 역할을 나눠야 하나?
역할이 다르다. 중복이 아니다.
| 관심사 | AWS WAF | Spring Security |
|---|---|---|
| 위치 | 인프라 레이어 (네트워크 엣지) | 애플리케이션 레이어 |
| 알려진 공격 패턴 차단 | O (SQLi, XSS, Log4Shell) | 제한적 |
| IP 기반 Rate Limiting | O (1/2/5/10분 슬라이딩 윈도우) | O (사용자 기반 가능) |
| 인증/인가 | X (사용자 컨텍스트 없음) | O (JWT, Session 기반) |
| CSRF | X | O |
| 비즈니스 권한 로직 | X | O |
| 사용자별 개인화 제한 | X | O |
경계 원칙:
AWS WAF 담당:
- "이 IP가 5분에 10,000번 요청을 보낸다" → Rate Limiting
- "이 쿼리 파라미터에 UNION SELECT가 있다" → SQLi 차단
- "이 헤더 패턴이 알려진 스캐너다" → Block
- "이 국가에서 오는 요청이다" → Geo Block
Spring Security 담당:
- "이 사용자가 로그인되어 있는가" → 인증
- "이 사용자가 /admin에 접근 권한이 있는가" → 인가
- "이 폼 요청이 CSRF 토큰을 갖고 있는가" → CSRF 방어
- "이 사용자가 오늘 API를 1,000번 이상 호출했는가" → 비즈니스 Rate Limiting
WAF로 막을 수 있는 것을 Spring Boot가 처리하게 두지 마라. 그 반대도 마찬가지다 — WAF에서 사용자 권한 검사를 하려 하면 아키텍처가 꼬인다.
최적화
WAF Logging 선택적 활성화:
CloudWatch Logs는 실시간 모니터링에 적합하지만 비용이 높다. S3에 저장 후 Athena로 분석하는 패턴이 대용량 로그에 경제적이다.
-- S3 WAF 로그를 Athena로 분석 — 최근 24시간 차단 요청
SELECT terminatingRuleId, COUNT(*) as cnt,
httpRequest.clientIp,
httpRequest.uri
FROM waf_logs
WHERE from_iso8601_timestamp(timestamp) > now() - interval '24' hour
AND action = 'BLOCK'
GROUP BY terminatingRuleId, httpRequest.clientIp, httpRequest.uri
ORDER BY cnt DESC
LIMIT 100;
Count 모드 활용 전략:
Phase 1 (첫 2주): 모든 Rule을 Count 모드로
→ 어떤 Rule이 얼마나 매칭되는지 파악
→ False Positive 후보 식별
Phase 2 (2주 후): 검증된 Rule부터 Block으로 전환
→ IP Reputation, Known Bad Inputs는 바로 Block 가능
→ Common Rule Set은 신중하게 (False Positive 많음)
Phase 3 (1달 후): 모니터링 루틴화
→ 차단 건수 주간 리포트
→ 새로운 패턴 발견 시 Custom Rule 추가
튜닝
Rule 우선순위 설정 — 낮은 숫자 = 높은 우선순위:
Priority 0: IP Block List (Custom, 저비용)
Priority 1: Rate-based Rule (저비용)
Priority 2: AWS IP Reputation List (Managed, 저비용)
Priority 3: AWSManagedRulesCommonRuleSet (중간 비용)
Priority 4: AWSManagedRulesSQLiRuleSet (중간 비용)
Priority 5: Bot Control Common (고비용)
Priority 6: Bot Control Targeted (매우 고비용)
IP Block List에 걸린 요청은 Bot Control까지 평가받지 않는다. 비용이 절감된다.
ALB 로그와 WAF 로그 연계:
WAF가 Block한 요청은 ALB 액세스 로그에 403 응답으로 기록되지 않는다. WAF 로그와 ALB 로그를 별개로 분석해야 전체 트래픽 그림이 나온다.
운영상 주의사항
도입 초기에 정상 트래픽을 먼저 이해하라
WAF를 붙이기 전에 현재 트래픽 패턴을 파악한다. 어떤 URI 경로에 어떤 파라미터가 오는지, 최대 요청 빈도는 어떤지. 이 기준선 없이 Rule을 설정하면 False Positive를 구분할 수 없다.
배포 후 WAF 로그 필수 확인
새 API 엔드포인트를 배포한 뒤 WAF 로그를 꼭 확인한다. 새 API의 파라미터 구조가 기존 Rule에 걸릴 수 있다.
Managed Rule Group 버전 고정
AWS는 Managed Rule Group을 정기적으로 업데이트한다. 업데이트가 자동 적용되면 갑자기 정상 요청이 차단되는 일이 생길 수 있다. 버전을 고정하고, 새 버전을 Count 모드에서 먼저 테스트한 뒤 적용하는 것이 안전하다.
알람 설정
WAF 차단 건수가 급격히 늘어나면 공격이 증가했거나 False Positive가 발생한 것이다.
CloudWatch Metric: AWS/WAFV2 BlockedRequests
Alarm: 5분 동안 차단 건수 > 1000 → SNS 알람