Rust 빌드 스크립트와 조건부 컴파일 마스터하기: 완전한 개발자 가이드

발행: (2025년 12월 22일 오전 08:55 GMT+9)
14 min read
원문: Dev.to

Source: Mastering Rust Build Scripts and Conditional Compilation – The Complete Developer’s Guide

위에 제공된 소스 링크 외에 번역할 텍스트를 알려주시면 도와드리겠습니다.

📚 내 책 탐색하기

베스트셀러 작가로서, Amazon에서 제 책들을 살펴보시길 초대합니다.
Medium에서도 저를 팔로우하는 것을 잊지 마세요. 감사합니다—여러분의 지원이 큰 힘이 됩니다!

🏗️ 집 짓기 vs. 러스트 프로그램 빌드

집을 짓는다고 상상해 보세요. 텅 빈 들판에서 바로 판자를 못으로 고정하지 않을 겁니다. 먼저 땅을 조사하고, 수원지를 확인하고, 기초를 부어야 합니다. 골조 작업은 그 다음에 이루어집니다.

러스트 프로그램을 작성하는 것도 비슷합니다. 실제 컴파일이 시작되기 전에 라이브러리를 시스템에서 찾거나, 코드를 생성하거나, 대상 플랫폼을 확인하는 등 사전 작업이 필요합니다. 바로 그때 러스트 빌드 스크립트(build.rs)가 등장하는데, 이들이 바로 여러분의 기초 작업 팀입니다.

build.rs란?

build.rs는 메인 크레이트가 컴파일되기 앞서 백스테이지에서 실행되는 작은 별도의 러스트 프로그램입니다. Cargo(러스트의 빌드 도구이자 패키지 매니저)는 이 스크립트를 자동으로 컴파일하고 실행합니다. 주요 역할은 Cargo와 소통하는 것으로, 콘솔에 Cargo가 이해할 수 있는 형식의 특수 명령을 출력함으로써 최종 제품을 어떻게 빌드할지 지시합니다.

📦 빌드 스크립트를 사용하는 이유는? 개인적인 예시

한 번은 PostgreSQL 데이터베이스와 통신해야 하는 도구를 작성했습니다. 훌륭한 libpq C 라이브러리가 이를 담당하고, 순수 Rust 코드가 그 기존 라이브러리에 링크해야 했습니다.

  • 친구의 macOS 머신에서는 libpq/usr/local/lib에 있었습니다.
  • 내 Linux 서버에서는 /usr/lib/x86_64-linux-gnu에 있었습니다.

Rust 소스 코드에 경로를 하드코딩하는 것은 불가능했습니다. 해결책은? 호스트 시스템에서 libpq를 검색하고 Cargo에게 위치를 알려주는 build.rs 스크립트를 사용하는 것이었습니다.

시스템 라이브러리를 링크하기 위한 최소 build.rs

// build.rs
fn main() {
    // Cargo에게 rustc가 시스템 libpq에 링크하도록 지시
    println!("cargo:rustc-link-lib=pq");

    // 선택 사항: wrapper.h가 변경되면 빌드된 크레이트를 무효화
    println!("cargo:rerun-if-changed=wrapper.h");
}

다음 라인

println!("cargo:rustc-link-lib=pq");

은 마법과 같습니다: 사람을 위한 것이 아니라 Cargo를 위한 것으로, Cargo가 이를 적절한 링커 플래그로 변환합니다.

🛠️ 빌드 스크립트로 Rust 코드 생성

라이브러리를 링크하는 것 외에도, 빌드 스크립트는 컴파일 타임에 Rust 코드를 생성할 수 있습니다.
예를 들어, 데이터 파일(colors.txt)을 Rust enum으로 변환하고 싶을 때, 이를 컴파일 타임에 수행하면 런타임 검증을 컴파일 타임으로 옮길 수 있습니다.

📄 예시 데이터 파일

red   #FF0000
green #00FF00
blue  #0000FF

🛠️ build.rscolors.rs 생성

// build.rs
use std::env;
use std::fs;
use std::io::Write;
use std::path::Path;

fn main() {
    // Cargo가 생성된 파일을 배치하도록 요구하는 디렉터리
    let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
    let dest_path = Path::new(&out_dir).join("colors.rs");
    let mut file = fs::File::create(&dest_path).expect("Unable to create file");

    // 원본 데이터 파일 읽기
    let color_data = fs::read_to_string("colors.txt").expect("Unable to read colors.txt");

    // 생성된 Rust 소스 작성
    writeln!(&mut file, "// Auto‑generated file from build.rs").unwrap();
    writeln!(&mut file, "#[derive(Debug)]").unwrap();
    writeln!(&mut file, "pub enum Color {{").unwrap();

    for line in color_data.lines() {
        // 각 라인을 이름과 헥스값으로 분리
        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() == 2 {
            let name = parts[0];
            // let _hex = parts[1]; // 추가 기능에 사용할 수 있음
            writeln!(&mut file, "    {},", name).unwrap();
        }
    }

    writeln!(&mut file, "}}").unwrap();
}

