Ray Book
운영체제 기초

파일 시스템과 I/O

데이터가 디스크에 저장되는 방법, inode, 파일 시스템 구조, 비동기 I/O를 시각화합니다

csosfile-systeminodeioasync-io

파일 시스템이란

프로세스가 종료되면 메모리의 데이터는 사라집니다. 데이터를 영구적으로 보관하려면 디스크에 저장해야 합니다. 파일 시스템 은 디스크 위에 데이터를 체계적으로 저장하고 관리하는 구조입니다.

파일 시스템이 해결하는 문제:

  • 이름 지정 : 0과 1의 연속인 디스크에 사람이 이해할 수 있는 이름 부여
  • 조직화 : 디렉터리(폴더) 구조로 파일을 계층적으로 관리
  • 메타데이터 : 파일 크기, 생성 시간, 권한 등 부가 정보 관리
  • 공간 할당 : 새 파일을 어디에 저장할지, 삭제된 공간을 어떻게 재사용할지 결정

파일 시스템 구조

전형적인 Unix 파일 시스템(ext4 등)은 디스크를 다음과 같은 영역으로 나눕니다.

영역역할
슈퍼블록파일 시스템 전체 메타데이터 (블록 크기, 총 블록 수, inode 수, 마운트 정보)
inode 비트맵어떤 inode가 사용 중인지 표시하는 비트 배열
데이터 비트맵어떤 데이터 블록이 사용 중인지 표시하는 비트 배열
inode 테이블모든 inode를 저장하는 영역
데이터 블록실제 파일 내용이 저장되는 영역
디스크 레이아웃 (ext4 단순화):

 슈퍼블록  inode 비트맵  데이터 비트맵  inode 테이블     데이터 블록
[  SB  ] [  IB  ]     [  DB  ]     [ I0 I1 I2 ... ] [ D0 D1 D2 D3 ... ]

슈퍼블록이 손상되면 파일 시스템 전체를 읽을 수 없으므로, 대부분의 파일 시스템은 슈퍼블록 사본 을 여러 곳에 백업합니다.

inode 구조

inode는 파일의 모든 메타데이터를 저장하는 자료구조입니다. 파일 이름을 제외한 거의 모든 정보가 inode에 들어 있습니다.

필드내용
파일 타입일반 파일, 디렉터리, 심볼릭 링크 등
권한rwxr-xr-x (소유자/그룹/기타)
소유자UID, GID
크기바이트 단위 파일 크기
타임스탬프atime (접근), mtime (수정), ctime (변경)
링크 카운트이 inode를 가리키는 디렉터리 항목 수
블록 포인터데이터 블록 주소 (직접, 간접, 이중 간접, 삼중 간접)

블록 포인터는 계층적 구조를 사용합니다.

inode 블록 포인터 구조:

직접 포인터 (12개)    : 각각 1개 블록을 직접 가리킴
                       12 x 4KB = 48KB까지 직접 접근

단일 간접 포인터 (1개) : 포인터 블록 1개를 거쳐 접근
                       (4KB / 4B) x 4KB = 4MB

이중 간접 포인터 (1개) : 포인터 블록 2단계
                       1024 x 1024 x 4KB = 4GB

삼중 간접 포인터 (1개) : 포인터 블록 3단계
                       1024 x 1024 x 1024 x 4KB = 4TB

작은 파일은 직접 포인터만으로 충분하고, 큰 파일만 간접 포인터를 사용합니다. 대부분의 파일이 작다는 통계적 사실을 반영한 설계입니다.

디렉터리 구조

디렉터리도 파일입니다. 다만 디렉터리의 데이터 블록에는 파일 내용 대신 이름과 inode 번호의 매핑 이 저장됩니다.

/ (루트 디렉터리, inode 2)의 데이터 블록 내용:

  이름        inode
  .           2      (자기 자신)
  ..          2      (부모, 루트이므로 자기 자신)
  etc         11
  home        14
  app.js      12
  data.json   13

파일 경로 /home/user/doc.txt를 찾는 과정:

  1. 루트 inode(2) 읽기 -- 루트 디렉터리의 데이터 블록 확인
  2. 루트 데이터 블록에서 "home" 찾기 -- inode 4
  3. inode 4의 데이터 블록에서 "user" 찾기 -- inode 12
  4. inode 12의 데이터 블록에서 "doc.txt" 찾기 -- inode 37
  5. inode 37에서 파일 메타데이터 및 데이터 블록 위치 확인

