파일 입출력

CHOI·2021년 7월 23일
0

C 언어

목록 보기
27/28
post-thumbnail

여기까지 강좌를 잘 따라 오면서 프로그램을 만들어 보았다면 다음과 같은 의문이 들을 수 있다.

데이터를 어떻게 프로그램이 종료되어도 저장할 수 있을까?

사실 그 방법은 간단하다. 특정한 데이터가 있으면 이를 하드디스크에 저장하면 된다.

여태까지 우리가 만든 프로그램의 변수들은 하드디스크가 아니라 언제나 RAM 에 상주한 데이터였다. 즉 프로그램이 종료되어도 그렇지만 컴퓨터가 꺼지게 되면 데이터가 날라가는 휘발성 메모리 이다. 하지만 우리들에 컴퓨터 속에 있는 프로그램이나 문서들은 비휘발성 저장매체인 하드디스크에 저장되어 있기 때문에 컴퓨터를 껏다 켜도 사라지지 않는다.

그렇다고 해서 하드디스크에 아무렇게나 보관할 수 있는 것은 아니다. 하드디스크에 데이터를 보관할 때는 파일의 단위로 데이터를 보관하게 된다. 따라서 이번에는 어떻게 파일을 만들고, 파일에 데이터를 저장하고, 파일을 읽을 수 있는지 알아보자.

파일에 출력하기

/* a.txt 에 내용을 기록한다. */
#include <stdio.h>

int main() {
  FILE *fp;
  fp = fopen("/Users/*****/Desktop/HellowWorld/HellowWorld/a.txt", "w");

  if (fp == NULL) {
    printf("Write Error!!\n");
    return 0;
  }

  fputs("Hello World!!! \n", fp);

  fclose(fp);
  return 0;
}

실행 결과

와 같이 아무것도 나타나지 않는다. 왜냐하면 화면에 출력하는 문장이 아무것도 없기 때문이다. 대신, 소스 파일이 위치한 곳으로 들어가보자.

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2c7b38c9-181a-4aa4-9e2f-be46e3bf382c/Untitled.png

그러면 이와 같이 파일이 만들어져 있고

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/79e3a002-6abf-40c1-af42-b7d44c16b69a/Untitled.png

우리가 작성한 문장이 들어가 있다.

다시 코드를 살펴보자

FILE *fp;
fp = fopen("a.txt", "w");

사실 우리가 하드디스크에 저장되어 있는 파일을 자유롭게 이용할 수 있다고 하기는 하나 이를 사용하는 과정은 매우 복잡하다. 왜냐하면 파일을 새로 만든다고 하여도 하드디스크에 어떠한 부분에 파일을 새로 만들어야 할지, 얼마나 크게 파일을 만들어야 할지 등을 전부 고려해야 한다. 그런데 다행이도 이러한 복잡한 일들을 컴퓨터 운영체제에서 알아서 해준다.

fopen 함수가 방금 말한 '운영체제가 알아서 해주는 부분' 을 처리한다. fopen 함수는 우리가 지정한 파일(a.txt) 과 소통할 수 있도록 스트림을 만들어준다. 그렇다면 스트림이 무엇일까?

스트림


우리가 printf 함수를 사용할 때 컴퓨터 내부에서 어떠한 일들이 일어나는지 생각해보자. 먼저 출력할 문자열들을 구성해야할 것이다. 그리고 이를 모니터에 전달해서 출력하라는 명령을 내리게 된다. 과연 이 작업이 쉬울까? 모니터에게 명령을 내리기 위해서는 모니터마다 만든 회사와 그 방식이 다를 것이고 어떠한 명령을 내려하는지도 다를 것이다. 하지만 우리는 단순히 printf 라는 함수 하나로 이 모든 것을 처리할 수 있다.

그 이유는 바로 스트림 에 있다. 스트림은 이 두 개의 완전히 다른 장치들을 이어주는 파이프 라고 보면 된다. 이러한 스트림은 우리가 직접 구현해야 하는 것이 아니라 운영체제가 스스로 처리해주는 것이다. 만일 우리가 모니터와 연결된 스트림을 이용한다면 운영체제는 모니터에 맞는 명령어를 내릴 것이고 키보드와 연결된 스트림을 이용하면 운영체제는 키보드에 맞는 명령어를 내릴 것이다. 우리 프로그래머 입장에서는 전혀 걱정할 필요가 없다.

따라서 우리가 만약 모니터에 A 를 출력하고 싶으면 단순히 스트림에 A 를 넣으면 된다. 왜냐하면 이렇게 스트림으로 전달된 문자 A 는 운영체제에 의해 알아서 모니터에 명령을 내려서 A 가 출력하게 된다. 마찬가지로 키보드에서 문자를 입력받고 싶다면 우리는 어떤 문자가 스트림을 타고 오는지에만 관심을 가지면 된다. 왜냐하면 우리가 키보드에 무언가 입력 받았다면 운영체제에서 알아서 잘 해석 한 다음에 우리가 이해할 수 있는 데이터로 만들어서 스트림에 전달하기 때문이다.

