icon

안동민 개발노트

10장 : 파일 시스템

파일의 개념과 속성


디스크에 데이터를 저장할 때 우리는 파일이라는 개념을 당연하게 사용합니다. 하지만 디스크 자체는 파일이 무엇인지 모릅니다. 디스크는 단지 번호가 매겨진 블록의 배열일 뿐입니다. 파일(File)은 운영체제가 만들어낸 추상화입니다. 이 추상화 없이는 모든 프로그램이 디스크의 물리적 주소를 직접 다뤄야 하고, 그렇게 되면 실수로 다른 프로그램의 데이터를 덮어쓰는 일이 일상이 됩니다.


파일이란 무엇인가

파일은 이름이 붙은 데이터의 연속입니다. 사용자 관점에서 파일은 디스크에 저장된 논리적 단위이며, 프로그램 코드, 이미지, 텍스트, 데이터베이스 등 무엇이든 담을 수 있습니다.

운영체제 관점에서 파일은 관련된 디스크 블록들의 모음과 그 메타데이터(이름, 크기, 위치, 권한 등)를 포함하는 추상화입니다. 사용자가 보고서.txt를 열어줘라고 하면, OS는 파일 이름을 디스크 블록 위치로 변환하여 데이터를 읽어옵니다. 이 변환 과정이 파일 시스템의 핵심 역할입니다.

파일 시스템 추상화는 세 가지 문제를 동시에 해결합니다. 첫째, 사용자에게 친숙한 이름을 부여합니다. 둘째, 여러 프로세스가 동시에 파일에 접근할 때 일관성을 보장합니다. 셋째, 권한을 통해 다른 사용자의 데이터를 보호합니다.


파일 유형

OS는 파일의 내용을 해석하는 방법을 알아야 합니다. 파일 유형을 결정하는 방식은 두 가지입니다.

확장자 기반: Windows가 주로 사용하는 방식입니다. .txt, .pdf, .exe 같은 확장자가 파일 유형을 나타냅니다. 사용자가 확장자를 바꾸면 연결 프로그램만 바뀔 뿐, 파일 내용은 변하지 않습니다.

매직 넘버 기반: Unix 계열이 주로 사용하는 방식입니다. 파일의 처음 몇 바이트를 읽어 유형을 판별합니다. PDF는 %PDF로, PNG는 \x89PNG로, ELF 실행 파일은 \x7fELF로 시작합니다. 확장자가 없어도 실제 내용을 정확히 판별할 수 있습니다.

magic_number_check.c
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) {
    if (argc < 2) return 1;

    FILE *fp = fopen(argv[1], "rb");
    if (!fp) { perror("fopen"); return 1; }

    unsigned char header[8];
    size_t n = fread(header, 1, sizeof(header), fp);
    fclose(fp);

    if (n >= 4 && memcmp(header, "\x7fELF", 4) == 0)
        printf("ELF 실행 파일\n");
    else if (n >= 4 && memcmp(header, "%PDF", 4) == 0)
        printf("PDF 문서\n");
    else if (n >= 8 && memcmp(header, "\x89PNG\r\n\x1a\n", 8) == 0)
        printf("PNG 이미지\n");
    else
        printf("알 수 없는 형식 (첫 바이트: 0x%02x)\n", header[0]);

    return 0;
}

Linux의 file 명령은 이 매직 넘버 데이터베이스(/usr/share/misc/magic)를 참조하여 확장자 없이도 파일 유형을 정확히 알려줍니다.


파일 속성

파일에는 데이터 외에 다양한 메타데이터(Metadata)가 함께 저장됩니다.

속성설명예시
이름사람이 읽을 수 있는 식별자report.txt
유형확장자 또는 매직 넘버.pdf, \x89PNG
크기바이트 단위1048576 (1MB)
위치데이터 블록들의 디스크 상 위치inode 번호
권한읽기/쓰기/실행 허용 범위rw-r--r--
타임스탬프생성(ctime), 수정(mtime), 접근(atime)2024-01-15 10:30
소유자파일 소유 사용자 및 그룹user:group
링크 수이 파일을 가리키는 하드 링크 수1
file_metadata.py
import os
import stat
import time

