
티켓팅은 트래픽이 “그냥 많다” 수준이 아니라, 정해진 시간에 폭발한다.
그리고 그 폭발의 일부는 사람이 아니라 자동화가 만든다.
단순하게 403/429를 박아도 되긴 하는데, 여기서 문제가 생긴다.
그래서 이 PoC는 방향을 바꿨다.
| 항목 | 현실 문제 | 흔한 대응 | 구멍 |
|---|---|---|---|
| 순간 폭발 트래픽 | 티켓 오픈 시점에 QPS 폭증 | HPA/KEDA 확장 | 확장 신호가 봇 트래픽에 오염될 수 있다 |
| 자동화 재시도 | 403/429면 곧바로 재시도 루프 | 레이트리밋 | 분산/로테이션이면 효율 떨어진다 |
| 봇 적응 | 헤더 위장, 쿠키 저장, 리다이렉트 처리 | 정적 룰 | 룰 고정이면 결국 학습된다 |
여기서 내가 한 선택은 “완벽 탐지”가 아니라 완화(mitigation) 레이어다.
즉, “1차 방어에서 자동화를 흔들고 서버 부담을 줄이는 장치”다.
Client
|
| (1) Request
v
Guard Middleware
| - whitelist?
| - rate count (Redis)
| - risk score
| - action 결정(pass/delay/redirect)
|
+--> PASS ----------------------------> Origin handler (/seat, /reserve, /pay ...)
|
+--> DELAY (100~800ms 랜덤) ----------- > Origin handler
|
+--> REDIRECT (302) --> /challenge?return_to=/seat
|
| (2) token 발급 + HttpOnly 쿠키 set
v
302 -> return_to 로 복귀
이 설계 자체는 README에 적어둔 흐름 그대로다.
여기서 포인트는 “리다이렉트 자체”가 아니다.
포인트는 자동화 클라이언트의 허술함이다.
-L 안 붙이면 302에서 멈춘다 즉, 이 PoC는 “봇을 못 들어오게” 막는 게 아니라, 봇이 들어오면 더 귀찮아지게 만든다.
| 모듈 | 역할 | 키 포인트 |
|---|---|---|
GuardMiddleware | 요청 가로채고 액션 결정 | kill switch/whitelist/score 구간별 pass·delay·redirect |
rate_limiter.py | IP별 슬라이딩 윈도우 카운트 | Redis ZSET로 10초/60초 카운트 |
scorer.py | risk score 계산 | rate + 쿠키/세션 + 헤더 + 민감경로 가중치 |
routes.py | /challenge, /metrics, 데모 엔드포인트 | HttpOnly 쿠키 토큰 발급 후 302 복귀 |
token.py | HMAC 토큰 생성/검증 | exp, ip+ua 바인딩, 서명 검증 |
metrics.py | 인메모리 지표 | redirect/delay/pass 카운트와 avg delay |
config.py | ENV 설정 | 임계값/지연범위/화이트리스트/민감경로 |
GUARD_ENABLED=false면 즉시 우회(킬스위치) /challenge 자체는 가드가 다시 건드리면 루프 난다 → 내부 엔드포인트는 bypass # app/middleware.py (요지)
if score >= cfg.score_high:
return _redirect_to_challenge(path)
if score >= cfg.score_mid:
if random.random() < 0.4:
return _redirect_to_challenge(path)
else:
await asyncio.sleep(delay_ms / 1000.0)
return await call_next(request)
이건 “너 봇이냐”를 맞추는 게 아니라, “너 좀 수상하네? 그럼 번거로운 루트로 돌아가” 라는 느낌이다.
정교한 ML 모델이 아니라, 운영에서 바로 쓸 수 있는 신호를 가볍게 점수로 합산했다.
| 신호 | 조건 | 점수 | 이유(내 생각) |
|---|---|---|---|
| short window rate | 10초 카운트가 limit 초과 | 최대 +40 | 매크로는 짧게 폭발한다 |
| long window rate | 60초 카운트가 limit 초과 | 최대 +30 | 장기적으로도 비정상 패턴 잡는다 |
| 쿠키/세션 없음 | no_cookie_or_session | +15 | 상태 없는 요청은 보통 수상하다 |
| UA 비어있음 | empty_ua | +10 | 봇 클라 대충 만든 경우 많다 |
| Accept-Language 없음 | no_accept_language | +5 | 브라우저스러움 체크 |
| 민감 경로 | /seat, /reserve, /pay 등 | +10 | 핵심 구간에 집중하는 트래픽은 더 엄격히 본다 |
점수는 최대 100으로 캡을 씌웠다.
토큰은 JSON payload + HMAC SHA-256 서명으로 구성한다.
| 필드 | 의미 |
|---|---|
exp | 만료 시각(유닉스 타임) |
jti | 토큰 식별자(UUID hex) |
ip_ua | `ip |
이렇게 하면 토큰이 유출되더라도 다른 IP/UA에서 그대로 재사용하기 어렵다. (완벽은 아니지만, PoC에서 현실적인 수준)
/challenge는 토큰을 HttpOnly 쿠키로 내려주고, 원래 경로로 302 복귀시킨다.
HttpOnly=TrueSameSite=laxmax_age=TOKEN_TTL_SECONDS# app/routes.py (요지)
response.set_cookie(
key="trg_token",
value=token,
httponly=True,
max_age=cfg.token_ttl_seconds,
samesite="lax",
path="/",
)
return RedirectResponse(url=safe_return, status_code=302)
IP별로 rate:{ip}:short, rate:{ip}:long 두 개 ZSET을 둔다.
그리고 “현재 시각 - 윈도우” 이전 데이터는 잘라낸 뒤 zcard로 개수를 센다.
이 방식 장점은 단순하다.
docker compose up --build -d
curl http://localhost:8000/health
레포에 검증 스크립트도 넣어놨다.
bash tests/test_scenarios.sh
curl -v -c cookies.txt -L -H "User-Agent: Mozilla/5.0" -H "Accept-Language: ko-KR,ko;q=0.9" http://localhost:8000/seat
curl -b cookies.txt -H "User-Agent: Mozilla/5.0" -H "Accept-Language: ko-KR,ko;q=0.9" http://localhost:8000/seat
for i in $(seq 1 25); do
curl -s -o /dev/null -w "요청 %2d -> HTTP %{http_code}
" http://localhost:8000/seat
done
/metrics)현재 카운터/지연 평균을 JSON으로 준다.
| 필드 | 의미 |
|---|---|
redirect_count | 302 챌린지로 돌린 횟수 |
delay_count, avg_delay_ms | 지연 건수/평균 지연 |
pass_count | 그냥 통과한 요청 수 |
challenge_pass_count, challenge_fail_count | 토큰 발급/실패 추정치 |
curl http://localhost:8000/metrics | python3 -m json.tool
가드 결정은 guard_decision으로 찍히고, extra 필드로 path/ip/score/action/delay_ms/reason이 들어간다.
{
"ts": "2026-02-26T00:00:00+00:00",
"level": "INFO",
"msg": "guard_decision",
"path": "/seat",
"ip": "203.0.113.10",
"score": 75,
"action": "redirect",
"delay_ms": 0,
"reason": "short_rate=20/15,no_cookie_or_session,empty_ua"
}
위 JSON은 “형식 예시”고, 실제 포맷은 logging_config.py에서 정의한 구조 그대로 나온다.
.env.example 기준으로 정리한다.
| 변수 | 기본값 | 의미 | 운영 팁 |
|---|---|---|---|
GUARD_ENABLED | true | 킬스위치 | 장애/이슈 나면 바로 false로 내려라 |
HMAC_SECRET | change-me-in-production | 토큰 서명 키 | 이거 안 바꾸면 그냥 끝이다 |
TOKEN_TTL_SECONDS | 120 | 토큰 TTL | 너무 길면 재사용 위험, 너무 짧으면 정상 UX 흔들림 |
SCORE_MID/HIGH | 30/70 | 구간 임계값 | 이벤트 직전엔 MID를 올려 “정상 영향” 줄이는 튜닝도 가능 |
DELAY_MIN/MAX_MS | 100/800 | 지연 범위 | 지연은 UX랑 바로 트레이드오프다 |
WHITELIST_IPS | 127.0.0.1,... | 예외 IP | 내부 모니터링/백오피스 대역 넣어라 |
WHITELIST_PATHS | /health,/metrics | 예외 경로 | 헬스/메트릭은 건드리면 운영 터진다 |
SENSITIVE_PATHS | /seat,/reserve,/pay,... | 민감 경로 | 실제 서비스 플로우에 맞게 조정해라 |
현재 /challenge는 return_to를 unquote해서 그대로 Redirect한다.
실서비스면 반드시 다음을 넣어야 한다.
/로 시작 + 도메인/스킴 금지)/seat, /reserve만)지금 구현은 jti를 Redis에 저장하는 유틸이 있고, 발급 시 mark_jti_used를 호출한다.
근데 검증(/challenge/verify)에서는 “jti 재사용 체크”를 아직 강하게 쓰진 않는다.
실무라면 보통 이런 선택지가 있다.
| 옵션 | 장점 | 단점 |
|---|---|---|
jti 1회성(원타임) | 탈취 재사용 어렵다 | Redis 부하 + 실패 시 UX 영향 |
| TTL 동안 재사용 허용 | 운영 단순 | 탈취 시 TTL 동안 재사용 가능 |
PoC의 의도는 “가볍고 빠른 완화”라서 여기서 타협한 거다.
rate limiter가 Redis를 전제로 돌아간다.
실서비스는 Redis 터지면 “전부 redirect” 같은 보수적 정책이 오히려 장애를 키울 수 있다.
이건 “봇 완전 박멸기”가 아니다.
그래서 포지션은 이렇게 잡는 게 맞다.
단독 방어책이 아니라, 게이트웨이 레이어에서 자동화를 흔드는 2차 완화 장치다.
이 PoC는 “탐지 정확도”보다 “운영 레버”에 더 초점을 뒀다.
완벽하진 않다. 근데 “완화 레이어”로는 충분히 의미 있다고 본다.
다음은 이거를 실제 플로우에 붙이면서 튜닝 데이터(메트릭/로그) 쌓고, 챌린지 강화로 넘어가면 된다.
| 항목 | 버전 |
|---|---|
| FastAPI | 0.115.6 |
| Uvicorn | 0.34.0 |
| Redis client | redis[hiredis] 5.2.1 |
| 설정 | pydantic-settings 2.7.1 |
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
api:
build: .
ports:
- "8000:8000"
env_file:
- .env
depends_on:
redis:
condition: service_healthy