Quipu-Log 교과서
파트 4 · DB가 공짜로 주던 보장을 파일로 재현

17 · 삭제와 보존: 세그먼트 unlink vs DELETE

감사 로그를 영원히 보관할 수는 없습니다. 디스크가 차면 오래된 것을 지워야죠. DB라면 DELETE FROM logs WHERE ts < cutoff를 실행하고 나중에 VACUUM을 돌려 공간을 회수합니다. Quipu-Log는 그 방식을 쓰지 않습니다. 대신 세그먼트 파일 통째로 unlink합니다. 왜 이 방법이 훨씬 간단하고 빠른지, 그리고 "레지스트리는 절대 지우지 않는다"는 선택의 이유를 살펴봅시다.

한 문장 요약

보존 정책은 오래된 sealed 세그먼트를 통째로 unlink합니다 — 행 단위 삭제·재작성이 없고, 레지스트리는 보존하며, active 세그먼트는 절대 지우지 않습니다.

당신이 아는 것: DELETE + VACUUM vs partition drop

관계형 DB에서 "오래된 행 삭제"는 사실 두 단계입니다. DELETE FROM logs WHERE ts < cutoff는 행을 삭제 표시(dead tuple)로만 바꿀 뿐, 디스크 공간을 바로 회수하지 않습니다. 나중에 VACUUM이 dead tuple을 청소하고, VACUUM FULL은 테이블 파일 자체를 재작성해 공간을 반환합니다. 테이블 크기에 비례하는 O(n) 작업이고, 그동안 테이블이 잠깁니다.

더 영리한 방법은 파티션 드롭입니다. CREATE TABLE logs_2024_01 PARTITION OF logs처럼 월별 파티션을 만들어두면, 1월치를 지울 때 DROP TABLE logs_2024_01 한 줄로 O(1)에 끝납니다. 그 테이블의 파일 inode를 참조 카운트 0으로 만들어 OS가 즉시 공간을 회수하기 때문입니다.

DB ↔ 파일시스템

DB의 DELETE + VACUUM: 행 단위 삭제 표시 → 별도 VACUUM 프로세스 청소 → 느리고 무겁다. DB의 partition drop: 테이블 파일을 통째로 OS에 돌려줌 → O(1), 즉시. Quipu-Log의 세그먼트 unlink: partition drop과 같다. 세그먼트 파일이 곧 파티션이다. std::fs::remove_file() 한 줄이 `DROP TABLE`이다.

RetentionPolicy: 나이와 크기 두 축

보존 정책은 RetentionPolicy로 선언합니다.

crates/quipu-core/src/retention.rspub struct RetentionPolicy {
    pub max_age:   Option<Duration>, // "이 기간보다 오래된 것 삭제"
    pub max_bytes: Option<u64>,     // "이 바이트를 넘으면 오래된 것 삭제"
}

impl RetentionPolicy {
    pub fn days(days: u64) -> Self { ... }
    pub const fn with_max_bytes(mut self, n: u64) -> Self { ... }
}

두 조건은 OR로 결합됩니다. 둘 중 하나라도 초과하면 가장 오래된 sealed 세그먼트부터 지웁니다. 예를 들어 90일 + 50 GB라면: "90일보다 오래된 게 있으면 지운다, 그리고 50 GB를 넘어도 지운다"입니다.

어떻게 지우나: unlink의 O(1) 비밀

실제 삭제는 Table::purge_older_than()Table::purge_oldest_sealed()가 담당합니다.

crates/quipu-core/src/storage/table.rspub fn purge_older_than(&mut self, cutoff_micros: u64) -> Result<usize> {
    let doomed: Vec<u64> = self.sealed.iter()
        .filter(|(_, s)| s.meta.max_timestamp < cutoff_micros) // 가장 최신 행도 기한 초과
        .map(|(&seq, _)| seq)
        .collect();
    for seq in &doomed {
        if let Some(s) = self.sealed.remove(seq) {
            std::fs::remove_file(s.path)?;            // 파일 unlink — O(1)
            let _ = std::fs::remove_file(meta_path(&self.dir, *seq));
        }
    }
    Ok(doomed.len())
}

remove_file()은 Unix의 unlink(2) 시스템 콜입니다. 파일 내용을 건드리지 않고 디렉토리 항목(이름 → inode 링크)만 제거합니다. inode의 참조 카운트가 0이 되면 OS가 디스크 블록을 즉시 반환합니다. 파일 크기가 64 MB이든 4 GB이든 같은 시간이 걸립니다 — 진정한 O(1)입니다.

비유

도서관 사서가 책을 한 페이지씩 찢어 버리지 않고, 책 한 권을 통째로 반납 카트에 올리는 것과 같습니다. 페이지를 읽어보지 않아도 되니까 순식간입니다.

DB: DELETE + VACUUM 테이블 (heap) dead dead live live 가비지 live dead live ① DELETE: dead 표시만 (공간 회수 X) ② VACUUM: dead 행을 청소 (시간 소요) ③ VACUUM FULL: 테이블 재작성 (O(n), 잠금) 비용: O(삭제 행 수 + live 행 재정렬) 부작용: 동시 읽기 차단, 공간 단편화 무결성: 남은 행 재작성 → 머클 해시 재계산 필요 Quipu-Log: 세그먼트 unlink 세그먼트 파일들 seg-0 max_ts 초과 seg-1 max_ts 초과 seg-2 active remove_file() ✓ O(1) — 파일 크기 무관 ✓ 남은 레코드 건드리지 않음 ✓ 머클 spine 보존 (삭제된 prefix 포함) seg-2(active)는 절대 지우지 않음 레지스트리 테이블도 영구 보존
DB의 DELETE+VACUUM은 O(n)에 잠금이 수반된다. Quipu-Log의 세그먼트 unlink는 파일 크기와 무관한 O(1)이며, 살아있는 레코드를 건드리지 않는다.

