Rust 설계를 제어 흐름 효과(실패 가능성, 비동기성, 반복) 관점에서 재구성하고, 각 효과를 다루는 네 가지 레지스터(코어·소비·조합기·제어-흐름)를 제안한다. 반복과 실패 가능성의 누락된 제어-흐름 레지스터(제너레이터와 try 블록), AsyncIterator 설계의 함정, 그리고 조합기 스타일과 제어 흐름 스타일의 경계를 논의한다.
Rust의 레지스터
활동적으로 Rust 프로젝트에 기여했던 때로부터 거의 2년 반이 지났습니다. 그동안 무척 기대했던 릴리스들도 있었고, 거대한 새 프로젝트보다는 안정성과 다듬기에 무게를 두자는 Niko의 최근 글을 보며 마음이 든든해지기도 했습니다. 하지만 한편으로는 프로젝트가 택한 여러 방향에 대해 불안함을 느끼기도 했고, 그 생각이 머릿속을 자주 떠나지 않기도 했습니다. 그런 집착에서 이 글이 나왔습니다. 앞으로 몇 주에 걸쳐 특히 async와 관련된 2023년의 Rust 설계에 대한 제 생각을 정리하는 연재의 첫 글이 되길 바라며, 그 영향이 주로 긍정적이길 바랍니다.
제가 Rust 설계에 대해 품고 있었고, 일을 그만두기 전 다른 사람들에게 전달하려 했지만 충분히 전파하지 못했다고 느끼는 한 가지 관점은, 바로 _제어 흐름 효과_를 중심으로 한 일관적이지만 불완전한 설계가 비교적 자연스럽게 도출되었다는 것입니다. 이는 일반적인 “효과 시스템”이 아니라, 사용자 코드에서 반복해서 나타나는 몇 가지 일반적인 제어 흐름 패턴을 위해 언어와 라이브러리가 제공하는 지원 양식에 관한 것입니다. 특히 세 가지 효과를 말하고자 합니다:
Result와 Option 타입, 그리고 ? 연산자가 이를 지원합니다.Future 트레이트와 async, await 연산자가 이를 지원합니다.Iterator 트레이트와 for 루프 연산자가 이를 지원합니다.Rust에서 이 세 제어 흐름 효과에 대한 사용 패턴을 서로 유비시키는 작업을 흐릿하게 만들고 복잡하게 하는 요소가 여럿 있지만, 가장 큰 문제는 Rust의 지원이 이 전체 패턴의 일부만을 이루고 있다는 점입니다. 즉, 유비가 빠진 부분이 있어 마치 겹치지만 불완전한 고대 문서 조각 둘을 번역하려는 것과 같습니다.
이들 기능 사이의 자연스러운 유비를 설명하고 정당화하며, 각각이 불완전하다는 생각을 정리하려다 보니 좀 더 일반적으로 유용하다고 생각하는 개념에 도달했습니다. 그것이 바로 프로그래밍 언어의 레지스터 개념입니다.
프로그래밍 언어가 방언을 가질 수 있다는 점—놀랄 만큼 비슷하고 겹치지만 전체적으로는 서로 호환되지 않는 버전들—과, 관용구(특정 알고리즘을 표현하는 공통 패턴과 기법)을 가질 수 있다는 점은 흔히 이해됩니다. 하지만 저는 프로그래밍 언어가 레지스터도 가진다고 주장합니다. 자연어에서 레지스터는 특정 사회적 맥락에 적합하다고 여겨지는 언어의 변종입니다. 개인은 맥락에 따라 서로 다른 레지스터로 말합니다. 레지스터는 주제에 구속되지 않습니다. 같은 주제를 어떤 레지스터로든 논할 수 있지만, 어떤 레지스터는 특정 주제에 더 어울린다고 여겨지며 이 둘의 불일치는 아이러니의 주요 원천이 됩니다.
프로그래밍 언어도 비슷합니다. 코드 한 조각을 작성할 때, 언어를 선택했고 의도한 효과를 이해했더라도, 사용자는 사용할 레지스터를 선택해야 합니다. Rust에서 이 레지스터의 구분은 다음과 같이 보일 수 있습니다:
std를 사용할 것인가, 아니면 코드는 no_std여야 하는가?이보다 더 많은 레지스터 구분이 존재합니다. 이것은 “한 가지 이상의 방법이 있다”는 생각의 핵심 부분이라고 봅니다. 같은 작업을 서로 다른 레지스터에서 수행할 수 있기 때문입니다. 언어 설계자가 “한 가지 방법만 있다”고 말할 때, 그들이 의미하는 바는 언어가 오직 하나의 레지스터만을 지향한다는 뜻이라고 생각합니다. 물론 여러 레지스터를 가지면 사용자가 적절한 레지스터를 선택하는 데 인지적 부담이 생깁니다. 그러므로 사용자가 관심 밖의 문제일 때 사용할 수 있는 “안전한 기본값” 레지스터가 있어야 하며, 필요할 때만 레지스터를 전환하면 됩니다. 불행히도 항상 그런 것은 아닙니다.
특히 Rust에서는 레지스터 간의 절충이 흔히 “일단 끝내기”와, 예컨대 성능이 중요한 경우 시스템의 런타임 동작을 세밀하게 제어하기 사이의 선택이 됩니다. 일반적으로 Rust는 분명한 레지스터—여전히 충분히 성능이 좋고(그리고 바라건대 충분히 접근 가능한!)—를 기본으로 제공하면서, 필요할 때 레지스터를 전환하도록 허용하려 합니다. 여기서 저는 사실 “제로 비용 추상화”라는 아이디어를 새로운 용어로 다시 표현하고 있을 뿐입니다.
이 개념은 일반적으로 유용하다고 생각합니다. Rust 설계를 생각할 때 항상 암묵적으로 존재했던 개념이었지만, 이를 명명하고 서술함으로써 설계 분석에 구조를 부여합니다. 이제 “제어 흐름 효과”라는 개념으로 되돌아가, 이러한 관용구가 어떤 레지스터에서 표현될 수 있는지 구체적으로 살펴보려 합니다.
비동기성이 가장 잘 갖춰진 레지스터 집합을 가진 효과라고 생각합니다. 이는 async/await가 MVP로, 가장 최근에 릴리스되었고, 모난 부분과 불완전함으로 평판이 좋지 않다는 사실과 모순되어 보일 수 있습니다. 하지만 사실 이는 모순이 아니라 오히려 양의 상관관계에 있습니다. async/await가 가장 완전한 레지스터 집합을 지녔기 때문에, 다른 효과들은 지원이 불완전하여 기회조차 갖지 못하는 방식으로 (트레이트 같은) 다른 언어 기능과 부딪히게 됩니다.
이 제어 흐름 효과들 모두에서, 비동기성의 다양한 레지스터는 그 제어 흐름 효과를 타입으로 구체화(reification) 한 공통 인터페이스—여기서는 Future 트레이트—를 중심으로 통일됩니다. 각 레지스터는 Future 트레이트와 상호작용하는 서로 다른 방법입니다:
이들 각 레지스터는 특히 잘 맞는 사용 사례를 가집니다. 핸드메이드 future 구현은 인지적 부담이 크고(잠재적 버그에 대한 주의 깊은 고려가 필요합니다) 대신 더 큰 제어를 제공하므로, 생태계에 근본적이거나 사용자가 제어에 매우 신경 쓰는 특정 용도에 적합합니다.
future를 실행하는 것은 실제로 완료 상태에 도달하게 하는 데 필요한 단계이며, future “안쪽” 코드와 “바깥쪽” 코드 사이의 경계를 이룹니다. 아래에서 보겠지만, 설명한 모든 효과에는 이와 유사한 경계 지점이 있습니다.
Future 조합기는 상태 공유 문제 등 조합기 모델의 특정 문제들로 인해 요즘은 잘 쓰이지 않지만, 경우에 따라 제어 흐름을 쓰는 것보다 더 우아하게 코드를 작성할 수 있는 방법입니다. 표준 라이브러리에 아직도 포함되지 않은 것은 아쉽고, 왜 그런지 모르겠습니다. 개인적으로는 단 하나의 await만 있는 async 블록을 쓴 뒤, 그냥 map 한 번이면 더 명료했겠다고 느낀 적이 많습니다.
마지막으로, async/await와 평범한 제어 흐름을 사용하는 것은 future를 만드는 가장 자명한 방법이며, 사용자가 효과 내부에 있다는 사실을 배경으로 밀어 두고, 실제로 일어나는 제어 흐름을 명시해야 할 때만 의식하도록 해줍니다. (일부 사용자는 이를 더 배경으로 밀어 완전히 보이지 않게 만들길 원하지만, Rust는 여러 이유로 그렇게 하지 않았습니다.)
이 레지스터들은 위에서 설명한 각 제어 흐름 효과에 대해 서로 유비될 수 있습니다. 레지스터를 추상화해 이렇게 설명할 수 있습니다:
이 틀을 오늘의 반복과 실패 가능성(언어 + std)에 적용해 보는 것도 유익합니다:
│ ASYNCHRONY │ ITERATION │ FALLIBILITY
──────────────────────┼───────────────┼────────────────────┼─────────────────────
│ │ │
REIFICATION │ Future │ Iterator │ Result/Option
│ │ │
CORE REGISTER │ impl Future │ impl Iterator │ Ok/Err & Some/None
│ │ │
CONSUMING REGISTER │ │ for loop/collect │ match/unwrap
│ │ │
COMBINATORIC REGISTER │ │ Iterator methods │ methods
│ │ │
CONTROL-FLOW REGISTER │ async/await │ │ ? operator
│ │ │
일부 칸은 그저 _예시(exempli gratis)_일 뿐입니다—예컨대 iterator를 소비하는 메서드는 collect뿐만이 아닙니다—하지만 패턴은 분명히 보입니다. 메서드가 다시 구체화된 타입을 반환하면 조합기 레지스터에 속하며, 그것을 소비하고 다른 것을 반환하면 소비 레지스터에 속합니다. 그리고 비동기성의 몇몇 칸은 비어 있지만, 서드파티 라이브러리가 제공합니다—소비 레지스터(spawn과 block_on)가 가장 중요하며, 표준 라이브러리에 어떻게 들여올지 프로젝트가 가장 자주 고민해 온 부분이기도 합니다.
더 흥미로운 점은, 마지막 행이 반복과 실패 가능성 모두에서 불완전하다는 것입니다. 둘 다 처음 세 레지스터는 잘 채워져 있지만, 비동기성과 비교하면 이 마지막 레지스터가 부족합니다. 각각의 효과에 대한 제어-흐름 레지스터는 자명한 답이 있지만 아직 안정화되지 않았습니다. 바로 제너레이터와 try 블록입니다.
반복을 고려할 때 사용자는 딜레마에 직면합니다. 반복을 수행하는 두 가지 자명하고 쉬운 방법은 소비 레지스터와 조합기 레지스터입니다. 실제로 이 둘만으로도 대다수의 경우를 적절히 커버할 수 있습니다. 하지만 제어-흐름 레지스터가 부재하기 때문에, 그 사이에 끼여들어 불만족스러운 전환을 반복해야 하는 경우가 종종 있습니다.
구체적으로, 사용자는 반복 효과를 벗어나지 않고 특정 반복 연산을 함수로 추상화하고 싶을 수 있습니다. 이는 -> impl Iterator를 안정화하려 했던 주요 동기 중 하나였습니다. 하지만 그렇게 하려면 조합기를 사용해야 합니다. 여기서 문제가 생깁니다. 조합기로 복잡한 제어 흐름 경로를 구성하기가 꽤 어렵습니다. 사용자는 이 방식이 부적절하다는 것을 자주 깨닫고, for 루프를 쓰는 편이 낫다고 판단합니다. 최악의 경우, 코드를 조합기로 구성하는 것이 불가능하거나 가독성을 해친다고 느껴, 중간에 한 번 할당해 collect한 뒤 곧바로 다시 그 위를 반복하는 식으로 끝나기도 합니다.
이 문제의 특정한 사례는 사용자가 효과를 결합하고 싶을 때입니다—예컨대 반복자에 실패 가능한 함수를 매핑하거나, Result 위에 비동기 함수를 매핑하는 경우입니다. 어떤 경우(이제 Result의 반복자가 생기므로, 이후 조합기마다 에러에서 단락해야 하는 등)엔 곡예 끝에 가능하지만, 어떤 경우엔 완전히 불가능합니다.
대안적 접근은 제너레이터를 제공하는 것입니다—값을 반환하는 대신 yield하여 반복자로 컴파일되는 함수입니다. yield 문을 가진 제너레이터는 누락된 제어-흐름 레지스터가 되어, 비동기성의 async/await 옆에 자연스럽고 단정하게 자리 잡을 것입니다. 이는 일반 목적 코루틴과는 다르다는 점을 강조하고 싶습니다. 제너레이터는 종종(다른 언어에서 그랬던 것처럼) 코루틴과 혼동됩니다. 아마도 값을 주고받으며 매번 yield할 때 값을 받을 수도 있는 더 일반적인 코루틴이 다른 목적엔 유용할 수 있습니다. 충분히 그럴 법한 주장입니다. 하지만 Rust에 절대적으로 필요한 건 반복자로 평가되는 함수에 대한 문법이며, 이 두 목적이 하나의 기능로 혼동되어선 안 된다고 생각합니다. async가 코루틴과 혼동되지 않았던 것과 같은 이유죠.
몇 해 전, 저는 이런 접근에 기반한 제너레이터 매크로 라이브러리 propane을 구현했습니다. 여전히 이것이 Rust가 가야 할 길이라고 생각하지만, 이 라이브러리를 작성하고 공개한 이후로 이 방향에 대한 움직임이나 프로젝트 차원의 본격적인 논의를 거의 보지 못했습니다.
실패 가능성의 경우는 ? 연산자를 이미 갖고 있으므로 덜 불완전합니다. 하지만 이것만 있는 건 await는 있는데 async는 없는 것과 비슷합니다. 저는 예전에 왜 “Ok” 래핑 함수가 Rust에 매우 좋은 추가가 될지에 대해 썼고, 직접 구현하기도 했습니다. 그때 이후로 제 관점에서 달라진 점(그리고 아마 Rust 프로젝트와 더 보조를 맞추게 된 점)은, 반환 타입을 바꾸지 않는 try fn 같은 문법을 사용해 사용자가 Option 또는 Result를 지정하도록 하는 것이 Rust에 더 적합하다는 것입니다. 하지만 전반적으로 사용자가 효과 내부에 완전히 머물게 해 주는 문법(Ok-래핑이 진정 의미하는 바)이 있다면, 그것은 더 편리할 뿐 아니라 비동기성과 반복과의 일관성 면에서도 더 낫습니다.
설계의 본질적인 측면 중 하나는 하나의 코드 조각에서 서로 다른 제어 흐름 효과를 결합할 수 있어야 한다는 점입니다. 비동기적이고 반복적인 코드는 종종 실패 가능하기도 하며, Result나 Option의 Future/Iterator를 만들어 타입 시스템 내에서 이를 쉽게 다룰 수 있습니다. 하지만 비동기성과 반복 효과를 결합하는 일은 그리 단순하지 않습니다.
이런 이유로, futures 라이브러리는 항상 비동기성과 반복을 결합한 Stream 인터페이스를 제공해 왔고, 이것은 std로 들어오면서 더 분명한 이름인 AsyncIterator로 바뀌었습니다. 이름 변경 자체에는 이의가 없지만, 그와 함께 제가 반대하는 이념적 전제가 묶여 들어왔다고 생각합니다—즉, AsyncIterator를 단지 Iterator의 “비동기 버전”으로만 보는 관점입니다. 우리는 그것이 Future의 “반복 버전”이기도 하다는 사실을 잊지 말아야 합니다.
이 혼란은 프로젝트를 제가 보기에 막다른 길로 이끌었습니다. Iterator 인터페이스를 단순히 “비동기화”하려는 시도, 구체적으로 AsyncIterator의 필수 구현 방식을 async fn next로 재정의하려는 명시적 목표가 그렇습니다. 하지만 여기서 제시한 레지스터 분석을 적용해 보면, 이것은 레지스터 간의 혼합입니다. 비동기성은 제어-흐름 레지스터에 있고, 반복은 코어 레지스터에 있습니다. 반복 기능이 불완전하기 때문에, 이를 “비동기화”하는 데서 암시되는 패턴 자체가 완전히 잘못됩니다. 앞을 내다보지 않는다면 반복에도 제어-흐름 레지스터가 있다는 사실을 볼 수조차 없기 때문입니다.
프로젝트가 이 길을 계속 간다면, AsyncIterator의 비동기 효과에 대한 코어 레지스터가 사용자에게 없다는 문제가 드러날 것입니다. 세밀한 제어가 필요할 때 코어 레지스터가 제공하는 능력이 필요한데, 선택지가 없게 됩니다. 시스템 프로그래밍 언어로서는 완전히 부적절합니다. 반면 poll_next가 남아 있다면, 비동기성과 반복의 조합에 대한 코어 레지스터가 존재하게 됩니다. 그리고 언어에 제너레이터가 추가된다면, 일반 함수가 그러하듯 그것들 또한 async가 될 수 있고—async 제너레이터는 AsyncIterator로 컴파일되어, 반쪽이 아닌 온전한 제어-흐름 레지스터로 작성될 수 있습니다.
더 넓게 보자면, 프로젝트가 비동기성의 코어 레지스터와 반복의 제어-흐름 레지스터를 낮게 평가하고 있는 듯합니다. 같은 맥락에서, 저는 AsyncRead가 단순히 Read의 “비동기화된 버전”이어야 한다고도 생각하지 않습니다. poll 메서드는 사용자가 제어가 필요할 때 중요합니다! 이는 비동기성의 제어-흐름 레지스터가 쓰기 쉬운 것에 과도하게 기댄 탓이라고 생각합니다—그러니 모든 것에 그것을 쓰면 된다는 생각으로 이어지고—반면 반복에는 제어-흐름 레지스터가 존재하지 않으니—그걸 사용할 생각 자체가 떠오르지 않는 것이죠.
이 네 가지 레지스터를 바라보면, 어떤 레지스터를 사용할지에 대한 사용 패턴이 드러납니다. 코어 레지스터는 장황하고 사용하기 어려울 수 있지만, 사용자에게 가장 절대적인 제어를 줍니다. 트레이드오프는 분명합니다. 소비 레지스터는 효과의 경계를 설정하는 데 필요합니다. 하지만 조합기 레지스터와 제어-흐름 레지스터에 이르면 질문이 남습니다. 둘의 실제 차이는 무엇일까요? 둘 다 목표를 쉽게 달성하는 방법으로 칭송됩니다. 둘 다 추상화를 수용하는 대가로(따라서 레이아웃에 대한 명시적 제어를 잃는 대가로) 일을 끝낼 수 있게 해줍니다. 그런데 둘의 구분은 무엇일까요?
명백한 차이 하나는 하나는 명령형 스타일(제어 흐름)이고, 다른 하나는 함수형 스타일(조합기)이라는 점입니다. 비슷하게, 하나는 문(statement) 블록에 더 자연스럽고(제어 흐름), 다른 하나는 식(expression) 지향 프로그래밍에 더 자연스럽습니다(조합기). 물론 어느 쪽도 다른 방식을 완전히 배제하지는 않습니다. 어느 정도까지는 이 둘의 차이가 패러다임적이자 스타일적인 것일 수도 있습니다. 어쨌든 Rust는 다중 패러다임 언어니까요.
하지만 제 경험상, 너무 복잡한 조합기 조합은 가독성에 부정적 영향을 줄 수 있습니다. 조합기 스타일이 코드를 더 명확하게 만드는 주관적 한계선이 있고, 그 선을 넘으면 오히려 덜 명확해집니다.
조합기 스타일의 한 가지 단단한 제약은, 조합기에 전달하는 클로저 밖으로 제어 흐름이 탈출할 수 없기 때문에 조기 반환(early return)이 불가능하다는 점입니다. 이는 효과가 조합기를 “통과”하는 능력을 제한합니다. 예컨대 map 내부에서 await한다거나, filter에서 에러를 던지는 식의 동작이 어렵습니다. 이로 인해 일부 라이브러리는 조합기의 try_ 또는 async_ 변형을 제공하려 했고, 키워드 제네릭의 주요 동기가 되기도 했습니다.
하지만 어쩌면 이 제약을 부정이 아닌 긍정으로 볼 수 있는 다른 프레이밍이 있을지도 모릅니다. 조합기 더미를 보면, 그 표현식 바깥으로 제어 흐름이 튀지 않는다는 사실을 알 수 있습니다. 평범한 블록에서는 알 수 없는 사실이죠. 조기 반환의 부재는 함수형 스타일의 상징 아닐까요? 어쩌면 이를 표현력의 부족이 아니라 조합기 스타일의 인지적 장점으로 껴안아야 할지도 모릅니다. 그리고 사용자가 더 복잡한 제어 흐름이 필요하다는 사실을, 이번 경우엔 레지스터를 전환해야 한다는 신호로 받아들여야 할지도요.
절대적 확신으로 말하는 것은 아닙니다. 특히 await나 ?(또는 아마도 yield)와 함께 조기 반환하는 것은 일반적인 제어 흐름과 다르다는 주장도 가능하다고 봅니다. 하지만 다른 고려들을 감안하면, 제어-흐름 레지스터를 충분히 채운 뒤에는 “효과를 가로지르는” 조합기를 지원하는 것이 그것이 없을 때만큼 필수적인 일은 아닐 수 있다고 말하고 싶습니다.
이 글은 이미 꽤 길어졌고, 마지막 부분에선 진짜 벌집을 건드릴 뻔했습니다: 키워드 제네릭. 오늘은 이 정도면 충분하다고 생각합니다. 가까운 시일 안에 글을 이어갈 수 있다면, 다음 번에는 그 논의로 시작하겠습니다.
레지스터와 제어 흐름 효과라는 이 틀이 다른 분들과 공명하고, 오늘날 Rust가 직면한 설계 문제를 풀어내는 데 길잡이가 되길 진심으로 바랍니다. 설사 이 적용 자체에 치명적인 흠이 있다고 생각하더라도, 레지스터라는 개념이 프로그래밍 언어를 설계하고 분석하려는 모든 이들에게 보다 일반적으로 유용하길 바랍니다.
지난 2년과는 달리, Rust 프로젝트에서 이 글이나 다른 어떤 주제든 직접 논의해 보고 싶은 분이 계시다면, 제 이메일은 이 웹사이트의 바닥글에 나와 있습니다.