모두의 코드
씹어먹는 C 언어 - <23 - 1. 파일 하고 이야기 하기 (파일 입출력에 대한 기본적 이해)>

이번 강좌에서는

씹어먹는 C 언어

안녕하세요~ 여러분 드디어 38 번째 강좌 입니다! 제 목표로는 40 번째 강좌를 끝으로 마칠 예정인데 조금 더 길어 질지도 모르겠군요 :) 여러분이 여태까지 프로그램들을 만들면서 '데이터를 어떻게 하면 프로그램이 종료되어도 보관할 수 있을까?' 라는 생각을 많이 하셨을 것입니다. 사실 그 방법은 단순합니다. 특정한 데이터가 있으면 이를 하드디스크에 기록하면 해결되는 일이지요.

여태까지 만든 모든 프로그램에서 변수는 하드디스크가 아니라 언제나 RAM 에 상주하는 데이터 였습니다. 즉, 프로그램이 종료되어도 그렇지만 컴퓨터가 꺼지게 되면 데이터가 날아가게 되는 휘발성 메모리 이지요. (여러분이 배운 내용이 이렇지 않기를 바랍니다) 하지만 여러분의 컴퓨터에 깔려있는 대부분의 프로그램이나 문서들은 껐다 켜도 사라지지 않습니다. 왜냐하면 그 내용들이 비휘발성 저장매체 인 하드 디스크에 저장되어 있기 때문입니다.

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

파일에 출력하기

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

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

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

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

  fclose(fp);
  return 0;
}

성공적으로 컴파일 하였다면

와 같이 아무것도 나오지 않습니다. 왜냐하면 화면에 출력하는 문장이 아무것도 없거든요. 대신, 소스 파일이 위치한 곳으로 들어가봅시다. 저의 경우 다음과 같은 경로에 소스파일이 위치해있습니다.

C:\Users\Lee\Documents\Visual Studio 2008\Projects\teach\teach

찾으셨다면 아래 그림처럼 예쁘게 a.txt 라는 파일이 생성된 것을 볼 수 있습니다.

그렇다면 기대되는 마음으로 이 파일을 열어보겠습니다.

와우! 우리가 원하던 문자열 "Hello World!!!" 가 제대로 들어가 있는 것을 보실 수 있습니다. 이제, 다시 소스 코드를 살펴보도록 하죠.

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 하고 거의 동일하다고 보시면 됩니다. 단지, 오류 메세지를 출력하는 스트림 입니다)

이제 다시 맨 처음의 예제로 돌아가보도록 합시다.

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 character 라고 해서 이상한 문자가 되므로 \\ 를 두개 붙여 써서 \ 로 나타내야 합니다.

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

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

이 다음은 아주 중요한 부분인데, 파일이 어떠한 이유에서라든지 열지 못한 경우 fopen 함수는 NULL 을 리턴합니다. fopen 이 실패하는 경우는 그리 많지 않으므로 이 부분을 생략하는 경우가 가끔 있는데, 만일 fopen 이 실패하게 되었을 경우 이렇게 검사하지 않는다면 소스 뒷부분에서 어떠한 문제가 발생할지 모르므로 이렇게 항상 검사하는 것이 중요합니다.

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

이제 fputs 라는 훌륭한 함수로 파일에 기록할 수 있습니다. 첫번째 인자로 파일에 기록할 문자열을 전달하고 두번째 인자로 어떠한 스트림을 택할지 그 포인터를 써주면 됩니다. 우리는 우리가 위에서 열은 파일 스트림을 택할 것이므로 fp 를 써주면 됩니다. 재미있는 사실은 표준 스트림들은 이미 이름이 정해져 있는데 앞서 말했듯이 stdout 은 컴퓨터의 모니터에 해당하는 표준 출력 스트림이라 했습니다. 즉, 두 번째 인자로 stdout 을 전달하면 우리 콘솔 화면에 그 문자열이 뜨게 되겠지요.

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

  을 해보면

  와 같이 실제로 잘 나오는 것을 알 수 있습니다. 아무튼

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

를 통해 파일에 "Hello World!!! \n" 을 기록하게 됩니다. 이제 마지막으로

fclose(fp);

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

재미있는 사실은 fclose 로 표준 스트림들도 닫아버릴 수 있는데 예를 들어

/* stdout 을 닫아버린다 */
#include <stdio.h>
int main() {
  fclose(stdout);
  printf("aaa");
  return 0;
}

으로 표준 출력 스트림을 닫아버리면

와 같이 printf 를 해도 아무것도 나오지 않는 재미있는 일이 발생합니다.

파일에서 입력 받기

/* fgets 로 a.txt 에서 내용을 입력 받는다. */

#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;
}

  성공적으로 컴파일 했다면

한 번 소스코드를 살펴봅시다.

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

이번에는 "w" 가 아니라 "r" 형으로 열었습니다. 이번에는 읽기 형식으로 파일을 열게됩니다.

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

이전 예제와 마찬가지로 fpNULL 인지 아닌지 확인하는데, 특히 읽기 형식으로 파일을 열 때 에는 더욱 주의해야 할 부분입니다. 왜냐하면 쓰기 형식으로 파일을 열었을 때 에는 파일이 존재하지 않는다면 새로 만들었지만 읽기 형식으로 열 때 에는 읽어들일 파일이 없다면 NULL 을 리턴하고 스트림을 만들지 않기 때문이지요.

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;
}

성공적으로 컴파일 하였다면

와 같이 나옵니다.

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

주목할 부분은 위 부분 입니다. fgetcfp 에서 문자 하나를 얻어옵니다. 즉, 한 문자씩 읽어들이는 것이지요. 이 때 문자열 맨 마지막이 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;
}

