퇴근하지 않는 코드 리뷰어: C++ 개발자가 Rust에 적응할 수 있는 이유

Rust가 어색한 C++ 개발자에게. 낯설어 보이는 개념 대부분은 C++ 개발자가 이미 손으로 지키던 규칙이다. 달라지는 건 그 규칙을 누가 지키게 하느냐뿐이다.

  • rust
  • cpp
  • programming-languages

2006년, 스물아홉 살 프로그래머 그레이든 호어가 밴쿠버의 아파트로 돌아왔다. 엘리베이터가 멈춰 있었다. 그의 집은 21층이었다. 엘리베이터를 움직이는 소프트웨어가 또 죽은 것이다. 처음 있는 일도 아니었다. 훗날 그는 계단을 오르며 이런 생각을 했다고 털어놓았다. “컴퓨터 한다는 사람들이 엘리베이터 하나 안 죽게 못 만들다니, 말이 되나!”

호어는 그때 모질라에서 일하고 있었다. 그해 그는 짬을 내 작은 프로그래밍 언어를 만들기 시작했다. 이름은 녹병균(rust fungi)에서 따왔다. 모질라는 2009년부터 이 프로젝트를 후원했고, 2015년 5월 15일 Rust 1.0이 나왔다.

C++ 엔지니어는 대개 몇 년 치 습관과 막연한 반감을 안고 Rust를 처음 만난다. 첫 주는 새집에 이사 온 기분이다. 전등 스위치가 하나같이 손이 가는 자리에서 몇 센티미터씩 비켜나 있다. 어려운 건 없다. 그런데 모든 게 조금씩 어긋나 있다.

자세히 들여다보면 사정이 다르다. 낯설어 보이는 것 대부분은 C++ 엔지니어가 이미 아는 것들이다.

늦은 밤, 모니터 하나만 켜진 책상

AI로 생성한 이미지

손으로 지키던 규칙

1980년대, 비야네 스트롭스트룹은 C++에 습관 하나를 심었다. 이름은 RAII(Resource Acquisition Is Initialization)다. 우리말로 옮기면 ’자원 획득은 초기화다’쯤 되는데, 이름만 들어서는 무슨 뜻인지 아무도 모른다. 정작 하는 일은 한 줄로 끝난다. 쓴 사람이 치운다.

C++에서 객체(object)는 태어날 때 자원을 챙기고, 사라질 때 돌려준다. 객체가 사라지는 때는 스코프(scope)를 벗어날 때다. 스코프는 변수가 살아 있는 범위로, 대개 중괄호 { } 한 쌍이다. 객체가 사라지는 순간에는 소멸자(destructor)라는 뒷정리 함수가 저절로 불린다. 열어 둔 파일, 네트워크 연결, 메모리, 여러 작업이 같은 데이터를 한꺼번에 건드리지 못하게 거는 락(lock)까지 모두 이 방식으로 관리한다. C++로 밥벌이를 해 봤다면 이 방식이 너무 몸에 배어서 이제는 의식조차 하지 않을 것이다.

2011년에는 C++11 표준에 std::unique_ptr와 std::move가 들어왔다. unique_ptr는 메모리 주소를 담는 포인터(pointer)이면서 그 메모리의 주인이기도 하다. 그래서 자기가 사라질 때 메모리를 알아서 해제한다. std::move는 그 주인 자리를 다른 변수에 넘긴다. 이제 메모리 한 조각에 주인을 딱 하나만 둘 수 있게 됐다. 실력 있는 C++ 팀들은 이 원칙을 중심으로 스타일 가이드를 짰다. 주인은 하나. 복사하지 말고 옮길 것. 옮긴 뒤에는 건드리지 말 것.

문제는 마지막 규칙이 우리 머릿속에만 있었다는 점이다. 컴파일러는 이 규칙을 어겨도 순순히 넘어갔다.

auto a = std::make_unique<Packet>();
auto b = std::move(a);   // 주인 자리를 b에게 넘긴다
a->send();               // 컴파일은 된다. 하지만 a는 이미 빈 포인터다

운 좋은 날에는 이 코드가 테스트에서 죽는다. 운 나쁜 날에는 고객 현장에서 죽는다. 그러면 누군가 노트북을 챙겨 비행기에 오르고, 내리는 곳에는 잔뜩 화가 난 고객이 기다리고 있다. 이 한 줄이 제품에 실려 나가는 걸 막아 줄 사람은, 그날 오후 마침 정신이 맑았던 코드 리뷰어 하나뿐이었다.

