diff --git a/docs/features/operations.md b/docs/features/operations.md
index 4fdf484f..49c09cf4 100644
--- a/docs/features/operations.md
+++ b/docs/features/operations.md
@@ -106,9 +106,57 @@ management:
| 빈자리 알림 | `0 30 9 * * *` | `...vacancy-cron` |
| 마감 임박 알림 | `0 0 10 * * *` | `...policy-deadline-cron` |
| 제보 요청 | `0 0 10 * * WED` | `...report-ask-cron` |
+| 데이터 신선도 점검 | `0 0 11 * * *` | `app.scheduler.freshness.cron` |
+| 작업 이력 정리(90일) | `0 10 4 * * *` | `app.scheduler.sync-run-cleanup.cron` |
순서의 근거는 [시스템 개요](../architecture/system-overview.md#배치-실행-시각)에 있습니다.
+### 데이터가 낡았는지 어떻게 아는가
+
+동기화가 멈춰도 사용자 화면은 예전 데이터를 그대로 보여줍니다. 알림이 없으면 누군가
+"요즘 목록이 안 늘던데" 라고 말할 때까지 모릅니다. 실제로 이 프로젝트에서 반복된 실패 방식입니다.
+
+그래서 작업 실행을 `TBL_SYNC_RUN` 에 남깁니다. 로그는 지나가면 사라지고 질의할 수 없습니다.
+
+| 상태 | 뜻 | 신선도로 인정 |
+|------|-----|--------------|
+| `SUCCESS` | 끝까지 돌았고 실패 건 없음 | O |
+| `PARTIAL` | 끝까지 돌았지만 일부 항목 실패 | O (데이터는 갱신됨) |
+| `INCOMPLETE` | 중간에 멈춤 (공공데이터 한도 초과 등) | X |
+| `FAILED` | 예외로 죽음 | X |
+
+예외는 `SyncRunTracker` 가 잡아 이력에 남기고 알린 뒤 **삼킵니다.** 한 작업의 실패가 뒤따르는
+작업을 막지 않아야 하기 때문입니다. 이력 저장은 별도 트랜잭션이라, 작업이 자기 트랜잭션을
+롤백해도 "돌았고 실패했다" 는 사실은 남습니다.
+
+#### 기준과 알림
+
+매일 11시에 작업별 기준을 넘겼는지 확인하고, 넘긴 작업을 **한 번에 묶어** 알립니다.
+기준은 `app.sync.freshness.<작업코드>`(시간 단위)로 덮어쓸 수 있습니다.
+
+| 작업 | 기본 기준 | 근거 |
+|------|-----------|------|
+| 어린이집·유치원 | 192시간(8일) | 주 1회 작업. 한 번 건너뛴 것은 견디고 두 번은 알린다 |
+| 병원 | 216시간(9일) | 주 1회 작업 |
+| 정부지원 서비스·좌표 보정 | 36시간 | 매일 작업 |
+| 알림 작업 3종 | 36시간 | 매일 작업. 발송이 멈춘 것도 장애다 |
+| 제보 요청 | 192시간 | 주 1회 작업 |
+
+기동 직후에는 이력이 없어 전부 "낡음" 으로 보이므로, 성공 기록이 아예 없는 작업은
+**기동 후 48시간이 지나서야** 알립니다. 새 서버가 첫 주기를 돌 시간을 주는 것입니다.
+
+#### 어디서 보는가
+
+| 경로 | 내용 |
+|------|------|
+| `GET /api/admin/sync/status` (ADMIN) | 작업별 마지막 성공 시각·경과 시간·기준 초과 여부·마지막 실행 결과 |
+| `carecode.sync.last.success.age.seconds{job=...}` | 마지막 성공 이후 경과(초). 값이 없으면 한 번도 성공하지 않음 |
+| `carecode.sync.stale{job=...}` | 기준 초과 여부(1=초과) |
+| `GET /facilities/statistics`, `GET /health/hospitals/statistics` | 공개 통계의 `dataUpdatedAt` — 소개 사이트가 "○월 ○일 기준" 표시에 쓴다 |
+
+수동 실행(`/api/admin/public-data/*/sync`)도 이력에 남습니다. 남기지 않으면 방금 돌린 동기화를
+신선도 지표가 모르고 낡았다고 알립니다.
+
### 로그는 서비스에서만 남긴다
스케줄러와 서비스가 **같은 결과를 각각 로그**하던 시절이 있었습니다.
diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md
index 445c0e1a..a3d9ab75 100644
--- a/docs/reference/access-control-matrix.md
+++ b/docs/reference/access-control-matrix.md
@@ -170,6 +170,7 @@ flowchart TD
| `/api/admin/analytics/**` | 퍼널·리텐션 |
| `/api/admin/policy-verification/**` | 금액 수기 검증 |
| `/api/admin/reports/**` | 신고 처리 |
+| `/api/admin/sync/status` | 주기 작업 상태·데이터 신선도 |
| `GET /facilities/{id}/bookings`, `/facilities/{id}/bookings/today`, `/facilities/bookings/today` | 다른 사용자의 예약(보호자 이름·연락처)이 담긴다. 메서드 `@PreAuthorize` |
| `PUT /facilities/bookings/{bookingId}/status` | 확정·완료·반려는 시설 측 업무. 본인 취소는 `DELETE` 로 한다 |
diff --git a/src/main/java/com/carecode/core/ops/sync/SyncFreshnessScheduler.java b/src/main/java/com/carecode/core/ops/sync/SyncFreshnessScheduler.java
new file mode 100644
index 00000000..7d546e9a
--- /dev/null
+++ b/src/main/java/com/carecode/core/ops/sync/SyncFreshnessScheduler.java
@@ -0,0 +1,61 @@
+package com.carecode.core.ops.sync;
+
+import com.carecode.core.ops.OperationalAlerter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.lang.management.ManagementFactory;
+import java.time.Duration;
+import java.util.List;
+
+/**
+ * 데이터가 낡았는지 매일 확인한다.
+ *
+ *
동기화가 멈춰도 사용자 화면은 그대로여서, 알림이 없으면 누군가 "요즘 목록이 안 늘던데" 라고
+ * 말할 때까지 모른다. 작업별 기준 시간을 넘기면 한 번 묶어서 알린다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SyncFreshnessScheduler {
+
+ /**
+ * 기동 직후에는 이력이 없어 전부 "낡음" 으로 보인다. 새 서버가 첫 주기를 돌 시간을 준다.
+ * 가장 긴 일간 작업 기준(36시간)보다 넉넉하게 잡는다.
+ */
+ private static final Duration GRACE_AFTER_STARTUP = Duration.ofHours(48);
+
+ private final SyncFreshnessService freshnessService;
+ private final OperationalAlerter alerter;
+
+ @Scheduled(cron = "${app.scheduler.freshness.cron:0 0 11 * * *}", zone = "Asia/Seoul")
+ public void checkFreshness() {
+ List stale = freshnessService.describeAll().stream()
+ .filter(SyncFreshnessService.JobFreshness::isStale)
+ .filter(job -> job.getLastFreshAt() != null || uptimeExceedsGrace())
+ .toList();
+
+ if (stale.isEmpty()) {
+ log.debug("데이터 신선도 점검: 기준을 넘긴 작업 없음");
+ return;
+ }
+
+ String detail = stale.stream()
+ .map(job -> "- " + job.getLabel() + ": "
+ + (job.getLastFreshAt() == null
+ ? "성공 기록 없음"
+ : job.getAgeHours() + "시간 전 (기준 " + job.getStaleAfterHours() + "시간)")
+ + (job.getLastStatus() != null ? ", 마지막 실행 " + job.getLastStatus() : ""))
+ .reduce((a, b) -> a + "\n" + b)
+ .orElse("");
+
+ log.warn("데이터 신선도 기준을 넘긴 작업 {}건\n{}", stale.size(), detail);
+ alerter.alert("sync-stale", "데이터가 낡았습니다 (" + stale.size() + "건)", detail);
+ }
+
+ private static boolean uptimeExceedsGrace() {
+ return ManagementFactory.getRuntimeMXBean().getUptime() > GRACE_AFTER_STARTUP.toMillis();
+ }
+}
diff --git a/src/main/java/com/carecode/core/ops/sync/SyncFreshnessService.java b/src/main/java/com/carecode/core/ops/sync/SyncFreshnessService.java
new file mode 100644
index 00000000..1761faab
--- /dev/null
+++ b/src/main/java/com/carecode/core/ops/sync/SyncFreshnessService.java
@@ -0,0 +1,163 @@
+package com.carecode.core.ops.sync;
+
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * 주기 작업이 얼마나 오래 성공하지 못했는지 본다.
+ *
+ * 동기화가 멈춰도 화면은 예전 데이터를 그대로 보여준다. 사람이 눈치채기 전에 알려면
+ * "마지막 성공 이후 지난 시간" 을 지표로 만들어야 한다. 공개 통계에는 같은 값을 기준 시각으로 함께 내보내
+ * 소개 사이트가 "○월 ○일 기준" 을 자동으로 표시할 수 있게 한다.
+ */
+@Slf4j
+@Service
+public class SyncFreshnessService {
+
+ private final SyncRunRepository syncRunRepository;
+ private final Environment environment;
+ private final ConcurrentHashMap> cache = new ConcurrentHashMap<>();
+
+ /** Prometheus 스크레이프마다 DB 를 때리지 않도록 잠깐 캐시한다. 테스트는 0 으로 끈다. */
+ private final Duration cacheTtl;
+
+ public SyncFreshnessService(SyncRunRepository syncRunRepository,
+ Environment environment,
+ MeterRegistry meterRegistry) {
+ this.syncRunRepository = syncRunRepository;
+ this.environment = environment;
+ this.cacheTtl = Duration.ofSeconds(
+ environment.getProperty("app.sync.freshness.cache-ttl-seconds", Long.class, 30L));
+
+ for (SyncJob job : SyncJob.values()) {
+ // 한 번도 성공하지 않은 작업은 NaN 이다. 0 으로 두면 "방금 성공" 과 구분되지 않는다.
+ Gauge.builder("carecode.sync.last.success.age.seconds", this, self -> self.ageSeconds(job))
+ .description("마지막 성공 이후 경과 시간(초). 값이 없으면 한 번도 성공하지 않았다")
+ .tag("job", job.getCode())
+ .register(meterRegistry);
+ Gauge.builder("carecode.sync.stale", this, self -> self.isStale(job) ? 1 : 0)
+ .description("신선도 기준을 넘겼는가 (1=넘김)")
+ .tag("job", job.getCode())
+ .register(meterRegistry);
+ }
+ }
+
+ /** 이 작업이 마지막으로 데이터를 갱신한 시각. 한 번도 없으면 빈 값. */
+ @Transactional(readOnly = true)
+ public Optional lastFreshAt(SyncJob job) {
+ return snapshot(job).lastFreshAt();
+ }
+
+ /** 공개 통계의 "기준 시각". 여러 작업이 한 화면을 채우면 그중 가장 오래된 값을 쓴다(가장 보수적). */
+ public Optional lastFreshAt(SyncJob... jobs) {
+ return Arrays.stream(jobs)
+ .map(this::lastFreshAt)
+ .flatMap(Optional::stream)
+ .min(LocalDateTime::compareTo);
+ }
+
+ public boolean isStale(SyncJob job) {
+ Optional lastFreshAt = snapshot(job).lastFreshAt();
+ if (lastFreshAt.isEmpty()) {
+ // 한 번도 안 돌았다. 방금 배포한 환경에서도 참이라 알림 판단은 호출부에서 기동 시간과 함께 본다.
+ return true;
+ }
+ return Duration.between(lastFreshAt.get(), LocalDateTime.now()).toHours() >= staleAfterHours(job);
+ }
+
+ public int staleAfterHours(SyncJob job) {
+ return environment.getProperty("app.sync.freshness." + job.getCode(), Integer.class, job.getStaleAfterHours());
+ }
+
+ /** 관리자 화면·알림에서 쓰는 작업별 현재 상태. */
+ @Transactional(readOnly = true)
+ public List describeAll() {
+ return Arrays.stream(SyncJob.values()).map(job -> {
+ Snapshot snapshot = snapshot(job);
+ SyncRun lastRun = snapshot.lastRun();
+ return JobFreshness.builder()
+ .job(job.getCode())
+ .label(job.getLabel())
+ .dataFreshness(job.isDataFreshness())
+ .lastFreshAt(snapshot.lastFreshAt().orElse(null))
+ .ageHours(snapshot.lastFreshAt()
+ .map(at -> Duration.between(at, LocalDateTime.now()).toHours())
+ .orElse(null))
+ .staleAfterHours(staleAfterHours(job))
+ .stale(isStale(job))
+ .lastStatus(lastRun != null ? lastRun.getStatus().name() : null)
+ .lastFinishedAt(lastRun != null ? lastRun.getFinishedAt() : null)
+ .lastProcessed(lastRun != null ? lastRun.getProcessed() : null)
+ .lastFailed(lastRun != null ? lastRun.getFailed() : null)
+ .lastDetail(lastRun != null ? lastRun.getDetail() : null)
+ .build();
+ }).toList();
+ }
+
+ private Double ageSeconds(SyncJob job) {
+ return snapshot(job).lastFreshAt()
+ .map(at -> (double) Duration.between(at, LocalDateTime.now()).toSeconds())
+ .orElse(Double.NaN);
+ }
+
+ private Snapshot snapshot(SyncJob job) {
+ AtomicReference holder = cache.computeIfAbsent(job, j -> new AtomicReference<>());
+ Cached cached = holder.get();
+ if (!cacheTtl.isZero() && cached != null
+ && Duration.between(cached.readAt(), LocalDateTime.now()).compareTo(cacheTtl) < 0) {
+ return cached.snapshot();
+ }
+ Snapshot fresh = load(job);
+ holder.set(new Cached(LocalDateTime.now(), fresh));
+ return fresh;
+ }
+
+ private Snapshot load(SyncJob job) {
+ try {
+ return new Snapshot(
+ syncRunRepository.findLastFreshRun(job.getCode()).map(SyncRun::getFinishedAt),
+ syncRunRepository.findFirstByJobOrderByFinishedAtDesc(job.getCode()).orElse(null));
+ } catch (RuntimeException e) {
+ // 지표 수집이 장애 원인이 되면 안 된다.
+ log.warn("작업 신선도 조회 실패 - job={}", job.getCode(), e);
+ return new Snapshot(Optional.empty(), null);
+ }
+ }
+
+ private record Snapshot(Optional lastFreshAt, SyncRun lastRun) {
+ }
+
+ private record Cached(LocalDateTime readAt, Snapshot snapshot) {
+ }
+
+ @Getter
+ @Builder
+ public static class JobFreshness {
+ private final String job;
+ private final String label;
+ private final boolean dataFreshness;
+ private final LocalDateTime lastFreshAt;
+ private final Long ageHours;
+ private final int staleAfterHours;
+ private final boolean stale;
+ private final String lastStatus;
+ private final LocalDateTime lastFinishedAt;
+ private final Integer lastProcessed;
+ private final Integer lastFailed;
+ private final String lastDetail;
+ }
+}
diff --git a/src/main/java/com/carecode/core/ops/sync/SyncJob.java b/src/main/java/com/carecode/core/ops/sync/SyncJob.java
new file mode 100644
index 00000000..1f782d80
--- /dev/null
+++ b/src/main/java/com/carecode/core/ops/sync/SyncJob.java
@@ -0,0 +1,47 @@
+package com.carecode.core.ops.sync;
+
+import lombok.Getter;
+
+/**
+ * 신선도를 추적하는 주기 작업 목록.
+ *
+ * {@code staleAfterHours} 는 "이 시간 안에 한 번은 성공했어야 한다" 는 기준이다.
+ * 주 1회 작업은 한 번 건너뛴 것까지는 견디되 두 번은 넘기지 않도록 주기보다 약간 길게 잡는다
+ * (주간 168시간 → 192시간). 기준은 {@code app.sync.freshness.} 로 덮어쓸 수 있다.
+ */
+@Getter
+public enum SyncJob {
+
+ CHILDCARE_FACILITIES("childcare-facilities", "전국 어린이집", 192, true),
+ KINDERGARTENS("kindergartens", "전국 유치원", 192, true),
+ GOVERNMENT_BENEFITS("government-benefits", "정부 지원 서비스", 36, true),
+ PEDIATRIC_HOSPITALS("pediatric-hospitals", "소아청소년과 병원", 216, true),
+ FACILITY_GEOCODING("facility-geocoding", "시설 좌표 보정", 36, false),
+ POLICY_CHANGE_NOTICE("policy-change-notice", "정책 변경 알림", 36, false),
+ FACILITY_VACANCY_NOTICE("facility-vacancy-notice", "빈자리 알림", 36, false),
+ POLICY_DEADLINE_NOTICE("policy-deadline-notice", "마감 임박 알림", 36, false),
+ BENEFIT_REPORT_SOLICIT("benefit-report-solicit", "실수령액 제보 요청", 192, false);
+
+ private final String code;
+ private final String label;
+ private final int staleAfterHours;
+
+ /** 공개 데이터의 신선도를 결정하는 작업인가. 알림 작업은 데이터를 갱신하지 않는다. */
+ private final boolean dataFreshness;
+
+ SyncJob(String code, String label, int staleAfterHours, boolean dataFreshness) {
+ this.code = code;
+ this.label = label;
+ this.staleAfterHours = staleAfterHours;
+ this.dataFreshness = dataFreshness;
+ }
+
+ public static SyncJob ofCode(String code) {
+ for (SyncJob job : values()) {
+ if (job.code.equals(code)) {
+ return job;
+ }
+ }
+ throw new IllegalArgumentException("알 수 없는 작업 코드입니다: " + code);
+ }
+}
diff --git a/src/main/java/com/carecode/core/ops/sync/SyncRun.java b/src/main/java/com/carecode/core/ops/sync/SyncRun.java
new file mode 100644
index 00000000..e3aac9d5
--- /dev/null
+++ b/src/main/java/com/carecode/core/ops/sync/SyncRun.java
@@ -0,0 +1,73 @@
+package com.carecode.core.ops.sync;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/** 주기 작업 실행 한 건. */
+@Entity
+@Table(name = "TBL_SYNC_RUN")
+@Getter
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@lombok.AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class SyncRun {
+
+ /** 실행 결과. 실패와 "완료했지만 일부 실패" 를 구분해야 어디를 봐야 하는지 알 수 있다. */
+ public enum Status {
+ /** 끝까지 돌았고 실패 건이 없다. */
+ SUCCESS,
+ /** 끝까지 돌았지만 일부 항목이 실패했다. */
+ PARTIAL,
+ /** 중간에 멈췄다 (공공데이터 한도 초과 등). */
+ INCOMPLETE,
+ /** 예외로 죽었다. */
+ FAILED;
+
+ /** 신선도 판단에 쓸 수 있는 실행인가. 일부 실패는 데이터가 갱신됐으므로 인정한다. */
+ public boolean countsAsFresh() {
+ return this == SUCCESS || this == PARTIAL;
+ }
+ }
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "ID")
+ private Long id;
+
+ @Column(name = "JOB", nullable = false, length = 50)
+ private String job;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "STATUS", nullable = false, length = 20)
+ private Status status;
+
+ @Column(name = "STARTED_AT", nullable = false)
+ private LocalDateTime startedAt;
+
+ @Column(name = "FINISHED_AT", nullable = false)
+ private LocalDateTime finishedAt;
+
+ @Column(name = "DURATION_MILLIS", nullable = false)
+ private long durationMillis;
+
+ @Column(name = "PROCESSED", nullable = false)
+ private int processed;
+
+ @Column(name = "FAILED", nullable = false)
+ private int failed;
+
+ @Column(name = "DETAIL", length = 1000)
+ private String detail;
+}
diff --git a/src/main/java/com/carecode/core/ops/sync/SyncRunRepository.java b/src/main/java/com/carecode/core/ops/sync/SyncRunRepository.java
new file mode 100644
index 00000000..be162bf7
--- /dev/null
+++ b/src/main/java/com/carecode/core/ops/sync/SyncRunRepository.java
@@ -0,0 +1,27 @@
+package com.carecode.core.ops.sync;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+public interface SyncRunRepository extends JpaRepository {
+
+ /** 신선도 기준이 되는 "마지막으로 데이터를 갱신한 실행". 일부 실패(PARTIAL)도 갱신은 됐으므로 포함한다. */
+ @Query("SELECT r FROM SyncRun r WHERE r.job = :job AND r.status IN ('SUCCESS', 'PARTIAL') "
+ + "ORDER BY r.finishedAt DESC LIMIT 1")
+ Optional findLastFreshRun(@Param("job") String job);
+
+ Optional findFirstByJobOrderByFinishedAtDesc(String job);
+
+ List findByJobOrderByFinishedAtDesc(String job);
+
+ /** 이력은 운영 판단용이라 오래된 것은 지운다. 정리 스케줄러가 호출한다. */
+ @Modifying
+ @Query("DELETE FROM SyncRun r WHERE r.finishedAt < :threshold")
+ int deleteOlderThan(@Param("threshold") LocalDateTime threshold);
+}
diff --git a/src/main/java/com/carecode/core/ops/sync/SyncRunTracker.java b/src/main/java/com/carecode/core/ops/sync/SyncRunTracker.java
new file mode 100644
index 00000000..3b6e04ce
--- /dev/null
+++ b/src/main/java/com/carecode/core/ops/sync/SyncRunTracker.java
@@ -0,0 +1,127 @@
+package com.carecode.core.ops.sync;
+
+import com.carecode.core.client.sync.SyncResult;
+import com.carecode.core.ops.OperationalAlerter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.function.Supplier;
+
+/**
+ * 주기 작업을 감싸 실행 이력을 남긴다.
+ *
+ * 예전에는 결과를 로그로만 남겨서 "마지막으로 성공한 게 언제인가" 를 질의할 수 없었고,
+ * 작업이 예외로 죽으면 스케줄러 스레드에서 스택만 찍히고 지나갔다. 여기서 잡아 이력에 남기고
+ * 운영 알림을 보낸 뒤 삼킨다 — 한 작업의 실패가 다음 작업을 막지 않아야 한다.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SyncRunTracker {
+
+ private static final int DETAIL_MAX_LENGTH = 1000;
+
+ private final SyncRunRepository syncRunRepository;
+ private final OperationalAlerter alerter;
+
+ /** 공공데이터 동기화처럼 {@link SyncResult} 를 돌려주는 작업. */
+ public void track(SyncJob job, Supplier action) {
+ LocalDateTime startedAt = LocalDateTime.now();
+ try {
+ SyncResult result = action.get();
+ SyncRun.Status status = statusOf(result);
+ record(job, status, startedAt,
+ result.getCreated() + result.getUpdated(), result.getFailed(), result.toString());
+ report(job, status, result.toString());
+ } catch (RuntimeException e) {
+ failed(job, startedAt, e);
+ }
+ }
+
+ /** 알림 작업처럼 돌려주는 값이 없는 작업. */
+ public void track(SyncJob job, Runnable action) {
+ LocalDateTime startedAt = LocalDateTime.now();
+ try {
+ action.run();
+ record(job, SyncRun.Status.SUCCESS, startedAt, 0, 0, null);
+ } catch (RuntimeException e) {
+ failed(job, startedAt, e);
+ }
+ }
+
+ /**
+ * 이미 실행한 동기화 결과를 이력에 남긴다. 관리자 수동 실행용.
+ *
+ * 수동 실행은 호출한 사람이 응답으로 결과를 바로 보므로 예외를 여기서 삼키지 않는다.
+ * (그래서 예외로 끝난 수동 실행은 이력에 남지 않는다 — 조용히 지나가지 않으니 문제되지 않는다.)
+ */
+ public void recordSyncResult(SyncJob job, LocalDateTime startedAt, SyncResult result) {
+ record(job, statusOf(result), startedAt,
+ result.getCreated() + result.getUpdated(), result.getFailed(), result.toString());
+ }
+
+ /** 결과 형태가 SyncResult 가 아닌 작업(좌표 보정 등)의 성공 기록. */
+ public void recordSuccess(SyncJob job, LocalDateTime startedAt, int processed, int failed, String detail) {
+ record(job, failed > 0 ? SyncRun.Status.PARTIAL : SyncRun.Status.SUCCESS, startedAt, processed, failed, detail);
+ }
+
+ private void failed(SyncJob job, LocalDateTime startedAt, RuntimeException e) {
+ log.error("{} 작업이 예외로 중단됐습니다", job.getLabel(), e);
+ record(job, SyncRun.Status.FAILED, startedAt, 0, 0, e.getClass().getSimpleName() + ": " + e.getMessage());
+ alerter.alert("sync-exception-" + job.getCode(), job.getLabel() + " 작업 실패", String.valueOf(e.getMessage()));
+ }
+
+ private static SyncRun.Status statusOf(SyncResult result) {
+ if (!result.isCompleted()) {
+ return SyncRun.Status.INCOMPLETE;
+ }
+ return result.getFailed() > 0 ? SyncRun.Status.PARTIAL : SyncRun.Status.SUCCESS;
+ }
+
+ private void report(SyncJob job, SyncRun.Status status, String detail) {
+ if (status == SyncRun.Status.INCOMPLETE) {
+ log.warn("{} 동기화 미완료 - {}", job.getLabel(), detail);
+ alerter.alert("sync-" + job.getCode(), job.getLabel() + " 동기화 미완료", detail);
+ } else if (status == SyncRun.Status.PARTIAL) {
+ alerter.alert("sync-failed-" + job.getCode(), job.getLabel() + " 동기화 중 일부 실패", detail);
+ } else {
+ log.info("{} 동기화 완료 - {}", job.getLabel(), detail);
+ }
+ }
+
+ /**
+ * 이력 저장은 별도 트랜잭션이다. 작업이 자기 트랜잭션을 롤백해도 "돌았고 실패했다" 는 사실은 남아야 한다.
+ * 이력 저장 자체가 실패해도 작업 결과를 덮어써서는 안 되므로 여기서 삼킨다.
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void record(SyncJob job, SyncRun.Status status, LocalDateTime startedAt,
+ int processed, int failed, String detail) {
+ LocalDateTime finishedAt = LocalDateTime.now();
+ try {
+ syncRunRepository.save(SyncRun.builder()
+ .job(job.getCode())
+ .status(status)
+ .startedAt(startedAt)
+ .finishedAt(finishedAt)
+ .durationMillis(Duration.between(startedAt, finishedAt).toMillis())
+ .processed(processed)
+ .failed(failed)
+ .detail(truncate(detail))
+ .build());
+ } catch (RuntimeException e) {
+ log.error("작업 이력 저장 실패 - job={} status={}", job.getCode(), status, e);
+ }
+ }
+
+ private static String truncate(String detail) {
+ if (detail == null) {
+ return null;
+ }
+ return detail.length() <= DETAIL_MAX_LENGTH ? detail : detail.substring(0, DETAIL_MAX_LENGTH);
+ }
+}
diff --git a/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java b/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java
index 945af1cd..8e5f83ad 100644
--- a/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java
+++ b/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java
@@ -1,5 +1,6 @@
package com.carecode.core.scheduler;
+import com.carecode.core.ops.sync.SyncRunRepository;
import com.carecode.domain.user.repository.EmailVerificationTokenRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -16,6 +17,7 @@
public class DataCleanupScheduler {
private final EmailVerificationTokenRepository emailVerificationTokenRepository;
+ private final SyncRunRepository syncRunRepository;
/** 만료·사용 완료된 이메일 인증 토큰 정리. 매일 새벽 4시. 정리하지 않으면 가입 시도마다 행이 쌓여 테이블이 무한히 커진다. */
@Scheduled(cron = "${app.scheduler.cleanup.cron:0 0 4 * * *}", zone = "Asia/Seoul")
@@ -27,4 +29,17 @@ public void cleanupExpiredVerificationTokens() {
log.info("만료된 이메일 인증 토큰 정리 완료 - 건수={}", deleted);
}
}
+
+ /**
+ * 주기 작업 실행 이력 정리. 매일 새벽 4시 10분.
+ * 운영 판단에 쓰는 기록이라 90일이면 충분하다. 그냥 두면 하루 10여 건씩 무한히 쌓인다.
+ */
+ @Scheduled(cron = "${app.scheduler.sync-run-cleanup.cron:0 10 4 * * *}", zone = "Asia/Seoul")
+ @Transactional
+ public void cleanupOldSyncRuns() {
+ int deleted = syncRunRepository.deleteOlderThan(LocalDateTime.now().minusDays(90));
+ if (deleted > 0) {
+ log.info("오래된 작업 이력 정리 완료 - 건수={}", deleted);
+ }
+ }
}
diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java
index 19e3093e..325b2d6d 100644
--- a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java
+++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java
@@ -4,13 +4,13 @@
import com.carecode.core.client.sync.KindergartenSyncService;
import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService;
import com.carecode.core.client.sync.PediatricHospitalSyncService;
-import com.carecode.core.client.sync.SyncResult;
import com.carecode.core.geocoding.FacilityGeocodingService;
import com.carecode.domain.careFacility.service.FacilityVacancyNotifier;
import com.carecode.domain.policy.service.BenefitReportSolicitor;
import com.carecode.domain.policy.service.PolicyChangeNotifier;
import com.carecode.domain.policy.service.PolicyDeadlineNotifier;
-import com.carecode.core.ops.OperationalAlerter;
+import com.carecode.core.ops.sync.SyncJob;
+import com.carecode.core.ops.sync.SyncRunTracker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
@@ -31,40 +31,37 @@ public class PublicDataSyncScheduler {
private final PolicyDeadlineNotifier policyDeadlineNotifier;
private final FacilityVacancyNotifier vacancyNotifier;
private final BenefitReportSolicitor reportSolicitor;
- private final OperationalAlerter alerter;
+ // 실행 이력·신선도 지표·실패 알림을 한곳에서 담당한다.
+ private final SyncRunTracker tracker;
/** 전국 어린이집 동기화. */
@Scheduled(cron = "${app.scheduler.public-data.facility-cron:0 0 3 * * MON}", zone = "Asia/Seoul")
public void syncChildcareFacilities() {
- SyncResult result = facilitySyncService.sync();
- logResult("전국 어린이집", result);
+ tracker.track(SyncJob.CHILDCARE_FACILITIES, facilitySyncService::sync);
}
/** 전국 유치원 동기화. 어린이집 작업과 겹치지 않게 시간을 벌린다. */
@Scheduled(cron = "${app.scheduler.public-data.kindergarten-cron:0 0 4 * * MON}", zone = "Asia/Seoul")
public void syncKindergartens() {
- SyncResult result = kindergartenSyncService.sync();
- logResult("전국 유치원", result);
+ tracker.track(SyncJob.KINDERGARTENS, kindergartenSyncService::sync);
}
/** 정부 지원 서비스(보조금24) 동기화. */
@Scheduled(cron = "${app.scheduler.public-data.benefit-cron:0 30 3 * * *}", zone = "Asia/Seoul")
public void syncGovernmentBenefits() {
- SyncResult result = benefitSyncService.sync();
- logResult("정부 지원 서비스", result);
+ tracker.track(SyncJob.GOVERNMENT_BENEFITS, benefitSyncService::sync);
}
/** 소아청소년과 병원 동기화. */
@Scheduled(cron = "${app.scheduler.public-data.hospital-cron:0 0 3 * * TUE}", zone = "Asia/Seoul")
public void syncPediatricHospitals() {
- SyncResult result = hospitalSyncService.sync();
- logResult("소아청소년과 병원", result);
+ tracker.track(SyncJob.PEDIATRIC_HOSPITALS, hospitalSyncService::sync);
}
/** 정책 변경 알림. 동기화가 끝난 뒤 돌아야 그날 바뀐 내용이 잡힌다. */
@Scheduled(cron = "${app.scheduler.public-data.policy-change-cron:0 0 9 * * *}", zone = "Asia/Seoul")
public void notifyPolicyChanges() {
- policyChangeNotifier.notifyPendingChanges();
+ tracker.track(SyncJob.POLICY_CHANGE_NOTICE, policyChangeNotifier::notifyPendingChanges);
}
/**
@@ -73,7 +70,7 @@ public void notifyPolicyChanges() {
*/
@Scheduled(cron = "${app.scheduler.public-data.vacancy-cron:0 30 9 * * *}", zone = "Asia/Seoul")
public void notifyFacilityVacancies() {
- vacancyNotifier.notifyNewVacancies();
+ tracker.track(SyncJob.FACILITY_VACANCY_NOTICE, vacancyNotifier::notifyNewVacancies);
}
/**
@@ -82,35 +79,19 @@ public void notifyFacilityVacancies() {
*/
@Scheduled(cron = "${app.scheduler.public-data.policy-deadline-cron:0 0 10 * * *}", zone = "Asia/Seoul")
public void notifyPolicyDeadlines() {
- policyDeadlineNotifier.notifyUpcomingDeadlines();
+ tracker.track(SyncJob.POLICY_DEADLINE_NOTICE, policyDeadlineNotifier::notifyUpcomingDeadlines);
}
/** 실수령액 제보 요청. 매일 보내면 소음이라 주 1회만 묻는다. */
@Scheduled(cron = "${app.scheduler.public-data.report-ask-cron:0 0 10 * * WED}", zone = "Asia/Seoul")
public void solicitBenefitReports() {
- reportSolicitor.solicitReports();
+ tracker.track(SyncJob.BENEFIT_REPORT_SOLICIT, reportSolicitor::solicitReports);
}
/** 좌표 보정. 동기화가 끝난 뒤 돌아야 새로 들어온 시설이 대상에 포함된다. */
@Scheduled(cron = "${app.scheduler.public-data.geocoding-cron:0 0 5 * * *}", zone = "Asia/Seoul")
public void fillMissingCoordinates() {
- geocodingService.fillMissingCoordinates();
+ tracker.track(SyncJob.FACILITY_GEOCODING, geocodingService::fillMissingCoordinates);
}
- private void logResult(String label, SyncResult result) {
- if (!result.isCompleted()) {
- log.warn("{} 동기화 미완료 - {}", label, result);
- alerter.alert("sync-" + label, label + " 동기화 미완료", result.toString());
- return;
- }
- if (result.getFailed() > 0) {
- alerter.alert("sync-failed-" + label,
- label + " 동기화 중 " + result.getFailed() + "건 실패", result.toString());
- }
- if (result.getTotalProcessed() == 0 && result.getFailed() == 0) {
- log.debug("{} 동기화: 변경 없음", label);
- return;
- }
- log.info("{} 동기화 완료 - {}", label, result);
- }
}
diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java
index e13cdb91..6f665067 100644
--- a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java
+++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java
@@ -6,6 +6,8 @@
import com.carecode.core.client.sync.PediatricHospitalSyncService;
import com.carecode.core.client.sync.SyncResult;
import com.carecode.core.geocoding.FacilityGeocodingService;
+import com.carecode.core.ops.sync.SyncJob;
+import com.carecode.core.ops.sync.SyncRunTracker;
import com.carecode.domain.careFacility.service.FacilityVacancyNotifier;
import com.carecode.domain.policy.service.PolicyDeadlineNotifier;
import io.swagger.v3.oas.annotations.Operation;
@@ -33,35 +35,53 @@ public class AdminPublicDataController {
private final FacilityGeocodingService geocodingService;
private final FacilityVacancyNotifier vacancyNotifier;
private final PolicyDeadlineNotifier policyDeadlineNotifier;
+ // 수동 실행도 데이터를 갱신하므로 신선도 이력에 남긴다. 남기지 않으면 방금 돌린 동기화를
+ // 신선도 지표가 모르고 "낡음" 으로 알린다.
+ private final SyncRunTracker tracker;
@PostMapping("/facilities/sync")
@Operation(summary = "전국 어린이집 동기화", description = "시설 코드 기준으로 갱신")
public ResponseEntity