[C언어 50강] 47강. 입력/실행 심화: EOF 처리, 버퍼링, argc/argv(인자 처리)

[C언어 50강] 47강. 입력/실행 심화: EOF 처리, 버퍼링, argc/argv(인자 처리)

프로그램이 "돌아간다"와 "현장에서 버틴다"의 차이는 입력과 실행 경계를 어떻게 다루느냐에서 갈립니다. 이번 강의는 EOF(End Of File), 표준 입출력 버퍼링, argc/argv 인자 처리를 하나의 흐름으로 묶어 이해합니다. 핵심은 문법이 아니라 프로그램이 데이터를 받는 입구를 설계하는 관점입니다.


핵심 개념

  • EOF는 파일의 끝만 의미하는 것이 아니라, "더 이상 읽을 수 없는 상태"를 표현하는 입력 종료 신호다.
  • 버퍼링은 성능 최적화 기술이면서 동시에 출력 시점/디버깅 체감에 직접 영향을 주는 동작 계약이다.
  • argc/argv는 실행 시점 설정값을 코드 밖에서 주입하는 가장 기본적인 인터페이스다.
  • 견고한 CLI 프로그램은 "인자 파싱 → 입력 처리 → 오류 코드 반환"을 분리해 설계한다.

개념 먼저 이해하기

초보 단계에서는 입력을 scanf 한두 번으로 받고, 출력은 printf로 찍으면 끝이라고 생각하기 쉽습니다. 그런데 실제 프로그램은 항상 불완전한 환경에서 실행됩니다. 터미널에서 직접 실행할 수도 있고, 파일 리다이렉션으로 데이터를 밀어 넣을 수도 있고, 다른 프로그램의 파이프 출력이 입력으로 들어올 수도 있습니다. 즉 입력은 사람만 넣는 게 아닙니다. 이 지점에서 EOF, 버퍼링, 실행 인자 개념이 한 번에 중요해집니다.

먼저 EOF를 "파일 끝 문자"처럼 오해하면 문제가 생깁니다. EOF는 문자가 아니라 읽기 함수가 더 이상 유효한 바이트를 제공할 수 없을 때 반환하는 상태입니다. 키보드 입력에서는 운영체제가 EOF 신호를 전달할 때(Ctrl-D/Ctrl-Z) 나타나고, 파일에서는 실제 끝에서 나타납니다. 중요한 포인트는 "EOF인지, 형식 오류인지, 일시적 실패인지"를 구분해야 한다는 점입니다. 예를 들어 scanf가 0을 반환한 경우는 EOF가 아니라 "읽기는 됐지만 원하는 형식 변환 실패"입니다. 여기서 이를 구분하지 않으면 무한 루프나 잘못된 재시도 로직이 생깁니다.

버퍼링은 더 자주 오해됩니다. 많은 사람이 "출력이 늦게 보인다 = printf가 느리다"라고 생각하지만, 실제로는 표준 라이브러리가 성능을 위해 출력을 모아서(버퍼에 저장했다가) 한 번에 내보내기 때문입니다. 터미널에 연결된 stdout은 보통 줄 단위 버퍼링(개행 시 flush), 파일로 리다이렉트되면 완전 버퍼링(버퍼가 찰 때 flush)으로 동작합니다. 그래서 같은 코드라도 실행 환경에 따라 로그가 즉시 보이기도 하고 늦게 보이기도 합니다. 디버깅 시점에는 이 차이가 치명적일 수 있습니다. 오류 메시지를 stderr로 분리하는 이유도 여기에 있습니다. stderr는 보통 비버퍼링 또는 최소 버퍼링이라 즉시 확인이 쉽습니다.

