왜 쓰기가 "빠른데" 데이터는 사라지는가

애플리케이션이 write()를 호출하면 데이터는 디스크가 아니라 커널의 페이지 캐시(page cache)에 먼저 들어간다. 그래서 write 자체는 마이크로초 단위로 끝난다. 문제는 이 시점의 데이터가 아직 "dirty page" 상태로 메모리에만 있다는 점이다. 전원이 나가거나 커널이 패닉하면 이 데이터는 그대로 유실된다. 데이터베이스의 커밋, 메시지 큐의 ack, 파일 기반 저널이 신뢰를 확보하려면 반드시 fsync()로 디스크까지 내려야 하고, 바로 이 fsync가 실무 지연의 핵심 원인이 된다.

dirty page가 디스크로 내려가는 경로

커널은 dirty page를 두 가지 방식으로 비운다. 하나는 백그라운드 flusher 스레드가 주기적으로 내리는 것이고, 다른 하나는 애플리케이션의 명시적 fsync다. 백그라운드 동작은 아래 커널 파라미터로 제어한다.

$ sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs
vm.dirty_ratio = 20
vm.dirty_background_ratio = 10
vm.dirty_expire_centisecs = 3000

# dirty 데이터가 전체 메모리의 5%를 넘으면 백그라운드 flush 시작
# 10%를 넘으면 write() 호출 프로세스가 직접 flush에 참여(=지연 급증)
sudo sysctl -w vm.dirty_background_ratio=5
sudo sysctl -w vm.dirty_ratio=10

dirty_ratio에 도달하면 커널은 쓰기 프로세스를 강제로 블로킹시켜 스스로 flush하게 만든다. 메모리가 큰 서버에서 기본값을 그대로 쓰면, 수 GB의 dirty page가 한꺼번에 디스크로 쏟아지며 fsync 지연이 수백 ms~초 단위로 튀는 "write stall"이 발생한다.

fsync 지연이 애플리케이션을 멈추는 순간

fsync는 해당 파일의 dirty page뿐 아니라, 파일시스템 저널링 방식에 따라 다른 프로세스가 쌓아둔 dirty page의 flush까지 기다리게 될 수 있다. 즉 조용하던 서비스의 fsync가, 배치 작업이 만든 수 GB dirty page 때문에 갑자기 느려진다. PostgreSQL, etcd, Kafka 같은 시스템이 "디스크는 안 바쁜데 커밋이 느리다"는 증상을 보이는 전형적 원인이다.

지연을 실제로 측정하기

추측 대신 fsync 소요 시간을 직접 재야 한다. eBPF 기반 biosnoop이나 간단한 파이썬으로 확인할 수 있다.

import os, time

fd = os.open("test.dat", os.O_WRONLY | os.O_CREAT, 0o644)
data = b"x" * 4096
worst = 0.0
for i in range(1000):
    os.write(fd, data)
    t0 = time.perf_counter()
    os.fsync(fd)              # dirty page를 디스크까지 강제
    dt = (time.perf_counter() - t0) * 1000
    worst = max(worst, dt)
os.close(fd)
print(f"worst fsync latency: {worst:.2f} ms")

이 값이 p99에서 튀는지, 특정 배치 시간대에만 나빠지는지를 함께 보면 원인이 애플리케이션인지 커널의 dirty flush인지 구분된다.

완화 전략: 어디를 손볼 것인가

해결은 계층별로 접근한다. 커널 튜닝, 애플리케이션의 fsync 빈도 조정, 스토리지 하드웨어 세 가지다.

계층조치효과 / 주의점
커널dirty_ratio·dirty_background_ratio 하향flush를 잘게 쪼개 지연 스파이크 감소 / 처리량 소폭 감소
애플리케이션그룹 커밋, fsync 배칭fsync 호출 수 감소 / 커밋당 지연 vs 유실 위험 트레이드오프
스토리지PLP 지원 엔터프라이즈 SSDfsync가 캐시에서 즉시 완료 / 소비자용 SSD는 효과 없음

그룹 커밋과 fsync 배칭

가장 효과가 큰 것은 fsync 호출 자체를 줄이는 것이다. 여러 트랜잭션을 모아 한 번의 fsync로 처리하는 그룹 커밋이 대표적이다. PostgreSQL은 이를 설정으로 제공한다.

# postgresql.conf
# 커밋을 잠깐 모았다가 한 번에 flush → fsync 호출 수 감소
commit_delay = 100        # 마이크로초, 첫 커밋 후 대기
commit_siblings = 5       # 동시 트랜잭션이 이 수 이상일 때만 지연 적용

# synchronous_commit = off 는 fsync를 건너뛰어 지연이 사라지지만
# 크래시 시 최근 수백 ms 커밋이 유실될 수 있음 — 의미를 알고 써야 함

synchronous_commit = off는 지연을 극적으로 없애지만 내구성을 포기하는 설정이다. 로그성 데이터에는 합리적이지만 금전·주문 데이터에 켜면 안 된다.

주의점

세 가지를 기억하면 된다. 첫째, PLP(전원 손실 보호)가 없는 소비자용 SSD는 fsync 시 실제로 낸드까지 flush하므로 지연이 크고 튄다. 벤치마크만 보고 도입하면 프로덕션에서 배신당한다. 둘째, dirty_ratio를 너무 낮추면 flush가 지나치게 잦아져 총 처리량이 떨어질 수 있으니 반드시 워크로드로 검증한다. 셋째, fsync를 줄이는 모든 최적화는 내구성과의 교환이다. "지연이 사라졌다"는 것은 대개 "유실 위험이 늘었다"와 같은 말이므로, 데이터의 중요도에 맞춰 선택해야 한다.