[Linux] 표준 라이브러리를 이용한 고수준 파일 입출력

 왜 굳이 시스템 호출이 있는데 라이브러리를 사용할까요? 가장 큰 이유는 이식성효율성입니다. 운영체제나 하드웨어가 바뀌어도 표준 라이브러리는 동일한 방법(스트림)으로 입출력을 제공합니다.

 특히 중요한 건 버퍼(buffer)입니다. 시스템 호출을 직접 쓰면 매번 커널을 호출해야 해서 비효율적일 수 있어요. 하지만 표준 라이브러리는 내부에 버퍼를 두어 데이터를 모았다가 한 번에 처리함으로써, 느린 입출력 장치에 접근하는 횟수를 줄여 성능을 높여줍니다.

 

FILE 구조체와 스트림

 C언어 표준 라이브러리에서는 파일을 다루기 위해 FILE이라는 구조체를 제공합니다. FILE 구조체란 파일에 대한 정보(파일 디스크립터, 버퍼 상태, 위치 등)을 담고 있는 구조체이지요. 우리는 파일 디스크립터(정수) 대신, 이 FILE이라는 구조체의 포인터(FILE *)를 사용하여 스트림(stream)에 접근합니다. 프로그램이 실행되면 기본적으로 열리는 세 가지 표준 스트림이 있죠?

  1. stdin(표준 입출력)
  2. stdout(표준 출력)
  3. stderr(표준 오류)

 이들은 각각 파일 디스크립터 0, 1, 2번과 매핑되지만, 여기서는 미리 정의된 포인터로 다룹니다.

파일 디스크립터(fd)와 파일 포인터(FILE*)

파일 디스크립터(fd, int): 운영체제(커널)가 파일을 관리하기 위해 부여한 단순한 정수(Integer)
   - 기능이 거의 없다. 그냥 "이 파일이야"라고 가리키기만 한다.
   - read, write 같은 투박한 시스템 호출 함수만 다룰 수 있다.
파일 포인터(FILE *, 구조체) : fd를 포함해서, 버퍼(임시 저장소), 현재 위치, 에러 상태 등 다양한 정보를 담고 있는 구조체(Struct)
   - fprintf, fscanf 처럼 편리하고 기능이 많은 표준 입출력 함수로 다룰 수 있다.

정리

스트림

: 일반적으로 데이터 흐름, 혹은 데이터 흐름을 형성해주는 통로

- 연속된 바이트의 흐름을 처리하기 위한 추상적 개념

  • 종류
    • 출력 스트림(Output stream): 데이터를 쓰는 대상이 되는 스트림
    • 입력 스트림(Input stream): 데이터를 읽어 들이는 대상이 되는 스트림

 

프로그램 입출력(I/O)

: 프로그맴이 외부와 상호작용

  • 표준화된 스트림 기반
    • 입출력 장치의 종류에 관계없이 동일한 방법으로 입출력 수행 가능(장치 독립적)
    • C에서는 FILE 구조체를 통해 스트림 객체 간접적으로 접근 가능
  • 버퍼링
    • CPU가 다양한 I/O를 일관된 방법으로 접근할 수 있게 함
    • CPU와 I/O 장치간의 속도 차이 보완

  

표준 입출력 스트림

: 표준 입출력 장치파일처럼 사용 가능

   - C 프로그램이 실행되면 자동적으로 생성

   - 프로그램이 종료될 때 자동으로 해제

: stdio.h에서 정의

표준 입출력 포인터 설명 가리키는 장치
stdin 표준 입력에 대한 FILE 포인터 키보드
stdout 표준 출력에 대한 FILE 포인터 모니터
stderr 표준 오류에 대한 FILE 포인터 모니터

 

파일 열기: fopen

파일을 열 때는 fopen 함수를 씁니다.

FILE * fopen(const char *pathname, const char *mode);

첫 번째 인자는 경로명, 두 번째 인자는 모드(Mode)입니다. 여기서 주의할 점! 모드는 문자 하나('r')가 아니라 문자열("r")로 전달해야 합니다. 왜냐하면 "rb", "rt"처럼 뒤에 문자가 더 붙을 수 있거든요. 

  • 기본 모드: r(읽기), w(쓰기-덮어쓰기), a(추가)
  • 확장 모드: r+, w+, a+ (모두 읽기/쓰기 가능)
    • r+: 파일이 반드시 존재해야 함
    • w+: 파일이 없으면 생성, 있으면 내용을 지움(Truncate)
    • a+: 파일이 없으면 생성, 있으면 뒤에 추가

 이 함수는 성공시 FILE 포인터를 반환하고, 실패 시 NULL을 반환합니다.


정리

