GC를 왜 또 얘기하냐고요?
가비지 컬렉터(Garbage Collector, 이하 GC)를 직접 구현한다고 하면, 보통 두 가지 반응이 나와요. 하나는 '그걸 왜 직접 만들어?' 그리고 다른 하나는 '그거 포인터 난장판 아니야?'. Nick Fitzgerald(Wasmtime 커미터이자 Mozilla 출신 엔지니어)의 이 글은 두 번째 반응에 정면으로 반박하는 내용이에요. Rust의 unsafe 코드를 한 줄도 쓰지 않고 GC를 만들 수 있다는 걸 실제 구현으로 보여주거든요.
이게 왜 흥미로운지 배경을 좀 풀어드리면, 전통적으로 GC 구현은 포인터 조작의 끝판왕 이었어요. 메모리 블록 주소를 직접 계산하고, 비트를 마스킹해서 태그를 심고, 객체를 옮기면서 모든 참조를 갱신하는 작업이니까요. 그래서 C/C++이나 Rust의 unsafe 블록에서 raw pointer를 다루는 게 당연하다고 여겨졌죠. 근데 Fitzgerald는 '사실 그럴 필요 없어'라고 말하는 거예요.
핵심 아이디어 - 인덱스가 포인터다
접근법을 쉽게 설명드리자면, heap을 하나의 큰 Vec<Object>로 표현하고, 객체 참조는 그 Vec의 인덱스(usize) 로 다루는 거예요. *mut Object 대신 GcRef(usize) 같은 작은 구조체를 쓰는 거죠. 이게 왜 안전하냐면, Vec은 경계 검사(bounds check)를 해주고, 잘못된 인덱스 접근 시 패닉을 내거나 Option을 반환하거든요. 세그폴트가 아니라 제어된 에러가 나는 거예요.
글에서는 이걸 바탕으로 mark-and-sweep(표시-수거) GC를 단계별로 만들어가요. mark 단계에서는 루트 객체에서 시작해서 참조 그래프를 따라가며 살아 있는 객체에 표시를 하고, sweep 단계에서는 표시 안 된 객체의 슬롯을 free list에 넣어 재사용해요. 코드를 보면 생각보다 간결해요. 모든 객체가 Vec<Object>의 한 슬롯을 차지하고, 슬롯은 Option<Object> 비슷한 enum으로 '살아있음/비어있음'을 표현하거든요.
그런데 여기서 걸리는 문제가 하나 있어요. 루트(root) 추적을 어떻게 하느냐예요. 루트라는 건 스택이나 전역 변수에서 heap을 가리키는 참조들을 말하는데, 이걸 놓치면 '쓰이고 있는데 치워져 버리는' 끔찍한 버그가 생겨요. 일반적인 GC 구현은 스택을 스캔하는 저수준 마술을 쓰는데, safe Rust에서는 불가능해요. Fitzgerald의 해법은 RootSet 이라는 명시적 컨테이너예요. 루트로 쓰고 싶은 참조는 반드시 이 RootSet에 등록해야 하고, RAII 패턴으로 스코프가 끝나면 자동으로 해제돼요. 불편해 보이지만 '잊어버려서 생기는 버그' 를 타입 시스템으로 원천 차단하죠.
성능은 괜찮은가
당연히 떠오르는 의문이죠. Vec 인덱싱은 포인터 역참조보다 느리지 않나? 이론적으로는 약간의 오버헤드가 있어요. 매번 배열 시작 주소에 인덱스를 더해서 주소를 계산해야 하고, bounds check도 들어가니까요. 그런데 실제로는 차이가 생각만큼 크지 않아요. 현대 CPU는 이런 단순 산술을 거의 무료로 처리하고, LLVM 옵티마이저가 bounds check를 대부분 제거해주거든요.
더 중요한 건 캐시 지역성(cache locality) 면에서 오히려 유리할 수 있다는 점이에요. 모든 객체가 하나의 연속된 Vec에 모여 있으니, malloc으로 여기저기 흩뿌려진 객체들보다 캐시 히트율이 높아요. 특히 GC의 mark 단계처럼 많은 객체를 순회할 때 유리하죠.
물론 프로덕션급 GC(V8의 Orinoco, JVM의 G1/ZGC 같은)에 비하면 한참 부족해요. 이들은 generational(세대별 수집), concurrent(동시 수집), compacting(압축) 같은 고급 기법을 쓰고, 수십 년의 최적화가 쌓여 있거든요. Fitzgerald의 접근법은 '안전하게 교육용/임베디드용 GC를 만들 수 있다'는 증명에 가까워요.
업계 맥락 - 왜 지금 safe GC 이야기가 중요한가
요즘 WebAssembly(Wasm) 에서 GC 지원이 큰 이슈예요. Wasm GC proposal이 2023년에 표준화되면서, Kotlin, Dart, Scheme 같은 GC 기반 언어들이 Wasm으로 컴파일되고 있거든요. 이런 런타임을 구현하는 Wasmtime 같은 프로젝트는 Rust로 작성된 호스트에서 GC를 돌려야 해요. 여기서 safe Rust로 GC를 짤 수 있다는 건 실무적으로 엄청난 의미가 있어요.
비슷한 시도로는 gc-arena(Starlight), rust-gc, shredder 같은 크레이트들이 있어요. 각자 접근법이 조금씩 달라서, gc-arena는 lifetime을 이용한 정교한 타입 트릭을 쓰고, rust-gc는 Rc 비슷한 스마트 포인터 기반이에요. Fitzgerald의 방식은 가장 단순하고 교육적이라는 점에서 입문자에게 좋고, 실제로 Wasmtime의 GC 구현에도 비슷한 아이디어가 녹아 있어요.
한국 개발자에게 주는 시사점
직접 GC를 만들 일은 거의 없겠지만, 이 글에서 가져갈 교훈은 많아요. '저수준 기능은 unsafe로만 구현 가능하다'는 통념을 깨는 사고방식이에요. 비슷하게, 우리가 실무에서 '어쩔 수 없이 reflection을 써야 해', '이건 Any 타입이 아니면 안 돼' 하고 넘어가는 경우들, 다시 한번 '타입 시스템 안에서 풀 수 있지 않을까?' 고민해볼 가치가 있어요.
또 Rust를 공부하시는 분이라면 이 글은 훌륭한 중급 학습 자료예요. ownership, borrowing, lifetime, trait object 같은 개념이 실제 복잡한 데이터 구조에서 어떻게 조합되는지 보여주거든요. 인터프리터나 작은 VM을 만들어보고 싶은 분들에게도 딱 좋은 출발점이 될 거예요.
마무리
포인터 대신 인덱스로, unsafe 대신 명시적 root set으로 GC를 만들 수 있다는 걸 보여준 글이었어요. '안전성과 저수준 제어는 양립할 수 있다'는 Rust의 핵심 가치를 잘 드러내는 사례이기도 하고요.
여러분은 어떠세요? 성능 약간을 포기하더라도 safe Rust를 고수하는 게 맞다고 보세요, 아니면 상황에 따라 unsafe가 더 합리적일 때도 있다고 생각하세요?
🔗 출처: Hacker News
"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"
실제 수강생 후기- 비전공자도 6개월이면 첫 수익
- 20년 경력 개발자 직강
- 자동화 프로그램 + 소스코드 제공