Rust 1.99 릴리즈 주요 내용 — C 가변 인자 정의부터 Vec::into_parts, Cargo debug 프로필까지

개요

2026년 10월 1일 Rust 1.99.0이 릴리즈되었습니다. 이번 버전에서 가장 눈에 띄는 변화는 C-variadic 함수 정의의 안정화입니다. Rust는 오래전부터 printf 같은 C의 가변 인자 함수를 호출할 수 있었지만, 그런 함수를 Rust로 직접 정의하는 것은 nightly에서만 가능했습니다. 1.99부터는 stable에서도 정의할 수 있습니다.

이 글에서는 공식 릴리즈 공지와 상세 릴리즈 노트를 바탕으로 다음 내용을 정리합니다.

  • 한눈에 보는 1.99: 영역별 변경 요약
  • C-variadic 함수 정의: VaList와 next_arg, 읽을 수 있는 타입과 제약 사항
  • 새로 안정화된 API: Vec::into_parts, Box::into_non_null 같은 raw 소유권 API와 레이아웃 raw API
  • Cargo 변화: debug 프로필, workspace 의존성의 default-features 오버라이드, CI 환경의 incremental 기본값
  • 업그레이드 시 주의할 호환성 변경

본문의 예제 코드는 모두 rustc 1.99.0 (b940084d7 2026-09-28)과 같은 버전의 Cargo로 직접 컴파일하고 실행해 확인했습니다. 이전 버전과 동작을 비교한 부분은 rustc 1.97.1로 확인한 결과입니다.


한눈에 보는 Rust 1.99


업데이트 방법

rustup으로 Rust를 설치했다면 아래 명령으로 1.99.0을 받을 수 있습니다.

rustup update stable

영역별 변경 요약

상세 릴리즈 노트는 변경 사항을 여러 영역으로 나눠 싣고 있습니다. 이 글에서 다루는 항목을 중심으로 영역별로 정리하면 다음과 같습니다.

영역주요 변경
언어C-variadic 함수 정의 안정화(extern "C"·"C-unwind"), #[unsafe(naked)] 함수로 C-variadic 함수 정의, #[my_macro] mod foo; 허용, x86 asm!에서 128비트 정수를 벡터 레지스터로 전달
컴파일러메서드 이름 제안 시 비슷한 이름보다 doc(alias)와 정확히 일치하는 이름을 우선
플랫폼riscv64-unknown-linux-musl이 host tools를 포함한 Tier 2로 승격
라이브러리RangeInclusive 반복 최적화, transmute_copy가 ?Sized 타입 허용
안정화된 APIVec::into_parts/from_parts, Box::into_non_null/from_non_null, Box<[T; N]>의 IntoIterator, VecDeque::retain_back, String::from_utf8_lossy_owned, fs::set_times, Layout::for_value_raw 등
Cargo새 debug 빌트인 프로필, Edition 2024+에서 workspace 의존성의 default-features 오버라이드, CI 환경에서 incremental 기본 비활성
Rustdocunused_footnote_definition lint 추가, trait impl 필터링 개선으로 평균 20% 성능 향상
호환성no_mangle_generic_items 하드 에러, 레거시 정수 모듈 완전 deprecated, Pin::new_unchecked 안전 조건 변경
내부 변경LLVM 23으로 업데이트

이제 이 중 비중이 큰 항목들을 하나씩 살펴보겠습니다.


C-variadic 함수 정의 안정화


Rust로 가변 인자 함수 정의하기

C에는 printf처럼 인자 개수가 정해지지 않은 가변 인자 함수(variadic function)가 있습니다. 매개변수 목록 끝에 ...을 붙여 선언하고, 호출하는 쪽은 원하는 만큼 인자를 넘깁니다.

Rust는 이런 C 함수를 extern 블록으로 선언해 호출하는 것까지는 이미 stable에서 지원했습니다. 하지만 같은 형태의 함수를 Rust 쪽에서 정의하는 기능(c_variadic)은 오랫동안 nightly 전용이었습니다. 1.99.0은 "C"와 "C-unwind" ABI에서 이 기능을 안정화했습니다.

공식 공지에 실린 예제는 다음과 같습니다.

/// SAFETY: must be called with (at least) 2 i32 arguments.
unsafe extern "C" fn sum(mut args: ...) -> i32 {
    // SAFETY: guaranteed by the caller.
    let a = unsafe { args.next_arg::<i32>() };
    let b = unsafe { args.next_arg::<i32>() };
    a + b
}

