열혈 C - Chapter 24 파일 입출력

2024. 11. 3. 23:49Programming Language/C

반응형

24-1 파일과 스트림(Stream), 그리고 기본적인 파일의 입출력

파일이 중요한게 아니라 입출력이 중요함.

입출력의 방법에 대한 전체적인 모델을 파일을 통해서 공부를 한다고 생각하면 된다.

 

파일에 저장되어 있는 데이터를 읽고 싶어요

먼저 복습을 해보자면 우리는 이전까지 console을 통해서 입출력을 했었다.

우리의 프로그램과 모니터, 키보드를 통해서 수없이 입출력을 했었다.

여기서 프로그램과 모니터에서 발생하는 스트림을, 표준 출력스트림이라고 했었고 이 스트림의 방향은

프로그램 ===> 모니터 였었고 이를 stdout이라는 이름으로 지칭했었다.

사실 stdout은 모니터를 의미하는게 아니라 스트림을 의미한다고 앞에서도 이야기 했었다.

이 stdout의 출력방향을 모니터가 아니라 다른 방향으로 redirection을 할 수도 있다고 했었다.

아무튼 stdout은 표준 출력스트림을 의미한다고 이야기했었다.

 

마찬가지로 키보드와 프로그램 사이에 발생하는 스트림을 표준 입력 스트림이라고 하고 그 방향성은

키보드 ===> 프로그램이였다.

그리고 그 스트림은 stdin이라고 이름이 붙어져 있고 그 스트림이 있기에 프로그램 상에서 외부에 있는 디바이스와 데이터를 주고 받을 수 있던거였다.

 

결국 스트림은 소프트웨어 적으로 구현되어 있던 다리라고 이야기했었다.

그리고 이 개념들은 입력과 출력을 하는 시작점 혹은 종료지점이(대상이) 파일이라고 다르지 않다.

 

프로그램의 외부에 있다면 어떤것이던지 데이터를 입출력하기 위해서는 스트림이라는것을 형성해야한다.

그런데 콘솔 입출력과 파일 입출력의 가장 큰 차이점은 콘솔 입출력을 했을때에는 모니터를 향한 표준출력, 마우스로 부터 생성되는 표준 입력의 두 스트림이 그냥 OS에서 자동적으로 생성해줬었기에 별도로 스트림을 생성해주는 코드를 작성해주지 않았다.

 

그러나 파일의 경우는 우리가 스트림을 직접 만들어줘야만 한다.

그런데 사실 위에서 처럼 스트림을 생성하는 주체는 OS가 된다.

 

그러면 우리가 스트림을 생성하려면 어떻게 해야하냐면 OS에게 스트림의 생성을 요청을 해야한다.

그렇게 스트림이 형성되고 나면 입출력하는 방법은 console입출력과 파일 입출력은 큰 차이는 없다(아예 없는것은 아님)

 

여기까지가 이전에 계속 했던 내용으로, 결론적으로 파일 입출력의 경우는 스트림을 우리가 직접 OS에게 요청해서 형성해줘야 한다는 것이다.

 

fopen 함수를 통한 스트림의 형성과 FILE 구조체

파일 입출력에 대해서 공부할때 가장 먼저 알아야할 함수는 fopen함수이다.

fopen은 file open함수라고 읽기도 하는데 이 함수는 호출할때 인자로 파일의 정보를 전달한다(파일의 이름정보).

호출 하는 방법은 아래 처럼 사용하는데

#include <stdio.h> 

int main(void) {
	
	FILE* fp1 = fopen("fin.dat", ...);
	FILE* fp2 = fopen("egn.dat", ...);

	. . . .

	return 0;   
}

작성 되어 있는 fin.dat라는 파일을 열어줘, egn.dat라는 파일을 열어줘라고 하면서 파일의 이름을 인자로 전달한다.

저 내부에 구체적으로 fopen함수를 어떻게 쓰는지는 작성되어 있진 않으나 fopen함수를 호출할때 첫번째 인자로 파일명을 전달한다는 사실을 알 수 있다.

 

따라서 이게 무엇을 의미하는 거냐면, fopen함수를 통해서 fin.dat라는 파일을 열어줘을 의미하는 것이다.

이게 일반적인 표현이다.

 

그런데 이 열었다(개방했다)라는 표현을 쓰는 이유는 파일을 상자처럼 생각해서 데이터를 읽기 위해서는 상자를 열어서 그 안에 있는 데이터를 읽을 수 있다는 개념이 들어가서 함수의 이름이 fopen이라는 명칭으로 정의해져 있고, 함수 호출을 통해서 파일을 열어야 데이터를 읽을 수 있다라는 의미가 들어 있다는 것이다.

 

그리고 이 파일을 열고 개방을 했더니 읽고 쓸 수 있다라는 의미는 fopen함수가 스트림의 형성을 유도하는 함수가 되는 것이다.

즉, fopen함수는 스트림의 형성을 운영체제에게 요청하는 함수이고 승인된다면 데이터를 읽고 쓸 수 있게 되는 것이다.

그래서 fopen함수데이터를 읽고 쓸 수 있게 하기 위해서 파일을 개방하는 함수이다라는 표현과 파일에 데이터를 읽고 쓰기 위해서 스트림의 형성을 운영체제에게 요청하는 함수이고, 이 함수가 호출되면 스트림이 형성된다 라는 표현도 같이 기억해둬야한다.

 

FILE* fp1 = fopen("fin.dat", ...);

그렇게 되면 이 fopen함수를 통해서 해당 파일에 스트림이 형성될거란 사실은 이제 알겠는데 fopen함수의 반환값을 FILE이란 타입의 포인터 변수 fp1에 저장하고 있는데, 이 반환값은 무엇을 의미하는 것일까?

 

여기서 FILE이라는 타입은 구조체이고 이는 우리가 생성한게 아닌 표준(C)에서 제공해주고 있는 구조체이다.

