전체 글

Rust never 타입 안정화, 1.100에서 fallback이 ()에서 !로 바뀐다

xXfacebook페이스북

Rust의 never 타입, 즉 !를 안정화하는 PR이 2026년 8월 24일 병합됐습니다. 먼저 짚을 것은 아직 stable이 아니라는 점입니다. 오늘(8월 27일) 기준 stable은 8월 20일에 나온 1.98.0이고 이 변경은 1.100.0에 실립니다. 1.100.0은 master 브랜치 컷이 9월 25일, stable 출시가 11월 12일 예정이라 77일이 남았습니다. 중간의 1.99.0(10월 1일)에는 이 변경이 없습니다. 구현자 본인도 현재 상태를 stable (on nightly)이라고 적었습니다.

PR 제목은 "stabilize never type" 하나지만 하는 일은 네 가지이고, 그중 never type fallback을 전 에디션에서 ()에서 !로 바꾸는 항목은 PR 본문이 스스로 breaking change라고 적었습니다. 내 코드가 안 될 수 있는 지점은 여기입니다.

순서는 오해하기 쉽습니다. 에디션 2024를 쓰고 있다면 같은 변경을 2025년 2월 1.85.0부터 이미 겪었습니다. 에디션 2021 이하에서는 이 fallback에 의존하는 코드가 2025년 12월 1.92.0부터 컴파일 오류였습니다. 다만 오류는 직접 빌드하는 크레이트에서만 나고 의존성에서 나온 것은 cargo 경고에 그쳤습니다. 그래서 지금 오류가 없다는 것이 안전을 뜻하지는 않습니다 — 크레이터 실패 3천 건 가까이가 옛 버전 의존성에서 나왔습니다.

에디션 지금 1.100.0부터
2021 이하 fallback () · 이에 의존하는 코드는 lint 오류(deny) · #[allow] 억제 가능 fallback ! · lint 제거로 그냥 타입 오류
2024 fallback ! · 1.85.0부터 타입 오류 같음

2015년 7월 19일 RFC 1216 작성 시작부터 1.100.0 stable 출시 예정일까지 never 타입 안정화 시도를 가로 시간축에 표시한 그림. 2016년 7월 RFC 병합, 2018년 3월 첫 안정화 병합과 다음 달 되돌림, 2019년 11월 두 번째 병합과 다음 달 되돌림, 2025년 2월 에디션 2024 적용, 2025년 12월 lint의 deny 전환, 2026년 8월 24일 병합, 2026년 11월 12일 stable 예정이 차례로 놓인다.

10년 사이 병합된 뒤 되돌려진 것이 두 번 있었다.

PR 하나가 네 가지를 바꿉니다

병합된 것은 rust-lang/rust의 PR 155499(작성자 WaffleLapkin)입니다. 4월 18일에 열려 8월 24일 22시 30분(UTC)에 병합됐고 마일스톤은 1.100.0입니다.

PR 본문에 적힌 네 항목은 never 타입(!) 안정화, never type fallback을 전 에디션에서 ()에서 !로, 표준 라이브러리 Infallible을 !의 별칭으로, 그리고 dependency_on_unit_never_type_fallback lint 제거입니다. 마지막 항목의 이유는 ()로 떨어지는 fallback이 없어지므로 이 lint가 발동할 수 없다는 것입니다. 이 병합으로 트래킹 이슈 35121·148922가 닫혔습니다.

오해를 하나 정리하면, fn f() -> !처럼 반환 위치에 !를 쓰는 것은 예전부터 되던 일입니다. 이번에 안정화된 것은 !를 제네릭 인자나 연관 타입, 필드 타입처럼 타입이 놓이는 자리 전반에서 쓰는 것입니다.

fn foo() -> !;
fn call_a_fn<T, F: Fn() -> T>(f: F) -> T;

call_a_fn(foo) // ERROR!

10년, 그리고 되돌려진 두 번

RFC 1216 "Promote ! to a type."는 시작 날짜가 2015년 7월 19일, 병합이 2016년 7월 29일입니다. 기능 이름은 bang_type이고 RFC 헤더에 적힌 Rust 이슈가 위에서 말한 35121입니다.

그 사이 병합된 뒤 되돌려진 것이 두 번 있습니다. 2018년 3월 15일 병합된 PR 47630은 한 달 뒤 PR 50121로(마일스톤 1.26, 사유 이슈 49691), 2019년 11월 21일 병합돼 1.41.0을 겨냥했던 PR 65355는 12월 15일 PR 67224로(사유 이슈 66757) 되돌려졌습니다. 두 번 다 fallback 의미론을 원래대로 돌려놓았습니다.

