파일 시스템이란
프로세스가 종료되면 메모리의 데이터는 사라집니다. 데이터를 영구적으로 보관하려면 디스크에 저장해야 합니다. 파일 시스템 은 디스크 위에 데이터를 체계적으로 저장하고 관리하는 구조입니다.
파일 시스템이 해결하는 문제:
- 이름 지정 : 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를 찾는 과정:
- 루트 inode(2) 읽기 -- 루트 디렉터리의 데이터 블록 확인
- 루트 데이터 블록에서 "home" 찾기 -- inode 4
- inode 4의 데이터 블록에서 "user" 찾기 -- inode 12
- inode 12의 데이터 블록에서 "doc.txt" 찾기 -- inode 37
- inode 37에서 파일 메타데이터 및 데이터 블록 위치 확인
경로가 깊을수록 디스크 접근 횟수가 늘어납니다. 이 비용을 줄이기 위해 OS는 디렉터리 엔트리 캐시 (dentry cache) 를 메모리에 유지합니다.
파일 디스크립터
애플리케이션이 파일을 열면 OS는 파일 디스크립터 (fd)라는 정수를 반환합니다. 이후 모든 파일 조작은 이 fd를 통해 이루어집니다.
| fd | 용도 |
|---|---|
| 0 | stdin (표준 입력) |
| 1 | stdout (표준 출력) |
| 2 | stderr (표준 에러) |
| 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, 버퍼 캐시, 최종 반환까지의 전체 흐름입니다.
같은 파일을 두 번째로 읽으면 버퍼 캐시 덕분에 디스크 I/O를 건너뜁니다.
동기 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 수 제한 없음, 여전히 매번 전체 검사 |
epoll | Linux | 이벤트 기반, 준비된 fd만 반환, 대규모 동시 접속에 적합 |
kqueue | macOS/BSD | epoll과 유사, 파일/프로세스/시그널 등 다양한 이벤트 지원 |
IOCP | Windows | 비동기 완료 통보 기반, 스레드 풀과 통합 |
버퍼 캐시 (페이지 캐시)
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-SCAN | SCAN 개선, 균등한 대기 시간 | 한 방향만 처리 |
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 완료 후 콜백 큐에 등록내부 흐름:
fs.readFile()호출- libuv가 스레드 풀 (기본 4개)의 워커 스레드에
open()+read()작업을 위임 - 워커 스레드가 블로킹 시스템 콜 로 파일 읽기 수행
- 읽기 완료 시 이벤트 루프에 완료 이벤트 등록
- 이벤트 루프의 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/O | inode, 디렉터리, 파일 디스크립터, 비동기 I/O, 버퍼 캐시 |
이 5개 주제는 서로 연결됩니다.
- 프로세스가 파일을 열면 파일 디스크립터 가 할당되고
- 파일 데이터를 읽으면 가상 메모리 의 페이지 캐시를 거치며
- I/O 대기 중인 프로세스는 스케줄러 에 의해 대기 상태로 전환되고
- 여러 스레드가 같은 파일에 동시 접근하면 동기화 가 필요합니다
OS는 하드웨어의 복잡성을 숨기고, 애플리케이션에 깔끔한 추상화를 제공하는 소프트웨어 계층입니다. 이 추상화의 원리를 이해하면, Node.js의 이벤트 루프가 왜 그렇게 동작하는지, 브라우저의 렌더링이 왜 메인 스레드에서 이루어지는지, 데이터베이스가 왜 fsync를 호출하는지 -- 모두 자연스럽게 이해됩니다.