C는 더 이상 프로그래밍 언어가 아닙니다: 우리가 벗어날 수 없는 거대한 프로토콜


최근 엔지니어링 커뮤니티에서 꽤나 도발적인 주제가 화두로 떠올랐습니다. 바로 “C는 이제 프로그래밍 언어가 아니다” 라는 주장입니다. 15년 넘게 시스템 프로그래밍과 아키텍처를 다뤄온 입장에서, 처음 이 문장을 접했을 때는 “또 어그로성 글인가?” 싶었습니다. 하지만 내용을 파고들수록 씁쓸하게 고개를 끄덕일 수밖에 없더군요.

오늘은 Aria Beingessner(Gankra)의 글을 바탕으로, 왜 우리가 모던 언어(Rust, Swift 등)를 쓰면서도 여전히 C의 망령에서 벗어날 수 없는지, 그리고 이것이 우리 시스템 아키텍처에 어떤 기술적 부채를 안겨주는지 깊이 있게 이야기해보려 합니다.

C는 언어가 아니라 ‘프로토콜’이다

우리가 새로운 언어, 가령 Bappyscript라는 혁신적인 언어를 만들었다고 가정해봅시다. 이 언어가 실제로 유용한 일을 하려면 무엇을 해야 할까요? 파일 입출력을 하거나 네트워크 통신을 해야 합니다. 즉, OS와 대화를 해야 하죠.

리눅스에서 파일을 열려면 open(2) 시스템 콜을 써야 합니다. 그런데 이 open 함수의 인터페이스는 무엇으로 정의되어 있을까요? 바로 C 입니다.

int open(const char *pathname, int flags);

여러분이 Rust를 쓰든, Python을 쓰든, Swift를 쓰든 상관없습니다. 결국 OS와 대화하려면 여러분의 언어는 C의 옷을 입고(simulate), C처럼 행동해야 합니다. 이것이 바로 FFI(Foreign Function Interface) 의 본질입니다.

What if we kissed and we were both C

위 이미지가 이 상황을 적나라하게 보여줍니다. 서로 다른 언어들이 소통할 때조차, 그들은 각자의 네이티브 인터페이스가 아닌 C ABI(Application Binary Interface)를 기준으로 대화합니다. 즉, C는 프로그래밍 언어로서의 지위를 넘어, 컴퓨팅 세계의 Lingua Franca(공용어) 이자 강제된 프로토콜 이 되어버렸습니다.

기술적 악몽: C와 대화한다는 것

