From a67ba676af99d69addd281c018c7c0f7a42f8afa Mon Sep 17 00:00:00 2001 From: RosieOh Date: Fri, 25 Sep 2026 02:38:56 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9E=85=EC=86=8C=20=EC=98=88?= =?UTF-8?q?=EC=B8=A1=20=EC=A0=95=ED=99=95=EB=8F=84=EB=A5=BC=20=EB=B0=B1?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EC=B8=A1=EC=A0=95?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EA=B3=B5=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확률을 보여주면서 그 확률이 맞는지 확인한 적이 없었다. 정원 관측 시계열이 이미 쌓이므로 예측을 기록해 몇 달 기다릴 필요 없이 과거 시점을 재현해 검증할 수 있다. - AdmissionForecastCalculator: 예측 계산을 순수 클래스로 분리. 라이브와 백테스트가 같은 코드를 쓴다 (검증용을 따로 구현하면 무엇을 검증했는지 알 수 없다). - ForecastBacktestService: 기준일 이전 관측만으로 예측을 재계산하고 이후 구간 관측으로 실제 결과를 확인. 기준일을 30일씩 옮기며 표본을 모은다. 확률대별 적중률, Brier 점수, 기준선(항상 평균으로 답했을 때)을 기록. - TBL_FORECAST_ACCURACY (V20): 기간별(1·3·6개월) 측정 결과. - 표본이 30건 미만이면 정확도를 내보내지 않고, 구간 표본 10건 미만이면 그 구간만 비운다. - 예측 응답에 accuracy(같은 확률대의 실제 적중률) 추가, GET /facilities/forecast-accuracy 공개. - 주 1회 자동 측정(일 04:30) + POST /api/admin/sync/forecast-accuracy/measure 수동 실행. --- docs/features/facility-intelligence.md | 53 ++++ docs/reference/access-control-matrix.md | 1 + .../com/carecode/core/ops/sync/SyncJob.java | 3 +- .../scheduler/ForecastBacktestScheduler.java | 29 +++ .../controller/AdminSyncStatusController.java | 29 +++ .../careFacility/app/CareFacilityFacade.java | 7 + .../controller/CareFacilityController.java | 11 + .../response/AdmissionForecastResponse.java | 6 + .../response/ForecastAccuracyResponse.java | 58 +++++ .../careFacility/entity/ForecastAccuracy.java | 65 +++++ .../FacilityCapacitySnapshotRepository.java | 5 + .../ForecastAccuracyRepository.java | 16 ++ .../service/AdmissionForecastCalculator.java | 173 +++++++++++++ .../service/AdmissionForecastService.java | 159 ++---------- .../service/ForecastAccuracyService.java | 99 ++++++++ .../service/ForecastBacktestService.java | 228 ++++++++++++++++++ .../db/migration/V20__forecast_accuracy.sql | 24 ++ .../service/AdmissionForecastServiceTest.java | 6 +- .../service/ForecastAccuracyServiceTest.java | 122 ++++++++++ .../service/ForecastBacktestServiceTest.java | 178 ++++++++++++++ .../ForecastAccuracyContractTest.java | 186 ++++++++++++++ 21 files changed, 1320 insertions(+), 138 deletions(-) create mode 100644 src/main/java/com/carecode/core/scheduler/ForecastBacktestScheduler.java create mode 100644 src/main/java/com/carecode/domain/careFacility/dto/response/ForecastAccuracyResponse.java create mode 100644 src/main/java/com/carecode/domain/careFacility/entity/ForecastAccuracy.java create mode 100644 src/main/java/com/carecode/domain/careFacility/repository/ForecastAccuracyRepository.java create mode 100644 src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastCalculator.java create mode 100644 src/main/java/com/carecode/domain/careFacility/service/ForecastAccuracyService.java create mode 100644 src/main/java/com/carecode/domain/careFacility/service/ForecastBacktestService.java create mode 100644 src/main/resources/db/migration/V20__forecast_accuracy.sql create mode 100644 src/test/java/com/carecode/domain/careFacility/service/ForecastAccuracyServiceTest.java create mode 100644 src/test/java/com/carecode/domain/careFacility/service/ForecastBacktestServiceTest.java create mode 100644 src/test/java/com/carecode/integration/ForecastAccuracyContractTest.java diff --git a/docs/features/facility-intelligence.md b/docs/features/facility-intelligence.md index cc55ef34..f1735260 100644 --- a/docs/features/facility-intelligence.md +++ b/docs/features/facility-intelligence.md @@ -52,6 +52,54 @@ flowchart TD 그럴듯한 숫자를 보여주는 쪽이 사용자 경험은 좋아 보이지만, 그 숫자를 믿고 다른 시설을 포기한 부모에게는 피해입니다. +## 예측이 맞는지 스스로 검증한다 (V20) + +확률을 보여주면서 **그 확률이 실제로 맞는지 확인한 적이 없었습니다.** 예측을 기록해 몇 달 기다릴 +필요는 없습니다 — 정원 관측 시계열이 이미 있으므로 과거로 돌아가 같은 계산을 다시 할 수 있습니다. + +``` +기준일(과거 어느 날) + → 그날 이전 관측만 넘겨 예측을 다시 계산 ← 이후 관측을 섞으면 미래를 보고 예측한 셈 + → 기준일 다음 N개월 관측으로 실제 결과 확인 (자리가 났는가) + → (예측 확률, 실제 결과) 쌍을 모아 집계 +``` + +기준일을 30일 간격으로 옮기며 표본을 모읍니다. 라이브 예측과 **같은 계산기** +(`AdmissionForecastCalculator`)를 씁니다. 검증용으로 따로 구현하면 무엇을 검증했는지 알 수 없습니다. + +### 무엇을 재는가 + +| 지표 | 뜻 | +|------|-----| +| 확률대별 실제 적중률 | "60~80% 로 예측한 건 중 실제로 자리가 난 비율". 예측이 정직한지 보여준다 | +| Brier 점수 | 낮을수록 정확. 0=완벽, 0.25=동전 던지기 | +| 기준선 Brier | 예측하지 않고 **항상 평균 발생률로 답했을 때**의 점수 | + +기준선을 함께 기록하는 이유: 예측이 이보다 못하면 확률을 보여줄 근거가 없습니다. +"평균으로 답하기" 보다 나은지는 응답의 `betterThanBaseline` 로 나갑니다. + +시설 이력은 시설당 한 번만 읽어 모든 기간에 재사용합니다. 기간마다 다시 읽으면 전국 규모(수만 곳)에서 +주간 작업이 쿼리 수만 건으로 늘어납니다. 대상 시설 수에는 상한이 있습니다. + +측정 기간은 1·3·6개월입니다. 6개월만 재면 관측이 6개월 넘게 쌓인 시설이 있어야 표본이 생겨, +수집 초기에는 아무것도 검증할 수 없습니다. + +### 표본이 적으면 보여주지 않는다 + +| 조건 | 처리 | +|------|------| +| 전체 표본 30건 미만 | 정확도를 아예 내보내지 않는다 | +| 해당 확률대 표본 10건 미만 | 구간 적중률만 비운다 | +| 검증 구간에 관측 없음 | 표본에서 버린다 (실제 결과를 알 수 없다) | + +"표본 3건 중 3건 적중 = 100%" 같은 숫자는 근거 없는 확률보다 더 나쁘게 오해를 만듭니다. + +### 어디로 나가는가 + +- `GET /facilities/{id}/admission-forecast` 응답의 `accuracy` — 지금 보여주는 확률이 속한 구간의 실제 적중률 +- `GET /facilities/forecast-accuracy` (공개) — 기간별 최신 측정값과 구간표 전체 +- 매주 일요일 04:30 자동 측정, `POST /api/admin/sync/forecast-accuracy/measure` 로 수동 실행 + ## 시설 인기도 충원율 **추이**로 판단합니다. 현재 충원율만 보면 정원이 작은 시설이 항상 높게 나옵니다. @@ -170,6 +218,8 @@ flowchart TD | GET | `/facilities/popular` | 공개 | | GET | `/facilities/statistics` | 공개 | | GET | `/facilities/{facilityId}/admission-forecast` | 공개 | +| GET | `/facilities/forecast-accuracy` | 공개 | +| POST | `/api/admin/sync/forecast-accuracy/measure` | 관리자 | | GET | `/facilities/search` | 인증 | | POST | `/facilities/{facilityId}/waitlist` | 인증 | | GET | `/facilities/waitlist/me` | 인증 | @@ -184,6 +234,8 @@ flowchart TD | `app.facility-vacancy.min-interval-days` | 14 | 같은 사람에게 다시 알리기까지 최소 간격 | | `app.facility-vacancy.min-increase` | 1 | 이만큼 늘어야 알림 | | `app.scheduler.public-data.vacancy-cron` | `0 30 9 * * *` | 실행 시각 (시설 동기화 이후) | +| `app.scheduler.forecast-backtest.cron` | `0 30 4 * * SUN` | 예측 정확도 측정 시각 | +| `app.forecast.backtest.max-facilities` | 2000 | 백테스트로 볼 시설 수 상한 (정확도는 표본 추정이라 전수를 볼 필요가 없다) | ## 미해결 @@ -191,3 +243,4 @@ flowchart TD |------|------| | 반별 정원 | 공공데이터가 주지 않습니다. 시설 직접 입력이나 크라우드 제보가 필요합니다 | | 대기 순번 검증 | 사용자가 입력한 순번을 검증할 방법이 없습니다 | +| 정확도 표본 | 관측이 쌓인 만큼만 검증됩니다. 수집 초기에는 1개월 기간만 표본이 모입니다 | diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md index a3d9ab75..0c8c08ff 100644 --- a/docs/reference/access-control-matrix.md +++ b/docs/reference/access-control-matrix.md @@ -77,6 +77,7 @@ flowchart TD | `/facilities/{id}/view` | 조회수 증가 | | `/facilities/{id}/rating` (GET) | 평점 조회 | | `GET /facilities/{id}`, `/facilities/{id}/with-reviews`, `/facilities/{id}/reviews` | 시설 상세·공개 리뷰. 프런트가 로그인 전에도 보여 준다 | +| `GET /facilities/forecast-accuracy` | 예측 정확도 측정값(백테스트 결과) | | `GET /facilities/{id}/admission-forecast`, `/facilities/{id}/popularity`, `/facilities/{id}/waitlist/stats` | 공공데이터 기반 예측·집계 | | `POST /facilities/search`, `POST /facilities/advanced-search` | 조건을 본문으로 받는 조회 | | `/api/public/care-facilities/**` | 공공데이터 조회 | diff --git a/src/main/java/com/carecode/core/ops/sync/SyncJob.java b/src/main/java/com/carecode/core/ops/sync/SyncJob.java index 1f782d80..e77679cf 100644 --- a/src/main/java/com/carecode/core/ops/sync/SyncJob.java +++ b/src/main/java/com/carecode/core/ops/sync/SyncJob.java @@ -20,7 +20,8 @@ public enum SyncJob { 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); + BENEFIT_REPORT_SOLICIT("benefit-report-solicit", "실수령액 제보 요청", 192, false), + FORECAST_BACKTEST("forecast-backtest", "입소 예측 정확도 측정", 192, false); private final String code; private final String label; diff --git a/src/main/java/com/carecode/core/scheduler/ForecastBacktestScheduler.java b/src/main/java/com/carecode/core/scheduler/ForecastBacktestScheduler.java new file mode 100644 index 00000000..ec300df7 --- /dev/null +++ b/src/main/java/com/carecode/core/scheduler/ForecastBacktestScheduler.java @@ -0,0 +1,29 @@ +package com.carecode.core.scheduler; + +import com.carecode.core.ops.sync.SyncJob; +import com.carecode.core.ops.sync.SyncRunTracker; +import com.carecode.domain.careFacility.service.ForecastBacktestService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 입소 예측 정확도 측정. 주 1회. + * + *

