</>PatchNote
목록으로

2024-07-21

HSPACE Rust 특강 #7 — 크레이트와 모듈

RustHSPACE 2024ModulesCargo

지금까지는 파일 하나에 코드를 다 넣고 있었다. 이번 회차는 코드를 어떻게 나누고, 무엇을 공개할 것인가에 대한 이야기다.

크레이트

크레이트(crate) 는 Rust의 컴파일 단위다. 하나의 크레이트는 통째로 컴파일된다.

  • 바이너리 크레이트src/main.rs 가 루트. 실행 파일이 나온다
  • 라이브러리 크레이트src/lib.rs 가 루트. 다른 크레이트에서 쓰인다

패키지 하나에 라이브러리 크레이트 하나와 바이너리 크레이트 여러 개를 둘 수 있다. 비동기 회차에서 다룰 async-chat 예제가 그 구조다.

async-chat/
├── Cargo.toml
└── src/
    ├── lib.rs           # 라이브러리 크레이트 (공용 프로토콜 정의)
    └── bin/
        ├── client.rs    # 바이너리 크레이트
        └── server/      # 또 다른 바이너리 크레이트

src/bin/ 아래 파일들은 각각 별도의 실행 파일이 되고, 공통 코드는 lib.rs 에 두고 서로 가져다 쓴다.

Cargo.toml

의존성과 메타데이터는 여기 적는다.

[package]
name = "my-project"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"

[dev-dependencies]
criterion = "0.5"        # 테스트·벤치마크에서만 쓰임

Cargo.lock 은 실제로 선택된 버전을 고정한다. 바이너리 프로젝트는 커밋하고, 라이브러리는 커밋하지 않는 게 관례라는 얘기가 있었다. 라이브러리는 여러 환경에서 쓰이니 버전을 고정하지 않는 편이 낫다는 것이다.

에디션(edition) 개념도 여기서 처음 제대로 이해했다. Rust는 3년마다 에디션을 내면서 문법을 바꾸는데, 에디션이 다른 크레이트끼리도 링크된다. 그래서 언어가 파괴적 변경을 하면서도 생태계가 쪼개지지 않는다. C++의 표준 버전 전환보다 훨씬 매끄러운 접근이다.

워크스페이스

여러 크레이트를 한 저장소에서 관리할 때 쓴다.

[workspace]
members = ["core", "cli", "server"]

target 디렉터리와 Cargo.lock 을 공유하므로 빌드 결과물이 중복되지 않는다. 과제 저장소도 워크스페이스로 묶여 있어서, 최상위에서 cargo test 하면 하위 크레이트가 전부 돌아간다.

모듈

크레이트가 컴파일 단위라면, 모듈은 그 안에서의 이름 공간이다.

mod spores {
    pub struct Spore { /* ... */ }

    pub fn produce_spore(factory: &mut Sporangium) -> Spore { /* ... */ }

    fn recombine(parent: &mut Cell) { /* ... */ }   // 비공개
}

인라인으로 쓸 수도 있지만, 보통은 파일로 나눈다. 여기서 규칙이 헷갈렸는데, 정리하면 이렇다.

mod widgets; 라고 쓰면 컴파일러가 두 곳을 찾는다.

  1. widgets.rs
  2. widgets/mod.rs

그리고 widgets 모듈의 하위 모듈은 widgets/ 디렉터리 안에 놓인다. 2018 에디션 이후로는 widgets.rs + widgets/ 디렉터리 조합이 권장된다. mod.rs 파일이 여러 개 열려 있으면 에디터 탭이 전부 "mod.rs"가 되는 문제가 있어서다.

과제로 나온 GUI 라이브러리 구조가 정확히 이 형태다.

src/
├── main.rs
├── widgets.rs          # widgets 모듈의 본체
└── widgets/
    ├── button.rs
    ├── label.rs
    └── window.rs
// src/main.rs
mod widgets;

use widgets::Widget;

fn main() {
    let mut window = widgets::Window::new("Rust GUI Demo 1.23");
    window.add_widget(Box::new(widgets::Label::new("This is a small text GUI demo.")));
    window.add_widget(Box::new(widgets::Button::new("Click me!")));
    window.draw();
}
// src/widgets.rs
mod button;
mod label;
mod window;

pub trait Widget {
    fn width(&self) -> usize;
    fn draw_into(&self, buffer: &mut dyn std::fmt::Write);

    fn draw(&self) {                      // 기본 구현
        let mut buffer = String::new();
        self.draw_into(&mut buffer);
        println!("{buffer}");
    }
}

pub use button::Button;
pub use label::Label;
pub use window::Window;

마지막 세 줄이 이 과제의 핵심이었다. mod button; 은 모듈을 비공개로 선언한다. 그래서 바깥에서 widgets::button::Button 으로 접근할 수 없다. 대신 pub use button::Button; 로 타입만 다시 내보내면, 사용자는 widgets::Button 이라고 짧게 쓴다.

내부 파일 구조와 공개 API를 분리하는 방법이다. 나중에 button.rscontrols/button.rs 로 옮겨도 사용자 코드는 안 바뀐다. widgets.rspub use 만 고치면 된다.

모듈 안에서의 경로

// src/widgets/label.rs
use super::Widget;      // 부모 모듈(widgets)의 Widget

경로 지시자는 세 가지다.

  • crate:: — 크레이트 루트부터
  • super:: — 부모 모듈
  • self:: — 현재 모듈

