함수의 반환 타입은 그 함수가 어떤 데이터를 내놓는지를 나타낸다. 그렇다면 아예 값을 반환하지 않는 함수, 예컨대 무한 루프를 돌거나 프로세스를 종료해버리는 함수는 어떻게 표현해야 할까. 러스트는 이를 위해 느낌표(!)로 표기하는 'never' 타입을 두고 있다. 값이 절대 존재할 수 없는 자리를 표시하는 타입이다. 이 타입은 오랫동안 컴파일러 내부에서만 쓰였고 정식 기능으로는 인정되지 않는 불안정(unstable) 상태였는데, 컴파일러 기여자 'waffle'이 2년 넘는 작업 끝에 지난 8월 24일 마침내 안정화에 성공했다. 안정화가 오래 걸린 이유 중 하나는 이 변경이 기존 러스트 에디션에 작은 하위 호환성 파괴를 동반했고, 그것이 실제 코드에 큰 영향을 주지 않는지 확인해야 했기 때문이다.
never 타입이 필요한 두 가지 이유
never 타입의 존재 이유는 실용적인 것과 철학적인 것으로 나뉜다. 실용적 측면은 더 효율적인 제네릭 코드를 가능하게 한다는 점이다. 표준 라이브러리의 FromStr 트레이트를 보자. 이 트레이트는 문자열로부터 타입을 생성하며, 변환 결과 또는 사용자 정의 오류 타입을 돌려준다. 예를 들어 "foo"를 정수로 바꾸려 하면 ParseIntError가 반환된다. 그러나 문자열을 ByteString으로 바꾸는 것처럼 결코 실패할 수 없는 변환도 있다. 이런 구현에서 오류 타입을 never로 지정하면, 컴파일러는 결과의 오류 분기가 절대 발생하지 않음을 알고 그 분기를 다루거나 검사하는 코드를 모두 제거할 수 있다.
철학적 측면은 올바른 타입 추론과 관련된다. 러스트에서 if 문이나 while 루프는 값을 만들어내는 표현식이며 그 결과를 변수에 대입할 수 있다. 프로그래머가 무한 루프를 작성했을 때도 컴파일러는 그 결과에 부여할 타입이 필요하다. 이런 경우를 위해 특별한 규칙을 따로 두기보다, never 타입으로 일괄 처리하는 편이 추론을 단순하게 만든다. never 타입은 다른 어떤 타입으로도 자동 변환된다는 유용한 성질을 갖는데, 이는 값을 결코 만들어내지 못하는 계산의 '결과'를 뜻하므로 안전하다. never 값이 있다고 주장하는 코드에는 애초에 도달할 수 없으니 무시해도 되는 것이다. 타입 시스템이 주도하는 죽은 코드 제거인 셈이다.
걸림돌이 된 타입 추론의 구석진 사례
안정화의 핵심 난관은 never에서 다른 타입으로의 변환 방식 때문에 생기는 추론 모호성이었다. 반환하지 않는 익명 함수를 정의하고 이를 ? 연산자와 함께 구체적인 오류 타입을 기대하는 위치에서 호출하면, 무한 루프에는 ! 타입이 부여되지만 지역적으로 정의돼 명시적 타입이 없는 함수라 컴파일러가 그 타입을 확정하지 못한다. 함수에 명시적 반환 타입을 달면 해결되지만, 값을 반환할 수 없는 함수에 굳이 가상의 타입을 붙이라고 요구하는 것은 무의미하다. 그래서 컴파일러는 모든 추론을 마친 뒤에도 확정되지 않는 타입이 남으면 지정된 대체(fallback) 타입으로 간주하는 규칙을 둔다. 2024 에디션 이전에는 그 대체 타입이 값이 하나뿐인 유닛 타입 ()이었지만, 2024 에디션에서는 ! 자체로 바뀌어 사실상 암묵적 변환을 상쇄하게 했다.
이 변경은 기술적으로 하위 호환성을 깨는 것이었다. 일부 코드의 타입 추론이 달라져 컴파일 오류로 이어질 수 있었다. 원래 러스트의 에디션 시스템은 전체 생태계를 한꺼번에 갱신하지 않고도 이런 파괴적 변경을 허용하기 위한 장치다. 다만 이번에는 옛 에디션에도 새 동작을 역이식해야 할 이유가 있었다. 표준 라이브러리는 오랫동안 never의 불안정성을 우회하려고 Infallible 타입을 제공해 왔는데, 이는 의미상 never와 같지만 컴파일러의 특별 지원이 없어 열거형에 불필요한 태그 계층이 생기거나 죽은 코드가 남는 등 최적화 여지를 놓치곤 했다. 계획은 never가 안정화되면 Infallible을 !의 타입 별칭으로 만들어 기존 코드가 조용히 더 효율적으로 바뀌게 하는 것이었다. 그런데 Infallible의 재정의가 뜻하지 않게 파괴적 변경이 되는 경우가 지적됐다. 다행히 Infallible 재정의와 대체 타입 변경이라는 두 변경이 거의 서로 상쇄돼, 이름으로 Infallible을 참조하는 코드는 그대로 동작한다.
3,300개 크레이트, 그리고 남은 문제
두 변경이 동시에 일어나면 거의 모든 기존 코드가 계속 컴파일된다고 러스트 관리자들은 봤지만, 호환성 문제에서 '거의'는 안심할 수 있는 표현이 아니다. 이를 검증하기 위해 러스트에는 crater가 있다. crates.io에 공개된 모든 라이브러리를 내려받아 컴파일하며 변경으로 깨지는 코드를 찾는 도구다. waffle이 4월에 crater를 돌린 결과, 3,300개 크레이트가 부정적 영향을 받았지만 완전히 깨진 것은 7개뿐이었고, 나머지는 이미 수정된 라이브러리의 옛 버전에 의존하는 경우였다. 러스트가 2024년부터 새 변경으로 깨질 never 대체 상황에 경고를 내보내 온 덕에 대부분 라이브러리가 미리 갱신할 시간이 있었다. 가장 흔히 남은 오류는 컴파일러가 반환 타입을 특정하기에 정보가 부족한 상태로 제네릭 함수를 호출하는 코드였다. Default 트레이트를 요구하는 타입 T를 지정하지 않고 호출하면, 예전에는 ()가 T로 가정돼 Default를 구현하므로 컴파일됐지만, 이제는 !가 가정되는데 !는 Default를 구현하지 않아 오류가 난다. 해결책은 호출부나 패턴 매칭 대입에서 타입을 명시하는 것이다.
관리자들은 3,300개 크레이트를 깨뜨릴 생각이 없었기에, waffle은 주요 라이브러리 관리자들과 협업해 이런 간단한 수정을 패치 버전으로 역이식하도록 했다. 많은 빌드 환경이 패치 버전을 자동으로 받아가기 때문이다. 응한 저자도 있었지만 수명이 다한 버전이라며 거절한 이들도 있었는데, 그 경우 사용자가 옛 러스트를 유지하거나 유지보수 중인 버전으로 올리면 된다고 지적했다. 그럼에도 성공한 역이식은 실패 크레이트 중 1,553개를 해결했다. 결국 러스트 1.99부터 never 타입은 안정화되고 Infallible은 never의 타입 별칭이 된다.
오래도록 문제없이 컴파일되던 코드가 갑자기 실패한다는 점에서 이는 러스트의 하위 호환성 약속에 대한 위반으로 볼 수도 있다. 반대로 문제가 비교적 드물고 고치는 방법이 여러 가지이며 수년간 경고돼 왔고 애초에 언어 계획의 일부였다는 점, 그리고 관리자들이 커뮤니티와 직접 협력해 수명이 끝난 인기 라이브러리의 수정까지 역이식했다는 점에서 오히려 호환성에 대한 진지함을 재확인한 사례로 볼 수도 있다. 한국의 러스트 실무자 관점에서 실질적인 조치는 명확하다. 2024 에디션으로 옮기거나 새 컴파일러를 채택할 때 never 대체 관련 경고를 무시하지 말고, Default 같은 트레이트를 요구하는 제네릭 호출에서 반환 타입을 명시하는 습관을 들이는 것이다. 대부분의 사용자는 아무 영향도 받지 않겠지만, 호환성 문제에서 '결코 없다(never)'고 장담하기는 어렵다.