http://en.wikipedia.org/wiki/File:Stdstreams-notitle.svg

그런데 사실 생각해보면 우리는 방금 말했던 예시인 키보드나 모니터에 대한 스트림을 한 번도 만든적이 없다. 파일을 이용할 때는 파일에 대한 스트림을 fopen 을 통해서 만든다고 했는데 말이다. 사실 키보드와 모니터에 대한 스트림은 표준 스트림(standard stream) 이라고 해서 프로그램이 실행될 때 자동으로 생성된다.

위 그림에서도 확인할 수 있듯이 모니터에 대한 스트림은 stdout 이고 키보드에 대한 스트림은 stdin 이다. (그 외에 stderr 이라는 표준 오류 스트림이란 것이 있는데 stdout 과 거의 동일하다고 보면 된다. 단지 오류 메세지를 출력하는 스트림이다.)

이제 다시 맨 처음 예제로 돌아가보자.

fopen

FILE *fp;
fp = fopen("a.txt", "w");

이렇게 해서 스트림을 만들었으면 fopen 함수는 만든 스트림을 가리키는 포인터를 리턴한다. 스트림에 관한 정보는 FILE 구조체에 들어가 있다.(FILE 구조체에 대해 자세한 내용은 여기) 이제 우리는 fp 를 가지고 파일을 사용할 수 있게 되는 것이다. 그런데 우리는 fopen 의 두번째 인자로 "w" 를 전달했는데, 이 말에 의미는 파일에 오직 '쓰기' 만 가능하게 하겠다는 의미이다. 다시 말해서 스트림인데 출력 스트림만 만든 것이다. (파일에 쓰는 것을 프로그래머 입장에서는 출력이므로 출력 스트림, 파일을 읽는 것은 프로그래머 입장에서 입력받는 것이기 때문에 입력 스트림이다 ) 쉽게 말하면 일방 통행 도로를 만든 것과 같다.

이렇게 출력만 하겠다고 했다면 당연히 파일에 쓰기 만 할 수 있다. 파일에서 데이터를 읽는 작업은 불가능하게 된다. 일단 읽는 것은 나중에 생각하기로 하고 어떻게 파일에 쓰기를 하는지 알아보자. fopen 에서 "w" 를 전달 했을 때, 특징이, 첫번째 인자로 전달된 파일이름이 존재하지 않는다면 아무 내용 없는 파일을 만들거나, 동일한 이름의 파일이 있다면 그 파일의 내용을 다 지워버린다. 참고로, "a.txt"로 그냥 파일 이름을 전달한다면 오직 '소스 파일과 동일한 경로에 들어있는 파일들'을 찾게 된다. 만일 다른 폴더에 있는 a.txt 를 찾고 싶다면 그 경로를 입력해주면 된다.

예를 들어 윈도우의 경우 C 드라이브의 BBB 라는 폴더의 a.txt 를 원한다면 다음과 같이 하면 된다.

fp = fopen("C:\\BBB\\a.txt", "w")

이 때 \\ 를 쓰는 이유은 \ 를 하나만 쓰게 된다면 escape charater 라고 해서 이상한 문자가 나오므로 두개를 써야 한다.

아무튼 우리의 a.txt 의 경우 원래 존재하지 않았으므로 fopen 에서 a.txt"w" 로 여는 순간 새로운 파일이 만들어진다.

if (fp == NULL) {
  printf("Write Error!!\n");
  return 0;
}

이 다음은 아주 중요한 부분인데 만약 어떠한 이유로 파일을 열지 못하게 된다면 fopen 함수는 NULL을 리턴한다. fopen 이 실패하는 경우가 거의 없어서 이 부분을 생략하는 경우가 가끔 있는데 만일 fopen 이 실패하게 되었을 경우 이렇게 검사하지 않으면 소스 코드 뒤에서 어떠한 문제가 발생한지 알 수 없기 때문에 이렇게 항상 검사하는 것이 중요하다.

fputs

fputs("Hello World!!! \n", fp);

이제 fputs 라는 훌륭한 함수를 기록할 수 있다. 첫번째 인자로 파일에 기록할 문자열을 전달하고 두번째 인자로 어떤 스트림을 택할지 그 포인터를 써준다.우리는 앞서 우리가 위에서 열었던 파일 스트림을 선택할 것이기 때문에 fp 를 써주면 된다 ( fp 에는 앞서 fopen 을 통해 파일을 만들고 그 파일과 연결되는 스트림을 만들고 그 스트림의 포인터가 들어가 있다.). 재미있는 사실은 앞서 표준 스트림들은 이미 이름이 정해져 있는데 앞서 말했듯이 stdout 은 컴퓨터의 모니터에 해당하는 표준 입력 스트림이라고 했다. 따라서 두번째 인자로 stdout 을 건내주면 우리 콘솔 화면에 그 문자열이 뜨게 된다.

