cargo workspace 모노레포 | Cargo.toml 구조·멤버·공통 의존성·빌드 최적화

cargo workspace 모노레포 | Cargo.toml 구조·멤버·공통 의존성·빌드 최적화

이 글의 핵심

워크스페이스는 공유 target 디렉터리와 workspace.dependencies로 버전 단일화를 동시에 노리는 Rust 모노레포의 표준 패턴입니다.

들어가며

Cargo workspace는 여러 패키지(크레이트)를 하나의 저장소에서 함께 빌드·테스트하기 위한 공식적인 모노레포 패턴입니다. Cargo.lock은 워크스페이스 루트에 하나만 두는 것이 일반적이며, 공유 target/ 덕분에 의존성 그래프가 겹칠 때 컴파일량을 줄일 수 있습니다.

CLI·라이브러리·프로토콜 크레이트를 나눈 팀, 혹은 내부 공유 크레이트를 여러 서비스가 참조하는 팀에서 특히 자주 씁니다. 이 글은 cargo workspace 모노레포 키워드에 맞춰 Cargo.toml 구조, 멤버 관리, 공통 의존성, 빌드 최적화를 실무 관점에서 정리합니다.

이 글을 읽으면

  • Cargo workspace의 루트 매니페스트 구조를 이해합니다
  • 멤버 추가·공통 의존성 설정 방법을 익힙니다
  • 빌드 최적화·CI 전략을 적용할 수 있습니다

목차

  1. 개념: workspace 루트와 멤버
  2. 실전: 최소 레이아웃과 명령
  3. 고급: workspace.dependencies·패치
  4. 비교: 단일 크레이트·멀티 레포
  5. 실무 사례
  6. 트러블슈팅
  7. 마무리

개념: workspace 루트와 멤버

Workspace란?

  • **루트 Cargo.toml**에 [workspace] 섹션이 있고, members에 하위 크레이트 경로가 나열됩니다.
  • 워크스페이스에 속한 패키지들은 동일한 Cargo.lock 정책 아래에서 의존성 버전을 맞추기 쉽습니다.
  • 기본 패키지(root package)를 두지 않고 메타만 있는 virtual manifest로 두는 팀도 많습니다.

구조 다이어그램

my-workspace/
├── Cargo.toml          (workspace 루트)
├── Cargo.lock          (공유)
├── target/             (공유 빌드 출력)
├── crates/
│   ├── api/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       └── main.rs
│   ├── core/
│   │   ├── Cargo.toml
│   │   └── src/
│   │       └── lib.rs
│   └── utils/
│       ├── Cargo.toml
│       └── src/
│           └── lib.rs
└── README.md

핵심 개념

항목설명
Virtual Manifest루트에 [package] 없이 [workspace]만 있는 구조
Members워크스페이스에 속한 크레이트 목록
Shared Lock모든 멤버가 동일한 Cargo.lock 사용
Shared Target컴파일 결과물을 target/에 공유
Workspace Dependencies공통 의존성 버전 관리

실전: 최소 레이아웃과 명령

1) 워크스페이스 생성

디렉터리 구조 생성

# 프로젝트 루트 생성
mkdir my-workspace
cd my-workspace

# 멤버 크레이트 생성
cargo new --lib crates/core
cargo new --bin crates/api
cargo new --lib crates/utils

루트 Cargo.toml 작성

[workspace]
resolver = "2"
members = [
    "crates/api",
    "crates/core",
    "crates/utils"
]

[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Your Name <[email protected]>"]
license = "MIT"
repository = "https://github.com/org/my-workspace"

[workspace.dependencies]
# 공통 의존성
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
anyhow = "1.0"
tracing = "0.1"

2) 멤버 크레이트 설정

crates/core/Cargo.toml

[package]
name = "core"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true

[dependencies]
serde.workspace = true
anyhow.workspace = true

crates/api/Cargo.toml

[package]
name = "api"
version.workspace = true
edition.workspace = true

[dependencies]
# 워크스페이스 내부 크레이트
core = { path = "../core" }
utils = { path = "../utils" }

# 워크스페이스 공통 의존성
tokio.workspace = true
serde.workspace = true
anyhow.workspace = true

# api 전용 의존성
axum = "0.7"
tower = "0.4"

crates/utils/Cargo.toml

[package]
name = "utils"
version.workspace = true
edition.workspace = true

[dependencies]
tracing.workspace = true

