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}