[OSTEP] 스터디 15주차 Part.1 파일과 디렉터리

운영체제의 가상화(Virtualization) 파트를 지나, 이제 영속성(Persistence)의 세계로 들어왔다. 영속성의 핵심은 전원이 꺼져도 데이터가 날아가지 않게 하는 것이다. 이를 위해 운영체제는 파일 시스템(File System)이라는 인터페이스를 제공한다.


1. 파일과 디렉터리: 기본 개념 (Files and Directories)

파일 시스템은 데이터를 저장하기 위해 두 가지 핵심 추상화를 제공한다.

  1. 파일 (File): 운영체제 입장에서 파일은 그저 바이트의 선형 배열(Linear Array of Bytes)일 뿐이다. 파일의 내용(이미지인지, 텍스트인지)은 OS가 알 바 아니다. 각 파일은 inode number(아이노드 번호)라는 저수준의 이름으로 식별된다.
  2. 디렉터리 (Directory): 디렉터리도 파일이다. 다만 그 내용이 (사용자가 읽을 수 있는 이름, inode number)의 매핑 정보 리스트라는 점이 특별하다. 디렉터리 구조는 트리(Tree) 형태를 띤다.

2. 파일 생성과 읽기/쓰기 (open, read, write)

파일을 다루는 가장 기본적인 작업이다.

파일 생성 (open)

int fd = open("foo", O_CREAT | O_WRONLY | O_TRUNC, S_IRUSR | S_IWUSR);
  • 플래그(Flags):
    • O_CREAT: 파일이 없으면 생성한다.
    • O_WRONLY: 쓰기 전용으로 연다.
    • O_TRUNC: 파일이 이미 있으면 내용을 싹 비우고(0바이트로 만듦) 연다.
  • 반환값 (File Descriptor): 성공 시 정수(fd)를 반환한다. 이 fd는 프로세스마다 개인적으로 관리하는 배열의 인덱스다.

읽기와 쓰기 (read, write)

// 파일을 읽는 명령어 cat의 동작 원리
while ((bytes_read = read(fd_in, buffer, sizeof(buffer))) > 0) {
    write(fd_out, buffer, bytes_read);
}
  • read()와 write()는 현재 파일의 오프셋(Offset) 위치에서 작업을 수행하고, 작업한 바이트 수만큼 오프셋을 이동시킨다.

3. 임의 접근: lseek (Reading and Writing, but Not Sequentially)

파일을 항상 순차적으로만 읽을 필요는 없다. 데이터베이스 같은 프로그램은 파일의 중간 내용을 빈번하게 읽어야 한다.

off_t lseek(int fd, off_t offset, int whence);
  • 동작: 디스크 헤드를 물리적으로 이동시키는 게 아니라, 커널 메모리 내의 오프셋 변수 값만 수정한다.
  • whence 옵션:
    • SEEK_SET: 파일의 시작점 기준 (절대 위치)
    • SEEK_CUR: 현재 오프셋 기준 (상대 위치)
    • SEEK_END: 파일의 끝 기준 (주로 파일 크기를 알거나 뒤에 덧붙일 때 사용)

4. [핵심] 오픈 파일 테이블과 공유 (Shared File Entries)

이 챕터에서 가장 중요하고 헷갈리기 쉬운 부분이다. 운영체제는 열린 파일을 어떻게 관리할까? 세 가지 자료구조가 관여한다.

  1. 프로세스별 파일 디스크립터 테이블 (Per-process Descriptor Table): 각 프로세스가 가진 fd 배열.
  2. 시스템 전체 오픈 파일 테이블 (System-wide Open File Table): 모든 프로세스가 공유하는 테이블. 여기에 현재 오프셋(Current Offset), 접근 모드, 참조 횟수 등이 저장된다.
  3. inode (메타데이터): 실제 파일의 정보.

케이스 A: fork()를 했을 때 (공유 O)

