대규모 러스트(Rust) 프로젝트, 효과적으로 구성하는 방법 알아볼까요?

대규모 러스트(Rust) 프로젝트, 효과적으로 구성하는 방법 알아볼까요?

그레이스 콜린스 (Grace Collins)

솔루션 엔지니어 (Solutions Engineer) · 립셀 (Leapcell)

러스트(Rust)를 공부하다 보면 프로젝트 파일 구조가 이게 맞나, 표준적인 건가 헷갈릴 때가 많죠.

이 글에서는 가장 기본적인 main.rslib.rs부터 시작해서, 큰 규모의 러스트(Rust) 프로젝트는 코드를 어떻게 구성하는지 함께 탐험해 볼까요?

러스트(Rust) 프로젝트 구조

크레이트 (Crate)

크레이트(Crate)는 러스트(Rust)의 가장 기본적인 컴파일 단위인데요.

각 크레이트(Crate)는 독립적인 컴파일 대상이며, 라이브러리 (lib 크레이트(Crate)) 또는 실행 파일 (바이너리 크레이트(Crate))이 될 수 있답니다.


크레이트(Crate)에는 루트 파일이 있는데요.

라이브러리 크레이트(Crate)의 경우 src/lib.rs이고, 바이너리 크레이트(Crate)의 경우 src/main.rs입니다.

패키지 (Package)

하지만 기본적인 러스트(Rust) 프로젝트가 이 두 파일만으로 이루어질 순 없겠죠.

패키지(Package)는 하나 이상의 크레이트(Crate) 모음인데요.

패키지(Package)의 메타데이터와 의존성을 정의하는 Cargo.tomlCargo.lock 파일을 포함한답니다.

실제 프로젝트에서 크레이트(Crate)는 코드와 모듈만 포함하고, Cargo.tomlCargo.lock 파일은 패키지(Package)의 일부로서 전체 패키지(Package)를 관리하고 빌드하는 역할을 담당합니다.

예를 들어, cargo new sdk 명령어로 라이브러리를 만들면 다음과 같은 구조가 됩니다.

예시 파일 구조

// 라이브러리 크레이트(Crate)
sdk/
├── Cargo.toml
├── Cargo.lock
└── src
    └── lib.rs



또는

// 바이너리 크레이트(Crate)
sdk/
├── Cargo.toml
├── Cargo.lock
└── src
    └── main.rs



TOML 파일

TOML 파일은 의존성 및 버전 정보를 관리하는 데 사용되는데요.

예를 들면 다음과 같습니다.

[package]
name = "sdk"
version = "0.1.0"
edition = "2021"

# 더 많은 키와 정의는 https://doc.rust-lang.org/cargo/reference/manifest.html 에서 확인하세요.

[dependencies]



오류 처리를 단순화하기 위해 자주 사용되는 패키지인 thiserror를 추가해 볼까요?

cargo add thiserror 명령어를 사용하면 된답니다.

[package]
name = "sdk"
version = "0.1.0"
edition = "2021"

# 더 많은 키와 정의는 https://doc.rust-lang.org/cargo/reference/manifest.html 에서 확인하세요.

[dependencies]
thiserror = "1.0.61"



테스트 코드와 성능 테스트

여기까지 왔다면 이미 완전한 프로젝트를 작성하고 있는 셈인데요.

하지만 어떤 프로젝트에서든 단위 테스트와 성능 테스트를 포함한 테스팅은 필수적인 부분입니다.

그렇다면 이 테스트 파일들은 어디에 두어야 할까요?

계속해서 우리 sdk 프로젝트를 예로 들어보겠습니다.

커뮤니티와 공식 표준에 따르면, 테스트 및 벤치마크 파일은 src와 동일한 수준의 testsbenches 디렉터리에 위치해야 하는데요.

아래와 같습니다.

sdk/
  ├── Cargo.toml
  ├── src/
  │   └── lib.rs  // 실제 라이브러리 코드가 있는 곳
  ├── tests/      // 통합 테스트 코드가 있는 곳
  │   ├── some-integration-tests.rs
  │   └── multi-file-test/          // 여러 파일로 구성된 테스트
  │       ├── main.rs               // 테스트 실행의 진입점 역할
  │       └── test_module.rs        // 테스트 관련 모듈
  └── benches/    // 벤치마크(성능 테스트) 코드가 있는 곳
      ├── large-input.rs
      └── multi-file-bench/         // 여러 파일로 구성된 벤치마크
          ├── main.rs               // 벤치마크 실행의 진입점 역할
          └── bench_module.rs       // 벤치마크 관련 모듈



