Ticket Redirect Guard: ktcloud techup 실무프로젝트 Poc

HoHk☔️🐁·2026년 2월 26일

Kt Cloud tech up

목록 보기
11/13
post-thumbnail

3줄 요약

  1. 티켓팅 백엔드 앞단에서 risk score로 요청을 분류하고, MID/HIGH 트래픽에 랜덤 지연302 리다이렉트 챌린지를 건다.
  2. 브라우저는 302를 자연스럽게 따라가며 HttpOnly 쿠키 토큰을 받고 복귀하지만, 단순 자동화는 흐름이 끊기기 쉽다.
  3. 킬스위치/임계값/지연범위/화이트리스트를 ENV로 즉시 조절하게 만들어서 운영 레버를 잡는 게 목표다.

시작하며

티켓팅은 트래픽이 “그냥 많다” 수준이 아니라, 정해진 시간에 폭발한다.
그리고 그 폭발의 일부는 사람이 아니라 자동화가 만든다.

단순하게 403/429를 박아도 되긴 하는데, 여기서 문제가 생긴다.

  • 매크로는 거절당하면 재시도한다
  • 프록시/IP 로테이션으로 회피 비용을 낮춘다
  • 결국 서버는 “차단 응답”을 만드는 데도 리소스를 쓴다

그래서 이 PoC는 방향을 바꿨다.

  • 아예 “너 접근 금지”라고 말하기보단
  • 흐름을 꼬아서 자동화가 값비싸지게 만들고,
  • 정상 브라우저는 최대한 자연스럽게 통과시키는 쪽이다.

이 프로젝트가 해결하려는 문제 정의

항목현실 문제흔한 대응구멍
순간 폭발 트래픽티켓 오픈 시점에 QPS 폭증HPA/KEDA 확장확장 신호가 봇 트래픽에 오염될 수 있다
자동화 재시도403/429면 곧바로 재시도 루프레이트리밋분산/로테이션이면 효율 떨어진다
봇 적응헤더 위장, 쿠키 저장, 리다이렉트 처리정적 룰룰 고정이면 결국 학습된다

여기서 내가 한 선택은 “완벽 탐지”가 아니라 완화(mitigation) 레이어다.
즉, “1차 방어에서 자동화를 흔들고 서버 부담을 줄이는 장치”다.


핵심 아이디어: 고의적 지연 + 302 챌린지

전체 플로우 (ASCII)

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에 적어둔 흐름 그대로다.


왜 302 리다이렉트가 먹히냐?

여기서 포인트는 “리다이렉트 자체”가 아니다.
포인트는 자동화 클라이언트의 허술함이다.

  • 브라우저: 302 따라감 + 쿠키 저장 + 다음 요청에 쿠키 포함
  • 단순 매크로/스크립트:
    • -L 안 붙이면 302에서 멈춘다
    • 쿠키 jar 안 쓰면 상태가 이어지지 않는다
    • UA/헤더가 비어있으면 점수도 올라간다

즉, 이 PoC는 “봇을 못 들어오게” 막는 게 아니라, 봇이 들어오면 더 귀찮아지게 만든다.


구현 상세: 파일 단위로 뜯어보기

구성 요소 한 장 요약

모듈역할키 포인트
GuardMiddleware요청 가로채고 액션 결정kill switch/whitelist/score 구간별 pass·delay·redirect
rate_limiter.pyIP별 슬라이딩 윈도우 카운트Redis ZSET로 10초/60초 카운트
scorer.pyrisk score 계산rate + 쿠키/세션 + 헤더 + 민감경로 가중치
routes.py/challenge, /metrics, 데모 엔드포인트HttpOnly 쿠키 토큰 발급 후 302 복귀
token.pyHMAC 토큰 생성/검증exp, ip+ua 바인딩, 서명 검증
metrics.py인메모리 지표redirect/delay/pass 카운트와 avg delay
config.pyENV 설정임계값/지연범위/화이트리스트/민감경로

