Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/features/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)도 이력에 남습니다. 남기지 않으면 방금 돌린 동기화를
신선도 지표가 모르고 낡았다고 알립니다.

### 로그는 서비스에서만 남긴다

스케줄러와 서비스가 **같은 결과를 각각 로그**하던 시절이 있었습니다.
Expand Down
1 change: 1 addition & 0 deletions docs/reference/access-control-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 로 한다 |

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 데이터가 낡았는지 매일 확인한다.
*
* <p>동기화가 멈춰도 사용자 화면은 그대로여서, 알림이 없으면 누군가 "요즘 목록이 안 늘던데" 라고
* 말할 때까지 모른다. 작업별 기준 시간을 넘기면 한 번 묶어서 알린다.
*/
@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<SyncFreshnessService.JobFreshness> 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();
}
}
163 changes: 163 additions & 0 deletions src/main/java/com/carecode/core/ops/sync/SyncFreshnessService.java
Original file line number Diff line number Diff line change
@@ -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;

/**
* 주기 작업이 얼마나 오래 성공하지 못했는지 본다.
*
* <p>동기화가 멈춰도 화면은 예전 데이터를 그대로 보여준다. 사람이 눈치채기 전에 알려면
* "마지막 성공 이후 지난 시간" 을 지표로 만들어야 한다. 공개 통계에는 같은 값을 기준 시각으로 함께 내보내
* 소개 사이트가 "○월 ○일 기준" 을 자동으로 표시할 수 있게 한다.
*/
@Slf4j
@Service
public class SyncFreshnessService {

private final SyncRunRepository syncRunRepository;
private final Environment environment;
private final ConcurrentHashMap<SyncJob, AtomicReference<Cached>> 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<LocalDateTime> lastFreshAt(SyncJob job) {
return snapshot(job).lastFreshAt();
}

/** 공개 통계의 "기준 시각". 여러 작업이 한 화면을 채우면 그중 가장 오래된 값을 쓴다(가장 보수적). */
public Optional<LocalDateTime> lastFreshAt(SyncJob... jobs) {
return Arrays.stream(jobs)
.map(this::lastFreshAt)
.flatMap(Optional::stream)
.min(LocalDateTime::compareTo);
}

public boolean isStale(SyncJob job) {
Optional<LocalDateTime> 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<JobFreshness> 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<Cached> 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<LocalDateTime> 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;
}
}
47 changes: 47 additions & 0 deletions src/main/java/com/carecode/core/ops/sync/SyncJob.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.carecode.core.ops.sync;

import lombok.Getter;

/**
* 신선도를 추적하는 주기 작업 목록.
*
* <p>{@code staleAfterHours} 는 "이 시간 안에 한 번은 성공했어야 한다" 는 기준이다.
* 주 1회 작업은 한 번 건너뛴 것까지는 견디되 두 번은 넘기지 않도록 주기보다 약간 길게 잡는다
* (주간 168시간 → 192시간). 기준은 {@code app.sync.freshness.<code>} 로 덮어쓸 수 있다.
*/
@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);
}
}
Loading
Loading