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

14 · 동시성 제어 ②: 읽기 스냅샷과 MVCC

쓰기가 진행되는 동안 읽기는 어떻게 될까요? DB는 MVCC(Multi-Version Concurrency Control)로 읽기와 쓰기가 서로를 막지 않게 합니다. Quipu-Log는 B-tree도, 트랜잭션 버전도 없습니다. 하지만 같은 목적 — 읽기가 쓰기를 막지 않고, 쓰기도 읽기를 막지 않는다 — 을 훨씬 단순한 방법으로 달성합니다. 인덱스를 복사하고, 파일 길이를 고정합니다.

한 문장 요약

ReadSnapshot은 인메모리 인덱스를 클론하고 각 세그먼트 파일의 "지금 이 순간 길이"를 기록한다. 그 이후로 쓰기가 아무리 일어나도 스냅샷은 그 순간을 본다 — 잠금 없이.

당신이 아는 것: DB의 MVCC

PostgreSQL이나 MySQL InnoDB는 각 행에 버전을 붙여 관리합니다. UPDATE를 하면 이전 버전을 지우지 않고, 새 버전을 만들고 이전 버전을 "만료" 처리합니다. 읽기 트랜잭션은 자신이 시작할 때의 "스냅샷 ID"를 받아, 그 이후에 커밋된 변경은 보이지 않습니다. 이것이 Snapshot Isolation입니다. 핵심: 읽기 트랜잭션이 쓰기를 막지 않고, 쓰기 트랜잭션도 읽기를 막지 않습니다.

하지만 이를 구현하려면 "어느 버전을 볼 수 있는가"를 추적하는 복잡한 자료구조(undolog, MVCC 체인, vacuum)가 필요합니다.

DB ↔ 파일시스템

DB의 MVCC는 같은 행의 여러 버전을 디스크에 두고, 읽기 트랜잭션마다 자신이 볼 수 있는 버전을 골라 읽는다. 복잡하지만 정밀하다. Quipu-Log의 ReadSnapshot은 인메모리 인덱스를 통째로 클론하고, 세그먼트 파일의 현재 길이에 상한선을 박는다. 구조는 다르지만 목적은 같다: 읽기와 쓰기의 비차단.

ReadSnapshot: copy-on-read

AuditStore::snapshot()을 부르면 ReadSnapshot이 만들어집니다.

crates/quipu-core/src/store.rs — AuditStore::snapshot()pub fn snapshot(&mut self) -> Result<ReadSnapshot> {
    Ok(ReadSnapshot {
        keys: self.cfg.keys.clone(),
        registries: self.registries
            .iter()
            .map(|(k, v)| (k.clone(), v.idx.clone())) // 인덱스 클론
            .collect(),
        logs: self.logs.slices()?,      // 세그먼트 파일 경로 + 현재 길이
        relations: self.relations.slices()?,
    })
}

두 가지가 일어납니다.

  1. 레지스트리 인덱스 클론 — 현재 인메모리 인덱스(어떤 엔티티 ID → 어떤 버전 uid)를 복사합니다. 이 클론은 독립적이어서, 이후 append()가 원본 인덱스를 업데이트해도 클론된 스냅샷은 변하지 않습니다.
  2. 세그먼트 슬라이스 기록 — 각 세그먼트 파일의 경로와 지금 이 순간의 파일 길이(bound)SegmentSlice로 기록합니다. 이후 append가 파일을 더 길게 만들어도, 스냅샷은 bound 이전까지만 읽습니다.
Writer 스레드 append() 계속 실행 AuditStore registries (인덱스 원본) logs (세그먼트 파일들) snapshot() → clone ReadSnapshot registries (클론 — 독립) logs: [SliceA(bound=1024), ...] 이 bound 이후는 안 읽음 clone() 호출자 스레드 snapshot.query() 실행 (Writer와 독립적으로) 전달
스냅샷은 Writer 스레드에서 만들어지지만(클론 비용만), 실제 스캔은 호출자 스레드에서 독립적으로 실행된다. Writer는 스캔을 기다리지 않는다.

SegmentReader의 bound: 파일 길이 고정

SegmentSlicebound 필드가 핵심입니다. 스냅샷을 찍은 순간의 파일 길이가 bound로 기록됩니다. 스캔할 때 SegmentReader::open_bounded()bound를 넘어서는 읽지 않습니다.