그래서 이 구조체를 기반으로 한 포인터에 반환값을 저장했다는 것은 fopen함수가 호출이 되면 파일형 구조체 변수가 생성되고 그 파일 구조체 변수의 주소값이 반환되는 것을 fp1에 저장해서 fp1이 그 파일 구조체 변수를 가리킨다는 것을 유추할 수 있다.

사실 이 스트림이란 개념은 매우 포괄적인 개념이기에 여러 말들이 존재한다.

반환된 구조체안에 데이터를 입출력하기 위한 해당 파일의 정보들이 담기기 때문에 이 또한 스트림의 일부이다 라고 이야기 하는 사람들도 있고 이건 스트림으로 보기에는 너무 벗어난 범위이다라고 이야기 하는 사람도 있다.

 

그러나 스트림으로 볼지 스트림으로 보지 않을지가 중요한게 아니라 이 fopen함수를 통해생성된 이 FILE구조체는 결국 해당 파일과 데이터의 입출력을 원활하게 하기 위해서 OS가 만들어주는 것이라는 게 중요하다.

 

이때 우리는 fp1을 어디에다 쓰는 것인지, 이걸 궂이 왜 만들어준것인지에 대해서 궁금증이 생길 것이다.

 

일단 fopen함수를 다수로 호출하면 호출한 갯수만큼의 FILE구조체 변수가 생성이 된다. 

그리고 각각의 FILE 구조체 변수에는 첫번째 인자로 전달한 파일명으로 전달한 파일에 대한 정보가 채워진다.

파일에 대한 정보라고 해서 파일 내부에 있는 데이터가 채워지는게 아니라 파일과의 데이터 입출력을 원활하게 하기 위한 정보들이 채워지는 것이다.(이 정보가 무엇인지는 나중에 확인해보자.)그리고 OS는 이 정보를 참조해서 파일을 대상으로 데이터의 입출력을 수행하는 것이다.

 

그리고 결국 코드에서는 이걸(fopen을 통해서 전달된 FILE구조체 변수를 저장한 구조체 포인터 fp1을) 어떻게 쓸지가 제일 중요한데 fopen을 통해서 전달한 파일을 가리키는 지시자로서 인식이 되기 시작한다.

 

그럼 지시자란 무엇일까..?

 

프로그램과 모니터, 키보드를 연결할때 생성된 스트림(표준스트림), stdout, stdin이 기억나는가?

여기서 stdout은 모니터를 가리키고 있었고 stdin은 마우스를 통해 프로그램을 가리켰었다.

이게 결국 표준스트림에 대한 지시자인 것이고 fp가 stdout혹은 stdin가 했었던 역할처럼 fopen의 첫번째 인자로 전달한 파일명에 해당하는 파일을 가리키는 지시자가 되는 것이다.

 

따라서 반환값에 해당하는 구조체 포인터 변수를 전달해주면서 해당 파일에 데이터를 입력 혹은 출력을 해라라고 코드 문장을 구성하게 될것이다.

 

*정리하자면 fopen함수는 파일 과의 스트림을 요청하는 기능을 가진 함수로 이 함수를 호출할때 첫번째 인자로 전달하는 파일명에 해당하는 파일에 대한 정보를 OS가 FILE구조체를 사용해서 구조체 변수로 생성을 해두고, 이 주소값을 반환한다.

그러면 FILE 구조체 포인터 변수로 이를 저장해두고 이 포인터 변수를 사용해서 해당 파일과의 데이터의 입출력에 대해서 제어를 할 수 있게 되는 것이다.(자체적인 이해를 정리해둔것.)

 

fopen 함수 호출의 결과

이제 구체적으로 fopen함수에 대해서 알아보자.

#include <stdio.h>
FILE * fopen(const char * filename, const char * mode);
->> 성공 시 해당 파일이 FILE 구조체 변수의 주소 값, 실패시 NULL 포인터 반환

**const char * filename - 스트림을 형성할 파일의 이름
**const char * mode     - 형성할 스트림의 종류

이전에 stdin과 stdout이 있듯이 데이터를 읽을지, 쓸지에 대해서 스트림의 종류가 나뉜다.

그것 뿐만 아니라 console같은 경우는 위 두가지만 있었는데 파일의 경우는 그 스트림의 종류가 훨씬 더 많다.

그래서 그 스트림의 종류에 대해서 두번째 인자로 전달을 하는데 이 내용에 대해서는 추후에 확인해보자.

 

  • fopen함수가 호출되면 FILE 구조체 변수가 생성된다(우리가 만든게 아니라 운영체제가 만들어준다).
  • 생성된 FILE  구조체 변수에는 파일에 대한 (데이터 입출력을 위한)정보가 담긴다.
  • FILE구조체의 포인터는 사실상 파일으 가리키는 "지시자"의 역할을 한다.

출력 스트림의 생성

사실 위에 fopen함수를 모두 알고 있다고 말하기 위해서는 두번째 인자에 대해서 알아야한다.

이 두번째 인자는 우리가 어떤 형태로 스트림을 형성할것인지를 전달하는데 이걸 보통 file open mode라고 한다.

이 두번째 인자가 다양한 형태의 조합으로 구성되어 있고 그 형태를 모두 알아야한다.

이 내용은 다음에 파일의 모드에 대해서 공부하면서 알아보도록하고 우선은 우리가 fopen함수가 파일을 개방하는지 안하는지에 대해서만 알 수 있게 아는 정도로 배워보도록 할것이다.

 

그래서 먼저 

FILE * fp = fopen("data.txt", "wt");

=>> 파일 data.txt와 스트림을 형성하되 wt모드로 스트림을 형성해라

 여기서 wt란 스트림의 종류를 의미하는데 이건 w와 t의 조합으로 w는 write를 의미하고 data.txt를 오픈을 할건데 write모드로 오픈을 시켜라 라는 의미이고 이는 쓰기 위한 스트림인 출력스트림을 형성해라 라는 의미이다.

그리고 t는 간단하게 설명이 되지는 않는데 우선 t는 text의 의미로 text 데이터를 쓰기위한 스트림을 형성한다 라는 의미를 담고 있다.

 

