안동민 개발노트

본문 시작

파일 시스템 실무

파일 디스크립터와 커널 파일 테이블, 페이지 캐시, 디스크 명령, RAID·SSD의 운영 특성을 실무 관점에서 연결합니다.

파일 시스템의 이론을 알았으니, 실제 개발과 운영에서 마주치는 실무적 개념들을 정리합니다.

파일 디스크립터, 캐시, 디스크 관리 명령어, RAID까지 — 서버를 다루는 개발자에게 필수적인 지식입니다.


파일 디스크립터와 파일 테이블

프로세스가 open()으로 파일을 열면 커널이 파일 디스크립터(File Descriptor, fd)를 반환합니다.

일반적으로 프로세스 시작 시 다음 표준 스트림에0,1,2를 연결합니다. 이 fd들도 닫거나 다른 대상으로 바꿀 수 있으며 영구 예약 번호는 아닙니다.

  • 0: 표준 입력(stdin)
  • 1: 표준 출력(stdout)
  • 2: 표준 오류(stderr)

3단계 테이블 구조

fork 이후 두 fd가 하나의 열린 파일 설명을 가리킵니다

fork 이후 두 fd가 하나의 열린 파일 설명을 가리킵니다

원문 파일 디스크립터 예제의 공유 관계부모와자식의fd는서로별도테이블에있지만동일한열린파일설명을참조해offset을공유한다.그설명은같은파일inode를가리킨다.부모의 fd자식의 fd열린 파일 설명offset · status flagsshared.txt inode파일 메타데이터
원문 파일 디스크립터 예제의 공유 관계부모와자식의fd는서로별도테이블에있지만동일한열린파일설명을참조해offset을공유한다.그설명은같은파일inode를가리킨다.부모의 fd자식의 fd열린 파일 설명offset · status flagsshared.txt inode파일 메타데이터

다음은 프로세스 fd, 열린 파일 설명(open file description), inode를 구별하는 개념 모델입니다. 실제 커널 구현이 물리적으로 세 개의 배열이라는 뜻은 아닙니다.

1단계 — 프로세스별 fd 테이블: 각 프로세스가 가진 배열입니다.

인덱스가 fd 번호이고, 열린 파일 테이블 엔트리에 대한 포인터를 담습니다.

2단계 — 시스템 전역 열린 파일 테이블(Open File Table): 모든 프로세스가 공유합니다.

각 엔트리에 파일 오프셋(현재 읽기/쓰기 위치), 접근 모드(읽기/쓰기), 참조 카운트가 저장됩니다.

fork() 후 부모와 자식은 같은 엔트리를 공유하므로 오프셋도 공유합니다.

3단계 — inode(vnode) 테이블: 파일의 실제 메타데이터입니다.

여러 열린 파일 테이블 엔트리가 동일한 inode를 가리킬 수 있습니다.

fd_table_demo.c
#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;
}

위 코드는 파일 연산·fork가 성공하고 자식이3바이트를 읽는 경로를 가정합니다. 부모는 wait 뒤 같은 열린 파일 설명의 오프셋3에서 읽습니다. _exit()는 stdio 버퍼를 flush하지 않아 자식의 출력 행은 리다이렉션 환경에서 사라질 수 있습니다.

dup2와 리다이렉션

셸에서 ./program > output.txt로 리다이렉션하면, 셸이 fd 1(stdout)을 output.txt의 fd로 교체합니다.

내부적으로 dup2() 시스템 콜을 사용합니다.

redirect_demo.c
#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;
}

리다이렉션 예제는 open·dup2 성공, 기존 stdout이 열려 새fd가1이 아닌 상태를 가정하며 오류 처리는 생략했습니다. stderr가 터미널에 연결되어 있다는 주석도 이 예제의 환경 가정입니다.

파이프(|)도 같은 원리입니다.

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: 쓰기 작업은 먼저 페이지 캐시에만 반영되고, 나중에 디스크에 기록됩니다.

커널의 writeback 작업이 시간·메모리 압박·더티 페이지 기준 등에 따라 저장장치로 내보냅니다. 더티 페이지의 만료 기준과 백그라운드 작업의 기상 주기는 서로 다른 설정이며 모든 쓰기가30초 뒤 저장되는 것은 아닙니다.

