Rust mod 키워드: 모듈을 선언하고 파일을 나누는 법

Rust mod 키워드: 모듈을 선언하고 파일을 나누는 법

Rust를 처음 배우면서 파일을 나누기 시작하면 mod에서 한 번쯤 멈칫하게 됩니다. 다른 언어의 importrequire에 익숙하다면 mod user;를 보고 “아, user 파일을 가져오는 건가?”라고 생각하기 쉬운데요. 반은 맞고 반은 틀립니다.

mod는 파일을 읽어 오는 명령이라기보다 이 위치에 이런 이름의 모듈이 있다고 선언하는 키워드입니다. 그 모듈의 내용이 같은 파일 안에 있을 수도 있고, 별도 파일에 있을 수도 있죠. 이번 글에서는 Rust의 mod 키워드가 어떤 일을 하는지, main.rslib.rs에서 모듈 트리가 어떻게 시작되는지, 그리고 실무에서 파일을 어떻게 나누면 덜 헷갈리는지 차근차근 살펴보겠습니다.

mod는 모듈을 선언합니다

가장 작은 예제부터 볼까요? 모듈은 한 파일 안에서도 만들 수 있습니다.

mod greetings {
    pub fn hello(name: &str) {
        println!("Hello, {name}!");
    }
}

fn main() {
    greetings::hello("Rust");
}

여기서 mod greetings { ... }greetings라는 모듈을 만듭니다. 그리고 greetings::hello()처럼 :: 경로 연산자로 모듈 안의 함수를 호출합니다.

중요한 점은 hello() 앞에 pub이 붙어 있다는 것입니다. Rust의 모듈 안에 있는 항목은 기본적으로 부모 모듈에서 볼 수 없습니다. 그래서 부모인 main 쪽에서 호출하려면 함수가 공개되어야 하죠. 모듈 시스템 전체의 큰 그림은 Rust 모듈 시스템 큰 그림에서 먼저 정리했고, 공개 범위는 Rust pub, crate, self, super 글에서 자세히 다룹니다. 여기서는 mod가 모듈을 만든다는 사실에 집중하겠습니다.

한 파일 안에 모듈을 직접 쓰는 방식은 작은 예제나 테스트에는 괜찮습니다. 하지만 코드가 길어지면 파일을 나누고 싶어지죠. 그때도 시작은 똑같이 mod입니다.

파일로 모듈 나누기

다음처럼 src/main.rssrc/greetings.rs로 파일을 나눠보겠습니다.

src/main.rs
mod greetings;

fn main() {
    greetings::hello("Rust");
}
src/greetings.rs
pub fn hello(name: &str) {
    println!("Hello, {name}!");
}

mod greetings;는 “현재 모듈 아래에 greetings라는 자식 모듈이 있다”고 선언합니다. 중괄호로 본문을 직접 쓰지 않았기 때문에, 컴파일러는 정해진 위치에서 모듈 본문을 찾습니다. 이 경우 src/greetings.rs를 찾죠.

여기서 자주 나오는 오해가 있습니다. mod greetings;greetings.rs의 내용을 매번 복사해 넣는 매크로가 아닙니다. 또 다른 언어의 런타임 import처럼 실행 중에 파일을 읽는 것도 아닙니다. 컴파일 시점에 크레이트의 모듈 트리를 구성하는 선언입니다.

그래서 같은 부모 모듈 안에서 같은 이름의 모듈을 두 번 선언하면 안 됩니다. 예를 들어 main.rs에서 이미 mod greetings;를 선언했다면, 다른 파일에서 같은 부모의 greetings 모듈을 다시 선언하는 식으로 구조를 흩뜨리면 혼란만 커집니다. 모듈 선언은 보통 그 모듈의 부모 파일에 한 번만 둔다고 생각하면 됩니다.

크레이트 루트에서 시작하기