fputs("Hello World!!! \n", stdout);

실행 결과

Hello World!!!

와 같이 나오는 것을 볼 수 있다. 아무튼

fputs("Hello World!!! \n", fp);

를 통해 파일에 "Hello World!!! \n" 를 기록하게 된다.

fclose

이제 마지막으로

fclose(fp);

를 통해 연결되어 있었던 스트림을 닫아 줘야 한다. 만일 이렇게 fclose 로 스트림을 닫지 않는다면 스트림이 계속 살아 있게 되어 이 파일은 계속 쓰기 상태로 남아 있게 된다. 이는 프로그램이 종료되기 전까지 이 상태로 계속 남아 있기 때문에, 마치 동적 메모리 할당에서 free 로 메모리를 반환해 줘야 하는 것 처럼 스트림도 닫아 줘야 한다.

사실 재미있는 사실은 fclose 로 표준 스트림도 닫을 수 있다. 예를 들어서

#include <stdio.h>
int main() {
  fclose(stdout);
  printf("aaa");
  return 0;
}

실행 결과

와 같이 printf 를 해도 아무것도 나타나지 않는 재미있는 현상이 발생한다.

파일에서 입력 받기

#include <stdio.h>
int main() {
  FILE *fp = fopen("a.txt", "r");
  char buf[20];  // 내용을 입력받을 곳
  if (fp == NULL) {
    printf("READ ERROR !! \n");
    return 0;
  }
  fgets(buf, 20, fp);
  printf("입력받는 내용 : %s \n", buf);
  fclose(fp);
  return 0;
}

실행 결과

Hello World!!

위 예제를 살펴보자

FILE *fp = fopen("a.txt", "r");

일단 이번에는 "w" 가 아니라 "r" 형으로 열었다. 이번에는 읽기 형식으로 열게 된다.

if (fp == NULL) {
  printf("READ ERROR !! \n");
  return 0;
}

이번 예제 또한 fpNULL 인지 아닌자 확인하는데, 특히 읽기 형식으로 파일을 열 때에는 더욱 주의해야 할 부분이다.왜냐하면 쓰기 형식으로 파일을 열때 만약에 파일이 없으면 새롭게 파일을 만들지만 읽기 형식은 읽을 파일이 없다면 NULL 를 리턴하고 스트림을 생성하지 않기 때문이다.

fgets

fgets(buf, 20, fp);

이제 fgets 함수를 통해 파일로 부터 문자열을 입력 받는다. 첫번째 인자로 어디에 입력 받을 것인지, 두번째 인자로 입력받을 바이트 수를, 세번째 인자로 어떤 스트림을 통해 입력 받을 것인지 명시해주면 된다.

우리의 경우 buf 라는 공간에 20 바이트를 입력 받을 것이다. fgets 의 좋은 점은 입력받을 바이트 수를 제한할 수 있다는 것이다. scanf 의 경우 문자열을 입력받을 때 따로 제한이 없기 때문에 할당된 메모리 크기를 넘어버리게 되면 오버플로우 (예를 들어 char str[20]; 을 했는데 100글자를 입력 받는다던지 ) 가 되는 경우가 있었지만 fgets 의 경우 이러한 일들을 방지 할 수 있으므로 상당히 안정적이다고 볼 수 있다.

printf("입력받는 내용 : %s \n", buf);

이렇게 입력받은 문자열을 printf 로 출력하면 된다.

한 글자 씩 입력 받기

#include <stdio.h>

int main() {
  FILE *fp = fopen("a.txt", "r");
  char c;

  while ((c = fgetc(fp)) != EOF) {
    printf("%c", c);
  }

  fclose(fp);
  return 0;
}

실행 결과

Hello World!!

EOF

여기서 주목할 부분은 다음과 같다.

while ((c = fgetc(fp)) != EOF) {
  printf("%c", c);
}

fgetcfp 에서 문자 하나를 얻어온다. 즉, 한 문자씩 읽어들이는 것이다. (따로 i ++ 와 같이 참고 하는 지점을 바꿀 필요 없이 반복될 때 마다 자동으로 위치를 이동하여 다음 문자를 읽어온다 자세한 내용은 뒤에)

이 때 문자열 맨 마지막이 NULL 문자로 종료를 나타내는 것 처럼, 파일의 맨 마지막에는 EOF 라고 End Of File 을 나타내는 값인 -1 이 들어 있다. 실제로 EOF 의 원형을 찾아보아도

#define EOF (-1)

-1 이 선언 되어 있다. 따라서 우리는 cEOF 인지 아닌지를 비교함으로써 파일의 끝까지 입력되었는지 확인할 수 있는 것이다. 이와 같은 방식을 활용하여 파일의 크기를 알아내는 프로그램을 다음과 같이 만들 수 있다.

