2018년 중순부터 4년간 Rust를 사용해보았고, 최근 1년 반 가량은 병역특례를 하면서 프로덕션에서도 사용을 해 보았다. 연말이기도 하니, 그 동안 내가 Rust를 하면서 어떤 인상을 받았는지를 중점으로 하여 되돌아보고자 한다.

주의: 작성자의 사견이 다량 포함되어 있습니다.

Rust의 철학

Rust는 폭넓게 쓰이는 다른 언어들에 비해 상당히 이질적인 부분이 많다. 이러한 부분은 기능의 설계 단계에서부터 특정한 철학 혹은 규칙을 적용하여 발생하는 괴리라고 생각이 든다. 이러한 부분에 대해 이해하면 조금 더 Rust를 쉽게 배울 수 있을 것이다.

Zero-cost Abstraction

The concept of zero cost abstractions originally came from the functional world. However, the terminology comes from C++. According to Bjarne Stroustrup,

In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better. 1

보통 zero-cost abstraction을 두 개의 문장으로 번역하여 설명하곤 한다.

  1. 사용하지 않는 기능에는 비용을 지불하지 않는다.
  2. 추상화한 코드의 비용이 추상화하지 않은 날것의 구현과 동일하다.

1번의 예를 들어보면, Rust 언어의 주요한 요소 중 하나인 lifetime은 포인터(혹은 레퍼런스)의 유효성을 런타임이 아닌 컴파일 타임에 검증하기 위해 존재한다.

JavaScript를 예로 들어보자. 객체를 변수에 담는다는 것은, 사실은 특정 객체를 바라보는 레퍼런스를 변수에 담는다는 것이고, 그 객체에 직접 접근할 수는 없다. 그리고 대입(=) 연산을 통해 변수를 ‘복제’하여도, 새로운 객체가 복제되어 생성되는 것이 아니라 같은 객체를 바라보는 레퍼런스가 하나 더 생기는 것이다. 그러면 우리는 레퍼런스가 항상 ‘살아 있는’ 객체를 바라본다는 것을 어떻게 확신할 수 있을까? 답은 역설적이게도 ‘자신을 바라보는 레퍼런스가 존재하는 동안에는 객체가 항살 살아 있는다’는 것이다.

다시 말해, JavaScript는 런타임에 자신을 바라보는 레퍼런스의 수, 유식한 말로 refcount를 추적하는 비용은 프로그래머의 의도와 관계없이 고정으로 지출되는 것이다. (요즘 자바스크립트 엔진은 상당한 최적화가 되어 있어서 자명한 경우에는 없앨 수도 있겠다.)

Rust의 경우에는 더 엄격한 규칙을 적용해서 컴파일 타임에 각각의 값들이 언제 사라져도 안전한지를 추적하고, 런타임에는 그러한 비용이 들지 않도록 설계했는데, 이 ‘언제’ 시점을 lifetime이 결정한다고 보면 된다.

2번의 예도 들어보자. Java에서 오버라이드된 메서드를 호출할 때는 메서드의 실제 구현을 찾은 뒤에 해당 구현을 사용해야 하기에, dynamic dispatch라고 불리는 기능이 필요하다. Dynamic이라는 말이 들어간 기능답게, 이는 공짜가 아니다. JVM에서 최적화를 할 수는 있겠지만 비용을 0으로 만들 수는 없다.

Rust의 경우에는 (trait object를 제외하면) 모두 static dispatch를 수행한다. 다르게 말하면, 컴파일 시점에 모든 함수 호출이 어느 구현을 사용해야 하는지 알게 되는 것이다(대신 이 또한 Java에 비해 이질적이거나 경직된 설계를 유도하는 원인이 된다).

Zero-cost abstraction이 주는 제약도 많지만, 나는 Rust가 C++와 경쟁하며 더 나아가 수많은 플랫폼에서 실행 가능하도록 하는 데 기여했다고 생각한다. 이는 ‘Rust의 장점’ 문단에서 다시 언급하겠다.

암묵적보다는 명시적 표현을 지향

C에서의 integer promotion을 기억하는가?

If an int can represent all values of the original type (as restricted by the width, for a bit-field), the value is converted to an int; otherwise, it is converted to an unsigned int. These are called the integer promotions. All other types are unchanged by the integer promotions. 2