argc/argv는 단순히 "main 인자"가 아니라, 프로그램을 재사용 가능한 도구로 만드는 핵심 메커니즘입니다. 코드를 수정하지 않고 실행 옵션을 바꿀 수 있고, 쉘 스크립트/자동화 파이프라인에서 조합하기 쉬워집니다. 실무에서 중요한 건 파싱 성공 여부, 옵션 누락, 범위 오류를 분리해 명확한 메시지와 종료 코드로 돌려주는 것입니다. "사용법(usage) 출력 + 비정상 종료 코드"를 일관되게 제공하면 운영 자동화 환경에서 실패 원인을 바로 분기할 수 있습니다.

결국 이 세 주제는 따로 노는 지식이 아닙니다. 입력 채널(파일/표준입력/인자)의 특성 차이를 이해하고, 실패를 구조화해 반환하는 능력으로 이어집니다. C언어는 안전장치를 자동으로 제공하지 않기 때문에, 개발자가 입력 경계를 설계하지 않으면 프로그램은 쉽게 깨집니다. 반대로 이 경계를 잘 설계하면, 적은 코드로도 매우 강한 CLI 프로그램을 만들 수 있습니다.

기본 사용

예제 1) EOF를 기준으로 정수 스트림 누적 처리

#include <stdio.h>

int main(void) {
    long long sum = 0;
    int x;

    while (1) {
        int rc = scanf("%d", &x);
        if (rc == 1) {
            sum += x;
        } else if (rc == EOF) {
            break;  // 정상 입력 종료
        } else {
            // 형식 오류: 잘못된 토큰 하나 소비 후 계속
            int ch;
            while ((ch = getchar()) != '\n' && ch != EOF) {}
            fprintf(stderr, "warning: invalid token skipped\n");
        }
    }

    printf("sum=%lld\n", sum);
    return 0;
}

설명:

  • scanf 반환값으로 성공(1), EOF, 형식 오류(0)를 분리 처리합니다.
  • 형식 오류에서 입력을 소비하지 않으면 같은 토큰을 계속 읽어 무한 루프가 납니다.
  • 경고를 stderr로 출력해 정상 결과(stdout)와 분리합니다.

예제 2) 버퍼링 제어와 즉시 로그 출력

#include <stdio.h>
#include <unistd.h>

int main(void) {
    // stdout을 줄 버퍼링으로 강제 (환경 차이 완화)
    setvbuf(stdout, NULL, _IOLBF, 0);

    for (int i = 1; i <= 5; ++i) {
        printf("progress: %d/5\n", i);
        fflush(stdout); // 중요한 진행 로그는 즉시 반영
        sleep(1);
    }

    fprintf(stderr, "done with stderr notice\n");
    return 0;
}

설명:

  • 리다이렉션 환경에서 로그 지연이 불편하면 fflush(stdout)로 시점을 명시할 수 있습니다.
  • 모든 출력마다 flush를 남발하면 성능 저하가 있으므로 "중요 지점만" 적용합니다.
  • 상태 로그는 stdout, 오류/경고는 stderr로 채널 분리하는 습관이 유지보수에 유리합니다.

예제 3) argc/argv 기반 파일 단어 수 세기 도구

#include <stdio.h>
#include <ctype.h>
#include <stdlib.h>

int count_words(FILE *fp) {
    int c, in_word = 0, words = 0;
    while ((c = fgetc(fp)) != EOF) {
        if (isspace((unsigned char)c)) {
            in_word = 0;
        } else if (!in_word) {
            in_word = 1;
            words++;
        }
    }
    return words;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s <file>\n", argv[0]);
        return 2;
    }

    FILE *fp = fopen(argv[1], "r");
    if (!fp) {
        perror("fopen");
        return 1;
    }

    int words = count_words(fp);
    if (ferror(fp)) {
        perror("read error");
        fclose(fp);
        return 3;
    }

    fclose(fp);
    printf("words=%d\n", words);
    return 0;
}

설명:

  • argc로 인자 개수를 먼저 검증해 잘못된 실행을 초기에 차단합니다.
  • 파일 처리 후 ferror를 확인해 EOF 정상 종료와 읽기 오류를 구분합니다.
  • 종료 코드를 다르게 반환하면 스크립트 자동화에서 실패 원인 분기 처리가 쉬워집니다.