3) 코드 예제

crates/core/src/lib.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub id: u64,
    pub name: String,
    pub email: String,
}

impl User {
    pub fn new(id: u64, name: String, email: String) -> Self {
        Self { id, name, email }
    }
}

crates/utils/src/lib.rs

use tracing::info;

pub fn log_startup(service_name: &str) {
    info!("Starting service: {}", service_name);
}

crates/api/src/main.rs

use axum::{
    routing::get,
    Router,
    Json,
};
use core::User;
use utils::log_startup;

#[tokio::main]
async fn main() {
    log_startup("API Server");

    let app = Router::new()
        .route("/users", get(get_users));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}

async fn get_users() -> Json<Vec<User>> {
    let users = vec![
        User::new(1, "Alice".to_string(), "[email protected]".to_string()),
        User::new(2, "Bob".to_string(), "[email protected]".to_string()),
    ];
    Json(users)
}

4) 자주 쓰는 명령

빌드 및 실행

# 전체 워크스페이스 검사
cargo check --workspace

# 전체 빌드
cargo build --workspace

# 전체 테스트
cargo test --workspace

# 특정 크레이트만 빌드
cargo build -p api

# 특정 크레이트만 테스트
cargo test -p core

# 특정 크레이트 실행
cargo run -p api

# 릴리스 빌드
cargo build -p api --release

의존성 관리

# 전체 의존성 트리 확인
cargo tree --workspace

# 특정 크레이트 의존성
cargo tree -p core

# 중복 의존성 확인
cargo tree --duplicates

# 의존성 업데이트
cargo update

# 특정 의존성만 업데이트
cargo update -p serde

정리 및 최적화

# 빌드 캐시 정리
cargo clean

# 특정 크레이트만 정리
cargo clean -p api

# 미사용 의존성 확인
cargo machete

# 포맷팅
cargo fmt --all

# Clippy (린터)
cargo clippy --workspace -- -D warnings

고급: workspace.dependencies·패치

1) 버전 단일화

workspace.dependencies에 올려 두고 멤버에서는 dep.workspace = true만 선언하면 serde·tokio 버전 불일치를 PR 단계에서 줄입니다.

루트 Cargo.toml

[workspace.dependencies]
# 버전 한 곳에서 관리
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
anyhow = "1.0"
thiserror = "1.0"

# 내부 크레이트도 정의 가능
core = { path = "crates/core" }
utils = { path = "crates/utils" }

멤버 Cargo.toml

[dependencies]
# workspace에서 상속
serde.workspace = true
tokio.workspace = true

# 내부 크레이트
core.workspace = true
utils.workspace = true

# 멤버 전용 의존성
axum = "0.7"

2) [patch] - 사내 포크 또는 긴급 패치

사내 포크 사용

# 루트 Cargo.toml
[patch.crates-io]
serde = { git = "https://github.com/company/serde-fork", branch = "custom-feature" }

효과: 모든 멤버가 자동으로 패치된 버전 사용

로컬 패치

[patch.crates-io]
tokio = { path = "../tokio-local" }

사용 시나리오:

  • 업스트림 버그 긴급 수정
  • 사내 커스터마이징
  • 의존성 디버깅

주의사항:

  • 유지보수 부담 증가
  • 업스트림 업데이트 추적 필요
  • 기한 설정 권장

3) default-members

[workspace]
members = ["crates/*"]
default-members = ["crates/api"]

효과:

  • cargo run 시 기본 타깃 지정
  • cargo build (인자 없음) 시 default-members만 빌드

4) exclude - 멤버 제외

[workspace]
members = ["crates/*"]
exclude = ["crates/experimental", "crates/deprecated"]

사용 시나리오:

  • 실험적 크레이트 제외
  • 레거시 코드 격리
  • 빌드 시간 단축

5) 프로파일 공유

# 루트 Cargo.toml
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = true

[profile.dev]
opt-level = 0
debug = true

[profile.dev.package."*"]
opt-level = 2  # 의존성만 최적화

효과: 모든 멤버가 동일한 프로파일 사용


비교: 단일 크레이트·멀티 레포

항목단일 크레이트Workspace멀티 레포
경계모듈로 분리크레이트로 분리레포로 분리
빌드 캐시단일 target공유 target레포별 독립
의존성 관리단일 Cargo.tomlworkspace.dependencies레포별 독립
버전 관리단일 버전멤버별 또는 공유레포별 독립
권한 관리레포 단위레포 단위레포별 세밀
릴리스단일 릴리스멤버별 또는 통합레포별 독립
학습 곡선낮음중간높음

