데이터 없음과 수집 실패를 구분하는 파서

공식 출처 등급, schema drift, ingestion run으로 빈 화면의 이유를 남겼다

공공데이터 앱에서 파싱은 쉬워 보인다. URL을 호출하고 JSON이나 HTML을 읽으면 끝날 것 같다.

하지만 실제 앱에서는 여기서 정보가 믿을 만한지 갈린다. 응답이 정말 비었는지, HTML이나 JSON 모양이 바뀌었는지, 공원명이 다르게 들어왔는지, 마지막으로 가져온 시간이 오래됐는지 알아야 한다. 이런 응답 구조 변화, 즉 schema drift를 앱이 모르면 사용자는 틀린 정보를 보게 된다.

개발 중 가장 자주 마주친 문제는 “데이터가 없다”와 “데이터를 못 읽었다”가 겉으로 비슷해 보인다는 점이었다. 행사 정보가 0건인 날도 있고, parser가 공식 페이지 변경을 따라가지 못해 빈 목록처럼 보이는 날도 있다.

이 둘을 같은 값으로 저장하면 앱은 조용히 잘못된 화면을 만든다.

한강자리의 파서는 단순 변환기가 아니다. 공식 출처의 성격, 얼마나 자주 바뀌는지, schema drift, 공원 매핑, 원문 URL, 화면에 보여도 되는지까지 함께 본다.

어떤 출처를 화면에 올릴지 먼저 정했다

모든 공개 데이터를 같은 무게로 취급하지 않는다. 먼저 출처를 등급으로 나눈다.

등급의미앱에서 쓰는 방식
Official API문서화된 공공 API기준 출처로 사용 가능
Official web/AJAX공식 웹에서 공개 호출되는 JSON/HTML기준 또는 보조 출처
Official HTML/RSS공식 페이지의 HTML/RSS파싱 안정성 확인 후 사용
Curated static사람이 검수한 정적 데이터시설, 매핑, 보정 데이터
Discovery only누락 탐지용 보조 신호사용자 응답에는 직접 노출하지 않음
Do not use로그인, 개인화, 결제, 차량번호, 캡차, 비공개 API사용하지 않음

이 구분은 문구와도 연결된다. “공식 출처에서 확인했다”고 말하려면 어떤 출처를 기준으로 삼는지 정해야 한다. 반대로 Discovery only 등급은 사용자 화면보다 빠진 값을 찾는 용도에 가깝다.

이 선은 다소 보수적이다. 더 많은 곳에서 데이터를 가져오면 화면은 풍성해 보일 수 있다. 하지만 사용자가 움직임을 바꿀 수 있는 정보라면, 어디서 왔고 언제 확인했는지 설명할 수 있어야 한다. 특히 주차나 통제 정보처럼 행동에 영향을 주는 데이터는 값 자체만큼 출처와 확인 시간이 크게 작동했다.

수집 주기를 코드 밖에서도 읽게 했다

한강자리에는 수집 주기를 적어 둔 schedule catalog가 있다. 각 수집 작업은 코드 안에 흩어진 cron 문자열이 아니라, 출처 ID와 실행 방식으로 묶어 둔다.

현재 큰 범주는 다음과 같다.

범주예시실행 방식
Parking master주차장 기준 데이터 동기화Kubernetes CronJob
Parking status주차장 실시간 상태worker scheduler
Outing facility편의시설·공원 시설Kubernetes CronJob
Outing event행사 정보worker scheduler
Outing notice공지·통제 정보worker scheduler
Realtime context서울 실시간 도시데이터worker scheduler
Transit dataset교통·접근성 보조 데이터CronJob
Forecast generation예측 만들기worker scheduler
Forecast backtest예측 검증CronJob

어디서 실행되느냐보다 어느 출처에서 문제가 났는지가 더 오래 남는다. 어떤 작업이 실패했는지, 어떤 출처가 stale인지, 어떤 parser가 row count drift를 만들었는지 추적할 수 있어야 한다.

여기서 worker schedulerKubernetes CronJob은 단순한 취향 차이가 아니다. 짧은 주기로 계속 확인해야 하는 출처는 worker 안의 scheduler가 맡고, 하루 한 번 또는 몇 시간 간격으로 돌면 충분한 master/시설/검증 작업은 CronJob으로 빼는 편이 나중에 장애를 보기 쉽다. 같은 “수집”이라도 실행 주기와 실패 영향이 다르기 때문이다.

원문을 화면용 데이터로 바꾸는 단계

flowchart LR
  Catalog["수집 계획표"] --> Fetch["출처 어댑터 가져오기"]
  Fetch --> Parse["파서<br/>schema_hash<br/>파서 버전"]
  Parse --> Normalize["도메인 정규화"]
  Normalize --> Validate["검증<br/>필수 필드<br/>공원 매핑<br/>시간"]
  Validate --> Upsert["Postgres upsert"]
  Upsert --> Runs["ingestion_runs<br/>상태<br/>행 수<br/>오류"]
  Upsert --> ReadModel["API 화면용 응답"]
  Runs --> Health["출처 상태"]

