Rust 프로젝트를 나누는 단위는 하나가 아닙니다. mod를 새로 만드는 일과 Cargo 패키지를 쪼개는 일은 비용도 효과도 다릅니다. 이 구분을 놓치면 파일 몇 개를 정리하려다가 매니페스트와 의존성까지 늘어나거나, 반대로 한 크레이트 안에서 책임이 뒤엉킵니다.
이 글의 예제는 도메인 규칙, 애플리케이션 유스케이스, CLI 어댑터를 세 패키지로 나눈 Rust 2024 워크스페이스입니다. 이 계층은 Cargo가 강제하는 아키텍처가 아닙니다. Cargo의 크레이트 의존성을 이용해 바깥쪽 코드가 안쪽을 향하도록 만든 설계 선택입니다.
1. 패키지, 크레이트, 모듈, 워크스페이스
먼저 네 용어부터 구분해 두겠습니다.
- **크레이트(crate)**는 Rust 컴파일 단위입니다. 라이브러리 크레이트나 실행 가능한 바이너리 크레이트가 됩니다.
- **패키지(package)**는 한
Cargo.toml이 설명하는 크레이트 묶음입니다. 적어도 한 크레이트가 있어야 하며 라이브러리 크레이트는 최대 하나, 바이너리 크레이트는 여러 개 둘 수 있습니다. - **모듈(module)**은 한 크레이트 안에서 경로, 범위, 공개 여부를 구성합니다. 파일을 나누더라도 컴파일 패키지가 새로 생기는 것은 아닙니다.
- **워크스페이스(workspace)**는 함께 관리하는 여러 패키지의 집합입니다. 멤버는
Cargo.lock과 출력 디렉터리를 공유하며 루트에서 전체 멤버에 Cargo 명령을 실행할 수 있습니다.
예제의 endpoint-cli 패키지는 src/lib.rs와 src/main.rs를 함께 둡니다. 패키지는 하나지만 Cargo가 라이브러리 크레이트와 바이너리 크레이트를 각각 만듭니다. 바이너리는 하이픈을 밑줄로 바꾼 크레이트 이름 endpoint_cli로 라이브러리를 가져옵니다. 패키지와 크레이트를 같은 말처럼 쓰면 이 구조를 설명하기 어렵습니다.
전체 예제는 examples/article-16-cargo-workspace에 있으며 다른 예제나 공유 endpoint-monitor에 의존하지 않습니다. 외부 크레이트도 사용하지 않습니다.
2. 가상 워크스페이스와 상속
루트 Cargo.toml에는 [package]가 없습니다. 이런 매니페스트를 가상 매니페스트라고 부릅니다. 루트 자체를 빌드 가능한 패키지로 꾸미지 않고 멤버와 공통 설정만 관리합니다.
[workspace]
members = [
"crates/domain",
"crates/application",
"crates/adapter-cli",
]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = "warn"
pedantic = "warn"
resolver = "3"을 명시해 Rust 2024에 맞는 의존성 리졸버를 선택했습니다. [workspace.package]와 [workspace.lints]는 공통값을 정의할 뿐, 멤버가 자동으로 상속하지는 않습니다. 각 패키지가 edition.workspace = true와 [lints] workspace = true처럼 상속을 선택해야 합니다.
애플리케이션 패키지의 매니페스트는 다음처럼 작습니다.
[package]
name = "endpoint-application"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
endpoint-domain = { path = "../domain" }
[lints]
workspace = true
워크스페이스는 하나의 패키지로 합치는 기능이 아닙니다. cargo metadata --format-version 1 --no-deps 결과에는 세 패키지가 따로 나타났고 endpoint-cli 패키지에는 라이브러리와 바이너리, 두 타깃이 잡혔습니다. 공유 잠금 파일과 공통 명령은 운영 편의이고 크레이트 경계는 그대로 남습니다.
3. 모듈의 내부 구조와 공개 API
도메인 크레이트의 루트는 두 줄뿐입니다.
mod monitor;
pub use monitor::{Endpoint, EndpointError};
mod monitor;는 모듈을 선언하지만 외부에 모듈 경로를 공개하지 않습니다. Rust 항목은 기본적으로 비공개입니다. 크레이트 루트는 필요한 타입만 pub use로 다시 내보내므로 사용자는 endpoint_domain::Endpoint를 쓰고 내부 파일 배치에는 결합하지 않습니다.
pub는 무조건 전 세계에 보인다는 단순한 스위치도 아닙니다. 외부에서 항목에 도달하려면 경로의 공개 조건을 충족해야 합니다. 범위를 더 좁혀야 한다면 pub(crate)로 현재 크레이트까지만 pub(super)로 부모 모듈까지만 열 수 있습니다. 먼저 비공개로 두고 실제 호출자가 필요한 가장 작은 범위만 여는 편이 경계를 읽기 쉽습니다.
모듈을 파일과 일대일로 맞춰야 한다는 규칙은 없습니다. 모듈 트리는 API와 이름 공간을 설계합니다. 파일 트리는 소스를 배치합니다. 둘은 자주 비슷하지만 같은 개념은 아닙니다.
4. 안쪽을 향하는 의존성
예제의 컴파일 의존성은 다음 한 방향입니다.
endpoint-cli (adapter + binary)
-> endpoint-application (use case + port)
-> endpoint-domain (business rules)
endpoint-domain은 엔드포인트 이름과 URL 규칙을 소유합니다. I/O나 저장 방식은 모릅니다. endpoint-application은 등록 유스케이스를 조정하고 저장 포트를 정의합니다. endpoint-cli는 메모리 저장소를 구현합니다. 입력과 출력도 이 패키지의 책임입니다. main.rs는 구현을 조립합니다.
애플리케이션의 핵심 경계는 trait과 유스케이스입니다.
pub trait MonitorRegistry {
fn insert(&mut self, endpoint: Endpoint) -> u64;
}
pub struct RegisterMonitor;
impl RegisterMonitor {
pub fn execute(
registry: &mut impl MonitorRegistry,
request: RegisterRequest,
) -> Result<RegisterResult, RegisterError> {
let endpoint = Endpoint::new(request.name, request.url)?;
let result = RegisterResult {
id: 0,
name: endpoint.name().to_owned(),
url: endpoint.url().to_owned(),
};
let id = registry.insert(endpoint);
Ok(RegisterResult { id, ..result })
}
}
포트가 애플리케이션 쪽에 있으므로 유스케이스는 구체 저장소를 가져오지 않습니다. 어댑터가 안쪽의 trait을 구현합니다. 테스트에서는 같은 포트를 가짜 저장소로 구현해 파일이나 네트워크 없이 유스케이스를 검사합니다.
어댑터 패키지의 라이브러리와 바이너리도 역할을 나눴습니다. src/lib.rs는 재사용하고 테스트할 수 있는 InMemoryRegistry를 노출합니다. src/main.rs는 프로세스 시작점과 출력만 맡습니다.
use endpoint_application::{RegisterMonitor, RegisterRequest};
use endpoint_cli::InMemoryRegistry;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut registry = InMemoryRegistry::default();
let registered = RegisterMonitor::execute(
&mut registry,
RegisterRequest {
name: "api".into(),
url: "https://example.com/health".into(),
},
)?;
println!(
"registered monitor #{}: {} -> {}",
registered.id, registered.name, registered.url
);
println!("registry size: {}", registry.len());
Ok(())
}
이 lib/bin 분리는 모든 바이너리에 필요한 의식이 아닙니다. 조립 코드가 몇 줄이고 재사용할 어댑터도 없다면 main.rs 하나면 충분합니다. 여기서는 패키지 하나가 여러 크레이트를 가질 수 있다는 점과, 실행 진입점을 얇게 유지하는 방법을 함께 보여주려고 나눴습니다.
5. 모듈과 크레이트의 분리 기준
기본 선택은 모듈입니다. 코드가 같은 릴리스 주기와 기능 플래그를 따르고 내부 타입을 긴밀하게 공유한다면 크레이트를 추가해도 얻는 격리가 적습니다. 모듈의 비공개 기본값과 제한 공개만으로도 상당한 경계를 만들 수 있습니다.
다음 조건에서는 크레이트 분리를 검토할 만합니다.
- 의존성 방향을 Cargo 그래프로 확인하려는 경우
- 도메인 코드를 CLI, 서버, 배치 같은 여러 진입점에서 재사용하는 경우
- 계층마다 허용할 의존성이나 린트 정책이 다른 경우
- 독립적인 테스트·빌드 단위가 실제 작업 흐름에 도움이 되는 경우
반대로 코드가 작고 경계가 자주 이동하거나 두 영역이 서로의 내부 타입을 계속 요구한다면 아직 나눌 시점이 아닙니다. 새 크레이트는 Cargo.toml, 공개 API, 크레이트 간 타입 경로, 의존성 관리를 추가합니다. 폴더를 깔끔하게 보이게 하려는 이유만으로 지불하기에는 큰 비용입니다. 먼저 모듈로 책임을 찾습니다. 안정된 의존성 경계가 보일 때 패키지로 올리는 순서가 안전합니다.
워크스페이스도 패키지가 둘 이상이라는 이유만으로 꼭 필요하지는 않습니다. 함께 빌드하고 같은 잠금 파일과 정책을 공유할 패키지일 때 효과가 있습니다. 서로 별도로 버전과 배포를 관리하는 프로젝트라면 저장소나 워크스페이스까지 억지로 묶을 필요가 없습니다.
6. 실행과 테스트
다음 명령은 워크스페이스 루트에서 실행합니다.
cd examples/article-16-cargo-workspace
cargo metadata --format-version 1 --no-deps
cargo fmt --check
cargo check --workspace --all-targets --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo run --quiet -p endpoint-cli
stable rustc 1.98.1, Cargo 1.98.1, Rust 2024에서 모든 명령이 종료 코드 0을 반환해야 합니다. 워크스페이스 테스트에는 도메인, 애플리케이션, 어댑터 라이브러리의 테스트가 각각 1개씩, 모두 3개 있습니다. 바이너리와 문서 테스트에는 테스트가 없습니다.
실행 출력은 다음과 같습니다.
registered monitor #1: api -> https://example.com/health
registry size: 1
이 예제에서 세 패키지는 폴더 장식이 아닙니다. 도메인은 바깥 계층을 모릅니다. 애플리케이션은 저장 구현을 모르며 어댑터만 안쪽 크레이트를 조립합니다. 규모가 이 정도로 작다면 모듈 세 개로 시작해도 합리적입니다. 분리의 근거는 코드 줄 수가 아니라 지키려는 의존성 규칙이어야 합니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 16 프로젝트에서 확인할 수 있습니다.
출처
- The Rust Programming Language: Packages and Crates
- The Rust Programming Language: Defining Modules to Control Scope and Privacy
- The Rust Programming Language: Paths for Referring to an Item in the Module Tree
- The Cargo Book: Workspaces
- The Cargo Book: Cargo Targets
- The Cargo Book: The Manifest Format
- The Rust Reference: Visibility and Privacy
답글 남기기