정원 관측이 주 1회 들어오므로 그보다 자주 돌릴 이유가 없다. 시설 동기화가 끝난 뒤에 돌려 + * 그 주의 관측까지 검증에 넣는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ForecastBacktestScheduler { + + private final ForecastBacktestService backtestService; + private final SyncRunTracker tracker; + + @Scheduled(cron = "${app.scheduler.forecast-backtest.cron:0 30 4 * * SUN}", zone = "Asia/Seoul") + public void measureAccuracy() { + tracker.track(SyncJob.FORECAST_BACKTEST, () -> backtestService.runAll()); + } +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java b/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java index 92b6b5e6..07d8c1ab 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminSyncStatusController.java @@ -1,6 +1,7 @@ package com.carecode.domain.admin.controller; import com.carecode.core.ops.sync.SyncFreshnessService; +import com.carecode.domain.careFacility.service.ForecastBacktestService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -27,6 +28,7 @@ public class AdminSyncStatusController { private final SyncFreshnessService freshnessService; + private final ForecastBacktestService backtestService; @GetMapping("/status") @Operation(summary = "주기 작업 상태", description = "작업별 마지막 성공 시각, 경과 시간, 신선도 기준 초과 여부") @@ -38,4 +40,31 @@ public ResponseEntity> status() { body.put("staleCount", jobs.stream().filter(SyncFreshnessService.JobFreshness::isStale).count()); return ResponseEntity.ok(body); } + + /** + * 예측 정확도 수동 측정. + * + *

스케줄러는 주 1회라, 새 관측이 들어온 뒤 결과를 바로 보고 싶을 때 쓴다. + * 표본이 부족한 기간은 결과에 포함되지 않는다(없는 정확도를 만들어내지 않는다). + */ + @org.springframework.web.bind.annotation.PostMapping("/forecast-accuracy/measure") + @Operation(summary = "예측 정확도 측정 실행", description = "과거 관측으로 백테스트를 돌려 기간별 정확도를 다시 계산") + public ResponseEntity> measureForecastAccuracy() { + var results = backtestService.runAll(); + + Map body = new LinkedHashMap<>(); + body.put("measured", results.size()); + body.put("results", results.stream().map(r -> { + Map row = new LinkedHashMap<>(); + row.put("horizonMonths", r.getHorizonMonths()); + row.put("samples", r.getSamples()); + row.put("facilities", r.getFacilities()); + row.put("actualRate", r.getActualRate()); + row.put("brierScore", r.getBrierScore()); + row.put("baselineBrierScore", r.getBaselineBrierScore()); + row.put("betterThanBaseline", r.betterThanBaseline()); + return row; + }).toList()); + return ResponseEntity.ok(body); + } } diff --git a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java index 840110ca..bced54d4 100644 --- a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java +++ b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java @@ -30,6 +30,7 @@ public class CareFacilityFacade { private final CareFacilityBookingService bookingService; private final AdmissionForecastService admissionForecastService; private final FacilityPopularityService facilityPopularityService; + private final com.carecode.domain.careFacility.service.ForecastAccuracyService forecastAccuracyService; @Transactional(readOnly = true) public List getAllCareFacilities(int page, int size) { @@ -91,6 +92,12 @@ public void updateRating(Long id, Double rating) { careFacilityService.updateRating(id, rating); } + /** 입소 예측 정확도(측정된 기간별 최신 결과). 공개 API. */ + @Transactional(readOnly = true) + public java.util.List getForecastAccuracy() { + return forecastAccuracyService.latestByHorizon(com.carecode.domain.careFacility.service.ForecastBacktestService.MEASURED_HORIZONS); + } + @Transactional(readOnly = true) public CareFacilityStatsResponse getFacilityStats() { return careFacilityService.getFacilityStats(); diff --git a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java index 38238079..195100b2 100644 --- a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java +++ b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java @@ -429,6 +429,17 @@ public ResponseEntity forecastAdmission( return ResponseEntity.ok(careFacilityFacade.forecastAdmission(facilityId, childAgeMonths, horizonMonths)); } + // 예측 정확도 (공개) + @GetMapping("/forecast-accuracy") + @LogExecutionTime + @Operation(summary = "입소 예측 정확도", + description = "과거 관측으로 같은 예측을 다시 계산해 실제 결과와 비교한 측정값. " + + "확률대별 실제 적중률과, 항상 평균으로 답했을 때(기준선)와의 비교를 함께 준다. " + + "표본이 부족한 기간은 목록에 없다.") + public ResponseEntity> getForecastAccuracy() { + return ResponseEntity.ok(careFacilityFacade.getForecastAccuracy()); + } + // 충원율 기반 인기도 @GetMapping("/{facilityId}/popularity") @LogExecutionTime diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/AdmissionForecastResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/AdmissionForecastResponse.java index 330fbb98..69aaf243 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/AdmissionForecastResponse.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/AdmissionForecastResponse.java @@ -38,4 +38,10 @@ public class AdmissionForecastResponse { /** 사용자에게 보여줄 근거 문장. */ private List reasons; + + /** + * 이 확률이 과거에 얼마나 맞았는지. 표본이 부족하면 null 이다. + * 확률만 보여주면 사용자는 믿을지 판단할 근거가 없다. + */ + private ForecastAccuracyResponse accuracy; } diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/ForecastAccuracyResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/ForecastAccuracyResponse.java new file mode 100644 index 00000000..13b53d76 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/ForecastAccuracyResponse.java @@ -0,0 +1,58 @@ +package com.carecode.domain.careFacility.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +/** + * 입소 예측이 실제로 얼마나 맞았는지. + * + *

확률만 보여주면 사용자는 그 숫자를 믿을지 판단할 근거가 없다. 과거 관측으로 같은 계산을 다시 돌려 + * 측정한 적중률을 함께 준다. 표본이 적으면 숫자를 만들지 않고 비운다. + */ +@Getter +@Builder +public class ForecastAccuracyResponse { + + /** 측정 실행일. */ + private final LocalDate measuredAt; + + private final int horizonMonths; + + /** 검증에 쓴 예측 건수. */ + private final int samples; + + private final int facilities; + + /** 표본에서 실제로 자리가 난 비율 (0~1). */ + private final double actualRate; + + /** 낮을수록 정확. 0=완벽, 0.25=동전 던지기. */ + private final double brierScore; + + /** 항상 평균 발생률로 답했을 때의 점수. */ + private final double baselineBrierScore; + + /** 예측이 "평균으로 답하기" 보다 나은가. false 면 화면에서 확률을 강조하지 않는 편이 맞다. */ + private final boolean betterThanBaseline; + + /** 지금 보여주는 확률이 속한 구간의 실제 적중률. 그 구간 표본이 적으면 null. */ + private final Bucket matchedBucket; + + /** 전체 구간표. 공개 통계에서 쓴다. 상세 응답에서는 생략될 수 있다. */ + private final List calibration; + + @Getter + @Builder + public static class Bucket { + /** 구간 시작(%)·끝(%). 예: 60~80 */ + private final int from; + private final int to; + private final int samples; + private final int actualTrue; + /** 이 구간으로 예측한 건 중 실제로 자리가 난 비율 (0~1). 표본 0이면 null. */ + private final Double actualRate; + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/entity/ForecastAccuracy.java b/src/main/java/com/carecode/domain/careFacility/entity/ForecastAccuracy.java new file mode 100644 index 00000000..f9553660 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/entity/ForecastAccuracy.java @@ -0,0 +1,65 @@ +package com.carecode.domain.careFacility.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 입소 예측 정확도 측정 결과 한 건(기간별 1회 실행). */ +@Entity +@Table(name = "TBL_FORECAST_ACCURACY") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ForecastAccuracy { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "RUN_DATE", nullable = false) + private LocalDate runDate; + + @Column(name = "HORIZON_MONTHS", nullable = false) + private int horizonMonths; + + @Column(name = "SAMPLES", nullable = false) + private int samples; + + @Column(name = "FACILITIES", nullable = false) + private int facilities; + + /** 낮을수록 정확하다. 0 이 완벽, 0.25 가 동전 던지기 수준. */ + @Column(name = "BRIER_SCORE", nullable = false) + private double brierScore; + + /** 항상 평균 발생률로 답했을 때의 점수. 예측이 이보다 낮아야 의미가 있다. */ + @Column(name = "BASELINE_BRIER_SCORE", nullable = false) + private double baselineBrierScore; + + @Column(name = "ACTUAL_RATE", nullable = false) + private double actualRate; + + @Column(name = "CALIBRATION_JSON", nullable = false, columnDefinition = "TEXT") + private String calibrationJson; + + @Column(name = "CREATED_AT", nullable = false) + private LocalDateTime createdAt; + + /** 예측이 "평균으로 답하기" 보다 나은가. 아니면 화면에 확률을 자랑할 근거가 없다. */ + public boolean betterThanBaseline() { + return brierScore < baselineBrierScore; + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/repository/FacilityCapacitySnapshotRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/FacilityCapacitySnapshotRepository.java index cfd990c1..dad05260 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/FacilityCapacitySnapshotRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/FacilityCapacitySnapshotRepository.java @@ -27,4 +27,9 @@ List findHistory(@Param("facilityId") Long facilityId, Optional findEarliestObservedDate(@Param("facilityId") Long facilityId); long countByFacilityId(Long facilityId); + + /** 백테스트 대상. 관측이 몇 번 이상 쌓인 시설만 검증할 수 있다. */ + @Query("SELECT s.facilityId FROM FacilityCapacitySnapshot s " + + "GROUP BY s.facilityId HAVING COUNT(s) >= :minSnapshots") + List findFacilityIdsWithAtLeast(@Param("minSnapshots") long minSnapshots); } diff --git a/src/main/java/com/carecode/domain/careFacility/repository/ForecastAccuracyRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/ForecastAccuracyRepository.java new file mode 100644 index 00000000..cc67cf71 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/repository/ForecastAccuracyRepository.java @@ -0,0 +1,16 @@ +package com.carecode.domain.careFacility.repository; + +import com.carecode.domain.careFacility.entity.ForecastAccuracy; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ForecastAccuracyRepository extends JpaRepository { + + Optional findFirstByHorizonMonthsOrderByRunDateDesc(int horizonMonths); + + List findByRunDateOrderByHorizonMonthsAsc(java.time.LocalDate runDate); + + Optional findFirstByOrderByRunDateDesc(); +} diff --git a/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastCalculator.java b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastCalculator.java new file mode 100644 index 00000000..734d90b1 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastCalculator.java @@ -0,0 +1,173 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import lombok.Builder; +import lombok.Getter; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.Month; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +/** + * 입소 가능 시점 추정 계산. + * + *

서비스에서 분리한 이유는 검증 때문이다. 정확도 백테스트는 과거 시점에서 같은 계산을 다시 돌려 + * 실제 결과와 비교하는데, 계산이 조회·이벤트 로깅과 섞여 있으면 "검증한 계산" 과 "사용자에게 보여준 계산" 이 + * 달라질 수 있다. 여기에는 DB·시계가 없고, 넘겨받은 관측과 기준일만 본다. + */ +@Component +public class AdmissionForecastCalculator { + + /** 이보다 관측이 적으면 추세라고 부를 수 없다. */ + public static final int MIN_OBSERVATIONS = 4; + public static final int MIN_OBSERVATION_DAYS = 60; + + /** 신학기. 승급·졸업으로 자리가 가장 많이 열리는 시점이다. */ + private static final Month NEW_TERM_MONTH = Month.MARCH; + + /** + * 기준일 이전 관측으로 목표 시점까지 자리가 날 확률을 추정한다. + * + * @param history 관측일 오름차순. {@code asOf} 이후 관측이 섞이면 미래를 보고 예측하는 셈이 되므로 호출부가 잘라서 넘긴다. + */ + public Forecast forecast(List history, LocalDate asOf, int horizonMonths) { + LocalDate targetDate = asOf.plusMonths(horizonMonths); + long observationDays = history.isEmpty() ? 0 + : ChronoUnit.DAYS.between(history.get(0).getObservedDate(), asOf); + + Forecast.ForecastBuilder base = Forecast.builder() + .observationCount(history.size()) + .observationDays(observationDays) + .targetDate(targetDate); + + String shortage = checkDataSufficiency(history.size(), observationDays); + if (shortage != null) { + return base.available(false).unavailableReason(shortage).reasons(List.of()).build(); + } + + List reasons = new ArrayList<>(); + + // 1. 관측 구간 중 자리가 있었던 비율 — 예측의 기준선 + long observedWithSeat = history.stream().filter(AdmissionForecastCalculator::hasSeat).count(); + double baseRate = (double) observedWithSeat / history.size(); + reasons.add(String.format("최근 관측 %d회 중 %d회에 잔여석이 있었습니다.", history.size(), observedWithSeat)); + + // 2. 자리가 실제로 열린 횟수. 잔여석이 늘어난 전환만 센다. + int openings = countSeatOpenings(history); + double monthsCovered = monthsCovered(history); + double openingsPerMonth = monthsCovered > 0 ? openings / monthsCovered : 0; + if (openings > 0) { + reasons.add(String.format("관측 기간에 자리가 %d회 열렸습니다 (월 평균 %.1f회).", openings, openingsPerMonth)); + } else { + reasons.add("관측 기간에 자리가 열린 적이 없습니다."); + } + + // 3. 목표 시점까지 신학기가 끼면 승급·졸업으로 자리가 크게 열린다. + boolean spansNewTerm = spansNewTerm(asOf, targetDate); + if (spansNewTerm) { + reasons.add("목표 시점까지 3월 신학기가 포함되어 승급·졸업으로 자리가 열릴 가능성이 높습니다."); + } + + int probability = estimateProbability(baseRate, openingsPerMonth, + ChronoUnit.MONTHS.between(asOf, targetDate), spansNewTerm); + + return base.available(true) + .probability(probability) + .confidence(resolveConfidence(history.size(), openings)) + .reasons(reasons) + .build(); + } + + /** 관측이 부족한 이유를 사용자가 이해할 수 있게 돌려준다. */ + private String checkDataSufficiency(int count, long days) { + if (count == 0) { + return "이 시설의 정원 관측 이력이 아직 없습니다."; + } + if (count < MIN_OBSERVATIONS) { + return String.format("관측 %d회로는 추세를 판단할 수 없습니다. (최소 %d회 필요)", count, MIN_OBSERVATIONS); + } + if (days < MIN_OBSERVATION_DAYS) { + return String.format("관측 기간이 %d일로 짧습니다. (최소 %d일 필요)", days, MIN_OBSERVATION_DAYS); + } + return null; + } + + /** 기준선(관측 중 자리 있던 비율)에 자리 발생률을 포아송으로 얹는다. */ + private int estimateProbability(double baseRate, double openingsPerMonth, long months, boolean spansNewTerm) { + // 기간 내 자리가 최소 1회 열릴 확률 = 1 - e^(-λt) + double openingProbability = 1 - Math.exp(-openingsPerMonth * Math.max(months, 1)); + double combined = 1 - (1 - baseRate) * (1 - openingProbability); + + if (spansNewTerm) { + // 신학기는 관측만으로 잡히지 않는 구조적 요인이라 하한을 둔다. + combined = Math.max(combined, 0.5); + } + return (int) Math.round(Math.min(combined, 0.95) * 100); + } + + /** 잔여석이 0 이하에서 1 이상으로 바뀐 전환 횟수. */ + private int countSeatOpenings(List history) { + int openings = 0; + boolean previousHadSeat = hasSeat(history.get(0)); + for (int i = 1; i < history.size(); i++) { + boolean current = hasSeat(history.get(i)); + if (current && !previousHadSeat) { + openings++; + } + previousHadSeat = current; + } + return openings; + } + + /** 관측 한 건에 잔여석이 있었는가. 백테스트의 "실제 결과" 판정도 같은 기준을 쓴다. */ + public static boolean hasSeat(FacilityCapacitySnapshot snapshot) { + Integer spots = snapshot.getAvailableSpots(); + if (spots != null) { + return spots > 0; + } + Integer capacity = snapshot.getCapacity(); + Integer enrolled = snapshot.getCurrentEnrollment(); + return capacity != null && enrolled != null && capacity > enrolled; + } + + private double monthsCovered(List history) { + long days = ChronoUnit.DAYS.between( + history.get(0).getObservedDate(), history.get(history.size() - 1).getObservedDate()); + return days / 30.0; + } + + private boolean spansNewTerm(LocalDate from, LocalDate to) { + LocalDate term = LocalDate.of(from.getYear(), NEW_TERM_MONTH, 1); + if (term.isBefore(from)) { + term = term.plusYears(1); + } + return !term.isAfter(to); + } + + /** 관측이 많고 자리 열림이 실제로 관측됐을수록 신뢰도가 높다. */ + private String resolveConfidence(int observations, int openings) { + if (observations >= 24 && openings >= 3) { + return "HIGH"; + } + if (observations >= 12 && openings >= 1) { + return "MEDIUM"; + } + return "LOW"; + } + + @Getter + @Builder + public static class Forecast { + private final boolean available; + private final String unavailableReason; + private final Integer probability; + private final String confidence; + private final List reasons; + private final LocalDate targetDate; + private final int observationCount; + private final long observationDays; + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java index fe39e8db..372f34e9 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java @@ -14,30 +14,28 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; -import java.time.Month; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; import java.util.List; -/** 관측된 정원 변동으로 입소 가능 시점을 추정한다. 통계적 근거가 부족하면 숫자를 만들어내지 않고 부족하다고 답한다. */ +/** + * 관측된 정원 변동으로 입소 가능 시점을 추정한다. 통계적 근거가 부족하면 숫자를 만들어내지 않고 부족하다고 답한다. + * + *

계산은 {@link AdmissionForecastCalculator} 에 있고, 여기서는 관측을 읽어 넘기고 응답을 만든다. + * 확률과 함께 {@link ForecastAccuracyService} 가 측정한 **같은 확률대의 실제 적중률**을 붙여 내보낸다 — + * 근거 없는 숫자를 그대로 보여주지 않기 위해서다. + */ @Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AdmissionForecastService { - /** 이보다 관측이 적으면 추세라고 부를 수 없다. */ - private static final int MIN_OBSERVATIONS = 4; - private static final int MIN_OBSERVATION_DAYS = 60; - - /** 신학기. 승급·졸업으로 자리가 가장 많이 열리는 시점이다. */ - private static final Month NEW_TERM_MONTH = Month.MARCH; - private static final int LOOKBACK_MONTHS = 18; private static final int DEFAULT_HORIZON_MONTHS = 6; private final CareFacilityRepository careFacilityRepository; private final FacilityCapacitySnapshotRepository snapshotRepository; + private final AdmissionForecastCalculator calculator; + private final ForecastAccuracyService accuracyService; private final EventLogger eventLogger; /** 아이 월령 기준으로 목표 시점까지 자리가 날 확률을 추정한다. */ @@ -47,142 +45,31 @@ public AdmissionForecastResponse forecast(Long facilityId, Integer childAgeMonth LocalDate today = LocalDate.now(); int horizon = horizonMonths != null && horizonMonths > 0 ? horizonMonths : DEFAULT_HORIZON_MONTHS; - LocalDate targetDate = today.plusMonths(horizon); eventLogger.log(EventType.ADMISSION_FORECAST_VIEWED, null, String.valueOf(facilityId)); List history = snapshotRepository.findHistory(facilityId, today.minusMonths(LOOKBACK_MONTHS)); - long observationDays = history.isEmpty() ? 0 - : ChronoUnit.DAYS.between(history.get(0).getObservedDate(), today); + AdmissionForecastCalculator.Forecast forecast = calculator.forecast(history, today, horizon); - AdmissionForecastResponse.AdmissionForecastResponseBuilder base = AdmissionForecastResponse.builder() + AdmissionForecastResponse.AdmissionForecastResponseBuilder response = AdmissionForecastResponse.builder() .facilityId(facilityId) .facilityName(facility.getName()) - .observationCount(history.size()) - .observationDays(observationDays) + .observationCount(forecast.getObservationCount()) + .observationDays(forecast.getObservationDays()) .targetClass(resolveClassName(childAgeMonths)) - .targetDate(targetDate); - - String shortage = checkDataSufficiency(history.size(), observationDays); - if (shortage != null) { - return base.available(false).unavailableReason(shortage).build(); - } - - return buildForecast(base, history, targetDate, today); - } - - /** 관측이 부족한 이유를 사용자가 이해할 수 있게 돌려준다. */ - private String checkDataSufficiency(int count, long days) { - if (count == 0) { - return "이 시설의 정원 관측 이력이 아직 없습니다."; - } - if (count < MIN_OBSERVATIONS) { - return String.format("관측 %d회로는 추세를 판단할 수 없습니다. (최소 %d회 필요)", count, MIN_OBSERVATIONS); - } - if (days < MIN_OBSERVATION_DAYS) { - return String.format("관측 기간이 %d일로 짧습니다. (최소 %d일 필요)", days, MIN_OBSERVATION_DAYS); - } - return null; - } - - private AdmissionForecastResponse buildForecast( - AdmissionForecastResponse.AdmissionForecastResponseBuilder base, - List history, LocalDate targetDate, LocalDate today) { - - List reasons = new ArrayList<>(); - - // 1. 관측 구간 중 자리가 있었던 비율 — 예측의 기준선 - long observedWithSeat = history.stream().filter(this::hasSeat).count(); - double baseRate = (double) observedWithSeat / history.size(); - reasons.add(String.format("최근 관측 %d회 중 %d회에 잔여석이 있었습니다.", history.size(), observedWithSeat)); - - // 2. 자리가 실제로 열린 횟수. 잔여석이 늘어난 전환만 센다. - int openings = countSeatOpenings(history); - double openingsPerMonth = monthsCovered(history) > 0 ? openings / monthsCovered(history) : 0; - if (openings > 0) { - reasons.add(String.format("관측 기간에 자리가 %d회 열렸습니다 (월 평균 %.1f회).", openings, openingsPerMonth)); - } else { - reasons.add("관측 기간에 자리가 열린 적이 없습니다."); - } - - // 3. 목표 시점까지 신학기가 끼면 승급·졸업으로 자리가 크게 열린다. - boolean spansNewTerm = spansNewTerm(today, targetDate); - if (spansNewTerm) { - reasons.add("목표 시점까지 3월 신학기가 포함되어 승급·졸업으로 자리가 열릴 가능성이 높습니다."); - } - - int probability = estimateProbability(baseRate, openingsPerMonth, - ChronoUnit.MONTHS.between(today, targetDate), spansNewTerm); - - return base.available(true) - .probability(probability) - .confidence(resolveConfidence(history.size(), openings)) - .reasons(reasons) - .build(); - } - - /** 기준선(관측 중 자리 있던 비율)에 자리 발생률을 포아송으로 얹는다. */ - private int estimateProbability(double baseRate, double openingsPerMonth, long months, boolean spansNewTerm) { - // 기간 내 자리가 최소 1회 열릴 확률 = 1 - e^(-λt) - double openingProbability = 1 - Math.exp(-openingsPerMonth * Math.max(months, 1)); - double combined = 1 - (1 - baseRate) * (1 - openingProbability); - - if (spansNewTerm) { - // 신학기는 관측만으로 잡히지 않는 구조적 요인이라 하한을 둔다. - combined = Math.max(combined, 0.5); - } - return (int) Math.round(Math.min(combined, 0.95) * 100); - } - - /** 잔여석이 0 이하에서 1 이상으로 바뀐 전환 횟수. */ - private int countSeatOpenings(List history) { - int openings = 0; - boolean previousHadSeat = hasSeat(history.get(0)); - for (int i = 1; i < history.size(); i++) { - boolean current = hasSeat(history.get(i)); - if (current && !previousHadSeat) { - openings++; - } - previousHadSeat = current; - } - return openings; - } - - private boolean hasSeat(FacilityCapacitySnapshot snapshot) { - Integer spots = snapshot.getAvailableSpots(); - if (spots != null) { - return spots > 0; - } - Integer capacity = snapshot.getCapacity(); - Integer enrolled = snapshot.getCurrentEnrollment(); - return capacity != null && enrolled != null && capacity > enrolled; - } - - private double monthsCovered(List history) { - long days = ChronoUnit.DAYS.between( - history.get(0).getObservedDate(), history.get(history.size() - 1).getObservedDate()); - return days / 30.0; - } - - private boolean spansNewTerm(LocalDate from, LocalDate to) { - LocalDate term = LocalDate.of(from.getYear(), NEW_TERM_MONTH, 1); - if (term.isBefore(from)) { - term = term.plusYears(1); - } - return !term.isAfter(to); - } - - /** 관측이 많고 자리 열림이 실제로 관측됐을수록 신뢰도가 높다. */ - private String resolveConfidence(int observations, int openings) { - if (observations >= 24 && openings >= 3) { - return "HIGH"; - } - if (observations >= 12 && openings >= 1) { - return "MEDIUM"; + .targetDate(forecast.getTargetDate()) + .available(forecast.isAvailable()) + .unavailableReason(forecast.getUnavailableReason()) + .probability(forecast.getProbability()) + .confidence(forecast.getConfidence()) + .reasons(forecast.getReasons()); + + if (forecast.isAvailable()) { + response.accuracy(accuracyService.describeFor(horizon, forecast.getProbability()).orElse(null)); } - return "LOW"; + return response.build(); } /** 어린이집 반 편성은 만 나이 기준이다. */ diff --git a/src/main/java/com/carecode/domain/careFacility/service/ForecastAccuracyService.java b/src/main/java/com/carecode/domain/careFacility/service/ForecastAccuracyService.java new file mode 100644 index 00000000..5b160bae --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/service/ForecastAccuracyService.java @@ -0,0 +1,99 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.domain.careFacility.dto.response.ForecastAccuracyResponse; +import com.carecode.domain.careFacility.entity.ForecastAccuracy; +import com.carecode.domain.careFacility.repository.ForecastAccuracyRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** 측정된 예측 정확도를 읽어 응답으로 만든다. */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ForecastAccuracyService { + + /** 이보다 표본이 적으면 정확도라고 부를 수 없어 아예 내보내지 않는다. */ + static final int MIN_SAMPLES = 30; + + /** 구간별 적중률은 구간 표본이 이만큼은 있어야 보여준다. */ + static final int MIN_BUCKET_SAMPLES = 10; + + private final ForecastAccuracyRepository accuracyRepository; + private final ObjectMapper objectMapper; + + /** 특정 기간·확률에 대한 정확도. 표본이 부족하면 빈 값(화면에서 숨긴다). */ + public Optional describeFor(int horizonMonths, Integer probability) { + return accuracyRepository.findFirstByHorizonMonthsOrderByRunDateDesc(horizonMonths) + .filter(accuracy -> accuracy.getSamples() >= MIN_SAMPLES) + .map(accuracy -> toResponse(accuracy, probability, false)); + } + + /** 공개 통계용. 측정된 모든 기간의 최신 결과를 구간표까지 함께 준다. */ + public List latestByHorizon(List horizons) { + List results = new ArrayList<>(); + for (int horizon : horizons) { + accuracyRepository.findFirstByHorizonMonthsOrderByRunDateDesc(horizon) + .map(accuracy -> toResponse(accuracy, null, true)) + .ifPresent(results::add); + } + return results; + } + + private ForecastAccuracyResponse toResponse(ForecastAccuracy accuracy, Integer probability, boolean includeAll) { + List buckets = readBuckets(accuracy); + List mapped = buckets.stream().map(ForecastAccuracyService::toBucket).toList(); + + ForecastAccuracyResponse.Bucket matched = null; + if (probability != null) { + matched = mapped.stream() + .filter(b -> probability >= b.getFrom() && (probability < b.getTo() || b.getTo() == 100)) + .filter(b -> b.getSamples() >= MIN_BUCKET_SAMPLES) + .findFirst() + .orElse(null); + } + + return ForecastAccuracyResponse.builder() + .measuredAt(accuracy.getRunDate()) + .horizonMonths(accuracy.getHorizonMonths()) + .samples(accuracy.getSamples()) + .facilities(accuracy.getFacilities()) + .actualRate(accuracy.getActualRate()) + .brierScore(accuracy.getBrierScore()) + .baselineBrierScore(accuracy.getBaselineBrierScore()) + .betterThanBaseline(accuracy.betterThanBaseline()) + .matchedBucket(matched) + .calibration(includeAll ? mapped : null) + .build(); + } + + private List readBuckets(ForecastAccuracy accuracy) { + try { + return objectMapper.readValue(accuracy.getCalibrationJson(), + new TypeReference>() { + }); + } catch (Exception e) { + // 예전 기록의 구조가 달라졌을 수 있다. 전체 지표는 살리고 구간표만 비운다. + log.warn("정확도 구간표를 읽지 못했습니다 - id={}", accuracy.getId(), e); + return List.of(); + } + } + + private static ForecastAccuracyResponse.Bucket toBucket(ForecastBacktestService.Bucket bucket) { + return ForecastAccuracyResponse.Bucket.builder() + .from(bucket.from()) + .to(bucket.to()) + .samples(bucket.samples()) + .actualTrue(bucket.actualTrue()) + .actualRate(bucket.actualRate()) + .build(); + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/service/ForecastBacktestService.java b/src/main/java/com/carecode/domain/careFacility/service/ForecastBacktestService.java new file mode 100644 index 00000000..eced0e16 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/service/ForecastBacktestService.java @@ -0,0 +1,228 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.entity.ForecastAccuracy; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import com.carecode.domain.careFacility.repository.ForecastAccuracyRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 예측이 맞았는지 과거 데이터로 확인한다 (백테스트). + * + *

확률을 보여주면서 그 확률이 얼마나 맞는지 모르는 상태였다. 실제 예측을 기록해 몇 달을 기다리는 대신, + * 이미 쌓인 정원 관측 시계열로 과거 시점을 재현한다: 기준일 이전 관측만 넘겨 그때의 예측을 다시 계산하고, + * 기준일 다음 구간의 관측으로 실제 결과를 확인한다. 라이브 예측과 **같은 계산기**를 쓴다 + * ({@link AdmissionForecastCalculator}) — 다른 코드로 검증하면 의미가 없다. + * + *

측정 결과는 {@code TBL_FORECAST_ACCURACY} 에 기간별로 남기고, 화면에는 확률과 함께 + * 같은 확률대의 실제 적중률을 붙여 보여준다. + */ +@Slf4j +@Service +public class ForecastBacktestService { + + /** + * 정확도를 측정·공개하는 예측 기간(개월). + * + *

6개월만 측정하면 관측이 6개월 이상 쌓인 시설이 있어야 표본이 생긴다. 수집을 시작한 지 얼마 + * 안 된 상태에서도 검증이 돌아가도록 짧은 기간을 함께 둔다. + */ + public static final List MEASURED_HORIZONS = List.of(1, 3, 6); + + /** 검증 대상이 되려면 이 정도 관측은 있어야 한다. */ + private static final int MIN_SNAPSHOTS_PER_FACILITY = 6; + + /** 기준일을 이 간격으로 옮기며 예측을 만든다. 더 촘촘하게 하면 같은 구간을 겹쳐 세게 된다. */ + private static final int STEP_DAYS = 30; + + /** 확률대 구간 폭(%). */ + private static final int BUCKET_WIDTH = 20; + + private final FacilityCapacitySnapshotRepository snapshotRepository; + private final ForecastAccuracyRepository accuracyRepository; + private final AdmissionForecastCalculator calculator; + private final ObjectMapper objectMapper; + + /** 주간 작업 시간을 묶기 위한 상한. 정확도는 표본 추정이라 전수를 볼 필요가 없다. */ + private final int maxFacilities; + + public ForecastBacktestService(FacilityCapacitySnapshotRepository snapshotRepository, + ForecastAccuracyRepository accuracyRepository, + AdmissionForecastCalculator calculator, + ObjectMapper objectMapper, + @org.springframework.beans.factory.annotation.Value( + "${app.forecast.backtest.max-facilities:2000}") int maxFacilities) { + this.snapshotRepository = snapshotRepository; + this.accuracyRepository = accuracyRepository; + this.calculator = calculator; + this.objectMapper = objectMapper; + this.maxFacilities = maxFacilities; + } + + /** 기본 기간 전부 측정. */ + public List runAll() { + return runAll(MEASURED_HORIZONS); + } + + /** + * 기간별로 백테스트를 돌려 결과를 저장한다. 표본이 없는 기간은 저장하지 않는다. + * + *

시설 이력은 시설당 한 번만 읽고 모든 기간에 재사용한다. 기간마다 다시 읽으면 + * 전국 규모(수만 곳)에서 주간 작업이 쿼리 수만 건으로 늘어난다. + */ + @Transactional + public List runAll(List horizonMonths) { + List facilityIds = snapshotRepository.findFacilityIdsWithAtLeast(MIN_SNAPSHOTS_PER_FACILITY); + if (facilityIds.size() > maxFacilities) { + // 정확도는 표본 추정이므로 전수를 보지 않아도 된다. 주간 작업 시간을 묶어 두는 편이 낫다. + log.info("백테스트 대상 시설이 {}곳이라 앞에서 {}곳만 본다", facilityIds.size(), maxFacilities); + facilityIds = facilityIds.subList(0, maxFacilities); + } + log.info("예측 정확도 측정 시작 - 대상 시설 {}곳, 기간 {}", facilityIds.size(), horizonMonths); + + Map> samplesByHorizon = new LinkedHashMap<>(); + Map facilitiesByHorizon = new LinkedHashMap<>(); + for (int horizon : horizonMonths) { + samplesByHorizon.put(horizon, new ArrayList<>()); + facilitiesByHorizon.put(horizon, 0); + } + + for (Long facilityId : facilityIds) { + List history = + snapshotRepository.findHistory(facilityId, LocalDate.of(2000, 1, 1)); + for (int horizon : horizonMonths) { + List samples = sampleFacility(history, horizon); + if (!samples.isEmpty()) { + samplesByHorizon.get(horizon).addAll(samples); + facilitiesByHorizon.merge(horizon, 1, Integer::sum); + } + } + } + + List saved = new ArrayList<>(); + for (int horizon : horizonMonths) { + summarize(horizon, samplesByHorizon.get(horizon), facilitiesByHorizon.get(horizon)) + .ifPresent(result -> saved.add(accuracyRepository.save(result))); + } + return saved; + } + + private Optional summarize(int horizon, List samples, int facilitiesUsed) { + if (samples.isEmpty()) { + log.info("예측 정확도 측정 - {}개월: 검증 가능한 표본이 없습니다(관측 기간이 예측 기간보다 짧음)", horizon); + return Optional.empty(); + } + + double actualRate = samples.stream().filter(Sample::actual).count() / (double) samples.size(); + double brier = samples.stream() + .mapToDouble(s -> Math.pow(s.probability() / 100.0 - (s.actual() ? 1 : 0), 2)) + .average().orElse(0); + // 비교 기준: 예측하지 않고 항상 평균 발생률로 답했을 때. 이보다 못하면 확률을 보여줄 근거가 없다. + double baselineBrier = samples.stream() + .mapToDouble(s -> Math.pow(actualRate - (s.actual() ? 1 : 0), 2)) + .average().orElse(0); + + ForecastAccuracy result = ForecastAccuracy.builder() + .runDate(LocalDate.now()) + .horizonMonths(horizon) + .samples(samples.size()) + .facilities(facilitiesUsed) + .brierScore(round(brier)) + .baselineBrierScore(round(baselineBrier)) + .actualRate(round(actualRate)) + .calibrationJson(toJson(calibrate(samples))) + .createdAt(LocalDateTime.now()) + .build(); + + log.info("예측 정확도 측정 - {}개월: 표본 {}건, 실제 발생률 {}, Brier {} (기준 {})", + horizon, samples.size(), result.getActualRate(), result.getBrierScore(), result.getBaselineBrierScore()); + return Optional.of(result); + } + + /** + * 한 시설에서 검증 가능한 (예측, 실제) 쌍을 모은다. + * + *

기준일 이후 관측은 예측 입력에서 제외한다. 넣으면 미래를 보고 예측하는 셈이라 정확도가 부풀려진다. + * 검증 구간에 관측이 아예 없으면 실제 결과를 알 수 없으므로 표본에서 버린다. + */ + private List sampleFacility(List history, int horizon) { + List samples = new ArrayList<>(); + if (history.size() < MIN_SNAPSHOTS_PER_FACILITY) { + return samples; + } + + LocalDate first = history.get(0).getObservedDate(); + LocalDate last = history.get(history.size() - 1).getObservedDate(); + LocalDate asOf = first.plusDays(AdmissionForecastCalculator.MIN_OBSERVATION_DAYS); + + while (!asOf.plusMonths(horizon).isAfter(last)) { + LocalDate cutoff = asOf; + List past = history.stream() + .filter(s -> !s.getObservedDate().isAfter(cutoff)) + .toList(); + List future = history.stream() + .filter(s -> s.getObservedDate().isAfter(cutoff) + && !s.getObservedDate().isAfter(cutoff.plusMonths(horizon))) + .toList(); + + AdmissionForecastCalculator.Forecast forecast = calculator.forecast(past, asOf, horizon); + if (forecast.isAvailable() && !future.isEmpty()) { + boolean actual = future.stream().anyMatch(AdmissionForecastCalculator::hasSeat); + samples.add(new Sample(forecast.getProbability(), actual)); + } + asOf = asOf.plusDays(STEP_DAYS); + } + return samples; + } + + /** 확률대별로 "그렇게 예측한 건 중 실제로 자리가 난 비율". 예측이 정직한지 보여주는 표다. */ + private List calibrate(List samples) { + List buckets = new ArrayList<>(); + for (int from = 0; from < 100; from += BUCKET_WIDTH) { + int to = from + BUCKET_WIDTH; + int lower = from; + List inBucket = samples.stream() + .filter(s -> s.probability() >= lower && (s.probability() < to || (to == 100 && s.probability() == 100))) + .toList(); + buckets.add(new Bucket(from, to, inBucket.size(), + (int) inBucket.stream().filter(Sample::actual).count())); + } + return buckets; + } + + private String toJson(List buckets) { + try { + return objectMapper.writeValueAsString(buckets); + } catch (JsonProcessingException e) { + // 구조가 고정된 값이라 실패할 이유가 없지만, 실패하면 측정 자체를 버리는 편이 낫다. + throw new IllegalStateException("정확도 구간 직렬화 실패", e); + } + } + + private static double round(double value) { + return Math.round(value * 10000) / 10000.0; + } + + /** 예측 한 건과 그 결과. */ + private record Sample(int probability, boolean actual) { + } + + /** 확률대 구간. JSON 으로 저장되므로 필드 이름을 바꾸면 예전 기록을 읽을 수 없다. */ + public record Bucket(int from, int to, int samples, int actualTrue) { + public Double actualRate() { + return samples == 0 ? null : Math.round(actualTrue * 1000.0 / samples) / 1000.0; + } + } +} diff --git a/src/main/resources/db/migration/V20__forecast_accuracy.sql b/src/main/resources/db/migration/V20__forecast_accuracy.sql new file mode 100644 index 00000000..28abdd2f --- /dev/null +++ b/src/main/resources/db/migration/V20__forecast_accuracy.sql @@ -0,0 +1,24 @@ +-- 입소 예측 정확도 측정 결과. +-- +-- 지금까지 "입소 확률 40%" 를 보여주면서 그 숫자가 맞았는지 확인한 적이 없다. 정원 관측 시계열이 +-- 이미 쌓이고 있으므로, 과거 시점으로 돌아가 그때 관측만으로 예측을 다시 계산하고 그 뒤 실제로 +-- 자리가 났는지 맞춰볼 수 있다(백테스트). 사용자에게는 확률과 함께 이 적중률을 같이 보여준다. + +CREATE TABLE TBL_FORECAST_ACCURACY ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + RUN_DATE DATE NOT NULL COMMENT '측정 실행일', + HORIZON_MONTHS INT NOT NULL COMMENT '예측 기간(개월)', + SAMPLES INT NOT NULL COMMENT '검증에 쓴 예측 건수', + FACILITIES INT NOT NULL COMMENT '표본에 포함된 시설 수', + BRIER_SCORE DOUBLE NOT NULL COMMENT '낮을수록 정확 (0=완벽, 0.25=동전 던지기)', + BASELINE_BRIER_SCORE DOUBLE NOT NULL COMMENT '항상 평균 발생률로 답했을 때의 점수. 이보다 낮아야 예측이 의미 있다', + ACTUAL_RATE DOUBLE NOT NULL COMMENT '표본에서 실제로 자리가 난 비율', + + -- 확률대별 적중률. 화면 표시에만 쓰고 질의 대상이 아니라 JSON 으로 둔다. + -- 구간 정의가 바뀔 수 있어 칼럼으로 고정하지 않는다. + CALIBRATION_JSON TEXT NOT NULL COMMENT '[{from,to,samples,actualTrue}] 형태', + CREATED_AT DATETIME NOT NULL, + + -- 조회는 "기간별 최신 1건" 이다. + INDEX IDX_FORECAST_ACCURACY_HORIZON_RUN (HORIZON_MONTHS, RUN_DATE) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='입소 예측 정확도 측정 결과'; diff --git a/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java index 9d2a1e68..d035c142 100644 --- a/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java +++ b/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java @@ -34,8 +34,12 @@ void setUp() { snapshotRepository = mock(FacilityCapacitySnapshotRepository.class); when(facilityRepository.findById(anyLong())) .thenReturn(Optional.of(CareFacility.builder().name("행복어린이집").build())); + // 정확도 측정 결과가 없으면 응답의 accuracy 는 비어 있다. 예측 자체 검증에는 영향이 없다. + ForecastAccuracyService accuracyService = mock(ForecastAccuracyService.class); + when(accuracyService.describeFor(org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any())) + .thenReturn(Optional.empty()); service = new AdmissionForecastService(facilityRepository, snapshotRepository, - mock(EventLogger.class)); + new AdmissionForecastCalculator(), accuracyService, mock(EventLogger.class)); } @Test diff --git a/src/test/java/com/carecode/domain/careFacility/service/ForecastAccuracyServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/ForecastAccuracyServiceTest.java new file mode 100644 index 00000000..8aa41c12 --- /dev/null +++ b/src/test/java/com/carecode/domain/careFacility/service/ForecastAccuracyServiceTest.java @@ -0,0 +1,122 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.domain.careFacility.dto.response.ForecastAccuracyResponse; +import com.carecode.domain.careFacility.entity.ForecastAccuracy; +import com.carecode.domain.careFacility.repository.ForecastAccuracyRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 표본이 적은 정확도는 보여주지 않는다. "표본 3건 중 3건 적중 = 100%" 같은 숫자가 + * 근거 없는 확률보다 더 나쁘게 오해를 만든다. + */ +@DisplayName("예측 정확도 노출 규칙") +class ForecastAccuracyServiceTest { + + private ForecastAccuracyRepository repository; + private ForecastAccuracyService service; + + @BeforeEach + void setUp() { + repository = mock(ForecastAccuracyRepository.class); + service = new ForecastAccuracyService(repository, new ObjectMapper()); + } + + @Test + @DisplayName("측정 기록이 없으면 비운다") + void noMeasurement() { + when(repository.findFirstByHorizonMonthsOrderByRunDateDesc(anyInt())).thenReturn(Optional.empty()); + + assertThat(service.describeFor(6, 70)).isEmpty(); + } + + @Test + @DisplayName("전체 표본이 30건 미만이면 내보내지 않는다") + void tooFewSamples() { + given(accuracy(29, "[{\"from\":60,\"to\":80,\"samples\":29,\"actualTrue\":20}]")); + + assertThat(service.describeFor(6, 70)).isEmpty(); + } + + @Test + @DisplayName("확률이 속한 구간의 실제 적중률을 붙여 준다") + void matchesBucket() { + given(accuracy(120, "[{\"from\":40,\"to\":60,\"samples\":50,\"actualTrue\":20}," + + "{\"from\":60,\"to\":80,\"samples\":70,\"actualTrue\":49}]")); + + ForecastAccuracyResponse response = service.describeFor(6, 72).orElseThrow(); + + assertThat(response.getMatchedBucket()).isNotNull(); + assertThat(response.getMatchedBucket().getFrom()).isEqualTo(60); + assertThat(response.getMatchedBucket().getActualRate()).isEqualTo(0.7); + assertThat(response.getSamples()).isEqualTo(120); + // 상세 응답에는 구간표 전체를 싣지 않는다. + assertThat(response.getCalibration()).isNull(); + } + + @Test + @DisplayName("구간 표본이 10건 미만이면 그 구간은 숨긴다") + void bucketTooSmall() { + given(accuracy(40, "[{\"from\":60,\"to\":80,\"samples\":5,\"actualTrue\":5}," + + "{\"from\":0,\"to\":20,\"samples\":35,\"actualTrue\":2}]")); + + ForecastAccuracyResponse response = service.describeFor(6, 70).orElseThrow(); + + assertThat(response.getMatchedBucket()).isNull(); + assertThat(response.getSamples()).isEqualTo(40); + } + + @Test + @DisplayName("공개용 조회는 구간표 전체를 준다") + void publicViewIncludesCalibration() { + given(accuracy(80, "[{\"from\":0,\"to\":20,\"samples\":40,\"actualTrue\":4}," + + "{\"from\":80,\"to\":100,\"samples\":40,\"actualTrue\":36}]")); + + List results = service.latestByHorizon(List.of(6)); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getCalibration()).hasSize(2); + assertThat(results.get(0).getCalibration().get(1).getActualRate()).isEqualTo(0.9); + } + + @Test + @DisplayName("구간표를 읽지 못해도 전체 지표는 살린다") + void brokenCalibrationJson() { + given(accuracy(80, "not-json")); + + ForecastAccuracyResponse response = service.describeFor(6, 70).orElseThrow(); + + assertThat(response.getSamples()).isEqualTo(80); + assertThat(response.getMatchedBucket()).isNull(); + } + + private void given(ForecastAccuracy accuracy) { + when(repository.findFirstByHorizonMonthsOrderByRunDateDesc(anyInt())).thenReturn(Optional.of(accuracy)); + } + + private static ForecastAccuracy accuracy(int samples, String calibrationJson) { + return ForecastAccuracy.builder() + .runDate(LocalDate.now()) + .horizonMonths(6) + .samples(samples) + .facilities(12) + .brierScore(0.18) + .baselineBrierScore(0.24) + .actualRate(0.45) + .calibrationJson(calibrationJson) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/test/java/com/carecode/domain/careFacility/service/ForecastBacktestServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/ForecastBacktestServiceTest.java new file mode 100644 index 00000000..d9cb0ad1 --- /dev/null +++ b/src/test/java/com/carecode/domain/careFacility/service/ForecastBacktestServiceTest.java @@ -0,0 +1,178 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.entity.ForecastAccuracy; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import com.carecode.domain.careFacility.repository.ForecastAccuracyRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 백테스트가 정확도를 부풀리지 않는지 확인한다. + * + *

이 검증에서 가장 위험한 실수는 기준일 이후 관측을 예측 입력에 섞는 것이다(look-ahead). + * 그러면 "미래를 보고 예측" 하게 되어 어떤 알고리즘이든 훌륭해 보인다. + */ +@DisplayName("예측 정확도 백테스트") +class ForecastBacktestServiceTest { + + private FacilityCapacitySnapshotRepository snapshotRepository; + private ForecastAccuracyRepository accuracyRepository; + private ForecastBacktestService service; + + @BeforeEach + void setUp() { + snapshotRepository = mock(FacilityCapacitySnapshotRepository.class); + accuracyRepository = mock(ForecastAccuracyRepository.class); + when(accuracyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + service = new ForecastBacktestService(snapshotRepository, accuracyRepository, + new AdmissionForecastCalculator(), new ObjectMapper(), 2000); + } + + @Test + @DisplayName("관측 기간이 예측 기간보다 짧으면 표본이 없어 아무것도 저장하지 않는다") + void notEnoughHistoryProducesNothing() { + givenFacility(1L, weekly(LocalDate.now().minusMonths(2), 10, true)); + + assertThat(service.runAll(List.of(6))).isEmpty(); + } + + @Test + @DisplayName("항상 자리가 있던 시설은 높은 확률을 예측하고 실제도 참이라 정확도가 좋다") + void alwaysOpenFacilityScoresWell() { + givenFacility(1L, weekly(LocalDate.now().minusMonths(14), 60, true)); + + ForecastAccuracy result = service.runAll(List.of(3)).get(0); + + assertThat(result.getSamples()).isPositive(); + assertThat(result.getActualRate()).isEqualTo(1.0); + assertThat(result.getBrierScore()).isLessThan(0.1); + assertThat(result.getHorizonMonths()).isEqualTo(3); + assertThat(result.getFacilities()).isEqualTo(1); + } + + @Test + @DisplayName("기준일 이후 관측은 예측 입력에서 빠진다 — 자리가 뒤늦게 열려도 그 전 예측은 낙관하지 않는다") + void noLookAhead() { + // 앞 10개월은 자리 없음, 그 뒤 4개월은 자리 있음. + List history = new ArrayList<>(); + history.addAll(weekly(LocalDate.now().minusMonths(14), 40, false)); + history.addAll(weekly(LocalDate.now().minusMonths(4), 16, true)); + givenFacility(1L, history); + + ForecastAccuracy result = service.runAll(List.of(1)).get(0); + List buckets = buckets(result); + + // 자리가 한 번도 없던 구간에서 계산된 예측은 0~20% 구간에 있어야 한다. + int lowBucketSamples = buckets.stream().filter(b -> b.from() == 0).mapToInt(ForecastBacktestService.Bucket::samples).sum(); + assertThat(lowBucketSamples) + .as("자리가 없던 기간의 예측이 낙관적으로 나오면 미래를 본 것이다") + .isPositive(); + // 그 구간 예측 중 실제로 자리가 난 건은 없거나 극히 적다(마지막 구간 경계 한두 건). + int lowBucketHits = buckets.stream().filter(b -> b.from() == 0).mapToInt(ForecastBacktestService.Bucket::actualTrue).sum(); + assertThat(lowBucketHits).isLessThan(lowBucketSamples); + } + + @Test + @DisplayName("구간표 표본 합계는 전체 표본과 같다") + void calibrationCoversEverySample() { + givenFacility(1L, mixed(LocalDate.now().minusMonths(16))); + + ForecastAccuracy result = service.runAll(List.of(3)).get(0); + + assertThat(buckets(result).stream().mapToInt(ForecastBacktestService.Bucket::samples).sum()) + .isEqualTo(result.getSamples()); + } + + @Test + @DisplayName("기준선(항상 평균으로 답하기)과 함께 기록해 예측이 나은지 판단할 수 있다") + void recordsBaselineForComparison() { + givenFacility(1L, mixed(LocalDate.now().minusMonths(16))); + + ForecastAccuracy result = service.runAll(List.of(3)).get(0); + + assertThat(result.getBaselineBrierScore()).isNotNull(); + assertThat(result.betterThanBaseline()) + .isEqualTo(result.getBrierScore() < result.getBaselineBrierScore()); + } + + @Test + @DisplayName("측정 기간별로 결과를 따로 남긴다") + void resultPerHorizon() { + givenFacility(1L, weekly(LocalDate.now().minusMonths(20), 80, true)); + + List results = service.runAll(List.of(1, 3)); + + assertThat(results).hasSize(2); + assertThat(results.stream().map(ForecastAccuracy::getHorizonMonths)).containsExactly(1, 3); + } + + @Test + @DisplayName("대상 시설이 상한을 넘으면 그만큼만 본다 — 주간 작업 시간을 묶는다") + void capsFacilityCount() { + when(snapshotRepository.findFacilityIdsWithAtLeast(anyLong())).thenReturn(List.of(1L, 2L, 3L)); + when(snapshotRepository.findHistory(any(), any())).thenReturn(weekly(LocalDate.now().minusMonths(14), 60, true)); + ForecastBacktestService capped = new ForecastBacktestService(snapshotRepository, accuracyRepository, + new AdmissionForecastCalculator(), new ObjectMapper(), 2); + + ForecastAccuracy result = capped.runAll(List.of(3)).get(0); + + assertThat(result.getFacilities()).isEqualTo(2); + } + + private List buckets(ForecastAccuracy accuracy) { + try { + return new ObjectMapper().readValue(accuracy.getCalibrationJson(), + new com.fasterxml.jackson.core.type.TypeReference>() { + }); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private void givenFacility(Long facilityId, List history) { + when(snapshotRepository.findFacilityIdsWithAtLeast(anyLong())).thenReturn(List.of(facilityId)); + when(snapshotRepository.findHistory(eq(facilityId), any())).thenReturn(history); + } + + /** 주 1회 관측. 공공데이터 시설 동기화 주기와 같다. */ + private static List weekly(LocalDate start, int count, boolean hasSeat) { + List snapshots = new ArrayList<>(); + for (int i = 0; i < count; i++) { + snapshots.add(snapshot(start.plusWeeks(i), hasSeat ? 3 : 0)); + } + return snapshots; + } + + /** 자리가 있다가 없다가 하는 시설. 확률이 중간 구간에 퍼진다. */ + private static List mixed(LocalDate start) { + List snapshots = new ArrayList<>(); + for (int i = 0; i < 70; i++) { + snapshots.add(snapshot(start.plusWeeks(i), i % 3 == 0 ? 2 : 0)); + } + return snapshots; + } + + private static FacilityCapacitySnapshot snapshot(LocalDate date, int availableSpots) { + return FacilityCapacitySnapshot.builder() + .facilityId(1L) + .observedDate(date) + .capacity(100) + .currentEnrollment(100 - availableSpots) + .availableSpots(availableSpots) + .build(); + } +} diff --git a/src/test/java/com/carecode/integration/ForecastAccuracyContractTest.java b/src/test/java/com/carecode/integration/ForecastAccuracyContractTest.java new file mode 100644 index 00000000..b9ab594e --- /dev/null +++ b/src/test/java/com/carecode/integration/ForecastAccuracyContractTest.java @@ -0,0 +1,186 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +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; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * 예측 정확도를 측정하고 공개하는 경로 전체. + * + *

확률을 보여주면서 그 확률이 맞는지 확인하지 않던 상태를 고친 기능이라, "측정 전에는 비어 있고 + * 측정 후에는 표본과 함께 나온다" 는 것이 계약이다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:carecode_forecast_accuracy;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("입소 예측 정확도") +class ForecastAccuracyContractTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + @Autowired CareFacilityRepository careFacilityRepository; + @Autowired FacilityCapacitySnapshotRepository snapshotRepository; + + @Test + @DisplayName("측정 전에는 공개 정확도가 비어 있고, 측정하면 표본과 구간표가 나온다") + void measureThenPublish() throws Exception { + assertThat(publicAccuracy()).isEmpty(); + + List facilityIds = List.of(seedFacility(true), seedFacility(false), seedFacility(true)); + + // 수동 측정은 관리자만 할 수 있다. + assertThat(mockMvc.perform(post("/api/admin/sync/forecast-accuracy/measure") + .header("Authorization", "Bearer " + token(saveUser(UserRole.PARENT)))) + .andReturn().getResponse().getStatus()).isEqualTo(403); + + MvcResult measured = mockMvc.perform(post("/api/admin/sync/forecast-accuracy/measure") + .header("Authorization", "Bearer " + token(saveUser(UserRole.ADMIN)))) + .andReturn(); + String measuredBody = measured.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(measured.getResponse().getStatus()).as(measuredBody).isEqualTo(200); + assertThat(objectMapper.readTree(measuredBody).path("measured").asInt()).isPositive(); + + JsonNode accuracy = publicAccuracy(); + assertThat(accuracy).isNotEmpty(); + JsonNode oneMonth = findHorizon(accuracy, 1); + assertThat(oneMonth.path("samples").asInt()).isGreaterThan(30); + assertThat(oneMonth.path("facilities").asInt()).isEqualTo(facilityIds.size()); + assertThat(oneMonth.path("calibration").isArray()).isTrue(); + assertThat(oneMonth.path("brierScore").isNumber()).isTrue(); + assertThat(oneMonth.has("betterThanBaseline")).isTrue(); + + // 예측 응답에도 같은 확률대의 실제 적중률이 붙는다. + JsonNode forecast = json(mockMvc.perform(get("/facilities/{id}/admission-forecast", facilityIds.get(0)) + .param("horizonMonths", "1")).andReturn()); + assertThat(forecast.path("available").asBoolean()).isTrue(); + assertThat(forecast.path("accuracy").path("samples").asInt()).isGreaterThan(30); + assertThat(forecast.path("accuracy").path("horizonMonths").asInt()).isEqualTo(1); + } + + private JsonNode publicAccuracy() throws Exception { + MvcResult result = mockMvc.perform(get("/facilities/forecast-accuracy")).andReturn(); + String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(result.getResponse().getStatus()).as("비로그인도 볼 수 있어야 한다: %s", body).isEqualTo(200); + return objectMapper.readTree(body); + } + + private static JsonNode findHorizon(JsonNode accuracy, int horizon) { + for (JsonNode node : accuracy) { + if (node.path("horizonMonths").asInt() == horizon) { + return node; + } + } + throw new AssertionError(horizon + "개월 측정 결과가 없습니다: " + accuracy); + } + + /** 20개월치 주간 관측. 자리가 자주 나는 시설과 거의 나지 않는 시설을 섞어 구간이 퍼지게 한다. */ + private Long seedFacility(boolean opensOften) { + Long facilityId = careFacilityRepository.save(CareFacility.builder() + .facilityCode("F-" + UUID.randomUUID()) + .name("행복 어린이집") + .facilityType(FacilityType.DAYCARE) + .isActive(true) + .capacity(100) + .build()).getId(); + + LocalDate start = LocalDate.now().minusMonths(20); + List snapshots = new ArrayList<>(); + for (int week = 0; week < 85; week++) { + int spots = opensOften ? (week % 2 == 0 ? 3 : 0) : (week % 20 == 0 ? 1 : 0); + snapshots.add(FacilityCapacitySnapshot.builder() + .facilityId(facilityId) + .observedDate(start.plusWeeks(week)) + .capacity(100) + .currentEnrollment(100 - spots) + .availableSpots(spots) + .createdAt(LocalDateTime.now()) + .build()); + } + snapshotRepository.saveAll(snapshots); + return facilityId; + } + + private JsonNode json(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)); + } + + 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()); + } +} From 2260aa67c65db40d9106a3011d1a9f440351c2ea Mon Sep 17 00:00:00 2001 From: RosieOh Date: Fri, 25 Sep 2026 03:10:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20JVM?= =?UTF-8?q?=20=ED=9E=99=202g,=20Spring=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BA=90=EC=8B=9C=20=EC=83=81=ED=95=9C=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통합 테스트 클래스를 추가하면서 컨텍스트가 14개까지 떠, 기본 힙으로는 워커가 OutOfMemoryError 로 죽었다(GC 스래싱으로 실행 시간도 3분 34초 → 9분 55초로 늘었다). 컨텍스트마다 JPA·커넥션풀·캐시를 들고 있어 개수가 곧 메모리다. - maxHeapSize 2g - spring.test.context.cache.maxSize=6: 기본값(32)이면 한 번 뜬 컨텍스트가 끝까지 남아 클래스를 추가할 때마다 메모리가 계단식으로 늘어난다. 넘치면 오래된 컨텍스트를 닫는다. --- build.gradle | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.gradle b/build.gradle index faa3cb0a..56a61aff 100644 --- a/build.gradle +++ b/build.gradle @@ -97,6 +97,14 @@ tasks.withType(Test).configureEach { // CI 가 통과하는 건 러너가 아직 이전 Engine 이기 때문이라, 러너가 올라가면 CI 도 같은 상태가 된다. // 1.44 는 Engine 25 이상이 지원하므로 로컬·CI 모두 안전하다. 필요하면 -Papi.version= 으로 덮어쓴다. systemProperty 'api.version', project.findProperty('api.version') ?: '1.44' + + // 통합 테스트가 늘면서 Spring 컨텍스트가 14개까지 떠, 기본 힙으로는 OutOfMemory 로 워커가 죽었다. + // 컨텍스트마다 JPA·커넥션풀·캐시를 들고 있어 개수가 곧 메모리다. + maxHeapSize = '2g' + + // 동시에 살아 있는 컨텍스트 수를 제한한다. 기본값(32)이면 한 번 뜬 컨텍스트가 끝까지 캐시에 남아 + // 테스트 클래스를 추가할 때마다 메모리가 계단식으로 늘어난다. 넘치면 오래된 컨텍스트를 닫는다. + systemProperty 'spring.test.context.cache.maxSize', '6' } tasks.named('test') {