대충 요약하면 C에서의 정수 연산은 손실이 일어나지 않는 한 암시적으로 각 값을 int 혹은 unsigned int로 변환 후 계산한다.

C가 아니더라도, Javascript의 1 + 'a'라던가 하는, 암시적 형변환이 가져올 수 있는 footgun이나 숨겨진 비용 문제를 제거하기 위해서 Rust는 가능한 한 명시적으로 코드를 표현하는 것을 좋아하는 것으로 보인다. 예를 들어 dynamic dispatch를 가능케 하는 trait object는 원래 trait 이름 그 자체만 쓰면 되었지만 Rust 2018부터는 dyn이라는 키워드를 앞에 붙여야 하도록 바뀌었다. 앞서 말한 타입캐스팅도 모두 as 키워드나 try_from(정보 손실이 가능한 경우에는 대부분 후자가 권장된다) 메서드를 명시적으로 사용해야만 발생한다.

Rust의 장점

내가 4년 동안, 질리지도 않고 Rust를 사용할 수 있었던 이유를 소개해보고자 한다.

매우 강력한 타입 시스템과 이에 따라붙는 표현력

Rust의 타입 시스템은 해결하고자 하는 문제를 모델링할 때 거슬릴 일이 없을 정도로 매우 강력하고, 타입 추론과 타입 레벨 연산 기능도 아주 훌륭해서 zero-cost abstraction을 달성하는 데에 많은 도움을 준다. 그 중에서 몇 개를 꼽아보자면…

Generics

Rust의 일반화 프로그래밍은 아주 강력해서, 다음의 기능들을 가지고 있다

  • Trait: 타입의 ‘특성’ 을 서술할 수 있다. 이 타입은 전순서를 가진다거나, 문자열로 변환 가능하다던가… (interface처럼 다중 구현이 가능하지만 abstract class처럼 default implementation이 가능한, 중간의 무언가로 봐도 무방하다.)

    pub trait Ord: Eq + PartialOrd<Self> {
        fn cmp(&self, other: &Self) -> Ordering;
    
        fn max(self, other: Self) -> Self { ... }
        fn min(self, other: Self) -> Self { ... }
        fn clamp(self, min: Self, max: Self) -> Self
        where
            Self: PartialOrd<Self>,
        { ... }
    }
    

    값이 전순서를 가짐을 표현하는 Ord는, 순서 정의인 cmp만 구현하면 max, min, clamp 메서드의 default implementation이 따라온다.

  • Type bound: 타입 파라미터에 trait/lifetime 조건을 명시할 수 있다.

    fn find_max<T: Ord>(xs: &[T]) -> &T { // T가 Ord를 구현해야 사용 가능하다
      match xs.len() {
          0 => panic!("xs is empty"),
          1 => &xs[0],
          _ => {
              let mut max = &xs[0];
              for x in xs {
                  if x > max { // 여기서 T: Ord가 필요해진다 
                      max = x;
                  }
              }
              max
          }
      }
    }
    
  • Generic trait: Trait에도 타입 파라미터를 붙일 수 있다. 가장 간단한 예시로 From<T>를 보자.

    pub trait From<T> {
        fn from(T) -> Self;
    }
    

    어떤 타입이 From<T> 를 구현하면, T로부터 자신(Self)으로 자명하게 변환 가능하다는 뜻이다.

  • Associated type: Trait에 ‘연관된’ 타입을 정의하여 구현하는 타입이 지정헤주도록 강제할 수 있다. From<T>의 ‘실패 가능한’ 버전인 TryFrom<T>를 보자.

    pub trait TryFrom<T> {
        type Error;
    
        fn try_from(value: T) -> Result<Self, Self::Error>;
    }
    

    어떤 타입이 TryFrom<T>를 구현하려면, ‘실패 가능한’ T로부터 오는 변환도 정의하고, 변환이 실패했을 때 반환할 리턴 타입도 지정해야 한다.

  • Generic implementation: 함수와 trait만 일반화 가능한 것이 아니라, impl 블록도 일반화가 가능하다. 즉 특정 조건을 만족하는 타입 모두에 대해서 다른 Trait에 대한 구현을 추가할 수 있다.

    impl<T, U> Into<U> for T
    where
        U: From<T>,
    {
        fn into(self) -> U {
            U::from(self)
        }
    }
    

    Into<U>From<T> 의 ‘역순’ trait으로, T -> U 방향을 뒤집어서 U -> T 방향으로 (something.into()와 같이) 사용 가능하도록 해 주는 trait이다(From이 존재하는데 Into가 왜 필요하냐고 의문을 가질 수 있지만, 매우 사소한(?) 이유이므로 일단 있다는 사실만 알고 가자).

    Into<U>U: From<T>를 만족하는 U에 해당하는 각각의 타입에 대고 구현하는 것은 불가능하므로, 일반화된 impl을 사용하여 모든 타입에 대해 한 번에 구현하는 것이다.

  • Generic associated type: associated type도 일반화시킬 수 있다. 다시 말해 associated type도 타입 파라미터를 가질 수 있다는 것인데, 조금 복잡한 예시를 하나 들어보면:

    trait PointerFamily {
    type Pointer<T>: Deref<Target = T>;
    fn new<T>(value: T) -> Self::Pointer<T>;
    }
    
    struct ArcFamily;
    
    impl PointerFamily for ArcFamily {
        type Pointer<T> = Arc<T>;
        fn new<T>(value: T) -> Self::Pointer<T> {
            Arc::new(value)
        }
    }
    
    struct RcFamily;
    
    impl PointerFamily for RcFamily {
        type Pointer<T> = Rc<T>;
        fn new<T>(value: T) -> Self::Pointer<T> {
            Rc::new(value)
        }
    }
    
    struct Foo<P: PointerFamily> {
        bar: P::Pointer<String>,
    }
    

    3

    여러 종류의 (스마트) 포인터를 PointerFamily라는 하나의 trait으로 묶어서 표현할 수 있게 된다. 포인터는 Rc<T>, Arc<T>와 같이 그 자체로 일반화되어있으므로 한 차원 위에서 다시 한번 일반화하는 기능이 필요한데, 이를 GAT를 사용하여 표현하는 것이다.