선택 가이드

단일 크레이트 선택:

  • ✅ 작은 프로젝트 (< 10,000 LOC)
  • ✅ 모듈 경계가 명확하지 않음
  • ✅ 팀 규모 작음 (1-3명)

Workspace 선택:

  • ✅ 중대형 프로젝트
  • ✅ 명확한 경계 (CLI, 라이브러리, 서버)
  • ✅ 공통 코드 재사용
  • ✅ 원자적 변경 필요

멀티 레포 선택:

  • ✅ 독립적 릴리스 주기
  • ✅ 레포별 권한 분리
  • ✅ 외부 공개 크레이트

실무 사례

사례 1: 공유 도메인 로직 + 여러 바이너리

구조:

my-app/
├── Cargo.toml
├── crates/
│   ├── core/          (공유 도메인 로직)
│   │   └── src/lib.rs
│   ├── api/           (REST API 서버)
│   │   └── src/main.rs
│   ├── worker/        (백그라운드 워커)
│   │   └── src/main.rs
│   └── cli/           (CLI 도구)
│       └── src/main.rs

루트 Cargo.toml:

[workspace]
members = ["crates/*"]

[workspace.dependencies]
core = { path = "crates/core" }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

crates/api/Cargo.toml:

[package]
name = "api"
version = "0.1.0"
edition = "2021"

[dependencies]
core.workspace = true
tokio.workspace = true
axum = "0.7"

crates/worker/Cargo.toml:

[package]
name = "worker"
version = "0.1.0"
edition = "2021"

[dependencies]
core.workspace = true
tokio.workspace = true

장점:

  • core 변경 시 모든 바이너리 자동 재빌드
  • 의존성 버전 일관성
  • 공유 target/로 빌드 시간 단축

사례 2: FFI 바인딩 (sys + safe wrapper)

구조:

libfoo-rs/
├── Cargo.toml
├── crates/
│   ├── libfoo-sys/    (C 라이브러리 바인딩)
│   │   ├── Cargo.toml
│   │   ├── build.rs
│   │   └── src/lib.rs
│   └── libfoo/        (안전한 Rust 래퍼)
│       ├── Cargo.toml
│       └── src/lib.rs

libfoo-sys/Cargo.toml:

[package]
name = "libfoo-sys"
version = "0.1.0"
edition = "2021"
links = "foo"

[build-dependencies]
cc = "1.0"

libfoo-sys/build.rs:

fn main() {
    cc::Build::new()
        .file("vendor/libfoo.c")
        .compile("foo");
}

libfoo/Cargo.toml:

[package]
name = "libfoo"
version = "0.1.0"
edition = "2021"

[dependencies]
libfoo-sys = { path = "../libfoo-sys" }

libfoo/src/lib.rs:

use libfoo_sys::*;

pub struct Foo {
    inner: *mut FooHandle,
}

impl Foo {
    pub fn new() -> Self {
        unsafe {
            Self {
                inner: foo_create(),
            }
        }
    }

    pub fn process(&self, data: &[u8]) -> Vec<u8> {
        unsafe {
            // 안전한 래퍼 제공
            let result = foo_process(self.inner, data.as_ptr(), data.len());
            // ...
            vec![]
        }
    }
}

impl Drop for Foo {
    fn drop(&mut self) {
        unsafe {
            foo_destroy(self.inner);
        }
    }
}

장점:

  • -sys와 안전 래퍼를 함께 버전업
  • 내부 API 변경 시 원자적 커밋

사례 3: 마이크로서비스 모노레포

구조:

services/
├── Cargo.toml
├── crates/
│   ├── shared/        (공통 타입, 유틸)
│   ├── auth-service/
│   ├── user-service/
│   ├── order-service/
│   └── gateway/

루트 Cargo.toml:

[workspace]
members = [
    "crates/shared",
    "crates/auth-service",
    "crates/user-service",
    "crates/order-service",
    "crates/gateway"
]

[workspace.dependencies]
shared = { path = "crates/shared" }
tokio = { version = "1", features = ["full"] }
tonic = "0.11"
prost = "0.12"

shared/src/lib.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserId(pub u64);

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderId(pub u64);

pub mod error {
    use thiserror::Error;