경로가 깊을수록 디스크 접근 횟수가 늘어납니다. 이 비용을 줄이기 위해 OS는 디렉터리 엔트리 캐시 (dentry cache) 를 메모리에 유지합니다.

파일 디스크립터

애플리케이션이 파일을 열면 OS는 파일 디스크립터 (fd)라는 정수를 반환합니다. 이후 모든 파일 조작은 이 fd를 통해 이루어집니다.

fd용도
0stdin (표준 입력)
1stdout (표준 출력)
2stderr (표준 에러)
3+사용자가 연 파일, 소켓 등

프로세스마다 파일 디스크립터 테이블을 가지며, 각 항목은 시스템 전역 오픈 파일 테이블 의 엔트리를 가리킵니다. 오픈 파일 테이블 엔트리는 다시 inode를 가리킵니다.

프로세스 fd 테이블    오픈 파일 테이블      inode 테이블
  fd 0 -----------> [ offset=0, flags ] ----> inode (stdin)
  fd 1 -----------> [ offset=0, flags ] ----> inode (stdout)
  fd 2 -----------> [ offset=0, flags ] ----> inode (stderr)
  fd 3 -----------> [ offset=124, R   ] ----> inode #3 (data.json)

fork()로 자식 프로세스를 만들면 fd 테이블이 복사되므로, 부모와 자식이 같은 오픈 파일 테이블 엔트리를 공유합니다. 이것이 파이프(|)가 동작하는 원리입니다.

파일 읽기 전체 흐름

아래 시각화는 read("/data.json") 호출 시 커널 내부에서 일어나는 과정을 보여줍니다. 애플리케이션의 시스템 콜부터 inode 조회, 데이터 블록 위치 확인, 디스크 I/O, 버퍼 캐시, 최종 반환까지의 전체 흐름입니다.

