이미지 로딩 중...

Rust 입문 가이드 33 모듈로 코드 구조화하기 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 5 Views

Rust 입문 가이드 33 모듈로 코드 구조화하기

Rust의 모듈 시스템을 활용하여 코드를 체계적으로 구조화하는 방법을 배웁니다. 모듈 정의부터 가시성 제어, 파일 분리까지 실무에서 바로 활용할 수 있는 모듈 시스템의 모든 것을 다룹니다.


목차

  1. 모듈 기본 개념 - 코드를 논리적으로 그룹화하는 방법
  2. 중첩 모듈과 경로 - 계층적 구조로 코드 조직화하기
  3. 파일 분리와 mod 키워드 - 모듈을 별도 파일로 관리하기
  4. use 키워드와 경로 단축 - 긴 경로를 간결하게 사용하기
  5. pub use와 재수출 - 모듈 API 설계하기
  6. super와 self 키워드 - 상대 경로로 모듈 참조하기
  7. 크레이트와 외부 의존성 - Cargo.toml로 외부 모듈 가져오기
  8. 프라이버시 규칙과 캡슐화 - pub을 활용한 접근 제어
  9. 테스트 모듈 구조화 - cfg(test)로 테스트 분리하기
  10. 워크스페이스와 다중 크레이트 - 대규모 프로젝트 구조화하기

1. 모듈 기본 개념 - 코드를 논리적으로 그룹화하는 방법

시작하며

여러분이 Rust 프로젝트를 진행하다 보면 모든 코드를 하나의 파일에 작성하기 힘들어지는 시점이 옵니다. 함수가 수십 개로 늘어나고, 구조체와 열거형이 여기저기 흩어져 있으면 어떤 코드가 어떤 역할을 하는지 파악하기 어려워집니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 코드가 정리되지 않으면 팀원들이 코드를 이해하는 데 시간이 오래 걸리고, 버그가 발생했을 때 원인을 찾기도 힘들어집니다.

또한 코드의 재사용성도 떨어져서 비슷한 기능을 여러 번 작성하게 됩니다. 바로 이럴 때 필요한 것이 모듈(Module)입니다.

모듈은 관련된 함수, 구조체, 상수 등을 논리적으로 그룹화하여 코드를 체계적으로 관리할 수 있게 해줍니다. 마치 책을 챕터로 나누듯이, 코드를 의미 있는 단위로 분리할 수 있습니다.

개요

간단히 말해서, 모듈은 Rust에서 코드를 네임스페이스로 구분하고 조직화하는 기본 단위입니다. mod 키워드를 사용하여 선언하며, 관련된 코드들을 하나의 그룹으로 묶어줍니다.

모듈이 필요한 이유는 크게 세 가지입니다. 첫째, 코드의 가독성이 향상됩니다.

관련된 기능들이 한곳에 모여 있으면 코드를 이해하기 쉽습니다. 둘째, 이름 충돌을 방지할 수 있습니다.

서로 다른 모듈에서 같은 이름의 함수를 사용할 수 있습니다. 셋째, 접근 제어를 통해 캡슐화를 구현할 수 있습니다.

예를 들어, 데이터베이스 연결 로직을 db 모듈로 분리하고, HTTP 요청 처리를 api 모듈로 분리하면 각 기능의 경계가 명확해집니다. 기존에는 모든 함수와 타입을 전역 스코프에 선언했다면, 이제는 모듈을 사용하여 계층적으로 구조화할 수 있습니다.

이는 마치 파일 시스템에서 폴더를 사용하여 파일을 정리하는 것과 같습니다. 모듈의 핵심 특징은 다음과 같습니다.

첫째, 중첩이 가능합니다. 모듈 안에 또 다른 모듈을 정의할 수 있어 복잡한 구조도 표현할 수 있습니다.

둘째, 기본적으로 비공개(private)입니다. pub 키워드를 사용하여 명시적으로 공개해야 합니다.

셋째, 파일 시스템과 연동됩니다. 모듈을 별도의 파일로 분리하여 관리할 수 있습니다.

이러한 특징들이 대규모 프로젝트에서 코드를 체계적으로 유지하는 데 핵심적인 역할을 합니다.

코드 예제

// 모듈 정의: 사용자 관련 기능을 그룹화
mod user {
    // 구조체 정의
    pub struct User {
        pub name: String,
        age: u32, // private 필드
    }

    // 생성자 함수
    pub fn new(name: String, age: u32) -> User {
        User { name, age }
    }

    // 내부 헬퍼 함수 (private)
    fn validate_age(age: u32) -> bool {
        age >= 18 && age <= 120
    }
}

// 모듈의 공개 항목 사용
fn main() {
    let user = user::new(String::from("홍길동"), 25);
    println!("사용자: {}", user.name);
}

설명

이 코드가 하는 일은 사용자 관련 기능을 user 모듈로 캡슐화하여 코드를 구조화하는 것입니다. 모듈을 사용하면 관련된 타입과 함수를 하나의 논리적 단위로 묶을 수 있습니다.

첫 번째로, mod user 블록은 새로운 모듈을 정의합니다. 중괄호 안의 모든 내용은 user 네임스페이스에 속하게 됩니다.

pub struct User는 외부에서 접근 가능한 공개 구조체를 선언합니다. 하지만 age 필드는 pub이 없으므로 모듈 외부에서 직접 접근할 수 없습니다.

이렇게 하는 이유는 데이터 무결성을 보호하기 위해서입니다. 나이 값을 외부에서 임의로 변경하지 못하게 막을 수 있습니다.

두 번째로, pub fn new 함수는 User 인스턴스를 생성하는 공개 생성자입니다. 이 함수를 통해서만 User 객체를 만들 수 있도록 설계되었습니다.

validate_age 함수는 pub이 없으므로 private 함수입니다. 모듈 내부에서만 사용되는 헬퍼 함수로, 외부에 노출할 필요가 없는 구현 세부사항입니다.

세 번째로, main 함수에서는 user::new처럼 경로(path) 문법을 사용하여 모듈의 함수를 호출합니다. 이중 콜론(::)은 모듈 경로를 구분하는 구분자입니다.

user.name은 공개 필드이므로 접근할 수 있지만, user.age는 private이므로 접근하면 컴파일 에러가 발생합니다. 여러분이 이 코드를 사용하면 데이터 은닉과 캡슐화를 통해 안전한 API를 설계할 수 있습니다.

외부 코드는 공개된 인터페이스만 사용하므로 내부 구현을 자유롭게 변경할 수 있고, 잘못된 데이터가 들어오는 것을 방지할 수 있습니다. 또한 코드의 의도가 명확해져서 다른 개발자가 코드를 이해하기 쉬워집니다.

실전 팁

💡 모듈 이름은 스네이크 케이스(snake_case)를 사용하는 것이 Rust의 네이밍 컨벤션입니다. mod user_manager처럼 작성하세요.

💡 구조체를 공개(pub struct)해도 필드는 기본적으로 private입니다. 각 필드마다 pub을 명시해야 공개됩니다.

💡 모듈 내부의 항목끼리는 pub 없이도 자유롭게 접근할 수 있습니다. private은 모듈 외부에 대한 제한입니다.

💡 생성자 패턴(new 함수)을 사용하면 객체 생성 시 유효성 검사를 강제할 수 있어 안전합니다.

💡 너무 많은 것을 public으로 만들지 마세요. 최소한의 인터페이스만 공개하는 것이 유지보수에 유리합니다.


2. 중첩 모듈과 경로 - 계층적 구조로 코드 조직화하기

시작하며

여러분이 실제 웹 애플리케이션을 개발할 때, 단순히 하나의 모듈만으로는 부족할 때가 많습니다. 예를 들어, 백엔드 API를 만든다면 데이터베이스 로직, 비즈니스 로직, HTTP 핸들러 등을 각각 분리해야 합니다.

그리고 각 영역 안에서도 더 세부적인 분류가 필요합니다. 이런 복잡한 구조를 평면적으로 관리하면 모듈 이름이 길어지고, 관계를 파악하기 어려워집니다.

database_user_repository, database_post_repository 같은 긴 이름들이 나열되면 코드를 읽기 힘들어집니다. 바로 이럴 때 필요한 것이 중첩 모듈입니다.

모듈 안에 또 다른 모듈을 정의하여 계층 구조를 만들 수 있습니다. 이를 통해 코드의 논리적 구조를 명확하게 표현할 수 있습니다.

개요

간단히 말해서, 중첩 모듈은 모듈 안에 하위 모듈을 정의하여 계층적 네임스페이스를 만드는 기능입니다. mod 키워드를 중첩해서 사용하면 됩니다.