프로젝트를 처음 작성할 때는 단위 테스트를 관련 코드 파일 바로 아래에 둘 수 있어서 multi-file-test 디렉터리와 파일을 만들 필요는 없답니다.

하지만 개발이 진행되고 테스트 코드가 상당한 공간을 차지하기 시작하면, 주 코드를 깔끔하게 유지하기 위해 tests 폴더로 옮기는 것이 좋습니다.

  • tests/ 에는 기능 테스트 코드가 포함되는데요.

    주로 기능 구현을 확인하는 데 사용됩니다.

  • benches/ 에는 성능 테스트 코드가 포함되는데요.

    주로 성능을 측정하는 데 사용됩니다 (예: 서비스 API 성능 테스트).

워크스페이스 (Workspace)

만약 큰 프로젝트가 여러 개의 러스트(Rust) 프로젝트로 구성된다면 어떨까요?

예를 들어, sdk 크레이트(Crate)는 한 개발자가 관리하고, 이를 기반으로 CLI 프로젝트와 서버 프로젝트도 만들어야 한다고 가정해 봅시다.

코드를 세 개의 개별 프로젝트로 나눠야 할까요?

그러면 일이 복잡해질 수 있답니다.

이를 통합적으로 관리할 방법이 있을까요?

네, 바로 워크스페이스(Workspace)를 사용하면 된답니다.

러스트(Rust)에서 워크스페이스(Workspace)는 단일 프로젝트 내에서 여러 패키지(Package)를 구성하고 관리하는 방법인데요.

워크스페이스(Workspace)는 여러 관련 패키지(Package)에 걸쳐 의존성 관리, 빌드, 테스트를 단순화하는 도구와 메커니즘을 제공합니다.

워크스페이스(Workspace) 사용의 이점

  • 여러 패키지(Package) 구성: 워크스페이스(Workspace)를 사용하면 라이브러리 크레이트(Crate), CLI 도구 또는 다른 유형의 패키지(Package)를 포함하는 여러 패키지(Package)를 그룹화할 수 있답니다.

  • 공유 의존성: 워크스페이스(Workspace)의 모든 패키지(Package)는 단일 Cargo.lock 파일을 공유하여 일관된 의존성 버전을 보장하고 충돌을 방지합니다.

  • 단순화된 빌드 프로세스: 루트 워크스페이스(Workspace) 디렉터리에서 cargo build 또는 cargo test를 실행하면 모든 워크스페이스(Workspace) 패키지(Package)가 재귀적으로 빌드되고 테스트됩니다.

  • 일관성: 공유 Cargo.lock 파일과 통합된 빌드 명령어는 모든 패키지(Package)가 일관되고 잘 조정되도록 보장합니다.

워크스페이스(Workspace) 구조

sdk, cli, server를 같은 워크스페이스(Workspace) 안에 두고 싶다고 가정해 봅시다.

디렉터리 구조는 다음과 같을 겁니다.

일반적인 워크스페이스(Workspace)는 루트 Cargo.toml 파일을 포함하는 최상위 디렉터리와 자체 Cargo.toml 파일 및 소스 디렉터리를 가진 여러 하위 패키지(Package)로 구성됩니다.

