C 언어의 구원투수가 될까? Clang의 -fbounds-safety 심층 분석


최근 Hacker News와 LLVM 커뮤니티에서 C 언어의 고질적인 문제인 메모리 안전성을 해결하기 위한 흥미로운 제안이 다시금 주목받고 있습니다. 바로 -fbounds-safety 입니다. “Rust로 다시 짜라(Rewrite it in Rust)“는 말이 밈(Meme)이 되어버린 시대지만, 현실의 엔지니어링은 그렇게 단순하지 않습니다. 수백만 라인의 레거시 C 코드를 하루아침에 언어를 바꿔버릴 수는 없으니까요.

오늘은 Apple이 주도하고 있고, LLVM 업스트림을 목표로 하는 이 기능이 과연 ‘C의 수명 연장’을 위한 실질적인 해법이 될 수 있을지, 아니면 그저 또 하나의 복잡한 확장에 불과한지 Principal Engineer의 관점에서 깊게 파헤쳐 보겠습니다.

왜 지금 -fbounds-safety인가?

C 언어 보안 취약점의 대다수는 Out-of-Bounds (OOB) 접근에서 발생합니다. 버퍼 오버플로우는 너무나 고전적이지만 여전히 가장 치명적인 공격 벡터입니다. 기존에도 ASan(AddressSanitizer) 같은 도구가 있었지만, 런타임 오버헤드가 너무 커서 프로덕션 환경에 배포하기엔 무리가 있었습니다.

-fbounds-safety 의 목표는 명확합니다:

  • 런타임 오버헤드 최소화: 프로덕션 레벨에서 사용 가능해야 함.
  • ABI 호환성 유지: 기존 바이너리와 링크가 가능해야 함.
  • 점진적 도입: 코드 전체를 한 번에 수정할 필요가 없어야 함.

이것은 단순한 정적 분석 도구가 아닙니다. 컴파일러가 직접 코드를 변형하여 런타임 체크를 삽입하는 언어 확장(Language Extension) 입니다.

기술적 심층 분석: 어떻게 작동하는가?

이 제안의 핵심은 ‘포인터에 경계(Bounds) 정보를 어떻게 효율적으로 붙일 것인가’ 에 있습니다. 여기서 엔지니어링적인 타협과 영리함이 돋보입니다.

1. ABI 경계에서의 명시적 어노테이션

함수 파라미터나 구조체 필드처럼 ABI에 영향을 주는 곳에서는 개발자가 직접 어노테이션을 달아야 합니다. 마이크로소프트의 SAL(Source Annotation Language)과 비슷해 보이지만, 컴파일러가 강제한다는 점이 다릅니다.

void foo(int *__counted_by(N) ptr, size_t N);

위 코드에서 ptrN개의 요소를 가진다는 것을 명시합니다. 컴파일러는 이 정보를 바탕으로 ptr에 접근할 때마다 bounds check 코드를 삽입합니다. 만약 범위를 벗어나면? Deterministic Trap 을 발생시켜 프로그램을 즉시 중단시킵니다. 보안 관점에서는 뚫리는 것보다 죽는 게 낫기 때문입니다.

2. 로컬 변수에서의 ‘암시적 Wide Pointer’

제가 이 제안에서 가장 감탄한 부분은 바로 로컬 변수 처리 방식입니다. 함수 내부의 로컬 포인터 변수는 ABI에 영향을 주지 않습니다. 따라서 컴파일러는 개발자의 개입 없이 이 포인터들을 Wide Pointer(Fat Pointer) 로 변환해 버립니다.

즉, int *p라고 선언해도 내부적으로는 다음과 같은 구조체처럼 취급됩니다:

struct wide_pointer {
    void* pointer;      // 실제 주소
    void* upper_bound;  // 상한선
    void* lower_bound;  // 하한선 (선택적)
};

이것을 __bidi_indexable 이라고 부릅니다. 개발자가 일일이 어노테이션을 달지 않아도, malloc 등으로 할당된 크기 정보를 포인터가 들고 다니며 자동으로 검사합니다. 이는 Rust의 슬라이스(Slice)와 유사한 안전성을 C의 문법 그대로 제공하려는 시도입니다.

3. ABI 호환성의 마법

가장 큰 난관은 “기존 라이브러리와 어떻게 섞어 쓸 것인가”입니다. -fbounds-safety는 이를 위해 ABI Visibility 에 따라 기본동작을 달리합니다.

  • ABI에 노출되는 포인터 (파라미터 등): 기본적으로 __single로 취급 (단일 객체 포인터). 포인터 연산 불가능.
  • ABI에 노출되지 않는 포인터 (로컬 변수): 기본적으로 __bidi_indexable로 취급 (Wide Pointer). 자유로운 연산 가능.