/* 파일 크기 알아내는 프로그램 */
#include <stdio.h>

int main() {
  FILE *fp = fopen("a.txt", "r");
  int size = 0;

  while (fgetc(fp) != EOF) {
    size++;
  }

  printf("이 파일의 크기는 : %d bytes \n", size);
  fclose(fp);
  return 0;
}

실행 결과

이 파일의 크기는 : 14 bytes

원리는 이전 예제와 동일하다. EOF 가 나올때 까지 size 를 증가시켜 파일의 크기를 알아내는 것이다.

파일 위치 지정자

여태까지 파일을 입력하면 처음부분부터 끝 부분까지 입력을 쭉 받아 나갔다. 즉, 이전에 입력 받은 부분은 다시 입력되지 않고 다음 부분이 입력되었다. 이것이 가능한 이유는 파일 위치 지정자(Position Indicator) 때문이다.

만일 a.txtqwertyu 가 들어가 있고 우리가 fgetc 를 통해 입력을 받는다고 하면 파일을 맨 처음 열었을 때 에는 파일 위치 지정자는 파일의 맨 첫 부분을 가리키고 있다. 따라서 q 를 가리키고 있다고 보아도 무방하다. 이제 우리가 fgetc 로 입력을 받는다면 파일 위치 지정자는 한 칸 넘어서 다음에 입력 받을 것을 가리키고 있게 된다. 따라서 fgetc 를 한 번 더하면 q 를 다시 입력하는 것이 아니라 그 다음인 w 를 입력 받게 되는 것이다. 그리고 파일 위치 지정자는 또 한 칸 이동하여 그 다음인 e 를 가리키고 있을 것이다.

그런데 만일 우리가 qwer 까지 파일에서 입력 받았는데 다시 처음부터 입력을 받고 싶다면 어떻게 할까? 여기서 방법이 두 가지가 있는데 첫번째는 다시 fopen 을 통해서 파일을 다른 스트림으로 또 여는 것이고, 또 다른 방법은 파일 위치 지정자를 맨 앞으로 옮기는 것이다. 여기서 후자를 해보자.

fseek

#include <stdio.h>
int main() {
  /* 현재 fp 에 qwertyu 가 들어있는 상태*/
  FILE *fp = fopen("a.txt", "r");
  fgetc(fp);
  fgetc(fp);
  fgetc(fp);
  fgetc(fp);
  /* r 까지 입력받았으니 파일 위치지정자는 이제 t 를 가리키고 있다 */
  fseek(fp, 0, SEEK_SET);
  printf("다시 파일 처음에서 입력 받는다면 : %c \n", fgetc(fp));
  fclose(fp);
  return 0;
}

실행 결과

다시 파일 처음에서 입력 받는다면 : q

위와 같이 다시 q 가 나오는 것을 확인할 수 있다.

fgetc(fp);
fgetc(fp);
fgetc(fp);
fgetc(fp);

일단 a.txt 에는 qwertyu 가 들어가 있다고 하자. 그러면 위 문장을 통해 차례대로 q,w,e,r 를 입력받고 (물론 저장은 하지 않았지만) 파일 위치 지정자는 t 를 가리키게 된다. 그런데,

fseek(fp, 0, SEEK_SET);

를 통해 파일 위치 지정자를 다시 맨 처음을 가키게 하였다.

fseek 함수는 fp 를 세번째 인자로 부터 두번째 인자만큼 떨어진 곳으로 파일 위치 지정자를 돌리는데, 위의 경우 SEEK_SET 으로 부터 0 만큼 떨어진 곳, 즉 SEEK_SET 으로 돌린다.

SEEK_SET 는 파일의 맨 처음을 나타내는 매크로 상수이다. 따라서 위 함수를 통해 fp 의 파일 위치 지정자는 맨 처음으로 돌아가서 다시 fgetc 를 하였을 때 q 를 입력받게 된다. 참고로 SEEK_SET 말고도 현재 위치를 표시하는 SEEK_CURSEEK_END 상수들이 있다.

/* 출력 스트림도 마찬가지*/
#include <stdio.h>
int main() {
    FILE *fp;
    fp = fopen("/Users/****/Desktop/HellowWorld/HellowWorld/a.txt", "w");
    fputs("He!!!", fp);
    fseek(fp,0,SEEK_SET);
    fputs("M", fp);
    fclose(fp);
    return 0;
}

이렇게 출력 스트림도 파일 위치 지정자를 맨 처음으로 돌려 다시 fputs 를 하면 이전에 내용이 덮여쓰여진다.


파일 위치 지정자에 대해서 다시 한 번 집고 넘어가보겠다. 스트림의 기본적인 모터는 "순차적으로 입력 받는다" 는 것이다. 즉, 뒤에서 부터 읽거나 중간에 다른 곳으로 가서 읽는 등의 비정상적인 흐름을 따르지 않고 순차적으로 앞에서부터 쭈르륵 읽는 다는 것이다. 이를 가능하게 해주는 것이 바로 파일 위치 지시자(지정자)이다.

