C와 C++에서 정수 파싱의 위험성
문자열을 정수로 파싱하는 것은 프로그래밍에서 가장 기본적인 작업 중 하나처럼 보입니다. 대부분의 현대적인 언어에서는 한 줄로 해결됩니다: Python의 int("123") 또는 JavaScript의 Number("123")와 같습니다. 하지만 C와 C++에서 이 간단한 작업은 정의되지 않은 동작(undefined behavior), 조용한 오버플로(silent overflows), 그리고 직관적이지 않은 API 설계라는 지뢰밭을 드러냅니다.
파서가 "correct"하다고 간주되려면 이상적으로 세 가지 기준을 충족해야 합니다: 저장된 수치 값이 문자열의 시각적 표현과 일치해야 하고, 전체 문자열이 소비되어야 하며(뒤에 쓰레기 값이 없어야 함), 이러한 기준을 충족하지 못하는 모든 실패 사례는 오류로서 명시적으로 보고될 수 있어야 합니다. 앞으로 살펴보겠지만, C 표준 라이브러리는 이러한 요구 사항을 충족하는 데 어려움을 겪고 있습니다.
C 표준 함수의 실패
C에서 정수 파싱을 시도하는 세 가지 주요 방법이 있으며, 각 방법은 상당한 주의 사항을 수반합니다.
1. atol(): 위험한 유산
atol()은 아마도 가장 문제가 많은 함수입니다. 오류를 감지할 방법이 전혀 없습니다. 숫자가 아닌 문자열(예: "timmy")을 전달하면 0을 반환합니다. 숫자로 시작하지만 쓰레기 값이 포함된 문자열(예: "123timmy")을 전달하면 123을 반환합니다.
더욱 경고할 점은, C와 POSIX 표준에 따르면 값을 표현할 수 없는 경우 동작이 정의되지 않는다는 것입니다. 이론적인 관점에서 이는 신뢰할 수 없는 입력에 직면했을 때 atol()이 프로그램 충돌을 포함하여 무엇이든 할 수 있음을 의미합니다.
2. strtol(): (부호 있는 정수를 위한) 유일한 실행 가능한 C 옵션
strtol()은 파싱이 중단된 위치를 가리키는 포인터(endptr)를 제공하고 범위 오류 시 errno를 설정하기 때문에 훨씬 더 견고합니다. 하지만 이를 올바르게 사용하려면 상당한 양의 보일러플레이트 코드가 필요합니다:
bool parse_long(const char* in, long* out)
{
if (!*in) return false; // 빈 문자열 감지
char* endp = NULL;
errno = 0;
*out = strtol(in, &endp, 0);
if (errno) return false; // 범위 오류
if (*endp) return false; // 뒤에 남은 쓰레기 값
return true;
}
3. strtoul() 및 sscanf(): 부호 없는 타입의 함정
strtol()은 부호 있는 숫자에 작동하지만, strtoul() (unsigned long)과 sscanf()는 많은 사용 사례에서 근본적으로 결함이 있습니다. 주요 문제는 strtoul()이 앞부분의 마이너스 부호를 허용한다는 점입니다. strtoul()에 -1을 전달하면 오류를 반환하는 대신, 랩어라운드(wrap-around)를 수행하여 ULONG_MAX (예: 64비트 시스템에서 $2^{64}-1$)를 반환합니다.
이는 모호함을 생성합니다: 결과로 ULONG_MAX를 받았다면, 입력이 실제로 최대 unsigned long이었는지, 문자열 "-1"이었는지, 아니면 범위를 초과한 값이었는지 알 수 없습니다. 이로 인해 표준 C 함수를 사용하여 64비트 플래그 필드나 큰 unsigned identifier를 신뢰할 수 있게 파싱하는 것이 불가능해집니다.
C++ 관점
C++은 이러한 C 함수들을 래핑하려고 시도하지만, 종종 그 결함들을 그대로 물려받습니다. std::stoul()과 std::istringstream은 C의 대응 함수들과 유사하게 동작하며, 종종 음수 부호를 부호 없는 타입으로 랩어라운드하여 파싱하도록 허용합니다.
현대적인 해결책: std::from_chars
C++17에서 도입된 std::from_chars는 직관적으로 동작하는 최초의 표준 라이브러리 함수입니다. 이 함수는 부호 없는 타입에 대해 마이너스 부호를 허용하지 않으며, 명확한 오류 코드(std::errc)와 파싱된 시퀀스의 끝을 가리키는 포인터를 제공합니다.
template<typename T>
std::optional<T> parse_int(std::string_view sv)
{
T i;
const auto [ptr, ec] = std::from_chars(sv.begin(), sv.end(), i);
if (ec != std::errc()) return {};
if (ptr != sv.end()) return {};
return i;
}
커뮤니티 통찰 및 우회 방법
이 주제에 관한 기술적 논의는 C 커뮤니티 내의 분열을을 보여줍니다. 일부는 C 표준 라이브러리가 "legacy pile"이라고 주장하며, 숙련된 C 프로그래머들은 성능과 정확성을 보장하기 위해 단순히 처음부터 자신만의 파싱 로직을을 위해 직접 작성한다고 합니다.
unsigned long을 반드시 사용해야 하는 C 프로그래머를 위한 한 가지 제안된 우회 방법은, 먼저 strtol()을 사용하여 문자열을 파싱하여 음수 부호를 확인한 다음, 실제 변환을 위해 strtoul()을 사용하는 것입니다:
"음수, 즉 범위를 벗어난 음수를 포함하여 제외하십시오... [그 다음] 이제부터는 순조롭게
strtoul()을 사용할 수 있습니다."
다른 기여자들은 "8진수 함정"과 같은 추가적인 위험 요소들을 지점했습니다. 예를 들어, 베이스를 10으로 명시적으로 설정하지 않으면 앞부분의 0이 자동으로 8진수 표시기로 해석될 수 있습니다. 이는 표준 10진수 파싱을 기대하는 사용자들에게는 예상치 못한 동작입니다.
결론
C에서의 정수 파싱은 설계 초기 결정—안전성과 오류 보고보다 단순함과 최소한의 오버헤드를 우선시했던 결정—이 어떻게 장기적인 기술 부채를 만들 수 있는지 보여주는 전 classic 클래식한 사례입니다. C에서 작업하는 사람들에게 가장 안전한 방법은 unsigned 타입에 대해 표준 라이브러리의 strto... 계열 함수를 피하고, 직접 커스텀 파서를 구현하거나 음수 부호와 빈 문자열을 명시적으로 확인하는 견고한 래퍼를 사용하는 것입니다.