중첩 모듈이 필요한 이유는 복잡한 시스템을 논리적으로 분해하기 위해서입니다. 예를 들어, api::handlers::user::create 같은 경로는 "API의 핸들러 중 사용자 관련 생성 기능"이라는 의미를 명확하게 전달합니다.

각 계층이 명확한 책임을 가지므로 코드를 찾기도 쉽고, 수정할 때도 영향 범위를 파악하기 쉽습니다. 대규모 프로젝트에서는 수백 개의 모듈이 있을 수 있는데, 계층 구조가 없으면 관리가 불가능합니다.

기존에는 모든 모듈을 같은 레벨에 나열했다면, 이제는 트리 구조로 조직화할 수 있습니다. 이는 파일 시스템의 디렉토리 구조와 유사합니다.

중첩 모듈의 핵심 특징은 다음과 같습니다. 첫째, 무제한 깊이로 중첩할 수 있습니다.

하지만 실무에서는 3-4단계 정도가 적당합니다. 둘째, 각 레벨마다 독립적인 가시성 제어가 가능합니다.

부모 모듈이 공개되어도 자식 모듈은 비공개일 수 있습니다. 셋째, 경로 문법을 사용하여 접근합니다.

절대 경로(crate::로 시작)와 상대 경로(self::, super::로 시작)를 모두 지원합니다. 이러한 특징들이 복잡한 코드베이스를 체계적으로 유지하는 데 필수적입니다.

코드 예제

// 최상위 모듈: 데이터베이스 관련 기능
mod database {
    // 하위 모듈: 사용자 저장소
    pub mod user {
        pub fn create(name: &str) {
            println!("사용자 생성: {}", name);
        }

        pub fn find_by_id(id: u32) -> Option<String> {
            Some(format!("User_{}", id))
        }
    }

    // 하위 모듈: 게시글 저장소
    pub mod post {
        pub fn create(title: &str) {
            println!("게시글 생성: {}", title);
        }
    }

    // private 하위 모듈: 연결 관리
    mod connection {
        pub fn get_pool() -> String {
            String::from("DB Pool")
        }
    }
}

fn main() {
    // 절대 경로로 접근
    database::user::create("홍길동");

    // 함수 호출 결과 사용
    if let Some(user) = database::user::find_by_id(1) {
        println!("찾은 사용자: {}", user);
    }
}

설명

이 코드가 하는 일은 데이터베이스 관련 기능을 논리적인 계층 구조로 조직화하는 것입니다. 최상위 database 모듈 아래에 user, post, connection 하위 모듈을 배치했습니다.

첫 번째로, mod database 블록 안에 pub mod userpub mod post를 정의했습니다. 이들은 공개 모듈이므로 외부에서 접근할 수 있습니다.

각 모듈은 자신의 책임 영역에 맞는 함수들을 포함합니다. user 모듈은 사용자 관련 작업만, post 모듈은 게시글 관련 작업만 담당합니다.

이렇게 분리하면 코드를 찾을 때 어디를 봐야 할지 명확합니다. 두 번째로, connection 모듈은 pub이 없으므로 database 모듈 외부에서는 접근할 수 없습니다.

이는 데이터베이스 연결 풀 같은 내부 구현 세부사항을 숨기기 위한 것입니다. userpost 모듈이 내부적으로 connection을 사용할 수 있지만, 외부 코드는 알 필요가 없습니다.

이런 캡슐화가 시스템의 복잡도를 낮춰줍니다. 세 번째로, main 함수에서는 database::user::create처럼 이중 콜론으로 구분된 경로를 사용합니다.

이는 절대 경로로, 크레이트의 루트에서부터 시작하는 전체 경로입니다. Rust는 컴파일 타임에 이 경로가 올바른지 검증하므로, 잘못된 경로를 사용하면 즉시 에러를 발견할 수 있습니다.

여러분이 이 패턴을 사용하면 코드의 구조가 한눈에 들어옵니다. 새로운 팀원이 프로젝트에 합류했을 때, 모듈 계층만 보고도 시스템의 전체 구조를 파악할 수 있습니다.

또한 기능을 추가할 때 어디에 코드를 작성해야 할지 명확해지므로 일관성 있는 코드베이스를 유지할 수 있습니다. 리팩토링할 때도 모듈 단위로 이동하거나 분리하기 쉽습니다.

실전 팁

💡 모듈 계층은 3-4단계를 넘지 않는 것이 좋습니다. 너무 깊으면 오히려 복잡해집니다.

💡 use 문을 사용하면 긴 경로를 짧게 줄일 수 있습니다. use database::user;user::create()로 호출하세요.

💡 super를 사용하면 부모 모듈을 참조할 수 있습니다. super::connection::get_pool()처럼 사용합니다.

💡 모듈 이름은 해당 모듈의 역할을 명확하게 표현해야 합니다. utilshelpers 같은 모호한 이름은 피하세요.

💡 비슷한 계층의 모듈들은 비슷한 수준의 추상화를 유지하세요. 한 모듈은 저수준이고 다른 모듈은 고수준이면 구조가 어색해집니다.


3. 파일 분리와 mod 키워드 - 모듈을 별도 파일로 관리하기

시작하며

여러분이 프로젝트가 커지면서 하나의 파일에 수백 줄의 코드가 쌓이면 스크롤하는 것만으로도 시간이 낭비됩니다. 특정 함수를 찾으려면 Ctrl+F로 검색하거나 끝없이 스크롤해야 합니다.

이런 문제는 팀으로 작업할 때 더 심각해집니다. 여러 사람이 같은 파일을 수정하면 Git 충돌이 자주 발생하고, 코드 리뷰도 어려워집니다.

1000줄짜리 파일의 변경사항을 검토하는 것은 누구에게나 부담스러운 일입니다. 바로 이럴 때 필요한 것이 파일 분리입니다.

Rust는 모듈을 별도의 파일이나 디렉토리로 분리할 수 있는 강력한 메커니즘을 제공합니다. 각 모듈을 독립적인 파일로 관리하면 코드 탐색이 쉬워지고, 동시에 여러 파일을 수정할 수 있어 협업이 원활해집니다.

개요

간단히 말해서, Rust에서 mod 모듈명; 선언은 해당 모듈의 내용을 별도 파일에서 찾아 로드하라는 지시입니다. 파일 이름은 모듈 이름과 같아야 합니다.

파일 분리가 필요한 이유는 여러 가지입니다. 첫째, 각 파일이 단일 책임을 가지게 되어 코드를 이해하기 쉽습니다.

user.rs 파일을 열면 사용자 관련 코드만 보입니다. 둘째, IDE의 파일 탐색 기능을 효과적으로 활용할 수 있습니다.

VSCode에서 Ctrl+P로 파일 이름을 검색하는 것이 파일 내에서 함수를 찾는 것보다 빠릅니다. 셋째, 컴파일러가 변경된 파일만 재컴파일할 수 있어 빌드 시간이 단축됩니다.

예를 들어, 마이크로서비스 프로젝트에서 각 서비스를 별도 모듈 파일로 분리하면 한 서비스를 수정해도 다른 서비스는 재컴파일되지 않습니다. 기존에는 모든 모듈을 한 파일에 인라인으로 정의했다면, 이제는 파일 시스템을 활용하여 물리적으로 분리할 수 있습니다.

Rust의 모듈 시스템은 파일 구조와 밀접하게 연결되어 있어서 직관적입니다. 파일 분리의 핵심 특징은 다음과 같습니다.

첫째, 두 가지 방식을 지원합니다. 모듈명.rs 파일 또는 모듈명/mod.rs 디렉토리 방식입니다.

둘째, 상대 경로가 자동으로 처리됩니다. 부모 모듈이 src/api.rs라면 하위 모듈은 src/api/ 디렉토리 안에 배치됩니다.

셋째, pub mod 선언으로 가시성을 제어합니다. 파일로 분리해도 공개/비공개 규칙은 동일하게 적용됩니다.

이러한 특징들이 대규모 프로젝트를 효율적으로 관리할 수 있게 해줍니다.

코드 예제

// src/main.rs
mod user; // user.rs 파일을 로드
mod database; // database.rs 또는 database/mod.rs를 로드

fn main() {
    let u = user::User::new(String::from("김철수"), 30);
    database::connect();
    println!("사용자: {}", u.name);
}

// src/user.rs 파일의 내용
pub struct User {
    pub name: String,
    age: u32,
}

impl User {
    pub fn new(name: String, age: u32) -> Self {
        User { name, age }
    }
}

// src/database.rs 파일의 내용
pub fn connect() {
    println!("데이터베이스 연결 성공");
}

fn internal_helper() {
    // private 함수
}

설명

이 코드가 하는 일은 모듈을 물리적인 파일로 분리하여 프로젝트 구조를 체계화하는 것입니다. main.rs는 진입점 역할만 하고, 실제 로직은 각각의 모듈 파일에 분산되어 있습니다.

