파일의 개념과 속성
번호가 매겨진 블록의 배열일 뿐인 디스크 위에 운영체제가 파일이라는 추상화를 만드는 이유를 살펴봅니다. 파일 형식을 판별하는 방법, 파일의 메타데이터와 세 가지 시각, 열기, 읽기, 쓰기, 탐색, 절단, 삭제 같은 연산과 이름이 지워져도 열린 파일이 남는 원리, 파일 디스크립터 누수, 순차 접근과 직접 접근, 파일 잠금을 정리합니다.
데이터를 저장할 때 우리는 파일이라는 개념을 당연하게 씁니다. 하지만 디스크는 파일이 무엇인지 모릅니다. 디스크가 아는 것은 0번부터 번호가 매겨진 블록들의 배열뿐이고, "몇 번 블록을 읽어라", "몇 번 블록에 써라"라는 요청만 처리합니다. 파일(file)은 운영체제가 이 블록들 위에 만들어 낸 추상화입니다. 파일이 없다면 모든 프로그램이 자기 데이터가 어느 블록에 있는지 직접 기억하고 관리해야 하며, 한 프로그램의 실수가 다른 프로그램의 블록을 덮어쓰는 일이 일상이 될 것입니다. 이 장에서는 운영체제가 파일과 디렉터리를 어떻게 제공하고 디스크 위에 구현하는지 살펴보며, 이 글은 파일 하나를 다루는 데서 시작합니다.
파일이라는 추상화
사용자에게 파일은 이름이 붙은 데이터의 덩어리입니다. 프로그램 코드든, 사진이든, 문서든, 데이터베이스든 무엇이든 담을 수 있고, 이름으로 찾아 열면 내용을 처음부터 순서대로 읽을 수 있습니다. 운영체제에게 파일은 데이터가 담긴 디스크 블록들의 모음과, 그 블록들이 어디에 있고 누가 쓸 수 있는지 같은 메타데이터를 함께 묶은 것입니다. 사용자가 report.txt를 열면, 운영체제는 그 이름을 메타데이터로, 메타데이터를 블록의 위치로 바꾸어 데이터를 읽어 옵니다. 이 변환을 맡는 것이 파일 시스템(file system)입니다.
파일이라는 추상화는 세 가지 문제를 함께 풉니다. 첫째, 블록 번호 대신 사람이 기억할 수 있는 이름으로 데이터를 찾게 해 줍니다. 둘째, 여러 프로세스가 같은 파일을 함께 쓸 수 있도록 연산과 잠금을 제공합니다. 다만 프로그램의 여러 연산을 하나의 트랜잭션처럼 묶어 주지는 않으므로, 그런 보장이 필요하면 프로그램이나 데이터베이스가 따로 마련해야 합니다. 셋째, 권한으로 다른 사용자의 데이터를 보호합니다.
파일의 형식은 누가 알까
운영체제의 커널은 파일을 일반 파일, 디렉터리, 심볼릭 링크, 장치 파일 같은 종류로 구분하지만, 일반 파일 안의 내용이 PDF인지 PNG인지는 신경 쓰지 않습니다. 그것은 파일을 쓰는 프로그램이 해석할 일입니다. 내용의 형식을 짐작하는 단서는 두 가지입니다.
하나는 확장자입니다. .txt, .pdf, .exe처럼 이름 끝에 붙인 표시로, Windows는 주로 이것을 보고 어떤 프로그램으로 열지 정합니다. 하지만 확장자는 이름의 일부일 뿐이어서, 사용자가 바꾸면 연결된 프로그램만 바뀌고 내용은 그대로입니다. 다른 하나는 매직 넘버(magic number)입니다. 많은 파일 형식은 처음 몇 바이트에 정해진 서명을 둡니다. PDF는 %PDF, PNG는 \x89PNG\r\n\x1a\n, Linux의 실행 파일 형식인 ELF는 \x7fELF로 시작합니다. Linux의 file 명령은 이런 서명을 모은 데이터베이스로 형식을 짐작합니다. 다음 코드는 매직 넘버로 형식을 판별하며, 확장자와 내용이 어긋난 파일들을 일부러 만들어 시험합니다.
import os
import tempfile
SIGNATURES = [(b"\x7fELF", "ELF 실행 파일"), (b"%PDF", "PDF 문서"), (b"\x89PNG\r\n\x1a\n", "PNG 이미지")]
def guess(path):
with open(path, "rb") as f:
head = f.read(8) # 앞의 몇 바이트만 읽는다
for magic, name in SIGNATURES:
if head.startswith(magic):
return name
return f"알 수 없음 (첫 바이트 {head[:1].hex() or '없음'})"
samples = {"report.txt": b"%PDF-1.7 ...", "photo.bin": b"\x89PNG\r\n\x1a\n...", "real.pdf": b"hello"}
with tempfile.TemporaryDirectory() as folder:
for name, data in samples.items():
path = os.path.join(folder, name)
with open(path, "wb") as f:
f.write(data)
print(f"{name:10} -> {guess(path)}")report.txt -> PDF 문서
photo.bin -> PNG 이미지
real.pdf -> 알 수 없음 (첫 바이트 68)report.txt는 확장자가 텍스트지만 내용이 %PDF로 시작하므로 PDF로, real.pdf는 확장자가 PDF지만 내용이 hello이므로 알 수 없는 형식으로 판별됩니다. 매직 넘버는 확장자보다 믿을 만한 단서지만, 이것도 앞의 몇 바이트만 볼 뿐 파일 전체가 올바른지까지 확인하지는 않습니다. 업로드된 파일을 처리하는 서버라면, 서명이 맞더라도 그 형식을 해석하는 라이브러리가 손상되거나 조작된 내용을 안전하게 처리하는지 따로 따져야 합니다.
파일의 속성
파일에는 데이터 말고도 여러 메타데이터(metadata)가 함께 저장됩니다.
| 속성 | 뜻과 예 |
|---|---|
| 이름 | 디렉터리 항목에 적힌 이름. report.txt |
| 종류 | 일반 파일, 디렉터리, 심볼릭 링크, 장치 파일 등 |
| 크기 | 바이트 수. 1,048,576바이트는 1MiB |
| 위치 정보 | 데이터가 담긴 블록들의 위치. Unix 계열에서는 inode에 기록한다 |
| 권한 | 누가 읽고 쓰고 실행할 수 있는지. rw-r--r-- |
| 시각 | 내용 수정, 접근, 상태 변경 시각 |
| 소유자 | 소유한 사용자와 그룹 |
| 링크 수 | 이 파일을 가리키는 이름(하드 링크)의 수 |
ls -l은 이 속성들을 한 줄로 요약합니다. -rw-r--r-- 1 user group 1024 Jan 15 10:30 report.txt라는 줄은 차례로 종류와 권한, 링크 수, 소유자, 그룹, 크기, 수정 시각, 이름을 보여 줍니다. 프로그램에서는 stat 계열의 시스템 콜로 읽습니다. 다음 Python 코드는 임시 파일을 만들어 몇 가지 속성을 읽고, 탐색과 절단이 파일에 무엇을 바꾸는지 비교합니다.
import os
import stat
import tempfile
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "test.txt")
with open(path, "wb") as f:
f.write(b"Hello, File System!\n")
st = os.stat(path)
print("크기:", st.st_size, "바이트")
print("일반 파일인가:", stat.S_ISREG(st.st_mode), "/ 폴더는 디렉터리인가:", stat.S_ISDIR(os.stat(folder).st_mode))
print("하드 링크 수:", st.st_nlink)
with open(path, "rb") as f:
f.seek(7) # 탐색: 다음에 읽을 위치만 옮긴다
print("7번 바이트부터 읽기:", f.read())
print("탐색 뒤에도 크기:", os.stat(path).st_size)
os.truncate(path, 10) # 절단: 파일의 길이 자체를 바꾼다
with open(path, "rb") as f:
print("10바이트로 자른 뒤:", f.read(), "크기", os.stat(path).st_size)크기: 20 바이트
일반 파일인가: True / 폴더는 디렉터리인가: True
하드 링크 수: 1
7번 바이트부터 읽기: b'File System!\n'
탐색 뒤에도 크기: 20
10바이트로 자른 뒤: b'Hello, Fil' 크기 10Hello, File System!\n은 마지막 줄바꿈까지 20바이트입니다. 문자열 끝을 나타내는 C의 NUL 바이트 같은 것은 파일에 들어가지 않습니다. 7번 바이트로 탐색해 읽으면 File System!\n을 얻지만 파일의 크기는 20 그대로입니다. 탐색은 열린 파일에서 다음에 읽고 쓸 위치만 옮기기 때문입니다. 반면 10바이트로 절단하면 파일의 길이가 줄어 처음 10바이트 Hello, Fil만 남습니다. 절단은 줄을 맞추어 주지 않으므로 마지막 줄바꿈도 함께 잘려 나갑니다.
세 가지 시각
Unix 계열 파일 시스템은 파일마다 세 가지 시각을 기록합니다. mtime(modification time)은 파일의 내용이 마지막으로 바뀐 시각입니다. 빌드 도구 make는 원본과 결과물의 mtime을 비교해, 원본이 더 새로우면 다시 컴파일합니다. atime(access time)은 파일을 마지막으로 읽은 시각입니다. ctime(change time)은 파일의 메타데이터, 곧 권한이나 소유자, 링크 수가 바뀐 시각이며, 내용을 쓸 때도 크기와 mtime이 바뀌므로 함께 갱신됩니다.
ctime의 c를 생성(creation)으로 오해하기 쉽지만 생성 시각이 아닙니다. 생성 시각(birth time)은 이 셋과 별도로, 지원하는 파일 시스템에서만 기록됩니다. Linux에서는 statx 시스템 콜이나 stat 명령의 Birth: 항목으로 볼 수 있습니다. 또 일반적인 시각 설정 함수로 mtime과 atime은 원하는 값으로 바꿀 수 있지만, ctime은 바꿀 수 없고 무언가를 바꾼 그 순간의 시각으로만 갱신됩니다.
atime은 성능 문제를 일으키기도 합니다. 파일을 읽기만 해도 atime을 기록해야 하므로, 읽을 때마다 디스크에 쓰기가 생기기 때문입니다. 그래서 Linux는 atime을 아예 기록하지 않는 noatime 마운트 옵션을 제공하고, 기본값으로는 atime이 mtime이나 ctime보다 이전이거나 하루 이상 지났을 때만 갱신하는 relatime을 씁니다.
파일 연산
운영체제는 파일을 다루는 연산을 시스템 콜로 제공합니다.
| 연산 | 하는 일 |
|---|---|
| 생성(create) | 새 파일을 만들고 디렉터리에 이름을 등록한다 |
| 열기(open) | 파일을 찾아 권한을 확인하고, 파일 디스크립터를 돌려준다 |
| 읽기(read), 쓰기(write) | 현재 위치에서 읽거나 쓰고, 위치를 그만큼 옮긴다 |
| 탐색(seek) | 다음에 읽고 쓸 위치를 옮긴다 |
| 절단(truncate) | 파일의 길이를 줄이거나 늘린다 |
| 삭제(unlink) | 디렉터리에서 파일의 이름 하나를 지운다 |
| 닫기(close) | 파일 디스크립터를 돌려준다 |
open()은 파일 디스크립터(file descriptor)라는 작은 정수를 돌려주고, 이후의 연산은 이름 대신 이 번호로 파일을 가리킵니다. 이름을 매번 찾는 대신 한 번 연 결과를 번호로 쥐고 있는 셈입니다. 파일 디스크립터와 커널의 자료구조는 10장 4편에서 자세히 다룹니다. 다음 C 프로그램은 파일을 만들어 쓰고, 다시 열어 읽고, 크기를 확인한 뒤 자릅니다.
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
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";
if (write(fd, msg, strlen(msg)) != (ssize_t) strlen(msg))
perror("write"); /* 오류이거나 일부만 쓴 경우 */
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) {
buf[n] = '\0';
printf("읽은 내용: %s", buf);
}
off_t size = lseek(fd, 0, SEEK_END); /* 끝으로 탐색하면 그 위치가 크기다. */
printf("파일 크기: %ld바이트\n", (long) size);
close(fd);
if (truncate("test.txt", 10) != 0) /* 처음 10바이트만 남긴다. */
perror("truncate");
return 0;
}쓰기 전용으로 연 디스크립터로는 읽을 수 없으므로, 읽기 위해 다시 열었습니다. 성공한다면 20바이트를 쓰고, 다시 읽어 같은 내용을 출력하며, 끝으로 탐색한 위치 20을 크기로 출력한 뒤, 파일을 10바이트로 자릅니다. write가 요청한 것보다 적게 쓰는 경우도 오류로 보고하도록 했지만, 실제 프로그램이라면 남은 부분을 이어서 쓰는 반복문이 필요합니다. 또 close가 성공했다고 데이터가 디스크에 저장되었다는 뜻은 아닙니다. 쓴 데이터는 페이지 캐시에 머물다가 나중에 디스크로 가며, 디스크까지 확실히 보내려면 fsync를 불러야 합니다. 이 내용은 10장 3편에서 다룹니다.
이름을 지워도 열린 파일은 남는다
Unix 계열에서 파일을 지우는 시스템 콜의 이름은 unlink입니다. 이름이 말해 주듯, 이것은 파일 자체가 아니라 디렉터리에서 파일을 가리키는 이름 하나를 지웁니다. 파일의 데이터가 차지한 공간은 그 파일을 가리키는 이름이 하나도 남지 않고, 그 파일을 열고 있는 프로세스도 없을 때 비로소 회수됩니다.
unlink는 이름을 지우고, 마지막 열린 참조가 사라져야 공간 회수가 가능합니다
이름과 열린 fd가 같은 파일을 유지한다. 남은 열린 참조도 제거한다. 각 단계의 값과 전환 이유를 비교합니다.
파일 연산 · 이름과 수명
unlink는 이름을 지우고, 마지막 열린 참조가 사라져야 공간 회수가 가능합니다
일반 파일 report.txt에 하드 링크 이름이 하나, 열린 fd가 하나 있고 다른 매핑·참조가 없는 예입니다. 이름을 제거하는 시점과 열린 파일의 수명이 끝나는 시점을 분리해서 봅니다.
영역은 같은 파일의 시간별 상태입니다. 영역 안의 화살표는 수명을 유지하는 참조이며, 이름을 지우는 동작과 열린 참조를 닫는 동작을 구분합니다. fd에서 파일로 가는 간선은 개념을 줄여 그린 것으로, 실제 열린 파일 설명 층은 파일 관리 도식에서 따로 봅니다.
이름도 열린 참조도 있음
report.txt→ 같은 파일하드 링크 수 1 · 열린 fd 3의 참조 1
이름으로 열거나 기존 fd로 읽을 수 있습니다.
unlink(report.txt) 성공
이름 제거 · 하드 링크 수 0
열린 fd 3의 참조는 남습니다. 그 fd로 파일을 계속 읽을 수 있지만, 사라진 이름으로 새로 열 수는 없습니다.
마지막 fd를 close
하드 링크 수 0 · 열린 참조 0
이 예에서는 파일을 유지하던 마지막 참조도 사라져 저장 공간 회수 조건이 충족됩니다.
다른 하드 링크, 열린 fd 또는 매핑이 있으면 수명이 이어질 수 있습니다. close와 공간 회수는 물리적 데이터의 즉시 덮어쓰기나 안전한 삭제를 보장하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 두 참조부터 마지막 close까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 같은 파일의 세 시간 상태이다. 첫 상태에는 report.txt 이름과 fd 3의 열린 참조가 각각 inode를 유지한다. unlink 성공 뒤 이름 간선은 없고 fd 3의 열린 참조만 남는다. 마지막 fd close 뒤 이름 0·열린 참조 0이며 다른 매핑·참조가 없다는 조건에서 공간 회수 조건이 충족된다. 영역 안 화살표는 수명 유지 참조를 줄여 그린 개념 간선이며 실제 fd 층의 열린 파일 설명은 별도로 구분한다. 모바일 영역 사이 화살표는 unlink와 close의 성공한 시간 전환이다. 공간의 즉시 덮어쓰기나 안전 삭제를 뜻하지 않는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 참조 이름과 열린 fd가 같은 파일을 유지한다. |
|
|
| unlink 성공 이름 참조만 제거한다. |
|
|
| 마지막 close 남은 열린 참조도 제거한다. |
|
|
unlink는 이름을 지우고, 마지막 열린 참조가 사라져야 공간 회수가 가능합니다
이름과 열린 fd가 같은 파일을 유지한다. 남은 열린 참조도 제거한다. 각 단계의 값과 전환 이유를 비교합니다.
파일 연산 · 이름과 수명
unlink는 이름을 지우고, 마지막 열린 참조가 사라져야 공간 회수가 가능합니다
일반 파일 report.txt에 하드 링크 이름이 하나, 열린 fd가 하나 있고 다른 매핑·참조가 없는 예입니다. 이름을 제거하는 시점과 열린 파일의 수명이 끝나는 시점을 분리해서 봅니다.
영역은 같은 파일의 시간별 상태입니다. 영역 안의 화살표는 수명을 유지하는 참조이며, 이름을 지우는 동작과 열린 참조를 닫는 동작을 구분합니다. fd에서 파일로 가는 간선은 개념을 줄여 그린 것으로, 실제 열린 파일 설명 층은 파일 관리 도식에서 따로 봅니다.
이름도 열린 참조도 있음
report.txt→ 같은 파일하드 링크 수 1 · 열린 fd 3의 참조 1
이름으로 열거나 기존 fd로 읽을 수 있습니다.
unlink(report.txt) 성공
이름 제거 · 하드 링크 수 0
열린 fd 3의 참조는 남습니다. 그 fd로 파일을 계속 읽을 수 있지만, 사라진 이름으로 새로 열 수는 없습니다.
마지막 fd를 close
하드 링크 수 0 · 열린 참조 0
이 예에서는 파일을 유지하던 마지막 참조도 사라져 저장 공간 회수 조건이 충족됩니다.
다른 하드 링크, 열린 fd 또는 매핑이 있으면 수명이 이어질 수 있습니다. close와 공간 회수는 물리적 데이터의 즉시 덮어쓰기나 안전한 삭제를 보장하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 두 참조부터 마지막 close까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 같은 파일의 세 시간 상태이다. 첫 상태에는 report.txt 이름과 fd 3의 열린 참조가 각각 inode를 유지한다. unlink 성공 뒤 이름 간선은 없고 fd 3의 열린 참조만 남는다. 마지막 fd close 뒤 이름 0·열린 참조 0이며 다른 매핑·참조가 없다는 조건에서 공간 회수 조건이 충족된다. 영역 안 화살표는 수명 유지 참조를 줄여 그린 개념 간선이며 실제 fd 층의 열린 파일 설명은 별도로 구분한다. 모바일 영역 사이 화살표는 unlink와 close의 성공한 시간 전환이다. 공간의 즉시 덮어쓰기나 안전 삭제를 뜻하지 않는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 참조 이름과 열린 fd가 같은 파일을 유지한다. |
|
|
| unlink 성공 이름 참조만 제거한다. |
|
|
| 마지막 close 남은 열린 참조도 제거한다. |
|
|
report.txt라는 이름이 하나 있고 한 프로세스가 이 파일을 디스크립터 3으로 열고 있다고 합시다. unlink("report.txt")가 성공하면 이름은 사라져 그 이름으로 새로 열 수는 없지만, 프로세스는 디스크립터 3으로 여전히 파일을 읽을 수 있습니다. 프로세스가 디스크립터를 닫아 마지막 열린 참조가 사라져야 공간을 회수할 수 있습니다. 그래서 서버에서 큰 로그 파일을 rm으로 지웠는데 디스크 공간이 늘지 않는다면, 그 로그를 아직 열고 쓰는 프로세스가 있는지 확인해야 합니다. lsof +L1이나 ls -l /proc/<PID>/fd로 이름은 지워졌지만 열려 있는 파일을 찾을 수 있습니다. 이 성질을 거꾸로 이용해, 임시 파일을 만들어 연 직후 이름을 지워 두면 프로그램이 어떻게 끝나든 파일이 남지 않게 할 수도 있습니다.
파일 디스크립터 누수
프로세스가 동시에 열어 둘 수 있는 파일의 수에는 한도가 있습니다. Linux에서는 셸에서 ulimit -n으로 지금의 한도를 볼 수 있으며, 많은 배포판에서 기본값은 1,024지만 설정에 따라 다릅니다. 파일을 열고 닫지 않으면 디스크립터가 하나씩 쌓이다가 한도에 이르러 open()이 EMFILE 오류로 실패합니다. 이를 파일 디스크립터 누수(file descriptor leak)라고 하며, 오래 실행되는 서버에서 흔한 버그입니다.
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void)
{
for (int i = 0; i < 100000; i++) {
int fd = open("/dev/null", O_RDONLY);
if (fd < 0) {
printf("%d번째 열기에서 실패\n", i + 1);
perror("open");
return 1;
}
/* close(fd); 이 줄을 빠뜨리면 디스크립터가 쌓인다. */
}
return 0;
}한도가 1,024라면 표준 입력, 출력, 에러가 이미 0, 1, 2번을 쓰고 있으므로, 대략 1,022번째 근처에서 open이 "Too many open files" 오류로 실패할 것입니다. 정확한 횟수는 한도와 이미 열려 있는 디스크립터의 수에 따라 다릅니다. 서버라면 이 오류가 나는 순간부터 새 연결을 받지도, 로그 파일을 열지도 못하게 되므로, 오류 경로를 포함한 모든 경로에서 디스크립터를 닫는지 확인해야 합니다.
순차 접근과 직접 접근
파일의 데이터에 접근하는 방식은 크게 두 가지입니다. 순차 접근(sequential access)은 처음부터 끝까지 차례로 읽거나 씁니다. read()를 부를 때마다 현재 위치가 읽은 만큼 앞으로 나아가므로, 반복해서 부르기만 하면 파일 전체를 읽게 됩니다. 테이프 시절부터 내려온 방식이며, 로그 파일을 읽거나 영상을 재생하는 일이 이에 해당합니다.
직접 접근(direct access) 또는 임의 접근(random access)은 파일의 원하는 위치로 바로 가서 읽거나 씁니다. lseek()로 위치를 옮긴 뒤 read()나 write()를 부르거나, 위치를 인자로 받는 pread()와 pwrite()를 씁니다. 디스크는 어느 블록이든 번호로 바로 읽을 수 있으므로 이 방식이 가능합니다. 데이터베이스처럼 수많은 레코드 가운데 특정 레코드를 바로 찾아야 하는 프로그램이 대표적입니다.
파일 잠금
여러 프로세스가 같은 파일을 동시에 고치면 내용이 섞여 망가질 수 있습니다. 파일 잠금(file locking)은 이를 막기 위한 장치이며, 강제하는 정도에 따라 두 가지로 나뉩니다.
권고 잠금(advisory lock)은 잠금을 확인하겠다고 약속한 프로세스들 사이에서만 효과가 있습니다. 한 프로세스가 잠금을 걸어도, 잠금을 확인하지 않는 다른 프로세스는 그 파일을 마음대로 읽고 쓸 수 있습니다. Linux의 flock()과 fcntl() 잠금이 모두 이 방식입니다. 강제 잠금(mandatory lock)은 운영체제가 잠긴 파일에 대한 읽기와 쓰기 자체를 막는 방식인데, Linux는 일반 파일의 강제 잠금을 5.15부터 더 지원하지 않습니다. Windows의 파일 공유 모드나 SMB 같은 원격 파일 시스템은 별도의 강제 방식을 가지므로 따로 확인해야 합니다.
#include <fcntl.h>
#include <stdio.h>
#include <sys/file.h>
#include <unistd.h>
int main(void)
{
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");
if (write(fd, "exclusive data\n", 15) != 15)
perror("write");
sleep(5);
flock(fd, LOCK_UN);
printf("잠금 해제\n");
close(fd);
return 0;
}이 프로그램을 터미널 두 개에서 동시에 실행하면, 나중에 실행한 쪽은 "잠금 대기 중..." 뒤에서 멈췄다가, 먼저 실행한 쪽이 잠금을 풀면 진행합니다. 하지만 같은 순간에 다른 터미널에서 echo hi >> shared.dat를 실행하면 아무 기다림 없이 파일에 씁니다. echo는 잠금을 확인하지 않기 때문이며, 이것이 권고 잠금의 뜻입니다. 잠금은 그 파일을 쓰는 모든 프로그램이 같은 규칙을 따를 때만 데이터를 지켜 줍니다.
flock()은 파일 전체를 잠그지만, fcntl()의 레코드 잠금(record lock)은 파일의 특정 바이트 범위만 잠글 수 있습니다. 데이터베이스처럼 한 파일 안에 많은 레코드가 있다면, 고치려는 레코드만 잠가 다른 레코드는 다른 프로세스가 동시에 고칠 수 있게 해 동시성을 높일 수 있습니다. Linux의 로컬 파일 시스템에서 flock() 잠금과 fcntl() 잠금은 서로 독립적이어서, 한쪽으로 건 잠금이 다른 쪽을 막지 않습니다. 반면 NFS에서는 flock()이 파일 전체에 대한 fcntl() 잠금으로 흉내 내어지므로 둘이 서로 영향을 줍니다. 그래서 같은 파일을 쓰는 프로그램들은 같은 종류의 잠금을 쓰기로 정해 두어야 합니다. 자세한 규칙은 flock(2)과 fcntl_locking(2)에 있습니다.
더 깊이: 닫기만 해도 풀리는 레코드 잠금
전통적인 fcntl() 레코드 잠금은 디스크립터가 아니라 프로세스에 묶입니다. F_SETLK(2const)는 이 때문에 생기는 두 가지 곤란한 결과를 적어 두었습니다. 첫째, 프로세스가 그 파일을 가리키는 디스크립터를 하나라도 닫으면, 어느 디스크립터로 걸었는지와 상관없이 그 파일에 건 잠금이 모두 풀립니다. 그래서 라이브러리 함수가 같은 파일을 잠깐 열어 읽고 닫기만 해도 프로그램이 모르는 사이에 잠금을 잃을 수 있습니다. 둘째, 한 프로세스의 스레드들은 잠금을 함께 가지므로, 레코드 잠금으로는 스레드끼리 같은 범위에 동시에 접근하지 못하게 막을 수 없습니다.
Linux 3.15부터 쓸 수 있는 열린 파일 설명 잠금(open file description lock)은 이 두 문제를 풉니다. F_OFD_SETLK 같은 명령으로 거는 이 잠금은 flock()처럼 잠금을 건 열린 파일 설명에 묶이므로, 그 열린 파일 설명이 마지막으로 닫힐 때만 풀립니다. 스레드마다 파일을 따로 open()하면 열린 파일 설명이 서로 다르므로 스레드 사이의 동기화에도 쓸 수 있습니다. 또 전통적인 레코드 잠금은 fork()로 만든 자식에게 물려지지 않지만, 이 잠금은 열린 파일 설명과 함께 자식에게 이어집니다.
정리
- 파일은 운영체제가 디스크 블록 위에 만든 추상화로, 이름으로 데이터를 찾고, 여러 프로세스가 함께 쓰며, 권한으로 보호하게 해 줍니다.
- 커널은 파일의 종류를 구분하지만 내용의 형식은 프로그램이 해석합니다. 확장자는 이름의 일부일 뿐이고, 처음 몇 바이트의 매직 넘버가 더 믿을 만한 단서입니다.
- 파일에는 크기, 권한, 소유자, 링크 수, 세 가지 시각 같은 메타데이터가 있습니다. ctime은 상태 변경 시각이지 생성 시각이 아닙니다.
- 탐색은 다음에 읽고 쓸 위치만 옮기고, 절단은 파일의 길이를 바꿉니다.
unlink는 이름 하나를 지울 뿐이며, 이름과 열린 참조가 모두 사라져야 공간이 회수됩니다. - 열고 닫지 않은 파일 디스크립터는 쌓여 한도에 이르면
open이 실패합니다. - Linux의 파일 잠금은 권고 잠금이어서 같은 규칙을 따르는 프로세스들 사이에서만 효과가 있으며,
fcntl레코드 잠금은 바이트 범위만 잠글 수 있습니다.
연습 문제
1. 쓰고 자르기
새 파일에 ASCII 문자열 log:ABCD\n을 쓰고, 다시 읽고, 끝으로 탐색해 크기를 확인한 뒤, 5바이트로 절단했습니다. 모든 호출이 성공했다고 할 때 처음 크기와 절단한 뒤의 크기, 남는 내용을 구하세요. 절단할 때 줄바꿈이나 NUL 바이트가 새로 붙을까요?
해설 보기
log:가 4바이트, ABCD가 4바이트, 줄바꿈이 1바이트이므로 처음 크기는 9바이트이고, 끝으로 탐색한 위치도 9입니다. 5바이트로 절단하면 처음 5바이트인 log:A만 남고 크기는 5가 됩니다. 원래 있던 줄바꿈은 잘려 나가며, 절단은 바이트 길이만 바꿀 뿐 텍스트의 줄을 맞추어 주지 않으므로 줄바꿈이나 NUL 바이트가 새로 붙지 않습니다. 본문의 Python 코드에서 쓰는 내용과 절단 길이를 바꾸어 확인할 수 있습니다.
2. 이름이 둘인 파일 지우기
일반 파일에 하드 링크로 연결된 이름이 두 개 있고, 한 프로세스가 이 파일을 디스크립터 3으로 열어 두었습니다. 다른 참조는 없습니다. 첫 이름을 unlink하고, 디스크립터 3을 닫은 뒤, 마지막 이름을 unlink합니다. 각 단계 뒤에 남은 이름의 수와 열린 참조의 수, 그리고 공간이 언제 회수될 수 있는지 설명하세요.
해설 보기
첫 unlink 뒤에는 이름이 1개, 열린 참조가 1개 남습니다. 남은 이름으로도, 디스크립터 3으로도 파일에 접근할 수 있습니다. 디스크립터를 닫은 뒤에는 이름이 1개, 열린 참조가 0개입니다. 열린 참조는 없지만 남은 이름이 파일을 붙잡고 있으므로 공간은 회수되지 않습니다. 마지막 unlink 뒤에야 이름도 열린 참조도 모두 0이 되어 공간을 회수할 수 있습니다. 디스크립터를 먼저 닫았다고 해서 남은 이름까지 사라지는 것은 아니며, 이름과 열린 참조를 모두 세어야 파일의 수명을 알 수 있습니다.
3. 지웠는데 줄지 않는 디스크
서버의 디스크가 가득 차서 10GB짜리 app.log를 rm으로 지웠는데, df로 보니 남은 공간이 늘지 않았습니다. 무엇이 원인일 가능성이 높으며, 어떻게 확인하고 해결할 수 있을까요?
해설 보기
애플리케이션이 app.log를 열어 둔 채 계속 쓰고 있을 가능성이 높습니다. rm은 unlink로 디렉터리의 이름만 지우므로, 그 파일을 열고 있는 프로세스가 있는 한 데이터는 디스크에 남아 공간을 차지합니다. lsof +L1로 이름은 지워졌지만 열려 있는 파일을 찾거나, 의심되는 프로세스의 /proc/<PID>/fd를 ls -l로 보면 (deleted)라고 표시된 항목으로 확인할 수 있습니다. 해결하려면 그 프로세스가 파일을 닫게 해야 합니다. 로그를 다시 여는 신호를 지원하는 프로그램이라면 그 신호를 보내고, 그렇지 않다면 프로세스를 다시 시작합니다. 처음부터 이런 일을 피하려면 로그를 지우지 말고 logrotate처럼 로그 파일을 돌려쓰는 도구를 쓰거나, 내용을 비워야 할 때는 이름을 지우는 대신 truncate -s 0 app.log로 길이를 0으로 만드는 방법을 씁니다. 다만 길이를 줄이는 방법은 프로세스가 쓸 때마다 파일 끝으로 옮겨 가는 O_APPEND로 파일을 열었을 때만 깔끔합니다. 그렇지 않으면 프로세스가 예전 위치에 이어 써서 파일 앞부분이 0으로 채워진 빈 구간이 생기며, 이 점은 10장 4편의 연습 문제에서 다시 봅니다.
다음 글 디렉터리 구조에서는 파일들을 정리하는 디렉터리가 어떻게 구성되고, 경로가 어떻게 해석되며, 하드 링크와 심볼릭 링크가 어떻게 다른지 살펴봅니다.