같은 실수를 Rust로 쓰면 이렇다.

let a = Packet::new();
let b = a;     // 주인 자리가 b로 넘어간다
a.send();      // error[E0382]: borrow of moved value: `a`

이번에는 빌드부터 되지 않는다. 에러 메시지는 “이미 다른 곳으로 옮겨 간 값 a를 쓰려 했다”는 뜻이다. Rust의 소유권(ownership) 규칙은 우리가 늘 강조하던 unique_ptr 규율을 언어 자체에 새겨 넣었다. 그리고 컴파일할 때마다 한 줄도 빠짐없이 검사한다. 이 검사를 맡은 것이 빌림 검사기(borrow checker)다. 모든 줄을 읽고, 점심시간에도 쉬지 않고, 금요일 오후라고 대충 승인 도장(LGTM, Looks Good To Me)을 찍어 주지도 않는 리뷰어다.

C++ 엔지니어가 Rust에 적응할 수 있는 이유가 여기 있다. 개념은 이미 익숙하다. 달라지는 건 그 규칙을 누가 지키게 하느냐뿐이다.

C++ 머리로 읽는 Rust

Rust를 배우다 헷갈리는 개념도 대부분 C++에 짝이 있다. 처음 몇 주 동안 옆에 펴 두면 좋을 대응표를 정리했다.

대입은 move다. Rust의 let b = a;는 C++의 auto b = std::move(a);와 같다. 다른 점은 std::move를 적지 않아도 기본이 move라는 것 하나다. C++에서 std::move를 열 번쯤 타이핑해 본 사람에게 Rust는 그 열 번을 대신 쳐 주는 언어다.

예외도 있다. 정수나 f64처럼 복사가 싼 타입은 move 대신 그냥 복사된다. 이런 타입에는 Copy라는 트레이트(trait)가 붙어 있다. 트레이트는 타입이 무엇을 할 수 있는지 밝혀 두는 이름표 같은 것이다. C++로 치면 메모리를 그대로 베껴도 되는 단순한 타입, 흔히 trivially copyable이라고 부르는 타입이다. 가리키는 데이터까지 통째로 복제하는 깊은 복사(deep copy)가 필요하면 .clone()을 직접 부른다. C++는 객체를 복사할 때 복사 생성자(copy constructor)를 알아서 부르지만, Rust에서는 그런 일이 몰래 일어나지 않는다.

let s = String::from("packet");
let t = s.clone();   // 복사 생성자를 직접 부르는 셈
let u = s;           // auto u = std::move(s);
let n = 42;
let m = n;           // i32는 Copy라서 n을 계속 쓸 수 있다

기본이 const다. C++의 const는 한번 정하면 바꿀 수 없다는 표시이고, auto는 타입을 컴파일러에게 맡기는 키워드다. Rust의 let x는 C++의 const auto x이고, let mut x가 그냥 auto x다. C++ 코드 리뷰에서 “여기 const 빠졌어요”라고 몇 번이나 썼는지 세어 보면, Rust가 왜 기본값을 거꾸로 정했는지 이해가 된다.

빌림(borrowing)은 참조를 만드는 일이다. Rust에서는 &로 참조를 만드는 것을 빌림이라고 부른다. 주인은 그대로 두고, 값을 잠시 써도 된다는 허락만 건네는 것이다. C++로 치면 함수에 const Packet&를 넘기는 것과 같다. C++의 참조(reference)는 값을 복사하지 않고 원본을 가리키는 별명이다. 앞에 const가 붙으면 그 별명으로는 원본을 고칠 수 없다. 받은 쪽은 읽기만 할 뿐 지우거나 가져갈 수 없고, 함수가 끝나도 값은 원래 주인에게 그대로 있다. 세 가지 경우를 함수 시그니처로 나란히 놓으면 이렇다.

fn checksum(p: &Packet) -> u32 { /* ... */ }  // 빌려서 읽기만: uint32_t checksum(const Packet& p)
fn stamp(p: &mut Packet) { /* ... */ }        // 빌려서 고치기: void stamp(Packet& p)
fn send(p: Packet) { /* ... */ }              // 아예 넘겨받기: void send(Packet p) + std::move

let mut pkt = Packet::new();
checksum(&pkt);       // 빌려줬다가 돌려받는다
stamp(&mut pkt);      // 고치라고 빌려줬다가 돌려받는다
send(pkt);            // 주인 자리를 넘긴다. 이제 pkt는 못 쓴다