첫 번째로, main.rsmod user; 선언은 Rust 컴파일러에게 "같은 디렉토리의 user.rs 파일을 찾아서 user 모듈로 포함시켜라"는 의미입니다. 세미콜론으로 끝나는 것이 중요합니다.

중괄호가 있으면 인라인 모듈이고, 세미콜론이면 외부 파일입니다. 컴파일러는 먼저 src/user.rs를 찾고, 없으면 src/user/mod.rs를 찾습니다.

이 두 가지 방식 중 하나를 선택할 수 있습니다. 두 번째로, user.rs 파일 자체는 최상위 레벨에서 작성됩니다.

파일 전체가 user 모듈의 내용이므로 mod user 블록으로 감싸지 않습니다. pub struct User는 이 모듈을 사용하는 다른 모듈에서 접근할 수 있습니다.

impl 블록도 같은 파일에 있으므로 private 필드에 자유롭게 접근할 수 있습니다. 이런 방식으로 관련된 타입과 구현을 한 파일에 모을 수 있습니다.

세 번째로, database.rsinternal_helper 함수는 pub이 없으므로 모듈 외부에서는 보이지 않습니다. 파일을 분리해도 가시성 규칙은 동일하게 작용합니다.

main.rs에서는 database::connect()만 호출할 수 있고, internal_helper()는 호출할 수 없습니다. 여러분이 이 패턴을 사용하면 프로젝트가 커져도 관리하기 쉽습니다.

파일 탐색기에서 모듈 구조를 한눈에 볼 수 있고, 각 파일이 적절한 크기를 유지하므로 읽기 편합니다. Git에서도 파일 단위로 변경 이력을 추적할 수 있어서 누가 언제 어떤 모듈을 수정했는지 명확합니다.

또한 테스트 코드도 각 모듈 파일에 함께 작성할 수 있어서 응집도가 높아집니다.

실전 팁

💡 모듈 파일 이름은 모듈 이름과 정확히 일치해야 합니다. mod user;user.rs를 찾습니다.

💡 하위 모듈이 많으면 디렉토리 방식을 사용하세요. database/mod.rs, database/connection.rs, database/query.rs 형태로 구성합니다.

💡 Rust 2018 에디션부터는 mod.rs 대신 부모 이름의 파일을 사용할 수 있습니다. database/mod.rs 대신 database.rsdatabase/ 디렉토리를 함께 사용할 수 있습니다.

💡 파일을 분리할 때는 한 파일이 200-300줄을 넘지 않도록 유지하는 것이 좋습니다. 너무 크면 다시 분리를 고려하세요.

💡 pub modmod를 구분하세요. 외부에 노출할 모듈만 pub mod로 선언하면 API가 명확해집니다.


4. use 키워드와 경로 단축 - 긴 경로를 간결하게 사용하기

시작하며

여러분이 코드를 작성하다 보면 같은 모듈의 함수를 여러 번 호출해야 할 때가 있습니다. database::user::repository::find_by_id(1) 같은 긴 경로를 매번 입력하는 것은 번거롭고, 코드도 지저분해 보입니다.

이런 문제는 외부 크레이트를 사용할 때 더 심각해집니다. std::collections::HashMap::new()를 수십 번 반복해서 쓴다고 상상해보세요.

타이핑도 많아지고, 코드의 본질적인 로직이 경로 때문에 가려집니다. 바로 이럴 때 필요한 것이 use 키워드입니다.

use를 사용하면 긴 경로를 스코프로 가져와서 짧은 이름으로 사용할 수 있습니다. 이는 다른 언어의 importusing과 유사하지만, Rust만의 강력한 기능들을 제공합니다.

개요

간단히 말해서, use 키워드는 모듈이나 타입, 함수의 경로를 현재 스코프로 가져와서 짧은 이름으로 접근할 수 있게 해주는 기능입니다. 코드의 가독성을 크게 향상시킵니다.

use가 필요한 이유는 명확합니다. 첫째, 반복적인 타이핑을 줄여줍니다.

한 번 선언하면 짧은 이름으로 계속 사용할 수 있습니다. 둘째, 코드의 의도가 명확해집니다.

HashMap::new()std::collections::HashMap::new()보다 읽기 쉽습니다. 셋째, 리팩토링이 쉬워집니다.

모듈 경로가 변경되어도 use 선언 한 곳만 수정하면 됩니다. 예를 들어, JSON을 다루는 코드에서 serde_json을 사용할 때, use serde_json::Value;로 선언하면 코드 전체에서 Value만 쓰면 됩니다.

기존에는 전체 경로를 항상 명시했다면, 이제는 필요한 항목만 스코프에 가져와서 간결하게 사용할 수 있습니다. 이는 코드의 노이즈를 줄이고 핵심 로직에 집중하게 해줍니다.

use의 핵심 특징은 다음과 같습니다. 첫째, 중괄호로 여러 항목을 한 번에 가져올 수 있습니다.

use std::collections::{HashMap, HashSet}; 같은 형태입니다. 둘째, as 키워드로 별칭을 지정할 수 있습니다.

이름 충돌을 피하거나 긴 이름을 짧게 만들 때 유용합니다. 셋째, *로 모든 공개 항목을 가져올 수 있지만, 이는 권장되지 않습니다.

이러한 특징들이 유연하면서도 명확한 코드를 작성하게 해줍니다.

코드 예제

// 여러 가지 use 패턴
use std::collections::HashMap; // 타입 가져오기
use std::io::{self, Write}; // 모듈 자체와 그 하위 항목 가져오기
use std::fmt::Result as FmtResult; // 별칭 사용

mod database {
    pub mod user {
        pub fn create(name: &str) {
            println!("사용자 생성: {}", name);
        }

        pub fn delete(id: u32) {
            println!("사용자 삭제: {}", id);
        }
    }
}

// 모듈의 함수들을 짧은 이름으로 사용
use database::user::{create, delete};

fn main() {
    // 전체 경로 없이 바로 사용
    create("홍길동");
    delete(1);

    // HashMap도 짧은 이름으로
    let mut map = HashMap::new();
    map.insert("키", "값");
}

설명

이 코드가 하는 일은 다양한 use 패턴을 활용하여 코드의 가독성을 높이는 것입니다. 각각의 use 선언이 서로 다른 목적과 기법을 보여줍니다.

첫 번째로, use std::collections::HashMap;은 가장 기본적인 형태입니다. 표준 라이브러리의 HashMap 타입을 현재 스코프로 가져옵니다.

이후 코드에서 HashMap::new()처럼 전체 경로 없이 사용할 수 있습니다. 이것이 Rust 코드에서 가장 자주 보는 패턴입니다.

컴파일러는 use 선언을 통해 HashMap이 어디서 왔는지 정확히 알고 있으므로 타입 안전성은 유지됩니다. 두 번째로, use std::io::{self, Write};는 중괄호 문법을 사용합니다.

selfio 모듈 자체를 의미하고, Write는 그 안의 트레이트입니다. 이렇게 하면 io::stdin()처럼 모듈을 사용할 수도 있고, Write 트레이트도 직접 사용할 수 있습니다.

관련된 여러 항목을 한 줄로 가져올 수 있어 효율적입니다. 세 번째로, use std::fmt::Result as FmtResult;는 별칭 기능을 보여줍니다.

Rust에는 여러 Result 타입이 있는데, std::io::Resultstd::fmt::Result가 둘 다 필요하면 이름이 충돌합니다. as 키워드로 하나에 별칭을 주면 이 문제를 해결할 수 있습니다.

이는 매우 실용적인 기능입니다. 네 번째로, use database::user::{create, delete};는 함수를 직접 가져오는 예시입니다.

이제 create("홍길동")처럼 마치 현재 모듈의 함수인 것처럼 호출할 수 있습니다. 하지만 주의할 점이 있습니다.

함수를 직접 가져오면 어디서 온 함수인지 불명확해질 수 있으므로, 일반적으로는 모듈까지만 가져오는 것이 좋습니다. use database::user;user::create()로 호출하는 것이 더 명확합니다.

여러분이 use를 적절히 활용하면 코드가 훨씬 깔끔해집니다. 특히 외부 크레이트를 많이 사용하는 프로젝트에서는 use 선언 블록을 파일 상단에 두면 어떤 의존성을 사용하는지 한눈에 파악할 수 있습니다.

또한 IDE의 자동완성 기능도 더 잘 작동합니다.

실전 팁

💡 관례적으로 타입은 직접 가져오고(use std::collections::HashMap;), 함수는 모듈까지만 가져옵니다(use std::mem;mem::drop()).

