Compare commits

..

2 Commits

Author SHA1 Message Date
joungmin
865cd86aff docs(changelog): v0.1.21 데몬/캐시/통계 결함 기록 2026-06-15 14:22:13 +09:00
joungmin
c6428e5d5f fix(infra): P4-2 데몬/캐시/통계 결함 (#275+#276+#274)
#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
2026-06-15 14:20:14 +09:00
8 changed files with 79 additions and 14 deletions

View File

@@ -6,6 +6,13 @@
## 2026-06-15
### ⚙️ P4-2 데몬/캐시/통계 결함 (v0.1.21)
- #275: updateConfig 가드(1+ 정수), Scheduler try-finally updateLastX, GET config admin-only
- #276: ping try-with-resources + ConnectionFactory null 가드, makeKey null 가드
- #274: SiteVisitStats int → long, recordVisit DataIntegrityViolationException 1회 재시도
- 후속 분리: #335 (분산락), #336 (SCAN/자동복구), #337 (봇/레이트리밋)
- Refs: #275 #276 #274 (close)
### 🧱 P4-1 백엔드 CRUD 결함 (v0.1.20)
- #294: MemoService/ReviewService 동시성 DuplicateKeyException 가드, rating 0~5 검증, getAvgRating NVL
- #295: 유니크 충돌 typed exception, channel_id "UC..." 형식 명시 분기, findByChannelId 컬럼 보완, body null 가드

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -7,7 +7,7 @@ public interface StatsMapper {
void recordVisit();
int getTodayVisits();
long getTodayVisits();
int getTotalVisits();
long getTotalVisits();
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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() {