처리중입니다. 잠시만 기다려주세요.
TTJ 코딩클래스
정규반 단과 자료실 테크 뉴스 코딩 퀴즈
테크 뉴스
Hacker News 2026.09.06 26
#AI

러스트 dyn Trait는 메모리에서 어떻게 동작하는가: C++ 개발자의 해부 실험

Hacker News 원문 보기

러스트의 다형성은 C++를 오래 다룬 개발자에게 낯선 경험을 준다. 한 C++ 개발자가 러스트를 배우며 남긴 실험 기록은, 문법이 비슷해 보이는 기능들이 실제로는 전혀 다른 철학 위에 서 있음을 메모리 수준까지 파고들어 보여준다. 원, 사각형, 삼각형 같은 도형 컬렉션에 각각 draw()를 호출한다는 단순한 문제에서 출발하지만, 그 답을 찾는 과정에서 정적 디스패치와 동적 디스패치, 와이드 포인터, 제로 크기 타입, 트레잇 오브젝트의 제약이 차례로 드러난다. 흥미로운 점은 저자 스스로 "새 언어를 기존 언어로 1:1 대응시켜 이해하려는 시도는 함정"이라고 지적한다는 것이다. 러스트를 문법만 다른 C++로 취급하면 그 언어가 왜 다른지를 결국 놓치게 된다.

정적 디스패치와 단일화(monomorphization)

러스트에서 가장 비용이 낮은 방식은 제네릭을 통한 정적 디스패치다. 컴파일러가 호출되는 타입마다 함수 사본을 따로 만들어내는데, 예를 들어 draw_shape::과 draw_shape::가 각각 생성된다. 런타임 비용은 0이지만 타입이 컴파일 시점에 확정되어야 하고, 사본이 늘어나는 만큼 코드 크기가 커진다. 이는 C++의 CRTP(Curiously Recurring Template Pattern)가 다소 번거로운 우회로로 달성하던 컴파일 타임 다형성을, 러스트가 언어 차원의 기본 기능으로 제공하는 셈이다. 철학적 차이도 분명하다. C++ 템플릿은 ".draw() 메서드를 가진 임의의 타입 T"를 암묵적으로 받아들이는 반면, 러스트는 "Square에 Draw 트레잇을 구현한다"고 계약을 명시적으로 적어야 한다.

저자는 이 대목에서 예상 밖의 발견을 한다. 필드가 없는 빈 구조체의 크기를 재보니 러스트는 0을 반환했다. C++ 표준은 서로 다른 객체가 항상 서로 다른 주소를 갖도록 모든 객체에 최소 1바이트를 보장한다. 즉 C++은 객체의 정체성을 런타임의 메모리 주소로 추적한다. 반면 러스트의 제로 크기 타입(ZST)은 메모리를 할당할 필요가 없다. 러스트는 정체성을 주소가 아니라 소유권으로 추적하며, 모든 값은 한 시점에 정확히 하나의 소유자를 가진다는 규칙을 컴파일 타임에 빌림 검사기가 강제하기 때문이다. ZST의 주소를 실제로 찍어보면 디버그 모드에서는 디버거가 변수를 참조로 추적할 수 있도록 더미 스택 슬롯이 배정되어 1바이트 간격으로 나타나지만, 릴리스 모드에서는 주소가 하나로 겹친다. 결국 컴파일러는 ZST의 주소에 대해 어떤 보장도 하지 않는다.

와이드 포인터와 동적 디스패치

동적 디스패치 버전의 코드는 정적 버전과 거의 똑같아 보인다. 차이는 대신 &dyn Draw를 쓴다는 것뿐이다. 그러나 내부는 근본적으로 다르다. &dyn Draw의 크기는 일반 포인터의 두 배인데, 이것이 바로 와이드 포인터다. 하나는 데이터를, 다른 하나는 vtable을 가리키며, 이 vtable이 런타임에 어떤 draw()를 호출할지 알려준다. std::mem::transmute로 &dyn Draw를 (usize, usize)로 비트 단위 복사해 들여다보면, 같은 타입의 객체들은 vtable을 공유하고 데이터 포인터만 인스턴스마다 바뀐다는 사실을 확인할 수 있다. 이 변환이 unsafe를 요구하는 이유는 임의의 비트 패턴이 대상 타입의 유효한 값을 이룬다고 컴파일러가 보장할 수 없기 때문이다.