구현자는 블로그에 10년간 불안정했고 다섯 번의 안정화 시도가 실패했으며 자신이 2년 넘게 이 일을 해 왔다고 적었습니다. 다섯 번이라는 수는 구현자 본인의 진술이고 저장소에서 확인한 것은 위의 되돌림 두 건입니다. 되돌림 이력이 있고 1.100.0까지 beta 단계가 남아 있으므로, 이 글에서도 stable 출시는 예정으로만 적습니다.

핵심은 fallback이 ()에서 !로 바뀌는 것입니다

never-to-any 강제변환에서 나온 추론 변수를 컴파일러가 추론할 수 없을 때가 있습니다. never type fallback은 그때 대신 채워 넣는 기본 타입입니다. 역사적으로 그 값이 ()였고 이번에 !로 바뀝니다. 아래 예시는 에디션 가이드와 표준 라이브러리 문서에 실린 것입니다.

// this
let x: u8 = panic!();

// is (essentially) turned by the compiler into
let x: u8 = absurd(panic!());

// where absurd is a function with the following signature
// (it's sound, because `!` always marks unreachable code):
fn absurd<T>(_: !) -> T { ... }

바꾸는 이유는 구현자가 PR 코멘트에 적었습니다. () 기본 처리가 "특히 클로저 주변에서 ! 타입 값이 저절로 붕괴하는" 혼란스러운 동작을 낳는다는 것입니다. 바깥에서 관측되는 차이는 에디션 2024 트래킹 이슈의 예시가 짧습니다.

print_return_type(|| todo!());
// before this change: prints `()`
// after this change:  prints `!`

같은 코드가 fallback이 ()일 때와 !일 때 어떻게 갈리는지 좌우로 비교한 그림. 왼쪽은 추론 변수가 ()로 채워져 Default 구현이 있어 컴파일되는 경로, 오른쪽은 !로 채워져 Default 구현이 없어 컴파일이 실패하는 경로를 보여준다.

같은 코드가 fallback 값에 따라 컴파일되기도, 실패하기도 한다.

이미 2025년 12월부터 오류였습니다

dependency_on_unit_never_type_fallback은 () fallback에 의존하는 코드를 잡아내는 future-compat lint입니다. 구현 PR은 125289인데 어느 릴리스에 처음 들어갔는지는 1차 출처로 확인하지 못했으므로 버전은 적지 않겠습니다. 1.89.0(2025년 8월 7일)에는 의존성 쪽 코드까지 보고하도록 넓어졌고(PR 141937), 1.92.0(2025년 12월 11일)에서 deny-by-default로 바뀌었습니다(PR 146167).

