파일의 개념과 속성
파일이 디스크 블록을 추상화하는 방식을 이해하고 유형·메타데이터·연산·접근 방식·잠금의 의미를 정리합니다.
디스크에 데이터를 저장할 때 우리는 파일이라는 개념을 당연하게 사용합니다.
하지만 디스크 자체는 파일이 무엇인지 모릅니다.
디스크는 단지 번호가 매겨진 블록의 배열일 뿐입니다.
파일(File)은 운영체제가 만들어낸 추상화입니다.
이 추상화 없이는 모든 프로그램이 디스크의 물리적 주소를 직접 다뤄야 하고, 그렇게 되면 실수로 다른 프로그램의 데이터를 덮어쓰는 일이 일상이 됩니다.
프로그램은 파일 이름으로 접근하지만 커널은 inode, 권한, 위치 정보를 통해 실제 디스크 블록을 다룬다.
- Name
report.txt 사람이 읽는 식별자
- Metadata
inode 권한/시간/크기
- Mapping
block list 디스크 위치
- Access
open/read/write 동시성 보호
파일이란 무엇인가
파일은 이름이 붙은 데이터의 연속입니다.
사용자 관점에서 파일은 디스크에 저장된 논리적 단위이며, 프로그램 코드, 이미지, 텍스트, 데이터베이스 등 무엇이든 담을 수 있습니다.
운영체제 관점에서 파일은 관련된 디스크 블록들의 모음과 그 메타데이터(이름, 크기, 위치, 권한 등)를 포함하는 추상화입니다.
사용자가 보고서.txt를 열어줘라고 하면, OS는 파일 이름을 디스크 블록 위치로 변환하여 데이터를 읽어옵니다.
이 변환 과정이 파일 시스템의 핵심 역할입니다.
파일 시스템 추상화는 세 가지 문제를 동시에 해결합니다.
첫째, 사용자에게 친숙한 이름을 부여합니다.
둘째, 여러 프로세스가 동시에 파일에 접근할 때 일관성을 보장합니다.
셋째, 권한을 통해 다른 사용자의 데이터를 보호합니다.
파일 유형
OS는 파일의 내용을 해석하는 방법을 알아야 합니다.
파일 유형을 결정하는 방식은 두 가지입니다.
확장자 기반: Windows가 주로 사용하는 방식입니다.
.txt, .pdf, .exe 같은 확장자가 파일 유형을 나타냅니다.
사용자가 확장자를 바꾸면 연결 프로그램만 바뀔 뿐, 파일 내용은 변하지 않습니다.
매직 넘버 기반: Unix 계열이 주로 사용하는 방식입니다.
파일의 처음 몇 바이트를 읽어 유형을 판별합니다.
PDF는 %PDF로, PNG는 \x89PNG로, ELF 실행 파일은 \x7fELF로 시작합니다.
확장자가 없어도 실제 내용을 정확히 판별할 수 있습니다.
#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 |
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(변경 시각)은 메타데이터(권한, 소유자 등)가 변경된 시각이며, 사용자가 직접 바꿀 수 없습니다.
파일 유형은 확장자나 매직 넘버로 읽고, 권한·소유자·시간·링크 수는 파일 시스템 메타데이터에서 조회한다.
- 유형 판별
확장자 .txt, .pdf, .exe처럼 이름 끝의 관례로 연결 프로그램을 고른다. 매직 넘버 파일 첫 바이트를 읽어 PNG, PDF, ELF 같은 실제 형식을 확인한다.
- stat가 드러내는 속성
st_size 파일 크기 st_ino inode 번호 st_mode 유형과 권한 st_uid 소유 사용자 st_gid 소유 그룹 st_nlink 하드 링크 수
- 타임스탬프 해석
mtime 내용이 바뀐 시각. make와 동기화 도구가 자주 본다. atime 읽힌 시각. 성능 때문에 noatime, relatime으로 줄이기도 한다. ctime 권한·소유자 같은 메타데이터가 바뀐 시각이다.
파일 연산
OS는 파일에 대해 다음 연산을 시스템 콜로 제공합니다.
- 생성(Create): 새 파일을 만들고 디렉토리에 등록합니다.
- 열기(Open): 파일 디스크립터를 할당하고, 커널 테이블에 엔트리를 만듭니다.
- 읽기(Read): 현재 파일 포인터 위치에서 데이터를 읽습니다.
- 쓰기(Write): 현재 파일 포인터 위치에 데이터를 씁니다.
- 탐색(Seek): 파일 포인터를 원하는 위치로 이동합니다.
- 삭제(Delete): 파일의 디렉토리 항목과 데이터 블록을 해제합니다.
- 절단(Truncate): 파일 내용을 지우되 속성은 유지합니다.
- 닫기(Close): 파일 디스크립터를 해제하고 버퍼를 디스크에 씁니다.
#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()이 실패합니다.
서버 프로그램에서 흔한 버그입니다.
#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;
}파일 디스크립터는 프로세스와 커널의 열린 파일 테이블을 잇고, 커널은 파일 포인터·모드·참조 수·잠금 상태를 관리한다.
- 프로세스
fd = open(path, flags) 정수 핸들을 받고 이후 연산은 fd로 요청한다. read / write / lseek 현재 파일 포인터를 기준으로 데이터 위치가 결정된다.
- 커널 열린 파일 테이블
offset read와 write 뒤 자동으로 전진한다. flags O_RDONLY, O_WRONLY, O_APPEND 같은 접근 모드다. ref count 복제된 fd와 공유 상태를 추적한다.
- 파일 시스템
inode 속성과 블록 위치 blocks 실제 데이터 조각 mode 권한 검사 기준 lock 동시 수정 조정
- inode
속성과 블록 위치
- blocks
실제 데이터 조각
- mode
권한 검사 기준
- lock
동시 수정 조정
- create
새 inode와 디렉토리 항목을 만든다.
- open
fd와 열린 파일 엔트리를 할당한다.
- read/write
offset 위치에서 데이터를 옮긴다.
- seek
offset을 원하는 위치로 바꾼다.
- close
fd를 해제하고 버퍼를 반영한다.
- 접근 방식
순차 접근 read 뒤 offset이 앞으로 간다. 직접 접근 lseek 뒤 원하는 위치를 읽는다.
- 잠금 기준
권고적 잠금 프로세스가 규칙을 지킨다. 레코드 잠금 파일 일부만 보호해 병행성을 남긴다.
- 닫지 않으면 생기는 문제
close가 빠지면 열린 파일 카운트가 줄지 않아 fd 누수가 생긴다. 서버에서는 결국 open 실패로 이어질 수 있다.
접근 방법
파일 데이터에 접근하는 방식은 크게 두 가지입니다.
순차 접근(Sequential Access): 처음부터 끝까지 순서대로 읽거나 씁니다.
테이프 시대의 접근 방식이며, 로그 파일이나 스트리밍 데이터가 이에 해당합니다.
read() 호출 후 파일 포인터가 자동으로 전진합니다.
직접 접근(Direct/Random Access): 파일의 임의 위치로 바로 이동하여 읽거나 씁니다.
디스크의 물리적 특성이 이를 가능하게 합니다.
lseek()로 원하는 위치로 이동한 후 read() 또는 write()를 호출합니다.
데이터베이스가 대표적입니다 — 특정 레코드에 바로 접근해야 합니다.
파일 잠금
여러 프로세스가 동시에 같은 파일을 수정하면 데이터가 손상될 수 있습니다.
파일 잠금(File Locking)은 이를 방지합니다.
권고적 잠금(Advisory Lock)은 프로세스가 자발적으로 잠금을 확인합니다.
잠금을 무시하고 접근하는 것이 기술적으로 가능합니다.
POSIX의 flock()이 이 방식입니다.
강제적 잠금(Mandatory Lock)은 OS가 강제로 접근을 차단합니다.
잠금된 파일에 대한 읽기/쓰기 시스템 콜 자체가 차단됩니다.
#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 같은 네트워크 파일 시스템에서는 잠금이 올바르게 동작하지 않을 수 있어 별도의 분산 잠금 메커니즘이 필요합니다.
전체 파일을 잠글지, 특정 레코드만 잠글지, 네트워크 파일 시스템까지 고려해야 합니다.
- 목표 동시 수정
파일 내용이 깨지는 일을 막는다.
- 범위 전체 파일, byte range
레코드 단위 중 하나를 고른다.
- 위험 POSIX
flock 혼용, NFS 환경, 잠금 누락을 확인한다.
| 방식 | 좋은 경우 | 주의할 점 |
|---|---|---|
| flock | 전체 파일 단위 배타 접근이 필요할 때 | POSIX record lock과 독립적으로 동작한다. |
| fcntl | 파일 안의 특정 영역만 보호해야 할 때 | 범위 계산과 해제 누락을 꼼꼼히 봐야 한다. |
| 분산 잠금 | 여러 머신이 같은 자원을 수정할 때 | 네트워크 지연, 장애, lease 만료 정책이 필요하다. |
다음 절에서는 파일들을 체계적으로 정리하는 디렉토리 구조를 살펴보겠습니다.
마무리 점검 다이어그램
open 이후에는 디스크 블록보다 파일 디스크립터, 현재 오프셋, 잠금, close 시점이 실제 버그의 원인이 되는 경우가 많습니다.
- 02
권한 확인
- 03
offset 이동
- 04
read write
- 05
close
- 파일 속성 유형, 크기, 소유자, 권한, 시간 정보
파일 내용을 읽기 전에도 OS가 판단하는 메타데이터입니다.
- 열린 핸들 프로세스
파일 이름이 아니라 디스크립터를 통해 열린 파일 상태와 현재 오프셋을 다시 찾습니다.
- 잠금 범위 파일 잠금
협력적 사용을 전제로 하며 프로세스 간 쓰기 순서를 명확히 하는 데 쓰입니다.
- 누수 징후 close
빠지면 삭제한 파일도 공간이 반환되지 않거나 FD 한도에 부딪히는 장애로 이어집니다.
파일은 바이트의 저장 단위이면서 이름, 위치, 크기, 시간, 소유자, 권한 같은 메타데이터를 함께 갖습니다. 운영체제는 이 정보를 inode나 파일 제어 블록에 담아 파일 이름과 실제 저장 블록을 연결합니다.
- 이름을 해석한다
경로 탐색은 디렉터리 엔트리를 따라가며 파일을 가리키는 내부 식별자를 찾는 과정입니다. 경로
- 메타데이터를 읽는다
크기, 시간, 권한, 링크 수, 블록 위치 같은 속성은 파일 내용과 별도로 관리됩니다. metadata
- 접근 권한을 검사한다
open 시점에 사용자 권한과 mode를 확인하고 이후 작업은 file descriptor를 통해 진행합니다. permission
- 저장 블록으로 이어진다
파일 시스템은 논리적 파일 offset을 실제 디스크 블록 위치로 변환합니다. block
마지막 점검에서는 파일 추상화를 속성, 연산, 접근 방식, 잠금 기준으로 다시 묶어 확인합니다.