본문으로 건너뛰기 cargo workspace 모노레포 | Cargo.toml 구조·멤버·공통 의존성·빌드 최적화

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

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

이 글의 핵심

Rust Cargo workspace로 크레이트를 한 저장소에서 묶는 법: 루트 매니페스트, 멤버 추가, workspace.dependencies, 빌드 캐시까지 실무 팁을 정리합니다.

들어가며

Cargo workspace는 여러 패키지(크레이트)를 하나의 저장소에서 함께 빌드·테스트하기 위한 공식적인 모노레포 패턴입니다. Cargo.lock은 워크스페이스 루트에 하나만 두는 것이 일반적이며, 공유 target/ 덕분에 의존성 그래프가 겹칠 때 컴파일량을 줄일 수 있습니다. CLI·라이브러리·프로토콜 크레이트를 나눈 팀, 혹은 내부 공유 크레이트를 여러 서비스가 참조하는 팀에서 특히 자주 씁니다. 이 글은 cargo workspace 모노레포 키워드에 맞춰 Cargo.toml 구조, 멤버 관리, 공통 의존성, 빌드 최적화를 실무 관점에서 정리합니다.

이 글을 읽으면

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

개념: 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 <you@example.com>"]
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(), "alice@example.com".to_string()),
        User::new(2, "Bob".to_string(), "bob@example.com".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 전체 실행

다음 단계


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Rust Cargo workspace로 크레이트를 한 저장소에서 묶는 법: 루트 매니페스트, 멤버 추가, workspace.dependencies, 빌드 캐시까지 실무 팁을 정리합니다. Start now. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Rust, Cargo, Workspace, 모노레포, 의존성 관리, 빌드 등으로 검색하시면 이 글이 도움이 됩니다.