#include <stdio.h>
int main(){
    FILE *fp = fopen("/Users/****/Desktop/HellowWorld/HellowWorld/some_data.txt", "r");
    char c;

    if (fp == NULL){
        printf("ERROR\n");
        return 0;
    }

    while ((c = fgetc(fp)) != EOF){
        printf("%c",c);
    }
    printf("\n");
    fclose(fp);

    return 0;
}

실행 결과

There is some data in this FILE!!!!

아래는 some_data.txt 에 들어있는 내용이다.


앞서 a.txt 에 있던 내용과 똑같이 출력된다. 여기서 우리가 살펴볼 부분은 바로 fgetc(fp) 를 실행할 때 마다 그 다음 문자를 입력 받는 다는 점이다. 컴퓨터는 어떻게 어디까지 읽어드렸는지 알고 다음 입력할 부분을 가리킬 수 있는 것일까? 그 이유는 간단하다. 다음에 입력받을 부분을 미리 표시해두면 된다. 이렇게 다음에 입력 받아야할 위치를 기억해 놓는 역할을 파일 위치 지정자가 한다. 파일 위치 지정자는 파일에서 다음에 입력 받을 부분의 위치를 가리키고 있다.

예를 들어서 fgetc 를 세번 호출 했다고 해보자, 그러면 파일 위치 지정자는 다음과 같은 위치를 가리키고 있을 것이다.

생각보다 간단하다. fgetc 가 호출되기 전에는 T 를 가리키고 있다가 한 번 호출하면 H 를 가리키고 두 번 호출하면 E 를 가리키게 된다. 따라서 다음번 fgetc 호출에선 E 를 읽어들이고 파일 위치 지정자는 한 칸 옆으로 이동하게 된다.

이렇게 파일 위치 지정자가 다음으로 한 칸씩 이동하기 때문에 데이터를 순차적으로 읽어들일 수 있게 되는 것이다. 하지만 놀랍게도 C 언어에서는 파일 위치 지정자의 위치를 사용자가 원하는대로 바꿀 수 있게 해주는 여러 함수가 존재한다. 그 중 대표적으로 많이 사용하는 함수가 fseek 함수이다.

fseek

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

기본적인 형태는 위와 같다.

stream 에는 우리가 어떤 스트림의 파일 위치 지정자를 옮길지 그 스트림의 포인터를, offset 은 얼마나 옮길지, orgin 은 어디에서 부터 옮길지에 대한 정보가 들어가게 된다. origin 에는 SEEK_SET , SEEK_CUR, SEEK_END 들이 있는데 각각 파일의 시작, 현재 파일 위치 지정자의 위치, 파일의 끝을 의미한다. offset 에는 origin 으로 부터 얼마나 옮길지 숫자를 적어주면 되는데 + 값을 쓰면 오른쪽, - 값을 쓰면 왼쪽으로 파일 위치 지정자가 이동한다.

예제 1

#include <stdio.h>
int main() {
  FILE *fp = fopen("some_data.txt", "r");
  char data[10];
  char c;

  if (fp == NULL) {
    printf("file open error ! \n");
    return 0;
  }

  fgets(data, 5, fp);
  printf("입력 받은 데이터 : %s \n", data);

  c = fgetc(fp);
  printf("그 다음에 입력 받은 문자 : %c \n", c);

  fseek(fp, -1, SEEK_CUR);

  c = fgetc(fp);
  printf("그렇다면 무슨 문자가? : %c \n", c);

  fclose(fp);
}

실행 결과

입력 받은 데이터 : Ther
그 다음에 입력 받은 문자 : e
그렇다면 무슨 문자가? : e

some_data.txt 에는 앞에서와 마찬가지로 There is some data in this FILE!!!! 이 들어가 있다.

소스 코드를 살펴보면

fgets(data, 5, fp);

fgets 함수를 통해서 fp 로 부터 입력 받는다. 이 때, 문자열 형태로 입력 받는데 \n 이 나올 때 까지 입력을 받거나 (두 번째 인자의 크기 - 1) 만큼 입력을 받을 때 까지 입력을 받게 된다. 위의 경우는 \n 가 나오기 이전에 4 바이트 까지 입력을 받아서 data 에는 "Ther" 라는 내용의 문자열이 들어가게 된다. 참고로 왜 1 을 뺀 만큼 입력하냐면 data 에 문자열을 구성하기 위해서 NULL 을 위한 공간이 필요하기 때문이다.
아무튼 이렇게 입력이 끝난 뒤에 파일 위치 지정자는 e 를 가리키게 된다.

c = fgetc(fp);
printf("그 다음에 입력 받은 문자 : %c \n", c);

