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

이 글의 핵심

크레이트가 두세 개를 넘으면 버전이 제각각 올라가고 target 디렉터리가 불어나는 문제가 먼저 옵니다. 최소 워크스페이스 레이아웃에서 시작해 공통 의존성·프로파일 공유, 단일 크레이트나 멀티 레포와 비교한 선택 기준, 공유 도메인 로직·FFI 바인딩·마이크로서비스 사례, 빌드 캐시와 순환 의존성 트러블슈팅까지 이어집니다.

들어가며

Cargo workspace는 여러 패키지(크레이트)를 하나의 저장소에서 함께 빌드·테스트하기 위한 공식적인 모노레포 패턴입니다. Cargo.lock은 워크스페이스 루트에 하나만 두는 것이 일반적이며, 공유 target/ 덕분에 의존성 그래프가 겹칠 때 컴파일량을 줄일 수 있습니다. CLI·라이브러리·프로토콜 크레이트를 나눈 팀, 혹은 내부 공유 크레이트를 여러 서비스가 참조하는 팀에서 특히 자주 씁니다. 이 글은 루트 Cargo.toml 구조와 멤버 관리에서 시작해 workspace.dependencies로 버전을 맞추는 방법, 단일 크레이트·멀티 레포와 비교한 선택 기준, 빌드·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공통 의존성 버전 관리

워크스페이스가 “여러 크레이트를 한 폴더에 둔 것”과 다른 핵심은 Cargo.lock 하나를 공유한다는 점입니다. 멤버가 각자 serde = "1.0"을 선언하더라도 워크스페이스 전체에서 실제로 쓰는 버전은 lock 파일에 한 번만 정해지므로, api는 1.0.190을 쓰고 worker는 1.0.210을 쓰는 식의 어긋남이 생기지 않습니다. 같은 이유로 공유 target/에서 같은 의존성을 한 번만 컴파일할 수 있습니다. 대신 feature 통합이라는 부작용이 있습니다. Cargo는 한 번의 빌드에서 같은 크레이트에 요청된 feature를 합쳐서 컴파일하므로, cargo build --workspace와 cargo build -p api는 의존성의 feature 조합이 달라 서로의 빌드 결과를 재사용하지 못하고 다시 컴파일하는 경우가 많습니다. “한 번 빌드했는데 -p로 다시 빌드하니 tokio부터 또 컴파일한다”는 현상이 대개 이 때문입니다.


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

워크스페이스 생성

디렉터리 구조 생성

# 프로젝트 루트 생성
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"

루트가 [package] 없는 virtual manifest일 때는 resolver를 반드시 명시해야 합니다. 멤버의 edition = "2021"은 루트에 전달되지 않아서, 생략하면 예전 방식인 resolver 1이 쓰이고 Cargo가 virtual workspace defaulting to resolver = "1" despite one or more workspace members being on edition 2021 경고를 냅니다. resolver 1은 빌드 스크립트용 의존성과 일반 의존성의 feature를 섞어 버려서, build-dependencies에서만 필요한 std feature가 no_std 타깃에 켜지는 식의 문제를 일으킵니다. Edition 2024를 쓰는 워크스페이스라면 resolver = "3"이 대응하는 값입니다.

멤버 크레이트 설정

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

이 예제는 설명을 위해 크레이트 이름을 core로 지었지만, 실제 프로젝트에서는 피해야 하는 이름입니다. core는 Rust에 기본으로 들어 있는 표준 크레이트(std의 기반) 이름이라, 같은 이름의 의존성을 추가하면 use core::User가 어느 쪽을 가리키는지 혼동되고 매크로가 생성하는 ::core::... 경로와도 부딪혀 빌드 에러를 만나기 쉽습니다. myapp-core처럼 접두사를 붙이고 코드에서는 use myapp_core::User;로 쓰는 것이 안전합니다(패키지 이름의 -는 코드에서 _로 바뀝니다). 멤버 크레이트끼리는 path 의존성으로 연결하는데, 경로 의존성이 있는 크레이트는 crates.io에 게시할 수 없으므로 게시할 크레이트라면 version도 함께 적어 두어야 합니다(myapp-core = { path = "../core", version = "0.1.0" }).

코드 예제

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)
}

자주 쓰는 명령

빌드 및 실행

# 전체 워크스페이스 검사
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·패치

버전 단일화

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"

상속할 때 멤버에서 feature를 추가하는 것은 가능합니다. tokio = { workspace = true, features = ["fs"] }처럼 쓰면 루트의 feature에 fs가 더해집니다. 반대로 루트에서 켠 feature를 멤버에서 끌 방법은 없고, default-features = false도 멤버 쪽에서 덮어쓸 수 없습니다. 그래서 루트의 [workspace.dependencies]에는 모든 멤버가 공통으로 쓰는 최소한의 feature만 두고, 추가 feature는 필요한 멤버에서 더하는 방식이 관리하기 쉽습니다. 루트에 features = ["full"]을 걸어 두면 가벼운 유틸 크레이트까지 tokio 전체를 컴파일하게 됩니다.

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

사내 포크 사용

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

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

로컬 패치

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

