API 밖에서 도는 worker와 CronJob
수집, 예측, 푸시, 기준 데이터 동기화를 사용자 요청과 다른 생명주기로 처리했다
한강자리 백엔드는 사용자가 부르는 API와 뒤에서 도는 worker를 따로 띄운다. 코드는 같지만 맡은 일이 다르다.
이렇게 나눈 이유는 API를 외부 데이터가 느려지거나 실패하는 일에서 떼어놓기 위해서다. 사용자가 앱을 열면 API는 바로 답해야 한다. 반면 공공데이터 수집, 예측 계산, 푸시 보낼 대상 고르기는 느릴 수 있고 실패할 수 있으며 도는 간격도 다르다.
처음에는 백엔드 하나에 scheduler를 붙이는 방식도 가능해 보인다. 실제로 작은 앱에서는 그렇게 시작하기 쉽다. 하지만 운영을 시작하면 사용자 요청과 수집 작업은 다르게 움직여야 한다는 사실이 금방 드러난다. API는 짧은 요청에 바로 답해야 하고, worker는 실패해도 다시 시도하고 흔적을 남겨야 한다.
한강자리의 worker를 나눈 이유는 규모를 키우기 위해서라기보다, 실패를 작게 가두기 위해서였다. 주차 상태 확인이 잠시 실패해도 API는 마지막으로 확인한 값을 내려줄 수 있어야 하고, push outbox가 밀려도 home-summary가 같이 느려지면 안 된다.
API 밖에서 미리 하는 일
지금 계속 떠 있는 worker는 네 종류다. 이들은 사용자가 기다리는 API 호출 밖에서 값을 먼저 만들어 둔다.
flowchart TB Scheduler["워커별 APScheduler"] --> Parking["worker-parking\n상태 폴링"] Scheduler --> Outing["worker-outing\n행사 / 공지 / 도시 맥락 / 번역"] Scheduler --> Forecast["worker-forecast\n예측 생성 / 첫 화면 워밍업 / 메트릭 집계"] Scheduler --> Push["worker-push\n후보 / 선호 인덱스 / outbox / 유지보수"] Parking --> PG["Postgres"] Parking --> Redis["Redis"] Outing --> PG Forecast --> PG Forecast --> Redis Push --> PG Push --> Redis Push --> APNs["APNs"]
가끔 기준 데이터를 맞추거나 값이 맞는지 확인하는 일은 CronJob 쪽으로 따로 본다. 상시 polling과 기준 데이터 sync를 같은 그림에 넣으면, 무엇이 자주 돌고 무엇이 드물게 도는지 흐려진다.
flowchart LR Cron["K8s CronJob"] --> Master["주차 기준 데이터 동기화"] Cron --> Facility["시설 동기화"] Cron --> Transit["대중교통 데이터셋 동기화"] Cron --> Backtest["예측 백테스트"] Master --> PG Facility --> PG Transit --> PG Backtest --> PG
헷갈리기 쉬운 점도 있다. worker-parking이라는 이름만 보면 master sync와 status polling이 모두 상시 scheduler에 있는 것처럼 보일 수 있다. 현재 코드에서 상시 scheduler에 등록된 것은
status polling이다. parking master sync는 별도 job entrypoint와 K8s CronJob 쪽 일정으로 분리되어 있다.
도는 간격과 맡은 일을 나눴다
코드에서는 일을 다음처럼 나눈다.
| worker | 주요 작업 |
|---|---|
| parking | status polling |
| outing | event sync, notice sync, realtime context sync, translation sync |
| forecast | forecast generation, home-summary precompute, metric rollup |
| push | candidate build, preference stream sync, preference index rebuild, shadow compare, outbox drain, maintenance |
| cronjob | parking master sync, facility sync, transit dataset sync, forecast backtest |
이렇게 나누면 한쪽 장애가 다른 화면까지 끌고 가지 않는다. 주차 데이터를 가져오는 쪽이 잠시 실패해도 API는 마지막으로 확인한 상태를 내려줄 수 있다. 푸시 발송이 지연되어도 주차 화면이 값을 읽는 쪽과는 떨어져 있다.
이름은 장애를 볼 때의 첫 단서이기도 하다. worker-parking이 빨간색이면 주차 상태 최신성부터 보고, worker-push가 밀리면 outbox와 delivery attempt를 본다. 하나의 worker에 모든 일이
들어 있으면 어디부터 봐야 할지 늦어진다.
같은 일이 두 번 돌지 않게 막았다
주차 상태 확인을 단순화하면 다음과 같다.
sequenceDiagram
autonumber
participant Scheduler as APScheduler
participant Job as 상태 폴링 작업
participant Lock as DB 잠금
participant Source as 주차 API
participant PG as Postgres
participant Redis as 상태 캐시
participant Fact as 알림 fact
Scheduler->>Job: 정해진 간격에 실행
Job->>Lock: 중복 실행 잠금 시도
alt 잠금 획득
Job->>Source: 주차 상태 원천 데이터 요청
Source-->>Job: 원천 응답 행 반환
Job->>PG: 상태 스냅샷과 수집 기록 저장
Job->>Redis: 최신 주차 상태 캐시 갱신
Job->>Fact: 변화가 있으면 알림 후보 기록
else 중복 실행
Job-->>Scheduler: 이번 실행 건너뛰기
end
핵심은 잠금과 실행 기록이다. 같은 일이 동시에 두 번 돌면 중복 row, 중복 알림, cache 경합이 생길 수 있다. 그래서 scheduler의 max_instances=1만 믿지 않고, DB transaction advisory lock을
같이 사용한다.
데이터마다 얼마나 자주 보러 갈지 적었다
한강자리에는 데이터를 가져오는 곳마다 갱신 주기를 적어 둔 schedule catalog가 있다. 여기에는 출처 ID, job ID, owner, interval, stale로 볼 시간, cronjob 이름 같은 정보가 들어간다.
이 파일은 운영자가 보는 문서이면서, 앱이 사용자에게 어떤 최신성을 말할 수 있는지 정하는 문서이기도 하다. 가져오는 곳이 늘어날수록 “어떤 데이터가 얼마나 자주 갱신되는가”를 코드와 문서에서 함께 따라가야 한다.
특히 공공데이터 앱에서는 각 데이터를 얼마나 자주 보러 가는지가 곧 사용자에게 말할 수 있는 최신성이다. 30초 단위로 확인하는 값과 하루 한 번 확인하는 값을 같은 “최신 정보”처럼 말하면 안 된다. catalog가 있으면 API의 최신성 표시와 운영 알림이 같은 숫자를 본다.
flowchart LR Catalog["수집 계획표"] --> SchedulerJobs["APScheduler 작업"] Catalog --> CronJobs["K8s CronJob"] SchedulerJobs --> Ingestion["수집 유스케이스"] CronJobs --> Ingestion Ingestion --> Runs["ingestion_runs\n성공 / row_count / schema_hash"] Runs --> Health["출처 상태와 최신성"] Health --> API["나들이/home-summary 최신성"]
작업을 만들 때 지킨 것
- API startup hook에 ingestion을 넣지 않는다.
- 작업은 가능한 idempotent하게 만든다.
- 데이터를 가져오는 곳마다 success, failure, row count, schema hash를 남긴다.
- 중복 실행은 scheduler 설정과 DB lock으로 막는다.
- 푸시 발송은 outbox claim과 delivery attempt를 보고 추적할 수 있게 만든다.
- 데이터를 가져오는 곳의 실패는 앱 전체 장애가 아니라 해당 영역의 최신성으로 드러낸다.
API와 worker를 나눈 뒤 남은 것
작은 서비스라도 worker를 빨리 나눠 두면 운영이 쉬워진다. 특히 공공데이터 앱에서는 데이터를 가져오는 곳이 언제든 느려지거나 실패할 수 있다.
API는 마지막으로 확인한 값을 빠르게 돌려준다. Worker는 그 값을 계속 새로 확인한다. 둘을 같은 실행 생명주기에 묶지 않는 편이 더 안정적이었다.