포스트

데이터 파이프라인의 단계별 관측과 운영 조회 API

데이터 파이프라인의 단계별 관측과 운영 조회 API

직전 작업에서는 DB 기록과 메시지 발행이 따로 성공하는 문제를 outbox로 다뤘다. 워커는 DB에 job과 발행 의도를 남기고, 발행은 별도 Relay가 NEW event를 SQS로 보내며 PUBLISHED로 바꿨다. 앞선 로컬 장애 주입 측정에서 publisher 경계까지의 유실은 0이었다.

어느 날 콘솔을 열었더니 위에서 아래까지 전부 초록이었다. 발행은 하나도 실패하지 않았는데, 정작 그날 나갔어야 할 설명은 한 건도 나가지 않았다.

PUBLISHED를 처리 완료로 읽고 있었다는 걸 그제서야 인정했다. 발행 성공은 그 메시지가 SQS에 실렸다는 것뿐, 메시지를 받아 업무를 수행하는 consumer가 그 일을 끝냈다는 게 아니었다.

그래서 하나로 뭉쳐 보던 파이프라인을 수집·발행·큐에 쌓인 대기 작업(queue backlog)·consumer 처리·최종 결과 다섯 단계로 갈라 각각의 기록으로 세기 시작했다.

발행 성공(PUBLISHED)과 업무 완료는 다른 사건이었다

그날 콘솔의 첫 화면은 거짓말을 하지 않았다. 수집도 초록, 발행도 초록이었으니까.

문제는 그 초록이 “일이 끝났다”가 아니라 “메시지를 부쳤다”까지만 뜻한다는 걸 화면이 말해주지 않은 것이었다.

삽질 — 전부 초록인데 설명 대기가 65건, 분석이 0이었다

2026-08-03 dev 스냅샷을 뜯어보다 이상한 걸 봤다(당시 관측). 장중 1분 price 세션은 하루치 window 390개를 전부 처리했고(당시 관측 381 VALID, 9 VALID_EMPTY, 무증거 0), price job도 390개, price-analysis-realtime outbox도 390개가 전부 PUBLISHED였다.

여기서 390은 그날 하루치 정상 window 수다. 381 VALID에 9 VALID_EMPTY, 무증거 0이라 빠진 window가 없다는 뜻이고, 수집 390도 발행 390도 전부 이 한 벌을 가리킨다. 두 390이 같은 08-03 날짜를 달고 있어 날짜만으로는 어느 단계 것인지 안 갈린다.

여기까지는 완벽했다. price consumer가 이 분석들을 받아 장중 trigger 65개를 만들었고, 그 trigger를 깨울 outbox event 65개도 전부 SQS 발행에 성공했다.

끊긴 곳은 그 다음이었다. trigger를 받아 실제 설명을 만들 price-explanation-realtime 큐에는 visible message가 65개 쌓여 있는데, 그 큐를 소비할 consumer 서비스가 terraform에도 코드에도 없었다. DB funnel로 세어보니 trigger 65 → observation 0 → explanation 0 → delivery 0이었다.

여기서 outbox의 PUBLISHED가 정확히 뭘 보장하는지가 드러났다. dual-write를 닫으면서 만든 그 상태는 Relay가 event를 SQS로 밀어넣고 PUBLISHED로 바꿨다는 것까지만 참이었다. 큐 반대편에서 누가 그걸 집어 일을 끝냈는지는 PUBLISHED 어디에도 없었다.

편지를 우체통에 넣으면 발송은 끝나지만, 그게 상대가 읽었다는 뜻은 아니다. 그날 65건은 전부 우체통에 잘 들어갔는데, 그 우체통을 비우러 오는 집배원이 아예 없던 셈이었다.

그래서 첫 화면에서 내가 봐야 할 가장 큰 숫자는 수집 성공 390이 아니었다. 설명 대기 65와 분석 0이었다. outbox의 PUBLISHED를 처리 성공으로 칠하지 않는다는 규칙은 여기서 나왔다.

수집 390·발행 390 PUBLISHED·trigger 65가 전부 초록으로 성공한 뒤 price-explanation-realtime 큐에 65가 쌓이고 그 아래 observation·explanation·delivery가 0으로 끊긴 funnel 발행이 전부 초록이어도 큐를 비울 consumer가 없으면 65는 그 자리에서 0으로 끝난다.

수집부터 최종 결과까지 단계별로 집계했다

