파일 시스템 실무
파일 시스템의 이론을 알았으니, 실제 개발과 운영에서 마주치는 실무적 개념들을 정리합니다. 파일 디스크립터, 캐시, 디스크 관리 명령어, RAID까지 — 서버를 다루는 개발자에게 필수적인 지식입니다.
파일 디스크립터와 파일 테이블
프로세스가 open()으로 파일을 열면 커널이 파일 디스크립터(File Descriptor, fd)를 반환합니다. 0, 1, 2는 미리 예약되어 있습니다.
- 0: 표준 입력(stdin)
- 1: 표준 출력(stdout)
- 2: 표준 오류(stderr)
3단계 테이블 구조
커널은 세 개의 테이블로 파일 접근을 관리합니다.
1단계 — 프로세스별 fd 테이블: 각 프로세스가 가진 배열입니다. 인덱스가 fd 번호이고, 열린 파일 테이블 엔트리에 대한 포인터를 담습니다.
2단계 — 시스템 전역 열린 파일 테이블(Open File Table): 모든 프로세스가 공유합니다. 각 엔트리에 파일 오프셋(현재 읽기/쓰기 위치), 접근 모드(읽기/쓰기), 참조 카운트가 저장됩니다. fork() 후 부모와 자식은 같은 엔트리를 공유하므로 오프셋도 공유합니다.
3단계 — inode(vnode) 테이블: 파일의 실제 메타데이터입니다. 여러 열린 파일 테이블 엔트리가 동일한 inode를 가리킬 수 있습니다.
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
int main() {
int fd = open("shared.txt", O_CREAT | O_RDWR | O_TRUNC, 0644);
write(fd, "ABCDEFGHIJ", 10);
lseek(fd, 0, SEEK_SET);
pid_t pid = fork();
if (pid == 0) {
/* 자식: 3바이트 읽기 → 오프셋 3으로 이동 */
char buf[4] = {0};
read(fd, buf, 3);
printf("자식 읽음: %s\n", buf); /* "ABC" */
_exit(0);
}
wait(NULL);
/* 부모: 자식이 움직인 오프셋을 공유하므로 3부터 읽음 */
char buf[4] = {0};
read(fd, buf, 3);
printf("부모 읽음: %s\n", buf); /* "DEF" (오프셋 공유!) */
close(fd);
return 0;
}dup2와 리다이렉션
셸에서 ./program > output.txt로 리다이렉션하면, 셸이 fd 1(stdout)을 output.txt의 fd로 교체합니다. 내부적으로 dup2() 시스템 콜을 사용합니다.
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("output.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);
/* fd 1(stdout)을 output.txt의 fd로 교체 */
dup2(fd, STDOUT_FILENO);
close(fd); /* 원래 fd는 닫아도 됨, stdout이 가리키고 있으므로 */
/* 이제 printf 출력이 output.txt로 감 */
printf("이 출력은 파일에 기록됩니다\n");
/* stderr(fd 2)는 여전히 터미널로 출력 */
fprintf(stderr, "이 출력은 터미널에 표시됩니다\n");
return 0;
}파이프(|)도 같은 원리입니다. ls | grep txt에서 셸은 pipe() 시스템 콜로 파이프를 만들고, ls의 stdout을 파이프의 쓰기 끝에, grep의 stdin을 파이프의 읽기 끝에 연결합니다.
버퍼 캐시와 페이지 캐시
디스크 I/O는 느리므로(HDD 약 10ms, SSD 약 0.1ms, DRAM 약 0.0001ms), OS는 최근 접근한 디스크 블록을 메모리에 캐시합니다.
페이지 캐시(Page Cache)는 Linux에서 파일 데이터를 메모리 페이지 단위로 캐시하는 메커니즘입니다. 파일을 읽으면 디스크에서 가져온 데이터가 페이지 캐시에 저장되고, 같은 데이터를 다시 읽을 때는 디스크를 거치지 않습니다.
free 명령의 buff/cache 열이 이 캐시입니다. 전체 메모리의 대부분이 캐시로 사용되는 것은 정상입니다. 메모리가 부족해지면 OS가 캐시를 해제하여 프로세스에 할당합니다.
쓰기 정책
Write-back: 쓰기 작업은 먼저 페이지 캐시에만 반영되고, 나중에 디스크에 기록됩니다. 커널 스레드(pdflush/flush)가 주기적으로(기본 30초) 또는 더티 페이지 비율이 임계값을 넘으면 디스크에 씁니다. 성능은 좋지만 전원 장애 시 데이터 손실 위험이 있습니다.
fsync(): 캐시의 내용을 즉시 디스크에 기록하도록 강제합니다. 데이터베이스처럼 데이터 무결성이 중요한 프로그램은 반드시 사용합니다. fdatasync()는 메타데이터를 제외하고 데이터만 동기화하여 약간 빠릅니다.
O_DIRECT: 페이지 캐시를 우회하여 디스크에 직접 읽고 씁니다. 데이터베이스(Oracle, MySQL InnoDB)는 자체 버퍼 풀을 관리하므로 OS의 이중 캐싱을 피하기 위해 O_DIRECT를 사용합니다.
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd = open("critical.dat", O_CREAT | O_WRONLY | O_TRUNC, 0644);
const char *data = "important transaction data\n";
write(fd, data, strlen(data));
/* 데이터를 즉시 디스크에 기록 (전원 장애 대비) */
fsync(fd);
close(fd);
return 0;
}선읽기(Read-ahead)
OS는 파일을 순차적으로 읽는 패턴을 감지하면, 아직 요청되지 않은 다음 블록들을 미리 읽어 캐시에 올려놓습니다. 순차 I/O 성능이 크게 향상됩니다. Linux에서 blockdev --getra /dev/sda로 선읽기 크기를 확인할 수 있습니다.
디스크 관리 명령어
# 파일 시스템별 사용량 (-T: 유형, -h: 사람이 읽기 좋게)
df -hT
# 디렉토리별 사용량 (정렬하여 큰 것 찾기)
du -sh /var/* | sort -rh | head -10
# inode 사용량 (파일 수가 많으면 용량이 남아도 inode 소진 가능)
df -i
# 특정 파일을 열고 있는 프로세스 확인
lsof /var/log/syslog
# 삭제됐지만 프로세스가 열고 있어 공간이 해제 안 된 파일 찾기
lsof +L1
# 파일 시스템 검사 (비마운트 상태에서)
fsck /dev/sda1
# 디스크 I/O 모니터링
iostat -x 1 5inode 소진은 흔한 장애 원인입니다. 작은 파일이 수백만 개 있으면 디스크 용량은 남아 있는데 새 파일을 만들 수 없습니다. 메일 서버나 캐시 서버에서 자주 발생합니다. df -i로 inode 사용률을 확인해야 합니다.
RAID
RAID(Redundant Array of Independent Disks)는 여러 디스크를 조합하여 성능, 용량, 안정성을 높이는 기술입니다.
| RAID | 방식 | 최소 디스크 | 용량 효율 | 읽기 속도 | 쓰기 속도 | 내결함성 |
|---|---|---|---|---|---|---|
| 0 | 스트라이핑 | 2 | 100% | N배 | N배 | 없음 |
| 1 | 미러링 | 2 | 50% | 2배 | 1배 | 1개 고장 |
| 5 | 분산 패리티 | 3 | (N-1)/N | ~(N-1)배 | 감소 | 1개 고장 |
| 6 | 이중 패리티 | 4 | (N-2)/N | ~(N-2)배 | 더 감소 | 2개 고장 |
| 10 | 미러+스트라이프 | 4 | 50% | N배 | N/2배 | 각 쌍 1개 |
RAID 0: 데이터를 여러 디스크에 분산합니다. 한 디스크라도 고장나면 전체 데이터를 잃습니다. 임시 데이터나 성능이 중요한 워크로드에 사용합니다.
RAID 1: 같은 데이터를 두 디스크에 복제합니다. 한 디스크가 고장나도 안전합니다. OS 부팅 디스크에 주로 사용합니다.
RAID 5: 패리티를 디스크에 분산 저장합니다. 하나의 디스크 고장까지 복구 가능합니다. 읽기가 많은 파일 서버에 적합합니다. 쓰기 시 패리티 계산 오버헤드가 있습니다(write penalty).
RAID 10: 미러링 + 스트라이핑. 성능과 안정성 모두 우수합니다. 데이터베이스 서버의 표준 구성입니다. 비용은 높습니다(디스크 용량 절반만 사용).
실무 고려사항
RAID는 백업이 아니다 — RAID는 디스크 하드웨어 고장에 대한 가용성을 높일 뿐, 실수로 삭제한 파일, 랜섬웨어, 소프트웨어 버그로 인한 데이터 손상으로부터 보호하지 못합니다. RAID + 별도 백업이 필수입니다.
RAID 리빌드: 디스크를 교체하면 패리티에서 데이터를 복원하는 리빌드가 진행됩니다. 대용량 디스크의 리빌드는 수 시간~수일 소요되며, 이 기간 동안 또 다른 디스크가 고장나면 데이터를 잃습니다. 이것이 RAID 6이나 RAID 10을 선호하는 이유입니다.
def raid_capacity(num_disks, disk_size_tb, level):
"""RAID 레벨별 사용 가능한 용량 계산"""
if level == 0:
return num_disks * disk_size_tb
elif level == 1:
return num_disks * disk_size_tb / 2
elif level == 5:
return (num_disks - 1) * disk_size_tb
elif level == 6:
return (num_disks - 2) * disk_size_tb
elif level == 10:
return num_disks * disk_size_tb / 2
# 4TB 디스크 6개
for level in [0, 1, 5, 6, 10]:
cap = raid_capacity(6, 4, level)
print(f"RAID {level:2d}: {cap:.0f} TB 사용 가능")
# RAID 0: 24 TB
# RAID 1: 12 TB
# RAID 5: 20 TB
# RAID 6: 16 TB
# RAID 10: 12 TBSSD와 파일 시스템
SSD는 HDD와 달리 기계적 부품이 없어 랜덤 접근이 빠릅니다. 하지만 SSD 특유의 특성을 파일 시스템이 고려해야 합니다.
쓰기 증폭(Write Amplification): SSD는 페이지 단위(416KB)로 쓰지만, 삭제는 블록 단위(128KB수 MB)로만 가능합니다. 한 페이지를 수정하려면 블록 전체를 읽고, 한 페이지 수정 후, 새 블록에 다시 씁니다.
TRIM 명령: 파일이 삭제되면 OS가 SSD에 "이 블록은 더 이상 사용하지 않는다"고 알려줍니다. SSD 컨트롤러가 가비지 컬렉션에 활용하여 성능을 유지합니다. ext4는 discard 마운트 옵션 또는 fstrim 명령으로 TRIM을 지원합니다.
웨어 레벨링(Wear Leveling): SSD 셀은 쓰기 수명이 있습니다(TLC 약 3,000회). 컨트롤러가 쓰기를 골고루 분산하여 특정 셀만 빨리 닳는 것을 방지합니다.
다음 장에서는 파일 시스템과 밀접한 I/O 시스템을 다루겠습니다.