1.92.0 발표문은 이 lint와 never_type_fallback_flowing_into_unsafe가 deny-by-default가 되어 "검출되면 컴파일 오류를 일으킨다"고 적었습니다. 영향 크레이트는 약 500개로 집계됐고, 앞서 본 두 조건(#[allow]로 억제 가능, 의존성에서 나온 것은 cargo 경고)도 같은 문서에 적혀 있습니다.

1.100.0에서 달라지는 지점이 여기입니다. lint가 제거되므로 같은 코드는 lint 오류가 아니라 그냥 타입 오류가 됩니다. #[allow]로 눌러 둔 곳이 있다면 그 억제는 더 이상 효과가 없습니다. 한편 never_type_fallback_flowing_into_unsafe는 이번에 제거되지 않습니다. 왜 이쪽을 남기는지는 원문에서 확인하지 못했습니다.

never type fallback 관련 lint의 변화를 네 단계로 나눈 그림. 경고로 시작해 1.89.0에서 의존성까지 보고, 1.92.0에서 deny-by-default로 오류, 2026년 11월 12일 예정인 1.100.0에서 lint 제거와 함께 타입 오류가 되는 순서를 단계별 막대로 보여준다.

1.100.0에서는 lint 자체가 없어져 allow 로 억제할 수 없다.

내 코드에서 깨지는 지점

? 연산자가 가장 흔합니다

구현자의 크레이터 분석에 따르면 깨짐의 대다수는 ? 연산자에서 나옵니다. 타입이 정해지지 않은 채 ?를 쓰면 추론 변수가 fallback으로 채워지는데, ()에는 있던 Default 구현이 !에는 없습니다.

fn x<T: Default>() -> Result<T, ()> { Ok(T::default()) }

// Before fallback change: `?` makes rustc infer T=(); (): Default; OK
// After fallback change:  `?` makes rustc infer T=!;  !: !Default; ERR
x()?;

고침은 타입을 직접 적는 것입니다. x::<()>()?; 또는 () = x()?; 입니다. 직접 깨진 7건 중 4건은 redis 크레이트를 값 타입 지정 없이 쓴 경우였습니다.

클로저와 분기, 그리고 unsafe

에디션 가이드의 마이그레이션 절에는 클로저 안에서 panic!을 쓰는 예시가 있습니다. 반환 타입을 -> ()로 적어 주면 됩니다.

trait Unit {}
impl Unit for () {}

fn run<R: Unit>(f: impl FnOnce() -> R) {
    f();
}

run(|| panic!());            // 깨짐
run(|| -> () { panic!() });  // 고침

분기 한쪽이 !인 경우도 같아서, 아래 코드는 fallback이 ()면 컴파일되고 !면 실패합니다. 좌변에 () =를 붙여 타입을 고정하면 됩니다.

if true {
    return
} else {
    Default::default()
};

같은 모양이 unsafe와 만나면 훨씬 나쁩니다. 아래 코드는 fallback이 ()면 건전하지만 !면 즉시 정의되지 않은 동작이 됩니다. std::mem::zeroed::<()>()처럼 타입을 박아 두면 되고 이 상황을 잡는 lint는 이번에도 남습니다.

if true {
    return
} else {
    unsafe { std::mem::zeroed() }
};

크레이터가 말한 것과 말하지 않은 것

4월 20일 craterbot 코멘트는 전체 9,024건 중 3,277건 회귀, 0건 수정으로 보고했습니다. 같은 코멘트의 "1149 spurious results"는 그중 1,149건이 허위라는 뜻이 아니라 재시도 후보 목록의 규모입니다.

그래서 이 수치를 "3,277개 크레이트가 깨진다"로 읽으면 안 됩니다. 앞서 본 대로 대부분은 이미 패치된 라이브러리의 옛 버전에 의존한 결과입니다. 다음 날 구현자가 올린 분석에서 Primary breakage 절이 꼽은 직접 깨진 크레이트는 7건입니다 — rups, helloworld-tonic, srt-tokio, redis-lock, rust-trending, alion-admin-api, cache-any입니다.

같은 코멘트의 "significant" issues 절은 층이 다릅니다 — 역의존이 많아 따로 확인한 목록입니다. redis와 sqlx-postgres 0.7.4(역의존 618개), solana-client, wl-clipboard-rs, mlua, trie-db, ashpd가 여기 올랐고 전부 상위 버전에 수정판이 있습니다. 수정판이 없는 것은 두 곳뿐이고 둘 다 후속 크레이트로 대체됐습니다(rlua→mlua, screenshots→xcap).

크레이터 실험 결과를 층으로 나눈 그림. 전체 9,024개 대상 중 회귀 3,277건, 그중 직접 깨진 크레이트 7건이 단계별로 좁혀지는 층으로 표시되고, 나머지는 이미 수정판이 있는 옛 버전 의존에서 나왔다는 설명이 붙는다.

회귀 3,277건과 직접 깨짐 7건은 층이 다른 숫자다.

Infallible 이 타입 별칭이 됩니다

표준 라이브러리의 Infallible은 pub enum Infallible에서 pub type Infallible = !;로 바뀝니다. 안정화 표기는 1.34.0으로 남지만 문서 주소가 type.Infallible.html로 옮겨가 예전 링크는 끊어집니다. 이를 위해 From<!> for T reservation impl이 먼저 제거됐습니다 — 안정화 PR보다 앞서 병합된 PR 160705입니다.

cargo-semver-checks는 이 변경을 major breaking으로 잡지만 논의에서 의도된 결과로 정리됐습니다. 승인 이슈는 별도로 155924이고, 8월 27일 현재 FCP가 끝난 disposition-merge 상태로 아직 열려 있습니다. 그 이슈는 깨짐을 크레이트 수로 집계했습니다. 강제변환된 never 타입에 메서드를 호출하는 경우(anyhow::anyhow!(e)) 약 20개, 안정성 검사 우회 9개, reservation impl 충돌 14개, Box<dyn Send> 강제변환 1개입니다. 이 집계는 PR 155500·155501에서 따로 돌린 크레이터 실험 결과로, 앞의 155499 리포트(9,024/3,277/7)와는 다른 실험입니다. 가장 큰 건은 anyhow 호환이라고 본인이 적었습니다.

stable은 11월 12일 — 지금 확인하는 법

지금 확인할 경로는 nightly입니다. beta 채널이 언제 1.100.0으로 넘어가는지는 릴리스 일정에 적혀 있지 않습니다. 확인은 #[allow(dependency_on_unit_never_type_fallback)]이 걸린 자리부터가 빠릅니다. 1.92.0부터 deny였던 lint를 눌러 둔 자리가 곧 타입 오류가 됩니다. 그다음은 위에서 본 ? 연산자와 클로저 패턴, 옛 버전에 머문 의존성입니다.

nightly 표준 라이브러리 문서에는 never 타입의 안정화 버전이 1.100.0으로 이미 표기돼 있습니다. 다만 같은 페이지의 fallback 설명 절은 낡아 있어 그 문장만 읽으면 변경이 아직 남은 일처럼 읽힙니다. 재작성 PR 158370이 8월 26일 병합됐습니다.

근거와 참고

이 글 공유하기xXfacebook페이스북