그 다음 fgetc 를 한 번 더 호출하여 c 에는 문자 e 가 들어가게 되고 파일 위치 지정자는 한 칸 더 이동하여 " " 인 띄어쓰기를 가리키게 된다. 띄어쓰기도 엄연히 문자이다. 즉, 아스키코드 값이 당연히 대응되어 있다.

fseek(fp, -1, SEEK_CUR);

드디어 fseek 함수를 사용하였다. SEEK_CUR 은 현재 파일 위치 지정자의 위치를 나타내고, -1 에 의미는 왼쪽으로 한 칸을 나타낸다. 현재 파일 위치 지정자는 " " 공백을 가리키고 있고 왼쪽으로 한 칸 이동하면 파일 위치 지정자는 이전에 가리켰던 e 를 가리키게 된다. 따라서 다시

c = fgetc(fp);
printf("그렇다면 무슨 문자가? : %c \n", c);

를 호출하여 문자를 입력 받으면 e 가 출력되는 것이다.

예제 2

#include <stdio.h>

int main() {
  FILE *fp = fopen("some_data.txt", "r");
  char data[10];
  char c;

  if (fp == NULL) {
    printf("file open error ! \n");
    return 0;
  }

  fseek(fp, -1, SEEK_END);
  c = fgetc(fp);
  printf("파일 마지막 문자 : %c \n", c);

  fclose(fp);
}

실행 결과

파일 마지막 문자 : !

앞선 예제와 비슷하지만 이번에는 SEEK_END 를 통해 맨 마지막을 가리키고 거기에 -1 를 하여 파일 위치 지정자가 마지막 문자로 부터 한 칸 왼쪽으로 이동하였다. 왜 왼쪽으로 한 칸 이동하냐면 맨 마지막 부분은 EOF 을 나타내는 것이 들어 있기 때문이다. 우리가 원하는 결과가 아니다. 따라서 우리가 파일에 입력한 맨 마지막 문자는 EOF 로 부터 한 칸 왼쪽에 있는 ! 이다.

파일에 쓰기, 읽기 같이하기

여태까지 우리는 안타깝게 하나의 파일에 읽기 또는 쓰기를 한번에 하나씩 밖에 못했었다. 그러나 다행스럽게도 fopen 에는 하나의 파일에 읽고 쓰기를 모두 할 수 있는 방법이 존재한다.

/* fopen 의 "r+" 인자 이용해보기 */
#include <stdio.h>
int main() {
  FILE *fp = fopen("some_data.txt", "r+");
  char data[100];

  fgets(data, 100, fp);
  printf("현재 파일에 있는 내용 : %s \n", data);

  fseek(fp, 5, SEEK_SET);

  fputs("is nothing on this file", fp);

  fclose(fp);
}

실행 결과

There is some data in this FILE!!!!

와 같이 잘 나온다. 그리고 수정된 some_data 에는 다음과 같이 나타난다.

소스 코드를 살펴보자.

FILE *fp = fopen("some_data.txt", "r+");

먼저 이와 같이 하였는데 r+ 라고 하였다. 이 것 말고도 w+ 도 있는데 두 가지 의미 모두 "파일을 읽기 및 쓰기 형식으로 열겠다" 라는 의미를 가지고 있다. 그러나 차이점이 있는데 이는 다음과 같다.

  • r+ : 파일이 존재하지 않으면 열지 않겠다. 만일 파일이 존재한다면 파일의 내용을 지우지 않는다.
  • w+ : 파일이 존재하지 않으면 파일을 새로 만들고 만일 존재한다면 파일의 내용을 싹 지워버린다.
fgets(data, 100, fp);
printf("현재 파일에 있는 내용 : %s \n", data);

를 통해 파일의 내용을 모두 읽어 들였다. (정확히 말하자면 100 바이트 까지 읽어들였는데 파일의 크기가 100 바이트 보다 작으므로 모두 읽어들였다고 보면 된다.) 그리고 이와 함께 파일 위치 지정자는 파일의 맨 끝을 가리키고 있을 것이다.

fseek(fp, 5, SEEK_SET);

그리고 위와 같이 fseek 함수를 이용하여 파일의 맨 앞에서 5 칸 떨어진 곳으로 이동해보자. 0 칸 떨어져 있을 때 T이고 한 칸 떨어져 있을 때 h 이므로 5 칸 떨어져 있을 때 ' '인 것을 알 수 있다(공백 문자가 위치한 곳). 그리고 이제 여기에

fputs("is nothing on this file", fp);

를 하게 되면 파일 위치 지정자가 가리키고 있던 ' '에서 부터 위 문자열이 덮여씌여진다. 따라서 위 사진과 같이 나타나게 된다.

대소문자 바꾸는 예제