파일 열기: fopen()

: 지정된 파일에 대한 스트림을 생성하고 생성된 스트림에 대한 파일 포인터 반환

- 파일 포인터: 파일 스트림을 가리키는 포인터

#include <stdio.h>      # standard input/output

FILE* fopen(const char* pathname, const char* mode);
// 파일 열기에 성공하면 파일 포인터(문자배열의 시작주소)를, 실패하면 NULL을 반환(끝나면 NULL을 반환)
@warning
fopen("data/mydata", "rt")
첫번째 인자 문자열 : 어떤 파일을 열 것인지, 파일의 주소
두번째 인자 문자열 : 어떤 모드로 파일을 열 것인지(read, write, append 등)

 

파일 포인터

: FILE 구조체를 가리키는 포인터

- 파일 디스크립터, 파일 위치 지시자, 버퍼 등을 가짐

- 플랫폼 독립적인 구조

 

파일 열기 모드

: 텍스트가 기본 ("r" = "rt")

mode 설명 파일 없을 때 파일 있을 때
"r" 읽기(read) 모드로 파일 open 오류 (NULL) 정상 오픈
"w" 쓰기(write) 모드로 파일 open
만약 파일이 존재하지 않으면 파일을 생성, 파일이 존재하면 기존의 내용을 지우고 덮어쓰는(overwrite) 모드
생성 내용 삭제(Truncate)
"a" 추가(append) 모드로 파일 open
만약 파일이 존재하지 않으면 파일 생성, 파일이 존재하면 데이터를 기존 파일 끝에 추가(append)하는 모드
생성 끝에 추가
"r+" "r" + write. 대상 파일이 반드시 존재해야 함 (특별한 경우에만 제한적으로 사용) 오류 (NULL) 정상 오픈(덮어쓰기 가능)
"w+" "w" + read. 대상 파일이 없으면 생성, 존재하면 길이를 0으로. 생성 내용 삭제 (Truncate)
"a+" "a" + read. 대상 파일이 없으면 생성, 존재하면 끝에 추가 생성 끝에 추가 (읽기는 자유, 쓰기는 밑에)
"t" 텍스트 파일 모드로 파일 open(기본 모드)    
"b" 이진(binary) 파일 모드로 파일 open.
위의 읽기, 쓰기 기본 모드 뒤에 결합하여 사용됨
   

텍스트 vs. 바이너리 파일

 여기서 중요한 개념이 나옵니다. 텍스트(Text) 모드와 바이너리(Binary) 모드입니다.

  • 바이너리 파일: 메모리에 있는 데이터를 그대로(비트 단위로) 저장합니다. 변환이 없어 효율적이고 데이터 왜곡이 없습니다. 예를 들어 정수 123456은 4바이트면 되죠. (일반적인 데이터 저장용)
  • 텍스트 파일: 사람이 볼 수 있는 문자(ASCII, Unicode 문자 등)로 변환해서 저장합니다. 123456을 저장하려면 문자 6개가 필요하니 6바이트를 씁니다. 변환 오버헤드가 발생하지요. 변환 과정이 필요해 느리고 공간도 더 차지할 수 있습니다. (소스코드, 설정 파일용)

 그래서 fopen 모드 뒤에 t(텍스트) 또는 b(바이너리)를 붙여 구분합니다 (예: "rb", "wt"). 기본값은 텍스트(t)입니다.


정리

텍스트 파일 vs. 이진 파일

  • 텍스트 파일 : 모든 데이터를 ASCII 코드를 이용한 문자열로 변환하여 저장
  • 이진 파일: 메모리에 데이터를 표현하는 방식 그대로 저장

데이터를 텍스트로 저장하기 vs. 이진 파일로 저장하기


버퍼 비우기와 파일 닫기

 쓰기 작업을 할 때 데이터가 버퍼에만 있고 디스크엔 아직 안써졌을 수 있습니다. 이때 fflush를 쓰면 강제로 디스크에 기록합니다. 사용이 끝난 파일은 반드시 fclose로 닫아줘야 버퍼가 정리되고 자원이 반환됩니다.


정리

파일 닫기: fclose()

: 열린 파일을 닫아 안전하게 파일 사용을 마침

- 성공 시 0, 실패시 EOF(-1)

#include <stdio.h>

int fclose(FILE*)
// 파일 닫기에 성공하면 0, 실패하면 -1을 반환
  • 파일 끝(EOF) 확인: int feof(FILE*)
    • 파일의 끝(End of FILE)에 도달했는지 확인하여 마지막으로 접근한 위치가 파일의 끝이면 참 반환
    • 끝이 아니면 거짓(0) 반환
