Quipu-Log 서버는 단일 프로세스입니다. 그 덕분에 파일 락 하나, 테이블당 끊기지 않는 해시 체인 하나로 변조 증명이 단순해졌죠. 하지만 그 대가가 있습니다 — 데몬이 내려가 있는 동안 감사 기록이 어떻게 될까요? 이 챕터는 그 "단일 장애점" 문제를, 서버 쪽이 아니라 클라이언트 쪽에서 해결하는 설계를 다룹니다.
데몬이 내려가도 감사 기록을 잃지 않으려면, 서버를 복잡하게 만드는 대신 클라이언트가 디스크에 버텨두고 나중에 재전송하게 합니다. "다운 시 유실"이 "다운 시 지연"으로 바뀝니다.
단일 writer가 만드는 숙명: SPOF
Quipu-Log 스토어는 13장 single-writer에서 다룬 대로, 스토어 루트 디렉터리에 파일 락을 걸고 딱 하나의 프로세스만 쓰기를 허락합니다. 이 단순함이 변조 탐지를 가능하게 합니다 — 여러 노드가 한 체인을 동시에 써넣으면 누가 진짜인지 합의가 필요하고, 그 합의 로직에 버그가 생기는 순간 "변조 안 됐음"이 "아마 안 됐을 것"으로 약해지거든요.
하지만 단일 프로세스 = 단일 장애점(SPOF)입니다. 데몬 재시작, 배포, 장애 — 이 잠깐 동안 감사 이벤트를 보내는 서비스들은 어떻게 해야 할까요?
서버 쪽에서 해결하는 방법은 Raft 같은 합의 프로토콜로 여러 노드에 리더를 선출하는 것입니다. 하지만 그러면 체인 헤드에 대한 분산 합의가 필요해지고, 감사 무결성 보장이 훨씬 복잡해집니다. Quipu-Log는 반대 방향을 선택했습니다 — 서버의 단순함을 지키고, 가용성 부담을 클라이언트로 옮기는 것입니다. 클라이언트 측 솔루션은 로컬에 파일 하나 두는 것으로 충분하니까요.
세 가지 무기: 멱등 키 · 백오프 · 스풀
quipu-client 크레이트는 이 클라이언트 측 내구성의 레퍼런스 구현입니다. 세 층으로 이루어집니다.
① 멱등 재전송: 재시도해도 두 번 기록 안 된다
서버가 받았는데 응답이 오기 전에 연결이 끊겼다면, 클라이언트 입장에서는 성공인지 실패인지 알 수 없습니다. 그냥 재시도하면 같은 이벤트가 두 번 기록될 수 있죠. 이걸 막는 게 멱등 키(Idempotency Key)입니다.
crates/quipu-client/src/retry.rs/// 이벤트 하나당 UUIDv4 하나. 모든 재전송에 같은 키를 씁니다.
pub fn new_idempotency_key() -> String {
let mut bytes = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut bytes);
// RFC 4122 version 4, variant 1 — 재시도마다 같은 키를 재사용한다.
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
// ... hex 인코딩 ...
}
서버는 최근 수락한 키를 메모리에 윈도로 기억합니다(기본 65,536개). 같은 키가 다시 오면 실제로 쓰지 않고 "status":"duplicate"로 응답합니다. 이렇게 클라이언트는 마음껏 재시도하고, 서버는 중복을 걸러냅니다.
멱등 윈도는 in-memory라 서버 재시작 후 사라집니다. 재시작을 가로지른 재전송은 두 번 기록될 수 있습니다. 하지만 두 레코드 모두 같은 occurred_at을 가지므로 사후에 탐지할 수 있습니다 — 설계된 경계이지 빈틈이 아닙니다.
② 지수 백오프 + 풀 지터: 서버가 돌아왔을 때 폭풍을 막는다
서버 장애 후 복구되면, 기다리던 수백 개의 클라이언트가 일제히 재연결을 시도합니다. 모두 고정 스케줄로 재시도하면 다음 타이밍에 또 폭풍이 몰립니다. Backoff는 풀 지터(full jitter)로 이 문제를 해결합니다.
crates/quipu-client/src/retry.rsimpl Backoff {
pub 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());
// [0, capped] 범위 균등 랜덤 — 모든 클라이언트가 같은 틱에 몰리지 않는다.
let jittered = rand::Rng::gen_range(&mut rand::thread_rng(), 0.0..=capped);
Some(Duration::from_secs_f64(jittered))
}
}
기본값은 base 100ms, 배율 2.0, 최대 지연 30초, 최대 재시도 6회입니다. 6번 다 실패하면 다음 층으로 넘어갑니다.
③ 디스크 스풀: "유실"을 "지연"으로 바꾼다
재시도를 모두 소진해도 서버가 응답하지 않으면, 이벤트는 로컬 디스크 파일에 저장됩니다. 이게 Spool입니다.
crates/quipu-client/src/spool.rsimpl Spool {
pub fn append(&mut self, record: &SpoolRecord) -> io::Result<()> {
let payload = serde_json::to_vec(record)?;
let len = u32::try_from(payload.len())?;
let crc = crc32fast::hash(&payload);
// [len][crc32][payload] 프레임 — quipu-core 세그먼트 프레이밍과 동일.
self.file.write_all(&frame)?;
self.file.sync_data()?; // fsync. 이게 스풀의 존재 이유다.
Ok(())
}
}
스풀 프레임은 9장 레코드 프레이밍과 정확히 같은 [len][crc32][payload] 구조입니다. 크래시로 쓰다 만 꼬리는 열 때 CRC 검사로 잘라냅니다. 서버가 복구되면 drain_spool()이 오래된 것부터 순서대로 재전송하고, 성공한 것만 원자적 rename으로 파일에서 지웁니다.
DB에서는 클라이언트 라이브러리가 연결 풀을 관리하고, 연결 끊기면 대개 예외를 던진다. Quipu-Log에서는 클라이언트가 "서버가 없어도 이벤트를 잃지 않는다"는 계약을 직접 구현한다 — 이벤트를 로컬 파일에 저장하고 나중에 재생하는 것으로.
occurred_at: 늦게 도착해도 기록은 행위 시각으로
스풀에서 재전송되는 이벤트는 몇 분, 몇 시간 늦게 서버에 도착할 수 있습니다. 하지만 서버 로그를 보는 사람은 도착 시각이 아니라 행위가 실제 언제 일어났는지를 알고 싶죠.
이것을 해결하는 게 occurred_at 필드입니다. 클라이언트가 이벤트를 만들 때 "지금"을 찍어서 함께 보내면, 서버는 그 값을 그대로 로그에 씁니다. 도착이 늦어도 로그에 남는 시각은 행위 당시 시각입니다. 지연은 생기지만 기록의 정확성은 보존됩니다.
콜드 스탠바이 vs 라이브 페일오버
클라이언트가 버텨준다고 해도, 서버 자체의 복구 시간이 길면 스풀이 계속 쌓입니다. 이를 줄이는 방법이 콜드 스탠바이입니다.
| 방식 | 설명 | 전제 |
|---|---|---|
| 콜드 스탠바이 | 스토어 루트를 미리 다른 호스트에 복사해 두고, 문제 시 거기서 데몬을 기동 | 2nd writer 없음 — 먼저 원 노드를 내리고 나서 스탠바이 기동 |
| 라이브 페일오버(Raft 등) | 리더-팔로어 복제, 자동 리더 선출 | 체인 헤드 합의 필요 → Quipu-Log 범위 밖 |
콜드 스탠바이 절차는 단순합니다. 원 노드를 SIGTERM으로 정상 종료(또는 POST /v1/admin/flush)해 액티브 세그먼트를 안정시킨 뒤, 스토어 루트를 스탠바이 호스트에 복사합니다. 봉인된 세그먼트는 불변이라 마지막 액티브 꼬리만 증분 복사하면 됩니다. 스탠바이에서 데몬을 기동하면 락을 잡고 torn tail을 절단한 후 바로 서비스 가능합니다.
재시작 속도는 액티브 세그먼트 크기에 비례합니다. max_segment_bytes를 적당히 작게 두면 재시작과 페일오버가 빨라집니다 — 12장 크래시 복구에서 다룬, open 시 sealed 세그먼트를 재스캔하지 않는 덕분입니다.
① 멱등 키를 "이벤트당 하나, 모든 재시도에 동일하게"로 쓰는 이유는 무엇인가요? 시도마다 새 키를 쓰면 어떤 일이 생길까요?
② 풀 지터(full jitter)가 없고 고정 스케줄로 재시도한다면, 서버 복구 직후 어떤 현상이 생길까요?
③ 스풀이 있어도 완전한 "무손실"을 보장하지 못하는 경우는 언제인가요? (힌트: 스풀 파일 자체의 위치를 생각해보세요.)