Rust의 `!` 타입이 왜 바닥 타입이 아니라 빈 타입이며, 그 차이가 코드에서 어떻게 드러나는지 설명합니다.
ettolrach가 작성, 2026-09-08.
이 블로그 글을 작성하는 데 LLM은 사용되지 않았습니다.
Rust는 최근 never 타입(waffle, 2026), 즉 !를 안정화했습니다. 이전에도 함수 반환 타입에는 !를 사용할 수 있었지만, 다른 타입 주석에는 사용할 수 없었습니다. 모든 타입 주석에 이를 가능하게 한 풀 리퀘스트는 2026년 8월 25일에 병합되었으며, 이 글을 쓰는 시점에는 Rust 1.100에 추가될 예정입니다. 사람들은 이를 흔히 바닥 타입과 비교하지만, 사실 빈 타입이라고 부르는 편이 더 정확합니다.
대체로 예상한 대로 작동하지만, 이를 배우는 대부분의 사람을 놀라게 하는 흥미로운 세부 사항이 하나 있습니다. 타입의 일부로 !를 포함하는 표현식이 반드시 다른 모든 타입으로 강제 변환되는 것은 아닙니다. 또한 빈 타입과 바닥 타입은 Rust를 포함한 어떤 언어에서도 반드시 같은 것이 아닙니다.
빈 타입은 값도 생성자도 없는 타입으로 정의합니다. 이와 함께 다음 함수도 둡니다. fn absurd<A>(x: !) -> A. 이 함수는 타입이 !인 임의의 값을 받아 원하는 타입의 무언가를 반환합니다(엄밀히 말하면 타입이 !인 것을 인자로 넘겨 호출할 수 없으므로 절대 호출되지 않으며, 따라서 무언가를 반환하지도 않습니다. 하지만 ‘무엇이든’을 구성할 수 있다고 보는 편이 개념적으로 훨씬 자연스럽습니다).
바닥 타입 역시 값도 생성자도 없습니다. 하지만 이 absurd 함수를 두는 대신, _서브타이핑_을 사용하여 모든 타입의 서브타입으로 만듭니다. 즉, 상속이 작동하는 방식 덕분에 다른 무엇이든 대신하여 사용할 수 있습니다(타입 A가 타입 B의 서브타입이라면, 코드가 B를 요구하는 어디서든 A를 사용할 수 있음을 기억하세요).
둘이 다른 이유는 분명합니다. 빈 타입은 언어에 서브타이핑이 있을 필요가 없지만, 바닥 타입은 그렇지 않기 때문입니다! Rust의 서브타이핑 규칙은 라이프타임을 가진 타입들 사이에서만 작동하므로, 바닥 타입을 가질 수 없습니다(예를 들어 !에서 bool로 가는 데에는 라이프타임이 전혀 관여하지 않습니다). 하지만 둘 사이에는 좀 더 실용적인 차이도 있습니다.
먼저 적절한 서브타이핑을 갖춘 언어인 Scala를 사용하겠습니다. Scala는 빈 타입을(그리고 서브타이핑을 갖추었으므로 바닥 타입도) Nothing이라고 부릅니다. Nothing을 활용하려면 예외를 throw하겠습니다. throw된 예외는 잡을 수 있지만, Rust의 panic!처럼 현재의 지역 코드에 값을 반환하지는 않습니다. 따라서 Nothing을 반환합니다.
val f: Int => Nothing = (_) => throw Error() val g: Int => Int = f @main def main() = ()
서브타이핑 규칙과 _변성_이라는 개념 덕분에 여기서는 특별한 일을 할 필요 없이 g가 f를 사용하도록 지정할 수 있습니다.
Rust에서는 Scala와 마찬가지로 어떤 값도 반환하지 않기 위해 panic!을 사용합니다(따라서 타입 !를 반환합니다). Rust에는 서브타이핑이 없으므로 absurd 함수를 활용해야 합니다. Rust는 타입이 !인 모든 표현식 주위에 암묵적으로 absurd 호출을 넣습니다. 사실 Rust에는 absurd 함수가 없지만(그래도 그렇게 생각하면 도움이 된다고 봅니다), 필요한 경우 타입 !의 표현식을 어떤 타입으로든 암묵적으로 타입캐스트할 뿐입니다. Rust에서 이 암묵적 타입캐스트를 _강제 변환_이라고 합니다. 중요합니다! 함수 타입처럼 다른 타입의 일부로서 어떤 타입 안에 !가 포함되어 있으면 강제 변환은 일어나지 않습니다. 그래서 아래 코드는 타입 검사를 통과하지 못합니다.
fn f() -> fn(i32) -> ! { |_| panic!() } fn g() -> fn(i32) -> i32 { f() } fn main() { }
그러면 다음 오류가 출력됩니다.
error[E0308]: mismatched types
--> src/main.rs:6:5
|
5 | fn g() -> fn(i32) -> i32 {
| -------------- expected fn(i32) -> i32 because of return type
6 | f()
| ^^^ expected i32, found !
|
= note: expected fn pointer fn(_) -> i32
found fn pointer fn(_) -> !
바닥 타입과 달리, 우리는 !에서만 다른 모든 타입으로 타입캐스트할 수 있습니다. 이것이 다른 타입 안에 중첩되어 있으면 같은 일을 할 수 없습니다. 따라서 대신 아래와 같은 코드를 작성해야 합니다.
fn f() -> fn(i32) -> ! { |_| panic!() } fn g() -> fn(i32) -> i32 { |x| { let never: ! = f()(x); // 여기서 강제 변환됩니다. 다른 언어에서는 ~absurd(never)~라고 쓸 것입니다. never // 더 명시적으로 쓰려면 다음과 같이 할 수 있습니다. // let number: i32 = never; // return number; } } fn main() { }
그러면 오류 없이 컴파일됩니다(사용하지 않음 경고는 발생하므로 #[allow(unused)]가 필요합니다)! 그러므로 이 경우 Rust는 Scala보다 표현력이 떨어지지 않습니다. 단지 사용하기가 조금 더 불편할 뿐입니다. 어떤 타입 A에 대해서나 이것이 일반화되지는 않는다는 점은 언급할 가치가 있습니다. A -> ! 게터 함수를 반드시 갖는다는 보장이 없기 때문입니다.
이제 두 타입 사이에 실제적이고 실용적인 차이가 왜 있는지, 그리고 제가 Rust의 !를 바닥 타입이 아닌 빈 타입과 비교하는 것이 중요하다고 생각하는 이유를 알 수 있기를 바랍니다.
많은 일이 그렇듯 Wikipedia에서 온 것이라 의심합니다! Bottom type(Wikipedia contributors, 2026) 문서에는 다음과 같이 쓰여 있습니다.
타입 시스템이 건전하다면 바닥 타입은 비거주적이며, 바닥 타입의 항은 논리적 모순을 나타낸다. 그러한 시스템에서는 일반적으로 바닥 타입과 빈 타입을 구별하지 않으며, 이 용어들은 서로 바꾸어 사용할 수 있다.
이 문장은 타입 시스템이 건전하다는 것만이 전제 조건인 것처럼 보여 상당히 오해의 소지가 있습니다. 실제로는 타입 시스템에 서브타이핑 관계도 필요합니다. 정의상 바닥 타입은 바로 그 방식으로 작동하기 때문입니다(아래에서 자세히 설명하겠습니다). 공정하게 말하자면, Bottom type 문서는 전반에 걸쳐 서브타이핑을 언급하므로 주변 문맥상 그것이 ‘명백하다’고 말하며 넘어갈 수도 있습니다. 하지만 Empty type(Wikipedia contributors, 2026) 문서에는 ‘subtype’이라는 말이 어디에도 없고 다음과 같이 쓰여 있습니다.
타입 시스템에 빈 타입이 포함되어 있다면 바닥 타입도 비거주적이어야 하므로, 둘을 구별하지 않으며 둘 다 로 표기한다.
다시 말해 ‘bottom type’에 링크가 걸려 있으니 독자가 언어에 서브타이핑도 필요하다고 가정하리라 기대할 수 있습니다. 하지만 대부분의 사람이 그 문장을 그렇게 읽는다고 생각하지는 않습니다. 제 생각에는 이것이 사람들을 오도해 온 원인입니다.
물론입니다! 프로그래밍 언어 이론에 관심이 없다면 다음 내용은 굳이 알 필요가 없지만, 궁금하다면 제대로 된 정의를 제시하는 편이 좋겠습니다.
빈 타입은 생성자가 없는 타입으로 정의하며, 보통 로 표기합니다. 일반적으로 빈 타입에는 를, 단위 타입(Rust에서는 ())에는 를 사용합니다. 빈 타입이 가진 유일한 규칙은(의존 타입 언어가 아닌 경우) 제거 규칙입니다.
위에서 보았듯이 이 규칙을 작성하는 방법은 두 가지입니다. 첫 번째에서는 패턴 매칭을 사용합니다. 다뤄야 할 경우가 전혀 없다는 점에 주목하세요. 이는 매칭해야 할 패턴이 없다는 뜻입니다. 두 번째에서는 흔히 _absurd_라고 알려진 함수를 사용하는데, 그 타입은 1입니다.
서브타입을 짧게 복습해 봅시다. 어떤 타입들과 에 대해 코드가 를 필요로 할 때마다 대신 를 사용할 수 있도록, 서브타이핑 관계를(‘의 서브타입’으로 읽습니다) 정의합니다. 예를 들어 Java에서는 ArrayList <: AbstractList가 성립하며, 이는 코드가 AbstractList를 요구하는 모든 곳에서 ArrayList를 사용할 수 있음을 뜻합니다.
서브타이핑은 전순서를 이룹니다 (Pierce, p. 185, 2002)2. 바닥 타입은 보통 로 표기하며, 이 역시 생성자 없이 선언하되 추론 규칙을 두는 대신 서브타이핑 전순서의 최소 타입으로 만듭니다. 실제로 모든 타입 에 대해 성립하도록 하는 최소 타입으로 만듭니다. 꼭대기 타입도 같은 방식으로 정의할 수 있으며, 방향만 반대입니다(모든 타입 에 대해 가 성립합니다).
이 두 정의는 모두 바닥 타입을 필요한 어떤 타입으로든 타입캐스트할 수 있게 합니다. 언어에 서브타이핑이 있다면, 바닥 타입은 빈 타입과 같습니다.
특히 사용자가 직접 enum을 정의할 수 있게 한다면, 언어에는 여러 빈 타입이 있을 수 있다는 점을 언급할 가치가 있습니다. 예를 들어 Rust에서는 enum E { }를 사용해 나만의 빈 타입을 만들 수 있고, 패턴 매칭을 사용해 타입 E의 값을 원하는 다른 어떤 타입으로든 변환할 수도 있습니다! 이것이 바닥 타입은 유일하지만 빈 타입은 유일하지 않은 이유입니다.
그 Scala 코드는 어떻게 작동했을까요? (Int => Nothing)이 (Int => Int)의 서브타입임을 어떻게 알 수 있을까요? 함수의 서브타이핑 규칙은 다음과 같습니다.
이를 반환 타입에서 _공변적_이라고 합니다. 인자에서는 서브타이핑 관계가 반대 방향으로 간다는 점에 주목하세요. 이를 인자 타입에서 _반공변적_이라고 합니다. 변성에 관해서는 Benjamin Hodgson의 The Fourth Type of Variance(2019)를 강력히 추천합니다. Nothing은 Int의 서브타입이므로 반환 타입은 실제로 공변적이며, 이 규칙으로 (Int => Nothing) <: (Int => Int)를 얻을 수 있습니다.
이 모든 말을 했으니, 이제 항상 스스로에게 물어야 할 질문을 던져야 합니다.
Haskell에는 잘 알려진 대로 지연 평가가 있습니다. Haskell이 Void라고 부르는 빈 타입을 다른 언어보다 조금 더 다룰 수 있게 해 주는, 특히 재미있는 조합이 있습니다.
module Main where import Data.Void import Control.Exception (assert) nameSide :: Either Void Int -> String nameSide (Left _) = "left" nameSide (Right _) = "right" main :: IO () main = do assert (nameSide leftVoid == "left") $ assert (length voidList == 2) $ return () where voidList :: [Void] -- ~undefined~는 Rust의 ~panic!()~와 같습니다. voidList = [undefined, undefined] leftVoid :: Either Void Int leftVoid = Left undefined
Void를 “만들었음에도”, 이를 평가하지 않는 한 프로그램은 문제없이 실행됩니다. Rust에서는 panic!()이 즉시 평가되어 프로그램을 종료하므로 작동하지 않습니다(적어도 지역 코드는 종료합니다). 이는 지연성이 엄격 평가와 다른 값을 만들어 내는 사례입니다.
지연성의 또 다른 효과는 현재 Either의 어느 변형이 점유되어 있는지 추적해야 한다는 점입니다. Left Void일까요, 아니면 Right Int일까요? Rust에서는 그럴 필요가 없습니다. Ok(/* something of type ! */) 형태의 값을 가질 수 없다는 사실을 알고 있기 때문입니다. 따라서 std::mem::size_of::<Result<i32, !>>()는 4, 즉 i32만 있을 때와 같은 크기를 반환합니다.
두 타입은 같지 않으며 Rust에는 바닥 타입이 없습니다(현재로서는 그렇고, 저는 이런 타입이 Rust에 절대 추가되지 않을 것이라고 과감히 주장하겠습니다). 하지만 그렇다고 Rust가 서브타이핑을 갖춘 언어만큼 표현력이 없다는 뜻은 아닙니다.
위 텍스트는 정정되었으며, 세부 사항은 아래와 같습니다.
size_of 예시를 더 명확하게 만들었습니다.!가 없었다는 인상을 더는 주지 않도록 서론을 다시 작성했습니다.Rust와 또 다른 객체 지향 프로그래밍 언어 모두에 존재하는, 제네릭을 사용해 다른 타입을 포함하는 타입(함수 타입 제외)을 하나 고르세요. 그 타입을 A<T>라고 합시다. 그러고 나서 위와 마찬가지로, 선택한 타입 X에 대해 객체 지향 언어에서 A<!>를 A<X>로 타입캐스트하는 것이 Rust와 어떻게 다른지 보이세요.
힌트: Result(Either라고도 함)를 사용하세요.
풀이: Scala를 사용하면, def getEitherNothing(): Either[Nothing, Nothing] = Left(throw Error()) // 이전과 마찬가지로 타입 Either[Nothing, Nothing]의 항을 그냥 사용할 수 있습니다. def getEitherRegular(): Either[Int, String] = getEitherNothing() @main def main() = () Rust에서는, fn get_result_never() -> Result<!, !> { Ok(panic!()) } fn get_result_regular() -> Result<i32, String> { // 하지만 Rust에서는 패턴 매칭을 해야 합니다. match get_result_never() { Ok(n) => n, Err(n) => n, }; } fn main() {}
Result<!, !>는 항상 비거주적임을 알고 있으므로, 이를 다른 어떤 타입으로든 강제 변환할 수 있어야 합니다. 서브타이핑을 사용하면 Nothing <: Int와 Nothing <: String이라는 사실을 이용하고, Either의 두 타입 매개변수가 모두 공변적이므로 Either[Nothing, Nothing] <: Either[Int, String]이라고 결론 내릴 수 있습니다. Rust에서는 먼저 ! 값 하나를 얻어야 하고(두 Result 변형이 모두 !이므로 가능합니다), 그다음 이를 반환하여 요구된 타입으로 강제 변환합니다.
absurd에 abort라는 이름을 쓰는 듯합니다. 예를 들어 Pfenning은 강의 노트에서 (Pfenning and Platzer, 2024), Harper는 PFPL 초판(Harper, 2013)에서 그렇게 합니다. 하지만 Harper는 이를 후회하며, ‘abort’가 ‘panic/프로그램 종료’처럼 너무 들린다는 이유로 패턴 매칭 문법 case L of { }를 선호한다고 말하게 됩니다(그리고 PFPL 2판에서 이를 사용합니다) (Harper, 2026). 제가 처음 프로그래밍 언어 이론을 배운 Edinburgh에서는 일반적으로 패턴 매칭을 사용하지만, absurd도 사용합니다. 어쨌든 저는 absurd 함수가 정말 좋습니다. 언어에 패턴 매칭이 필요하지 않기 때문입니다. ↑1{ x: Int, y: Int }와 { y: Int, x: Int }를 서로의 서브타입으로 선언할 수 있지만, 둘은 같지 않기 때문입니다. ↑2