파이프라인을 하나의 성공/실패로 보던 눈을 버렸다. 수집, 발행, queue backlog, consumer 처리, 최종 결과를 서로 다른 다섯 단계로 갈라, 각 단계가 자기 증거로 서게 했다.

한 숫자로 뭉치면 위쪽의 초록이 아래쪽의 공백을 덮어버린다. 발행 390이라는 사실이, 그중 아무것도 최종 처리되지 않았다는 사실을 가린다.

단계를 갈라두면 초록과 공백이 같은 줄에 나란히 선다. 발행 390 옆에 explanation 0이 보이면, 그 낙차 자체가 경보가 된다.

이렇게 갈라두니 단계마다 다른 대응이 붙었다. queue backlog를 따로 세는 게 특히 값졌는데, 그 뒤 이 큐를 소비할 consumer가 배포되면서 backlog 깊이를 신호로 0~4대까지 자동 확장하게 했다. 설명 한 건에 중앙값 588초가 걸려서, 하루 42~65건이 몰리면 1대로는 6시간 넘게 밀리고 수동 확장은 공유 RDS를 고갈시켰기 때문이다.

집계 대상 — 같은 날의 트리거에서 시작한 결과를 따라간다

다섯 단계를 각자 세다 보면 새 함정이 생겼다. 단계마다 그 단계의 날짜로 오늘 것을 고르면, 서로 다른 날에 시작된 무리가 한 줄에 나란히 서게 됐다.

오늘 발행된 설명 중에는 어제 trigger된 게 섞이고, 오늘 trigger된 것 중 일부는 아직 설명이 안 된다. 각 단계를 자기 날짜로 필터하면 분자와 분모가 서로 다른 작업 집합이 된다.

그래서 facts 응답의 날짜 축을 트리거 하나로만 뒀다. 그날 트리거된 것만 날짜로 고르고, observation·explanation·delivery는 그 트리거의 자손을 LEFT JOIN으로 따라가 셌다. 전 단계를 DISTINCT로 묶어, 테넌트가 둘이면 전달이 설명의 두 배로 부풀어 보이는 잠복 결함도 미리 없앴다.

DB 원장의 실행 상태를 Step Functions 기록과 대조했다

funnel로 갈라 세고 나니 다음 질문이 나왔다. 내 DB 원장이 “이 작업 성공”이라고 적어둔 걸 그대로 믿어도 되나. 내 장부에 “입금 완료”라고 적어둬도 은행 원장과 맞춰보기 전엔 그게 진짜인지 알 수 없는 것과 같았다.

같은 08-03 스냅샷에 답이 있었다. 15:40 market 실행은 AWS Step Functions 정본이 FAILED인데, 내 DB 투영은 16:20이 되도록 RUNNING으로 남아 있었다(당시 관측). 제어면의 진실과 내 원장이 40분 넘게 어긋난 것이다.

어긋나는 건 구조 때문이었다. 실행의 정본은 AWS Step Functions이고, 내 DB 원장은 그 상태를 복사해 저장한 조회용 기록이다. 이렇게 원본 상태를 다른 형태로 옮겨 둔 것을 투영이라고 한다. 둘을 이어주는 Reconciler가 15분 주기로 돌면서 AWS 실행 상태를 읽어 원장에 반영한다.

그러니 원장은 마지막 대조가 돈 시점까지만 최신이다. AWS에서 상태가 바뀌고 다음 대조 tick이 오기 전까지는, 원장이 설계상 뒤처져 있다.

다만 40분 넘게 어긋난 사례의 원인을 15분 주기만으로 설명할 수는 없었다. 정기 갱신 사이의 지연과 그보다 길게 지속된 차이는 나눠 봐야 했다.

삽질 — 15분 주기 상태 갱신의 지연이 거짓 경보가 됐다

그래서 Step Functions를 직접 대조하기로 했다. Reconciler가 DescribeExecution으로 AWS 실행 상태를 가져와 내 원장과 맞춰봤다.

근데 대조하자마자 거짓 경보가 쏟아졌다. 실측한 한 실행은 원장이 RUNNING(10:10:45)인데 Step Functions는 이미 SUCCEEDED(10:13:06)였다.

이건 진짜 불일치가 아니었다. Reconciler가 15분 주기로 도니까, 내 원장이 아직 SUCCEEDED를 반영 못 한 몇 분짜리 투영 지연을, 대조기가 불일치로 오해한 것이었다.