EOF 처리의 함정(feof vs. 반환값)

- 함정: while(!feof(fp)) 구문을 사용할 때, 파일 끝에 도달하고 나서도 루프가 한 번 더 도는 문제가 자주 발생한다.
- 올바른 방법: 입출력 함수(fgetc, fscanf 등)의 반환값을 먼저 확인하고, 그 뒤에 EOF인지 검사하는 것이 정석이다.
- 심화: fget() 함수가 char이 아니라 int를 반환하는 이유는?
   - EOF 값은 보통 -1이다. 만약 반환 타입이 char(보통 0~255 표현)라면, 실제 데이터인 0xFF(255)와 EOF(-1)을 구분할 수 없기 때문이다.

 

e. 파일 열기와 닫기 예시

#include <stdio.h>

int main(void)
{
    FILE* fp = NULL;
    fp = fopen("sample.txt", "w");
    
    if (fp == NULL)
        printf("파일 열기 실패\n");
    else
        printf("파일 열기 성공\n");
        
    fclose(fp);
    
    return 0;
}

 

버퍼링(Buffering)

: 블록 단위 장치인 disk의 입출력 효율성을 위해 Disk drive블록 단위의 입출력 수행

   - 일반적으로 1 block = 1024 byte

   - fopen() 호출파일 버퍼가 자동으로 생성

   - fclose()fflush() 호출 시  파일 버퍼를 비움 (write 시 버퍼의 내용이 disk에 기록됨)

- 파일 버퍼(buffer) : 파일로부터 읽고 쓰는 데이터의 임시 저장 장소로 이용되는 메모리 공간

  • 버퍼 비우기
    • fflush(FILE *fp) - 버퍼에 남은 데이터를 강제로 디스크에 기록(단, 지나치게 많은 fflush() 동작은 성능 저해)
버퍼링의 3가지 종류(Buffering Modes)
버퍼링이 구체적으로 어떻게 동작하는지 알아보자!

1. 완전 버퍼링(Fully Buffered)
   - 버퍼가 꽉 찰 때만 실제로 I/O가 일어난다.
   - 주로 파일(File) 입출력 시 기본 모드이다.
2. 라인 버퍼링(Line Buffered)
   - 엔터키(\n, 개행 문자)를 만날 때 실제 I/O가 일어난다.
   - 주로 터미널(키보드 입력, 모니터 출력)인 stdin, stdout이 이 모드이다.
   - **printf("Hello");만 하고 \n을 안 넣으면 화면에 바로 안 뜰 수 있는 이유가 바로 이 '라인 버퍼링' 때문이다.
3. 비버퍼링(Unbuffered)
   - 버퍼를 쓰지 않고 즉시 출력한다.
   - 주로 stderr(표준 오류)가 이 모드이다.
   - 이유: 오류 메시지는 프로그램이 죽기 직전이라도 즉시 보여야 하기 때문이다.

입출력 함수

  1. 바이너리 입출력: fread, fwrite를 사용합니다. 구조체나 배열을 통째로 입출력할 때 좋습니다.
    • 인자: 버퍼 주소, 요소 하나의 크기(size), 요소의 개수(count), 파일 포인터
  2. 텍스트 입출력: fscanf, fprintf를 사용합니다. 서식 지정자(%d, %s 등)를 사용해 포맷팅된 입출력을 합니다.

정리

파일 입출력 라이브러리 함수

