안동민 개발노트

본문 시작

파일의 개념과 속성

파일이 디스크 블록을 추상화하는 방식을 이해하고 유형·메타데이터·연산·접근 방식·잠금의 의미를 정리합니다.

디스크에 데이터를 저장할 때 우리는 파일이라는 개념을 당연하게 사용합니다.

하지만 디스크 자체는 파일이 무엇인지 모릅니다.

디스크는 단지 번호가 매겨진 블록의 배열일 뿐입니다.

파일(File)은 운영체제가 만들어낸 추상화입니다.

이 추상화 없이는 모든 프로그램이 디스크의 물리적 주소를 직접 다뤄야 하고, 그렇게 되면 실수로 다른 프로그램의 데이터를 덮어쓰는 일이 일상이 됩니다.


파일이란 무엇인가

파일은 이름이 붙은 데이터의 연속입니다.

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

운영체제 관점에서 파일은 관련된 디스크 블록들의 모음과 그 메타데이터(이름, 크기, 위치, 권한 등)를 포함하는 추상화입니다.

사용자가 보고서.txt를 열어줘라고 하면, OS는 파일 이름을 디스크 블록 위치로 변환하여 데이터를 읽어옵니다.

이 변환 과정이 파일 시스템의 핵심 역할입니다.

파일 시스템 추상화는 세 가지 문제를 동시에 해결합니다.

첫째, 사용자에게 친숙한 이름을 부여합니다.

둘째, 여러 프로세스가 파일을 공유할 수 있는 연산과 잠금 수단을 제공합니다. 애플리케이션의 여러 연산을 자동으로 하나의 트랜잭션으로 만들지는 않습니다.

셋째, 권한을 통해 다른 사용자의 데이터를 보호합니다.


파일 유형

일반 파일의 내용 형식은 보통 애플리케이션과 도구가 해석합니다. 커널의 일반 파일·디렉토리 같은 유형과 PDF·PNG 같은 내용 형식은 다릅니다.

내용 형식을 추정하는 대표적인 단서는 확장자와 매직 넘버입니다.

확장자 기반: 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, "\x7f" "ELF", 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 if (n > 0)
        printf("알 수 없는 형식 (첫 바이트: 0x%02x)\n", header[0]);
    else
        printf("읽은 바이트 없음 (빈 파일 또는 읽기 오류)\n");

    return 0;
}

Linux의 file은 매직 데이터베이스와 다른 검사로 내용 형식을 추정합니다. 데이터베이스 설치 경로는 배포판에 따라 다릅니다. 위 C 코드는 세 가지 서명만 검사하며 파일 형식 검증기가 아닙니다.


파일 속성

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

속성의미와 예시
이름디렉토리 엔트리의 이름 · report.txt
유형일반 파일·디렉토리 등; PDF·PNG 내용 형식은 별도 추정
크기바이트 단위 · 1,048,576바이트 = 1MiB
위치 정보inode 등 메타데이터가 데이터 블록 매핑을 관리
권한접근 허용 범위 · rw-r--r--
시각내용 수정 mtime, 접근 atime, 상태 변경 ctime; 생성 시각과 구별
소유자사용자·그룹 · user:group
링크 수같은 파일을 가리키는 하드 링크 수
file_metadata.py
import os
import stat
import time