    #[derive(Debug, Error)]
    pub enum ServiceError {
        #[error("Not found: {0}")]
        NotFound(String),
        
        #[error("Internal error: {0}")]
        Internal(String),
    }
}

auth-service/src/main.rs:

use shared::{UserId, error::ServiceError};

#[tokio::main]
async fn main() {
    println!("Auth service started");
}

async fn authenticate(token: &str) -> Result<UserId, ServiceError> {
    // 인증 로직
    Ok(UserId(123))
}

CI 빌드 최적화:

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
      
      - uses: Swatinem/rust-cache@v2
        with:
          shared-key: "workspace"
      
      - name: Check all
        run: cargo check --workspace
      
      - name: Test all
        run: cargo test --workspace
      
      - name: Clippy
        run: cargo clippy --workspace -- -D warnings

트러블슈팅

문제 1: 멤버가 인식 안 됨

증상:

cargo build -p api
# error: package `api` is not a member of the workspace

원인:

  • members 경로 오타
  • 중첩 workspace (금지)

해결:

# 루트 Cargo.toml
[workspace]
members = [
    "crates/api",  # 경로 확인
    "crates/core"
]

확인:

# 워크스페이스 멤버 목록
cargo metadata --no-deps | jq '.workspace_members'

문제 2: 의존성 버전 충돌

증상:

error: failed to select a version for `serde`

원인: 멤버들이 서로 다른 major 버전 지정

해결:

# 루트 Cargo.toml
[workspace.dependencies]
serde = "1.0"  # 버전 통일

# 멤버 Cargo.toml
[dependencies]
serde.workspace = true  # 루트 버전 사용

문제 3: 빌드 캐시 비대

증상: target/ 디렉터리가 수 GB

원인:

  • 불필요한 feature 활성화
  • 여러 프로파일 빌드
  • 미사용 의존성

해결:

# 정기적 정리
cargo clean

# 특정 프로파일만 정리
cargo clean --release

# 미사용 의존성 제거
cargo machete

sccache 사용:

# 설치
cargo install sccache

# 환경 변수 설정
export RUSTC_WRAPPER=sccache

# 빌드 (캐시 공유)
cargo build --workspace

문제 4: 테스트 시간 과다

증상: cargo test --workspace 실행 시 수 분 소요

해결 1: nextest 사용

# 설치
cargo install cargo-nextest

# 병렬 테스트
cargo nextest run --workspace

해결 2: 변경된 크레이트만 테스트

# Git diff 기반
CHANGED=$(git diff --name-only HEAD~1 | grep '^crates/' | cut -d'/' -f2 | sort -u)

for crate in $CHANGED; do
    cargo test -p $crate
done

문제 5: 순환 의존성

증상:

error: cyclic package dependency: package `api` depends on itself

원인: A → B → A 순환

해결: 공통 타입을 세 번째 크레이트로 추출

Before:
api → core → api  (순환!)

After:
api → core → shared

shared

예시:

# shared/Cargo.toml
[package]
name = "shared"

# core/Cargo.toml
[dependencies]
shared = { path = "../shared" }

# api/Cargo.toml
[dependencies]
shared = { path = "../shared" }
core = { path = "../core" }

마무리

Cargo workspace는 Rust에서 모노레포를 공식 패턴으로 가져가는 방법이며, workspace.dependencies좁은 -p 빌드만으로도 운영 체감이 크게 좋아집니다.

핵심 요약

  1. 구조

    • 루트에 [workspace] 선언
    • 멤버 크레이트는 members 배열에 나열
    • 공유 Cargo.locktarget/
  2. 의존성 관리

    • workspace.dependencies로 버전 통일
    • 멤버는 .workspace = true로 상속
    • [patch]로 긴급 수정
  3. 빌드 최적화

    • -p 옵션으로 특정 크레이트만 빌드
    • sccache로 캐시 공유
    • cargo nextest로 병렬 테스트
  4. CI/CD

    • 변경된 크레이트만 빌드
    • 공유 캐시 키 사용
    • Clippy·fmt 전체 실행

다음 단계

  • Rust 입문: Rust 시작하기
  • 타입 시스템: String vs &str
  • 프로젝트 구조: Rust 프로젝트 구조 가이드

Cargo workspace는 Rust 모노레포의 표준입니다. 처음에는 복잡해 보이지만, workspace.dependencies와 공유 target/의 이점을 경험하면 다시 돌아가기 어렵습니다.