검증 함수를 여기저기 두는 대신 타입 수준에서 불변식을 인코딩해 API를 더 안전하고 리팩터링에 강하게 만드는 ‘Parse, don’t Validate’ 패턴을 Rust 중심으로 설명한다.
@haruda gondi
읽는 데 걸리는 시간: 17분
1.1 0으로 나누기
1.2 실전에서의 예시
1.3 타입 주도 설계의 격언
1.4 우리가 할 수 있는 일
1.5 결론

Rust Programming Language Community Server에는 -parse-dont-validate라는 태그가 있는데, 이는 “검증 함수(validation functions)를 피하고, 대신 타입 수준에 불변식(invariants)을 인코딩하라”는 개념을 다룬 글로 연결됩니다. 저는 API 설계에 어려움을 겪는 Rust 입문/중급자에게 종종 이 글을 추천하곤 합니다.
유일한 문제는, 그 글이 Haskell로 개념을 설명한다는 점입니다.
뭐, 괜찮긴 한데, 함수형 패러다임에 익숙하지 않은 초보자에게는 접근성이 떨어질 수 있죠. 그래서 저는 이 패턴을 Rust 중심으로 설명하는 글을 쓰고 싶었습니다. 그럼 시작해봅시다!
가장 기본적인 예로, 어떤 수를 다른 수로 나누는 함수를 들 수 있습니다.
fn divide(a: i32, b: i32) -> i32 { a / b}
겉보기엔 괜찮지만, b가 0이면 패닉이 발생합니다.
fn main() { let a = 5; let b = 0; dbg!(divide(a, b));}
다음과 같은 에러가 납니다:
Compiling playground v0.0.1 (/playground) Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.28s Running `target/debug/playground`
thread 'main' (41) panicked at src/main.rs:2:5:attempt to divide by zeronote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
잘못된 값은 런타임에서 크게 실패하도록 만드는 게 목적이라면 이도 나쁘지 않습니다. 하지만 더 강한 보장을 원한다면 어떨까요? 특히 아래처럼 “크게 실패하지 않는” 연산도 있을 때 중요해집니다:
fn divide_floats(a: f32, b: f32) -> f32 { a / b}
fn main() { let a = 5.0; let b = 0.0; dbg!(divide_floats(a, b));}
Compiling playground v0.0.1 (/playground) Finished devprofile [unoptimized + debuginfo] target(s) in 0.62s Runningtarget/debug/playground[src/main.rs:8:2] divide_floats(a, b) = inf
에러가 없습니다! 그런데 이게 우리가 원하는 동작일까요?
정수 나눗셈과 비슷한 동작을 흉내 내기 위해 divide_floats 함수 안에 assert!를 넣을 수도 있습니다.
fn divide_floats(a: f32, b: f32) -> f32 { assert_ne!(b, 0.0, "Division by zero is not allowed."); a / b}
Compiling playground v0.0.1 (/playground) Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.65s Running `target/debug/playground`
thread 'main' (32) panicked at src/main.rs:2:5:assertion `left != right` failed: Division by zero is not allowed. left: 0.0 right: 0.0
귀엽긴 한데, 여전히 문제는 런타임에 가서야 패닉을 만난다는 점입니다. 제가 Python(혹은 다른 동적 언어 전반)에 불만이 있는 부분도 바로 이것입니다. 많은 오류가 프로그램을 실제로 실행할 때에만 드러나죠. 그래서 요즘 이런 언어들에도 타입체킹을 붙이고 있습니다. 사람들은 일부 실수를 컴파일 타임(혹은 타입체킹 타임)으로 끌어올리고 싶어하니까요. Rust의 풍부한 타입 시스템을 이용하면 이런 오류를 빌드 타임에 더 잘 드러낼 수 있습니다.
제가 보기에 흔한 방식은 실패 가능(fallible)한 함수를 만들고 Option이나 Result를 반환하는 것입니다.
fn divide_floats(a: f32, b: f32) -> Option<f32> { if b == 0 { return None; } Some(a / b)}
이 방식은 (1) 함수가 실패할 수 있음을 전달하고, (2) 호출자가 실패 케이스를 나중에 처리할 수 있게 해 준다는 점에서 훌륭합니다. †† 물론 catch_unwind가 있지만, 여기서는 없다고 치겠습니다. 제게는 이 함수의 불변식(“b는 0이면 안 된다”)이 사후적으로, 즉 반환 타입인 Option<T>에 인코딩된 셈입니다. 그렇다면 그 불변식을 사전적으로, 즉 함수의 매개변수에 인코딩할 수도 있지 않을까요? 그건 어떤 모습일까요?
여기서 등장하는 것이 newtype 패턴입니다.
f32와 비슷하지만 절대 0이 될 수 없는 타입을 만든다고 해 봅시다. 이름은 NonZeroF32로 하죠.
struct NonZeroF32(f32);
이 구조체는 f32 필드 하나만 갖습니다. 이름에서 읽히는 의미는 “평범한 f32지만 0은 허용하지 않는다”입니다. 그걸 어떻게 보장할까요? Rust는 모듈 단위로 캡슐화를 하므로, 타입은 public으로 두되 필드는 private으로 둡니다.
mod nonzero { pub struct NonZeroF32(f32);}
그럼 이 타입을 만들 수 있는 유일한 방법은 실패 가능한 생성자 함수뿐입니다.
impl NonZeroF32 { fn new(n: f32) -> Option<NonZeroF32> { if n == 0 { return None; }
Some(NonZeroF32(n)) }}
편의성을 위해 몇 가지 트레이트도 구현해 둡시다.
impl Add for NonZeroF32 { ... }impl Add<f32> for NonZeroF32 { ... }impl Add<NonZeroF32> for f32 { ... }// 그리고 다른 연산자들도 잔뜩...
이제 divide_floats에서 이를 사용할 수 있습니다.
fn divide_floats(a: f32, b: NonZeroF32) -> f32 { a / b}
이 패턴에는 흥미로운 함의가 있습니다.
divide_floats의 두 번째 버전에서는 패닉을 피하려고 반환 타입을 f32에서 Option<f32>로 바꿨습니다. Alexis King의 원문에서 말하듯, 이는 반환 타입(그리고 함수의 약속)을 _약화(weakening)_시키는 것입니다. 호출자의 기대치를 낮추는 거죠. 즉 “이 함수는 어떤 방식으로든 실패할 수 있고, 너는 그걸 처리해야 한다”라고 말합니다. 그리고 그 약화가 타입 시스템에서는 Option enum으로 표현됩니다.
세 번째 버전에서는 관점을 바꿔 “반환 타입을 약화시키는 대신, 함수 인자를 _강화(strengthen)_하면 어떨까?”라고 자문합니다. 그리고 NonZeroF32를 받는다고 표현했죠. 함수 안에 검증 코드를 넣는 대신 그 책임을 호출자에게 넘깁니다. 검증은 이제 함수 실행 _이전_에 일어납니다.
검증을 사용자(호출자) 쪽으로 당기는 것의 장점을 보기 위해, 다음과 같은 함수가 하나 더 있다고 해 봅시다.
// 이차방정식 근의 공식!fn roots(a: f32, b: f32, c: f32) -> [f32; 2] { // 시연을 위해 복소근은 무시합니다 let discriminant = b * b - 4 * a * c; [ -b + discriminant.sqrt() / (2 * a), -b - discriminant.sqrt() / (2 * a), ]}
이 함수는 판별식(discriminant)이 음수면 실패할 수 있고(이 작위적인 예에선 무시), a가 0이면 실패할 수 있습니다. 이를 다루는 두 가지 방식은 다음처럼 쓸 수 있겠죠:
fn try_roots(a: f32, b: f32, c: f32) -> Option<[f32; 2]> { if a == 0 { return None; } // ...}
fn newtyped_roots(a: NonZeroF32, b: f32, c: f32) -> [f32; 2] { // 그대로}
Option 버전은 최소 두 개 이상의 함수에서 같은 조건문을 복제하게 만듭니다. DRY에 민감하다면 찝찝할 수도 있죠. 또한 함수가 “0인지 검증”해야 할 뿐 아니라, 호출자도 Option에 대한 매칭으로 다시 한 번 검증(처리)을 해야 합니다. 중복처럼 느껴집니다. 이상적으로는 한 번만 체크하고 싶습니다.
let roots = try_roots(5, 4, 7); // try_roots가 검증 체크를 하고// 그 다음 우리는 결과를 매칭하며 또 한 번 검증한다match roots { Some(result) => do_something(), None => { handle_error(); return },}
NonZeroF32 버전은 검증이 앞에서, 그리고 한 번만 일어나도록 도와줍니다(두 번이 아니라).
// 특수 케이스를 한 번만 처리let Some(a) = NonZeroF32::new(5) else { handle_error(); return;}
// `newtyped_roots`는 다시 처리할 필요가 없고,
// 이는 함수가 `Option`을 반환할 필요가 없다는 점과
// 우리가 결과를 처리하지 않는다는 점에서 드러난다.
let [root1, root2] = newtyped_roots(a, 4, 7);
이제 divide_floats를 벗어나, 원래 블로그 글의 예시를 Rust로 옮긴 것을 봅시다.
fn get_cfg_dirs() -> Result<Vec<PathBuf>, Box<dyn Error>> { let cfg_dirs_string = std::env::var("CONFIG_DIRS")?;
let cfg_dirs_list = cfg_dirs_string.split(',') .map(PathBuf::from) .collect::<Vec<PathBuf>>();
if cfg_dirs_list.is_empty() { return Err("CONFIG_DIRS cannot be empty".into()); }
Ok(cfg_dirs_list)}
fn main() -> Result<(), Box<dyn Error>> { let cfg_dirs = get_cfg_dirs()?; match cfg_dirs.first() { Some(cache_dir) => init_cache(cache_dir), None => unreachable!("should never happen; already checked configDirs is non-empty"), }}
다음 사항을 주목해 보세요.
get_cfg_dirs 함수에서 cfg_dirs_list가 비었는지 검사했습니다. 그런데 main에서 cfg_dirs.first()로 다시 한 번 “검사”를 해야만 합니다. Vec가 비지 않았다는 사실을 알고 있는데도, 또 확인해야 할까요? 그럼 이런 검사를 계속 반복해야 한다면 성능에도 영향이 있지 않을까요?is_empty 체크가 리팩터링 과정에서 제거되었는데 프로그래머가 main을 업데이트하는 걸 잊었다면, unreachable! 분기가 실제로 실행될 수 있고, 컴퓨터를 폭발시키거나 뭐 그런 일이 벌어질 수 있겠죠.대신 Vec가 절대 비지 않음을 존재 자체로 보장하는 특수한 NonEmptyVec<T> newtype(엄밀히 말하면 특별하다기보단…)를 만들면 이렇게 할 수 있습니다.
struct NonEmptyVec<T>(T, Vec<T>);
impl<T> NonEmptyVec<T> { // `Option`을 반환할 필요가 없다는 점에 주목 fn first(&self) -> &T { ... }}
fn get_cfg_dirs() -> Result<NonEmptyVec<PathBuf>, Box<dyn Error>> { let cfg_dirs_string = std::env::var("CONFIG_DIRS")?;
let cfg_dirs_list = cfg_dirs_string.split(',') .map(PathBuf::from) .collect::<Vec<PathBuf>>();
// `Vec`를 더 구조화된 타입으로 파싱한다
let cfg_dirs_list = NonEmptyVec::try_from(cfg_dirs_list)?;
Ok(cfg_dirs_list)}
fn main() -> Result<(), Box<dyn Error>> { let cfg_dirs = get_cfg_dirs()?; // `Vec`가 비었는지 다시 확인할 필요가 없다.
// `NonEmptyVec` 타입이 그걸 보장하니까.
init_cache(cfg_dirs.first());}
이 맥락에서 NonZeroF32::new와 NonEmptyVec::try_from를 파싱(parsing) 함수라고 부를 수 있습니다. 덜 의미적인 타입을 검증하고 더 의미가 담긴 타입으로 변환하기 때문이죠. 즉 “float의 비영(0 아님)”과 “Vec의 비어있지 않음”이 이제 타입에 인코딩되어 있습니다. 코드에서 NonZeroF32라는 단어만 봐도 이후로는 절대 0이 아닌 f32일 거라는 걸 이해할 수 있게 됩니다.
반면, 검증(validation)/체크 함수는 값을 검증만 하고 타입은 그대로 둡니다. 예를 들어 is_nonzero(f32) -> bool 같은 함수가 있다면, 어떤 f32에 is_nonzero를 호출했는지 안 했는지 사이에 “읽기 좋은” 차이가 거의 없습니다.
fn is_nonzero(n: f32) -> bool;fn to_nonzero(n: f32) -> Option<NonZeroF32>;
명명적(nominative) 타입 시스템을 이용하면, 단지 _검증_하는 대신 새 타입으로 _파싱_해서 이 f32가 0이 아님을 전달할 수 있습니다. 검증만 하면, 코드를 파고들기 전까지 그 f32가 비영이었는지 알 수 없습니다. 하지만 파싱해서 NonZeroF32가 되면, 코드에서 NonZeroF32를 보는 것만으로도 항상 비영임을 말할 수 있습니다.
물론 위 예시는 상당히 작위적이지만, newtype을 만드는 게 실제로 도움이 되는 경우가 있을까요? 있습니다. 사실 대부분의 사람들이 이미 사용해왔습니다. 바로 String입니다.
내부 구현을 들여다보면, String은 그저 Vec<u8> 위에 올려진 newtype입니다.
#[derive(PartialEq, PartialOrd, Eq, Ord)]#[stable(feature = "rust1", since = "1.0.0")]#[lang = "String"]pub struct String { vec: Vec<u8>,}
그리고 그 파싱 함수는 String::from_utf8인데, 바이트 벡터가 유효한 UTF-8인지 검사하는 검증 코드가 들어 있습니다.
#[inline] #[stable(feature = "rust1", since = "1.0.0")] #[rustc_diagnostic_item = "string_from_utf8"] pub fn from_utf8(vec: Vec<u8>) -> Result<String, FromUtf8Error> { match str::from_utf8(&vec) { Ok(..) => Ok(String { vec }), Err(e) => Err(FromUtf8Error { bytes: vec, error: e }), } }
따라서 Vec<u8>을 여기저기 들고 다니면서 계속 검증하는 대신, 그냥 String으로 파싱해버리면 “타입 안전한 String”과 각종 편의 함수들을 모두 누릴 수 있습니다.
또 다른 예시는 serde_json입니다. Python에서 json.loads는 단순히 딕셔너리를 돌려줍니다. 데이터가 충분히 임의적이라면 괜찮지만, 스키마와 타입 시스템이 있다면 타입 시스템이 json 파싱의 일을 하게 하는 편이 더 낫습니다.
우리 용어로, 검증(validation)은 이런 모습입니다.
use serde_json::{from_str, Value};
const SAMPLE_JSON: &str = r#"{ "foo": 1, "bar": [1, 2, 3] }"#;
let json = from_str::<Value>(SAMPLE_JSON) .unwrap();
let first_elem = json.get("bar") .and_then(|bar| bar.get(0)) .unwrap();
// `first_elem`로 뭔가를 한다
unwrap가 두 번이죠! 하나는 문자열이 유효한 JSON인지 확인하려고, 다른 하나는 bar 필드가 존재하는지 확인하려고 씁니다. 이제 타입과 Deserialize derive 매크로를 통해 파싱 메커니즘을 사용하는 예를 봅시다.
struct Sample { foo: i32, bar: [i32; 3]}
impl Sample { fn first_elem(&self) -> i32 { self.bar[0] // 정의상 패닉이 나지 않는다 }}
let json = from_str::<Sample>(SAMPLE_JSON).unwrap();
let first_elem = json.first_elem();
// `first_elem`로 뭔가를 한다
JSON을 실제 타입으로 역직렬화(deserialize)했기 때문에, 우리는 다음 보장을 안전하게 할 수 있습니다.
foo와 bar가 항상 존재한다.foo는 항상 정수 값을 가진다.bar는 항상 정수 3개짜리 배열이다.first_elem는 절대 패닉이 나지 않는다.여기서 유일한 실패 지점은 from_str가 일어나는 맨 앞부분으로 밀려납니다. 그 이후에는, 검증이 함수 수준이 아니라 타입 수준에 표현되어 있으니, 크게 처리해야 할 오류가 없습니다.
그렇다면 여기서 어떤 교훈을 얻을 수 있을까요? 함수형 언어 프로그래머들은 이미 여러 교훈을 알고 있는데, Rust도 이런 FP 개념을 적용하는 면에서는 크게 다르지 않습니다.
첫 번째 교훈은 불법 상태(illegal states)를 표현 불가능하게 만들라입니다.
무슨 뜻일까요?
NonZeroF32와 NonEmptyVec 예시로 돌아가보면, “0인 상태”는 NonZeroF32에서 불법이고, “비어 있는 상태”는 NonEmptyVec에서 불법입니다. 불법 상태이므로 그 타입들로는 그런 상태를 표현할 수 없습니다. 그래서 이 타입들의 생성자는 실패 가능할 수밖에 없습니다. 값이 파싱에 성공하면 새 타입이 나오고, 실패하면 새 타입이 나오지 않습니다.
반면 f32가 0이 아닌지 검사하는 것처럼 검증만 하면, 불법 상태는 여전히 표현 가능합니다. 특히 리팩터링 후에 어떤 조건문 체크가 실수로든 의도로든 제거되면, 그 값이 0일 가능성이 다시 생깁니다.
이건 다른 언어들이 정수로 센티널 값(sentinel values)을 쓰는 방식이 떠오르네요. 위키피디아의 코드 조각을 봅시다:
int find(int arr[], size_t len, int val) { for (int i = 0; i < len; i++) { if (arr[i] == val) { return i; } } return -1; // not found}
인덱싱은 음이 아닌 정수에서만 유효하니, 에러를 -1로 반환합니다. 하지만 이상합니다. (1) -2 이하의 수들도 존재할 수 있지만 어쨌든 유효하지는 않고, (2) 특정 값을 특별하게 다루는 건 너무 오류를 부르기 쉽습니다. 미래에는 음수가 의미적으로 유효해질 수도 있으니까요.
두 번째 교훈은 불변식을 증명(proving invariants)하는 일은 가능한 한 일찍 해야 한다입니다.
여기에는 샷건 파싱(shotgun parsing)이라는 개념이 있는데, 링크된 논문에서는 다음처럼 설명합니다:
Shotgun Parsing: Shotgun parsing is a programming antipattern whereby parsing and input-validating code is mixed with and spread across processing code—throwing a cloud of checks at the input, and hoping, without any systematic justification, that one or another would catch all the “bad” cases.
즉, 파싱/입력 검증 코드가 처리 코드 사이사이에 섞여 흩어져 있는 안티패턴을 말합니다. 입력에 검사를 마구 던져놓고(구름처럼), 체계적인 근거 없이 “어쨌든 뭔가가 나쁜 케이스를 잡아주겠지”라고 기대하는 것이죠.
핵심은 “데이터 전체가 사전에 온전히 검증되지 않은 상태에서” 데이터를 사용한다는 문제입니다. 데이터의 일부는 미리 검증된 채로 사용하지만, 다른 부분이 유효하지 않다는 사실을 나중에야 발견할 수 있죠.
논문은 입력에 ..를 넣어서 임의 파일을 읽게 할 수 있었던 버그인 CVE-2016-0752를 언급합니다. 검증을 ‘의도적’이 아니라 ‘우연히 emergent하게’ 다루면 이런 보안 버그로 이어질 수 있다고 주장합니다.
검증을 의도적으로 다루려면 가능한 한 빠르게, 그리고 가능한 한 포괄적으로 이루어져야 합니다. 먼저 파싱해버리면, 어떤 데이터로 작업하기 전에 모든 불변식을 먼저 증명할 수 있습니다.
람다 대수에 관한 이 영상이 기억납니다. 영상은 “타입은 논리에서의 명제로, 항(term)은 증명으로 표현될 수 있다”고 결론 내립니다. 제게는 눈을 뜨게 해 준 영상이라 추천합니다. 여러분에게도 어떤 깨달음을 줄지도요.
근본적으로, 프로그램이 타입체킹을 통과한다면 그 증명이 올바르다고 말할 수 있습니다. 커리-하워드 대응(Curry-Howard Correspondence) 고맙습니다. Lean이나 Agda 같은 증명 보조기(proof assistant) 언어도 있지만, Rust에서도 어느 정도 흉내 낼 수 있습니다. typenum 같은 괴상한 라이브러리가 돌아가는 방식이 그렇죠.
use std::ops::Add;
use typenum::*; // 1.19.0
type Lhs = <P3 as Add<P4>>::Output;
type Rhs = P8;
type Result = <Lhs as Same<Rhs>>::Output;
pub fn is_proof_correct()
where
Result: {}
이건 Rust에서 3 + 4가 8과 같은지 검사하는 간단한 프로그램입니다. 당연히 틀렸으므로 컴파일 에러가 납니다.
Compiling playground v0.0.1 (/playground)
error[E0277]: the trait bound `PInt<UInt<UInt<UInt<UTerm, B1>, B1>, B1>>: Same<PInt<UInt<..., ...>>>` is not satisfied
--> src/lib.rs:11:5
|
11 | Result:
| ^^^^^^ unsatisfied trait bound
|
= help: the trait `typenum::Same<PInt<UInt<UInt<UInt<UInt<UTerm, B1>, B0>, B0>, B0>>>` is not implemented for `PInt<UInt<UInt<UInt<UTerm, B1>, B1>, B1>>`
= note: the full name for the type has been written to '/playground/target/debug/deps/playground-e4f34f6f1769e3b6.long-type-6323804316620900.txt'
= note: consider using `--verbose` to print the full type name to the console
For more information about this error, try `rustc --explain E0277`.
error: could not compile `playground` (lib) due to 1 previous error
에러 메시지가 끔찍하다는 게 슬프네요. 인생이란.
RPLCS 디스코드 서버에서 사람들에게 종종 하는 추천들이 있는데, 원문에서 가져와 약간 각색해 보겠습니다.
첫째, 함수가 어떤 타입을 받는다고 해서 여러분의 struct에 그 타입을 그대로 저장해야 한다는 뜻은 아닙니다. 또한 그 타입으로 영원히 표현해야 하는 것도 아닙니다. 예를 들어, 어떤 서드파티 라이브러리에 이런 함수가 있다고 해 봅시다.
fn set_lightbulb_state(is_on: bool) {}
여러분의 App/Context struct에 App { lightbulb_state: bool }처럼 bool을 저장할 필요는 없습니다. 혼란스럽거든요. 대신 의미를 더 담은 별도의 enum을 정의하는 게 낫습니다.
enum LightBulbState { Off, On,}
impl From<LightBulbState> for bool { ... }
struct App { lightbulb_state: LightBulbState}
// ...
fn main() { let app = App { ... } set_lightbulb_state(app.lightbulb_state.into());}
네, 더 장황해진다고 할 수도 있겠죠. 하지만 저는 장황함보다 정확성을 더 신경 씁니다. 미안합니다.
둘째, 저는 이런 API를 보면 가끔 의심하게 됩니다:
fn do_something_fallible(data: &Thing) -> Result<(), MyError> {}
// 혹은 더 나쁜 경우,
fn verify(data: &Thing) -> bool {}
함수 본문이 부수효과(side effect)가 있는 일을 전혀 하지 않는다면, 파싱을 통해 Thing을 더 구조화된 데이터 타입으로 바꾸는 방식이 도움이 될 가능성이 큽니다. 부수효과가 있는 경우에도, 특정 상황을 더 잘 표현하는 타입들이 있습니다. 예를 들어 무한 루프 함수가 반환 타입을 Result<!, MyError> 또는 Result<Infallible, MyError>로 표현하는 경우 같은 것들 말이죠.
타입을 더 많이 만드는 걸 저는 정말 좋아합니다. 모두에게 500만 개의 타입을 주세요.
Rust 프로그램 설계가 타입에 의해 주도되는 사례가 참 많다는 점이 흥미롭습니다. 예를 들어 Vec는 네 겹의 newtype 레이어와 추가 필드를 갖고 있죠. sqlx는 query! 매크로에서 익명 struct를 생성합니다. bon은 함수들을 타입을 통한 컴파일 타임 빌더로 바꾸는 매크로 크레이트입니다.
물론 모든 것이 타입만으로 해결되지는 않습니다. 그래도 개인적으로는 검증 코드를 타입 쪽으로 밀어 넣는 것이 코드를 더 명확하고 더 견고하게 만든다고 생각합니다. 타입 시스템이 검증을 처리하도록 하세요. 존재하는데, 최대한 활용해야죠.
이 아이디어를 처음 접하게 해 준 Alexis King의 이 글에 감사드립니다. 이 주제를 후속 글의 연장선에서 더 다루고 싶고, Rust에서는 unsafe 키워드를 통해 재맥락화하는 것도 도움이 될 것 같습니다.
물론 newtype이 모든 문제의 답은 아닙니다. newtype을 더 편하게 만들어 주는 기능(예: delegation)이 언어에 부족하기 때문에, 많은 사람이 이 패턴 사용을 꺼리기도 합니다. 그럼에도 누군가 충분히 좋은 RFC를 낸다면 저는 기꺼이 보고 싶습니다.
컴파일러가 제 프로그램을 쓰는 걸 도와주길 바라며 타입 시스템을 컴파일 타임 체커로 사용하는 건 정말 좋습니다. 여러분도 타입 시스템을 활용해 보세요. Rust만큼 좋은 타입 시스템을 가진 언어는 많지 않으니까요 :)
2026년 2월 21일 토요일 — #design pattern#rust#type system#type-driven design
다음 링크에서 저를 찾을 수 있습니다: