07.갱신 주기가 다른 소스를 한 스냅샷으로 정렬하기

2026. 9. 2. 23:34카테고리 없음

화면 뒤의 데이터 판단 · 데이터 수집

갱신 주기가 다른 소스를 한 스냅샷으로 정렬하기

DART · ECOS · KRX · FRED · SEC — 어떤 건 하루마다, 어떤 건 넉 달 전 것이다

화면 하나에 여러 소스의 숫자가 나란히 놓입니다. 사용자는 그것들이 모두 오늘 값이라고 읽습니다.

실제로는 갱신 주기가 열 배 넘게 벌어집니다.

소스 주기 기준일이 뒤처지는 정도
yfinance · FRED 일간 당일 ~ 1영업일
KOFIA — 신용공여 일간, T+1 공시 1 ~ 2영업일
ECOS — 한국은행 월간 1 ~ 2개월
DART — 재무제표 분기 공시 1 ~ 3개월
SEC N-PORT — ETF 보유 분기 + 60일 유예 3 ~ 5개월

맨 아래가 극단입니다. IVV 의 공시일은 2026-07-13 인데 대상 기간 종료는 2026-03-31 입니다. 오늘 받아도 넉 달 전 구성입니다.

이걸 한 화면에 올리려면 세 가지를 정해야 합니다 — 무엇을 기준일로 삼을지, 어떻게 같은 축에 붙일지, 언제부터 "늦었다"고 할지.

① 수집 시각과 데이터 기준일을 분리한다

가장 흔한 실수는 "언제 받았나"로 "언제 것인가"를 대신하는 것입니다. 둘은 다릅니다.

매일 배치가 돌아 캐시를 다시 써도, 소스가 새 값을 안 냈으면 내용은 석 달 전 그대로입니다. 쓰기 시각만 보면 항상 신선해 보입니다.

그래서 payload 안에 데이터 기준일을 따로 담고, 지연 판정도 그 필드로 합니다.

# payload 안의 '데이터 기준일' 필드명. 지정하면 쓰기 시각 대신 이 날짜로 판정한다.
data_date_key: str | None = None
# 그 기준일이 오늘보다 몇 영업일까지 뒤처져도 정상으로 볼지(소스 공시 지연 허용).
max_lag_bdays: int = 4

주의할 게 하나 더 있습니다. 날짜라고 다 신선도 지표는 아닙니다. 지수 밸류에이션 캐시의 as_of 는 실적 기준일(미국 2026-03-31)이라 그걸로 재면 상시 지연으로 오탐합니다. 시계열이 어디까지 왔는지는 current_asof 가 말해 줍니다. 같은 payload 안에 날짜가 둘이고, 신선도를 말하는 건 하나뿐입니다.

② 붙일 때는 앞으로 채우고, 잴 때는 채우지 않는다

월간 지표를 일간 모델에 넣으려면 같은 인덱스로 맞춰야 합니다. 방법 자체는 한 줄입니다.

def _align(obj, index):
    # 일간 인덱스에 맞추고 빈 날은 직전 값으로 채운다(과거 방향으로만)
    return obj.reindex(index).ffill()

ffill  과거 값을 앞으로만 흘립니다. 뒤로 채우면(bfill) 아직 발표되지 않은 값이 과거 행에 실려 그대로 누수가 됩니다.

다만 이 채우기를 신선도 판정에 쓰면 안 됩니다. 채운 값은 "그 날짜의 값"이 아니라 "그 이전 값"이기 때문입니다. 모델에는 채운 값을 주되, 화면과 감시에는 원래 기준일을 그대로 노출합니다.

③ "늦었다"의 기준을 소스마다 선언한다

정렬보다 어려운 건 판정입니다. 일간 소스에 맞춘 임계를 분기 소스에 쓰면 매일 경보가 울리고, 반대로 넉넉히 잡으면 멈춘 걸 못 잡습니다.

그래서 판정 규칙을 코드가 아니라 선언 테이블로 뺐습니다. 이 표가 "무엇을 언제까지 받아야 정상인가"의 단일 진실원천입니다.

# 일간 데이터셋 — 장 마감 + lag_h 까지는 없어도 정상
Spec("chart_predict", "차트 30일예측", lag_h=10)
# 월간 데이터셋 — 관측월 다음 달 발표(1) + 지연 여유(1)
Spec("sector", "섹터 경기국면", cadence="monthly")
# 배치 캐시 — 평일에만 도는 잡이라 주말 시간은 나이에서 뺀다
CacheSpec("direction_signals", "방향 신호", 3, min_items=2, weekdays_only=True)
# T+1 공시 + 새 값 없으면 재저장 안 함 → 쓰기 시각이 아니라 기준일로 잰다
CacheSpec("credit_leverage:kr", "빚투 레버리지", 48,
          data_date_key="as_of", max_lag_bdays=3)

판정 결과는 세 가지입니다 — ok · late · unknown.

세 번째가 중요합니다. 기대 영업일을 못 구하면 정상으로 통과시키지 않고 unknown 으로 남깁니다. 판단 불가를 정상으로 숨기면 그게 곧 미탐입니다.

실제로 당한 함정들

주기의 2배로 잡으면 1회 누락을 못 잡는다

하루 1회 도는 잡에 임계 48시간을 주면 한 번 걸러도 아슬아슬하게 통과합니다. 주기 × 1.5 로 조여야 1회 누락에서 걸립니다.

주말 시간을 나이에 넣으면 금요일 갱신분이 계속 지연으로 뜬다

평일에만 도는 잡은 토·일에 원래 안 돕니다. 그 시간을 나이에 포함하면 월요일 아침까지 빨간불입니다. 그래서 평일 시간만 세는 함수를 따로 둡니다.