종류 입력 함수 출력 함수
문자 단위 int fgetc(FILE *fp) int fputc(int c, FILE *fp)
문자열 단위 char* (fgets(char *s, int n, FILE *fp) int fputs(char *s, FILE *fp)
형식화된 입출력 int fscanf(FILE *fp, ...) int fprintf(FILE *fp, ...)

- fgetc() : 파일 끝에 도달하면 EOF(= -1) 반환

- fgets() : 개행문자를 만나거나, n-1개의 문자를 read. 개행문자를 null 문자로 대체하지 않고 포함

 

이진 파일의 쓰기 - fwrite()

: size_t fwrite(const void *buffer, size_t size, size_t cunt, FILE *stream);

  • Return Value: the number of full itmes actually written, which may be less than count if an error occurs. Also, if an error occurs, the file-position indicator cannot be determined.
  • Parameters
    • buffer: 작성된 데이터를 가리키는 포인터
    • size: Item size (bytes 단위)
    • count: 작성된 items의 최대 숫자
    • stream: FILE 구조체의 포인터
#include <stdio.h>
#include <stdlib.h>

int main()
{
    int buffer[] = {10, 20, 30, 40, 50};
    FILE* fp = NULL;
    size_t i, size, count;
    
    fp = fopen("binary.dat", "wb");
    if (fp == NULL) {
        fprintf(stderr, "binary.dat 파일을 열 수 없습니다.");
        exit(1);
    }
    
    size = sizeof buffer[0];
    count = sizeof buffer / sizeof buffer[0];
    
    i = fwrite(buffer, size, count, fp);
    
    fclose(fp);
    
    return 0;
}

 

이진 파일의 읽기 - fread()

: size_t fread(void *buffer, size_t size, size_t count, FILE *stream);

  • Return value
    • fread: the number of full items actually read, which may be less than count if an error occurs or if the end of the file is encountered before reaching count. Use the feof or ferror function to distinguish a read error from an end-of-file condition. If size or count is 0, fread returns 0 and the buffer contents are unchanged
  • Parameters
    • buffer: 데이터를 저장하는 위치
    • size: 바이트 단위의 Item 크기
    • count: 읽힐 수 있는 items의 최대 숫자
    • stream: FILE 구조체의 포인터
#include <stdio.h>
#include <stdlib.h>

#define SIZE 5

int main()
{
    int buffer[SIZE], i;
    FILE* fp = NULL;
    size_t size;
    
    fp = fopen("binary.dat", "rb");
    if (fp == NULL) {
        fprintf(stderr, "binary.dat 파일을 열 수 없습니다.");
        exit(1);
    }
    
    size = fread(buffer, sizeof(int), SIZE, fp);
    if (size != SIZE) {
        fprintf(stderr, "읽기 동작 중 오류가 발생했습니다.\n");
    }
    
    fclose(fp);
    
    for (i = 0; i < SIZE; i++)
        printf("%d\n", buffer[i]);
    
    return 0;
}

파일 위치 제어

 fseek 함수로 파일 포인터 위치를 이동할 수 있습니다. SEEK_SET(시작), SEEK_CUR(현재), SEEK_END(끝)을 기준으로 오프셋만큼 이동하죠. rewind는 무조건 시작점으로 보냅니다. ftell을 사용하면 현재 위치를 반환합니다.

 fseek 사용 시 주의할 점이 있습니다. 텍스트 파일은 줄 길이가 일정하지 않아 fseek로 정확한 위치 이동이 어렵습니다. 따라서 보통 텍스트 파일에서는 바이트 단위 이동이 어렵기 때문에 fseek는 주로 바이너리 파일 입출력 시 사용합니다. 


정리

파일 위치 관련 함수

  • fseek(FILE* fp, long offset, int mode) : 파일 포인터 위치mode 기준으로 offset 만큼 옮김
mode 의미
SEEK_SET 0 파일 시작
SEEK_CUR 1 현재 위치
SEEK_END 2 파일 끝
  • rewind(FILE* fp) : 파일 위치를 파일 시작점에 위치시킴
  • ftell(FILE* fp) : 파일의 현재 파일 위치를 반환

미니 테스트!

Q. fopen 모드에서 "w"와 "w+"의 차이점은 무엇이며, 만약 파일이 이미 존재할 때 "w"로 열면 파일 내용은 어떻게 되는가?
Answerw는 쓰기 전용, w+는 읽기/쓰기 겸용. 둘 다 파일 내용은 싹 지워짐(Truncate)
Q. 텍스트 파일에서 fseek를 사용하여 임의의 줄(line)으로 이동하는 것이 어려운 이유는?
Answer줄마다 길이가 달라서 몇 번재 바이트인지 계산할 수 없기 때문
Q. printf를 사용했는데 화면에 바로 출력되지 않았다. 그 이유는 무엇이며 해결 방법은?
Answer라인 버퍼링 때문. \n을 출력하거나 fflush(stdout)을 호출해야 함
Q. cp 명령어를 구현할 때 한 바이트씩 읽고 쓰는 것(fgetc/fputc)보다 버퍼를 이용해 통째로 읽고 쓰는 것(fread/fwrite)이 더 빠른 시스템적 이유는?
Answer시스템 호출(User Mode ↔ Kernel Mode 전환)의 오버헤드를 줄이기 때문

 

실습

//mycat.c
int main(int argc, char* argv[]) // ./mycat mydata.txt
{
   if (argv < 2) {
       fprintf(stderr, "Usage: %s <filename> \n", argv[0]);
       return 1;
   }
   
   char buf[BUFSIZ];
   FILE* fp;
   int nread;
   
   if ((fp = fopen(argv[1], "r")) == NULL) {
       perror(argv[1]);
       exit(1);
   }
   
   while ((nread = fscanf(fp, "%[^\n]\n", buf)) != EOF) {
      fprintf(stdout, "%s\n", buf);
   }
  
   fclose(fp);
  
   return 0;
}