💡 use 선언은 파일 상단에 모아두되, 표준 라이브러리, 외부 크레이트, 내부 모듈 순으로 그룹화하세요.

💡 use *는 프렐루드(prelude)를 제외하고는 사용하지 마세요. 어떤 항목이 어디서 왔는지 추적하기 어려워집니다.

💡 rustfmt를 사용하면 use 선언을 자동으로 정렬하고 그룹화해줍니다. 일관된 스타일을 유지할 수 있습니다.

💡 이름이 충돌할 때는 as를 사용하거나, 모듈 경로를 일부 포함시켜서 구분하세요. 명확성이 가장 중요합니다.


5. pub use와 재수출 - 모듈 API 설계하기

시작하며

여러분이 라이브러리를 만들 때, 내부적으로는 복잡한 모듈 구조를 가지고 있지만 사용자에게는 간단한 인터페이스를 제공하고 싶을 때가 있습니다. 예를 들어, 내부적으로 internal::utils::helpers::common::parse_data 같은 깊은 경로에 함수가 있지만, 사용자는 mylib::parse_data로 바로 접근하게 하고 싶습니다.

이런 문제는 공개 API를 설계할 때 매우 중요합니다. 내부 구조를 그대로 노출하면 사용자가 복잡한 경로를 외워야 하고, 나중에 리팩토링할 때 하위 호환성이 깨질 수 있습니다.

내부 구조는 자주 변경되지만, 공개 API는 안정적이어야 합니다. 바로 이럴 때 필요한 것이 pub use입니다.

다른 모듈의 항목을 현재 모듈에서 재수출(re-export)하여, 외부 사용자에게는 마치 현재 모듈의 항목인 것처럼 보이게 할 수 있습니다. 이를 통해 편리한 공개 API를 설계할 수 있습니다.

개요

간단히 말해서, pub use는 다른 모듈의 항목을 가져와서(use) 동시에 외부로 공개하는(pub) 기능입니다. 이를 재수출(re-export)이라고 하며, 라이브러리의 공개 인터페이스를 설계하는 핵심 도구입니다.

pub use가 필요한 이유는 API의 사용성과 유지보수성 때문입니다. 첫째, 사용자에게 간결한 API를 제공할 수 있습니다.

깊이 중첩된 모듈의 항목을 최상위 레벨로 끌어올려서 접근하기 쉽게 만듭니다. 둘째, 내부 구조 변경으로부터 공개 API를 보호할 수 있습니다.

내부 모듈을 재구성해도 pub use로 같은 경로를 유지하면 하위 호환성이 보장됩니다. 셋째, 논리적 그룹화가 가능합니다.

여러 모듈에 흩어진 관련 항목들을 한곳에 모아서 재수출할 수 있습니다. 예를 들어, Tokio 라이브러리는 tokio::io, tokio::net 등 여러 모듈을 제공하지만, 자주 사용하는 항목들은 tokio::prelude로 재수출하여 편의성을 높입니다.

기존에는 사용자가 내부 모듈 구조를 정확히 알아야 했다면, 이제는 라이브러리 작성자가 의도한 공개 인터페이스만 제공할 수 있습니다. 이는 API 설계의 자유도를 크게 높여줍니다.

pub use의 핵심 특징은 다음과 같습니다. 첫째, 원본 항목의 가시성과 무관하게 작동합니다.

Private 모듈의 public 항목도 재수출할 수 있습니다. 둘째, 여러 단계를 거쳐 재수출할 수 있습니다.

A 모듈이 B 모듈의 항목을 재수출하고, 다시 루트가 A 모듈의 항목을 재수출할 수 있습니다. 셋째, 문서화에도 영향을 줍니다.

pub use로 재수출된 항목은 재수출한 모듈의 문서에 나타납니다. 이러한 특징들이 사용자 친화적인 라이브러리를 만드는 데 필수적입니다.

코드 예제

// 내부 모듈 구조 (복잡함)
mod internal {
    pub mod parser {
        pub fn parse_json(data: &str) -> String {
            format!("파싱됨: {}", data)
        }
    }

    pub mod validator {
        pub fn validate_email(email: &str) -> bool {
            email.contains('@')
        }
    }
}

// 공개 API: 간단한 경로로 재수출
pub use internal::parser::parse_json;
pub use internal::validator::validate_email;

// 여러 항목을 한 번에 재수출
pub use internal::parser;

fn main() {
    // 사용자는 짧은 경로로 접근
    let result = parse_json("{\"key\": \"value\"}");
    let valid = validate_email("test@example.com");

    println!("{}", result);
    println!("유효한 이메일: {}", valid);

    // 모듈 전체를 재수출했으므로 이것도 가능
    parser::parse_json("{}");
}

설명

이 코드가 하는 일은 복잡한 내부 모듈 구조를 pub use로 재수출하여 간단한 공개 API를 제공하는 것입니다. 사용자는 내부 구조를 알 필요 없이 최상위 레벨의 함수만 사용하면 됩니다.

첫 번째로, internal 모듈은 pub이 없으므로 외부에서 직접 접근할 수 없습니다. 이는 의도적인 설계입니다.

내부 구조는 언제든 변경될 수 있으므로 사용자에게 노출하지 않습니다. 하지만 그 안의 parservalidator 모듈은 pub이므로 재수출이 가능합니다.

이처럼 private 모듈 안의 public 항목도 pub use로 끌어올릴 수 있는 것이 핵심입니다. 두 번째로, pub use internal::parser::parse_json; 선언은 parse_json 함수를 루트 레벨로 재수출합니다.

이제 외부 사용자는 internal::parser::parse_json 대신 그냥 parse_json으로 호출할 수 있습니다. 이는 마치 최상위 모듈에 직접 정의된 것처럼 보입니다.

실제로 Rust 문서 생성 시에도 재수출된 위치에 문서가 나타납니다. 세 번째로, pub use internal::parser;는 모듈 전체를 재수출하는 예시입니다.

이렇게 하면 parser::parse_json()처럼 모듈 이름을 포함한 경로로도 접근할 수 있습니다. 어떤 항목들이 관련되어 있는지 그룹화하여 보여줄 때 유용합니다.

예를 들어, json 모듈을 재수출하면 사용자는 "JSON 관련 기능은 json 모듈에 있구나"라고 직관적으로 이해할 수 있습니다. 여러분이 이 패턴을 사용하면 라이브러리의 사용성이 크게 향상됩니다.

인기 있는 Rust 크레이트들을 보면 모두 이 기법을 활용합니다. 예를 들어, serde 크레이트는 serde::Serializeserde::Deserialize를 최상위에서 제공하지만, 실제로는 내부의 복잡한 매크로 시스템에서 가져온 것입니다.

사용자는 이런 복잡성을 알 필요가 없습니다. 또한 버전업을 하면서 내부 구조를 변경해도 pub use로 같은 경로를 유지하면 기존 코드가 깨지지 않습니다.

실전 팁

💡 라이브러리의 lib.rs에서 pub use를 활용하여 자주 사용되는 항목들을 최상위로 노출하세요.

💡 prelude 패턴을 사용하세요. pub mod prelude { pub use super::*; }로 자주 쓰는 항목을 모아두면 사용자가 use mylib::prelude::*;로 편리하게 가져올 수 있습니다.

💡 재수출할 때 as를 함께 사용하면 이름을 변경할 수 있습니다. pub use internal::OldName as NewName;처럼 API 이름을 개선할 수 있습니다.

💡 문서 주석은 재수출하는 곳에 작성하세요. 사용자는 재수출된 위치의 문서를 보게 됩니다.

💡 내부 모듈을 private으로 유지하면서 선택적으로 항목만 재수출하면, 캡슐화를 유지하면서도 유연한 API를 제공할 수 있습니다.


6. super와 self 키워드 - 상대 경로로 모듈 참조하기

시작하며

여러분이 모듈을 리팩토링하여 다른 위치로 이동시킬 때, 모든 절대 경로를 수정해야 한다면 매우 번거로울 것입니다. 예를 들어, crate::database::user::validate라고 하드코딩된 경로가 여러 곳에 있는데, 모듈 구조를 변경하면 일일이 찾아서 고쳐야 합니다.

이런 문제는 테스트 코드에서도 발생합니다. 각 모듈 파일에 테스트를 작성할 때, 같은 파일의 함수를 호출하는데도 전체 경로를 써야 한다면 코드가 지저분해집니다.

특히 중첩된 모듈 구조에서는 경로가 매우 길어질 수 있습니다. 바로 이럴 때 필요한 것이 superself 키워드입니다.

파일 시스템의 .././처럼, 현재 모듈을 기준으로 상대적인 경로를 표현할 수 있습니다. 이를 통해 유연하고 유지보수하기 쉬운 코드를 작성할 수 있습니다.

개요

