rustc의 새로운 기능인 externally implementable items가 어떻게 고안되고 구현되었으며 언젠가 안정화될 수 있을지, 그리고 그 과정이 Rust 프로젝트에서 사람과 협업이 얼마나 중요한지 보여주는 이야기입니다.
작성자:
검토:
Periodic1911
bal-e
wafflelapkin
yara-blue
nia-e
scrabsha
jyn514
게시일: 2026-03-16
읽는 시간: 30분
태그: rust
지난 2년 동안 저는 rustc의 새로운 기능인 "externally implementable items"를 설계하고 구현하는 일에 참여해 왔습니다. 아직 완성된 기능은 아니지만, 원한다면 이미 nightly에서 시험해 볼 수 있습니다! 이 글은 externally implementable items가 어떻게 발명되고, 구현되었으며, 언젠가 어떻게 안정화될 수 있을지에 대한 이야기입니다. 이 글을 읽는 분들 가운데 일부에게는 분명 흥미로울 것입니다.
하지만 이것이 이 블로그 글의 მთავარი 주제는 아닙니다. 대신 이것을 예시로 사용해 Rust 프로젝트가 어떻게 운영되는지 보여주려 합니다. 하나의 변경에 얼마나 많은 사람이 관여하는지, 사람들이 서로에게서 어떻게 배우는지, 저 역시 그들로부터 얼마나 많은 것을 배웠는지 보여주려 합니다. 오픈 소스에서 사회적 상호작용이 얼마나 중요한지 보여주는 한 사례입니다. 코드를 직접 작성하는 것보다도 훨씬 더 중요합니다.
이 글은 제가 Rust in Paris 2026에서 발표한 내용을 글로 옮긴 버전입니다.
목차를 보려면 누르세요
이 이야기는 거의 2년 전, RustNL 2024 직후부터 시작됩니다.
m-ou-se가 Externally Implementable Functions라는 RFC를 올렸고, 활발한 논의가 진행되고 있었습니다. 아이디어는 다음과 같습니다.
Rust에서는 모듈의 순서가 중요하지 않습니다. 함수가 충분히 보이기만 하면(pub 한정자를 사용해), 정의 순서나 모듈 트리 내 위치와 관계없이 어떤 함수든 다른 어떤 함수든 호출할 수 있습니다. 아마 이런 코드를 호출하는 것은 좋은 생각이 아니겠지만, 다음 코드는 Rust에서 실제로 컴파일됩니다:
1fn foo() {2 bar();3}4
5fn bar() {6 foo();7}
이는 예를 들어 C가 동작하는 방식과는 다릅니다. C에서는 전역 스코프가 순서 비의존적이지 않으며, 선언이 위에서 아래로 처리됩니다. 즉, 함수는 선언된 뒤에야 호출할 수 있습니다. 이를 우회하는 방법이 바로 전방 선언입니다. 먼저 정의 없이 함수의 타입만 선언할 수 있습니다. 그러면 다른 함수들은 이 선언을 어떤 타입의 정의가 어딘가에 존재할 것이라는 약속으로 사용할 수 있고, 마지막에 그 선언을 만족하는 정의를 제공할 수 있습니다.
C에서는 앞의 Rust 예제와 비슷한 것을 하려면 다음과 같이 작성해야 합니다:
1// forward declaration, no body2void bar();3
4void foo() {5 bar(); // use the forward declaration6}7
8// definition, with a body9void bar() {10 foo();11}
C에서는 이런 전방 선언을 헤더 파일에 모아두는 것이 일반적이지만, Rust에서는 하나의 크레이트 내부에서는 전역 스코프가 순서 비의존적이기 때문에 헤더 파일이 필요하지 않습니다.
Rust의 전역 스코프는 순서 비의존적이지만, 크레이트 그래프는 그렇지 않으며, 유향 비순환 그래프(DAG)여야 합니다. 이런! 이제 우리는 사실상 C와 똑같은 문제를 갖게 됩니다. 어떤 크레이트가 다른 크레이트가 구현할 함수를 “전방 선언”하고 싶어 할 수도 있다고 생각해 볼 수 있습니다. 하지만 현재 Rust에서는 그렇게 할 수 없고, 바로 이것을 “Externally Implementable Functions”가 extern impl fn을 도입해 해결합니다:
1// In a crate called log2extern impl fn logger() -> Logger;
이것은 다른 크레이트에서 다음과 같이 구현할 수 있습니다:
1// In a dependent of log2impl fn log::logger() -> Logger {3 Logger::to_stdout().with_colors()4}
이 제안은 많은 의견을 불러왔습니다. RFC가 가끔 그렇듯이, Comment를 요청하는 문서가 Rustacean들이 끝없이 댓글을 다는 장이 되어 버렸습니다. RFC PR 안에서도 그랬고, 회의 현장에서도 그랬습니다. 우리 중 많은 사람이 그 컨퍼런스에 함께 있었기 때문입니다. 그리고 불과 며칠 뒤, 이 논의는 Externally Definable Statics라는 또 다른 RFC로 이어졌습니다. 이 RFC는 extern static의 도입을 제안합니다. 이것도 똑같이 강력한데, static 안에는 함수 포인터를 담을 수 있고, 아니면 메서드가 달린 struct를 담을 수도 있기 때문입니다:
1// In a crate called log2extern static LOGGER: Logger;
그러면 다른 크레이트가 이 static에 값을 할당할 수 있습니다:
1// In a dependent of log2impl static log::LOGGER = Logger::to_stdout().with_colors();
어떤 크레이트가 static이든 함수든 형태로 externally implementable item을 정의하면, 이것을 여러 associated item을 가진 trait를 크레이트가 export하는 것으로 볼 수 있습니다. 그리고 downstream 크레이트는 컴파일이 성공하려면 그 trait를 구현해야 합니다. 지금까지의 두 제안 모두 같은 키워드 impl을 사용하기까지 합니다. 그래서 더 많은 논의 끝에 Externally Implementable Traits라는 또 다른 RFC로 세 번째 제안이 나왔고, 여기서는 크레이트가 extern trait를 정의할 수 있습니다:
1// In a crate called log2extern trait GlobalLogger {3 fn logger();4}
그러면 다른 크레이트가 다음과 같이 구현할 수 있습니다:
1extern impl log::GlobalLogger {2 fn logger() -> Logger {3 Logger::to_stdout().with_colors()4 }5}
이 방식의 장점은 externally implementable item들을 한데 묶을 수 있다는 점입니다.
이 시점에서 논의는 조금 포화 상태가 되었다고 생각합니다. 세 제안 중 어느 것이 명백히 더 낫다고 하기는 어려웠고, 차이점의 대부분은 문법적이었습니다. 그리고 실제 구현이 없는 상태였기 때문에, 우리는 주로 사소한 표현을 두고 끝없는 논쟁만 하고 있었고, 그것으로는 아무 데도 갈 수 없었습니다. 그래서 2024년 여름, 이 논의를 완전히 비켜 가기 위해
m-ou-se와 저는 앉아서 네 번째 구현을 고안했습니다. 네, 네, 경쟁하는 표준 이야기죠.
하지만 그 시점에서 더 많은 의견을 요청하는 것은 도움이 되지 않는다고 생각했습니다. 실제로 가지고 놀 수 있는 구현이 있다면, 임의의 절충점을 추측하는 대신 훨씬 더 의미 있는 논의를 할 수 있었을 테니까요.
아마 모르셨겠지만, Rust에는 이미 externally implementable item이 두 가지 있습니다. 예전에 임베디드 개발을 해 보셨다면 분명 마주쳤을 것입니다. panic handler와 global allocator가 그것입니다.
Rust의 모든 프로그램에는 panic handler가 하나 있어야 합니다. 이는 프로그램이 panic할 때 실행되는 함수입니다. panic!()는 core에 정의되어 있지만, core는 panic이 발생했을 때 무엇을 해야 하는지 반드시 알지는 못합니다. 대신 크레이트 그래프 안의 하나의 크레이트가 #[panic_handler] 속성을 붙여 core에 panic handler를 제공해야 합니다. 예를 들면 다음과 같습니다:
1#[panic_handler]2fn my_panic_handler(pi: &PanicInfo) -> ! {3 loop {}4}
임베디드 프로그래밍을 해 보셨다면 이것을 본 적이 있을 수 있지만, 보통 panic handler 구현은 std가 제공합니다. 하지만 core가 알고 있는 한 가지는 panic handler가 가질 함수 시그니처입니다.
그래서 실제로는 panic하는 모든 코드는 미리 정해진 linker symbol을 가진 함수를 호출합니다. rustc가 #[panic_handler]가 붙은 함수를 찾으면, 시그니처가 올바른지, 다른 panic handler가 이미 존재하지 않는지를 검사합니다. 그런 다음 같은 알려진 linker symbol로 그 함수를 생성합니다. 마지막으로 linker가 그 두 심볼을 보고 최종 프로그램이 올바르게 동작하도록 맞춰 줍니다.
그러니까 어떤 의미에서는 core가 panic handler의 전방 선언을 사용하고 있는 셈입니다.
allocator도 #[global_allocator] 속성을 통해 비슷하게 동작합니다. 이 속성은 사실 알려진 linker symbol을 갖는 네 개의 개별 “externally implementable function”에 대응합니다:
1fn __rust_alloc(size: usize, align: usize) -> *mut u8;2fn __rust_dealloc(ptr: *mut u8, size: usize, align: usize);3fn __rust_realloc(ptr: *mut u8, old_size: usize, align: usize, new_size: usize) -> *mut u8;4fn __rust_alloc_zeroed(size: usize, align: usize) -> *mut u8;
panic handler와 global allocator는 둘 다 컴파일러 안에 각각 전용 코드 경로를 가지고 있습니다. 그래서 Externally Implementable Items(이제부터 EII라고 부르겠습니다)를 구현하기 시작한 방식은, 기존의 이 두 코드 경로를 일반화하는 것이었습니다.
이것이 주는 이점은 두 가지입니다. 컴파일러 전역에 흩어진 복잡성이 줄어들고, 이 EII들의 선언이 core 안에서 명시적이 됩니다. 컴파일러 마법이 아니라, 시그니처를 문서화하고 이름으로 가리킬 수 있게 되는 것입니다. 사실 마지막 지점이 중요합니다. EII가 name resolution에 참여하게 만들면 symbol name을 올바르게 맹글링할 수 있습니다. 더 이상 미리 정해진 이름일 필요가 없습니다.
externally implementable function은 본문이 없는 함수 시그니처와, 본문을 제공하는 다른 함수를 표시하는 attribute의 조합입니다. panic handler의 경우 core는 다음과 같이 작성할 수 있습니다:
1#[eii] // <- mark this signature as externally implementable2fn panic_handler(_: &PanicInfo) -> !;
그러면 다른 코드는 다음과 같이 작성할 수 있습니다:
1#[core::panic::panic_handler]2fn my_panic_handler(_: &PanicInfo) -> ! {3 loop {}4}
이 방식의 멋진 점은 기존 panic handler attribute와 하위 호환된다는 것입니다(#[panic_handler]를 prelude에 넣어 기본적으로 core에서 import되게 할 것입니다).
#[eii] 자체는 두 부분으로 확장됩니다. foreign function과 attribute macro입니다:
1unsafe extern "Rust" {2 safe fn panic_handler(_: &PanicInfo) -> !;3}4
5macro panic_handler() { /* compiler generated */ }
이 macro 문법은 낯설 수 있습니다. macro_rules!의 불안정한 대안으로, 더 합리적인 name resolution 동작을 갖습니다. #[eii] attribute가 자동 생성하므로 이 점이 좋습니다. 우리는 core와 std의 몇몇 곳에서도 이 문법을 사용하고 있지만, 영원히 안정화되지 않을 가능성도 있습니다. 이 문법 자체를 안정화하지 않더라도, 컴파일러 내부에서는 attribute macro를 정의하는 일관된 방법을 제공합니다.
Rust는 서로 다른 namespace에 있기만 하면 같은 이름이 여러 번 스코프에 존재하는 것을 허용합니다. 따라서 이제 같은 이름의 macro와 함수가 생긴다는 사실은 실제로 문제가 아닙니다. panic_handler를 호출하면 foreign function을 호출하는 것이고, #[panic_handler]라고 쓰면 macro를 가리키는 것입니다. 하지만 이런 모호함을 명시적으로 피하고 싶다면, macro의 이름을 명시적으로 설정하는 #[eii(macro_name)]도 지원합니다. 예를 들면 다음과 같습니다:
1#[eii(panic_handler)] // <-- still generates the #[panic_handler] attribute2fn call_to_panic(_: &PanicInfo) -> !;
이렇게 하면 macro만 re-export하고 함수는 re-export하지 않거나, 반대로도 가능해집니다.
EII를 올바르게 name resolve하려면 마지막 한 단계가 더 필요합니다. name resolution이 이 macro와 foreign function이 somehow 서로 연결되어 있다는 것을 이해해야 합니다. 그래서 실제 확장은 다음에 더 가깝습니다:
1unsafe extern "Rust" {2 safe fn function_panic_handler(_: &PanicInfo) -> !;3}4
5#[eii_macro_for(function_panic_handler)] // link the two together6macro macro_panic_handler() { /* compiler generated */ }
그런데 #[eii_macro_for()]를 구현하는 일은 간단함과는 거리가 멉니다. 사실 Rust는 다소 근본적으로 attribute 내부에서의 name resolution을 지원하지 않습니다. attribute 자체는 resolve할 수 있지만, 그 내용물은 proc macro가 받는 것과 비슷한 raw tokenstream으로 표현되기 때문에 내부의 것은 resolve되지 않습니다.
Rust에서 name resolution은 모든 identifier에 node id라는 id를 부여하는 방식으로 동작합니다. 그런 다음 이 id로 식별된 참조와 정의를 연결하는 hashmap을 구성합니다. tokenstream 안의 모든 token에 node id를 부여하는 일은 완전히 비현실적이라는 것이 드러났습니다. 컴파일러에서 attribute가 표현되는 방식을 완전히 다시 만들어야 했기 때문입니다.
그래서 자연스럽게도… 정말로 그렇게 했습니다!
RFC 수준의 문구 논쟁을 피하려던 시도는 제가 다뤄 본 것 중 가장 큰 yak 중 하나로 이어졌습니다. 관심이 있다면 저는 2025년에 Eurorust에서 이 프로젝트에 관한 발표도 했습니다.
처음 PR은 구현 작업에 대략 일주일이 걸렸고, 그 뒤에는 이것을 실제로 추진하자고 여러 사람을 설득하는 데 한 달이 걸렸습니다. 여기에는 이 거대한 변경에 적합한 리뷰어를 찾는 일, 하나의 PR로는 누군가에게 리뷰를 요청하기에 너무 클 정도였기 때문에 어떤 순서로 병합할지 정하는 일, 초기 PR들이 실제로는 상황을 일시적으로 더 나쁘게 만들었기 때문에 재작성 자체가 정말 좋은 일이라는 점을 사람들에게 납득시키는 일이 포함되어 있었습니다. 즉, 초기 PR이 병합된 뒤 제가 그냥 사라지지 않을 것이라는 약속도 해야 했습니다.
이 글을 쓰는 시점인 2026년 3월 기준으로, 우리는 마침내 Rust의 대략 200개에 이르는 내장 attribute 전부에 대해 파싱 인프라를 전환했고, 약 250개의 pull request에 걸쳐 20명이 넘는 사람의 공동 노력으로 이를 해냈습니다. 제가 생각하기에 정말 멋진 점은 이 리팩터링이 “good first issue”의 훌륭한 원천이 되었다는 것입니다. 초심자도 해결할 수 있는 작업들이었기 때문입니다. 그 20명 중 여러 명은 이전까지 Rust 컴파일러에 기여한 적이 거의 없거나 전혀 없었습니다. 그리고 attribute 리팩터링을 통해 그중 한 명인
jonathanbrouwer 는 이제 우리 최신 compiler team 멤버 중 한 명이 되어, Rust 컴파일러에서 자신의 프로젝트를 시작하고 있습니다. 다른 첫 기여자들의 다른 기여들도 저는 보아 왔고, 이 결과로 compiler team이 더 커진다 해도 놀라지 않을 것입니다.
저는 여러 이유로 대규모 언어 모델을 그다지 좋아하지 않습니다. 그래도 이런 작은 이슈들 중 일부는 LLM으로 해결할 수 있었을 것이라고 확신합니다. 하지만 분명, 여기서는 같은 효과를 내지 못했을 것입니다! 오픈 소스 기여는 단지 버그를 해결하는 일만이 아닙니다. 공개 프로젝트인 Rust 프로젝트에서는 당연히 사람들의 출입이 있고, 그러다 보면 컴파일러의 어떤 구석을 어떻게 다뤄야 하는지 아는 사람이 한동안 아무도 없는 문제가 생기기도 합니다. 따라서 유지보수를 가능하게 유지하려면 새로운 사람들이 컴파일러를 다루는 방법을 계속 배워야 합니다.
그런 일이 일어나는 순간 중 하나가 바로 pull request 리뷰이며, 저는 이를 작성자와 리뷰어 모두에게 학습 기회라고 봅니다. 저는 rustc 리뷰 로테이션에 추가된 것이 정말 좋았습니다. 이전에는 본 적 없던 컴파일러의 일부를 볼 수 있었기 때문입니다. 작성자가 LLM이거나, 큰 부분에서 LLM에 의존한다면, 이 과정은 완전히 일방적인 것이 됩니다. 리뷰어로서 당신은 사람을 가르치는 것이 아니라 LLM과 대화하게 됩니다. 반대도 마찬가지입니다. 리뷰가 자주 LLM에 의존하면, 그들도 배우지 못합니다.
물론 작성자가 모든 상호작용을 LLM에 맡기는 것과 단지 도구로 사용하는 것은 다릅니다. 예를 들어 boilerplate 코드를 생성하거나, 무언가를 검색해 요약하는 데 쓰는 정도는 말이죠. 하지만 정말로 저는 우리가 모든 boilerplate 코드를 LLM으로 생성하게 되면, 애초에 boilerplate를 타이핑할 필요 자체를 줄이는 도구에 대한 투자를 멈추게 된다고 믿습니다. 그리고 스스로 요약할 때 훨씬 더 많이 배운다는 사실은 반복해서 입증되어 왔습니다. 이는 일반적으로도 사실이지만, 최근에는 TU Eindhoven가 대규모 언어 모델의 맥락에서 이를 보여 주기도 했습니다.
이 이야기가 우리가 그런 방식으로는 운영될 수 없다는 점을 보여 주었기를 바랍니다. Rust 프로젝트든, 사실 어떤 오픈 소스 프로젝트든, 그런 식으로는 유지할 수 없습니다. 우리가 서로에게서 배우는 것을 멈추면 세상은 멈춰 서게 됩니다. 그리고 그래서 저는 다른 누군가가 제 시간 투자로부터 배우고 있다는 것을 알 때 제 시간을 더 기꺼이 투자하게 됩니다. 아마 rustc에서 일하기 전 교사였던 것을 좋아했던 이유도 그 때문인지 모르겠습니다 :3
externally implementable item은 근본적으로 linker 기능입니다. Rust에서는 rustc가 각 크레이트를 하나씩 처리하고, 다음 크레이트에는 그것에 의존하는 크레이트를 위한 메타데이터가 담긴 .rmeta 파일만 전달합니다. 이것이 Rust의 크레이트 그래프가 DAG여야 하는 이유입니다. 그래프를 따라 정보를 다시 위로 전달할 방법이 없습니다.
Rust는 여러 지점에서 이 문제를 다뤄야 합니다. 예를 들어 제네릭에서는 monomorphization이라고 하는 일을 합니다. 모든 generic function을 타입 매개변수로 다시 인스턴스화하고, 다시 코드 생성합니다. upstream 크레이트는 downstream에 어떤 인스턴스화가 존재할지 아직 알 수 없으므로, generic function에 대한 코드 생성을 할 수 없습니다. 따라서 .rmeta 파일이 담는 것 중 하나는 크레이트의 모든 generic function을 어떻게 인스턴스화하고 코드 생성할지에 대한 정보입니다.
EII는 전방 선언과 비슷합니다. upstream 크레이트는 downstream이 그 item을 제공한다고 가정해야 하며, 최악의 경우 모든 것을 함께 링크할 때가 되어서야 그것이 실제로 제공되었는지 알게 됩니다. linker 오류를 피하기 위해, 우리는 구현을 이미 보았는지 여부에 대한 정보를 크레이트 그래프 아래로 전달합니다. 어떤 크레이트가 서로 다른 두 크레이트가 같은 EII를 제공한다는 것을 보면, linker 오류 대신 친절한 Rust 오류를 내게 됩니다.
EII 기본값을 도입하면 상황이 조금 더 까다로워집니다. 다시 logging 예시를 써 보겠습니다. EII 기본값을 사용하는 것은 선언에 본문을 추가하는 것처럼 보입니다:
1#[eii]2fn logger() -> Logger {3 Logger::default()4}
사용자가 EII를 구현하지 않으면 기본값이 사용됩니다. 이것은 global allocator에 필요한 기능입니다. 기본적으로 Rust는 시스템 allocator를 사용하고, 사용자 코드가 이를 override하지 않는 한 계속 그렇습니다.
문제를 더 어렵게 만드는 점은 컴파일 끝에서 “아, 명시적 구현을 못 봤네”라고 판단하고, 그때 기본 함수를 컴파일하기로 결정해야 하는 시점이 필요하다는 것입니다. 처음에는 crate type을 보고 이를 “최종”과 “비최종”으로 분류하면 간단할 것이라고 바랐습니다. 예를 들어 바이너리를 생성하는 크레이트는 최종이므로, 바이너리 크레이트를 컴파일하는데 아직 명시적 구현을 못 봤다면 기본값을 넣으면 된다고 생각한 것입니다.
하지만 다른 crate type을 생각하면 이 방식은 무너집니다. 예를 들어 cdylib는 어떻게 해야 할까요? 이것은 최종일까요? 여전히 다른 코드와 링크할 의도는 있지만, 반드시 Rust 컴파일러와 함께일 필요는 없습니다. 따라서 기본값을 넣지 않으면 다시는 기회가 없을 수 있습니다. 하지만 넣으면 나중에 linker 오류를 일으킬 수도 있습니다.
Rust가 때때로 한 번에 rlib와 cdylib를 둘 다 생성할 수 있다는 점까지 고려하면 더 복잡해집니다. Rust가 둘 중 하나에만 심볼을 넣도록 만드는 것은 매우 어렵습니다. 둘 다이거나, 아니면 둘 다 아예 없는 식입니다.
지금까지의 문제들은 기술적으로는 더 많은 공학적 시간을 들이면 해결 가능한 도전들입니다. 하지만 Rust Week 2025 동안 저는 Rust for Linux의
ojeda와 함께 बैठ었었습니다. 그들은 rustc를 약간 C 컴파일러처럼 사용하고 있었습니다. object file을 생성한 뒤, 그것을 커널의 C 코드와 스스로 링크하는 방식입니다. 적어도 대략 그런 상황이었던 것 같습니다. 또 저는 이것이 다른 곳에서도 관심사라는 것을 알게 되었고,
amanieu가 google도 비슷하게 하고 있다고 말해 주었습니다. 사실 bazel이나 buck2처럼 최종 바이너리 생성 과정을 직접 통제하려는 외부 빌드 시스템을 사용한다면 언제든 이 전략은 작동하지 않습니다. 여기서 Rust와의 중요한 차이는 Rust는 linker를 직접 구동하는 것을 선호한다는 점이고, 외부 빌드 시스템이 이 구동을 맡으면 잘 작동하지 않는다는 것입니다!
그래서 우리의 유일한 선택지는 링크 시점에 EII 기본값을 해결하는 것처럼 보였습니다. 다행히 거의 모든 linker는 weak linker symbol을 지원합니다. weak symbol은 단순히 linker에서 우선순위가 낮게 취급되며, 같은 이름의 non-weak symbol이 발견되면 그것이 선호됩니다. 이렇게 하면 중복 정의 오류를 피할 수 있고, EII 기본값의 동작과도 깔끔하게 맞아떨어집니다.
몇 달 뒤, 저는
cramertj와 이야기하고 있었습니다. 그녀는 EII가 foreign function interface(FFI)와 어떻게 상호작용할지, 특히 C++와의 호환성에 관심이 있었습니다. 우리는 EII가 weak linker symbol로 desugar된다고 보장할 수 있다면 매우 바람직하다는 결론에 도달했습니다. 그러면 C와 C++로 작성된 코드가 EII와 쉽게 상호작용할 수 있고, FFI를 넘어 EII를 구현하는 것까지 쉬워집니다. 왜냐하면 실제 해석은 C와 Rust 코드가 함께 링크될 때 일어나기 때문입니다.
이런 종류의 논의 때문에 각종 Rust 컨퍼런스가 유용하고, 또 5월에 all-hands를 다시 여는 것이 유용합니다(모든 Rust 프로젝트 구성원이 함께 모이는 며칠간의 행사입니다). 게다가 이런 이야기들은 사실 좋은 소식이라고 생각합니다. 1년 전에는 우리가 사소한 문구를 두고 논쟁하며 추측만 했다면, 이제는 최종 구현의 요구사항에 대한 집중된 논의를 하고 있기 때문입니다.
12월 말, 기본값에 대해 weak symbol을 사용하는 이 EII 버전이 컴파일러의 main branch에 병합되었습니다. 즉, nightly에서 사용할 수 있다는 뜻입니다. #![feature(extern_item_impls)]만 추가하면 됩니다. 다만 현재는 linux와 macOS에서만 동작한다는 점은 염두에 두세요. 후자는 Mach-O 바이너리가 하나의 파일 안에 여러 대상용 코드를 담을 수 있기 때문에 조금 더 까다로웠습니다.
이제 기본 구현이 생겼기 때문에, 앞으로 사람들이 이 프로젝트에 기여하기도 훨씬 쉬워졌습니다. 실제로 무엇이 아직 남아 있는지 추적하기 위한 _Tracking Issue_가 rust-lang/rust 저장소에 있습니다. 그리고 이전에 attribute 때와 마찬가지로 TODO 목록을 명확하게 전달하기만 했는데도, 사람들은 실제로 다시 도와주기 시작했습니다. 그리고 저는 다시 한 번, 사람들이 컴파일러의 새로운 부분에 기여하는 법을 배워 가는 모습을 보는 것을 무척 즐기고 있습니다.
그것을 더 장려하기 위해, 12월에 우리는 “compiler office hours”라고 불리는 것을 다시 조직하기 시작했습니다. 컴파일러에서 일하는 사람들은 전 세계에 흩어져 살기 때문에, 리뷰를 제외하면 서로 대화할 일이 그리 많지 않습니다. 같은 사무실에서 일하는 것과 달리, Rust에는 사람들이 오늘 무엇을 했는지 이야기하는 복도가 없습니다.
그래서 이제는 가능하면 누구든 참여할 수 있는 통화를 주 2회 합니다. 온갖 사람들이 참여하고, 그중에는 제가 이전에 한 번도 이야기해 본 적 없는 사람도 있습니다. 매주 같은 사람들이 오는 것은 아니지만, 오히려 그게 재미있습니다. 만날 때마다 저는 사람들이 무엇을 하고 있는지, 어떤 일을 자랑스럽게 생각하는지, 혹은 무엇에 완전히 막혀 있는지를 조금씩 배웁니다. codegen backend, LTO, miri, reflection, never type, automatic differentiation, pattern matching, rustdoc, 또는 그냥 Ben의 고추 화분에 이르기까지 다양한 진척 상황이 오갑니다. EII 기능을 앞으로 어떻게 이어 갈지에 관한 여러 논의도 여기서 이루어졌습니다.
예를 들어 저는
bjorn3와 꽤 정기적으로 이야기를 나눠 왔습니다. Windows용 컴파일, 그리고 일반적인 linker와 code generation에 대한 그의 지식은 정말로 매우 소중했습니다. EII와 관련해 아직 열려 있는 이슈 중 하나는 Windows를 어떻게 처리하느냐입니다. Windows에서도 weak symbol은 지원되지만, 다른 플랫폼과는 약간 다르게 동작합니다.
weak symbol은 linker에 전달되는 flag 안에서 명시적으로 이름이 지정되어야 합니다. 그 flag를 주는 일 자체는 대체로 가능하지만, Windows는 바이너리의 헤더에 linker flag를 넣을 수 있기 때문입니다. 문제는 LLVM이 때때로 심볼 이름을 한 번 더 맹글링할 수 있다는 점입니다. 그리고 linker flag는 LLVM이 최종적으로 생성하는 변경된 이름과 정확히 일치해야 합니다. 그래서 Rust가 같은 맹글링을 수행해 그 심볼 이름을 예측해야 할 가능성이 큽니다.
안타깝게도 global allocator를 EII로 바꾸기 전에 이 문제를 해결해야 합니다. 다만 panic handler는 조만간 EII로 바꿔 볼 수도 있을 것 같습니다. panic handler와의 큰 차이는 panic handler는 기본값을 전혀 필요로 하지 않는다는 점입니다.
office hours는 우리가 동적 링크에 대해 처음 논의한 곳이기도 합니다. Rust의 동적 링크 관련 이야기는 그리 좋지 않습니다. 일반적으로 Rust는 동적으로 링크된 코드에 대해 거의 보장을 제공하지 못합니다. 이론적으로는 동적 로딩이 프로그램 시작 시점(프로그램이 메모리에 올라갈 때)에 일어난다면 일부 완화할 수 있습니다. 하지만 특히 dlopen과 함께라면 불건전성을 만들기 쉽습니다.
사실 Rust의 global allocator를 설정하는 것은 현재 dlopen과 결합되면 실제로 불건전합니다. 한 allocator에서 Box::new로 객체를 할당한 뒤 dlopen된 동적 라이브러리로 넘기고, 거기서 다른 allocator로 해제하는 일이 가능해지기 때문입니다.
하지만 office hours에서 rustc의 query system에 EII를 쓰면 멋지겠다는 이야기가 나왔습니다. 이것은 Rust가 증분 컴파일을 수행할 수 있게 해 주는 추상화 계층입니다. 그런데 EII가 동적 링크를 지원하지 않는다면 문제가 됩니다. 왜냐하면 Rust 컴파일러 자체가 _동적으로 링크된 라이브러리_이기 때문입니다. 직접 확인해 보세요!
Terminal window
1du -h ~/.rustup/toolchains/<toolchain-version>/bin/rustc
저에게는 644K가 나오는데, 컴파일러 전체 크기치고는 너무 작습니다. 그런데 이어서 ldd를 실행해 보면:
Terminal window
1ldd ~/.rustup/toolchains/1.92-x86_64-unknown-linux-gnu/bin/rustc2 linux-vdso.so.1 (0x00007f9e05e1d000)3 librustc_driver-d31eb41759495bb2.so => /home/jana/.rustup/toolchains/1.92-x86_64-unknown-linux-gnu/bin/../lib/librustc_driver-d31eb41759495bb2.so (0x00007f9dfd800000)4 libdl.so.2 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/libdl.so.2 (0x00007f9e05e10000)5 librt.so.1 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/librt.so.1 (0x00007f9e05e0b000)6 libpthread.so.0 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/libpthread.so.0 (0x00007f9e05e06000)7 libc.so.6 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/libc.so.6 (0x00007f9dfd400000)8 libLLVM.so.21.1-rust-1.92.0-stable => /home/jana/.rustup/toolchains/1.92-x86_64-unknown-linux-gnu/bin/../lib/../lib/libLLVM.so.21.1-rust-1.92.0-stable (0x00007f9df3400000)9 libgcc_s.so.1 => /nix/store/r7vdyf8pwlqsgd5ydak9fmq3q1i5nm3m-xgcc-15.2.0-libgcc/lib/libgcc_s.so.1 (0x00007f9e05dd7000)10 libm.so.6 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/libm.so.6 (0x00007f9e05cdf000)11 /nix/store/wb6rhpznjfczwlwx23zmdrrw74bayxw4-glibc-2.42-47/lib/ld-linux-x86-64.so.2 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib64/ld-linux-x86-64.so.2 (0x00007f9e05e1f000)12 libz.so.1 => /nix/store/ri9paa3mri4kqakljak8ldvbcp7lpmif-zlib-1.3.1/lib/libz.so.1 (0x00007f9e05cc1000)
두 번째 항목이 librustc_driver.so인데, 크기가 150MiB입니다. 컴파일러는 그곳에 있습니다. Rust가 이렇게 동작하는 이유는 clippy와 rustfmt도 컴파일러를 호출할 수 있게 하기 위해서입니다. librustc_driver.so 외에도 Rust는 cranelift나 gcc 같은 codegen backend를 동적으로 열기 위해 dlopen을 사용합니다. 따라서 EII가 동적 링크를 지원하지 않는다면 일반적으로는 대체로 괜찮을 수 있어도, 컴파일러 자체에서는 EII를 사용할 수 없게 됩니다.
그리고 office hours는 바로 이런 여러 해결책을 함께 이야기할 수 있는 공간이었습니다.
jyn514와 함께, 우리는 EII를 선택적으로 thread-local로 desugar하고, 기본 구현이든 명시적 구현이든 런타임에 resolve하는 접근법을 떠올렸습니다. 이렇게 하면 어느 정도 성능을 대가로 dlopen을 포함한 완전한 동적 링크 지원을 얻을 수 있습니다!
결국 솔직히 말하면, EII가 영영 안정화되지 않을 가능성도 있습니다. 사실 저는 그래도 괜찮습니다. panic handler와 allocator의 코드 경로를 통합해 주기 때문에, 언젠가는 표준 라이브러리와 컴파일러 안에서 이를 쓰게 될 것이라고 확신합니다. 특정 종류의 panic, 예를 들어 정수 오버플로우에 대한 동작을 바꾸기 쉽게 만들 수도 있을 것입니다. 하지만 이것이 사용자가 직접 상호작용하길 원하는 것인지에 대해서는 RFC 이후에 결정해야 할 것입니다.
하지만 서두에서 말했듯, 이 글은 사실 그것 자체에 관한 글은 아닙니다. 단지 기술적 도전에 관한 글만도 아닙니다. 제가 언급한 것들 중 몇 가지를 흥미롭게 느끼셨을 수도 있겠지만요. 이 모든 과정의 매 단계는, Rust 프로젝트 안팎의 얼마나 많은 사람이 하나의 구현에 도달하는 데 필요한 각종 지식 조각들을 가지고 있는지에 관한 이야기입니다. 그리고 그렇기 때문에 우리가 서로에게서 계속 배우는 이 공동체를 유지하는 것이 얼마나 중요한지, 사람에서 사람으로 배우는 일이 얼마나 중요한지에 관한 이야기입니다.
목차:
작성자:
검토:
Periodic1911
bal-e
wafflelapkin
yara-blue
nia-e
scrabsha
jyn514
게시일: 2026-03-16
읽는 시간: 30분
태그: rust