러스트의 제어 흐름, 에러 처리, 언어 비대화, 컴파일 타임 평가, 저수준 코드 생성의 예측 가능성, unsafe와 async 모델, 표준 라이브러리와 생태계·툴링까지 폭넓게 짚으며, 이 언어가 현실 세계에서 주는 고통과 한계를 비판적으로 논한다.
그렇다면 우리에게 남는 것은 무엇일까? 제어 흐름을 다루는 많은 방법은 있지만, 은탄환은 없다. 물론, 일에 맞는 올바른 도구를 고르는 게 합리적이다. 그 과정에서 너무 많은 것을 희생해야 한다면 굳이 단 하나의 방법만을 고집할 필요는 없다. 하지만 현실은 다르다. 러스트는 여기서 일에 맞는 올바른 도구를 고르게 두지 않고, 그나마 덜 나쁜 도구만을 고르게 한다. 그리고 어쨌든 항상 고통스럽다. 이건 단순한 코드에서는 잘 드러나지 않는다. 패턴 매칭은 우아해 보이고, 메서드 체인은 깔끔하며, try 연산자는 문제를 사라지게 해 준다. 그러나 슬라이드웨어를 넘어 현실로 가면, 빛은 바랜다.
덧붙이자면, 타입 시스템 덕에 러스트에서 리팩터링이 대체로 괜찮긴 하지만, 메서드 체인을 더 복잡하게 만들거나, 커지면서 패턴 매칭으로 바꾸는 작업은 종종 두통을 유발한다. 결국 해낼 것이다. 컴파일러가 그리로 몰아갈 테니. 하지만 그 과정이 유쾌하진 않을 것이다[3].
러스트에서 에러 처리는 재미있는 주제다. 전도사들은 이것이 러스트가 제공하는 최고의 장점 중 하나이며 다른 언어보다 훨씬 낫다고 알려 준다. 하지만 나는 동의하지 않는다. 더 중요하게는, 러스트 라이브러리 팀도 여기에 더 많은 일이 남았음을 인정한다. 앞으로 해결될 것 같은 현재의 문제들은 언급하지 않겠다고 했지만, 지금까지의 작업과 전반적인 방향성을 보면 내가 우려하는 지점이 해소될 것 같지 않다.
좋은 부분부터 보자. 러스트는 예측 가능하고, 값 기반이며, 합타입을 이용한 에러 처리를 제공한다. 물론 여전히 에러 코드를 반환하고 아웃 파라미터를 사용할 수도 있다(혹은 std::process::exit(1)로 에러에서 탈출할 수도 있다). 하지만 언어가 설탕을 제공하여, 관용적인 방식으로 에러를 처리하는 것을 더 쉽게 해 준다.
예를 들어 러스트는 반환 값을 무시하면 경고를 내지만, 아웃 파라미터라는 개념은 없다. 또한 정수 반환 코드는 강한 타입만큼 안전하지 않다. 특정 에러 타입을 위해 만들어 낸 값들을 모두 매치하는지 컴파일러가 보장하지 않기 때문이다. try 연산자를 이들과 함께 사용할 수도 없다.
반면, 사용자 정의 합타입에 대해 match를 사용한다면, 모든 변형(variant)에 대한 처리를 선택적으로 생략할 수 있으며, 새로운 변형을 추가하더라도 마찬가지다. 또한 try 연산자를 사용해 그 에러를 간단히 위로 전파할 수 있다.
러스트가 예외를 지원하지 않는다는 것은 (대부분의 경우[4]) 제어 흐름이 어디서든 예측 가능하다는 뜻이고, 이것도 장점이다. 에러 처리가 당신이 보지 못하는 사이에 다른 곳으로 튀지 않으므로 코드 감사를 훨씬 쉽게 만든다. 성능 예측도 쉬워진다. 예외를 제어 흐름으로 써서는 안 되지만, 애초에 예외가 없으면 그 비용(공간과 시간 모두)을 결코 치르지 않는다.
마지막으로, 러스트는 소멸자(드롭 트레이트)를 지원하므로, C처럼 goto로 가야 하는 정리(cleanup) 섹션이나, Zig처럼 수동 defer가 필요 없다.
무엇보다 먼저, 러스트의 흐름 제어의 모든 함정은 에러 처리에도 똑같이 적용된다는 점을 강조해야 한다. 에러 처리는 에러 처리와 교집합이 크기 때문이다[5]. 패턴 매칭은 고통스럽다. 특히 에러 열거형을 합성하기 시작하면 더 그렇다. 메서드 체인은 다루기 성가시거나 어떤 상황에서는 아예 쓸 수 없고, C 스타일 에러 코드는 매우 에러를 유발하기 쉽다.
단순히 에러가 될지도 모르는 합타입 위에서 패턴 매칭을 하는 것을 넘어, 애초에 그러한 타입을 정의하고 합성하는 일 자체, 그리고 호출 지점에서 그런 폭넓은 열거형을 다루는 일이 성가시다. 그고려한 라이브러리들이 이 고통을 덜어 주려고 한다. 선언부와 호출부 모두에서 말이다. 하지만 (특히 비표준) 의존성을 프로젝트에 추가하는 건 좋은 해법이 아니다. 서로 다른 라이브러리를 섞어 쓰는 상황이라면 더더욱.
또 사람들이 종종 간과하는 것은 그런 식의 명시적이고 값 기반인 에러 처리가 얼마나 비싼가 하는 점이다. 결국 조건과 점프의 연쇄로 귀결되는데, 이는 특히 예측 가능한 코드를 좋아하는 현대 하드웨어에서 비싸다. 사람들은 예외에 비용이 든다며 싫어하지만, 실제로 그 비용은 코드 크기(그리고 러스트는 이를 최적화하지 않으니 분명 나쁜 트레이드오프라고만 할 수 없다)와 에러 경로 실행에서만 지불된다. 예외적인 에러에 대해서는 그 경로가 자주 실행되지 않는다(예외적인 ‘에러’가 아주 핫스팟에서 충분히 자주 발생할 수도 있음을 유의하라). C++에서 특정 종류의 에러를 처리할 때 더 많은 사람이 예외를 고려해야 한다고 자세히 주장할 생각은 없다(그건 다른 글로 남겨 둔다). 하지만 러스트가 그 선택지를 제공하지 않는 것은 많은(혹은 다수의) 경우에 성능과 사용성 양면에서 최선이 아니라고 본다[6].
값을 에러로 사용하는 데서 오는 기본적인 검사와 점프 자체는 크게 어쩔 수 없지만, try 연산자는 이를 다루는 인체공학을 크게 개선한다. 써 두면 에러를 만나면 위로 전파시켜 준다. 하지만 실제로는 좀 더 복잡하다. 아무 것이나 전파할 수 있는 것도 아니고, 아무 곳에서나 빠져나올 수 있는 것도 아니다. 예를 들어, 불리언에는 try 연산자를 쓸 수 없다. 즉, 어떤 술어에 기반해 그냥 포기할 수 없다. 반대로, 반환값이 없는 함수에서도 빠져나올 수 없다. 이런 제약은 정당한 시나리오에서 발생하기 때문에 문제라고 본다. 관용적인 에러 처리 패턴에서 일부 경우에만 분기하여 벗어나야 한다면, 코드는 더 복잡하고 덜 읽기 좋게 된다.
try 연산자는 또한 서로 다른 타입 간의 배선을 자동으로 해결해 주지 않는다. std::{option::Option,result::Result}로 자신을 제한하더라도, 여전히 하나를 다른 것으로 명시적으로 변환해야 하고(그 과정에서 한쪽 경우에는 Err 값을 제공해야 한다), 변형 타입이 서로 암시적으로 변환되지는 않는다. 마지막 지적은 러스트의 설계상 바람직하지 않을 수도 있겠지만, 변환 인체공학을 개선할 수단은 있었으면 좋겠다.
try 연산자는 또한 깊이 생각하지 않고 에러를 위로 전파하게 만들기 쉬운데, 그러다 보면 어느 시점에서는 맥락이 붙지 않은 값만 남게 된다. std::error::Error는 어느 시점에 백트레이스를 지원할 예정이라고 하지만, 실제로 가동될 때 그게 실질적인 해결이 될지는 두고 봐야 한다.
끝으로, 다른 언어의 체크드 예외처럼 보이는 방식으로 에러를 처리할 수 있게 해 줄 구문 설탕을 언어에 넣자는 제안과 큰 동력이 있다. 나는 이게 좋은 생각이라고 보지 않는다. 언어를 더 복잡하게 만들 뿐더러 이득은 거의 없기 때문이다(성능은 변하지 않고, 타입 변환은 여전히 필요하다). 그리고 많은 사용자를 현상에 대해 오해하게 만들 수 있다. 물론 try 매크로/연산자를 추가할 때도 같은 반대 논거가 있었다. 그러니 어떻게 진화하는지 지켜보자.
마지막으로, 메서드 체인에 대해 한 번 더 이야기하자. 일반 목적 제어 흐름에는 상당히 한계가 있다는 점을 확인했다. 에러 처리는 보다 단순한 편이지만, 또 다른 중대한 문제에 부딪힌다. 메서드 체인은 클로저를 많이 쓴다. 더 구체적으로, 표준 에러 처리 메서드들 상당수가 클로저를 받는다. 조건이 충족될 때 무엇을 할지 결정하거나 값을 지연/동적으로 생성하기 위해서다(예: Result::or vs Result::or_else). 문제는 클로저에서 반환할 수 있는 스코프는 그 클로저 자신뿐이라는 것이다. 다시 말해, 에러 처리 메서드 체인의 일부로 평가되는 클로저 내부에서 함수 바깥으로 바로 빠져나올 수 없다. 언어 관점에서 보면 당연하다(그리고 실제로도 그렇다. 임의로 goto_upper_scope_twice 같은 것이 가능하길 바라진 않는다). 하지만 그 결과, 메서드 체인이 실용적이지 않게 된다.
좋다. 제어 흐름과 에러 처리에 대한 이야기는 이 정도면 충분하다. 다음으로 넘어가자…
나는 좋은 언어라면 필요한 복잡성은 어느 정도 갖춰야 한다고 믿는다(제네릭, 소멸자, 안전성 보장 방식, 강한 타이핑 같은 것). 하지만 가능한 곳에서는 단순함을 유지하는 도구를 선호한다. 그 가장 큰 이유는, 복잡한 언어일수록 배우고 생산성을 내기 어렵고, 오용하기 쉽고, 경쟁 구현을 만들기 어려워지기 때문이다. 게다가 언어가 복잡해질수록 잘못될 위험도 커지고, 언어가 보급될수록 그 실수를 바로잡는 일은 더 복잡해진다. 이런 모든 함정의 자명한 예시는 역시 C++이다. 역겹고, 복잡한 괴물이 되어, 이제는 누구도 바닥부터 컴파일러를 만들 수 없다. PL 영역 밖의 또 다른 예시는 웹(구현체로서의 웹 브라우저들)일 것이다.
우리 분야가 아직 너무 젊다는 것은 이 균형이 매우 주관적이라는 뜻이기도 하다. 하지만 이 글은 내 취향에 관한 글이니, 괜찮다.
언어 비대화의 좋은 예시는, 한 기능을 언어 자체로 구현할 수 없고 어떤 마법 같은 컴파일러 훅을 호출해야만 하는 경우다. 불행히도 format_args 매크로가 그 사례다:
macro_rules! format_args { ($fmt:expr) => {{ /* compiler built-in / }}; ($fmt:expr, $($args:tt)) => {{ /* compiler built-in */ }}; } 이건 꽤 슬프다. 불필요할 뿐 아니라, 인자 포맷팅 같은 걸(많은 시스템 언어에서 일반 코드로 여겨지는 일) 하려면 컴파일러를 확장해야 한다는 뜻이기 때문이다. 그리고 그건 절대다수의 경우에 실행 불가능하다.
더 슬픈 것은 러스트가 포맷팅 매크로에 암시적 이름 있는 인자를 도입하고 있다는 점이다. 이건 전체를 더 마법처럼 만든다. 또 포맷팅 호출의 모양새를 어떻게 자전거만들기(bikeshedding)할지 자체가 중요하지 않다고 여전히 생각한다. 반면, 이는 언어의 복잡성을 키우고, 같은 일을 하는 더 많은 방식을 도입한다. 왜 그게 나쁘다고 생각하는지는 앞서 설명했다.
반대로, 러스트는 보통 매크로를 사용함으로써 varargs에서 오는 비대함을 피한다. 아이러니하게도, 이게 최선의 방식인지 확신하진 못하겠다(지그가 튜플을 일반 함수에 넘기는 방식이 꽤 마음에 든다). 그래도 최소한 언어에 복잡하고 깨진 varargs 버전을 두지 않는 건 좋다. 또 다른 언어 비대화의 예시는 표준 인트린식이다. 러스트에는 이게 아주 많고, 여기에 대해 이견이 있다.
인트린식은 유용하다. 컴파일러나 플랫폼이 지원하는 특수한 것들에 접근할 수 있게 해 준다. 그 결과 추가적인 기능이나 성능을 얻는 경우가 많다. 어려운 점은 이들이 이식성이 없다는 것이다. 한 플랫폼이 노출하는 것이 다른 곳에서는 직접 대응하는 것이 없을 수 있다(컴파일러 인트린식도 마찬가지). 그런 상황이 아니었다면, 그냥 라이브러리나 언어 기능로 노출되었을 것이다! 그러니 인트린식에 의존하려면 자신에게 맞는 추상을 신중히 골라야 한다. 특정 기능을 지원하는 플랫폼에서 작업하던 코드를, 그 기능을 지원하지 않는 다른 플랫폼으로 포팅한다면, 소프트웨어로 그 기능을 구현하기보다는 아예 다른 알고리즘/설계를 택하는 편이 나을 수도 있다. 반대로, 같은 기능을 달리 구현하는 두 개의 서로 다른 기능을 가진 플랫폼으로 옮긴다면, 어느 쪽을 택할지 결정해야 한다. 이는 특정 애플리케이션에만 타당한 트레이드오프(예: 실행 속도 vs 정밀도)에 기반하는 결정이다. 이것이 인트린식을 사용해 얻는 이득을 위해 치러야 하는 대가다.
컴파일러 인트린식도 마찬가지다(예: 플랫폼 독립적인 Clang의 __builtin_memcpy_inline). 여기에 의존한다면, 다른 컴파일러를 지원하기 위해 그것 없이도 동작하게 할 준비를 하거나, 새 타겟이 이 영역에서 더 많은 선택지를 제공한다면 그중 무엇이 가장 적절한지 골라야 한다.
위의 이야기가 왜 어떤 것들은 인트린식이고, 왜 일반 함수/언어 기능이 아닌지 잘 보여줬길 바란다. 그렇다면 표준 인트린식이란 무엇을 의미할까? 이는 모든 러스트 컴파일러가 모든 플랫폼에서 그것들을 노출해야 한다는 뜻이다. 어떤 플랫폼이 부동소수점의 사인을 계산하는 것을 지원하지 않는다면? 그렇다면 sinf64는 소프트웨어로 구현하겠지 ¯\_(ツ)_/¯. 그 자체로 나쁘다고는 할 수 없지만, 인트린식이 아니라 함수로 노출되어야 한다. 그리고 이는 다시 말해, 이런 연산들은 인트린식이 아니라 함수로 노출되는 편이 낫다는 것을 보여준다.
또한 black_box도 있다. 이는 본질적으로 특정 코드 조각에 대한 최적화를 막는 데 쓰인다. 예를 들어 벤치마크에서. 하지만 이건 "최대한 노력"의 기초로 제공된다. 즉, 어떤 컴파일러에서는 아무 것도 하지 않을 수 있고, 이식성에 대해 예측 불가능하다. 이쯤 되면 차라리 rustc 인트린식으로만 두고, 그 맥락에서 잘 문서화하고 거기서 끝내는 것이 낫지 않을까? 그것만으로도 올바르게 다루기 어려운데, 어떤 API를 표준으로 만들면서 "뭔가를 할지도 모른다"고만 규정하는 건 무의미하고 위험하다.
러스트의 컴파일 타임 평가는 C++과 매우 비슷하다. 상수는 변수와 거의 같은 방식으로 정의할 수 있다(let 대신 const 사용). 해당 값을 만들어 내는 표현식이 상수 표현식인 한 말이다. 상수 표현식은 함수 호출로 이뤄질 수도 있는데, 그 함수가 const로 표시되어 있어야 한다.
명백한 단점은 const로 표시되지 않은 모든 함수는 상수 표현식에서 사용할 수 없다는 점이다. 자신이 제어하지 못하는 코드도 포함된다.
또 다른 단점은 어떤 문맥에서는 무언가를 const로 선언하는 게 편리하지 않다는 것이다. 내가 최근에 마주친 예시(비록 C++에서였지만, 러스트도 유사할 것이다)는 근본적으로 다음과 같았다:
// 특정 정밀도로 foo_t를 buffer_t에 직렬화 auto serialize(buffer_t*, foo_t const&, precision_t);
// 호출 지점 constexpr precision_t precision = /* ... */; serialize(buffer, foo, precision); 정밀도 인자의 초기화를 추출해 컴파일 타임에 생성되도록 해야 했다는 점에 주목하라. 정밀도 인자를 함수 호출에 인라인했어도, 컴파일러가 최적화로 그렇게 해 줬을 수 있다. 하지만 보장되지는 않는다. 이건 단순한 예시고, 실제로는 큰 문제가 아닌 경우가 많지만, 다른 모델이 더 효과적일 수 있을지 자꾸 의문이 든다. 어떤 모습일지는 모르겠지만.
이는 앞 절의 일반화에 해당한다. 요점은 이렇다. 컴파일러가 무엇을 할지, 어떤 (기계) 코드를 뱉을지 알 수 없다. 이에 대해 길게 별도의 글을 쓸 생각이라, 간단히만 적겠다.
러스트는 이론적으로 최적에 가까운 코드 생성을 가능하게 한다. 예를 들어, 이터레이터는 for 루프와 맞먹는 수준의 어셈블리로 컴파일될 수 있다. 하지만 그건 이터레이터가 잘 작성되어 있고 컴파일러가 그 코드 전체를 최적화해 없애야 한다. 물론 이는 이터레이터뿐 아니라 모든 "제로-코스트 추상화"에 해당한다.
내 문제의식은, 이 모든 것이 믿음을 많이 요구한다는 점이다. 그건 공학이라기보다는 신앙에 가깝다. 자신이 쌓아 올린 추상화의 더미가 실제로 잘 최적화될 거라 믿어야 한다. 언어 차원의 보장이 없다면 이상적인 코드가 생성될지 확신할 수 없다. 언어가 보장을 제공하는 부분에서조차, 그 보장이 코드베이스에(특히 추이적으로) 적용되는지 식별하기 쉽지 않다. 다시 말해, 특히 자동 벡터화기가 개입될 때, 눈앞의 코드가 좋은 어셈블리로 컴파일될지 단번에 확신하기 어렵다(이에 대해 값진 통찰을 주는 Matt Pharr의 ispc 이야기를 추천한다). 나는 언어가 코드 생성에 대해 고수준에서 추론하기 쉽게 만들어 주길 바란다(이 점에서 C는 아직도 칭찬받을 만하다).
차선책은 툴링이다. 공정하게 말하자면, Matt Godbolt의 Compiler Explorer는 이 점에서 엄청난 도구다. 하지만 규모가 커지면 다룰 수 없다. 코드베이스 전체를 "고드볼트"하는 건 말이 안 된다. 더 중요한 건, 컴파일러 변경이나 코드 복잡성 증가로 인한 회귀를 막기 위해, 시간에 따라 이를 모니터링해 줄 도구를 나는 알지 못한다는 것이다.
나는 개인적으로, std::accumulate 같은 STL의 "제로-코스트 추상화"를 사용할 때가 해당하는 for 루프보다 몇 배에서 몇 자릿수까지 느려지는 C++ 코드를 본 적이 있다. 이는 완전히 용납 불가능하며, 특히 std 네임스페이스에 있는 것들에 대해서는 명세 차원에서 불법이어야 한다. 이 때문에, 특히 크로스플랫폼 환경에서 내 코드가 무엇으로 컴파일될지 두렵다. C++은 러스트가 아니지만, 그 주요 컴파일러들은 rustc보다 훨씬 성숙했고, LLVM에게 최적화를 맡기기 위해 IR을 마구 던져 놓기만 하지는 않는다는 점을 명심하자.
성능, 단순성, 안전성은 현대 시스템 언어의 일차적 목표로 자주 언급된다. 여기에 생성 코드의 예측 가능성도 고려해 주길 바란다.
오늘날의 러스트에서, 배열 위의 for 루프보다 이터레이터가 더 낫다는 건 안다. 경계 검사(bounds check)를 피하니까. 이터레이터 코드가 최적화되어 사라지는 한에서 말이다. 하지만 동시에, 컴파일러가 앞으로 범위를 벗어나는 접근이 없음을 미리 판단할 수 있다면, 그냥 루프를 쓰는 편이 나을 수도 있다. 다시 말해, 배열 비슷한 타입을 성능 좋게 순회하는 올바른 방법이 무엇인지, 실제로 들여다본 사람에게도 자명하지 않다. 이는 사소한 프로그래밍에 엄청난 정신적 오버헤드, 의심, 불확실성을 더하는 끔찍한 일이다.
이건 아주 중요하니 별도의 절이 필요하다. unsafe가 없다면, 러스트는 빌림 검사기가 성가신 고수준 언어에 불과할 것이고, 표준 구조체 더미가 빠를 수는 있어도 일반인이 구현할 수는 없었을 것이다. 즉, 사용자 정의 자료구조, 사용자 정의 할당자, 유니언, 메모리 매핑 IO를 통한 하드웨어 대화 등, 흥미로운 건 아무것도 없었을 것이다.
전체적으로 사소하지만, 어쨌든 거슬린다. unsafe가 많은 코드(CPU 에뮬레이터에서 유니언 접근, 서로 다른 수치 타입 간 캐스팅이 필요한 저수준 코드 전반, 단순 자료구조나 "무서운 포인터 조작"만이 아니라)를 생각해 보라. 대개 보기 흉하다. unsafe 코드의 범위를 제한하고 싶다면(나는 그렇다), 키워드를 많이 써야 한다. 이를 도와주는 헬퍼 함수를 둘 수도 있지만, 그러면 unsafe의 성격을 숨기게 되고, 무슨 일이 일어나는지 직접 보는 것보다 덜 직관적이다. 개인적으로는 C의 그런 면이 좋다.
최선의 관행은 아니라는 건 안다. 하지만 모든 것이 최선의 관행일 필요는 없다. 코드의 범위가 좁을수록, 특히 그 자체로 암시된 영역(예: 직렬화)에서는 큰 경고판을 사방에 붙일 필요가 적다. 예를 들어, C++에서 잠재적으로 까다로운 부분을 {static,reinterpret}_cast로 표시하는 건 좋지만, 어떤 문맥에서는 터무니없이 무겁다.
러스트가 틀렸다고 말하는 건 아니다. 다만 가끔 거슬릴 뿐이다.
이건 반대로 위험하다고 생각한다. 러스트 커뮤니티가 unsafe의 과도한/감사되지 않은 사용을 못마땅해하는 건 좋다. 하지만 커뮤니티 밖의 프로젝트에는 적용되지 않고, 진짜 시스템 프로그래밍은 unsafe 연산을 요구한다(최소한, 추상화가 당신의 사용 사례에 대해 제로-코스트라고 믿으며 후프를 통과하느라 시간을 보내기보다, 무언가를 만들고 싶다면[7]).
"그래서 뭐? C/C++/다른 시스템 언어는 전부 unsafe잖아. 그럼 러스트가 더 낫지!" 그게 정확히 무슨 뜻일까? 자세히 들여다보면, 어떤 언어에서든 unsafe 코드 사용에는 두 가지 문제적 패턴이 있다. unsafe 문맥에서 즉시 프로그램을 망치거나 데이터를 손상시키는 멍청한 짓을 할 수 있다. 예를 들어 널 포인터 역참조. 어떤 언어도 거기서 당신을 구해 주지 못한다. 바로 그런 곳에서 unsafe 같은 기능을 쓸 테니까(물론 단지 nullptr를 역참조하려는 게 아니라, 메모리 매핑 IO 같은 의도된 일을 하려 했겠지만…). 또 하나는, 안전한 코드의 불변식을 깨뜨리는 일을 할 수 있다는 것이다. C++에서는 std::unique_ptr나 std::string의 기저 저장소를 해제해 버리는 식이다. 러스트에서도 같은 일이 벌어질 수 있다.
여기서 핵심은, unsafe 코드가 "안전한" 코드에 영향을 준다는 것이다. unsafe 코드에서 불변식을 지키지 못하면, 프로그램은 ill-formed가 되어 지저분한 짓을 할 것이다. 특히 컴파일러가 정의되지 않은 동작이 허용하는 최적화를 활용한 후에는 더더욱. 이를 최소화하는 두 가지 방법은, 불변식이 깨질 수 있는 장소(unsafe 블록)를 제한하고 그곳을 정말정말정말 꼼꼼히 살피는 것, 혹은 지켜야 할 불변식의 수(혹은 난이도)를 제한하는 것이다. 모든 C++ 프로그램이 본질적으로 unsafe fn main()의 문맥에서 실행된다고 본다면, 러스트가 그 영역에서는 확실히 더 잘 갖춰져 있다. 지켜야 할 불변식의 양/성격에 관해서는 더 까다롭다. 러스트 레퍼런스는 unsafe로 간주되는 동작 목록에 대해 "unsafe 코드에서 무엇이 허용되고 허용되지 않는지에 대한 러스트 의미론의 정식 모델이 없으므로, 더 많은 동작이 unsafe로 간주될 수 있다"고 밝힌다. 다시 말해, 예컨대 C++보다 unsafe 러스트에서 더 많은 것을 조심해야 할 뿐 아니라, unsafe 블록에서 바깥에서는 할 수 없는 일을 하면 그것이 정의되지 않은 동작일 수도 아닐 수도 있고, 그 결과 "안전한" 코드를 망칠 수도 아닐 수도 있다. 무섭지 않은가?
실전에서는, 잘 알려진 정의되지 않은 동작이 아닌 것에 대해 컴파일러가 쓰레기 코드를 생성하지는 않을 것이다. 그래도 말이다.
운 좋게도, 나는 러스트에서 비동기 코드를 많이 쓰지 않는다. 나는 비동기 러스트를 좋아하지 않는다. 가끔은 필요하지만, 대부분은 그냥 의존성을 잔뜩 끌어오고, 첫 빌드에 15분쯤 기다린 다음, 필요한 곳에 보일러플레이트를 덕지덕지 붙이고 다시 직선형 코드를 쓰러 돌아간다. 으, 벌써 나쁘게 들리지 않는가?
비동기 러스트에 대한 내 가장 큰 불만은 색 문제, 그 주변 생태계의 이질성, 전반적인 무거움과 투박함이다. 맞다, 더 작은 런타임을 쓰거나 직접 만들 수도 있다. 하지만 그래도 여전히 더 많은 코드나 더 많은 의존성이 필요하고, 이제는 Tokio에 의존하는 크레이트들과 상호 운용할 수 없다. 그게 무슨 이득인가.
색 문제에 관해서는, 어떤사람들은 문제가 아니라고 생각하고, 다른 이들은 오히려 좋은 것이라고도 본다. 그들의 요지는 이해하지만, 동의하지 않는다.
익스큐터에 그냥 얹으면 되잖아? 아니다! 함수가 쓸모 있는 일을 한다면, 나는 동기 문맥에서 그걸 호출해 단순히 완료될 때까지 기다릴 수 있길 원한다. 익스큐터도, 추가 크레이트도, 아무 것도 없이. 어쩌면 나는 성능에 신경 쓰지 않을지 모른다. 어쩌면 그냥 빌어먹을 페이지 하나를 내려받아 내용만 긁고 싶을 뿐이고, 몇 밀리초 더 기다리는 건 신경 쓰지 않는다. 내 프로젝트에서 Tokio를 어떻게 돌리는지 찾아보고, 내려받고, 컴파일하고, 보일러플레이트를 덧붙이는 데 너무 수고가 든다면, 다른 언어로 그걸 가져와 러스트에서 읽어 버릴 것이다. 타입 안전성은 좋다, Future는 Option과 같다. 동의한다. 무슨 일이 벌어지는지 구분할 수 있는 건 중요하다. 언어가 내 뒤에서 뭔가를 하거나, 함수가 비동기인지/가능한지 문서에 의존해 알아내고 싶지 않다. std는 동기뿐이니, 뭐가 불만이냐고? 러스트 재단이 이것이 앞으로도 변치 않도록 보장해 줄 거라 믿지 않는다. 설사 그렇다 해도, 더 넓은 생태계에 대해서는 아무 것도 말해 주지 않는다. 그중 상당 부분이 내 코드와 자명하게 호환되지 않는다면(동기든 비동기든), 나는 슬프다. 코드 중복이 잔뜩 필요하다면, 나는 슬프다. 코드 중복을 피하려고 동기 코드베이스에 익스큐터를 들이밀어야 한다면, 나는 슬프다.
공정하게 말해, Go 같은 언어의 접근법이 옳다고 보지도 않는다(에를랑/엘릭서는 다뤄 본 적이 없어 모르겠다). 또 나는 비동기 전문가도 아니다. 내가 본 바로는, 지그가 내가 좋아하는 것에 조금 더 가깝게 느껴진다. 하지만 전역 설정인
pub const io_mode = .evented; // 또는 pub const io_mode = .blocking; 은 나로선 무섭다. 호출마다 동기/비동기 실행을 명시적으로 고르고 싶다. 약간의 장황함은 개의치 않는다. 하지만 컴파일러가 특정 제네릭 매개변수나 키워드로 호출하는 모든 비동기 코드를, 내가 원하면 추이적으로 동기 코드로 바꿔 주길 바란다. 또 진짜 비동기 코드가 더 접근하기 쉬웠으면 하지만, 그건 단지 희망 사항일 뿐이다. 기본 표준 익스큐터가 내가 비동기 코드를 쓰기만 하면 뒤에서 마법처럼 돌아가는 것도 원치 않으니 말이다.
전반적으로, 나는 async/await, 코루틴 같은 걸 좋아하지 않는다. 그래서 내가 하는 대부분의 일이 "OS 스레드 하나당 실행 스레드 하나"라는 단순한 코드로 잘 맞아떨어지는 게 기쁘다. 러스트가 내 감정을 바꾸진 못했다.
표준 라이브러리에 대해 불만이 산더미인 건 아니다. 전반적으로 꽤 좋다. 많은 컨테이너가 자신의 할당자(혹은 그 비슷한 것)을 노출하지 않는 건 아쉽다. 커스텀 할당자가 요구되는 시나리오에서는 사용할 수 없게 만들기 때문이다. 그럴 때 BTreeMap을 처음부터 직접 구현할 가치를 찾기 어려울 수 있다.
어떤 API는 배우기 좀 성가시다. 예를 들어, 처음으로 문자열을 순회하려고 하면, Chars가 무엇인지 파악하는 데 시간이 좀 든다. std::process 대부분도 마찬가지다. 더 성가신 점은, 알고리즘이 C++ STL처럼 포인터/이터레이터를 돌려줄 수 없다는 것이다. 러스트가 러스트이니 당연하긴 하다.
전반적으로, 표준 라이브러리는 좋은 기초적 컬렉션과, OS와 상호 작용하기 위한 괜찮은 API를 제공한다.
러스트의 가장 눈에 띄는 문제 중 하나는 빌드 시간이 끔찍하게 길다는 것이다. 이는 러스트의 문제이지, 단순히 rustc의 문제가 아니다(rustc는 사실 꽤 최첨단 기술을 쓴다). 언어가 복잡하고, 빌드 시간은 거의 언제나 성능이나 안전성과 맞교환된다. 나는 그 맞교환에 만족하지만… 그렇다고 내 프로젝트가 더 빨리 빌드되지는 않는다.
관련해서, 러스트는 크로스-테크놀로지 프로젝트에 통합하기 꽤 성가시다. Cargo는 정말 좋은 도구지만, 거의 러스트만 신경 쓴다. Bazel(이것도 훌륭하다)에 러스트 지원이 괜찮다는 얘기는 들었는데, 그게 거의 전부다.
다른 언어와의 통합, 심지어 C와의 FFI도 꽤 무겁다. 역시 그럴 만한 이유는 있지만, 지그 팬들이 프로젝트에서 C 코드를 그냥 #include해 쓸 수 있음을 즐기는 이유를 이해한다.
마지막으로, 나는 러스트 생태계의 팬이 아니다. 장점이 많고, 오늘날 어떤 언어와 비교해도 최고라 할 만한 혁신적인 크레이트가 많다. 하지만 그중 상당수는 통합 및/또는 의존성 문제를 갖고 있다. 많은 것들이 특정 비동기 런타임에 의존하고, 동기 API를 노출하지 않으며, 같은 도메인의 다른 크레이트들과 잘 합성되지 않는다. 또 이유 없이 거대한 추이적 의존성을 갖는 경우가 많아, 패키징/감사/보안을 훨씬 어렵게 만든다. 많은 크레이트가 강하게 의존되는 동시에, 유일한 관리자가 흥미를 잃거나 더 나은 대안을 쓰기로 하면서 업데이트가 끊기곤 한다. 이는 물론 이해할 만하고, 단일 개인의 지속적인 노고에 의존하는 오픈소스 라이브러리라면 흔한 일이다. Actix 사태는 이에 대한 슬픈 사례이며, 러스트 커뮤니티가 이 문제를 어떻게 더 악화시킬 수 있는지도 보여 준다.
이상이다. 러스트에 대해 내가 싫어하는 것들 대부분. 분명 빠뜨린 것도 있을 것이다. 앞으로도 수년 간 러스트를 잔뜩 쓸 테니, 다시 부딪히면 이 글을 업데이트할 것이다…
[1] 전혀 DRY를 신경 쓰지 않는다면, 언제든 C/어셈블리/마이크로코드를 직접 쓰면 된다. 하지만 C++/러스트에 관심이 있다면 원치 않을 것이다. Jump back
[2] 예: 타입 지정과 타입 인스턴스화에 세미콜론을 쓰고, 함수 호출과 타입 구조 분해에 괄호를 쓰며, 값 인덱싱이나 매크로 호출에는 대괄호를 쓰고, 제네릭 목록에서 타입과 라이프타임을 섞는 등. Jump back
[3] 그래도 파이썬이나 자바스크립트 같은 동적 언어보다는 훨씬 낫다. 좋은 것을 누릴 수 있다. 더 좋은 것을 누릴 수 있었으면 할 뿐. Jump back
[4] 러스트는 여전히 소멸자를 지원하며, 이는 숨은 제어 흐름이다. 그리고 언제든 패닉할 수 있다. Jump back
[5] 그렇다. 에러 처리는, 에러에 직면했을 때 기계적으로 무언가를 하는 것 이상의 일을 포함한다. 에러 처리에 관한 일반 계획 수립, 정제(sanitization)로 인해 에러가 발생할 수 있는/없는 경계 표면 정의, 시스템의 어떤 부분이 실패할 것으로 예상되는지, 그렇다면 어떤 방식으로 실패할지, 에러를 어느 정도의 세분도로 보고해야 하는지 등등. 하지만 언어에서 우리가 신경 쓰는 건, 그런 에러 처리 전략을 실현하기 위해 어떤 설비를 제공하느냐 하는 점이다. Jump back
[6] 예외가 코드를 추론하기 어렵게 만든다는 주장을 이해한다. 나도 어느 정도 동의한다(예: 외부 라이브러리가 언제 어디서든 무엇이든 던질 수 있고, 당신이 의존할 수 있는 것은 기껏해야 무엇이 일어날 수 있는지 상세히 적은 문서뿐일 때). 하지만 그런 사례는 기술적·사회적 관점 모두에서 상당 부분 완화될 수 있다고 본다. 예를 들어, 예외가 크레이트 경계를 넘는 것을 금지할 수 있다. 그러면 라이브러리 내부에서 무슨 일이 벌어지는지는 당신의 문제가 아니다(당신은 이미 그들이 패닉하지 않거나/충분히 빠르다고 믿고 쓰고 있으니, 무엇이 다르겠는가?). 단일 크레이트 내부에서는 예외 사용(혹은 배제)은 대체로 사회적 문제다. 코드에 예외를 원치 않는가? 들여오지 마라! 예외를 쓰고 싶은가? 어디서 어떻게 쓸 수 있는지 정하고, 코드 리뷰에서 그걸 강제하라. 아니면 더 나아가, 러스트가 더 나을 수 있음을 보여 주고, 그걸 자동으로 해 주는 도구를 만들어라! Jump back
[7] 간단히 상기하자. 제로-코스트 추상화는 당신이 쓰는 코드가 최적이라는 뜻이 아니라, ‘그 추상화가 설계된 것’을 더 효율적으로 쓸 수 있는 방법이 없다는 뜻일 뿐이다. 추상화에 대해 컴파일 타임과 인지적 오버헤드(머릿속에서 차지하는 공간과, 추론해야 하는 계층 수)의 비용을 여전히 치른다는 사실은 제쳐 두더라도, 당신의 사용 사례가 그 추상화가 겨냥한 것과 ‘정확히’ 일치하지 않는다면, 문제를 더 잘 푸는 방법이 ‘확실히’ 있다. Jump back