간단히 말해서, self는 현재 모듈을 의미하고, super는 부모 모듈을 의미하는 키워드입니다. 이를 사용하면 모듈 간 관계를 상대적으로 표현할 수 있습니다.

superself가 필요한 이유는 코드의 유연성과 가독성 때문입니다. 첫째, 모듈을 이동시켜도 상대 경로는 유지됩니다.

형제 모듈이나 부모 모듈과의 관계는 바뀌지 않으므로, 절대 경로를 수정할 필요가 없습니다. 둘째, 코드의 의도가 명확해집니다.

super::validate()는 "부모 모듈의 validate 함수"라는 의미를 즉시 전달합니다. 셋째, 타이핑이 줄어듭니다.

crate::a::b::c::d::e::function() 대신 super::function()이 훨씬 간결합니다. 예를 들어, 테스트 모듈에서 같은 파일의 함수를 테스트할 때, super::my_function()으로 접근하면 테스트가 어떤 함수를 검증하는지 바로 알 수 있습니다.

기존에는 모든 경로를 크레이트 루트부터 시작하는 절대 경로로 작성했다면, 이제는 상황에 따라 상대 경로를 활용할 수 있습니다. 이는 코드를 더 모듈화되고 재사용 가능하게 만듭니다.

superself의 핵심 특징은 다음과 같습니다. 첫째, 체이닝이 가능합니다.

super::super::function()처럼 여러 단계 위로 올라갈 수 있습니다. 둘째, use 문에서도 사용할 수 있습니다.

use super::common;처럼 부모 모듈의 항목을 가져올 수 있습니다. 셋째, self는 종종 생략됩니다.

self::function()function()은 같은 의미입니다. 하지만 use 문에서는 명시적으로 사용됩니다.

이러한 특징들이 모듈 간 의존성을 명확하게 표현하는 데 도움을 줍니다.

코드 예제

mod database {
    pub fn connect() {
        println!("데이터베이스 연결");
    }

    pub mod user {
        pub fn create(name: &str) {
            // super로 부모 모듈의 함수 호출
            super::connect();
            println!("사용자 생성: {}", name);
        }

        pub fn delete(id: u32) {
            // self로 현재 모듈 명시 (생략 가능)
            self::create("임시사용자");
            println!("사용자 삭제: {}", id);
        }
    }

    pub mod post {
        pub fn create(title: &str) {
            // 부모의 connect 함수 사용
            super::connect();
            // 형제 모듈 접근: super로 부모로 갔다가 user 접근
            super::user::create("작성자");
            println!("게시글 생성: {}", title);
        }
    }
}

fn main() {
    database::post::create("Rust 모듈 가이드");
}

설명

이 코드가 하는 일은 superself 키워드를 사용하여 모듈 간 관계를 상대적으로 표현하는 것입니다. 절대 경로 대신 상대 경로를 사용하면 코드가 더 유연해집니다.

첫 번째로, user 모듈의 create 함수 안에서 super::connect()를 호출합니다. super는 부모 모듈인 database를 가리키므로, 이는 database::connect()와 같은 의미입니다.

하지만 super를 사용하면 "이 함수는 부모 모듈에 의존한다"는 관계를 명확히 드러냅니다. 만약 나중에 user 모듈을 다른 곳으로 이동시키더라도, 새로운 부모 모듈에 connect 함수만 있다면 코드를 수정할 필요가 없습니다.

두 번째로, delete 함수에서 self::create()를 호출합니다. self는 현재 모듈인 user를 의미하므로, 같은 모듈의 create 함수를 호출하는 것입니다.

사실 self::는 생략 가능하지만, 명시적으로 작성하면 "이것은 현재 모듈의 함수"임을 강조할 수 있습니다. 특히 같은 이름의 함수가 여러 곳에 있을 때 혼동을 방지합니다.

세 번째로, post 모듈의 create 함수에서 형제 모듈인 user의 함수를 호출합니다. super::user::create()는 "부모(database)로 올라간 다음, 형제 모듈(user)의 함수를 호출"한다는 의미입니다.

이는 절대 경로 crate::database::user::create()와 같지만, 상대 경로가 더 간결하고 리팩토링에 강합니다. post 모듈과 user 모듈이 함께 다른 모듈 아래로 이동해도 이 코드는 그대로 작동합니다.

여러분이 상대 경로를 적절히 사용하면 모듈의 응집도가 높아집니다. 부모-자식, 형제 관계가 코드에 명시적으로 드러나므로 모듈 구조를 이해하기 쉽습니다.

또한 대규모 리팩토링 시 상대 경로를 사용한 부분은 자동으로 올바르게 동작하므로, 수정해야 할 코드가 줄어듭니다. 테스트 모듈에서도 super::를 사용하면 테스트 대상을 명확히 할 수 있습니다.

실전 팁