늦게 현상되는 사진 같았다. 셔터는 이미 눌렸는데 인화만 안 끝났을 뿐인데, 그 사이에 “사진이 없다”고 소리치는 격이었다.

지연과 진짜 불일치를 시간으로 가른다 — ledger_updated < aws_stop

정상 지연과 진짜 불일치를 가를 기준이 필요했다. 시각을 쓰기로 했다.

대조할 때 AWS 실행이 끝난 시각(aws_stop)을 같이 실었다. 내 원장이 갱신된 시각이 aws_stop보다 이르면(ledger_updated < aws_stop) 아직 투영이 안 따라온 지연이고, 그게 아니면 진짜 불일치였다.

여기에 하나 더 정했다. Step Functions 조회가 AccessDenied로 막히면, 일부만 본 걸 관측으로 접지 않고 제어면 축 전체를 비웠다. 절반만 본 상태를 “봤다”로 적는 순간 그 공백이 정상으로 둔갑하기 때문이다.

하나의 시간축 위에 aws_stop(SUCCEEDED 10:13:06) 세로 임계선과 그 왼쪽의 ledger_updated(RUNNING 10:10:45)를 놓고, 임계선 왼쪽은 지연·오른쪽은 진짜 불일치로 가른 타임라인 원장 갱신이 aws_stop보다 이르면 아직 안 따라온 지연이고 늦으면 진짜 불일치다.

과거일 재수집이 실시간 분석을 발행하지 않도록 분리했다

관측을 촘촘히 하다 보니, 관측만으로 안 끝나는 사건이 하나 걸렸다. 발행 경로 자체가 실시간과 과거를 구분하지 않는 구조라, 며칠 전 데이터가 오늘의 실시간 분석으로 새어 나갔다.

삽질 — 과거일 재수집이 오늘의 실시간 분석 390건을 깨웠다

가격 window를 확정하는 commit_price_window는 조건 없이 outbox event를 냈다. 그게 실시간 커밋이든 과거일 백필 커밋이든 가리지 않았다.

그래서 과거일을 다시 수집하는 backfill 커밋이 Relay를 타고 오늘의 price-analysis-realtime으로 나갔다. 며칠 전 봉을 기준으로 오늘의 트리거와 LLM 설명이 돌아버린 것이다.

2026-08-03 재수집에서 이게 실제로 터졌다. 실시간 분석 event 390건이 잘못 발행됐고, 그걸 손으로 DEAD 격리했다(당시 관측, 커밋 본문 기준).

며칠 지난 신문을 오늘 아침 뉴스로 다시 돌린 셈이었다. 데이터는 과거인데 그걸 처리하는 경로는 “지금”이라고 믿었다.

session_date — 과거일에는 산출물만 남기고 실시간 발행을 막는다

처음엔 벤더나 소스 종류로 걸러볼까 했지만, 문제의 축은 그게 아니었다. 실시간이냐 과거냐를 가르는 건 결국 날짜였다.

그래서 outbox를 낼지 말지를 session_date로 판정하게 바꿨다. 오늘 세션이면 실시간 event를 내고, 과거일 세션이면 내지 않는다.

한 가지는 일부러 안전하게 실패하도록 뒀다. is_backfill에 기본값을 두지 않아서, 어디선가 이 값을 배선하는 걸 빠뜨리면 조용히 통과하는 대신 TypeError로 죽게 했다. 조용한 오발행보다 시끄러운 중단이 낫다고 봤다.

대신 window와 job 원장은 백필에서도 그대로 남겼다. 백필이 무엇을 수집했는지는 기록으로 남기되, 실시간 판정 event만 만들지 않는 것이다. 이 경계는 변이 5종을 심어 전부 잡히는 걸 확인했고, 2,183개 테스트가 통과했다.

위쪽은 과거일 커밋이 commit_price_window를 거쳐 오늘의 price-analysis-realtime로 새는 수정 전 경로, 아래쪽은 session_date 게이트가 실시간 발행을 차단하고 window·job 원장만 남기는 수정 후 경로 실시간이냐 과거냐를 벤더가 아니라 session_date로 가르자 과거일 커밋은 원장만 남고 실시간 발행은 막혔다.

빈 결과와 관측 실패를 구분했다

관측 실패나 빈 결과는 보통 0이나 빈 배열([])로 접는다. 그게 편하지만, 그 순간 “봤는데 0이었다”와 “아예 못 봤다”가 같은 칸에 들어간다.

