Rust #[non_exhaustive]로 깨지지 않는 API 만들기
라이브러리를 만들다 보면 이미 공개한 열거형에 새 배리언트를 추가해야 할 때가 있습니다. 그런데 열거형에 배리언트를 하나 추가하는 순간 그 열거형을 match로 매칭하던 모든 사용자 코드에서 컴파일 에러가 터져요. 구조체도 마찬가지로 새 필드를 추가하면 구조체를 직접 생성하던 코드가 깨지죠.
semver를 지키려면 이런 변경은 메이저 버전을 올려야 합니다. 하지만 에러 타입에 새 종류를 추가하거나 설정 구조체에 옵션 하나를 추가하는 게 정말 파괴적 변경일까요? 🤔
Rust는 이 문제를 해결하기 위해 #[non_exhaustive] 속성을 제공합니다.
“이 타입은 앞으로 변경될 수 있으니 모든 경우를 다 안다고 가정하지 마세요”라고 컴파일러에게 알려주는 거예요.
열거형에 적용하기
가장 흔한 용도는 열거형입니다. 라이브러리에서 에러 타입을 정의한다고 해봅시다.
#[non_exhaustive]
pub enum ApiError {
NotFound,
Unauthorized,
RateLimit,
}
이 라이브러리를 사용하는 쪽에서는 match할 때 반드시 와일드카드 팔(_)을 포함해야 합니다.
use my_lib::ApiError;
fn handle_error(err: ApiError) {
match err {
ApiError::NotFound => println!("찾을 수 없음"),
ApiError::Unauthorized => println!("인증 필요"),
ApiError::RateLimit => println!("요청 제한"),
_ => println!("알 수 없는 에러"), // 이 줄이 없으면 컴파일 에러
}
}
_ 팔이 없으면 컴파일러가 “패턴이 완전하지 않다”고 거부합니다.
덕분에 나중에 라이브러리가 Timeout 배리언트를 추가해도 사용자 코드는 그대로 컴파일돼요.
새 배리언트는 _ 팔에서 처리되니까요.
구조체에 적용하기
구조체에 #[non_exhaustive]를 붙이면 외부 크레이트에서 두 가지가 제한됩니다.
직접 생성할 수 없고 패턴 매칭에서 모든 필드를 나열할 수 없어요.
#[non_exhaustive]
pub struct Config {
pub width: u16,
pub height: u16,
}
impl Config {
pub fn new(width: u16, height: u16) -> Self {
Config { width, height }
}
}
외부 크레이트에서 이 구조체를 다루면 이렇게 됩니다.
use my_lib::Config;
// 직접 생성 불가 — 컴파일 에러
// let config = Config { width: 1920, height: 1080 };
// 생성자 함수를 통해서만 만들 수 있음
let config = Config::new(1920, 1080);
// 패턴 매칭 시 .. 필수
let Config { width, height, .. } = config;
Config { width, height }처럼 필드를 전부 나열하면 컴파일 에러가 나요.
반드시 ..을 붙여야 합니다.
나중에 fullscreen 같은 필드가 추가되더라도 ..이 나머지를 무시해주니까 기존 코드가 깨지지 않죠.
생성도 직접 할 수 없기 때문에 new 같은 생성자 함수나 빌더 패턴을 제공해야 합니다.
새 필드를 추가할 때 생성자에서 기본값을 넣어주면 되니까 하위 호환성이 유지돼요.
배리언트에 개별 적용하기
열거형 전체가 아니라 특정 배리언트에만 #[non_exhaustive]를 붙일 수도 있습니다.
이 경우 열거형 자체에는 새 배리언트를 자유롭게 추가할 수 없지만 해당 배리언트의 필드 구조는 보호돼요.
pub enum Message {
#[non_exhaustive]
Send { from: u32, to: u32, contents: String },
#[non_exhaustive]
Reaction(u32),
#[non_exhaustive]
Quit,
}
외부에서 Send 배리언트를 매칭할 때 ..이 필요하고 Reaction의 생성자 가시성은 pub(crate)로 줄어듭니다.
Quit처럼 유닛 배리언트도 외부에서 생성하거나 매칭하려면 Quit { .. } 같은 중괄호 문법을 써야 해요.
열거형 전체에도 #[non_exhaustive]를 붙이고 개별 배리언트에도 붙이면 이중 보호가 됩니다.
새 배리언트 추가와 기존 배리언트 필드 추가 모두 하위 호환이 유지되죠.
크레이트 경계의 비대칭
#[non_exhaustive]의 핵심 특성은 같은 크레이트 안에서는 아무 효과가 없다는 점입니다.
#[non_exhaustive]
pub enum Error {
Io(std::io::Error),
Parse(String),
}
#[non_exhaustive]
pub struct Config {
pub width: u16,
pub height: u16,
}
// 같은 크레이트에서는 직접 생성 가능
let config = Config { width: 640, height: 480 };
// 같은 크레이트에서는 와일드카드 없이 매칭 가능
match error {
Error::Io(e) => handle_io(e),
Error::Parse(s) => handle_parse(s),
// _ 팔 없어도 컴파일됨
}
라이브러리 내부에서는 모든 배리언트와 필드를 알고 있으니 제약을 걸 이유가 없는 거예요. 제약은 오직 외부 크레이트에서 사용할 때만 적용됩니다.
이 비대칭 덕분에 라이브러리 작성자는 기존처럼 편하게 코드를 쓰면서도 API 안정성을 보장할 수 있어요.
표준 라이브러리와 실무 활용
Rust 표준 라이브러리도 #[non_exhaustive]를 적극적으로 씁니다.
대표적인 게 std::io::ErrorKind예요.
use std::io::ErrorKind;
match error.kind() {
ErrorKind::NotFound => println!("파일 없음"),
ErrorKind::PermissionDenied => println!("권한 없음"),
_ => println!("기타 에러: {}", error),
}
Rust 버전이 올라가면서 ErrorKind에 새 배리언트가 계속 추가되고 있는데 #[non_exhaustive] 덕분에 기존 코드가 깨지지 않습니다.
_ 팔이 새 에러 종류를 처리해주니까요.
실무에서 #[non_exhaustive]가 빛나는 곳이 몇 가지 있어요.
우선 에러 타입에 가장 잘 어울립니다.
에러 종류는 시간이 지나면서 늘어나기 마련이고 thiserror 같은 크레이트로 에러를 정의할 때 #[non_exhaustive]를 함께 쓰면 안전해요.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("연결 실패: {0}")]
Connection(String),
#[error("쿼리 실패: {0}")]
Query(String),
#[error("시간 초과")]
Timeout,
}
설정이나 옵션 구조체도 좋은 후보입니다. 기능이 추가될 때마다 설정 항목이 늘어나는 건 자연스러운 일이거든요.
#[non_exhaustive]
pub struct ClientOptions {
pub base_url: String,
pub timeout_secs: u64,
pub retry_count: u32,
}
impl Default for ClientOptions {
fn default() -> Self {
ClientOptions {
base_url: "https://api.example.com".into(),
timeout_secs: 30,
retry_count: 3,
}
}
}
Default 트레이트를 구현해두면 사용자가 ..Default::default()로 나머지 필드를 채울 수 있어서 새 필드가 추가돼도 자연스럽게 기본값이 적용됩니다.
Serde와 함께 쓸 때도 궁합이 좋습니다.
역직렬화 시 #[serde(default)]를 붙여두면 JSON에 없는 새 필드는 기본값으로 채워지니까요.
언제 쓰고 언제 안 쓸까
#[non_exhaustive]는 만능이 아닙니다.
외부에서 직접 생성이 안 되고 모든 매칭에 와일드카드가 필요하니까 사용자 입장에서 약간 불편하죠.
라이브러리 크레이트를 만들고 향후 배리언트나 필드가 추가될 가능성이 있다면 쓰는 게 맞습니다. 에러 타입, 설정 구조체, 이벤트 열거형 같은 게 전형적인 예시예요.
반면 바이너리 크레이트(애플리케이션)에서는 보통 필요 없어요. 외부 크레이트 경계가 없으니 효과도 없고 의미도 없습니다. 또한 배리언트가 확정적이어서 절대 바뀔 일이 없는 열거형(방향, 요일 같은)에도 굳이 붙일 이유가 없죠.
마치며
#[non_exhaustive]는 “미래의 나”를 위한 속성입니다.
지금은 배리언트가 세 개뿐이지만 나중에 늘어날 가능성이 있다면 미리 붙여두는 게 좋아요.
한 줄의 속성으로 메이저 버전 범프 없이 API를 확장할 수 있는 건 꽤 큰 이점입니다.
핵심은 크레이트 경계에서의 비대칭 동작이에요. 같은 크레이트 안에서는 아무 제약 없이 쓰고 외부에서만 와일드카드를 강제하니까 라이브러리 작성자의 자유도와 사용자의 안정성을 동시에 챙길 수 있습니다.
더 자세한 내용은 Rust Reference의 non_exhaustive 문서를 참고하세요.
This work is licensed under
CC BY 4.0