감사 로그는 "최선 노력(best-effort)"이 아닌 "최대한 보장(at-least-once)"이어야 합니다. 디스크가 잠깐 꽉 찼거나, 서버가 재시작됐거나, I/O 에러가 났을 때도 이벤트가 조용히 사라지면 안 됩니다. 이 챕터는 Quipu-Log가 어떻게 그 약속을 지키는지 살펴봅니다.
쓰기 실패 → 재시도(지수 백오프+지터) → 모두 실패 시 DLQ에 디스크 파킹 → 나중에 redrive로 재생. 이벤트는 최소한 DLQ에는 남습니다.
재시도와 지수 백오프
writer 스레드는 이벤트 하나를 쓰는 데 실패하면 즉시 포기하지 않습니다. PipelineConfig에 설정된 max_retries(기본 3회)만큼 재시도하되, 시도 사이에 잠깐 기다립니다. 기다림 시간이 매 시도마다 늘어나는 방식이 지수 백오프(exponential backoff)입니다.
클라이언트 사이드의 재시도 로직은 더 정교합니다. Backoff 구조체가 지수 증가에 완전 지터(full jitter)를 더합니다.
crates/quipu-client/src/retry.rspub fn delay(&self, attempt: u32) -> Option<Duration> {
if attempt == 0 || attempt > self.max_retries { return None; }
let exp = self.base.as_secs_f64() * self.multiplier.powi((attempt - 1) as i32);
let capped = exp.min(self.max_delay.as_secs_f64());
let jittered = rand::Rng::gen_range(&mut rand::thread_rng(), 0.0..=capped);
Some(Duration::from_secs_f64(jittered))
}
지터가 왜 필요할까요? 서버 하나가 다운됐다 돌아올 때, 큐에 이벤트를 쌓아둔 수백 개의 클라이언트가 동시에 재시도를 시작하면 서버에 폭풍이 몰립니다(thundering herd). 대기 시간에 무작위성을 더하면 각 클라이언트가 다른 시점에 재시도해 부하가 분산됩니다.
지진 직후 모든 사람이 동시에 119에 전화하면 교환원이 마비됩니다. "1~30초 후 무작위로 전화하라"고 했을 때 부하가 고르게 분산되는 것이 완전 지터입니다.
멱등키: 재시도가 중복 기록을 만들지 않게
재시도에는 함정이 있습니다. 서버가 이벤트를 저장했는데 응답이 오다가 끊겼다면, 클라이언트는 실패로 보고 재시도합니다. 그러면 같은 이벤트가 두 번 기록되죠. 이를 막는 게 멱등키(idempotency key)입니다.
crates/quipu-client/src/retry.rs// 이벤트당 한 번 생성 → 모든 재시도에 동일 키 사용
pub fn new_idempotency_key() -> String {
// UUIDv4 생성 (RFC 4122 version 4, variant 1)
// 같은 키를 가진 두 번째 요청은 서버가 중복으로 인식해 무시
}
키는 이벤트가 처음 만들어질 때 생성하고, 클라이언트 사이드 스풀에도 함께 저장합니다. 프로세스가 재시작돼도 키가 살아 있어 중복을 막을 수 있습니다.
DLQ: 재시도도 실패하면
모든 재시도가 소진됐다면? 이벤트를 그냥 버리지 않고 Dead-Letter Queue(DLQ)에 파킹합니다. DLQ는 <store root>/dlq/ 아래의 또 다른 append-only 세그먼트 파일입니다 — 메인 로그와 별도 저장소이므로 메인 디스크가 꽉 차도(ENOSPC) DLQ 쓰기를 시도하고, 프로세스가 죽어도 파일로 남습니다.
redrive: DLQ 재생
DLQ에 쌓인 이벤트는 handle.redrive_dlq(&admin_role)로 재생합니다. redrive는 충돌 안전하게 설계돼 있습니다. 재생 결과(성공한 것, 또 실패한 것)를 staging 디렉토리에 먼저 쓰고, 모두 fsync한 뒤에 기존 DLQ를 삭제하고 staging을 rename합니다. 중간에 프로세스가 죽어도 이미 성공한 이벤트가 다시 재생될 수 있습니다(at-least-once) — 없어지지는 않습니다.
at-least-once이므로 redrive 후 드물게 중복 레코드가 생길 수 있습니다. 감사 로그에서는 "없어지는 것"이 "두 번 기록되는 것"보다 훨씬 나쁘므로, at-least-once가 올바른 트레이드오프입니다.
마지막 방어선: fallback 훅
DLQ 쓰기마저 실패하면(디스크가 완전히 꽉 찬 경우 등) 이벤트는 영구 유실됩니다. 그 경우 FallbackFn 훅을 호출합니다. 파이프라인 시작 시 등록한 클로저인데, 여기에 경보 발송·stderr 출력 등을 연결할 수 있습니다.
examples/axum-demo/src/main.rsAuditPipeline::start(
store, root,
PermissionPolicy::allow_all(),
PipelineConfig::default(),
Some(Arc::new(|event, err| {
eprintln!("AUDIT FALLBACK: {} {} failed: {err}", event.method, event.url);
})),
)?;
① 지수 백오프에 지터를 더하는 이유를 "thundering herd" 키워드로 설명해 보세요.
② 멱등키가 이벤트당 한 번 생성되고, 재시도마다 같은 키를 쓰는 이유는?
③ redrive가 "기존 DLQ 삭제 → staging rename" 순서로 처리하는 이유는 무엇인가요?