Data Platform · Time-Series · Weather-based ML
How Many Blood
대한적십자사 혈액정보 시스템(biss.bloodinfo.net)의 차트 데이터를 매시간 자동 수집해 시간대·요일·지역별 헌혈 패턴을 시각화하고, 날씨 데이터와 결합해 D+7 일일 헌혈량을 예측하는 데이터 플랫폼.
1. 화면
대시보드는 일별 헌혈 추이 + 7일 이동평균, 지역별 합계, 시간대 피크를 한 화면에 묶는다.
분석/예측 탭은 시간×요일 히트맵, 시간대 평균, 7일 예측, 인사이트 카드를 함께 보여준다.
2. 문제
혈액정보 시스템은 차트 형태로 시각화된 데이터만 제공한다 — 다운로드 가능한 표가 없다. 의료진이 캠페인/배차/인력 운영 결정을 내려야 하는데, "요일·시간·날씨에 따라 헌혈량이 어떻게 움직이는가"를 정량적으로 보는 도구가 없었다. 그래서 수집·정규화·시각화·예측을 한 줄로 묶는 도구를 직접 만들었다.
핵심 두 가지:
- 기존 일 1회 수집(20시)으로는 시간대 패턴이 보이지 않는다 → 시간별 수집으로 전환.
- 날씨와 헌혈량 사이의 상관관계는 혈액원 실무에서 체감하지만 정량 데이터가 없다 → 관측·예보 데이터 결합.
3. 시스템 설계
3.1 데이터 파이프라인
3.2 시간별 수집 스키마
기존 일 1회 → 11시간 × 17개 시도 구조로 전환 (2025년 1월).
hourly_blood_records/{date}_{hour} // 예: 2026-08-05_14
{
date: "2026-08-05",
hour: 14, // 10~20
regions: [
{ regionId, regionName, individual, group, total }
],
metadata, timestamp
} 20시 스냅샷 = 해당 일의 일일 총량. 별도 집계 없이 일별 통계를 낼 수 있어 v1의 일별 문서 구조를 시간별 문서로 분리하면서도 backward compatible하게 유지했다.
3.3 스크래퍼 · CORS · 스케줄
- Cloud Scheduler → Firebase Functions (functions/src/scraper.ts) 매시간 10:00~20:00 KST
- CORS 프록시 + HTML/JS 파싱 → JSON 정규화
- 5분 캐시로 과도한 호출 방지, 실패 시 exponential backoff 재시도
3.4 시각화 컴포넌트 — 설계 초점
단순 차트 라이브러리 호출이 아니라, 실무에서 패턴을 읽는 데 필요한 화면을 직접 설계했다. Recharts + Styled Components 기반, 각 차트는 18px 제목 / 12px 보조 설명 / hover 시 그림자 lift.
- MainChart — 일별 추이 + 7일 이동평균 오버레이 (ComposedChart). 추세와 단일 일 변동을 한 화면에서 비교.
- DayOfWeekChart / MonthlyAverageChart — 요일·월별 패턴. "금요일 단체 헌혈이 많은가, 1월 헌혈이 줄어드는가" 식의 운영 질문에 직접 응답.
- HourlyAveragesChart — 11개 시간대 막대 그래프, 14시 피크가 시각적으로 즉시 읽힘.
- WeekdayPatternHeatmap — 7요일 × 11시간 히트맵, 🔥 아이콘. 평일 오후 피크와 일요일 10시 일부 지점 미운영이 한눈에.
- RegionalChart — 17개 시도 개인/단체 분리 막대, 지역별 운영 비중 비교.
- MLForecastChart — ComposedChart + ReferenceLine, 오늘을 기준으로 좌측 실측 / 우측 예측·신뢰구간을 다른 색으로 분리 (500px).
- InsightCard / WeatherImpactCard — positive/negative/neutral 색 구분, hover 시 2px lift. 추세·위험·날씨 영향 카드.
- DataLimitationNotice — 데이터 한계 명시 배너. 데이터가 계절성 학습에 짧은 기간이라는 점 등을 사용자에게 알린다.
다크 모드 / 라이트 모드 모두 지원, 한국어·영어 i18n, 모바일(480px) ~ 태블릿 ~ 데스크탑 반응형. 모든 필터는 localStorage에 저장되어 새로고침해도 유지된다.
3.5 날씨 통합
- 관측 데이터: 기상청 ASOS — 수원(119) 관측소. Firebase Functions로 주간(일 01:00 KST) 결측분 백필.
- 예보 데이터: Open-Meteo 7일 일일 예보 (6시간 주기 수집).
- Raw Data 보존 — 점수화/정규화하지 않고 원본 수치 그대로 저장. 통계 분석 정밀도 우선.
3.6 ML 예측 모델 — 5가지 시각으로 보기
개인/단체 분리 모델 2개. 헌혈의 집(개인)과 헌혈 버스(단체)는 운영 패턴이 다르므로 (개인은 토요일도 운영 / 단체는 월~금 위주, 날씨 영향도 다름) 별도 학습한다. 같은 아키텍처를 수식 · 흐름도 · 기하 · 실제 코드 · 메트릭 5가지 방식으로 풀어본다.
(a) 수식 — 변수별 단변량 OLS 후 합성
모델은 다변량 선형회귀이지만 한 번에 행렬식을 푸는 대신, 변수별로 1차 회귀를 돌려 기울기(β)만 뽑고, 요일은 평균 차이로 가중치를 만든다. 이렇게 한 이유: 데이터가 ~58일로 적어 변수 간 상호작용 항을 안정적으로 추정하기 어렵기 때문이다.
predicted = intercept
+ β_temperature · 기온 // (Tmin + Tmax) / 2
+ β_precipitation · 강수량(mm)
+ β_snowfall · 적설(cm)
+ dayOfWeek[dow] // 0=일 ... 6=토, 평균 차이값
intercept = 전체 기간 평균 헌혈량
dayOfWeek[i] = (요일 i 평균) − intercept
β_x = (n·ΣXY − ΣX·ΣY) / (n·ΣX² − (ΣX)²) // 단변량 OLS
predictedCount = round( max(0, predicted) )
confidence = clamp(R², 0, 1) (b) 흐름도 — 주 1회 재학습 파이프라인
(c) 기하학적 의미 — 회귀 = 직선 맞추기
(d) 실제 코드 — 핵심 발췌
functions/src/predictionModel.ts 의 단변량 OLS 구현. 외부 ML 라이브러리 없음.
function calculateLinearRegression(x: number[], y: number[]) {
const n = x.length
const sumX = x.reduce((a, b) => a + b, 0)
const sumY = y.reduce((a, b) => a + b, 0)
const sumXY = x.reduce((a, b, i) => a + b * y[i], 0)
const sumXX = x.reduce((a, b) => a + b * b, 0)
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX)
const intercept = (sumY - slope * sumX) / n
return { slope, intercept }
}
// 예측 적용
function predictDonation(model, T, P, S, dow) {
const { coefficients } = model
const predicted = coefficients.intercept
+ coefficients.temperature * T
+ coefficients.precipitation * P
+ coefficients.snowfall * S
+ coefficients.dayOfWeek[dow]
return {
predictedCount: Math.round(Math.max(0, predicted)),
confidence: Math.max(0, Math.min(1, model.metrics.r2Score)),
}
}
변수마다 calculateLinearRegression을 호출해 β를 뽑고,
요일 가중치는 "요일 평균 − 전체 평균"의 차이값으로 정의한다.
이렇게 하면 intercept가 자연스럽게 "전체 평균"이 되어 모델이 직관적으로 해석된다.
(e) 메트릭 — 모델을 어떻게 평가하는가
R² = 1 − Σ(yᵢ − ŷᵢ)² / Σ(yᵢ − ȳ)² // 결정계수 — 1에 가까울수록 설명력 높음
MAE = Σ|yᵢ − ŷᵢ| / n // 평균 절대 오차
RMSE = √(Σ(yᵢ − ŷᵢ)² / n) // 큰 오차에 패널티를 더 주는 평균제곱근오차
featureImportance[i] = |β_i| / Σ|β_j| // 계수 절대값의 정규화 비율 - R² ≈ 0.72 — 평일 14~16시 피크 패턴을 잘 잡지만, 이상 기후·특별 캠페인 등은 설명 못함.
- MAE/RMSE — 같은 데이터를 in-sample로 평가하므로 낙관적이다. 시계열 교차검증은 후속.
- featureImportance — 실무자(혈액원)가 "어떤 변수가 얼마나 영향을 주는가"를 즉시 이해할 수 있게 함.
왜 Ridge/GBT가 아닌가
약 10개월 데이터에서 Ridge 회귀는 정규화가 강해 패턴을 오히려 평탄화한다.
GBT(XGBoost/LightGBM)는 학습 데이터 1,000건 이상에서 MAPE ~18% 수준으로
성능이 좋아지지만 (현재 ~58일치로는 과적합 위험), 데이터가 누적되면 동일 인터페이스(coefficients + predict())
로 교체 가능하도록 설계했다.
4. 데이터 현황 및 한계
- 보유 기간: 약 10개월 — 계절성 학습엔 짧다 (최소 3년 권장).
- 현재 R²: ~0.72 (학습 데이터 58일 기준). 평일 14~16시 피크는 안정적으로 포착.
- 한계: 결빙/폭우 등 이상 기후 케이스가 충분치 않음, 다른 혈액원 일반화는 추가 학습 필요.
참고. 혈액정보 시스템 측 API 구조 변경으로 현재는 실시간 데이터 수집이 중단된 상태다. 본 페이지는 기능과 데이터 파이프라인 설계를 기록한 것이며, 실제 운영 화면은 별도 목업/스크린샷으로 검증할 수 있다.
5. 레포지토리
6. 관련 문서
docs/PREDICTION_SYSTEM_PLAN.md— 예측 시스템 구축 계획. 데이터 구조·스케줄·모델 설계 결정.docs/MID_TERM_FORECAST.md— 기상청 중기예보 API 명세와 Firestore 스키마.docs/weather-forecast-migration.md— 예보 구역 → 지점별 동네예보 마이그레이션 기록.docs/features/hourly-blood-collection-spec.md— 시간별 수집 도입 사양 (v1 → v2).docs/architecture.md— 프론트/백엔드/상태관리/스키마/배포 전략.