프로덕션 API에서 흔히 발생하는 ‘필드 누락 vs null vs 값 존재’의 의미 차이를 Rust 타입 시스템으로 명시적으로 모델링하는 presence-rs를 소개한다.
프로덕션 API를 오래 만들다 보면 뼈아픈 교훈을 하나 배우게 됩니다. 대부분의 버그는 값(value) 때문이 아니라 ‘의도(intent)’ 때문이라는 사실입니다.
요청 페이로드는 단순한 “데이터”가 아닙니다. 그건 _명령_입니다:
JSON은 이를 위해 서로 다른 세 가지 신호를 줍니다:
{}) → 의견 없음 / 변경하지 않음null ({"name": null}) → 값을 비움(삭제){"name": "Alice"}) → 값을 설정이 세 상태를 하나로 뭉개 버리면, 언젠가 반드시 데이터를 잘못 덮어쓰거나 잘못 지우는 버그를 출시하게 됩니다. 드물 수는 있습니다. 하지만 한 번 터지면 비용은 큽니다.
실제 예시: 모바일 앱이 사용자의 소개글(bio)을 지우기 위해 {"bio": null}을 보냅니다. 서버는 이를 역직렬화하면서 None으로 만들고, “제공되지 않음”으로 취급해 업데이트를 조용히 무시합니다. 사용자는 “소개글이 삭제가 안 돼요.”라고 제보합니다. 몇 시간 디버깅한 끝에, 문제는 앱도 DB도 아니라 요청을 모델링한 방식에 있었다는 걸 발견합니다.
presence-rs는 바로 이 문제를 해결하기 위해 존재합니다. 즉 “누락(missing) vs null vs 존재(present)”를 타입 시스템에서 명시적으로 표현하기 위한 라이브러리입니다.
리포지토리: https://github.com/minikin/presence-rs
프로젝트에 presence-rs를 추가하세요:
toml[dependencies] presence-rs = "0.1" serde = { version = "1.0", features = ["derive"] }
기본 사용법:
rustuse presence_rs::Presence; let absent: Presence<String> = Presence::Absent; let null: Presence<String> = Presence::Null; let value: Presence<String> = Presence::Some("Alice".to_string()); if value.is_some() { println!("We have a value!"); }
Option<T>는 두 가지 상태만 가집니다. 하지만 patch/update 모델에는 세 가지가 필요합니다.
그래서 Presence<T>는 의도를 1급 시민으로 만듭니다:
Presence::Absent → 제공되지 않음Presence::Null → 명시적으로 nullPresence::Some(T) → 값이 제공됨핵심은 이것입니다: 이제 이 구분을 무시할 수 없습니다. 컴파일러가 반드시 선택하게 만듭니다.
“누락”과 “null”을 같은 것으로 취급하면, 흔한 규칙들을 안전하게 구현할 수 없습니다:
사람들은 관례로 처리한다고 _생각_하지만, 관례는 쉽게 새어 나갑니다:
Presence<T>는 의미를 모호하지 않게 만듭니다.
리뷰어가 다음을 보면:
name: Option<Option<String>>
두 겹이 각각 무엇을 의미하는지 멈춰서 해석해야 합니다. 그리고 절반은 코드베이스 전반에서 의미가 일관되지 않습니다.
반면 다음을 보면:
name: Presence<String>
의미가 즉시 분명해집니다. optionality(선택성)가 아니라 존재(presence) 를 모델링하고 있다는 뜻이니까요.
이 3상(tri-state)은 다음에서 계속 등장합니다:
올바른 추상화를 가지면, 같은 취약한 접착 코드를 반복해서 쓰지 않게 됩니다.
Option<Option<T>>도 3가지를 표현하잖아 — 왜 안 쓰지?”맞습니다. 표현은 가능합니다:
None → 누락Some(None) → nullSome(Some(v)) → 존재그렇다면 왜 Presence<T>를 도입할까요? 비교해 봅시다:
| 항목 | Option<Option<T>> | Presence<T> |
|---|---|---|
| 가독성 | Some(None)는 애매함 | Presence::Null은 명시적 |
| 자기 문서화 | 의미가 위치에 인코딩됨 | 의미가 variant 이름에 인코딩됨 |
| 실수로 상태를 합쳐버림 | .flatten()으로 쉽게 발생 | 반드시 명시적으로 해야 함 |
| match 사용성 | 중첩 패턴이 불편함 | 깔끔한 평면(단일) variant |
| 코드 리뷰 명확성 | 주석/맥락이 필요 | 의도가 즉시 명확 |
| 도메인 모델링 | 일반적인 중첩 | 목적에 맞춘 타입 |
왜 이게 중요한지 좀 더 파고들어 보겠습니다.
Option<Option<T>>는 의미를 _이름_이 아니라 _위치_에 담습니다. 그래서 계속 이런 질문을 하게 됩니다:
None이 누락이야, null이야?”None이 누락이야, null이야?”Presence에서는 이름이 곧 의도입니다: Absent, Null, Some.
흔한 패턴들이 의미를 조용히 파괴합니다:
rustlet x: Option<Option<T>> = ...; let y: Option<T> = x.flatten(); // boom: "null vs absent" 구분이 사라짐
또는:
if x.is_none() { /* 어떤 "none"을 뜻한 거지? */ }
Presence<T>에서도 원한다면 상태를 합칠 수는 있습니다. 하지만 의도적으로 해야 합니다.
실제 코드는 읽기 쉬운 분기가 필요합니다:
rustmatch update.name { Presence::Absent => { /* 유지 */ } Presence::Null => { /* 삭제 */ } Presence::Some(v) => { /* 설정 */ } }
이를 중첩 Option과 비교해 보세요:
rustmatch update.name { None => { /* ??? */ } Some(None) => { /* ??? */ } Some(Some(v)) => { /* ??? */ } }
주석을 달아도 구조 자체가 방해합니다. 이게 필드 30개, 엔드포인트 20개로 늘어나면 버그가 숨는 대표 지점이 됩니다.
Option<Option<T>>는 영리한 인코딩입니다. Presence<T>는 하나의 개념(concept)입니다.
근본적으로 진짜 주장은 이것입니다: 도메인 의미를 관례가 아니라 타입으로 인코딩하라. 관례는 조합되지도 않고, 팀이 바뀌면 살아남지도 못합니다.
전형적인 업데이트 규칙을 모델링해 봅시다:
rustuse presence_rs::Presence; use serde::{Deserialize, Serialize}; // 기존 도메인 모델 - 필드는 nullable이므로 Option<T> #[derive(Debug)] struct User { id: String, name: Option<String>, email: Option<String>, bio: Option<String>, } // patch 요청 - 누락/null/값을 구분하기 위해 Presence<T> #[derive(Debug, Deserialize)] struct UserPatch { #[serde(default)] // 누락된 필드는 Presence::Absent로 역직렬화됨 name: Presence<String>, #[serde(default)] email: Presence<String>, #[serde(default)] bio: Presence<String>, } // 업데이트 의미를 한 번만 정의해 재사용하는 헬퍼 fn apply_field<T>(target: &mut Option<T>, update: Presence<T>) { match update { Presence::Absent => {}, // JSON에 필드가 없음 → 변경 없음 Presence::Null => *target = None, // JSON에서 null → 삭제 Presence::Some(v) => *target = Some(v), // JSON에 값 있음 → 설정 } } fn apply_patch(user: &mut User, patch: UserPatch) { apply_field(&mut user.name, patch.name); apply_field(&mut user.email, patch.email); apply_field(&mut user.bio, patch.bio); }
이 코드가 보장하는 동작:
{} → 어떤 필드도 바뀌지 않음{"name": null} → name은 삭제, email과 bio는 유지{"name": "Alice", "bio": null} → name은 설정, bio는 삭제, email은 유지이런 코드는 의도가 명시적이라 리팩터링 중에도 정확성을 유지하기가 훨씬 쉽습니다.
Presence<T>는 이미 사용 중인 Rust 생태계와 자연스럽게 맞물립니다.
Serde (JSON/API 직렬화/역직렬화):
rust#[derive(Deserialize)] struct UpdateRequest { #[serde(default)] // JSON에 없으면 Absent field: Presence<String>, }
웹 프레임워크 (Axum, Actix, Rocket): 요청 역직렬화에 serde를 쓰는 프레임워크라면 별도 작업 없이 그대로 동작합니다.
DB 작업 (sqlx, diesel): Presence<T>를 SQL 업데이트 전략에 매핑하세요:
Absent → UPDATE 문에서 해당 필드를 건너뜀Null → SET field = NULLSome(v) → SET field = $1GraphQL: GraphQL의 undefined(누락) vs null 구분을 자연스럽게 모델링합니다.
즉, 스택 전체에 새 패턴을 “가르치는” 게 아니라, 이미 애드혹으로 하고 있던 일을 정식화하는 것입니다.
작은 타입은 도입이 쉽지만, 일관성이 중요합니다. 검증된 몇 가지 규칙입니다:
Null이 허용되는가? 허용된다면 의미는 무엇인가?Null에 대해 4xx를 반환Presence<T>목표는 “Absent/Null/Value일 때 무엇이 일어나는지”가 명백하고 테스트 가능해지는 것입니다.
Presence<T>를 쓰지 말아야 할 때어떤 추상화든 적절한 자리가 있습니다. 다음 상황에서는 더 단순한 접근을 고려하세요.
전체 교체 업데이트: API가 항상 리소스 전체를 교체한다면(PUT 의미), 표준 Option<T>로 충분합니다. 모든 업데이트가 전부라면 3상은 필요 없습니다.
내부 전용 API: 클라이언트와 서버를 모두 강하게 통제할 수 있다면(모놀리식, 타입을 공유하는 마이크로서비스 등) 관례로도 충분할 수 있습니다. 타입 시스템의 장점은 특히 내가 통제하지 못하는 경계에서 더 빛납니다.
no-op 의미: 도메인 규칙상 “누락”과 “null”을 정말로 동일하게 취급한다면, 인위적인 구분을 추가하지 마세요. 비즈니스 규칙에 맞는 가장 단순한 모델을 쓰는 게 맞습니다.
고처리량·저모호성 시나리오: 초당 수백만 건의 업데이트를 처리하고, 실제로 누락/null 구분이 전혀 중요하지 않다면, 추가 명확성이 정신적 오버헤드를 정당화하지 못할 수 있습니다.
규칙은 이것입니다: absent/null을 잘못 처리했을 때의 비용이, 명시적으로 처리하는 비용보다 클 때 Presence<T>를 쓰세요. PATCH 엔드포인트, 사용자-facing API, 여러 팀이 엮인 시스템에서는 대개 그 기준이 낮습니다.
Presence<T>는 의도적으로 작습니다. “누락 vs null”이라는 흔한 발밑 지뢰를 명시적이고, 읽기 쉽고, match 가능한 타입으로 바꿔 줍니다.
물론 Presence<T>로도 나쁜 로직을 쓸 수는 있습니다. 하지만 세 상태가 있다는 사실을 실수로 잊을 수는 없습니다. 그게 이 라이브러리의 승리입니다.
시스템에 patch 의미가 있거나, 스키마 기반 모델이 있거나, 혹은 “제공되지 않음”과 “지워라”가 다른 API라면, 이를 타입 시스템에 인코딩하는 것은 _“아마 동작할 것”_을 _“반드시 동작한다”_로 바꿔 주는 변화 중 하나입니다. 컴파일러가 값뿐 아니라 의도를 처리하도록 강제하기 때문입니다.
직접 써 보세요:
cargo add presence-rs
예제, 문서, 기여 방법은 리포지토리에서 확인할 수 있습니다.