여기까지는 C++ 개발자에게 새로울 게 없다. 다른 점은 C++가 관례로 지키던 약속을 Rust는 규칙으로 검사한다는 것이다. 빌림의 규칙은 세 가지다. 첫째, 빌린 참조는 주인보다 오래 살 수 없다. 둘째, 누가 빌려 간 동안 주인은 그 값을 옮기거나 고칠 수 없다. 셋째가 바로 아래의 독점 규칙이다.

&mut는 화장실 열쇠다. Rust의 &T는 C++의 const T&이고, &mut T는 T&다. 읽기 전용 참조 &T는 동시에 몇 개든 만들 수 있다. 고칠 수 있는 참조 &mut T는 딱 하나만, 그것도 읽기 전용 참조가 하나도 없을 때만 만들 수 있다. 열쇠는 하나뿐이고, 누가 들고 들어가면 나머지는 밖에서 기다린다. 읽는 사람은 몇이든 들여보내고 쓰는 사람만 혼자 들여보내는 읽기·쓰기 락(reader-writer lock)을 컴파일 단계에서 거는 셈이다. 이 규칙이 무엇을 막는지는 C++ 개발자가 제일 잘 안다.

std::vector<int> v{1, 2, 3};
const int& first = v[0];
v.push_back(4);          // 메모리를 새로 잡아 이사하면 first는 빈집을 가리킨다
std::cout << first;      // 컴파일은 된다. 결과는 미정의 동작
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);   // error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
println!("{first}");

C++를 쓰지 않는 독자를 위해 풀어 보자. C++에서는 std::vector처럼 여러 값을 담아 두는 자료구조를 컨테이너(container)라고 부른다. std::vector는 크기가 늘어나는 배열로, 원소를 연속된 메모리 한 덩어리에 담는다. 자리가 꽉 찬 상태에서 push_back으로 원소를 더하면, 더 큰 메모리를 새로 잡아 원소를 모두 옮기고 옛 메모리는 반납한다. 이사를 가는 것이다.

그런데 first는 여전히 옛 집 주소를 들고 있다. 그 주소로 찾아가면 무엇이 나올지 아무도 모른다. 대개는 옛 값이 아직 남아 있어서 멀쩡해 보이다가, 어느 날 엉뚱한 값을 읽거나 프로그램이 죽는다. 이렇게 컨테이너가 바뀌면서 그 안을 가리키던 참조나 포인터, 반복자(iterator)가 쓸모없어지는 현상을 반복자 무효화(iterator invalidation)라고 한다. C++ 컴파일러는 이걸 막아 주지 않는다. C++ 표준은 이런 상황을 미정의 동작(undefined behavior, UB)이라고 부른다. 무슨 일이 벌어지든 언어는 책임지지 않겠다는 뜻이다.

반복자 무효화로 밤을 새워 본 사람이라면 빌림 검사기가 왜 이렇게 까다로운지 단번에 이해할 것이다.

수명(lifetime) 표기는 컴파일되는 주석이다. 처음 보면 가장 겁나는 문법이 'a다. C++에서 참조를 돌려주는 함수에 “반환값은 인자 a, b보다 오래 살지 않음”이라고 주석을 달아 본 적이 있다면, 수명 표기는 그 주석을 컴파일러가 읽을 수 있게 쓴 것이다.

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

C++에서 같은 함수에 임시 객체(temporary)를 넘기면 사고가 난다. 임시 객체는 한 문장이 끝나면 바로 사라지는 이름 없는 값이다. 함수가 돌려준 참조는 이미 사라진 값을 가리키게 되고, 이를 댕글링 참조(dangling reference)라고 한다. C++는 조용히 넘어가지만, Rust는 함수를 부르는 자리에서 잡아낸다. 다행히 수명은 대부분 컴파일러가 알아서 추론하므로 직접 쓸 일은 생각보다 드물다.

스마트 포인터는 이름표만 바뀌었다. 스마트 포인터(smart pointer)는 메모리를 가리키다가 쓸모가 다하면 해제까지 알아서 하는 포인터다. 새 언어를 배우는 줄 알았는데, 이 대목에서는 이름표만 갈아 끼우면 된다.

  • Box<T>는 std::unique_ptr<T>다.
  • Arc<T>는 std::shared_ptr<T>다. 여러 주인이 함께 가리키고, 주인이 몇인지 세어 두었다가 0이 되면 해제한다. 이 수를 참조 카운트(reference count)라고 한다. 동시에 도는 여러 스레드(thread)가 함께 세어도 꼬이지 않도록 원자적(atomic)으로 센다.
  • Rc<T>는 원자적 연산을 뺀 가벼운 shared_ptr다. 그만큼 빠르지만 한 스레드 안에서만 쓸 수 있고, 다른 스레드로 넘기려 하면 컴파일러가 막는다. C++에는 없는 물건이다.
  • Weak<T>는 std::weak_ptr<T>다. 가리키기만 하고 참조 카운트에는 끼지 않는다.
  • Drop 트레이트는 소멸자다.