성공적으로 컴파일 했다면

와 같이 잘 나옵니다.

원리는 이전의 예제와 동일합니다. EOF 가 나오기 전 까지 계속 size 를 증가시켜서 파일의 크기를 알아내는 것이지요.

파일 위치 지정자

여태까지 파일에서 입력을 받을 때 언제나 파일의 시작 부분에서 끝 부분으로 입력을 쭉 받아 나갔습니다. 즉, 이전에 입력 받았던 데이터는 다시 입력 받지 않았다는 것이지요. 이것이 가능하게 된 이유는 파일 위치 지정자 때문 입니다. 영어로 Position Indicator 라고 합니다.

만일 a.txtabcdefg 가 들어있고 우리가 fgetc 로 입력을 받는다고 해봅시다. 파일을 맨 처음 열었을 때 에는 파일 위치 지정자는 파일의 맨 첫부분을 가리키고 있습니다. 따라서 a 를 가리키고 있다고 보아도 무방합니다. 이제, 우리가 fgetc 로 입력을 받는다면 파일 위치지정자는 한 칸 넘어가서 다음에 입력 받을 것을 가리키고 있게 되지요. 따라서 fgetc 를 한 번 더하면 a 를 다시 입력 받는 것이 아니라 그 다음인 b 를 입력 받게 됩니다. 그리고 또 파일 위치지정자는 또 한 칸 이동해서 그 다음인 c 를 가리키고 있겠지요.

그런데 만일 여러분이 abcd 까지 파일에서 입력 받았는데 다시 처음 부터 입력받고 싶다면 어떻게 할까요? 일단 두 가지 방법이 있는데 하나는 fopen 으로 파일을 다른 스트림으로 또 여는 것이고, 또다른 방법은 파일 위치지정자를 맨 앞으로 옮기면 되겠지요. 여기서는 후자를 택하도록 합시다.

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

성공적으로 컴파일 하였다면

와 같이 a 가 다시 잘 나오는 것을 보실 수 있습니다.

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

일단 a.txt 에 원래 abcdef 가 들어있었다고 합시다. 그렇다면 위 문장을 통해 차례대로 a,b,c,d 를 입력받고 (물론 저장은 하지 않지만) 이제 파일 위치지정자는 e 를 가리키게 됩니다. 그런데,

fseek(fp, 0, SEEK_SET);

를 통해 파일 위치지정자를 맨 처음으로 돌려버릴 수 있었습니다. fseek 함수는 fp 를 세번째 인자로 부터 두번째 인자 만큼 떨어진 곳으로 파일 위치지정자를 돌리는데, 위 경우 SEEK_SET 으로 부터 0 번째 떨어진 곳, 즉 SEEK_SET 으로 돌린다고 볼 수 있습니다.

이 때 SEEK_SET 은 파일의 맨 처음을 일컫는 매크로 상수 입니다. 따라서 위 함수를 통해 fp 의 파일 위치지정자를 맨 처음으로 돌려서 다시 fgetc 를 하였을 때 a 를 입력받게 됩니다. 참고로, SEEK_SET 외에도, 현재의 위치를 표시하는 SEEK_CUR 과 파일의 맨 마지막을 표시하는 SEEK_END 상수들이 있습니다.

/* 출력 스트림도 마찬가지*/#include <stdio.h>
int main() {
  FILE *fp = fopen("a.txt", "w");
  fputs("Psi is an excellent C programmer", fp);
  fseek(fp, 0, SEEK_SET);
  fputs("is Psi", fp);
  fclose(fp);
  return 0;
}

성공적으로 컴파일 하였을 때, a.txt 의 모습을 보면

로 나타납니다. 사실 이번 예제도 상당히 쉬운데, 먼저 fputs

fputs("Psi is an excellent C programmer", fp);

Psi is an excellent C programmer 을 넣었고, 이 때 파일을 열어보았더라면 이와 같은 문장이 들어 있었을 것입니다. 그런데,

fseek(fp, 0, SEEK_SET);

로 파일 위치지정자를 맨 처음으로 돌려서 다시 fputs 를 했을 때, 파일 앞에 내용이 끼워져 들어가는 것이 아니라 이전의 내용에 덮어쓰기 하면서 기록이 되므로 맨 처음 ‘Psi is’ 를 ‘is Psi’ 로 내용을 바꿔버립니다. 따라서 결국에는 is Psi an excellent C programmer 라는 문장이 파일에 남아 있게 됩니다.

이번 강좌에서는 이렇게 대략적으로 파일 입출력을 어떻게 하는 것인지, 그리고 파일 위치지정자가 무엇인지 소개했습니다. 사실 파일 입출력의 백미는 다음 강좌에서 부터 시작이라 보시면 됩니다 :)

생각해보기

문제 1

사용자로 부터 경로를 입력 받아서 그 곳에 파일을 생성하고 a 를 입력해놓는 프로그램을 만들어보세요 (난이도 : 下)

문제 2

a.txt 에 어떠한 긴 글이 들어 있는데, 이 글을 입력 받아서 특정한 문자열을 검색하는 프로그램을 만들어보세요 (난이도 : 中)

문제 3

a.txt 에 문자열을 입력 받아서 b.txt 에 그 문자열을 역으로 출력하는 프로그램을 만들어보세요 (난이도 : 中下)

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는<<씹어먹는 C 언어 - <23 - 1. 파일 하고 이야기 하기 (파일 입출력에 대한 기본적 이해)>>> 입니다. 이번 강좌의모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요


 다음 강좌 보러가기
프로필 사진 없음
댓글에 글쓴이에게 큰 힘이 됩니다