[C언어 50강] 09강. 표준 입출력: scanf 포맷, 입력 버퍼 개념, 안전한 입력 습관

[C언어 50강] 09강. 표준 입출력: scanf 포맷, 입력 버퍼 개념, 안전한 입력 습관

표준 입출력은 C 입문에서 가장 빨리 배우지만, 실무에서 가장 오래 발목을 잡는 주제이기도 합니다. 특히 scanf는 문법이 단순해 보여도 버퍼 상태, 포맷 문자열, 반환값 검증까지 함께 다루지 않으면 버그가 누적되기 쉽습니다. 오늘은 입력 함수를 “외운다”가 아니라 “왜 안전하지 않은 코드가 생기는지”를 개념으로 이해해보겠습니다.


핵심 개념

  • scanf는 입력을 “읽는 함수”이기 전에 “포맷 규칙대로 파싱하는 함수”다. 즉 포맷과 실제 입력이 어긋나면 일부만 읽고 실패할 수 있다.
  • 입력 버퍼에는 사용자가 친 문자가 한꺼번에 들어오며, 함수는 필요한 만큼만 소비한다. 남은 문자는 다음 입력으로 넘어간다.
  • 안전한 입력의 기본은 3가지다: 길이 제한, 반환값 확인, 실패 시 버퍼 정리.

개념 먼저 이해하기

초보자가 scanf를 어렵게 느끼는 이유는 함수 자체가 복잡해서가 아니라, “입력 장치(키보드)”와 “프로그램이 실제로 받는 데이터(버퍼)”를 같은 것으로 생각하기 때문입니다. 사용자가 123<Enter>를 입력하면 프로그램은 숫자 123만 받는 게 아니라 1, 2, 3, \n이 버퍼에 들어옵니다. 여기서 scanf("%d", &x)는 정수 부분인 123을 읽고 변환한 뒤, 개행 문자는 그대로 남길 수 있습니다. 그리고 다음 입력에서 그 남은 문자가 예상치 못한 동작을 만들죠. 이게 “왜 방금 엔터만 쳤는데 입력이 끝났지?” 같은 현상의 핵심입니다.

또 하나 중요한 점은 scanf가 성공/실패를 매우 명확하게 반환값으로 알려준다는 사실입니다. 예를 들어 %d 하나를 읽는 호출은 성공 시 1을 반환합니다. 숫자가 아닌 문자를 만났다면 0을 반환하고, EOF 상황이면 EOF를 반환합니다. 그런데 많은 코드가 반환값을 확인하지 않고 “어차피 숫자를 입력하겠지”라고 가정합니다. 이 순간부터 프로그램 상태는 쉽게 망가집니다. 값이 갱신되지 않았는데 갱신된 줄 알고 계산하거나, 실패 입력이 버퍼에 남아 무한 반복이 발생할 수 있기 때문입니다.

포맷 문자열의 공백 처리도 반드시 이해해야 합니다. %d, %f, %s는 앞쪽 공백(스페이스, 탭, 개행)을 자동으로 건너뜁니다. 반면 %c는 기본적으로 공백도 하나의 문자로 읽습니다. 그래서 scanf("%c", &ch)가 직전에 남은 \n을 읽어버리는 일이 자주 발생합니다. 이를 방지하려고 " %c"처럼 앞에 공백을 둬서 공백 문자를 건너뛰게 만드는 패턴을 씁니다. 이건 트릭이 아니라 C 표준 입력 파싱 규칙을 이해한 결과입니다.

문자열 입력은 더 신중해야 합니다. %s는 공백 전까지 읽기 때문에 이름에 공백이 있으면 중간에서 끊기고, 길이 제한을 안 주면 버퍼 오버플로 위험이 생깁니다. 그래서 실무에서는 fgets를 우선적으로 사용하고, 필요하면 후처리(개행 제거, 파싱)를 하는 방식이 안정적입니다. 즉 “바로 원하는 타입으로 읽기”보다 “문자열로 안전하게 받은 뒤 변환하기”가 유지보수에 유리한 경우가 많습니다.

