C와 C++에서 정의되지 않은 동작이 얼마나 광범위한지, 왜 단순한 실수의 문제가 아닌지, 그리고 현대 개발에서 이를 어떻게 다뤄야 하는지에 대한 글.
Cardinal Richelieu가 프로그래머였다면 이렇게 말했을 것이다. “세상에서 가장 뛰어난 C 프로그래머의 손으로 쓰인 여섯 줄만 내게 주면, 그 안에서 정의되지 않은 동작을 일으키기에 충분한 것을 찾아내겠다.”
아무도 올바른 C나 C++를 작성할 수 없다. 그리고 나는 거의 30년 동안 거의 매일 C와 C++를 써 온 사람으로서 이렇게 말한다. 나는 C++ 팟캐스트를 듣는다. C++ 컨퍼런스 발표를 본다. C++를 읽고 쓰는 일을 즐긴다.
C++는 우리에게 큰 도움이 되어 왔지만, 지금은 2026년이고, 1985년(C++)이나 1972년(C)의 환경은 오늘날의 환경이 아니다.
분명 내가 처음으로 이런 말을 하는 사람은 아니다. 나는 약 10년 전쯤 어떤 저명한 사람이 C++ 사용은 SOX 위반이라고 충분히 주장할 수 있다고 쓴 글을 읽었던 기억이 난다. 그리고 나는 그 사람의 나머지 장광설에는 동의하지 않았고(“its”와 “it’s”를 헷갈린 점도 포함해서), 그 지점에 대해서만큼은 한 번도 반대한 적이 없다.
시간이 지나면서 나는 이것이 점점 더 사실이라고 느끼게 되었다. 예상보다 훨씬 더 많은 것들이 정의되지 않은 동작(UB)이다.
누구나 이중 해제, 해제 후 사용, 객체(예: 배열) 경계 밖 접근, 초기화되지 않은 메모리 접근이 UB라는 것은 안다. 결국 C/C++는 메모리 안전 언어가 아니다. 그런데도 업계 전체로 보면 우리는 그런 실수조차 반복해서 저지르는 일을 멈추지 못하는 것처럼 보인다.
하지만 그게 전부가 아니다. 더 있다. 더 미묘하고, 더 비논리적이다.
어떤 사람들은 최적화를 켜지 않고 컴파일하기만 하면 정의되지 않은 동작이 자신을 해치지 못한다고 생각하는 듯하다. 그들은 컴파일러가 마치 의도적으로 적대적으로 행동하면서 “아하! UB군! 여기서는 내가 하고 싶은 대로 할 수 있지!”라고 말하는 것이고, 최적화를 끄면 그러지 않을 것이라고 믿는다.
이것은 틀렸다.
UB는 컴파일러가 당신의 부주의를 이용할 수 있다는 뜻이 아니다. UB는 컴파일러가 당신의 코드가 유효하다고 가정할 수 있다는 뜻이다. 즉, 사람이 읽으면 너무나 분명한 당신 코드의 의도조차 컴파일 단계나 모듈 사이에서는 표현할 방법 자체가 없을 수 있다 는 뜻이다.
UB는 컴파일러가 자기 코드 생성 과정에서 어떤 특수한 경우를 굳이 구현할 필요조차 없다는 뜻이다. 왜냐하면 그런 일은 “일어날 수 없기” 때문이다.
컴파일러, 그리고 사실 그 아래의 하드웨어까지도, 당신의 UB 의도를 가지고 전화놀이를 하고 있는 셈이다. 우연히 당신이 원한 결과에 도달할 수도 있지만, 지금도 미래에도 아무 보장은 없다.
아래 내용은 세상에 존재하는 모든 UB를 열거하려는 시도가 아니다. 그저 UB는 어디에나 있고, 아무도 이것을 제대로 할 수 없다면 프로그래머만 탓하는 것이 과연 공정한가를 말하려는 것이다. 내 요점은 모든 비사소한 C/C++ 코드는 UB를 포함한다는 것이다.
예로 다음 코드를 보자.
int foo(const int* p) {
return *p;
}
이 함수가 올바르게 정렬되지 않은 포인터와 함께 호출된다면(아마 주소가 sizeof(int)의 배수여야 한다는 뜻이겠지만, 누가 알겠는가), 이것은 UB다. C23 6.3.2.3.
Linux Alpha에서는 어떤 경우에는 이것이 단지 커널로 트랩되어, 커널이 당신이 의도한 동작을 소프트웨어로 에뮬레이션해 주었다. 다른 경우에는 (아마도) SIGBUS로 프로그램이 죽었을 것이다.
SPARC에서는 SIGBUS를 일으켰다.
물론 x86/amd64(이하 그냥 “x86”)에서는 이건 아마 문제없다. 빌어먹게도, 아마 원자적 읽기이기까지 할 것이다. x86은 캐시 일관성의 미묘한 문제들에 대해 악명 높을 정도로 매우 관대하다.
그래서 여기에는 세 가지 경우가 있다.
그렇다면 ARM, RISC-V, 기타 아키텍처는 어떤가? 미래의 아키텍처는 어떤가? 미래의 어떤 아키텍처는 특별한 int-pointer registers를 가질 수도 있고, 그런 포인터는 존재할 수 없으니 하위 비트를 채우지 않을 수도 있다.
설령 지금 동작하더라도, 언젠가 컴파일러가 한 종류의 로드 명령 대신 다른 명령을 사용하도록 바뀌면서 갑자기 커널이 더 이상 그것을 보정해 주지 않을 수도 있다.
왜냐하면 컴파일러는 정렬되지 않은 포인터에서도 동작하는 어셈블리 명령을 생성할 의무가 없기 때문이다. UB이기 때문이다.
아니면 이런 것은 어떤가.
void set_it(std::atomic<int>* p) {
p->store(123);
}
int get_it(std::atomic<int>* p) {
return p->load();
}
객체가 올바르게 정렬되지 않았을 때 이 연산은 원자적인가? 그것은 잘못된 질문이다. Mu, 질문을 거두어라. 이것은 UB다. (하지만 실제로는 그렇다. 쉽게 원자성 문제가 될 수 있다.)
좀 더 확신하고 싶다면, 원자적으로 읽는다고 생각한 객체가 pages를 걸쳐 있을 때 무슨 일이 일어나는지 생각해 볼 수 있다. 하지만 너무 깊이 생각하지는 마라. 그러면 “괜찮네”라는 결론에 도달할 수도 있다. 괜찮지 않다. UB다.
위의 foo() 함수를 탓하지 마라. 문제는 포인터를 역참조한 행위가 아니었다. 그 포인터를 만드는 것만으로도 이미 문제였다.
예:
bool parse_packet(const uint8_t* bytes) {
const int* magic_intp = (const int*)bytes; // UB!
int magic_raw = foo(magic_intp); // 아마 SPARC에서 크래시.
int magic = ntohl(magic_raw); // 적어도 이건 괜찮다.
[…]
}
문제는 foo()가 아니라 그 캐스트다.
컴파일러가 int*의 하위 비트에 가비지 컬렉션이나 보안 태깅 비트 같은 특별한 의미를 부여하는 것은 완전히 유효하다.
char 입력에 대한 isxdigit()bool bar(char ch) {
return isxdigit(ch);
}
isxdigit()는 문자를 받아 그것이 16진수 숫자면 1을 반환하는 단순한 함수다. 0-9 또는 a-f다. 또한 EOF 값도 받을 수 있다. 음, 그래. EOF는 무슨 값인가? C23 7.4p1에 따르면 그것은 int이고, unsigned char로 표현 가능한 값은 아니라는 것을 추론할 수 있다.
따라서 isxdigit()는 char가 아니라 int를 받는다. char의 모든 값은 int 안에 들어가므로 괜찮아 보인다. char에서 int로의 캐스팅도 들어맞으니 6.3.1.3에 따라 괜찮은 것 아닌가?
아니다. 왜냐하면 bar()가 0-127 이외의 값으로 호출되고, 그리고 당신의 아키텍처에서 char가 signed라면(C23 6.2.5, 단락 20에 따라 구현 정의), 그 정수 값은 음수가 되기 때문이다.
그리고 다음은 isxdigit()의 유효한 구현이며, 이것은 대체 어느 메모리인지 모를 곳을 읽게 만들 수 있다. 심지어 메모리 매핑 I/O일 수도 있어서, 단순히 랜덤한 값을 얻거나 크래시하는 것을 넘어 어떤 동작을 촉발할 수도 있다. 모터를 돌리기 시작할 수도 있다. 데스크톱 운영체제 위에서 실행되는 애플리케이션에서는 임베디드 시스템보다 가능성이 낮겠지만, 사용자 공간 네트워크 드라이버도 있으므로 성능 때문에 사용자 공간이라고 해서 당신을 보호해 주지는 않는다.
int isxdigit(int c) {
if (c == EOF) {
return false;
}
return some_array[c];
}
float에서 int로 캐스팅하기int milliseconds(float seconds) {
int tmp = (int)(seconds * 1000.0); /* WRONG */
return tmp + 1; /* WRONG separately (signed overflow is UB) */
}
When a finite value of real floating type is converted to an integer type[…]If
the value of the integral part cannot be represented by the integer type, the
behavior is undefined.
— 6.3.1.4
그리고 생략되어 있다는 사실 때문에, 그 float가 유한값이 아닐 때도 역시 UB다.
그렇다면 float를 INT_MAX와 어떻게 비교해야 하는가? float를 int로 캐스팅하는가? 아니다. 그것이 바로 피하고 싶은 UB다. 그럼 INT_MAX를 float로 캐스팅하는가? 그것이 정확히 표현 가능한지 어떻게 아는가? INT_MAX를 float로 캐스팅할 때 반올림되어 int로는 표현할 수 없는 값이 되고, 비교가 의미를 잃을 수도 있지 않은가?
아마 다음은 동작할까? 아주 큰 값 몇 개는 표현하지 못하겠지만, 어쩌면 괜찮을지도 모른다.
int milliseconds(float seconds) {
const float ftmp = seconds * 1000.0f;
if (!isfinite(ftmp)) {
// 또는 다른 오류 보고.
return 0;
}
if ((float)(INT_MIN + 1000) > ftmp) {
// 또는 다른 오류 보고.
return 0;
}
if ((float)(INT_MAX - 1000) < ftmp) {
// 또는 다른 오류 보고.
return 0;
}
// 이제 변환해도 안전.
const int tmp = (int)ftmp;
if (INT_MAX == tmp) {
// 또는 다른 오류 보고.
return 0;
}
// 이제 더해도 안전.
return tmp + 1;
}
나는 그저 float를 int로 변환하고 싶었을 뿐이다. :-(
초 단위 값을 받아서 정수 밀리초로 바꾸기 위해 그냥 곱하고 캐스팅하는 코드가 세상에 무수히 많을 것이라고 나는 장담한다.
대부분의 프로그래머는 이것을 다룰 일이 없겠지만, 실제로는 주소 0에 객체를 두는 C 표준 준수 방법이 없다고 나는 생각한다. 이것은 운영체제 커널이나 임베디드 코딩에서 문제가 될 수 있다.
6.3.2.3에 따르면 정수 상수 0(포인터로 변환 가능)과 nullptr는 “널 포인터 상수”다(이하 그냥 NULL이라고 하겠다). C는 실제 포인터 NULL이 머신 주소 0을 가리킨다고 명시하지 않는다. 왜냐하면 C 표준은 하드웨어가 아니라 C 추상 기계에 대해서만 이야기하기 때문이다.
C가 보장하는 것은 오직 NULL을 0과 비교 하면 같게 보인다는 것뿐이다. 하지만 그것은 0이 플랫폼 고유의 NULL로 변환되기 때문일 수도 있고, 그 값이 우연히 0xffff일 수도 있다.
또한 어떤 값이든 널 포인터를 역참조하는 것은 명시적으로 UB라고 말한다. 3.4.3에서 바로 그 예시로 제시된다.
이것은 또한 memset(&ptr, 0, sizeof(ptr));가 NULL 포인터를 만든다고 가정할 수 없다는 뜻이기도 하다! 이런 식으로 구조체를 초기화하고 멤버 포인터들이 NULL이라고 가정할 수 없다! 그리고 이것은 대부분의 프로그래머에게도 적용된다.
그리고 그렇다. 역사적으로는 널 포인터가 0이 아닌 기계들도 있었다.
하지만 현대적인 기계가 있고, 거기서는 NULL이 주소 0을 가리키며, 실제로 그곳에 객체가 있다고 해 보자.
다시 말하지만, C 6.3.2.3은 NULL이 “어떤 객체나 함수”와도 같지 않다고 말한다. 따라서 이것은 UB다.
void (*func_ptr)() = NULL;
func_ptr();
C는 “거기에는 함수가 없다”고 말한다. 컴파일러 내부에는 애초에 당신의 의도를 표현할 방법조차 없을 수 있다. 당신은 “그래도 설마 전부 0 비트 패턴인 곳으로 call 명령을 내보내겠지? 그 외에 다른 건 별로 그럴듯하지 않은데”라고 주장할지도 모른다.
그런데 “전부 0”이란 무엇인가? 16비트 x86에서는 0000:0000인가? CS:0000인가?
%lld 대신 %ld를 쓰는 printf)이것은 UB다.
execl("/bin/sh", "sh", "-c", "date", NULL); /* WRONG */
execl("/bin/sh", "sh", "-c", "date", 0); /* WRONG */
이것은 아니다.
execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
그 이유는 인자가 포인터여야 하고, NULL 매크로가 정수 0으로 오해될 수 있기 때문이다.
비슷하게, 이것도 UB다.
uint64_t blah = 123;
printf("%ld\n", blah); /* WRONG */
이렇게 해야 한다.
uint64_t blah = 123;
printf("%"PRIu64"\n", blah);
그렇다면 uid_t는 어떻게 출력하는가? 음, uintmax_t로 캐스팅해서 PRIuMAX로 출력할 수는 있을 것이다. 하지만 uid_t가 정말 unsigned인가? 뭐, 최악의 경우 -1 대신 말도 안 되는 값이 출력되겠지.
물론, 아마 이것은 알고 있었을 것이다. 하지만 그 보안 측면을 생각해 본 적은 있는가? 분모가 신뢰할 수 없는 입력에서 오는 경우는 드물지 않다.
그리고 그 외에도 훨씬 더 많다. C23 표준에는 “undefined”라는 단어가 283번 등장한다. 그리고 그것조차 생략을 통해 정의되지 않은 것들은 포함하지 않는다.
정수 승격 규칙을 코드를 훑어보는 속도로 적용할 수 있는 사람은 아무도 없다. 아무도.
이 글은 이미 충분히 길지만, 시작으로 이것을 보자.
unsigned char a = 0xff;
unsigned char b = 1;
unsigned char zero = 0;
bool overflowed = (a + b) == zero;
// overflowed is set to zero, not one.
unsigned char a = 0x80;
uint64_t b = a << 24; // Bonus UB(?)
// b is now 18446744071562067968 (ffffffff80000000), not 2147483648 (0x80000000).
// even with all our variables unsigned.
아무 C 코드에나 LLM을 들이대고 UB를 찾아보라고 하면, 찾아낸다. 그리고 요즘은 거의 대부분 맞다.
내 코드에서 UB를 정확히 찾아냈을 때는 조금 기분이 이상해서, 나는 성숙하고 꼼꼼하게 작성된 OpenBSD에도 적용해 보기로 했다. 그냥 생각나는 첫 번째 도구인 find를 집어 들었고, 이것도 여러 개를 뱉어냈다.
나는 프로젝트에 경계 밖 쓰기에 대한 패치를 보냈다(그리고 UB는 아닌 논리 버그에 대한 것도 보냈다). 여기저기 남아 있던 UB에 대해서는 패치를 보내지 않았다. 부분적으로는 OpenBSD 프로젝트가 과거에 버그 리포트에 그다지 호의적이지 않았고, “이건 실제로는 아마 괜찮을 것이다”라는 내 감각도 있었고, OpenBSD가 자기 코드베이스에서 UB를 걷어내고 싶다면 그것은 내가 LLM과 그들 사이의 단순한 중간 전달자 역할을 하며 여기저기 패치를 보내는 방식보다 더 나은 방식으로 수행해야 할 대형 프로젝트이기 때문이다.
그냥 C/C++ 코드베이스를 버릴 수는 없다. 하지만 본질적으로 망가진 상태로 내버려 두는 것도 선택지가 아니다.
우리는 AI 쓰레기를 양산하지도 않고, 인간 리뷰어를 압도하지도 않으면서, 대규모로 UB를 수정할 수 있는 어떤 방법이 필요하다.
이 역시 새로운 의견도 아니고, 대단한 계시도 아니다.
하지만 그렇다. 2026년에 UB를 감시해 주는 LLM 없이 C/C++를 작성하는 것은 아마 SOX 위반으로 간주되어야 하고, 그냥 무책임한 일이다. OpenBSD 사람들도 30년이 넘도록 이런 문제를 못 찾았다면, 나머지 우리에게 무슨 희망이 있겠는가?
대규모 코드베이스에는 확장되지 않을 수도 있지만, 내 개인 프로젝트들에서는 LLM에게 UB를 찾아달라고, 필요하면 설명하고 고쳐달라고 요청했다. 그리고 나서 내가 문제와 수정 사항을 확인할 수 있을 때까지 그 출력을 들여다본다.
문제는 이런 발견을 확인하려면 전문가 인간이 필요하다는 점이다. 하지만 대체로 전문가 인간은 다른 일로 바쁘다. 이것은 잡역부의 일 같지만, 전통적으로 그런 일을 맡아 온 주니어 프로그래머들에게 맡기기에는 너무 미묘하다.
disqus가 광고를 보여주기 시작했다. :-(
정적 읽기 전용 보기에서 댓글을 표시하는 중(아마 불완전할 수 있음). 댓글을 남기려면 버튼을 클릭하라.