path = "/etc/passwd"
st = os.stat(path)

print(f"크기:     {st.st_size} bytes")
print(f"inode:   {st.st_ino}")
print(f"링크 수: {st.st_nlink}")
print(f"소유자:  UID={st.st_uid}, GID={st.st_gid}")
print(f"권한:    {oct(stat.S_IMODE(st.st_mode))}")  # 0o644
print(f"수정:    {time.ctime(st.st_mtime)}")
print(f"접근:    {time.ctime(st.st_atime)}")

# 파일 유형 확인
if stat.S_ISREG(st.st_mode):
    print("일반 파일")
elif stat.S_ISDIR(st.st_mode):
    print("디렉토리")
elif stat.S_ISLNK(st.st_mode):
    print("심볼릭 링크")

ls -la를 실행하면 이 속성들이 한 줄에 요약됩니다. -rw-r--r-- 1 user group 1024 Jan 15 10:30 report.txt에서 권한, 링크 수, 소유자, 그룹, 크기, 수정 시각, 이름까지 담겨 있습니다.

타임스탬프의 실무적 의미

mtime(수정 시각)은 파일 내용이 변경된 시각입니다. make는 mtime을 비교하여 재컴파일 대상을 결정합니다. atime(접근 시각)은 파일이 읽힌 시각입니다. 매 읽기마다 atime을 갱신하면 디스크 쓰기가 발생하므로, 성능을 위해 noatime 또는 relatime 마운트 옵션을 사용합니다. ctime(변경 시각)은 메타데이터(권한, 소유자 등)가 변경된 시각이며, 사용자가 직접 바꿀 수 없습니다.


파일 연산

OS는 파일에 대해 다음 연산을 시스템 콜로 제공합니다.

  • 생성(Create): 새 파일을 만들고 디렉토리에 등록합니다.
  • 열기(Open): 파일 디스크립터를 할당하고, 커널 테이블에 엔트리를 만듭니다.
  • 읽기(Read): 현재 파일 포인터 위치에서 데이터를 읽습니다.
  • 쓰기(Write): 현재 파일 포인터 위치에 데이터를 씁니다.
  • 탐색(Seek): 파일 포인터를 원하는 위치로 이동합니다.
  • 삭제(Delete): 파일의 디렉토리 항목과 데이터 블록을 해제합니다.
  • 절단(Truncate): 파일 내용을 지우되 속성은 유지합니다.
  • 닫기(Close): 파일 디스크립터를 해제하고 버퍼를 디스크에 씁니다.