path = "/etc/passwd"
st = os.lstat(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("심볼릭 링크")

위 코드는 Unix 경로의 메타데이터 조회 예시입니다. lstat은 링크 자체를, stat은 링크 대상을 조회합니다. 주석의 권한 값은 환경에 따라 달라지며 여기서 실행한 결과가 아닙니다.

ls -la를 실행하면 이 속성들이 한 줄에 요약됩니다.

-rw-r--r-- 1 user group 1024 Jan 15 10:30 report.txt에서 권한, 링크 수, 소유자, 그룹, 크기, 수정 시각, 이름까지 담겨 있습니다.

타임스탬프의 실무적 의미

mtime(수정 시각)은 파일 내용이 변경된 시각입니다.

make는 mtime을 비교하여 재컴파일 대상을 결정합니다.

atime(접근 시각)은 파일이 읽힌 시각입니다.

매 읽기마다 atime을 갱신하면 디스크 쓰기가 발생하므로, 성능을 위해 noatime 또는 relatime 마운트 옵션을 사용합니다.

ctime(상태 변경 시각)은 권한·소유자뿐 아니라 내용 쓰기 등으로 inode 상태가 바뀔 때도 갱신됩니다. 생성 시각이 아니며 일반적인 파일 시각 설정 API로 임의 값을 직접 지정하지 않습니다.


파일 연산

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

  • 생성(Create): 새 파일을 만들고 디렉토리에 등록합니다.
  • 열기(Open): 파일 디스크립터를 할당하고, 커널 테이블에 엔트리를 만듭니다.
  • 읽기(Read): 현재 파일 포인터 위치에서 데이터를 읽습니다.
  • 쓰기(Write): 현재 파일 포인터 위치에 데이터를 씁니다.
  • 탐색(Seek): 파일 포인터를 원하는 위치로 이동합니다.
  • 삭제(Delete): 파일 이름을 제거합니다. 마지막 링크와 열린 참조 등이 사라져야 저장 공간을 회수할 수 있습니다.
  • 절단(Truncate): 파일 길이를 변경합니다. 내용 일부를 잘라내거나 확장하며 크기·시각 같은 속성도 바뀔 수 있습니다.
  • 닫기(Close): 파일 디스크립터를 해제합니다. 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);
    if (n < 0) { perror("read"); close(fd); return 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;
}

앞의 C 예제는 연산 순서를 보여줍니다. 짧은 쓰기·중단, lseek·truncate·close 오류의 완전한 처리는 생략되어 있으며 성공한 경로를 전제로 설명합니다.

open() 시스템 콜은 파일 디스크립터(File Descriptor)를 반환합니다.

이후의 모든 연산은 이 정수값을 통해 파일을 참조합니다.

파일 디스크립터에 대해서는 10-4절에서 자세히 다룹니다.

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

OS는 시스템 전체에서 열 수 있는 파일 수를 제한합니다.

Linux에서 ulimit -n으로 현재 셸에서 상속될 프로세스당 soft 한도를 확인합니다. 값은 설정에 따라 다르므로 1,024로 고정되지 않습니다.

파일을 열고 닫지 않으면 파일 디스크립터 누수(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)은 프로세스가 자발적으로 잠금을 확인합니다.

잠금을 무시하고 접근하는 것이 기술적으로 가능합니다.

BSD 계열의 flock()은 Linux의 일반 로컬 파일 시스템에서 이 방식으로 동작합니다.

강제적 잠금(Mandatory Lock)은 OS가 강제로 접근을 차단합니다.

잠금된 파일에 대한 읽기·쓰기 자체를 제한하는 개념입니다. Linux의 과거 일반 파일 mandatory locking은 5.15 이후 지원하지 않으며, SMB 등 원격 파일 시스템의 정책은 별도로 확인합니다.

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() 기반 레코드 잠금은 파일의 특정 영역만 잠글 수 있습니다.

데이터베이스처럼 하나의 파일 안에 여러 레코드가 있을 때, 전체 파일이 아닌 수정할 레코드만 잠그면 병행성이 크게 향상됩니다.

Linux 로컬 파일 시스템의 flock과 전통적인 fcntl 레코드 잠금은 별개입니다. NFS·SMB에서는 전체 파일의 바이트 범위 잠금으로 구현되어 서로 상호작용할 수 있으므로 프로토콜·커널·마운트 옵션을 확인합니다. 원격 파일이라고 항상 별도 분산 잠금이 필요한 것은 아닙니다.

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