깊이 중첩된 곳에서는 super::super:: 를 이어붙이는 것보다 crate::widgets::Widget 처럼 절대 경로를 쓰는 게 읽기 좋다는 감이 생겼다.

pub의 여러 단계

pub 은 켜고 끄는 스위치가 아니라 범위를 지정하는 도구다.

문법의미
(없음)현재 모듈과 그 자식들에서만
pub모두에게 공개
pub(crate)이 크레이트 안에서만
pub(super)부모 모듈까지만
pub(in path)지정한 경로 안에서만

pub(crate) 이 특히 유용하다. "크레이트 안 여러 모듈이 써야 하지만, 외부에 노출하고 싶지는 않은" 내부 헬퍼가 항상 생기기 때문이다. C++의 friend 나 Java의 패키지 프라이빗보다 훨씬 세밀하게 조절된다.

구조체 필드도 각각 따로 지정한다.

pub struct Config {
    pub name: String,       // 공개
    secret: String,         // 비공개 — 접근자를 통해서만
}

기본이 비공개라는 게 좋다. 의도적으로 공개한 것만 공개된다.

상수, 정적 변수, 그리고 속성

const MAX_SIZE: usize = 1024;           // 인라인됨. 주소가 없다
static NAME: &str = "brain-patchnote";  // 고정 주소를 가진다

const 는 쓰이는 자리마다 값이 복사되고, static 은 프로그램 전체에서 하나의 메모리 위치를 갖는다. static mut 은 데이터 경합이 가능하므로 unsafe 가 필요하다. 동시성 회차에서 다시 나온다.

속성(attribute)도 이 회차에서 정리됐다.

#[derive(Debug, Clone, PartialEq)]      // 트레잇 자동 구현
#[cfg(test)]                            // 조건부 컴파일
#[allow(dead_code)]                     // 린트 끄기
#[test]                                 // 테스트 함수 표시
#[inline]                               // 인라인 힌트

#[cfg(...)] 는 플랫폼별 코드를 나눌 때 쓴다.

#[cfg(target_os = "linux")]
fn platform_specific() { /* ... */ }

테스트와 문서

테스트가 언어에 내장되어 있다는 게 인상적이었다. 별도 프레임워크가 필요 없다.

pub fn luhn(cc_number: &str) -> bool {
    // ...
}

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

    #[test]
    fn test_valid_cc_number() {
        assert!(luhn("4263 9826 4026 9299"));
    }

    #[test]
    fn test_invalid_cc_number() {
        assert!(!luhn("4223 9826 4026 9299"));
    }
}

#[cfg(test)] 덕에 테스트 모듈은 cargo test 일 때만 컴파일된다. 릴리스 바이너리에는 들어가지 않는다. 그리고 use super::*; 로 부모 모듈의 비공개 항목까지 볼 수 있어서, 내부 함수도 테스트할 수 있다. 별도 파일에 테스트를 두는 언어들과 비교하면 큰 장점이다.

문서 주석은 더 재미있다.

/// 신용카드 번호가 Luhn 검사를 통과하는지 확인한다.
///
/// # Examples
///
/// ```
/// assert!(luhn("4263 9826 4026 9299"));
/// ```
pub fn luhn(cc_number: &str) -> bool { /* ... */ }

cargo doc --open 으로 HTML 문서가 나오는데, 예제 코드 블록이 테스트로도 실행된다(doctest). 문서에 적은 예제가 낡아서 컴파일도 안 되는 상황이 cargo test 에서 바로 잡힌다. 문서와 코드가 어긋나는 고전적 문제를 언어가 직접 해결한 셈이다.

과제 #2

  1. Modules for a GUI Library — 위에서 본 그 과제. 한 파일짜리 GUI 라이브러리를 모듈로 쪼개고 pub use 로 API를 정리한다
  2. Luhn Algorithm — 신용카드 번호 검증. 뒤에서부터 순회하며 격번으로 두 배, 9를 넘으면 9를 뺀다
pub fn luhn(cc_number: &str) -> bool {
    let mut sum = 0;
    let mut double = false;
    let mut digits = 0;

    for c in cc_number.chars().rev() {
        if let Some(digit) = c.to_digit(10) {
            digits += 1;
            if double {
                let double_digit = digit * 2;
                sum += if double_digit > 9 { double_digit - 9 } else { double_digit };
            } else {
                sum += digit;
            }
            double = !double;
        } else if c.is_whitespace() {
            continue;
        } else {
            return false;   // 숫자도 공백도 아니면 실패
        }
    }

    digits >= 2 && sum % 10 == 0
}

chars().rev(), to_digit(10)Option 을 돌려주는 것, if let 으로 받는 것까지 앞 회차들에서 배운 게 전부 들어 있다.

  1. Rewriting with Result — 오류 처리 회차에서 이미 다룬 파서 과제

정리

  • 크레이트는 컴파일 단위, 모듈은 그 안의 이름 공간
  • mod foo;foo.rs 또는 foo/mod.rs 를 찾고, 하위 모듈은 foo/
  • mod 는 비공개로 선언하고 pub use 로 필요한 것만 재수출 → 내부 구조와 공개 API 분리
  • pub(crate) 처럼 공개 범위를 세밀하게 지정할 수 있다
  • 테스트와 문서가 언어에 내장되어 있고, 문서 예제가 곧 테스트다

다음 편은 클로저 — 캡처 방식에 따라 갈리는 Fn / FnMut / FnOnce.