HTTP 요청 하나를 처리하는 데 수십 마이크로초가 걸린다면, 감사 로그를 디스크에 쓰는 데 수백 마이크로초를 허용할 수는 없습니다. 이 챕터에서는 Quipu-Log가 어떻게 emit을 "큐에 넣고 즉시 반환"하는 논블로킹 경로로 만드는지 살펴봅니다.
emit은 바운디드 채널에 이벤트를 밀어 넣고 즉시 돌아옵니다. 실제 디스크 쓰기는 전용 writer 스레드가 맡습니다.
프로듀서-컨슈머 패턴
당신이 아는 큐(Queue)와 완전히 같은 구조입니다. 요청을 처리하는 프로듀서(앱 스레드들)와, 파일에 쓰는 컨슈머(writer 스레드) 사이에 채널 하나가 놓입니다. 프로듀서는 이벤트를 채널에 넣는 것으로 책임이 끝나고, 컨슈머는 채널에서 꺼내 순차적으로 디스크에 씁니다.
Rust 표준 라이브러리의 sync_channel이 이 채널입니다. 바운디드(bounded)라는 말은 채널에 들어갈 수 있는 이벤트 수에 상한(기본값 4096)이 있다는 뜻입니다. 상한이 없으면 메모리를 무제한으로 먹게 되니까요.
핵심 코드: emit이 하는 일
AuditHandle은 앱 코드가 직접 쥐는 클론 가능한 핸들입니다. emit_unchecked를 보면 실제로 어떻게 짧게 끝나는지 확인할 수 있습니다.
crates/quipu-middleware/src/pipeline.rspub fn emit_unchecked(&self, event: AuditEvent) -> Result<(), MiddlewareError> {
if self.reject_emit_when_disk_full && self.health.disk_full() { ... }
self.metrics.queue_inc();
match self.tx.try_send(Command::Emit(Box::new(event))) {
Ok(()) => Ok(()),
Err(TrySendError::Full(Command::Emit(ev))) => {
self.metrics.rejected_queue_full();
Err(MiddlewareError::QueueFull(ev))
}
Err(_) => Err(MiddlewareError::WorkerGone),
}
}
try_send는 채널이 꽉 찼을 때 블로킹하지 않고 즉시 Err를 돌려줍니다. 그러면 QueueFull 에러가 나고, 이벤트는 호출자에게 다시 돌아갑니다(호출자가 버리거나 별도 폴백을 쓸 수 있게). 이것이 백프레셔(backpressure) — "나 지금 너무 바빠, 더 이상 받을 수 없어"를 알려주는 메커니즘입니다.
레스토랑 대기표 번호를 받는 것을 생각해 보세요. 손님(프로듀서)은 번호를 받는 순간 기다림 없이 자리를 찾아 앉고, 주방(writer 스레드)이 순서대로 처리합니다. 대기표가 다 떨어지면(큐 가득) 더 이상 발급하지 않는 것이 QueueFull입니다.
AuditPipeline과 AuditHandle의 관계
AuditPipeline은 writer 스레드를 소유합니다. 서버가 살아 있는 동안 한 개만 존재하며, 종료 시 shutdown()을 불러 큐에 남은 이벤트를 모두 비우고 스레드를 정리합니다. AuditHandle은 파이프라인에서 handle()로 얻는 경량 클론입니다. Arc<SyncSender>를 감싸고 있어서 복사 비용이 거의 없습니다. 필요한 모든 곳에 복사해서 전달하면 됩니다.
crates/quipu-middleware/src/pipeline.rs// 파이프라인 시작 (서버 초기화 때 한 번)
let pipeline = AuditPipeline::start(store, root, permissions, cfg, fallback)?;
let handle = pipeline.handle(); // 클론 가능한 AuditHandle
// handle을 axum State, Arc 등으로 어디든 전달
handle.emit(&role, event)?; // 논블로킹
quipu-core의 AuditStore는 단일 writer 원칙(13장)을 따릅니다. 멀티스레드 서버에서 여러 요청 스레드가 직접 AuditStore를 호출하면 락 경합 또는 정합성 문제가 생깁니다. 채널로 단일 스레드에 직렬화하면 스토어는 단일 writer 제약을 그대로 유지하면서 앱은 완전 논블로킹이 됩니다.
① try_send가 아니라 send(블로킹 전송)를 썼다면 어떤 문제가 생길까요?
② QueueFull 에러 시 이벤트가 호출자에게 돌아오는 이유는 무엇인가요? 단순히 버리지 않는 이유는?
③ AuditHandle이 Clone을 구현하도록 설계된 이유는?