u64와 String만으로도 엔드포인트 하나를 표현할 수 있습니다. 문제는 그 타입들이 아무 규칙도 말해 주지 않는다는 데 있습니다. ID 자리에 0이 들어가거나, 검사 주기와 ID가 뒤바뀌거나, 지원하지 않는 스킴의 문자열이 URL 필드에 들어가도 구조체 리터럴만 보면 막을 곳이 없습니다.
이번 예제는 엔드포인트 ID, URL, 검사 주기를 각각 새 타입으로 만들고 Endpoint의 필드를 비공개로 둡니다. 생성자는 처음부터 유효한 값만 만듭니다. 값을 바꾸는 메서드도 같은 규칙을 지킵니다. 다만 EndpointUrl이 보장하는 범위는 일부러 좁게 적습니다. 몇 줄짜리 문자열 검사를 완전한 URL 검증이라고 부르지 않습니다.
1. 원시 타입의 의미 충돌
다음 구조체는 컴파일되지만 세 필드의 도메인 규칙을 표현하지 못합니다.
struct Endpoint {
id: u64,
url: String,
check_every_seconds: u64,
}
두 u64는 호출부에서 쉽게 뒤바뀝니다. url도 URL인지 확인되지 않은 일반 문자열입니다. 값이 어떤 단위를 쓰는지, 0을 허용하는지, 어느 스킴을 받는지는 필드 이름과 주석에만 남습니다.
newtype은 기존 타입을 한 필드짜리 튜플 구조체로 감쌉니다. 런타임 값을 더 붙이지 않고도 역할이 다른 값을 서로 다른 정적 타입으로 만듭니다. 이 예제에서는 EndpointId(u64), EndpointUrl(String), CheckInterval(Duration)이 그 경계입니다. EndpointId를 받는 자리에 CheckInterval을 넘기면 컴파일되지 않습니다.
2. 생성자와 불변식
독립 실행 가능한 Rust 2024 크레이트는 examples/article-12-structs-domain-model에 있습니다. 핵심 생성자는 다음과 같습니다.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EndpointId(u64);
impl EndpointId {
pub const fn new(value: u64) -> Result<Self, ModelError> {
if value == 0 {
Err(ModelError::ZeroEndpointId)
} else {
Ok(Self(value))
}
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CheckInterval(Duration);
impl CheckInterval {
pub const fn new(value: Duration) -> Result<Self, ModelError> {
if value.is_zero() {
Err(ModelError::ZeroCheckInterval)
} else {
Ok(Self(value))
}
}
pub const fn from_secs(seconds: u64) -> Result<Self, ModelError> {
Self::new(Duration::from_secs(seconds))
}
#[must_use]
pub const fn duration(self) -> Duration {
self.0
}
}
EndpointId::new와 CheckInterval::from_secs에는 self 매개변수가 없습니다. 특정 인스턴스에 동작하는 메서드가 아니라 타입에 묶인 연관 함수입니다. 이런 함수는 생성자에 자주 쓰이며 Type::function 문법으로 호출합니다. new는 언어의 특별한 키워드가 아니라 관례입니다.
검사 주기의 내부 표현에는 u64 대신 표준 라이브러리의 Duration을 썼습니다. 초, 밀리초 같은 단위를 필드 이름으로 추적하는 대신 시간 간격이라는 의미를 타입에 둡니다. 도메인 규칙상 0초 검사를 허용하지 않으므로 공개 생성자 두 개가 모두 ZeroCheckInterval을 반환합니다.
get()과 duration()은 값을 읽을 때만 내부 표현을 꺼냅니다. 래퍼의 튜플 필드에 pub를 붙이지 않았으므로 외부 코드는 EndpointId(0)이나 CheckInterval(Duration::ZERO)를 직접 만들 수 없습니다. 같은 모듈 안의 코드는 비공개 필드에 접근할 수 있으므로 이것은 모듈 API가 세우는 경계이지, 구조체 자체에 붙은 마법 같은 증명은 아닙니다.
3. URL 타입의 보장 범위
EndpointUrl도 문자열 newtype이지만 이름만 바꿔 붙인 것은 아닙니다. 그렇다면 어디까지 보장할까요? 공개 생성 경로에서 애플리케이션이 요구하는 최소 조건을 확인합니다.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EndpointUrl(String);
impl EndpointUrl {
pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
let value = value.into();
let remainder = value
.strip_prefix("https://")
.or_else(|| value.strip_prefix("http://"))
.ok_or(ModelError::UnsupportedUrlScheme)?;
if remainder.split('/').next().is_none_or(str::is_empty) {
return Err(ModelError::EmptyUrlAuthority);
}
if value.chars().any(char::is_whitespace) {
return Err(ModelError::UrlContainsWhitespace);
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
이 타입이 보장하는 것은 세 가지뿐입니다. 문자열은 http:// 또는 https://로 시작합니다. 다음 슬래시 앞의 authority 텍스트는 비어 있지 않으며 공백이 없습니다. 호스트 문법, 포트 범위, 국제화 도메인, 퍼센트 인코딩, 정규화까지 검사하지 않습니다. 따라서 이 코드는 애플리케이션의 작은 입력 정책이지 범용 URL 파서가 아닙니다. 그런 보장이 필요하다면 검증된 URL 파서를 선택하고 서비스가 허용할 스킴과 호스트 정책도 파싱과 별개로 정해야 합니다.
as_str()은 내부 문자열을 빌려 주는 변환입니다. as_ 접두사는 빌린 값에서 빌린 값으로 가는 저비용 뷰에 맞습니다. 호출자는 읽을 수 있지만 String을 직접 수정해 검사를 우회할 수는 없습니다.
4. 검증된 값으로 Endpoint 조립
상위 모델의 필드도 비공개입니다. Endpoint::new는 앞에서 검사된 값 객체를 입력으로 삼습니다. 아직 남아 있는 이름 규칙을 확인한 뒤에만 인스턴스를 반환합니다.
#[derive(Debug, PartialEq, Eq)]
pub struct Endpoint {
id: EndpointId,
name: String,
url: EndpointUrl,
check_interval: CheckInterval,
}
impl Endpoint {
pub fn new(
id: EndpointId,
name: impl Into<String>,
url: EndpointUrl,
check_interval: CheckInterval,
) -> Result<Self, ModelError> {
let name = name.into();
validate_name(&name)?;
Ok(Self {
id,
name,
url,
check_interval,
})
}
#[must_use]
pub const fn id(&self) -> EndpointId {
self.id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn url(&self) -> &EndpointUrl {
&self.url
}
#[must_use]
pub const fn check_interval(&self) -> CheckInterval {
self.check_interval
}
}
공개 필드가 없으므로 외부 호출자는 일부 필드를 빠뜨린 구조체 리터럴을 만들거나 URL 문자열을 임의로 덮어쓸 수 없습니다. 읽기는 id(), name(), url(), check_interval()로 제한합니다. 비공개 필드에 공개 getter를 두면 읽기 전용 API를 만들 수 있습니다. 일반 getter는 get_ 접두사 대신 필드 이름을 그대로 씁니다.
getter가 항상 원시 타입을 반환할 필요는 없습니다. url()은 &EndpointUrl을 반환해 URL이라는 의미를 보존하고 필요할 때 호출자가 as_str()을 선택하게 합니다. check_interval()은 작고 Copy인 래퍼를 값으로 돌려줍니다. 필드를 공개하는 대신 사용 목적에 맞는 반환형을 고를 수 있다는 점이 접근자의 실제 이점입니다.
5. 변경 메서드와 불변식
생성 시점만 검사하고 나중에 필드를 자유롭게 바꿀 수 있다면 불변식은 오래가지 않습니다. 이 모델은 name을 직접 노출하지 않고 rename을 제공합니다. 새 이름의 검사가 끝난 뒤에만 대입하므로 실패했을 때 기존 이름도 보존됩니다.
pub fn rename(&mut self, new_name: impl Into<String>) -> Result<(), ModelError> {
let new_name = new_name.into();
validate_name(&new_name)?;
self.name = new_name;
Ok(())
}
#[must_use]
pub fn is_due(&self, elapsed: Duration) -> bool {
elapsed >= self.check_interval.duration()
}
두 함수는 인스턴스를 첫 매개변수로 받으므로 메서드입니다. rename은 &mut self로 상태를 바꿉니다. is_due는 &self로 현재 검사 주기를 읽습니다. 연관 함수와 메서드를 가르는 기준은 이름이 아니라 수신자입니다. 새 값을 만드는 작업은 Endpoint::new(...), 이미 있는 엔드포인트에 묻는 작업은 endpoint.is_due(...)로 읽힙니다.
불변식은 "항상 참이어야 하는 조건"을 가능한 작은 타입에 둡니다. ID의 0 금지는 EndpointId, 검사 주기의 0 금지는 CheckInterval, URL의 입력 정책은 EndpointUrl이 맡습니다. Endpoint는 이름 규칙과 이 값들의 조합만 관리합니다. 모든 검사를 거대한 생성자 하나에 몰아넣는 것보다 각 타입이 책임을 분명히 가집니다.
6. 실패 경로와 API 형태 테스트
예제의 9개 단위 테스트는 0 ID, 지원하지 않는 URL 스킴, 빈 authority와 공백, 0초 간격, 빈 이름을 거부하는지 확인합니다. getter, 실패한 이름 변경의 원자성, 검사 시각의 경계값도 다룹니다. 함수 포인터 타입 테스트는 생성자와 메서드의 핵심 시그니처가 뜻하지 않게 바뀌지 않았는지 컴파일 단계에서 확인합니다.
#[test]
fn constructors_and_methods_have_the_intended_signatures() {
let _: fn(u64) -> Result<EndpointId, ModelError> = EndpointId::new;
let _: fn(u64) -> Result<CheckInterval, ModelError> = CheckInterval::from_secs;
let _: fn(&Endpoint, Duration) -> bool = Endpoint::is_due;
}
다음 명령을 프로젝트 디렉터리에서 실행할 수 있습니다.
cd examples/article-12-structs-domain-model
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
stable rustc 1.98.1, Cargo 1.98.1에서 모든 명령이 성공해야 합니다. 첫 블록은 라이브러리 테스트 실행 부분입니다. 이어지는 바이너리와 문서 테스트에는 각각 0개 테스트가 있습니다.
running 9 tests
test tests::check_interval_rejects_zero ... ok
test tests::constructors_and_methods_have_the_intended_signatures ... ok
test tests::due_check_uses_the_interval_boundary ... ok
test tests::endpoint_id_rejects_zero ... ok
test tests::endpoint_rejects_an_empty_name ... ok
test tests::endpoint_url_rejects_an_unsupported_scheme ... ok
test tests::endpoint_url_rejects_empty_authority_and_whitespace ... ok
test tests::failed_rename_preserves_the_old_name ... ok
test tests::getters_expose_domain_values_without_exposing_fields ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
실행 결과도 그대로 고정했습니다.
endpoint 42 status-api -> https://status.example.com/health
check every 30s; due after 45s: true
renamed: public-status
구조체는 관련 필드를 묶는 데서 끝나지 않습니다. 서로 바뀌면 안 되는 값은 newtype으로 나누고 생성자와 변경 메서드가 유효성 규칙을 한곳에서 지키게 할 수 있습니다. 비공개 필드와 좁은 getter는 그 경계를 외부 코드에도 유지합니다. 단, 타입 이름이 실제 검사보다 강한 보장을 암시하지 않도록 문서와 테스트에 정확한 범위를 남겨야 합니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 12 프로젝트에서 확인할 수 있습니다.
답글 남기기