C와 C++에서 정의되지 않은 동작(Undefined Behavior)의 편재성

수십 년 동안 C와 C++은 성능과 하드웨어와의 근접성 덕분에 시스템 프로그래밍의 근간이 되어 왔습니다. 하지만 21세기로 접어들면서 냉혹한 현실이 드러났습니다. 진정으로 "correct"한 C 또는 C++ 코드를 작성하는 것은 거의 불가능에 가깝다는 것입니다. 언어 명세는 정의되지 않은 동작(Undefined Behavior, UB)으로 너무 가득 차 있어서, 수십 년의 경력을 가진 전문가 프로그래머조차 표준을 위반하는 코드를 생성할 가능성이 높습니다.

이것은 단순히 학술적인 궤변이 아닙니다. 현대적인 컴파일 환경에서 UB는 치명적인 실패, 보안 취약점, 그리고 디버깅이 거의 불가능한 논리 버그를 초래할 수 있는 조용한 살인자입니다.

명백한 것을 넘어: UB가 실제로 의미하는 것

대부분의 개발자는 "큰" UB 함정인 double-free, use-after-free, 그리고 out-of-bounds array access에 익숙합니다. C/C++은 메모리 안전 언어가 아니기 때문에, 이러한 것들은 예상되는 위험으로 간주됩니다. 그러나 UB가 최적화가 켜져 있을 때만 문제가 된다는 흔한 오해가 있습니다. 일부는 높은 최적화 수준이 없다면 컴파일러가 적대적으로 UB를 "이용"하지 않을 것이라고 믿습니다.

이것은 근본적인 오해입니다. UB는 컴파일러가 당신의 코드를 고의로 망가뜨리려 한다는 뜻이 아닙니다. 그것은 컴파일러가 당신의 코드가 유효하다고 가정할 수 있음을 의미합니다. 프로그래머가 UB를 도입하면, 그들은 본질적으로 컴파일러에게 "이 상태는 절대 일어날 수 없다"라고 말하는 것과 같습니다. 결과적으로, 컴파일러는 필요한 체크를 생략하거나 프로그래머의 실제 의도와 무관한 코드를 생성할 수 있습니다. 왜냐하면 그 의도가 언어의 규칙 내에서 법적으로 표현되지 않았기 때문입니다.

한 커뮤니티 구성원이 언급했듯이:

컴파일러는 UB 코드가 발생하지 않을 것이라고 기대합니다. 따라서 당신이 UB 코드를 작성하더라도 컴파일러(특히 최적화 도구)는 자신의 happy path에 편리한 방식이라면 무엇이든으로 번역할 수 있는 권한이 있습니다.

보이지 않는 지뢰밭: 흔한 UB의 예시

UB는 메모리 손상보다 훨씬 더 널리 퍼져 있습니다. 그것은 가장 일상적인 작업 속에 숨어 있습니다.

정렬(Alignment)과 포인터 캐스팅

정수 포인터를 역참조하는 간단한 함수는 포인터가 올바르게 정렬되어 있지 않으면(예: sizeof(int)의 배수가 아님) UB를 유발할 수 있습니다. x86 아키텍처는 정렬되지 않은 읽기에 관대하기로 유명하지만, SPARC나 Alpha와 같은 다른 아키텍처는 SIGBUS를 유발하거나 커널 에뮬레이션을 필요로 하여 성능을 떨어뜨리거나 프로그램을 충돌시킬 수 있습니다.

결정적으로, UB는 종종 역참조가 일어나기 에 발생합니다. 바이트 버퍼(예: 네트워크 패킷)를 정수 포인터로 직접 캐스팅하는 것은 종종 UB로 언급됩니다. 컴파일러가 보안 태깅이나 가비지 컬렉션을 위해 포인터의 하위 비트에 특정 의미를 부여할 수 있기 때문입니다.

표준 라이브러리 함정: isxdigit()

표준 라이브러리 함수조차 함정이 될 수 있습니다. isxdigit() 함수는 unsigned char로 표현 가능하거나 EOF 값을 갖는 int를 기대합니다. 만약 char가 signed인 시스템에서 프로그래머가 char를 전달하면, 0-127 범위를 벗어나는 모든 값은 음수 정수가 됩니다. 만약 isxdigit()의 내부 구현이 음수 값을 확인하지 않고 입력을 배열 인덱스로 사용한다면, 이는 out-of-bounds memory reads로 이어질 수 있으며, 임베디드 시스템에서는 잠재적으로 I/O 매핑된 메모리를 트리거할 수 있습니다.

부동 소수점 변환

floatint로 변환하는 것은 UB의 빈번한 발생 원인입니다. C23 표준에 따르면, 부동 소수점 값의 정수 부분이 대상 정수 타입으로 표현될 수 없는 경우, 그 동작은 정의되지 않습니다. 여기에는 비유한(non-finite) 값(NaN 또는 Infinity)이 포함됩니다.

float를 정수로 안전하게 변환하려면, 개발자는 유한성(finiteness)과 범위 경계에 대해 광범위한 체크를 수행해야 합니다. 이는 단일 캐스트 작업처럼 보이는 작업에 비해 엄청난 양의 보일러플레이트 코드를 요구합니다.

널 포인터 역설

대부분의 개발자들은 NULL이 주소 0이라고 가정하지만, C 표준은 NULL이 0과 비교했을 때 같다는 것만 보장합니다. 널 포인터의 역참조는 UB의 전형적인 예시입니다. 또한, memset을 사용하여 구조체를 0으로 채우는 것은 기술적으로 포인터 멤버가 플랫폼의 NULL 값으로 설정된다는 것을 보장하지는 않지만, 대부분의 현대적인 시스템에서는 작동합니다.

나아갈 길: 인간의 전문성 vs. AI

C23 표준에 "undefined"라는 단어가 수백 번 등장한다는 점을 고려할 때, 대규모 코드베이스를 수동으로 UB 검사하는 작업은 압도적입니다. 이 지점에서 대규모 언어 모델(LLM)이 놀한 놀라운 유용성을 보여주었습니다. LLM은 인간 리뷰어가 놓치기 쉬운 미묘-예를 들어 잘못된 printf 형식 지정자나 정렬 문제-와 같은 미묘한 UB를 찾아내는 데 종종 능력이 있습니다.

하지만 이것은 새로운 긴장을 유래합니다. AI가 이러한 버그를 찾을 수는 있지만, 수정 사항을 확인하는 데는 여전히 전문가인 인간의 확인이 필요합니다. 산업계가 변화함에 따라, 우리는 근본적인 아키텍처적 함의를를 이해하지 못한 채 UB를 "수정"하기 위해 AI에 의존하게 될 위험이 있으며, 이는 잠재적으로 더 새롭고 더 미묘한 버그를 유발할 수 있습니다.

결론

현대적인 도구와 감독 없이 C 또는 C++를 작성하는 것은 점점 더 무책임한 일이 되고 있습니다. 인간이 코드를 읽는 방식과 현대적인 컴파일러가 이를 해석하는 방식 사이의 간극극이 너무 커졌습니다. 메모리 안전 언어를 채택하거나 LLM 기반의 감사(auditing)를 통합하는 방식 중 하나를 통해, 산업계는 C 추상 머신에 내재된 시스템적 불안정성을 해결할 방법을 찾아야 합니다.

Sources