GuardMiddleware: “여기서 다 결정된다”

핵심 정책(진짜 중요)

  • GUARD_ENABLED=false즉시 우회(킬스위치)
  • 화이트리스트(IP/경로/UA)는 무조건 통과
  • /challenge 자체는 가드가 다시 건드리면 루프 난다 → 내부 엔드포인트는 bypass
  • 점수 구간별 액션:
    • HIGH: 무조건 redirect
    • MID: 40% redirect, 아니면 랜덤 지연 (아니면 둘다 예정)
    • LOW: pass

코드 스니펫 (미들웨어 액션 분기)

# 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)

이건 “너 봇이냐”를 맞추는 게 아니라, “너 좀 수상하네? 그럼 번거로운 루트로 돌아가” 라는 느낌이다.


Risk Score: 점수화는 이렇게 했다

정교한 ML 모델이 아니라, 운영에서 바로 쓸 수 있는 신호를 가볍게 점수로 합산했다.

스코어 룰 요약표

신호조건점수이유(내 생각)
short window rate10초 카운트가 limit 초과최대 +40매크로는 짧게 폭발한다
long window rate60초 카운트가 limit 초과최대 +30장기적으로도 비정상 패턴 잡는다
쿠키/세션 없음no_cookie_or_session+15상태 없는 요청은 보통 수상하다
UA 비어있음empty_ua+10봇 클라 대충 만든 경우 많다
Accept-Language 없음no_accept_language+5브라우저스러움 체크
민감 경로/seat, /reserve, /pay+10핵심 구간에 집중하는 트래픽은 더 엄격히 본다

점수는 최대 100으로 캡을 씌웠다.


302 챌린지: 토큰은 어떻게 발급/검증하나

토큰 내용

토큰은 JSON payload + HMAC SHA-256 서명으로 구성한다.

