From 3a3fc468e4d85bd6b6788aaad1ef50a1ff39212b Mon Sep 17 00:00:00 2001 From: RosieOh Date: Fri, 25 Sep 2026 01:44:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B8=B0=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=20=EC=9D=B4=EB=A0=A5=EA=B3=BC=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=8B=A0=EC=84=A0=EB=8F=84=20=EC=A7=80?= =?UTF-8?q?=ED=91=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스케줄 작업 12개가 로그만 남겨서 "마지막으로 성공한 게 언제인가" 를 질의할 수 없었다. 동기화가 조용히 멈추면 데이터는 낡아가는데 화면은 그대로 보여준다 — 이 프로젝트에서 반복된 실패 방식이고, 지금까지 감시 수단이 없었다. - TBL_SYNC_RUN (V19): 작업별 실행 이력. SUCCESS/PARTIAL/INCOMPLETE/FAILED 구분. PARTIAL 은 데이터가 갱신됐으므로 신선도로 인정한다. - SyncRunTracker: 스케줄러 작업을 감싸 이력을 남긴다. 예외는 잡아 기록·알림 후 삼킨다 (한 작업 실패가 다음 작업을 막지 않게). 이력 저장은 별도 트랜잭션. - SyncFreshnessService: 작업별 경과 시간·기준 초과 여부. Prometheus 게이지 2종. - 매일 11시 신선도 점검 → 기준 넘긴 작업을 묶어 운영 알림. 기동 후 48시간은 유예. - GET /api/admin/sync/status: 관리자용 상태 목록. - 공개 통계(/facilities/statistics, /health/hospitals/statistics)에 dataUpdatedAt 추가. 소개 사이트가 "○월 ○일 기준" 을 자동으로 표시할 수 있다. 이력이 없으면 null. - 수동 동기화(/api/admin/public-data/*)도 이력에 남긴다. - 이력은 90일 후 정리. --- docs/features/operations.md | 48 ++++++ docs/reference/access-control-matrix.md | 1 + .../core/ops/sync/SyncFreshnessScheduler.java | 61 +++++++ .../core/ops/sync/SyncFreshnessService.java | 163 ++++++++++++++++++ .../com/carecode/core/ops/sync/SyncJob.java | 47 +++++ .../com/carecode/core/ops/sync/SyncRun.java | 73 ++++++++ .../core/ops/sync/SyncRunRepository.java | 27 +++ .../core/ops/sync/SyncRunTracker.java | 127 ++++++++++++++ .../core/scheduler/DataCleanupScheduler.java | 15 ++ .../scheduler/PublicDataSyncScheduler.java | 45 ++--- .../controller/AdminPublicDataController.java | 28 ++- .../controller/AdminSyncStatusController.java | 41 +++++ .../response/CareFacilityStatsResponse.java | 6 + .../service/CareFacilityService.java | 5 + .../domain/health/app/HealthFacade.java | 3 + .../dto/response/HospitalStatsResponse.java | 3 + .../resources/db/migration/V19__sync_run.sql | 23 +++ .../ops/sync/SyncFreshnessServiceTest.java | 132 ++++++++++++++ .../core/ops/sync/SyncRunTrackerTest.java | 118 +++++++++++++ .../integration/PublicStatsContractTest.java | 73 ++++++++ 20 files changed, 1003 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/carecode/core/ops/sync/SyncFreshnessScheduler.java create mode 100644 src/main/java/com/carecode/core/ops/sync/SyncFreshnessService.java create mode 100644 src/main/java/com/carecode/core/ops/sync/SyncJob.java create mode 100644 src/main/java/com/carecode/core/ops/sync/SyncRun.java create mode 100644 src/main/java/com/carecode/core/ops/sync/SyncRunRepository.java create mode 100644 src/main/java/com/carecode/core/ops/sync/SyncRunTracker.java create mode 100644 src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java create mode 100644 src/main/resources/db/migration/V19__sync_run.sql create mode 100644 src/test/java/com/carecode/core/ops/sync/SyncFreshnessServiceTest.java create mode 100644 src/test/java/com/carecode/core/ops/sync/SyncRunTrackerTest.java 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> syncFacilities() { - return ResponseEntity.ok(toResponse(facilitySyncService.sync())); + java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); + SyncResult result = facilitySyncService.sync(); + tracker.recordSyncResult(SyncJob.CHILDCARE_FACILITIES, startedAt, result); + return ResponseEntity.ok(toResponse(result)); } @PostMapping("/kindergartens/sync") @Operation(summary = "전국 유치원 동기화", description = "유치원명·주소 기준으로 갱신") public ResponseEntity> syncKindergartens() { - return ResponseEntity.ok(toResponse(kindergartenSyncService.sync())); + java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); + SyncResult result = kindergartenSyncService.sync(); + tracker.recordSyncResult(SyncJob.KINDERGARTENS, startedAt, result); + return ResponseEntity.ok(toResponse(result)); } @PostMapping("/benefits/sync") @Operation(summary = "정부 지원 서비스 동기화", description = "육아 관련 서비스만 정책으로 갱신") public ResponseEntity> syncBenefits() { - return ResponseEntity.ok(toResponse(benefitSyncService.sync())); + java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); + SyncResult result = benefitSyncService.sync(); + tracker.recordSyncResult(SyncJob.GOVERNMENT_BENEFITS, startedAt, result); + return ResponseEntity.ok(toResponse(result)); } @PostMapping("/hospitals/sync") @Operation(summary = "소아청소년과 병원 동기화", description = "요양기호 기준으로 갱신") public ResponseEntity> syncHospitals() { - return ResponseEntity.ok(toResponse(hospitalSyncService.sync())); + java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); + SyncResult result = hospitalSyncService.sync(); + tracker.recordSyncResult(SyncJob.PEDIATRIC_HOSPITALS, startedAt, result); + return ResponseEntity.ok(toResponse(result)); } @PostMapping("/facilities/geocode") @Operation(summary = "시설 좌표 보정", description = "좌표 없는 시설의 주소를 좌표로 변환") public ResponseEntity> geocode() { + java.time.LocalDateTime startedAt = java.time.LocalDateTime.now(); var result = geocodingService.fillMissingCoordinates(); + tracker.recordSuccess(SyncJob.FACILITY_GEOCODING, startedAt, + result.getResolved(), result.getFailed(), result.getSkippedReason()); Map body = new LinkedHashMap<>(); body.put("resolved", result.getResolved()); body.put("failed", result.getFailed()); diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java b/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java new file mode 100644 index 00000000..92b6b5e6 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java @@ -0,0 +1,41 @@ +package com.carecode.domain.admin.controller; + +import com.carecode.core.ops.sync.SyncFreshnessService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 주기 작업 상태 조회. + * + *

"동기화가 돌고 있나" 를 확인할 방법이 로그뿐이었다. 배포 로그를 뒤지지 않고 + * 작업별 마지막 성공 시각과 기준 초과 여부를 한눈에 본다. + * 같은 값이 Prometheus 지표({@code carecode.sync.last.success.age.seconds})로도 나간다. + */ +@RestController +@RequestMapping("/api/admin/sync") +@RequiredArgsConstructor +@Tag(name = "어드민 - 주기 작업", description = "동기화·알림 작업 실행 상태") +public class AdminSyncStatusController { + + private final SyncFreshnessService freshnessService; + + @GetMapping("/status") + @Operation(summary = "주기 작업 상태", description = "작업별 마지막 성공 시각, 경과 시간, 신선도 기준 초과 여부") + public ResponseEntity> status() { + List jobs = freshnessService.describeAll(); + + Map body = new LinkedHashMap<>(); + body.put("jobs", jobs); + body.put("staleCount", jobs.stream().filter(SyncFreshnessService.JobFreshness::isStale).count()); + return ResponseEntity.ok(body); + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityStatsResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityStatsResponse.java index 4575579c..c4475448 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityStatsResponse.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityStatsResponse.java @@ -24,5 +24,11 @@ public class CareFacilityStatsResponse { private long todayBookings; private long thisWeekBookings; private long thisMonthBookings; + + /** + * 시설 데이터가 마지막으로 갱신된 시각. 소개 사이트가 "○월 ○일 기준" 을 자동으로 표시한다. + * 동기화 이력이 없으면 null (한 번도 돌지 않았다는 뜻이라 0 이나 현재 시각으로 속이지 않는다). + */ + private java.time.LocalDateTime dataUpdatedAt; } diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java index b8c24836..43766a47 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java @@ -46,6 +46,7 @@ public class CareFacilityService { private final CareFacilityRepository careFacilityRepository; private final com.carecode.domain.careFacility.repository.CareFacilityBookingRepository bookingRepository; + private final com.carecode.core.ops.sync.SyncFreshnessService syncFreshnessService; private final ReviewRepository reviewRepository; private final UserRepository userRepository; private final CareFacilityMapper careFacilityMapper; @@ -428,6 +429,10 @@ public CareFacilityStatsResponse getFacilityStats() { .todayBookings(bookingRepository.countTodayBookings()) .thisWeekBookings(bookingRepository.countThisWeekBookings()) .thisMonthBookings(bookingRepository.countThisMonthBookings()) + // 시설 목록은 어린이집·유치원 두 동기화가 채운다. 둘 중 오래된 쪽이 이 화면의 기준이다. + .dataUpdatedAt(syncFreshnessService.lastFreshAt( + com.carecode.core.ops.sync.SyncJob.CHILDCARE_FACILITIES, + com.carecode.core.ops.sync.SyncJob.KINDERGARTENS).orElse(null)) .build(); } diff --git a/src/main/java/com/carecode/domain/health/app/HealthFacade.java b/src/main/java/com/carecode/domain/health/app/HealthFacade.java index f390fd06..cc819e6a 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -40,6 +40,7 @@ public class HealthFacade { private final HealthService healthService; private final HospitalRepository hospitalRepository; + private final com.carecode.core.ops.sync.SyncFreshnessService syncFreshnessService; private final HospitalLikeRepository hospitalLikeRepository; private final HospitalReviewRepository hospitalReviewRepository; private final HospitalMapper hospitalMapper; @@ -253,6 +254,8 @@ public com.carecode.domain.health.dto.response.HospitalStatsResponse getHospital return com.carecode.domain.health.dto.response.HospitalStatsResponse.builder() .totalHospitals(total) .byType(sorted) + .dataUpdatedAt(syncFreshnessService.lastFreshAt( + com.carecode.core.ops.sync.SyncJob.PEDIATRIC_HOSPITALS).orElse(null)) .build(); } diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalStatsResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalStatsResponse.java index 7da16888..51cc81c1 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HospitalStatsResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalStatsResponse.java @@ -18,4 +18,7 @@ public class HospitalStatsResponse { /** 진료과목(종별) → 병원 수. 값이 비어 있는 병원은 "기타" 로 묶는다. 많은 순. */ private final Map byType; + + /** 병원 데이터가 마지막으로 갱신된 시각. 동기화 이력이 없으면 null. */ + private final java.time.LocalDateTime dataUpdatedAt; } diff --git a/src/main/resources/db/migration/V19__sync_run.sql b/src/main/resources/db/migration/V19__sync_run.sql new file mode 100644 index 00000000..771cb5e3 --- /dev/null +++ b/src/main/resources/db/migration/V19__sync_run.sql @@ -0,0 +1,23 @@ +-- 주기 작업 실행 이력. +-- +-- 지금까지 스케줄 작업은 로그만 남겼다. 로그는 지나가면 사라지고 질의할 수 없어서 +-- "마지막으로 성공한 게 언제인가" 를 아무도 답할 수 없었다. 동기화가 조용히 멈추면 +-- 데이터는 낡아가는데 화면은 그대로 보여준다 - 이 프로젝트에서 반복된 실패 방식이다. +-- +-- 이력을 남겨 (1) 신선도 지표/알림의 근거로 쓰고, (2) 공개 통계에 기준 시각을 함께 준다. + +CREATE TABLE TBL_SYNC_RUN ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + JOB VARCHAR(50) NOT NULL COMMENT '작업 코드 (SyncJob enum)', + STATUS VARCHAR(20) NOT NULL COMMENT 'SUCCESS, PARTIAL, INCOMPLETE, FAILED', + STARTED_AT DATETIME NOT NULL, + FINISHED_AT DATETIME NOT NULL, + DURATION_MILLIS BIGINT NOT NULL, + PROCESSED INT NOT NULL DEFAULT 0 COMMENT '생성+수정 건수', + FAILED INT NOT NULL DEFAULT 0, + DETAIL VARCHAR(1000) NULL COMMENT '중단 사유나 예외 요약', + + -- 신선도 조회는 "작업별 최근 성공 1건" 이라 이 순서가 필요하다. + INDEX IDX_SYNC_RUN_JOB_STATUS_FINISHED (JOB, STATUS, FINISHED_AT), + INDEX IDX_SYNC_RUN_FINISHED (FINISHED_AT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='주기 작업 실행 이력'; diff --git a/src/test/java/com/carecode/core/ops/sync/SyncFreshnessServiceTest.java b/src/test/java/com/carecode/core/ops/sync/SyncFreshnessServiceTest.java new file mode 100644 index 00000000..d3048877 --- /dev/null +++ b/src/test/java/com/carecode/core/ops/sync/SyncFreshnessServiceTest.java @@ -0,0 +1,132 @@ +package com.carecode.core.ops.sync; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.env.MockEnvironment; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("데이터 신선도 판단") +class SyncFreshnessServiceTest { + + @Mock SyncRunRepository syncRunRepository; + + private MockEnvironment environment; + + @BeforeEach + void setUp() { + environment = new MockEnvironment(); + } + + private SyncFreshnessService service() { + return new SyncFreshnessService(syncRunRepository, environment, new SimpleMeterRegistry()); + } + + @Test + @DisplayName("기준 시간 안에 성공했으면 낡지 않았다") + void freshWithinThreshold() { + givenLastFresh(SyncJob.GOVERNMENT_BENEFITS, LocalDateTime.now().minusHours(5)); + + assertThat(service().isStale(SyncJob.GOVERNMENT_BENEFITS)).isFalse(); + } + + @Test + @DisplayName("기준 시간을 넘기면 낡았다 (일간 작업 36시간)") + void staleBeyondThreshold() { + givenLastFresh(SyncJob.GOVERNMENT_BENEFITS, LocalDateTime.now().minusHours(40)); + + SyncFreshnessService service = service(); + assertThat(service.isStale(SyncJob.GOVERNMENT_BENEFITS)).isTrue(); + assertThat(service.staleAfterHours(SyncJob.GOVERNMENT_BENEFITS)).isEqualTo(36); + } + + @Test + @DisplayName("주 1회 작업은 한 번 건너뛴 정도는 견딘다 (8일 기준)") + void weeklyJobToleratesOneMiss() { + givenLastFresh(SyncJob.CHILDCARE_FACILITIES, LocalDateTime.now().minusDays(7).minusHours(2)); + + assertThat(service().isStale(SyncJob.CHILDCARE_FACILITIES)).isFalse(); + + givenLastFresh(SyncJob.CHILDCARE_FACILITIES, LocalDateTime.now().minusDays(9)); + assertThat(service().isStale(SyncJob.CHILDCARE_FACILITIES)).isTrue(); + } + + @Test + @DisplayName("성공 기록이 없으면 낡은 것으로 본다") + void noRunIsStale() { + when(syncRunRepository.findLastFreshRun(anyString())).thenReturn(Optional.empty()); + when(syncRunRepository.findFirstByJobOrderByFinishedAtDesc(anyString())).thenReturn(Optional.empty()); + + SyncFreshnessService service = service(); + assertThat(service.isStale(SyncJob.CHILDCARE_FACILITIES)).isTrue(); + assertThat(service.lastFreshAt(SyncJob.CHILDCARE_FACILITIES)).isEmpty(); + } + + @Test + @DisplayName("기준은 설정으로 덮어쓸 수 있다") + void thresholdIsConfigurable() { + environment.setProperty("app.sync.freshness.government-benefits", "72"); + givenLastFresh(SyncJob.GOVERNMENT_BENEFITS, LocalDateTime.now().minusHours(40)); + + assertThat(service().isStale(SyncJob.GOVERNMENT_BENEFITS)).isFalse(); + } + + @Test + @DisplayName("한 화면을 여러 작업이 채우면 가장 오래된 시각을 기준으로 준다") + void oldestAmongJobs() { + LocalDateTime older = LocalDateTime.now().minusDays(3); + givenLastFresh(SyncJob.CHILDCARE_FACILITIES, older); + givenLastFresh(SyncJob.KINDERGARTENS, LocalDateTime.now().minusHours(1)); + + assertThat(service().lastFreshAt(SyncJob.CHILDCARE_FACILITIES, SyncJob.KINDERGARTENS)) + .contains(older); + } + + @Test + @DisplayName("조회가 실패해도 예외를 던지지 않는다 — 지표 수집이 장애 원인이 되면 안 된다") + void repositoryFailureIsContained() { + when(syncRunRepository.findLastFreshRun(anyString())).thenThrow(new RuntimeException("DB 연결 끊김")); + + assertThat(service().lastFreshAt(SyncJob.CHILDCARE_FACILITIES)).isEmpty(); + } + + @Test + @DisplayName("상태 목록은 모든 작업을 담고 데이터 작업과 알림 작업을 구분한다") + void describeAllCoversEveryJob() { + givenLastFresh(SyncJob.CHILDCARE_FACILITIES, LocalDateTime.now().minusHours(1)); + + var jobs = service().describeAll(); + + assertThat(jobs).hasSize(SyncJob.values().length); + assertThat(jobs).anyMatch(j -> j.getJob().equals("childcare-facilities") && j.isDataFreshness()); + assertThat(jobs).anyMatch(j -> j.getJob().equals("policy-deadline-notice") && !j.isDataFreshness()); + } + + private void givenLastFresh(SyncJob job, LocalDateTime finishedAt) { + SyncRun run = SyncRun.builder() + .job(job.getCode()) + .status(SyncRun.Status.SUCCESS) + .startedAt(finishedAt.minusMinutes(1)) + .finishedAt(finishedAt) + .durationMillis(60_000) + .processed(10) + .failed(0) + .build(); + when(syncRunRepository.findLastFreshRun(job.getCode())).thenReturn(Optional.of(run)); + when(syncRunRepository.findFirstByJobOrderByFinishedAtDesc(job.getCode())).thenReturn(Optional.of(run)); + } +} diff --git a/src/test/java/com/carecode/core/ops/sync/SyncRunTrackerTest.java b/src/test/java/com/carecode/core/ops/sync/SyncRunTrackerTest.java new file mode 100644 index 00000000..cd5b0766 --- /dev/null +++ b/src/test/java/com/carecode/core/ops/sync/SyncRunTrackerTest.java @@ -0,0 +1,118 @@ +package com.carecode.core.ops.sync; + +import com.carecode.core.client.sync.SyncResult; +import com.carecode.core.ops.OperationalAlerter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 주기 작업이 조용히 실패하지 않게 하는 것이 이 클래스의 목적이다. + * 로그만 남기던 예전 동작에서는 예외로 죽은 실행이 아무 기록도 남기지 않았다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("주기 작업 이력 기록") +class SyncRunTrackerTest { + + @Mock SyncRunRepository syncRunRepository; + @Mock OperationalAlerter alerter; + @InjectMocks SyncRunTracker tracker; + + @Test + @DisplayName("정상 완료는 SUCCESS 로 남고 건수를 기록한다") + void success() { + SyncResult result = new SyncResult("datagokr", "facilities"); + result.countCreated(); + result.countUpdated(); + result.countUpdated(); + + tracker.track(SyncJob.CHILDCARE_FACILITIES, () -> result); + + SyncRun saved = captureSaved(); + assertThat(saved.getStatus()).isEqualTo(SyncRun.Status.SUCCESS); + assertThat(saved.getProcessed()).isEqualTo(3); + assertThat(saved.getFailed()).isZero(); + assertThat(saved.getJob()).isEqualTo("childcare-facilities"); + verify(alerter, never()).alert(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("일부 실패는 PARTIAL 로 남기고 알린다 — 데이터는 갱신됐으므로 신선도는 인정한다") + void partial() { + SyncResult result = new SyncResult("datagokr", "facilities"); + result.countCreated(); + result.countFailed(); + + tracker.track(SyncJob.CHILDCARE_FACILITIES, () -> result); + + SyncRun saved = captureSaved(); + assertThat(saved.getStatus()).isEqualTo(SyncRun.Status.PARTIAL); + assertThat(saved.getStatus().countsAsFresh()).isTrue(); + assertThat(saved.getFailed()).isEqualTo(1); + verify(alerter).alert(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("중단된 실행은 INCOMPLETE 이고 신선도로 인정하지 않는다") + void incomplete() { + SyncResult result = new SyncResult("datagokr", "facilities"); + result.stop("일일 호출 한도 초과"); + + tracker.track(SyncJob.GOVERNMENT_BENEFITS, () -> result); + + SyncRun saved = captureSaved(); + assertThat(saved.getStatus()).isEqualTo(SyncRun.Status.INCOMPLETE); + assertThat(saved.getStatus().countsAsFresh()).isFalse(); + assertThat(saved.getDetail()).contains("일일 호출 한도 초과"); + verify(alerter).alert(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("예외로 죽어도 FAILED 로 남기고 알린 뒤 삼킨다 — 한 작업 실패가 다음 작업을 막지 않는다") + void exceptionIsRecordedAndSwallowed() { + assertThatCode(() -> tracker.track(SyncJob.KINDERGARTENS, () -> { + throw new IllegalStateException("공공데이터 응답 파싱 실패"); + })).doesNotThrowAnyException(); + + SyncRun saved = captureSaved(); + assertThat(saved.getStatus()).isEqualTo(SyncRun.Status.FAILED); + assertThat(saved.getDetail()).contains("공공데이터 응답 파싱 실패"); + verify(alerter).alert(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("값을 돌려주지 않는 알림 작업도 기록한다") + void runnableJob() { + tracker.track(SyncJob.POLICY_DEADLINE_NOTICE, () -> { + }); + + assertThat(captureSaved().getStatus()).isEqualTo(SyncRun.Status.SUCCESS); + } + + @Test + @DisplayName("이력 저장이 실패해도 작업 결과를 망가뜨리지 않는다") + void recordFailureDoesNotPropagate() { + when(syncRunRepository.save(any())).thenThrow(new RuntimeException("DB 연결 끊김")); + + assertThatCode(() -> tracker.track(SyncJob.CHILDCARE_FACILITIES, + () -> new SyncResult("datagokr", "facilities"))).doesNotThrowAnyException(); + } + + private SyncRun captureSaved() { + ArgumentCaptor captor = ArgumentCaptor.forClass(SyncRun.class); + verify(syncRunRepository).save(captor.capture()); + return captor.getValue(); + } +} diff --git a/src/test/java/com/carecode/integration/PublicStatsContractTest.java b/src/test/java/com/carecode/integration/PublicStatsContractTest.java index 44b5f086..b9afe217 100644 --- a/src/test/java/com/carecode/integration/PublicStatsContractTest.java +++ b/src/test/java/com/carecode/integration/PublicStatsContractTest.java @@ -5,7 +5,13 @@ import com.carecode.domain.careFacility.entity.FacilityType; import com.carecode.domain.careFacility.repository.CareFacilityRepository; import com.carecode.domain.health.entity.Hospital; +import com.carecode.core.ops.sync.SyncJob; +import com.carecode.core.ops.sync.SyncRunTracker; import com.carecode.domain.health.repository.HospitalRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.user.service.JwtService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; @@ -21,6 +27,7 @@ import org.springframework.test.web.servlet.MvcResult; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -46,6 +53,8 @@ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", "spring.jpa.hibernate.ddl-auto=create-drop", "spring.flyway.enabled=false", + // 신선도 캐시를 끄고 방금 기록한 이력이 바로 보이게 한다. + "app.sync.freshness.cache-ttl-seconds=0", "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", "springdoc.api-docs.enabled=false", "springdoc.swagger-ui.enabled=false", @@ -68,6 +77,9 @@ class PublicStatsContractTest { @Autowired ObjectMapper objectMapper; @Autowired CareFacilityRepository careFacilityRepository; @Autowired HospitalRepository hospitalRepository; + @Autowired SyncRunTracker syncRunTracker; + @Autowired UserRepository userRepository; + @Autowired JwtService jwtService; @Test @DisplayName("시설 통계는 유형별 분포와 활성 시설 수를 실제 값으로 준다") @@ -101,6 +113,67 @@ void hospitalStatistics() throws Exception { assertThat(stats.path("byType").path("기타").asLong()).isGreaterThanOrEqualTo(1); } + /** + * 공개 통계는 "언제 기준 수치인가" 를 함께 줘야 한다. 소개 사이트가 이 값을 그대로 표시한다. + * 동기화가 한 번도 돌지 않았으면 현재 시각으로 속이지 않고 null 이다. + */ + @Test + @DisplayName("공개 통계는 데이터 기준 시각을 함께 준다") + void statisticsExposeDataUpdatedAt() throws Exception { + assertThat(getJson("/facilities/statistics").path("dataUpdatedAt").isNull()).isTrue(); + + LocalDateTime startedAt = LocalDateTime.now().minusMinutes(1); + syncRunTracker.recordSuccess(SyncJob.CHILDCARE_FACILITIES, startedAt, 120, 0, "테스트"); + syncRunTracker.recordSuccess(SyncJob.KINDERGARTENS, startedAt, 80, 0, "테스트"); + syncRunTracker.recordSuccess(SyncJob.PEDIATRIC_HOSPITALS, startedAt, 40, 0, "테스트"); + + assertThat(getJson("/facilities/statistics").path("dataUpdatedAt").asText()).isNotEmpty(); + assertThat(getJson("/health/hospitals/statistics").path("dataUpdatedAt").asText()).isNotEmpty(); + } + + @Test + @DisplayName("작업 상태 조회는 관리자만 볼 수 있다") + void syncStatusIsAdminOnly() throws Exception { + assertThat(mockMvc.perform(get("/api/admin/sync/status")).andReturn().getResponse().getStatus()) + .isEqualTo(401); + assertThat(status(saveUser(UserRole.PARENT), "/api/admin/sync/status")).isEqualTo(403); + + MvcResult result = mockMvc.perform(get("/api/admin/sync/status") + .header("Authorization", "Bearer " + token(saveUser(UserRole.ADMIN)))) + .andReturn(); + String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(result.getResponse().getStatus()).as(body).isEqualTo(200); + + JsonNode json = objectMapper.readTree(body); + assertThat(json.path("jobs")).hasSize(SyncJob.values().length); + assertThat(json.path("jobs").findValuesAsText("job")).contains("childcare-facilities"); + assertThat(json.path("staleCount").isNumber()).isTrue(); + } + + private int status(User user, String path) throws Exception { + return mockMvc.perform(get(path).header("Authorization", "Bearer " + token(user))) + .andReturn().getResponse().getStatus(); + } + + private String token(User user) { + return jwtService.generateAccessToken(user.getUserId(), user.getEmail(), user.getRole().name()); + } + + private User saveUser(UserRole role) { + String id = UUID.randomUUID().toString().substring(0, 8); + return userRepository.save(User.builder() + .userId("user_" + id) + .email(id + "@example.com") + .password("{noop}unused") + .name("사용자" + id) + .role(role) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .createdAt(LocalDateTime.now()) + .build()); + } + private JsonNode getJson(String path) throws Exception { MvcResult result = mockMvc.perform(get(path)).andReturn(); String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);