본문으로 건너뛰기

워크스페이스를 이용한 개발(점점 커지는 프로젝트) 팁1

들어가며

이 문서는 2023년에 개인 메모로 끄적이던 워크스페이스 트리를, Cargo 공식 레퍼런스와 Rust Book 14-03 기준으로 다시 정리한 가이드다. 예전 버전은 링크 문법이 깨져 있었고, src/ 아래 폴더를 "internal 패키지"라 부르는 등 용어가 부정확했다. 여기서는 현재 Cargo 기준의 정확한 용어와 계층을 먼저 잡고, 이 저장소가 선호하는 functional core 방향, 즉 순수 로직은 라이브러리 크레이트에 두고 부수 효과는 진입점 껍데기에만 두는 구조에 맞춰 워크스페이스를 구성하는 방법을 다룬다.

용어부터: workspace, package, crate, module

워크스페이스 이야기에서 가장 많이 꼬이는 지점이 계층이다. 공식 문서 기준으로 정리하면 다음과 같다.

  • Workspace: 하나 이상의 package를 함께 관리하는 단위. 멤버들은 워크스페이스 루트의 Cargo.lock 하나와 target/ 출력 디렉토리 하나를 공유하고, cargo check --workspace 같은 명령을 전체 멤버에 한 번에 적용할 수 있다.
  • Package: Cargo.toml 한 개로 정의되는 단위. 하나 이상의 crate target을 담으며, library crate는 패키지당 최대 하나, binary crate는 여러 개 둘 수 있다.
  • Crate: 컴파일 단위. binary crate의 기본 루트는 src/main.rs, library crate의 기본 루트는 src/lib.rs다.
  • Module: crate 내부에서 코드를 조직하는 이름공간. src/interfaces/ 같은 소스 폴더는 "internal package"가 아니라 module이다.

패키지가 되려면 자체 Cargo.toml이 필요하다. 폴더만으로는 module일 뿐이며, module은 mod.rs(또는 폴더명.rs)를 통해 상위 module 트리에 연결된다. 예전 문서처럼 소스 폴더를 패키지라고 부르면 Cargo가 실제로 제공하는 공유·상속 메커니즘을 오해하게 되므로, 용어는 반드시 구분한다. 깨진 링크 문법도 바로잡는다. [텍스트][URL]은 reference link 정의가 없으면 렌더링되지 않는다. Inline link는 Rust Book 14-03: Cargo Workspaces처럼 쓴다.

Root package vs virtual workspace

루트 Cargo.toml[workspace][package]가 함께 있으면 그 패키지가 워크스페이스의 root package다. "주" 패키지가 명확하고 나머지가 보조 라이브러리인 구조에 어울린다. 반면 [package] 없이 [workspace]만 있는 루트 매니페스트를 virtual manifest라 부르고, 이 구성을 virtual workspace라고 한다. 모든 패키지를 대등한 별도 디렉터리로 관리할 때 적합하다.

동작 차이는 크게 두 가지다. 첫째, virtual workspace는 resolver 버전을 추론할 package.edition이 없으므로 resolver를 반드시 명시해야 한다. 둘째, 루트에서 패키지 선택 플래그 없이 cargo build를 실행할 때 root package 구성은 root package만 빌드하지만, virtual workspace는 전체 멤버를 빌드한다.

resolver = "3"를 명시하는 이유

resolver 버전은 의존성 feature unification 동작을 결정한다. edition 2021 패키지는 resolver "2", edition 2024 패키지는 resolver "3"이 기본으로 추론되지만, virtual workspace에는 추론의 근거가 되는 [package]가 없다. 이때 resolver를 생략하면 Cargo가 경고와 함께 구버전 동작을 가정하므로, 새 워크스페이스라면 루트에 resolver = "3"를 명시하는 것이 안전하다. root package 구성에서도 추론에 맡기기보다 명시해 두면 edition을 올릴 때 동작이 갑자기 바뀌는 일을 막을 수 있다.

members와 default-members