이렇게 Rust의 강력한 일반화 프로그래밍 기능을 아래의 ADT와 조합하면, 사실상 거의 모든 문제상황을 타입으로 모델링할 수 있게 된다.

ADT: enum과 패턴 매칭

Algebraic Data Type의 줄임말로, 타입을 이를 만족하는 값들의 집합으로 보았을 때 집합 간의 연산 결과로 나오는 타입을 말한다. 일반적으로 ‘합연산’과 ‘곱연산’을 따지는데, 각각 Rust의 enumstruct에 대응된다. 다시 말해, enum은 ‘or’, struct는 ‘and’를 뜻하기 때문에 표현하고 싶은 상황을 타입으로 나타내기에 매우 적합하다.

예를 들어, 3층짜리 찬장에 있는 세 가지 색깔의 컵이 어떻게 배치되어 있는지 알고 싶다고 하자. 찬장의 각 ‘층’은 동시에 존재하므로, ‘and’ 연산인 struct를 이용하도록 한다.

struct Cupboard {
    first: Vec<Cup>,
    second: Vec<Cup>,
    third: Vec<Cup>,
}

컵의 경우에는 세 가지 색깔이 있고 동시에 두 가지 색을 가질 수는 없으므로 ‘or’ 연산인 ’enum’을 이용하면 되는 것이다.

enum Cup {
    Red,
    Blue,
    Green,
}

더 나아가서, Rust의 enum에는 각 variant마다 값을 넣을 수 있다. 만약 찬장에 넣을 수 있는 것이 접시도 있다면?

enum Object {
    Cup(Cup),
    Dish(Dish),
}

이번에는, 접시를 쌓아서 보관할 수도 있다면?

enum Object {
    Cup(Cup),
    Dish(Dish),
    StackedDish(Vec<Dish>),
}

이렇게 만들어진 enum이나 struct에서 값을 뽑아오려면, 패턴 매칭을 사용할 수 있다(struct의 경우 다른 언어와 동일하게 .으로 접근하는 것도 가능하다).

match object {
  Cup(cup) => { /* ... */ }
  Dish(dish) => { /* ... */ }
  StackedDish(stacked_dish) => { /* ... */ }
}