필드의미
exp만료 시각(유닉스 타임)
jti토큰 식별자(UUID hex)
ip_ua`ip

이렇게 하면 토큰이 유출되더라도 다른 IP/UA에서 그대로 재사용하기 어렵다. (완벽은 아니지만, PoC에서 현실적인 수준)

쿠키 설정

/challenge는 토큰을 HttpOnly 쿠키로 내려주고, 원래 경로로 302 복귀시킨다.

  • HttpOnly=True
  • SameSite=lax
  • max_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)

Redis 슬라이딩 윈도우 카운터: 왜 ZSET을 썼나

IP별로 rate:{ip}:short, rate:{ip}:long 두 개 ZSET을 둔다.

  • score: timestamp
  • member: timestamp 문자열(중복 허용 목적)

그리고 “현재 시각 - 윈도우” 이전 데이터는 잘라낸 뒤 zcard로 개수를 센다.

이 방식 장점은 단순하다.

  • in-memory KV보다 “정확한 윈도우 유지”가 쉽다
  • 만료(expire)로 키를 자동 청소할 수 있다

실행 방법: 로컬에서 바로 돌리기

요구사항

  • Docker + Docker Compose
  • 포트: 8000(FastAPI), 6379(Redis)

실행

docker compose up --build -d
curl http://localhost:8000/health

테스트 스크립트

레포에 검증 스크립트도 넣어놨다.

bash tests/test_scenarios.sh

실전 느낌 테스트: curl로 “정상 vs 의심” 비교

정상 유저 시나리오(브라우저처럼)

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_count302 챌린지로 돌린 횟수
delay_count, avg_delay_ms지연 건수/평균 지연
pass_count그냥 통과한 요청 수
challenge_pass_count, challenge_fail_count토큰 발급/실패 추정치
curl http://localhost:8000/metrics | python3 -m json.tool

구조화 로그(JSON)

가드 결정은 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): 운영 레버를 표로 정리

.env.example 기준으로 정리한다.

변수기본값의미운영 팁
GUARD_ENABLEDtrue킬스위치장애/이슈 나면 바로 false로 내려라
HMAC_SECRETchange-me-in-production토큰 서명 키이거 안 바꾸면 그냥 끝이다
TOKEN_TTL_SECONDS120토큰 TTL너무 길면 재사용 위험, 너무 짧으면 정상 UX 흔들림
SCORE_MID/HIGH30/70구간 임계값이벤트 직전엔 MID를 올려 “정상 영향” 줄이는 튜닝도 가능
DELAY_MIN/MAX_MS100/800지연 범위지연은 UX랑 바로 트레이드오프다
WHITELIST_IPS127.0.0.1,...예외 IP내부 모니터링/백오피스 대역 넣어라
WHITELIST_PATHS/health,/metrics예외 경로헬스/메트릭은 건드리면 운영 터진다
SENSITIVE_PATHS/seat,/reserve,/pay,...민감 경로실제 서비스 플로우에 맞게 조정해라

“이거 실무에 붙이면?” 체크리스트

1) return_to 검증(오픈 리다이렉트 방지)

현재 /challengereturn_tounquote해서 그대로 Redirect한다.
실서비스면 반드시 다음을 넣어야 한다.

  • return_to는 내부 path만 허용(/로 시작 + 도메인/스킴 금지)
  • 허용 리스트 방식이 더 안전하다(예: /seat, /reserve만)

2) 토큰의 “사용 여부” 처리

지금 구현은 jti를 Redis에 저장하는 유틸이 있고, 발급 시 mark_jti_used를 호출한다.
근데 검증(/challenge/verify)에서는 “jti 재사용 체크”를 아직 강하게 쓰진 않는다.

실무라면 보통 이런 선택지가 있다.

옵션장점단점
jti 1회성(원타임)탈취 재사용 어렵다Redis 부하 + 실패 시 UX 영향
TTL 동안 재사용 허용운영 단순탈취 시 TTL 동안 재사용 가능

PoC의 의도는 “가볍고 빠른 완화”라서 여기서 타협한 거다.

3) Redis 장애 시 정책

rate limiter가 Redis를 전제로 돌아간다.
실서비스는 Redis 터지면 “전부 redirect” 같은 보수적 정책이 오히려 장애를 키울 수 있다.

  • 티켓팅은 장애 = 매출/신뢰 손실이라
  • 보통은 “최소 지연 + 통과” 같은 fail-open 성격이 안전한 경우가 많다

한계

이건 “봇 완전 박멸기”가 아니다.

  • 제대로 만든 봇은 302 따라가고 쿠키도 저장한다
  • 튜닝을 못 하면 정상 유저도 지연/리다이렉트에 걸려 UX가 흔들릴 수 있다

그래서 포지션은 이렇게 잡는 게 맞다.

단독 방어책이 아니라, 게이트웨이 레이어에서 자동화를 흔드는 2차 완화 장치다.

마치며

이 PoC는 “탐지 정확도”보다 “운영 레버”에 더 초점을 뒀다.

  • 오픈 직전 갑자기 봇 파도 들어올 때
  • WAF/레이트리밋만으론 비용이 감당 안 될 때
  • 일단 서버를 살려야 할 때

완벽하진 않다. 근데 “완화 레이어”로는 충분히 의미 있다고 본다.
다음은 이거를 실제 플로우에 붙이면서 튜닝 데이터(메트릭/로그) 쌓고, 챌린지 강화로 넘어가면 된다.


부록 A. 기술 스택

항목버전
FastAPI0.115.6
Uvicorn0.34.0
Redis clientredis[hiredis] 5.2.1
설정pydantic-settings 2.7.1

부록 B. 로컬 구성(docker-compose)

services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
  api:
    build: .
    ports:
      - "8000:8000"
    env_file:
      - .env
    depends_on:
      redis:
        condition: service_healthy

profile
nyo님 좋아합니다!

0개의 댓글