자주 하는 실수

실수 1) while (!feof(fp)) 패턴으로 읽기 루프 작성

  • 원인: EOF를 "미리 확인 가능한 상태"로 오해함.
  • 해결: 읽기 함수(fgetc, fgets, fscanf)의 반환값으로 루프를 제어한다.

실수 2) scanf 실패 시 입력 소비 없이 그대로 재시도

  • 원인: 반환값 0(형식 실패)의 의미를 정확히 이해하지 못함.
  • 해결: 실패 토큰을 버퍼에서 제거하고 다음 입력으로 진행한다.

실수 3) 진행 로그를 stdout에만 찍고 버퍼링 지연을 버그로 착각

  • 원인: 터미널/파일 리다이렉션의 버퍼링 정책 차이를 모름.
  • 해결: 중요한 로그는 stderr 또는 명시적 fflush로 시점을 통제한다.

실수 4) 인자 검증 없이 argv[1] 접근

  • 원인: 테스트 시 항상 인자를 넣어 실행해서 문제를 놓침.
  • 해결: argc 검사 후 usage 출력, 비정상 종료 코드 반환을 표준화한다.

실무 패턴

  • main을 세 단계로 분리: parse_argsrunreport_error.
  • 표준입력 처리 프로그램은 EOF 종료를 정상 시나리오로 문서화합니다.
  • 출력 채널 규칙을 명확히 둡니다: 결과는 stdout, 진단은 stderr.
  • 종료 코드를 의미 있게 설계합니다(예: 1=IO 실패, 2=인자 오류, 3=입력 형식 오류).
  • 파이프라인 친화성을 높이기 위해 불필요한 대화형 프롬프트를 기본 모드에서 제거합니다.

오늘의 결론

한 줄 요약: EOF·버퍼링·실행 인자를 따로 외우지 말고, "입력 경계와 실패 시나리오를 제어하는 하나의 설계"로 묶어서 다뤄야 C 프로그램이 실전에서 버틴다.

연습문제

  1. 표준입력에서 실수를 읽어 평균을 계산하는 프로그램을 작성하세요. 형식 오류 토큰은 건너뛰고, EOF에서 정상 종료해야 합니다.
  2. -n <count> -v 옵션을 받는 CLI를 만들고(argc/argv), 잘못된 옵션 조합에서 usage와 종료 코드(2)를 반환하세요.
  3. 긴 처리 작업(반복 10회)을 시뮬레이션하고, 리다이렉션 환경에서도 진행률이 바로 보이도록 버퍼링 제어를 적용해보세요.

이전 강의 정답

46강(표준 라이브러리 활용) 연습문제 핵심:

  • parse_u32strtoul + endptr + 범위 검사(<= UINT32_MAX) 조합으로 구현하고, 실패 원인을 "숫자 없음/쓰레기 문자/범위 초과"로 분리하는 것이 핵심입니다.
  • 문자 분류기는 isalpha/isdigit/isspace를 쓰되, 인자를 (unsigned char)로 캐스팅해야 플랫폼/로케일 차이에서 UB를 피할 수 있습니다.
  • 시간 계산은 time_t now = time(NULL);now + 24*60*60처럼 내부 계산을 하고, 출력 직전에 localtime_r(s) + strftime으로 문자열화하는 구조가 안정적입니다.

실습 환경/재현 정보

  • 컴파일러: Apple clang 17+ 또는 gcc 12+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -Werror -O0
  • 실행 환경: macOS(arm64), Linux(x86_64)
  • 재현 체크:
    • 예제 1: echo "1 2 x 3" | ./a.out 실행 시 경고 후 합계 6 확인
    • 예제 2: 터미널 실행/파일 리다이렉션 실행에서 로그 노출 시점 차이 확인
    • 예제 3: 인자 누락 시 usage와 종료코드 2, 존재하지 않는 파일 시 perror와 종료코드 1 확인