마지막으로, 입력 실패는 예외 상황이 아니라 정상 흐름의 일부로 설계해야 합니다. 사용자는 오타를 낼 수 있고, 빈 입력을 줄 수 있고, 파일 리디렉션에서는 EOF가 올 수 있습니다. 따라서 입력 루프는 (1) 읽기 시도, (2) 반환값 검사, (3) 실패 시 복구 혹은 종료라는 구조를 가져야 합니다. 이 패턴을 익히면 scanf를 써도 안정적이고, 나중에 fgets + strtol 구조로 넘어갈 때도 같은 사고방식을 그대로 가져갈 수 있습니다.

기본 사용

예제 1) scanf 반환값 검증이 포함된 정수 입력 루프

#include <stdio.h>

int main(void) {
    int value;

    while (1) {
        printf("정수를 입력하세요: ");
        int rc = scanf("%d", &value);

        if (rc == 1) {
            printf("입력 성공: %d\n", value);
            break;
        }

        if (rc == EOF) {
            printf("입력이 종료되었습니다(EOF).\n");
            return 0;
        }

        // rc == 0: 숫자로 변환 실패
        printf("숫자가 아닙니다. 다시 입력하세요.\n");

        int ch;
        while ((ch = getchar()) != '\n' && ch != EOF) {
            // 잘못된 토큰 버퍼 비우기
        }
    }

    return 0;
}

설명:

  • scanf 성공 여부를 반환값으로 분기하면 실패 상황에서도 프로그램 상태를 통제할 수 있습니다.
  • 실패 후 버퍼를 비우지 않으면 같은 잘못된 문자가 계속 남아 무한 루프가 생깁니다.
  • 입력 코드는 “사용자 친절”보다 먼저 “상태 일관성”을 지키는 것이 핵심입니다.

예제 2) %c와 공백 처리 차이 이해하기

#include <stdio.h>

int main(void) {
    int n;
    char op;

    printf("정수 하나 입력: ");
    scanf("%d", &n);

    // 잘못된 예: scanf("%c", &op);
    // 올바른 예: 앞 공백으로 남은 개행/공백 건너뛰기
    printf("연산자(+,-,*,/) 입력: ");
    scanf(" %c", &op);

    printf("n=%d, op=%c\n", n, op);
    return 0;
}

설명:

  • %c는 공백을 소비하지 않으므로 직전 입력의 \n을 읽을 수 있습니다.
  • " %c" 패턴은 “눈속임”이 아니라 공백 스킵 규칙을 의도적으로 적용한 코드입니다.
  • 여러 입력을 연달아 받을 때 타입별 공백 처리 규칙을 알고 있어야 예측 가능한 코드가 됩니다.

예제 3) 안전한 문자열 입력과 개행 제거

#include <stdio.h>
#include <string.h>

int main(void) {
    char name[32];

    printf("이름 입력: ");
    if (fgets(name, sizeof(name), stdin) == NULL) {
        printf("입력 실패\n");
        return 1;
    }

    name[strcspn(name, "\n")] = '\0';
    printf("안녕하세요, %s님!\n", name);
    return 0;
}

설명:

  • fgets는 최대 길이를 알고 읽기 때문에 버퍼 오버플로 위험을 크게 줄입니다.
  • strcspn으로 개행을 제거하면 후속 문자열 처리(strcmp, 파일 저장)가 안정됩니다.
  • 입력을 먼저 안전하게 수집하고, 의미 해석은 그다음에 하는 것이 실무형 패턴입니다.

예제 4) fgets + strtol로 숫자 파싱까지 안전하게

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

int main(void) {
    char buf[64];

    printf("나이를 입력하세요: ");
    if (!fgets(buf, sizeof(buf), stdin)) {
        printf("입력 실패\n");
        return 1;
    }

    errno = 0;
    char *endptr;
    long age = strtol(buf, &endptr, 10);

    if (buf == endptr) {
        printf("숫자가 아닙니다.\n");
        return 1;
    }
    if (errno != 0) {
        printf("범위를 벗어났습니다.\n");
        return 1;
    }

    printf("입력된 나이: %ld\n", age);
    return 0;
}

