파일은 디스크 위의 단순한 바이트 덩어리가 아니다
개발자는 파일을 open, read, write, close 같은 함수로 다룬다. 그래서 파일을 하나의 단순한 데이터 덩어리처럼 생각하기 쉽다. 하지만 운영체제 관점에서 파일은 이름, 권한, 크기, 시간 정보, 실제 데이터 블록 위치 같은 메타데이터와 연결된 구조다.
파일 시스템은 디스크나 SSD 같은 저장장치 위에 파일과 디렉터리를 배치하고 찾을 수 있게 만드는 규칙이다. 애플리케이션은 저장장치의 특정 물리 위치를 직접 다루지 않는다. 대신 /var/log/app.log 같은 경로를 사용한다. 운영체제와 파일 시스템은 이 경로를 해석해 실제 데이터가 저장된 위치를 찾아준다.
디렉터리는 파일을 담는 상자처럼 보이지만, 실제로는 이름과 파일 식별자를 연결하는 특수한 파일에 가깝다. 예를 들어 유닉스 계열 시스템에서는 파일의 실제 정보가 주로 inode에 저장된다. inode는 파일의 소유자, 권한, 크기, 시간 정보, 데이터 블록 위치 같은 메타데이터를 가진다. 파일 이름은 inode 자체에 저장되지 않고, 디렉터리가 이름과 inode 번호를 매핑한다.
이 구조를 알면 몇 가지 현상을 이해하기 쉬워진다. 파일 이름을 바꾸는 rename은 데이터 전체를 복사하는 작업이 아니라 디렉터리의 이름 매핑을 바꾸는 작업이다. 그래서 같은 파일 시스템 안에서는 큰 파일의 이름을 바꿔도 매우 빠르다. 반대로 다른 파일 시스템으로 이동하면 이름 매핑만 바꿀 수 없기 때문에 실제 데이터 복사가 필요할 수 있다.
또 하나 중요한 점은 "열린 파일"과 "파일 이름"이 완전히 같은 개념이 아니라는 것이다. 어떤 프로세스가 파일을 열고 있는 동안 다른 프로세스가 그 파일을 삭제해도, 유닉스 계열 시스템에서는 열린 파일 디스크립터를 통해 데이터에 계속 접근할 수 있다. 디렉터리에서 이름은 사라졌지만, 파일을 참조하는 프로세스가 남아 있기 때문이다. 로그 파일을 삭제했는데 디스크 공간이 바로 회수되지 않는 문제가 여기서 발생한다.
AI 생성 이미지
순차 접근과 랜덤 접근은 비용이 다르다
파일을 읽는 방식은 크게 순차 접근과 랜덤 접근으로 나눌 수 있다. 순차 접근은 처음부터 끝까지 차례대로 읽거나 쓰는 방식이다. 로그 파일을 뒤에 계속 추가하거나, 대용량 CSV를 한 줄씩 읽는 작업이 여기에 가깝다. 랜덤 접근은 파일의 특정 위치로 이동해 필요한 부분만 읽거나 쓰는 방식이다. 데이터베이스 인덱스를 따라 특정 페이지를 읽는 작업, 동영상 파일의 중간 위치로 이동하는 작업이 대표적이다.
두 방식의 성능 차이는 저장장치 특성과 연결된다. HDD는 회전하는 디스크와 이동하는 헤드로 데이터를 읽는다. 원하는 위치가 여기저기 흩어져 있으면 헤드 이동과 회전 대기 시간이 커진다. 그래서 HDD에서는 순차 읽기가 랜덤 읽기보다 훨씬 유리하다.
SSD는 물리적으로 회전하지 않기 때문에 랜덤 접근 비용이 HDD보다 낮다. 하지만 그렇다고 랜덤 접근이 공짜는 아니다. SSD도 내부적으로 페이지와 블록 단위로 데이터를 관리하고, 컨트롤러, 플래시 변환 계층, 쓰기 증폭 같은 비용이 있다. 작은 랜덤 쓰기가 많이 발생하면 순차 쓰기보다 처리량이 떨어지고 장치 수명에도 영향을 줄 수 있다.
실무에서 이 차이는 자주 드러난다. 같은 1GB 데이터를 읽더라도 큰 덩어리로 순차 읽기하면 빠르게 끝날 수 있지만, 작은 조각을 수십만 번 랜덤으로 읽으면 훨씬 느려질 수 있다. 로그 수집기, 배치 처리, 검색 인덱스 생성, 데이터베이스 스캔 성능을 볼 때는 단순히 데이터 크기만 보지 말고 접근 패턴을 함께 봐야 한다.
데이터베이스가 데이터를 페이지 단위로 읽고, 인덱스를 사용하며, 쓰기 앞 로그(WAL)를 순차적으로 기록하려는 이유도 여기에 있다. 저장장치는 한 번에 큰 덩어리를 연속적으로 처리할 때 효율이 높다. 작은 I/O를 많이 만드는 코드는 시스템 콜 비용, 커널 버퍼 처리, 장치 대기 시간이 누적되어 예상보다 느려진다.
write가 끝났다고 데이터가 안전한 것은 아니다
파일 I/O에서 가장 많이 오해하는 부분은 write()의 의미다. 운영체제의 write() 시스템 콜이 성공했다는 것은 보통 데이터가 커널에 전달되었다는 뜻이지, 그 데이터가 즉시 물리 저장장치에 기록되었다는 뜻은 아니다. 또 표준 라이브러리나 언어 런타임의 파일 객체를 사용한다면, 그보다 앞단의 사용자 공간 버퍼에 잠시 머물 수도 있다. 운영체제는 성능을 위해 여러 쓰기를 모았다가 나중에 저장장치로 내보낸다.
버퍼링은 성능을 높이는 핵심 장치다. 한 글자씩 파일에 쓰는 것보다 메모리에 모았다가 한 번에 쓰는 편이 훨씬 빠르다. 시스템 콜 횟수가 줄고, 저장장치도 큰 단위의 순차 쓰기를 더 잘 처리한다. 표준 라이브러리의 파일 객체, 언어 런타임, 운영체제 페이지 캐시는 모두 이런 목적의 버퍼를 사용할 수 있다.
하지만 버퍼링은 데이터 안정성과 충돌한다. 애플리케이션 로그를 썼다고 생각했는데 프로세스가 갑자기 종료되거나 서버 전원이 꺼지면 마지막 몇 줄이 사라질 수 있다. 데이터가 애플리케이션 버퍼에는 있었지만 커널로 전달되지 않았거나, 커널 페이지 캐시에는 있었지만 저장장치에 반영되지 않았기 때문이다.
여기서 flush와 fsync를 구분해야 한다. 일반적으로 flush는 언어 런타임이나 라이브러리 버퍼에 남아 있는 데이터를 운영체제 쪽으로 밀어 넣는 동작이다. 즉, 프로그램 내부 버퍼를 비운다는 의미에 가깝다. 반면 fsync는 운영체제에 "이 파일의 변경 사항을 실제 저장장치에 반영해 달라"고 요청하는 동작이다.
따라서 flush만 호출했다고 장애 상황에서도 데이터가 반드시 보존된다고 말할 수 없다. 안정성이 정말 중요하다면 fsync 또는 그에 준하는 동기화 옵션을 고려해야 한다. 예를 들어 결제 내역, 주문 생성, 데이터베이스 트랜잭션 로그처럼 사라지면 안 되는 데이터는 쓰기 완료 시점을 엄격하게 정의해야 한다.
물론 fsync는 비싸다. 저장장치에 실제 반영될 때까지 기다려야 하므로 지연 시간이 커진다. 매 로그 한 줄마다 fsync를 호출하면 처리량이 급격히 떨어질 수 있다. 그래서 시스템은 보통 선택을 한다. "마지막 1초의 로그 손실은 허용하지만 성능을 높일 것인가", "응답은 느려져도 모든 트랜잭션을 안전하게 저장할 것인가" 같은 판단이다.
로그 시스템은 보통 일정 크기나 시간 단위로 버퍼를 비운다. 데이터베이스는 WAL을 먼저 기록하고, 트랜잭션 커밋 시점에 동기화 정책을 적용한다. 파일을 안전하게 교체할 때는 임시 파일에 쓰고, 파일 내용을 fsync한 뒤, rename으로 원자적으로 교체하는 패턴을 사용하기도 한다. 장애 상황까지 더 엄격하게 고려한다면 새 파일 이름이 들어 있는 디렉터리의 메타데이터도 동기화해야 한다. 이처럼 파일 I/O는 단순 저장이 아니라 성능과 안정성 사이의 설계다.
캐시는 빠르지만 착시를 만든다
파일을 두 번째 읽을 때 첫 번째보다 훨씬 빠른 경우가 있다. 코드가 최적화된 것이 아니라 운영체제 페이지 캐시에 데이터가 남아 있기 때문일 수 있다. 페이지 캐시는 디스크에서 읽은 파일 내용을 메모리에 보관해 다음 접근을 빠르게 만든다.
이 캐시는 매우 유용하다. 자주 읽는 설정 파일, 정적 리소스, 라이브러리 파일은 매번 디스크에서 읽지 않아도 된다. 하지만 성능 측정에서는 착시를 만든다. 개발 환경에서 한 번 실행한 뒤 다시 측정하면 파일이 메모리에 올라와 있어 실제 디스크 성능보다 좋게 보일 수 있다.
운영 환경에서도 캐시는 양날의 검이다. 메모리가 충분할 때는 파일 읽기가 빠르지만, 다른 프로세스가 메모리를 많이 사용하거나 워킹 셋이 커지면 캐시 적중률이 떨어진다. 갑자기 디스크 I/O가 증가하고 응답 시간이 흔들릴 수 있다. "어제까지 빠르던 조회가 오늘 느려졌다"는 현상은 데이터 크기 증가로 캐시가 더 이상 버티지 못할 때 자주 나타난다.
따라서 파일 기반 시스템을 설계할 때는 평균 속도만 보지 말고 캐시가 비었을 때, 데이터가 커졌을 때, 여러 프로세스가 동시에 접근할 때를 함께 생각해야 한다.
블로킹 I/O는 단순하지만 기다림을 만든다
입출력은 CPU 연산보다 훨씬 느린 작업이다. 파일을 읽거나 네트워크에서 데이터를 받을 때 프로그램은 외부 장치의 응답을 기다려야 한다. 이때 호출한 함수가 작업이 끝날 때까지 반환하지 않으면 블로킹 I/O라고 한다.
블로킹 I/O는 이해하기 쉽다. 코드는 위에서 아래로 실행되고, read()가 끝나면 다음 줄로 넘어간다. 작은 CLI 도구나 단순 배치 작업에서는 이 모델이 충분히 좋다. 문제는 서버 프로그램이다. 요청 하나를 처리하는 스레드가 디스크 읽기나 네트워크 응답을 기다리는 동안 그 스레드는 다른 일을 하지 못한다.
스레드를 많이 만들면 동시에 더 많은 요청을 처리할 수 있지만 한계가 있다. 스레드는 스택 메모리를 사용하고, 운영체제 스케줄링 대상이 되며, 많아질수록 컨텍스트 스위칭 비용이 커진다. 결국 "요청이 많아져서 느린 것"이 아니라 "대부분의 스레드가 I/O를 기다리느라 묶여 있는 것"일 수 있다.
논블로킹과 비동기는 서버 구조를 바꾼다
논블로킹 I/O는 작업이 당장 완료되지 않으면 기다리지 않고 즉시 반환하는 방식이다. 예를 들어 읽을 데이터가 아직 없으면 "지금은 읽을 수 없음"을 알려주고 프로그램은 다른 일을 할 수 있다. 이벤트 루프 기반 서버는 이런 방식으로 많은 연결을 적은 수의 스레드에서 관리한다.
논블로킹 I/O를 제대로 쓰려면 "언제 다시 읽거나 쓸 수 있는가"를 알아야 한다. 이를 위해 운영체제는 이벤트 통지 기능을 제공한다. 리눅스의 epoll, BSD 계열의 kqueue, 윈도우의 IOCP 같은 메커니즘이 대표적이다. 개발자는 직접 다루지 않더라도 Node.js, Nginx, Netty, async 런타임 같은 도구 안에서 이 구조를 간접적으로 사용한다.
비동기 I/O는 작업을 요청해 두고 완료되면 나중에 결과를 받는 방식이다. 호출자는 I/O가 끝날 때까지 현재 흐름을 붙잡고 있지 않아도 된다. 다만 모든 비동기가 운영체제 수준의 진짜 비동기 I/O인 것은 아니다. 어떤 런타임은 내부 스레드 풀에 블로킹 작업을 맡기고, 겉으로만 비동기 API처럼 보이게 만들기도 한다.
이 차이는 서버 설계에 영향을 준다. CPU를 많이 쓰는 작업을 이벤트 루프에서 오래 실행하면 다른 I/O 이벤트 처리가 밀린다. 반대로 블로킹 파일 I/O를 이벤트 루프 안에서 직접 호출하면 전체 서버가 멈춘 것처럼 보일 수 있다. 그래서 비동기 서버에서는 CPU 작업, 디스크 작업, 네트워크 작업을 어떤 실행 단위에 배치할지 신중히 정해야 한다.
개발자가 판단해야 할 것
파일 시스템과 I/O를 이해한다는 것은 API 이름을 많이 아는 것이 아니다. 데이터가 어디까지 도착했는지, 어떤 버퍼에 머물러 있는지, 저장장치에는 언제 반영되는지 판단할 수 있어야 한다.
로그가 사라졌다면 로그 라이브러리 버퍼, 프로세스 종료 방식, 파일 회전, flush 정책, 운영체제 페이지 캐시를 의심해야 한다. 디스크 접근이 느리다면 데이터 크기뿐 아니라 순차 접근인지 랜덤 접근인지, 작은 I/O가 너무 많은지, 캐시 적중률이 떨어졌는지 봐야 한다. 서버가 멈춘다면 CPU가 바쁜지, 스레드가 I/O에서 막혔는지, 이벤트 루프를 블로킹한 코드가 있는지 확인해야 한다.
파일과 입출력은 운영체제가 제공하는 추상화 덕분에 단순해 보인다. 하지만 그 아래에는 메타데이터, 캐시, 버퍼, 저장장치 특성, 동기화 비용이 있다. 이 계층을 이해하면 "파일에 썼는데 왜 없어졌는가", "디스크는 충분히 빠른데 왜 느린가", "비동기 서버인데 왜 멈추는가" 같은 질문에 더 정확히 답할 수 있다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.