file_operations.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main() {
    /* 파일 생성 및 열기 (O_CREAT | O_WRONLY | O_TRUNC) */
    int fd = open("test.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);
    if (fd < 0) { perror("open"); return 1; }

    /* 쓰기 */
    const char *msg = "Hello, File System!\n";
    write(fd, msg, strlen(msg));

    /* lseek으로 처음으로 돌아가기 */
    lseek(fd, 0, SEEK_SET);
    /* 쓰기 전용이므로 읽기는 다시 열어야 함 */
    close(fd);

    /* 읽기 */
    fd = open("test.txt", O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    char buf[100];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    buf[n] = '\0';
    printf("Read: %s", buf);

    /* 파일 끝에서의 lseek: 파일 크기 확인 */
    off_t size = lseek(fd, 0, SEEK_END);
    printf("파일 크기: %ld bytes\n", (long)size);

    close(fd);

    /* 절단: 크기를 10바이트로 줄이기 */
    truncate("test.txt", 10);

    /* 삭제 (unlink) */
    /* unlink("test.txt"); */
    return 0;
}

open() 시스템 콜은 파일 디스크립터(File Descriptor)를 반환합니다. 이후의 모든 연산은 이 정수값을 통해 파일을 참조합니다. 파일 디스크립터에 대해서는 10-4절에서 자세히 다룹니다.

열린 파일 카운트와 자원 누수

OS는 시스템 전체에서 열 수 있는 파일 수를 제한합니다. Linux에서 ulimit -n으로 프로세스당 한도를 확인합니다(기본 1024). 파일을 열고 닫지 않으면 파일 디스크립터 누수(fd leak)가 발생하여, 결국 open()이 실패합니다. 서버 프로그램에서 흔한 버그입니다.

fd_leak_check.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    /* 의도적으로 close를 빠뜨린 코드 */
    for (int i = 0; i < 2000; i++) {
        int fd = open("/dev/null", O_RDONLY);
        if (fd < 0) {
            printf("fd 소진! 반복 %d에서 open 실패\n", i);
            perror("open");
            return 1;
        }
        /* close(fd); ← 이 줄이 없으면 fd 누수 */
    }
    printf("2000번 열기 성공 (실제로는 닫아야 함)\n");
    return 0;
}

접근 방법

파일 데이터에 접근하는 방식은 크게 두 가지입니다.

순차 접근(Sequential Access): 처음부터 끝까지 순서대로 읽거나 씁니다. 테이프 시대의 접근 방식이며, 로그 파일이나 스트리밍 데이터가 이에 해당합니다. read() 호출 후 파일 포인터가 자동으로 전진합니다.

직접 접근(Direct/Random Access): 파일의 임의 위치로 바로 이동하여 읽거나 씁니다. 디스크의 물리적 특성이 이를 가능하게 합니다. lseek()로 원하는 위치로 이동한 후 read() 또는 write()를 호출합니다. 데이터베이스가 대표적입니다 — 특정 레코드에 바로 접근해야 합니다.


파일 잠금

여러 프로세스가 동시에 같은 파일을 수정하면 데이터가 손상될 수 있습니다. 파일 잠금(File Locking)은 이를 방지합니다.

권고적 잠금(Advisory Lock)은 프로세스가 자발적으로 잠금을 확인합니다. 잠금을 무시하고 접근하는 것이 기술적으로 가능합니다. POSIX의 flock()이 이 방식입니다.

강제적 잠금(Mandatory Lock)은 OS가 강제로 접근을 차단합니다. 잠금된 파일에 대한 읽기/쓰기 시스템 콜 자체가 차단됩니다.

file_locking.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>

int main() {
    int fd = open("shared.dat", O_RDWR | O_CREAT, 0644);
    if (fd < 0) { perror("open"); return 1; }

    /* 배타적 잠금 획득 (다른 프로세스의 접근 차단) */
    printf("잠금 대기 중...\n");
    if (flock(fd, LOCK_EX) < 0) {
        perror("flock"); close(fd); return 1;
    }
    printf("잠금 획득! 5초간 작업 수행\n");

    /* 임계 영역: 파일 읽기/수정 */
    write(fd, "exclusive data\n", 15);
    sleep(5);

    /* 잠금 해제 */
    flock(fd, LOCK_UN);
    printf("잠금 해제\n");

    close(fd);
    return 0;
}

fcntl() 기반 레코드 잠금은 파일의 특정 영역만 잠글 수 있습니다. 데이터베이스처럼 하나의 파일 안에 여러 레코드가 있을 때, 전체 파일이 아닌 수정할 레코드만 잠그면 병행성이 크게 향상됩니다.

POSIX 잠금과 BSD 잠금(flock)은 서로 독립적으로 동작합니다. NFS 같은 네트워크 파일 시스템에서는 잠금이 올바르게 동작하지 않을 수 있어 별도의 분산 잠금 메커니즘이 필요합니다.

다음 절에서는 파일들을 체계적으로 정리하는 디렉토리 구조를 살펴보겠습니다.

목차