def weekday_hours(start, end):
    # start~end 중 평일(월~금)에 속한 시간만 더한다 — 주말은 0으로 친다
    ...
    # 월(0)~금(4)만 나이에 반영한다
    if cur.weekday() < 5:
        total += (nxt - cur).total_seconds() / 3600.0

 화면에는 실제 경과 시간을 보여줍니다. 사용자는 "언제 갱신됐나"를 실제 시계로 알고 싶어 하니, 판정만 평일 기준으로 합니다. 재는 시간과 보여주는 시간이 다릅니다.

배치가 돌기 전인데 지연으로 찍으면 매일 아침이 오탐이다

기대 영업일보다 데이터가 옛날이면 일단 아직 배치 시간이 아닌지부터 봅니다. 장 마감 + lag_h 를 넘겨야 비로소 지연입니다.

def due_utc(expected, region, lag_h):
    # 기대 영업일 장 마감(KR 15:30 · US 16:00) + lag_h → UTC 시점
    ...
# 최신일이 기대일보다 옛날 — 아직 배치가 돌 시간이 아닌지부터 확인한다
if now < due_utc(expected, region, spec.lag_h):
    return OK, "배치 예정 전"

빈 값으로 덮어써도 신선해 보인다

수집이 0건이어도 캐시를 쓰면 시각은 갱신됩니다. 화면엔 카드가 안 뜨는데 감시는 초록입니다. 막는 자리는 두 곳입니다 — 생산자 쪽에서 "수집 0건이면 기존 캐시 유지", 그리고 필요하면 min_items 로 항목 수를 셉니다.

다만 min_items 는 함부로 붙이지 않습니다. 붙이면 현황 페이지가 그 캐시 본문을 통째로 읽어야 해서 DB 에서 가장 비싼 쿼리가 됩니다. 막을 수 있는 곳에서 막고, 감시는 싸게 유지합니다.

감시 밖에 있으면 조용히 멈춘다

이 규칙들을 만든 계기는 전부 사고입니다.

  • 차트 30일예측 — 감시가 없어서 한 지수가 4주 멈춘 것도, 배치가 최근 12회 중 5회 실패한 것도 아무 데도 뜨지 않았다
  • 뉴스 TOP5 — 2시간마다 도는 잡에 임계 24시간을 두면 12연속 실패해야 알람이 뜬다. 하루 넘게 비는 동안 화면은 조용했다
  • 지수 밸류 시계열 — 매일 쓰기는 성공했는데 내용이 3월에서 멈춰 있었다

공통점이 있습니다. 에러가 나지 않습니다. 카드가 안 뜨거나 옛 숫자가 그대로 보일 뿐입니다. 그래서 새 캐시·배치를 만들면 같은 커밋에서 감시 목록에 넣는 것을 규칙으로 두었습니다.

전체 흐름

수집 — 소스마다 다른 주기로
수집기
DART·ECOS·KRX·FRED·SEC 각자의 배치
기준일 부착
payload 에 as_of·current_asof 를 함께 저장
적재
수집 0건이면 기존 값을 지킨다
사용 — 세 갈래로 갈린다
모델 입력
reindex + ffill 로 일간 축에 맞춘다
화면
채운 값이 아니라 원래 기준일을 표기
감시
선언 테이블로 ok·late·unknown 판정

파란 칸이 이 글의 핵심 — 수집 시각과 데이터 기준일을 같은 자리에서 나눠 저장한다. 그 뒤로는 모델·화면·감시가 각자 필요한 날짜를 골라 쓴다.

[수집]   소스별 배치 — 일간(FRED·KRX) / 월간(ECOS) / 분기(DART·SEC)
   │       주기가 다르므로 서로 기다리지 않는다
   ▼
[기준일] payload 에 as_of 를 함께 적는다
   │       "언제 받았나"와 "언제 것인가"를 따로 남긴다
   ▼
[적재]   수집 0건이면 기존 값을 지키고 덮어쓰지 않는다
   │       빈 값으로 갱신하면 감시가 초록이 된다
   ▼
[정렬]   reindex + ffill — 과거 방향으로만 채운다
   │       bfill 은 미래를 과거 행에 싣는 누수다
   ▼
[판정]   소스마다 다른 임계로 ok · late · unknown
   │       판단 불가는 정상으로 숨기지 않는다
   ▼
[화면]   값 옆에 기준일을 함께 노출
           넉 달 전 구성이면 넉 달 전이라고 쓴다

정리

  • 수집 시각은 신선도가 아니다 — 매일 써도 내용은 석 달 전일 수 있다
  • 같은 payload 의 날짜가 다 같은 뜻은 아니다 — 실적 기준일과 시계열 기준일을 구분한다
  • 채우기는 과거 방향으로만  bfill 은 누수다
  • 모델엔 채운 값, 화면엔 원래 기준일
  • 임계는 주기 × 1.5 — 2배로 두면 1회 누락을 못 잡는다
  • 재는 시간과 보여주는 시간을 나눈다 — 판정은 평일만, 표시는 실제 경과
  • 판단 불가는 정상이 아니다  unknown 을 따로 남긴다
  • 새 캐시를 만들면 같은 커밋에서 감시에 넣는다 — 감시 밖은 조용히 멈춘다

주기가 다른 소스를 한 화면에 올리는 일은 정렬 문제로 보이지만, 실제로 시간을 쓰는 곳은 "언제 것인지를 잃지 않게 만드는 데"입니다. 값을 맞추는 건 한 줄이고, 그 값이 언제 것인지를 지키는 건 표 하나 분량의 규칙입니다.

화면 뒤의 데이터 판단 · 수집 → 정렬 — 데이터 파이프라인