각각의 match 곁가지들에서 각 variant가 담고 있는 값을 변수로 할당하여 사용 가능한 것을 알 수 있다.

패턴 매칭은 단순히 ‘한 겹’ 벗기는 것을 넘어 중첩된 패턴에도 매칭할 수 있다.

match (object1, object2) {
  // 두 값에 대해 동시에 매칭하면서, 안쪽의 값에 대해서도 한번 더 매칭
  (Cup(Cup::Red), Cup(Cup::Blue)) => { /* ... */ }
  // 두 값에 대해 동시에 매칭하면서, 안쪽의 값을 써서 추가적인 조건 제시
  (Cup(a), Cup(b)) if a == b => { /* ... */ } // Cup: Eq 라 가정하자.
  // _ 는 항상 매칭에 성공하는 값이다.
  (Dish(dish), _) => { /* ... */ }
  (StackedDish(stacked_dish), _) => { /* ... */ }
  // 위의 패턴들 중 아무것도 만족하지 않으면...
  _  => { /* ... */ }
}

조금 더 현실적인 enum의 예시로는, 에러 핸들링의 중추가 되는 Result<T, E> 타입이 있다.

enum Result<T, E> {
    Ok(T),
    Err(E)
}

(여기서 알 수 있겠지만 enum이나 struct도 일반화할 수 있다.)

높은 런타임 성능

Rust의 zero-cost abstraction 덕분에, Rust 프로그램은 얼마든지 필요한 만큼만 사용하는 ‘가벼운’ 코드를 작성할 수 있다(주의: 약간의 예외 있음). 이는 높은 범용성과도 맞물리는데, 좀 더 아래에서 서술하겠다.

Zero-cost Asynchronous Programming

Rust에서 제일 강력한 기능 중 하나를 꼽으라면, 나는 망설임 없이 async/await을 선택할 것이다. 다른 언어에서 구현하는 비동기 프로그래밍과는 약간의 차이가 있지만(체계 자체가 조금 더 명시적이다), 손으로 state machine을 짜서 구현한 것과 대등한 성능을 보이며4 GC가 필요없는 언어 중에서는 Rust가 독보적으로 편의성과 안전성 면에서 우위를 점하고 있다.

특히 이는 대규모 네트워크 서비스를 제공하는 기업에서 저지연, 고부하 조건을 만족해야 할 때 크게 작용하는데, Discord는 GC로 인한 지연시간 편차로 인해 Rust로 특정 마이크로서비스를 재작성하여 큰 성능 향상을 얻었고, CloudFlare는 내/외부망을 연결하는 프록시를 NGINX에서 자체 Rust 구현으로 교체하여 CPU 자원을 더 효율적으로 사용하기도 했다. 덤으로 얻는 memory safety 덕에 CloudFlare는 수백조 단위의 요청을 처리히면서 단 한번의 프록시 버그로 인한 크래시도 경험하지 않았다고 한다. 오히려 하드웨어 문제와 알려지지 않은 리눅스 커널의 버그를 찾기까지 했다고…

개발 도구의 성숙함

개인적으로 Visual Studio보다는 한 수 아래라고 생각하지만, 다른 ‘신생’ 언어들과 비교하면 독보적인 1순위를 달리고 있지 않나 생각한다. 빌드 시스템과 의존성 관리 역할을 해 주는 cargo는 다른 언어에서 벤치마킹할 정도로5 Rust의 장점 중 하나로 꼽히고, Rust 툴체들의 버전 관리 및 설치를 도와주는 rustup도 매우 편리하다. LSP 구현체인 rust-analyzer 덕분에 LSP 클라이언트가 내장된 많은 에디터에서 IDE와 비슷한 경험을 가질 수도 있다.

Memory Safety

앞서 말한 zero-cost abstraction을 통해 궁극적으로 Rust는 ‘memory safety’를 달성한다. Rust에서 unsafe 코드를 사용하지 않는 한, 컴파일되는 모든 코드는 메모리 버그를 일으키지 않음이 보장된다. 아주 가끔씩 safe 코드만으로 undefined behavior를 만들어낼 수 있는 구멍이 발생하지만(soundness hole), 그러한 동작들은 명백히 버그로 규정되어 최우선적으로 수정된다.

