Rust의 vtable 시각화: dyn Trait의 메모리 동작 원리
Rust 입문자의 실험을 통해 제네릭(정적 디스패치)과 dyn Trait(동적 디스패치)이 메모리에서 어떻게 동작하는지 설명하는 글입니다. C++의 가상 함수와 CRTP와 비교하며, Rust가 이들과 어떤 철학적 차이를 갖는지 다룹니다. vtable 구조를 직접 확인하고 싶은 개발자에게 유용한 심층 분석 자료입니다.
Rust의 vtable 시각화: dyn Trait의 메모리 내 동작 원리
저는 최근 Rust에 발을 들였는데, 만족스러우면서 동시에 머리가 아픈 경험입니다. 지금까지 책과 Mara Bos의 책으로 공부해왔지만, 직접 해부해보고 싶은 욕구가 생겼습니다. 이 실험의 초기 목표는 Rust의 다형성 접근 방식을 C++와 비교하는 것이었습니다. 하지만 결국 깨달은 것은, 새 언어를 배울 때 다른 언어와 1:1 대응을 그리려는 시도는 함정이 될 수 있다는 점입니다. 도움이 되는 것처럼 보이지만, 결국 Rust를 '문법만 다른 C++'로 취급할 수는 없습니다. 만약 그렇다면 혁명적인 점이 없겠죠. 그래도 이리저리 뜯어보며 '왜'를 이해하는 데는 가치가 있다고 믿습니다. 저처럼 개념을 진정으로 이해하려면 메모리에서 정확히 무슨 일이 일어나는지 알아야 직성이 풀리는 분이라면 이 글이 유용하길 바랍니다 :)
참고로, 썸네일 이미지는 녹병균(rust fungus) 사진인데, Rust라는 이름이 여기서 유래했습니다. 크레딧: gailhampshire from Cradley, Malvern, U.K, CC BY 2.0, via Wikimedia Commons. 모든 코드와 실험은 GitHub에서 찾을 수 있습니다.
서론: 핵심 문제
달성하려는 것은 아주 간단합니다. 원, 사각형, 삼각형 등 여러 도형이 있고, 각각에 draw()를 호출하고 싶다고 합시다.
C++ 접근법 #1: 가상 함수(virtual functions)
C++에서 가장 먼저 떠오르는 방법은 가상 함수를 통한 런타임 다형성입니다. vtable 포인터가 객체 내부에 존재하고, 가상 디스패치가 자동으로 일어납니다.
std::vector<Shape*> shapes = { new Circle(), new Square() }; for (auto* s : shapes) s->draw();
Rust의 동등한 기능은 dyn Trait인데, 이것이 궁극적으로 이해하고자 하는 대상입니다. 하지만 먼저 C++에서 이 문제를 해결할 수 있는 다른 방법을 살펴보겠습니다.
C++ 접근법 #2: CRTP
CRTP(Curiously Recurring Template Pattern, 신기하게 재귀하는 템플릿 패턴) 방식, 즉 컴파일 타임 다형성을 사용할 수도 있습니다. 관심이 있다면 Klaus Iglberger의 훌륭한 강연이 이 주제에 대한 저의 첫 입문이자 계속 참고하는 자료입니다.
template
본질적으로 vtable이 없고 컴파일 타임에 해결되며, 가독성을 희생합니다(정말 장황합니다). Rust는 CRTP에 해당하는 훨씬 더 간단명료한 기능, 즉 모노모피제이션(monomorphization)을 제공합니다. Rust가 제공하는 것에 대한 mental model을 구축하기 위해 먼저 이 접근법을 파헤쳐 보겠습니다.
정적 디스패치(Static Dispatch)
정적 디스패치, 일명 제네릭(generics)은 CRTP와 유사한 결과를 달성합니다. 컴파일러가 호출되는 각 타입마다 함수의 별도 복사본을 생성합니다. 런타임 비용은 0이지만, 타입이 컴파일 타임에 알려져야 합니다.
trait Draw { fn draw(&self) -> &str; }
struct Circle; struct Square;
impl Draw for Circle { fn draw(&self) -> &str { "Drawing a circle" } }
impl Draw for Square { fn draw(&self) -> &str { "Drawing a square" } }
fn draw_shape<T: Draw>(shape: T) { println!("{}", shape.draw()); }
fn main() { let circle = Circle; let square = Square; draw_shape(circle); draw_shape(square); }
내부적으로 컴파일러는 draw_shape::
C++의 템플릿과 어떻게 다를까요? 차이는 철학에 있습니다. C++는 제약을 암시적으로 만듭니다. 템플릿은 우연히 .draw() 메서드를 가진 모든 타입 T를 받아들입니다. 반면 Rust에서는 계약을 명시적으로 타이핑합니다. 즉, "Square에 대해 Draw 트레이트를 구현한다"는 것이죠.
그렇다면 이것으로 충분하지 않은 순간은 언제일까요? 이 질문으로 넘어가기 전에, 잠시 사이드 퀘스트를 즐겨봅시다.
사이드 퀘스트: Rust의 크기 없는 타입(Zero-Sized Types)
(본문이 여기서 잘립니다)