#include <stdio.h>
int main() {
  FILE *fp = fopen("some_data.txt", "r+");
  char c;

  if (fp == NULL) {
    printf("파일 열기를 실패하였습니다! \n");
    return 0;
  }

  while ((c = fgetc(fp)) != EOF) {
    /* c 가 대문자일 경우 */
    if (65 <= c && c <= 90) {
      /* 한 칸 뒤로 가서*/
      fseek(fp, -1, SEEK_CUR);
      /* 소문자로 바뀐 c 를 출력한다*/
      fputc(c + 32, fp);

    }
    /* c 가 소문자일 경우*/
    else if (97 <= c && c <= 122) {
      fseek(fp, -1, SEEK_CUR);
      fputc(c - 32, fp);
    }
  }

  fclose(fp);
}


위 그림을 보면 위 소스 코드를 이해하는데 큰 무리가 없을 것이다. 그러나 위 코드에는 문제가 있다. 위와 같이 파일을 읽기 및 쓰기 형식으로(+r or +w) 열었다면 읽기/쓰기 변환시에는 fflush 함수를 호출하거나 fseek 이나 rewind 함수를 호출하여 파일 위치 지정자를 다시 설정해줘야 한다. 따라서 위와 같이 쓰기 작업 후에 읽기 작업을 할 때, fflushfseek 함수를 호출해야 한다.

이를 토대로 수정하면 다음과 같다.

#include <stdio.h>

int main() {
  FILE *fp = fopen("some_data.txt", "r+");
  char c;

  if (fp == NULL) {
    printf("파일 열기를 실패하였습니다! \n");
    return 0;
  }

  while ((c = fgetc(fp)) != EOF) {
    /* c 가 대문자일 경우 */
    if (65 <= c && c <= 90) {
      /* 한 칸 뒤로 가서*/
      fseek(fp, -1, SEEK_CUR);
      /* 소문자로 바뀐 c 를 출력한다*/
      fputc(c + 32, fp);
      /*

      쓰기 - 읽기 모드 전환을 위해서는 무조건
      fseek 함수와 같은 파일 위치 지정자 설정 함수들을
      호출해야 한다.

      */
      fseek(fp, 0, SEEK_CUR);
    }
    /* c 가 소문자일 경우*/
    else if (97 <= c && c <= 122) {
      fseek(fp, -1, SEEK_CUR);
      fputc(c - 32, fp);
      fseek(fp, 0, SEEK_CUR);
    }
  }

  fclose(fp);
}

위에서 아래와 같이

/*

쓰기 - 읽기 모드 전환을 위해서는 무조건
fseek 함수와 같은 파일 위치 지정자 설정 함수들을
호출해야 한다.

*/
fseek(fp, 0, SEEK_CUR);

파일 위치 지정자의 위치를 옮길 필요가 없음에도 불구하고 fseek 함수를 통해 파일 위치 지정자를 설정해주었다. 사실 위와 같이 fseek 함수를 호출하면 파일 위치 지정자는 하나도 옮겨지지 않지만 단순히 쓰기 작업에서 읽기 작업으로 바꾸기 위해서 fseek 함수를 호출한 것이다.

만약 fseek 함수를 사용하기 싫다면

/* 한 칸 뒤로 가서*/
fseek(fp, -1, SEEK_CUR);
/* 소문자로 바뀐 c 를 출력한다*/
fputc(c + 32, fp);

fflush(fp);

이와 같이 해도 된다.

fopen 함수의 기타 인자 사용

/* fopen 의 'append' 기능 사용*/
#include <stdio.h>
int main() {
  FILE *fp = fopen("some_data.txt", "a");
  char c;
  if (fp == NULL) {
    printf("파일 열기를 실패하였습니다! \n");
    return 0;
  }
  /* 아래 내용이 파일 뒤에 덧붙여진다.*/
  fputs("IS ADDED HAHAHAHA", fp);
  fclose(fp);
}


위 파일이 아래와 같이 바뀌었다.

FILE *fp = fopen("some_data.txt", "a");

위 부분을 살펴보면 파일을 "a" 형식으로 열었다. 이것의 의미는 파일을 덧붙이기 형식(append)으로 연다는 의미이다. 기존의 "w" 로 열었을 때는 파일의 내용이 모두 지워지는 대신에 맨 앞 부터 내용이 쓰여졌는데 덧붙이기 형식은 파일의 맨 끝부분 부터 내용이 쓰여지고 앞에 내용은 변화가 없다. 즉, 이전에 내용은 잘 보호가 된다.

r+, w+ 와 마찬가지로 a+ 형식도 있는데 이도 마찬가지로 읽기/덧붙이기 를 번갈아가며 사용할 때 사용한다. 참고로 읽은 작업은 파일 위치 지정자로 어디에서부터든 읽을 수 있지만 덧붙이기 작업은 파일 위치 지정자를 이동시켜도 언제나 파일의 맨 끝 부분부터 쓰여진다.

fscanf 사용하기

#include <stdio.h>

