Compare commits

..

3 Commits

Author SHA1 Message Date
joungmin
319fd18258 feat(stats): #337 봇 UA 필터 + IP 레이트리밋
- BotDetector 유틸 (Pattern.CASE_INSENSITIVE: bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
- RateLimitService: Redis SET NX EX(60s) 패턴으로 같은 IP 윈도우 차단
  - Bucket4j 대신 spring-data-redis 기존 의존성 재사용 (간결)
  - Redis 다운 시 fail-open (사용자 경험 우선)
- StatsController.recordVisit: HttpServletRequest 받아 UA + X-Forwarded-For 우선 IP
  - 봇/리밋 초과 → 200 + counted:false (사용자 페이지 로드 지장 X)
  - 통과 → 200 + counted:true → statsService.recordVisit()
- application.yml: app.rate-limit.visit-window-seconds (env VISIT_WINDOW_SECONDS) 기본 60
- dev 검증: 봇 UA → counted=false, Mozilla → true, 즉시 재호출 → false

설계서: docs/design/337-stats-bot-ratelimit/README.md

Refs: #337 (Developer 단계)
2026-06-15 15:26:27 +09:00
joungmin
0fa58a622c docs(design): #337 통계 봇 필터 + 레이트리밋 설계서 (Architect)
User-Agent 봇 패턴 필터 + Bucket4j-redis IP 레이트리밋(1/min).
응답은 항상 200 + counted:bool로 사용자 페이지 로드 지장 X.

설계서: docs/design/337-stats-bot-ratelimit/README.md (Approved, 12개 섹션)

Refs: #337 (Architect 단계)
2026-06-15 15:23:12 +09:00
joungmin
9743f96af7 docs(changelog): v0.1.30 #335 ShedLock 분산 락 기록 2026-06-15 15:21:20 +09:00
7 changed files with 245 additions and 4 deletions

View File

@@ -6,6 +6,15 @@
## 2026-06-15 ## 2026-06-15
### 🔒 #335 데몬 분산 락 ShedLock+Redis (v0.1.30)
- shedlock-spring 5.16.0 + shedlock-provider-redis-spring
- @EnableSchedulerLock(defaultLockAtMostFor=PT15M)
- DaemonScheduler.run: @SchedulerLock(name="daemon-runner", lockAtMostFor=PT15M, lockAtLeastFor=PT30S)
- ShedLockConfig: RedisLockProvider Bean (in-cluster Redis 재사용)
- 멀티 파드(RollingUpdate) + dev/prod ATP 공유 환경에서 데몬 중복 실행 차단
- 설계서: docs/design/335-daemon-distributed-lock/README.md
- Refs: #335 (close)
### 💾 #336 캐시 SCAN/UNLINK + 자동 복구 + 에러 메트릭 (v0.1.29) ### 💾 #336 캐시 SCAN/UNLINK + 자동 복구 + 에러 메트릭 (v0.1.29)
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹 (500 batch) - CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹 (500 batch)
- @Scheduled(30s) checkHealth: Redis ping → disabled 자동 토글 (재기동 시 자동 복구) - @Scheduled(30s) checkHealth: Redis ping → disabled 자동 토글 (재기동 시 자동 복구)

View File

@@ -32,6 +32,8 @@ dependencies {
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0' implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0' implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
// #337 — IP 레이트리밋은 Redis SET NX EX 패턴으로 자체 구현 (기존 spring-data-redis 활용)
// Oracle JDBC + Security (Wallet support for Oracle ADB) // Oracle JDBC + Security (Wallet support for Oracle ADB)
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01' implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01' implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'

View File

@@ -1,7 +1,12 @@
package com.tasteby.controller; package com.tasteby.controller;
import com.tasteby.domain.SiteVisitStats; import com.tasteby.domain.SiteVisitStats;
import com.tasteby.service.RateLimitService;
import com.tasteby.service.StatsService; import com.tasteby.service.StatsService;
import com.tasteby.util.BotDetector;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
@@ -10,20 +15,51 @@ import java.util.Map;
@RequestMapping("/api/stats") @RequestMapping("/api/stats")
public class StatsController { public class StatsController {
private final StatsService statsService; private static final Logger log = LoggerFactory.getLogger(StatsController.class);
public StatsController(StatsService statsService) { private final StatsService statsService;
private final RateLimitService rateLimitService;
public StatsController(StatsService statsService, RateLimitService rateLimitService) {
this.statsService = statsService; this.statsService = statsService;
this.rateLimitService = rateLimitService;
} }
@PostMapping("/visit") @PostMapping("/visit")
public Map<String, Object> recordVisit() { public Map<String, Object> recordVisit(HttpServletRequest req) {
// #337 — 봇 UA + IP 레이트리밋. 모두 통과해야 카운트 진행.
String ua = req.getHeader("User-Agent");
if (BotDetector.isBot(ua)) {
log.debug("visit skipped (bot): {}", ua);
return Map.of("ok", true, "counted", false);
}
String clientIp = resolveClientIp(req);
if (!rateLimitService.tryConsume(clientIp)) {
log.debug("visit skipped (rate-limit): {}", clientIp);
return Map.of("ok", true, "counted", false);
}
statsService.recordVisit(); statsService.recordVisit();
return Map.of("ok", true); return Map.of("ok", true, "counted", true);
} }
@GetMapping("/visits") @GetMapping("/visits")
public SiteVisitStats getVisits() { public SiteVisitStats getVisits() {
return statsService.getVisits(); return statsService.getVisits();
} }
/**
* #337 — X-Forwarded-For 우선 (Nginx Ingress 뒤). chain이면 첫 번째(원본).
* 없으면 RemoteAddr 폴백.
*/
private static String resolveClientIp(HttpServletRequest req) {
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
int comma = fwd.indexOf(',');
return (comma > 0 ? fwd.substring(0, comma) : fwd).trim();
}
String addr = req.getRemoteAddr();
return addr != null ? addr : "unknown";
}
} }

View File

@@ -0,0 +1,51 @@
package com.tasteby.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* #337 — IP 기반 레이트리밋 (방문 카운트 어뷰즈 차단).
*
* 단순 Redis SETIFABSENT(SET NX EX) 패턴:
* - 첫 호출 시 키 등록 + TTL → 허용
* - TTL 동안 다음 호출은 키 존재로 차단
*
* Redis 다운 시 fail-open (true 반환) — 사용자 페이지 로드 우선.
* 멀티 파드 + Redis 단일 인스턴스 환경에서 자연스럽게 동작.
*/
@Service
public class RateLimitService {
private static final Logger log = LoggerFactory.getLogger(RateLimitService.class);
private static final String PREFIX = "ratelimit:visit:";
private final StringRedisTemplate redis;
@Value("${app.rate-limit.visit-window-seconds:60}")
private long visitWindowSeconds;
public RateLimitService(StringRedisTemplate redis) {
this.redis = redis;
}
/**
* 단일 IP의 visit 호출 허용 여부.
* @return true = 허용 (첫 호출 또는 윈도우 만료), false = 차단 (윈도우 안 재호출)
*/
public boolean tryConsume(String ipKey) {
try {
String key = PREFIX + ipKey;
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(visitWindowSeconds));
return Boolean.TRUE.equals(ok);
} catch (Exception e) {
// fail-open: Redis 문제로 통계가 약간 부풀어도 사용자 영향 X
log.debug("RateLimit error (fail-open): {}", e.getMessage());
return true;
}
}
}