💡 테스트 모듈(#[cfg(test)] mod tests)에서는 거의 항상 use super::*;를 사용하여 부모 모듈의 모든 항목을 가져옵니다.

💡 super를 두 번 이상 체이닝하는 것은 피하세요. super::super::function()은 코드를 이해하기 어렵게 만듭니다. 이 경우 절대 경로를 사용하는 것이 낫습니다.

💡 모듈 내부에서는 상대 경로를, 모듈 외부에서는 절대 경로를 사용하는 것이 일반적인 관례입니다.

💡 use self::submodule::*;를 사용하면 하위 모듈의 모든 항목을 현재 모듈로 가져올 수 있지만, 남용하면 이름 충돌이 발생할 수 있습니다.

💡 IDE는 superself도 자동완성과 리팩토링을 지원하므로, 적극적으로 활용하세요.


7. 크레이트와 외부 의존성 - Cargo.toml로 외부 모듈 가져오기

시작하며

여러분이 실제 프로젝트를 진행하다 보면 혼자서 모든 것을 구현할 수 없습니다. JSON 파싱, HTTP 요청, 데이터베이스 연결 등의 기능을 처음부터 만드는 것은 비효율적입니다.

다른 개발자들이 이미 잘 만들어놓은 라이브러리를 활용하는 것이 현명합니다. 이런 외부 라이브러리를 사용하는 것은 다른 언어에서도 흔한 일이지만, Rust는 Cargo라는 강력한 패키지 매니저와 crates.io라는 중앙 저장소를 통해 매우 쉽게 관리할 수 있습니다.

하지만 처음 접하는 개발자들은 어떻게 외부 크레이트를 프로젝트에 추가하고 사용하는지 헷갈릴 수 있습니다. 바로 이럴 때 필요한 것이 Cargo.toml 파일과 외부 크레이트 시스템입니다.

Cargo를 사용하면 의존성 관리, 버전 제어, 빌드까지 모든 과정이 자동화됩니다. 단 몇 줄의 설정만으로 수천 개의 고품질 라이브러리를 프로젝트에 통합할 수 있습니다.

개요

간단히 말해서, 크레이트(crate)는 Rust의 컴파일 단위이자 패키지를 의미합니다. 여러분의 프로젝트도 하나의 크레이트이고, 외부 라이브러리도 크레이트입니다.

Cargo.toml에 의존성을 추가하면 자동으로 다운로드되고 빌드에 포함됩니다. 외부 크레이트가 필요한 이유는 명확합니다.

첫째, 개발 속도가 향상됩니다. 검증된 라이브러리를 사용하면 바퀴를 재발명하지 않아도 됩니다.

둘째, 코드 품질이 높아집니다. 인기 있는 크레이트들은 수많은 사용자와 기여자에 의해 테스트되고 개선되었습니다.

셋째, 표준화된 솔루션을 사용하므로 팀원들이 코드를 이해하기 쉽습니다. 예를 들어, serde로 JSON을 다루고 tokio로 비동기 처리를 하면, Rust 생태계에 익숙한 개발자라면 누구나 코드를 바로 이해할 수 있습니다.

실무에서는 웹 프레임워크(actix-web, axum), ORM(diesel, sqlx), CLI 도구(clap) 등 다양한 크레이트를 조합하여 프로젝트를 구성합니다. 기존에는 라이브러리를 수동으로 다운로드하고 링크해야 했다면, 이제는 Cargo가 모든 것을 자동으로 처리해줍니다.

버전 충돌도 자동으로 해결하고, 크레이트 간 의존성 트리도 관리합니다. 외부 크레이트 시스템의 핵심 특징은 다음과 같습니다.

첫째, 시맨틱 버저닝을 따릅니다. 1.2.3 형태의 버전 번호로 호환성을 보장합니다.

둘째, Cargo.lock 파일로 정확한 버전을 고정합니다. 팀원 모두가 동일한 버전의 의존성을 사용하게 됩니다.

셋째, 피처 플래그로 선택적 기능을 활성화할 수 있습니다. 필요한 기능만 컴파일하여 바이너리 크기를 줄일 수 있습니다.

이러한 특징들이 대규모 프로젝트를 안정적으로 관리할 수 있게 해줍니다.

코드 예제

// Cargo.toml 파일
// [dependencies]
// serde = "1.0"
// serde_json = "1.0"

// src/main.rs
use serde::{Serialize, Deserialize};
use serde_json;

// 외부 크레이트의 트레이트 사용
#[derive(Serialize, Deserialize, Debug)]
struct User {
    name: String,
    age: u32,
}

fn main() {
    // serde_json 크레이트의 함수 사용
    let user = User {
        name: String::from("홍길동"),
        age: 25,
    };

    // 구조체를 JSON 문자열로 직렬화
    let json = serde_json::to_string(&user).unwrap();
    println!("JSON: {}", json);

    // JSON 문자열을 다시 구조체로 역직렬화
    let parsed: User = serde_json::from_str(&json).unwrap();
    println!("파싱됨: {:?}", parsed);
}

설명

이 코드가 하는 일은 외부 크레이트인 serdeserde_json을 사용하여 Rust 구조체와 JSON 간 변환을 수행하는 것입니다. 이는 웹 API 개발에서 가장 흔한 작업 중 하나입니다.

첫 번째로, Cargo.toml 파일의 [dependencies] 섹션에 serde = "1.0"serde_json = "1.0"을 추가합니다. 이는 "serde 크레이트의 1.x 버전 중 최신 호환 버전을 사용하겠다"는 의미입니다.

1.0은 실제로는 ^1.0의 축약형으로, 1.0.0 이상 2.0.0 미만의 버전을 허용합니다. cargo build를 실행하면 Cargo가 자동으로 crates.io에서 이 크레이트들과 그들의 의존성을 다운로드합니다.

두 번째로, 코드에서 use serde::{Serialize, Deserialize};로 필요한 트레이트를 가져옵니다. serde는 크레이트 이름이자 최상위 모듈 이름입니다.

외부 크레이트도 내부 모듈과 똑같이 use로 가져올 수 있습니다. #[derive(Serialize, Deserialize)]는 매크로를 사용하여 자동으로 직렬화/역직렬화 구현을 생성합니다.

이렇게 하면 수백 줄의 보일러플레이트 코드를 작성하지 않아도 됩니다. 세 번째로, serde_json::to_string(&user)serde_json 크레이트의 함수를 호출합니다.

이 함수는 Serialize 트레이트를 구현한 어떤 타입이든 JSON으로 변환할 수 있습니다. 반대로 from_str은 JSON 문자열을 Rust 타입으로 파싱합니다.

타입 추론 덕분에 parsed: User로 타입만 지정하면 올바른 구조체로 변환됩니다. 여러분이 외부 크레이트를 활용하면 생산성이 비약적으로 높아집니다.

serde는 Rust 생태계에서 사실상 표준이므로, 대부분의 데이터 포맷 라이브러리(JSON, YAML, TOML, MessagePack 등)가 serde와 호환됩니다. 한 번 Serialize/Deserialize를 구현하면 다양한 포맷을 모두 지원할 수 있습니다.

또한 crates.io에는 수만 개의 크레이트가 있어서, 거의 모든 문제에 대한 솔루션을 찾을 수 있습니다.

실전 팁

💡 crates.io에서 크레이트를 검색할 때 다운로드 수, 최근 업데이트 날짜, 문서 품질을 확인하세요. 인기 있고 활발히 유지보수되는 크레이트를 선택하세요.

💡 cargo add serde 명령어를 사용하면 CLI에서 바로 의존성을 추가할 수 있습니다. Cargo.toml을 직접 편집할 필요가 없습니다.

💡 피처 플래그를 활용하세요. serde = { version = "1.0", features = ["derive"] } 형태로 필요한 기능만 활성화할 수 있습니다.

💡 cargo tree 명령어로 의존성 트리를 확인하고, 불필요한 의존성이 포함되지 않았는지 점검하세요.

💡 버전 업데이트는 신중하게 하세요. cargo update는 호환 가능한 범위 내에서만 업데이트합니다. 메이저 버전 변경은 수동으로 Cargo.toml을 수정해야 합니다.


8. 프라이버시 규칙과 캡슐화 - pub을 활용한 접근 제어

시작하며

여러분이 라이브러리를 만들 때, 모든 함수와 타입을 공개하면 사용자가 내부 구현에 의존하게 됩니다. 나중에 리팩토링하려고 해도 "이미 사용 중인 공개 API"라서 변경할 수 없는 상황이 발생합니다.

이는 기술 부채로 이어집니다. 이런 문제는 팀 프로젝트에서도 마찬가지입니다.

한 모듈의 내부 헬퍼 함수를 다른 팀원이 "편하다고" 직접 사용하기 시작하면, 모듈 간 결합도가 높아지고 수정이 어려워집니다. 명확한 경계가 없으면 코드가 스파게티처럼 엉키게 됩니다.

바로 이럴 때 필요한 것이 Rust의 프라이버시(privacy) 시스템입니다. 기본적으로 모든 항목은 비공개이고, pub 키워드로 명시적으로 공개해야 합니다.

이를 통해 안정적인 공개 API와 자유롭게 변경 가능한 내부 구현을 분리할 수 있습니다.

개요

간단히 말해서, Rust의 프라이버시 규칙은 모듈 경계에서 작동하며, 기본값은 비공개(private)입니다. pub 키워드를 사용하여 선택적으로 항목을 공개할 수 있고, 심지어 공개 범위도 세밀하게 제어할 수 있습니다.

프라이버시 규칙이 필요한 이유는 소프트웨어 설계의 핵심 원칙인 캡슐화 때문입니다. 첫째, 구현 세부사항을 숨길 수 있습니다.

내부 데이터 구조나 알고리즘을 변경해도 공개 API만 유지하면 사용자 코드는 영향을 받지 않습니다. 둘째, 불변성을 강제할 수 있습니다.

필드를 private으로 만들고 getter/setter를 제공하면 유효성 검사를 강제할 수 있습니다. 셋째, 인터페이스가 명확해집니다.

공개된 항목만 보면 "이 모듈이 제공하는 기능"을 바로 알 수 있습니다. 예를 들어, 데이터베이스 연결 풀을 구현할 때, 연결을 얻고 반환하는 public 메서드만 제공하고, 내부 연결 관리 로직은 private으로 숨기면 사용자는 단순한 인터페이스만 다루면 됩니다.

기존에는 모든 것이 공개되어 있거나, 언어 차원의 접근 제어가 약했다면, Rust는 컴파일 타임에 프라이버시 규칙을 엄격하게 검증합니다. Private 항목에 접근하려는 시도는 컴파일 에러가 됩니다.

프라이버시 규칙의 핵심 특징은 다음과 같습니다. 첫째, pub(crate), pub(super), pub(in path) 같은 제한적 공개를 지원합니다.

전체 공개와 완전 비공개 사이의 중간 단계를 제공합니다. 둘째, 구조체의 필드는 개별적으로 공개 여부를 설정할 수 있습니다.

일부 필드만 공개하고 나머지는 숨길 수 있습니다. 셋째, 자식 모듈은 부모 모듈의 private 항목에 접근할 수 있습니다.

모듈 내부에서는 자유롭게 공유하되, 외부에는 숨길 수 있습니다. 이러한 특징들이 유연하면서도 안전한 API 설계를 가능하게 합니다.

코드 예제

mod api {
    // 크레이트 내부에서만 공개
    pub(crate) struct Config {
        pub endpoint: String,
        api_key: String, // 완전 private
    }

    impl Config {
        pub fn new(endpoint: String, key: String) -> Self {
            Config {
                endpoint,
                api_key: key,
            }
        }

        // private 메서드
        fn validate_key(&self) -> bool {
            !self.api_key.is_empty()
        }

        // public 메서드에서 private 메서드 사용
        pub fn is_valid(&self) -> bool {
            self.validate_key() && !self.endpoint.is_empty()
        }
    }

    // 부모 모듈에서만 공개
    pub(super) fn internal_helper() {
        println!("내부 헬퍼 함수");
    }
}

fn main() {
    let config = api::Config::new(
        String::from("https://api.example.com"),
        String::from("secret123"),
    );

    // public 필드와 메서드는 접근 가능
    println!("엔드포인트: {}", config.endpoint);
    println!("유효성: {}", config.is_valid());

    // 컴파일 에러: api_key는 private
    // println!("{}", config.api_key);

    // 컴파일 에러: validate_key는 private
    // config.validate_key();
}

설명

이 코드가 하는 일은 다양한 프라이버시 수준을 활용하여 API를 설계하는 것입니다. 각 항목의 공개 범위를 세밀하게 제어하여 캡슐화를 구현합니다.

첫 번째로, pub(crate) struct Config는 크레이트 내부에서만 사용 가능한 구조체입니다. 같은 프로젝트의 다른 모듈에서는 접근할 수 있지만, 이 크레이트를 라이브러리로 사용하는 외부 코드에서는 볼 수 없습니다.

이는 "내부 API"를 만들 때 유용합니다. 프로젝트 내에서는 공유하되, 공식 공개 API로는 노출하고 싶지 않을 때 사용합니다.

endpoint 필드는 pub이므로 어디서든 읽고 쓸 수 있지만, api_key는 구조체 외부에서 접근할 수 없습니다. 이렇게 하면 API 키가 실수로 로그에 남거나 노출되는 것을 방지할 수 있습니다.

두 번째로, validate_key 메서드는 pub이 없으므로 완전히 private입니다. 이는 구현 세부사항으로, 외부에서 호출할 필요가 없습니다.

is_valid 같은 public 메서드에서 내부적으로 사용됩니다. 이런 구조를 만들면 나중에 validate_key의 구현을 자유롭게 변경할 수 있습니다.

정규식을 추가하거나, 외부 검증 서비스를 호출하도록 바꿔도 public API는 영향받지 않습니다. 세 번째로, pub(super) fn internal_helper()는 부모 모듈에서만 접근 가능합니다.

api 모듈의 부모는 크레이트 루트이므로, main 함수가 있는 레벨에서는 호출할 수 없습니다. 만약 api 모듈과 같은 레벨에 다른 모듈이 있다면, 그 모듈도 접근할 수 없습니다.

이는 모듈 간 결합도를 낮추는 데 효과적입니다. 여러분이 이런 프라이버시 규칙을 잘 활용하면 코드의 유지보수성이 크게 향상됩니다.

공개 API는 최소화하고, 내부 구현은 자유롭게 변경할 수 있는 구조를 만들 수 있습니다. 또한 컴파일러가 프라이버시 위반을 즉시 잡아내므로, 실수로 내부 API를 사용하는 일이 없습니다.

코드 리뷰 시에도 "이것은 왜 public인가?"라는 질문을 통해 API 설계를 개선할 수 있습니다.

실전 팁

💡 기본적으로 모든 것을 private으로 만들고, 정말 필요한 것만 pub으로 공개하세요. 과도한 공개는 나중에 제거하기 어렵습니다.

💡 구조체를 공개할 때 필드는 private으로 유지하고, 생성자 함수(new)와 getter/setter를 제공하는 패턴이 일반적입니다.

💡 pub(crate)를 활용하여 테스트나 벤치마크에서만 사용되는 내부 API를 만들 수 있습니다. 외부에는 숨기되 테스트에서는 접근 가능합니다.

💡 열거형(enum)의 variant도 개별적으로 공개 여부를 설정할 수 있습니다. pub enum Message { pub Start, Stop }처럼 일부만 공개할 수 있습니다.

💡 #[non_exhaustive] 속성을 사용하면 enum이나 struct에 나중에 variant나 필드를 추가해도 하위 호환성이 유지됩니다.


9. 테스트 모듈 구조화 - cfg(test)로 테스트 분리하기

시작하며

여러분이 함수를 작성한 후 테스트 코드를 추가하려고 할 때, 테스트 코드를 어디에 작성해야 할지 고민하게 됩니다. 별도의 파일로 분리할까요, 아니면 같은 파일에 둘까요?

테스트 코드가 프로덕션 바이너리에 포함되면 크기가 커지는 것은 아닐까요? 이런 문제는 실제로 많은 프로젝트에서 발생합니다.

테스트 코드와 프로덕션 코드가 섞여 있으면 가독성이 떨어지고, 테스트를 위한 헬퍼 함수들이 불필요하게 공개되기도 합니다. 또한 프로덕션 빌드에 테스트 코드가 포함되면 낭비입니다.

바로 이럴 때 필요한 것이 #[cfg(test)] 속성입니다. 이를 사용하면 테스트 모듈을 소스 파일 안에 포함하되, 실제 빌드 시에는 제외할 수 있습니다.

코드와 테스트를 가까이 두면서도 깔끔하게 분리하는 Rust의 관례입니다.

개요

간단히 말해서, #[cfg(test)]는 조건부 컴파일 속성으로, "테스트 빌드일 때만 이 코드를 포함하라"는 의미입니다. 일반적으로 mod tests 모듈에 붙여서 테스트 코드를 분리합니다.

#[cfg(test)]가 필요한 이유는 여러 가지입니다. 첫째, 테스트 코드를 프로덕션 바이너리에서 제외하여 크기를 줄입니다.

테스트 함수, 테스트 전용 의존성, 모킹 코드 등이 모두 제거됩니다. 둘째, 소스 코드와 테스트를 한 파일에 둘 수 있어서 관련성이 명확합니다.

함수를 수정하면 바로 아래의 테스트도 함께 업데이트할 수 있습니다. 셋째, 테스트에서 private 항목에 접근할 수 있습니다.

같은 파일 안의 tests 모듈은 부모 모듈의 private 함수도 테스트할 수 있습니다. 예를 들어, 복잡한 계산 함수를 작성했다면, 그 바로 아래에 #[cfg(test)] mod tests를 두고 다양한 입력값으로 테스트하는 것이 자연스럽습니다.

기존에는 테스트를 별도 디렉토리에 분리했다면, Rust는 유닛 테스트를 같은 파일에, 통합 테스트를 tests/ 디렉토리에 두는 것을 권장합니다. #[cfg(test)]가 이를 가능하게 합니다.

#[cfg(test)]의 핵심 특징은 다음과 같습니다. 첫째, cargo test로 실행할 때만 컴파일됩니다.

cargo buildcargo run에서는 완전히 무시됩니다. 둘째, 테스트 모듈 안에서 use super::*;로 부모 모듈의 항목을 가져올 수 있습니다.

셋째, #[test] 속성을 함수에 붙여서 테스트 함수를 표시합니다. 넷째, assert!, assert_eq! 같은 매크로로 검증합니다.

이러한 특징들이 효과적인 테스트 구조를 만들게 해줍니다.

코드 예제

// src/calculator.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn multiply_internal(a: i32, b: i32) -> i32 {
    a * b
}

