Rust에서 ‘검증하지 말고 파싱하라’와 타입 주도 설계
(harudagondi.space)- 타입 시스템을 활용해 런타임 검증 대신 컴파일 타임에 불변식을 보장하는 Rust 설계 방식을 설명함
-
NonZeroF32,NonEmptyVec같은 새로운 타입(newtype) 을 정의해 잘못된 상태(0, 빈 벡터 등)를 표현 불가능하게 만듦 -
Option이나Result로 실패를 반환하는 대신, 함수 인자에서 제약을 강화해 오류를 사전에 차단함 -
String::from_utf8이나serde_json::from_str처럼 파싱을 통해 의미 있는 타입으로 변환하는 사례를 제시함 - 불법 상태를 표현 불가능하게 만들고, 검증을 가능한 한 앞당기는 설계 원칙이 코드 안정성과 가독성을 높임
1. 런타임 검증 대신 타입으로 제약 표현
-
divide(a, b)함수에서 0으로 나누면 런타임 패닉이 발생함-
Option을 반환해 실패를 표현할 수 있지만, 이는 반환 타입을 약화시키는 형태임
-
-
NonZeroF32타입을 정의해 0이 아닌 값만 생성 가능하게 함- 생성자는
fn new(n: f32) -> Option<NonZeroF32>형태로, 실패 시None반환 -
divide_floats(a: f32, b: NonZeroF32)로 정의하면 런타임 검증이 불필요함
- 생성자는
- 검증 책임을 함수 내부에서 호출자 쪽으로 이동시켜, 오류를 사전에 제거함
2. 중복 검증 제거와 코드 단순화
-
roots(a, b, c)함수에서a == 0검증을Option으로 처리하면 호출자와 함수 양쪽에서 중복 검증 발생 -
NonZeroF32를 사용하면 검증이 한 번만 수행되고, 이후 로직은 단순화됨 - 같은 원리로
NonEmptyVec<T>를 정의해 빈 벡터를 허용하지 않음-
get_cfg_dirs()가NonEmptyVec<PathBuf>를 반환하면, 이후main()에서 추가 검증이 불필요함
-
3. 실제 사례: String과 serde_json
-
String은 내부적으로Vec<u8>의 새로운 타입(newtype) 이며,String::from_utf8이 유효성 검사를 수행함- 이후에는 UTF-8이 보장된 문자열로 안전하게 사용 가능
-
serde_json의from_str::<Sample>은 JSON을 구조체로 파싱해 필드 존재와 타입 일관성을 컴파일 타임에 보장함-
foo,bar필드 존재, 타입 일치, 배열 길이 등 모든 제약이 타입 수준에서 확인됨
-
4. 타입 주도 설계의 두 가지 원칙
-
불법 상태를 표현 불가능하게 만들기
-
NonZeroF32는 0을,NonEmptyVec은 빈 상태를 표현할 수 없음 - 단순 검증 함수(
is_nonzero)는 여전히 잘못된 상태를 표현할 수 있어 불완전함
-
-
검증은 가능한 한 앞당겨 수행하기
- ‘Shotgun Parsing’처럼 검증이 코드 전반에 흩어지면 보안 취약점(CVE-2016-0752 등)으로 이어질 수 있음
- 파싱 단계에서 모든 제약을 확인하면 이후 로직은 안전하게 실행 가능
5. Rust에서의 타입 기반 증명과 응용
-
Curry-Howard 대응에 따라 타입은 논리 명제, 값은 그 증명으로 볼 수 있음
-
typenum크레이트를 사용하면 컴파일 타임에 수학적 관계(3 + 4 = 8)를 검증 가능
-
- 타입 시스템을 이용해 프로그램의 정확성을 컴파일 시점에 증명할 수 있음
6. 실무 적용 조언
- 외부 API가 단순 타입(
bool,i32)을 요구하더라도, 내부에서는 의미 있는 enum이나 newtype으로 표현할 것- 예:
LightBulbState { On, Off }를 정의하고From<LightBulbState> for bool구현
- 예:
-
verify()나do_something_fallible()처럼 단순 검증 함수가 있다면, 파싱을 통한 구조화된 타입 변환을 고려할 것 - 부작용 없는 함수라면
Result<Infallible, MyError>처럼 의도적으로 불가능한 상태를 타입으로 표현할 수 있음
7. 결론
- Rust의 타입 시스템을 검증 도구로 활용하면 코드의 명확성과 안정성이 향상됨
-
Vec,sqlx,bon등 Rust 생태계의 여러 도구가 이미 타입 기반 설계를 활용 중임 - 모든 문제를 타입으로 해결할 수는 없지만, 검증 로직을 타입으로 끌어올리는 접근은 유지보수성과 안전성을 높임
- Rust의 강력한 타입 시스템을 최대한 활용해 컴파일러가 오류를 잡아주는 코드 작성을 권장함
Hacker News 의견들
-
이 글에서 사용된 0으로 나누기 예시는 “Parse, Don’t Validate” 원칙을 설명하기엔 적절하지 않음
이 원칙의 핵심은 신뢰할 수 없는 데이터를 구조적으로 올바른 타입으로 변환하는 함수에 있음
Alexis King의 "Names are not type safety" 글에서도newtype패턴은 완전한 ‘correct by construction’을 보장하지 못한다고 밝힘
타입 시스템이 불변식을 직접 표현할 수 없을 때는 스마트 생성자(smart constructor) 로 파서를 흉내 내는 추상 타입을 사용하는 것이 현실적인 접근임
두 번째 예시인 non-empty vec이 훨씬 좋은 사례로, 타입 시스템 안에서 “항상 하나 이상의 요소가 존재함”을 보장함-
newtype기반의 “parse, don’t validate”도 실제로는 매우 유용함
문자열이 어디서 왔는지 모를 때, 캡슐화된 값은 신뢰성을 크게 높여줌
완전한 correctness-by-construction을 위해선 의존 타입 시스템이 필요하지만, Rust의 pattern types 같은 경량 대안도 있음
예를 들어i8 is 0..100처럼 범위를 제한하거나[T] is [_, ..]로 비어 있지 않은 슬라이스를 표현할 수 있음
다만(T, Vec<T>)형태의 non-empty list는 실용성과 이론적 순수성의 충돌을 보여주는 예로, 벡터처럼 다루기엔 제약이 많음 - ‘correct by construction’이 궁극적인 목표임
NonZeroU32같은 타입은 단순하지만, 진짜 힘은 도메인 로직 전체를 타입으로 설계해 컴파일러가 게이트키퍼 역할을 하게 하는 데 있음
이렇게 하면 디버깅 부담이 런타임에서 설계 시점으로 옮겨짐 - “make invalid states impossible/unrepresentable”라는 키워드로도 관련 자료를 찾을 수 있음
예시로 "Domain Modeling Made Functional"과 관련 영상을 참고할 만함 - 0으로 나누기 예시는 관심사의 분리가 잘못된 사례임
이런 수준에서 래핑하려 하기보다, 오버플로우 같은 산술 함수의 동작을 감싸보면 차이를 더 명확히 볼 수 있음
-
-
최근 관련 토론 링크를 정리했음
Parse, Don't Validate (2019) (2026년 2월, 172개 댓글)
Parse, Don’t Validate – Some C Safety Tips (2025년 7월, 73개 댓글)
Parse, Don't Validate (2019) (2024년 7월, 102개 댓글) 등
단순히 참고용으로 공유한 것임 -
Parsing over validation 접근은 현실의 모든 경우를 알 수 없을 때 한계가 있음
파일 포맷처럼 가능한 한 빨리 실패하게 만드는 건 좋지만, 비즈니스 로직이나 상태 전이 모델링에 적용할 땐 주의해야 함
현실의 요구가 바뀌면 시스템이 이를 수용하지 못하고, 결국 사용자가 우회하게 됨 -
다른 언어에서는 의존 타입(dependent typing) 으로 더 나아갈 수 있음
예를 들어get_elem_at_index(array, index)가 배열 길이를 미리 몰라도 인덱스 범위를 컴파일 시점에 보장할 수 있음
Idris의Vect n a와Fin n타입이 그 예임 -
하나의 타입에 여러 함수를 두는 접근도 있음
Clojure처럼 맵 하나로 모든 데이터를 표현하고, 표준 라이브러리 전체가 이를 조작할 수 있게 하는 방식임- Perlis의 “하나의 자료구조에 100개의 함수”라는 말과 “Parse, Don’t Validate” 사이에는 긴장이 존재함
중요한 불변식을 타입에 담을 수도 있고, 단순한 함수로 표현할 수도 있음
동적 타입 언어에서도 비슷한 효과를 내는 설계 습관이 있음 - 이건 순수한 대안이라기보다 트레이드오프임
외부 입력은 결국 파싱해야 하므로, 완전히 대체되는 건 아님 - “stringly typed language” 비판과 비슷하게 들리지만, 실제로는 데이터 형태를 점진적으로 정제하는 과정임
- 균형이 중요함
구조적 타입 시스템에서 branding으로 명목 타입을 흉내낼 수도 있고, 반대로도 가능하지만 인체공학적이지 않음
결국 두 방식을 적절히 섞는 게 현실적임
- Perlis의 “하나의 자료구조에 100개의 함수”라는 말과 “Parse, Don’t Validate” 사이에는 긴장이 존재함
-
이 논의는 C++의 concepts 기능을 떠올리게 함
Bjarne Stroustrup의 Concept-based Generic Programming에서는 정수 변환을 자동 검증하는 예시를 보여줌
Number<unsigned int>나Number<char>타입이 범위를 벗어나면 예외를 던지는 식임 -
글의
try_roots예시는 사실 반례임
b^2 - 4ac >= 0제약을 타입으로 표현하려면 Rust에서는 매우 복잡해짐
이런 경우엔 단순히Option을 반환하고 함수 내부에서 검증하는 게 더 합리적임
대부분의 검증은 여러 값의 상호작용을 다루므로, “파싱”으로 해결하기엔 불편함- 입력의 유효성이 여러 인자 간의 관계에 따라 달라질 때는, 결국
fn(abc: ValidABC)같은 형태로 합쳐야 함
- 입력의 유효성이 여러 인자 간의 관계에 따라 달라질 때는, 결국
-
이 패턴은 API 설계에도 잘 맞음
JSON 요청을 검증하기보다, 처음부터 타입이 보장된 구조체로 파싱하면 이후 로직에서 중복 검증이 필요 없음
Rust의 serde + custom deserializer 조합으로 쉽게 구현 가능함
실제로 이런 방식으로 에러 처리 코드가 60% 감소한 사례를 봤음- Go에서도 시도하지만, 포인터 남용과 대수적 타입 부재로 인해 다소 장황해짐
-
같은 철학을 UI 디자인 시스템에도 적용함
CSS를 나중에 검사하는 대신, 격자 단위로만 배치 가능한 타입을 정의해 13px 같은 임의 마진을 컴파일 에러로 처리함
이렇게 하면 디자인이 결정론적으로 유지됨- 어떤 툴링을 사용하는지 궁금하다는 질문이 있었음
-
C#의 records + pattern matching은 이 접근에 근접함
F#의 discriminated unions는 더 강력해서,Result<'T,'Error>로 잘못된 상태를 표현 불가능하게 만들 수 있음
C#도 향후 네이티브 DU가 도입되면 훨씬 깔끔해질 것임