View File

@@ -0,0 +1,25 @@
package com.tasteby.util;
import java.util.regex.Pattern;
/**
* #337 — User-Agent 기반 봇 패턴 매칭.
*
* Googlebot / bingbot / facebookexternalhit / 일반 crawler/spider 등을 일괄 차단.
* 빈 UA는 봇으로 간주하지 않음(모바일 앱 등 정상 케이스 보호).
*/
public final class BotDetector {
private BotDetector() {}
// 일반적인 봇/크롤러 패턴. 케이스 무시.
private static final Pattern BOT_PATTERN = Pattern.compile(
"bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse",
Pattern.CASE_INSENSITIVE
);
public static boolean isBot(String userAgent) {
if (userAgent == null || userAgent.isBlank()) return false;
return BOT_PATTERN.matcher(userAgent).find();
}
}

View File

@@ -64,6 +64,10 @@ app:
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값. # 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
max-distance: ${SEARCH_MAX_DISTANCE:0.57} max-distance: ${SEARCH_MAX_DISTANCE:0.57}
rate-limit:
# #337 — 같은 IP에서 visit 카운트 허용 간격(초). 기본 60.
visit-window-seconds: ${VISIT_WINDOW_SECONDS:60}
build: build:
# #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown. # #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown.
version: ${APP_VERSION:dev} version: ${APP_VERSION:dev}

View File

@@ -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:<ip>` — 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.