pub fn square(n: i32) -> i32 {
    multiply_internal(n, n)
}

// 테스트 모듈: cargo test 시에만 컴파일됨
#[cfg(test)]
mod tests {
    use super::*; // 부모 모듈의 모든 항목 가져오기

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
    }

    #[test]
    fn test_square() {
        assert_eq!(square(4), 16);
        assert_eq!(square(0), 0);
    }

    // private 함수도 테스트 가능
    #[test]
    fn test_multiply_internal() {
        assert_eq!(multiply_internal(3, 4), 12);
    }

    #[test]
    #[should_panic]
    fn test_overflow() {
        add(i32::MAX, 1); // 오버플로우 발생
    }
}

설명

이 코드가 하는 일은 함수 구현과 테스트를 한 파일에 구조화하되, 조건부 컴파일로 분리하는 것입니다. Rust의 표준적인 테스트 패턴을 보여줍니다.

첫 번째로, addsquare는 public 함수로 모듈 외부에 제공하는 API입니다. multiply_internal은 private 함수로 내부 구현 세부사항입니다.

일반적으로 외부 코드는 multiply_internal에 접근할 수 없지만, 테스트 모듈은 예외입니다. 두 번째로, #[cfg(test)] mod tests 블록은 컴파일러에게 "테스트 설정일 때만 이 모듈을 포함하라"고 지시합니다.

cargo build --release로 릴리스 빌드를 만들면 이 블록 전체가 사라집니다. 바이너리를 역컴파일해도 테스트 코드는 찾을 수 없습니다.

use super::*;는 부모 모듈(calculator)의 모든 항목을 현재 스코프로 가져옵니다. 덕분에 add 대신 super::add로 쓸 필요가 없습니다.

세 번째로, #[test] 속성이 붙은 함수는 cargo test 실행 시 자동으로 발견되어 실행됩니다. assert_eq! 매크로는 두 값이 같은지 검증하고, 다르면 테스트를 실패시킵니다.

test_multiply_internal처럼 private 함수도 테스트할 수 있는 것이 중요합니다. 복잡한 private 로직도 철저히 검증할 수 있습니다.

네 번째로, #[should_panic] 속성은 "이 테스트는 패닉이 발생해야 성공"이라는 의미입니다. 오버플로우나 잘못된 입력에 대한 에러 처리를 검증할 때 사용합니다.

디버그 모드에서는 정수 오버플로우가 패닉을 일으키므로 이 테스트가 통과합니다. 여러분이 이 패턴을 사용하면 테스트가 코드와 함께 진화합니다.