그래서 프로그램과 파일 사이에 text데이터를 쓰기위한 출력 스트림을 생성한다는 의미가 되고 여기서 포인터 변수인 fp에 저장된 값은 data.txt의 스트림에 데이터를 전송하기 위한 도구가 된다.

입력 스트림의 생성

FILE * fp = fopen("data.txt", "rt");

=>> 파일 data.txt와 스트림을 형성하되 rt모드로 스트림을 형성해라

입력 스트림의 경우는 rt를 두번째 인자로 전달해주면 된다.

여기서 r은 read모드로 data.txt를 오픈하겠다는 의미이고 이는 읽기 위한 스트림인 입력 스트림을 형성해라 라는 의미와 같다.

t는 wt의 t와 동일한 의미를 갖고 있다.

 

그래서 프로그램과 파일 사이에 text 데이터를 읽기 위한 입력 스트림을 생성한다는 의미가 되고 여기서 포인터 변수인 fp에 저장된 값은 data.txt의 스트림으로 부터 데이터를 수신하기 위한 도구가 된다.

 

여기서 w와 r를 사용할때 주의해야할 점이 있다.

 

w의 경우는 write 모드라는 의미로 알고 있는데 이는 data.txt가 기존에 존재 했더라고 기존의 내용에 이어서 저장하겠다는 의미가 아니라 새로 쓰겠다는 의미를 가진다.

그렇기에 기존에 data.txt라는 파일이 존재하지 않는다면 새로 만들고 data.txt라는 파일이 존재했다면 지금 write하는 내용은 그 파일 위에 덮어 씌워지기에 기존에 있던 내용은 삭제되게 된다.

 

r의 경우는 당연하게 기존에 존재하는 파일이여야만 파일을 읽을 수 있기에 파일이 이미 존재하고 있어야한다.

 

이렇게 두번째 인자를 어떻게 전달하냐에 따라서 파일과 관련해서 동작하는 방식이 다르기 때문에 이런 부분을 신경쓸 필요가 있다.

 

파일에 데이터를 써보자

현재 디렉터리(main이 실행되는 파일의 위치)에서 data.txt를 찾고 없다면 생성하면서 wt, text 쓰기모드로 스트림을 형성한다.

 

**여기서 현재 디렉터리는 빌드를 하고 나온 exe파일이 실행되는 위치이다.

그러나 코드 쪽에서 현재 디렉터리가 어느 곳인지 변경할 수 도 있기에 유동적인 경로라고 알고 있어야만 한다.(프로그램 상에서 확인해야만 한다 - 파일 탐색기로 해당 파일이 어디에 만들어 졌는지를 확인해야 그게 현재 디렉터리가 되는 것임.)

여기서 파일의 위치를 경로를 직접 넣어주면 그 경로에 있는 파일과 스트림을 형성하게 된다.

여기서 \를 표현하기 위해선 \\를 사용해야만 문자열로 인식한다.

이제 fopen함수가 실패하지 않았다는 가정하에 반환된 데이터를 담은 fp는 새롭게 생성된 data.txt를 가리키는 지시자가 되고 이제 fp는 data.txt를 가리키는 것이 된다.

이제 이 fp를 사용해서 데이터를 쓸 수 있게 된다.

 

그래서 아래 fputc함수를 보면 이전에는 fput("문자열", stdin)을 사용했었는데 이제는 stdin대신에 fp를 사용해서 파일에 출력을 할 수 있게 된다.

그 사용 방법은 