범용성

위에서 계속 언급했듯이, Rust 코드는 수많은 플랫폼에서 구동되고 있다.

  • 데스크탑 및 스마트폰 OS의 컴포넌트와 같은 시스템 라이브러리
    • Windows, Linux, MacOS, Android, iOS
  • 고성능 웹 백엔드
  • Linux Kernel 드라이버 (>= 6.1)
  • eBPF 및 WebAssembly
  • 블록체인(블록체인 노드, 스마트 컨트랙트 구현 등에 모두 쓰인다).
  • 임베디드 프로그래밍(이 쪽은 비교적 생태계가 약하다)

이것이 가능한 이유는 Rust가 근본적으로 zero-cost abstraction을 지향하기 때문에, 다른 플랫폼에 올려놓아도 추가적인 비용이 거의 발생하지 않기 때문이다. 예를 들어 Javascript를 eBPF 위에 올리려 하면, 가비지 컬렉션이나 약한 동적 타입 시스템의 비용이 필연적으로 따라오게 될 것이다. (AssemblyScript와 같이 파편화된 생태계가 발생할 수도 있다).

Rust의 단점

여기까지만 보면 Rust는 ‘완전한’ 언어 같지만, 그렇지 않다. Rust를 사용해보면서 그러한 결점들을 크게 체감할 수 있었다.

높은 진입 장벽

Rust는 어려운 문제를 쉽게 추상화하지 않는다(쉽게 추상화하면 반드시 비용이 발생하는 지점이 있기 때문이다)6.

예를 들어, Rust에서 문자열 처리를 하고 싶으면 다음의 타입들에 대해 이해할 필요가 있다:

  • String (힙에 할당된 UTF-8 문자열)

  • &str (문자열 ‘슬라이스’. UTF-8로 표현된 바이트열을 바라보는 레퍼런스)

    &String은 거의 사용되지 않고, 대부분의 경우에 &str을 사용하는 것이 권장된다.
    
  • &[u8] (바이트열. 그 자체로 문자열은 아니지만 파싱 전에 만날 확률이 높다)

  • Cow<str> (‘Copy-on-Write’ 문자열. 복사를 최소화하고 싶을 때 만날 수 있다)

  • impl AsRef<str> (그 자체로 타입은 아니지만, &strString을 동시에 처리하고 싶을 때 사용할 수 있다)

OsString, CString, Path와 같이 특수한 목적을 지닌 타입들을 거론하지 않았는데도 벌써 공부할 것이 산더미가 되었다. 다른 언어들은 모르는 부분을 배제하고 쉬운 길로 (비용을 지불하고)나아갈 수 있지만, Rust의 경우에는 그것을 허용하지 않아 학습자를 좌절시키는 경우가 허다하다.

물론 Rust도 ‘쉬운 길’을 택하는 방법이 존재하지만, 대부분의 실전 코드들은 그렇지 않기 때문에 어떻게 비용을 지불하는지조차도 알기 어려운 경우가 많다.

설계의 제약

일반화 프로그래밍과 ADT를 찬양했다가 제약이 있다고 하면 모순되지 않냐고 할 수도 있지만, Rust의 타입 시스템이 ‘안전하며 비용 없이’ 표현하는 것이 불가능한 구조도 있다. 자기참조/순환참조 구조가 그것인데, lifetime의 존재로 인해 모든 Rust의 값들 간의 참조 관계는 DAG(방향 있고 순환 없는 그래프)로 표현될 수밖에 없다. 순환 참조가 발생하는 순간 lifetime을 정의할 수 없기 때문에 컴파일에 실패한다.

물론 Arc::new_cyclic 과 같이 순환참조를 가능케 하는 도구들도 있으나, weak pointer를 사용한다던가 하는 식으로 추가적인 비용(메모리 할당 포함)을 지불하거나, Pin::new_unchecked를 써서 unsafe하게 작성해야 한다. (후자는 ouroboros라는 crate가 자기참조를 편하게 구성할 수 있도록 해 준다고 하는데, 직접 써보지는 않아서 언급하지는 않겠다.)