두 가지 절대 규칙: active 세그먼트 보존, 레지스트리 보존

Quipu-Log의 retention에는 두 가지 예외가 있습니다. 이 둘은 어떤 정책 설정에도 절대 삭제되지 않습니다.

1. Active 세그먼트는 절대 지우지 않는다

코드는 항상 sealed 세그먼트만 대상으로 합니다. Active 세그먼트(현재 append 중인 것)는 아직 닫히지 않았으므로 보존 판단의 대상 자체가 아닙니다.

결과적으로 max_bytes목표이지 하드 상한이 아닙니다. active 세그먼트가 아무리 커도 지울 수 없으므로, 설정값을 active 세그먼트 크기만큼 초과할 수 있습니다. 다음 롤오버 후 그 세그먼트가 sealed되면 다음 retention 실행 때 지워집니다.

crates/quipu-core/src/retention.rs — 설명 주석// Enforcement drops whole sealed segments, so purging never rewrites data
// and costs one unlink per segment.
//
// The active segment is never dropped, so max_bytes is a *target*, not a
// hard ceiling: the store can exceed it by up to one active segment per
// table until the next roll.

2. 레지스트리는 절대 지우지 않는다

apply_retention()logsrelations 테이블만 purge합니다. 레지스트리(registry/<type>/)는 건드리지 않습니다. 코드 주석이 그 이유를 명확히 설명합니다.

crates/quipu-core/src/retention.rs — 설명 주석// Registries (and their meta/checkpoint bookkeeping) are intentionally not
// purged and not counted against max_bytes:
// version history is what lets old logs keep rendering as-recorded values.

90일 전 로그 레코드가 그때의 "사용자 이름"을 보여주려면, 그때의 레지스트리 버전이 살아있어야 합니다. alice가 나중에 이름을 "Alicia"로 바꿨어도, 90일 전 로그는 "Alice"로 렌더링되어야 합니다. 레지스트리가 지워지면 과거 로그의 actor/target 정보를 복원할 수 없습니다.

실제로 레지스트리 레코드는 엔티티 수에 비례합니다. 사용자 10만 명 × 평균 2버전 = 20만 레코드 정도라, 로그 레코드 수천만 건에 비해 무시할 수준입니다.

왜 이렇게?

레지스트리를 보존하지 않으면 "과거 로그 렌더링 가능성"을 로그 보존 기간과 연동해야 합니다. 설계 복잡성이 크게 올라갑니다. 레지스트리가 작다는 사실 덕분에 "레지스트리는 영원히 보존"이라는 단순한 규칙이 실용적입니다.

보존 후 체크포인트 재발행

purge 후 한 가지 일이 더 일어납니다. 직전 체크포인트가 가리키던 머클 루트가 지워진 세그먼트의 레코드를 포함하고 있을 수 있습니다. 그 체크포인트는 더 이상 검증 가능한 상태가 아닙니다. 그래서 retention 직후 새 체크포인트를 자동으로 발행합니다.

crates/quipu-core/src/store.rspub fn apply_retention(&mut self) -> Result<usize> {
    // ... purge_older_than(), purge_to_byte_budget() ...
    if dropped_main > 0 {
        // re-anchor after the unlink: a fresh checkpoint covers the surviving records
        self.write_checkpoint()?;
    }
    Ok(dropped)
}

머클 spine(잎 해시 목록)은 retention의 영향을 받지 않습니다 — 지워진 세그먼트의 잎 해시도 spine에 남아서 "그 레코드가 한때 존재했음"을 증명할 수 있습니다. 이 점에 대한 자세한 내용은 20장 머클 히스토리 트리에서 다룹니다.

정리

  • DB의 DELETE+VACUUM은 행 단위 삭제+공간 회수로 O(n), 무거운 작업이다.
  • DB의 partition drop처럼, Quipu-Log는 세그먼트 파일 전체를 unlink한다 — O(1), 살아있는 레코드를 건드리지 않는다.
  • RetentionPolicy는 나이(max_age)와 크기(max_bytes)를 OR로 결합하고, 조건이 충족되면 가장 오래된 sealed 세그먼트부터 제거한다.
  • Active 세그먼트는 절대 지우지 않는다 → max_bytes는 하드 상한이 아니라 목표.
  • 레지스트리는 절대 지우지 않는다 → 과거 로그가 항상 "기록 당시의 값"으로 렌더링된다.
  • purge 후 새 체크포인트를 자동 발행해 무결성 검증 기준을 갱신한다.
스스로 확인

① 세그먼트 unlink가 행 단위 DELETE보다 빠른 이유를 OS 파일시스템 관점에서 설명해 보세요. (힌트: inode 참조 카운트)
RetentionPolicy::days(90).with_max_bytes(50 * 1024 * 1024 * 1024)를 설정했는데, 70일 된 세그먼트가 크기 초과로 지워질 수 있을까요?
③ 레지스트리를 지우지 않는 이유를 "90일 전 로그의 actor 이름을 보려면 어떤 데이터가 필요한가"로 설명해 보세요.