성능은 좋지만 전원 장애 시 데이터 손실 위험이 있습니다.

fsync(): 해당 파일의 변경 데이터와 메타데이터 동기화를 요청하고 완료 또는 오류를 기다립니다. 반환값을 확인해야 하며, 응용 프로그램은 동기화가 필요한 경계를 설계합니다.

fdatasync()도 파일 길이처럼 이후 데이터 읽기에 필요한 메타데이터는 동기화합니다. 불필요한 메타데이터 쓰기를 줄일 수 있으나 항상 더 빠르다고 보장하지 않습니다.

O_DIRECT: 지원되는 파일 시스템·정렬 조건에서 페이지 캐시 영향을 최소화하는 직접I/O를 요청합니다. 이 플래그만으로 동기 영속성까지 보장하지는 않습니다.

자체 버퍼 풀을 관리하는 데이터베이스는 이중 캐싱을 줄이기 위해 직접I/O를 선택할 수 있으며 실제 사용 여부는 제품·설정에 따라 다릅니다.

새 파일 생성이나 rename 뒤 이름의 영속성이 필요하면 파일뿐 아니라 해당 디렉토리 fd의 동기화도 고려합니다. 아래 코드는 write의 짧은 반환·오류와 fsync 오류를 검사하지 않으므로 완성된 장애 안전 저장 루틴은 아닙니다.

write_sync_demo.c
#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로 선읽기 크기를 확인할 수 있습니다.


디스크 관리 명령어

디스크_관리.sh
# 파일 시스템별 사용량 (-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 5

inode 소진은 흔한 장애 원인입니다.

작은 파일이 수백만 개 있으면 디스크 용량은 남아 있는데 새 파일을 만들 수 없습니다.

메일 서버나 캐시 서버에서 자주 발생합니다.

df -i로 inode 사용률을 확인해야 합니다.

오류 코드와 대상을 먼저 확인합니다. ENOSPC는 블록·inode·할당 제한, EROFS는 읽기 전용 상태, EACCES는 경로 권한·ACL·정책을 구분해 봅니다. 삭제 뒤 공간이 남으면 열린 참조를 확인합니다. 위 fsck는 파일 시스템 종류와 오프라인 조건을 확인한 뒤 선택하는 예시이며 실행하지 않았습니다.


RAID

RAID(Redundant Array of Independent Disks)는 여러 디스크를 조합하여 성능, 용량, 안정성을 높이는 기술입니다.

RAID 용량과 고장 허용 조건

RAID 용량과 고장 허용 조건

동일 용량 디스크의 일반적인 RAID 구성
레벨구성과 가용 용량고장 허용 조건
RAID 02개 이상 스트라이프 · N × S중복이 없어 디스크 하나의 고장에도 배열 데이터가 손실될 수 있습니다.
RAID 12개 이상 동일 데이터의 복제 · S동기화된 복제본 하나가 남으면 계속 제공할 수 있습니다. N-way 미러의 효율은1/N입니다.
RAID 53개 이상 분산 패리티 · (N−1) × S디스크1개 고장을 허용합니다. 작은 쓰기에는 패리티 갱신 비용이 있습니다.
RAID 64개 이상 이중 패리티 · (N−2) × S디스크2개 고장을 허용합니다. 패리티 갱신과 리빌드 비용을 고려합니다.
RAID 104개 이상 짝수, 2개씩 미러 후 스트라이프 · N × S / 2각 미러 쌍에 정상 디스크가 하나 이상 있어야 합니다.
레벨: RAID 0
구성과 가용 용량: 2개 이상 스트라이프 · N × S
고장 허용 조건: 중복이 없어 디스크 하나의 고장에도 배열 데이터가 손실될 수 있습니다.
레벨: RAID 1
구성과 가용 용량: 2개 이상 동일 데이터의 복제 · S
고장 허용 조건: 동기화된 복제본 하나가 남으면 계속 제공할 수 있습니다. N-way 미러의 효율은1/N입니다.
레벨: RAID 5
구성과 가용 용량: 3개 이상 분산 패리티 · (N−1) × S
고장 허용 조건: 디스크1개 고장을 허용합니다. 작은 쓰기에는 패리티 갱신 비용이 있습니다.
레벨: RAID 6
구성과 가용 용량: 4개 이상 이중 패리티 · (N−2) × S
고장 허용 조건: 디스크2개 고장을 허용합니다. 패리티 갱신과 리빌드 비용을 고려합니다.
레벨: RAID 10
구성과 가용 용량: 4개 이상 짝수, 2개씩 미러 후 스트라이프 · N × S / 2
고장 허용 조건: 각 미러 쌍에 정상 디스크가 하나 이상 있어야 합니다.