와 같다.(fputc에서 첫번째 인자를 "으로 감싸면 문자열에서 에러가 남, '으로 감싸줘야만 문제가 없는 듯 보임 - 확인해봤는데 fputc의 첫번째 인자는 int형 char로 단일 char를 전달해줘야만 하는데 ""으로 전달할 경우 해당 값은 char * 으로 인식 되어 에러를 발생시킨다는 듯 보임, ''로 전달해줘야만 단일 문자를 보내줄것이라고 생각한다고 하니 ""가 아닌 ''를 꼭 전달해야함)

 

그리고 가장 마지막의

fclose 함수는 fp랑 연결된 data.txt와 연결된 스트림을 해제해라 라는 의미로 fopen함수의 반대 기능을 수행한다(반대라고 파일을 삭제하지는 않는다, 그냥 스트림만 종료함.).

스트림의 소멸을 요청하는 fclose 함수

먼저 fopen함수를 통해서 형성한 스트림은 fclose함수를 호출하지 않고 프로그램이 종료되면 그 스트림은 계속 유지되는 것일까.

 

아니다 프로그램이 return(종료)를 하면 프로그램을 실행하면서 생성했던 모든 스트림이 종료가 된다.

 

모든 스트림이 종료되면 궂이 왜 스트림을 직접 종료해줘야만 할까?

 

기본적으로 fclose함수가 호출되면 두가지 기능을 동반한다.

  • 운영체제가 할당한 자원을 반환
  • 버퍼링 되었던 데이터의 출력

여기서 먼저 운영체제가 할당한 자원을 반환한다는 의미는 파일과 스트림을 형성했을때 출력스트림을 형성하거나 입력 스트림을 형성할때 모두 버퍼가 생성되는데(이 버퍼는 스트림과 별개가 아니라 스트림에 포함되어 있는 형태이다) 이 스트림과 버퍼를 모두  반환(소멸)시킨다는 의미이다(메모리 공간을 차지하고 있던걸 소멸시킨다는 의미).

 

그리고 버퍼링 되었던 데이터의 출력은 먼저 버퍼는 입력 버퍼보다 출력 버퍼가 중요하기에 출력 버퍼를 기준으로 내용을 작성하자면, fputc 함수를 통해서 "A"와 "B"와 "C"를 전달한다고 해보자.

fputc함수가 A를 쓰고 종료하면 파일에는 이 A가 작성되어 있다고 단언 할 수 있을까?

아니다 fputc함수가 A를 쓰고 종료하더라도 파일에 이 A가 반환되어 있지 않다.

실제 A라는 문자가 fputc로부터 반환되는 시점은 A라는 문자가 출력버퍼에 저장되는 순간 반환이 되어 진다.

fputc를 통해서 A를 통해 버퍼에 반환되는 시점에 flush가 이루어 졌다면 파일에 쓰여질 수 있으나 그렇지 않을 수 도 있다.

B와 C 또한 동일하고 그렇기에 fputc가 세번이나 호출되어 A,B,C를 반환했지만 이 시점에 파일에는 어떤것도 저장되지 않았을 수 도 있다는 것이다.

이게 바로 퍼버의 특성이다.

그런데 이 fclose가 호출되면 버퍼링 되었던 데이터가 출력된다고 하는데 우리가 fclose를 통해서 fp를 전달하면(스트림을 종료하면) 혹시 버퍼에 쌓여 있던 데이터가 있다면 flush의 기능을 추가로 수행해준다는 의미이다.

 

그러면 프로그램이 종료되면 운영체제가 할당한 지원이 반환되지 않고 버퍼링 되었던 데이터가 출력되지 않고 소실되냐 하면 둘다 아니다.

 

그러면 fclose를 도대체 왜 궂이 사용해서 스트림을 종료해주라는 것일까

 

프로그램의 시작이 1 - 10라고 보자(시간의 흐름).

그러면 내가 A.txt를 만들어서 데이터를 출력하는 타이밍이 2,5,7,8 있다고 해보자.

그러면 우리가 fclose를 사용하지 않고 쭉 사용하면 문제가 되는 부분이 이 프로그램은 프로그램이 종료될때까지 해당 스트림을 계속 유지한채 프로그램이 시작되어야 한다.

스트림이 필요한 시점은 2, 5, 7, 8이 필요한데 남은 반절 이상의 시간을 스트림을 열어 놓은채로 리소스를 차지한다는 것은 메모리는 비효율적으로 사용하는 측면이 있는 것이다.

 

메모리의 효율적인 사용을 위해서 불필요할때는 fclose를 사용해서 스트림을 닫고 필요할때만 열어주자는 이유가 첫번째 이유이다.

 

그런데 이것보다 더 중요한 내용은 버퍼링 때문이다.

위에서 2번째에 데이터를 쓰고 5번에서 데이터를 썼고 이 데이터들이 모두 너무 작아서 버퍼안에 담겨서 flush되지 않았다고 생각해보자.

 

그런데 어쩌다가 갑자기 정전이 나서 프로그램이 갑자기 종료되었다면 파일이 저장될까?

출력 버퍼에 남은 채로 전원이 나가면 그걸로 데이터는 소실되는 것이다.

그런데 open할때마다 write했던것을 flush해줬다면 정전 시점에서 이전에 작성한 파일은 안전하게 저장이 될것이다.

이런 안정성적인 면에서도 close가 필요하다는 것이다.

 

그런데 버퍼 플러쉬 같은 경우는 출력버퍼에서만 의미가 강하다.

보통 read용으로 스트림을 여는 경우에는 닫지 않고 계속 가져가기도 한다.

왜냐면 입력 버퍼로 읽어 들인다고 해도 그 값은 복사한 값이기 때문에 파일에는 어떠한 문제도 발생시키지 않는다(변화도 못줌)

그래서 read 모드인 경우는 그냥 계속 열어 놓는 경우도 있다(실무적으로).

근데 보통 깐깐한 경우는 read인 경우도 열면 닫기도 한다.

 

그런데 write의 경우는 반드시 출력버퍼를 flush해줘야한다는 측면에서도 동일하게 fclose를 써야 좋다는 것이다

 

그러면 앞에서 본 예제를 보면

이건 궂이 쓰지 않는다고 문제가 발생할 수 있는 코드는 아니다. 

그러나 우리가 습관화를 들여야한다는 측면에서 사용한 것이고, 왠만하면 fopen을 하면 fclose를 하는 버릇을 만들기로 하자.

 

CH21에서 호출해본적 있는 fflush함수

#include <stdio.h>

int fflush (FILE * stream);

=>> 함수 호출 성공 시 0, 실패시 EOF반환

 

이전에 우리는 fflush함수에 스트림의 자리에 stdout를 인자로 전달해서 console에 flush했었다.

여기서 출력버퍼를 비운다는 의미는 저장된 데이터를 목적지로 전송한다는 의미이고, 입력 버퍼를 비운다는 의미는 입력 버퍼에 저장된 데이터를 소멸시킨다는 의미를 가진다고 했었다.

그리고 fflush의 경우는 출력 버퍼를 대상으로 하기 때문에 stdin이 담기면 안된다.

만약 stdin을 flush하고 싶다면 그냥 read해서 버리면 되는 것이라고 하면서 그냥 변수에 담지 않고 그냥 read했던 경험이 있다.

 

마찬가지로 파일에도 그대로 적용이 되는 내용이다.

파일 중에서도 read 모드와 write모드로 open이 가능하다.

여기서 write 모드로 전달되는 파일의 스트림만이 fflush에 전달이 가능하다.

이게 동일하게 입력 버퍼를 대상으로는 호출이 불가능하다는 내용과도 동일하게 적용되는 의미이다.

 

그래서 결론적으로 말하고자 하는건 

// 적절한 사용법(write모드인 스트림을 fflush에 전달하는건 적절함)
FILE * fp = fopen("data.txt", "wt");

fflush(fp); //출력 버퍼를 비워라

// 부적절한 사용법(read모드인 스트림을 fflush에 전달하는건 적절하지 않음)
FILE * fp = fopen("data.txt", "rt");

fflush(fp); //입력 버퍼를 비워라 - 부적절한 사용법

 

이렇게 사용하면 파일에 안정적으로 사용을 할 수 있게 된다.

물론 fflush를 사용했다면 fclose를 쓰지 않아도 된다는 말은 아니다.

언젠가는 무조건 fclose를 사용해야한다.

 

그러면 파일의 입력버퍼는 어떻게 비울까?

이건 동일하게 그냥 읽어버리면 끝난다.

 

 근데 참고로 입력버퍼를 비우는 일은 거의 없다고 한다.

 

파일로부터 데이터를 읽어보자.

여기서 보면 fgetc를 통해서 fp를 받아 하나의 문자를 받아 ch에 저장하고 그걸 출력하는 것을 볼 수 있다.

read 모드로 설정 했더라도 fclose로 스트림을 닫는걸 습관화 하자.

24-2 파일의 개방 모드(Mode)

스트림의 구분 기준 두가지(Basic)

  1. 읽기를 위한 스트림 / 쓰기를 위한 스트림
  2. 텍스트 데이터를 위한 스트림 / 바이너리 데이터를 위한 스트림

이 두가지 구분으로 4가지 스트림의 종류를 나눌 수 있는데

  1. 읽기 스트림 - 텍스트 데이터        ==> 텍스트 데이터 읽기 스트림
  2. 읽기 스트림 - 바이너리 데이터    ==> 바이너리 데이터 읽기 스트림
  3. 쓰기 스트림 - 텍스트 데이터       ==> 텍스트 데이터 쓰기 스트림
  4. 쓰기 스트림 - 바이너리 데이터   ==> 바이너리 데이터 쓰기 스트림

으로 우선 기본적으로 나뉜다.

그러나 이게 끝이 아니라 조금 더 세분화 해서 나뉠 수 있다.

읽기인가 쓰기인가:Read or Write

스트림의 성격은 Read/Write를 기준으로 세분화된다.

 

 

모드(mode) 스트림의 성격 파일이 없는 경우
r 읽기 가능 에러
w 쓰기 가능 생성
a 파일의 끝에 덧붙여 쓰기 가능 생성
r+ 읽기/쓰기 가능 에러
w+ 읽기/쓰기 가능 생성
a+ 읽기/덧붙여 쓰기 가능 생성

 

+는 읽기, 쓰기가 모두 가능한 스트림을 생성하는 모드를 의미한다

a는 쓰기위한 스트림인데 기존에는 파일을 새로 덮어 씌웠다면 해당 모드는 파일의 기존내용에 추가적으로 덧붙여 쓰는 모드이다.

 

텍스트인가 바이너리인가

또한 스트림은 데이터의 종류에 따라 

  • 텍스트 모드 스트림(t) : 문자 데이터를 저장하는 스트림
  • 바이너리 모드 스트림(b) : 바이너리 데이터를 저장하는 스트림

으로 나눌 수 있다.

 

문자 데이터

사람이 인식할 수 있는 유형의 문자로 이루어진 데이터를 의미하고 파일에 저장된 문자 데이터는 Windows의 메모장으로 열어서 문자 확인이 가능하다.

 

바이너리 데이터

컴퓨터가 인식할 수 있는 유형의 데이터로 메모장 같은 편집기로는 그 내용이 의미하는 바를 이해할 수 없다.

그 종류는 음원, 영상, 그래픽 디자인 프로그램으로 만들어진 디자인 파일, 사진등이 있다.

 

파일의 개방모드 조합

파일의 모드는 r/w/a/r+/w+/a+와 b/t의 조합으로 구성된다.

 

예를 들어 바이너리 데이터 읽기 스트림이라면 rb라고 작성할 수 있고 텍스트 테이터 쓰기 스트림이라면 wt라고 작성할 수 있다.

 

여기서 어떤 데이터 타입인지를 명시하지 않는다면 (w나 r으로만 작성된 경우) 자동적으로 텍스트모드(t)로 파일을 개방한다.

 

텍스트 스트림이 별도로 존재하는 이유

c언어는 개행을 \n으로 표시하기로 약속되어 있다.

따라서 우리가 개행 정보를 저장할때 C 프로그램 상에서 우리는 \n을 작성해서 저장한다.

 

그런데 사실 운영체제별로 개행을 표기하는 방법에는 차이가 있다.

  • Windows -   \r\n
  • Linus       -   \n
  • Mac        -    \r

그러면 개행을 \n으로 인식하지 않는 운영체제는 \n으로 전달된 데이터를 전혀 다른 것으로 해석하게 된다.

그렇기에 개행 정보를 정확하게 저장하기 위해선 이 운영체제에 맞게 변환하는 과정이 필요해진다.

이걸 해결해주는 것이 텍스트모드로 데이터를 입출력하는 방식이다.

텍스트 모드로 데이터를 입출력하면 운영체제에 맞게 자동적으로 변환처리를 해준다.

24-3 파일 입출력 함수의 기본

Chapter 21에서 학습한 파일 입출력 함수들

int fputc(int c, FILE * stream);              // 문자 출력

int fgetc(FILE * stream);                     // 문자 입력

int fputs(const char * s, FILE * stream);     // 문자열 출력

char * fgets(char * s, int n, FILE * stream); // 문자열 입력

 

처음 위 함수들을 배울때는 stream에 stdin이나 stdout을 전달해서 콘솔을 대상으로 입출력을 했었는데, 파일을 대상으로도 입출력을 동일하게 사용할 수 있다.

 

파일 입출력의 예

이렇게 만든 파일을 저장하고, Ctrl + shift + b를 누르면(VS 단축키) 코드를 빌드해서 

exe파일로 떨줘군다.

그리고 이 파일을 실행해보면

우리가 만들고자 했던 

simple.txt가 생성된다.

이걸 켜보면 

이렇게 우리가 스트림으로 보낸 문자들이 모두 저장된것을 볼 수 있다.

 

이렇게 만든 파일을 rt로 읽어들여보자.

더보기

* fgets에 사용법을 코드를 치면서 좀 헷갈려서 조금 작성해두고자 한다.

 

- char * fgets(char * str, int n, FILE * stream);

 

우선 fgets의 경우는 최대 n-1개의 문자를 읽어오고 마지막 문자는 항상 널문자(\o)로 종료된다

이 함수는 개행문자(\n)를 만나면 그 전까지만 읽고 종료되고 개행문자 또한 포함시켜서 버퍼에 가져온다

파일의 끝(EOF)에 도달하거나 오류가 발생하면 NULL을 반환한다.

 

각 매개변수에 대해서 설명하자면

 

1. char * str : 읽어온 문자열을 저장할 버퍼의 포인터 (이쪽으로 전달된 내용이 저장된다)

2. int n : 최대 읽어 들여올 수 있는 문자수(널문자 포함이기에 위에서 n-1개라고 말했던 것임)

3. stream: 읽어들여올 파일 스트림 포인터

이렇게 작성해주면 파일을 구분해서 다 읽어온다.

여기서 이야기해줘야 할건 fputs를 사용할때 입력할 문자열에는 끝에 \n 개행문자를 넣어줬다는 것이다.

파일에서 이렇게 문장에 대해서 구분하지 않으면 해당 문장이 어디서 끝나는지는 프로그램이 이해할 수 없기 때문에 문장을 구분하기 위해서는 \n 개행문자를 넣어줘야한다. 

 

feof함수 기반의 파일 복사 프로그램

 

#include <stdio.h>

int feof(FILE * stream);

=>> 파일의 끝에 도달한 경우 0이 아닌 값 반환

 

파일의 끝을 확인해야 하는 경우는 이 함수를 사용하면 된다.

파일 입력 함수는 개행문자를 만나는 경우에 EOF를 반환하지만 오류가 발생하는 경우에도 EOF를 반환하기에 EOF 반환원인을 정확히 판단하기 위해서는 해당 함수로 확인해봐야 한다.

 

여기서 내가 이상하게 봤던건 while문의 조건절에 ch를 할당하고 그걸 비교하는 것이였다.

확인해 봤는데 자바의 경우는 변수에 값을 할당할때 void를 반환하나(이게 반환된다는것고 이제 알았음) C언어의 경우는 변수를 할당하면 할당된 값이 반환된다고 한다.

그래서  이렇게 자주 사용된다고 한다.

 

위와 같이 src가 존재할때(rt이기 때문에 파일이 존재해야함 ) 

스트림이 제대로 생성되었다면 

만약 src.txt가 존재하지 않는다면 여기서 에러를 발생시킴

fgetc를 사용해서 src에 존재하는 문자를 하나씩 읽어서 ch에 담는다.

담는 동시에 반환되는 할당한 값이 EOF인지 확인하고 EOF가 아닌 동안에는(문자가 존재하는 경우에는) des에 연결된 파일에 ch의 값을 넣는다.

feof함수를 통해서 src가 파일의 끝에 도달한다면 "파일복사 완료!"라는 문자열을 전달하고 파일의 끝에 도달하지 못하고 0을 출력한 경우는 "파일복사 실패!"라는 문자열을 출력한다.

 

바이너리 데이터의 입출력 : fread - 입력

#include <stdio.h>

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

=>> 성공 시 전달인자 count, 실패 또는 파일의 끝 도달 시 count보다 작은 값 반환

 

FILE * stream으로 부터 데이터를  size_t size * size_t count만큼 읽어들여서 void * buffer에 저장해라 라는 의미를 갖고 있다.

그래서 

int buf[12];

fread((void*)buf, sizeof(int), 12, fp);

라는 코드가 있다면 sizeof(int)크기의 데이터를 12만큼 fp가 가리키는 파일에서 읽어서 buf안에 넣어라 라는 의미를 갖는다 

 

 

바이너리 데이터의 입출력: fwrite - 입력

#include <stdio.h>

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

=>> 성공 시 전달인자 count, 실패 시 count 보다 작은 값 반환

이건 반대로 const void * buffer에서 size_t size의 데이터를 size_t count 만큼 읽어서 FILE * stream에 저장해라이다.

 

int buf[7] = {1,2,3,4,5,6,7};

fwrite((void*)buf, sizeof(int), 7, fp);

와 같이 사용할 수 있고 이건 buf 배열의 sizeof(int)크기의 데이터를(int형 데이터를) 7만큼 읽어서 fp에 저장해라 라는 의미를 가진다.

 

fread와 fwrite에서 size_t size * size_t count 로 나오는 읽어들일 byte의 크기는(ex. int형 7바이트를 읽어라) 최대 값이 아니라 아예 지정한 크기인것임.

그렇기에 그 크기만큼의 데이터를 읽으면 그 크기만큼을 fread, fwrite가 반환하고 그 크기보다 작은 값이 반환되는 경우에는 파일의 끝에 도달했다거나 오류가 발생한 것이다.

 

바이너리 파일 복사 프로그램

이 코드를 보면 먼저 파일 src.bin과 dst.bin파일에 대한 스트림을 형성하고

그걸 src, des에 담는다.

 

그리고 이 바이너리 데이터를 담아갖고 올 buf 배열과 읽어오는 값의 길이를 확인한 readCnt변수를 생성한다.

 

모든 변수가 선언되었다면 src.bin파일과 dst.bin파일을 잘 열었는지 확인을 한다

파일에 대한 포인터가 담기지 않았다면 비정상적 접근으로 확인하고 종료시킨다.

 

그 후에

while문을 통해서 src포인터로 연결되어 있는 파일을 1byte * sizeof(buf)만큼 읽어들여온 다음에 buf에 담고 readCnt에 읽은 길이를 반환한다.

만약 읽어들인 크기가(readCnt) 읽으려고 한 크기(sizeof(buf))보다 작은 경우는 오류 혹은 파일의 내용을 모두 읽은 것이기에 우선 오류인지 파일의 내용을 모두 읽은 것인지  파악한다. 

feof에 src를 전달해서 현재 읽고 있는 위치가 파일의 끝인지를 확인한다.

혹시 파일의 끝이라면 des가 가리키는 파일 dst.bin에 buf의 크기만큼이 아니라 readCnt의 크기만큼(읽어들인 크기만큼) 저장한다.

 

더보기

feof(src)를 통해서 파일의 끝을 찾을 수 있는 이유는 src가 아예 그 파일을 의미하는게 아니라 이 src안에 파일을 얼마나 현재 읽어들여 왔었는지에 대한 포인터가 들어 있기에 이 값을 확인해서 해당 파일을 얼마나 읽었는지 판단할 수 있다.

 

src가 파일의 끝을 바라보고 있다면 feof는 0을 반환한다는 것이다.

 

src가 파일을 읽고 있는 위치를 변경하는 함수는 fgetc, fgets, fread등이 있다.

 

파일을 모두 읽으면 src가 가리키는 위치는 파일의 끝이 된다(약간 마트에 적외선 막는 막대라고 생각하면 좀 더 이해가 쉬울지도 모른다. 벨트에 있는 물건들을 모두 찍고 나면 막대는 적외선 레이저에 닿아서 컨베이어 벨트가 멈추듯이...)

그리고 읽어 들인 크기가 sizeof(buf)보다 작지 않다면 파일을 정상적으로 읽고 싶은 만큼 읽은 것이기에 읽어 들인 값을 des가 가리키는 파일의 위치에 읽어들인 값을 작성한다.

그 후에 열어둔 파일 스트림을 모두 닫고 프로그램을 종료한다.

 

이러면 파일이 그대로 복사가 된다.

물론 text파일 또한 결국 바이너리 파일이기에 그대로 복사가 가능하다.

24-4 텍스트 데이터와 바이너리 데이터를 동시에 입출력 하기

바이너리 데이터가 어려운것 같지만 사실 텍스트 데이터를 입출력하는게 더 어렵다.

바이너리 데이터의 경우는 fread, fwrite외에는 사용하지 않는데 반면, 텍스트 데이터의 경우는 종류가 더 많고 그 방법을 더 고민해봐야한다.

실무를 하다보면 텍스트 데이터의 입출력이 더 까다롭다.

 

그런데 예를 들어 한 파일에서 문자열, 바이너리 데이터 모두 입출력해야하는 경우에 대한 사용은 너무 복잡해진다.

그렇기에 그 부분에 도움을 주기 위한 함수가 fprintf와 fscanf이다.

서식에 따른 데이터 입출력:fprintf, fscanf

먼저 fprintf와 fscanf의 사용법에 대해서 보자.

char name[10] = "홍글동"; // text 데이터
char sex = 'M';          // text 데이터
int age = 24;            // binary 데이터

fprintf(fp, "%s %c %d", name, sex, age);

 

위에 보면 텍스트 데이터와 바이너리 데이터를 같이 fprintf로 파일에 출력하려고 한다.

보이는 것 과 같이 그 매개체가 되는건 문자열이다.

바이너리 데이터를 텍스트로 변환해서 fp가 가리키는 위치에 출력하는 것이다.

 

fscanf의 경우는 해당 파일이 어떻게 구성되어 있는지를 알아야한다.

예를 들어서 위에 fprintf를 사용한 경우 name - 문자열, sex-문자, age - 바이너리의 형태로 입력이 되어 있고 파일에도 그렇게 작성되어 있을 것이다.

다만 이 내용은 모두 문자열로 구성되어 있기에 fscanf가 받을때는 이 문자열의 순서대로 데이터를 입력 받아야 한다.

char name[10];
char sex;
int age;
fscanf(fp, "%s %c %d", name, &sex, &age);

 

이렇게 fprintf를 했던 형태 그대로 받아줘야 그 타입까지 같이 받아질 수 있다.

 

fprintf & fscanf 관련 예제

우선 이렇게 사용자의 입력을 받아서 파일 friend.txt를 생성하면서 값을 넣어준다.

더보기

여기서 이렇게 

텍스트 모드로 사용한 이유는 바이너리 데이터와 문자 데이터를 한번에 문자열로 묶어서 파일에 저장하는 방식을 사용했기 때문이다.

 

다른 두 형태의 데이터를 하나로 묶어서 저장하는 방법은 여러가지가 있겠지만 여기서 선택한것은 하나로 묶기 위해서 문자열을 사용해서 파일저장하는 방법을 사용했으니까 텍스트로 입력 하도록 모드를 설정해서 사용할수 밖에 없다.

이렇게 작성 한 다음에 빌드를 하고 

해당경로에 hello.exe파일이 생성된것을 실행시키면

이렇게 입력을 요청하고 그 값을 넣어보면 

파일이 생성된다.

이를 열어보면

이렇게 입력 된것을 확인할 수 있다.

 

그럼 이걸 다시 fscanf함수를 사용해서 파일로부터 값을 읽어서 화면에 출력해보도록하자.

여기서는 fscanf를 통해서 파일을 받는데 기존에 파일을 보면 이름 - 성별 - 나이의 순으로 데이터가 문자열로 작성되어 있고 이를 fscanf가 받아서 값들을 변수에 넣어주고

더보기


* fscanf의 경우는 기본적으로 데이터를 성공적으로 읽어들인 경우는 그 갯수를 정수로 반환한다.

예를 들어 두개의 정수를 읽으면 2를 반환한다.

 

만약 EOF를 반환하는 경우는 파일의 끝에 도달했거나 오류가 발생한 경우이다.

 

0을 반환하는 경우는 fscanf가 호출되었으나 아무런 데이터를 읽지 못한 경우이다(형식이 안맞거나 입력이 없는 경우)

 

이 세가지의 반환 상황을 고려해서 처리해주자.

반환값이 EOF인 경우에만 while문을 종료시키고 fp를 닫아준다.

반환값이 EOF가 아닌 경우는 fscanf를 통해 가져온 값을 prinft를 통해서 콘솔에 출력해준다.

 

이 결과값은 

빌드를 해주고 

빌드한 exe파일이 있는 곳에서 cmd를 열어주고 

hello를 켜주면 

이렇게 결과값을 확인할 수 있다.

 

 

Text/ Binary의 집합체인 구조체 변수 입출력 

실제로 프로그램을 구현하다보면 구조체단위 변수로 파일 입출력하는 경우가 매우 많다.

먼저 

의 형태로 구조체가 선언이 되었다 할때 이 내부에 보이는 값들은 각각이 타입이 있으나 사실 이 구조체 하나가 통채로 바이너리 데이터이다.

그리고 이게 텍스트 데이터건 바이너리 데이터건 모두 바이너리 데이터로 입출력을 제어할 수 있다.

 

그냥 구조체의 경우는 내부를 구분할 필요 없이 그냥 구조체 변수 그자체를 통째로 보관하고 통째로 복원한다고 생각하면 된다.

이 때 사용되는게 fread와 fwrite이다.

먼저 파일의 정보를 받아줄 fp를 생성하고 구조체 두개를 생성한다

fp에 파일 friend.bin을 바이너리 입력 모드로 열어주고

구조체에다 값을 입력 받아 넣어준다.

 

그리고 fwrite를 사용해서 구조체의 크기의 데이터 한개를 입력 받아서 파일에 입력한다.

여기서 fwrite의 첫번째 인자는 (void*) 주소값 의 형태로 전달 되어야 하기 때문에 구조체의 주소값을 주기 위해 &연산자를 붙여 전달한다.

더보기

여기서 첫번째인자로 전달할때 그 값 자체가 주소값인 경우(배열의 이름, 포인터)는 그냥 그 변수 자체만 전달하면 되는데

int * ptr = &intVar;

fwrite((void*)ptr, ...)

 

만약 이 값이 그냥 값을 넣어놓은 변수라면 그 주소값을 전달해야 한다.

int intVar = 10;
Friend bf;

fwrite((viod*)&intVar ...);
fwrite((viod*)&bf ...);

그리고 나서 파일을 닫아준다.

 

이제 파일을 읽어볼 차례이다.

읽기 위해서 다시 파일을 바이너리 출력모드로 열어주고

fread함수를 사용해서 fp가 바라보는 파일에서 Friend구조체의 크기만큼 1데이터를 읽어서 myfriend2에 넣어준다.

그리고 이 값을 출력하고

다시 열었던 파일을 닫아준다.

이 파일을 실행하면 

이렇게 구조체를 통째로 바이너리 모드로 저장하고 복원할 수 있게 되었다.

 

구조체 변수의 입출력은 통째로 한다고 생각하고 있어야한다..!!

24-5 임의 접근을 위한 ‘파일 위치 지시자’의 이동

파일 위치 지시자란

FILE 구조체의 멤버중 하나로 파일 내에서 현재 읽기, 쓰기작업이 수행될 위치를 가리키는 개념이다.

 

파일 위치 지시자는 파일 내에서 다음 읽기 또는 쓰기 작업이 수행될 바이트 위치를 나타낸다.

파일을 열면 위치 지시자는 기본적으로 파일의 시작 부분을 가리키고 읽기나 쓰기 작업을 수행할 때마다 자동으로 이동한다.

 

이 파일 위치 지시자의 용도는 파일을 처음부터 끝까지 순차적으로 읽거나 쓰는 순차적 접근에서 사용되고 파일의 특정 위치로 이동해서 읽기나 쓰기를 수행하는 임의 접근시에도 사용된다

 

이 파일 위치 지시자와 관련된 함수로는 fseek(), ftell(), rewind()등이 있다.

 

여태까지 fputs, fread, fwrite와 같은 함수가 호출 될 때마다 파일 위치 지시자의 참조 위치를 변경 했던 거고 그렇가에 feof()함수에 FILE 구조체를 전달했을때 다 읽었는지를 확인할 수 있었던건 이 파일 위치 지시자가 FILE 구조체 안에 존재 했고 현재 어디를 읽고 있는지를 나타냈기 때문에 가능했던 것이다.

 

파일 위치 지시자의 이동: fseek

fseek 함수는 파일 위치 지시자의 참조 위치를 변경시키는 함수이다.

#include <stdio.h>

int fseek(FILE * stream, long offset, int wherefrom);

=>> 성공 시 0, 실패 시 0이 아닌 값 반환

 

FILE * stream - 파일 구조체 포인터로 위치를 변경하고자 하는 파일을 가리킴

long offset - 이동하고자 하는 바이트 수, 양수면 파일의 끝 방향으로 진행, 음수면 파일의 시작점으로 진행함

int wherefrom - 파일 구조체 포인터의 위치 지시자의 시작점을 설정한다.

 

int wherefrom에 들어 갈 수 있는 값은 세가지로 

  • SEEK_SET : 파일의 시작점
  • SEEK_CUR : 현재 위치
  • SEEK_END : 파일의 끝

으로 구성되고 이는 모두 stdio.h에 있는 전역 상수로써 실제 값은 순서대로 0, 1, 2로 구성되어 있다.

 

fseek 함수 사용 예제

 

설명을 해보자면 SEEK_SET, SEEK_CUR은 이해가 될 수 있다.

가리키고 있는게 SEEK_SET이라면 0을 가리키고 있다가 2개 앞으로 가라 한다면 두개 건너 뛴 2를 가리키는게 맞고 

SEEK_CUR를 통해서 2를 가리키던 파일 위치 지시자가 3칸 건너 뛴 6을 가리키는게 맞다.

그런데 SEEK_END의 경우는 끝이 9인데 -2 하면 7이여야 하는데 8을 가리키고 있다.

 

그 이유는 SEEK_END는 마지막 데이터가 아니라 파일의 끝인 EOF에 위치하고 있다가 fseek을 통해서 그 앞으로 2칸 이동 한 8으로 이동하는 것이기 때문임.

 

현재 파일 위치 지시자의 위치 : ftell

#include <stdio.h>

long ftell(FILE * stream);

=>> 파일 위치 지시자의 위치 정보를 반환

 

크게 말할 내용은 아니고 그냥 파일 위치정보를 정수값으로 반환한다.

이건 fgetc를 통해서 전진한 그 위치를 fpos에 전달해 놓고 마지막에 그 위치로 되돌리면서 한칸씩 전진한 결과를 for문의 시작에서 한번씩 출력해준다.

이렇게 해서 결과 값은 

이 출력 된다.

 

반응형