나머지 대응표.

  • Vec<T>는 std::vector<T>, String은 std::string이다.
  • &str은 std::string_view, &[T]는 std::span<T>다. 둘 다 데이터를 복사하지 않고 일부를 들여다보는 창이다.
  • Option<T>는 std::optional<T>다. 값이 있을 수도, 없을 수도 있다.
  • Result<T, E>는 C++23의 std::expected<T, E>다. 값 아니면 에러를 담는다.
  • 데이터를 품은 enum과 match는 std::variant와 std::visit다. variant는 여러 타입 중 하나를 담는 상자이고, visit는 상자에 든 타입에 따라 처리를 나눈다. Rust에서는 빠뜨린 경우가 하나라도 있으면 컴파일이 안 된다.
  • 트레이트와 제네릭(generic)은 템플릿(template)과 C++20 콘셉트(concept)다. 템플릿은 타입 자리를 빈칸으로 남겨 둔 코드 틀이고, 콘셉트는 그 빈칸에 들어올 타입이 갖춰야 할 조건이다. Rust 제네릭도 템플릿처럼 타입마다 코드를 따로 찍어 낸다(monomorphization).
  • dyn Trait는 가상 함수(virtual function)다. 실행 중에 객체의 실제 타입을 보고 부를 함수를 고르는 방식이다. C++는 함수 주소표(vtable)를 가리키는 포인터를 객체마다 넣어 두는데, Rust는 이 포인터를 참조 옆에 붙여 들고 다닌다. 이렇게 덩치가 커진 참조를 팻 포인터(fat pointer)라고 한다.
  • unsafe는 그동안 써 온 C++ 전부다. unsafe 블록 안에서는 컴파일러가 안전 검사를 끄고, 책임은 프로그래머가 진다. C++ 개발자에게는 그리운 고향 같은 곳이다. 다만 문 앞에 ’여기부터 책임은 본인에게’라는 팻말이 붙어 있다.

예외 대신 ?. C++의 예외(exception)는 에러가 나면 함수 호출을 거슬러 올라가며 처리할 곳을 찾는 장치다. Rust에는 예외가 없다. 실패할 수 있는 함수는 Result를 돌려주고, 부르는 쪽은 ? 한 글자로 에러를 위로 넘긴다. 줄마다 if (!ok) return err;를 적던 C식 에러 처리를 연산자 하나로 접은 것이다. 물음표 한 글자가 그런 코드 수백 줄을 대신한다. C 개발자라면 이 대목에서 조금 울컥할지도 모른다.

fn load(path: &str) -> Result<Config, std::io::Error> {
    let text = std::fs::read_to_string(path)?;  // 실패하면 여기서 바로 돌아간다
    Ok(parse(&text))
}

이 대응표를 옆에 두고 시작하면, 처음 몇 주에 만나는 컴파일 에러 대부분이 “아, 그 얘기구나”로 바뀐다.

구글이 내놓은 숫자 두 개

2019년, 안드로이드 보안 취약점의 76%는 메모리 안전성(memory safety) 버그였다. 이미 해제한 메모리를 다시 쓰거나 배열 끝을 넘어 읽는 식의 버그다. 구글은 새로 짜는 안드로이드 코드를 Rust 같은 메모리 안전 언어로 쓰기 시작했다. 2024년, 그 비중은 24%로 떨어졌다.

2022년, 구글은 Rust 코드를 작성해 커밋한 적이 있는 사내 개발자 1,000여 명에게 설문을 돌렸다. 그중 전에 Rust를 써 본 사람은 13% 남짓이었다. 응답자의 3분의 2 이상이 두 달 안에 Rust 코드베이스에 기여할 자신이 생겼다고 답했다. 27%는 23주, 39.8%는 12개월 만이었다.

옛 전등 스위치를 더듬지 않게 되기까지 두 달이면 된다는 얘기다.

끝내 어색한 것들

이 설문은 Rust의 아픈 곳도 드러냈다. 느린 빌드가 단연 1위 불만이었다. 빌드 속도가 괜찮다고 답한 사람은 40%를 조금 넘었을 뿐이다. 가장 어려운 주제로는 소유권과 빌림이 매크로(macro), 비동기 프로그래밍(async)과 함께 꼽혔다. 매크로는 코드를 만들어 내는 코드다.

