Cargo 작업공간
하나의 Cargo 작업공간에 여러 패키지를 구성해 공통 잠금 파일과 산출물·외부 의존성을 공유하고 함께 테스트합니다.
모듈성과 에러 처리 향상을 위한 리팩터링에서는 바이너리 크레이트와 라이브러리 크레이트가 든 패키지를 만들었습니다. 프로젝트가 커지다 보면 라이브러리 크레이트가 계속 커져, 패키지를 여러 라이브러리 크레이트로 더 나누고 싶어질 수 있습니다. Cargo는 함께 개발하는 서로 관련된 여러 패키지를 관리하도록 돕는 작업공간(workspace)이라는 기능을 제공합니다.
작업공간 생성하기
작업공간은 같은 Cargo.lock과 결과물 폴더를 함께 쓰는 패키지들의 모음입니다. 작업공간으로 프로젝트를 만들어 봅니다. 작업공간의 구조에 집중할 수 있도록 간단한 코드만 씁니다. 작업공간을 구성하는 방법은 여러 가지가 있으므로, 흔히 쓰는 방법 하나만 봅니다. 이 작업공간에는 바이너리 하나와 라이브러리 둘이 들어갑니다. 주요 기능을 제공하는 바이너리는 두 라이브러리에 의존합니다. 한 라이브러리는 add_one 함수를, 다른 라이브러리는 add_two 함수를 제공합니다. 세 크레이트는 같은 작업공간에 속합니다. 먼저 작업공간을 위한 새 폴더를 만듭니다.
mkdir add
cd add다음으로 add 폴더에 작업공간 전체를 설정할 Cargo.toml 파일을 만듭니다. 이 파일에는 [package] 섹션이 없습니다. 대신 [workspace] 섹션으로 시작하며, 이 섹션으로 작업공간에 멤버를 더합니다. 또 작업공간에서 Cargo의 최신 의존성 해석기(resolver)를 쓰도록 resolver 값을 "3"으로 정합니다.
[workspace]
resolver = "3"그다음 add 폴더 안에서 cargo new를 실행해 adder 바이너리 크레이트를 만듭니다. --lib 없이 실행하므로 src/main.rs가 있는 바이너리 패키지가 만들어지며, 출력의 둘째 줄은 이 패키지가 작업공간의 멤버가 되었다는 것을 알려 줍니다.
cargo new adder Created binary (application) `adder` package
Adding `adder` as member of workspace at `file:///projects/add`작업공간 안에서 cargo new를 실행하면, 새로 만든 패키지를 작업공간 Cargo.toml의 [workspace] 정의에 있는 members 키에 자동으로 더합니다. 그래서 members를 직접 적지 않아도 아래처럼 adder가 든 목록이 생기며, 목록의 각 항목은 작업공간 폴더를 기준으로 한 멤버 패키지의 경로입니다.
[workspace]
resolver = "3"
members = ["adder"]이 상태에서 cargo build로 작업공간을 빌드할 수 있습니다. 그러면 add 폴더의 파일은 다음과 같습니다.
작업공간에는 맨 위에 컴파일된 결과물이 들어갈 target 폴더가 하나 있으며, adder 패키지에는 자기 target 폴더가 없습니다. adder 폴더 안에서 cargo build를 실행하더라도, 컴파일된 결과물은 add/adder/target이 아니라 add/target에 들어갑니다. Cargo가 작업공간의 target 폴더를 이렇게 구성하는 까닭은, 작업공간의 크레이트들이 서로 의존하게 되어 있기 때문입니다. 크레이트마다 자기 target 폴더가 있다면, 각 크레이트는 작업공간의 다른 크레이트를 다시 컴파일해 그 결과물을 자기 target 폴더에 두어야 합니다. 하나의 target 폴더를 함께 쓰면 크레이트들이 불필요하게 다시 빌드되는 것을 피할 수 있습니다.
작업공간에 두 번째 패키지 생성하기
다음으로 작업공간에 멤버 패키지를 하나 더 만들고 이름을 add_one으로 짓습니다. 새 라이브러리 크레이트 add_one을 만듭니다.
cargo new add_one --lib Created library `add_one` package
Adding `add_one` as member of workspace at `file:///projects/add`이제 맨 위의 Cargo.toml의 members 목록에 add_one 경로가 들어 있습니다. adder 때와 마찬가지로 cargo new가 목록 끝에 새 멤버를 더한 것이므로, 이 파일을 직접 고칠 필요는 없습니다.
[workspace]
resolver = "3"
members = ["adder", "add_one"]add 폴더에는 이제 다음 폴더와 파일이 있습니다.
add_one/src/lib.rs 파일에 add_one 함수를 더합니다.
pub fn add_one(x: i32) -> i32 {
x + 1
}이제 바이너리를 가진 adder 패키지가 라이브러리를 가진 add_one 패키지에 의존하게 할 수 있습니다. 먼저 adder/Cargo.toml에 add_one으로 가는 경로 의존성(path dependency)을 더해야 합니다.
[dependencies]
add_one = { path = "../add_one" }Cargo는 작업공간의 크레이트들이 서로 의존한다고 가정하지 않으므로, 의존 관계를 명시해야 합니다. 아래 그림은 작업공간의 멤버 관계와 패키지 사이의 의존 관계를 나누어 보여 줍니다.
RUST · WORKSPACE BOUNDARIES
workspace는 패키지를 모으고 의존성은 각 패키지가 선언한다
본문 add 작업공간의 adder → add_one 호출을 따라갑니다. 공동 lockfile·산출물과 패키지의 API·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 명령이 항상 모든 멤버를 선택하는 것은 아닙니다.
루트의 add/Cargo.toml은 members로 adder와 add_one 두 패키지를 모으며, 각 패키지는 자기 매니페스트(manifest)와 빌드 대상을 가집니다. adder가 라이브러리 add_one을 쓰려면 자기 매니페스트에 경로 의존성을 적어야 하며, 같은 작업공간의 멤버라는 사실만으로는 의존 관계가 생기지 않습니다. 그림의 세로 배치는 루트에서 adder, add_one으로 차례로 실행된다는 뜻이 아니고, 아직 add_two를 더하기 전의 두 패키지 상태입니다. 멤버들은 Cargo.lock과 기본 target 폴더를 함께 쓰며, [workspace.dependencies]에 적은 의존성도 각 멤버가 workspace = true로 물려받겠다고 적어야 쓰입니다. cargo test --workspace와 cargo test -p add_one처럼 명령의 옵션에 따라 고르는 범위가 달라지고, 버전과 배포는 패키지마다 따로 정합니다.
다음으로 adder 크레이트에서 add_one 크레이트의 add_one 함수를 씁니다. adder/src/main.rs 파일을 열어 main 함수가 add_one 함수를 부르도록 고칩니다.
fn main() {
let num = 10;
println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}맨 위의 add 폴더에서 cargo build를 실행해 작업공간을 빌드합니다. 작업공간의 맨 위에서 빌드하면 모든 멤버를 함께 빌드하며, 출력에서 add_one이 adder보다 먼저 컴파일되는 것은 adder가 add_one에 의존하기 때문입니다.
cargo build Compiling add_one v0.1.0 (file:///projects/add/add_one)
Compiling adder v0.1.0 (file:///projects/add/adder)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22sadd 폴더에서 바이너리 크레이트를 실행하려면, -p 인수와 패키지 이름을 cargo run과 함께 써서 작업공간의 어느 패키지를 실행할지 지정합니다. -p는 --package의 짧은 형태입니다. 출력에 Compiling 줄이 없는 것은 바로 앞에서 빌드해 두었기 때문입니다.
cargo run -p adder Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/adder`
Hello, world! 10 plus one is 11!이 명령은 adder/src/main.rs의 코드를 실행하며, 이 코드는 add_one 크레이트에 의존합니다.
작업공간에서 외부 패키지 의존하기
작업공간에는 각 크레이트의 폴더마다 Cargo.lock이 있는 것이 아니라, 맨 위에 Cargo.lock 파일 하나만 있다는 점을 보세요. 그래서 모든 크레이트가 모든 의존성의 같은 버전을 쓰게 됩니다. adder/Cargo.toml과 add_one/Cargo.toml에 rand 패키지를 더하면, Cargo는 둘을 하나의 rand 버전으로 해석하고 그것을 하나의 Cargo.lock에 기록합니다. 작업공간의 모든 크레이트가 같은 의존성을 쓰게 하면, 크레이트들이 언제나 서로 호환됩니다. 다만 서로 호환되지 않는 버전 조건을 적었다면 두 버전이 함께 들어갈 수 있으며, 같은 버전을 쓴다고 모든 API와 동작이 서로 맞는다는 보장은 아닙니다.
add_one 크레이트에서 rand 크레이트를 쓸 수 있도록, add_one/Cargo.toml의 [dependencies] 섹션에 rand 크레이트를 더합니다.
[dependencies]
rand = "0.10.2"이제 add_one/src/lib.rs 파일의 맨 위에 use rand;를 더하고, add 폴더에서 cargo build를 실행해 작업공간 전체를 빌드하면, rand 크레이트를 가져와 컴파일합니다. 스코프로 가져온 rand를 쓰지 않으므로 경고가 하나 나옵니다.
Updating crates.io index
--생략--
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)]` (part of `#[warn(unused)]`) on by default
warning: `add_one` (lib) generated 1 warning (run `cargo fix --lib -p add_one` to apply 1 suggestion)
Compiling adder v0.1.0 (file:///projects/add/adder)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.18srand와 그 의존성을 내려받고 컴파일하는 줄은 생략했습니다. 맨 위의 Cargo.lock에는 이제 add_one이 rand에 의존한다는 정보가 들어 있습니다. 하지만 작업공간의 어딘가에서 rand를 쓰더라도, 작업공간의 다른 크레이트에서 rand를 쓰려면 그 크레이트의 Cargo.toml에도 rand를 더해야 합니다. 예를 들어 adder 패키지의 adder/src/main.rs 파일에 use rand;를 더하면 오류가 납니다.
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 패키지를 쓰는 모든 패키지의 모든 크레이트가, 서로 호환되는 버전을 지정하는 한 같은 버전을 쓰게 해, 공간을 아끼고 작업공간의 크레이트들이 서로 호환되게 합니다.
작업공간의 크레이트들이 같은 의존성의 서로 호환되지 않는 버전을 지정하면, Cargo는 각각을 해석하되 가능한 한 적은 수의 버전으로 해석하려고 합니다.
작업공간에 테스트 추가하기
또 다른 개선으로, add_one 크레이트 안에 add_one::add_one 함수의 테스트를 더합니다. 테스트는 테스트 작성 방법에서 본 것과 같은 모양으로 add_one/src/lib.rs의 tests 모듈에 두며, add_one(2)가 3을 돌려주는지 확인합니다.
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` profile [unoptimized + debuginfo] target(s) in 0.20s
Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)
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-3a47283c568d2b6a)
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 크레이트에서 문서 테스트를 하나도 찾지 못했다는 것을 보여 줍니다. 이 작업공간의 루트는 [package]가 없는 가상 작업공간이고 default-members도 정하지 않았으므로, 루트에서 실행한 cargo test가 모든 멤버를 고릅니다. 일반적인 작업공간에서 루트의 명령이 언제나 모든 멤버를 고르는 것은 아니며, 모든 멤버를 확실히 고르려면 --workspace를 줍니다.
맨 위 폴더에서 -p 플래그와 테스트하고 싶은 크레이트의 이름을 주어, 작업공간의 특정 크레이트의 테스트만 실행할 수도 있습니다.
cargo test -p add_one Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)
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에 배포하면, crates.io에는 작업공간이 아니라 각 크레이트가 저마다의 이름과 버전으로 따로 올라갑니다. cargo test처럼 -p 플래그와 크레이트 이름을 주면 작업공간의 특정 크레이트만 배포할 수 있습니다. Rust 1.90부터는 cargo publish --workspace로 모든 멤버를 서로 의존하는 순서에 맞춰 한 번에 배포할 수도 있으며, default-members를 정하지 않은 가상 작업공간의 루트에서는 옵션 없이 cargo publish를 실행해도 모든 멤버가 골라집니다. 다만 한꺼번에 올려도 전부 성공하거나 전부 실패하는 것은 아니어서, 중간에 네트워크나 서버 오류가 나면 일부 크레이트만 올라간 채로 남을 수 있습니다. 또 crates.io는 경로만 적은 의존성을 받지 않으므로, adder까지 배포하려면 add_one = { path = "../add_one", version = "0.1.0" }처럼 version도 함께 적어야 합니다.
연습 삼아, add_one 크레이트와 같은 방식으로 이 작업공간에 add_two 크레이트를 더해 보세요. 프로젝트가 커지면 작업공간을 쓰는 것을 고려해 보세요. 큰 코드 덩어리 하나보다 작은 개별 구성 요소가 이해하기 쉽습니다. 또 크레이트들을 작업공간에 두면, 자주 함께 바뀌는 크레이트들을 조율하기가 쉬워집니다.
더 깊이: 해석기 버전과 작업공간 전체에 걸친 설정
루트 Cargo.toml의 resolver 값은 작업공간 전체에 적용되며, 멤버나 의존성에 적힌 값은 무시됩니다. Cargo 문서에 따르면 "2"는 2021 에디션의 기본값으로 기능(feature) 통합 방식을 바꾸고, "3"은 2024 에디션의 기본값으로 Rust 1.84 이상이 필요하며 의존성을 고를 때 자기 rust-version 이하를 요구하는 버전을 먼저 고르게 합니다. 보통의 패키지는 package.edition에서 해석기 버전을 정하지만, 이 글의 add처럼 [package]가 없는 가상 작업공간은 정할 에디션이 없으므로 resolver를 직접 적어야 합니다.
해석기 "2"가 바꾼 기능 통합은 작업공간에서 특히 드러납니다. 해석기 "1"은 한 의존성에 켠 기능을 어디서 켰든 모두 합쳤지만, "2"부터는 지금 빌드하지 않는 대상의 플랫폼별 의존성, 빌드 스크립트와 절차적 매크로가 쓰는 의존성, 그리고 지금 빌드하지 않는 개발용 의존성에 켠 기능을 일반 의존성과 합치지 않습니다. 예를 들어 [dev-dependencies]에서만 serde의 std 기능을 켰다면, 라이브러리를 빌드할 때는 그 기능 없이 연결되고 cargo test처럼 테스트를 함께 빌드할 때만 기능이 켜집니다.
여러 멤버가 같은 의존성을 쓴다면 루트에 [workspace.dependencies]로 버전을 한 번 적고, 각 멤버에서 rand.workspace = true처럼 물려받을 수 있습니다. 작업공간 문서에 따르면 이 기능과 버전 같은 패키지 정보를 물려받는 [workspace.package]는 Rust 1.64 이상에서 쓸 수 있습니다. 또 [patch], [replace], [profile.*] 섹션도 루트 매니페스트의 것만 인식되고 멤버의 것은 무시됩니다.
정리
- 작업공간은 같은
Cargo.lock과target폴더를 함께 쓰는 패키지들의 모음으로, 루트Cargo.toml의[workspace]에resolver와members를 적습니다. - 작업공간 안에서
cargo new를 실행하면 새 패키지가members에 자동으로 더해집니다. - 멤버라는 사실만으로 의존 관계가 생기지 않으므로,
add_one = { path = "../add_one" }처럼 각 패키지의Cargo.toml에 경로 의존성을 적습니다. - 외부 의존성은 쓰는 패키지마다 자기
Cargo.toml에 적어야 하며, Cargo는 호환되는 한 작업공간 전체에서 같은 버전을 씁니다. cargo run -p 이름과cargo test -p 이름은 특정 패키지만 고르고,--workspace는 모든 멤버를 고릅니다. 배포는 패키지 단위로 올라가며,cargo publish -p 이름으로 하나만,cargo publish --workspace로 모든 멤버를 배포할 수 있습니다.
연습 문제
1. add_two 더하기
이 작업공간에 add_two 라이브러리를 더하고 adder에서 쓰려면 무엇을 해야 하나요?
해설 보기
add 폴더에서 cargo new add_two --lib를 실행하면 members에 add_two가 자동으로 더해집니다. add_two/src/lib.rs에 pub fn add_two(x: i32) -> i32 { x + 2 }를 쓰고, adder/Cargo.toml의 [dependencies]에 add_two = { path = "../add_two" }를 더한 뒤, adder/src/main.rs에서 add_two::add_two(num)을 부릅니다. 경로 의존성을 적지 않으면 같은 작업공간에 있어도 adder에서 add_two를 찾지 못합니다.
2. 어디에 무엇이 생길까
add/adder 폴더 안에서 cargo build를 실행하면 결과물은 어느 target 폴더에 생기나요? add 폴더에서 cargo test -p adder를 실행하면 add_one의 it_works 테스트도 실행되나요?
해설 보기
작업공간은 맨 위의 target 폴더 하나를 함께 쓰므로, adder 폴더 안에서 빌드해도 결과물은 add/target에 생깁니다. cargo test -p adder는 adder 패키지의 테스트만 실행하므로, adder가 add_one에 의존해 함께 컴파일되더라도 add_one의 it_works는 실행되지 않습니다.
다음 글 cargo install로 바이너리 설치에서는 crates.io의 바이너리 크레이트를 로컬에 설치해 명령줄 도구로 쓰는 방법을 살펴봅니다.