Cargo: 프로젝트 생성, 의존성 관리, 워크스페이스 구성
1. 개요
Cargo는 Rust의 공식 빌드 도구이자 패키지 관리자입니다. 프로젝트를 생성하고, 의존성을 내려받으며, 컴파일하고, 테스트를 실행하며, 같은 저장소 내에서 여러 크레이트를 관리합니다.
이 글에서는 Cargo로 매일 수행하는 세 가지 작업을 다룹니다. 첫째는 바이너리 프로젝트와 라이브러리 생성입니다. 둘째는 의존성 추가 및 구성입니다. 셋째는 여러 크레이트로 구성된 워크스페이스를 정리하는 것입니다. 이 글을 읽고 나면, 여러 모듈로 이루어진 Rust 프로젝트를 구축할 수 있으며, 의존성 버전을 곳곳에 복사할 필요가 없습니다.
2. 새 프로젝트 생성
2.1. 바이너리와 라이브러리
cargo new 명령어는 새로운 크레이트를 생성합니다. 기본적으로 main 함수를 가진 바이너리 크레이트입니다:
cargo new order-service
cd order-service
생성된 디렉토리 구조:
order-service/
├── Cargo.toml
├── .gitignore
└── src/
└── main.rs
다른 크레이트에서 사용할 라이브러리를 작성하려면 --lib 플래그를 추가하세요:
cargo new pricing --lib
이 경우 Cargo는 src/main.rs 대신 src/lib.rs를 생성합니다. 하나의 크레이트는 두 파일을 모두 가질 수 있습니다. 이 경우 main.rs는 크레이트 이름을 통해 lib.rs의 로직을 호출합니다.
2.2. Cargo.toml 파일
Cargo.toml은 크레이트의 manifest 파일입니다:
[package]
name = "order-service"
version = "0.1.0"
edition = "2024"
[dependencies]
name: 크레이트의 이름입니다. 이름의-기호는 코드에서 사용할 때_가 됩니다. 예를 들어order_service입니다.version: Semantic Versioning에 따른 버전입니다.edition: 언어 판본입니다. Edition 2024는 Rust 1.85부터 사용 가능하며, 현재 도구 체인에서cargo new의 기본값입니다.[dependencies]: 외부 크레이트의 목록이며, 현재 비어있습니다.
2.3. 기본 빌드 명령어
cargo check # 컴파일 오류를 확인하며, 실행 파일을 생성하지 않습니다
cargo build # target/debug에 디버그 버전을 빌드합니다
cargo run # 빌드한 후 실행합니다
cargo build --release # target/release에 최적화 버전을 빌드합니다
코드를 작성할 때는 cargo check를 사용해야 합니다. 이 명령어는 기계 코드 생성 단계를 건너뛰기 때문에 cargo build보다 훨씬 빠릅니다. 성능을 측정하거나 배포할 때만 --release를 실행하세요. 디버그 버전은 릴리스 버전보다 수십 배 느릴 수 있습니다.
3. 의존성 추가
3.1. cargo add 사용
Order를 JSON으로 직렬화해야 한다고 가정해봅시다. 이를 위한 두 가지 일반적인 크레이트는 serde와 serde_json입니다:
cargo add serde --features derive
cargo add serde_json
Cargo가 Cargo.toml을 자동으로 수정합니다:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
이 파일을 수동으로 수정할 수 있습니다. cargo add는 오타를 피하고 호환되는 최신 버전을 자동으로 선택하는 데 도움을 줄 뿐입니다.
3.2. 버전 요구사항의 의미
문자열 "1.0"은 정확히 버전 1.0.0을 의미하지 않습니다. Cargo는 이를 ^1.0으로 해석하며, 즉 >= 1.0.0이고 < 2.0.0인 모든 버전입니다. 다른 기재 방식:
[dependencies]
regex = "1.10" # >= 1.10.0, < 2.0.0
chrono = "=0.4.38" # 정확히 0.4.38
rand = "~0.8.5" # >= 0.8.5, < 0.9.0
uuid = ">=1.4, <1.9" # 사용자 정의 범위
0.x 버전의 경우, Cargo는 두 번째 숫자를 “주(major)” 부분으로 간주합니다. 따라서 "0.4"는 0.4.*만 허용하고 0.5로 업그레이드하지 않습니다.
실제로 사용되는 버전은 Cargo.lock에 있습니다. Cargo는 첫 번째 빌드에서 이 파일을 작성합니다. 이후 빌드는 다음을 실행할 때까지 정확히 그 버전을 사용합니다:
cargo update # 허용 범위 내의 모든 의존성을 업그레이드합니다
cargo update -p serde # serde만 업그레이드합니다
3.3. 코드에서 의존성 사용
src/main.rs를 수정하세요:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Order {
id: u64,
customer: String,
total_cents: u64,
}
fn main() {
let order = Order {
id: 42,
customer: "An".to_string(),
total_cents: 150_000,
};
let json = serde_json::to_string(&order).expect("serialize Order");
println!("{json}");
let parsed: Order = serde_json::from_str(&json).expect("deserialize Order");
println!("{parsed:?}");
}
#[derive(Serialize, Deserialize)]는serde의derive기능을 활성화했을 때만 가능합니다. 이 기능이 없으면 코드에서 매크로를 찾을 수 없다는 오류가 발생합니다.serde_json::to_string은 구조체를 JSON 문자열로 변환합니다.serde_json::from_str은 반대 방향으로 작동합니다. 대상 타입은Order주석에서 추론됩니다.
cargo run을 실행하세요:
{"id":42,"customer":"An","total_cents":150000}
Order { id: 42, customer: "An", total_cents: 150000 }
3.4. 다른 종류의 의존성
[dependencies] 외에도 Cargo에는 두 가지 일반적인 그룹이 있습니다:
[dev-dependencies]
pretty_assertions = "1.4" # 테스트, 예제, 벤치마크를 실행할 때만 사용
[build-dependencies]
cc = "1.0" # build.rs에서만 사용
[dev-dependencies]의 크레이트는 최종 바이너리에 포함되지 않으며 라이브러리 사용자에게 영향을 주지 않습니다. 테스트만을 위한 라이브러리를 여기에 배치하세요.
의존성을 로컬 디렉토리 또는 Git으로 가리킬 수도 있습니다:
[dependencies]
pricing = { path = "../pricing" }
payment-sdk = { git = "https://example.com/payment-sdk.git", tag = "v2.1.0" }
4. 워크스페이스 구성
프로젝트가 커지면 여러 크레이트로 분할하고 싶을 것입니다. 예를 들어, domain 크레이트는 비즈니스 로직을 포함하고, api 크레이트는 실행하는 프로그램입니다. 워크스페이스를 사용하면 이러한 크레이트가 Cargo.lock, target/ 디렉토리 및 의존성 버전을 공유할 수 있습니다.
4.1. 루트 워크스페이스 생성
mkdir shop && cd shop
shop/Cargo.toml 생성:
[workspace]
resolver = "3"
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
edition = "2024"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
- 이 파일은
[package]가 없으므로 가상 manifest입니다. 하위 크레이트를 모으기 위해서만 사용됩니다. resolver = "3"은 가상 manifest에서 명시적으로 선언해야 합니다.[package]가 없으면 Cargo는 edition에서 resolver를 추론할 수 없으며, 이 줄을 생략하면 경고를 표시합니다.members = ["crates/*"]는crates/디렉토리 내의 모든 크레이트를 모읍니다.[workspace.package]와[workspace.dependencies]는 하위 크레이트가 상속할 공유 값입니다.
두 개의 하위 크레이트를 생성하세요:
cargo new crates/domain --lib
cargo new crates/api
얻은 구조:
shop/
├── Cargo.toml
├── Cargo.lock
├── target/
└── crates/
├── domain/
│ ├── Cargo.toml
│ └── src/lib.rs
└── api/
├── Cargo.toml
└── src/main.rs
4.2. 워크스페이스에서 구성 상속
crates/domain/Cargo.toml:
[package]
name = "domain"
version.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
crates/api/Cargo.toml:
[package]
name = "api"
version.workspace = true
edition.workspace = true
[dependencies]
domain = { path = "../domain" }
serde_json.workspace = true
serde.workspace = true는 이 크레이트가 [workspace.dependencies]에서 버전과 기능을 가져온다는 의미입니다. serde를 업그레이드해야 할 때는 루트 파일의 한 곳만 수정하면 됩니다.
하위 크레이트는 여전히 추가 기능을 활성화할 수 있습니다:
[dependencies]
serde = { workspace = true, features = ["rc"] }
여기의 기능은 워크스페이스에서 선언된 기능에 추가되며, 이를 대체하지 않습니다.
4.3. domain 크레이트의 코드
crates/domain/src/lib.rs:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderLine {
pub product: String,
pub unit_price_cents: u64,
pub quantity: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
pub id: u64,
pub customer: String,
pub lines: Vec<OrderLine>,
}
impl Order {
pub fn total_cents(&self) -> u64 {
self.lines
.iter()
.map(|line| line.unit_price_cents * u64::from(line.quantity))
.sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line(product: &str, price: u64, qty: u32) -> OrderLine {
OrderLine {
product: product.to_string(),
unit_price_cents: price,
quantity: qty,
}
}
#[test]
fn total_sums_all_lines() {
let order = Order {
id: 1,
customer: "Binh".to_string(),
lines: vec![line("Coffee", 35_000, 2), line("Cake", 25_000, 1)],
};
assert_eq!(order.total_cents(), 95_000);
}
#[test]
fn empty_order_has_zero_total() {
let order = Order { id: 2, customer: "Chi".to_string(), lines: vec![] };
assert_eq!(order.total_cents(), 0);
}
}
- 구조체와 필드의
pub은api크레이트가 이들에 접근할 수 있도록 합니다. u64::from(line.quantity)는u32를u64로 변환하며 데이터 손실이 없습니다.#[cfg(test)]는 테스트 모듈이cargo test를 실행할 때만 컴파일되도록 합니다.
domain 크레이트에 대한 테스트를 실행하세요:
cargo test -p domain
running 2 tests
test tests::empty_order_has_zero_total ... ok
test tests::total_sums_all_lines ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
루트 디렉토리에서 cargo test를 실행하면 워크스페이스의 모든 크레이트 테스트가 실행됩니다.
4.4. api에서 domain 사용
crates/api/src/main.rs:
use domain::{Order, OrderLine};
fn main() {
let order = Order {
id: 7,
customer: "Dung".to_string(),
lines: vec![OrderLine {
product: "Tea".to_string(),
unit_price_cents: 20_000,
quantity: 3,
}],
};
println!("Total: {}", order.total_cents());
println!("{}", serde_json::to_string_pretty(&order).expect("serialize"));
}
루트 디렉토리에서:
cargo run -p api
Total: 60000
{
"id": 7,
"customer": "Dung",
"lines": [
{
"product": "Tea",
"unit_price_cents": 20000,
"quantity": 3
}
]
}
api 크레이트는 serde를 선언하지 않지만 여전히 Order를 직렬화할 수 있습니다. 이는 Serialize 트레이트가 이미 domain 크레이트 내에서 구현되었기 때문입니다. api는 변환 함수를 호출하기 위해 serde_json만 필요합니다.
4.5. 워크스페이스에서 유용한 명령어
cargo build --workspace # 모든 크레이트를 빌드합니다
cargo check -p api # api 크레이트와 그 의존성만 확인합니다
cargo tree -p api # 의존성 트리를 확인합니다
cargo tree -d # 중복된 크레이트를 나열합니다
cargo tree -d는 바이너리가 비정상적으로 커질 때 매우 유용합니다. 결과는 일반적으로 같은 크레이트의 두 버전이 포함되었음을 보여줍니다.
5. 일반적인 오류
참고: Cargo의 기능은 통합됩니다 (기능 통합). 크레이트 A가
tokio/full을 활성화하고 크레이트 B가tokio/rt만 필요하다고 가정합니다. 워크스페이스에서 함께 빌드할 때, B도full을 모두 받습니다. 워크스페이스에서 실행되는 코드는 B를 개별적으로 빌드할 때 오류가 발생할 수 있습니다. CI에서 각 크레이트에 대해cargo check -p <크레이트 이름>을 실행하세요.
- 기능 활성화 잊음. “cannot find derive macro
Serialize” 같은 오류는 거의 항상features = ["derive"]가 없는 것입니다. Cargo.lock커밋하지 않음. Cargo의 현재 지침은 바이너리 및 라이브러리 모두에 대해 이 파일을 커밋하도록 권장합니다. CI에서의 빌드는 이렇게 하여 실행 간에 재현될 수 있습니다.- 발행할 때 경로 의존성.
domain = { path = "../domain" }은version이 없으면 crates.io에 발행할 수 없습니다. 발행하려면 둘 다 선언하세요:{ path = "../domain", version = "0.1.0" }. - 중첩된 워크스페이스. 하나의 크레이트는 하나의 워크스페이스에만 속합니다. 다른 워크스페이스의 하위 디렉토리 내에서
cargo new를 실행하면, Cargo는 오류를 보고하거나 자동으로 크레이트를members에 추가합니다. 생성 후 루트 파일을 다시 확인하세요. - 워크스페이스 오용. 작은 프로그램의 경우, 하나의 크레이트 내에서 모듈만 나누면 됩니다 (
mod order;). 크레이트를 분리하면 manifest의 수와 구성 시간이 증가합니다. 부분이 명확한 경계를 가지고, 개별적으로 빌드 또는 테스트가 필요하거나, 재사용이 필요할 때 워크스페이스를 사용하세요.
6. 결론
이 글은 Cargo의 세 가지 핵심 작업을 다루었습니다:
cargo new로 바이너리 또는 라이브러리 프로젝트를 생성하고, 빠른 피드백을 위해cargo check를 사용합니다.cargo add로 의존성을 추가하고, 버전 요구사항과Cargo.lock이 실제 버전을 결정하는 방식을 이해하며,dev-dependencies를 주 의존성과 분리합니다.- 여러 크레이트를 하나의 워크스페이스로 모으고,
[workspace.dependencies]에서 공유 버전을 선언한 다음workspace = true로 상속합니다.
프로젝트가 성장할 때는 간단한 원칙을 따르세요: 모든 의존성 버전은 루트 파일에 있고, 각 크레이트는 실제로 사용하는 것만 선언합니다. 이 방식은 업그레이드 및 바이너리 크기 제어를 훨씬 쉽게 만듭니다.