From 319fd182588bceb53126afad5d30323e2798d1f0 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 15:26:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(stats):=20#337=20=EB=B4=87=20UA=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20+=20IP=20=EB=A0=88=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 단계) --- backend-java/build.gradle | 2 + .../tasteby/controller/StatsController.java | 44 ++++++++++++++-- .../com/tasteby/service/RateLimitService.java | 51 +++++++++++++++++++ .../java/com/tasteby/util/BotDetector.java | 25 +++++++++ .../src/main/resources/application.yml | 4 ++ 5 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 backend-java/src/main/java/com/tasteby/service/RateLimitService.java create mode 100644 backend-java/src/main/java/com/tasteby/util/BotDetector.java diff --git a/backend-java/build.gradle b/backend-java/build.gradle index 8f8a58e..46226ef 100644 --- a/backend-java/build.gradle +++ b/backend-java/build.gradle @@ -32,6 +32,8 @@ dependencies { implementation 'net.javacrumbs.shedlock:shedlock-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) implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01' implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01' diff --git a/backend-java/src/main/java/com/tasteby/controller/StatsController.java b/backend-java/src/main/java/com/tasteby/controller/StatsController.java index 588b32a..80ed697 100644 --- a/backend-java/src/main/java/com/tasteby/controller/StatsController.java +++ b/backend-java/src/main/java/com/tasteby/controller/StatsController.java @@ -1,7 +1,12 @@ package com.tasteby.controller; import com.tasteby.domain.SiteVisitStats; +import com.tasteby.service.RateLimitService; 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 java.util.Map; @@ -10,20 +15,51 @@ import java.util.Map; @RequestMapping("/api/stats") 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.rateLimitService = rateLimitService; } @PostMapping("/visit") - public Map recordVisit() { + public Map 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(); - return Map.of("ok", true); + return Map.of("ok", true, "counted", true); } @GetMapping("/visits") public SiteVisitStats 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"; + } } diff --git a/backend-java/src/main/java/com/tasteby/service/RateLimitService.java b/backend-java/src/main/java/com/tasteby/service/RateLimitService.java new file mode 100644 index 0000000..6143980 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/RateLimitService.java @@ -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; + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/util/BotDetector.java b/backend-java/src/main/java/com/tasteby/util/BotDetector.java new file mode 100644 index 0000000..6aab31d --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/util/BotDetector.java @@ -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(); + } +} diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index acf899b..922611f 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -64,6 +64,10 @@ app: # 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값. max-distance: ${SEARCH_MAX_DISTANCE:0.57} + rate-limit: + # #337 — 같은 IP에서 visit 카운트 허용 간격(초). 기본 60. + visit-window-seconds: ${VISIT_WINDOW_SECONDS:60} + build: # #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown. version: ${APP_VERSION:dev}