함수를 수정하면 바로 아래의 테스트를 확인하고 업데이트할 수 있습니다. 또한 테스트가 문서 역할도 합니다.

함수 사용법이 궁금하면 테스트 코드를 보면 됩니다. 실제로 많은 Rust 프로젝트가 각 모듈 파일 하단에 #[cfg(test)] mod tests를 두는 구조를 따릅니다.

통합 테스트는 tests/ 디렉토리에, 유닛 테스트는 각 파일에 두는 것이 관례입니다.

실전 팁

💡 테스트 모듈 이름은 관례적으로 tests를 사용합니다. 이름 자체는 중요하지 않지만 일관성을 위해 따르세요.

💡 cargo test test_add처럼 특정 테스트만 실행할 수 있습니다. 테스트 이름 패턴으로 필터링됩니다.

💡 #[ignore] 속성을 붙이면 기본적으로 건너뛰는 테스트를 만들 수 있습니다. cargo test -- --ignored로 실행할 수 있습니다.

💡 테스트 전용 헬퍼 함수는 tests 모듈 안에 작성하세요. #[cfg(test)] 덕분에 프로덕션 빌드에 포함되지 않습니다.

💡 cargo test --doc으로 문서 주석의 예제 코드도 테스트할 수 있습니다. 문서와 코드의 일치성을 보장합니다.


10. 워크스페이스와 다중 크레이트 - 대규모 프로젝트 구조화하기

시작하며

여러분이 프로젝트가 점점 커지면서 하나의 크레이트로는 관리하기 어려워질 때가 있습니다. 예를 들어, 백엔드 서버, 프론트엔드 CLI, 공통 라이브러리를 모두 하나의 저장소에서 관리하고 싶지만, 각각 독립적인 바이너리나 라이브러리로 빌드해야 하는 경우입니다.

이런 문제는 모노레포(monorepo) 스타일로 작업할 때 흔합니다. 여러 프로젝트가 코드를 공유하면서도 각각 독립적으로 배포되어야 합니다.

또한 공통 의존성을 중복으로 다운로드하거나 컴파일하는 것은 비효율적입니다. 바로 이럴 때 필요한 것이 Cargo 워크스페이스(workspace)입니다.

하나의 저장소에 여러 크레이트를 두고, 의존성과 빌드 캐시를 공유하면서 효율적으로 관리할 수 있습니다. 대규모 프로젝트나 마이크로서비스 아키텍처에 필수적인 기능입니다.

개요

간단히 말해서, 워크스페이스는 여러 크레이트(패키지)를 하나의 루트 Cargo.toml로 통합 관리하는 기능입니다. 각 크레이트는 독립적으로 컴파일되지만, 의존성과 빌드 결과물을 공유합니다.

워크스페이스가 필요한 이유는 명확합니다. 첫째, 코드 재사용이 쉬워집니다.

공통 라이브러리를 별도 크레이트로 만들고, 여러 바이너리에서 사용할 수 있습니다. 둘째, 빌드 효율이 높아집니다.

같은 의존성을 여러 크레이트가 사용하면 한 번만 다운로드하고 컴파일합니다. target/ 디렉토리도 공유되어 디스크 공간을 절약합니다.

셋째, 버전 관리가 일관됩니다. 워크스페이스 레벨에서 의존성 버전을 고정하면 모든 크레이트가 같은 버전을 사용하게 됩니다.

예를 들어, 회사의 제품이 API 서버, 관리자 CLI, 공통 데이터 모델로 구성되어 있다면, 각각을 별도 크레이트로 만들고 워크스페이스로 묶는 것이 이상적입니다. 기존에는 각 프로젝트를 별도 저장소로 관리하거나, 하나의 거대한 크레이트로 만들었다면, 워크스페이스는 그 중간의 균형을 제공합니다.

논리적으로 분리하되, 물리적으로는 통합된 구조입니다. 워크스페이스의 핵심 특징은 다음과 같습니다.

첫째, 루트 Cargo.toml[workspace] 섹션으로 멤버 크레이트를 나열합니다. 둘째, 각 멤버는 자체 Cargo.toml을 가지지만, Cargo.lock은 루트에 하나만 생성됩니다.

셋째, cargo build --workspace로 모든 크레이트를 한 번에 빌드하거나, cargo build -p crate_name으로 특정 크레이트만 빌드할 수 있습니다. 넷째, 멤버 간 의존성은 경로로 지정합니다.

이러한 특징들이 대규모 프로젝트를 체계적으로 관리할 수 있게 해줍니다.

코드 예제

// 프로젝트 구조:
// my_project/
// ├── Cargo.toml (워크스페이스 루트)
// ├── server/
// │   ├── Cargo.toml
// │   └── src/main.rs
// ├── cli/
// │   ├── Cargo.toml
// │   └── src/main.rs
// └── common/
//     ├── Cargo.toml
//     └── src/lib.rs

// my_project/Cargo.toml (루트)
// [workspace]
// members = ["server", "cli", "common"]

// server/Cargo.toml
// [package]
// name = "server"
// version = "0.1.0"
// [dependencies]
// common = { path = "../common" }

// common/src/lib.rs
pub fn greet(name: &str) -> String {
    format!("안녕하세요, {}님!", name)
}

// server/src/main.rs
use common::greet;

fn main() {
    println!("{}", greet("홍길동"));
}

설명

이 코드가 하는 일은 하나의 저장소에 세 개의 독립적인 크레이트를 두고, 워크스페이스로 묶어서 관리하는 것입니다. 실무에서 매우 흔한 구조입니다.

첫 번째로, 루트의 Cargo.toml[workspace] 섹션만 있고 [package] 섹션은 없습니다. 이는 "이 디렉토리 자체는 크레이트가 아니라 워크스페이스 루트"라는 의미입니다.

members 배열에 나열된 경로(server, cli, common)가 실제 크레이트들입니다. Cargo는 이 경로들을 찾아서 각각의 Cargo.toml을 읽습니다.

두 번째로, server/Cargo.toml에서 common 크레이트를 의존성으로 추가할 때 path = "../common"을 사용합니다. 이는 "crates.io에서 다운로드하지 말고, 로컬 경로의 크레이트를 사용하라"는 의미입니다.

같은 워크스페이스의 멤버끼리는 이런 경로 의존성을 사용합니다. Cargo는 common 크레이트가 변경되면 자동으로 server를 재컴파일합니다.

세 번째로, common/src/lib.rs는 라이브러리 크레이트입니다. main.rs가 아니라 lib.rs이므로 실행 파일이 아닌 라이브러리로 컴파일됩니다.

pub fn greet는 이 라이브러리가 제공하는 공개 API입니다. servercli 둘 다 이 함수를 사용할 수 있습니다.

네 번째로, server/src/main.rs에서 use common::greet;로 공통 라이브러리의 함수를 가져옵니다. common은 크레이트 이름이자 최상위 모듈 이름입니다.

이렇게 하면 중복 코드 없이 여러 바이너리가 같은 로직을 공유할 수 있습니다. 예를 들어, 데이터베이스 모델, 비즈니스 로직, 유틸리티 함수를 common에 두고, servercli가 각자의 역할(API 제공, 명령줄 도구)만 수행하도록 분리할 수 있습니다.

여러분이 워크스페이스를 사용하면 프로젝트가 커져도 관리 가능합니다. cargo test --workspace로 모든 크레이트의 테스트를 한 번에 실행할 수 있고, CI/CD 파이프라인도 간단해집니다.

또한 공통 라이브러리를 나중에 오픈소스로 공개하거나, 다른 프로젝트에 재사용하기도 쉽습니다. Rust의 유명한 프로젝트들(Tokio, Serde, Diesel 등)도 모두 워크스페이스 구조를 사용합니다.

실전 팁

💡 cargo new --lib common으로 라이브러리 크레이트를, cargo new server로 바이너리 크레이트를 생성하세요.

💡 워크스페이스 루트에서 cargo build를 실행하면 모든 멤버가 빌드되지만, 특정 디렉토리에서 실행하면 해당 크레이트만 빌드됩니다.

💡 [workspace.dependencies]를 사용하면 공통 의존성을 한곳에서 관리할 수 있습니다. Rust 1.64부터 지원됩니다.

💡 exclude를 사용하여 워크스페이스에서 특정 디렉토리를 제외할 수 있습니다. 예제나 실험적인 코드를 제외할 때 유용합니다.

💡 각 크레이트가 너무 많은 책임을 가지지 않도록 주의하세요. 단일 책임 원칙은 크레이트 레벨에서도 적용됩니다.


#Rust#Module#Visibility#CodeStructure#Namespace#프로그래밍언어

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.