본문으로 건너뛰기

안동민 개발노트

본문 시작

파일 시스템 실무

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

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

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

fd에서 저장장치까지 이어지는 운영 관점

파일 접근은 커널 테이블, 페이지 캐시, 디스크 점검 명령, 저장장치 설계를 함께 봐야 실무 장애를 빠르게 좁힐 수 있습니다.

  1. 열린 파일 추적

    fd 작은 정수 fd는 열린 파일 엔트리와 inode로 이어지며, fork와 dup2 뒤에는 같은 열린 파일 엔트리를 가리키는지로 오프셋 공유 여부를 판단합니다.

  2. 성능과 내구성

    cache 페이지 캐시는 빠른 I/O를 만들지만, write-back 데이터는 fsync로 디스크 반영 시점을 관리해야 합니다.

  3. 운영 점검

    ops df -i inode 소진 확인 lsof +L1 삭제 후 열린 파일 확인

  4. 저장장치 선택

    storage RAID는 가용성, SSD TRIM은 성능 유지, 백업은 복구를 담당합니다. 셋은 서로 대체 관계가 아닙니다.


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

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

0, 1, 2는 미리 예약되어 있습니다.

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

3단계 테이블 구조

fd 번호는 데이터가 아니라 커널 객체를 따라가는 참조다

프로세스는 fd table의 정수 인덱스를 사용하고, 커널은 그 인덱스를 open file entry, inode/vnode, disk blocks로 이어서 실제 파일 데이터를 찾는다.

  1. #17 shared
    offset, flags, refcount 공유

    #17 shared offset, flags, refcount 공유 A.fd3 A.fd4 B.fd3 하나가 read하면 같은 entry의 offset이 움직인다.

  2. #32 separate
    같은 파일을 다시 open

    #32 separate 같은 파일을 다시 open inode는 같아도 offset과 flags는 별도 entry에 저장된다.

  3. inode #8842
    파일의 정체성과 메타데이터

    inode #8842 파일의 정체성과 메타데이터 권한, 크기, 링크 수, 블록 주소 같은 파일 시스템 정보를 가진다.

  4. vnode
    커널의 추상 파일 객체

    vnode 커널의 추상 파일 객체 파일 시스템 종류가 달라도 같은 인터페이스로 접근하게 한다.

  5. 4 KB blocks
    실제 데이터 위치

    KB blocks 실제 데이터 위치 파일 시스템이 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;
}

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;
}

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

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를 사용합니다.

쓰기 호출이 성공했다는 말과 디스크에 안전하게 남았다는 말은 다릅니다.

페이지 캐시, writeback, fsync(), rename()까지 어디에서 보장이 생기는지 분리해서 봐야 합니다.

write 성공과 페이지 캐시

파일 시스템 실무에서는 fd, page cache, writeback, fsync(), 저장장치 캐시의 경계를 구분해야 데이터 유실을 줄일 수 있다.

  1. 사용자 버퍼 복사

    write 커널은 데이터를 페이지 캐시에 복사하고 dirty 표시를 남긴 뒤 곧바로 성공을 반환할 수 있다.

  2. 백그라운드 반영

    writeback 커널 플러셔가 dirty page를 디스크 요청으로 내보내며, 이 시점은 애플리케이션 반환 시점과 다르다.

  3. 파일 단위 동기화

    fsync fsync(fd)는 파일 데이터와 필요한 메타데이터가 안정 저장소까지 가도록 기다린다.

  4. 디렉토리 메타데이터

    rename 임시 파일을 rename()으로 교체했다면 새 이름 자체를 보장하려고 디렉토리 fd도 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 사용률을 확인해야 합니다.

운영 장애를 볼 때는 “디스크가 찼다”로 뭉뚱그리지 말고, 블록 공간·inode·마운트 옵션·열린 파일·권한을 나누어 확인해야 합니다.

쓰기 실패는 먼저 errno와 mount 상태로 갈라 본다

오류 문자열·대상 경로·발생 시각을 먼저 고정한다. “디스크가 찼다”는 가설부터 세우지 말고 공간, read-only 전환, 접근 권한, 삭제 후 열린 파일을 각각 확인한다.

  1. 블록인가, inode인가

    ENOSPC 검증 df -h /path df -i /path 판단 용량이 남아도 inode 100%면 새 파일 생성이 실패한다.

  2. read-only로 바뀌었나

    EROFS 검증 findmnt -T /path journalctl -k -b 판단 ext4의 errors=remount-ro 는 오류 때 보호를 위해 쓰기를 막는다.

  3. 경로 권한인가, 정책인가

    EACCES 검증 namei -l /path getfacl /path 판단 소유권·mode·ACL·LSM과 mount 옵션을 구분한다.

  4. 삭제 파일을 누가 열었나

    SPACE STAYS 검증 lsof +L1 판단 link count가 0이어도 열린 file description이 닫혀야 공간이 반환된다.

  5. 1

    그·mount·사용량 증거와 백업 상태를 보존

  6. 2

    쓰기 주체를 멈추고 필요하면 unmount 또는 격리

  7. 3

    filesystem 유형에 맞는 checker와 복구 절차 선택


