Cargo features로 조건부 컴파일과 선택적 의존성 다루기

Cargo features로 조건부 컴파일과 선택적 의존성 다루기

Rust로 크레이트를 가져다 쓰다 보면 Cargo.toml에서 이런 줄을 한 번쯤 보셨을 거예요.

serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

여기서 features는 대체 뭘까요? 왜 어떤 크레이트는 그냥 추가하면 되는데, 어떤 크레이트는 ["derive"]["full"] 같은 걸 콕 집어줘야 제대로 동작할까요? 🤔

Cargo features는 크레이트의 기능을 조건부로 켜고 끄는 스위치입니다. 크레이트를 만드는 쪽에서는 “이 기능은 옵션이야”라고 미리 나눠두고, 쓰는 쪽에서는 필요한 것만 골라서 켤 수 있죠. 덕분에 안 쓰는 기능까지 컴파일하느라 빌드가 느려지거나 바이너리가 커지는 걸 막을 수 있습니다.

이번 글에서는 features를 어떻게 정의하고, 선택적 의존성과 어떻게 엮이며, 왜 “기능을 더하기만” 해야 하는지, 그리고 커맨드라인에서 어떻게 켜고 끄는지까지 하나씩 살펴보겠습니다.

Cargo features란?

features는 Cargo.toml[features] 테이블에 정의합니다. 각 피처는 이름과 배열로 이루어지는데, 배열 안에는 그 피처가 함께 켜는 다른 피처를 적습니다.

이미지 라이브러리를 만든다고 상상해볼게요. 포맷별로 피처를 나누면 이렇게 됩니다.

Cargo.toml
[features]
bmp = []
png = []
ico = ["bmp", "png"]

bmppng는 딸린 피처가 없어서 빈 배열이고, ico는 배열에 bmppng를 담고 있습니다. 즉 ico를 켜면 bmppng도 함께 켜지는 거죠. 이렇게 피처가 다른 피처를 전이적으로 켜도록 조립할 수 있습니다.

그럼 이 피처는 코드에서 어떻게 쓰일까요? #[cfg(feature = "...")] 속성으로 특정 피처가 켜졌을 때만 컴파일되도록 표시합니다.

#[cfg(feature = "png")]
pub mod png;

png 피처가 꺼져 있으면 png 모듈은 아예 컴파일 대상에서 빠집니다. 이것이 조건부 컴파일(conditional compilation)이고, features의 핵심 동작이에요.

기본 피처(default features)

대부분의 크레이트는 아무 설정 없이 추가해도 기본 기능이 동작합니다. default라는 특별한 피처에 미리 켜둘 피처 목록을 적어두기 때문이에요.

Cargo.toml
[features]
default = ["ico", "png"]
bmp = []
png = []
ico = ["bmp", "png"]

이제 이 크레이트를 그냥 추가하면 default에 들어 있는 icopng가 자동으로 켜집니다.

그런데 기본 기능이 필요 없다면요? 쓰는 쪽에서 default-features = false로 기본 피처를 통째로 끄고, 원하는 것만 다시 켤 수 있습니다.

Cargo.toml
[dependencies]
imagelib = { version = "1.0", default-features = false, features = ["png"] }

이렇게 하면 ico는 빠지고 png만 켜집니다. 안 쓰는 포맷을 덜어내서 컴파일 시간과 바이너리 크기를 아끼는 거죠. 크레이트를 처음 추가했을 때 빌드가 유난히 무겁다면, 기본 피처에 뭐가 딸려오는지 한번 들여다볼 만합니다.

다른 크레이트의 피처 켜기

앞에서 본 serdetokio 예제가 바로 이 경우입니다. 의존성을 선언할 때 features 배열로 그 크레이트의 피처를 켜는 거죠.