Rust 프로젝트의 모듈 트리는 크레이트 루트(crate root)에서 시작합니다. 바이너리 크레이트라면 보통 src/main.rs가 크레이트 루트이고, 라이브러리 크레이트라면 src/lib.rs가 크레이트 루트입니다.

예를 들어 이런 구조가 있다고 해봅시다.

프로젝트 구조
src/
├── main.rs
└── config.rs

main.rs가 크레이트 루트이므로 config 모듈은 main.rs에서 선언합니다.

src/main.rs
mod config;

fn main() {
    let port = config::default_port();
    println!("server port: {port}");
}
src/config.rs
pub fn default_port() -> u16 {
    8080
}

라이브러리라면 모양이 비슷하지만 시작점이 lib.rs입니다.

프로젝트 구조
src/
├── lib.rs
└── config.rs
src/lib.rs
pub mod config;

여기서는 pub mod config;라고 썼습니다. 라이브러리 바깥의 사용자가 my_crate::config::default_port()처럼 접근하려면 모듈 자체도 공개되어야 하기 때문입니다. 함수만 pub이고 모듈이 비공개라면 외부 크레이트에서는 그 경로로 들어올 수 없습니다.

바이너리와 라이브러리를 함께 가진 프로젝트도 있습니다. 예를 들어 src/lib.rs에는 재사용 가능한 로직을 두고, src/main.rs에서는 그 라이브러리를 호출하는 식입니다.

프로젝트 구조
src/
├── lib.rs
├── config.rs
└── main.rs
src/lib.rs
pub mod config;
src/main.rs
fn main() {
    let port = my_app::config::default_port();
    println!("server port: {port}");
}

이 경우 main.rslib.rs는 서로 같은 파일을 자동으로 공유하지 않습니다. main.rs에서 mod config;를 다시 선언하면 src/config.rs를 바이너리 크레이트의 모듈로 또 컴파일하게 됩니다. 보통은 공통 로직을 lib.rs 쪽에 공개해두고, main.rs에서는 패키지 이름으로 라이브러리 크레이트를 사용하는 편이 깔끔합니다. 작은 프로젝트에서는 차이가 잘 안 보이지만, 테스트와 문서화, 여러 바이너리 구성으로 확장할 때 이 구분이 중요해집니다.

중첩 모듈 만들기

프로젝트가 커지면 한 단계 모듈만으로는 부족합니다. 예를 들어 API 관련 코드를 api 모듈 아래에 묶고, 그 안에 usersposts 모듈을 두고 싶다고 해보겠습니다.

요즘 Rust 코드에서는 다음 구조를 많이 씁니다.

프로젝트 구조
src/
├── main.rs
├── api.rs
└── api/
    ├── users.rs
    └── posts.rs

부모 파일에서 자식 모듈을 선언합니다.

src/main.rs
mod api;

fn main() {
    api::users::list_users();
}
src/api.rs
pub mod users;
pub mod posts;
src/api/users.rs
pub fn list_users() {
    println!("users");
}
src/api/posts.rs
pub fn list_posts() {
    println!("posts");
}

main.rsmod api;src/api.rs를 찾습니다. 그리고 api.rs 안의 pub mod users;src/api/users.rs를 찾습니다. 즉 파일 시스템 구조는 모듈 트리를 따라가지만, 항상 부모 모듈이 자식 모듈을 선언한다는 규칙이 유지됩니다.

예전에는 src/api/mod.rs 파일을 많이 썼습니다. 아직도 유효한 방식입니다.

mod.rs 방식
src/
├── main.rs
└── api/
    ├── mod.rs
    ├── users.rs
    └── posts.rs
src/api/mod.rs
pub mod users;
pub mod posts;

두 방식 모두 동작합니다. 다만 새 코드에서는 api.rsapi/ 디렉터리를 함께 두는 방식이 파일을 열었을 때 경로가 더 직접적으로 보여서 선호되는 편입니다. 기존 코드베이스가 mod.rs 방식을 쓰고 있다면 굳이 한꺼번에 바꿀 필요는 없습니다. 한 프로젝트 안에서는 한 가지 스타일을 꾸준히 유지하는 편이 더 중요합니다.

