cargo semver-checks로 Rust API 호환성 자동 검사하기
Rust 크레이트를 배포하다 보면 한 번쯤 이런 경험이 있을 거예요. 패치 버전만 올려서 배포했는데, 사실은 공개 함수의 시그니처를 바꿔버렸던 거죠. 의존하는 쪽에서는 갑자기 빌드가 깨지고, 이슈가 올라오고… 😅
시맨틱 버저닝(Semantic Versioning)은 API 변경의 종류에 따라 메이저, 마이너, 패치 버전을 구분하는 규칙인데, 사람이 매번 이걸 정확히 판단하기는 쉽지 않습니다. 구조체에 필드 하나 추가한 게 호환성을 깨는 변경인지, 트레이트에 기본 구현이 있는 메서드를 추가한 건 괜찮은 건지, 헷갈리는 경우가 많거든요.
cargo-semver-checks는 이 판단을 자동화해주는 린트 도구입니다. 현재 코드의 공개 API를 이전 버전과 비교해서 호환성이 깨지는 변경이 있으면 파일명과 줄 번호까지 정확히 알려줍니다. 이번 글에서는 설치부터 실제 사용, CI 연동까지 차근차근 살펴보겠습니다.
시맨틱 버저닝이 왜 중요한가요?
본격적으로 들어가기 전에 시맨틱 버저닝을 간단히 짚고 넘어갈게요. 버전 번호가 MAJOR.MINOR.PATCH 형태로 되어 있을 때 각각의 의미는 이렇습니다.
우선 메이저 버전은 기존 API와 호환되지 않는 변경이 있을 때 올립니다. 공개 함수를 삭제하거나 시그니처를 바꾸는 게 대표적이죠. 마이너 버전은 하위 호환성을 유지하면서 새로운 기능을 추가할 때 올리고요. 패치 버전은 API 변경 없이 버그만 수정할 때 올립니다.
Cargo는 의존성 버전을 해석할 때 이 규칙을 따릅니다. Cargo.toml에 serde = "1.0"이라고 쓰면 >=1.0.0, <2.0.0 범위의 모든 버전을 허용하는데, 이건 메이저 버전이 같으면 호환된다는 전제가 있기 때문이에요. 크레이트 저자가 이 약속을 어기면 생태계 전체에 영향을 줍니다.
그래서 릴리스 전에 “이 변경이 정말 패치 수준인가, 아니면 메이저 범프가 필요한가?”를 기계적으로 확인할 수 있으면 좋겠죠. cargo-semver-checks가 바로 그 역할을 합니다.
설치하기
설치 방법은 두 가지가 있습니다.
가장 빠른 방법은 cargo-binstall을 사용하는 건데, 미리 빌드된 바이너리를 받아오기 때문에 컴파일 시간이 걸리지 않아요.
cargo binstall cargo-semver-checks
직접 소스에서 빌드하려면 cargo install을 사용합니다. 시간이 좀 걸리지만 확실한 방법이죠.
cargo install cargo-semver-checks --locked
설치가 끝나면 cargo semver-checks --help로 사용 가능한 옵션을 확인할 수 있어요.
기본 사용법
사용법은 아주 간단합니다. 크레이트 프로젝트 디렉토리에서 다음 명령어를 실행하면 됩니다.
cargo semver-checks
이 명령어는 내부적으로 두 가지 일을 합니다. 먼저 crates.io에서 현재 배포된 최신 버전을 가져와서 rustdoc JSON을 생성하고, 로컬 코드에서도 동일하게 rustdoc JSON을 생성합니다. 그다음 두 버전의 공개 API를 비교해서 호환성이 깨지는 변경이 있는지 검사하는 거예요.
문제가 없으면 이런 출력이 나옵니다.
Parsed my-crate v0.3.0 (current)
Parsed my-crate v0.2.0 (baseline)
Checked 252 checks: 0 breaking changes detected.
호환성이 깨지는 변경이 발견되면 구체적인 위치와 설명을 보여줍니다.
--- failure enum_variant_missing ---
Description: A publicly-visible enum has a variant that is no longer available.
ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
Occurred:
--> src/lib.rs:15
|
15 | pub enum Color {
| ^^^^^ variant `Green` was removed from this enum
|
= note: removing an enum variant is a breaking change
--- failure function_parameter_count_changed ---
Description: A publicly-visible function now takes a different number of parameters.
Occurred:
--> src/lib.rs:28
|
28 | pub fn parse(input: &str, strict: bool) -> Result<Value, Error> {
| ^^^^^ now takes 2 parameters instead of 1
|
Summary: 2 breaking changes detected
파일 경로와 줄 번호가 표시되니까 어디를 고쳐야 하는지 바로 알 수 있어요.
비교 대상 지정하기
기본적으로는 crates.io에 배포된 최신 버전과 비교하지만, 다른 기준을 지정할 수도 있습니다.
특정 버전과 비교하고 싶다면 --baseline-version 옵션을 사용합니다.
cargo semver-checks --baseline-version 1.2.3
git 브랜치나 커밋과 비교할 수도 있어요. PR을 올리기 전에 main 브랜치 대비 변경사항을 확인하고 싶을 때 유용합니다.
cargo semver-checks --baseline-rev main
로컬에 이전 버전 소스가 있다면 디렉토리 경로를 직접 지정할 수도 있고요.
cargo semver-checks --baseline-root ../old-version/
이미 생성해둔 rustdoc JSON 파일이 있다면 그걸 기준으로 쓸 수도 있습니다.
cargo semver-checks --baseline-rustdoc path/to/baseline.json
CI에서 캐싱과 함께 쓸 때 rustdoc JSON을 재사용하면 실행 시간을 줄일 수 있어서, 이 옵션이 꽤 쓸모 있어요.
피처 플래그 다루기
Rust 크레이트는 피처 플래그로 조건부 컴파일을 하는 경우가 많죠. cargo-semver-checks는 피처별 API 변경도 검사할 수 있습니다.
모든 피처를 켜고 검사하려면 --all-features를 붙이면 되고, 기본 피처만 쓰려면 --default-features를 지정합니다.
cargo semver-checks --all-features
cargo semver-checks --default-features
특정 피처 조합만 골라서 검사할 수도 있어요.
cargo semver-checks --features serde,async
반대로 피처를 하나도 켜지 않은 상태에서 검사하고 싶다면 --only-explicit-features를 씁니다.
cargo semver-checks --only-explicit-features
피처에 따라 공개 API가 달라지는 크레이트라면 여러 조합으로 돌려보는 게 안전해요.
어떤 변경을 감지할까?
cargo-semver-checks는 250개가 넘는 린트 규칙을 갖고 있습니다. 주요 카테고리별로 어떤 변경을 잡아내는지 살펴볼게요.
함수와 메서드 쪽에서는 공개 함수 삭제, 매개변수 개수 변경, unsafe 추가, #[must_use] 추가 같은 변경을 감지합니다. 구조체에서는 공개 필드 삭제, 새 필수 필드 추가, #[non_exhaustive] 추가 등을 잡아내고요.
열거형은 변형(variant) 삭제나 추가, repr 타입 변경 등을 검사합니다. 트레이트는 가장 규칙이 많은 영역인데, 필수 메서드 추가, 슈퍼트레이트 추가, 리시버 타입 변경 같은 다양한 호환성 문제를 감지합니다.
그 외에도 매크로, 피처 플래그, static/const 아이템, 제네릭과 라이프타임, 그리고 #[doc(hidden)]으로 숨기는 변경까지 폭넓게 커버하고 있어요.
참고로 아직 감지하지 못하는 것도 있습니다. 필드나 매개변수의 타입 변경, 제네릭과 라이프타임의 미묘한 호환성 변경 등은 현재 지원 범위 밖이에요. 하지만 버전이 올라갈 때마다 새로운 린트가 꾸준히 추가되고 있어서 커버리지는 계속 넓어지고 있습니다.
린트 설정 커스터마이징
기본 동작으로도 충분하지만, 프로젝트 사정에 따라 특정 린트의 심각도를 조정하고 싶을 수 있어요. Cargo.toml에서 패키지 메타데이터로 설정할 수 있습니다.
[package.metadata.cargo-semver-checks.lints]
# 특정 린트를 경고로 낮추기
function_must_use_added = "warn"
# 심각도와 필요한 버전 범프를 함께 지정
struct_pub_field_missing = { level = "deny", required-update = "major" }
린트 레벨은 세 가지가 있습니다. deny는 에러로 처리하고(기본값), warn은 경고만 표시하며, allow는 해당 린트를 완전히 무시합니다.
Cargo 워크스페이스에서는 루트 Cargo.toml에 공통 설정을 두고 각 패키지에서 상속받을 수 있어요.
[workspace.metadata.cargo-semver-checks.lints]
function_missing = { level = "deny", required-update = "major" }
[package.metadata.cargo-semver-checks.lints]
workspace = true
워크스페이스 전체에 일관된 정책을 적용하고 싶을 때 편리합니다.
GitHub Actions로 CI 연동하기
cargo-semver-checks의 진가는 CI에서 자동으로 돌릴 때 나타납니다. PR마다 API 호환성을 자동 검사하면 실수로 호환성을 깨는 변경이 머지되는 걸 막을 수 있거든요.
공식 GitHub Action이 있어서 설정이 간단합니다.
name: Semver Check
on:
pull_request:
jobs:
semver-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: obi1kenobi/cargo-semver-checks-action@v2
이게 전부예요. PR이 올라올 때마다 crates.io의 최신 배포 버전과 비교해서 호환성이 깨지는 변경이 있으면 CI가 실패합니다.
옵션을 좀 더 지정하고 싶다면 with 블록을 추가하면 됩니다.
- uses: obi1kenobi/cargo-semver-checks-action@v2
with:
# 워크스페이스에서 특정 패키지만 검사
package: my-core-lib, my-client
# 모든 피처 활성화
feature-group: all-features
# 특정 피처만 지정
features: serde,async
이 Action은 rustdoc JSON 캐싱이 내장되어 있어서 두 번째 실행부터는 훨씬 빨라집니다.
release-plz와 함께 쓰기
release-plz로 Rust 패키지 릴리스 자동화하기에서 다뤘던 release-plz는 내부적으로 cargo-semver-checks를 활용합니다. Conventional Commits로 커밋 메시지를 분석하는 것에 더해서, 실제 API 변경을 검사해서 적절한 버전을 결정하는 거예요.
예를 들어 커밋 메시지가 feat:으로 시작해서 마이너 범프만 필요한 것 같지만, 실제로는 기존 함수의 시그니처를 바꿨다면 cargo-semver-checks가 이를 감지해서 메이저 범프가 필요하다고 알려줍니다.
release-plz의 설정 파일에서 이 기능을 켜고 끌 수 있어요.
[workspace]
# API 호환성 검사 활성화 (기본값: true)
semver_check = true
커밋 메시지 기반의 버전 결정과 API 호환성 검사를 결합하면 버전 관리의 정확도가 한층 올라갑니다.
내부 동작 방식
cargo-semver-checks가 내부적으로 어떻게 돌아가는지 궁금하신 분들을 위해 간단히 정리해볼게요.
핵심은 rustdoc JSON이라는 Rust 컴파일러의 실험적 기능입니다. 크레이트의 공개 API를 JSON 형태로 내보내주는 건데, cargo-semver-checks는 이전 버전과 현재 버전 각각에 대해 이 JSON을 생성합니다.
그다음 Trustfall이라는 쿼리 엔진으로 두 JSON을 비교합니다. 재미있는 점은 린트 규칙이 명령형 코드가 아니라 선언적 쿼리로 작성되어 있다는 거예요. rustdoc JSON을 데이터베이스처럼 취급해서 “이전에 있던 공개 함수 중 현재 없어진 게 있나?”를 쿼리하는 방식이죠. 이런 설계 덕분에 새로운 린트를 추가하기가 비교적 쉽고, rustdoc JSON 형식이 바뀌어도 쿼리 엔진과 데이터 어댑터만 업데이트하면 됩니다.
성능도 꽤 괜찮은데, aws-sdk-ec2처럼 API 항목이 24만 개나 되는 거대한 크레이트도 인덱스 기반 쿼리 최적화 덕에 약 8초면 검사가 끝납니다.
실전 팁
써보면서 느낀 점 몇 가지를 공유할게요.
먼저 #[non_exhaustive]를 적극 활용하세요. 공개 열거형이나 구조체에 이 어트리뷰트를 붙여두면 나중에 변형이나 필드를 추가해도 호환성이 깨지지 않습니다. 처음부터 붙여두는 게 나중에 후회를 줄여줘요.
새 크레이트를 배포하기 전에 cargo-semver-checks를 한번 돌려보는 습관도 좋습니다. 아직 crates.io에 배포된 적이 없는 크레이트라면 --baseline-rev로 이전 커밋과 비교하면 되고요.
cargo semver-checks --baseline-rev HEAD~5
CI에서 검사가 실패했을 때는 두 가지 선택지가 있어요. 호환성을 유지하도록 코드를 수정하거나, 의도적인 변경이라면 버전 번호를 적절히 올리면 됩니다. 실수인지 의도인지를 판단하는 계기가 되니까, 어느 쪽이든 유익한 거죠.
마치며
cargo-semver-checks는 Rust 크레이트를 배포하는 사람이라면 꼭 한번 써볼 만한 도구입니다. 사람이 놓치기 쉬운 API 호환성 문제를 기계적으로 잡아주니까, 실수로 생태계에 영향을 주는 일을 예방할 수 있어요.
CI에 한번 설정해두면 PR마다 자동으로 검사가 돌아가서 별도로 신경 쓸 것도 없고요. release-plz와 함께 쓰면 버전 결정까지 자동화되어 릴리스 파이프라인이 더욱 견고해집니다.
더 자세한 내용은 cargo-semver-checks 공식 저장소에서 확인할 수 있고, 지원하는 린트 목록은 공식 문서에서 볼 수 있습니다.
This work is licensed under
CC BY 4.0