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로 상속합니다.

프로젝트가 성장할 때는 간단한 원칙을 따르세요: 모든 의존성 버전은 루트 파일에 있고, 각 크레이트는 실제로 사용하는 것만 선언합니다. 이 방식은 업그레이드 및 바이너리 크기 제어를 훨씬 쉽게 만듭니다.

rustcargodependencyworkspacebuild-tool