‘Rust의 영혼’이라는 표현의 의도를 풀어 설명하며, 투명성과 생산성·범용성 사이의 긴장, 비동기 트레이트 설계에서의 절충과 잠재적 ‘제3의 길’을 논의한다.
이전 글(https://smallcultfollowing.com/babysteps/blog/2022/09/18/dyn-async-traits-part-8-the-soul-of-rust/)을 다시 읽어보니, 왜 그것을 ‘Rust의 영혼’이라고 불렀는지 분명히 해야겠다는 생각이 들었습니다. 내 생각에, Rust의 영혼은 분명히 할당을 명시적으로 하는 것이 아닙니다. 오히려 몇 가지 핵심 가치들—특히 생산성과 범용성1이 투명성과 긴장을 이루는—사이의 씨름에 관한 것입니다. Rust의 목표는 언제나 고수준 언어처럼 느껴지되 저수준 언어의 성능과 제어력을 갖추는 것이었습니다. 종종 우리는 절충을 제거하는 ‘제3의 길’을 찾아 양쪽 목표를 상당히 잘 만족시키곤 합니다. 하지만 그런 ‘제3의 길’을 찾는 데에는 시간이 걸립니다 — 그리고 때로는 진전을 위해 당분간은 어떤 가치에는 일정한 손해를 감수해야 하기도 합니다. 바로 이런 어려운 결정을 내려야 할 때 ‘Rust의 영혼’에 대한 질문이 고개를 듭니다. 요즘 이 문제를 많이 생각해왔기에, Rust에서 투명성이 하는 역할과 그 주변에서 발생하는 긴장에 대해 더 자세히 써보려 합니다.
🔧 투명성: “저수준 세부사항을 예측하고 통제할 수 있다”
C 언어는, 잘 알려져 있듯이, 기계가 일반적으로 동작하는 방식과 매우 가깝게 대응됩니다. 그래서 사람들은 때때로 그것을 “이식 가능한 어셈블리”라고 부르곤 했죠.2 C++과 Rust는 그 전통을 이어가되 더 높은 수준의 추상화를 덧붙이려 합니다. 필연적으로 긴장이 생깁니다. 예를 들어, 연산자 오버로딩은 a + b가 무엇을 하는지 파악하기를 더 어렵게 만듭니다.3
투명성이 자동으로 높은 성능을 보장하는 것은 아니지만, 제어력을 제공합니다. 이는 시스템을 설계할 때 원하는 대로 동작하도록 설정하는 데 도움이 될 뿐만 아니라, 성능을 분석하거나 디버깅할 때도 도움이 됩니다. 눈에 보이는 코드만 몇 시간이고 들여다보다가 문제의 원인이 보이지 않는, 명시되지 않은 어떤 상호작용에 있었음을 깨닫는 것만큼 좌절스러운 일은 없습니다.
투명성의 이면에는 과도한 명세화가 있습니다. 프로그램이 어셈블리에 더 직접적으로 대응될수록, 컴파일러와 런타임이 영리한 일을 할 여지가 줄어들어 성능이 낮아질 수 있습니다. Rust에서는 언제나 성능을 얻기 위해 더 적은 투명성을 허용할 수 있는 지점을 찾고 있지만 — 어디까지나 한계선 안에서입니다. 한 가지 예가 구조체 레이아웃입니다. Rust 컴파일러는 구조체의 필드들을 재배열할 자유를 유지하여 더 압축적인 데이터 구조를 만들 수 있게 합니다. 이는 C보다 덜 투명하지만, 보통은 신경 쓸 일이 아닙니다. (물론 필드 순서를 지정하고 싶다면 #[repr] 속성을 제공합니다.)
하지만 투명성의 더 큰 대가는 범용성입니다. 실제 문제에 중요하지 않을 수도 있는 저수준 세부사항에 모두가 신경 쓰도록 강제하기 때문입니다4. dyn async trait와 관련해서 말하자면, 대부분의 비동기 Rust 시스템은 예를 들어 이곳저곳에서 할당을 수행합니다. 특정 비동기 함수 호출이 Box::new를 부를 수도 있다는 사실은 성능 문제로 이어질 가능성이 낮습니다. 그런 사용자에게 Boxing 어댑터를 선택하도록 하는 것은 얻는 것에 비해 관리해야 할 복잡성만 늘립니다. 최고 성능이 필요하지 않은 프로젝트를 하고 있다면, 이는 러스트를 다른 언어들보다 덜 매력적으로 만들 것입니다. 좋고 나쁨의 문제가 아니라, 사실일 뿐입니다.
현재 비동기 트레이트 설계에서 우리는 “Rust가 얼마나 범용적일 수 있는가”라는 핵심 질문과 씨름하고 있습니다. 지금으로서는 “제로섬 상황”처럼 느껴집니다. 투명성을 보존하기 위해 Boxing::new 같은 것을 도입할 수 있지만, 그만큼 범용성에서 비용을 치르게 될 것입니다 — 부디 크지 않기를 바랄 뿐이죠.
하지만 어딘가에 ‘제3의 길’이 숨어 있지 않을까 하는 생각이 듭니다. 이전 글에서 약간 힌트를 주었죠. 당장은 그 제3의 길이 무엇인지 모르겠고, 명시적인 어댑터를 요구하는 것이 가장 현실적인 전진 방법이라고 생각합니다. 하지만 아직 완벽한 달콤한 지점은 아닌 듯하고, 더 일반적인 무언가 속에 이것을 흡수해낼 수 있기를 기대하고 있습니다.
‘제3의 길’로 이어질지도 모를 몇 가지 재료:
const fn을 작성할 수 있게 할 수도 있습니다.댓글은 이 internals 스레드에 남겨주세요. 감사합니다!
원글에서는 범용성에 대해 쓰지 않았고 생산성의 타격에 초점을 맞췄습니다. 하지만 지금 생각해보면, 여기서 핵심은 범용성입니다 — 범용성은 Rust가 고수준 작업에도, 저수준 작업에도 유용하다는 뜻이었고, 명시적인 dyn 어댑터를 요구하는 것은 분명히 고수준 측면에 불리한 타격입니다. 흥미롭게도, 나는 목록에서 범용성을 투명성 뒤에 두었는데, 이는 우선순위가 더 낮음을 의미하며, 어떤 형태로든 명시적 어댑터를 두자는 결정에 힘을 실어주는 듯합니다.↩︎
이 대목에서 어떤 분들은 C 코드에 실제로 숨어 있는 수많은 미묘함과 디테일을 지적하곤 합니다. 쉿.↩︎
예전에 함께 일하던 동료가 우리 코드베이스에서 누군가 -> 연산자를 오버로딩했다는 사실을 알아냈던 일이 떠오릅니다. 그 동료는 분노의 이메일을 보냈죠. “도대체 어디까지입니까? 코드의 모든 점과 꼬불꼬불한 기호 하나하나를 다 확인해야 합니까?” (주: Rust는 deref 연산자 오버로딩을 지원합니다.)↩︎
달리 말하면, 한 가지를 지나치게 투명하게 만들면 다른 것들이 더 모호해질 수 있습니다(‘나무만 보고 숲을 보지 못하는’ 격). ↩︎