[C언어 50강] 25강. 문자열 파싱: strtok/sscanf/strtol, 토큰 분해 실습

[C언어 50강] 25강. 문자열 파싱: strtok/sscanf/strtol, 토큰 분해 실습

C 프로그램이 현실 데이터를 다루기 시작하면, 결국 대부분의 시간은 “문자열을 잘라서 의미 있는 값으로 바꾸는 일”에 쓰입니다. 로그 한 줄, CSV 한 줄, 명령행 입력 한 줄을 받아 필요한 토큰을 꺼내고, 숫자로 변환하고, 검증하는 흐름이 핵심입니다. 오늘은 strtok, sscanf, strtol을 단순 암기가 아니라 “언제 무엇을 써야 안전한가”라는 관점으로 정리합니다.


핵심 개념

  • 문자열 파싱은 문자열 분해(tokenization) + 형 변환(conversion) + 검증(validation) 의 3단계로 본다.
  • strtok은 원본 문자열을 직접 수정하며 상태를 내부적으로 유지하므로, 멀티스레드/중첩 파싱에 주의해야 한다.
  • 숫자 변환은 atoi보다 strtol이 안전하다. 실패 여부와 변환 종료 지점을 함께 확인할 수 있기 때문이다.
  • sscanf는 강력하지만 형식 문자열 의존도가 높다. 입력이 조금만 흔들려도 실패할 수 있어 반환값 확인이 필수다.

개념 먼저 이해하기

문자열 파싱에서 초보자가 가장 많이 하는 오해는 “문자열을 숫자로 바꾸는 건 한 줄이면 끝난다”는 생각입니다. 실제로는 그렇지 않습니다. 프로그램은 사람처럼 문맥을 추측하지 못하기 때문에, 입력이 조금만 어긋나도 오작동합니다. 예를 들어 사용자가 "42" 대신 "42abc"를 입력했을 때, 우리가 원하는 건 상황마다 다릅니다. 앞의 42만 쓰고 넘어갈 수도 있고, 전체가 순수 숫자가 아니므로 에러로 처리해야 할 수도 있습니다. 즉 파싱은 단순 변환이 아니라 정책 결정입니다.

첫 단계인 토큰 분해는 “어디서 자를 것인가”를 정하는 문제입니다. 공백, 쉼표, 탭, 콜론 같은 구분자를 기준으로 문자열을 나누는 작업이죠. 이때 strtok은 매우 간편하지만, 내부적으로 구분자를 \0로 바꿔 원본 버퍼를 훼손합니다. 따라서 리터럴 문자열(읽기 전용)에 쓰면 안 되고, 나중에 원문이 필요하다면 사본을 만들어야 합니다. 또한 strtok은 다음 토큰 위치를 정적 내부 상태에 저장하므로, 동시에 두 문자열을 교차 파싱하면 상태가 꼬일 수 있습니다.

둘째 단계인 형 변환은 “문자열 토큰을 원하는 타입으로 바꾸는 일”입니다. 여기서 atoi는 실패 정보를 주지 않기 때문에 실무에서는 자주 배제됩니다. 반면 strtol은 변환이 멈춘 위치를 endptr로 알려주고, 범위를 벗어난 경우 errno를 통해 감지할 수 있어 안정적입니다. 즉 “숫자가 맞는지, 일부만 숫자인지, 범위를 넘었는지”를 코드로 분기할 수 있습니다.

셋째 단계인 검증은 가장 중요하지만 가장 자주 생략됩니다. 예를 들어 나이를 파싱했다면 0~150 범위를 확인해야 하고, 포트 번호라면 1~65535인지 봐야 합니다. 파싱 성공과 비즈니스 유효성은 다릅니다. 문자열이 숫자로 바뀌었다고 해서 곧바로 유효한 값은 아닙니다.

sscanf는 이 세 단계를 어느 정도 한 번에 처리할 수 있어 편리합니다. 예를 들어 "kim,27,175.5" 같은 고정 포맷은 sscanf 한 번으로 이름/나이/키를 뽑을 수 있습니다. 하지만 포맷이 흔들리거나 공백 규칙이 바뀌면 취약해지기 쉽습니다. 그래서 sscanf는 “입력 형식이 확실히 통제되는 경우”에 강하고, 사용자 자유 입력처럼 불규칙한 데이터에서는 토큰화 + strtol 조합이 더 예측 가능합니다.

정리하면, 파싱의 핵심은 함수 이름이 아니라 책임 분리입니다. 자르기(어디서), 바꾸기(어떻게), 검사하기(무엇을 허용할지) 를 분리해 생각하면 버그가 급격히 줄어듭니다. 이 구조를 머리에 넣고 나면, 새로운 입력 포맷을 만나도 당황하지 않고 동일한 프레임으로 설계할 수 있습니다.

기본 사용

예제 1) strtok으로 CSV 한 줄 토큰 분해

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

int main(void) {
    char line[] = "apple,banana,grape";  // 수정 가능한 배열
    const char *delim = ",";

    char *token = strtok(line, delim);
    while (token != NULL) {
        printf("token: %s\n", token);
        token = strtok(NULL, delim);
    }

    return 0;
}

설명:

  • strtok(line, ",") 첫 호출에서 line을 스캔하며 첫 토큰 시작 주소를 반환합니다.
  • 쉼표 위치를 \0로 바꾸므로 원본 문자열은 조각난 형태로 바뀝니다.
  • 이후 호출은 strtok(NULL, ",")로 이어가며 내부 상태를 기준으로 다음 토큰을 찾습니다.
  • 이 특성 때문에 line 원문이 이후에도 필요하면 strcpy 등으로 복사본을 만들어 파싱해야 합니다.

