cron 대신 fixedDelay — 밀려도 놓치지 않는 소비 루프
봉인된 버킷을 소비하는 스케줄을 cron으로 짜면 작업이 5분을 넘길 때 겹칩니다. cron 대신 fixedDelay로, 가장 오래된 버킷을 한 번에 하나씩 비워 나가는 소비 루프를 설계하고, 단계별 로그로 관측성까지 챙긴 기록입니다.
지난 글에서 변경 로그를 5분 버킷으로 끊고, 봉인된 것만 소비하기로 했습니다. 이번엔 그 소비를 언제, 어떻게 반복 실행할지입니다. 가장 흔한 답은 cron이지만, 여기선 일부러 다른 길로 갔습니다.
cron의 함정: 겹침
"5분마다 버킷 하나 소비" — cron으로 짜면 딱 이렇습니다. */5 * * * *. 깔끔해 보이죠.
문제는 소비가 5분을 넘길 때입니다. 버킷에 변경이 많거나 LLM 요약이 느리면, 한 번 도는 데 6~7분이 걸릴 수 있습니다. 그런데 cron은 시계만 봅니다 — 이전 작업이 끝났든 말든 5분이 되면 또 실행합니다.
- 이전 소비가 아직 도는데 다음 소비가 겹쳐 뜬다.
- 둘이 같은 버킷을 집어 중복 처리하거나, 서로 밟는다.
- 한 번 밀리기 시작하면 실행이 쌓여 상황이 더 나빠지는 악순환에 빠진다.
주기를 고정하는 스케줄은, 작업 시간이 그 주기를 넘는 순간 무너집니다. 그리고 LLM 요약은 언제 오래 걸릴지 예측하기 어려웠습니다.
아이디어를 구체화하다
여기서 바로 코드로 뛰어들지 않고 한 발 물러섰습니다. "이미 상시 돌고 있는 서비스가 있으니, 거기에 반복을 얹어 소비를 돌리면 어떨까?" 하는 생각을 다듬었습니다. 핵심은 밀리더라도 뒤로 밀릴 뿐, 중복되거나 놓치지 않게 만드는 것이었습니다. 이 목표를 문장으로 명확히 적고 나니, 필요한 스케줄 방식이 저절로 정해졌습니다.
해법: fixedDelay
그래서 fixedDelay 방식으로 갔습니다. 정해진 시각에 실행하는 게 아니라, 한 번 소비가 끝나면 → 짧게(예: 10초) 쉬고 → 다시 소비를 반복하는 겁니다.
차이가 핵심입니다.
- cron: "5분마다" — 이전 작업과 무관하게 시계로 실행 (겹칠 수 있음)
- fixedDelay: "끝나고 10초 뒤" — 이전 작업이 끝나야 다음이 시작 (절대 안 겹침)
이러면 무거운 버킷이 걸려 오래 걸려도, 그저 다음이 뒤로 밀릴 뿐 겹치거나 놓치지 않습니다. 매 회차는 항상 가장 오래된 봉인 버킷 하나를 집어 비웁니다. 밀린 만큼 순서대로 따라잡을 뿐, 순서가 꼬이지 않습니다. 대기가 10초로 짧으니 평소엔 거의 실시간처럼 따라붙고, 몰릴 땐 알아서 큐처럼 줄을 섭니다.
한 버킷 안에서: ref당 LLM 한 번
버킷 하나를 집으면, 그 안의 변경들에서 source·ref를 모읍니다. 그리고 ref 하나당 LLM을 한 번 돌립니다.
같은 버킷 안에서 한 문서(ref)가 여러 번 바뀌었더라도, LLM은 그 ref에 대해 한 번만 부릅니다. 어차피 최신 상태를 원문에서 다시 읽어 요약하니, 중간 변경마다 부를 이유가 없습니다. 호출 수가 줄어 견고하고 빠릅니다. 처음엔 변경 건마다 부르던 걸, "ref당 1회"로 견고화한 것이 체감 차이가 컸습니다.
그럼 이전에 처리한 문서가 또 바뀌면?
당연한 의문이 생깁니다. PROJ-1234를 10:15 버킷에서 이미 소비했는데, 10:25에 또 수정하면 스킵될까요?
답은 다시 소비합니다(스킵 아님). 10:25 변경은 새 버킷에 담기고, 그 버킷을 소비할 때 PROJ-1234를 최신 상태로 다시 요약해 위키를 갱신합니다. 지식 베이스는 "가장 최근 사실"을 반영해야 하니, 재요약은 낭비가 아니라 정상 동작입니다. 버킷 단위로 최신을 덮어쓰는 셈이죠. 덕분에 "한 번 처리한 건 영영 안 본다"는 취약한 가정 없이도, 늘 최신이 유지됩니다. 같은 문서가 며칠에 걸쳐 여러 번 바뀌더라도, 각 버킷을 처리할 때마다 그 시점의 최신으로 덮어쓰니 결과는 언제나 한 방향으로 수렴합니다. 순서가 밀려 뒤늦게 처리돼도 최종 상태는 달라지지 않습니다.
보이지 않으면 못 고친다 — 단계별 로그
한 가지 실전 교훈이 있었습니다. 소비가 오래 걸리는데 완료 로그만 찍으니, 그동안 화면에 아무것도 안 올라와서 "멈춘 건가, 도는 건가"를 알 수 없었습니다.
그래서 로그를 단계별로 찍게 고쳤습니다 — 어떤 버킷을 집었는지, 지금 몇 번째 ref를 원문 조회 중인지, 요약 중인지, 저장 중인지. 진행 상태가 흐르니 오래 걸려도 불안하지 않고, 어디서 시간이 새는지도 눈에 보였습니다. 지난 시즌에서 vLLM 튜닝을 하며 배운 교훈 — 관측할 수 없으면 개선할 수 없다 — 이 여기서도 그대로였습니다.
상주화
이 루프는 결국 **"봉인된 가장 오래된 버킷을 집어 소비하고, 끝나면 잠깐 쉬고 반복"**하는 견고한 스크립트 하나로 수렴합니다. 개발 중엔 이 로직을 수동으로 1회 실행해 결과를 확인하고, 충분히 다듬은 뒤 시스템 서비스로 등록했습니다. 서비스로 올리면 끝입니다 — 죽으면 되살아나고, 밀리면 따라잡고, 겹치지 않습니다.
정리
- cron은 작업 시간이 주기를 넘으면 겹친다 → 위험.
- fixedDelay: 끝나야 다음 시작 → 밀려도 겹치지 않고 놓치지 않음.
- 매 회차 가장 오래된 봉인 버킷 하나만 처리, 버킷 안에선 ref당 LLM 1회.
- 재수정은 다음 버킷에서 재요약해 늘 최신 유지.
- 단계별 로그로 긴 작업의 진행을 드러낸다.
이제 소비 루프의 뼈대가 섰습니다. 마지막으로, 실제 요약을 맡을 에이전트를 어떻게 붙였는지 — 다음 글에서 스킬과 스키마 주입으로 마무리합니다.