crates/quipu-core/src/storage/segment.rs — SegmentReader::open_bounded()pub fn open_bounded(path: &Path, bound: u64) -> Result<Self> {
    let file = File::open(path)?;
    let end = file.metadata()?.len().min(bound); // bound 이상은 읽지 않는다
    let mut reader = BufReader::with_capacity(256 * 1024, file);
    if end >= SEGMENT_HEADER as u64 { read_header(&mut reader, path)?; }
    Ok(Self { path, reader, offset: SEGMENT_HEADER.min(end as usize) as u64,
               end /* 읽기는 여기까지 */ })
}

왜 이것이 중요할까요? append가 파일 끝에 바이트를 추가하는 중에도, 스냅샷 리더는 bound까지만 봅니다. 반쯤 쓰인 프레임(BufWriter가 아직 flush하지 않은 것)을 볼 일이 없습니다. 읽기와 쓰기가 같은 파일을 공유하지만, 경계가 명확하게 나뉩니다.

스캔은 호출자 스레드에서

파이프라인 모드에서 AuditHandle::snapshot()을 부르면, 스냅샷 객체 생성만 writer 스레드에서 일어납니다(인덱스 클론). 그 뒤 스냅샷은 호출자에게 반환되고, snapshot.query()는 호출자 스레드에서 실행됩니다. writer 스레드는 그 사이에도 emit()된 이벤트를 계속 씁니다. 느린 쿼리가 emit을 막지 않습니다.

README의 Snapshots 섹션 설명:

Queries run on a read snapshot (handle.snapshot(&role)?): it clones the in-memory registry indexes and scans on the caller's thread, never blocking writes.

이것이 Quipu-Log의 "MVCC"입니다. 버전 체인 없이, 인덱스 클론 + 파일 bound로 읽기-쓰기 격리를 달성합니다.

MVCC와의 비교: 무엇이 같고 무엇이 다른가

속성 DB MVCC Quipu-Log ReadSnapshot
목적 읽기와 쓰기 비차단 읽기와 쓰기 비차단
구현 방법 행마다 버전 체인, undolog 인덱스 클론 + 파일 bound
스냅샷 비용 낮음(트랜잭션 ID 부여) 인덱스 클론 (O(엔티티 수))
스냅샷 이후 변경 버전 체인으로 가려진다 bound 이후 파일은 읽지 않는다
vacuum 필요 있음 (오래된 버전 청소) 없음 (retention이 세그먼트 단위로 삭제)
비유

도서관에 새 책이 계속 들어옵니다. "지금 보유 목록"을 인쇄해서 나눠줬다고 하면, 그 이후에 새 책이 들어와도 내 인쇄물은 바뀌지 않습니다. 나는 인쇄 시점의 목록으로 책을 찾습니다. Quipu-Log의 스냅샷도 마찬가지입니다 — 인덱스 클론이 그 "인쇄물"입니다.

스냅샷의 한계

주의

인덱스 클론 비용은 등록된 엔티티 수에 비례한다. 엔티티가 수백만 건이면 클론 자체가 수십 밀리초가 될 수 있다. 또 스냅샷은 불변이므로 스캔 중에 append된 레코드는 보이지 않는다 — 이것이 의도된 "스냅샷 격리" 동작이지만, "방금 append한 것이 왜 안 보이지?"로 혼란스러울 수 있다. flush()를 호출하고 새 스냅샷을 얻으면 반드시 보인다.

정리

  • DB의 MVCC와 같은 목적(읽기-쓰기 비차단)을, Quipu-Log는 copy-on-read 스냅샷으로 달성한다.
  • ReadSnapshot은 레지스트리 인덱스 클론 + 세그먼트 파일의 bound 기록으로 만들어진다. 이후 append가 파일을 늘려도 스냅샷은 bound 이전만 본다.
  • 스캔은 호출자 스레드에서 실행된다. Writer 스레드는 스캔을 기다리지 않는다.
  • 버전 체인, undolog, vacuum이 없다. 단순하지만 스냅샷 비용이 인덱스 크기에 선형 비례한다.
스스로 확인

① DB MVCC와 Quipu-Log ReadSnapshot이 "같은 목적"을 달성하는 방법의 차이를 한 문단으로 설명해 보세요.
② 스냅샷을 찍은 직후에 append가 일어났습니다. 그 append를 스냅샷으로 읽을 수 있나요? 없다면 왜인가요?
③ 쿼리 스캔이 Writer 스레드가 아닌 호출자 스레드에서 실행되어야 하는 이유를 파이프라인 관점에서 설명해 보세요.