이미지 로딩 중...
AI Generated
2025. 11. 13. · 4 Views
Rust pub 키워드와 공개 API 설계 가이드
Rust의 pub 키워드를 활용한 모듈 가시성 제어와 공개 API 설계 방법을 배웁니다. 실무에서 안전하고 유지보수하기 쉬운 라이브러리를 만드는 방법을 다양한 예제와 함께 학습합니다.
목차
- pub 키워드 기본 - 공개와 비공개의 차이
- pub(crate)와 제한된 가시성 - 크레이트 내부 공유
- pub(super)와 부모 모듈 접근 - 계층적 가시성 제어
- 구조체 필드의 가시성 - 부분 공개 패턴
- 열거형과 트레이트의 가시성 - 확장 가능한 API 설계
- 재수출(re-export)로 깔끔한 API 만들기 - pub use 패턴
- 모듈 파일 구조와 가시성 - mod.rs vs 파일명.rs
- super와 crate 경로 - 상대 경로와 절대 경로
- 공개 API 진화 전략 - 버전 관리와 deprecation
- cfg와 조건부 컴파일의 가시성 - 플랫폼별 API
1. pub 키워드 기본 - 공개와 비공개의 차이
시작하며
여러분이 Rust로 라이브러리를 만들 때 이런 상황을 겪어본 적 있나요? 다른 모듈에서 함수를 호출하려고 하는데 "private function" 에러가 발생해서 당황했던 경험 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. Rust는 기본적으로 모든 항목을 비공개(private)로 만들기 때문에, 명시적으로 공개하지 않으면 다른 모듈에서 사용할 수 없습니다.
바로 이럴 때 필요한 것이 pub 키워드입니다. pub를 사용하면 함수, 구조체, 모듈 등을 외부에 공개하여 다른 곳에서 사용할 수 있게 만들 수 있습니다.
개요
간단히 말해서, pub 키워드는 Rust의 가시성(visibility)을 제어하는 도구입니다. 함수, 구조체, 필드, 메서드 앞에 pub를 붙이면 해당 항목이 외부에 공개됩니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 캡슐화(encapsulation)를 통해 안전한 API를 설계하기 위해서입니다. 예를 들어, 내부 구현 세부사항은 숨기고 안정적인 공개 인터페이스만 노출하는 경우에 매우 유용합니다.
기존 다른 언어에서는 public, private 같은 키워드를 클래스 단위로 사용했다면, Rust에서는 모듈 시스템과 결합하여 더 세밀한 제어가 가능합니다. pub의 핵심 특징은 명시적 공개(explicit publicity), 모듈 단위 가시성, 그리고 계층적 접근 제어입니다.
이러한 특징들이 코드의 안정성과 유지보수성을 크게 향상시킵니다.
코드 예제
// 비공개 함수 - 같은 모듈 내에서만 사용 가능
fn private_helper() -> i32 {
42
}
// 공개 함수 - 외부 모듈에서 사용 가능
pub fn public_function() -> i32 {
// 같은 모듈 내에서는 비공개 함수 호출 가능
private_helper() * 2
}
// 공개 구조체
pub struct Counter {
value: i32, // 비공개 필드
}
impl Counter {
// 공개 생성자
pub fn new() -> Self {
Counter { value: 0 }
}
// 공개 메서드
pub fn increment(&mut self) {
self.value += 1;
}
}
설명
이것이 하는 일: pub 키워드는 Rust의 모듈 시스템에서 어떤 항목을 외부에 노출할지 결정합니다. 기본적으로 모든 것이 비공개이므로, 외부에서 접근해야 하는 항목에만 pub를 명시적으로 붙입니다.
첫 번째로, private_helper 함수는 pub가 없기 때문에 같은 모듈 내에서만 호출할 수 있습니다. 이렇게 하는 이유는 내부 구현 세부사항을 숨겨서 나중에 자유롭게 변경할 수 있도록 하기 위해서입니다.
그 다음으로, public_function은 pub가 붙어있어서 다른 모듈에서도 호출할 수 있습니다. 하지만 이 함수 내부에서는 비공개 함수인 private_helper를 자유롭게 사용할 수 있습니다.
Counter 구조체의 경우, 구조체 자체는 pub로 공개되어 있지만 value 필드는 비공개입니다. 이렇게 하면 외부에서 Counter를 사용할 수 있지만, value를 직접 수정하지 못하게 막을 수 있습니다.
여러분이 이 코드를 사용하면 안전한 API를 설계할 수 있습니다. 공개 메서드(new, increment)를 통해서만 내부 상태를 변경할 수 있으므로, 불변성 규칙을 강제하고 예상치 못한 버그를 방지할 수 있습니다.
실전 팁
💡 기본적으로 모든 것을 비공개로 두고, 정말 필요한 것만 pub로 공개하세요. 한 번 공개한 API는 나중에 변경하기 어렵습니다.
💡 구조체를 공개할 때 필드는 비공개로 두고 getter/setter 메서드를 제공하면 내부 구현을 자유롭게 변경할 수 있습니다.
💡 pub를 붙이기 전에 "이것이 내 라이브러리의 공개 API가 되어도 괜찮을까?"라고 자문해보세요. 공개 API는 하위 호환성을 유지해야 합니다.
💡 테스트 코드에서 비공개 함수를 테스트하려면 같은 파일에 #[cfg(test)] 모듈을 만들거나, tests/ 디렉토리 대신 각 파일 내부에 테스트를 작성하세요.
💡 pub(crate)를 사용하면 같은 크레이트 내에서만 접근 가능한 "반공개" 항목을 만들 수 있어서 내부 모듈 간 공유에 유용합니다.
2. pub(crate)와 제한된 가시성 - 크레이트 내부 공유
시작하며
여러분이 큰 프로젝트를 만들 때 이런 고민을 해본 적 있나요? 여러 모듈에서 공통으로 사용하는 유틸리티 함수가 있는데, 외부에는 공개하고 싶지 않은 경우 말이죠.
이런 문제는 실제 라이브러리 개발에서 자주 발생합니다. 완전히 공개(pub)하면 외부 사용자에게 노출되고, 비공개로 두면 다른 내부 모듈에서 사용할 수 없습니다.
바로 이럴 때 필요한 것이 pub(crate)입니다. 같은 크레이트 내부에서만 공개하고, 외부 크레이트에는 숨길 수 있는 완벽한 해결책입니다.
개요
간단히 말해서, pub(crate)는 "이 크레이트 안에서만 공개"라는 의미의 제한된 가시성 지정자입니다. pub보다는 제한적이지만, 완전 비공개보다는 넓은 범위에서 사용할 수 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 내부 아키텍처를 유연하게 구성하면서도 외부 API는 깔끔하게 유지하기 위해서입니다. 예를 들어, 여러 모듈이 공통으로 사용하는 파싱 로직이나 검증 함수를 공유하면서도 라이브러리 사용자에게는 노출하지 않는 경우에 매우 유용합니다.
기존에는 pub로 전부 공개하거나 각 모듈에 중복 코드를 작성했다면, 이제는 pub(crate)로 내부에서만 공유할 수 있습니다. pub(crate)의 핵심 특징은 크레이트 경계 인식, 내부 구현 공유, 그리고 외부 API 보호입니다.
이러한 특징들이 대규모 프로젝트의 모듈화를 훨씬 쉽게 만듭니다.
코드 예제
// src/utils.rs - 내부 유틸리티 모듈
pub(crate) fn validate_input(input: &str) -> bool {
!input.is_empty() && input.len() < 100
}
pub(crate) struct InternalConfig {
pub(crate) debug_mode: bool,
pub(crate) max_retries: u32,
}
// src/api.rs - 공개 API 모듈
use crate::utils::{validate_input, InternalConfig};
pub struct ApiClient {
config: InternalConfig, // 비공개 필드
}
impl ApiClient {
pub fn new() -> Self {
ApiClient {
config: InternalConfig {
debug_mode: false,
max_retries: 3,
}
}
}
pub fn send_request(&self, data: &str) -> Result<(), String> {
// 내부 함수 사용 가능
if !validate_input(data) {
return Err("Invalid input".to_string());
}
Ok(())
}
}
설명
이것이 하는 일: pub(crate)는 해당 항목을 크레이트(라이브러리 또는 바이너리) 내의 모든 모듈에서 접근 가능하게 만들지만, 외부 크레이트에서는 접근할 수 없게 합니다. 첫 번째로, validate_input 함수는 pub(crate)로 선언되어 있어서 같은 크레이트의 api 모듈에서 사용할 수 있습니다.
이렇게 하는 이유는 여러 모듈이 공통으로 사용하는 검증 로직을 중복 없이 공유하기 위해서입니다. 그 다음으로, InternalConfig 구조체도 pub(crate)로 선언되어 내부 모듈들이 설정을 공유할 수 있지만, 라이브러리 사용자는 이 구조체의 존재를 알 수 없습니다.
내부적으로 설정 구조를 변경해도 외부 API에는 영향을 주지 않습니다. ApiClient는 완전히 공개(pub)된 구조체이지만, 그 내부에서 pub(crate) 항목들을 자유롭게 사용합니다.
send_request 메서드는 공개 API이지만, 내부적으로 validate_input이라는 크레이트 전용 함수를 호출합니다. 여러분이 이 패턴을 사용하면 내부 모듈 간 협업이 쉬워지고, 동시에 외부 API는 간결하게 유지할 수 있습니다.
내부 구조를 리팩토링해도 외부 사용자에게 영향을 주지 않으므로, 안전하게 코드를 개선할 수 있습니다.
실전 팁
💡 pub(crate)는 라이브러리를 만들 때 가장 유용합니다. 바이너리 프로젝트에서는 모든 코드가 한 크레이트에 있으므로 일반 pub와 차이가 없습니다.
💡 여러 모듈이 공유하는 타입이나 상수는 pub(crate)로 선언하면 중복을 피하면서도 외부 API를 깔끔하게 유지할 수 있습니다.
💡 pub(crate) 항목에 문서 주석(///)을 작성하면 팀원들이 내부 API를 이해하는 데 도움이 됩니다. cargo doc --document-private-items로 확인할 수 있습니다.
💡 테스트에서 pub(crate) 함수를 호출할 수 있으므로, 단위 테스트 작성이 훨씬 쉬워집니다. 통합 테스트(tests/)에서는 접근할 수 없다는 점만 주의하세요.
💡 pub(crate)를 남용하면 내부 모듈 간 결합도가 높아질 수 있으니, 정말 여러 곳에서 사용하는 항목에만 적용하세요.
3. pub(super)와 부모 모듈 접근 - 계층적 가시성 제어
시작하며
여러분이 복잡한 모듈 계층 구조를 설계할 때 이런 상황을 겪어본 적 있나요? 자식 모듈들이 공유하는 헬퍼 함수를 만들고 싶은데, 부모 모듈에서만 접근 가능하게 하고 싶은 경우 말이죠.
이런 문제는 실제 대규모 프로젝트에서 자주 발생합니다. pub(crate)를 사용하면 너무 넓은 범위에 공개되고, 비공개로 두면 형제 모듈에서 접근할 수 없습니다.
바로 이럴 때 필요한 것이 pub(super)입니다. 부모 모듈과 그 자식 모듈들 사이에서만 공유할 수 있는 정교한 가시성 제어를 제공합니다.
개요
간단히 말해서, pub(super)는 "부모 모듈에서만 공개"라는 의미의 상대적 가시성 지정자입니다. 현재 모듈의 부모와 형제 모듈에서 접근할 수 있지만, 다른 곳에서는 접근할 수 없습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모듈 계층 구조를 설계할 때 세밀한 캡슐화를 구현하기 위해서입니다. 예를 들어, database 모듈 안에 connection과 query 서브모듈이 있고, 이들이 공유하는 internal_pool을 상위 database 모듈 레벨에서만 관리하고 싶은 경우에 매우 유용합니다.
기존에는 모든 것을 같은 파일에 넣거나 pub(crate)로 전체 공개했다면, 이제는 pub(super)로 모듈 계층에 맞춰 정확한 범위만 공개할 수 있습니다. pub(super)의 핵심 특징은 상대적 가시성, 계층 구조 존중, 그리고 세밀한 캡슐화입니다.
이러한 특징들이 복잡한 모듈 구조에서도 명확한 책임 분리를 가능하게 합니다.
코드 예제
// src/database/mod.rs
pub mod connection;
pub mod query;
// 부모 모듈(database) 레벨의 공유 리소스
pub(super) struct ConnectionPool {
max_connections: usize,
}
impl ConnectionPool {
pub(super) fn new(max: usize) -> Self {
ConnectionPool { max_connections: max }
}
}
// src/database/connection.rs
use super::ConnectionPool; // pub(super)이므로 접근 가능
pub struct Connection {
pool: ConnectionPool,
}
impl Connection {
pub fn establish() -> Self {
// 부모 모듈의 pub(super) 항목 사용
let pool = ConnectionPool::new(10);
Connection { pool }
}
}
// src/database/query.rs
use super::ConnectionPool; // 형제 모듈도 부모를 통해 접근 가능
pub fn execute_query(pool: &ConnectionPool) {
// query 로직
}
설명
이것이 하는 일: pub(super)는 해당 항목을 부모 모듈과 같은 부모를 공유하는 형제 모듈에서만 접근할 수 있게 만듭니다. 모듈 계층을 따라 한 단계 위로 올라간 범위까지만 공개됩니다.
첫 번째로, ConnectionPool은 database/mod.rs에서 pub(super)로 선언되었습니다. 이는 database 모듈의 부모(src/lib.rs 또는 main.rs)에서는 접근할 수 있지만, 완전히 외부에는 공개되지 않습니다.
이렇게 하는 이유는 데이터베이스 관련 내부 구조를 상위 레벨에서만 제어하기 위해서입니다. 그 다음으로, connection.rs와 query.rs는 모두 database의 자식 모듈이므로, use super::ConnectionPool로 부모의 pub(super) 항목에 접근할 수 있습니다.
두 모듈이 공통으로 사용하는 ConnectionPool을 공유하면서도, 외부에는 노출하지 않습니다. Connection::establish()는 완전히 공개된(pub) 메서드이지만, 내부적으로는 pub(super) 항목인 ConnectionPool을 사용합니다.
사용자는 Connection을 만들 수 있지만, ConnectionPool의 존재는 알 수 없습니다. 여러분이 이 패턴을 사용하면 모듈 계층 구조에 따라 자연스러운 캡슐화를 구현할 수 있습니다.
각 레벨마다 적절한 추상화 수준을 유지하면서, 관련된 서브모듈들만 내부 세부사항을 공유하게 됩니다.
실전 팁
💡 pub(super)는 모듈을 파일로 분리할 때 특히 유용합니다. 하나의 큰 파일을 여러 개로 나눌 때 내부 공유 로직을 pub(super)로 표시하세요.
💡 계층이 깊은 모듈 구조에서는 pub(in crate::some::path) 문법으로 특정 상위 모듈까지만 공개할 수 있습니다. 더 정밀한 제어가 가능합니다.
💡 pub(super)는 리팩토링 안전성을 높입니다. 모듈을 재구성해도 가시성이 계층 구조에 상대적이므로, 외부 API에 영향을 주지 않습니다.
💡 형제 모듈 간 순환 의존성을 피하려면, 공통 로직을 부모 모듈에 pub(super)로 정의하고 자식들이 참조하게 하세요.
💡 디버깅할 때 pub(super) 항목이 어디서 접근 가능한지 헷갈리면, cargo doc --document-private-items로 문서를 생성하여 확인할 수 있습니다.
4. 구조체 필드의 가시성 - 부분 공개 패턴
시작하며
여러분이 API를 설계할 때 이런 고민을 해본 적 있나요? 구조체는 공개하고 싶은데, 일부 필드는 외부에서 직접 수정하지 못하게 막고 싶은 경우 말이죠.
이런 문제는 실제 라이브러리 개발에서 필수적으로 다뤄야 하는 부분입니다. 모든 필드를 공개하면 내부 불변성을 유지할 수 없고, 모두 비공개로 두면 사용하기 불편합니다.
바로 이럴 때 필요한 것이 필드별 가시성 제어입니다. 구조체는 pub로 공개하되, 각 필드에 개별적으로 가시성을 지정하여 안전하고 사용하기 편한 API를 만들 수 있습니다.
개요
간단히 말해서, 구조체의 각 필드는 독립적으로 pub 또는 비공개로 선언할 수 있습니다. 구조체 자체가 pub라도, 필드가 비공개면 외부에서 직접 접근할 수 없습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 불변성(invariant)을 보장하는 안전한 API를 만들기 위해서입니다. 예를 들어, 이메일 주소를 저장하는 구조체에서 검증된 이메일만 저장되도록 보장하려면, 필드를 비공개로 하고 검증 로직이 있는 생성자만 제공해야 합니다.
기존 객체지향 언어에서 private 필드와 public getter/setter를 사용하던 패턴을, Rust에서는 비공개 필드와 pub 메서드로 구현합니다. 필드 가시성 제어의 핵심 특징은 세밀한 접근 제어, 불변성 보장, 그리고 API 진화 가능성입니다.
이러한 특징들이 장기적으로 유지보수 가능한 라이브러리를 만드는 기반이 됩니다.
코드 예제
// 부분 공개 구조체
pub struct User {
pub username: String, // 공개 필드 - 읽기/쓰기 가능
email: String, // 비공개 필드 - 외부 접근 불가
pub(crate) role: UserRole, // 크레이트 내부에서만 접근
verified: bool, // 비공개 필드
}
pub enum UserRole {
Admin,
Member,
}
impl User {
// 생성자 - 검증 로직 포함
pub fn new(username: String, email: String) -> Result<Self, String> {
if !email.contains('@') {
return Err("Invalid email format".to_string());
}
Ok(User {
username,
email,
role: UserRole::Member,
verified: false,
})
}
// Getter - 읽기 전용 접근
pub fn email(&self) -> &str {
&self.email
}
// 제어된 수정 메서드
pub fn verify_email(&mut self) {
self.verified = true;
}
pub fn is_verified(&self) -> bool {
self.verified
}
}
설명
이것이 하는 일: 구조체 필드의 가시성을 개별적으로 제어하여, 어떤 데이터는 직접 접근 가능하게 하고 어떤 데이터는 메서드를 통해서만 접근하게 만듭니다. 첫 번째로, username 필드는 pub로 공개되어 있어서 외부에서 직접 읽고 수정할 수 있습니다.
이렇게 하는 이유는 username은 자유롭게 변경해도 무방한 데이터이고, 직접 접근이 편리하기 때문입니다. user.username = "new_name".to_string()처럼 사용할 수 있습니다.
그 다음으로, email과 verified 필드는 비공개로 선언되어 있습니다. email은 반드시 유효한 형식이어야 하므로, new() 생성자에서 검증을 거친 후에만 저장됩니다.
외부에서 user.email = "invalid"처럼 직접 대입하면 컴파일 에러가 발생합니다. email() 메서드는 비공개 필드에 대한 읽기 전용 접근을 제공합니다.
&str을 반환하므로 외부에서 읽을 수는 있지만 수정할 수는 없습니다. verified 필드도 마찬가지로 is_verified()로 읽고, verify_email()로만 변경할 수 있습니다.
여러분이 이 패턴을 사용하면 API의 안전성이 크게 향상됩니다. 잘못된 상태가 생성될 가능성이 원천적으로 차단되고, 나중에 내부 구현을 변경해도 공개 메서드만 유지하면 되므로 하위 호환성을 지키기 쉽습니다.
실전 팁
💡 기본 원칙은 "모든 필드를 비공개로, 필요한 경우에만 공개"입니다. 한 번 공개한 필드는 나중에 비공개로 바꾸기 어렵습니다.
💡 읽기 전용 접근이 필요하면 &self를 받는 getter 메서드를 만드세요. 필드 이름과 같은 이름의 메서드를 만드는 것이 Rust 관례입니다.
💡 Builder 패턴을 사용하면 많은 필드를 가진 구조체를 유연하게 생성할 수 있습니다. derive_builder 크레이트가 도움이 됩니다.
💡 #[non_exhaustive] 애트리뷰트를 구조체에 추가하면, 나중에 필드를 추가해도 외부 코드가 깨지지 않습니다. 라이브러리 개발 시 필수입니다.
💡 pub(crate) 필드는 같은 크레이트 내부 모듈 간 데이터 공유에 유용하지만, 외부 API에는 노출되지 않아 안전합니다.
5. 열거형과 트레이트의 가시성 - 확장 가능한 API 설계
시작하며
여러분이 라이브러리를 만들 때 이런 딜레마를 겪어본 적 있나요? enum을 공개하면 외부에서 새로운 variant를 추가할 수 없고, trait를 공개하면 외부에서 구현할 수 있어서 나중에 메서드를 추가하기 어려운 상황 말이죠.
이런 문제는 실제 API 진화(evolution)에서 핵심적인 이슈입니다. enum과 trait는 강력한 추상화 도구이지만, 가시성과 확장성 사이에서 신중한 균형이 필요합니다.
바로 이럴 때 필요한 것이 enum과 trait의 가시성 전략입니다. #[non_exhaustive], sealed trait 패턴 등을 활용하여 안전하면서도 유연한 API를 설계할 수 있습니다.
개요
간단히 말해서, enum과 trait의 가시성은 단순히 pub를 붙이는 것 이상의 고민이 필요합니다. 향후 변경 가능성, 외부 확장성, 하위 호환성을 모두 고려해야 합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 라이브러리의 장기적인 유지보수를 위해서입니다. 예를 들어, 에러 타입을 enum으로 만들었는데, 나중에 새로운 에러 케이스를 추가하면 외부 코드의 match 문이 깨질 수 있습니다.
#[non_exhaustive]를 사용하면 이를 방지할 수 있습니다. 기존에는 enum을 공개하면 고정된 variant 집합에 묶였다면, 이제는 #[non_exhaustive]로 미래의 확장을 예약할 수 있습니다.
trait도 마찬가지로 sealed trait 패턴으로 내부에서만 구현 가능하게 만들 수 있습니다. 이러한 기법들의 핵심 특징은 하위 호환성 보장, 제어된 확장성, 그리고 명확한 API 경계입니다.
이러한 특징들이 성숙한 생태계를 만드는 데 필수적입니다.
코드 예제
use std::fmt;
// non_exhaustive enum - 나중에 variant 추가 가능
#[non_exhaustive]
pub enum ApiError {
NetworkError(String),
ParseError(String),
AuthenticationFailed,
// 미래에 새로운 variant를 추가해도 기존 코드 호환
}
// Sealed trait 패턴 - 외부에서 구현 불가
mod sealed {
pub trait Sealed {}
}
pub trait Operation: sealed::Sealed {
fn execute(&self) -> String;
// 미래에 메서드 추가 가능 (외부 구현이 없으므로)
fn rollback(&self) -> String {
"No rollback needed".to_string()
}
}
// 내부에서만 구현
pub struct CreateOperation;
impl sealed::Sealed for CreateOperation {}
impl Operation for CreateOperation {
fn execute(&self) -> String {
"Creating resource".to_string()
}
}
pub struct DeleteOperation;
impl sealed::Sealed for DeleteOperation {}
impl Operation for DeleteOperation {
fn execute(&self) -> String {
"Deleting resource".to_string()
}
}
// 사용 예시
pub fn handle_error(error: ApiError) {
match error {
ApiError::NetworkError(msg) => println!("Network: {}", msg),
ApiError::ParseError(msg) => println!("Parse: {}", msg),
ApiError::AuthenticationFailed => println!("Auth failed"),
_ => println!("Other error"), // non_exhaustive 필수
}
}
설명
이것이 하는 일: enum과 trait의 가시성을 전략적으로 제어하여, 라이브러리 제작자는 유연하게 변경하고 사용자는 안정적으로 사용할 수 있게 만듭니다. 첫 번째로, ApiError enum에 #[non_exhaustive] 애트리뷰트가 붙어있습니다.
이렇게 하는 이유는 나중에 새로운 에러 타입(예: RateLimitExceeded)을 추가해도 기존 코드가 깨지지 않게 하기 위해서입니다. 외부 코드는 반드시 _ 와일드카드를 사용해야 하므로, 새 variant가 추가되어도 컴파일 에러가 발생하지 않습니다.
그 다음으로, sealed trait 패턴은 비공개 모듈(sealed)에 Sealed trait를 정의하고, 공개 trait(Operation)가 이를 상속하게 만듭니다. 외부 크레이트는 sealed::Sealed에 접근할 수 없으므로, Operation을 구현할 수 없습니다.
이를 통해 나중에 rollback() 같은 새 메서드를 추가해도 외부 구현체가 없으므로 안전합니다. CreateOperation과 DeleteOperation은 모두 내부에서 Sealed와 Operation을 구현합니다.
라이브러리 사용자는 이 구조체들을 사용할 수 있지만, 자신만의 Operation 구현체를 만들 수는 없습니다. 여러분이 이 패턴들을 사용하면 라이브러리를 장기적으로 발전시킬 수 있습니다.
semantic versioning을 지키면서 기능을 추가할 수 있고, 사용자는 안정적인 API를 신뢰할 수 있습니다. 특히 #[non_exhaustive]는 Rust 1.40부터 표준화된 best practice입니다.
실전 팁
💡 공개 enum에는 거의 항상 #[non_exhaustive]를 붙이세요. 나중에 추가하면 breaking change가 되므로, 처음부터 적용하는 것이 좋습니다.
💡 sealed trait는 trait의 메서드 시그니처를 변경할 때 유용합니다. 외부 구현체가 없으므로, default 구현이 있는 새 메서드를 추가하는 것이 minor version 업데이트로 가능합니다.
💡 enum variant에도 개별적으로 pub를 붙일 수 없습니다. enum이 pub면 모든 variant가 공개됩니다. 일부만 공개하려면 별도의 함수나 메서드로 접근을 제어하세요.
💡 trait object(dyn Operation)를 반환하는 API를 만들 때는 sealed trait가 필수입니다. 외부에서 임의의 구현체를 만들어 전달하는 것을 방지할 수 있습니다.
💡 #[non_exhaustive]는 struct에도 사용할 수 있습니다. 외부에서 구조체 리터럴 문법({ field: value })으로 생성하지 못하게 만들어, 생성자 함수를 통해서만 만들 수 있게 강제합니다.
6. 재수출(re-export)로 깔끔한 API 만들기 - pub use 패턴
시작하며
여러분이 라이브러리를 만들 때 이런 불편함을 겪어본 적 있나요? 내부적으로는 여러 모듈로 잘 나누어져 있는데, 사용자가 use your_lib::internal::sub_module::deeply::nested::Type처럼 긴 경로를 써야 하는 상황 말이죠.
이런 문제는 실제 라이브러리 사용성(ergonomics)에서 큰 불만 요소입니다. 내부 구조는 복잡해도 외부 API는 단순하고 직관적이어야 합니다.
바로 이럴 때 필요한 것이 pub use를 통한 재수출입니다. 내부 모듈 구조는 유지하면서, 자주 사용하는 타입들을 최상위에서 바로 접근할 수 있게 만들 수 있습니다.
개요
간단히 말해서, pub use는 다른 모듈의 항목을 현재 모듈에서 다시 공개하는 기능입니다. use로 가져온 후 pub로 공개하여, 사용자가 짧은 경로로 접근할 수 있게 만듭니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, API의 사용성과 내부 구조의 조직화를 동시에 달성하기 위해서입니다. 예를 들어, 실제 구현은 src/parser/json.rs에 있지만, 사용자는 use mylib::JsonParser처럼 간단하게 접근할 수 있게 만들 수 있습니다.
기존에는 단순한 API를 위해 모든 것을 한 파일에 넣거나, 조직화를 위해 복잡한 경로를 강요했다면, 이제는 pub use로 둘 다 달성할 수 있습니다. pub use의 핵심 특징은 경로 단축, 모듈 구조 숨기기, 그리고 Facade 패턴 구현입니다.
이러한 특징들이 사용자 친화적인 라이브러리를 만드는 핵심입니다.
코드 예제
// src/lib.rs - 라이브러리 루트
// 자주 사용하는 타입들을 최상위로 재수출
pub use client::HttpClient;
pub use error::{ApiError, Result};
pub use request::{Request, RequestBuilder};
pub use response::Response;
// 내부 모듈들은 공개하되, 직접 접근은 권장하지 않음
pub mod client;
pub mod error;
pub mod request;
pub mod response;
// 고급 사용자를 위한 세부 항목들
pub mod prelude {
// 모든 주요 타입을 한 번에 가져오기
pub use crate::{
HttpClient,
Request, RequestBuilder,
Response,
ApiError, Result,
};
}
// src/client.rs
pub struct HttpClient {
base_url: String,
}
// src/error.rs
pub enum ApiError {
NetworkError,
}
pub type Result<T> = std::result::Result<T, ApiError>;
// src/request.rs
pub struct Request { /* ... */ }
pub struct RequestBuilder { /* ... */ }
// src/response.rs
pub struct Response { /* ... */ }
// 사용자 코드 - 간단한 import
// use mylib::{HttpClient, Request, Response}; // 짧은 경로!
// use mylib::prelude::*; // 또는 모든 주요 타입 한 번에
설명
이것이 하는 일: pub use는 다른 곳에 정의된 항목을 현재 위치에서 다시 공개하여, 사용자가 더 짧고 직관적인 경로로 접근할 수 있게 만듭니다. 첫 번째로, lib.rs에서 pub use client::HttpClient처럼 각 모듈의 주요 타입을 재수출합니다.
이렇게 하는 이유는 사용자가 use mylib::HttpClient처럼 간단하게 import할 수 있게 하기 위해서입니다. 실제 정의는 src/client.rs에 있지만, 사용자는 이를 알 필요가 없습니다.
그 다음으로, error 모듈에서 ApiError와 Result를 재수출합니다. Result는 특히 중요한데, std::result::Result<T, ApiError>라는 긴 타입을 단순히 Result<T>로 사용할 수 있게 만들어줍니다.
이는 Rust 표준 라이브러리에서도 널리 사용되는 패턴입니다. prelude 모듈은 모든 주요 타입을 한 곳에 모아둔 특수한 모듈입니다.
사용자가 use mylib::prelude::* 한 줄로 필요한 모든 타입을 가져올 수 있습니다. tokio, serde 같은 유명 라이브러리들이 모두 이 패턴을 사용합니다.
여러분이 이 패턴을 사용하면 라이브러리의 사용성이 극적으로 향상됩니다. 사용자는 몇 개의 핵심 타입만 알면 되고, 복잡한 내부 구조를 이해할 필요가 없습니다.
동시에 내부적으로는 모듈을 논리적으로 잘 나누어 유지보수하기 쉬운 구조를 유지할 수 있습니다.
실전 팁
💡 자주 사용되는 80%의 타입만 재수출하세요. 모든 것을 재수출하면 오히려 혼란스러워집니다. 덜 중요한 항목은 모듈 경로로 접근하게 두세요.
💡 prelude 모듈은 관례적으로 glob import(*)를 위한 것입니다. 이름 충돌이 없도록 신중하게 선택된 항목만 포함하세요.
💡 pub use as를 사용하면 재수출하면서 이름을 바꿀 수 있습니다. pub use internal::LongTypeName as Short처럼 간단한 별칭을 제공할 수 있습니다.
💡 문서 주석에서 재수출된 타입을 언급할 때는 원래 경로 대신 짧은 경로를 사용하세요. 사용자가 보는 것과 일치해야 합니다.
💡 cargo doc으로 생성된 문서에서 재수출된 항목이 어떻게 보이는지 확인하세요. #[doc(inline)]이나 #[doc(no_inline)]으로 문서 표시 방식을 제어할 수 있습니다.
7. 모듈 파일 구조와 가시성 - mod.rs vs 파일명.rs
시작하며
여러분이 프로젝트를 확장할 때 이런 혼란을 겪어본 적 있나요? 모듈을 파일로 분리하려는데, mod.rs를 써야 할지 파일명.rs를 써야 할지, 그리고 가시성은 어떻게 제어해야 할지 헷갈리는 상황 말이죠.
이런 문제는 실제 Rust 프로젝트 구조 설계에서 자주 만나는 혼란입니다. 파일 시스템 구조와 모듈 가시성이 어떻게 연결되는지 이해하지 못하면 복잡한 프로젝트를 관리하기 어렵습니다.
바로 이럴 때 필요한 것이 모듈 파일 구조 패턴의 이해입니다. Rust 2018 edition 이후의 새로운 방식과 가시성 제어를 결합하여 명확한 프로젝트 구조를 만들 수 있습니다.
개요
간단히 말해서, Rust는 두 가지 모듈 파일 구조 방식을 지원합니다. 구식(Rust 2015): module/mod.rs, 신식(Rust 2018): module.rs와 module/ 디렉토리 병행입니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 프로젝트가 커질수록 모듈을 논리적으로 그룹화하고 가시성을 체계적으로 관리해야 하기 때문입니다. 예를 들어, database 모듈 아래 connection, query, pool 서브모듈이 있고, 각각의 가시성을 제어하면서 파일로 깔끔하게 분리하고 싶은 경우에 필수적입니다.
기존에는 mod.rs가 필수였고 파일이 많아지면 여러 mod.rs가 혼재해서 헷갈렸다면, 이제는 module.rs 방식으로 더 직관적인 구조를 만들 수 있습니다. 모듈 파일 구조의 핵심 특징은 파일 시스템 반영, 계층적 조직화, 그리고 가시성 제어와의 통합입니다.
이러한 특징들이 대규모 프로젝트의 복잡도를 관리하는 열쇠입니다.
코드 예제
// 프로젝트 구조 (Rust 2018+ 방식)
// src/
// ├── lib.rs
// ├── database.rs # database 모듈 정의
// ├── database/ # database 서브모듈들
// │ ├── connection.rs
// │ ├── query.rs
// │ └── pool.rs
// └── api.rs
// src/lib.rs
pub mod database; // database.rs를 공개 모듈로
pub mod api; // api.rs를 공개 모듈로
// src/database.rs - 부모 모듈
pub mod connection; // database/connection.rs를 공개
pub mod query; // database/query.rs를 공개
mod pool; // database/pool.rs를 비공개 (내부 전용)
// 재수출로 간단한 API 제공
pub use connection::Connection;
pub use query::{Query, QueryBuilder};
// 내부 전용 유틸리티
pub(crate) fn internal_helper() -> bool {
pool::check_availability()
}
// src/database/connection.rs
pub struct Connection {
url: String,
}
impl Connection {
pub fn new(url: String) -> Self {
Connection { url }
}
}
// src/database/query.rs
pub struct Query { /* ... */ }
pub struct QueryBuilder { /* ... */ }
// src/database/pool.rs - 비공개 모듈
pub(super) fn check_availability() -> bool {
true // 부모(database) 모듈에서만 접근 가능
}
설명
이것이 하는 일: 파일 시스템 구조가 모듈 계층을 직접 반영하며, 각 파일에서 mod 선언과 가시성 지정자로 세밀한 접근 제어를 구현합니다. 첫 번째로, src/database.rs 파일이 database 모듈의 "루트" 역할을 합니다.
이 파일에서 pub mod connection, pub mod query로 서브모듈을 공개하고, mod pool로 내부 전용 서브모듈을 선언합니다. 이렇게 하는 이유는 서브모듈의 가시성을 부모 모듈에서 제어하기 위해서입니다.
그 다음으로, database/ 디렉토리 안의 각 파일(connection.rs, query.rs, pool.rs)은 해당 서브모듈의 내용을 정의합니다. pool.rs는 mod pool로만 선언되었으므로, database 모듈 외부에서는 접근할 수 없습니다.
하지만 internal_helper() 함수가 pool::check_availability()를 호출하는 것처럼 내부에서는 사용할 수 있습니다. pub use를 통해 database.rs에서 Connection과 Query를 재수출하면, 사용자는 use mylib::database::{Connection, Query}처럼 간단하게 접근할 수 있습니다.
실제로는 database::connection::Connection이지만, 이를 숨기고 깔끔한 API를 제공합니다. 여러분이 이 패턴을 사용하면 프로젝트 구조가 파일 탐색기에서 보는 것과 코드에서 사용하는 것이 일치하여 직관적입니다.
새로운 팀원이 프로젝트에 합류해도 파일 구조만 보면 모듈 계층을 이해할 수 있고, 각 모듈의 가시성도 명확하게 파악할 수 있습니다.
실전 팁
💡 Rust 2018+ 에디션을 사용 중이라면 mod.rs 대신 module.rs 방식을 사용하세요. 더 명확하고 파일 이름 충돌이 적습니다.
💡 서브모듈이 없는 단순한 모듈은 그냥 module.rs 파일 하나로 두고, 서브모듈이 필요해지면 그때 module/ 디렉토리를 만드세요.
💡 src/lib.rs 또는 src/main.rs에서 최상위 모듈들의 가시성을 결정하세요. 여기서 pub를 붙이지 않으면 완전히 내부 전용 모듈이 됩니다.
💡 #[path = "custom_path.rs"] 애트리뷰트로 파일 경로를 명시적으로 지정할 수 있지만, 특별한 이유가 없으면 관례를 따르는 것이 좋습니다.
💡 tests/, benches/, examples/ 디렉토리의 파일들은 별도의 크레이트로 취급되므로, pub로 공개된 항목만 접근할 수 있습니다. 통합 테스트 작성 시 주의하세요.
8. super와 crate 경로 - 상대 경로와 절대 경로
시작하며
여러분이 복잡한 모듈 구조에서 코드를 작성할 때 이런 불편함을 겪어본 적 있나요? 다른 모듈의 항목을 참조하려는데, use crate::very::long::path::to::item처럼 긴 경로를 써야 하거나, 현재 위치에서 어떻게 접근해야 할지 헷갈리는 상황 말이죠.
이런 문제는 실제 모듈 간 의존성 관리에서 자주 발생합니다. 절대 경로는 길고 번거롭고, 상대 경로는 어디를 가리키는지 헷갈립니다.
바로 이럴 때 필요한 것이 super와 crate 키워드입니다. 현재 모듈 기준으로 상대적 접근(super)과 크레이트 루트 기준 절대 접근(crate)을 적절히 조합하여 명확하고 간결한 코드를 작성할 수 있습니다.
개요
간단히 말해서, super는 부모 모듈을 의미하고(파일 시스템의 ..와 유사), crate는 크레이트 루트를 의미합니다(절대 경로의 시작점). 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모듈 간 참조를 명확하고 유지보수하기 쉽게 만들기 위해서입니다.
예를 들어, database/connection.rs에서 database/query.rs를 참조할 때, use super::query가 use crate::database::query보다 간결하고, 나중에 database 모듈을 다른 곳으로 이동해도 코드 수정이 적습니다. 기존에는 외부 크레이트와 같은 방식으로 use ::my_crate::module처럼 접근했다면, 이제는 crate::module로 더 명확하게 현재 크레이트를 표현할 수 있습니다.
super와 crate의 핵심 특징은 명확한 범위 지정, 리팩토링 안전성, 그리고 가독성 향상입니다. 이러한 특징들이 대규모 코드베이스의 유지보수를 쉽게 만듭니다.
코드 예제
// src/lib.rs
pub mod database;
pub mod api;
pub const APP_VERSION: &str = "1.0.0";
// src/database/mod.rs
pub mod connection;
pub mod query;
pub fn init() {
println!("Database module initialized");
}
// src/database/connection.rs
use super::query::Query; // 같은 부모(database) 아래 형제 모듈
use crate::APP_VERSION; // 크레이트 루트의 상수
pub struct Connection {
version: &'static str,
}
impl Connection {
pub fn new() -> Self {
super::init(); // 부모 모듈의 함수 호출
Connection {
version: APP_VERSION, // 크레이트 루트에서 가져온 상수
}
}
pub fn execute(&self) -> Query {
Query::new() // super::query::Query의 메서드
}
fn helper(&self) {
// self 모듈의 다른 함수는 경로 없이 호출
validate_connection();
}
}
fn validate_connection() {
println!("Validating connection");
}
// src/database/query.rs
pub struct Query { /* ... */ }
impl Query {
pub fn new() -> Self {
Query { /* ... */ }
}
}
// src/api/mod.rs
use crate::database::Connection; // 크레이트 루트부터 절대 경로
pub fn start_api() {
let conn = Connection::new();
}
설명
이것이 하는 일: super와 crate는 모듈 경로에서 현재 위치를 기준으로 명확한 참조점을 제공하여, 다른 모듈의 항목을 간결하고 정확하게 참조할 수 있게 합니다. 첫 번째로, connection.rs에서 use super::query::Query를 사용합니다.
이렇게 하는 이유는 connection과 query가 모두 database의 자식 모듈이므로, 부모(super)를 거쳐 형제를 찾는 것이 가장 간결하기 때문입니다. use crate::database::query::Query라고 쓸 수도 있지만, 더 길고 database 모듈이 이동하면 수정해야 합니다.
그 다음으로, use crate::APP_VERSION으로 크레이트 루트의 상수를 가져옵니다. APP_VERSION은 src/lib.rs에 정의되어 있는데, 어느 모듈에서든 crate::APP_VERSION으로 일관되게 접근할 수 있습니다.
crate는 항상 src/lib.rs(라이브러리) 또는 src/main.rs(바이너리)를 가리킵니다. super::init() 호출은 현재 모듈(connection)의 부모 모듈(database)에 있는 init() 함수를 실행합니다.
같은 모듈 내의 함수는 경로 없이(validate_connection()), 부모는 super::, 루트는 crate::로 구분하여 코드의 의도가 명확해집니다. 여러분이 이 패턴을 사용하면 코드 리팩토링이 훨씬 쉬워집니다.
database 모듈 전체를 다른 이름으로 변경하거나 다른 위치로 이동해도, super를 사용한 상대 경로는 수정할 필요가 없습니다. 반면 crate::를 사용한 절대 경로는 명확한 진입점을 제공하여 큰 프로젝트에서 항목의 위치를 빠르게 파악할 수 있게 합니다.
실전 팁
💡 같은 모듈 계층 내에서는 super를 사용하고, 완전히 다른 모듈 트리를 참조할 때는 crate를 사용하세요. 일관성이 가독성을 높입니다.
💡 super::super처럼 여러 단계 올라갈 수 있지만, 2단계 이상이면 crate::를 사용하는 것이 더 명확합니다.
💡 테스트 모듈(#[cfg(test)])에서는 super::를 사용하여 테스트 대상 모듈의 비공개 항목에 접근할 수 있습니다. 같은 파일에 있으므로 가능합니다.
💡 pub use와 결합하면 강력합니다. pub use super::parent_item처럼 부모의 항목을 자식에서 재수출할 수 있습니다.
💡 self::는 현재 모듈을 명시적으로 가리킵니다. use self::SubModule처럼 가끔 명확성을 위해 사용하지만, 보통은 생략합니다.
9. 공개 API 진화 전략 - 버전 관리와 deprecation
시작하며
여러분이 라이브러리를 장기간 유지보수할 때 이런 딜레마를 겪어본 적 있나요? 더 나은 API 디자인을 생각해냈는데, 기존 사용자의 코드를 깨뜨리지 않고 어떻게 전환해야 할지 고민되는 상황 말이죠.
이런 문제는 실제 오픈소스 프로젝트나 회사 내부 라이브러리에서 필수적으로 다뤄야 하는 주제입니다. 잘못 처리하면 사용자가 업그레이드를 꺼리거나, 반대로 너무 보수적이면 기술 부채가 쌓입니다.
바로 이럴 때 필요한 것이 API 진화 전략입니다. #[deprecated], 버전 관리, 점진적 마이그레이션 경로를 통해 기존 사용자를 존중하면서도 API를 개선할 수 있습니다.
개요
간단히 말해서, API 진화는 semantic versioning(SemVer)을 따르면서 #[deprecated] 애트리뷰트와 문서로 사용자에게 변경 사항을 안내하는 프로세스입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 생태계의 신뢰를 유지하면서 코드 품질을 향상시키기 위해서입니다.
예를 들어, 잘못 설계된 함수 이름을 바꾸고 싶을 때, 갑자기 제거하면 breaking change가 되지만, 1) 새 함수 추가(minor), 2) 옛 함수 deprecated(minor), 3) 다음 major 버전에서 제거하는 3단계 전략을 사용할 수 있습니다. 기존에는 "한 번 공개하면 영원히 유지"하거나 "과감하게 깨뜨리기"의 양극단이었다면, 이제는 점진적 전환으로 양쪽 모두를 만족시킬 수 있습니다.
API 진화 전략의 핵심 특징은 하위 호환성 존중, 명확한 마이그레이션 경로, 그리고 투명한 커뮤니케이션입니다. 이러한 특징들이 성숙한 라이브러리 생태계를 만듭니다.
코드 예제
// 버전 1.0.0 - 초기 API
pub fn parse_json(input: &str) -> Result<Value, Error> {
// 구현...
unimplemented!()
}
// 버전 1.5.0 - 더 나은 API 추가 + 기존 API deprecated
use std::path::Path;
/// JSON 파일을 파싱합니다.
///
/// 더 많은 옵션을 제공하는 [`parse_json_advanced`]를 사용하세요.
#[deprecated(
since = "1.5.0",
note = "대신 `parse_json_advanced`를 사용하세요. 더 나은 에러 처리를 제공합니다."
)]
pub fn parse_json(input: &str) -> Result<Value, Error> {
// 내부적으로 새 함수 호출
parse_json_advanced(input, ParseOptions::default())
.map_err(|e| e.into())
}
/// JSON을 파싱합니다 (개선된 버전).
///
/// # Examples
/// ```
/// let opts = ParseOptions::new().strict(true);
/// let value = parse_json_advanced(data, opts)?;
/// ```
pub fn parse_json_advanced(
input: &str,
options: ParseOptions,
) -> Result<Value, ParseError> {
// 개선된 구현
unimplemented!()
}
#[derive(Default)]
pub struct ParseOptions {
strict: bool,
}
impl ParseOptions {
pub fn new() -> Self {
Self::default()
}
pub fn strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
}
pub struct Value;
pub struct Error;
pub struct ParseError;
impl From<ParseError> for Error {
fn from(_: ParseError) -> Self {
Error
}
}
// 버전 2.0.0 - deprecated 함수 제거
// parse_json() 함수를 완전히 삭제
// CHANGELOG.md에 migration guide 제공
설명
이것이 하는 일: API 진화 전략은 기존 사용자의 코드를 존중하면서도, 명확한 경로를 통해 더 나은 API로 점진적으로 전환하게 만듭니다. 첫 번째로, 버전 1.5.0에서 parse_json_advanced라는 새로운 함수를 추가합니다.
이것은 breaking change가 아니므로 minor 버전 증가입니다. 동시에 기존 parse_json 함수에 #[deprecated] 애트리뷰트를 붙여서, 컴파일러가 사용자에게 경고를 보내게 합니다.
since와 note 매개변수로 언제부터 deprecated되었고 무엇을 사용해야 하는지 명확히 알려줍니다. 그 다음으로, deprecated된 parse_json은 내부적으로 새 함수를 호출하도록 구현합니다.
이렇게 하면 기존 코드는 계속 작동하면서, 유지보수는 새 함수만 하면 됩니다. 중복 로직이 생기지 않고, 버그 수정도 한 곳에서만 하면 됩니다.
ParseOptions는 builder 패턴을 사용하여 확장 가능한 API를 제공합니다. 나중에 새로운 옵션을 추가해도 기존 코드가 깨지지 않습니다.
이것도 API 진화를 고려한 설계입니다. 여러분이 이 전략을 사용하면 사용자는 충분한 시간을 갖고 마이그레이션할 수 있습니다.
1.5.0에서는 경고만 보고 계속 사용할 수 있고, 2.0.0 출시 전에 충분한 공지 기간을 두고 전환할 수 있습니다. Rust 생태계의 대부분 성숙한 라이브러리들이 이 패턴을 따르고 있습니다.
실전 팁
💡 CHANGELOG.md를 꼼꼼히 작성하세요. 각 버전에서 무엇이 추가/변경/deprecated/제거되었는지 명확히 기록하면 사용자가 업그레이드를 계획하기 쉽습니다.
💡 #[deprecated]만 붙이지 말고, note에 구체적인 마이그레이션 방법을 적으세요. "대신 X를 사용하세요"보다 "X::new().option(value) 패턴으로 전환하세요"가 훨씬 도움이 됩니다.
💡 major 버전 0.x.y는 SemVer에서 특별합니다. 0.y.z에서 y 증가는 breaking change를 허용하므로, 초기 개발 단계에서는 더 자유롭게 API를 실험할 수 있습니다.
💡 cargo-semver-checks 같은 도구로 실수로 breaking change를 만들지 않았는지 자동 검사할 수 있습니다. CI에 통합하세요.
💡 문서에 "Stability" 섹션을 추가하여 어떤 API가 안정적이고 어떤 것이 실험적인지 명시하세요. #[doc(cfg(feature = "unstable"))]로 불안정한 기능을 표시할 수 있습니다.
10. cfg와 조건부 컴파일의 가시성 - 플랫폼별 API
시작하며
여러분이 크로스 플랫폼 라이브러리를 만들 때 이런 상황을 겪어본 적 있나요? 특정 기능이 Windows에서만 또는 Unix에서만 필요한데, 모든 플랫폼에 공개하면 컴파일 에러가 발생하는 경우 말이죠.
이런 문제는 실제 시스템 프로그래밍이나 크로스 플랫폼 라이브러리 개발에서 필수적으로 다뤄야 하는 부분입니다. 플랫폼마다 다른 API를 제공하면서도 일관된 사용자 경험을 유지해야 합니다.
바로 이럴 때 필요한 것이 #[cfg]와 가시성의 결합입니다. 조건부 컴파일과 pub를 함께 사용하여 플랫폼별, 피처별 API를 안전하게 설계할 수 있습니다.
개요
간단히 말해서, #[cfg]는 특정 조건에서만 코드를 컴파일하는 애트리뷰트이고, pub와 결합하면 조건부로 공개되는 API를 만들 수 있습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 다양한 환경에서 최적화된 API를 제공하면서도 불필요한 코드를 제거하기 위해서입니다.
예를 들어, Unix의 file descriptor 관련 함수는 Windows에서는 의미가 없으므로, cfg(unix)로 Unix에서만 공개할 수 있습니다. 기존에는 모든 플랫폼에서 컴파일되는 공통 API만 제공하거나, 플랫폼별 크레이트를 따로 만들었다면, 이제는 하나의 크레이트에서 조건부 API를 제공할 수 있습니다.
조건부 가시성의 핵심 특징은 플랫폼 최적화, 컴파일 타임 검증, 그리고 명확한 API 경계입니다. 이러한 특징들이 크로스 플랫폼 라이브러리의 품질을 결정합니다.
코드 예제
// Unix 전용 API
#[cfg(unix)]
pub mod unix {
use std::os::unix::io::RawFd;
/// Unix file descriptor를 사용하는 함수
pub fn from_raw_fd(fd: RawFd) -> File {
File { fd }
}
pub struct File {
fd: RawFd,
}
}
// Windows 전용 API
#[cfg(windows)]
pub mod windows {
use std::os::windows::io::RawHandle;
/// Windows handle을 사용하는 함수
pub fn from_raw_handle(handle: RawHandle) -> File {
File { handle }
}
pub struct File {
handle: RawHandle,
}
}
// 공통 API - 모든 플랫폼에서 사용 가능
pub struct CommonFile {
path: String,
}
impl CommonFile {
pub fn open(path: &str) -> Self {
CommonFile {
path: path.to_string(),
}
}
// 플랫폼별 확장 메서드
#[cfg(unix)]
pub fn as_unix_file(&self) -> Option<&unix::File> {
// Unix에서만 컴파일됨
None
}
#[cfg(windows)]
pub fn as_windows_file(&self) -> Option<&windows::File> {
// Windows에서만 컴파일됨
None
}
}
// 피처 플래그 기반 API
#[cfg(feature = "advanced")]
pub mod advanced {
/// advanced 피처가 활성화되었을 때만 제공
pub fn experimental_feature() -> String {
"This is experimental".to_string()
}
}
// 문서에서 조건 표시
#[cfg_attr(docsrs, doc(cfg(unix)))]
#[cfg(unix)]
pub fn unix_only_function() {
// docs.rs에서 "This is supported on Unix only" 표시
}
설명
이것이 하는 일: #[cfg]와 pub를 결합하여 특정 조건(플랫폼, 피처, 빌드 설정)에서만 공개되는 모듈, 함수, 타입을 만듭니다. 첫 번째로, unix 모듈은 #[cfg(unix)]로 감싸져 있어서 Unix 계열 시스템(Linux, macOS, BSD 등)에서만 컴파일되고 공개됩니다.
이렇게 하는 이유는 RawFd 같은 Unix 특화 타입을 Windows에서는 사용할 수 없기 때문입니다. Windows에서 컴파일하면 이 모듈 자체가 존재하지 않으므로, 실수로 사용하려 하면 컴파일 에러가 발생합니다.
그 다음으로, CommonFile은 모든 플랫폼에서 공개되는 공통 API입니다. 하지만 as_unix_file()과 as_windows_file() 메서드는 각각 해당 플랫폼에서만 존재합니다.
이렇게 하면 공통 인터페이스를 유지하면서도, 플랫폼별 고급 기능에 접근할 수 있습니다. #[cfg(feature = "advanced")]는 Cargo.toml의 features를 기반으로 조건부 컴파일합니다.
사용자가 cargo build --features advanced를 실행했을 때만 experimental_feature()가 제공됩니다. 이렇게 하면 선택적 의존성을 줄이고, 기본 빌드를 가볍게 유지할 수 있습니다.
여러분이 이 패턴을 사용하면 하나의 라이브러리로 여러 환경을 지원하면서도, 각 환경에 최적화된 API를 제공할 수 있습니다. 사용자는 자신의 플랫폼에서만 의미 있는 함수를 호출할 수 있고, 컴파일러가 잘못된 사용을 방지해줍니다.
실전 팁
💡 cfg(target_os = "linux")는 Linux만, cfg(unix)는 모든 Unix 계열을 의미합니다. 가능한 넓은 범위를 지원하되, 정말 특정 OS가 필요할 때만 target_os를 사용하세요.
💡 #[cfg_attr(docsrs, doc(cfg(...)))]를 사용하면 docs.rs에서 "This is supported on ... only" 배지가 표시되어 사용자가 플랫폼 제한을 쉽게 알 수 있습니다.
💡 피처 플래그는 Cargo.toml에서 정의하세요. default = ["std"]처럼 기본 피처를 지정하고, optional 의존성을 피처와 연결할 수 있습니다.
💡 cfg!(unix)는 런타임 체크입니다. 조건부 컴파일이 아닌 조건부 실행이 필요할 때 사용하세요. if cfg!(unix) { ... }처럼 쓸 수 있습니다.
💡 test, debug_assertions 같은 내장 cfg도 유용합니다. #[cfg(test)]로 테스트 전용 pub(crate) 함수를 만들면 프로덕션 코드 크기를 줄일 수 있습니다.