int main() {
  FILE *fp = fopen("some_data.txt", "r");
  char data[100];

  if (fp == NULL) {
    printf("파일 열기 오류! \n");
    return 0;
  }

  printf("---- 입력 받은 단어들 ---- \n");

  while (fscanf(fp, "%s", data) != EOF) {
    printf("%s \n", data);
  }

  fclose(fp);
}

실행 결과

---- 입력 받은 단어들 ----
There
is
some
data
in
this
FILE!!!!

fscanf 는 우리가 여태까지 사용해 왔던 scanf 와 아주 유사한데, scanfstdin 에서만 입력을 받고 fscanf 는 임의의 스트림에서도 입력받을 수 있는 좀 더 일반화된 함수라고 보면된다.

fscanf 함수는 첫번째 인자로 입력 받을 스트림을 써준다. 따라서

fscanf(stdin, %s, data);
scanf("%s",date);

위 두 문장은 정확히 동일한 문장이다. 아무튼 fscanf 는 사용자가 지정한 형식에 맞게 데이터를 읽어오는데 fgets 와 달리 띄어쓰기와 탭 문자를 모두 인식하므로 각각의 단어들을 읽어오는데 유용하게 사용할 수 있다.

while (fscanf(fp, "%s", data) != EOF) {
  printf("%s \n", data);
}

위 문장을 살펴보면 fscanffp 로 부터 문자열을 얻어오는데 fgets\n 이 나올 때 까지 하나의 문자열로 보는 반면에 fscanf\n, 띄어쓰기, 탭 문자(\t) 중 어느 하나라도 나올 때 까지 입력을 받으므로 각 단어들을 하나씩 읽어들이게 된다. 물론 읽어 들인 만큼 파일 위치 지정자가 이동하게 된다. 이 때, fscanf 가 데이터를 더 이상 입력 받을 수 없게 되면 EOF 를 리턴하게 된다. 즉, 파일에 끝에 도달하면 EOF 를 리턴하여 while 문을 빠져나가게된다.

this 를 that 으로 바꾸기 예제

/* 파일에서 'this' 를 'that' 으로 바꾸기*/
#include <stdio.h>
#include <string.h>

int main() {
  FILE *fp = fopen("some_data.txt", "r+");
  char data[100];

  if (fp == NULL) {
    printf("파일 열기 오류! \n");
    return 0;
  }

  while (fscanf(fp, "%s", data) != EOF) {
    if (strcmp(data, "this") == 0) {
      fseek(fp, -(long)strlen("this"), SEEK_CUR);
      fputs("that", fp);

      fflush(fp);
    }
  }

  fclose(fp);
}


위와 같던 파일이 아래와 같아졌다.

this 가 전부 that 으로 바뀌었다. 그 원리는 아주 간단하다.

while (fscanf(fp, "%s", data) != EOF) {
  if (strcmp(data, "this") == 0)

이전 예제와 마찬가지로 fscanf 를 통해 파일에서 단어를 입력 받고 strcmp 함수를 통해 "this" 와 같은지 비교한다. 만약 같다면 0을 리턴할 것이고 이제 "this"를 "that" 으로 덮여 씌여주면 된다.

fseek(fp, -(long)strlen("this"), SEEK_CUR);
fputs("that", fp);

fscanf 에서 "this" 를 입력 받은 시점에서 파일 위치 지정자는 "this" 다음 문자를 지정하고 있을 것이므로 fseek 함수를 통해 "this"길이만큼 왼쪽으로 이동하면 파일 위치 지정자는 "this"에서 "t"를 가리키고 있을 것이다. 이제 이 상태에서 fputs 함수를 통해 "that" 을 덮여씌기만 하면 된다.

위 작업이 끝나면

fflush(fp);

다시 while 문에서 읽기 작업을 하므로 fflush 함수를 사용해줘야 한다. 물론 fseek 을 사용해도 된다.

fprintf

이 함수는 printf 와 비슷하게 생겼는데 printf 가 콘솔 화면에(정확하게 말하자면 stdout 에) 출력했던 반면에 fprintf 는 사용자가 지정한 스트림에 출력하게 된다. 따라서

fprintf(stdout, "Hello, World! \n");
printf("Hello, World! \n");

위 두 문장은 정확히 동일하다.

위를 적용해서 실제로 사용해보면

FILE *fp = fopen("/Users/choewonjun/Documents/GitHub/C_TIL/C_learning/C_learning/Library_system/list.txt", "w");
if (fp == NULL) {
    printf("fp ERROR\n");
    return 0;
}
fprintf(fp, "책 번호 / 책 이름 / 저자 / 출판사 / 대출가능\n");

를 하면

이렇게 할 수 있다 또한 printf 와 마찬가지로 fprintf("%s",var); 이런식으로 문자열을 넣어서 입력할 수도 있다. 그렇다면 지금까지 배운 내용을 가지고 이전에 만들었던 도서 관리 프로그램을 업그레이드 해보자.

profile
벨로그보단 티스토리를 사용합니다! https://flight-developer-stroy.tistory.com/

0개의 댓글