부모 프로세스가 파일을 열고(fd=3), fork()를 호출해 자식을 낳으면?

  • 자식도 똑같은 fd=3을 가진다.
  • 중요: 둘 다 같은 오픈 파일 테이블 엔트리를 가리킨다.
  • 결과: 부모가 읽어서 오프셋을 이동시키면, 자식의 오프셋도 같이 이동한다.

케이스 B: open()을 따로 두 번 했을 때 (공유 X)

같은 파일을 프로세스 A가 열고, 프로세스 B가 따로 열면?

  • 각각 다른 오픈 파일 테이블 엔트리가 생성된다.
  • 결과: 서로 오프셋이 독립적이다. A가 읽어도 B의 오프셋은 변하지 않는다.

5. 영속성 보장: fsync() (Writing Immediately)

write()를 호출했다고 해서 데이터가 즉시 디스크에 기록되는 것은 아니다. 성능을 위해 OS는 데이터를 메모리(Buffer Cache)에 잠시 담아둔다. 하지만 이 상태에서 전원이 꺼지면? 데이터는 날아간다.

데이터베이스(DBMS)처럼 데이터 유실이 치명적인 프로그램은 fsync(fd)를 사용해야 한다.

write(fd, buffer, size); // 메모리 버퍼에만 씀
fsync(fd);               // 디스크에 강제로 내려 씀 (Dirty Page Flush)
  • 주의: 파일의 내용뿐만 아니라, 파일을 포함하는 디렉터리 정보까지 안전하게 쓰려면 디렉터리에 대해서도 fsync()를 해줘야 완벽하다.

6. 파일 이름 변경: rename()

파일 이름을 바꿀 때 mv 명령어를 쓰는데, 내부적으로는 rename() 시스템 콜을 사용한다.

rename("old_name", "new_name");
  • 원자성 (Atomicity): rename의 가장 큰 특징은 원자적(Atomic)이라는 것이다. 이름이 바뀌는 도중에 시스템이 셧다운 되어도, 파일은 old_name으로 남아있거나 new_name으로 바뀌거나 둘 중 하나다. 어중간한 상태(파일 소실)는 없다.
  • 이 특징을 이용해 에디터들은 임시 파일에 저장을 완료한 후, 원본 파일로 rename 하는 방식으로 안전한 저장을 구현한다.

7. 파일 정보 얻기: stat() (Metadata)

파일 시스템은 파일의 내용(Content) 외에도 파일에 대한 정보(Metadata)를 inode에 저장한다. 이를 확인하는 함수가 stat()이다.

struct stat {
    dev_t     st_dev;     // 디바이스 ID
    ino_t     st_ino;     // Inode 번호 (중요!)
    mode_t    st_mode;    // 권한 및 파일 타입
    nlink_t   st_nlink;   // 하드 링크 수 (Reference Count)
    uid_t     st_uid;     // 소유자 ID
    off_t     st_size;    // 파일 크기 (바이트)
    time_t    st_mtime;   // 마지막 수정 시간
    ...
};
  • 파일의 이름은 inode에 없다. 이름은 디렉터리가 관리한다.

8. 파일 삭제: unlink() (Removing Files)

왜 파일을 지우는 함수 이름이 remove나 delete가 아니라 unlink일까?

파일 삭제의 메커니즘은 참조 횟수(Reference Count)에 기반한다.

  1. 파일(inode)은 link count라는 숫자를 가진다. (이 파일을 가리키는 이름이 몇 개인가?)
  2. unlink("foo")를 호출하면:
    • "foo"라는 이름을 디렉터리에서 지운다.
    • 해당 inode의 link count를 1 감소시킨다.
  3. 만약 link count가 0이 되면, 그때 비로소 inode와 데이터 블록을 해제(Free)하여 파일이 진짜 삭제된다.

9. 디렉터리 다루기 (mkdir, readdir, rmdir)

디렉터리는 일반 파일과 달리 직접 write 할 수 없다. (그랬다간 파일 시스템 구조가 망가진다.)

디렉터리 읽기

