본문 바로가기

Cargo 작업공간

하나의 Cargo 작업공간에 여러 패키지를 구성해 공통 잠금 파일과 산출물·외부 의존성을 공유하고 함께 테스트합니다.

11장에서 바이너리 크레이트와 라이브러리 크레이트를 포함하는 패키지를 만들어 봤습니다.

하지만 프로젝트를 개발하다 보면, 라이브러리 크레이트가 점점 거대해져서 패키지를 여러 개의 라이브러리 크레이트로 분리하고 싶을 겁니다.

Cargo는 작업공간(workspace)이라는 기능을 제공하여 나란히 개발되는 여러 관련 패키지를 관리하는 데 도움을 줄 수 있습니다.


작업공간 생성하기

작업공간은 동일한 Cargo.lock과 출력 디렉터리를 공유하는 패키지들의 집합입니다.

작업공간을 이용하여 프로젝트를 만들어 봅시다. 여기서는 간단한 코드만 사용하여 작업공간의 구조에 집중하겠습니다.

작업공간을 구성하는 방법은 여러 가지가 있으므로, 그중 일반적인 방법 하나를 보겠습니다.

우리의 작업공간은 하나의 바이너리와 두 개의 라이브러리를 담을 것입니다.

주요 기능을 제공할 바이너리는 두 라이브러리를 의존성으로 가지게 될 것입니다.

첫 번째 라이브러리는 add_one 함수를 제공하고, 두 번째 라이브러리는 add_two 함수를 제공할 것입니다.

이 세 크레이트는 같은 작업공간의 일부가 될 겁니다.

작업공간을 위한 새 디렉터리를 만드는 것부터 시작하겠습니다.

RUST · WORKSPACE BOUNDARIES

workspace는 패키지를 모으고 의존성은 각 패키지가 선언한다

본문 add 작업공간의 adder → add_one 호출을 따라갑니다. 공동 lockfile·산출물과 패키지의 API·target·게시 경계를 구분합니다.

add 작업공간의 멤버와 adder의 add_one 경로 의존성루트는 adder와 add_one을 멤버로 모은다. adder가 add_one을 쓰는 의존성을 자기 manifest에 명시한다. 공유 lock과 기본 target은 별도 관리 경계다.add/Cargo.toml[workspace] membersadder 패키지binary · 자체 manifestadd_one 패키지library · 자체 manifestadder가 add_one에 의존공유 Cargo.lock · 기본 target/
멤버 소속과 패키지 의존성은 다른 관계원 desktop과같은세노드세관계다. 루트→adder 및루트→add_one은멤버소속,adder→add_one은패키지manifest의path의존이다. 루트→add_one은왼쪽외부레일을사용하여adder내부를관통하지않는다. 세로배치는루트→adder→add_one의순차실행이나멤버등록이자동의존성을만든다는뜻이아니다. 공유Cargo.lock과기본target은별도관리경계이며현재그림은add_two를추가하기전두패키지상태다.add/Cargo.toml[workspace] members두 패키지를 모음adder 패키지binary · 자체 manifestadd_one 패키지library · 자체 manifest멤버멤버path 의존공유 lock · 기본 target

루트 · add/Cargo.toml

members가 adder/add_one 패키지를 모읍니다. 각 패키지는 자체 manifest와 target 집합을 가집니다.

adder → add_one

adder가 library add_one을 쓰려면 자기 manifest에 path dependency를 명시합니다.

공유와 선택

Cargo.lock과 기본 target/을 공유합니다. cargo test --workspace와 cargo test -p add_one은 선택 범위가 다릅니다.

본문 · adder/Cargo.toml

[dependencies]
add_one = { path = "../add_one" }

같은 작업공간의 멤버라는 사실만으로 의존성이 생기지는 않습니다.

루트 · 현대 가상 workspace 설정

[workspace]
resolver = "3"
members = ["adder", "add_one"]

Rust 2024 기준의 명시 설정입니다. [workspace.dependencies]도 각 멤버가 workspace = true로 상속해야 사용합니다.

lockfile · 선택한 의존성 그래프

하나의 lockfile이 해석 결과를 기록합니다. 호환되지 않는 버전은 함께 들어갈 수 있으며, 버전 공유가 모든 API·동작의 호환성을 보장하지는 않습니다.