mod와 use는 역할이 다릅니다

mod를 이해할 때 꼭 같이 분리해야 하는 키워드가 use입니다. 둘 다 파일 위쪽에 자주 등장해서 비슷해 보이지만 역할은 완전히 다릅니다.

mod는 모듈을 선언합니다. 즉 컴파일러에게 “이 모듈이 프로젝트 안에 있다”고 알려줍니다.

mod config;

use는 이미 존재하는 경로를 현재 스코프로 가져와 짧게 쓰게 해줍니다.

use crate::config::default_port;

use만 쓴다고 새 모듈이 생기지는 않습니다. 반대로 mod만 쓴다고 경로가 자동으로 짧아지는 것도 아닙니다. 그래서 다음 글인 Rust use 키워드에서는 use가 정확히 무엇을 가져오는지, crate::, super::, self:: 같은 경로와 어떻게 함께 쓰는지 따로 살펴보겠습니다.

흔히 하는 실수

첫 번째 실수는 자식 파일 안에서 자기 자신을 다시 mod로 선언하는 것입니다.

src/main.rs
mod api;
src/api.rs
mod api; // 이런 식으로 다시 선언하면 안 됩니다.

api.rs는 이미 api 모듈의 본문입니다. 그 안에서 mod api;를 또 쓰면 api 안에 또 다른 api 모듈을 찾게 됩니다. 의도한 구조가 아니라면 대부분 잘못된 선언입니다.

두 번째 실수는 파일을 만들기만 하고 부모에 mod 선언을 안 하는 것입니다. src/config.rs 파일이 있어도 main.rslib.rs에서 mod config;를 선언하지 않으면 모듈 트리에 들어오지 않습니다. Rust는 디렉터리를 자동으로 훑어서 모든 파일을 컴파일하지 않습니다. 명시적으로 연결된 모듈만 크레이트의 일부가 됩니다.

세 번째 실수는 pub fn만 붙이면 외부에서 바로 접근할 수 있다고 생각하는 것입니다. 라이브러리에서 src/config.rs 안에 pub fn default_port()가 있어도, lib.rs에서 mod config;로 비공개 모듈을 선언했다면 외부 사용자는 my_crate::config::default_port()로 접근할 수 없습니다. 이럴 때는 pub mod config;로 모듈도 공개하거나, pub use config::default_port;로 필요한 항목만 재공개해야 합니다.

마지막으로, 모듈 이름과 파일 이름은 Rust 식별자 규칙을 따라야 합니다. 파일 이름에 하이픈을 넣어 user-profile.rs처럼 만들고 mod user-profile;로 선언할 수는 없습니다. 하이픈은 뺄셈 연산자로 해석되기 때문입니다. Rust 모듈 파일은 보통 user_profile.rs처럼 스네이크 케이스를 씁니다. 외부 크레이트 이름에는 하이픈이 들어갈 수 있지만 코드에서 참조할 때는 언더스코어로 바뀌는 경우가 많다는 점도 함께 기억해두면 좋습니다.

마치며

mod는 Rust 프로젝트의 파일과 모듈 구조를 이어주는 출발점입니다. 핵심은 단순합니다. 부모 모듈에서 자식 모듈을 mod로 선언하고, 본문을 같은 파일에 쓰거나 정해진 위치의 별도 파일에 둡니다. main.rslib.rs는 그 모듈 트리가 시작되는 크레이트 루트이고요.

처음에는 mod, use, pub이 한꺼번에 섞여 보여 헷갈릴 수 있습니다. 하지만 mod는 모듈 선언, use는 경로 단축, pub은 공개 범위라는 식으로 역할을 나눠 보면 훨씬 선명해집니다. 다음 단계로는 Rust use 키워드에서 긴 경로를 현재 스코프로 가져오는 방법을 이어서 보시면 좋습니다.

더 자세한 규칙은 Rust Reference의 items and modules 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord