---
title: "C 코드는 거의 어디에나 정의되지 않은 동작이 숨어 있다는 얘기"
published: 2026-05-20T06:07:22.000Z
canonical: https://jeff.news/article/2925
---
# C 코드는 거의 어디에나 정의되지 않은 동작이 숨어 있다는 얘기

30년 가까이 C와 C++를 써온 개발자가 “비자명한 C/C++ 코드에는 사실상 정의되지 않은 동작이 있다”고 주장한 글이다. 흔한 메모리 버그뿐 아니라 정렬 안 된 포인터, char를 넘긴 isxdigit, float에서 int 캐스팅, NULL 포인터, printf 포맷 불일치 같은 일상적인 코드까지 위험하다는 게 핵심이다. 결론은 C/C++를 버리자는 게 아니라, 2026년에는 대규모 언어 모델(LLM)을 동원해 정의되지 않은 동작을 찾고 사람이 검토하는 프로세스가 필요하다는 쪽에 가깝다.

## “C를 잘 쓰면 된다”는 말이 점점 방어가 안 된다는 주장

- 글쓴이는 C/C++를 30년 가까이 거의 매일 써온 사람인데도, 결론이 꽤 세다. “비자명한 C/C++ 코드에는 정의되지 않은 동작(Undefined Behavior, UB)이 있다”는 쪽임.
  - double-free, use-after-free, 배열 범위 밖 접근, 초기화 안 된 메모리 읽기 같은 건 다들 위험하다고 알고 있음.
  - 그런데 글의 핵심은 그런 뻔한 메모리 버그가 아니라, 평소에 별생각 없이 쓰는 코드에도 표준상 UB가 숨어 있다는 것임.

- “최적화 끄면 괜찮지 않나?”도 틀린 접근이라고 봄.
  - UB는 컴파일러가 “어? 네 실수 잡았다” 하고 공격적으로 최적화하는 문제가 아니라, 컴파일러가 애초에 “그런 코드는 유효하지 않으니 고려하지 않아도 된다”고 가정하는 계약 문제임.
  - 그래서 최적화 옵션을 끄더라도, 컴파일러 내부 단계나 하드웨어로 의도가 전달되는 과정에서 이미 보장 자체가 사라질 수 있음.

> [!IMPORTANT]
> 글쓴이가 인용한 수치가 꽤 묵직함. C23 표준에는 “undefined”라는 단어가 283번 나오고, 명시되지 않아서 undefined가 되는 영역까지 합치면 실제 위험면은 더 넓다는 주장임.

## 생각보다 가까운 곳에 있는 UB 사례들

- 정렬(alignment)이 맞지 않는 포인터를 만드는 것부터 이미 위험하다고 설명함.
  - 예를 들어 int 포인터가 sizeof(int)의 배수 주소를 가리키지 않는다면, 어떤 플랫폼에서는 그냥 동작하고 어떤 플랫폼에서는 SIGBUS로 터질 수 있음.
  - Linux Alpha에서는 커널이 일부 load를 소프트웨어로 보정해주기도 했지만, 다른 경우에는 크래시가 났고 SPARC도 SIGBUS가 날 수 있음.
  - x86/amd64에서는 대체로 관대하게 돌아가지만, 그건 표준이 보장해서가 아니라 그 플랫폼이 봐주는 것에 가깝다는 얘기임.

- 더 골때리는 포인트는 “역참조하기 전”에도 이미 UB일 수 있다는 점임.
  - 정렬 안 된 주소를 int*로 캐스팅하는 순간부터 문제가 될 수 있다고 봄.
  - 미래 아키텍처가 int 포인터의 하위 비트를 태깅이나 보안 용도로 쓴다고 해도, 표준상 불가능한 포인터를 만든 코드가 항의할 근거가 약함.

- isxdigit()에 char를 그대로 넘기는 코드도 함정이라고 지적함.
  - isxdigit()은 int를 받지만, 허용되는 값은 unsigned char로 표현 가능한 값 또는 EOF임.
  - char가 signed인 아키텍처에서 128 이상 값이 음수로 승격되면, 테이블 인덱싱 같은 구현에서 엉뚱한 메모리를 읽을 수 있음.
  - 데스크톱 앱에서는 그냥 크래시나 이상한 값으로 끝날 수 있지만, 임베디드나 유저스페이스 네트워크 드라이버 같은 환경에서는 I/O mapped memory를 건드리는 식의 더 이상한 일이 생길 수도 있다고 봄.

- float를 int로 캐스팅하는 단순한 코드도 만만치 않음.
  - float 값이 int로 표현 가능한 범위를 벗어나면 UB이고, non-finite 값이어도 UB라고 설명함.
  - 그러면 INT_MAX와 비교하려고 float를 int로 바꾸면 그 자체가 위험하고, INT_MAX를 float로 바꾸면 정확히 표현되는지 또 따져야 함.
  - “초를 밀리초 정수로 바꾸려고 곱하고 캐스팅하는 코드” 같은 흔한 패턴도 입력 범위에 따라 위험해질 수 있다는 얘기임.

## NULL, printf, divide by zero도 그냥 교과서 문제가 아님

- 주소 0에 객체를 두는 것도 C 표준 관점에서는 실전에서 거의 답이 없다고 봄.
  - C의 NULL은 “기계 주소 0”을 의미하는 게 아니라 C 추상 머신의 null pointer constant임.
  - 역사적으로 NULL 포인터가 실제 주소 0이 아닌 머신도 있었고, memset으로 포인터 필드를 0으로 채운다고 반드시 NULL 포인터가 된다고 보장할 수도 없음.
  - OS 커널이나 임베디드처럼 주소 0을 실제로 다루고 싶은 코드에서는 이 간극이 꽤 불편해짐.

- printf 계열의 가변 인자도 타입이 조금만 어긋나면 UB가 됨.
  - %ld가 필요한 곳에 long long을 넘기거나, %lld가 필요한 곳에 long을 넘기는 식의 실수는 표준상 안전하지 않음.
  - NULL을 %p에 넘길 때도 NULL 매크로가 정수 0처럼 해석될 수 있어서, 명시적으로 void*로 맞추는 식의 주의가 필요하다고 봄.
  - uid_t를 출력하려고 해도 signed인지 unsigned인지, 어떤 포맷 매크로를 써야 하는지 계속 타입 계약을 따라가야 함.

- divide by zero는 다들 알지만, 보안 관점에서는 더 민감하다고 짚음.
  - 분모가 신뢰할 수 없는 입력에서 온다면 단순 크래시가 아니라 서비스 거부나 더 큰 취약점 표면이 될 수 있음.
  - C/C++에서는 “아마 이 값은 0이 아니겠지”가 코드 계약으로 남아 있으면, 언젠가 공격 입력이나 엣지 케이스가 그 부분을 찌를 수 있음.

> [!WARNING]
> 이 글의 불편한 결론은 “숙련자가 조심하면 된다”가 아니라 “숙련자도 놓친다”에 가까움. 특히 플랫폼을 바꾸거나 컴파일러가 바뀌거나 최적화 조건이 달라지면, 운 좋게 돌아가던 코드가 갑자기 터질 수 있음.

## 그래서 LLM을 코드 리뷰에 넣자는 결론

- 글쓴이는 대규모 언어 모델(LLM)이 C 코드에서 UB를 찾는 데 사람보다 꽤 잘한다고 말함.
  - 아무 C 코드나 던지고 UB를 찾아보라고 하면 요즘 모델은 꽤 높은 확률로 맞는 지적을 한다는 경험담을 공유함.
  - 본인 코드에서 문제를 찾은 뒤, 더 성숙하고 엄격하게 작성됐을 OpenBSD의 find 코드에도 시도했고, out-of-bounds write와 로직 버그를 찾아 패치를 보냈다고 함.

- 다만 “AI가 고쳐준 코드 그냥 머지하자”는 얘기는 아님.
  - 글쓴이도 LLM 결과를 보고 사람이 다시 확인해야 한다고 선을 그음.
  - 문제는 이 검토가 주니어에게 맡기기엔 너무 미묘하고, 시니어에게 맡기기엔 너무 많은 반복 노동이라는 점임.
  - 그래서 “AI slop을 커밋하지 않으면서도, 사람 리뷰어를 압도하지 않는 규모의 UB 정리 방식”이 필요하다고 정리함.

- C/C++ 코드베이스를 당장 버릴 수 없다는 현실도 인정함.
  - 레거시 시스템, 커널, 임베디드, 고성능 네트워크, 런타임, 데이터베이스 같은 영역에는 C/C++가 여전히 깊게 박혀 있음.
  - 하지만 “그냥 원래 이렇게 해왔다”로 계속 두기에는 2026년의 컴파일러, 하드웨어, 보안 환경이 너무 달라졌다는 게 글의 큰 메시지임.

---

## 기술 맥락

- 이 글에서 중요한 선택은 C/C++를 버리자는 게 아니라, UB를 찾는 리뷰 경로에 LLM을 넣자는 거예요. 왜냐하면 UB는 문법 에러처럼 바로 보이는 문제가 아니라 표준, 컴파일러, 아키텍처 지식이 겹쳐야 보이는 경우가 많거든요.

- 기존 정적 분석 도구만으로 충분하지 않다는 뉘앙스도 있어요. 왜냐하면 도구는 정해진 규칙을 안정적으로 잡는 데 강하지만, “이 캐스팅이 특정 플랫폼에서는 왜 위험한가” 같은 설명과 수정 방향까지 이어가는 데는 사람이 계속 맥락을 보태야 하거든요.

- LLM을 쓰는 이유는 후보를 빠르게 많이 뽑아내기 위해서예요. 다만 글쓴이는 모델이 낸 패치를 그대로 믿는 방식은 경계해요. 왜냐하면 UB 수정은 겉보기 동작을 바꿀 수 있고, 리뷰어가 표준상 문제와 실제 영향 범위를 같이 확인해야 하기 때문이에요.

- 이 접근이 특히 잘 맞는 곳은 오래된 C/C++ 코드베이스예요. 왜냐하면 수십 년간 돌아간 코드라도 x86에서는 우연히 안전해 보였을 수 있고, ARM이나 RISC-V, 새 컴파일러, 다른 최적화 조건에서는 전혀 다른 결과가 나올 수 있거든요.

## 핵심 포인트

- 정의되지 않은 동작은 최적화 옵션 문제가 아니라, 컴파일러와 하드웨어가 애초에 그런 상황을 고려하지 않아도 되는 계약 문제다.
- C23 표준에는 undefined라는 단어가 283번 나오고, 명시되지 않아 사실상 정의되지 않은 영역까지 합치면 위험면은 더 넓다.
- 정렬 안 된 포인터, signed char와 isxdigit, float-to-int 캐스팅, NULL 표현, printf 가변 인자 타입 불일치 같은 코드가 모두 실제 버그 표면이 될 수 있다.
- 저자는 OpenBSD의 find 코드에서도 LLM으로 정의되지 않은 동작과 out-of-bounds write를 찾아 패치를 보냈다고 말한다.
- C/C++ 코드베이스를 당장 버릴 수는 없지만, LLM을 보조 리뷰어로 써서 UB를 대규모로 줄이는 방식이 필요하다는 주장이다.

## 인사이트

이 글이 세게 들리는 이유는 “C 개발자가 조심하면 된다”는 오래된 믿음을 정면으로 건드리기 때문임. 특히 레거시 C/C++를 운영하는 팀이라면 보안 점검을 정적 분석 도구에만 맡길 게 아니라, LLM 기반 리뷰를 사람 검토와 묶어 반복 가능한 프로세스로 만드는 쪽을 진지하게 볼 만함.