target·test·publish 경계

cargo test --workspace
cargo test -p add_one
cargo run -p adder

기본 target 경로는 설정으로 바꿀 수 있습니다. root/default-members와 명령 옵션은 선택 범위를 정하며 버전·게시 경계는 패키지별입니다.

이 본문의 가상 루트는 default-members를 바꾸지 않아 루트 cargo test가 모든 멤버를 선택합니다. 일반 workspace에서 root 명령이 항상 모든 멤버를 선택하는 것은 아닙니다.

$ mkdir add
$ cd add

다음으로 add 디렉터리 내에 Cargo.toml을 생성하여 전체 작업공간에 대한 설정을 합니다.

이 파일은 [package] 절이 없습니다.

대신 [workspace] 절로 시작하여 바이너리 크레이트 패키지에 대한 경로를 명시하는 방식으로 이 작업공간에 멤버를 추가할 것입니다; 지금의 경우 해당 경로는 adder입니다.

Cargo.toml
[workspace]

members = [
    "adder",
]

다음엔 add 디렉터리 내에서 cargo new를 실행하여 adder 바이너리 크레이트를 생성하겠습니다.

$ cargo new adder
     Created binary (application) `adder` package

이 시점에서 작업 공간을 cargo build로 빌드할 수 있습니다.

add 디렉터리 내의 파일들은 아래와 같은 형태여야 합니다.

Cargo.lock
Cargo.toml
Cargo.toml
main.rs

작업공간은 컴파일된 결과가 위치할 하나의 target 디렉터리를 최상위 디렉터리에 가집니다; adder 크레이트는 자신의 target 디렉터리를 갖지 않습니다.

adder 디렉터리 내에서 cargo build 명령어를 실행하더라도 컴파일 결과는 add/adder/target이 아닌 add/target에 위치하게 될 겁니다.

Cargo가 이처럼 target 디렉터리를 작업공간 내에 구성하는 이유는, 작업공간 내의 크레이트들이 서로 의존할 수 있기 때문입니다.

만약 각 크레이트가 각자의 target 디렉터리를 갖는다면, 각 크레이트는 작업공간 내의 다른 크레이트들을 다시 컴파일하여 그 결과물을 자신의 target 디렉터리에 넣어야 합니다.

하나의 target 디렉터리를 공유하면 크레이트들의 불필요한 재빌드를 피할 수 있습니다.


작업공간에 두 번째 패키지 생성하기

다음으로 다른 멤버 패키지를 작업공간에 생성하여 add_one이라고 이름을 붙입시다.

최상위 Cargo.toml을 수정하여 members 목록에 add_one 경로를 지정하세요.

Cargo.toml
[workspace]

members = [
    "adder",
    "add_one",
]

그런 다음 add_one이라는 이름의 새 라이브러리 크레이트를 생성하세요.

$ cargo new add_one --lib
     Created library `add_one` package

add 디렉터리는 이제 다음과 같은 디렉터리와 파일을 갖추어야 합니다.

Cargo.lock
Cargo.toml
Cargo.toml
lib.rs
Cargo.toml
main.rs

add_one/src/lib.rs 파일에 add_one 함수를 추가합시다.

add_one/src/lib.rs
pub fn add_one(x: i32) -> i32 {
    x + 1
}

이제 바이너리를 가지고 있는 adder 패키지와 이것이 의존하는 라이브러리를 갖고 있는 add_one 패키지를 갖추었습니다.

먼저 adder/Cargo.toml에 add_one의 경로 의존성을 추가할 필요가 있습니다.

adder/Cargo.toml
[dependencies]
add_one = { path = "../add_one" }

Cargo는 작업 공간 내의 크레이트들이 서로 의존할 것이라고 가정하지 않으므로, 의존성 관계에 대해 명시할 필요가 있습니다.

다음으로 adder 크레이트에서(add_one 크레이트에 있는) add_one 함수를 사용해봅시다.

adder/src/main.rs 파일을 열어서 제일 윗줄에 use를 추가하여 스코프로 새로운 add_one 라이브러리를 가져옵시다.