read() 시스템 콜단계 1 / 6
애플리케이션
read() 대기 중
시스템 콜
파일 디스크립터
미할당
inode 조회
inode 테이블
#이름타입크기블록
0/DIR4KB[0]
1etcDIR4KB[1]
2app.jsFILE12KB[5, 6, 7]
3data.jsonFILE8KB[10, 11]
블록 위치 확인
데이터 블록 (디스크)
블록 5const app = ...
블록 6function init()
블록 7module.exports
블록 10{"users":[...
블록 11"config":{...
캐시 적재
버퍼 캐시 (메모리)
비어 있음
애플리케이션이 read("/data.json") 시스템 콜을 호출합니다. 사용자 모드에서 커널 모드로 전환되고, VFS(Virtual File System) 계층이 요청을 받습니다. 커널은 파일 경로를 분석하여 해당 파일의 inode를 찾아야 합니다.

같은 파일을 두 번째로 읽으면 버퍼 캐시 덕분에 디스크 I/O를 건너뜁니다.

read() 시스템 콜단계 1 / 4
애플리케이션
read() 대기 중
시스템 콜
파일 디스크립터
미할당
inode 조회
inode 테이블
#이름타입크기블록
0/DIR4KB[0]
1etcDIR4KB[1]
2app.jsFILE12KB[5, 6, 7]
3data.jsonFILE8KB[10, 11]
블록 위치 확인
데이터 블록 (디스크)
블록 5const app = ...
블록 6function init()
블록 7module.exports
블록 10{"users":[...
블록 11"config":{...
캐시 적재
버퍼 캐시 (메모리)CACHED
블록 10{"users":[...
블록 11"config":{...
애플리케이션이 data.json을 두 번째로 읽습니다. 다시 read() 시스템 콜이 호출됩니다. 이전 읽기에서 버퍼 캐시에 데이터가 남아 있을 수 있습니다.

동기 I/O vs 비동기 I/O

I/O 방식에 따라 프로세스가 대기하는 방법이 다릅니다.

동기 (Blocking) I/O

애플리케이션       커널             디스크
    |               |               |
    |-- read() ---->|               |
    |   (블로킹)     |-- 디스크 요청 ->|
    |   (대기...)    |   (대기...)    |
    |               |<- 데이터 반환 --|
    |<- 데이터 반환 --|               |
    |               |               |
    | (이제야 다음 코드 실행)

read()를 호출하면 디스크 I/O가 완료될 때까지 프로세스가 블로킹 됩니다. 단순하지만, I/O 대기 동안 CPU를 낭비합니다.

비동기 (Non-blocking) I/O

애플리케이션       커널             디스크
    |               |               |
    |-- read() ---->|               |
    |<- EAGAIN -----|               |  (즉시 반환)
    |               |-- 디스크 요청 ->|
    | (다른 작업)    |               |
    |               |<- 데이터 반환 --|
    |-- poll/epoll ->|               |
    |<- 준비됨! -----|               |
    |-- read() ---->|               |
    |<- 데이터 반환 --|               |

비동기 I/O에서는 read() 호출이 즉시 반환됩니다. 데이터가 준비되면 이벤트 알림 (epoll, kqueue, IOCP 등)을 통해 애플리케이션에 통보합니다.

I/O 멀티플렉싱

메커니즘OS특징
select범용최대 fd 수 제한 (보통 1024), 매번 전체 검사
poll범용fd 수 제한 없음, 여전히 매번 전체 검사
epollLinux이벤트 기반, 준비된 fd만 반환, 대규모 동시 접속에 적합
kqueuemacOS/BSDepoll과 유사, 파일/프로세스/시그널 등 다양한 이벤트 지원
IOCPWindows비동기 완료 통보 기반, 스레드 풀과 통합

버퍼 캐시 (페이지 캐시)

OS는 디스크에서 읽은 데이터를 메모리에 캐싱합니다. Linux에서는 이를 페이지 캐시라고 부릅니다.

접근 속도 비교:
  L1 캐시       ~1ns
  메인 메모리    ~100ns
  SSD           ~100,000ns   (0.1ms)
  HDD           ~10,000,000ns (10ms)

메모리와 디스크의 속도 차이가 10만 배이므로, 캐싱의 효과는 극적입니다.

캐시 동작 정책:

  • Read-ahead : 요청한 블록 이후의 블록도 미리 읽어옴 (순차 접근 최적화)
  • Write-back : 쓰기를 즉시 디스크에 반영하지 않고 캐시에만 기록, 나중에 일괄 반영
  • Write-through : 쓰기를 캐시와 디스크에 동시 반영 (안전하지만 느림)

Write-back은 성능이 좋지만, 캐시에만 있는 데이터가 정전 시 유실될 수 있습니다. 데이터베이스는 이 문제를 fsync() 시스템 콜로 해결합니다 -- 캐시를 강제로 디스크에 플러시합니다.

디스크 스케줄링

HDD에서 디스크 헤드의 이동(탐색)은 가장 비용이 큰 작업입니다. 여러 I/O 요청이 쌓여 있을 때, 어떤 순서로 처리할지 결정하는 것이 디스크 스케줄링 입니다.

FCFS (First-Come First-Served)

요청이 들어온 순서대로 처리합니다. 공정하지만, 헤드가 불필요하게 왕복할 수 있습니다.

요청 큐: [98, 183, 37, 122, 14, 124, 65, 67]
현재 헤드 위치: 53

이동 순서: 53 -> 98 -> 183 -> 37 -> 122 -> 14 -> 124 -> 65 -> 67
총 이동 거리: 640 실린더

SCAN (엘리베이터 알고리즘)

헤드가 한쪽 끝까지 이동하면서 경로 상의 요청을 처리하고, 디스크 끝 에 도달하면 방향을 반전합니다. 엘리베이터 동작과 동일합니다.

요청 큐: [98, 183, 37, 122, 14, 124, 65, 67]
현재 헤드 위치: 53, 방향: 증가, 디스크 범위: 0-199

이동 순서: 53 -> 65 -> 67 -> 98 -> 122 -> 124 -> 183 -> 199(디스크 끝) -> 37 -> 14
총 이동 거리: (199 - 53) + (199 - 14) = 146 + 185 = 331 실린더

실제로는 마지막 요청까지만 이동하고 반전하는 LOOK 변형이 더 많이 사용됩니다. LOOK은 199까지 가지 않고 183에서 반전하므로 이동 거리가 299로 줄어듭니다.

알고리즘장점단점
FCFS공정, 구현 단순헤드 이동 거리 큼
SCAN이동 거리 적음, 처리량 좋음양 끝 요청에 불공정
C-SCANSCAN 개선, 균등한 대기 시간한 방향만 처리

SSD는 탐색 시간이 없으므로 디스크 스케줄링의 중요성이 크게 줄어듭니다. 현대 Linux에서는 SSD에 mq-deadline이나 none 스케줄러를 사용합니다.

실무: Node.js fs 모듈과 libuv

Node.js에서 파일 시스템 작업은 fs 모듈을 통해 이루어집니다. 그 내부에서 libuv 가 OS의 비동기 I/O를 추상화합니다.

fs.readFile 내부 동작

const fs = require('fs');

// 비동기 읽기 - 논블로킹
fs.readFile('/data.json', 'utf8', (err, data) => {
  console.log(data); // 3. 콜백 실행
});
console.log('다음 코드'); // 1. 먼저 실행
// 2. libuv 스레드 풀에서 I/O 완료 후 콜백 큐에 등록

내부 흐름:

  1. fs.readFile() 호출
  2. libuv가 스레드 풀 (기본 4개)의 워커 스레드에 open() + read() 작업을 위임
  3. 워커 스레드가 블로킹 시스템 콜 로 파일 읽기 수행
  4. 읽기 완료 시 이벤트 루프에 완료 이벤트 등록
  5. 이벤트 루프의 Poll 단계에서 콜백 실행
메인 스레드 (이벤트 루프)        libuv 스레드 풀
    |                              |
    |-- fs.readFile() 요청 -------->|
    |   (즉시 반환, 논블로킹)        |
    |                              |-- open() (블로킹)
    | console.log('다음 코드')      |-- read() (블로킹)
    |                              |-- (완료)
    |<---- 완료 이벤트 등록 ---------|
    |                              |
    |-- 콜백 실행: callback(data) --|

Node.js의 비동기 파일 I/O는 실제로는 libuv 스레드 풀에서 동기적으로 실행됩니다. 메인 스레드가 블로킹되지 않을 뿐, 파일 I/O 자체는 OS의 블로킹 시스템 콜을 사용합니다. 반면 네트워크 I/O는 스레드 풀을 사용하지 않고 OS의 비동기 메커니즘 (epoll, kqueue) 을 직접 활용합니다.

동기 vs 비동기 API

// 동기 - 메인 스레드 블로킹 (서버에서 절대 사용 금지)
const data = fs.readFileSync('/data.json', 'utf8');

// 비동기 콜백
fs.readFile('/data.json', 'utf8', (err, data) => { /* ... */ });

// Promise 기반
const fsPromises = require('fs').promises;
const data2 = await fsPromises.readFile('/data.json', 'utf8');

서버 환경에서 readFileSync를 사용하면 파일 읽기 동안 다른 모든 요청 처리가 멈춥니다. 이벤트 루프가 블로킹되기 때문입니다. 반드시 비동기 API를 사용해야 합니다.

libuv 스레드 풀 크기 조정

// UV_THREADPOOL_SIZE 환경 변수로 스레드 풀 크기 설정 (기본 4, 최대 1024)
// 파일 I/O가 많은 서버에서 늘릴 수 있음
// UV_THREADPOOL_SIZE=16 node server.js

스레드 풀이 모두 바쁘면 새 I/O 요청은 큐에서 대기합니다. fs, dns.lookup(), crypto 등이 모두 같은 풀을 공유하므로, 한 종류의 작업이 풀을 독점하면 다른 작업에 영향을 줍니다.

시리즈 전체 복습

OS 기초 시리즈 5편을 통해 다음을 다뤘습니다.

주제핵심 개념
1편프로세스와 스레드프로세스 메모리 구조, 스레드 공유 모델, 컨텍스트 스위칭
2편CPU 스케줄링FCFS, SJF, RR, MLFQ, 선점/비선점
3편동기화와 데드락뮤텍스, 세마포어, 교착 상태 조건, 회피 전략
4편메모리 관리가상 메모리, 페이징, TLB, 페이지 폴트, 교체 알고리즘
5편파일 시스템과 I/Oinode, 디렉터리, 파일 디스크립터, 비동기 I/O, 버퍼 캐시

이 5개 주제는 서로 연결됩니다.

  • 프로세스가 파일을 열면 파일 디스크립터 가 할당되고
  • 파일 데이터를 읽으면 가상 메모리 의 페이지 캐시를 거치며
  • I/O 대기 중인 프로세스는 스케줄러 에 의해 대기 상태로 전환되고
  • 여러 스레드가 같은 파일에 동시 접근하면 동기화 가 필요합니다

OS는 하드웨어의 복잡성을 숨기고, 애플리케이션에 깔끔한 추상화를 제공하는 소프트웨어 계층입니다. 이 추상화의 원리를 이해하면, Node.js의 이벤트 루프가 왜 그렇게 동작하는지, 브라우저의 렌더링이 왜 메인 스레드에서 이루어지는지, 데이터베이스가 왜 fsync를 호출하는지 -- 모두 자연스럽게 이해됩니다.