대부분의 경우에는 이러한 구조가 필요하지 않도록 설계하는 것이 최선인데, 다른 언어를 사용하다 진입한 초심자 입장에서는 어려울 수밖에 없다. C/C++/Java 등으로 작성된 프로그램을 한줄 한줄(line-by-line) 번역해 이식하는 것이 불가능해지기 때문이다. 그러한 언어들에서는 자기참조나 순환참조 구조가 나타나는 패턴이 종종 보이곤 한다. (링크드 리스트라던가, 양방향 그래프라던가)

자기참조가 아니더라도, Rust의 borrow checker와 궁합이 맞지 않는 패턴들이 여럿 존재하는데, 예를 들어
이벤트 리스너와 callback이 주가 되는 옵저버 모델의 경우 callback이 사용하는 값들의 생명주기를 컴파일 타임에 추적하는
것이 힘들기 때문에 추가적인 할당이나 레퍼런스 카운팅이 필요해지게 될 것이다.

물론 이러한 부분에 대해서만 주의 깊게 unsafe 코드를 작성하거나 비용을 지불하여 모듈화하는 식으로 격리할 수는 있지만(Rust의 표준 라이브러리에서도 링크드 리스트와 이진 트리를 제공한다.) Rust가 의도하는 이상이 항상 현실에 100% 적용될 수는 없다는 사실을 인지해야 한다.

그리고 이러한 ‘격리’ 작업에 unsafe를 쓰는 경우 ‘올바르게’ 마무리하는 것이 아주 어려운 것으로 평가된다. Rust의 memory safety 등이 제공하는 보장의 특성상, 지켜야 하는 규칙이 엄격하고 이를 위반할 시에 컴파일러의 공격적인 최적화로 인한 오작동이 더 큰 문제를 일으킬 수 있기 때문이다.

비교적 낮은 생산성(개발 속도)

숙련된 Rust 프로그래머도 lifetime, borrow checker, type checker와 씨름하는 일이 자주 발생한다. 빠르게 구현해서 내보내는 프로세스가 정립된 B2C 서비스들을 운영하는 기업의 경우에는 Rust를 도입했을 때 개발 속도가 비교적 떨어질 수도 있음을 명심해야 한다.

사견으로, 이는 Go가 웹 백엔드 분야에서 크게 유행한 이유와 거의 정반대되는데, Go는 복잡한 문제도 비용을 내고
쉽게 추상화하며, 타입 시스템에 별다른 기능이 없고(1.18에야 일반화 프로그래밍이 정식으로 추가되었다), 다른 기능도
많지 않아 진입장벽이 매우 낮기 때문에 높은 생산성을 기대할 수 있다.

매크로

이 기능은 Rust에서 무제한에 가까운 메타프로그래밍을 할 수 있게 해주는 강력한 도구이지만, 개인적으로는 썩 좋아하지 않기 때문에 여기서 서술하고자 한다.

첫 번째로, 매크로는 문법 토큰 트리를 받아서 특정 작업을 수행하여 새로운 문법 트리를 반환하는 규칙이라고 말할 수 있는데, 설명에서도 유추할 수 있듯이 기존의 Rust 지식과는 판이한, Rust의 문법 구조에 관한 지식을 습득해야만 사용 가능하다. 다시 말해, 매크로는 사실상 Rust 문법만 차용한 또다른 Rust 안의 미니 언어로서 기능한다는 점에서 개발 경험의 괴리를 발생시킨다. (이 단점은 두 가지 종류의 매크로 정의 방식인 procedural macro와 declarative macro에 모두 해당된다.)

두 번째로, proc-macro는 매크로를 적용하려는 코드 이전에 컴파일되어야 하기 때문에 전체적인 컴파일 시간을 증가시킨다. 특히 이는 소스코드만을 압축해서 올리는 Rust의 crate 시스템과 맞물려 문제를 가중시키는데, proc-macro를 미리 컴파일한 결과를 게시할 수 없기 때문이다. (관련하여 watt라는 실험적인 WebAssembly 기반 proc-macro 런타임이 있지만 메인스트림에 들어오기는 아직 곤란하다.)

세 번째로, IDE와 proc-macro 간의 조화가 좋은 편은 아니라 proc-macro를 지나치게 많이 사용하는 경우 개발 경험이 오히려 감소하게 되는 경우가 발생할 수 있다.

