Rust에서 main 함수 이전의 생명주기
이 글은 Rust 프로그램이 실행될 때 개발자가 작성한 main 함수가 호출되기 전, 런타임과 운영체제 수준에서 어떤 일이 일어나는지 심도 있게 다룹니다. C 런타임을 기반으로 구축된 Rust 런타임의 초기화 과정을 설명하며, 이 전처리 단계를 활용해 가변 데이터(Mutable data) 등을 다루는 새로운 기법을 소개합니다. Rust 바이너리의 저수준 동작 원리와 부트스트래핑(Bootstrapping) 환경의 중요성을 이해하려는 시스템 프로그래머에게 유용한 통찰을 제공합니다.
투명한 정보 공개 🧠 이 글은 100% 인간이 직접 작성했습니다. Claude는 피드백 및 링커 심볼 다이어그램 작성을 보조하는 데 사용되었으며, Cursor는 피드백 및 예제 코드가 컴파일되도록 검증하는 데 사용되었습니다. 이 글의 저자는 'main 이전의 생명주기(life-before-main)'라는 주제에 깊은 관심을 가지고 있으며, 아래 예제에서 사용할 'ctor' 크레이트(crate)의 저자이자 'linktime' 프로젝트의 창시자이기도 합니다.
모든 Rust 바이너리에는 한 가지 공통점이 있습니다. 바로 fn main() 입니다. C 언어를 다뤄보셨다면 int main(argc, argv)라는 형태가 더 익숙하실 것입니다. 일부 플랫폼에서는 이를 조금 더 감출 수도 있지만, 내부적으로 모든 바이너리는 진입점(Entrypoint)을 가지고 있습니다. 우리는 main 함수가 호출되기 전에 어떤 일이 일어나는지, 그리고 그 시점에 우리가 어떤 흥미로운 작업들을 할 수 있는지 논의할 것입니다. 더불어, 현재 Rust 생태계에서 흔히 사용되지 않는 가변 데이터(Mutable data)를 다루기 위한 몇 가지 새로운 기법도 보여줄 것입니다.
이 글은 Rust 소스 코드가 Rust 바이너리가 되는 기술적인 세부 사항에 대한 심층 분석입니다. 독자에게 Rust의 참조(References), Unsafe Rust 등의 배경 지식이 있으면 이해하는 데 도움이 될 것입니다.
main 이전의 과정 (Before main) 대부분의 개발자들에게 익숙하지 않은 부분은 바로 어떻게 main 함수 안으로 진입하게 되는지 하는 점일 것입니다. 모든 언어의 내부에는 런타임(Runtime)이 존재합니다. C 언어에는 libc로 잘 알려진 C 런타임이 있습니다. Rust에도 자체적인 런타임인 Rust 표준 라이브러리가 있습니다. 그리고 실행 가능한 코드의 런타임 표준 언어(링구아 프랑카)가 대부분 C 언어이기 때문에, Rust는 C의 런타임 위에 자체 런타임을 구축합니다. 즉, C의 런타임을 캡슐화하는 더 높은 수준의 추상화 계층을 효과적으로 만드는 것입니다.
런타임을 정의하기란 조금 모호할 수 있습니다. 디스크에 존재하는 실행 가능한 코드이기도 하며, 컴파일 타임에 사용되는 컴파일 가능한 헤더와 라이브러리이기도 합니다. 하지만 런타임의 목적은 항상 같습니다. 바로 개발자가 작성한 코드를 플랫폼의 운영체제와 통합하는 것입니다.
개발자가 main으로 선언한 함수가 시작되기 전에도 처리해야 할 거대한 생태계가 존재합니다. C는 이 전단계를 사용하여 메모리 할당, 파일 접근, 스레드 로컬 저장소(Thread-local storage) 및 기타 C 런타임 서비스를 구성합니다. Rust 역시 이 시간을 활용해 자체 언어 및 런타임의 일부를 구성합니다. 구체적으로, Rust는 패닉(Panic) 및 스택 되감기(Unwinding)를 처리하는 인프라를 갖추고 있습니다. 또한 C 스타일의 프로그램 인자를 Rust 자체의 std::env::args 인터페이스로 변환해야 합니다. 이 모든 메커니즘은 Rust 컴파일러 프로젝트에서 확인할 수 있습니다.
런타임이 이러한 main 호출 전 단계(Pre-main phase)를 활용하는 이유는, 이 단계가 (1) 사용자 코드보다 먼저 실행된다는 것을 보장하며, (2) 안정적이고 결정론적인 초기화를 가능하게 하는 단일 스레드 기반의 높은 일관성과 예측 가능한 순서를 가진 환경을 제공하기 때문입니다. 이 환경을 활용하지 않는다면, 당신은 매우 유용한 부트스트래핑(Bootstrapping) 단계를 낭비하고 있는 셈입니다. 이 글의 뒷부분에서는 이 'main 이전의 생명주기'를 활용해 유용한 기본 요소(Primitives)들을 어떻게 구축할 수 있는지 살펴볼 것입니다.
진입점 (Entry Points) 바이너리는 운영체제의 로더(Loader) - 바이너리를 메모리에 로드하고 환경을 설정하는 OS의 일부 - 가 제어권을 넘겨줄 때 시작됩니다. 런타임은 로더로부터 이 제어권을 넘겨받는 역할을 담당합니다. 모든 운영체제에는 제어권을 넘겨받기 위한 플랫폼별 후크(Hook)가 존재하며, 어떤 면에서 이것이 진짜 main이라고 할 수 있습니다. Linux에서는 ELF 헤더의 e_entry 필드에 진입점이 저장되며, 기본적으로 링커(Linker)는 _start라는 이름의 심볼 주소를 이곳에 배치합니다. Windows에도 유사한 후크가 존재하며, _WinMainCRTStartup이라는 이름의 함수에서 실행 파일을 부팅합니다.
이 시점에서 C 런타임은 자신을 구성(Configuring)할 기회를 갖게 되며, 모든 런타임이 이 작업을 수행하는 방식은 바로 초기화 함수(Initialization functions)를 통하는 것입니다. 초기 런타임에서 부트스트래핑은 '파일 I/O 초기화, 할당자(Allocator) 초기화' 등과 같은 정적인 트리(Tree) 형태의 함수 호출이었습니다. 런타임이 점점 더 복잡해지면서 이러한 함수 호출 트리 역시 복잡해졌고, 필요하지 않을 수도 있는 더 많은 C 런타임 기능을 흡수하면서 바이너리 크기도 함께 증가했습니다.
시간이 지나면서 링커는 사용하지 않는 코드(사용되지 않는 C 런타임 부분 포함)를 바이너리를 디스크에 기록하기도 전에 폐기(Discard)하는 능력을 개발했습니다. 그리고 이 과정과 함께 정적 초기화 호출 트리를 대체할 수 있는 방법의 필요성이 대두되었습니다.