이 전략 덕분에 기존 라이브러리를 호출할 때는 일반 포인터로 변환(decay)되어 전달되고, 내 함수 안에서는 안전한 Wide Pointer로 동작합니다. 물론 이 과정에서 경계 정보를 잃어버릴 수 있으므로, 필요한 경우 __unsafe_indexable 등을 통해 명시적으로 “나 이거 위험한 거 알아”라고 컴파일러에게 알려줘야 합니다.

실제 코드에서의 모습

문서에 나온 예제를 보면, malloc과 같은 할당 함수와의 연동이 매우 자연스럽습니다.

// malloc은 __sized_by(size)를 반환한다고 정의됨
void *__sized_by(size) malloc(size_t size);

int *__counted_by(n) get_array(size_t n) {
    // buf는 로컬 변수이므로 암시적으로 Wide Pointer가 됨
    // malloc의 리턴값에서 경계 정보를 자동으로 가져옴
    int *buf = malloc(sizeof(int) * n);

    for (size_t i = 0; i < n; ++i)
        buf[i] = i; // 안전함: 컴파일러가 bounds check 삽입

    return buf; // 리턴할 때 Wide Pointer -> __counted_by 포인터로 변환
}

이 코드는 C 개발자에게 매우 익숙한 패턴입니다. 추가적인 문법적 노이즈가 거의 없습니다.

Hacker News와 커뮤니티의 반응

이 주제에 대해 HN에서는 뜨거운 논쟁이 있었습니다. 주요 반응을 정리하고 제 생각을 덧붙여 봅니다.

  1. “왜 그냥 C++ std::span을 쓰지 않는가?”

    • 많은 유저들이 “C++에는 이미 있는 기능”이라며 의문을 제기했습니다. 하지만 임베디드나 커널, 레거시 시스템에서는 C++ 런타임을 도입하는 것이 불가능한 경우가 많습니다. C 언어 자체의 안전성을 높이는 것은 여전히 유효한, 아니 필수적인 과제입니다.
  2. “애플은 이미 쓰고 있다”

    • anon 유저의 지적처럼, 이 기능은 Xcode(AppleClang)에는 이미 탑재되어 있습니다. 수백만 라인의 프로덕션 코드에서 검증되었다는 점은 큰 신뢰를 줍니다. 단순한 실험실 프로젝트가 아니라는 뜻이죠.
  3. “업스트림이 너무 느리다”

    • 이게 가장 큰 문제입니다. LLVM 메인라인에 병합되는 속도가 매우 더딥니다. 현재 공개된 스펙은 Apple의 포크 버전을 기반으로 하고 있으며, 일반 리눅스 배포판의 Clang에서 -fbounds-safety를 쓰려면 꽤 오랜 시간을 기다려야 할지도 모릅니다.

나의 생각: C의 수명 연장인가, 희망 고문인가?

솔직히 말해, 저는 이 기능에 대해 매우 긍정적 입니다. 15년 넘게 C/C++ 코드를 다뤄온 입장에서, “모든 것을 Rust로 재작성”하는 것은 경제적으로 불가능한 판타지에 가깝습니다. 우리에게는 점진적인 개선책이 필요합니다.

-fbounds-safety의 접근 방식은 매우 실용적(Pragmatic)입니다:

  • 타협점: 100%의 메모리 안전성을 보장하진 않습니다 (Use-after-free 등은 못 잡음). 하지만 가장 빈번한 OOB를 잡습니다.
  • 비용: 약간의 코드 수정(어노테이션)과 런타임 오버헤드가 있지만, ASan보다는 훨씬 가볍습니다.

하지만 우려되는 점도 있습니다. 문법이 다소 장황해질 수 있다는 점(__counted_by 등으로 도배된 헤더 파일), 그리고 무엇보다 표준화 문제입니다. 이것이 Clang만의 독자 규격으로 남는다면, GCC와의 호환성을 중시하는 오픈소스 프로젝트들은 도입을 꺼릴 것입니다.

결론

-fbounds-safety는 C 언어가 현대적인 보안 요구사항을 맞추기 위해 내놓은 가장 현실적인 대답 중 하나입니다. 특히 로컬 변수를 Wide Pointer로 자동 승격시키는 아이디어는 ABI 호환성과 안전성이라는 두 마리 토끼를 잡은 영리한 설계입니다.

만약 여러분이 Apple 플랫폼에서 C 개발을 하고 있다면 지금 당장 시도해 볼 가치가 있습니다. 하지만 리눅스나 크로스 플랫폼 환경이라면? 아직은 조금 더 지켜봐야 할 것 같습니다. C는 아직 죽지 않았습니다. 다만 조금 더 ‘안전벨트’가 필요할 뿐입니다.