members는 워크스페이스에 포함할 패키지 디렉터리 목록이며 crates/* 같은 glob 패턴을 지원한다. 워크스페이스 디렉터리 안에 있는 path 의존성은 자동으로 멤버가 되고, exclude로 특정 경로를 제외할 수 있다. default-members는 루트에서 -p/--workspace 플래그 없이 명령했을 때 대상이 되는 멤버를 지정한다. 지정하지 않으면 root package 구성은 root package가, virtual workspace는 전체 멤버가 기본 대상이 된다. CI에서 습관적으로 cargo build만 치는 경우 이 기본 대상이 무엇인지에 따라 결과가 달라지므로, 의도한 기본값을 default-members로 고정해 두는 편이 예측 가능하다.

workspace.package와 workspace.dependencies 상속

루트의 [workspace.package] 테이블에는 멤버가 상속할 수 있는 메타데이터 키(version, edition, authors, license, rust-version, repository 등)를 정의한다. 멤버 쪽에서는 version.workspace = true처럼 키마다 상속을 선언한다. [workspace.dependencies]에는 공통 의존성의 버전을 한 번만 정의하고, 멤버에서는 workspace = true로 상속받는다. 여기서 선언한 features는 멤버의 [dependencies]에 적은 features와 합산(additive)되며, optional은 이 테이블에서 선언할 수 없고 멤버 쪽에서 결정한다. 두 기능 모두 Rust 1.64 이상에서 동작한다. [workspace.lints](1.74 이상)로 린트 설정도 같은 방식으로 상속할 수 있다. 버전 숫자를 멤버마다 하드코딩해 두면 드리프트가 생기므로, 워크스페이스 내부 의존성과 공통 외부 크레이트는 가급적 루트에서 한 번만 고정한다.

profile과 patch는 루트에서만

[profile.*], [patch], [replace] 섹션은 워크스페이스 루트 매니페스트에서만 인식되고, 멤버의 Cargo.toml에 적으면 경고와 함께 무시된다. 릴리스 최적화 옵션이나 임시 의존성 교체가 왜 적용되지 않는지 확인할 때 가장 먼저 볼 지점이다.

권장 구조: 얇은 binary 껍데기와 library core

이 저장소의 기본 방향은 타입 → 코어 로직 → 어댑터 → 엔트리 포인트 순으로 쌓고, 부수 효과는 경계층에만 두는 것이다. 워크스페이스에서는 이를 두 패키지로 옮기면 된다.

  • core (library package): 순수 도메인 타입과 로직. 파일·네트워크·환경 변수 접근 없이 입력을 받아 출력을 돌려주는 함수들로 구성해 테스트 대부분이 여기서 끝나게 한다.
  • cli (binary package): src/main.rs는 진입점일 뿐이다. 인자 파싱, 설정 로딩, core 호출, 결과 출력만 담당하는 얇은 껍데기로 유지하고 비즈니스 로직은 두지 않는다.

이렇게 나누면 core는 독립적으로 테스트·재사용할 수 있고, 나중에 TUI나 서버 같은 다른 바이너리가 생겨도 같은 core를 그대로 가져다 쓴다. 부수 효과가 어디에 있는지가 패키지 경계로 드러나므로 리뷰하기도 쉽다.

참고로 예전 이 문서는 진입점 패키지와 디렉터리 이름으로 main을 쓰는 것을 권장처럼 적었지만, 그것은 보편적인 권장이 아니라 글쓴이의 역사적 취향이다. Cargo 관례는 패키지와 바이너리 이름을 기능에서 따오는 것이고, cargo run -p cli처럼 이름이 곧 역할이 되게 짓는 편이 낫다.

프로젝트 트리

my-project/
├── Cargo.toml # virtual workspace 루트
├── Cargo.lock # 전체 워크스페이스가 공유
├── target/ # 전체 워크스페이스가 공유하는 빌드 출력
└── crates/
├── core/ # library package — 순수 로직
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # library crate root
│ ├── domain.rs # module
│ └── pipeline/
│ ├── mod.rs # module
│ └── steps.rs
└── cli/ # binary package — 진입점 껍데기
├── Cargo.toml
└── src/
└── main.rs

루트 Cargo.toml

[workspace]
resolver = "3"
members = ["crates/*"]
default-members = ["crates/cli"]

[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
rust-version = "1.85"

[workspace.dependencies]
my-core = { path = "crates/core" }
anyhow = "1"
thiserror = "2"

[profile.release]
lto = true

crates/core/Cargo.toml

[package]
name = "my-core"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true

[dependencies]
thiserror.workspace = true

crates/cli/Cargo.toml

[package]
name = "my-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true

[dependencies]
my-core.workspace = true
anyhow.workspace = true

패키지 단위 명령어

워크스페이스 안에서는 거의 모든 Cargo 명령에 패키지 선택 플래그를 붙일 수 있다.

  • cargo build -p my-cli — 특정 패키지만 빌드. cargo run -p my-cli도 같은 방식.
  • cargo test -p my-core — core 테스트만 빠르게 순환할 때.
  • cargo check --workspace — 전체 멤버 타입체크.
  • 플래그 없이 루트에서 실행 — default-members 대상.
  • 멤버 디렉터리 안에서 실행 — 해당 디렉터리의 패키지가 자동 선택.

자주 하는 실수

  1. 소스 폴더를 패키지라고 부르기src/ 안의 폴더는 module이지 package가 아니다. package는 Cargo.toml이 있는 단위다.
  2. 멤버 매니페스트에 [profile]이나 [patch] 적기 — 무시된다. 루트에만 둔다.
  3. virtual workspace에서 resolver 누락 — 추론할 edition이 없어 경고와 함께 구버전 동작이 가정된다. resolver = "3"를 명시한다.
  4. [workspace.dependencies]optional = true 시도 — 허용되지 않는다. optional 여부는 사용하는 멤버 쪽에서 선언한다.
  5. 한 패키지에 라이브러리 두 개 넣기 — 패키지당 library crate는 최대 하나다. 분리하려면 패키지를 나눠야 한다.
  6. 멤버마다 버전·edition 하드코딩 — 드리프트의 시작이다. [workspace.package] 상속으로 통일한다.
  7. 루트에서 cargo test를 쳤는데 일부만 돌았다default-members 동작 때문이다. 전체를 원하면 --workspace를 붙인다.

언제 쪼갤 것인가 (growth trigger)

작은 프로젝트는 단일 패키지에 module로 나누는 것만으로 충분하며, 워크스페이스는 매니페스트 관리 비용이 따르는 선택지다. 다음 신호가 보일 때 분리를 고려한다.

  • 빌드 시간cargo build --timings 측정에서 독립적으로 바뀌는 코드가 큰 재컴파일 범위를 만들 때. 크레이트 경계는 변경 전파를 제한할 수 있지만, public API나 compilation fingerprint가 바뀌면 의존하는 크레이트도 다시 컴파일되므로 분리 자체가 자동 개선을 보장하지 않는다.
  • 재사용 경계 — 로직을 다른 바이너리나 외부 프로젝트에서도 쓰고 싶을 때. library crate로 분리해야 path 의존성이나 배포 단위가 생긴다.
  • 의존성 격리tokio 같은 무거운 의존성을 특정 바이너리에만 두고 core는 가볍게 유지하고 싶을 때.
  • 테스트 속도 — 순수 core 테스트만 빠르게 돌리는 루프가 필요할 때.
  • 책임 경계 — 도메인별로 소유자나 리뷰 경로가 달라질 때.

마이그레이션 체크리스트

단일 패키지에서 위 구조로 옮길 때의 순서는 다음과 같다.

  1. 루트에 [workspace]를 추가하고 resolver = "3", members, default-members를 정의한다.
  2. 기존 로직을 crates/core로 옮기고 lib.rs를 crate root로 삼는다. 폴더는 module로 연결한다.
  3. main.rscrates/cli로 옮기고, 본문은 core 호출과 입출력만 남긴다.
  4. 공통 의존성 버전을 [workspace.dependencies]로 모으고 멤버는 workspace = true로 상속받는다.
  5. version, edition, license, rust-version[workspace.package]로 통일한다.
  6. [profile]이나 [patch]가 멤버에 남아 있지 않은지 확인하고 루트로 옮긴다.
  7. cargo check --workspacecargo test --workspace가 통과하는지 확인한다.
  8. CI나 스크립트가 특정 패키지를 대상으로 했다면 -p 플래그와 작업 디렉터리를 점검한다.

관련 문서

내부 문서

공식 문서