From 0fa58a622cdad459468ddadeedb779247d7eefbd Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 15:23:12 +0900 Subject: [PATCH] =?UTF-8?q?docs(design):=20#337=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EB=B4=87=20=ED=95=84=ED=84=B0=20+=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EB=B0=8B=20=EC=84=A4=EA=B3=84=EC=84=9C=20(Ar?= =?UTF-8?q?chitect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Agent 봇 패턴 필터 + Bucket4j-redis IP 레이트리밋(1/min). 응답은 항상 200 + counted:bool로 사용자 페이지 로드 지장 X. 설계서: docs/design/337-stats-bot-ratelimit/README.md (Approved, 12개 섹션) Refs: #337 (Architect 단계) --- docs/design/337-stats-bot-ratelimit/README.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/design/337-stats-bot-ratelimit/README.md diff --git a/docs/design/337-stats-bot-ratelimit/README.md b/docs/design/337-stats-bot-ratelimit/README.md new file mode 100644 index 0000000..421967f --- /dev/null +++ b/docs/design/337-stats-bot-ratelimit/README.md @@ -0,0 +1,114 @@ +# 설계서: 통계 방문 카운트 봇 필터 + 레이트리밋 (#337) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #337 · 부모: #274 (현행화 backend-stats, 09-Done) +> · 구현 파일: `backend-java/build.gradle`, `backend-java/src/main/java/com/tasteby/controller/StatsController.java`, `backend-java/src/main/java/com/tasteby/util/BotDetector.java` (신규), `backend-java/src/main/java/com/tasteby/service/RateLimitService.java` (신규) +> · 테스트: 수동 (curl로 봇 UA + 같은 IP 다중 호출) + +## 1. 목적 (Why) + +`POST /api/stats/visit`이 인증 없이 누구나 호출 가능 + 봇/크롤러 필터 없음 → (a) 일반 봇이 사이트 인덱싱하면서 카운터 인플레이션, (b) 악의적 새로고침 어뷰즈로 통계 왜곡. 또한 페이지 로드마다 DB write QPS 증가 — 클라이언트 어뷰즈에 취약. + +## 2. 범위 (Scope) + +- **포함** + - User-Agent 기반 봇 패턴 필터 (Googlebot, bingbot, crawler 등 일반 패턴). + - IP 기반 레이트리밋: 같은 IP에서 1분에 1회만 카운트 (Bucket4j + Redis). + - X-Forwarded-For 헤더 우선 (Nginx Ingress 뒤이므로). + - 봇/리밋 초과: 200 응답하되 카운터 미증가 (사용자 경험 영향 X). +- **제외 (out of scope)** + - WAF 수준 봇 차단 (Cloudflare 등). + - UU(고유 방문자) — 별도 후속 (쿠키/세션). + - Redis INCR 기반 비동기 통계 (DB write 분리) — 별도 후속. + +## 3. 인수조건 + +- [ ] build.gradle에 bucket4j-redis 추가. +- [ ] User-Agent에 'bot'/'crawler'/'spider' 포함 시 카운트 skip + 디버그 로그. +- [ ] 같은 IP에서 60초 안에 두 번째 visit 호출 시 카운트 skip. +- [ ] 응답은 항상 `{ "ok": true, "counted": bool }` 형태. +- [ ] 봇/리밋 초과 호출도 200 응답 (사용자 페이지 로드 지장 X). +- [ ] 운영 배포 후 회귀 없음 — 통계 카운터가 정상 증가. + +## 4. 컨텍스트 & 제약 + +- Bucket4j 8.x + Redis backend (Lettuce 호환). +- 메모리만 사용하는 경우(단일 파드) ConcurrentLinkedHashMap도 가능하나 ShedLock 사례처럼 멀티 파드 미래 대비 Redis. +- 운영 트래픽 작아 Bucket4j 부하 미미. +- 1분 1회 정책은 동일 IP에서 사용자가 페이지 새로고침해도 영향 작음. 너무 빡빡하면 다중 사용자 NAT(회사/카페) 영향 → 1분/IP는 균형. + +## 5. 아키텍처 개요 + +``` +사용자 페이지 로드 + │ + ▼ POST /api/stats/visit (X-Forwarded-For: client-ip) +StatsController.recordVisit(req) + │ + ├─ BotDetector.isBot(userAgent) → skip + │ + ├─ RateLimitService.tryConsume(clientIp) → false면 skip + │ + ▼ +StatsService.recordVisit() → DB MERGE +``` + +## 6. 데이터 모델 + +Redis 키: +- `bucket4j:visit:` — Bucket4j가 자동 관리 (token bucket state) + +응답: +```json +{ "ok": true, "counted": true } // 정상 카운트 +{ "ok": true, "counted": false } // 봇/리밋 초과 +``` + +## 7. 함수 명세 + +| 함수 | 책임 | 시그니처 | 비고 | +|------|------|----------|------| +| `BotDetector.isBot(ua)` | UA 문자열 봇 패턴 매칭 | `static boolean isBot(String)` | 순수 함수 | +| `RateLimitService.tryConsume(key)` | Bucket4j 1 토큰 소비 | `boolean tryConsume(String)` | Bucket 1/min | +| `StatsController.recordVisit(req)` (수정) | 봇 + IP 필터 후 카운트 | `@PostMapping("/visit")` | request 헤더 활용 | + +## 8. 흐름 + +1. POST `/api/stats/visit` 진입. +2. `X-Forwarded-For` 우선 → 없으면 `request.getRemoteAddr()`. +3. User-Agent 검사 (`isBot` true면 counted=false). +4. IP 레이트 검사 (`tryConsume` false면 counted=false). +5. 통과 시 `recordVisit()` 호출. +6. 응답 `{ok:true, counted: ...}`. + +## 9. 엣지케이스 + +- **여러 IP가 헤더에 chain**: X-Forwarded-For 첫 번째(원본 클라이언트) 사용. +- **헤더 위조**: Nginx Ingress 뒤라 외부 위조는 어렵지만, 신뢰는 가정. +- **Redis 다운**: Bucket4j Redis 에러 → fail open(즉, counted=true 진행). 통계는 약간 부풀지만 사용자 경험 우선. +- **빈 UA**: 봇 판정 안 함 (정상 모바일 앱일 수도). + +## 10. 테스트 + +- 수동: + ``` + curl -X POST -H "User-Agent: Googlebot/2.1" https://www.tasteby.net/api/stats/visit + → counted=false + curl -X POST -H "User-Agent: Mozilla/5.0" https://www.tasteby.net/api/stats/visit + → counted=true + (즉시 재호출) → counted=false (rate limit) + ``` + +## 11. 리스크 & 대안 + +- **선택**: Bucket4j + Redis backend + UA 정규식. +- **대안 A**: 메모리 LRU만 사용 — 단일 파드는 OK, 멀티 파드는 어뷰즈 가능. +- **대안 B**: Nginx Ingress에서 rate limit — 인프라 분리 깔끔. 다만 봇 UA 필터는 ingress nginx 모듈 추가 부담, 어플리케이션 가시성↓. +- **대안 C**: 비동기 큐(@Async 또는 Redis INCR) — DB write 부담 해소. 본 이슈는 어뷰즈 차단이 우선이라 후속으로 분리. + +## 12. 미해결 질문 + +- UU(쿠키 기반 고유 방문자) — 별도 후속 (#274 후속 #337의 잔여 항목으로 분리). +- Bucket4j 1분 정책의 적정성 — 운영 1주일 관찰 후 조정. +- 봇 UA 화이트리스트(친화적 검색 엔진은 카운트 포함?) — 현재 일괄 skip.