8.3 파일 I/O와 I/O 모델 — write()가 리턴한 순간 데이터는 어디에 있는가
write()가 리턴해도 데이터는 디스크가 아니라 페이지 캐시에 있고, 이벤트 루프의 이득은 속도가 아니라 "적은 스레드로 감당하는 동시성"이다. 이 문서는 파일 I/O의 데이터 경로(VFS, 페이지 캐시, fsync 내구성 계약)와 제어 경로(블로킹 → epoll → io_uring으로 이어지는 I/O 모델의 계보)를 세워서, 서버 아키텍처 선택(thread-per-connection vs 이벤트 루프)을 커널 모델 위에서 판단할 수 있게 한다. 8.1의 태스크·스위치 비용과 8.2의 페이지 캐시로의 연결을 전제로 한다. 기준 OS는 Linux(커널 6.x)다.
학습 목표
write()리턴 시점의 데이터 위치를 페이지 캐시 모델로 설명하고, fsync·저널링이 각각 보장하는 것과 보장하지 않는 것을 구분한다.- 페이지 캐시(read-ahead, write-back)의 동작으로 I/O 지표와 벤치마크 결과의 왜곡을 해석한다.
- readiness 모델(epoll)과 completion 모델(io_uring)의 구조 차이, 그리고 정규 파일에 readiness가 성립하지 않는 이유를 설명한다.
- 동시 연결 수와 연결당 작업 성격을 근거로 thread-per-connection과 이벤트 루프 중에서 선택을 판단한다.
배경: 왜 이것이 존재하는가
이 문서의 두 주제는 같은 뿌리에서 나온다: I/O 장치는 CPU보다 자릿수로 느리고, 그 속도 차를 커널이 흡수해야 한다는 것이다.
데이터 경로 쪽 답은 캐싱이다. 디스크 접근이 메모리보다 수천 배 느리다면, 읽은 것을 메모리에 남겨 두고(재사용) 쓴 것을 메모리에 모아서 나중에 내리는(지연 쓰기) 것이 자연스럽다. 그 대가로 "썼다"의 의미가 모호해졌다 — 리턴한 write()와 전원이 나가도 살아남는 데이터 사이에 간극이 생겼고, 이 간극을 관리하는 계약(fsync)과 사고 수습 장치(저널링)가 필요해졌다.
제어 경로 쪽 답은 대기의 재설계다. 느린 I/O를 기다리는 가장 단순한 방법은 스레드를 재우는 것(블로킹)이고, 연결이 수십 개일 때는 그걸로 충분했다. 1999년 Dan Kegel이 "이제 웹 서버 한 대가 동시 연결 1만 개를 다뤄야 한다"고 정리한 C10K 문제는 이 모델의 한계를 공론화했다 — 연결마다 스레드를 태우면 8.1에서 본 비용(스택, 스케줄링, 스위치)이 연결 수에 비례해 쌓인다. 그 뒤 20여 년의 진화 — 논블로킹 fd와 다중화(select → epoll), 그리고 완료 기반(io_uring) — 는 전부 "스레드 하나가 여러 대기를 감당하는 방법"의 개선사다.
두 경로는 정규 파일에서 교차한다. 디스크 파일은 readiness 모델이 성립하지 않는 예외라서, 이벤트 루프 런타임(libuv)조차 파일 I/O만은 스레드 풀로 우회한다. 이 교차점까지 가는 것이 이 문서의 목표다.
핵심 개념
VFS와 파일 디스크립터 — 모든 것이 fd인 이유
Linux에서 정규 파일, 디렉터리, 소켓, 파이프, 디바이스는 전부 같은 인터페이스(read/write/close)로 다뤄진다. 이를 가능하게 하는 층이 VFS(Virtual File System)다 — 파일시스템(ext4, xfs, tmpfs, …)과 소켓·파이프 같은 유사 파일들이 공통 연산 집합을 구현하고, 시스템 콜은 그 위에서 동작한다. I/O 모델 논의가 소켓과 파일을 한 틀에서 다룰 수 있는 것도, 곧 보게 될 "정규 파일만 예외"라는 균열이 문제가 되는 것도 이 통일 때문이다.
**파일 디스크립터(fd)**는 프로세스별 테이블의 인덱스이고, 그 테이블 항목이 가리키는 것은 커널의 열린 파일 기술(open file description) — 현재 오프셋, 접근 모드, 상태 플래그(O_NONBLOCK 등)를 담는 개체 — 이다. 열린 파일 기술이 다시 실제 대상(inode, 소켓)을 가리킨다. 한 겹 더 있는 이 구조가 중요한 이유: dup(2)이나 fork로 복제된 fd들은 열린 파일 기술을 공유하므로 오프셋도 공유한다. 부모·자식이 같은 로그 파일에 쓸 때 서로 덮어쓰지 않는 것(오프셋을 공유하며 append되는 것)도, 8.1의 fork/exec에서 셸 리다이렉션이 성립하는 것도 이 공유 구조 위에 있다.
페이지 캐시 — 모든 파일 I/O의 중간역
8.2에서 세운 페이지 캐시를 이제 정면으로 다룬다. 페이지 캐시는 파일 내용을 페이지 단위로 담는 물리 메모리의 큰 영역이고, 모든 버퍼드 파일 I/O는 페이지 캐시를 경유한다.
읽기 경로. read()는 페이지 캐시에서 복사해 온다. 캐시에 없으면(미스) 디스크에서 읽어 캐시에 채운 뒤 복사한다 — 이때 커널은 순차 접근 패턴을 감지하면 요청받지 않은 다음 페이지들까지 미리 읽는다(read-ahead). 순차 읽기가 무작위 읽기보다 압도적으로 빠른 이유의 소프트웨어 쪽 절반이다(하드웨어 쪽 절반은 디스크 특성으로, 챕터 11의 스토리지 엔진 논의가 이어받는다). mmap으로 읽어도 같은 캐시의 같은 페이지를 매핑할 뿐이다 — 경로만 다르고 중간역은 같다.
쓰기 경로. write()는 데이터를 페이지 캐시에 복사하고 해당 페이지를 dirty로 표시한 뒤 리턴한다. 이 시점에 디스크에는 아무것도 내려가지 않았다. 디스크 반영(write-back)은 커널의 플러셔 스레드가 나중에 한다 — dirty 총량이 임계를 넘거나(vm.dirty_background_ratio, 기본 10%), 페이지가 일정 시간 묵거나(vm.dirty_expire_centisecs, 기본 30초), 누가 fsync를 부르거나. dirty가 더 높은 임계(vm.dirty_ratio, 기본 20%)를 넘으면 쓰던 프로세스 자신이 write-back에 동원되어 write()가 갑자기 느려진다 — "쓰기가 평소엔 빠른데 대량 쓰기 중 간헐적으로 멈칫한다"의 흔한 정체다. (임계값들은 기본값 기준이며 sysctl -a | grep dirty로 확인한다.)
관찰 — dirty page의 증가와 배수(Linux 전용):
grep -E 'Dirty|Writeback' /proc/meminfo # 기준값
dd if=/dev/zero of=dirty.bin bs=1M count=256
grep -E 'Dirty|Writeback' /proc/meminfo # Dirty가 ~256MB 증가
sync # 모든 dirty page를 디스크로 내린다
grep -E 'Dirty|Writeback' /proc/meminfo # Dirty가 기준값 근처로 복귀예상되는 관찰: dd가 리턴한 직후 Dirty가 쓴 양만큼 커져 있다 — 데이터가 아직 메모리에 있다는 직접 증거다. sync 후 Dirty가 빠진다. (30초쯤 기다렸다 다시 봐도 플러셔가 내린 것을 볼 수 있다.)
벤치마크 왜곡. 페이지 캐시는 I/O 측정의 단골 함정이다. 같은 파일을 두 번 읽는 벤치마크의 2회차는 디스크가 아니라 메모리를 측정한 것이고(8.2의 major/minor fault 관찰과 같은 현상), 쓰기 벤치마크는 fsync 없이는 "메모리에 복사하는 속도"를 측정한 것이다. 콜드 캐시 측정이 필요하면 echo 3 > /proc/sys/vm/drop_caches(root)로 캐시를 비우거나, fio 같은 도구의 direct I/O 옵션으로 캐시를 우회한다.
fsync — 내구성은 기본값이 아니라 계약이다
그래서 "저장됐다"를 보장받으려면 명시적으로 요구해야 한다. 보장 수준의 사다리를 정리하면:
write()리턴: 데이터가 커널의 페이지 캐시에 들어갔다. 프로세스가 죽어도 살아남지만(커널이 갖고 있으므로), 전원 장애·커널 패닉에는 유실된다.fsync(fd)리턴: 그 파일의 dirty 데이터와 메타데이터를 저장 장치에 내리라고 명령하고 완료를 기다렸다. POSIX가 정의하는 내구성 요구 지점이다. 자매인fdatasync는 파일 크기 같은 필수 메타데이터만 포함해 조금 싸다.- 함정 하나 — 디렉터리. 새로 만든 파일은 fsync해도, 그 파일 이름을 담은 디렉터리 엔트리는 별개의 대상이다. 크래시 후 "내용은 디스크에 있는데 이름이 없는" 상태를 피하려면 디렉터리 fd에도 fsync가 필요하다. rename 기반 원자적 교체 패턴(임시 파일에 쓰고 fsync 후 rename)에서 특히 중요하다.
- 함정 둘 — 장치 캐시. fsync는 커널이 저장 장치에 플러시 명령(FLUSH/FUA)까지 보내게 하지만, 전원 보호가 없는 일부 소비자용 디스크가 캐시 플러시를 거짓 보고하는 사례가 알려져 있다. 서버급 스토리지(전원 손실 보호)인지가 내구성 사슬의 마지막 고리다.
- 함정 셋 — 오류 처리. fsync가 실패했을 때의 의미론은 오랫동안 어두운 구석이었다. Linux는 write-back 실패 시 해당 페이지를 dirty가 아닌 상태로 되돌렸으므로, 실패한 fsync를 재시도하면 (실제로는 안 내려갔는데) 성공이 리턴될 수 있었다. 2018년 PostgreSQL 커뮤니티가 이 문제를 공론화한 사건이 "fsyncgate"로 불리며, 이후 커널의 오류 보고가 개선되고 DB들은 fsync 실패를 재시도 불가능한 치명 오류로 다루도록 바뀌었다. 교훈만 가져가면 된다: fsync의 실패는 "다시 해 보면 되는" 오류가 아니다.
O_SYNC(모든 write가 동기 플러시)와 O_DIRECT(페이지 캐시 우회)는 이 계약을 호출 단위·경로 단위로 바꾸는 플래그라는 위치만 알아 두면 된다.
저널링은 다른 문제를 푼다. write-back 도중 크래시가 나면 "일부만 내려간" 상태가 남는데, 데이터 페이지가 일부만 내려간 것은 파일 내용의 문제로 끝나지만 메타데이터(inode, 블록 할당 비트맵, 디렉터리)가 반쯤 내려가면 파일시스템 구조 자체가 깨진다. 저널링 파일시스템(ext4 등)은 메타데이터 변경을 먼저 로그(저널)에 기록하고 나서 본 위치에 반영하므로, 크래시 후에는 저널 재생만으로 구조적 정합성이 복구된다(전체 디스크 스캔 fsck가 필요 없다). 중요한 경계: ext4의 기본 모드(data=ordered)에서 저널이 지키는 것은 메타데이터다. 데이터 내용의 내구성은 여전히 fsync 계약의 몫이고, fsync하지 않은 최근 쓰기는 크래시에 유실될 수 있다. 데이터베이스가 이 계약 위에 WAL(write-ahead logging)로 자기만의 내구성·원자성을 쌓아 올리는 이야기는 챕터 11(데이터베이스 시스템 내부)로 이어진다.
관찰 — fsync의 가격:
# 1) 페이지 캐시에만 쓰기 (내구성 보장 없음)
dd if=/dev/zero of=a.bin bs=4k count=1000
# 2) 마지막에 한 번 fdatasync (전체가 내려간 뒤 리턴)
dd if=/dev/zero of=b.bin bs=4k count=1000 conv=fdatasync
# 3) 매 블록마다 동기 쓰기 (write 1000번 각각이 장치 왕복)
dd if=/dev/zero of=c.bin bs=4k count=1000 oflag=dsync예상되는 관찰: (1)이 가장 빠르고(메모리 복사 속도), (2)는 총량을 한 번에 내리는 비용이 더해지며, (3)은 자릿수로 느리다 — 4KiB마다 장치 플러시 왕복을 기다리기 때문이다. (1)과 (3)의 배율이 곧 "내구성의 단가"이고, 트랜잭션마다 fsync하는 DB가 그룹 커밋(여러 트랜잭션의 플러시를 한 번에 묶기)을 발명한 이유다. VM·컨테이너에서는 가상 디스크 계층이 배율을 왜곡할 수 있다는 점을 결과 해석에 반영한다.
블로킹 I/O와 thread-per-connection — 단순함의 가격표
이제 제어 경로다. 기본 모델부터: fd는 기본적으로 블로킹이다. 소켓에 읽을 데이터가 없으면 read()는 태스크를 재운다 — 8.1의 자발적 컨텍스트 스위치다. 이 모델에서 여러 연결을 다루는 자연스러운 구조가 thread-per-connection이다: 연결마다 스레드(또는 고전적으로는 프로세스)를 하나 배정하고, 각 스레드는 자기 연결만 상대로 순차적인 코드를 실행한다.
장점은 프로그래밍 모델이다. "요청을 읽고, 처리하고, 응답을 쓴다"가 그대로 코드가 되고, 대기는 커널이 알아서 처리한다. 비용은 연결 수에 비례해 쌓인다.
- 메모리: 스레드당 스택. 가상 8MiB의 실제 지급은 쓴 만큼이지만(8.2), 수십 KiB씩이라도 1만 연결이면 수백 MiB다.
- 스케줄링: 커널 태스크 1만 개는 run queue와 로드 밸런서의 부담이고, 동시에 깨어나면 스위치 폭풍이 된다.
- 스위치 비용: 연결당 I/O마다 자발적 스위치 두 번(재우기, 깨우기)과 그 간접 비용.
핵심은 이 비용이 동시 연결 수의 함수라는 것이다. 연결 수백 개, 연결당 실질 작업이 있는 워크로드에서는 지금도 완전히 유효한 모델이다. 붕괴는 "연결은 많은데 대부분이 놀고 있는"(유휴 keep-alive, 롱 폴링, 채팅) 워크로드에서 온다 — 노는 연결에도 스레드 하나씩을 물려 놓아야 하기 때문이다.
readiness 모델 — "준비되면 알려 달라"
대안의 재료는 두 가지다. 첫째, fd를 논블로킹(O_NONBLOCK)으로 바꾸면 read()는 데이터가 없을 때 재우는 대신 EAGAIN 오류로 즉시 리턴한다. 둘째, 그럼 "언제 다시 시도할지"를 알아야 하는데, 그것을 알려 주는 다중화(multiplexing) 시스템 콜이 있다: 여러 fd를 등록해 두고 "이 중 하나라도 준비되면 깨워 달라"고 한 번에 기다리는 것이다. 스레드 하나가 연결 1만 개의 대기를 대표할 수 있게 된다.
고전 인터페이스의 한계가 epoll의 존재 이유다. select(2)는 fd 집합을 비트마스크로 넘기는데 크기 상한(FD_SETSIZE, 통상 1024)이 있고, poll(2)은 상한은 없지만 호출마다 전체 fd 배열을 커널에 복사하고 커널이 전부 순회한다 — 관심 fd가 n개면 매 호출이 O(n)이고, 그중 준비된 것이 몇 개든 상관없다. 연결 1만 개 중 활동 중인 것이 10개인 전형적 상황에서 9,990개를 매번 헛확인하는 구조다.
epoll은 이 반복 비용을 등록 시점으로 옮긴다. epoll_create1로 커널 안에 인스턴스를 만들고, epoll_ctl로 관심 fd를 한 번 등록하면 커널에 상주한다. fd가 준비되는 순간 커널 내부 콜백이 그 fd를 인스턴스의 ready 리스트에 넣고, epoll_wait는 그 리스트를 꺼내 온다 — 비용이 관심 fd 수가 아니라 준비된 fd 수에 비례한다. C10K의 해법이 정확히 이 구조 전환이다.
epoll에는 통지 시맨틱이 두 가지 있고, 이 차이는 성능 옵션이 아니라 프로그래밍 계약이다.
- level-triggered(LT, 기본): "조건이 성립하는 동안" 계속 통지한다. 버퍼에 데이터가 남아 있으면 다음
epoll_wait도 그 fd를 반환한다. 덜 읽고 돌아와도 다시 알려 주므로 관대하다. - edge-triggered(ET,
EPOLLET): "조건이 성립하게 된 순간"에만 통지한다. 통지를 받았으면EAGAIN이 나올 때까지 소진하는 것이 계약이다 — 남긴 채epoll_wait로 돌아가면 새 데이터가 오기 전까지 그 fd의 이벤트는 다시 오지 않고, 해당 연결은 영원히 멈춘 것처럼 보인다. 대가로 얻는 것은 불필요한 깨움의 감소다.
이 다중화 루프를 한 스레드에 감은 것이 이벤트 루프다: epoll_wait → 준비된 fd들의 콜백 실행 → 다시 epoll_wait. Node.js(libuv), nginx, Redis가 모두 이 구조이고, 8.1에서 예고한 "커널의 선점형 스케줄링 위에 세운 사용자 공간의 협력적 스케줄링"이 바로 이것이다 — 콜백들은 서로를 선점하지 못하므로, 하나가 오래 돌면 전부가 기다린다(실무 관점에서 재론한다).
관찰 — Node.js 서버가 epoll 사용자임을 확인한다:
// echo.js — TCP echo 서버
const net = require('node:net');
net.createServer((sock) => sock.pipe(sock)).listen(4000);strace -f -e trace=epoll_create1,epoll_ctl,epoll_wait node echo.js
# 다른 터미널에서: echo hello | nc -q1 localhost 4000예상되는 관찰: 시작 시 epoll_create1(...), 리스닝 소켓 등록 epoll_ctl(..., EPOLL_CTL_ADD, ...), 그리고 반복되는 epoll_wait(...)가 보인다. 클라이언트가 접속하면 새 연결 fd에 대한 epoll_ctl(ADD)이 찍힌다. "libuv는 epoll의 포장"이라는 명제를 시스템 콜 수준에서 확인하는 것이다.
completion 모델 — "다 되면 알려 달라"
readiness 모델에도 남은 비용이 있다. 준비 통지는 통지일 뿐이라 I/O 자체는 여전히 별도 시스템 콜이다 — 활성 연결 1만 개면 epoll_wait 1번 + read 1만 번. 그리고 뒤에서 볼 정규 파일 문제가 있다.
completion 모델은 질문을 바꾼다. "읽을 준비가 됐는가"가 아니라 **"이 읽기를 수행해 두고, 끝나면 알려 달라"**다. Windows의 IOCP가 오래된 선례이고, Linux의 답이 커널 5.1(2019)의 io_uring이다. 구조는 이름 그대로 링 버퍼 두 개다: 애플리케이션과 커널이 공유하는 메모리에 제출 큐(SQ)와 완료 큐(CQ)를 두고, 애플리케이션은 요청(어떤 fd에 어떤 연산)을 SQ에 써넣고, 커널은 완료 결과를 CQ에 써넣는다. 제출 수십 건을 큐에 쌓고 io_uring_enter 한 번으로 알리면 되므로 시스템 콜 수가 연산 수에서 분리되고, 옵션에 따라 커널 폴링으로 시스템 콜 없이도 돌 수 있다. 모드 전환 비용(8.1)이 상수화되는 것이다.
readiness와의 근본 차이를 한 줄로: epoll은 "언제"를 알려 주고 I/O는 네가 한다. io_uring은 I/O를 커널이 해 주고 "끝났다"를 알려 준다. 완료 기반이므로 대상이 소켓인지 파일인지를 가리지 않는다는 점이 다음 절의 문제를 해소한다.
현실 경계도 있다. io_uring은 인터페이스가 넓고 커널 코드 경로가 새로워 보안 취약점의 소재가 되어 왔고, 일부 운영 환경(컨테이너 기본 seccomp 프로파일 등)이 이를 제한한다. 채택 판단에는 "우리 배포 환경에서 허용되는가"가 포함되어야 한다.
정규 파일의 함정 — epoll이 무의미한 곳
readiness 모델에는 구멍이 하나 있다. 정규 파일은 항상 "준비됨"으로 취급된다. readiness의 의미론은 "블로킹 없이 진행할 수 있는가"인데, 디스크 파일에는 소켓 같은 "아직 도착하지 않은 데이터를 기다리는" 상태 개념이 없기 때문이다 — 데이터는 항상 디스크에 "있고", 단지 가져오는 데 시간이 걸릴 뿐이다. 그래서 poll/select는 정규 파일 fd를 늘 ready로 보고하고, epoll은 아예 정규 파일 등록을 거부한다(epoll_ctl이 EPERM을 반환한다). 게다가 O_NONBLOCK도 정규 파일에는 사실상 효력이 없다 — 캐시 미스인 read()는 디스크 I/O 동안 그냥 블로킹된다.
결론: 이벤트 루프 스레드에서 정규 파일을 읽으면, 페이지 캐시 미스 순간 루프 전체가 디스크 지연만큼 멈춘다. libuv가 소켓은 epoll로 다루면서 파일 I/O(와 DNS 조회 등)만 별도 스레드 풀(기본 4개, UV_THREADPOOL_SIZE로 조정)로 우회하는 이유가 정확히 이것이다. "Node는 싱글 스레드"라는 통념은 이 지점에서 부정확하다 — 이벤트 루프는 하나지만, 그 루프가 파일 I/O를 위임하는 워커 스레드들이 뒤에 있다. fs.readFile을 여러 개 던지면 strace -f에서 별도 스레드들의 pread64가 관찰된다. 그리고 이 우회로가 io_uring이 매력적인 이유의 반대편이다: 완료 기반에서는 파일 읽기도 소켓과 같은 큐에 넣으면 되므로, 스레드 풀 우회 자체가 불필요해진다.
실무 관점
이벤트 루프를 멈추는 것들 — 구조적 이유
이벤트 루프의 전제는 "각 콜백이 짧다"이다. 루프는 협력적 스케줄링이라 선점이 없으므로, 콜백 하나가 오래 돌면 다른 모든 연결의 지연에 그대로 가산된다. 루프를 멈추는 3대 원인과 각각의 이유:
- CPU 바운드 코드 (큰 JSON 파싱, 압축, 암호화, 정규식 폭주): 콜백이 CPU를 놓지 않는다. 대응은 워커 스레드/프로세스로 분리.
- 동기 파일 API (
fs.readFileSync류): 디스크 지연 동안 루프 정지. 위에서 본 정규 파일의 함정이 언어 API에 노출된 형태다. - 비동기지만 스레드 풀이 포화된 파일 I/O:
fs.readFile도 풀 4개가 다 차면 큐에서 대기한다 — "비동기인데 왜 느리지"의 흔한 답.
증상은 p99 지연 스파이크로 나타나고, 원인 콜백은 프로파일러나 이벤트 루프 지연 지표(런타임이 제공)로 찾는다. 8.1의 관찰과 연결하면: 이 정체는 사용자 공간 스케줄링 내부의 일이라 커널 지표에는 잡히지 않는다.
"비동기 = 빠르다"가 아니다 — 정확한 이득의 진술
단일 요청의 지연만 보면 이벤트 루프는 블로킹 모델보다 빠를 이유가 없다. 같은 커널 경로로 같은 I/O를 하고, 오히려 콜백 디스패치 오버헤드가 얹힌다. 저동시성 구간에서는 시스템 콜 수도 불리하다 — 블로킹 read 1번이면 될 것을 epoll_wait + read로 2번 한다. 이득은 동시성의 단가다: 대기 중인 연결이 스레드가 아니라 epoll 등록 항목 하나(수십 바이트)이므로, 유휴 연결 1만 개를 스레드 1만 개 없이 유지한다. 따라서 판단 기준은 "빠른 게 필요한가"가 아니라 **"동시에 열려 있는, 대부분 노는 연결이 얼마나 많은가"**다. 연결 수십~수백에 연결당 작업이 실한 내부 서비스라면 thread-per-connection(또는 스레드 풀 + 블로킹 I/O)이 더 단순하고 충분히 빠르다. 교차점이 어디인지는 워크로드마다 다르며 — 그것을 직접 측정하는 것이 이 챕터의 실습 Part B다.
아키텍처 선택 체크리스트
| 질문 | thread-per-connection 쪽 신호 | 이벤트 루프 쪽 신호 |
|---|---|---|
| 동시 연결 수 | 수백 이하 | 수천~수만 (특히 유휴 다수) |
| 연결당 작업 | CPU·블로킹 호출이 실하게 있음 | 짧은 I/O 중계가 대부분 |
| 의존 라이브러리 | 블로킹 API뿐 (동기 드라이버 등) | 비동기 생태계 성숙 |
| 코드 복잡도 허용치 | 순차 코드 선호 | 비동기 제어 흐름 감당 (async/await로 완화) |
| 지연 특성 | 콜백 정체 걱정 없음 | CPU 바운드 유입 통제 가능할 때 |
혼합도 일반적이다: 이벤트 루프로 연결을 받고 CPU 작업은 워커 풀로 — nginx(+업스트림), Node(+worker_threads)가 이 형태다.
쓰기 내구성 체크리스트
데이터를 "잃으면 안 되는" 경로를 설계할 때 확인할 사슬: (1) write 후 fsync를 부르는가, 부른다면 트랜잭션 경계마다인가 배치인가(그룹 커밋). (2) 새 파일 생성·rename이 끼면 디렉터리 fsync까지 하는가. (3) fsync 실패를 치명 오류로 다루는가(재시도 금지). (4) 저장 장치에 전원 손실 보호가 있는가. (5) 반대로, 잃어도 되는 데이터(재생성 가능한 캐시, 지표)에 fsync를 남발해 위 관찰의 "자릿수" 비용을 내고 있지 않은가. 이 사슬 위에 DB가 WAL로 무엇을 쌓는지는 챕터 11에서 이어진다.
더 깊이
POSIX AIO는 왜 실패했고 io_uring은 왜 다른가
"비동기 파일 I/O"의 시도는 io_uring이 처음이 아니다. POSIX AIO(aio_read 등)는 표준이지만 glibc 구현이 커널 지원 없이 사용자 공간 스레드 풀로 흉내 낸 것이라 성능 이점이 없었고, Linux 네이티브 AIO(io_submit)는 O_DIRECT에서만 제대로 비동기라는 제약으로 DB 외에는 쓰이지 못했다. io_uring이 다른 점은 (1) 버퍼드 I/O를 포함한 광범위한 연산의 진짜 비동기 실행, (2) 공유 링 버퍼로 제출·수거 자체의 시스템 콜 비용을 제거한 것, (3) 소켓·파일·fsync까지 한 인터페이스로 통일한 것이다. 역사의 교훈: 인터페이스(표준)가 있어도 커널 쪽 실행 모델이 받쳐 주지 않으면 비동기는 스레드 풀 분장에 그친다.
edge trigger의 실전 계약 — 소진과 공정성
ET를 실제로 쓸 때의 계약 두 가지. 첫째, 소진: 통지받은 fd는 EAGAIN까지 읽어야 한다(accept도 마찬가지 — 연결 폭주 시 한 번의 통지에 여러 연결이 쌓여 있다). 둘째, 소진 계약의 부작용인 공정성: 초고속으로 데이터가 밀려오는 fd 하나를 EAGAIN까지 읽다 보면 다른 fd들이 굶는다. 프로덕션 이벤트 루프들이 "한 번에 읽는 양의 상한 + 남았으면 자체 ready 목록에 되넣기"를 구현하는 이유다. LT로 시작해서, 깨움 비용이 실측으로 문제가 될 때 ET로 가는 것이 안전한 기본값이다.
정리
- 모든 버퍼드 파일 I/O는 페이지 캐시를 경유한다.
write()리턴은 "커널 메모리에 복사됨"이고, 디스크 반영은 write-back이 나중에 한다. 내구성은 fsync 계약의 몫이며, 디렉터리 fsync·장치 캐시·fsync 오류 처리까지가 그 사슬이다. - 저널링은 크래시 후 파일시스템의 구조적 정합성(메타데이터)을 지키는 장치이고, 기본 모드에서 데이터 내용의 내구성은 보장하지 않는다.
- thread-per-connection의 비용(스택, 스케줄링, 스위치)은 동시 연결 수에 비례한다. readiness 다중화(epoll)는 대기를 스레드에서 분리해 그 비용을 상수화하되, LT/ET라는 통지 계약을 부과한다.
- io_uring의 completion 모델은 준비 통지 대신 완료 통지를 주고, 공유 링으로 시스템 콜 수를 연산 수에서 분리하며, 정규 파일까지 한 틀로 다룬다.
- 정규 파일에는 readiness가 성립하지 않는다 — epoll은 등록을 거부하고, 논블로킹 read도 디스크에서 블로킹된다. libuv가 파일 I/O만 스레드 풀로 우회하는 이유다.
- 이벤트 루프의 이득은 속도가 아니라 적은 스레드로 감당하는 동시성이다. 선택 기준은 유휴 동시 연결의 규모이고, 교차점은 측정으로 찾는다.
확인 문제
1. 로그 수집기가 write()로 이벤트를 기록하고 리턴을 확인한 뒤 업스트림에 ACK를 보낸다. 어느 날 서버 전원이 나갔고, ACK된 이벤트 중 마지막 수 초 분량이 디스크에 없다. 유실 구간이 "수 초"인 이유까지 포함해 메커니즘을 설명하고, 유실 허용량이 0인 경우와 "수 초 허용"인 경우 각각의 설계를 제시하라.
정답과 해설
write() 리턴은 페이지 캐시 도달만 보장한다. dirty page는 플러셔가 주기적으로(만료 기준 기본 30초, 백그라운드 임계 등) 내리므로, 전원 장애 시점에 아직 안 내려간 최근 쓰기 — 플러시 주기·부하에 따라 대략 수 초~수십 초 분량 — 가 유실된다. ACK가 내구성보다 앞서 나간 것이 결함이다. 유실 0 설계: fsync(또는 fdatasync)가 리턴한 뒤에만 ACK한다. 이벤트당 fsync는 자릿수로 느리므로 그룹 커밋(수 ms 창 안의 이벤트를 모아 한 번의 fsync 후 일괄 ACK)으로 단가를 나눈다. 파일 로테이션이 있다면 새 파일 생성 시 디렉터리 fsync도 사슬에 넣는다. 수 초 허용 설계: 지금 구조를 유지하되 허용량을 명시적으로 만든다 — 주기적 fsync(예: 1초)로 유실 상한을 계약하고, ACK 의미를 "수신"으로 재정의해 업스트림 재전송으로 복구 가능하게 한다.
2. edge-triggered epoll 기반 프록시에서, 트래픽이 많은 특정 클라이언트의 연결만 간헐적으로 "응답이 멈췄다가 새 요청을 보내면 밀린 응답이 한꺼번에 온다"는 신고가 있다. 원인 가설을 세우고 코드에서 무엇을 확인할지 말하라.
정답과 해설
ET의 소진 계약 위반이 유력하다. 통지를 받고 소켓을 읽되 EAGAIN까지 읽지 않고(예: 고정 크기 버퍼로 한 번만 read하고) 루프로 돌아가면, 커널 버퍼에 데이터가 남아 있어도 ET는 "새 데이터 도착"이라는 상태 변화가 있어야만 다시 통지한다. 트래픽이 많은 연결일수록 한 통지에 쌓인 데이터가 버퍼 크기를 넘겨 잔량이 남을 확률이 높다 — 특정 바쁜 클라이언트에서만 재현되는 이유다. 클라이언트가 새 요청을 보내면(새 데이터 도착 = 상태 변화) 통지가 다시 와서 밀린 것이 한꺼번에 처리된다 — 신고 내용과 정확히 일치한다. 확인할 것: read 루프가 EAGAIN(또는 read가 요청 크기보다 적게 리턴)까지 반복하는지, write 쪽도 EAGAIN 시 EPOLLOUT 등록으로 재개하는지. 수정 대안: LT로 전환하면 잔량이 있는 한 계속 통지되므로 이 부류의 버그가 사라진다(깨움 비용 증가와 교환).
3. 동시 연결이 평균 60, 피크 200인 사내 API 서버(연결당 DB 질의 + 응답 조립, 블로킹 JDBC 드라이버 사용)를 "성능을 위해" 이벤트 루프 프레임워크로 재작성하자는 제안이 나왔다. 이 제안을 평가하라.
정답과 해설
기각 쪽 논거가 강하다. (1) 이벤트 루프의 이득은 대량의 유휴 동시 연결을 스레드 없이 유지하는 것인데, 연결 200이면 thread-per-connection 비용(스택 수십 MiB, 태스크 200개)이 전혀 문제 규모가 아니다. (2) 단일 요청 지연은 이벤트 루프가 더 빠를 이유가 없다 — "성능을 위해"라는 전제가 이득의 종류를 잘못 짚었다. (3) 결정적으로 블로킹 JDBC가 남는다: 이벤트 루프 스레드에서 블로킹 드라이버를 부르면 루프가 정지하므로, 결국 DB 호출용 스레드 풀에 위임해야 하고 — 그러면 "이벤트 루프 + 뒤의 스레드 풀"로 원래 모델보다 복잡해지기만 한다. 재작성이 정당해지는 조건을 명시하면 평가가 완성된다: 동시 연결이 수천 이상으로 갈 전망(롱 폴링/스트리밍 도입 등)이거나, 논블로킹 DB 드라이버로의 전환을 포함한 전면 개편일 때다.
4. 새 스토리지 서비스의 읽기 벤치마크가 "예상보다 40배 빠르게" 나왔다. 팀은 기뻐하지만 당신은 의심스럽다. 페이지 캐시 관점에서 벤치마크 설계의 결함 후보를 들고, 신뢰할 수 있는 측정 절차를 제시하라.
정답과 해설
후보: (1) 같은 파일 집합을 반복해서 읽는 설계라 2회차부터는 디스크가 아니라 페이지 캐시(메모리)를 측정했다. (2) 데이터셋 총량이 물리 메모리보다 작아 웜업 후 전부 캐시에 상주한다. (3) 쓰기 직후 읽기라 방금 쓴 dirty page를 캐시에서 되읽었다. 절차: 측정 목적을 먼저 정한다 — "캐시 적중 포함한 실사용 성능"이 목적이면 실제 접근 분포와 실제 규모의 데이터셋으로 측정하되 캐시 적중률을 함께 보고하고, "디스크 경로 성능"이 목적이면 (a) 매 라운드 전 echo 3 > /proc/sys/vm/drop_caches로 콜드 스타트를 만들거나 (b) fio의 direct I/O로 캐시를 우회하고 (c) 데이터셋을 물리 메모리보다 충분히 크게 잡는다. 어느 쪽이든 환경(메모리 크기, 장치, 커널 버전)과 캐시 조건을 결과에 명시해야 재사용 가능한 결론이 된다.
참고 자료
- Remzi Arpaci-Dusseau, Andrea Arpaci-Dusseau, Operating Systems: Three Easy Pieces — 39~40장(파일과 디렉터리, 파일시스템 구현), 42장(크래시 일관성과 저널링). 데이터 경로 쪽 뼈대.
- man pages: epoll(7) — LT/ET 계약과 사용 패턴의 공식 서술(ET 소진 요건 포함), select(2), fsync(2) — 내구성 보장 범위와 주의사항, open(2) — O_SYNC/O_DIRECT/O_NONBLOCK의 의미론.
- Dan Kegel, The C10K problem — I/O 모델 계보의 역사적 출발점.
- Jens Axboe, Efficient IO with io_uring — io_uring 설계 문서(1차 자료). SQ/CQ 구조와 동기의 원저자 서술.
- libuv Design overview — 이벤트 루프와 스레드 풀의 역할 분담. "파일 I/O는 스레드 풀"의 공식 근거.
- LWN, PostgreSQL's fsync() surprise — fsync 오류 의미론 문제(fsyncgate)의 정리(보조 검증용).
- kernel.org 문서: Documentation for /proc/sys/vm/ — dirty_ratio 계열 write-back 파라미터의 공식 정의.
- Brendan Gregg, Systems Performance 2nd ed. (2020) — 8장 File Systems, 9장 Disks. 페이지 캐시 관찰과 I/O 지표 해석.