그런 다음 예제 13-7과 같이 main 함수를 수정하여 add_one 함수를 호출하세요.

예제 13-7: adder 크레이트에서 add_one 라이브러리 크레이트 사용하기
adder/src/main.rs
use add_one;

fn main() {
    let num = 10;
    println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}

최상위 add 디렉터리에서 cargo build를 실행하여 작업공간을 빌드해봅시다!

$ cargo build
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.68s

add 디렉터리에서 cargo run -p adder를 쓰면 실행할 패키지를 명시적으로 선택할 수 있습니다. -p가 모든 workspace 실행에 필수인 것은 아닙니다. 이 단계의 가상 workspace는 adder 바이너리 하나와 add_one 라이브러리로 이루어져 있으므로, 기본 선택 범위 안에서 실행 가능한 바이너리가 하나라면 cargo run만으로도 실행 대상을 정할 수 있습니다. 여러 바이너리가 후보가 되는 경우에는 패키지 선택과 바이너리 target 선택을 구분해야 합니다.

$ cargo run -p adder
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/adder`
Hello, world! 10 plus one is 11!

이 명령은 adder/src/main.rs의 코드를 실행시키고, 이는 add_one 크레이트에 의존하고 있습니다.

여기서 선택의 순서를 따라가면 다음과 같습니다. 먼저 현재 manifest와 default-members 또는 -p로 패키지 범위를 정합니다. 그 범위에 실행할 바이너리가 하나인지 확인하고, 한 패키지에 여러 바이너리가 있으면 --bin 이름이나 그 패키지의 default-run 설정으로 target을 정합니다. 라이브러리인 add_one은 adder의 의존성으로 빌드되지만 cargo run의 실행 파일 후보는 아닙니다. 이 두 단계는 Cargo run의 패키지·target 선택과 workspace 기본 멤버에 대응합니다.

다른 바이너리를 추가했을 때

루트에 [package]와 default-members가 없는 가상 workspace라고 합시다. adder와 별도로 단일 바이너리를 가진 viewer 패키지를 멤버로 추가했고, 두 패키지에는 default-run이 없습니다. 이때 cargo run은 두 실행 파일 중 하나를 임의로 고르지 않습니다. adder를 실행하려면 기존처럼 cargo run -p adder로 패키지를 선택하세요. 이후 adder 안에도 여러 바이너리가 생기면 cargo run -p adder --bin adder처럼 패키지와 target을 함께 명시할 수 있습니다.

루트의 default-members = ["adder"]로 기본 선택 범위를 좁히면 어떨까요? adder에 바이너리가 하나라는 조건에서는 기본 cargo run이 그 바이너리를 선택할 수 있습니다. 이 설정이 adder가 빌드할 add_one 의존성을 삭제하거나 add_one의 단위 테스트까지 실행 대상으로 정하는 것은 아닙니다. 뒤의 테스트 범위 연습에서도 같은 선택/의존성 구분을 사용합니다.

지금까지 만든 작업공간의 실행 흐름을 한 번에 보면, 최상위 Cargo.toml이 멤버를 모으고 각 패키지의 의존성 선언을 따라 공유 Cargo.lock과 target을 거쳐 원하는 패키지의 바이너리를 실행하게 됩니다.

작업공간에서 외부 패키지 의존하기

작업공간에는 각 크레이트 디렉터리마다 Cargo.lock이 생기지 않고, 최상위에 하나의 Cargo.lock이 생긴다는 점을 주목하세요.

이는 작업공간 전체의 의존성 해석 결과를 하나의 파일에 기록합니다. 같은 패키지의 호환되지 않는 여러 버전이 함께 들어갈 수도 있습니다.

adder/Cargo.toml과 add_one/Cargo.toml에 호환되는 rand 버전 요구를 추가하면, Cargo는 가능한 경우 하나의 버전을 공유하고 그 결과를 Cargo.lock에 기록합니다.

같은 의존성 버전을 공유하면 타입 불일치와 중복 빌드를 줄일 수 있지만, 크레이트 사이의 모든 API와 동작이 호환된다는 보장은 아닙니다.

add_one/Cargo.toml 파일의 [dependencies] 절에 rand 크레이트를 추가하여 add_one 크레이트에서 rand 크레이트를 사용해봅시다.

add_one/Cargo.toml
[dependencies]
rand = "0.8.5"

이제 add_one/src/lib.rs 파일에 use rand;를 추가할 수 있으며, add 디렉터리에서 cargo build를 실행하여 전체 작업공간을 빌드하면 rand 크레이트를 가져와 컴파일할 것입니다.

아직 스코프로 가져온 rand를 참조하지 않았으므로 경고 하나를 받을 겁니다.

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
   --snip--
   Compiling rand v0.8.5
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
warning: unused import: `rand`
 --> add_one/src/lib.rs:1:5
  |
1 | use rand;
  |     ^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `add_one` (lib) generated 1 warning
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 10.18s

최상위의 Cargo.lock에는 이제 add_one의 rand 의존성에 대한 정보가 포함됩니다.

하지만 작업공간의 어딘가에서 rand가 사용되더라도 작업공간의 다른 크레이트의 Cargo.toml 파일에 마찬가지로 rand를 추가하지 않으면 이를 사용할 수 없습니다.

예를 들어 use rand;를 adder 패키지의 adder/src/main.rs 파일에 추가하면 다음과 같은 에러가 납니다.

$ cargo build
  --snip--
   Compiling adder v0.1.0 (file:///projects/add/adder)
error[E0432]: unresolved import `rand`
 --> adder/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no external crate `rand`

이를 수정하려면 adder 패키지의 Cargo.toml을 고쳐서 이 패키지도 rand에 의존함을 알려주세요.

adder 패키지를 빌드하면 Cargo.lock에 있는 adder에 대한 의존성 목록에 rand를 추가하지만, rand의 추가 복제본을 내려받지는 않을 것입니다.

이 예제처럼 버전 요구가 호환되면 Cargo가 동일한 rand 버전을 재사용할 수 있습니다. 호환되지 않는 요구는 여러 버전으로 해석되거나 다른 제약과 충돌해 오류가 날 수 있습니다.

작업공간에 테스트 추가하기

또 다른 개선 사항으로, add_one::add_one 함수의 테스트를 add_one 크레이트 내에 추가해봅시다.

add_one/src/lib.rs
pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}

이제 최상위 add 디렉터리에서 cargo test를 실행해보세요.

이런 구조의 작업공간에서 cargo test를 실행하면 작업공간의 모든 크레이트에 대한 테스트를 실행할 것입니다.

$ cargo test
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.27s
     Running unittests src/lib.rs (target/debug/deps/add_one-f0253159197f7841)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/adder-49979ff40686fa8e)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

출력의 첫 번째 절은 add_one 크레이트의 it_works 테스트가 통과되었음을 보여줍니다.

다음 절은 adder 크레이트에서 아무 테스트도 발견하지 못했음을 보여주고, 마지막 절에서는 add_one 크레이트 내에서 아무런 문서 테스트도 발견하지 못했음을 보여줍니다.

-p 플래그와 테스트하고자 하는 크레이트의 이름을 명시하면 최상위 디렉터리에서 작업공간에 있는 특정 크레이트에 대한 테스트를 실행할 수도 있습니다.

$ cargo test -p add_one
    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/add_one-b3235fea9a156f74)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

이 출력은 cargo test가 add_one 크레이트에 대한 테스트만 실행했으며 adder 크레이트의 테스트는 실행하지 않았음을 보여줍니다.

작업공간의 크레이트를 crates.io에 배포한다면, 작업공간 내 각 크레이트를 별도로 배포할 필요가 있습니다.

cargo test처럼 -p 플래그와 배포하고자 하는 크레이트의 이름을 지정하여 작업공간 내의 특정 크레이트를 배포할 수 있습니다.

작업공간에서는 공유되는 것과 패키지별로 명시해야 하는 것을 구분해야 합니다.

Cargo.lock과 target은 최상위에서 공유되지만, 각 크레이트가 사용할 의존성은 자기 Cargo.toml에 직접 적어야 합니다.

추가 연습을 위해 이 작업공간에 add_one 크레이트와 비슷한 방식으로 add_two 크레이트를 추가해보세요!

예시

앞의 출력은 adder와 add_one 두 패키지만 있는 시점의 결과입니다. 처음에 계획한 세 번째 패키지를 완성하려면 add 루트에서 cargo new add_two --lib를 실행하고, 루트 [workspace]의 members 배열에 "add_two"를 포함합니다. 같은 [workspace] 표를 두 번 추가하지 않습니다. 생성된 테스트 대신 add_two/src/lib.rs를 다음 전체 내용으로 바꾸세요.

add_two/src/lib.rs
pub fn add_two(x: i32) -> i32 {
    x + 2
}

#[cfg(test)]
mod tests {
    use super::add_two;

    #[test]
    fn ten_becomes_twelve() {
        assert_eq!(add_two(10), 12);
    }
}

이 작은 테스트는 범위를 넘지 않는 입력 10을 씁니다. 원 함수처럼 임의의 큰 i32에 대한 오버플로 정책까지 구현한 코드는 아닙니다. adder/Cargo.toml의 기존 [dependencies] 안에 add_two = { path = "../add_two" }를 넣고, 기존 main의 출력문을 println!("10 plus two is {}!", add_two::add_two(10));으로 교체하면 예상 출력은 10 plus two is 12!입니다. 앞의 add_one 예제와 출력 11은 다른 단계의 기록으로 남겨 둡니다.

가상 workspace는 루트에 [package]가 없으므로 package edition에서 resolver를 추론하지 못합니다. 현재 설정에서는 루트 [workspace]에 resolver = "3"을 명시하는 예를 사용할 수 있으며, 이는 그 resolver를 지원하는 Cargo를 전제로 합니다. 기존 예제의 edition 2021 패키지를 저절로 edition 2024로 바꾸는 설정이 아닙니다. 루트의 default-members는 기본 명령이 선택할 패키지를, 각 패키지의 [dependencies]는 빌드에 필요한 패키지를 정합니다. 두 목록을 같다고 가정하지 마세요.

스스로 실행 범위 고르기

세 패키지가 모두 members이고 루트에 [package]는 없습니다. default-members = ["adder"]로 두고 adder는 두 라이브러리에 의존하지만 자체 테스트는 0개입니다. 두 라이브러리에는 각각 위와 같은 단위 테스트가 1개 있고 문서 테스트는 0개라고 합시다. 루트의 cargo test, cargo test --workspace, cargo test -p add_two가 어느 패키지의 테스트를 실행하는지 구분하세요. 멤버로 등록만 하고 adder의 path dependency를 빼면 adder에서 add_two::add_two를 호출할 수 있나요?

범위와 풀이 확인

기본 cargo test는 adder를 선택합니다. 의존 라이브러리는 빌드에 필요하지만 그 라이브러리의 단위 테스트까지 선택된 것은 아닙니다. 이 조건의 adder 테스트 수는 0입니다. --workspace는 세 패키지를 선택하여 라이브러리 테스트 2개를 실행하고, -p add_two는 add_two 테스트 1개를 실행합니다.

members는 adder에 자동 import나 의존성을 주지 않습니다. path dependency가 빠지면 같은 workspace라는 이유만으로 해당 라이브러리를 사용할 수 없습니다. 같은 이름의 다른 외부 의존성이 없다는 조건에서 그 호출은 해석되지 않습니다. Cargo.lock과 기본 target은 공유되지만, target 경로도 별도 설정으로 바꿀 수 있습니다.

선택 범위와 resolver는 Cargo workspaces의 가상 manifest·default-members 규칙으로 확인할 수 있습니다.

프로젝트가 커지면 작업공간 사용을 고려해보세요: 하나의 커다란 코드 덩어리보다는 작고 개별적인 구성 요소들을 이해하는 것이 쉬우니까요.

게다가 작업공간에 크레이트들을 유지하는 것은 이 크레이트들이 동시에 자주 변경될 경우 크레이트 간의 조정을 더 쉽게 해 줄 수 있습니다.

Cargo 작업공간은 루트 manifest, member 패키지, 공유 의존성, 패키지별 테스트 경계를 함께 그려야 구조가 명확해집니다.


Cargo 작업공간 학습 판단 기준표

Cargo 작업공간 내용을 예제 코드에 적용할 때 먼저 볼 조건과 나중에 검증할 조건을 나눕니다.