Cargo.toml
[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

Serdederive 피처는 #[derive(Serialize, Deserialize)] 매크로를 켜줍니다. 이 매크로는 컴파일 시간이 제법 드는 기능이라, 필요 없는 사람에게까지 강제하지 않으려고 별도 피처로 빼둔 거예요.

Tokio는 더 잘게 나뉘어 있습니다. rt는 런타임, net은 네트워크, time은 타이머 하는 식으로 기능마다 피처가 따로 있고, 이걸 전부 켜는 단축 피처가 바로 full입니다. 처음에는 full로 편하게 시작하고, 나중에 빌드를 가볍게 만들고 싶을 때 필요한 피처만 골라 담으면 됩니다.

내 크레이트의 피처가 의존성의 피처를 켜도록 연결할 수도 있습니다. [features] 테이블에서 크레이트명/피처명 구문을 쓰면 돼요.

Cargo.toml
[dependencies]
jpeg-decoder = { version = "0.3", default-features = false }

[features]
parallel = ["jpeg-decoder/rayon"]

이제 내 크레이트의 parallel 피처를 켜면 jpeg-decoderrayon 피처가 따라서 켜집니다.

선택적 의존성(optional dependencies)

어떤 의존성은 특정 피처를 켰을 때만 필요할 수 있습니다. 이럴 때는 의존성에 optional = true를 붙여 선택적으로 만듭니다.

Cargo.toml
[dependencies]
gif = { version = "0.13", optional = true }

선택적 의존성은 기본적으로 빌드에서 빠지고, 흥미롭게도 같은 이름의 피처를 암묵적으로 만들어냅니다. 즉 위 설정만으로 gif라는 피처가 생기고, 이 피처를 켜야 gif 크레이트가 딸려옵니다.

그런데 이 암묵적 피처가 늘 반가운 건 아닙니다. 의존성 이름이 그대로 피처 이름으로 노출되어버리니까요. 그래서 Rust 1.60부터는 dep: 구문으로 의존성을 명시적으로 참조하면서, 암묵적 피처가 생기지 않게 할 수 있습니다.

Cargo.toml
[dependencies]
ravif = { version = "0.11", optional = true }
rgb = { version = "0.8", optional = true }

[features]
avif = ["dep:ravif", "dep:rgb"]

이제 사용자는 avif 피처 하나만 알면 됩니다. ravifrgb 같은 내부 구현 크레이트를 피처로 직접 켜는 길은 막히고, 대신 의미가 분명한 avif로 묶어서 노출하는 거죠. 공개 API를 깔끔하게 유지하고 싶을 때 특히 유용합니다.

약한 의존성 피처(pkg?/feature)

조금 더 미묘한 경우도 있습니다. “저쪽 선택적 의존성이 어차피 켜져 있다면, 그것의 특정 피처도 같이 켜고 싶다. 하지만 그것 때문에 없던 의존성까지 끌어오고 싶지는 않다.”

바로 이럴 때 Rust 1.60부터 도입된 약한 의존성 피처, 크레이트명?/피처명 구문을 씁니다.

Cargo.toml
[dependencies]
serde = { version = "1.0", optional = true }
rgb = { version = "0.8", optional = true }

[features]
serde = ["dep:serde", "rgb?/serde"]

여기서 rgb?/serde의 물음표가 핵심입니다. “rgb다른 이유로 이미 켜져 있을 때만 rgbserde 피처를 켜라”는 뜻이에요. 만약 물음표를 빼고 rgb/serde라고 쓰면, 내 serde 피처를 켜는 것만으로 rgb까지 강제로 끌려옵니다. rgb를 안 쓰는 사용자에게는 불필요한 의존성이 생기는 셈이죠. 물음표 하나가 “필요하면 얹되, 없던 걸 새로 만들지는 않는다”는 뉘앙스를 만들어줍니다.

피처는 더하기만 해야 한다

features를 쓸 때 가장 중요한 원칙이 하나 있습니다. 바로 피처는 기능을 더하기만 해야 한다(additive)는 것입니다. 이 원칙은 Cargo의 피처 통합(feature unification) 방식에서 나옵니다.

같은 크레이트가 의존성 그래프 여기저기서 쓰이면, Cargo는 그 크레이트를 여러 곳에서 켠 피처의 합집합으로 딱 한 번만 빌드합니다. 예를 들어 패키지 A가 winapi["fileapi"]로, 패키지 B가 같은 winapi["std"]로 쓰면, 최종적으로 winapi는 두 피처가 모두 켜진 채로 빌드됩니다.

여기서 문제가 생깁니다. 누군가 피처를 켰다고 해서 기능이 사라지거나 동작이 바뀌면, 나는 켠 적도 없는 피처 때문에 내 빌드가 망가질 수 있거든요. 그래서 피처는 기능을 켜는 방향으로만 설계해야 합니다.

흔한 안티패턴이 no_std 피처입니다.

// 안티패턴: no_std 피처가 기능을 "빼는" 방향이라 additive 원칙 위반
#![cfg_attr(feature = "no_std", no_std)]

이렇게 하면 누군가 no_std를 켜는 순간 표준 라이브러리가 통째로 빠져서, 같은 크레이트를 쓰는 다른 코드가 깨질 수 있습니다. 올바른 방향은 반대로 std를 옵트인 피처로 두는 것입니다.

#![no_std]

// std 피처를 켰을 때만 표준 라이브러리를 더한다
#[cfg(feature = "std")]
extern crate std;

정말로 동시에 켜면 안 되는 상호 배타적인 피처가 있다면, 차라리 컴파일 단계에서 막아버리는 게 안전합니다.

#[cfg(all(feature = "foo", feature = "bar"))]
compile_error!("foo와 bar 피처는 동시에 켤 수 없습니다");

커맨드라인에서 켜고 끄기

피처는 Cargo.toml뿐 아니라 커맨드라인에서도 켜고 끌 수 있습니다. 잠깐 특정 피처를 넣어서 빌드하거나 테스트할 때 편하죠.

# 피처 여러 개 켜기 (공백 또는 쉼표로 구분)
cargo build --features "png ico"
cargo build -F png,ico

# 기본 피처를 끄고 원하는 것만 켜기
cargo build --no-default-features --features png

# 모든 피처 켜기 (주로 문서화나 테스트용)
cargo build --all-features

-F--features의 짧은 표기이고, --all-features는 크레이트의 모든 피처를 한꺼번에 켭니다.

빌드 스크립트(build.rs)에서는 어떤 피처가 켜졌는지 환경 변수로 확인할 수 있습니다. Cargo가 켜진 피처마다 CARGO_FEATURE_<이름> 형태의 환경 변수를 넣어주는데, 이름은 대문자로 바뀌고 -_로 치환됩니다.

참고로 워크스페이스에서 특정 멤버의 피처를 지정하려면 cargo build -p my-crate --features my-crate/foo처럼 쓸 수 있는데, 이 동작은 리졸버 v2(resolver = "2") 이상에서 매끄럽게 지원됩니다.

피처와 SemVer

features는 공개 API의 일부라서 시맨틱 버저닝(SemVer)과도 얽힙니다.

새 피처를 추가하거나 선택적 의존성을 더하는 것은 기존 사용자를 깨뜨리지 않으니 마이너 버전에서 해도 됩니다. 반면 피처를 제거하거나, default 목록에서 피처를 빼거나, 원래 항상 켜져 있던 공개 코드를 피처 뒤로 숨기는 것은 호환성을 깨는 변경이라 메이저 버전을 올려야 합니다. 그래서 처음 설계할 때부터 “이건 기본으로 켤 것인가, 옵션으로 뺄 것인가”를 신중하게 정하는 게 좋습니다. 한번 기본으로 켜두면 나중에 빼기가 어렵거든요.

이런 실수는 눈으로 잡기 어려운데, cargo semver-checks를 CI에 붙여두면 피처 변경으로 인한 호환성 위반도 자동으로 걸러낼 수 있습니다.

마치며

Cargo features는 크레이트의 기능을 조건부로 조립하는 스위치입니다. [features] 테이블로 기능을 나누고, default로 기본값을 정하고, optionaldep:으로 선택적 의존성을 묶고, pkg?/feature로 있을 때만 얹는 방식까지 조합하면 꽤 정교한 빌드 구성을 만들 수 있습니다.

무엇보다 기억할 것은 피처는 언제나 기능을 더하는 방향이어야 한다는 원칙입니다. 피처 통합 때문에 내가 켜지 않은 피처가 켜질 수 있으니, 켜서 뭔가가 사라지는 설계는 피해야 합니다. 이 감각만 있으면 남의 크레이트를 쓸 때도, 내 크레이트를 공개할 때도 features를 훨씬 편하게 다룰 수 있습니다.

더 자세한 내용은 Cargo 공식 문서의 Features 페이지를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

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

Discord