N은 디스크 수, S는 디스크 하나의 용량이며 메타데이터 공간을 제외했습니다. 읽기·쓰기 속도 배수는 장치·컨트롤러·요청 패턴에 따라 달라집니다.

중복이 없는 RAID0은 다시 만들 수 있는 임시 데이터 등에 고려합니다. RAID1은 부팅 디스크, 패리티 구성은 용량과 읽기 비중, RAID10은 쓰기 부하와 복제 비용을 함께 평가해 선택할 수 있습니다. 어느 레벨도 모든 서버의 표준 정답은 아닙니다.

실무 고려사항

RAID는 백업이 아니다 — RAID는 디스크 하드웨어 고장에 대한 가용성을 높일 뿐, 실수로 삭제한 파일, 랜섬웨어, 소프트웨어 버그로 인한 데이터 손상으로부터 보호하지 못합니다.

RAID + 별도 백업이 필수입니다.

RAID 리빌드: 디스크를 교체하면 패리티에서 데이터를 복원하는 리빌드가 진행됩니다.

리빌드 시간과 추가 고장 허용 범위는 용량·부하·RAID구성에 따라 다릅니다. RAID5는 두 번째 고장을 견디지 못하지만 RAID6·10의 허용 조건은 서로 다릅니다.

이것이 RAID 6이나 RAID 10을 선호하는 이유입니다.

아래는 동일 용량 디스크, 메타데이터 공간 제외, 각 레벨의 유효한 디스크 수를 가정한 용량 계산입니다. RAID1은 모든 디스크가 한 복제 집합이고 RAID10은2개씩 미러인 짝수 구성이며, 입력 검증은 생략했습니다.

raid_capacity.py
def raid_capacity(num_disks, disk_size_tb, level):
    """RAID 레벨별 사용 가능한 용량 계산"""
    if level == 0:
        return num_disks * disk_size_tb
    elif level == 1:
        return disk_size_tb  # 모든 디스크가 같은 데이터의 복제본
    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: 4 TB
# RAID  5: 20 TB
# RAID  6: 16 TB
# RAID 10: 12 TB

SSD와 파일 시스템

SSD는 HDD와 달리 기계적 부품이 없어 랜덤 접근이 빠릅니다.

하지만 SSD 특유의 특성을 파일 시스템이 고려해야 합니다.

쓰기 증폭(Write Amplification): SSD는 페이지 단위(416KB)로 쓰지만, 삭제는 블록 단위(128KB수 MB)로만 가능합니다.

보통 수정본을 다른 빈 페이지에 쓰고 논리 주소 매핑을 바꿉니다. 이후 가비지 컬렉션이 유효 페이지를 옮기고 erase block을 지우는 과정에서 호스트 쓰기보다 더 많은 내부 쓰기가 발생할 수 있습니다.

TRIM 명령: 파일이 삭제되면 OS가 SSD에 "이 블록은 더 이상 사용하지 않는다"고 알려줍니다.

SSD 컨트롤러가 가비지 컬렉션에 활용하여 성능을 유지합니다.

ext4는 discard 마운트 옵션 또는 fstrim 명령으로 TRIM을 지원합니다.

웨어 레벨링(Wear Leveling): 플래시에는 유한한 프로그램·삭제 수명이 있으며 제품·공정·동작 조건에 따라 달라집니다.

컨트롤러가 쓰기를 골고루 분산하여 특정 셀만 빨리 닳는 것을 방지합니다.

다음 장에서는 파일 시스템과 밀접한 I/O 시스템을 다루겠습니다.