Compare commits
7 Commits
v0.1.29
...
109ad106ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
109ad106ac | ||
|
|
319fd18258 | ||
|
|
0fa58a622c | ||
|
|
9743f96af7 | ||
|
|
e5dc0534c4 | ||
|
|
c88cb6ad54 | ||
|
|
079384b645 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -6,6 +6,33 @@
|
|||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🛡️ #337 통계 봇 필터 + 레이트리밋 (v0.1.31)
|
||||||
|
- BotDetector: UA 정규식 (bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
|
||||||
|
- RateLimitService: Redis SET NX EX(60s) 패턴, fail-open (의존성 최소화)
|
||||||
|
- StatsController.recordVisit: X-Forwarded-For 우선 IP + 봇/IP 가드
|
||||||
|
- 응답: {ok, counted:bool} — 차단도 200 (사용자 페이지 지장 X)
|
||||||
|
- application.yml: app.rate-limit.visit-window-seconds (기본 60)
|
||||||
|
- 운영 검증: Googlebot/Mozilla/즉시 재호출 인수조건 모두 충족
|
||||||
|
- 설계서: docs/design/337-stats-bot-ratelimit/README.md
|
||||||
|
- Refs: #337 (close)
|
||||||
|
|
||||||
|
### 🔒 #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)
|
||||||
|
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹 (500 batch)
|
||||||
|
- @Scheduled(30s) checkHealth: Redis ping → disabled 자동 토글 (재기동 시 자동 복구)
|
||||||
|
- AtomicLong errorCount + volatile lastError + 로그 throttle (n==1 또는 n%100==0)
|
||||||
|
- GET /api/admin/cache/stats: disabled/errorCount/lastError 노출 (admin only)
|
||||||
|
- 설계서: docs/design/336-cache-scan-recovery/README.md
|
||||||
|
- Refs: #336 (close)
|
||||||
|
|
||||||
### 🔧 P5-2 작은 후속 (v0.1.26)
|
### 🔧 P5-2 작은 후속 (v0.1.26)
|
||||||
- #338: /api/version 신규 (HealthController + permitAll), application.yml app.build.{version,commit} env 주입 준비
|
- #338: /api/version 신규 (HealthController + permitAll), application.yml app.build.{version,commit} env 주입 준비
|
||||||
- #320: findRegionFromCoords 거리 보정 (유클리드 → cos(lat) 가중치)
|
- #320: findRegionFromCoords 거리 보정 (유클리드 → cos(lat) 가중치)
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
|
||||||
|
// #335 — 분산 락 (RollingUpdate 시 멀티 파드 공존 중 데몬 중복 실행 차단)
|
||||||
|
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)
|
// 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'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.tasteby;
|
package com.tasteby;
|
||||||
|
|
||||||
|
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
@@ -8,6 +9,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
// #335 — defaultLockAtMostFor: 어떤 작업이 lockAtMostFor 명시 안 해도 보호 (안전 마진)
|
||||||
|
@EnableSchedulerLock(defaultLockAtMostFor = "PT15M")
|
||||||
public class TastebyApplication {
|
public class TastebyApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(TastebyApplication.class, args);
|
SpringApplication.run(TastebyApplication.class, args);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.tasteby.config;
|
||||||
|
|
||||||
|
import net.javacrumbs.shedlock.core.LockProvider;
|
||||||
|
import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #335 — ShedLock LockProvider (Redis 기반).
|
||||||
|
*
|
||||||
|
* 데몬 스케줄러가 다중 파드 환경에서 한 번에 하나만 실행되도록 보장.
|
||||||
|
* key prefix는 ShedLock 기본 ("lock:")을 사용 → Redis 키는 `lock:daemon-runner`.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ShedLockConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
|
||||||
|
return new RedisLockProvider(connectionFactory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.tasteby.service;
|
package com.tasteby.service;
|
||||||
|
|
||||||
import com.tasteby.domain.DaemonConfig;
|
import com.tasteby.domain.DaemonConfig;
|
||||||
|
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -37,6 +38,10 @@ public class DaemonScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
|
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
|
||||||
|
// #335 — 분산 락: 멀티 파드 환경에서 한 인스턴스만 실행. Redis 키 `lock:daemon-runner`.
|
||||||
|
// lockAtMostFor: 작업이 비정상 종료돼도 15분 후 강제 해제 (다음 cron이 잡을 수 있게)
|
||||||
|
// lockAtLeastFor: 빨리 끝나도 30초 동안 유지 (즉시 다른 cron이 같은 작업 잡는 것 방지)
|
||||||
|
@SchedulerLock(name = "daemon-runner", lockAtMostFor = "PT15M", lockAtLeastFor = "PT30S")
|
||||||
public void run() {
|
public void run() {
|
||||||
// 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지).
|
// 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지).
|
||||||
// dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함.
|
// dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal file
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
104
docs/design/335-daemon-distributed-lock/README.md
Normal file
104
docs/design/335-daemon-distributed-lock/README.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 설계서: 데몬 스케줄러 분산 락 (#335)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #335 · 부모: #275 (현행화 backend-daemon, 09-Done)
|
||||||
|
> · 구현 파일: `backend-java/build.gradle`, `backend-java/src/main/java/com/tasteby/TastebyApplication.java`, `backend-java/src/main/java/com/tasteby/config/ShedLockConfig.java` (신규), `backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java`
|
||||||
|
> · 테스트: 수동 (롤링 업데이트 시 두 파드 공존 시뮬레이션)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
OKE 운영에서 backend Pod 1개로 동작하지만 RollingUpdate(maxSurge>0) 시 신·구 Pod이 잠시 공존. 또한 dev(PM2)와 운영이 같은 Oracle ATP를 공유 — 이미 `DAEMON_ENABLED` 플래그로 dev 폴링은 차단했지만, 운영 자체에서 두 Pod이 같은 30초 주기로 `scanAllChannels`를 호출하면 YouTube/OCI GenAI 중복 호출 + 동일 영상 두 번 처리 + 봇 감지 위험. ShedLock으로 한 인스턴스만 실행하도록 보장.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `DaemonScheduler.run()`을 분산 락으로 보호 (lockAtMostFor + lockAtLeastFor).
|
||||||
|
- Lock provider: Redis (이미 운영 중인 in-cluster Redis 재사용).
|
||||||
|
- 의존성: `net.javacrumbs.shedlock:shedlock-spring`, `shedlock-provider-redis-spring`.
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- 다른 @Scheduled 메서드(CacheService.checkHealth, 향후 추가될 cron). 필요 시 같은 패턴으로 확장.
|
||||||
|
- 락 획득 실패 시 알람 — Spring Actuator/Micrometer 도입 후 후속.
|
||||||
|
- DB 기반 lock provider (JDBC) — Redis가 충분.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] build.gradle에 shedlock-spring + shedlock-provider-redis-spring 추가.
|
||||||
|
- [ ] `@EnableSchedulerLock` 활성화.
|
||||||
|
- [ ] `DaemonScheduler.run`에 `@SchedulerLock(name="daemon-runner", ...)` 적용.
|
||||||
|
- [ ] 락 키는 `lock:daemon-runner` 형태로 Redis에 저장 (prefix 기본).
|
||||||
|
- [ ] 운영 배포 후 로그에 lock acquire/release 메시지 또는 정상 동작 확인.
|
||||||
|
- [ ] 회귀 없음 — 자동 cron 정상 동작.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Redis는 in-cluster 단일 인스턴스. ShedLock의 Redis provider는 단일 인스턴스에서 SET NX EX로 동작.
|
||||||
|
- Pod 1개 운영이라 평소엔 락 경합 없음 → ShedLock 부하 미미 (Redis 1회 SET NX EX, <1ms).
|
||||||
|
- `lockAtMostFor`: 락이 강제로 해제되기까지 시간. `scanAllChannels`는 channel 6 × 영상 fetch 시간 ≈ 최대 10분 예상. `PT15M`로 안전 마진.
|
||||||
|
- `lockAtLeastFor`: 작업이 빨리 끝나도 락 유지하는 최소 시간 (다음 cron이 즉시 잡지 못하게). 30초 cycle이라 PT30S로 충분.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
[Pod A] [Pod B]
|
||||||
|
│ │
|
||||||
|
│ @Scheduled(fixedDelay=30s)
|
||||||
|
▼ ▼
|
||||||
|
DaemonScheduler.run DaemonScheduler.run
|
||||||
|
│ │
|
||||||
|
│ @SchedulerLock │ @SchedulerLock
|
||||||
|
▼ ▼
|
||||||
|
LockProvider (Redis)
|
||||||
|
├─ SET lock:daemon-runner EX 900 NX ✓ → Pod A 진행
|
||||||
|
└─ SET lock:daemon-runner EX 900 NX ✗ → Pod B 즉시 종료(no-op)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
scanAllChannels / processPending 실행 (A만)
|
||||||
|
│
|
||||||
|
▼ 종료 시 락 키 lockUntil 시각으로 갱신 (lockAtLeastFor 보장)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
Redis 키 1개:
|
||||||
|
- key: `lock:daemon-runner`
|
||||||
|
- value: lockedBy(host:pid) + lockedAt
|
||||||
|
- expiry: lockAtMostFor
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 시그니처 | 비고 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| `DaemonScheduler.run()` (수정) | @SchedulerLock 추가 | 기존 | name="daemon-runner" |
|
||||||
|
| `ShedLockConfig.lockProvider(...)` (신규) | Bean 등록 | `LockProvider lockProvider(RedisConnectionFactory)` | Redis provider |
|
||||||
|
|
||||||
|
## 8. 흐름
|
||||||
|
|
||||||
|
1. 30초마다 fixedDelay로 run() 호출.
|
||||||
|
2. ShedLock AOP가 SET NX EX 시도.
|
||||||
|
3. 성공: 본문 실행. 실패: 즉시 반환(no-op).
|
||||||
|
4. 본문 종료 시 lockUntil 갱신.
|
||||||
|
|
||||||
|
## 9. 엣지케이스
|
||||||
|
|
||||||
|
- **lockAtMostFor 초과 작업**: 락 자동 해제 후 다른 Pod이 잡을 수 있음. scanAllChannels가 15분 넘기지 않게 channel별 timeout 적용 권고(설계서 #275 §11 참고).
|
||||||
|
- **Pod 죽음**: lockAtMostFor 만료 후 자동 해제.
|
||||||
|
- **Redis 다운**: SET 실패 → Spring AOP가 RuntimeException → 다음 30초에 재시도. 캐시 disabled와 별개.
|
||||||
|
- **clock skew**: ShedLock은 Redis 서버 시간 기준이라 클러스터 노드 간 시간 차이 무관.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
|
||||||
|
- 수동: Pod 2개 동시 실행 (kubectl scale deploy backend --replicas=2) 후 로그에서 한 쪽만 `Running scheduled channel scan...` 찍히는지 확인.
|
||||||
|
- 자동: 후속 (ShedLock 자체는 lib 차원에서 테스트됨).
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: ShedLock + Redis.
|
||||||
|
- **대안 A**: Redis SET NX EX 수동 구현 — 가능하나 ShedLock이 lockAtMostFor/lockAtLeastFor 자동 처리해서 더 안전.
|
||||||
|
- **대안 B**: DB(Oracle) 기반 ShedLock — 추가 테이블 필요 + DB 부하. Redis가 더 단순.
|
||||||
|
- **대안 C**: 단일 leader pod (k8s Lease object) — Spring Cloud Kubernetes 도입 부담 크다.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- ShedLock 의존성이 standard library가 아닌 4th-party에 가까움 — 검증된 라이브러리(8년+ 사용, 4k+ stars)지만 향후 Spring 마이크로 버전 호환성은 별도 모니터링.
|
||||||
|
- CacheService.checkHealth는 락 안 걸어도 됨(idempotent). 추가 cron 도입 시 same name 충돌 주의.
|
||||||
114
docs/design/337-stats-bot-ratelimit/README.md
Normal file
114
docs/design/337-stats-bot-ratelimit/README.md
Normal 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.
|
||||||
Reference in New Issue
Block a user