2023년의 async Rust 설계를 논평하는 연재의 두 번째 글. ‘키워드 제네릭’과 관련 제안(트레이트 트랜스포머)을 비판적으로 검토하며, 비동기, 이터레이션, 실패 가능성 같은 제어 흐름 효과를 패턴과 추상화의 관점에서 비교한다. 키워드 제네릭의 동기를 재고하고, 복잡한 제어 흐름에는 제너레이터와 충실한 명령형 레지스터를 제안한다. 다음 글에서는 const와 자동 트레이트의 유비를 더 탐구할 예정이다.
이번 글은 2023년 async Rust 설계에 대한 비공식 연재의 두 번째 글입니다. 이전 글에서, Rust에서 제어 흐름 효과를 처리할 수 있는 “레지스터”에 대해 논한 뒤, 최근 제안된 “키워드 제네릭(keyword generics)” 개념으로 관심을 돌리겠다고 예고했습니다. 참고로, 이번 논평의 기준점이 되는 Rust 현 설계 팀의 글은 다음 두 편입니다.
이 글들 전체를 장황히 반복하지는 않겠지만, 간단히 말해 “키워드 제네릭”은 특정 효과를 기준으로 타입을 추상화할 수 있도록 Rust에 새로운 종류의 추상화를 도입하자는 제안입니다. 예시들은 주로 async와 const를 효과로 다루고 있으며, 때때로 try에 대한 논의도 있습니다. 눈치 빠른 독자라면 이것이 지난 글에서 제가 식별한 효과들과 겹치지만 동일하지는 않은 집합이라는 점을 알아차릴 것입니다. 저는 const를 효과로 다루지 않았고, 제가 알기로 키워드 제네릭 작업 그룹은 반복(이터레이션)을 효과로 간주하는 데에는 거의 시간을 쓰지 않았습니다.
또한 이 제안에는 두 가지 버전이 있음을 주목해야 합니다. 하나는 특정 효과의 유무를 추상화하는 것(예: ?async), 다른 하나는 임의의 효과의 유무 자체를 추상화하는 보다 일반화된 형태(예: ?effect)입니다.
제 관점에서 보면, 이러한 제안들에 대한 반응은 기껏해야 엇갈렸습니다. Rust 프로젝트의 여러 전 핵심 기여자들(저를 포함)이 공개적으로 Rust가 나아갈 바를 잘못 잡았다는 의구심을 표했습니다. 비판은 대체로 두 축에서 제기됩니다.
await만으로는 불가능합니다.저는 이러한 반론들에 동의합니다. 하지만 더 상위 수준의 반론도 있습니다. 이것은 저는 본질적으로 하나의 _패턴_을 _추상화_로 만들려는 잘못된 시도이며, 무엇을 같은 개념 아래에서 추상화할지에 대한 그물망이 잘못 쳐졌다고 생각합니다. 그 이야기에 들어가기 전에, 지난 글에 대한 부록을 잠깐 덧붙여 생산적인 방향으로 나아가고자 합니다.
추가적인 성찰과 논의(특히 가보르 레헬과 래프 레비엔의 댓글) 덕분에, 제가 “소비” 레지스터라고 부른 것은 사실 레지스터가 아니라 각 제어 흐름 효과 내의 직교적인 구분이라는 것을 깨달았습니다. 각 제어 흐름 효과에는 일종의 “품사”가 있으며, 이는 컴파일러 변환을 가능하게 하는 효과와의 필수 접점입니다. 이 “품사”들은 각기 다른 레지스터에서 수행될 수 있습니다.
소위 “소비 레지스터”는 사실 효과를 완료하는 행위를 가리켰고, 이는 제어 흐름 레지스터(예: for 루프)나 함수형/콤비네이터 레지스터(예: fold)에서 수행될 수 있습니다. 이러한 재구성을 바탕으로, Rust에서 제어 흐름 효과를 다루는 데에는 세 가지 레지스터가 있다고 보는 편이 낫습니다.
제너레이터가 해결하는 문제는, 반복을 위한 명령형 레지스터가 반복자를 즉시 평가(즉 for 루프 사용)와 결부되어 있어, 즉시 평가를 원치 않는 경우 사용자가 함수형 레지스터로 밀려난다는 점입니다. 함수형 레지스터가 매우 강력하긴 하지만, Rust에서는 몇 가지 한계가 있습니다. 첫째, 이미 논의했듯 콤비네이터에서 사용하는 클로저 밖으로 “탈출”할 수 없습니다(키워드 제네릭은 일부 특수 상황에서 이 제약을 풀려는 시도입니다). 둘째, 잠깐 언급만 하고 자세히 풀지 않았던 내용이지만, 콤비네이터 연쇄를 거치며 상태를 변경(mutate)할 수 없습니다. 실제로는 별칭(aliasing)이 생기지 않더라도, 그 연쇄의 순차성이 타입 시그니처에 내재하지 않기 때문입니다. 이것이 async 코드에서 콤비네이터가 크게 제약되었던 가장 큰 이유입니다.
물론 이러한 제약이 아쉽다는 주장에는 일리가 있고, 추가 비용 없이 이를 피할 수 있는 설계가 있다면 반대하기 어려울 것입니다. 하지만 명령형 레지스터가 필요한 제어 흐름 구성을 제대로 갖춘다면, 조기 반환이나 상태 변경이 필요한 코드에는 명령형 스타일을 권장함으로써 이 문제를 비켜갈 수 있습니다. 이 두 가지는 명령형 스타일의 특징이기도 합니다. 문장(statement)이 해야 할 일은 문장에게, 모나드(monad)가 해야 할 일은 모나드에게 맡기자는 제안이 그렇게 황당한가요? (이 작은 성경적 참조에는 층위가 있습니다. “명령형(imperative)” 프로그래밍은 “임페라토르(Imperator)”와 같은 어원을 가지며, “모나드”는 고대의 신성을 가리키는 용어였습니다. 여기서 더 파고들지는 않겠지만, 상태적(style with state) vs 무상태적 스타일의 차이는 더 깊은 인식론적 뿌리를 가지고 있다고 봅니다.)
본론으로 돌아가서: 키워드 제네릭으로 async와 try를 추상화하려는 가장 설득력 있는 동기는 이터레이터 콤비네이터 문제였던 것 같습니다. 하지만 문제의 범위가 너무 좁습니다. 진짜 문제는 반복 효과와 실패 가능성, 비동기성을 결합하는 것입니다. 현재로서는 반복 효과 “안에 머무는” 유일한 방법이 콤비네이터를 사용하는 것뿐이기 때문입니다. 제 주장은 제너레이터를 구현하고, 복잡한 제어 흐름이 있는 코드에는 콤비네이터를 쓰지 않음으로써 이 문제를 완전히 우회할 수 있다는 것입니다.
제어 흐름 효과를 다루는 “품사”를 확인했으니, 기능이 충분히 갖춰졌다는 가정하에 이것들이 명령형 스타일에서 각각 어떻게 표현될 수 있을지 살펴볼 가치가 있습니다. 일부 기능의 문법은 여기서 가정한 것이며(달라질 수 있음), 다만 그 문법이 암시하는 제어 흐름은 자명하리라 생각합니다.
│ 비동기성 │ 이터레이션 │ 실패 가능성
─────────┼──────────────────┼──────────────┼──────────────
│ │ │
컨텍스트 │ async { } │ gen { } │ try { }
│ │ │
효과 │ │ yield │ throw
│ │ │
전달 │ .await │ yield from │ ?
│ │ │
완료 │ spawn/block_on │ for │ match
│ │ │
여기서 비동기성, 이터레이션, 실패 가능성 사이에 유비가 그려지는 매우 분명한 패턴이 드러납니다. async 블록이 제너레이터 블록과, 그것이 다시 try 블록과 서로 닮아 있음을 볼 수 있습니다. await가 ?와, 그리고 다른 이터레이터에서 값을 양도하는 yield from과 어떻게 유사한지도 보입니다. 등등.
하지만 동시에 서로 간의 거대한 불규칙성도 드러납니다. 표면적인 문법 차이조차도 대개는 각 효과의 의미론적 차이에서 비롯됩니다.
예를 들어, 실패 가능성은 비동기성과 이터레이션과 달리 구체적인 단일 타입을 반환하며, 익명 타입이 특정 트레이트를 구현하는 형태가 아닙니다. 또한 가능한 구체 타입이 여럿일 수 있습니다(예: Result, Option). 이는 실패 가능성이 비동기성과 이터레이션처럼 반복적인 제어 흐름 패턴을 구현하지 않기 때문이며, 따라서 최적화를 위해 상태 기계(state machine)로 컴파일될 필요가 없습니다. 이러한 의미론적 차이는 문법과 다른 효과들과의 합성 방식에도 영향을 줍니다.
마찬가지로, 비동기성은 async 구문이 생성하는 상태 기계를 비동기 이벤트를 관리하기 위해 최종적으로 호출되는 기반 태스크 시스템과 결합합니다. 이 태스크 시스템은 async/await 구문에서는 보이지 않지만, 저수준 레지스터에서는 분명히 존재하며 표면 설계에도 큰 영향을 미칩니다(예컨대 비동기성의 “효과” 행에 해당하는 항목이 없고, 효과 완료가 내장 연산자가 아니라 라이브러리 함수인 이유가 여기에 있습니다). 이 별도의 시스템을 추상화에 통합해야 하는 필연성은 이터레이션이나 실패 가능성에는 대응물이 없습니다.
게다가 이터레이션은 일종의 “역전”을 통해 다른 둘과 구분됩니다. 비동기성과 실패 가능성에서는, 효과가 발생하지 않아 전달할 필요가 없는 경우(즉, future가 준비되었거나 결과가 Ok인 경우)가 “행복한 경로(happy path)”입니다. 반면 이터레이션에서는 효과가 발생할 때 산출되는 값들에 대해 의미 있는 작업을 수행하며, 그 효과가 더 이상 발생하지 않을 때 이터레이션이 끝납니다. 그래서 yield from은 ()로 평가되어, await와 ?처럼 접미(postfix) 연산자로 쓸 동기가 상대적으로 약합니다. 또한 직접 전달보다 yield를 포함한 for 루프를 쓰는 일이 흔하기에, 직접 전달 연산자의 중요성이 상대적으로 낮습니다.
여기서 지적할 수 있는 차이는 더 많습니다. 하지만 이 정도만으로도 질문이 떠오릅니다. 이 모든 것에서 무엇을 배울 수 있을까요? 분명 세 경우에 걸쳐 유사한 연산자 집합이라는 패턴은 있지만, 그 패턴은 고르고 매력적인 일관성을 갖지 못한 채 불규칙하고 얼룩덜룩합니다. 그 결과, 이 세 집합을 가로지르는 추상화를 만들기가 어렵습니다. 추상화는 규칙적이어야 하기 때문입니다. 이를 억지로 끼워 맞추면, 흔히 말하는 “새어 나오는(leaky) 추상화”가 될 가능성이 큽니다.
“패턴은 약한 언어의 징후다. 더 표현력 있는 언어라면 그에 대한 추상화를 갖고 있을 것이다.”라는 유명한 농담이 있습니다. 저는 이 관점이 다소 섬세함을 잃었다고 생각합니다. 예컨대 초기의 Java가 제네릭이나 고차 함수가 없어서 사용자들이 책 한가득 패턴으로 빈틈을 메워야 했다면, 이는 Java의 추상화 역량의 약점이었고 보완할 가치가 있었습니다. 하지만 모든 패턴이 반드시 추상화로 치환되어야 하는 것은 아니라고 생각합니다.
패턴과 추상화의 큰 차이는 사용자에게 부과되는 인지 부하를 구조화하는 방식입니다. 추상화는 그 인지 부하를 “선불”로 받게 합니다. 추상화의 동작 원리를 먼저 배우고 그에 대한 멘탈 모델을 형성해야, 추상화가 적용된 코드를 이해할 수 있기 때문입니다. 이것이 키워드 제네릭에 대한 불안의 근원입니다. 사람들은 곳곳에 ?async, ?const 같은 주석이 빼곡한 모습을 보고, 이미 전하고 있는 정보가 너무 많은 시그니처에 새로운 사용자가 어떤 반응을 보일지 상상합니다. 반면 패턴은 암묵적입니다(언어의 “실제” 일부가 아니기 때문에) 그리고 나중에 언제든 배울 수 있습니다. 처음부터 배워야 하는 것이 아닙니다.
물론 패턴은 코드 중복을 막아주지 못합니다. 이것이 추상화의 큰 이점 중 하나이기도 합니다. 유사한 API를 여럿 배워야 하는 인지 부담을 줄일 수 있으니까요. 그러나 패턴도 두 유사하지만 다른 API 사이의 유비를 더 명확히 해줌으로써 이러한 인지 부담을 덜어줄 수 있습니다. 예를 들어, 사용자는 async, gen, try 블록을 단일 언어 개념 아래서 추상화할 수 없더라도, 서로 유사한 구성물로 이해할 수 있습니다. 추상화가 지나치고 패턴이 너무 적은 언어에서는, 사용자가 방향 감각을 잃고 어떻게 진행해야 할지 막막함을 느낄 수 있습니다. 모든 것이 가능하지만 아무것도 자명하지 않은, 무질서한 공구상자처럼 느껴지는 과도하게 추상적인 언어 말이죠.
더군다나, 패턴이 추상화가 아니기 때문에, 추상화라면 허용되지 않을 수 있는 구체의 변형을 허용합니다. 앞서 강조한 모든 불규칙성은 각 패턴 적용이 다루는 구체적 과제가 정당화합니다. 이를 평준화하여 추상화를 만들면, 그 불규칙성이 제공하던 이점을 없애는 비용을 치르게 됩니다. 우리는 좋은 이유로 의도적으로 이러한 불규칙성을 선택해 왔고, 추상화를 위해 그것들을 규칙적으로 만들면 각 기능을 오히려 더 나쁘게 만들 수 있습니다.
때로는 패턴을 추상화로 만드는 것이 좋지 않을 뿐 아니라, 식별한 패턴 자체가 최선이 아닐 때도 있습니다. 앞서 말했듯, 키워드 제네릭으로 추상화하려는 효과들의 집합은 여기서 제가 식별한 제어 흐름 효과들과 동일하지 않습니다. 제가 아는 한 이터레이션은 포함되지 않았고, 대신 “const-성(컴파일 타임에 실행 가능함)”이 포함되었습니다.
제가 본 모든 키워드 제네릭 설명은, 조작하는 하위 타입에서 효과를 그저 “전달”하는 코드에 초점을 맞춥니다. 즉, 함수를 호출하고, 그 효과성에 따라 .await(하거나 말거나)와 ?(하거나 말거나)를 적용하는 방식입니다. 여기서 앞서 언급한 이터레이션의 차이가 드러납니다. 정상 경로가 비효과적 경로인 경우에는 상당히 단순해 보입니다. 하지만 이터레이션에서는 다릅니다. 정상 경로가 바로 이터레이션 경로이며, 이터레이션하는 함수와 그렇지 않은 함수를 추상화하는 것은 훨씬 명확하지 않습니다. 둘 사이의 차이가 너무 크기 때문입니다.
반면, async/try와 const 사이의 유비는 한층 더 무리해 보입니다. const를 어떤 의미에서 “효과”로 개념화할 수는 있지만, 제가 지금까지 논의한 제어 흐름 효과들과는 현저히 다릅니다. 그래서 제가 식별한 패턴은 const에는 적절하지 않습니다.
어떤 의미에서 const는 제어 흐름 효과들과 대조적입니다. const는 해당 컨텍스트에서 허용되는 연산자의 집합을 엄격히 축소합니다(컴파일 타임에 실행 가능한 것들로만). 반면 제어 흐름 효과는 새로운 연산자들을 도입합니다. 또 다른 의미에서 const는 완전히 직교적입니다. const는 코드의 제어 흐름—어떻게 실행되는가—에는 영향을 주지 않고, 대신 언제 실행되는가를 결정합니다.
즉, const는 제어 흐름 효과처럼 컴파일러가 코드를 확장하는 구문 설탕이 아닙니다. 그래서 동작이 매우 다릅니다. 이를 감안하면, const와 async에 대한 주석(애노테이션)이 작년에 요슈아 위츠가 지적했듯 서로 다르게 동작하는 것은 놀랍지 않습니다. 이는 정규화되어야 할 불규칙이 아니라, 사용상의 차이에서 비롯된 차이입니다.
키워드 제네릭 아이디어의 상당 부분은 const의 특정 문제를 해결하려는 작업(최초에는 RFC 2632에서 서술됨)에서 출발해, 이후 async에 적용된 듯합니다. const에만 적용하면 이 아이디어는 어느 정도 더 그럴듯해 보입니다. const에서 발생하는 문제는, 트레이트 메서드를 const 컨텍스트에서 호출할 수 있다고 지정할 방법이 없어, const 컨텍스트에서 for 루프 같은 것을 사용할 수 없다는 점입니다. 이는 “아마도 async일 수도 있는” 메서드를 작성하려는 동기보다 더 설득력 있어 보입니다.
하지만 저는 const를 제어 흐름 효과들과 묶은 것이 잘못되었다고 생각합니다. 트레이트 트랜스포머 글에서는 더 열매를 맺을 수 있는 새로운 묶음이 등장합니다. 여기에서 const와 자동 트레이트 사이의 유비가 제기됩니다. async/제너레이터의 경우처럼, 트레이트 메서드가 const일 수도 있고 아닐 수도 있는 것처럼, 그 메서드의 상태도 Send일 수도 있고 아닐 수도 있다는 것입니다. (이 글에서는 async와의 유비도 함께 제시되지만, 앞서 설명한 이유로 저는 그 유비는 잘못되었다고 봅니다.)
아쉽게도 이번 글도 또 길어졌네요. 다음 글에서는 const와 자동 트레이트 사이의 유비로 돌아가, const에 대해 키워드 제네릭이 해결하려는 문제를 그 비교를 바탕으로 다른 방식으로 해결할 수 있는지를 탐구하겠습니다.
당장은 다음과 같은 인상을 남기고 싶습니다. 패턴은, 비록 추상화할 수 없더라도, 좋은 것입니다. 그리고 때로는 패턴을 추상화하는 것이 잘못일 때도 있습니다. 제어 흐름 효과라는 구체적 사례에서는, 효과들 간의 차이로 인해 패턴을 명확히 추상화하기 어렵습니다. 그러나 그 덕분에 각 효과를 목적에 맞게 설계할 수 있고, 이들 효과의 명령형 레지스터가 충분히 구현되어 있다면, 명령형 제어 흐름 연산자들의 자연스러운 합성으로 서로를 추상화 없이도 합성할 수 있습니다.
(이 글의 초기 초안을 검토해 준 그레이던 호어에게 깊이 감사드립니다.)