원문은 다섯 단계를 거쳐 화면에 올라갈 값이 된다.

  1. Catalog에서 출처별 약속과 실행 주기를 읽는다.
  2. Adapter가 공식/public 데이터를 가져온다.
  3. Parser가 원문을 앱이 다룰 후보 값으로 바꾼다.
  4. Normalizer가 공원, 시간, 상태, URL을 앱에서 쓰는 모양으로 맞춘다.
  5. Validator와 repository가 DB에 반영하고 ingestion run을 남긴다.

이렇게 나누면 실패를 숨기지 않는다. 수집 실패는 “데이터가 없음”과 다르다. 앱이 보여줄 수 없는 상태라면 unavailable 또는 stale로 표현해야 한다.

공원명과 시간을 화면에 맞췄다

외부 데이터는 앱이 바로 쓰기 좋은 모양으로 오지 않는다. 한강자리는 다음 항목을 별도로 정규화한다.

항목이유
공원 매핑공식 필터명, 공원명, 좌표, 키워드가 서로 다를 수 있음
시간시작, 종료, 등록, 수정, 수집 시각을 분리해야 함
상태예정, 진행, 종료, 취소, 알 수 없음을 앱에서 쓰는 상태로 맞춤
최신성오래된 성공 데이터를 최신처럼 보이면 안 됨
원문 URL사용자가 출처를 확인할 수 있어야 함
raw payload디버깅에는 필요하지만 앱 응답에 그대로 내리지 않음

공원 매핑은 특히 조심해서 봤다. 한강공원은 이름이 익숙해 보이지만, 출처마다 표현이 조금씩 다르다. “잠원”, “반포”, “반포·잠원”처럼 쓰이는 맥락도 다르다.

앱에서 같은 장소로 다룰지, 다른 장소로 나눌지 먼저 정해야 한다.

이 부분은 자동화만으로 해결되지 않았다. 문자열 유사도나 좌표 거리만 믿으면 사람이 보기엔 당연한 장소가 어긋나기도 하고, 반대로 다른 장소가 한 덩어리로 합쳐지기도 한다.

그래서 공원 매핑은 parser에 붙은 보조 기능이 아니라 앱의 장소 모델 일부로 다뤘다.

수집 결과도 데이터로 남겼다

가져온 결과만 DB에 저장하면 문제가 생겼을 때 볼 수 있는 것이 없다. 수집 과정 자체도 데이터가 되어야 한다.

erDiagram
  DATA_SOURCES ||--o{ INGESTION_RUNS : 보고
  DATA_SOURCES ||--o{ OUTING_SIGNALS : 발행
  DATA_SOURCES ||--o{ OUTING_FACILITIES : 제공
  PARKS ||--o{ OUTING_FACILITIES : 포함
  OUTING_SIGNALS ||--o{ OUTING_SIGNAL_PARK_LINKS : 매핑
  PARKS ||--o{ OUTING_SIGNAL_PARK_LINKS : 수신

ingestion_runs는 어디서 실패했는지 보는 창구다.

  • 언제 성공했는지.
  • 몇 row를 읽었는지.
  • 응답 모양 hash가 바뀌었는지.
  • status distribution이 갑자기 달라졌는지.
  • row count drift가 생겼는지.
  • 어떤 error로 실패했는지.

이 값들이 있어야 “앱이 느리다”와 “출처 응답이 바뀌었다”를 구분할 수 있다. 또 parser를 고친 뒤 실제 수집이 나아졌는지도 확인할 수 있다.

번역은 수집 실패와 분리했다

다국어는 단순 번역 파일 문제가 아니다. 공식 데이터가 한국어 중심이면, 행사명·공지·시설명을 언제 번역하고 언제 원문을 유지할지 정해야 한다.

한강자리에서는 가져온 원문과 앱에 보여줄 표시 문구를 분리한다. 번역 캐시는 화면 표시를 위한 값이고, 원문 출처를 대체하지 않는다. 번역 실패가 수집 실패가 되어서도 안 된다.

공식 데이터라는 말만으로는 부족했다

parser는 화면의 값을 믿을 수 있게 만드는 관문이었다. 어떤 출처를 쓸지 정하지 않고 “공식 데이터 기반”이라고 말하면, 0건과 수집 실패가 같은 빈 화면으로 보일 수 있다.

그래서 수집 성공, row count, schema hash, 최신성을 나중에 볼 수 있는 데이터로 남겼다. raw payload는 사용자 응답에 그대로 내리지 않았고, 공원 매핑과 다국어 표시도 원문 보존과 분리했다.

파서를 고치며 가장 많이 배운 것은 “공식 데이터”라는 말만으로는 믿을 만한 화면이 생기지 않는다는 점이었다. 어떤 출처를 믿고, 실패를 어떻게 기록하고, 0건을 어떻게 설명할지 정해야 화면의 한 줄도 믿을 수 있게 된다.

이미지 확대