두 개는 다른 사건이다. 앞은 관측한 결과이고 뒤는 계측 공백인데, 둘을 0으로 뭉치면 공백이 결과처럼 보인다.

부재 표기 여섯 갈래 — 0 / — / 관측 불가 / 계측 없음 / VALID_EMPTY / MISSING

그래서 부재를 한 표기로 접지 않고 여섯 갈래로 나눴다.

표기뜻
0관측 결과가 실제로 0
—집계가 없음
관측 불가접근 채널이 없음
계측 없음기록 자체가 없음
VALID_EMPTY실행됐고 결과가 0 (돌았다는 증거)
MISSING실행 증거 또는 기대 데이터가 없음 (증거 부재)

핵심은 VALID_EMPTY와 MISSING의 차이였다. 앞은 “돌았고 결과가 비었다”는 증거이고, 뒤는 “돌았다는 증거 자체가 없다”는 부재다. 이 둘을 합산하는 순간 계측 구멍이 정상 0으로 둔갑한다.

없는 분모로 성공률을 만들지 않는다 — TagNews 64는 BigKinds 4,000의 분자가 아니다

숫자를 나눌 때도 같은 원칙이 걸렸다. 분모가 없는데 비율을 만들면 그 비율은 거짓말이 된다.

뉴스 수집이 딱 그랬다. BigKinds 최신 수집은 status success에 records 4,000이었지만 MAX_PAGES(40) 절단으로 INCOMPLETE였고, 그날 TagNews로 새로 태깅된 건 64건이었다.

언뜻 64/4,000 같은 비율을 만들고 싶어지지만, 64는 4,000의 분자가 아니다. 실제 분해는 read 9,532 / already-tagged 3,568 / no-mention 5,900 / newly-tagged 64였고, 64는 그중 마지막 갈래일 뿐이었다.

load counter도 같은 함정이었다. LOAD_ETF_HOLDINGS의 records_out 10,289에 failed 2,415가 찍혀 있어도, 이건 당일 raw 906행의 분자·분모가 아니라 누적 재판정 범위였다. “2,415 / 12,704 = 일일 유실률” 같은 비율을 만들지 않았다.

조회 실패는 [] 대신 null — 일부 큐가 안 보이면 큐 축 전체를 비운다

빈 배열도 거짓말을 할 수 있었다. SQS 큐 깊이를 관측할 때 여러 큐 중 하나라도 조회에 실패하면, 나머지만 담아 부분 결과를 내보내기 쉬웠다.

그래서 일부 큐가 안 보이면 큐 축 전체를 null로 접었다. SqsQueueControlPlane은 한 큐라도 조회에 실패하면 부분 성공을 전체 관측처럼 내보내지 않고 축을 통째로 비웠다.

[]로 보내지 않는 게 핵심이었다. []는 “큐가 0개”라는 거짓말이고, 규칙 엔진이 그걸 통과시키면 평가됨·위반 0, 즉 “봤고 괜찮다”가 된다. null이면 “못 돎”으로 남아, 공백이 정상으로 새지 않는다.

지금은 이 표기 규칙과 클라이언트 판정으로 충분하다. 원장이 제대로 된 분모를 줄 때에야 진행률이나 완전성 집계로 넘어갈 수 있다.

운영 조회 API는 사실을 제공하고 클라이언트가 상태를 판정했다

운영 상태 API는 보통 서버가 상태를 평가해서 “정상 / 경고 / 오류”를 내려준다. 화면은 그 판정을 그대로 그리면 되니 편하다.

이번엔 반대로 갔다. 엔드포인트는 사실만 주고, 정상인지 아닌지 판정은 프론트의 순수 함수가 하게 했다(ADR-0050).

조회 대상 — 실행 상태·1분 원장·데이터 최신성·큐 깊이

Java·Spring Boot로 짠 조회 API가 주는 건 넷이다. AWS 제어면 실행 상태, 1분 원장, 데이터가 얼마나 최신인지, 그리고 SQS 큐 깊이.

콘솔 진입점은 GET /api/v1/console/facts다. ConsoleController의 javadoc에 “응답은 사실이고 위반 목록이 아니다”라고 못박아뒀다. date를 안 주면 원장이 아는 가장 최근 날을 주되, 그게 거래일이라는 보장은 하지 않는다.

실시간 1분 축은 /api/v1/sources/minute으로 따로 뒀다. JdbcMinuteStatusRepository가 session의 phase와 expected_window_count·processed_through, window 판정 카운트, 무증거 술어, gaps, price job, news job을 준다.

