본문 바로가기

개발기술/운영체제 핵심개념

I/O 시스템과 스트림

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부터 시작  

 

스트림 구현 구조

 "커서 위치가 이동하면서 데이터를 순차적으로 읽는 것"이 바로 스트림의 순차적 동작 방식입니다.

    1. fd는 파일 디스크립터 (정수형 핸들, 예: 3)
      • open() 시스템콜을 통해 생성됨
      • 어떤 자원이든 동일한 open()시스템콜을 사용하지만 커널 내부에서  파일 테이블에 등록한 자원(파일, 소켓 등)을 식별하고 시스템콜 내부에서 자원종류에 따라서 분기 처리를 합니다.
    2. 커널은 fd에 해당하는 자원을 찾음
      • 이 자원에는 커서(offset), 권한, 파일 상태 정보 등이 포함됨
    3. 해당 자원의 커서(cursor, 위치)를 기준으로 100바이트를 읽음
      • read(fd, buffer, 100) 호출 시
      • 커널은 현재 커서 위치에서 최대 100바이트를 읽음
    4. 커서가 그만큼 앞으로 이동됨
      • 커널 내부에서 자동으로 offset += n (읽은 바이트 수)
      • 다음 read는 이어진 위치부터 진행됨
    5. 데이터는 buffer로 복사됨
      • 커널 공간에서 사용자 공간의 buffer로 실제 데이터가 옮겨짐
      • 이 과정은 copy_to_user() 같은 내부 커널 함수로 수행됨
    6. 복사된 buffer는 사용자 코드에 의해 소비됨
      •  버퍼는 메모리 절약과 성능때문에 한정된 크기로 만들고 버퍼가 가득 차면 더 이상 read 불가함
      • write(STDOUT_FILENO, buffer, n)로 화면에 출력하거나 파싱, 가공, 로깅 등 다음 단계 처리 로직에 넘겨 Buffer을 소비하여 비움
    7. 이 과정을 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);

 

  1. **fd로 지정된 자원(파일, 터미널, 소켓 등)**을 커널이 찾음
  2. 해당 자원의 현재 커서(offset) 위치에 데이터를 씀
  3. 커널은 데이터를 커널 모드에서 직접 처리
  4. 커서가 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() 없이 바로 복사됨 (즉, 고속)
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