Data Platform · Time-Series · Weather-based ML

How Many Blood

대한적십자사 혈액정보 시스템(biss.bloodinfo.net)의 차트 데이터를 매시간 자동 수집해 시간대·요일·지역별 헌혈 패턴을 시각화하고, 날씨 데이터와 결합해 D+7 일일 헌혈량을 예측하는 데이터 플랫폼.

역할
개인 프로젝트 — 데이터 수집·저장·분석·예측·시각화 전 과정 단독 설계/구현
프론트
React 18 · TypeScript · Vite · Styled Components · Recharts · React Query · Zustand · i18next
백엔드
Firebase Functions · Firestore · Firebase Hosting · Cloud Scheduler
데이터 소스
혈액정보 시스템 (개인/단체 17개 시도별 시간별) + 기상청 ASOS + Open-Meteo
ML 모델
다변량 선형회귀 (기온/강수/적설/요일) · 주 1회 재학습

1. 화면

대시보드는 일별 헌혈 추이 + 7일 이동평균, 지역별 합계, 시간대 피크를 한 화면에 묶는다.

How Many Blood 대시보드 — 4,451명 합계, 17개 시도 헌혈 통계 표
메인 대시보드 — 90일치 mock 데이터로 앱 동작. 개인 2,628 / 단체 1,823 / 합계 4,451, 17개 시도 표

분석/예측 탭은 시간×요일 히트맵, 시간대 평균, 7일 예측, 인사이트 카드를 함께 보여준다.

How Many Blood 통계 분석 — 인사이트 카드, AI 헌혈량 예측 (기상 데이터 반영) 90일치 시계열 차트
통계 분석 — 90일치 mock 데이터 기반. 주간 추세, 가장 활발한 요일, AI 예측 차트
How Many Blood 일별 통계 — 일별 헌혈 추이 + 7일 이동평균 + 요일별 평균
일별 통계 — 일별 헌혈 추이 + 7일 이동평균, 요일별 평균, 월별 평균. 시계열 라인 + 막대
How Many Blood 시간대 통계 — 11개 시간대 막대 + 요일×시간대 히트맵
시간대 통계 — 11개 시간대(10~20시) 막대, 7요일 × 11시간 히트맵. 평일 오후 피크 + 일요일 10시 일부 지점 미운영 패턴

2. 문제

혈액정보 시스템은 차트 형태로 시각화된 데이터만 제공한다 — 다운로드 가능한 표가 없다. 의료진이 캠페인/배차/인력 운영 결정을 내려야 하는데, "요일·시간·날씨에 따라 헌혈량이 어떻게 움직이는가"를 정량적으로 보는 도구가 없었다. 그래서 수집·정규화·시각화·예측을 한 줄로 묶는 도구를 직접 만들었다.

핵심 두 가지:

  1. 기존 일 1회 수집(20시)으로는 시간대 패턴이 보이지 않는다 → 시간별 수집으로 전환.
  2. 날씨와 헌혈량 사이의 상관관계는 혈액원 실무에서 체감하지만 정량 데이터가 없다 → 관측·예보 데이터 결합.

3. 시스템 설계

3.1 데이터 파이프라인

데이터 파이프라인 — 소스 → 스크래퍼 → Firestore → ML → 시각화 5단계
① 혈액정보 시스템 → ② Firebase Functions 스크래퍼 → ③ Firestore → ④ ML 학습 → ⑤ 시각화

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 · 스케줄

3.4 시각화 컴포넌트 — 설계 초점

단순 차트 라이브러리 호출이 아니라, 실무에서 패턴을 읽는 데 필요한 화면을 직접 설계했다. Recharts + Styled Components 기반, 각 차트는 18px 제목 / 12px 보조 설명 / hover 시 그림자 lift.

다크 모드 / 라이트 모드 모두 지원, 한국어·영어 i18n, 모바일(480px) ~ 태블릿 ~ 데스크탑 반응형. 모든 필터는 localStorage에 저장되어 새로고침해도 유지된다.

3.5 날씨 통합

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회 재학습 파이프라인

How Many Blood 학습 흐름 — 데이터 수집 → 피처 구성 → 변수별 OLS → 계수 합성 → 메트릭 → 예측
predictionScheduler.ts (Cloud Scheduler) → predictionModel.ts → Firestore `predictionModels/{id}` 저장

(c) 기하학적 의미 — 회귀 = 직선 맞추기

회귀 직선과 잔차의 기하학적 의미 — 데이터 점들에 최소제곱으로 직선 맞춤
기온-헌혈량 산점도에 최소제곱으로 직선 ŷ = β₀ + β₁·T 를 맞춤. 잔차 e = y − ŷ 의 제곱합이 최소가 되도록 β가 결정된다.

(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|     // 계수 절대값의 정규화 비율

왜 Ridge/GBT가 아닌가

약 10개월 데이터에서 Ridge 회귀는 정규화가 강해 패턴을 오히려 평탄화한다. GBT(XGBoost/LightGBM)는 학습 데이터 1,000건 이상에서 MAPE ~18% 수준으로 성능이 좋아지지만 (현재 ~58일치로는 과적합 위험), 데이터가 누적되면 동일 인터페이스(coefficients + predict()) 로 교체 가능하도록 설계했다.

4. 데이터 현황 및 한계

참고. 혈액정보 시스템 측 API 구조 변경으로 현재는 실시간 데이터 수집이 중단된 상태다. 본 페이지는 기능과 데이터 파이프라인 설계를 기록한 것이며, 실제 운영 화면은 별도 목업/스크린샷으로 검증할 수 있다.

5. 레포지토리

6. 관련 문서