Rust pub, crate, self, super: 모듈 경계와 공개 범위 이해하기
Rust에서 모듈 시스템의 큰 그림을 잡고 파일을 나누는 mod, 경로를 가져오는 use까지 익히고 나면 다음으로 부딪히는 벽은 공개 범위입니다.
분명 함수에 pub을 붙였는데 외부에서 안 보이거나, 반대로 내부 구현까지 밖으로 드러나서 API가 지저분해지는 일이 생기죠.
여기에 crate::, self::, super::, pub(crate), pub use까지 섞이면 머릿속 모듈 지도가 금방 흐려집니다.
이번 글에서는 Rust 모듈 시스템에서 공개 범위가 어떻게 결정되는지 정리해보겠습니다. 핵심은 “항목이 공개인가?”만 보는 게 아니라, 그 항목까지 가는 경로 전체가 공개 가능한가를 함께 봐야 한다는 점입니다.
기본은 비공개입니다
Rust의 모듈 안에 있는 항목은 기본적으로 비공개(private)입니다. 부모 모듈은 자식 모듈 안의 비공개 항목을 마음대로 사용할 수 없고, 외부 크레이트는 더더욱 볼 수 없습니다.
mod config {
fn default_port() -> u16 {
8080
}
}
fn main() {
let port = config::default_port();
println!("{port}");
}
이 코드는 컴파일되지 않습니다.
config 모듈은 보이지만, 그 안의 default_port() 함수가 비공개이기 때문입니다.
부모에서 호출하려면 함수에 pub을 붙여야 합니다.
mod config {
pub fn default_port() -> u16 {
8080
}
}
fn main() {
let port = config::default_port();
println!("{port}");
}
여기서 pub은 “이 항목을 부모 모듈 쪽으로 공개한다”는 뜻에 가깝습니다.
같은 크레이트 안에서는 부모가 자식의 공개 항목에 접근할 수 있습니다.
하지만 라이브러리의 외부 사용자까지 접근할 수 있는지는 한 단계 더 봐야 합니다.
경로 전체가 열려 있어야 합니다
라이브러리 크레이트를 예로 들어보겠습니다.
mod config;
pub fn default_port() -> u16 {
8080
}
default_port()는 pub입니다.
그런데 외부 크레이트에서 my_crate::config::default_port()로 접근할 수 있을까요?
안 됩니다.
config 모듈 자체가 mod config;로 비공개 선언되어 있기 때문입니다.
경로로 접근하려면 중간에 있는 모듈도 공개되어야 합니다.
pub mod config;
이제 my_crate::config::default_port() 경로가 열립니다.
다만 모든 모듈을 pub mod로 열어두는 방식은 조심해야 합니다.
외부 사용자가 내부 파일 구조에 의존하게 되면 나중에 파일을 옮기거나 모듈을 합치는 작업도 파괴적 변경이 될 수 있습니다.
그래서 라이브러리에서는 내부 모듈은 숨기고 필요한 항목만 재공개하는 패턴을 자주 씁니다.
pub use로 공개 API 만들기
pub use는 어떤 항목을 다른 경로로 다시 공개하는 기능입니다.
덕분에 내부 파일 구조는 숨기면서, 외부 사용자에게는 단순한 API를 제공할 수 있습니다.
mod config;
pub use config::Config;
pub struct Config {
pub port: u16,
}
외부 사용자는 이제 my_crate::Config로 타입을 가져올 수 있습니다.
config 모듈은 여전히 비공개라서 my_crate::config::Config 경로는 열리지 않습니다.
내부 파일 이름을 API로 노출하지 않는 셈이죠.
이 패턴은 규모가 있는 라이브러리에서 특히 유용합니다.
예를 들어 내부적으로 client, error, request, response 모듈이 있어도, 사용자가 자주 쓰는 타입은 lib.rs에서 한 번에 재공개할 수 있습니다.
mod client;
mod error;
mod request;
pub use client::Client;
pub use error::{Error, Result};
pub use request::RequestBuilder;
이렇게 하면 사용자는 내부 구조를 몰라도 됩니다.
use my_crate::{Client, RequestBuilder};
pub use는 단순히 짧은 이름을 만드는 것 이상입니다.
라이브러리의 공개 표면을 설계하는 도구입니다.
이미 공개한 경로는 사용자의 코드가 의존하기 시작하므로, non_exhaustive 속성으로 깨지지 않는 API 만들기에서 다룬 것처럼 공개 API는 신중하게 관리해야 합니다.
pub(crate), pub(super), pub(in …)
모든 공개가 외부 크레이트까지 열려야 하는 것은 아닙니다. Rust는 공개 범위를 더 좁게 지정할 수 있습니다.
pub(crate)는 현재 크레이트 안에서만 공개합니다.
pub(crate) fn load_from_env() -> Config {
Config { port: 8080 }
}
애플리케이션 내부 여러 모듈에서 공유하지만 외부 API로 내보내고 싶지 않은 함수에 잘 맞습니다. 라이브러리에서도 내부 구현 세부 사항을 크레이트 안에서만 공유할 때 유용합니다.
pub(super)는 부모 모듈까지만 공개합니다.
mod api {
mod auth {
pub(super) fn check_token(token: &str) -> bool {
!token.is_empty()
}
}
pub fn handle_request(token: &str) {
if auth::check_token(token) {
println!("ok");
}
}
}
check_token()은 api 모듈에서는 보이지만, 그 바깥에서는 보이지 않습니다.
자식 모듈의 작은 helper를 부모에서만 쓰게 하고 싶을 때 적당합니다.
더 세밀하게는 pub(in path)도 있습니다.
mod api {
pub mod users {
pub(in crate::api) fn normalize_path(path: &str) -> String {
path.trim_matches('/').to_string()
}
}
}
이 함수는 crate::api 경로 안에서만 사용할 수 있습니다.
자주 필요한 문법은 아니지만, 큰 프로젝트에서 특정 하위 시스템 안에만 공개하고 싶을 때 선택지가 됩니다.
crate, self, super는 경로의 출발점입니다
pub 계열이 공개 범위를 다룬다면, crate, self, super는 경로를 어디서 시작할지 정합니다.
crate::는 현재 크레이트 루트에서 시작합니다.
use crate::config::Config;
절대 경로라서 파일 위치가 바뀌어도 의미가 비교적 안정적입니다. 현재 크레이트의 핵심 타입을 가져올 때 자주 씁니다.
self::는 현재 모듈에서 시작합니다.
mod model {
pub struct User;
}
use self::model::User;
짧은 예제에서는 생략해도 되는 경우가 많지만, 매크로나 복잡한 경로에서 현재 모듈 기준임을 분명히 하고 싶을 때 도움이 됩니다.
super::는 부모 모듈에서 시작합니다.
mod service {
pub struct AppState;
pub mod users {
use super::AppState;
pub fn list_users(_state: &AppState) {}
}
}
테스트 모듈에서 use super::*;를 쓰는 이유도 같습니다.
테스트 모듈은 보통 테스트 대상 코드의 자식 모듈이기 때문에, 부모에 있는 함수와 타입을 super로 가져옵니다.
fn is_valid_name(name: &str) -> bool {
!name.trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::is_valid_name;
#[test]
fn empty_name_should_be_invalid() {
assert!(!is_valid_name(" "));
}
}
use super::*;처럼 별표로 한꺼번에 가져오는 방식도 가능하지만, 테스트가 커지면 필요한 이름만 명시하는 편이 읽기 쉽습니다.
어떤 함수가 테스트 대상인지 바로 보이기 때문입니다.
공개 모듈과 내부 모듈 나누기
실무에서는 “파일을 어떻게 나눌까?”보다 “무엇을 공개 API로 삼을까?”가 더 중요해지는 순간이 옵니다. 모듈은 코드를 정리하는 도구이면서 동시에 API 경계를 만드는 도구이기 때문입니다.
예를 들어 HTTP 클라이언트 라이브러리를 만든다고 해보겠습니다.
src/
├── lib.rs
├── client.rs
├── error.rs
└── transport.rs
transport는 내부 구현이고, Client와 Error만 외부에 공개하고 싶다면 이렇게 구성할 수 있습니다.
mod client;
mod error;
mod transport;
pub use client::Client;
pub use error::{Error, Result};
use crate::transport::send;
pub struct Client {
base_url: String,
}
impl Client {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
pub fn get(&self, path: &str) -> crate::error::Result<String> {
send(&self.base_url, path)
}
}
pub(crate) fn send(base_url: &str, path: &str) -> crate::error::Result<String> {
Ok(format!("{base_url}/{path}"))
}
transport::send()는 pub(crate)입니다.
크레이트 내부의 client 모듈에서는 사용할 수 있지만, 외부 사용자는 볼 수 없습니다.
사용자에게 필요한 것은 Client::get()이지, 내부 전송 함수가 아니니까요.
이렇게 설계하면 나중에 transport.rs를 http.rs로 바꾸거나 구현을 통째로 갈아엎어도 외부 API는 그대로 유지할 수 있습니다.
모듈 공개 범위를 좁히는 것은 단순한 캡슐화가 아니라, 유지보수 비용을 줄이는 장치입니다.
마치며
Rust 모듈 시스템에서 공개 범위는 기본적으로 닫혀 있습니다.
필요한 항목에 pub을 붙이고, 경로 중간의 모듈도 공개되어야 외부에서 접근할 수 있습니다.
다만 모든 것을 pub mod로 열어두기보다, 내부 모듈은 숨기고 pub use로 안정적인 공개 API를 만드는 편이 보통 더 좋습니다.
pub(crate)와 pub(super)는 “외부에는 숨기되 내부에서는 공유하고 싶다”는 요구를 표현할 수 있게 해줍니다.
crate::, self::, super::는 그 공개 범위 안에서 경로를 어디서 시작할지 정해주고요.
이 조합을 이해하면 Rust 프로젝트 구조가 훨씬 덜 우연적으로 보입니다.
파일 배치가 아니라 경계 설계로 모듈을 바라볼 수 있게 되니까요.
더 자세한 규칙은 Rust Reference의 visibility and privacy 문서를 참고하세요.
This work is licensed under
CC BY 4.0