설명:

  • scanf 하나로 해결하기보다 입력/파싱 단계를 분리하면 오류 메시지와 복구 전략이 정교해집니다.
  • strtol은 파싱 실패, 범위 초과를 구분해 처리할 수 있어 실무에서 매우 유용합니다.
  • 견고한 CLI 프로그램은 대부분 이 패턴(문자열 입력 후 변환)을 기본으로 사용합니다.

자주 하는 실수

실수 1) scanf 반환값을 확인하지 않음

  • 원인: “입력이 늘 정상일 것”이라는 가정.
  • 해결: 모든 입력 호출에서 기대한 항목 수와 반환값을 비교합니다.

실수 2) %s에 길이 제한을 주지 않음

  • 원인: 짧은 테스트 입력만 사용해 위험이 드러나지 않음.
  • 해결: %31s처럼 폭을 지정하거나, 가능하면 fgets를 사용합니다.

실수 3) 실패 입력 후 버퍼를 비우지 않음

  • 원인: 실패 원인을 값 문제로만 보고 버퍼 상태를 무시함.
  • 해결: getchar() 루프로 개행/EOF까지 소비해 상태를 복구합니다.

실수 4) %c에서 남은 개행 문자 이슈를 모름

  • 원인: 모든 변환 지정자가 공백을 자동 무시한다고 착각.
  • 해결: %c 앞에 공백을 넣는 패턴(" %c")을 습관화합니다.

실무 패턴

  • 사용자 입력은 “신뢰할 수 없는 외부 데이터”로 취급합니다.
  • 단일 값 파싱도 반환값 확인 -> 실패 복구 -> 재시도 구조를 표준화합니다.
  • 문자열은 fgets, 숫자는 strtol/strtod로 분리하면 에러 처리가 단순해집니다.
  • 팀 규칙으로 입력 유틸 함수를 만들어 중복 실수를 줄입니다. 예: read_int_checked(), read_line_trimmed().
  • 컴파일 경고(-Wall -Wextra)와 함께 런타임 테스트(잘못된 입력, 빈 입력, 긴 입력)를 체크리스트에 넣습니다.

오늘의 결론

한 줄 요약: C 입력 안정성의 본질은 “함수 선택”보다 “버퍼 상태와 실패 흐름을 설계하는 습관”에 있다.

연습문제

  1. 정수 2개를 입력받아 합을 출력하되, 중간에 문자가 들어오면 오류를 알리고 재입력받는 프로그램을 작성하세요.
  2. %c 입력에서 개행 문제를 재현한 뒤, " %c"로 수정했을 때 왜 해결되는지 설명하세요.
  3. fgets + strtol로 0~120 범위의 나이를 입력받아 유효하지 않으면 다시 묻는 루프를 작성하세요.

이전 강의 정답

지난 8강(형 변환) 연습문제 해설:

  1. int total = 13, n = 5; 평균 계산
  • 정답 예시: double avg = (double)total / n;
  • 이유: total / n만 쓰면 정수 나눗셈이 먼저 일어나 2가 되고, 그 뒤 double로 바뀌어 2.0이 됩니다.
  1. int a = -20; unsigned int b = 10; 비교 이슈
  • 재현: if (a < b)가 직관과 다르게 동작할 수 있음.
  • 수정: 같은 부호/폭으로 맞춘 뒤 비교. 예: if (a < (int)b) (단, 범위 안전성 검토 필요).
  1. short 저장 전 검증 함수
  • 핵심: SHRT_MIN <= value <= SHRT_MAX를 확인하고, 통과할 때만 캐스팅.
  • 포인트: 캐스팅은 경고 제거용이 아니라 “안전 조건을 충족한 뒤” 수행해야 함.

실습 환경/재현 정보

  • 컴파일러: Apple clang 17.x 또는 GCC 13+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -Wconversion -O0
  • 실행 환경: macOS (arm64), 터미널 zsh
  • 재현 체크:
    • clang -std=c11 -Wall -Wextra -Wconversion -O0 lesson09.c -o lesson09
    • ./lesson09
    • 정상 입력/문자 입력/빈 입력/긴 문자열 입력을 각각 테스트해 동작을 확인합니다.