u64인 사용자 ID를 주문 ID를 기대하는 함수에 넘기면 Rust는 불평하지 않는다. 둘 다 u64이기 때문이다. 컴파일러는 두 타입이 동일하게 보이므로 당신을 도울 수 없다. 버그는 런타임에, 보통 프로덕션에서, 보통 당신이 안전하다고 생각한 리팩터링 이후에야 발견된다.
이것이 바로 newtype가 존재하는 이유이자 없애려는 정확한 버그 유형이다.
newtype는 기존 타입을 감싸는 단일 필드 tuple struct다: struct UserId(u64);. 컴파일 타임에 UserId와 OrderId는 서로 호환되지 않는다. 런타임에는 순수한 u64와 정확히 동일한 바이트를 차지한다. 추가 할당도, 간접 참조도, 비용도 없다.
이것이 실제로 해결하는 문제는 무엇인가?
정적 타입을 가진 모든 언어는 이 문제를 갖고 있다. 표현은 같지만 의미가 다른 두 값이 있는 것이다. 데이터베이스 키, 물리적 단위, 통화 금액, 퍼센트와 원시 카운트. C에서는 typedef를, Go에서는 타입 별칭(type alias)을 쓸 것이다. 하지만 이것들은 기저 타입에 대한 이름일 뿐이다. 컴파일러는 여전히 둘을 동일하게 취급한다.
Rust의 newtype 패턴은 다르다. struct UserId(u64);는 별개의 타입을 만든다. OrderId가 기대되는 곳에 UserId를 넘길 수 없다. 실수로 퍼센트를 원시 카운트에 더할 수 없다. 오류는 컴파일 타임에 드러나며, 프로덕션 인시던트가 아니다.
이것이 사람들이 말하는 “불법적인 상태를 표현 불가능하게 만들기”다. 이는 이론이 아니다. 나는 한때 계정 ID와 이체 금액, 둘 다 u64인 API 엔드포인트를 본 적이 있다. 리팩터링에서 호출자가 둘을 바꿨다. 컴파일 중에는 아무것도 고장나지 않았다. 돈은 잘못된 곳으로 이동했다. newtype가 있었다면, 그 리팩터링은 누군가 배포하기 전에 컴파일에 실패했을 것이다.
newtype가 내부적으로 작동하는 방식
Rust의 newtype는 이름 없는 필드가 하나인 struct일 뿐이다:
struct UserId(u64);
struct OrderId(u64);
컴파일러는 UserId를 OrderId 및 u64와 완전히 별개의 타입으로 취급한다. 명시적으로 생성한다: let id = UserId(42);. 내부 값은 id.0으로 접근한다.
struct가 알려진 크기의 단일 필드를 가지고 있기 때문에, 컴파일러는 사실상 최적화가 아닌, 그저 struct가 작동하는 방식인 최적화를 적용한다. UserId의 메모리 레이아웃은 u64와 동일하다. 같은 크기, 같은 정렬(alignment), 같은 ABI.
직접 확인할 수 있다:
use std::mem;
assert_eq!(mem::size_of::<UserId>(), mem::size_of::<u64>());
assert_eq!(mem::align_of::<UserId>(), mem::align_of::<u64>());
vtable도, discriminant도, 래퍼 객체(wrapper object)도 없다. 함수에 UserId를 전달하기 위해 생성된 기계어 코드는 u64를 전달하는 것과 동일하다. 타입 시스템이 구분을 강제한다. 런타임은 그것을 지워버린다.
구체적인 예시: 픽셀과 포인트를 혼동하기
한때 나를 물었던 패턴이 여기 있다. 그래픽 라이브러리는 거리를 픽셀과 디바이스 독립적 포인트(device-independent points) 두 가지로 표현했다. 둘 다 f32였다. 나는 픽셀이 기대되는 곳에 포인트를 넘겼고, UI는 고DPI 화면에서 절반 크기로 렌더링되었다. 컴파일러는 침묵했는데, 둘 다 f32였기 때문이다.
newtype를 사용하면:
struct Pixels(f32);
struct Points(f32);
fn scale_to_pixels(points: Points, dpi: f32) -> Pixels {
Pixels(points.0 * dpi / 96.0)
}
fn draw_line(length: Pixels) {
// render at this pixel length
}
fn main() {
let width = Points(150.0);
let dpi = 192.0;
// This compiles:
draw_line(scale_to_pixels(width, dpi));
// This does not:
// draw_line(width);
// error: expected `Pixels`, found `Points`
}
컴파일러는 프로그램을 실행하기 전에 그 실수를 거부한다. Points와 Pixels 값은 f32와 동일한 4바이트를 사용한다. 안전성은 런타임에 아무런 비용도 들지 않는다.
아무도 알려주지 않는 트레이드오프
newtype를 작성하는 데는 비용이 든다. 감싼 타입의 메서드는 자동으로 전파되지 않는다. u64에는 wrapping_add, leading_zeros, 수십 가지 메서드가 있다. 맨 bare UserId는 직접 구현하지 않는 한 이 중 어느 것도 없다.
세 가지 선택지가 있고, 그중 하나만이 좋다.
옵션 1: Deref를 구현한다. auto-deref를 통해 내부 값의 모든 메서드를 사용할 수 있다:
use std::ops::Deref;
struct UserId(u64);
impl Deref for UserId {
type Target = u64;
fn deref(&self) -> &u64 { &self.0 }
}
이것은 작동하지만, 목적을 무색하게 만든다. Deref는 UserId에서 u64로의 암시적 강제 변환(implicit coercion)을 허용하며, 이는 u64가 기대되는 어디에든 UserId를 넘길 수 있다는 뜻이다. 방금 얻은 타입 안전성을 잃게 된다. newtype에 대해서는 이렇게 하지 마라.
옵션 2: 모든 것을 수동으로 구현한다. 이는 지루하지만, 도메인에 맞는 연산만 노출할 수 있다. ID 타입의 경우, 아마도 Display, Debug, PartialEq, Eq, Hash만 필요할 것이다:
use std::fmt;
#[derive(Debug, PartialEq, Eq, Hash)]
struct UserId(u64);
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
산술 연산이 가능한 수치 타입의 경우, Add, Sub 등을 구현한다. 이는 보일러플레이다. 표준 derive 매크로와 derive_more 같은 크레이트가 도움이 되지만, 여전히 원시 타입보다 더 많은 코드다.
옵션 3: 필요할 때 내부 값을 직접 사용한다. 이것이 내가 선호하는 방법이다. API 경계에서 newtype를 유지하고, 원시 값이 필요할 때 .0으로 언랩(unwrap)하고, Deref는 완전히 피한다. 조금 더 장황하지만, 안전성 보장을 유지한다.
이것을 견딜 만하게 만드는 derive 매크로
Rust의 표준 라이브러리는 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]를 무료로 제공한다. 산술 연산의 경우, std::ops나 derive_more 같은 크레이트를 사용한다:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct Meters(f64);
impl std::ops::Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
컴파일러는 여전히 순수한 f64 덧셈과 동일한 기계어 코드를 생성한다. Add trait도 zero-cost abstraction다. 이는 모노모피제이션(monomorphization)을 통해 연산을 인라인화한다.
newtype가 잘못된 도구인 경우
모든 원시 타입이 newtype를 필요로 하는 것은 아니다. 함수가 timeout_ms: u64를 받고 로컬에서만 사용된다면, 이를 감싸는 것은 실제 버그를 잡지 못한 채 노이즈만 추가한다. newtype는 API 경계를 넘나드는 값, 데이터베이스에 지속되는 값, 혹은 혼동했을 때 실제 결과를 초래하는 개념을 나타내는 값에 대해서만 사용하라.
직렬화(serialization)도 이상해진다. serde를 사용할 때, UserId(u64)는 기본적으로 42가 아닌 {"0": 42}라는 맵으로 직렬화된다. 이를 고치려면 #[serde(transparent)]가 필요하다:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);
이것은 잊기 쉽고, JSON API가 갑자기 숫자 대신 객체를 기대할 때 디버깅하기 짜증난다.
공개 API부터 시작하라
같은 원시 타입이지만 다른 의미를 가진 여러 인수를 받는 함수를 찾아라. 모듈이나 서비스 경계를 넘나드는 것들을 감싸라. Deref를 derive하지 마라. 직렬화를 한다면 #[serde(transparent)]를 사용하라.
cargo-expand가 설치되어 있다면, newtype에 대해 cargo expand를 실행하고 생성된 코드를 살펴보라. struct는 단지 다른 타입 이름을 가진 원시 값일 뿐임을 알게 될 것이다. zero-cost 주장은 마케팅이 아니다. 말 그대로 컴파일러가 출력하는 것이다.