예제 2) strtol로 안전한 정수 변환

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

int parse_int_strict(const char *s, int *out) {
    char *endptr;
    long v;

    errno = 0;
    v = strtol(s, &endptr, 10);

    if (s == endptr) return 0;          // 숫자 시작조차 못함
    if (*endptr != '\0') return 0;      // 뒤에 찌꺼기 문자가 남음
    if (errno == ERANGE) return 0;       // long 범위 초과
    if (v < INT_MIN || v > INT_MAX) return 0; // int 범위 초과

    *out = (int)v;
    return 1;
}

int main(void) {
    const char *samples[] = {"123", "42abc", "999999999999", "-7", "abc"};
    int n;

    for (int i = 0; i < 5; ++i) {
        if (parse_int_strict(samples[i], &n)) {
            printf("OK: %s -> %d\n", samples[i], n);
        } else {
            printf("FAIL: %s\n", samples[i]);
        }
    }

    return 0;
}

설명:

  • strtol은 “얼마나 읽었는지”를 endptr로 알려주므로 불완전 입력을 검출할 수 있습니다.
  • atoi("abc")처럼 모호한 결과(0) 대신 명확한 성공/실패 분기를 만들 수 있습니다.
  • 실무에서는 변환 성공 후 도메인 범위(예: 1~100)도 추가 검증해야 안전합니다.

예제 3) sscanf로 고정 포맷 레코드 파싱

#include <stdio.h>

int main(void) {
    const char *record = "kim,27,175.5";
    char name[32];
    int age;
    float height;

    int matched = sscanf(record, "%31[^,],%d,%f", name, &age, &height);

    if (matched == 3) {
        printf("name=%s, age=%d, height=%.1f\n", name, age, height);
    } else {
        printf("parse error: matched=%d\n", matched);
    }

    return 0;
}

설명:

  • %31[^,]는 쉼표 전까지 최대 31문자를 읽어 버퍼 오버플로를 예방합니다.
  • sscanf는 성공적으로 읽은 항목 수를 반환하므로 반드시 기대 개수와 비교해야 합니다.
  • 포맷이 고정된 데이터에는 매우 효율적이지만, 형식이 자주 변하면 유지보수 비용이 높아집니다.

자주 하는 실수

실수 1) 문자열 리터럴에 strtok 사용

  • 원인: char *p = "a,b,c";처럼 리터럴을 수정 가능한 메모리로 오해.
  • 해결: char buf[] = "a,b,c";처럼 배열에 복사한 뒤 파싱한다. 리터럴은 읽기 전용으로 취급.

실수 2) atoi 결과만 믿고 검증 생략

  • 원인: 코드가 짧아 보여서 atoi를 습관적으로 사용.
  • 해결: strtol + endptr + 범위 검사를 표준 패턴으로 고정한다.

실수 3) sscanf 반환값 미확인

  • 원인: 형식 문자열이 맞을 거라 가정하고 바로 변수 사용.
  • 해결: matched == 기대개수를 확인하고, 실패 시 에러 처리/재입력 루프를 둔다.

실무 패턴

  • 파싱 함수를 분리한다: int parse_xxx(const char *src, Xxx *out) 형태로 작성해 테스트 가능성 확보.
  • 파싱 단계 로그를 남긴다: 실패 시 원문, 실패 위치, 실패 이유(형식/범위)를 구분해 남긴다.
  • 사용자 입력은 fgets로 한 줄 단위 수집 후 파싱한다. scanf 직파싱은 버퍼/개행 이슈를 키우기 쉽다.
  • 경계값 테스트를 자동화한다: 빈 문자열, 공백만 있는 문자열, 최대/최소값, 초과값, 기호 포함 문자열.
  • 토큰 개수가 가변이면 strtok 기반 루프 + 각 토큰별 변환 함수를 조합해 확장성을 확보한다.

오늘의 결론

한 줄 요약: 문자열 파싱의 품질은 “변환 함수 선택”보다 “검증을 어디까지 체계화했는지”에서 갈린다.

연습문제

  1. "100,200,300"을 파싱해 정수 배열로 저장하고 합계를 출력하세요. 단, 토큰 하나라도 숫자 변환에 실패하면 전체를 실패 처리하세요.
  2. "name:lee age:31 score:88" 형식을 sscanf 또는 토큰화 방식으로 파싱해 구조체에 담아 출력하세요.
  3. 사용자 입력 한 줄을 받아 정수 1개만 허용하는 parse_menu_choice() 함수를 작성하세요. 범위는 1~5이며 실패 시 이유를 문자열로 반환해보세요.

이전 강의 정답

지난 24강(문자열 라이브러리) 연습문제 예시 정답:

  1. strlensizeof 차이
  • strlen("abc")는 3(널 문자 제외 길이), sizeof("abc")는 4(널 문자 포함 배열 크기).
  1. strncpy 사용 시 주의점
  • 소스 길이가 제한 길이 이상이면 널 종료가 보장되지 않으므로, 마지막 문자를 직접 \0 처리해야 안전.
  1. 문자열 비교
  • if (a == b)는 주소 비교이고, 내용 비교는 strcmp(a, b) == 0을 사용해야 한다.

실습 환경/재현 정보

  • 컴파일러: Apple clang version 17.x (환경별 상이)
  • 컴파일 옵션: -std=c11 -Wall -Wextra -O2
  • 실행 환경: macOS (arm64), 터미널 zsh
  • 재현 체크:
    • clang -std=c11 -Wall -Wextra -O2 parse_demo.c -o parse_demo
    • 경계 입력("", "abc", "123abc", 초대형 숫자)으로 성공/실패 분기 확인