디렉터리는 단순히 read()로 읽는 대신 전용 라이브러리를 쓴다.

DIR *dp = opendir("."); // 디렉터리 열기
struct dirent *d;
while ((d = readdir(dp)) != NULL) { // 엔트리 하나씩 읽기
    printf("inode: %d, name: %s\n", d->d_ino, d->d_name);
}
closedir(dp);
  • ls 명령어가 내부적으로 이렇게 동작한다.

10. 링크: 하드 링크 vs 심볼릭 링크

두 가지 링크 방식의 차이를 이해하는 것은 매우 중요하다.

(1) 하드 링크 (Hard Link)

ln file target  # link() 시스템 콜
  • 동작: 디렉터리에 (target, 기존 inode 번호) 쌍을 추가한다.
  • 특징:
    • 원본 파일과 똑같은 inode 번호를 가진다.
    • 즉, 별도의 파일이 아니라 같은 파일에 이름만 하나 더 생긴 것이다.
    • 원본을 지워도(unlink), 링크가 남아있으면(ref count > 0) 파일은 사라지지 않는다.
    • 제약: 디렉터리에는 하드 링크를 걸 수 없다(순환 참조 방지). 다른 파티션(파일 시스템) 간에는 걸 수 없다(inode 번호는 파티션 내에서만 유일하므로).

(2) 심볼릭 링크 (Symbolic Link / Soft Link)

ln -s file target # symlink() 시스템 콜
  • 동작: 완전히 새로운 파일(새로운 inode)을 만든다. 이 파일의 내용(Content)은 원본 파일의 경로 문자열이다.
  • 특징:
    • 원본과 다른 inode 번호를 가진다.
    • 원본 파일을 지우면, 심볼릭 링크는 가리킬 곳을 잃어버린 상태(Dangling Reference)가 되어 사용할 수 없다.
    • 디렉터리나 다른 파티션에 대해서도 자유롭게 걸 수 있다.

11. 권한과 접근 제어 (Permission Bits and ACLs)

  • Permission Bits: 유닉스 파일 시스템은 rwxr-xr-x와 같은 9비트 정보를 통해 소유자(User), 그룹(Group), 기타(Other)에 대한 읽기/쓰기/실행 권한을 관리한다. (chmod로 변경)
  • ACL (Access Control List): 9비트만으로는 "철수한테만 쓰기 권한을 주고 싶다" 같은 정교한 제어가 어렵다. 이를 위해 ACL을 사용하여 사용자별로 구체적인 권한을 설정할 수 있다.

12. 파일 시스템 만들기 및 마운트 (mkfs, mount)

파일 시스템을 사용하려면 먼저 초기화하고 트리에 붙여야 한다.

  • mkfs (Make Filesystem): 디스크 파티션에 빈 파일 시스템 구조(루트 inode, 프리 리스트 등)를 쓴다.
  • mount: 물리적인 장치(예: /dev/sda1)를 현재 파일 시스템 트리의 특정 위치(예: /home)에 갖다 붙인다.
    • 마운트 하기 전까지 /home은 그냥 빈 디렉터리지만, 마운트 후에는 해당 디스크의 루트 디렉터리가 /home 위치에 오버레이 된다.

요약 (Summary)

OSTEP 39장은 파일 시스템의 인터페이스를 폭넓게 다뤘다.

  • 파일은 바이트 배열, 디렉터리는 이름 목록이다.
  • open을 통해 Open File Table 엔트리를 만들고 Offset을 관리한다.
  • fork 시 오프셋이 공유되는 원리를 이해해야 한다.
  • fsync로 영속성을, rename으로 원자성을 보장한다.
  • unlink는 링크 카운트를 줄이며, 0이 될 때 파일이 삭제된다.
  • 하드 링크는 이름 추가, 심볼릭 링크는 바로가기 파일 생성이다.

이 API들은 유닉스 철학의 정수이며, 오늘날 대부분의 시스템(Linux, macOS 등)이 이 표준을 따르고 있다.