“그냥 C 인터페이스에 맞춰주면 되는 거 아니냐?”라고 반문할 수 있습니다. 하지만 시니어 엔지니어라면 이 작업이 얼마나 끔찍한지 알 겁니다. C와 대화한다는 것은 단순히 함수 이름을 부르는 게 아닙니다.

  1. 헤더 파싱의 불가능성: C 헤더 파일은 기계적으로 파싱하기가 거의 불가능합니다. 매크로(#define), 전처리 구문, typedef 등이 얽혀 있기 때문이죠. 그래서 Rust의 bindgen 같은 도구들도 결국 내부적으로 libclang이라는 거대한 C 컴파일러를 통째로 돌려서 해석합니다.
  2. ABI의 부재: 놀랍게도 C 언어 표준은 ABI를 정의하지 않습니다. int가 몇 바이트인지, 구조체 패딩을 어떻게 넣을지는 아키텍처와 OS, 그리고 컴파일러가 결정합니다.

176개의 방언 (Target Triples)

Rust 컴파일러에서 지원하는 타겟 리스트(rustc --print target-list)를 뽑아보면 176개가 넘는 조합이 나옵니다. x86_64-unknown-linux-gnu, aarch64-apple-darwin… 이 수많은 타겟이 존재하는 이유는 각 환경마다 C 타입의 사이즈와 정렬(Alignment) 규칙이 제각각이기 때문입니다.

심지어 같은 x64 리눅스 위에서도 clanggcc__int128 타입을 처리하는 ABI가 달라서 호환되지 않는 경우도 있습니다. 저자는 abi-checker라는 도구를 돌려보고 이 충격적인 사실을 발견했죠.

“ABIS ARE LIES (ABI는 거짓말이다)”

이것이 저자가 내린 결론입니다. 우리는 견고한 토대 위에 서 있는 게 아니라, 수많은 예외 처리와 관습으로 기워진 누더기 위에 서 있는 셈입니다.

굳어버린 공룡: intmax_t의 비극

C가 프로토콜이 되어버린 것의 가장 큰 문제는 “더 이상 개선할 수 없다” 는 점입니다.

대표적인 예가 intmax_t입니다. 이 타입은 시스템에서 가장 큰 정수를 담기 위해 설계되었습니다. 하지만 대부분의 플랫폼에서 intmax_tint64_t(64비트)로 고정되어 있습니다. 128비트 정수가 나와도 이를 intmax_t로 바꿀 수 없습니다. 왜냐고요?

만약 intmax_t의 크기를 바꾸면, 기존에 컴파일된 모든 바이너리와의 ABI 호환성이 깨져버리기 때문입니다. 리눅스 배포판 전체를 새로 빌드해야 하는 대참사가 벌어지죠. 결국 C는 자신의 성공(압도적인 점유율) 때문에 스스로를 감옥에 가둬버렸습니다.

반면 마이크로소프트의 MINIDUMP_HANDLE_DATA_STREAM 같은 구조체는 설계 단계부터 버전링과 가변 길이를 고려하여(Forward Compatibility) 이런 문제를 유연하게 대처합니다. 하지만 C는 초기에 그런 설계를 하지 못했고, 이제는 너무 늦어버렸습니다.

Hacker News의 반응과 나의 생각

이 글은 Hacker News에서도 뜨거운 논쟁을 불러일으켰습니다. 한 유저는 이렇게 말하더군요.

“목수나 배관공은 300년 된 집에서도 작업할 수 있다. 오직 C만이 그런 수준의 내구성을 제공한다. 대안이 그 정도의 내구성을 증명하기 전까지 C는 대체되지 않을 것이다.”

일리가 있는 말입니다. 하지만 제 생각은 조금 다릅니다. C가 유지되는 건 그게 완벽해서가 아니라, 너무 깊숙이 박혀 있어서 빼낼 수 없기 때문 입니다. 우리는 C가 제공하는 ‘안정성’이 사실은 ‘정체(Stagnation)‘라는 비용을 지불하고 얻은 것임을 인지해야 합니다.

또 다른 유저가 지적했듯, 우리가 실제로 쓰는 건 순수한 C99가 아닙니다. 수많은 컴파일러 확장(__attribute__, #pragma)과 매크로로 떡칠된, 표준과는 거리가 먼 C를 쓰고 있죠.

결론: 엔지니어로서의 자세

이 글을 읽는 CTO나 시니어 엔지니어들에게 전하고 싶은 메시지는 명확합니다.

  1. C는 인프라다: 이제 C를 언어가 아니라 TCP/IP 같은 저수준 프로토콜로 바라봐야 합니다. 좋든 싫든 우리는 이를 준수해야 합니다.
  2. FFI 비용을 과소평가하지 마라: 서로 다른 언어를 섞어 쓰는 MSA나 시스템을 설계할 때, 그 접점(Boundary)에는 항상 C ABI라는 보이지 않는 복잡도가 숨어 있습니다. 데이터 직렬화 비용뿐만 아니라, 이런 ABI 불일치로 인한 런타임 버그 가능성을 항상 염두에 둬야 합니다.
  3. Rust/Swift의 가치: 이들이 C와의 호환성을 위해 들이는 노력은 눈물겹습니다. 하지만 장기적으로는 C의 레거시를 안전하게 추상화해주는 이들 언어로 시스템의 중심을 옮겨가는 것이, 기술 부채를 줄이는 길이라 생각합니다.

결국 C는 세상을 정복했지만, 그 대가로 영원히 변할 수 없는 저주에 걸렸습니다. 우리는 그 저주 위에서 아슬아슬한 곡예를 하고 있는 셈이죠.


References: