UNIX의 철학 : 모든 것은 파일이다의 의미
- 진짜 전부 파일이라는 뜻이 아니라,운영체제가 그것들을 '파일처럼 다룰 수 있게 만든다'는 의미입니다.
- 즉, 운영체제가 다양한 자원을 파일 인터페이스로 추상화했다는 뜻
자원 종류 | 설명 | 실제 파일인가? | 파일처럼 다뤄지는가? (read, write, FD 사용 등) |
file.txt | 디스크 상의 실제 파일 | ✅ 예 | ✅ 예 |
/dev/null | 쓰면 버리고, 읽으면 EOF 반환하는 특수 장치 | ❌ 아니오 | ✅ 예 |
/dev/sda, /dev/tty0 | 하드디스크, 터미널 등 물리 장치 | ❌ 아니오 | ✅ 예 |
소켓 (AF_INET) | TCP, UDP 같은 네트워크 통신 | ❌ 아니오 | ✅ 예 |
stdin, stdout, stderr | 표준 입출력 스트림 (fd = 0, 1, 2) | ❌ 아니오 | ✅ 예 |
파일인터페이스란
진짜 물리적 파일이 아니더라도 read, write, close가 가능하면 파일처럼 다뤄진다고 말함.
- 파일 디스크립터(File Descriptor, FD)
- 모든 열려 있는 자원은 정수 ID로 관리됨 (예: fd = 3)
- 공통 인터페이스 함수
- read(fd, buf, n)
- write(fd, buf, n)
- close(fd)
- ioctl(fd, ...) (장치 제어 등)
내부적 구현은 ?
커널 입장에서는:
- /dev/null, 소켓, 파이프, 일반 파일은 분기처리를 통해서 각기 다른 드라이버 또는 커널 서브시스템이 처리합니다.
- 하지만 외부로 노출되는 인터페이스는 동일: read(fd, ...)
UNIX의 철학 : 모든 파일은 스트림처럼 다룬다.
- 파일, 소켓, 터미널, 네트워크, 파이프, 장치 등 대부분의 자원을 파일처럼 다룬다는 것은 운영체제 수준에서는 스트림처럼 처리하는 것을 말합니다.
- 즉 ,스트림(Stream)은 파일 인터페이스를 감싼 사용자용 추상화이고, 파일 인터페이스는 커널이 제공하는 모든 I/O 자원에 대한 공통 시스템콜 규약이다.
파일 시스템 계층구조
계층 | 예시 | 역할 | 추상화 수준 |
시스템콜 계층 (파일 인터페이스) |
open, read, write, close | 커널과 직접 소통. 모든 자원의 최소 공통 인터페이스 |
낮음 (가장 근본) |
C 라이브러리 (libc) |
fopen, fread, fgets, fprintf | 시스템콜을 감싸고 버퍼링, 포맷 등 편의 기능 제공 |
중간 |
스트림 계층 (언어별 추상화) |
Java InputStream, Python file, bash 쉘의 stdin, stdout, stderr |
언어 레벨의 객체지향/ 이벤트 기반 API 제공 |
높음 (가장 추상적) |
스트림 = 바이트의 연속적인 흐름
- 스트림(Stream)은 데이터가 "한 방향으로 흐르는 것"을 추상화한 개념입니다. 파일이든 네트워크든, 데이터는 순차적으로 읽거나 쓴다는 식으로 처리됩니다.
- 스트림은 연속된 바이트를 차례차례 읽는 구조를 가지고 있습니다. 이걸 가능하게 하기 위해 버퍼(buffer)와 파일 디스크립터(file descriptor, FD)를 기반으로 동작합니다.
요소 | File I/O 관점 | Stream 추상화 관점 |
FD (파일 디스크립터) | 파일 디스크립터, 커널이 관리하는 파일 테이블의 인덱스 | 스트림이 어떤 자원을 대상으로 동작하는지 지정 |
커서 (offset) | 커널이 파일마다 내부적으로 관리하는 "현재 위치" | 스트림의 순차성 보장 |
버퍼 (buffer) | 시스템 콜 호출 수 줄이기 위한 메모리 캐시 | 스트림 성능 최적화를 위한 내부 구조 |
read() | 커서 위치 기준으로 읽고, 읽은 만큼 커서 자동 이동 | |
open() | 커서(offset) = 0부터 시작 |
스트림 구현 구조
"커서 위치가 이동하면서 데이터를 순차적으로 읽는 것"이 바로 스트림의 순차적 동작 방식입니다.
- fd는 파일 디스크립터 (정수형 핸들, 예: 3)
- open() 시스템콜을 통해 생성됨
- 어떤 자원이든 동일한 open()시스템콜을 사용하지만 커널 내부에서 파일 테이블에 등록한 자원(파일, 소켓 등)을 식별하고 시스템콜 내부에서 자원종류에 따라서 분기 처리를 합니다.
- 커널은 fd에 해당하는 자원을 찾음
- 이 자원에는 커서(offset), 권한, 파일 상태 정보 등이 포함됨
- 해당 자원의 커서(cursor, 위치)를 기준으로 100바이트를 읽음
- read(fd, buffer, 100) 호출 시
- 커널은 현재 커서 위치에서 최대 100바이트를 읽음
- 커서가 그만큼 앞으로 이동됨
- 커널 내부에서 자동으로 offset += n (읽은 바이트 수)
- 다음 read는 이어진 위치부터 진행됨
- 데이터는 buffer로 복사됨
- 커널 공간에서 사용자 공간의 buffer로 실제 데이터가 옮겨짐
- 이 과정은 copy_to_user() 같은 내부 커널 함수로 수행됨
- 복사된 buffer는 사용자 코드에 의해 소비됨
- 버퍼는 메모리 절약과 성능때문에 한정된 크기로 만들고 버퍼가 가득 차면 더 이상 read 불가함
- write(STDOUT_FILENO, buffer, n)로 화면에 출력하거나 파싱, 가공, 로깅 등 다음 단계 처리 로직에 넘겨 Buffer을 소비하여 비움
- 이 과정을 while 루프 안에서 반복
- while ((n = read(...)) > 0) ← 스트림이 끝날 때까지 반복
- read()가 0을 반환하면 EOF → 스트림 종료
- 이러한 반복 구조 덕분에 **스트림은 "연속된 데이터의 흐름"**이 됨
버퍼링은 왜 필요한가?
- 디스크나 네트워크는 I/O가 느리기 때문에, 매번 작은 단위로 read() 호출하면 매우 비효율적입니다.
- 내부에서 미리 큰 덩어리를 읽고 버퍼에 저장함
- 이후 작은 단위로 쪼개서 사용자에게 제공 → 성능 개선
- 내부 버퍼에 한 번에 4096바이트 정도 read(fd, ...) 호출
- 사용자 요청마다 버퍼에서 잘라서 제공
- 버퍼 다 쓰면 → 다시 read()
구분 | 고수준 I/O (fopen, fread 등) | 저수준 I/O (open, read 등) |
제공 주체 | C 표준 라이브러리 (<stdio.h>) | 운영체제 커널 (<unistd.h>, <fcntl.h>) |
단위 | FILE* 스트림 | 파일 디스크립터 int |
버퍼링 | ✅ 있음 (자동 버퍼) | ❌ 없음 (직접 처리) |
사용 예 | fopen, fread, fprintf, fclose | open, read, write, close |
편리성 | ✅ 더 편함 | ❌ 저수준이라 더 복잡 |
저수준 I/O : 대표적인 시스템 콜
- open() : file.txt 파일을 열고, 파일 디스크립터(int) 를 반환, 실패 시 -1 반환
- O_RDONLY(읽기전용) , O_WRONLY(쓰기전용) , O_CREAT(파일이없으면 새로만든다) 등의 플래그 사용
int fd = open("file.txt", O_RDONLY);
- read() : fd가 가리키는 파일에서 최대 N바이트를 읽어와서 buffer에 저장
- 실제로 읽은 바이트 수 반환
- 이때, 파일 커서(offset)는 커널 내부에서 자동으로 N만큼 이동
int n = read(fd, buffer, 100);
파일커서
- 커널이 파일마다 내부적으로 관리하는 "현재 읽기/쓰기 위치 값"으로 사용자 프로그램은 이 값을 직접 접근할 수 없음
- read() 또는 write()가 성공적으로 수행되면 → 자동으로 이동
- 커서를 ㅈ작하고싶으면 lseek() 시스템콜을 사용해야 합니다.
- write() : buffer에 있는 n 바이트 데이터를 파일(fd) 또는 출력(stdout)에 씀
- fd : 출력 대상 파일 디스크립터 (ex. 1은 stdout)
- buf : 출력할 데이터의 메모리 주소
- count : 출력할 바이트 수
- 반환값: 실제로 출력한 바이트 수 (에러 시 -1)
ssize_t write(int fd, const void *buf, size_t count);
- **fd로 지정된 자원(파일, 터미널, 소켓 등)**을 커널이 찾음
- 해당 자원의 현재 커서(offset) 위치에 데이터를 씀
- 커널은 데이터를 커널 모드에서 직접 처리
- 커서가 count만큼 앞으로 자동 이동
- close() : 열어둔 파일 디스크립터를 닫고, 커널 자원 해제
close(fd);
고수준 I/O
- fopen() – 파일 열기
- 파일디스크립터 대신 파일구조체(FILE*) 사용
#include <stdio.h>
FILE *fp = fopen("example.txt", "r");
if (fp == NULL) {
perror("파일 열기 실패");
}
- FILE 구조체란?
- "파일 디스크립터 + 버퍼 + 상태 정보 + 포맷 도구"까지 모두 들어 있는 고급 구조체
이 구조체의 핵심 역할
구성 | 요소역할 |
fd | 커널이 반환한 파일 디스크립터 저장 |
*_buf | 읽기/쓰기 동작을 위한 중간 버퍼 |
_pos | 현재 버퍼 내 위치 (다음에 읽을 위치 등) |
_buf_size | 버퍼 크기 (보통 8192바이트 등) |
_eof | EOF 도달 여부 |
_error | 입출력 중 발생한 에러 상태 저장 |
typedef struct {
int _fd; // 내부 파일 디스크립터 (open()으로 얻음)
char *_buf; // 내부 버퍼 포인터
int _buf_size; // 버퍼 크기
int _pos; // 현재 버퍼에서의 위치
int _eof; // EOF(파일 끝) 여부
int _error; // 에러 플래그
... // 줄 단위 읽기, 포맷 정보, flush 여부 등
} FILE;
- fread(buf, 요소당 크기 (바이트 단위), 요소 개수, 파일포인터)
- fread() 호출 시:
- 파일구조체 fp의 buf에 읽을 데이터가 충분한가 확인
- 있으면 → 거기서 꺼내서 buffer에 복사, 없으면 → read(fd, _buf, 8192)로 시스템콜
- 다음 호출시 버퍼에 남은 데이터가 있으면 → read() 없이 바로 복사됨 (즉, 고속)
- fread() 호출 시:
char buffer[100];
size_t n = fread(buffer, 1, sizeof(buffer), fp);
- fgets() – 줄 단위로 읽기
- 줄 단위로 읽음 (\n 포함됨)
- 문자열 기반 파일 읽기에 많이 사용
char line[256];
while (fgets(line, sizeof(line), fp)) {
printf("읽은 줄: %s", line);
}
- fwrite() – 이진 데이터 쓰기
- 읽은 데이터를 다른 파일에 쓰는 데 사용
fwrite(buffer, 1, n, fp);
- fprintf() – 포맷 출력 (텍스트)
- printf()처럼 사용하지만, 파일로 출력
- 내부적으로 write() 호출 + 포맷 처리 기능 포함
fprintf(fp, "나이는 %d살입니다\n", 27);
- fclose() – 파일 닫기
- 열었던 파일을 닫고, 버퍼에 남아있던 데이터도 모두 flush
fclose(fp);
#define BUFFER_SIZE 1024
int main(void) {
FILE* input = fopen("input.txt", "r");
FILE* output = fopen("output.txt", "w");
char buffer[BUFFER_SIZE];
size_t length;
length = fread(buffer, BUFFER_SIZE, sizeof(char), input);
while (length > 0) {
fwrite(buffer, length, sizeof(char), output);
length = fread(buffer, BUFFER_SIZE, sizeof(char), input);
}
fclose(input);
fclose(output);
return 0;
}
표준 스트림과 파일 디스크립터 번호
"파일 디스크립터 번호(0, 1, 2)"로 미리 커널에 예약되어 있어서, 해당 fd에 대해 read()나 write() 시스템콜을 호출하면 입출력이 발생합니다
스트림 이름 | 의미 | FD 번호 |
stdin | 표준 입력 (keyboard 등) | 0 |
stdout | 표준 출력 (터미널 화면 등) | 1 |
stderr | 표준 에러 출력 (터미널) | 2 |
pipe :
- 빈 배열(fd[2])을 입력으로 받고 그 배열 안에 두 개의 새로운 파일 디스크립터를 채워 넣어준다.
- 프로세스 간 통신을 위해, 커널 내부에 임시 버퍼를 만들고, 그 버퍼의 읽기용/쓰기용 핸들(fd[0], fd[1])을 새로 만들어서 호출자에게 줍니다.
- 쓰기용 디스크립터(fd[1])
- 읽기용 디스크립터(fd[0])
를 각각 만들어서 fd[2] 배열에 채워줌 - write(fd[1], ...) → 파이프에 데이터가 들어가고
- read(fd[0], ...) → 파이프에서 데이터가 꺼내짐
- 음 근데 굳이 버퍼를 만들어서 해야하나 ? 두 프로세스간에도 버퍼를 갖고있는데 거기서 보내면안되는건지 이거는 좀더 공부해야할듯 프로세스생성에 대해서
- 예: ls | grep txt → ls가 쓴 데이터를 grep이 읽음 → 이걸 pipe가 연결해줌
int pipe(int fd[2]);
dup2
- newfd가 oldfd와 같은 커널 리소스를 가리키도록 만든다. 즉, newfd는 이제 oldfd와 같은 파일/파이프/소켓을 참조하게 됨.
- 많은 시스템 함수는 고정된 fd(0,1,2)를 사용하는데 이 표준스트림을 파일을 참조하고있는 fd로 변경하면, 표준 스트림의 내용이 파일에 들어감.
- 많은 함수(printf, puts, write(1, ...))는 fd를 직접 받지 않음 그런 함수들을 표준입출력이 아닌 파일로 변경할때 유일한방법
int dup2(int oldfd, int newfd);
백엔드 개발자가 가져가야 할 I/O 관점 핵심 인사이트
진짜 실무 백엔드 개발자는 단순히 "I/O가 느리다"를 넘어서, "시스템콜 관점에서 병목이 어디서, 왜 일어나는지 추적할 줄 알아야 한다" → 이게 고급 실력의 기준입니다.
시스템콜 관점에서 병목을 추적할 수 있어야 한다
- strace, lsof, perf, iotop, vmstat 등을 통해
→ read(), write(), open() 같은 syscall 패턴 파악
→ 병목의 위치를 커널 수준에서 직접 확인 가능
strace -tt -p <PID>
12:00:01.123 read(3, ..., 4096) = 4096
12:00:01.124 read(3, ..., 4096) = 4096
12:00:01.900 write(1, ..., 100) = 100
'개발기술 > 운영체제 핵심개념' 카테고리의 다른 글
C언어 정리 (0) | 2025.05.18 |
---|---|
운영체제의 표준화 : POSIX와 시스템 콜 동작 (0) | 2025.05.16 |