fn foo() -> i32 {
    unsafe { sum(0i32, 2i32) }
}

매개변수 목록 마지막의 args: ...이 가변 인자 목록을 받는 자리입니다. 함수 본문에서는 args에서 next_arg로 인자를 하나씩 꺼내 읽습니다. C로 치면 va_start로 목록을 초기화하고 va_arg로 읽는 과정에 해당합니다.

이 기능이 실제로 쓸모 있는 곳은 C와 맞닿는 지점입니다. 예를 들어 C 라이브러리가 printf 형식의 로그 콜백을 요구하거나, 기존 C API를 Rust로 다시 구현하면서 함수 시그니처를 그대로 유지해야 할 때 이제 C 코드를 거치지 않고 Rust만으로 함수를 정의할 수 있습니다.


VaList와 next_arg

... 매개변수의 타입은 core::ffi::VaList입니다. 공식 문서는 VaList를 "C의 va_list와 ABI 호환되는 가변 인자 목록"으로 설명합니다. C의 va_list 연산은 Rust에서 다음과 같이 대응됩니다.

CRust
va_start함수에 진입할 때 ... 매개변수가 자동으로 초기화됨
va_argVaList::next_arg::<T>()
va_copyVaList::clone() (원본과 독립적으로 읽을 수 있는 복사본)
va_endVaList가 drop될 때

VaList는 FFI 경계를 넘어 전달할 수도 있습니다. 덕분에 C의 vprintf처럼 va_list를 받는 함수에 가변 인자를 그대로 넘길 수 있습니다.

use std::ffi::{VaList, c_char, c_int};