그렇다면 정적 디스패치로 충분하지 않은 순간은 언제인가. Circle과 Square를 한 Vec에 섞어 담으려는 순간 벽에 부딪힌다. Vec는 모든 원소가 정확히 같은 타입과 크기를 가져야 하지만, 서로 무관한 두 타입은 크기도 다를 수 있고 러스트에는 C++ 같은 기반 클래스도 없다. 여기서 Box가 해법이 된다. Box는 값을 힙에 할당하고 포인터로 소유하는데, 그 자체가 와이드 포인터이므로 담기는 실제 타입과 무관하게 항상 같은 크기를 갖는다.

디스패치를 결정하는 지점도 두 언어가 다르다. C++에서는 메서드에 virtual을 붙이는 순간 그 클래스는 항상 동적 디스패치를 쓰고, vtable 포인터가 객체 안에 박힌다. 러스트에서는 선택이 호출 지점에서 이뤄진다. Circle은 그저 Circle일 뿐 디스패치에 대해 아무것도 모르며, &Circle로 참조하면 정적, &dyn Draw로 참조하면 동적이 된다. 한 타입이 여러 트레잇을 구현할 때 이 구조가 더 선명해진다. Fly와 Swim을 모두 구현한 Duck을 각각 트레잇 오브젝트로 만들면 데이터 포인터는 공유하지만 vtable 포인터는 서로 다르다. vtable은 객체 안에 박혀 있는 것이 아니라 외부의 정적 데이터로 존재하다가, 동적 디스패치를 요청할 때 객체와 짝지어지는 것이다.

트레잇 오브젝트의 한계

모든 트레잇을 dyn Trait로 쓸 수 있는 것은 아니다. 트레잇 오브젝트가 되려면 이른바 객체 안전성(object safety) 규칙을 만족해야 한다. 대표적 반례가 Clone으로, fn clone(&self) -> Self라는 시그니처를 갖는다. Self는 그 트레잇을 구현하는 구체 타입의 자리표시자이므로, Self를 값으로 반환한다는 것은 호출자가 반환값에 얼마의 메모리를 할당할지 알기 위해 구체 타입을 알아야 함을 뜻한다. 그러나 vtable을 거치면 구체 타입을 모르므로 컴파일러가 거부한다. C++은 가상 디스패치가 항상 포인터를 통하고 반환 타입도 포인터라 이 문제를 겪지 않지만, 대신 템플릿 가상 메서드는 타입마다 별도의 vtable 항목이 필요해 사실상 무한해지므로 C++도 같은 벽에 부딪힌다.

정리하면 러스트의 단일화와 C++의 CRTP는 런타임 비용이 없는 컴파일 타임 다형성이며 코드 크기가 대가다. dyn Trait와 C++ 가상 함수는 vtable로 동적 디스패치를 하지만, C++은 vtable 포인터가 객체마다 박혀 다형성을 쓰지 않아도 모든 인스턴스에 오버헤드가 붙는 반면 러스트는 &dyn이나 Box을 명시할 때만 그 비용이 생긴다. 실무자 입장에서 이 실험이 주는 교훈은 분명하다. 러스트에서 정적과 동적 디스패치의 선택은 타입 설계가 아니라 사용 지점의 문제이며, 성능이 중요한 경로에서는 제네릭으로 비용을 없애고 이질적 컬렉션이 필요한 곳에서만 트레잇 오브젝트를 꺼내 쓰는 식으로 세밀하게 조율할 수 있다는 점이다. 다만 이 글은 저자 본인의 학습용 해부 기록이며 벤치마크 수치나 프로덕션 사례가 아니라는 한계는 감안할 필요가 있다.

이 뉴스가 유용했나요?

이 기술을 직접 배워보세요

AI 도구, 직접 활용해보세요

AI 시대, 코딩으로 수익을 만드는 방법을 배울 수 있습니다.

AI 활용 강의 보기

"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"

실제 수강생 후기
  • 비전공자도 6개월이면 첫 수익
  • 20년 경력 개발자 직강
  • 자동화 프로그램 + 소스코드 제공

매일 AI·개발 뉴스를 받아보세요

주요 테크 뉴스를 매일 아침 이메일로 전해드립니다.

스팸 없이, 언제든 구독 취소 가능합니다.