빌림 검사기(borrow checker)는 공정하지만 느리고 고집 센 리뷰어다. 실제로는 문제없는 코드도 문제없음을 증명하지 못하면 퇴짜를 놓는다. 깐깐한 선배 리뷰어 밑에서 일해 본 C++ 엔지니어라면 이 기분을 안다. 투덜대며 고쳐 쓰다 보면, 몇 달 뒤에는 어느새 처음부터 그렇게 쓰고 있다.

가장 유명한 골칫거리는 이중 연결 리스트(doubly linked list)다. 원소마다 앞뒤 원소를 서로 가리키는 구조라, 원소 하나를 두 이웃이 동시에 붙잡고 있다. 주인은 하나라는 Rust의 규칙과 정면으로 부딪힌다. C++에서는 오후 한나절이면 짜는 물건이다. Rust에서는 두 길 중 하나를 고른다. Rc와 RefCell을 꺼내 들거나, unsafe 블록을 열고 책임을 직접 지는 것이다. RefCell은 빌림 규칙을 프로그램이 도는 동안 검사하는 상자다. unsafe 쪽은 C++에서 내내 해 오던 일이다. 다른 점은 Rust가 그 자리에 팻말을 세워 두게 한다는 것뿐이다.

C++를 만든 사람

2025년 2월 7일, 스트롭스트룹은 C++ 표준위원회 WG21에 메모를 보냈다. 그는 이 메모를 “C++에 대한 전례 없는 심각한 공격에 일부 대응하는, 긴급 행동 촉구”라고 불렀다. 그가 내놓은 답은 프로파일(Profiles)이라는 틀이다. 컴파일러가 강제하는 규칙으로 C++를 타입 안전(type-safe)하고 자원 안전(resource-safe)하게 만들자는 것이다. 메모리를 엉뚱한 타입으로 읽거나 이미 돌려준 자원을 다시 쓰는 일을 컴파일 단계에서 막겠다는 얘기다.

이 설명을 Rust를 떠올리며 다시 읽어 보자. RAII를 만든 사람이, RAII가 늘 우리에게 요구해 온 것을 컴파일러가 강제하게 하려 한다. 호어가 밴쿠버의 계단을 오르며 떠올린 생각과 같다. 이번에는 그 생각이 C++ 쪽에서 나왔을 뿐이다.

오해는 없기를 바란다. 나는 스트롭스트룹 박사를 존경한다. 빌림 검사기(borrow checker)가 내게 들이미는 규칙은 하나같이 그의 언어가 가르쳐 준 습관에서 출발했다. 40년 넘게 자기 언어를 더 안전하게 만들려고 애써 온 사람을 블로그 글 한 편으로 놀릴 생각은 없다.

그래도 궁금한 건 어쩔 수 없다. 언젠가 C++에도 빌림 검사기가 생기면, 우리는 그걸 모던 C++라고 부를까? 아니면 그동안 손으로 Rust를 짜 왔다고 마침내 인정하게 될까?


참고 자료

  1. Clive Thompson, “How Rust went from a side project to the world’s most-loved programming language,” MIT Technology Review, 2023년 2월 14일 (CACM 재게재). https://cacmb4.acm.org/news/270144-how-rust-went-from-a-side-project-to-the-worlds-most-loved-programming-language
  2. “Rust (programming language),” Wikipedia. https://en.wikipedia.org/wiki/Rust_(programming_language)
  3. Google Open Source Blog, “Rust fact vs. fiction: 5 Insights from Google’s Rust journey in 2022,” 2023년 6월. https://opensource.googleblog.com/2023/06/rust-fact-vs-fiction-5-insights-from-googles-rust-journey-2022.html
  4. Jeff Vander Stoep, Alex Rebert, “Eliminating Memory Safety Vulnerabilities at the Source,” Google Security Blog, 2024년 9월 25일. https://security.googleblog.com/2024/09/eliminating-memory-safety-vulnerabilities-Android.html
  5. TechRadar, “Google hails move to Rust for huge drop in memory vulnerabilities.” https://www.techradar.com/pro/google-hails-move-to-rust-for-huge-drop-in-memory-vulnerabilities
  6. The Register, “C++ creator calls for help to defend programming language from ‘serious attacks’,” 2025년 3월 2일. https://www.theregister.com/2025/03/02/c_creator_calls_for_action/