unsafe extern "C" {
    fn vprintf(format: *const c_char, ap: VaList<'_>) -> c_int;
}

/// SAFETY: `format`은 NUL로 끝나야 하고, 가변 인자는 `format`의 지정자와 맞아야 합니다.
unsafe extern "C" fn my_printf(format: *const c_char, args: ...) -> c_int {
    unsafe { vprintf(format, args) }
}

fn main() {
    // 출력: answer = 42
    unsafe { my_printf(c"%s = %d\n".as_ptr(), c"answer".as_ptr(), 42 as c_int) };
}

C에서 printf가 vprintf에 처리를 넘기듯, 가변 인자 함수가 VaList를 받는 함수에 실제 작업을 맡기는 패턴도 Rust 안에서 그대로 쓸 수 있습니다. VaList 문서의 예제를 조금 고친 코드입니다.

use std::ffi::{VaList, c_int};

/// SAFETY: 호출자는 `count`개 이상의 c_int 인자를 넘겨야 합니다.
unsafe extern "C" fn sum(count: c_int, args: ...) -> c_int {
    unsafe { vsum(count, args) }
}

/// SAFETY: `ap`에 c_int 인자가 `count`개 이상 남아 있어야 합니다.
unsafe fn vsum(count: c_int, mut ap: VaList<'_>) -> c_int {
    let mut total = 0;
    for _ in 0..count {
        total += unsafe { ap.next_arg::<c_int>() };
    }
    total
}

fn main() {
    assert_eq!(unsafe { sum(3, 10, 20, 30) }, 60);
}

VaList에는 lifetime 매개변수가 있습니다. 안정화 보고서에 따르면 이 lifetime은 VaList가 자신을 만든 함수보다 오래 살아남지 못하게 하려는 장치입니다. 보고서는 VaList가 의미상 호출자나 피호출자의 스택을 가리키는 가변 참조를 담고 있다고 설명합니다. 함수가 반환되면 그 스택은 사라지므로 VaList도 함께 끝나야 합니다.


읽을 수 있는 타입과 안전 조건

next_arg::<T>()의 T에는 VaArgSafe trait을 구현한 타입만 올 수 있습니다. 이 trait은 core 밖에서 구현할 수 없고, 이름 자체도 아직 nightly 전용이라 stable에서는 bound로 쓸 수 없습니다. 그래도 어떤 타입을 읽을 수 있는지는 이 trait의 구현 목록이 정합니다. 문서가 모든 플랫폼에서 구현을 보장하는 타입은 다음과 같습니다.

  • c_int, c_long, c_longlong
  • c_uint, c_ulong, c_ulonglong
  • c_double
  • *const T, *mut T

i32나 usize에도 구현이 있기는 하지만 문서는 이식성이 보장되지 않으니 직접 의존하지 말라고 안내합니다. 앞의 vsum 예제가 공식 공지와 달리 c_int를 쓴 이유가 이것입니다.

반대로 i16, u8 같은 작은 정수 타입과 f32는 읽을 수 없습니다. C는 가변 인자를 넘길 때 기본 인자 승격(default argument promotion)을 적용해 int보다 작은 정수를 int로, float을 double로 바꿔서 넘깁니다. 그래서 C에서도 va_arg로 short나 float을 읽으면 undefined behavior입니다. Rust는 이 실수를 타입 수준에서 막습니다.

unsafe extern "C" fn read_short(mut ap: ...) -> i16 {
    unsafe { ap.next_arg::<i16>() }
}
error[E0277]: the trait bound `i16: VaArgSafe` is not satisfied

다만 타입 검사가 모든 것을 막아주지는 않기 때문에 next_arg는 여전히 unsafe입니다. 1.99 문서에 적힌 안전 조건은 다음 네 가지입니다.

  1. 읽을 가변 인자가 아직 남아 있어야 합니다.
  2. 호출자가 실제로 넘긴 인자의 타입 U가 읽으려는 타입 T와 호환되어야 합니다.
  3. U와 T가 모두 정수 타입이면, 넘긴 값이 두 타입 모두에서 표현 가능해야 합니다.
  4. T가 Copy가 아니라면, clone한 VaList에서 이미 next_arg로 읽은 인자를 다시 읽으면 안 됩니다. 문서는 지금은 VaArgSafe를 구현한 타입이 모두 Copy라고 덧붙이고 있어서, 현재로서는 1~3번만 신경 쓰면 됩니다.

여기서 "호환된다"는 두 타입이 같거나, 크기가 같은 정수 타입이거나, 둘 다 호환되는 대상을 가리키는 포인터이거나, 한쪽이 c_void 포인터이고 다른 쪽이 i8·u8 포인터인 경우를 말합니다. 예를 들어 u64로 읽는 자리에 -1i64를 넘기면 크기는 같지만 값을 u64로 표현할 수 없으므로 조건 3을 어깁니다.


제약 사항과 naked variadic

C-variadic 함수 정의에는 몇 가지 제약이 있습니다. 1.99.0 컴파일러로 확인한 결과와 안정화 보고서의 설명을 함께 정리하면 다음과 같습니다.

  • 반드시 unsafe 함수여야 합니다. 인자 개수나 타입이 틀리면 undefined behavior이므로, 모든 호출 지점이 안전 조건을 직접 보장해야 하기 때문입니다. unsafe를 빼면 functions with a C variable argument list must be unsafe 에러가 납니다.
  • ABI는 "C"와 "C-unwind"만 허용됩니다. extern "Rust" 함수에 ...을 쓰면 `...` is not supported for `extern "Rust"` functions 에러가 납니다. 보고서는 앞으로 sysv64, win64처럼 호출은 이미 가능한 ABI까지 정의 범위를 넓히고 싶다고 밝히고 있습니다.
  • ...은 매개변수 목록의 마지막에 와야 하며, 정의에서는 패턴이 필요합니다. 쓰지 않을 때는 _: ...로 적습니다. C23처럼 ...만 단독으로 받는 것도 가능합니다.
  • trait 메서드에도 쓸 수 있지만, 그 trait은 dyn 호환이 되지 않습니다. 보고서는 가변 인자를 그대로 전달하는 shim을 안전하게 만들 방법이 없다는 것을 이유로 듭니다.

함께 안정화된 것이 #[unsafe(naked)] 함수로 C-variadic 함수를 정의하는 기능입니다. naked 함수는 컴파일러가 프롤로그·에필로그를 생성하지 않고 본문 전체를 naked_asm! 인라인 어셈블리로 작성하는 함수입니다. 일반 C-variadic 정의는 "C"·"C-unwind"만 받지만, naked 함수로는 "aapcs", "cdecl", "efiapi", "system", "sysv64", "win64"처럼 C-variadic 함수 호출이 허용되는 ABI 전부로 정의할 수 있습니다. 공식 공지도 이 기능을 "C가 아닌 ABI의 naked variadic 함수 정의"로 소개하며, 이 경우 본문은 인라인 어셈블리로 작성해야 한다고 덧붙입니다.


새로 안정화된 API


raw 소유권 API

1.99.0에서는 컬렉션과 Box의 소유권을 raw 포인터로 꺼냈다가 다시 되돌리는 API가 NonNull 기반으로 추가되었습니다.

Vec::into_parts와 Vec::from_parts

Vec::into_parts는 Vec<T>를 (NonNull<T>, 길이, 용량) 튜플로 분해합니다. Vec::from_parts는 같은 순서의 인자로 Vec을 다시 조립하는 unsafe 함수입니다.

use std::ptr::NonNull;

let v: Vec<u32> = vec![1, 2, 3];
let (ptr, len, cap): (NonNull<u32>, usize, usize) = v.into_parts();

// 이 시점부터 메모리 해제 책임은 호출자에게 있습니다.
let rebuilt = unsafe { Vec::from_parts(ptr, len, cap) };
assert_eq!(rebuilt, [1, 2, 3]);

기존의 into_raw_parts/from_raw_parts와 하는 일은 거의 같지만, 포인터를 *mut T 대신 NonNull<T>로 주고받습니다. 포인터가 null이 아니라는 사실이 타입에 드러납니다.

into_parts를 호출한 뒤에는 원래 Vec이 관리하던 메모리를 호출자가 책임져야 합니다. 1.99 문서는 이 메모리를 정리하는 유일한 방법으로 from_parts로 Vec을 되살려 소멸자가 정리하게 하는 것을 듭니다. into_raw_parts 문서가 dealloc으로 직접 해제하는 방법도 안내하는 것과 다른 점입니다.

from_parts는 unsafe이며, 문서에 적힌 주요 안전 조건은 다음과 같습니다.

  • 포인터는 global allocator로 할당된 것이어야 합니다.
  • T의 정렬은 할당 당시의 정렬과 같아야 하고, T의 크기 × capacity는 할당된 바이트 수와 같아야 합니다.
  • length는 capacity 이하이고, 앞쪽 length개 원소는 초기화되어 있어야 합니다.
  • capacity는 할당 당시의 용량이어야 하고, 할당 크기는 isize::MAX 바이트를 넘으면 안 됩니다.

from_raw_parts 문서에는 T가 zero-sized이거나 용량이 0이면 포인터가 null이 아니고 정렬만 맞으면 된다는 완화 조건이 있지만, from_parts 문서에는 이런 예외가 적혀 있지 않습니다.

Box::into_non_null과 Box::from_non_null

Box에도 같은 짝이 생겼습니다. Box::into_non_null은 Box를 소비하고 NonNull<T>를 돌려주며, Box::from_non_null은 그 포인터로 Box를 다시 만듭니다. 내부 타입의 메서드와 이름이 충돌하지 않도록 b.into_non_null()이 아닌 Box::into_non_null(b) 형태로 호출하는 associated function입니다.

use std::ptr::NonNull;

struct Node {
    value: i32,
}

let b = Box::new(Node { value: 42 });
let p: NonNull<Node> = Box::into_non_null(b);
assert_eq!(unsafe { p.as_ref() }.value, 42);

// Box로 되돌려 소멸자가 메모리를 정리하게 합니다.
let b = unsafe { Box::from_non_null(p) };
drop(b);

Box::leak 문서의 권고

이와 관련해 공식 공지는 언어 의미가 바뀐 것은 아니라고 전제한 뒤, Box::leak 문서에 새 권고가 추가되었다고 알립니다. Box::leak으로 메모리를 누수시킨 뒤, 돌려받은 &mut T로 Box를 다시 만들어 해제하는 이른바 "unleaking" 패턴을 피하라는 내용입니다.

공지는 그 이유로 이런 코드가 현재와 미래의 컴파일러 최적화와 문제를 일으킬 수 있고, 곧 안정화될 custom allocator와는 특히 문제가 된다는 점을 듭니다. 1.99 문서는 이 패턴이 allocator가 Global일 때만 가능하고, 그마저도 그럴듯해 보이는 방법 상당수가 undefined behavior인 회색 지대라고 설명합니다. 나중에 해제할 메모리라면 처음부터 Box::into_non_null이나 Box::into_raw를 쓰라는 것이 권고의 핵심입니다. 공지는 이 지침이 표준 라이브러리의 다른 leak 함수에도 적용된다고 덧붙입니다.


raw 포인터의 레이아웃 정보

공식 공지는 raw 포인터에서 크기와 정렬을 얻을 때의 안전 조건이 이번 릴리즈로 확정되었다고 소개합니다. 이를 위해 세 함수가 안정화되었습니다.

기존의 size_of_val, align_of_val, Layout::for_value는 참조(&T)를 받습니다. 그런데 아직 초기화되지 않은 메모리처럼 참조를 만들면 안 되는 상황에서도 크기를 알아야 할 때가 있습니다. 새 함수들은 raw 포인터(*const T)를 받기 때문에 이런 경우에 쓸 수 있습니다. 특히 슬라이스([T])나 trait object처럼 크기가 컴파일 타임에 정해지지 않는 타입에서 의미가 있습니다.

use std::alloc::Layout;
use std::mem;

let arr = [0u16; 5];
let raw: *const [u16] = &arr[..];

assert_eq!(unsafe { mem::size_of_val_raw(raw) }, 10);
assert_eq!(unsafe { mem::align_of_val_raw(raw) }, 2);

let layout = unsafe { Layout::for_value_raw(raw) };
assert_eq!((layout.size(), layout.align()), (10, 2));

세 함수 모두 unsafe이고 const fn입니다. 문서에 따르면 포인터를 &T로 다시 빌려도 안전한 경우라면 언제나 호출해도 안전하며, T가 Sized인 경우에도 항상 안전합니다. 그 밖의 경우, 크기가 정해지지 않은 꼬리 부분(unsized tail)이 슬라이스·str·dyn Trait이면 동적 길이를 포함한 전체 크기가 isize에 들어가야 합니다. 동적 길이가 0이면 항상 안전합니다. 문서는 현재 존재하는 unsized tail이 이 세 종류뿐이라고 설명합니다.


컬렉션과 문자열

Box<[T; N]>의 IntoIterator

Box<[T; N]>, &Box<[T; N]>, &mut Box<[T; N]>에 IntoIterator가 구현되었습니다. 이전에는 박스에 담긴 배열을 for 루프에 바로 넣을 수 없었습니다.

let boxed: Box<[i32; 3]> = Box::new([1, 2, 3]);

for x in &boxed {
    // x: &i32
}
for x in boxed {
    // x: i32 (배열의 원소를 소유권째로 꺼냄)
}

같은 코드를 1.97.1로 컴파일하면 두 루프 모두 에러가 납니다.

error[E0277]: `&Box<[i32; 3]>` is not an iterator
error[E0277]: `[i32; 3]` is not an iterator

VecDeque::retain_back

VecDeque::retain_back(len)은 뒤쪽 len개 원소만 남기고 나머지를 drop합니다. 앞쪽 len개를 남기는 truncate와 짝을 이루는 메서드입니다. len이 현재 길이 이상이면 아무 일도 하지 않습니다. 최근 N개의 로그만 유지하는 링 버퍼 같은 곳에 쓰기 좋습니다.

use std::collections::VecDeque;

let mut log: VecDeque<i32> = (1..=5).collect();
log.retain_back(2); // 뒤쪽 2개만 남김
assert_eq!(log, [4, 5]);

log.truncate(1); // 앞쪽 1개만 남김
assert_eq!(log, [4]);

String::from_utf8_lossy_owned와 FromUtf8Error::into_utf8_lossy

String::from_utf8_lossy_owned는 Vec<u8>을 받아 잘못된 UTF-8 시퀀스를 대체 문자(U+FFFD, �)로 바꾼 String을 돌려줍니다. 기존 String::from_utf8_lossy는 &[u8]을 받아 Cow<str>을 돌려주므로, 소유한 String이 필요하면 한 번 더 변환해야 했습니다. 다만 문서는 원래 Vec의 할당을 재사용한다고 보장하지는 않는다고 적고 있습니다.

let bytes: Vec<u8> = b"Hello \xF0\x90\x80World".to_vec();
let s: String = String::from_utf8_lossy_owned(bytes);
assert_eq!(s, "Hello \u{FFFD}World");

함께 안정화된 FromUtf8Error::into_utf8_lossy는 String::from_utf8이 실패했을 때 그 에러에서 같은 방식으로 변환된 String을 얻습니다. 유효한 UTF-8이었는지에 따라 분기하면서도 실패한 경우의 문자열을 살리고 싶을 때 쓸 수 있습니다. 문서는 에러가 검증이 멈춘 위치를 기록하고 있어서 이미 유효한 앞부분을 다시 검사하지 않는다고 설명합니다.

let s = match String::from_utf8(b"ab\xFF".to_vec()) {
    Ok(s) => s,
    Err(e) => e.into_utf8_lossy(),
};
assert_eq!(s, "ab\u{FFFD}");

파일 타임스탬프

std::fs::set_times는 경로를 받아 파일이나 디렉터리의 접근 시각과 수정 시각을 바꿉니다. 지금까지는 File을 열고 File::set_times를 호출해야 했지만, 이제 경로만으로 바로 바꿀 수 있습니다.

use std::fs::{self, FileTimes};
use std::time::{Duration, SystemTime};

fn main() -> std::io::Result<()> {
    fs::write("foo.txt", "x")?;

    let t = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
    fs::set_times("foo.txt", FileTimes::new().set_accessed(t).set_modified(t))?;

    assert_eq!(fs::metadata("foo.txt")?.modified()?, t);
    Ok(())
}

set_times는 경로가 심볼릭 링크이면 링크를 따라가 대상 파일의 시각을 바꿉니다. 링크 자체의 시각을 바꾸려면 함께 안정화된 fs::set_times_nofollow를 씁니다. 문서에 따르면 내부적으로 Unix에서는 utimensat, Apple 플랫폼에서는 setattrlist, Windows에서는 SetFileTime을 호출합니다.


Cargo 변화


새 debug 빌트인 프로필

Cargo에 debug라는 새 빌트인 프로필이 추가되었습니다. Cargo 문서는 이 프로필을 "디버거로 실행할 바이너리를 만드는 프로필"로 설명하며, 기본 설정은 dev를 그대로 상속합니다.

[profile.debug]
inherits = "dev"
cargo build --profile debug

릴리즈 노트에 따르면 지금은 dev와 debug 사이에 차이가 없습니다. 이 프로필은 앞으로 dev 프로필에서 디버깅 용도를 떼어내고, 개발 중 빠른 반복에 더 맞는 기본값을 주기 위한 준비 단계입니다. 지금 당장 바뀌는 동작은 없습니다. 다만 1.99 이전 Cargo에서는 debug가 예약된 프로필 이름이라 cargo build --profile debug가 profile name `debug` is reserved 에러로 실패합니다(1.97.1에서 확인). MSRV 검증 등으로 이전 툴체인도 함께 돌리는 CI라면 이 명령을 쓰기 전에 확인이 필요합니다.


workspace 의존성 default-features 오버라이드

workspace에서 의존성을 [workspace.dependencies]에 모아 두고 각 멤버가 workspace = true로 상속하는 경우가 많습니다. 그런데 지금까지는 workspace 정의가 기본 feature를 켜고 있으면 멤버 쪽에서 default-features = false로 끌 수 없었습니다.

1.99부터는 Edition 2024 이상인 멤버가 상속한 의존성의 default-features를 오버라이드할 수 있습니다(RFC 3945).

# workspace 루트 Cargo.toml
[workspace.dependencies]
dep = { path = "dep", default-features = true }
# 멤버 Cargo.toml (edition = "2024")
[dependencies]
dep = { workspace = true, default-features = false }

dep의 기본 feature에 따라 다른 문자열을 출력하는 작은 workspace로 확인해 보면 다음과 같습니다.

조건결과
Cargo 1.97.1, 멤버 Edition 2024에러: `default-features = false` cannot override workspace's `default-features`
Cargo 1.97.1, 멤버 Edition 2021경고 후 무시되고 기본 feature가 켜짐
Cargo 1.99.0, 멤버 Edition 2024기본 feature가 꺼짐
Cargo 1.99.0, 멤버 Edition 2021경고 후 무시되고 기본 feature가 켜짐

즉 1.99에서 달라진 것은 Edition 2024 멤버의 동작입니다. Edition 2021 멤버는 이전과 마찬가지로 경고와 함께 설정이 무시되며, 1.99의 경고 문구는 오버라이드에 1.99 이상과 2024 edition이 필요하다고 안내합니다. 빌드는 계속 진행되므로 경고를 놓치지 않도록 주의해야 합니다.


CI 환경에서 incremental 기본 비활성

이제 Cargo는 CI 환경에서 incremental compilation을 기본으로 끕니다. 관련 PR(rust-lang/cargo#17220)은 그 이유를 두 가지로 설명합니다. 캐시를 쓰지 않거나 재사용률이 낮으면 incremental 상태를 디스크에 쓰는 시간이 낭비되고, 캐시를 쓰면 incremental 데이터 때문에 캐시 크기가 크게 늘어 네트워크·압축 비용이 빌드 단축 효과를 넘어설 수 있다는 것입니다.

CI 여부는 환경 변수로 판단합니다. Cargo 1.99.0 소스의 is_ci 함수는 CI 또는 TF_BUILD 환경 변수가 존재하는지만 확인합니다.

pub fn is_ci() -> bool {
    std::env::var("CI").is_ok() || std::env::var("TF_BUILD").is_ok()
}

값은 보지 않기 때문에 CI=false로 설정해도 CI로 판단됩니다. 실제로 cargo build -v로 rustc에 -C incremental 플래그가 전달되는지 확인해 보면 다음과 같습니다.

조건incremental
CI 환경 변수 없음켜짐
CI=true꺼짐
CI=false꺼짐
CI=true + [profile.dev] incremental = true꺼짐
CI=true + CARGO_INCREMENTAL=1켜짐

눈여겨볼 부분은 네 번째 줄입니다. PR 설명에 따르면 적용 우선순위는 CARGO_INCREMENTAL 환경 변수 → build.incremental 설정 → CI 감지 → profile.*.incremental 순입니다. CI 감지가 프로필 설정보다 앞서기 때문에, CI에서 incremental을 계속 쓰려면 Cargo.toml의 프로필이 아니라 CARGO_INCREMENTAL=1 환경 변수나 build.incremental 설정으로 켜야 합니다.


업그레이드 시 주의할 호환성 변경


no_mangle_generic_items 하드 에러

타입이나 const에 대해 generic한 함수에 #[unsafe(no_mangle)]을 붙이는 코드는 이전까지 no_mangle_generic_items lint 경고였지만, 1.99부터는 컴파일 에러입니다.

#[unsafe(no_mangle)]
pub fn generic<T>(x: T) -> T {
    x
}
error: functions generic over types or consts must be mangled

1.97.1에서는 같은 코드가 경고만 내고 컴파일되었습니다. generic 함수는 타입마다 따로 단형화(monomorphization)되므로 하나의 고정된 심볼 이름과 맞지 않습니다. 경고를 무시하고 지나온 코드베이스라면 업그레이드 전에 한 번 검색해 보는 것이 좋습니다.


레거시 정수 모듈 완전 deprecated

std::i32, std::u8 같은 정수 타입 이름의 모듈과 그 안의 상수(std::i32::MAX 등), 그리고 std::f32·std::f64 모듈의 상수가 완전히 deprecated되었습니다. 관련 PR(rust-lang/rust#146882)은 이를 레거시 정수 모듈 정리 작업의 마지막 단계로 소개합니다. 다만 std::f64::consts 서브모듈은 deprecated 대상이 아닙니다.

let _ = std::i32::MAX;        // 1.99: deprecated 경고
let _ = std::f64::EPSILON;    // 1.99: deprecated 경고
let _ = std::f64::consts::PI; // 경고 없음

let _ = i32::MAX;             // 대신 associated constant를 사용
let _ = f64::EPSILON;
warning: use of deprecated constant `std::i32::MAX`: replaced by the `MAX` associated constant on this type

1.97.1에서는 위 코드에서 아무 경고도 나오지 않았습니다. 경고이므로 빌드가 깨지지는 않지만, CI에서 -D warnings로 경고를 에러로 다루고 있다면 업그레이드와 함께 빌드가 실패할 수 있습니다.


Pin::new_unchecked 안전 조건 변경

릴리즈 노트는 Pin::new_unchecked의 안전 조건이 "약간" 바뀌었다고 적고 있습니다. 관련 PR(rust-lang/rust#156935)은 기존의 PinCoerceUnsized trait을 PinSafePointer로 이름을 바꾸고, Pin이 사용하는 여러 safe trait 구현에 대한 안전 요구 사항을 명시합니다.

1.99 문서의 Pin::new_unchecked는 이 메서드를 호출하는 것이 포인터 타입 Ptr의 trait 구현에 대해서도 약속하는 것이라고 설명하고, 구체적인 요구 사항은 PinSafePointer에 명시되어 있으며 Ptr이 그 trait의 안전 요구 사항을 지켜야 한다고 적고 있습니다. PR이 정리한 요구 사항은 다음과 같습니다.

  • 항상 같은 객체를 가리킬 것: 같은 Pin<P>에서 deref/deref_mut를 호출하면 항상 같은 객체를 가리켜야 하고, 포인터 타입이 이동해도 주소가 바뀌면 안 됩니다. unsizing coercion이나 dynamic dispatch에 참여하는 포인터라면 그 과정에서 대상의 실제 타입이 바뀌어서도 안 됩니다.
  • 대상 값을 이동시키지 않을 것: deref_mut와 포인터 타입의 소멸자는 &mut self로 호출되지만, Pin<&mut Self>를 받은 것처럼 동작해야 합니다.
  • 공유 참조를 고정되지 않았다는 증거로 쓰지 않을 것: &P가 존재한다는 사실을 "값이 pin되지 않았다"는 근거로 쓰는 포인터라면, Clone이나 fmt::Debug·Display·Pointer에 전달되는 &self는 그런 근거로 취급하면 안 됩니다.
  • clone 결과가 다시 pin될 수 있을 것: Pin<P>를 clone하면 clone이 돌려준 포인터가 Pin::new_unchecked로 다시 감싸지므로, Clone 구현은 그것이 안전한 값을 돌려줘야 합니다.

PinSafePointer trait 자체는 1.99에서도 nightly 전용이라 stable에서 직접 구현할 수는 없지만, Pin::new_unchecked를 호출하는 쪽은 여전히 이 조건을 지켜야 합니다. Box, &mut T, Rc, Arc 같은 표준 포인터만 쓴다면 신경 쓸 일이 거의 없습니다. 하지만 직접 만든 스마트 포인터를 Pin::new_unchecked로 감싸는 코드가 있다면 그 타입의 Deref·DerefMut·Clone·Drop·포맷팅 trait 구현과 unsizing 동작이 위 조건을 지키는지 다시 점검해 봐야 합니다.


그 밖에 확인할 변경

호환성 노트와 라이브러리 항목에는 이 밖에도 동작이 바뀌는 변경이 몇 가지 있습니다. 그중 일상적인 코드에서 마주칠 가능성이 있는 두 가지를 짚어 둡니다.

  • 소진된 RangeInclusive의 동작: a..=b 범위의 반복이 일부 상황에서 더 잘 최적화되도록 바뀌었습니다. 그 부작용으로 반복자로 이미 소진된 RangeInclusive 값의 start()·end() 반환값이나, 그런 값을 슬라이스 인덱스로 쓸 때의 동작이 달라질 수 있습니다. 릴리즈 노트는 이 동작이 원래 보장된 것이 아니었으므로 breaking change로 보지 않는다고 설명합니다.
  • 다른 crate의 매크로가 만드는 세미콜론 lint: 세미콜론으로 끝나는 식으로 확장되는 매크로에 대한 lint(semicolon_in_expressions_from_non_local_macros)가 이제 다른 crate에서 온 매크로에도 경고를 냅니다. 릴리즈 노트는 이 경고를 만나면 자기 crate에서 경고를 끄기보다 매크로를 제공하는 crate에 알려 달라고 당부합니다.

전체 목록은 상세 릴리즈 노트의 Compatibility Notes에서 확인할 수 있습니다.


마무리

Rust 1.99.0의 주요 변경 사항을 정리하면 다음과 같습니다.

  • C-variadic 함수 정의: unsafe extern "C" fn f(args: ...) 형태로 C의 가변 인자 함수를 Rust에서 직접 정의할 수 있습니다. VaList는 C의 va_list와 ABI 호환되고, 읽을 수 있는 타입은 VaArgSafe로 제한되어 C의 인자 승격 실수를 타입 수준에서 막습니다.
  • raw 소유권 API: Vec::into_parts/from_parts, Box::into_non_null/from_non_null로 NonNull 기반의 분해·재조립이 가능해졌고, Box::leak 후 되살리는 패턴 대신 Box::into_non_null이나 Box::into_raw를 쓰라는 권고가 문서에 추가되었습니다.
  • 레이아웃 raw API와 편의 API: size_of_val_raw, Layout::for_value_raw로 참조 없이 크기와 정렬을 얻을 수 있고, Box<[T; N]>의 반복, VecDeque::retain_back, String::from_utf8_lossy_owned, fs::set_times가 추가되었습니다.
  • Cargo: debug 프로필이 생겼고, Edition 2024 멤버는 workspace 의존성의 default-features를 끌 수 있으며, CI에서는 incremental이 기본으로 꺼집니다. CI에서 다시 켜려면 프로필이 아니라 CARGO_INCREMENTAL 또는 build.incremental을 써야 합니다.
  • 호환성: generic 함수의 no_mangle은 이제 에러이고, std::i32::MAX 같은 레거시 상수는 경고를 냅니다. 직접 만든 포인터 타입을 Pin으로 감싸고 있다면 PinSafePointer의 조건을 확인해야 합니다.

업그레이드 전에는 no_mangle generic 함수와 레거시 정수 상수를 먼저 검색해 보고, CI 캐시 전략이 incremental에 의존하고 있었다면 빌드 시간과 캐시 크기가 어떻게 달라지는지 함께 살펴보시길 권합니다.


References