핵심 포인트

  • OUT_DIR은 Cargo가 생성된 아티팩트를 저장하기 위해 만든 디렉터리입니다.
  • include!(후에 사용)는 생성된 파일을 크레이트에 포함시킵니다.
  • 스크립트는 소스 파일이나 데이터 파일이 변경될 때마다 자동으로 실행됩니다.

📦 생성된 colors.rs

// Auto‑generated file from build.rs
#[derive(Debug)]
pub enum Color {
    red,
    green,
    blue,
}

🚀 main.rs에서 생성된 코드 사용하기

// src/main.rs
// build.rs가 생성한 코드를 포함합니다
include!(concat!(env!("OUT_DIR"), "/colors.rs"));

fn main() {
    let my_color = Color::red;
    println!("My color is {:?}", my_color);

    // 다음 줄은 `yellow`가 존재하지 않기 때문에 컴파일 타임 오류를 일으킵니다:
    // let other_color = Color::yellow;
}

colors.txtyellow #FFFF00과 같이 새로운 색을 추가하면, 빌드 스크립트가 다시 실행되어 colors.rs를 재생성하고 Color::yellow를 자동으로 사용할 수 있게 됩니다.

🏷️ Conditional Compilation – The Architect

빌드 스크립트가 기초 작업팀이라면, 조건부 컴파일(#[cfg(...)])은 설계자와 같습니다. 같은 청사진에서 산장과 해변가 집을 위한 서로 다른 설계를 그려냅니다. (이 논의는 이 발췌 이후에도 계속됩니다.)

컴파일러의 문 앞에 서 있는 경비원처럼 동작합니다. 조건을 확인하고, 조건이 참일 경우에만 그 뒤의 코드를 최종 바이너리에 포함시킵니다.

가장 흔한 사용 사례는 서로 다른 운영 체제에 대한 처리입니다. 예를 들어 현재 사용자의 홈 디렉터리를 가져와야 한다고 가정해 보겠습니다. Unix‑계열 시스템(Linux, macOS)에서는 보통 HOME 환경 변수를 사용합니다. Windows에서는 HOMEDRIVEHOMEPATH를 조합합니다. 런타임에 OS를 검사하는 함수를 작성할 수도 있지만, 이는 약간의 오버헤드와 항상 평가해야 하는 분기를 추가합니다. 대신 컴파일러가 대상 플랫폼에 맞는 코드만 빌드하도록 할 수 있습니다.

fn get_home_dir() -> Option<String> {
    #[cfg(target_os = "windows")]
    {
        std::env::var("HOMEDRIVE")
            .and_then(|drive| std::env::var("HOMEPATH").map(|path| drive + &path))
            .ok()
    }

    #[cfg(any(target_os = "linux", target_os = "macos"))]
    {
        std::env::var("HOME").ok()
    }

    #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
    {
        compile_error!("This OS is not supported for finding home directory.");
    }
}

Windows용으로 컴파일하면 #[cfg(target_os = "windows")] 블록만 포함되고, 나머지는 버려집니다. 다른 플랫폼에서도 동일한 원리가 적용됩니다.

때때로 표현식 안에서 결정을 내려야 할 때가 있습니다. 이때 cfg! 매크로를 사용합니다—컴파일 시점에 true 또는 false를 반환합니다.

fn print_greeting() {
    let greeting = if cfg!(target_os = "windows") {
        "Howdy from Windows!"
    } else if cfg!(target_family = "unix") {
        "Hello from Unix!"
    } else {
        "Greetings, unknown system!"
    };
    println!("{}", greeting);
}

컴파일러는 컴파일 중에 cfg!를 평가하고, 사용되지 않은 분기는 데드 코드 제거를 통해 사라집니다.

Build Scripts + Custom cfg Flags

빌드 스크립트는 환경에 대한 정보를 탐지하고 이를 커스텀 조건부 컴파일 플래그로 메인 코드에 전달할 수 있습니다.

// build.rs
fn main() {
    // Try to find libpq using pkg-config
    if pkg_config::probe_library("libpq").is_ok() {
        println!("cargo:rustc-link-lib=pq");
        // Tell the main code: "We found the system libpq!"
        println!("cargo:rustc-cfg=has_system_libpq");
    }
}

println!("cargo:rustc-cfg=has_system_libpq"); 라인은 커스텀 설정 플래그를 정의합니다.

// src/lib.rs or src/main.rs

#[cfg(has_system_libpq)]
use some_system_libpq_binding::Connection;

#[cfg(not(has_system_libpq))]
use pure_rust_postgres::Connection as FallbackConnection;

pub struct Database {
    #[cfg(has_system_libpq)]
    conn: Connection,
    #[cfg(not(has_system_libpq))]
    conn: FallbackConnection,
}

impl Database {
    pub fn connect(connection_string: &str) -> Result<Self, Box<dyn std::error::Error>> {
        #[cfg(has_system_libpq)]
        {
            let conn = Connection::establish(connection_string)?;
            Ok(Database { conn })
        }
        #[cfg(not(has_system_libpq))]
        {
            let conn = FallbackConnection::connect(connection_string)?;
            Ok(Database { conn })
        }
    }
}

libpq가 설치된 머신에서는 has_system_libpq 플래그가 설정되어 효율적인 시스템 네이티브 바인딩을 사용할 수 있습니다. 그렇지 않은 경우 순수 Rust 구현이 대체로 사용되며, 모든 것이 컴파일 시점에 해결되어 런타임 오버헤드가 없습니다.

Features as cfg Flags

Features는 선택적 의존성과 조건부 코드를 그룹화합니다.

# Cargo.toml
[package]
name = "my_app"
version = "0.1.0"

[features]
default = []                # No default features
json_output = ["serde_json"] # Enabling this feature pulls in the serde_json crate

[depend

```toml
[dependencies]
serde_json = { version = "1.0", optional = true } # Marked as optional
// src/main.rs

#[cfg(feature = "json_output")]
use serde_json;

fn process_data(data: &str) {
    // ... do some processing ...

    #[cfg(feature = "json_output")]
    {
        if let Ok(json) = serde_json::to_string_pretty(&data) {
            println!("{}", json);
        }
    }

    #[cfg(not(feature = "json_output"))]
    {
        println!("Processed: {}", data);
    }
}

사용자는 다음과 같이 기능을 활성화할 수 있습니다:

cargo build --features "json_output"

101 책

101 Books는 저자 Aarav Joshi가 공동 설립한 AI 기반 출판사입니다. 첨단 AI 기술을 활용해 출판 비용을 매우 낮게 유지하고 있으며—일부 도서는 $4에 판매됩니다—그 결과 양질의 지식을 모두에게 제공할 수 있습니다.

  • 📚 Amazon에서 우리의 책 **Golang Clean Code**을 확인해 보세요.
  • 🔔 업데이트와 흥미로운 소식을 기대해 주세요.
  • 🔎 책을 구매할 때 Aarav Joshi를 검색하면 더 많은 타이틀을 찾을 수 있습니다.
  • 🎁 위의 링크를 사용해 특별 할인을 누리세요!

우리의 창작물

우리 프로젝트를 탐색하세요:

We Are on Medium

# Conditional Compilation in Rust

A user runs

```bash
cargo build --features "json_output"

to get the JSON version. Otherwise, they get the simple text output. This is how large libraries offer modular functionality without forcing all users to download every possible dependency.

A word of caution from experience:
Conditional compilation is incredibly useful, but it can make your code harder to test. If you have a block of code guarded by #[cfg(target_os = "windows")], you can’t test it on your Linux laptop. Sometimes, for integration tests, you might want to compile and run tests for all targets on a CI server. Over‑using cfg can also make the logical flow of your code harder to follow, as chunks of it become invisible depending on how you’re compiling.

The philosophy behind these systems is what I find most compelling. They move decisions from runtime to compile time where possible. They turn what is often a messy, script‑heavy build process in other languages into a declarative, integrated, and type‑safe part of your Rust project.

  • My build.rs is just another Rust file, subject to the same safety and tooling.
  • My conditional compilation is checked by the compiler, catching typos in feature names or cfg predicates.

It allows you to write one codebase that is honest about the differences in the world—different OSes, different hardware, different sets of required features—and then lets the compiler assemble the exact right program for a given situation. It’s not about hiding complexity, but about managing it in a structured, reliable, and efficient way.

Think of it as starting with a plan for all possible houses; the build system, guided by your scripts and conditions, gathers the right materials and builds the one you actually need for the plot of land you’re on.


📘 Check out my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!

Back to Blog

관련 글

더 보기 »

공허

초기 경험 안녕하세요. 이것은 제가 Rust 프로그래밍 언어와 얽힌 이야기를 시리즈로 만들고자 하는 첫 번째 글이 될 것입니다. 제가 좋아하는 만큼...