운영체제 아주 쉬운 세가지 이야기 14주차(파일과 디렉터리)

2025. 12. 9. 18:20개념 공부

파일 시스템 인터페이스(UNIX) 한 번에 잡기: Files & Directories 핵심 정리

목차


1) 파일/디렉터리 모델(가장 중요한 그림)

  • 파일(file): 선형 바이트 배열. OS는 “내용의 의미(텍스트/이미지/코드)”를 몰라도 됨.
  • 저수준 이름: 파일은 내부적으로 숫자 같은 고유 식별자로 관리됨(보통 inode number).
  • 디렉터리(directory): 내용이 특별한 파일. 내부는 (사람이 읽는 이름, 저수준 이름)의 리스트.
  • 경로(pathname): 루트(/)에서 시작하는 트리로 파일/디렉터리를 이름으로 찾아감.
  • 디렉터리 특수 엔트리: . (자기 자신), .. (부모)
핵심 요약
“이름(문자열) → 디렉터리 엔트리 → inode(숫자) → 데이터 블록(바이트)”
즉, ‘파일 이름’은 본질이 아니라 매핑일 뿐입니다.

2) open/read/write/close: “파일을 쓴다”의 진짜 의미

프로세스가 파일에 접근하려면 보통 open()으로 OS에 요청하고, OS가 허용하면 파일 디스크립터(fd)를 돌려줍니다. 이 fd로 read()/write()를 하고 마지막에 close()를 합니다.

왜 open()이 3을 반환하나요?

대부분의 프로세스는 시작할 때 이미 3개의 표준 FD를 가지고 있습니다:

  • 0: 표준 입력(stdin)
  • 1: 표준 출력(stdout)
  • 2: 표준 에러(stderr)

그래서 첫 번째로 추가 파일을 열면 대개 3부터 배정됩니다.

추천 도구: strace (mac은 dtruss)

“cat이 내부적으로 무슨 시스템콜을 치는지” 같은 걸 눈으로 확인하면 이해가 10배 빨라집니다.


3) FD & Open File Table(OFT): 오프셋이 어디에 저장되나?

  • FD는 프로세스별(private)로 존재하는 숫자입니다.
  • FD는 커널의 Open File Table(OFT) 엔트리를 가리킵니다.
  • OFT 엔트리는 보통 다음을 트래킹합니다:
    • 어떤 파일(inode)을 가리키는지
    • 현재 오프셋(current offset)
    • 읽기/쓰기 가능 여부 등 상태
중요 포인트
오프셋은 “파일 자체”가 아니라 OFT 엔트리(=열려있는 파일 핸들)에 있습니다.
따라서 “같은 파일을 두 번 open”하면 오프셋이 따로 움직일 수 있습니다.

4) lseek: 랜덤 액세스

read()/write()는 기본적으로 “현재 오프셋에서부터” 동작하고 오프셋을 자동으로 갱신합니다. 랜덤 액세스를 하려면 lseek()으로 오프셋을 재설정합니다.

off_t lseek(int fd, off_t offset, int whence);
// whence:
//   SEEK_SET: offset bytes로 절대 이동
//   SEEK_CUR: 현재 위치 + offset
//   SEEK_END: 파일 끝 + offset

5) fork/dup: FD 공유가 만드는 함정

fork() 후에는 무슨 일이 일어나나?

부모가 열어둔 FD는 자식에게 복사되는데, 경우에 따라 같은 OFT 엔트리를 공유할 수 있습니다. 그러면 오프셋도 공유됩니다. 즉, 자식이 lseek()로 오프셋을 바꾸면 부모가 보는 오프셋도 바뀔 수 있습니다.

dup(): 같은 OFT 엔트리를 가리키는 “새 FD 만들기”

dup()은 기존 FD와 동일한 open file(=OFT 엔트리)을 가리키는 새 FD를 만들어 줍니다. 쉘의 리다이렉션(예: cmd > out.txt) 같은 구현에서 아주 자주 쓰입니다.

실무 함정
“각 프로세스가 따로따로 오프셋을 가진다”라고 생각했다가 fork/dup에서 틀리기 쉽습니다.
공유 여부는 FD 숫자가 아니라 OFT 엔트리 공유가 핵심입니다.

6) fsync/rename: “크래시에도 안전하게 저장”

write()는 ‘지금 디스크에 저장’이 아닐 수 있다

대부분의 파일시스템은 성능을 위해 write()를 메모리에 버퍼링했다가 나중에 디스크에 씁니다. 그래서 전원이 꺼지면 write()가 성공했어도 데이터가 날아갈 수 있습니다.

fsync(fd): “이 파일의 더티 데이터를 지금 디스크로”

fsync(fd)는 해당 FD가 가리키는 파일의 더티 데이터가 디스크에 반영될 때까지 기다립니다.

디테일 주의
새 파일을 만들었다면 “파일 내용”만 fsync로는 부족할 수 있고,
그 파일이 속한 디렉터리도 fsync 해야 “디렉터리 엔트리까지” 내구적으로 남는 경우가 있습니다.

rename()의 원자성(atomicity): 크래시에도 ‘중간 상태’가 없음

rename(old, new)는 보통 크래시 관점에서 원자적으로 구현되어, 시스템이 중간에 죽어도 파일 이름은 old 또는 new 중 하나로만 남도록 보장하는 경우가 많습니다. 이 특성을 이용한 대표 패턴이 “임시 파일에 쓰고 → fsync → rename으로 스왑”입니다.

// emacs 같은 편집기가 자주 쓰는 안전 업데이트 패턴(개념)
int fd = open("foo.txt.tmp", O_WRONLY|O_CREAT|O_TRUNC, S_IRUSR|S_IWUSR);
write(fd, buffer, size);
fsync(fd);
close(fd);
rename("foo.txt.tmp", "foo.txt");

7) stat 메타데이터: inode, 링크 수(=link count)

파일시스템은 파일에 대한 여러 정보를 메타데이터로 관리합니다(크기, inode 번호, 권한, 접근/수정 시간 등). 이를 확인하는 대표 API/도구가 stat() / fstat() / stat 명령입니다.


unlink(): “삭제”의 실체는 디렉터리 엔트리 제거

  • rm은 내부적으로 보통 unlink()를 호출해 “이 이름→inode” 연결을 끊습니다.
  • 파일은 즉시 사라진다기보다, inode의 링크 수(link count)가 감소합니다.
  • 링크 수가 0이 될 때 inode와 데이터 블록이 실제로 해제되어 “진짜 삭제”가 됩니다.

하드링크(hard link)

  • 같은 inode를 가리키는 “다른 이름”을 하나 더 만드는 것.
  • 그래서 원본 이름을 rm해도(링크 수만 줄었을 뿐) 다른 이름으로 파일이 남아있을 수 있습니다.
  • 제약: 디렉터리에 하드링크 금지(사이클 위험), 다른 파일시스템 파티션에 하드링크 불가(inode 유일성 범위).

심볼릭링크(symbolic link, soft link)

  • 심볼릭링크는 “목표 파일의 경로 문자열”을 자기 데이터로 들고 있는 특수 파일입니다.
  • 그래서 링크 파일의 크기가 “경로 길이”가 됩니다.
  • 단점: 원본이 삭제되면 dangling reference(깨진 링크)가 될 수 있습니다.

9) 권한 비트 & ACL: 공유를 ‘통제’하는 법

UNIX permission bits (rwx rwx rwx)

권한은 크게 세 그룹(소유자/그룹/기타) × 세 동작(읽기/쓰기/실행)으로 표현됩니다.

-rw-r--r--  (예: 일반 파일)
 d rwx r-x --- (예: 디렉터리)
  • 예: chmod 600 foo.txt → 소유자만 읽기/쓰기 가능(그룹/기타는 0)
  • 실행 비트
    • 일반 파일: 실행 가능(프로그램/스크립트 실행)
    • 디렉터리: 해당 디렉터리로 cd 가능, (쓰기 비트와 조합해) 내부 파일 생성/접근에 영향

ACL(Access Control List)

권한 비트보다 더 정교하게 “누가 무엇을 할 수 있는지”를 표현하는 메커니즘이며, 분산 파일시스템(예: AFS) 등에서 널리 활용됩니다.


10) mount: 여러 파일시스템을 하나의 트리로

리눅스/유닉스는 여러 파일시스템(ext3, proc, tmpfs, 분산 FS 등)을 하나의 디렉터리 트리에 붙여서 씁니다. mount는 특정 장치/파티션의 파일시스템을 특정 경로(마운트 포인트)에 연결해, 이름 공간을 통합합니다.


11) TOCTTOU: 권한 있는 코드가 털리는 고전 패턴

TOCTTOU(Time Of Check To Time Of Use)는 “검사(check)와 사용(use) 사이의 시간 틈”을 공격자가 파고드는 취약점입니다. 예를 들어, 루트 권한 서비스가 lstat()로 “이 파일은 안전한 일반 파일이네”를 확인한 다음, 그 사이 공격자가 rename()으로 그 파일을 /etc/passwd 같은 민감 파일로 바꿔치기하면, 서비스가 민감 파일을 업데이트해버리는 일이 생길 수 있습니다.

  • 완벽한 해법은 쉽지 않습니다.
  • 완화책 예: O_NOFOLLOW로 심볼릭링크를 따라가지 않게 하여 일부 공격을 막기.
  • 근본적으로는 “권한 있는 코드에서 파일 다룰 때” 매우 조심해야 합니다.

12) 자가 피드백 & 실무 체크리스트

A. 반드시 머리에 박아야 하는 5개

  1. 이름은 매핑이다: 파일명은 inode를 가리키는 디렉터리 엔트리일 뿐.
  2. 오프셋은 파일이 아니라 OFT에 있다: 같은 파일을 두 번 open하면 오프셋은 따로.
  3. write() != durable: 크래시가 나면 유실 가능. 내구성이 필요하면 fsync 설계가 필요.
  4. rename은 “원자적 스왑” 도구: 임시파일 → fsync → rename 패턴은 안전 업데이트의 정석.
  5. 삭제(unlink)는 “참조 끊기”: 링크 수 0이 되기 전까지 파일 데이터는 남을 수 있다.

B. 운영/서비스에서 자주 터지는 포인트

  • fsync 비용: 너무 자주 하면 성능이 급락합니다. “정말 필요한 데이터만” 내구성 보장 설계(배치/그룹 커밋 등)로.
  • 새 파일 생성의 내구성: 파일 fsync만으로는 디렉터리 엔트리가 날아갈 수 있는 케이스를 항상 염두에.
  • fork 이후 FD 공유: 로그/출력 파일에 여러 프로세스가 동시에 쓰는 설계는 쉽지만, 오프셋 공유/원자성/동기화 이슈를 동반.
  • 심볼릭링크 공격: root 권한 서비스, setuid 바이너리, 백업/배치 스크립트에서 TOCTTOU+symlink는 단골.
  • 디렉터리 execute 비트: “rwx가 파일과 디렉터리에서 의미가 다르다”를 모르면 권한 디버깅이 지옥이 됩니다.

C. 실무형 체크리스트(코드 리뷰 때 그대로 쓰는 버전)

  • 이 데이터는 “크래시 후에도” 남아야 하나? 그렇다면 어떤 경로에서 fsync가 필요한가?
  • 원자적 업데이트가 필요한가? 필요하면 “temp file + fsync + rename” 패턴을 쓰고 있는가?
  • root/권한 있는 코드인가? 그렇다면 lstat/open 플로우에 TOCTTOU 틈이 없는가? O_NOFOLLOW 등을 고려했는가?
  • 삭제/정리 로직이 unlink 기반일 때, “열려있는 FD/링크 수” 때문에 파일이 즉시 사라지지 않을 수 있음을 고려했는가?
  • 심볼릭링크가 깨져도 안전한가(dangling reference)? 깨지면 어떻게 복구/에러 처리할 건가?

13) 미니 퀴즈/실습 과제

퀴즈(짧게)

  1. 같은 파일을 두 번 open()하면 오프셋은 공유될까?
  2. write()가 성공했는데도 데이터가 날아갈 수 있는 이유는?
  3. rm이 “진짜로 파일 내용을 즉시 삭제”하지 않을 수 있는 이유는?
  4. 심볼릭 링크의 파일 크기가 “경로 길이”가 될 수 있는 이유는?
  5. 디렉터리에서 execute 비트는 어떤 의미를 가지나?