RAID

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

RAID방식최소 디스크용량 효율읽기 속도쓰기 속도내결함성
0스트라이핑2100%N배N배없음
1미러링250%2배1배1개 고장
5분산 패리티3(N-1)/N~(N-1)배감소1개 고장
6이중 패리티4(N-2)/N~(N-2)배더 감소2개 고장
10미러+스트라이프450%N배N/2배각 쌍 1개

RAID 0: 데이터를 여러 디스크에 분산합니다.

한 디스크라도 고장나면 전체 데이터를 잃습니다.

임시 데이터나 성능이 중요한 워크로드에 사용합니다.

RAID 1: 같은 데이터를 두 디스크에 복제합니다.

한 디스크가 고장나도 안전합니다.

OS 부팅 디스크에 주로 사용합니다.

RAID 5: 패리티를 디스크에 분산 저장합니다.

하나의 디스크 고장까지 복구 가능합니다.

읽기가 많은 파일 서버에 적합합니다.

쓰기 시 패리티 계산 오버헤드가 있습니다(write penalty).

RAID 10: 미러링 + 스트라이핑.

성능과 안정성 모두 우수합니다.

데이터베이스 서버의 표준 구성입니다.

비용은 높습니다(디스크 용량 절반만 사용).

실무 고려사항

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

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

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

대용량 디스크의 리빌드는 수 시간~수일 소요되며, 이 기간 동안 또 다른 디스크가 고장나면 데이터를 잃습니다.

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

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 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 TB

SSD와 파일 시스템

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

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

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

한 페이지를 수정하려면 블록 전체를 읽고, 한 페이지 수정 후, 새 블록에 다시 씁니다.

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

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

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

SSD TRIM 효과

파일 삭제는 곧바로 셀을 지우는 일이 아닙니다. OS가 더 이상 쓰지 않는 블록을 알려야 SSD 컨트롤러가 내부 정리를 준비할 수 있습니다.

  1. 파일 삭제

    사용자는 파일을 지우고, 파일 시스템은 해당 논리 블록을 빈 공간으로 표시합니다.

  2. 해제 목록 생성

    커널은 더 이상 보존할 필요가 없는 블록 범위를 모아 장치 계층에 넘깁니다.

  3. TRIM 전달

    discard 옵션이나 fstrim 실행으로 SSD에 사용 종료 블록을 통지합니다.

  4. 내부 정리

    SSD 컨트롤러는 가비지 컬렉션과 웨어 레벨링에 이 정보를 활용합니다.

  5. TRIM 적용 신호 확인

    주기 실행 여부와 장치 지원 상태를 확인해 장기 쓰기 성능을 안정화합니다.

웨어 레벨링(Wear Leveling): SSD 셀은 쓰기 수명이 있습니다(TLC 약 3,000회).

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

파일 시스템 실무 문제는 fd, 페이지 캐시, 저장장치 정책이 한 흐름으로 이어질 때 빠르게 좁힐 수 있습니다.

마지막으로 어떤 층을 확인해야 하는지 한 장으로 정리합니다.

파일 장애는 증상에서 해당 층으로 내려가며 좁힌다

fd 누수, 더티 페이지, inode 소진, RAID 리빌드, SSD TRIM은 서로 다른 층의 문제다. 먼저 쓰기 경로에서 데이터가 멈춘 위치를 찾는다.

  1. process fd

    write() 로 커널에 쓰기 요청

  2. page cache

    메모리에 더티 페이지로 먼저 남음

  3. flush / fsync

    주기 flush 또는 명시적 동기화

  4. block layer

    파일 요청을 블록 장치 I/O로 변환

  5. RAID / SSD

    패리티·미러·TRIM·웨어 레벨링

  6. fd 한도와 누수

    open 실패 fd 한도와 누수 파일을 닫지 않으면 프로세스 한도에 먼저 걸린다. ulimit -n lsof -p PID

  7. 용량과 inode 분리

    공간 부족 용량과 inode 분리 작은 파일이 많으면 용량보다 inode가 먼저 소진된다. df -hT · df -i du -sh

  8. 동기화 경계

    내구성 동기화 경계 중요 데이터가 커널 캐시에만 머물지 않게 한다. fsync() fdatasync()

  9. 중복 캐시

    DB I/O 중복 캐시 DB 버퍼 풀과 OS page cache의 중복을 검토한다. O_DIRECT

  10. 가용성 ≠ 백업

    RAID 장애 가용성 ≠ 백업 삭제·손상·랜섬웨어는 RAID로 복구되지 않는다. 리빌드 상태 별도 백업

  11. TRIM·웨어 레벨링

    SSD 저하 TRIM·웨어 레벨링 삭제 블록 통지와 쓰기 분산이 성능·수명에 영향을 준다. lsblk --discard fstrim

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