데이터베이스를 쓴다면 "커밋하면 영원히 남는다"는 약속을 당연하게 여겼을 겁니다. 하지만 그 약속은 저절로 생기지 않습니다. DB 안에서는 누군가가 fsync를 적절한 때에 호출하고 있고, "언제 fsync할 것인가"를 놓고 내구성과 처리량 사이의 줄다리기가 벌어집니다. 이 챕터에서는 그 줄다리기를 Quipu-Log가 파일 레벨에서 어떻게 정책으로 드러내는지 배웁니다.
SyncPolicy는 "얼마나 자주 fsync를 부를 것인가"를 코드로 표현한 내구성 정책이다. 안전을 택할수록 느려지고, 속도를 택할수록 정전 시 잃을 수 있는 창이 넓어진다.
당신이 아는 것: DB의 커밋 내구성
관계형 DB에서 COMMIT을 치면 DB는 WAL 레코드를 반드시 디스크에 내려보내고(fsync) 그 뒤에야 "성공"을 돌려줍니다. 이것이 ACID의 D(Durability)입니다. 그런데 트랜잭션마다 fsync를 부르면 느립니다. 디스크 하나의 fsync 레이턴시는 수백 μs ~ 수 ms, SSD도 수십 μs가 보통이니까요.
그래서 고성능 DB들이 오래전부터 쓰는 기법이 group commit입니다. 여러 트랜잭션이 거의 동시에 커밋을 요청하면 그걸 한 묶음으로 모아 fsync 한 번에 처리합니다. "N개를 한꺼번에 내려쓰고, N개 모두에게 성공을 돌려준다." 처리량이 크게 올라가지만, 마지막 fsync 이후 커밋된 것들은 정전이 나면 같이 사라집니다.
DB에서는 commit durability·group commit이 엔진 내부에 숨겨져 있다. 개발자는 innodb_flush_log_at_trx_commit, PostgreSQL의 synchronous_commit 같은 파라미터로 제어할 뿐이다. Quipu-Log에서는 같은 결정을 SyncPolicy 열거형 하나로 코드에 직접 노출한다. "숨기지 않겠다"는 설계 선택이다.
두 개의 버퍼: BufWriter와 OS 페이지 캐시
fsync 정책을 이해하려면 "쓰기"가 사실 두 단계로 나뉜다는 걸 먼저 짚어야 합니다. 5장 page cache와 fsync에서 자세히 다루지만, 여기서도 간단히 짚겠습니다.
Quipu-Log의 세그먼트 파일 쓰기는 BufWriter를 사용합니다. 256KB 크기의 유저공간 버퍼를 두고, 버퍼가 차거나 명시적으로 flush()를 부를 때 OS 페이지 캐시에 넘깁니다. 거기서 한 번 더 fsync를 불러야 비로소 디스크에 보장됩니다.
crates/quipu-core/src/storage/segment.rspub fn flush(&mut self) -> Result<()> {
self.writer.flush()?; // 유저공간 → OS 페이지 캐시
Ok(())
}
pub fn sync(&mut self) -> Result<()> {
self.writer.flush()?;
self.writer.get_ref().sync_data()?; // OS 캐시 → 디스크 (fdatasync)
Ok(())
}
세 가지 정책: SyncPolicy
이 두 단계를 "언제 부를 것인가"를 결정하는 것이 SyncPolicy입니다.
crates/quipu-core/src/store.rspub enum SyncPolicy {
Always, // fsync after every append. Safest, slowest.
EveryN(u32), // fsync after every N appends; otherwise only flush.
OsManaged, // Never fsync explicitly; rely on the OS to write back. Fastest.
}
세 가지를 하나씩 뜯어봅시다.
Always — 매번 fsync
append를 할 때마다 sync()를 부릅니다. 정전이 일어나도 직전 append까지는 반드시 살아남습니다. 잃을 수 있는 창이 0에 가깝습니다. 대신 append마다 디스크 왕복이 생기므로, 초당 수백~수천 건 이상은 버텁니다.
EveryN(n) — N개마다 fsync (기본값: 64)
이것이 group commit입니다. N번 append 동안은 OS 캐시에만 남겨두고(flush만), N번째에 sync()로 한꺼번에 내립니다. 정전이 나면 마지막 fsync 이후 append된 것들(최대 N-1개)을 잃을 수 있습니다. 하지만 fsync 횟수가 N배 줄어드니 처리량도 N배 가까이 올라갑니다.
crates/quipu-core/src/store.rs — apply_sync_policy()match self.cfg.sync_policy {
SyncPolicy::Always => self.sync_all()?,
SyncPolicy::EveryN(n) => {
self.appends_since_sync += 1;
if self.appends_since_sync >= n {
self.sync_all()?; // N번째: fsync
} else {
self.logs.flush()?; // 그 외: flush만
self.relations.flush()?;
}
}
SyncPolicy::OsManaged => {
self.logs.flush()?;
self.relations.flush()?;
}
}
sync_all()은 fsync 순서를 지킵니다. 레지스트리를 먼저, 그다음 로그 테이블을 내립니다. 이유는 코드 주석에 나와 있습니다: "로그 레코드가 디스크에 먼저 내려가 있는데 그것이 참조하는 레지스트리 버전은 없는 상황"이 생기면 안 되기 때문입니다.
OsManaged — fsync 하지 않음
flush()만 불러 OS 캐시에 넘기고, 디스크 내려쓰기는 OS에 맡깁니다. 정전이 나면 OS 캐시에 있던 모든 데이터가 사라집니다. 그 대신 디스크 왕복이 없으니 가장 빠릅니다. "감사 로그를 잃어도 괜찮은" 개발/테스트 환경이나 배터리 백업 UPS가 있는 서버에 적합합니다.
성능 수치: 정책의 차이가 얼마나 큰가
README의 벤치마크 수치를 그대로 인용합니다 (Apple M4, NVMe SSD, rustc 1.96, release 빌드).
| SyncPolicy | 내구 처리량 | 잃을 수 있는 창 |
|---|---|---|
OsManaged |
~56,000 events/s | OS 재시작 전까지 (무한) |
EveryN(64) (기본) |
~4,800 events/s | 최대 63개 이벤트 |
Always |
(~수백~수천 events/s) | 0 (직전 append까지 보장) |
EveryN(64)가 기본값인 이유가 보입니다. OsManaged보다 12배 느리지만, 잃을 수 있는 창이 최대 63개 이벤트로 좁습니다. 감사 로그에서 이것이 실전의 최선에 가깝습니다. Always는 금융·의료처럼 단 하나도 잃어선 안 되는 환경에서 선택합니다.
음식 주문 메모장을 떠올리세요. Always는 주문 하나 받을 때마다 금고에 잠근다. EveryN(64)는 64개 모아서 한 번 잠근다. OsManaged는 메모장을 책상 위에 두고 퇴근할 때만 잠근다. 화재(정전)가 나면 금고 안 것만 남는다.
OsManaged는 "빠르지만 내구성 보장 없음"이다. 가상 머신이나 컨테이너 환경에서 호스트 중단이 잦다면 이 정책은 안전하지 않다. fsync 없이 빠른 처리량이 필요하다면 대신 배터리 백업 RAID 컨트롤러 뒤에서 Always를 쓰는 것이 더 정직한 선택이다.
정리
- DB의 commit durability·group commit과 똑같은 트레이드오프가 파일 레벨에서
SyncPolicy라는 이름으로 노출된다. Always= 매번 fsync (가장 안전, 가장 느림),EveryN(n)= group commit (기본, 균형),OsManaged= fsync 없음 (가장 빠름, 내구성 없음).- 쓰기 경로는 BufWriter(유저버퍼) →
flush()(OS 캐시) →fsync()(디스크) 순서다. 정책은 마지막 fsync 빈도를 조절한다. sync_all()은 레지스트리 → 로그 순서로 내려써, 크래시 후 "로그가 참조하는 레지스트리 레코드가 없는" 상황을 막는다.
① EveryN(64)로 설정했을 때 정전이 나면 최대 몇 개의 이벤트를 잃을 수 있나요? 그리고 그게 감사 로그에서 수용 가능한 이유는?
② DB의 group commit과 EveryN(n)의 구조적 유사점을 한 문장으로 설명해 보세요.
③ sync_all()이 레지스트리를 먼저, 로그를 나중에 fsync하는 이유는 무엇인가요?