사용 시나리오:

  • 업스트림 버그 긴급 수정

  • 사내 커스터마이징

  • 의존성 디버깅 주의사항:

  • 유지보수 부담 증가

  • 업스트림 업데이트 추적 필요

  • 기한 설정 권장

default-members

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

효과:

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

exclude - 멤버 제외

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

사용 시나리오:

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

프로파일 공유

# 루트 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  # 의존성만 최적화

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

[profile]과 [patch]는 루트 Cargo.toml에서만 효과가 있습니다. 멤버의 Cargo.toml에 [profile.release]를 적으면 warning: profiles for the non root package will be ignored, specify profiles at the workspace root가 뜨고 설정은 무시됩니다. 경고가 빌드 로그에 묻혀서 “LTO를 켰는데 바이너리 크기가 그대로”인 원인을 한참 찾는 경우가 흔합니다. lto = "fat"과 codegen-units = 1은 실행 속도와 크기에는 유리하지만 릴리스 빌드 시간이 크게 늘어나므로, CI에서 매 커밋마다 릴리스 빌드를 한다면 lto = "thin"으로 절충하는 경우가 많습니다. [profile.dev.package."*"]로 의존성만 최적화하는 설정은 게임이나 이미지 처리처럼 디버그 빌드가 너무 느려 쓰기 어려운 프로젝트에서 특히 효과가 큽니다.


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

항목단일 크레이트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: dtolnay/rust-toolchain@stable   # actions-rs/toolchain은 유지보수 중단
        with:
          components: clippy
      
      - 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 ID specification `api` did not match any packages

cd crates/api && cargo build
# error: current package believes it's in a workspace when it's not:
# current:   /path/my-workspace/crates/api/Cargo.toml
# workspace: /path/my-workspace/Cargo.toml
# this may be fixable by adding `crates/api` to the `workspace.members` array ...

원인:

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

두 번째 에러는 Cargo가 상위 디렉터리를 거슬러 올라가며 [workspace]가 있는 Cargo.toml을 찾는 방식 때문에 생깁니다. cargo new crates/api로 크레이트를 만들었는데 members에 추가하지 않았다면, 그 크레이트는 자신이 워크스페이스 안에 있다고 판단하지만 워크스페이스는 그 크레이트를 모르는 상태가 됩니다. 최근 Cargo는 워크스페이스 안에서 cargo new를 실행하면 members에 자동으로 추가해 주지만, glob("crates/*")이 아닌 명시 목록을 쓰는 경우에는 직접 확인해야 합니다. 의도적으로 워크스페이스 밖에 두려는 크레이트라면 루트의 exclude에 넣거나 그 크레이트의 Cargo.toml에 빈 [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`

원인: 같은 크레이트에 대해 동시에 만족할 수 없는 버전 요구가 있을 때 납니다. 흔히 오해하는 것과 달리, 멤버 A가 rand = "0.7", 멤버 B가 rand = "0.8"처럼 semver상 호환되지 않는 버전을 요구하는 것만으로는 에러가 나지 않습니다. Cargo는 두 버전을 모두 빌드해 링크하기 때문입니다(cargo tree --duplicates로 보이는 중복이 이것입니다). 실제로 이 에러가 나는 경우는 한쪽이 serde = "=1.0.150"처럼 정확한 버전을 고정하고 다른 의존성이 ^1.0.160을 요구하는 경우, 또는 links 키가 같은 네이티브 라이브러리 바인딩(openssl-sys 등)이 두 버전으로 들어오려는 경우입니다. 에러 메시지 아래에 “required by package …” 형태로 요구 경로가 나오므로 그 체인을 따라가면 원인을 찾을 수 있습니다.

해결:

# 루트 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.lock과 target/
  2. 의존성 관리
    • workspace.dependencies로 버전 통일
    • 멤버는 .workspace = true로 상속
    • [patch]로 긴급 수정
  3. 빌드 최적화
    • -p 옵션으로 특정 크레이트만 빌드
    • sccache로 캐시 공유
    • cargo nextest로 병렬 테스트
  4. CI/CD
    • 변경된 크레이트만 빌드
    • 공유 캐시 키 사용
    • Clippy·fmt 전체 실행

다음 단계

처음에는 설정할 것이 많아 보이지만, 크레이트가 여러 개로 늘어나면 workspace.dependencies와 공유 target/이 버전 관리와 빌드 시간 양쪽에서 부담을 줄여 줍니다.


자주 묻는 질문 (FAQ)

Q. 워크스페이스에서 failed to select a version for serde 오류가 나면 어떻게 하나요?

A. 같은 의존성에 대해 동시에 만족할 수 없는 버전 요구가 있을 때 나는 오류입니다. 서로 다른 메이저 버전은 Cargo가 둘 다 빌드하므로 문제가 되지 않고, 보통은 어딘가에서 =1.0.150처럼 정확한 버전을 고정했거나 links를 쓰는 네이티브 바인딩 크레이트가 두 버전으로 들어오려 할 때 생깁니다. 에러 메시지의 요구 경로를 따라가 고정된 버전을 풀고, 루트 Cargo.toml의 [workspace.dependencies]에 버전을 한 번만 선언한 뒤 각 멤버에서는 serde.workspace = true로 상속하게 하면 같은 문제가 다시 생기지 않습니다.


같이 보면 좋은 글