힙 메모리를 해제할 책임이 누구에게 있는지라는 시스템 프로그래밍의 핵심 질문을 C, C++, Rust의 소유권/RAII/드롭 모델과 오류 처리, 이동 시맨틱, 참조 카운팅까지 통해 살펴본다.
URL: https://lukefleed.xyz/posts/who-owns-the-memory-pt2/
이 시리즈의 1부에서는 객체가 저장소(storage)를 차지하고, 저장소에는 지속 기간(duration)이 있으며, 타입 시스템이 원시 바이트에 구조를 부여한다는 것을 보았습니다. 하지만 실제 시스템 프로그래밍을 지배하는 질문 하나를 비켜가고 있었습니다. 힙에 할당된 메모리를 해제해야 할 때, 그 책임은 누가 지는가?
이 글은 Hacker News와 Lobsters에서 토론할 수 있습니다.
스택은 스스로 관리됩니다. 함수가 반환하면 스택 포인터가 이동하고 자동 저장소(automatic storage)는 사라집니다. 결정할 것도, 호출할 함수도, 오류 가능성도 없습니다. 힙은 다릅니다. malloc으로 얻은 메모리는 누군가 free를 호출할 때까지 지속됩니다. 할당자는 우리가 언제 할당을 끝냈는지 알 수 없습니다. 그건 오직 프로그램 로직만이 압니다. 그래서 그 부담은 우리에게 떨어집니다.
이 부담은 종종 악용 가능한 심각한 결과를 낳습니다. 너무 일찍 해제하면 이후 접근은 쓰레기 값을 읽거나, 더 나쁘게는 새 할당이 그 자리에 놓은 데이터를 읽게 됩니다(해제 후 사용(use-after-free): 원격 코드 실행 익스플로잇의 상당 비율을 차지하는 취약점 분류). 두 번 해제하면 할당자의 메타데이터가 망가집니다. 공격자가 타이밍을 통제할 수 있다면 이를 임의 쓰기(arbitrary write) 원시(primitives)로 악용할 수 있는 경우가 많습니다. 전혀 해제하지 않으면 누수가 생기고 프로세스는 운영체제가 개입할 때까지 커집니다.
질문은 프로그래밍 언어들이 이 책임을 관리하도록 어떻게 도와주는지, 혹은 아예 도와주지 않는지입니다.
C는 두 가지 기본 도구만 제공합니다. 획득을 위한 malloc, 해제를 위한 free. 나머지는 모두 관례입니다.
파일을 열고 읽기 버퍼를 할당하는 함수를 생각해 봅시다:
ctypedef struct { int fd; char *buffer; size_t capacity; } FileReader; FileReader *filereader_open(const char *path, size_t buffer_size) { FileReader *reader = malloc(sizeof(FileReader)); if (!reader) return NULL; reader->buffer = malloc(buffer_size); if (!reader->buffer) { free(reader); return NULL; } reader->fd = open(path, O_RDONLY); if (reader->fd < 0) { free(reader->buffer); free(reader); return NULL; } reader->capacity = buffer_size; return reader; }
세 가지 리소스를 획득합니다. 구조체 자체, 버퍼, 파일 디스크립터. 각 획득은 실패할 수 있으며, 각 실패 경로는 그 전에 획득한 모든 것을 해제해야 합니다. 위 코드는 이를 올바르게 처리합니다. 하지만 형태를 보세요. 정리(cleanup) 로직은 획득 로직을 역순으로 거울처럼 따라가며, 우리는 리소스를 획득하는 모든 함수마다 이를 손으로 써야 합니다.
대응되는 정리 함수는 다음과 같습니다:
cvoid filereader_close(FileReader *reader) { if (!reader) return; if (reader->fd >= 0) close(reader->fd); free(reader->buffer); free(reader); }
만약 같은 포인터에 filereader_close를 두 번 호출하면 어떻게 될까요? 첫 번째 호출은 디스크립터를 닫고 메모리를 해제합니다. 두 번째 호출은 같은 주소를 free에 넘겨 할당자의 프리 리스트를 망가뜨립니다. 컴파일러는 경고하지 않습니다.
filereader_open과 filereader_close 사이에서 reader->buffer를 이전 버퍼를 해제하지 않고 재할당하면 어떤 일이 생길까요? 누수가 납니다. 원래 할당은 더 이상 도달할 수 없게 됩니다.
근본 문제는 C 포인터가 소유권 의미론을 담고 있지 않다는 것입니다. 어떤 함수가 void process(FileReader *reader)를 선언할 때, 그 시그니처 어디에도 process가 reader를 해제할지, 이후 우리가 해제해야 하는지, 혹은 더 긴 기간 동안 유효하다고 가정하는지에 대한 정보가 없습니다. 타입 FileReader *는 오직 “FileReader의 주소”를 뜻할 뿐, 책임에 대해서는 아무 말도 하지 않습니다.
큰 C 코드베이스는 이를 관리하기 위해 관례를 발전시킵니다. 리눅스 커널은 공유 구조체에 대해 참조 카운팅을 사용하며, 획득과 해제를 나타내는 _get, _put 접미사를 씁니다. GLib은 할당에 _new, 해제에 _free, 참조 카운팅 객체에 _ref/_unref를 씁니다. 이런 관례는 작동하지만 어디까지나 관례입니다. 코드 리뷰로 강제되는 패턴이지 컴파일러가 강제하는 것이 아닙니다. 위반 하나하나는 잠복 버그입니다.
C++는 C에도 있지만 C가 활용하지 않는 성질을 이용합니다. 지역 변수는 명확히 정의된 스코프를 가지며, 그 스코프가 끝나면 변수는 존재를 멈춥니다. 언어는 소멸자(destructor)를 통해 그 순간에 사용자 정의 정리 로직을 붙일 수 있게 해줍니다.
소멸자는 ~ClassName()으로 표기되는 특별한 멤버 함수로, 객체의 수명이 끝날 때 컴파일러가 자동으로 호출합니다. 이 호출은 선택 사항이 아닙니다. 스코프를 빠져나가는 제어 흐름이 무엇이든—정상 반환, 조기 반환, 예외 전파—항상 일어납니다.
cppclass FileReader { public: explicit FileReader(const char *path, size_t buffer_size) : buffer_(new char[buffer_size]) , capacity_(buffer_size) , fd_(::open(path, O_RDONLY)) { if (fd_ < 0) { delete[] buffer_; throw std::system_error(errno, std::generic_category()); } } ~FileReader() { if (fd_ >= 0) ::close(fd_); delete[] buffer_; } FileReader(const FileReader &) = delete; FileReader &operator=(const FileReader &) = delete; private: char *buffer_; size_t capacity_; int fd_; };
다음을 작성하면:
cppvoid process_file(const char *path) { FileReader reader(path, 4096); do_something(reader); }
컴파일러는 do_something이 정상적으로 반환하든 예외를 던지든 상관없이 닫는 중괄호에서 ~FileReader()에 대한 암시적 호출을 생성합니다. 우리는 이 호출을 쓰지 않았고, 언어가 이를 보장합니다.
파괴 순서는 정확합니다. 소멸자 본문을 실행하고 그 안에서 선언된 자동 객체들을 파괴한 뒤, 컴파일러는 선언의 역순으로 모든 비정적 데이터 멤버를 파괴하고, 생성의 역순으로 모든 직접 기반 클래스를 파괴합니다. 이 역순은 중요합니다. 나중에 선언된 멤버가 먼저 선언된 멤버에 의존할 수 있으므로, 구축한 순서의 반대로 해체해야 합니다.
이는 예외 안전성(exception safety)에 중요한 의미가 있습니다. 리소스 획득과 해제 사이의 어떤 연산이 예외를 던져도, 소멸자는 스택 언와인딩(stack unwinding) 동안 실행됩니다. 모든 종료 경로마다 명시적 정리 코드를 둘 필요가 없습니다. 리소스 관리 로직은 소멸자에 한 번만 쓰고, 컴파일러가 필요한 모든 지점에 호출을 삽입합니다.
표준 라이브러리는 흔한 리소스에 대한 RAII 래퍼를 제공합니다. 힙 메모리의 독점 소유권에는 std::unique_ptr, 참조 카운팅 공유 소유권에는 std::shared_ptr, 뮤텍스에는 std::lock_guard, 파일에는 std::fstream 등이 있습니다. 이런 타입을 사용하면 new나 delete를 직접 쓸 일은 드뭅니다.
하지만 C++의 RAII는 옵트인(opt-in)입니다. 다음을 막는 것은 아무것도 없습니다:
cppvoid leaky() { int *p = new int[1000]; // delete[]를 잊음 }
로 포인터를 빼내어 오용하는 것도 막지 못합니다:
cppvoid dangling() { int *raw; { auto owner = std::make_unique<int>(42); raw = owner.get(); } // owner는 여기서 파괴됨 *raw = 10; // 해제 후 사용 }
컴파일러는 어떤 포인터가 소유하고 어떤 포인터가 관찰만 하는지 추적하지 않습니다. 로 new/delete로 RAII를 완전히 우회할 수 있고, 소유자의 수명을 넘어서 로 포인터를 들고 있을 수 있습니다. 소멸자가 virtual이 아닌 베이스 클래스 포인터로 delete할 수도 있는데, 이는 파생 소멸자가 실행되지 않기 때문에(리소스가 실제로 누수되지 않더라도) 정의되지 않은 동작입니다.
C++는 안전한 리소스 관리를 위한 기계장치를 제공합니다. 그러나 그 기계장치를 쓰는 것은 선택이며, 언어가 강제할 수 없습니다. 로 포인터, unique_ptr, shared_ptr, 수동 new/delete가 섞인 코드베이스에서는 함수 경계마다 소유권을 추론해야 합니다. 답은 프로그래머의 머릿속, 주석, 코딩 규칙에 있습니다. 이는 C보다 낫습니다(거긴 기계장치조차 없으니까요). 하지만 큰 코드베이스에서 메모리 안전 버그를 없애기에는 여전히 부족합니다.
Rust는 RAII 패턴을 가져와 타입 시스템에 협상 불가능한 규칙으로 내장합니다. 모든 값은 정확히 한 명의 소유자(owner)를 가지며, 그 소유자가 스코프를 벗어나면 값은 드롭(drop)됩니다. 컴파일러는 이 성질을 정적으로 검증하며, 프로그래머 의도가 어떻든 이를 우회할 수 없습니다.
벡터를 할당하면 어떤 일이 일어나는지 봅시다:
rustfn example() { let v = vec![1, 2, 3]; }
바인딩 v는 힙에 할당된 버퍼를 소유합니다. v가 닫는 중괄호에서 스코프를 벗어나면 Rust는 Vec에 대해 drop을 호출해 버퍼를 해제합니다. 실수로 메모리 해제를 잊을 수 없습니다.
핵심 메커니즘은 이동(move)입니다. 값을 다른 바인딩에 대입하면 소유권이 이전됩니다:
rustlet v1 = vec![1, 2, 3]; let v2 = v1;
이 대입 이후 v1은 더 이상 유효하지 않습니다. 이를 사용하려는 시도는 컴파일 타임 오류입니다. 이는 C++ 이동 시맨틱처럼 얕은 복사 후 원본을 “유효하지만 미정의 상태(valid but unspecified state)”로 남기는 것이 아닙니다. Rust에서 소유권 이전은 소스 바인딩이 타입 시스템 관점에서 더 이상 존재하지 않음을 뜻합니다. 컴파일러는 이를 미초기화(uninitialized)로 표시합니다. 이동-후 상태는 없습니다.
왜 이것이 중요할까요? 이 규칙이 없다면, v1과 v2가 모두 살아 있고 둘 다 스코프 종료 시 같은 버퍼를 해제하려 할 것입니다. 이동 규칙은 이중 해제를 불가능하게 만듭니다. 어떤 시점에서도 정확히 하나의 바인딩만이 할당을 소유하고, 정확히 한 번만 드롭이 일어납니다.
함수 호출에도 같은 논리가 적용됩니다. 값을 함수에 넘기면 소유권이 매개변수로 이전됩니다:
rustfn consume(v: Vec<i32>) { // v는 여기에서 소유됨; 함수 끝에서 드롭됨 } fn main() { let data = vec![1, 2, 3]; consume(data); // 여기서는 data가 더 이상 유효하지 않음 }
시그니처 fn consume(v: Vec<i32>)는 consume이 소유권을 가져간다고 선언합니다. 호출자는 호출 후 data를 사용할 수 없습니다(소유권이 이동했기 때문). fn borrow(v: &Vec<i32>)는 소유권을 가져가지 않고 빌리며, fn mutate(v: &mut Vec<i32>)는 가변으로 빌립니다. 타입이 소유권 관계를 인코딩합니다.
값이 스코프를 벗어나면 Rust는 소멸자를 실행합니다. Drop 트레이트를 구현한 타입은 drop 메서드를 호출하는 것으로 소멸됩니다:
ruststruct FileHandle { fd: i32, } impl Drop for FileHandle { fn drop(&mut self) { unsafe { libc::close(self.fd); } } }
drop은 self가 아니라 &mut self를 받습니다. 드롭 중에 self에서 이동(move out)할 수 없습니다(가변 참조를 받기 때문). 이는 닫지 않기 위해 내부 파일 디스크립터를 빼내 반환하는 것을 막습니다. drop이 반환하면 값은 사라집니다.
drop 실행 후, Rust는 구조체의 모든 필드를 재귀적으로 드롭합니다. 이는 자동이며 피할 수 없습니다. 예를 들어:
ruststruct Connection { socket: TcpStream, buffer: Vec<u8>, }
Connection이 Drop을 구현하지 않더라도 Connection이 스코프를 벗어나면 Rust는 socket과 buffer를 드롭합니다. 컴파일러는 드롭이 필요한 모든 타입에 대해 이 “드롭 글루(drop glue)”를 생성합니다. 자식들을 드롭하기 위한 보일러플레이트를 쓰지 않아도 되며, 언어가 처리합니다. Connection이 Drop을 구현하면 Rust는 먼저 우리의 drop을 호출하고, 그 다음 필드들을 드롭합니다. 필드의 재귀적 드롭을 막을 수는 없습니다. 우리의 drop이 끝나면, 우리가 무엇을 했든 필드들은 드롭됩니다.
파괴 순서는 결정적이며 언어에 의해 규정됩니다. 지역 변수는 선언 역순으로 드롭됩니다(나중에 선언된 것이 먼저 드롭). 이유는 나중 변수들이 앞선 변수들을 참조할 수 있으니, 빌린 쪽을 먼저 파괴해야 하기 때문입니다. 구조체 필드는 선언 순서대로(역순이 아님) 드롭됩니다. 튜플은 요소가 순서대로 드롭됩니다. 배열은 인덱스 0부터 끝까지 드롭됩니다. enum은 활성 변형(variant)의 필드만 드롭됩니다. move로 캡처한 클로저 캡처 변수들은 드롭 순서가 지정되지 않으므로, 캡처 값들 사이의 파괴 순서가 중요하다면 클로저 드롭 순서에 의존하지 마세요.
재귀적 드롭은 파괴를 세밀하게 제어해야 할 때 문제를 일으킵니다. 예를 들어 Box를 감싸고 내용을 커스텀 방식으로 해제하고 싶은 타입을 생각해 봅시다:
ruststruct SuperBox<T> { my_box: Box<T>, } impl<T> Drop for SuperBox<T> { fn drop(&mut self) { unsafe { // 박스 내용물을 직접 해제하고 싶다 let ptr = Box::into_raw(self.my_box); // 오류: &mut self에서 이동 불가 std::alloc::dealloc(ptr as *mut u8, Layout::new::<T>()); } } }
컴파일되지 않습니다. &mut self에서 self.my_box를 꺼내 이동할 수 없습니다. 설령 가능하더라도, 우리의 drop이 끝난 뒤 Rust는 my_box를 다시 드롭하려 하여 이중 해제가 됩니다.
한 가지 해결책은 Option입니다:
ruststruct SuperBox<T> { my_box: Option<Box<T>>, } impl<T> Drop for SuperBox<T> { fn drop(&mut self) { if let Some(b) = self.my_box.take() { // b를 직접 처리; self.my_box는 이제 None // Rust가 self.my_box를 드롭하면 None을 드롭하게 되는데, 이는 아무 것도 하지 않는다 } } }
작동은 하지만 타입에 Option 의미론이 섞입니다. 항상 Some이어야 할 필드가 소멸자 때문만으로 Option이 됩니다. 코드 다른 곳에서 my_box에 접근할 때마다(드롭 밖에서는 일어나면 안 되는) None을 처리해야 합니다.
ManuallyDrop<T>는 더 깔끔한 해결책을 제공합니다. 이는 내용물에 대한 자동 드롭을 억제하는 래퍼입니다:
rustuse std::mem::ManuallyDrop; struct SuperBox<T> { my_box: ManuallyDrop<Box<T>>, } impl<T> Drop for SuperBox<T> { fn drop(&mut self) { unsafe { // 내부 Box의 소유권을 가져온다 let b = ManuallyDrop::take(&mut self.my_box); // 이제 b를 소유하므로 원하는 대로 할 수 있다 // 우리의 drop이 끝나면 Rust는 self.my_box를 "드롭"하려 하지만 // ManuallyDrop의 drop은 no-op이다 } } }
ManuallyDrop<T>는 T와 동일한 크기와 정렬을 가집니다. Deref와 DerefMut를 구현하므로 감싼 값을 평소처럼 사용할 수 있습니다. 하지만 Rust가 ManuallyDrop<T>를 드롭할 때는 아무 일도 일어나지 않습니다. 내부 T는 드롭되지 않습니다. 우리는 ManuallyDrop::drop(&mut x)로 수동 드롭하거나 ManuallyDrop::take(&mut x)로 소유권을 가져와야 합니다.
이는 커스텀 소멸자뿐 아니라, Rust가 보통 드롭하려는 문맥에서 값을 이동시키고 싶을 때도 유용합니다. ManuallyDrop은 그 드롭을 억제하고 우리가 직접 값을 처리할 수 있게 해줍니다. unsafe가 필요한 이유는 값이 결국 드롭되도록(또는 의도적으로 누수되도록) 보장할 책임을 우리가 지기 때문입니다.
Rust는 가능하면 컴파일 타임에 초기화 상태를 추적합니다. 하지만 다음을 보세요:
rustlet x; if condition { x = Box::new(0); }
스코프가 끝날 때 Rust는 x를 드롭해야 할까요? condition이 참이었는지에 달려 있습니다. 컴파일러가 초기화 여부를 정적으로 결정할 수 없으면, 런타임 드롭 플래그(drop flag)—숨겨진 불리언—를 삽입해 값이 초기화되었는지 추적합니다. 스코프 종료 시 Rust는 플래그를 검사하고 드롭을 호출합니다.
직선 코드와 일관되게 초기화되는 분기에서는 컴파일러가 정적 분석으로 이런 플래그를 제거할 수 있습니다. 플래그는 진짜로 필요할 때만 존재하고, 생성된 코드는 초기화 상태가 모호한 지점에서만 이를 검사합니다.
Rust는 Drop::drop 메서드를 명시적으로 호출하는 것을 막습니다:
rustlet v = vec![1, 2, 3]; v.drop(); // 오류: 소멸자 메서드의 명시적 사용
만약 drop을 직접 호출할 수 있다면, 값은 그 후에도 스코프 안에 남아 있을 것입니다. 스코프가 끝나면 Rust는 다시 drop을 호출할 테니 이중 드롭이 됩니다. 대신 조기 정리(early cleanup)를 위해 std::mem::drop을 사용합니다:
rustlet v = vec![1, 2, 3]; drop(v); // v가 drop()으로 이동되고 거기서 드롭됨
drop 함수는 값으로 소유권을 받습니다: fn drop<T>(_: T) {}. 값은 함수로 이동되고 함수가 반환할 때 드롭됩니다. 소유권이 이동했으므로 원래 바인딩은 무효화되어 두 번째 드롭이 일어나지 않습니다.
이제 Rust의 라이프타임 시스템과 소멸자 의미론 사이의 미묘한 상호작용을 봅시다. 겉보기엔 무해한 다음 코드:
ruststruct Inspector<'a>(&'a u8); struct World<'a> { inspector: Option<Inspector<'a>>, days: Box<u8>, } fn main() { let mut world = World { inspector: None, days: Box::new(1), }; world.inspector = Some(Inspector(&world.days)); }
이 코드는 컴파일됩니다. Inspector는 days에 대한 참조를 들고 있고 둘 다 World의 필드이며, world가 스코프를 벗어나면 둘 다 드롭됩니다. 여기서는 어느 쪽도 소멸자에서 상대를 관찰할 수 없으므로 days가 inspector보다 엄밀히 오래 살아야 할 필요가 없습니다.
하지만 Inspector에 Drop 구현을 추가하면 상황이 달라집니다:
ruststruct Inspector<'a>(&'a u8); impl<'a> Drop for Inspector<'a> { fn drop(&mut self) { println!("I was only {} days from retirement!", self.0); } } struct World<'a> { inspector: Option<Inspector<'a>>, days: Box<u8>, } fn main() { let mut world = World { inspector: None, days: Box::new(1), }; world.inspector = Some(Inspector(&world.days)); }
이건 컴파일되지 않습니다:
error[E0597]: world.days does not live long enough
무엇이 바뀌었을까요? Drop 구현입니다. Inspector에 소멸자가 있으면, 소멸자가 들고 있는 참조에 접근할 수 있습니다. 만약 days가 inspector보다 먼저 드롭되면 소멸자는 해제된 메모리를 역참조하게 됩니다. 이제 빌림 검사기는 Inspector가 빌린 데이터가 Inspector 자체보다 오래 살아남음을 강제해야 합니다. 소멸자가 그 데이터를 관찰할 수 있기 때문입니다.
이것이 드롭 체커(dropck)입니다. 소멸자가 있는 타입에 대해 더 엄격한 규칙을 적용합니다. 제네릭 타입이 건전하게 드롭을 구현하려면, 그 제네릭 인자들은 그 타입보다 엄밀히 더 오래 살아야 합니다. 단순히 “적어도 같은 기간”이 아니라 “엄밀히 더 오래”입니다. 미묘하지만 중요합니다. 소멸자가 없으면 둘은 동시에 스코프를 벗어나도 됩니다(파괴 중 서로를 관찰하지 않으니). 소멸자가 있으면, 소멸자가 빌린 데이터를 관찰할 수 있으므로 그 데이터는 소멸자가 실행될 때 아직 유효해야 합니다.
이는 제네릭 타입만 신경 쓰면 됩니다. 제네릭이 아닌 타입이 포함할 수 있는 라이프타임은 사실상 'static뿐이며, 이는 정말로 영원히 삽니다. 문제는 타입이 라이프타임 또는 타입 파라미터에 대해 제네릭이고, Drop을 구현하며, 그 Drop이 잠재적으로 빌린 데이터에 접근할 수 있을 때 발생합니다.
#[may_dangle] 탈출 해치드롭 체커는 보수적입니다. 제네릭 타입의 Drop 구현은 제네릭 파라미터의 데이터를 접근할 수 있다고 가정합니다. 하지만 종종 그렇지 않습니다. Vec<T>를 생각해 봅시다:
rustimpl<T> Drop for Vec<T> { fn drop(&mut self) { // 버퍼 해제 // 각 T 원소도 드롭하긴 하지만, T가 담고 있을지 모르는 참조를 // 역참조하는 의미에서 "T를 사용"하진 않는다 } }
Vec<&'a str>를 드롭하면 우리는 버퍼를 해제합니다. 각 &'a str 원소에 대해 drop을 호출하긴 하지만, &'a str은 소멸자가 없으므로 drop은 no-op입니다. Vec의 drop은 실제로 그 &'a str 값을 역참조하지 않습니다. 문자열을 읽지 않습니다. 단지 backing 메모리를 해제합니다.
하지만 드롭 체커는 이를 모릅니다. impl<'a> Drop for Vec<&'a str>를 보고 'a가 Vec보다 엄밀히 오래 살아야 한다고 결론 내립니다. 이는 다음 코드를 막습니다:
rustfn main() { let mut v: Vec<&str> = Vec::new(); let s: String = "Short-lived".into(); v.push(&s); drop(s); // v가 참조를 들고 있는 동안 s를 드롭 }
이는 올바르게 거부됩니다. 하지만 다음도 거부합니다:
rustfn main() { let mut v: Vec<&str> = Vec::new(); let s: String = "Short-lived".into(); v.push(&s); } // s와 v가 여기서 드롭되는데, 순서는?
두 번째 예시는 괜찮아야 합니다. v와 s는 main 끝에서 드롭됩니다. 변수는 선언 역순으로 드롭되므로 v가 먼저 드롭되고 그 다음 s가 드롭됩니다. s가 드롭될 때 v는 이미 사라졌습니다. v 안의 참조는 v 파괴 중에 역참조되지 않습니다.
이 패턴을 허용하기 위해 Rust는 불안정(unstable)하고 unsafe인 속성 #[may_dangle]을 제공합니다. 이는 드롭 체커에게 “내 소멸자에서 이 제네릭 파라미터에 접근하지 않겠다”고 말합니다:
rustunsafe impl<#[may_dangle] T> Drop for Vec<T> { fn drop(&mut self) { // ... } }
T에 붙은 #[may_dangle]은 Drop 구현이 유효성을 요구하는 방식으로 T 값을 접근하지 않는다는 약속입니다. 드롭 체커는 요구 조건을 완화하여, 이제 Vec가 드롭될 때 T가 댕글링(해제된 메모리를 가리키는 참조)일 수 있게 합니다.
하지만 이는 거짓이거나, 적어도 불완전한 진실입니다. Vec<T>의 소멸자는 각 T 원소를 드롭합니다. T의 소멸자가 빌린 데이터에 접근한다면, 그 데이터는 여전히 유효해야 합니다. 따라서 더 정확한 약속은 “나는 T를 직접 접근하지 않지만, T의 소멸자를 트리거할 수는 있다”입니다. 이 구분은 전체 시스템의 건전성에 중요합니다.
#[may_dangle]을 쓰면 드롭 체커의 보수적 가정을 일부 거부하는 것입니다. 하지만 T를 전이적으로(transitively) 드롭하는 경우에는 다시 그 정보를 드롭 체커에 알려야 합니다. 여기서 PhantomData가 등장합니다.
Vec의 실제 구현을 생각해 봅시다:
ruststruct Vec<T> { ptr: *const T, // 로 포인터, 소유권 의미론 없음 len: usize, cap: usize, }
로 포인터 *const T는 소유권을 암시하지 않습니다. 드롭 체커는 *const T를 포함한다고 해서 Vec이 T를 소유한다고 가정하지 않습니다. 우리가 다음처럼 쓰면:
rustunsafe impl<#[may_dangle] T> Drop for Vec<T> { fn drop(&mut self) { // 원소 드롭, 버퍼 해제 } }
드롭 체커에게 T가 댕글링해도 된다고 말한 셈이 됩니다. 하지만 Vec은 실제로 T 값을 소유하고 드롭합니다. 만약 T가 PrintOnDrop<'a>(소멸자가 'a를 역참조하는 타입)라면, Vec이 드롭될 때 T의 소멸자가 실행되므로 'a는 유효해야 합니다.
이를 전달하는 메커니즘이 PhantomData<T>입니다:
rustuse std::marker::PhantomData; struct Vec<T> { ptr: *const T, len: usize, cap: usize, _marker: PhantomData<T>, }
PhantomData<T>는 크기가 0인 타입으로 “이 구조체가 T를 소유하는 것처럼 행동하라”고 컴파일러에 알려줍니다. 이는 분산(variance), 자동 트레이트(auto-trait) 추론, 그리고 결정적으로 드롭 체크에 영향을 줍니다. 드롭 체커가 구조체 안에서 PhantomData<T>를 보면, 그 구조체를 드롭하는 것이 T 값 드롭을 포함할 수 있음을 압니다.
상호작용은 다음과 같습니다:
Drop 구현의 #[may_dangle] T는 “내 소멸자에서 T를 직접 접근하지 않겠다”고 말합니다.PhantomData<T>는 “하지만 나는 T 값을 소유하고 드롭한다”고 말합니다.T 자체는 댕글링할 수 있지만(즉, T가 담는 참조는 무효일 수 있지만), T에 드롭 글루가 있으면 그 글루가 실행되며, 그 글루가 접근하는 대상은 여전히 유효해야 한다고 판단합니다.이는 미묘합니다. T가 &'a str이라면, 참조 타입은 소멸자가 없으므로(drop이 no-op) PhantomData<&'a str>은 추가 제약을 만들지 않습니다. #[may_dangle]이 온전히 적용되고 'a는 댕글링할 수 있습니다.
반면 T가 PrintOnDrop<'a>처럼 소멸자가 'a를 역참조한다면, PhantomData<PrintOnDrop<'a>>는 Vec이 그 값을 드롭할 것임을 드롭 체커에 알려줍니다. #[may_dangle]은 Vec 자신은 'a를 접근하지 않는다고 말하지만, PrintOnDrop<'a>의 드롭은 'a에 접근합니다. 따라서 Vec이 드롭될 때에도 'a는 여전히 유효해야 합니다(비록 #[may_dangle]이 있더라도).
규칙은 올바르게 합성됩니다. #[may_dangle]은 모든 것이 댕글링해도 된다는 만능 허가가 아닙니다. 특정 Drop 구현이 파라미터를 직접 접근하지 않는다는 허가이며, PhantomData가 전이적 드롭이 여전히 일어날 수 있음을 나타냅니다.
#[may_dangle]이 없으면, Vec 같은 컬렉션 구현은 불필요하게 제한적이 됩니다. Vec<&'a T>는, Vec이 빌린 데이터보다 먼저 드롭될 때조차, 'a가 Vec보다 엄밀히 오래 살아야 합니다. #[may_dangle]과 PhantomData의 결합은 표준 라이브러리가 정확한 소유권 의미론—“우리는 T 값을 드롭하지만, 소멸자에서 그 값을 관찰하진 않는다”—을 표현하게 해줍니다.
여기서 많은 사람이 놀라는 설계 결정을 마주합니다. Rust에서 메모리 누수는 안전합니다. std::mem::forget은 값의 소유권을 가져가며 소멸자를 실행하지 않습니다:
rustlet v = vec![1, 2, 3]; std::mem::forget(v); // v의 소멸자는 실행되지 않으며, 힙 할당은 누수됨
이는 safe 함수입니다. unsafe가 필요 없습니다. 이유는 Rust에서 “안전”이란 “정의되지 않은 동작을 일으킬 수 없다”는 뜻이기 때문입니다. 누수는 낭비이지만 메모리를 손상시키거나, 댕글링 포인터를 만들거나, 데이터 레이스를 만들지 않습니다. 누수하는 프로그램은 버그가 있지만 UB는 아닙니다.
게다가 forget을 호출하지 않아도 safe 코드에서 누수가 발생할 수 있습니다. 가장 단순한 예는 Rc의 참조 사이클입니다:
rustuse std::cell::RefCell; use std::rc::Rc; struct Node { next: Option<Rc<RefCell<Node>>>, } fn create_cycle() { let a = Rc::new(RefCell::new(Node { next: None })); let b = Rc::new(RefCell::new(Node { next: Some(a.clone()) })); a.borrow_mut().next = Some(b.clone()); }
create_cycle이 반환하면 Rc들이 스코프를 벗어나지만, 사이클 때문에 각각 참조 카운트가 2입니다. 감소해도 0이 아니라 1이 됩니다. 노드는 해제되지 않습니다. unsafe 블록이 하나도 없는 safe 코드가 누수합니다.
safe 코드에서 누수가 가능하므로, 언어는 소멸자가 항상 실행된다고 가정할 수 없습니다. 이는 unsafe 코드에 깊은 함의를 가집니다. 안전 불변식이 소멸자 실행에 의존하는 타입은 mem::forget이나 사이클이 있는 환경에서 건전하지 않습니다.
표준 라이브러리는 thread::scoped API에서 이 교훈을 얻었습니다. 이 API는 스택 데이터를 참조하는 스레드를 생성할 수 있게 했고, 가드의 소멸자가 스레드를 join하는 데 의존했습니다:
rustpub fn scoped<'a, F>(f: F) -> JoinGuard<'a> where F: FnOnce() + Send + 'a
가드의 라이프타임이 빌린 데이터에 묶여 있고, 가드가 드롭되면 스레드를 join하여 데이터가 스코프를 벗어나기 전에 스레드가 끝나도록 보장합니다. 하지만 mem::forget(guard)는 소멸자 실행을 막습니다. 스레드는 계속 실행되며 참조하던 스택 데이터는 해제됩니다. safe 코드에서 해제 후 사용이 발생합니다. 이 API는 건전하지 않았고 제거되어야 했습니다.
올바른 설계 원칙은: unsafe 코드는 안전 불변식을 유지하기 위해 소멸자가 실행된다고 가정할 수 없다는 것입니다. 안전한 추상화는 소멸자가 생략될 가능성을 고려해야 합니다. 표준 라이브러리의 Vec::drain이 좋은 예입니다. drain은 원소를 하나씩 밖으로 이동시키는데, drain 이터레이터를 반복 중간에 forget하면 일부 원소가 이동되어 벡터 길이가 틀어집니다. 벡터를 불일치 상태로 남기지 않기 위해 Drain은 반복 시작 시 벡터 길이를 0으로 설정합니다. Drain이 forget되면 남은 원소는 누수되지만(소멸자가 실행되지 않고 메모리가 재사용되지 않음), 벡터는 유효한 상태(빈 벡터)로 남습니다. 누수는 누수를 증폭시키지만, UB는 발생하지 않습니다.
우리는 소유권에 대한 포괄적인 그림을 만들었습니다. Rust는 소유권 추적을 필수로 하고 정적으로 검증합니다. 하지만 Rust 모델에도 한계는 있습니다.
C++이든 Rust든 RAII 모델은 구조적 한계를 공유합니다. 소멸자는 인자 없는(parameterless) 단일 함수이며 아무것도 반환하지 않습니다. 객체가 스코프를 벗어나면 정확히 하나의 동작이 일어납니다. 대안을 선택할 수 없습니다. 정리 로직에 런타임 정보를 전달할 수도 없고, 결과를 받을 수도 없습니다.
많은 리소스에서는 이 제약이 보이지 않습니다. 파일 핸들에는 합리적인 정리 동작이 하나뿐입니다(디스크립터 닫기). 힙 할당도 하나뿐입니다(메모리 해제). 뮤텍스 가드도 하나뿐입니다(언락). 소멸자는 자명한 일을 하고, 단일 소멸자 모델은 잘 작동합니다.
하지만 데이터베이스 트랜잭션을 생각해 보세요. 트랜잭션은 결국 커밋(변경 사항 영구화) 또는 롤백(변경 폐기) 중 하나를 해야 합니다. 이는 서로 다른 의미론, 실패 모드, 종종 다른 매개변수를 갖는 근본적으로 다른 연산입니다. 커밋에는 우선순위가 필요할 수 있고, 롤백에는 포기 이유를 로깅해야 할 수 있습니다. 소멸자는 이를 수용할 수 없습니다. 하나를 선택해야 합니다.
C++과 Rust에서의 표준적 우회는 기본을 롤백으로 하고 명시적 commit 메서드를 제공하는 것입니다:
cppclass Transaction { public: explicit Transaction(Database& db) : db_(db), committed_(false) { db_.begin(); } void commit() { db_.commit(); committed_ = true; } ~Transaction() { if (!committed_) { db_.rollback(); } } private: Database& db_; bool committed_; };
트랜잭션이 성공하면 commit()을 호출하고, 오류 경로나 조기 반환에서는 소멸자가 롤백하게 둡니다. 예외 안전성은 자연스럽게 따라옵니다. 예외가 전파되면 소멸자가 실행되어 커밋되지 않은 트랜잭션이 롤백됩니다.
문제는 commit() 호출을 잊어도 컴파일 타임 오류가 아니라는 것입니다. 성공적으로 일을 마친 함수가 commit()을 호출하지 않으면 소멸자는 조용히 롤백합니다. 프로그램은 틀리지만 컴파일러는 알 수 없습니다. 우리는 “정리 잊기”라는 버그 범주를 “마무리(finalize) 잊기”라는 다른 범주로 바꾼 셈입니다. 후자는 조용히 잘못된 일을 하기 때문에 더 교활할 수 있습니다.
Rust의 소유권 시스템도 여기서는 도움이 되지 않습니다:
ruststruct Transaction<'a> { db: &'a mut Database, committed: bool, } impl<'a> Transaction<'a> { fn commit(mut self) { self.db.commit(); self.committed = true; } } impl<'a> Drop for Transaction<'a> { fn drop(&mut self) { if !self.committed { self.db.rollback(); } } }
commit은 self를 값으로 받아 트랜잭션을 소비합니다. commit을 호출하면 바인딩은 사라집니다. 하지만 commit을 호출하지 않으면 트랜잭션은 스코프를 벗어나고 drop이 실행되어 롤백합니다. 컴파일러 오류는 없습니다. 타입 시스템은 소유권은 추적했지만 의무(obligation)는 추적하지 못했습니다.
몇몇 언어는 다른 접근을 택합니다. 객체 파괴에 정리를 결박하는 대신, 스코프 종료 시 실행되는 명시적 defer 문을 제공합니다.
Zig의 defer는 둘러싼 블록을 빠져나갈 때 표현식을 무조건 실행합니다:
zigfn processFile(path: []const u8) !void { const file = try std.fs.cwd().openFile(path, .{}); defer file.close(); const buffer = try allocator.alloc(u8, 4096); defer allocator.free(buffer); // file과 buffer로 작업 // 어떤 방식으로 빠져나가든 둘 다 스코프 종료 시 정리됨 }
정리 코드는 획득 코드 바로 다음에 위치합니다. 할당과 해제를 함께 볼 수 있어 이해에 도움이 됩니다. Zig는 errdefer도 제공해, 함수가 에러로 반환할 때만 실행되게 하여 성공 경로의 이전과 오류 경로 정리를 분리합니다.
defer 모델에는 RAII에는 없는 구조적 한계가 있습니다. 정리는 스코프에 결박됩니다. RAII 객체를 반환하듯 리소스를 호출자에게 반환할 수 없습니다. defer는 현재 스코프가 끝나면 반드시 실행됩니다. 반면 RAII는 더 유연합니다. RAII 객체를 반환하거나, 자료구조에 저장하거나, 다른 스레드로 소유권을 이전할 수 있습니다. 정리 로직이 객체와 함께 이동합니다. defer는 지역적이고, RAII는 이전 가능(transferable)합니다.
하지만 defer에는 단순함이라는 장점이 있습니다. 타입을 정의하거나 트레이트를 구현하거나 구조체 필드 간 드롭 순서를 고민할 필요가 없습니다. 획득 지점에 인라인으로 정리 코드를 씁니다. 현재 함수 밖으로 나가지 않는 리소스라면 defer가 종종 더 깔끔합니다.
트랜잭션 예시는 RAII 보장의 빈틈을 보여줍니다. RAII는 정리가 일어나도록 보장하지만, 어떤 정리를 할지에 대해 명시적 결정을 내렸는지는 보장하지 않습니다. 소멸자가 대신 조용히 선택합니다.
선형 타입(linear types)은 이 빈틈을 메웁니다. 선형 타입은 반드시 명시적으로 소비(consumed)되어야 하며, 단순히 스코프를 벗어나도록 둘 수 없습니다. 선형 값을 소비 함수에 넘기지 않고 스코프를 벗어나게 하면 컴파일러가 프로그램을 거부합니다.
가상의 Rust 확장을 생각해 봅시다:
rust// 가상의 문법 #[linear] struct Transaction { db: Database } fn commit(txn: Transaction) -> Result<(), Error> { txn.db.commit()?; destruct txn; // 명시적으로 소비 Ok(()) } fn rollback(txn: Transaction, reason: &str) { txn.db.rollback(); destruct txn; // 명시적으로 소비 } fn do_work(db: Database) { let txn = Transaction { db }; // 오류: `txn`이 소비되지 않은 채 스코프를 벗어남 // commit(txn) 또는 rollback(txn, ...) 중 하나를 반드시 호출해야 함 }
트랜잭션을 무시할 수 없습니다. 결정을 잊는 것은 런타임 조용한 롤백이 아니라 컴파일 타임 오류입니다. 선형 타입을 가진 언어(Vale, Austral, 그리고 어느 정도 Haskell의 LinearTypes)는 RAII로는 표현하기 어려운 패턴을 표현할 수 있습니다.
Rust의 타입은 선형이 아니라 아핀(affine)입니다. 아핀 타입은 최대 한 번 사용할 수 있고, 선형 타입은 정확히 한 번 사용해야 합니다. 이 차이는 패닉이 스택을 언와인딩할 때 중요합니다.
Rust는 Drop::drop이 항상 호출 가능해야 하므로 조용한 드롭을 허용합니다. 스코프가 끝나거나, 패닉이 언와인딩하거나, 변수를 재대입할 때 Rust는 drop을 호출합니다. 언어 전체는 어떤 값이든 언제든 드롭될 수 있다고 가정합니다.
선형 타입은 이 가정을 깨뜨립니다. 값이 반드시 명시적으로 소비되어야 한다면, 그 값을 들고 있는 함수가 패닉 언와인딩을 통해 빠져나갈 때 무슨 일이 일어날까요? 언와인딩 코드는 어떤 소비 함수를 호출해야 하는지, 어떤 매개변수를 전달해야 하는지, 반환값을 어떻게 처리해야 하는지 알 수 없습니다. 모든 제네릭 컨테이너, 모든 이터레이터 어댑터, 값을 버릴 수 있는 모든 함수는 다시 설계되어야 합니다.
Rust는 아핀 타입을 선택했고, 컴파일러가 명시적 소비를 강제할 수는 없지만, 어떤 값이든 드롭될 수 있는 단순한 모델을 얻었습니다. #[must_use] 속성은 더 약한 보장—사용되지 않으면 경고(오류 아님)—을 제공합니다. 일부 실수는 잡아내지만 선형 타입이 주는 강한 보장을 제공하진 못합니다.
RAII는 스코프가 끝날 때 정리가 일어남을 보장합니다. 하지만 실패를 어떻게 신호(signal)할지는 말해주지 않습니다. 함수가 일을 완료할 수 없을 때 호출자에게 이를 전달해야 하고, 호출자는 대응할 기회를 가져야 합니다. 세 언어는 이 문제에 대해 근본적으로 다른 접근을 택하며, 그 차이는 오류 처리가 어떤 모습이어야 하는지에 대한 깊은 가정을 드러냅니다.
C 라이브러리 함수는 반환값으로 실패를 나타내지만 관례는 함수마다 달라서 각각 찾아봐야 합니다. C 표준은 몇 가지 패턴을 정의합니다. fopen 같은 함수는 실패 시 널 포인터를 반환합니다. 널은 유효한 결과와 혼동될 수 없는 대역 외(out-of-band) 센티넬입니다. puts/fclose 같은 함수는 실패 시 EOF를 반환합니다. fgetpos/fsetpos는 실패 시 0이 아닌 값을 반환합니다(0은 성공, 반환값 자체는 중요하지 않음). thrd_create는 특수한 성공 코드를 반환하고 다른 값들은 특정 실패 조건을 나타냅니다. printf는 실패 시 음수를 반환하고 성공 시 양의 개수를 반환합니다.
cif (puts("hello world") == EOF) { perror("can't output to terminal:"); exit(EXIT_FAILURE); }
perror는 실패한 라이브러리 함수가 설정한 스레드-로컬 변수 errno의 현재 값에 기반해 진단 메시지를 출력합니다. 반환값 검사와 errno 확인을 함께 하면 탐지와 진단을 모두 할 수 있습니다. 하지만 errno는 취약합니다. 실패한 호출 직후 즉시 확인해야 합니다. 그 사이의 어떤 함수 호출(성공한 호출조차)도 errno를 바꿀 수 있습니다. 다른 작업을 거쳐 오류 정보를 보존하려면 복사해야 합니다:
cFILE *f = fopen(path, "r"); if (!f) { int saved = errno; log_message("fopen failed"); // errno를 수정할 수도 있음 errno = saved; return -1; }
더 심각한 문제는 전파(propagation)입니다. 파일을 열고 버퍼를 할당하고 데이터를 읽고 처리하는 함수를 생각해 보세요. 각 단계는 실패할 수 있습니다. 각 실패는 이전 단계에서 획득한 리소스를 해제해야 합니다:
cint process_file(const char *path) { FILE *f = fopen(path, "r"); if (!f) return -1; char *buf = malloc(4096); if (!buf) { fclose(f); return -1; } if (fread(buf, 1, 4096, f) == 0 && ferror(f)) { free(buf); fclose(f); return -1; } // buf 처리... free(buf); fclose(f); return 0; }
정리 코드가 각 오류 지점마다 중복됩니다. 네 번째 리소스를 추가하면 세 개의 오류 경로를 모두 고쳐야 합니다. 중복은 실수를 부릅니다. 한 곳만 빠뜨려도 누수입니다.
goto는 정리를 한 곳으로 모읍니다:
cint process_file(const char *path) { int result = -1; FILE *f = NULL; char *buf = NULL; f = fopen(path, "r"); if (!f) goto cleanup; buf = malloc(4096); if (!buf) goto cleanup; if (fread(buf, 1, 4096, f) == 0 && ferror(f)) goto cleanup; // buf 처리... result = 0; cleanup: free(buf); if (f) fclose(f); return result; }
모든 리소스는 맨 위에서 NULL로 초기화되어야 합니다. cleanup 블록은 부분 초기화 상태를 처리해야 합니다. free(NULL)은 아무것도 하지 않도록 정의되어 있지만, fclose(NULL)은 정의되지 않은 동작이므로 명시적 체크가 필요합니다. 이 패턴은 дисципline이 필요합니다. 리뷰어는 goto 이전에 획득한 모든 리소스가 cleanup 블록에서 해제되는지, cleanup 블록이 모든 부분 상태를 처리하는지 확인해야 합니다.
로컬에서 처리할 수 없는 오류에 대해 C는 setjmp/longjmp를 제공합니다. setjmp는 코드에서 위치를 표시하고, longjmp는 중간 코드 실행 없이 그 위치로 제어를 옮깁니다. 이는 비지역 점프(non-local jump)로, 깊은 호출 사슬의 오류가 전체 작업을 중단해야 할 때 사용됩니다:
c#include <setjmp.h> jmp_buf error_handler; void deep_function(void) { if (catastrophic_failure()) { longjmp(error_handler, 1); // 반환하지 않음 } } int main(void) { if (setjmp(error_handler) != 0) { // longjmp가 여기로 옴 fprintf(stderr, "operation aborted\n"); return EXIT_FAILURE; } deep_function(); return EXIT_SUCCESS; }
longjmp는 호출자에게 돌아오지 않습니다. 제어는 마치 setjmp가 방금 반환한 것처럼 setjmp 지점으로 이동하되, 어떤 longjmp가 트리거했는지 나타내는 0이 아닌 값과 함께 돌아옵니다. 이 과정은 모든 중간 스택 프레임을 우회합니다. 그 프레임의 지역 변수는 정리되지 않으며(우리가 작성했을지도 모르는 정리 코드도 실행되지 않음) setjmp와 longjmp 사이에서 획득한 리소스는 수동으로 추적해 해제하지 않으면 누수됩니다. 또한 longjmp 호출 시 jmp_buf는 유효해야 합니다. setjmp를 포함한 함수가 이미 반환했다면 동작은 정의되지 않습니다.
정수 반환 코드는 제공할 수 있는 정보가 제한적입니다. 성공/실패는 구분할 수 있고 ENOENT/EACCES 같은 errno 코드는 어느 정도 맥락을 주지만, 풍부한 오류 정보는 out-parameter, 커스텀 오류 구조체, 또는 전역 상태가 필요합니다.
C++ 예외는 전파 문제를 직접 해결합니다. 함수가 계약을 만족할 수 없으면 예외 객체와 함께 throw를 실행합니다. 제어는 타입이 맞는 가장 가까운 catch 블록으로 즉시 이동하며, 중간 코드는 건너뛰되 그 사이의 소멸자는 실행합니다.
cppstd::string read_file(const std::string& path) { std::ifstream f(path); if (!f) { throw std::runtime_error("cannot open: " + path); } std::stringstream buf; buf << f.rdbuf(); return buf.str(); } void process() { try { auto content = read_file("data.txt"); // content 사용... } catch (const std::runtime_error& e) { std::cerr << e.what() << '\n'; } }
try 블록은 예외를 잡을 수 있는 코드 범위를 정합니다. catch는 타입을 지정하고, 던져진 객체의 타입이 맞으면(파생 클래스 포함) 그 핸들러가 실행됩니다. 여러 catch는 서로 다른 예외 타입을 처리할 수 있고, 런타임은 순서대로 시도하여 첫 매치를 실행합니다. const 참조로 잡으면 복사를 피하고 예외 계층에서 다형성을 유지할 수 있습니다.
예외가 스코프를 통과해 전파될 때 컴파일러는 모든 지역 객체의 소멸자를 생성 역순으로 호출하도록 보장합니다. 이것이 스택 언와인딩입니다. RAII 객체는 오류 경로에서도 자동으로 리소스를 해제합니다:
cppvoid safe_operation() { auto resource = std::make_unique<Widget>(); might_throw(); } // might_throw()가 던지든 아니든 resource는 파괴됨
스코프를 어떻게 벗어나든 소멸자가 실행됩니다. 우리는 정리 코드를 쓰지 않았고, 컴파일러가 삽입했습니다.
문제는 예외가 _모든 함수 호출을 잠재적 탈출 지점_으로 만든다는 것입니다. 위 코드에서 might_throw()가 던지면 제어는 safe_operation을 즉시 떠납니다. 자동 정리를 원할 때는 편리하지만, 여러 연산에 걸쳐 불변식을 유지할 때는 위험합니다.
cppvoid transfer(Account& from, Account& to, int amount) { from.withdraw(amount); // 던질 수 있음 to.deposit(amount); // 던질 수 있음 }
withdraw가 성공한 뒤 deposit이 던지면 돈은 from에서 빠져나갔지만 to에는 들어오지 않습니다. 프로그램은 불일치 상태입니다. 강한 예외 보장(실패하면 상태가 변하지 않음)을 위해서는 명시적 노력이 필요합니다:
cppvoid transfer(Account& from, Account& to, int amount) { int withdrawn = from.withdraw(amount); // 던질 수 있음 try { to.deposit(amount); } catch (...) { from.deposit(withdrawn); // 롤백 throw; } }
이제 우리는 다시 수동 오류 처리를 쓰게 됩니다. 다만 오류 경로가 보이지 않는다는 반전이 있습니다. 다른 함수를 호출하는 함수를 볼 때, 어떤 호출이 던질 수 있는지 그 선언(또는 전이적 호출들)을 살펴보기 전에는 알 수 없습니다. goto cleanup을 쓴 C 코드는 장황하지만, 모든 탈출 지점이 소스에서 보였습니다.
소멸자는 예외를 던져서는 안 됩니다. C++11 이후 소멸자는 암시적으로 noexcept입니다. 다른 예외로 언와인딩 중 소멸자가 던지면 활성 예외가 둘이 되어 둘 다 처리할 방법이 없으므로 std::terminate가 호출됩니다. 이 제약은 클래스 설계에 영향을 줍니다. 소멸자는 실패할 수 없는 연산만 하거나, 내부에서 예외를 잡아 억제해야 합니다.
noexcept 지정자는 함수가 예외를 던지지 않는다고 선언합니다. 실제로 던지면 언와인딩 대신 std::terminate가 호출됩니다. 이동 생성자/이동 대입은 가능하면 noexcept여야 합니다. 이는 std::vector가 재할당 중 원소를 복사 대신 이동하게 해줍니다. 이동이 던질 수 있으면 벡터는 강한 예외 보장을 위해 복사해야 합니다. 복사는 비싼 복사를 가진 타입에 대해 극적으로 느립니다. 벡터 성능은 이동 생성자가 noexcept인지에 달려 있습니다.
예외 안전성 보장은 예외 발생 시 동작을 분류합니다. nothrow 보장은 함수가 예외를 던지지 않음을 의미하며 소멸자와 swap이 제공합니다. strong 보장은 예외가 발생하면 상태가 변하지 않음을 의미하며 std::vector::push_back이 제공합니다. basic 보장은 예외가 발생해도 누수는 없고 유효한 상태는 유지되지만 상태는 변할 수 있음을 의미합니다. C++ 코드베이스의 모든 함수는 저자가 생각했든 아니든 이 중 하나의 보장을 암묵적으로 가집니다.
C++23은 std::expected<T, E>를 도입했습니다. 이는 T 타입의 값 또는 E 타입의 오류를 담는 보캐뷸러리 타입입니다. 설계는 Rust의 Result와 Haskell의 Either를 명시적으로 모델로 했습니다.
cpp#include <expected> std::expected<int, std::errc> parse_int(std::string_view s) { int value; auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value); if (ec != std::errc{}) { return std::unexpected(ec); } return value; }
std::unexpected 래퍼는 오류 값과 성공 값을 구분합니다. 호출자는 결과를 검사합니다:
cppauto result = parse_int("42"); if (result) { use(*result); } else { handle(result.error()); }
C++23은 체이닝을 위한 모나딕 연산도 추가했습니다. and_then은 값이 있을 때(성공) 또 다른 std::expected를 반환하는 함수를 적용합니다. transform은 평범한 값을 반환하는 함수를 적용하고 결과를 감쌉니다. or_else는 오류 케이스를 처리합니다:
cppauto result = parse(input) .and_then(validate) .transform([](int n) { return n * 2; });
타입도 있고 모나딕 연산도 있지만, C++에는 이 패턴을 인체공학적으로 만드는 연산자가 없습니다.
예를 들어 실패 가능 연산 두 개를 호출하고 결과를 결합하는 함수는 예외를 쓰면 간단합니다:
cppauto strcat(int i) -> std::string { return std::format("{}{}", foo(i), bar(i)); }
foo와 bar가 예외를 던질 수 있다는 것조차 알 필요가 없습니다. 오류는 보이지 않게 전파됩니다.
std::expected로 같은 일을 하면:
cppauto strcat(int i) -> std::expected<std::string, E> { auto f = foo(i); if (!f) { return std::unexpected(f.error()); } auto b = bar(i); if (!b) { return std::unexpected(b.error()); } return std::format("{}{}", *f, *b); }
수동 오류 전파가 함수를 지배합니다. 우리가 관심 있는 값이 아니라 expected 객체(f, b)에 이름을 붙이고, *f, *b로 값을 접근합니다. 의식이 로직을 가립니다.
Rust는 ? 연산자로 이를 해결합니다:
rustfn strcat(i: i32) -> Result<String, E> { Ok(format!("{}{}", foo(i)?, bar(i)?)) }
실패 가능 연산마다 문자 하나. 정상 경로는 예외 버전만큼 자연스럽고, 오류 경로는 명시적이지만 최소입니다.
C++는 Rust의 ? 문법을 채택할 수 없습니다. 조건 연산자 ?: 때문에 모호성이 생깁니다. a ? *b ? *c : d에서 파서는 ?가 조건 연산자인지 후위 전파 연산자인지 판단할 수 없습니다. P2561R0는 대안으로 ??를 제안하지만, C++26 기준으로 전파 연산자는 채택되지 않았습니다.
모나딕 연산은 우회책을 제공합니다:
cppauto strcat(int i) -> std::expected<std::string, E> { return foo(i).and_then([&](int f) { return bar(i).transform([&](int b) { return std::format("{}{}", f, b); }); }); }
작동은 하지만 중첩되고 람다가 많아 예외 버전이나 Rust 버전보다 읽기 어렵습니다. 인체공학 격차는 중요합니다. 안전한 패턴이 위험한 패턴보다 장황하면, 프로그래머는 위험한 쪽을 택합니다. std::expected를 채택한 코드베이스는 이를 일관되게 쓰기 위해 문법과 싸워야 합니다.
예외를 쓰는 코드와 std::expected를 쓰는 코드를 섞는 것도 어색하지만 피할 수 없습니다. 경계에서는 변환 보일러플레이트가 필요합니다:
cppstd::expected<Data, Error> safe_wrapper() { try { return legacy_function_that_throws(); } catch (const std::exception& e) { return std::unexpected(Error{e.what()}); } }
반대로 예외 코드가 std::expected를 호출하면 검사 후 throw해야 합니다:
cppData wrapper_for_exception_code() { auto result = modern_function(); if (!result) { throw std::runtime_error(result.error().message()); } return *result; }
실제 코드베이스는 둘 다를 오랫동안 포함할 것입니다. 경계 코드는 변환 비용만 더할 뿐 의미는 없습니다.
Rust는 복구 가능한 오류를 값으로 표현합니다. 실패할 수 있는 연산은 Result<T, E>를 반환하고, 타입 시스템은 두 가능성을 모두 처리하도록 강제합니다.
rustuse std::fs::File; use std::io::{self, Read}; fn read_file(path: &str) -> Result<String, io::Error> { let mut f = File::open(path)?; let mut contents = String::new(); f.read_to_string(&mut contents)?; Ok(contents) }
?는 오류 시 조기 반환을 위한 문법 설탕입니다. Result에 적용하면 Ok 값을 꺼내거나, Err를 둘러싼 함수에서 즉시 반환합니다. 대략 다음으로 디슈가링됩니다:
rustmatch expr { Ok(val) => val, Err(e) => return Err(From::from(e)), }
.into() 호출은 From 트레이트를 통해 자동 오류 타입 변환을 가능하게 합니다. Result<T, MyError>를 반환하는 함수는 Into<MyError>를 구현하는 오류 타입의 Result에 ?를 사용할 수 있습니다.
x86-64에서 ?는 판별자(discriminant) 검사와 조건 분기로 컴파일됩니다:
asmtest eax, eax jne .Lerror
?의 비용은 ?마다 비교 1회와 조건 분기 1회입니다. 이는 C++에서의 수동 if (!result) 검사와 같은 비용이지만 문법적 부담이 없습니다.
Result는 #[must_use]로 표시되어 반환값을 무시하면 컴파일러 경고가 납니다. 오류는 조용히 버려질 수 없습니다. 타입 시스템이 모든 실패 모드를 인정하도록 강제합니다.
?는 Option<T>에도 작동합니다:
rustfn get_username(id: UserId) -> Option<String> { let user = users.get(&id)?; let profile = user.profile()?; Some(profile.name.clone()) }
같은 문법이 “오류로 실패할 수 있음”(Result)과 “없을 수 있음”(Option)을 모두 처리합니다. 변환은 명시적입니다. result.ok()는 오류를 버리고 Option을 만들고, option.ok_or(err)는 오류를 붙여 Result를 만듭니다.
패닉은 복구 불가능한 오류를 처리합니다. 위반된 어서션, 범위 밖 인덱싱, panic!() 호출 등입니다. 기본적으로 패닉은 스택을 언와인딩하며 지역 값들의 Drop 구현을 호출합니다. 이 메커니즘은 플랫폼 언와인딩 라이브러리를 사용하며, C++ 예외와 같은 인프라를 씁니다.
rustfn get(v: &[i32], i: usize) -> i32 { v[i] // i >= v.len()이면 패닉 }
예외와 달리 패닉은 일상적으로 잡기 위한 것이 아닙니다. std::panic::catch_unwind가 패닉을 가로챌 수 있지만, 목적은 FFI 경계와 스레드 격리이지 오류 처리(error handling)가 아닙니다:
rustuse std::panic; let result = panic::catch_unwind(|| { panic!("oops"); }); assert!(result.is_err());
패닉이 extern "C" 경계를 넘어가면 정의되지 않은 동작입니다. 패닉할 수 있는 Rust 코드는 C로 들어가기 전에 패닉을 잡아야 합니다. panic=abort로 컴파일하면 언와인딩이 아예 제거되어 패닉은 즉시 프로세스를 종료합니다.
Result와 패닉의 구분은 복구 가능/불가능 오류 구분에 해당합니다. 파일 누락은 복구 가능입니다. 다른 경로를 시도하거나 사용자에게 묻거나 보고하고 계속할 수 있습니다. 인덱스 범위 밖은 버그입니다. 가정이 깨졌고 계속하면 손상 위험이 있습니다. 복구 가능 오류는 Result로 반환합니다. 버그는 패닉합니다. 타입 시스템이 이 구분을 인코딩합니다.
? 연산자는 일반 제어 흐름으로 예외 안전성을 달성합니다. ?가 오류를 만나면 함수에서 반환합니다. RAII 객체는 그 반환 과정에서 드롭됩니다. 컴파일러는 ?에 의한 조기 반환에도 정상 반환과 같은 정리 코드를 생성합니다.
rustfn safe_operation() -> Result<(), Error> { let resource = acquire()?; might_fail()?; Ok(()) }
might_fail()이 Err를 반환하면 함수는 조기 반환하고, 반환 전에 resource는 드롭됩니다. 이는 언와인딩이 아니라 정상 반환 경로를 통해 일어납니다. 생성된 코드는 함수 에필로그로의 조건 분기입니다.
모든 잠재적 탈출 지점은 소스에서 보입니다. ?가 제어가 떠날 수 있는 지점을 표시합니다. 숨겨진 탈출도, 보이지 않는 전파도 없습니다. 함수를 읽으면 조기 반환이 어디에서 일어나는지 정확히 알 수 있습니다. C++ 예외 모델은 탈출 지점을 숨기고, Rust 모델은 이를 명시적으로 만들면서도 문법을 가볍게 유지합니다.
Rust 코드는 기본 예외 안전성 보장을 자동으로 달성합니다. ?에 의한 조기 반환은 모든 지역 리소스를 해제합니다. 강한 보장은 C++과 같은 주의가 필요합니다. fallible 연산 전에 상태를 변경하면 오류 시 롤백 로직이 필요합니다. 하지만 제어 흐름이 명시적이라 상태에 대한 추론이 더 쉽습니다.
Safe Rust는 패닉으로 메모리 안전성을 위반할 수 없습니다. 하지만 unsafe 코드는 불변식을 깨는 중간 상태를 만들 수 있고, 그 창에서 패닉이 발생하면 소멸자가 잘못된 상태를 관찰할 수 있습니다.
슬라이스에서 원소를 clone하여 벡터를 확장하는 예를 봅시다:
rustimpl<T: Clone> Vec<T> { unsafe fn extend_unchecked(&mut self, src: &[T]) { self.reserve(src.len()); let old_len = self.len(); self.set_len(old_len + src.len()); // 길이를 먼저 업데이트 for (i, x) in src.iter().enumerate() { // clone()은 패닉할 수 있다! self.as_mut_ptr().add(old_len + i).write(x.clone()); } } }
set_len 후 clone()이 패닉하면 벡터는 실제로 초기화되지 않은 원소까지 초기화되었다고 주장합니다. 드롭될 때 초기화되지 않은 메모리에 drop을 호출합니다. 수정은 길이를 초기화 뒤에 업데이트하거나 가드를 쓰는 것입니다:
ruststruct SetLenOnDrop<'a, T> { vec: &'a mut Vec<T>, len: usize, } impl<T> Drop for SetLenOnDrop<'_, T> { fn drop(&mut self) { unsafe { self.vec.set_len(self.len); } } } impl<T: Clone> Vec<T> { fn extend_from_slice(&mut self, src: &[T]) { self.reserve(src.len()); let mut guard = SetLenOnDrop { vec: self, len: self.len() }; for x in src { unsafe { self.as_mut_ptr().add(guard.len).write(x.clone()); } guard.len += 1; // write 성공 후에만 증가 } std::mem::forget(guard); // Drop을 실행하지 않음, 길이는 이미 정확함 } }
가드는 성공적으로 초기화된 원소 수를 추적합니다. clone()이 패닉하면 가드가 드롭되며 길이를 실제 초기화된 개수로 되돌립니다. 패닉을 가로질러도 불변식이 유지됩니다. safe Rust는 패닉으로 메모리 안전성을 위반할 수 없습니다. unsafe 코드는 가능한 모든 패닉 지점에 대해 불변식을 유지해야 합니다.
지금까지의 소유권 논의는 소유권이 효율적으로 이전될 수 있다는 가정 위에 있습니다. 어떤 값이 한 바인딩에서 다른 바인딩으로 “이동”할 때, 바이트 수준에서는 실제로 무슨 일이 일어날까요? 그리고 왜 복사보다 선호될까요?
정답은 C, C++, Rust에 따라 크게 다르며, 각 언어가 값, 정체성(identity), 리소스에 대해 갖는 근본 가정을 드러냅니다.
함수에 값을 전달할 때 가장 단순한 모델은 호출자의 값을 피호출자의 매개변수로 복사하는 것입니다. 32비트 정수라면 4바이트 복사에 불과합니다. 하지만 동적 크기 컨테이너는 어떨까요?
C++의 std::vector<int>나 Rust의 Vec<i32>는 구조가 비슷합니다. 힙 저장소를 가리키는 포인터, 길이, 용량. 구조체 자체는 작지만(64비트에서 보통 24바이트) 큰 힙 할당을 소유할 수 있습니다.
cstruct Vec { int* data; // 8 bytes size_t len; // 8 bytes size_t cap; // 8 bytes }; // sizeof(Vec) == 24, 하지만 data는 훨씬 큰 메모리를 가리킬 수 있음
이 구조체를 바이트 단위로 복사하면 같은 힙 할당을 가리키는 Vec 인스턴스가 두 개 생깁니다. 이는 얕은 복사(shallow copy)입니다. 두 인스턴스 모두 메모리를 소유한다고 믿게 되고, 하나의 소멸자가 해제하면 다른 쪽 포인터는 댕글링합니다. 두 번째 소멸자가 실행되면 이중 해제입니다.
대안은 깊은 복사(deep copy)입니다. 새 힙 저장소를 할당하고 모든 데이터를 복사하며 새 구조체의 포인터를 업데이트합니다. 이는 올바르지만 비쌉니다. 시간이 들고, 더 중요하게는 메모리 할당이 필요하며(시스템 콜 혹은 최소한 할당자 장부 작업), 다시 사용하지 않을 수도 있는 데이터로 캐시를 오염시킵니다.
값이 함수 경계를 반복해서 넘나들면 오버헤드는 감당하기 어려워집니다. 벡터를 값으로 받아 처리하고 새 벡터를 반환하는 함수는 수백만 바이트를 두 번 복사할 수도 있습니다(진입 시 한 번, 반환 시 한 번). 성능 민감 코드에서는 값 전달을 피하고 포인터/레퍼런스를 선호하게 되는데, 이는 API를 복잡하게 만들고 소유권을 흐립니다.
하지만 벡터를 소비(consuming)하는 함수에 전달할 때는 호출자가 더 이상 그 사본을 필요로 하지 않는다는 것을 관찰할 수 있습니다. 호출 직후 호출자의 스택 프레임 속 바이트는 죽은(dead) 상태가 됩니다. 힙 데이터 자체를 복제하지 않고 소유권만 이전할 수 있다면, 값 시맨틱의 명료함과 포인터 전달의 효율을 동시에 얻을 수 있습니다.
이것이 이동 시맨틱이 제공하는 것입니다. 전체 자료구조를 복사하는 대신 구조체(포인터, 길이, 용량)만 복사하고 소스를 무효화하여 정리를 시도하지 못하게 합니다.
그 “무효화”를 어떻게 하느냐가 언어마다 갈립니다.
C에는 언어 차원의 지원이 없습니다. 직접 구현해야 합니다:
ctypedef struct { int* data; size_t len; size_t cap; } Vec; void transfer(Vec* dest, Vec* src) { *dest = *src; // 구조체 얕은 복사 src->data = NULL; // 소스 무효화 src->len = 0; src->cap = 0; }
transfer 후 목적지는 힙 메모리를 소유하고, 소스는 이동-후 상태(널 포인터, 0 크기)에 있습니다. 작동은 하지만 강제되지 않습니다. transfer 후에도 src->data에 접근할 수 있습니다. NULL을 읽게 될 수도 있고, 더 나쁘게는 무효화를 잊어 누군가가 해제할 포인터를 읽게 될 수도 있습니다.
C++11 이전에는 레퍼런스가 한 종류뿐이었습니다. lvalue 참조 T&입니다. lvalue는 대략 “이름과 주소가 있는 것”입니다. 변수, 역참조된 포인터, 배열 원소 같은 것입니다. lvalue 참조는 lvalue에 바인딩되어 기존 객체에 대한 별칭 접근을 제공합니다.
하지만 임시(temporary)를 생각해 봅시다:
cppstd::vector<int> make_vector() { return std::vector<int>{1, 2, 3, 4, 5}; } void consume(std::vector<int> v); consume(make_vector()); // 인자는 임시
make_vector()의 반환값은 lvalue가 아닙니다. 이름도, 영속 저장소도, 취할 수 있는 주소도 없습니다. 이는 rvalue이며, 완전 표현식(full expression) 끝에서 파괴될 임시입니다. C++11 이전에는 이 임시를 consume에 전달하면, 원본이 곧 사라질 텐데도 복사가 일어났습니다. 순간 후 파괴될 데이터를 복제한 것입니다.
C++11은 rvalue 참조 T&&를 도입했습니다. rvalue 참조는 rvalue—임시, 표현식, 함수 반환값—에 바인딩됩니다. 타입 시스템은 이제 “이 객체를 관찰하고 싶다”(const T&)와 “이 객체에서 훔치고 싶다”(T&&)를 구분합니다.
이 구분은 오버로딩을 가능하게 합니다. 클래스는 생성자를 두 버전으로 정의할 수 있습니다:
cppclass vector { public: // 복사 생성자: 소스는 const lvalue 참조 vector(const vector& other) { data_ = new int[other.cap_]; std::copy(other.data_, other.data_ + other.len_, data_); len_ = other.len_; cap_ = other.cap_; } // 이동 생성자: 소스는 rvalue 참조 vector(vector&& other) noexcept { data_ = other.data_; len_ = other.len_; cap_ = other.cap_; // 소스 무효화 other.data_ = nullptr; other.len_ = 0; other.cap_ = 0; } };
인자가 rvalue(임시 또는 std::move 결과)면 오버로드 해석이 이동 생성자를 선택합니다. 우리는 구조체 얕은 복사를 수행하고 소스를 무효화합니다. C 버전과 동일하지만, 선택이 값 범주(value category)에 따라 자동으로 일어납니다.
핵심은 std::move가 아무것도 “이동”시키지 않는다는 것입니다. 그것은 캐스트입니다:
cpptemplate<typename T> constexpr std::remove_reference_t<T>&& move(T&& t) noexcept { return static_cast<std::remove_reference_t<T>&&>(t); }
어떤 종류의 레퍼런스든 받아(레퍼런스 붕괴 덕분에) 같은 객체에 대한 rvalue 참조를 반환합니다. 객체는 실제로 움직이지 않습니다. 단지 타입 시스템에서의 분류를 바꿉니다.
이 캐스트로 이름 있는 변수에서도 이동할 수 있습니다:
cppstd::vector<int> v1{1, 2, 3}; std::vector<int> v2 = std::move(v1); // v1을 rvalue로 캐스트, 이동 생성자 호출
이후 v2는 원래 v1이 소유하던 힙 할당을 소유합니다. v1은 이동-후 상태(포인터 null, 길이/용량 0)에 있습니다. 소멸자는 v1 스코프 종료 시 실행되지만, 해제할 것이 없으므로 아무 일도 하지 않습니다.
컴파일러는 이동 생성자를 자동 생성할 수 있습니다. 클래스에 사용자 선언 복사 생성자, 복사 대입, 이동 대입, 소멸자가 없으면 컴파일러는 암시적 이동 생성자를 선언합니다. 이 암시적 버전은 멤버별 이동을 수행합니다. 각 비정적 멤버는 그 멤버의 이동 생성자로 이동되고(이동이 없으면 복사됨), 단순 타입은 그냥 복사됩니다.
cppstruct Wrapper { std::vector<int> data; std::string name; int id; // 암시적 이동 생성자: // Wrapper(Wrapper&& other) noexcept // : data(std::move(other.data)) // , name(std::move(other.name)) // , id(other.id) {} };
자명하게 이동 가능한(trivially movable) 타입(대략 C와 호환되는 타입)에서는 이동 생성자가 std::memmove처럼 객체 표현을 복사합니다. 런타임 멤버별 이동은 없고 구조체 바이트 복사로 줄어듭니다.
사용자 선언 특별 멤버 함수 규칙은 중요합니다. 소멸자를 선언하면(빈 소멸자라도) 암시적 이동 생성자는 생성되지 않습니다:
cppstruct C { std::vector<int> data; ~C() {} // 소멸자 선언됨(비어 있어도) }; C c1; C c2 = std::move(c1); // 이동이 아니라 복사 생성자 호출!
이 동작은 사용자 소멸자가 클래스가 컴파일러가 추론할 수 없는 방식으로 리소스를 관리한다는 신호이기 때문에 존재합니다. 보수적으로 복사로 돌아갑니다. = default로 이동 생성자 생성을 강제할 수 있습니다:
cppstruct D { std::vector<int> data; ~D() {} D(D&&) = default; // 이동 생성자 명시적 요청 };
대입에도 같은 패턴이 적용됩니다. 이동 대입 연산자는 rvalue 참조를 받아 소유권을 이전합니다:
cppvector& operator=(vector&& other) noexcept { if (this != &other) { delete[] data_; // 현재 저장소 해제 data_ = other.data_; len_ = other.len_; cap_ = other.cap_; other.data_ = nullptr; other.len_ = 0; other.cap_ = 0; } return *this; }
self-assignment 체크는 필요합니다. std::move는 어떤 lvalue에도 적용될 수 있으며(좌변 자체에도 가능하지만 이상한 코드), 그런 경우를 방지합니다.
이동된-from 객체의 소멸자는 여전히 실행됩니다. 이동은 리소스의 소유권을 이전하지만, 소스 객체는 스코프가 끝날 때까지 존재합니다. 이동-후 상태는 소멸자가 안전하게 실행될 만큼 유효해야 합니다. vector라면 널 포인터와 0 길이/용량이면 소멸자가 아무 일도 하지면 됩니다.
noexcept는 최적화에 중요합니다. std::vector는 성장할 때 원소를 재배치해야 합니다. 원소 타입의 이동 생성자가 noexcept면 새 버퍼로 이동할 수 있습니다. 던질 수 있다면 강한 예외 보장을 위해 복사해야 합니다. 재배치 중 예외가 발생하면 원래 벡터는 온전해야 합니다. 벡터의 벡터에서는 차이가 극적일 수 있습니다.
C++11은 단순 lvalue/rvalue 이분법을 넘어 값 범주를 세분화했습니다:
std::move() 결과, rvalue 참조로의 캐스트 결과, T&&를 반환하는 함수의 반환값 등.오버로드 해석은 이 범주를 사용합니다:
cppvoid f(Widget& w); void f(const Widget& w); void f(Widget&& w); Widget w; const Widget cw; f(w); f(cw); f(Widget{}); f(std::move(w));
const T&와 T&&가 모두 있으면 rvalue(prvalue와 xvalue)는 T&& 오버로드를 선호합니다. lvalue는 lvalue 참조 오버로드에만 바인딩됩니다. const T&만 제공하면 모두를 받을 수 있으므로, C++11 이전에는 rvalue도 const lvalue 참조에 바인딩되었고 그래서 복사가 fallback이었습니다.
이 기계장치는 전부 컴파일 타임에 작동합니다. 머신 코드에 도달하면 값 범주나 rvalue 참조 같은 것은 없고 주소와 데이터만 있습니다. 타입 시스템은 올바른 생성자/연산자를 선택했고, 그 결과 생성된 코드는 우리가 지정한 메모리 연산을 수행합니다.
C++에서 이동 이후 소스 객체는 어떻게 될까요? 소스 객체는 여전히 존재합니다. 이름, 주소, 타입이 있고 스코프 끝에서 소멸자도 실행됩니다. 메서드를 호출하거나 필드를 읽거나 함수에 전달할 수도 있습니다. 이동 생성자는 리소스를 옮겼지만 객체 자체는 남습니다.
표준은 이동된-from 객체를 “유효하지만 미정의 상태(valid but unspecified state)”라고 설명합니다. 유효(valid)란 소멸될 수 있고(그리고 보통 대입과 일부 질의 연산) 타입 불변식을 어느 정도 만족한다는 뜻입니다. 미정의(unspecified)란 그 멤버 값이 무엇인지 예측할 수 없다는 뜻입니다.
std::unique_ptr는 이동-후 상태가 완전히 지정됩니다. 포인터가 null이 됩니다:
cppstd::unique_ptr<int> p = std::make_unique<int>(42); std::unique_ptr<int> q = std::move(p); if (p) { std::cout << *p; } else { std::cout << "p is null"; } int* raw = p.get(); p.reset(new int(7));
이동된-from unique_ptr는 완전히 기능하는 객체입니다. null을 들고 있다는 것을 알고 일관되게 동작합니다. get()은 null을 반환하고, bool 변환은 false를 반환합니다. reset()으로 재사용도 할 수 있습니다. 이는 정의된 동작입니다.
std::vector는 더 모호합니다. 표준은 이동된-from 벡터가 유효하지만 미정의 상태임만 보장합니다. 실제 구현은 보통 비어 있게 남기지만:
cppstd::vector<int> v1{1, 2, 3, 4, 5}; std::vector<int> v2 = std::move(v1); std::cout << v1.size();
대부분 0이 출력될 가능성이 크지만 보장되진 않습니다. 구현은 v1의 데이터 포인터를 null로 하고 size를 0으로 둘 수 있지만, 표준은 이를 요구하지 않습니다. 어떤 구현은 다른 "유효하지만 예측 불가"한 상태를 둘 수도 있습니다.
문제는 컴파일러가 이동-후 사용을 막지 않는다는 점입니다. 경고도 오류도 없습니다. 이동을 잊고 변수를 사용하면 컴파일되고 실행됩니다:
cppvoid process(std::vector<int> data); void example() { std::vector<int> v{1, 2, 3, 4, 5}; process(std::move(v)); // 버그: v는 이동됨 for (int x : v) { std::cout << x << " "; } }
이는 엄밀한 의미의 UB는 아닐 수 있지만(빈 벡터 순회는 정의됨), 거의 확실히 버그입니다. 프로그래머는 원래 데이터를 순회하고 싶었지만 process가 이를 소비했다는 사실을 잊었습니다. 프로그램은 조용히 잘못된 일을 합니다.
C++가 이동된-from 객체의 존재를 유지한 것은 호환성과 유연성 때문입니다. 실제로 이동된-from 객체를 재사용하거나 재대입하거나 swap하는 사용처가 있습니다. 그 대가로 타입 시스템은 “이동 후에는 사용하지 말라”는 규율을 강제할 수 없습니다.
정적 분석기가 때때로 use-after-move를 잡을 수는 있지만, 모든 경우에 신뢰할 수 있게 잡진 못합니다. 분석은 흐름 민감(flow-sensitive)이고 문맥 의존이며, 함수 경계가 데이터 흐름을 가립니다. T&&를 받는 함수가 인자를 실제로 이동할지 여부는 호출자에게 시그니처만 보고는 알 수 없습니다.
Rust는 완전히 다른 접근을 취합니다. 값이 이동하면 소스 바인딩은 무효가 됩니다. “유효하지만 미정의”가 아니라 무효입니다. 이후 사용은 컴파일러가 거부합니다:
rustfn process(data: Vec<i32>); fn example() { let v = vec![1, 2, 3, 4, 5]; process(v); for x in v { // 오류: 이동된 값을 사용 println!("{}", x); } }
에러 메시지는 명확합니다:
texterror[E0382]: use of moved value: `v` --> src/main.rs:7:14 | 4 | let v = vec![1, 2, 3, 4, 5]; | - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait 5 | process(v); | - value moved here 6 | 7 | for x in v { | ^ value used here after move
관찰할 이동-후 상태가 없습니다. 관찰할 방법 자체가 없기 때문입니다. 바인딩 v는 null도, 빈 것도, 미정의도 아닙니다. 이동 후 컴파일러 관점에서 그 바인딩은 존재하지 않습니다. 이름은 스코프 안에 남지만(새 바인딩으로 섀도잉 가능), 컴파일러의 초기화 추적은 이를 미초기화로 표시합니다.
어셈블리 수준에서 실제 데이터 이동은 C++와 거의 동일합니다. Vec의 세 워드(포인터, 길이, 용량)가 한 스택 위치에서 다른 위치로 복사되거나 함수 호출을 위해 레지스터로 이동합니다. 힙 할당도 깊은 복사도 없고, 24바이트만 옮깁니다. 차이는 전부 컴파일 타임 개념입니다. Rust는 소스가 더 이상 유효하지 않음을 추적합니다.
rustfn example() { let v1 = vec![1, 2, 3]; let v2 = v1; println!("{:?}", v1); // 오류: 이동된 값 차용 }
let v2 = v1의 어셈블리는 구조체의 memcpy에 가깝고, C++가 생성할 코드와 본질적으로 같습니다. 하지만 C++는 이후 v1을 접근하도록 허용하는 반면, Rust는 컴파일을 멈춥니다.
이 추적은 컴파일러의 데이터 흐름 분석으로 이루어집니다. 각 변수는 초기화 상태를 갖고 문장을 처리하며 업데이트됩니다. v1이 v2에 대입되면 컴파일러는 v1을 미초기화로 표시합니다. 이후 v1 사용은 let v1: Vec<i32>;만 선언해 초기화하지 않은 것처럼 오류입니다.
재초기화는요? 이동된 변수는 새 값으로 대입해 다시 초기화할 수 있습니다:
rustfn example() { let mut v = vec![1, 2, 3]; let v2 = v; v = vec![4, 5, 6]; println!("{:?}", v); }
컴파일러의 추적은 흐름 민감합니다. 이동 후 v는 미초기화, 재대입 후 v는 초기화됩니다. 재초기화는 Rust 모델에서 변이(mutation)의 한 형태이므로 mut가 필요합니다.
제어 흐름은 분석을 복잡하게 합니다. 한 분기에서 이동이 일어나고 다른 분기에서는 일어나지 않으면, 변수의 초기화 상태는 어느 경로가 실행되었는지에 달려 있습니다:
rustfn example(condition: bool) { let v = vec![1, 2, 3]; if condition { drop(v); } println!("{:?}", v); // 오류: v가 이동되었을 수 있음 }
컴파일러는 어떤 분기가 실행될지 알 수 없으므로 보수적으로 v가 미초기화일 수 있다고 가정합니다. 이는 때때로 코드를 재구성하거나 Option<T>로 “아마 이동됨” 상태를 명시적으로 표현하게 만듭니다.
컴파일러가 정적으로 초기화 여부를 결정할 수 없는 경우, Rust는 런타임 드롭 플래그를 사용합니다. 이는 보통 스택에 저장되는 불리언으로 값이 이동되었는지 추적합니다. 변수 스코프 종료 시 생성된 코드는 플래그를 검사하고 소멸자를 호출합니다.
이 메커니즘은 Rust가 받아들인 트레이드오프를 보여줍니다. 직선 코드에서는 컴파일러가 모든 시점에서 초기화 상태를 정확히 알고 런타임 오버헤드 없이 직접 드롭을 생성합니다. 하지만 조건부 이동은 선택을 강요합니다. 유효한 프로그램을 더 많이 거부할 것인지, 아니면 런타임 체크를 넣을 것인지. Rust는 후자를 선택했습니다. 플래그는 스택에서 1바이트와 스코프 종료 시 조건 분기 하나의 비용입니다. 핫 루프에서는 정적 추적이 가능하도록 코드를 재구성할 수 있고, 콜드 경로에서는 플래그 비용이 무시할 만합니다.
safe Rust에서 할 수 없는 것은 이동된 바인딩을 관찰하는 것입니다. C++와의 비대칭성은 런타임이 아니라 컴파일러가 허용하는 코드에 있습니다. 두 언어 모두 런타임에서는 같은 바이트를 복사하고, 스택 프레임이 회수되기 전까지 소스 메모리를 그대로 둡니다. C++는 이동된-from 객체를 이후 표현식에 참여시키도록 허용하지만, Rust는 허용하지 않습니다.
우리는 “이동”을 하나의 개념처럼 말했지만, Rust는 세 가지 관련 연산을 구분합니다. 암시적 복사(copy), 이동(move), 명시적 클론(clone)입니다. 언제 어떤 것이 적용되는지 이해하려면 타입 시스템이 데이터에 대해 무엇을 아는지 이해해야 합니다.
i32는 4바이트입니다. x: i32에 대해 let y = x를 쓰면 컴파일러는 4바이트를 복사하는 mov를 생성합니다. 이후 x와 y는 독립적 사본을 갖고 둘 다 사용할 수 있습니다. 이것이 복사입니다.
Vec<i32>는 스택에서 24바이트지만, 그 24바이트는 임의 크기의 힙 할당을 제어합니다. x: Vec<i32>에 대해 let y = x를 쓰면 컴파일러는 같은 종류의 mov로 24바이트를 복사합니다. 하지만 이제 x와 y가 같은 힙 할당을 가리킵니다. 둘 다 허용하면 별칭(aliasing)과 스코프 종료 시 이중 해제가 발생합니다. 그래서 대입 후 x는 무효화됩니다. 이것이 이동입니다.
머신 수준에서 copy와 move는 동일합니다. 둘 다 값을 구성하는 바이트를 복사합니다. 차이는 그 이후 컴파일러가 무엇을 허용하는가입니다. Copy 타입에서는 소스가 유효하며, 비-Copy 타입에서는 소스가 무효화됩니다.
Rust는 바이트 단위 복제가 의미론적으로 완전한 타입을 Copy 트레이트로 표시합니다. 바이트를 복사하면 두 개의 독립적이고 완전히 기능하는 값이 생긴다면 그 타입은 Copy가 될 수 있습니다. 정수, 부동소수, bool, char, 로 포인터, 그리고 Copy 타입들로 이루어진 튜플/배열은 모두 Copy입니다. 핵심 특징은 바이트 외의 추가 리소스 관리가 없다는 것입니다.
Copy에는 제약이 있습니다. 타입은 Copy와 Drop을 동시에 구현할 수 없습니다. 소멸자가 있는 타입에서 바이트를 복제하면 두 값이 모두 정리를 시도합니다. Vec는 이중 해제, File은 같은 파일 디스크립터를 두 번 닫게 됩니다. 이 상호 배타성은 컴파일러가 강제합니다:
rust#[derive(Copy, Clone)] struct Point { x: i32, y: i32 } #[derive(Copy, Clone)] struct Wrapper(Vec<i32>);
두 번째는 에러가 납니다:
texterror[E0204]: the trait `Copy` cannot be implemented for this type --> src/main.rs:4:10 | 4 | #[derive(Copy, Clone)] | ^^^^ 5 | struct Wrapper(Vec<i32>); | -------- this field does not implement `Copy`
C++에도 평행한 개념인 “자명하게 복사 가능한(trivially copyable)” 타입이 있습니다. 하지만 C++은 비-자명 타입을 memcpy로 복사하는 것을 언어가 막지 않습니다. 잘못하면 메모리를 손상시킬 수 있습니다. Rust에서는 Copy를 파생(derive)하려는 시도 자체가 자격을 갖추지 않으면 하드 에러입니다.
Clone은 명시적 깊은 복사입니다. Copy가 대입 시 암시적으로 일어나는 반면, Clone::clone()은 반드시 명시적으로 호출해야 합니다. 구현은 무엇이든 할 수 있습니다. 새 메모리 할당, 원소 복사, 참조 카운트 증가 등 타입에 맞는 동작을 합니다. Vec<T>의 clone()은 새 버퍼를 할당하고 각 원소를 클론합니다.
Copy와 Clone의 관계는 Copy가 Clone의 상위 트레이트라는 것입니다. 모든 Copy 타입은 Clone도 구현해야 하며, Copy 타입의 clone()은 바이트 복사와 같습니다. 중복처럼 보이지만 제네릭 코드가 통일되게 동작하게 합니다:
rustfn duplicate<T: Clone>(x: &T) -> T { x.clone() }
이 함수는 i32(간단한 로드로 컴파일)에도, String(할당과 복사)에도 작동합니다. 호출부는 같지만 생성된 코드는 다릅니다.
Rust 코드에서 .clone()을 보면 비용이 들 수 있는 일이 일어나고 있음을 알 수 있습니다. Rust 철학은 비싼 연산을 눈에 보이게 하는 것입니다. .clone()을 쓰게 함으로써 비용을 인정하도록 강제합니다.
C++은 복사 생성자에 대해 반대 접근을 택합니다. std::vector<int> v2 = v1;는 복사 생성자를 호출해 할당과 원소 복사를 합니다. 하지만 문법은 int 복사와 동일합니다. vector가 비싼 복사를 한다는 사실을 코드가 말해주지 않습니다. C++11 이동 시맨틱은 비싼 연산을 더 눈에 띄게 하기 위해 추가되었지만, 복사는 여전히 암시적입니다.
한 가지 미묘한 점: Rust의 clone()은 항상 직관적 의미의 “깊은 복사”는 아닙니다. Rc<T>에서 clone()은 참조 카운트를 증가시키고 같은 할당을 가리키는 새 Rc를 반환합니다. 데이터는 공유됩니다. 이는 Rc의 올바른 의미론이지만, clone()이 독립 사본을 만든다고 가정할 수 없다는 뜻입니다. 트레이트 계약은 더 약합니다. clone()은 타입의 인터페이스 관점에서 원본과 의미적으로 동등한 값을 생성합니다.
이동은 소스에서 목적지로 값을 구성하는 바이트를 복사하는 것입니다. 24바이트 Vec라면 8바이트 쓰기 세 번입니다. 그렇다면 함수에서 Vec를 반환할 때는 어떨까요? 단순히 생각하면 함수 스택 프레임에서 Vec를 만들고, 반환 시 호출자 스택 프레임으로 이동하고, 호출자는 반환값을 받습니다. 그러면 24바이트를 두 번 쓸 것 같습니다.
정답은 ABI 수준에서 반환이 어떻게 구현되는지에 달려 있습니다. Itanium C++ ABI(대부분의 유닉스 계열에서 호출 규약을 지배)는 반환 타입을 trivial/non-trivial로 구분합니다. 반환 타입이 비자명(소멸자나 복사/이동 생성자가 비자명)하면, ABI는 호출자가 숨겨진 주소를 암시적 인자로 넘기고, 피호출자가 그 주소에 반환값을 직접 구성하도록 규정합니다.
Itanium ABI는 더 나아가, 전달된 주소가 반드시 호출자 스택의 임시 메모리일 필요가 없다고 말합니다. 복사 엘리전(copy elision)은 그 주소가 어디를 가리키든(지역 변수 저장소, 전역 메모리, 힙 등) 가능하게 합니다. 포인터는 프로토타입에서 첫 번째 인자처럼 전달되며(this보다도 앞), 반환 타입에 비자명 소멸자가 있으면 호출자는 피호출자가 정상 반환한 뒤에만 파괴 책임을 가집니다. 반환 완료 전에 예외가 발생하면 피호출자가 반환 객체를 파괴해야 합니다.
이 기계장치가 C++의 “복사 엘리전”을 가능하게 합니다. 반환 객체는 최종 위치에 직접 구성됩니다. 흔히 두 형태를 말합니다:
RVO(Return Value Optimization): 이름 없는 prvalue 임시를 반환할 때
cppstd::vector<int> make_vector() { return std::vector<int>{1, 2, 3}; }
벡터는 호출자가 제공한 주소에 직접 구성됩니다. make_vector 스택 프레임의 임시는 존재하지 않습니다.
NRVO(Named Return Value Optimization): 이름 있는 지역 변수를 반환할 때
cppstd::vector<int> make_vector() { std::vector<int> v{1, 2, 3}; v.push_back(4); return v; }
컴파일러는 처음부터 v를 호출자가 제공한 공간에 직접 배치할 수 있습니다. 그러면 반환 시 복사/이동이 없습니다. 불가능하면 반환 시 이동 생성자가 호출됩니다.
C++17 이전에는 이 둘 다 허용되지만 보장되진 않았습니다. 표준 준수 컴파일러가 엘리전을 하지 않을 수도 있었고, 그 경우 복사/이동으로 돌아갑니다. 엘리전에 의존해 비복사/비이동 타입을 반환하는 코드는 이식성이 없었습니다.
C++17은 prvalue에 대해 이를 바꾸었습니다. 값 범주를 재정의해 prvalue가 필요할 때까지 “물질화(materialize)”되지 않는다고 규정했고, prvalue가 임시 객체를 만들지 않고 대상 객체를 직접 초기화한다고 했습니다. 결과는 prvalue에 대한 보장된 복사 엘리전입니다. std::vector<int> v = make_vector();는 표준이 요구하는 대로 v에 직접 구성됩니다.
NRVO는 여전히 선택 사항입니다. 실무적으로 주요 컴파일러는 가능하면 NRVO를 수행하지만, 서로 다른 지역 변수를 반환하는 여러 return 문은 보통 NRVO를 방해합니다.
Rust는 언어 차원의 “복사 엘리전”이라는 명칭을 갖지 않습니다. 문제 구성이 다르기 때문입니다. Rust 이동은 소스를 무효화하는 비트 단위 복사로 정의됩니다. 이동 생성자도 없고 실행될 사용자 코드도 없으며 관찰 가능한 부작용이 없습니다. Vec의 이동은 문맥과 무관하게 24바이트 복사입니다.
하지만 Rust도 ABI 수준에서는 같은 최적화를 합니다. 레지스터에 들어가지 않는 값을 반환할 때 호출자는 숨겨진 포인터를 전달하고(시스템 V에서는 rdi, MS x64에서는 rcx), 피호출자는 그 위치에 직접 씁니다. Rust에는 언어 수준 엘리전 규칙이 필요 없는데, 관찰 가능한 차이가 없기 때문입니다. 목적지에 직접 구성하든 로컬에서 구성 후 복사하든 비트 패턴은 같습니다.
rustfn make_vec() -> Vec<i32> { vec![1, 2, 3, 4, 5] } fn caller() { let v = make_vec(); }
최적화 시 make_vec는 숨겨진 포인터를 받아 Vec의 세 필드를 caller가 원하는 위치에 직접 씁니다. 중간 Vec는 없습니다. 힙 할당은 한 번, Vec 헤더도 한 번만 최종 위치에 기록됩니다.
레지스터에 들어가는 타입은 두 언어 모두 RAX/RDX로 반환합니다. (i32, i32) 반환은 반환 자체에 대한 메모리 연산이 없습니다.
결과적으로 Rust에서 큰 값을 값으로 반환하는 것이 반환 메커니즘 때문에 비싸지는 않습니다. 비용은 자료구조를 구성하는 데 있습니다. 할당, 원소 초기화, 재할당 가능성 등이 비용입니다. 결과를 호출자에게 돌려주는 기계장치는 ABI가 요구하는 것 이상을 추가하지 않습니다.
이동 시맨틱은 독점 소유권을 효율적으로 이전하는 문제를 해결합니다. 하지만 어떤 자료구조는 독점 소유권만으로 공유 패턴을 표현할 수 없습니다. 여러 에지가 같은 노드를 가리키는 그래프, 어떤 단일 사용자보다 오래 사는 캐시, 여러 호출자에게 유효해야 하는 콜백 등은 리소스가 마지막 소유자가 사라질 때만 해제되는 공유 소유권이 필요합니다.
우리는 C++과 Rust가 unique_ptr와 Box로 독점 힙 소유권을 어떻게 처리하는지 보았습니다. 살펴본 이동 시맨틱은 직접 적용됩니다. unique_ptr는 이동 후 nullptr를 남기고, Box는 유효한 바인딩을 남기지 않습니다. 이제 더 어려운 문제인 공유 소유권으로 넘어갑니다.
shared_ptr프로그램 여러 부분이 힙 데이터를 공유 소유해야 한다면 참조 카운팅이 필요합니다. C++의 shared_ptr는 이를 컨트롤 블록(control block) 으로 구현합니다. 이는 공유 객체에 대한 메타데이터를 저장하는 별도의 힙 할당입니다.
일반적인 shared_ptr<T>는 두 포인터를 포함합니다. 관리 대상 객체(T*)에 대한 포인터 하나, 컨트롤 블록에 대한 포인터 하나. 컨트롤 블록은 다음을 담습니다:
shared_ptr 개수)weak_ptr 개수 + 강한 카운트가 0이 아니면 1)shared_ptr를 복사하면 강한 카운트가 아토믹하게 증가합니다. shared_ptr가 파괴되면 강한 카운트가 감소합니다. 강한 카운트가 0이 되면 관리 대상 객체가 파괴됩니다(딜리터 호출). 컨트롤 블록 자체는 약한 카운트도 0이 될 때까지 파괴되지 않습니다. weak_ptr는 객체의 존재 여부를 확인하기 위해 컨트롤 블록을 참조해야 하기 때문입니다.
cppauto p = std::make_shared<Widget>(); auto q = p; // strong count: 2 q.reset(); // strong count: 1 // p 파괴, strong count: 0, Widget 파괴
make_shared는 shared_ptr<T>(new T)보다 선호됩니다. 컨트롤 블록과 객체를 한 번의 할당으로 묶어 캐시 지역성과 할당 오버헤드를 개선하기 때문입니다. 단점은 약한 참조가 남아 있는 동안 컨트롤 블록과 객체가 같은 할당을 공유하므로, 객체 메모리를 약한 참조가 모두 사라질 때까지 해제할 수 없다는 점입니다.
Rc<T>와 Arc<T>Rust는 단일 스레드와 멀티 스레드 케이스를 다른 타입으로 분리합니다. Rc<T>(reference counted)는 비아토믹 연산을 사용하며 스레드 안전하지 않습니다. Arc<T>(atomically reference counted)는 아토믹 연산을 사용하며 스레드 간 공유할 수 있습니다.
Rc<T>의 레이아웃은 shared_ptr와 비슷한 정신을 가지지만 더 단순합니다. Rc<T>는 다음을 포함한 힙 할당을 가리키는 단일 포인터입니다:
Cell<usize>, 비아토믹)Cell<usize>, 비아토믹)T)별도의 딜리터나 할당자가 없습니다. Rc<T>는 항상 전역 할당자를 쓰고 T를 제자리에서 드롭합니다. 즉, Rc<T>는 8바이트 단일 포인터이며, shared_ptr처럼 두 포인터가 아닙니다.
rustuse std::rc::Rc; let a = Rc::new(Widget::new()); let b = Rc::clone(&a); drop(b); // a 드롭, strong count: 0, Widget 드롭, RcBox 해제
관례는 a.clone()보다 Rc::clone(&a)를 쓰는 것입니다. 둘은 동일하게 작동하지만, 명시적 형태는 깊은 복사가 아니라 참조 카운트를 증가시키는 것임을 드러냅니다.
Arc<T>는 카운트가 AtomicUsize인 점만 빼면 같은 레이아웃입니다:
rustuse std::sync::atomic::AtomicUsize; pub struct ArcInner<T> { strong: AtomicUsize, weak: AtomicUsize, data: T, }
아토믹 연산은 오버헤드를 추가합니다. 아토믹 카운터 증가는 하드웨어 수준 동기화를 요구합니다(x86의 lock 프리픽스, ARM의 LL/SC 등). 경합이 높으면 캐시 라인이 코어 사이를 튕깁니다. 공유 소유권이 필요하지만 스레드 안전이 필요 없으면 Rc는 이 비용을 완전히 피합니다.
Rust는 이 분리를 컴파일 타임에 강제합니다. Rc<T>는 Send나 Sync를 구현하지 않습니다. Rc를 스레드 경계 밖으로 이동하려 하면 타입 에러입니다:
rustuse std::rc::Rc; use std::thread; let rc = Rc::new(42); thread::spawn(move || { println!("{}", rc); });
이는 런타임 패닉이 아니라 컴파일 오류입니다. 잘못된 스마트 포인터 타입으로 데이터 레이스를 실수로 도입할 수 없습니다.
Arc::clone의 증가는 Ordering::Relaxed를 사용합니다. 멀티 스레드 원시 타입에서 위험해 보일 수 있지만, 논리는 정밀합니다. 카운트를 증가시키는 것은 다른 메모리 접근과의 순서 관계를 만들지 않습니다. 우리는 이미 Arc에 접근할 수 있는데, 이는 데이터에 대한 유효한 참조가 있다는 뜻입니다. 우리가 하는 일은 단지 소유자가 하나 더 생겼음을 기록하는 것뿐입니다. 필요한 것은 원자성(동시 증가가 카운트를 잃지 않도록)입니다.
rustimpl<T> Clone for Arc<T> { fn clone(&self) -> Arc<T> { let inner = unsafe { self.ptr.as_ref() }; inner.rc.fetch_add(1, Ordering::Relaxed); Arc { ptr: self.ptr, phantom: PhantomData } } }
감소가 복잡한 부분입니다. 마지막 소유자가 Arc를 드롭할 때, 이제 어떤 스레드도 참조를 갖지 않으며 해제해야 합니다. 하지만 해제 전에 이전 소유자들이 수행한 모든 쓰기가 우리에게 보이도록 해야 합니다. 스레드 A가 arc.data에 쓰고 Arc를 드롭한 뒤, 스레드 B가 마지막 Arc를 드롭한다면, B는 소멸자를 실행하기 전에 A의 쓰기를 봐야 합니다.
표준 해법은 release-acquire 동기화입니다:
rustimpl<T> Drop for Arc<T> { fn drop(&mut self) { let inner = unsafe { self.ptr.as_ref() }; if inner.rc.fetch_sub(1, Ordering::Release) != 1 { return; } std::sync::atomic::fence(Ordering::Acquire); unsafe { drop(Box::from_raw(self.ptr.as_ptr())); } } }
Release의 fetch_sub는 그 스레드의 이전 쓰기들이, 같은 아토믹에 대해 Acquire를 수행해 값을 관찰하는 스레드에게 보이도록 보장합니다. 스레드 B의 fetch_sub가 1을 반환하면(마지막 소유자), 뒤이은 Acquire 펜스는 그 전에 일어난 모든 Release 감소들과 동기화합니다. 이제 B는 이전 소유자들이 드롭 전에 수행한 모든 쓰기를 봅니다.
x86에서는 Release fetch_sub가 lock xadd로 컴파일되며, x86의 강한 메모리 모델 때문에 필요한 정렬 보장을 이미 제공합니다. Acquire 펜스는 아무 코드도 생성하지 않을 수 있습니다. ARM에서는 다릅니다. Release는 저장 전에 dmb ish 장벽이 필요하고, Acquire는 로드 후 장벽이 필요합니다.
감소에 Relaxed를 쓰면 해제 스레드가 오래된 데이터를 볼 수 있어, 초기화되지 않은 메모리를 읽거나 부분적으로 쓰인 값을 보는 문제가 생길 수 있습니다. 모든 곳에 SeqCst를 쓰면 올바르지만 불필요하게 비싸고 강한 장벽을 추가합니다. release-acquire 패턴은 올바름에 필요한 최소 동기화입니다.
Arc는 내부적으로 힙 할당에 대한 로 포인터를 들고 있습니다. 순진하게 *mut ArcInner<T>를 쓰면 NonNull<T>와 PhantomData<T>가 해결하는 두 문제가 생깁니다.
로 포인터는 타입 파라미터에 대해 불변(invariant) 입니다. Arc<&'static str>은( 'static: 'a라면) Arc<&'a str>이 기대되는 곳에 들어갈 수 있어야 합니다. 더 긴 수명의 참조는 더 짧은 수명 자리에도 쓸 수 있기 때문입니다. 하지만 *mut T는 이런 대체를 허용하지 않습니다. NonNull<T>는 T에 대해 공변(covariant) 인 포인터 래퍼로, 기대하는 서브타이핑 관계를 되살립니다.
두 번째 문제는 드롭 체커와 관련됩니다. 컴파일러가 타입을 안전하게 드롭할 수 있는지 분석할 때, 타입이 논리적으로 무엇을 소유하는지 알아야 합니다. 로 포인터는 소유권 정보를 담지 않으므로 컴파일러는 *mut T가 T를 소유하지 않는다고 가정합니다. 하지만 Arc<T>는(마지막 소유자일 때) T를 소유합니다. 이를 컴파일러에 전달하지 않으면 거부되어야 할 코드가 컴파일될 수 있습니다.
PhantomData<ArcInner<T>>를 추가하면 드롭 체커에 Arc<T>가 ArcInner<T>를 포함하는 것처럼 행동하며, 이는 T를 포함함을 알려줍니다. 그러면 드롭 체커는 T가 Arc보다 오래 살아야 함을 보장해 소멸자에서의 댕글링 참조를 막습니다.
최종 구조는 다음과 같습니다:
rustpub struct Arc<T> { ptr: NonNull<ArcInner<T>>, phantom: PhantomData<ArcInner<T>>, }
이는 여전히 8바이트 단일 포인터 크기입니다. PhantomData는 크기 0 타입이므로 메모리를 차지하지 않고 타입 시스템에만 영향을 줍니다. NonNull은 *const T에 대한 #[repr(transparent)] 래퍼로 역시 포인터 크기입니다. 레이아웃은 로 포인터와 동일하지만 의미론은 참조 카운팅 스마트 포인터에 맞게 올바릅니다.
weak_ptr 또는 Weak는 관리 대상 객체를 살려두지 않습니다. 이는 객체 자체가 아니라 컨트롤 블록(또는 내부 할당)을 가리키는 포인터를 들고 있습니다. 약한 참조를 강한 참조로 업그레이드하려면, 객체가 아직 존재하는지 아토믹하게 확인하고, 다른 스레드가 0으로 감소시키기 전에 강한 카운트를 증가해야 합니다.
레이스는 이렇습니다. 스레드 A가 마지막 shared_ptr를 들고 있고 드롭하려고 합니다. 스레드 B는 weak_ptr를 들고 lock()을 호출합니다. B가 강한 카운트를 1로 읽고, 그 사이 A가 0으로 감소시키고 해제한 다음, B가 1로 증가시키면 해제 후 사용입니다. 업그레이드는 감소와 원자적으로 경쟁해야 합니다.
C++에서 weak_ptr::lock()은 compare-and-swap 루프를 수행합니다. 강한 카운트를 로드해 0이면 빈 shared_ptr를 반환하고, 아니라면 관찰한 값에서 1 증가시키는 CAS를 시도합니다. 중간에 다른 스레드가 값을 바꾸면 CAS가 실패하고 루프가 재시도됩니다:
cppshared_ptr<T> weak_ptr<T>::lock() const noexcept { long count = control_block->strong_count.load(std::memory_order_relaxed); while (count != 0) { if (control_block->strong_count.compare_exchange_weak( count, count + 1, std::memory_order_acq_rel)) { return shared_ptr<T>(/* from control block */); } // compare_exchange_weak는 실패 시 count를 갱신 } return shared_ptr<T>(); }
Rust의 Weak::upgrade()도 같은 패턴을 따릅니다. 성공 CAS의 acq_rel 순서는, 0이 아닌 카운트를 관찰했다면 이후 데이터 접근이 이전 소유자가 참조를 릴리즈하기 전의 모든 쓰기를 보게 함을 보장합니다.
컨트롤 블록은 두 단계 파괴를 합니다. 강한 카운트가 0이 되면 관리 대상 객체가 파괴됩니다(소멸자 실행, 별도 할당이면 메모리 해제). 컨트롤 블록은 남습니다. 약한 카운트도 0이 될 때 컨트롤 블록이 해제됩니다. 이는 약한 참조가 객체 존재 여부를 안전하게 질의할 수 있게 합니다.
make_shared에서는 객체와 컨트롤 블록이 하나의 할당을 공유합니다. 강한 카운트가 0이 되면 객체 소멸자는 실행되지만, 약한 참조가 남아 있으면(컨트롤 블록 메타데이터가 같은 할당에 있으므로) 메모리를 해제할 수 없습니다. shared_ptr<T>(new T)처럼 분리 할당하면, 강한 카운트가 0이 될 때 객체 메모리는 즉시 해제되고 더 작은 컨트롤 블록만 남습니다.
Arc::clone은 x86에서 lock xadd로 컴파일됩니다. lock 프리픽스는 참조 카운트가 들어 있는 캐시 라인을 독점 상태로 만들어 다른 모든 코어의 복사본을 무효화합니다. 8코어 시스템에서 모두 같은 Arc를 clone하면, 각 clone은 7번의 캐시 무효화를 유발합니다. 참조 카운트는 코어 사이를 핑퐁하며, 각 증가는 캐시 라인이 독점 상태로 도착할 때까지 기다립니다.
asm; x86-64에서 Arc::clone mov rax, qword ptr [rdi] lock xadd qword ptr [rax], 1
lock xadd 자체는 비경합 시 현대 x86에서 대략 15~30 사이클 정도이며, 경합 시 캐시 라인 경쟁으로 100+ 사이클까지 올라갈 수 있습니다. ARM에서는 load-exclusive/store-exclusive 쌍을 씁니다:
asm.retry: ldxr x1, [x0] add x1, x1, #1 stxr w2, x1, [x0] cbnz w2, .retry
다른 코어가 로드와 스토어 사이에 캐시 라인을 수정하면 store-exclusive가 실패해 재시도가 필요합니다. 높은 경합에서는 스레드가 이 루프에서 스핀하며 사이클을 낭비합니다.
Arc::drop의 감소도 같은 아토믹 비용을 가지며, 카운트가 0이 될 때 Acquire 펜스가 추가됩니다. x86에서는 lock xadd가 이미 acquire-release 성격이 있어 펜스가 사실상 무료일 수 있지만, ARM에서는 dmb ish로 확장되어 파이프라인을 멈춥니다.
명령 비용 외에도 참조 카운팅은 현대 캐시 계층과 좋지 않게 상호작용합니다. 참조 카운트와 데이터는 같은 할당(때로는 같은 캐시 라인)에 있습니다. 어떤 스레드가 Arc를 clone/drop하는 동안 다른 스레드가 데이터를 읽으면, 참조 카운트 쓰기 때문에 읽기 스레드의 캐시 라인이 무효화되어 false sharing이 발생합니다. 이를 피하려면 카운트를 별도 할당으로 분리해야 하지만 그러면 간접 참조가 늘고 단일 포인터 레이아웃 장점이 사라집니다.
Rc<T>는 이를 모두 피합니다. 스레딩이 없으므로 증가는 단순 add, 감소는 단순 sub입니다. 캐시 일관성 트래픽도, 메모리 장벽도, 재시도 루프도 없습니다. 타입 시스템이 Rc가 Send가 아님을 보장하므로 동기화를 생략할 수 있습니다.
asm; x86-64에서 Rc::clone mov rax, qword ptr [rdi] inc qword ptr [rax]
C에는 내장 참조 카운팅 스마트 포인터가 없지만 패턴은 널리 쓰입니다. 리눅스 커널의 kref, GLib의 GObject, COM의 IUnknown 등은 모두 수동 참조 카운팅을 구현하며, 각각 고유한 관례와 실패 모드를 가집니다.
단일 스레드 구현은 간단합니다:
cstruct Widget { int refcount; // ... data ... }; struct Widget *widget_ref(struct Widget *w) { w->refcount++; return w; } void widget_unref(struct Widget *w) { if (--w->refcount == 0) { widget_destroy(w); free(w); } }
스레딩에는 아토믹이 필요합니다. C11 이전에는 GCC의 __sync_fetch_and_add, MSVC의 InterlockedIncrement, 인라인 어셈블리 등 컴파일러/플랫폼 특화였고, C11이 아토믹을 표준화했지만 메모리 오더링은 여전히 올바르게 선택해야 합니다:
c#include <stdatomic.h> struct Widget { atomic_int refcount; // ... data ... }; struct Widget *widget_ref(struct Widget *w) { atomic_fetch_add_explicit(&w->refcount, 1, memory_order_relaxed); return w; } void widget_unref(struct Widget *w) { if (atomic_fetch_sub_explicit(&w->refcount, 1, memory_order_release) == 1) { atomic_thread_fence(memory_order_acquire); widget_destroy(w); free(w); } }
오더링은 Arc에서 본 것과 같습니다. 증가에는 relaxed(이미 유효한 참조를 가지고 있으니), 감소에는 release(우리의 쓰기를 공개), 파괴 전에는 acquire 펜스(모든 릴리저와 동기화). 이 중 하나라도 틀리면 데이터 레이스가 생기며, 특정 타이밍 창에서만 드러나는 간헐적 손상, 해제 후 사용, 희귀 크래시로 나타날 수 있습니다.
리눅스 커널의 kref는 이 패턴을 kref_get/kref_put 같은 작은 구조체로 감쌉니다. 하지만 дисципline은 강제되지 않습니다. 카운트가 이미 0이 된 객체에 kref_get을 호출하거나, 오류 경로에서 kref_put을 빼먹는 것을 막는 것은 없습니다. Sparse나 Coverity 같은 정적 분석 도구가 일부 버그를 잡고, 나머지는 테스트/퍼징/프로덕션 크래시로 발견됩니다.
모든 사용 지점은 참조를 획득할 때 widget_ref, 해제할 때 widget_unref를 기억해야 합니다. 스코프 종료 시 자동 정리가 없고, 블록 끝에서 호출될 소멸자도 없습니다. 함수가 오류로 조기 반환하면, 획득한 모든 참조는 명시적으로 해제되어야 합니다. unref 하나를 놓치면 누수이고, unref를 하나 더 하면 카운트가 망가지거나 이중 해제가 됩니다.
3부에서는 타입이 메모리에서 어떻게 표현되는지와 다형성이 어떻게 구현되는지 살펴볼 것입니다. Rust는 기본적으로 패딩을 최소화하기 위해 구조체 필드 순서를 재배치하지만, repr(C)는 FFI를 위해 예측 가능한 레이아웃을 복원합니다. 팻 포인터(fat pointer)는 데이터 주소와 함께 메타데이터를 담습니다. 슬라이스는 길이를, 트레이트 객체는 vtable 포인터를 담습니다. 우리는 단형화(monomorphization: C++ 템플릿, Rust 제네릭)와 동적 디스패치(C++ 가상 함수, Rust 트레이트 객체)를 비교하며, 생성된 코드와 바이너리 크기, 호출 오버헤드, 캐시 동작의 트레이드오프를 살펴볼 것입니다. 마지막으로 클로저가 전체 그림을 완성합니다. 클로저는 환경을 캡처하는 익명 구조체이며, Fn/FnMut/FnOnce 트레이트가 캡처된 변수를 어떻게 접근하는지 인코딩합니다.