my_workspace /
├── Cargo.lock // 모든 워크스페이스 멤버가 공유하는 락 파일
├── Cargo.toml // 워크스페이스 정의 파일
├── crates/    // 개별 크레이트(패키지)들을 모아두는 디렉터리 (관례적)
│   ├── sdk/
│   │   ├── Cargo.toml
│   │   ├── src/
│   │   │   └── lib.rs
│   │   └──── tests/
│   │       ├── some-integration-tests.rs
│   │       └── multi-file-test/
│   │           ├── main.rs
│   │           └── test_module.rs
│   ├── cli/
│   │   ├── Cargo.toml
│   │   ├── src/
│   │   │   └── main.rs
│   │   ├── bin/ // 추가적인 바이너리 실행 파일들을 위한 곳
│   │   │   ├── named-executable.rs
│   │   │   ├── another-executable.rs
│   │   │   └── multi-file-executable/
│   │   │       ├── main.rs
│   │   │       └── some_module.rs
│   │   └──── tests/
│   │       ├── some-integration-tests.rs
│   │       └── multi-file-test/
│   │           ├── main.rs
│   │           └── test_module.rs
│   └── server/
│       ├── Cargo.toml
│       ├── src/
│       │   └── main.rs
│       ├── bin/
│       │   ├── named-executable.rs
│       │   ├── another-executable.rs
│       │   └── multi-file-executable/
│       │       ├── main.rs
│       │       └── some_module.rs
│       ├── tests/
│       │   ├── some-integration-tests.rs
│       │   └── multi-file-test/
│       │       ├── main.rs
│       │       └── test_module.rs
│       └── benches/
│           ├── large-input.rs
│           └── multi-file-bench/
│               ├── main.rs
│               └── bench_module.rs



워크스페이스(Workspace) TOML 파일

더 효율적인 카고(Cargo)의 2세대 리졸버를 사용하기 위해 resolver = "2"를 지정합니다.

[workspace]
resolver = "2" # 2세대 의존성 해결기 사용



워크스페이스(Workspace) 패키지(Package) 정보를 정의합니다.

[workspace.package]
name = "my-workspace"
version = "0.1.0"
edition = "2021"
# 이 정보는 개별 크레이트에서 상속받아 사용할 수 있습니다.



워크스페이스(Workspace) 멤버를 추가합니다.

[workspace]
members = [
    "crates/sdk",
    "crates/cli",
    "crates/server",
]



워크스페이스(Workspace) 멤버 간에 공유되는 의존성을 지정합니다.

[workspace.dependencies]
thiserror = "1.0.61" # 워크스페이스 레벨에서 thiserror 버전 정의



"어? 그런데 sdkCargo.toml에 이미 정의했는데 왜 워크스페이스(Workspace)에 또 정의하죠?" 라고 궁금해하실 수 있는데요.

이는 워크스페이스(Workspace)가 의존성 관리를 중앙 집중화할 수 있게 해주기 때문입니다.

만약 cliserverthiserror가 필요하다면, 각자의 Cargo.toml 파일에 thiserror = "1.0.61"를 별도로 정의해야 할까요?

그럴 수도 있지만, 이는 잠재적인 문제를 야기합니다.

프로젝트마다 다른 버전이 사용되면 컴파일이 느려질 수 있고, 컴파일된 바이너리에 thiserror의 중복된 복사본이 포함될 수 있답니다.

컴파일 시간과 바이너리 크기를 최적화하기 위해 워크스페이스(Workspace)에서 통합된 의존성 버전을 설정합니다.

# 워크스페이스 `Cargo.toml` 파일에
[workspace.dependencies]
thiserror = "1.0.61"

# `cli` 및 `server` 패키지의 `Cargo.toml` 파일에
[dependencies]
thiserror = { workspace = true } # 워크스페이스에 정의된 버전을 사용



패키지(Package) 간 의존성

cliserversdk의 메서드를 사용할 수 있도록 하려면 워크스페이스(Workspace)에서 의존성을 선언해야 합니다.

# 워크스페이스 `Cargo.toml` 파일에
[workspace.dependencies]
# 기존 thiserror 외에 sdk, cli, server 자체도 workspace.dependencies에 정의할 수 있습니다.
# 이렇게 하면 다른 멤버들이 이들을 참조할 때 버전을 명시하지 않아도 됩니다.
sdk = { path = "crates/sdk", version = "0.1.0" } # sdk의 경로와 버전을 명시
# cli = { path = "crates/cli", version = "0.1.0" } # 필요하다면 cli도
# server = { path = "crates/server", version = "0.1.0" } # 필요하다면 server도
thiserror = "1.0.61"



그런 다음, 패키지(Package)별 Cargo.toml 파일에서 다음과 같이 사용합니다.

# cli/Cargo.toml 또는 server/Cargo.toml 파일에
[dependencies]
sdk = { workspace = true } # 워크스페이스에 정의된 sdk를 사용
thiserror = { workspace = true } # 워크스페이스에 정의된 thiserror를 사용



이렇게 하면 워크스페이스(Workspace)의 모든 프로젝트가 중복된 의존성 버전 없이 sdk를 참조할 수 있게 된답니다.