‘전통적인’ 매크로 정의 방법인 ‘decl-macro’, 다른 말로 macros by example 방식은 상술한 두 번째와 세 번째 문제에 대해 비교적 자유롭지만, proc-macro보다도 더 이질적인 문법과 정의 방식을 가지고 있고(간단히 설명하면, Rust 코드의 문법 구조에 대해 패턴 매칭을 시도한다), 표현 가능한 범위에도 제약이 있어서 기능의 복잡도가 특정 지점을 넘으면 사용할 수 없게 된다. (그 예시로, 동일한 기능을 수행하는 pin-projectpin-project-lite를 보면 전자는 proc-macro, 후자는 macros by example 방식인데 후자는 전자의 일부 기능을 제거했음에도 불구하고 훨씬 더 이해하기 어렵다).

마이너리티

Rust 구인/구직 시장은 Go와 비교해서도 매우 좁다. 특히 한국은 격차가 훨씬 심하다.

결론: 10년 뒤에도 Rust로 밥벌이가 가능할까?

단점을 줄줄이 나열했지만 답은 ‘yes’라고 생각한다. 내가 생각하는 현재(그리고 근미래)의 Rust 사용처는 다음과 같다.

대규모 서비스의 백엔드

현재로서는, 감히 말하건데 다음의 상황에서 Rust를 이길 대안은 없다.

  • 대규모 서비스를 운영하여 높은 워크로드를 가지고
  • I/O가 매우 잦으며
  • 불특정 다수에게 서비스(B2C)하여 강건성을 특히 신경써야 하는

(이들 중 두 개만 해당되어도 아주 매력적인 선택이 된다)

흔히 말하는 ‘빅테크’ 백엔드 분야에서는 Rust의 zero-cost abstraction, async/await, memory safety는 독보적인 매력을 가진다. 한 가지 문제점은 이러한 매력이 비교적 작은 조직에서는 어필되기 어렵다는 점이다.

크로스플랫폼 비즈니스 로직 모듈

Rust의 표현력과 범용성은 여러 플랫폼에서 공유되어야 하는 비즈니스 로직을 작성할 때 큰 이점이 된다. rustc의 타깃 목록을 보면, 주로 사용되는 PC/모바일 플랫폼들은 모두 Tier 1/2에 들어가 있는 것을 볼 수 있다. 즉 Rust 코드를 각 플랫폼에 대해 네이티브로 빌드하여 사용하여도 무방하다. 또한 rust-bindgen, cbindgen 등을 사용하면 C FFI를 통해 플랫폼 고유 코드와 소통하는 데에도 어려움이 없다.

이 부분은 인프콘 2022에서 크로스플랫폼 Rust를 발표한 자료를 참고하면 좋을 것으로 보인다.

FFI로 호출 가능한 고성능 연산 모듈

최근에 알게 되어 아직 검토가 부족하지만 불확실함을 무릅쓰고 여기에 주장을 남겨 보면, Rust의 비교적 경직된 설계를 최소화할 수 있는 방법으로 동적 스크립팅 언어와의 바인딩으로 사용하는 방법이 유효할 것으로 생각된다. 예를 들어, Python에 쉽게 Rust 코드를 붙일 수 있는 PyO3나, Ruby 익스텐션을 쉽게 만들 수 있는 Magnus 등을 사용하여 스크립팅을 위주로 작성하되 병목 지점에서 Rust로 작성한 모듈을 사용하여 고성능을 꾀하는 방식이다. 개인적으로는 LuaJIT과의 연동에 관심이 있는데, mlua라는 crate가 있어 주의깊게 지켜보고 있다.

마무리

Rust를 선도적으로 도입한 사례들에서 알 수 있듯이, 소수의 빅테크 회사들을 시작으로 한 Rust의 물결은 이제 부정하기 어렵게 되었다. 상술하였듯이 Rust가 모든 상황에서 만능으로 사용 가능한 은탄환이 될 수는 없는 걸 알면서도, Rust를 좋아하는 개발자 입장에서는 Rust가 지금보다 훨씬 폭넓은 범위에서 사용되었으면 하는 바램이 있다. 앞으로도 Rust를 써서 좋아하는 일을 할 수 있기를 기대하며 부족한 회고록을 마치도록 하겠다.