무증거를 세는 SQL과 그 근거 목록을 뽑는 SQL은 같은 술어를 쓰게 맞췄다. 둘이 어긋나면 집계 수와 근거 목록이 서로 안 맞기 때문이다.

조회 축주는 것
제어면 실행Step Functions 실행 상태·aws_stop
1분 원장session·window·job·gaps
freshnessstck_bsop_date 증거
큐 깊이visible·in-flight·DLQ

왼쪽 서버가 제어면 실행·1분 원장·freshness·큐 깊이 네 축의 사실만 내보내고, 오른쪽 클라이언트 순수 함수가 19룰로 판정하며, outbox 원장은 회색 다음 단계로 축 밖에 놓인 구조도 서버는 사실만 넘기고 정상·경고·오류 판정은 클라이언트 순수 함수가 하며 outbox 원장은 아직 이 축 밖이다.

클라이언트 판정 — 규칙을 한곳에서 관리한다

판정을 클라이언트에 둔 이유는 기존 로직의 위치와 규칙 변경 주기 때문이었다.

첫째, 실시간 축이 이미 클라이언트에서 평가 중이었다. 여기서 콘솔 판정만 서버로 올리면 같은 시스템의 판정 로직이 서버와 클라 두 곳으로 갈라진다.

둘째, 규칙이 아직 흔들리는 단계였다. 콘솔 규칙 엔진은 화면 구조가 바뀔 때마다 같이 흔들려서 9라운드 동안 16룰에서 19룰로 늘었다. 규칙이 이렇게 자주 바뀌는데 서버에 두면, 룰 문구 한 줄 고칠 때마다 배포가 따라붙는다.

원래 이 19룰의 입력은 앱 번들 안에 박아둔 정적 facts-snapshot.json(2026-08-03 스냅샷)이었다. ADR-0050에서 이걸 실제 응답으로 교체해, 화면이 박제된 스냅샷이 아니라 그날의 사실을 보고 판정하게 했다.

규칙이 화면 구조와 함께 자리를 잡으면 그때는 평가를 서버로 옮겨도 된다. 지금은 아니었다.

freshness는 완전성과 다른 축 — 완전성 VALID여도 STALE일 수 있다

데이터가 얼마나 최신인지는 완전성과 아예 다른 축이었다. window가 전부 VALID로 채워졌어도 그 안의 데이터는 며칠 전 것일 수 있고, 반대로 INCOMPLETE여도 있는 데이터는 최신일 수 있다.

그래서 freshness를 요청일이나 실행일로 추정하지 않았다. KIS 응답에 실제로 찍혀 오는 영업일 증거(stck_bsop_date)로만 판정하게 했다(ADR-0043). 실행일이나 파티션 날짜로 넘겨짚으면 오래된 데이터에 거짓 FRESH가 붙는다.

증거가 없거나 손상됐으면 FRESH도 STALE도 아닌 UNKNOWN으로 보존했다. KRX holdings가 그 극단이었다. 이 소스는 데이터가 언제 기준인지 말해주는 독립 as-of 증거 자체가 없어서, 어쩌다 한 번이 아니라 언제 조회해도 판정할 근거가 없다.

그래서 실행일 같은 걸로 추정해 채우지 않고 설계상 영구 UNKNOWN(NULL)으로 못박았다(ADR-0043). 모른다는 걸 모른다고 적는 게, 아는 척하는 것보다 안전했다.

마무리

이번에 받아들인 건, 발행 성공(PUBLISHED)이 완료의 증거가 아니라는 사실이었다. 그건 메시지를 SQS에 실었다는 것까지만 참이고, 큐 반대편에서 그 일이 끝났는지는 다른 축으로 따로 세야 했다.

그래서 파이프라인을 수집·발행·backlog·처리·결과 다섯 단계로 갈라 각자 증거로 세고, 모르는 것을 0으로 칠하지 않는 표기와 사실만 주는 조회 API로 닫았다.

가장 인상 깊었던 건 전부 초록인 첫 화면이 거짓말을 할 수 있다는 순간이었다. 그날 가장 큰 숫자는 수집 성공 390이 아니라 설명 대기 65와 분석 0이었다.

끝까지 관통한 판단은 하나였다. 관측은 “봤고 괜찮다”와 “못 봤다”를 절대 같은 칸에 그리지 않는 데서 시작한다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.