From c6428e5d5f5a73dadbca47321796b99012687751 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 14:20:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(infra):=20P4-2=20=EB=8D=B0=EB=AA=AC/?= =?UTF-8?q?=EC=BA=90=EC=8B=9C/=ED=86=B5=EA=B3=84=20=EA=B2=B0=ED=95=A8=20(#?= =?UTF-8?q?275+#276+#274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #275 (데몬): - DaemonConfigService.updateConfig: 정수 필드 가드 (비숫자/0/음수 → 400) - DaemonScheduler: 외부 호출(scan/process) try-finally로 updateLastX 보장 (예외 시에도 다음 cron까지 backoff) - DaemonController.getConfig: AuthUtil.requireAdmin() 추가 (운영 설정 노출 차단) #276 (캐시): - CacheService 생성자: ping을 try-with-resources로 자원 누수 차단, ConnectionFactory null 가드 - makeKey: null/빈 parts 가드 (잘못된 키 생성 방지) #274 (통계): - SiteVisitStats: int → long (21억 누적 시 오버플로 방지) - StatsMapper: getTodayVisits/getTotalVisits long 반환 - StatsService.recordVisit: 자정 경계 동시성 DataIntegrityViolationException 1회 재시도, 2회 실패 시 1건 손실 수용 (운영 영향 미미) 후속 분리: - #336 (#275 분산 락 + DTO + 테스트) - #337 (#276 SCAN + 자동복구 + 메트릭) - #338 (#274 봇/레이트리밋 + Redis INCR + 테스트) Refs: #275 #276 #274 --- .../tasteby/controller/DaemonController.java | 2 ++ .../com/tasteby/domain/SiteVisitStats.java | 5 +++-- .../java/com/tasteby/mapper/StatsMapper.java | 4 ++-- .../com/tasteby/service/CacheService.java | 18 ++++++++++++++-- .../tasteby/service/DaemonConfigService.java | 21 ++++++++++++++++--- .../com/tasteby/service/DaemonScheduler.java | 17 +++++++++++---- .../com/tasteby/service/StatsService.java | 19 ++++++++++++++++- 7 files changed, 72 insertions(+), 14 deletions(-) diff --git a/backend-java/src/main/java/com/tasteby/controller/DaemonController.java b/backend-java/src/main/java/com/tasteby/controller/DaemonController.java index e0af561..a075eea 100644 --- a/backend-java/src/main/java/com/tasteby/controller/DaemonController.java +++ b/backend-java/src/main/java/com/tasteby/controller/DaemonController.java @@ -19,6 +19,8 @@ public class DaemonController { @GetMapping("/config") public DaemonConfig getConfig() { + // #275 — 데몬 운영 설정은 admin 전용 (이전: 공개 노출 — 정보 누출 위험) + AuthUtil.requireAdmin(); DaemonConfig config = daemonConfigService.getConfig(); return config != null ? config : DaemonConfig.builder().build(); } diff --git a/backend-java/src/main/java/com/tasteby/domain/SiteVisitStats.java b/backend-java/src/main/java/com/tasteby/domain/SiteVisitStats.java index 10b7593..29f4641 100644 --- a/backend-java/src/main/java/com/tasteby/domain/SiteVisitStats.java +++ b/backend-java/src/main/java/com/tasteby/domain/SiteVisitStats.java @@ -10,6 +10,7 @@ import lombok.NoArgsConstructor; @NoArgsConstructor @AllArgsConstructor public class SiteVisitStats { - private int today; - private int total; + // #274 — long으로 변경 (21억 이상 누적 시 int 오버플로 방지) + private long today; + private long total; } diff --git a/backend-java/src/main/java/com/tasteby/mapper/StatsMapper.java b/backend-java/src/main/java/com/tasteby/mapper/StatsMapper.java index 8bf2fb4..428d0f2 100644 --- a/backend-java/src/main/java/com/tasteby/mapper/StatsMapper.java +++ b/backend-java/src/main/java/com/tasteby/mapper/StatsMapper.java @@ -7,7 +7,7 @@ public interface StatsMapper { void recordVisit(); - int getTodayVisits(); + long getTodayVisits(); - int getTotalVisits(); + long getTotalVisits(); } diff --git a/backend-java/src/main/java/com/tasteby/service/CacheService.java b/backend-java/src/main/java/com/tasteby/service/CacheService.java index f2dba58..9bbd49c 100644 --- a/backend-java/src/main/java/com/tasteby/service/CacheService.java +++ b/backend-java/src/main/java/com/tasteby/service/CacheService.java @@ -27,8 +27,15 @@ public class CacheService { this.redis = redis; this.mapper = mapper; this.ttl = Duration.ofSeconds(ttlSeconds); - try { - redis.getConnectionFactory().getConnection().ping(); + // #276 — ping 연결 자원 누수 방지: try-with-resources + var factory = redis.getConnectionFactory(); + if (factory == null) { + log.warn("Redis ConnectionFactory is null, caching disabled"); + disabled = true; + return; + } + try (var conn = factory.getConnection()) { + conn.ping(); log.info("Redis connected"); } catch (Exception e) { log.warn("Redis unavailable ({}), caching disabled", e.getMessage()); @@ -37,6 +44,13 @@ public class CacheService { } public String makeKey(String... parts) { + // #276 — null/빈 파트로 "tasteby::" 같은 잘못된 키 생성 방지 + if (parts == null || parts.length == 0) { + throw new IllegalArgumentException("makeKey requires at least one part"); + } + for (String p : parts) { + if (p == null) throw new IllegalArgumentException("makeKey parts must not be null"); + } return PREFIX + String.join(":", parts); } diff --git a/backend-java/src/main/java/com/tasteby/service/DaemonConfigService.java b/backend-java/src/main/java/com/tasteby/service/DaemonConfigService.java index 4c5c31a..6771a20 100644 --- a/backend-java/src/main/java/com/tasteby/service/DaemonConfigService.java +++ b/backend-java/src/main/java/com/tasteby/service/DaemonConfigService.java @@ -2,7 +2,9 @@ package com.tasteby.service; import com.tasteby.domain.DaemonConfig; import com.tasteby.mapper.DaemonConfigMapper; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; import java.util.Map; @@ -27,20 +29,33 @@ public class DaemonConfigService { current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled"))); } if (body.containsKey("scan_interval_min")) { - current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue()); + // #275 — 0/음수 입력으로 30초 사이클 폭주 방지. ClassCastException 대신 400. + current.setScanIntervalMin(requirePositiveInt(body.get("scan_interval_min"), "scan_interval_min")); } if (body.containsKey("process_enabled")) { current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled"))); } if (body.containsKey("process_interval_min")) { - current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue()); + current.setProcessIntervalMin(requirePositiveInt(body.get("process_interval_min"), "process_interval_min")); } if (body.containsKey("process_limit")) { - current.setProcessLimit(((Number) body.get("process_limit")).intValue()); + current.setProcessLimit(requirePositiveInt(body.get("process_limit"), "process_limit")); } mapper.updateConfig(current); } + /** #275 — 양의 정수 가드. 비숫자/0/음수는 400. */ + private static int requirePositiveInt(Object raw, String field) { + if (!(raw instanceof Number n)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 정수여야 합니다"); + } + int v = n.intValue(); + if (v < 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 1 이상이어야 합니다 (폭주 방지)"); + } + return v; + } + public void updateLastScan() { mapper.updateLastScan(); } diff --git a/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java b/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java index dad57c1..4e31d39 100644 --- a/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java +++ b/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java @@ -50,8 +50,13 @@ public class DaemonScheduler { Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null; if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) { log.info("Running scheduled channel scan..."); - int newVideos = youTubeService.scanAllChannels(); - daemonConfigService.updateLastScan(); + int newVideos = 0; + try { + newVideos = youTubeService.scanAllChannels(); + } finally { + // #275 — 외부 호출 예외 시에도 last_scan_at을 갱신해 다음 cron까지의 backoff를 보장 + daemonConfigService.updateLastScan(); + } if (newVideos > 0) { cacheService.flush(); log.info("Scan completed: {} new videos", newVideos); @@ -63,8 +68,12 @@ public class DaemonScheduler { Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null; if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) { log.info("Running scheduled video processing (limit={})...", config.getProcessLimit()); - int restaurants = pipelineService.processPending(config.getProcessLimit()); - daemonConfigService.updateLastProcess(); + int restaurants = 0; + try { + restaurants = pipelineService.processPending(config.getProcessLimit()); + } finally { + daemonConfigService.updateLastProcess(); + } if (restaurants > 0) { cacheService.flush(); log.info("Processing completed: {} restaurants extracted", restaurants); diff --git a/backend-java/src/main/java/com/tasteby/service/StatsService.java b/backend-java/src/main/java/com/tasteby/service/StatsService.java index 172298a..a237291 100644 --- a/backend-java/src/main/java/com/tasteby/service/StatsService.java +++ b/backend-java/src/main/java/com/tasteby/service/StatsService.java @@ -2,11 +2,16 @@ package com.tasteby.service; import com.tasteby.domain.SiteVisitStats; import com.tasteby.mapper.StatsMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @Service public class StatsService { + private static final Logger log = LoggerFactory.getLogger(StatsService.class); + private final StatsMapper mapper; public StatsService(StatsMapper mapper) { @@ -14,7 +19,19 @@ public class StatsService { } public void recordVisit() { - mapper.recordVisit(); + // #274 — 자정 경계 동시성: 두 트랜잭션이 동시에 'NOT MATCHED' 판정 → 둘 다 INSERT + // → PK/UNIQUE 충돌 시 한 쪽 500. 1회 재시도(다음엔 MATCHED → UPDATE 분기). + try { + mapper.recordVisit(); + } catch (DataIntegrityViolationException e) { + log.debug("recordVisit conflict (midnight race), retry once: {}", e.getMessage()); + try { + mapper.recordVisit(); + } catch (DataIntegrityViolationException retryFail) { + // 두 번째 시도도 실패: 카운트 1건 손실은 수용 (운영 영향 미미) + log.warn("recordVisit double-conflict, dropping one count: {}", retryFail.getMessage()); + } + } } public SiteVisitStats getVisits() {