피드 핸들러를 Rust로 재작성하지 않은 이유

ko생성일: 2025. 10. 13.갱신일: 2025. 10. 13.

핀테크에서 Rust의 부상에도 불구하고 Databento가 실시간 피드 핸들러 재작성에 C++23을 선택한 기술적 이유를 설명합니다. 버퍼 재사용, 자기 참조 구조체, 컴파일 타임 제네릭에서 Rust의 소유권·수명 모델이 만든 제약을 사례로 살펴보고, C++의 장점과 향후 전망을 논의합니다.

우리는 최근 흥미로운 결정을 마주했습니다. 마켓 데이터 피드 핸들러를 어떤 언어로 다시 쓸 것인가? 핀테크에서 Rust의 인기가 높아지고 있고, 우리가 다른 시스템에서 Rust를 성공적으로 사용해 왔음에도, 우리는 C++을 선택했습니다. 이 글은 그 선택의 기술적 이유를 설명하며, Rust의 엄격한 소유권 모델이 우리의 사용 사례에서 마찰을 일으킨 구체적 패턴들을 파고듭니다.

배경

우리의 실시간 마켓 데이터 아키텍처는 초당 1,400만 메시지를 처리하며, 지연 시간은 100마이크로초 미만을 요구합니다. 그 중심에는 거래소의 독자 피드를 파싱해 우리 DBN 포맷으로 정규화하는 코로케이션(동일 위치 배치)된 애플리케이션, 피드 핸들러가 있습니다.

기존 구현에는 시간이 흐르며 기술적 부채가 쌓였습니다. 실제로 사용하지 않는 워크플로까지 지원하느라 과도하게 제네릭했고, 아키텍처가 충분히 모듈식이 아니어서 최적화가 침습적이고 어렵게 되었습니다. 가장 치명적으로는 복잡한 동시성 모델 때문에 컨텍스트 스위칭이 많았고, 공유 상태가 풍부해 특정 지점의 스파이크가 애플리케이션 전반으로 연쇄되는 락 경합을 초래했습니다.

이미지 1: 슬라이드 3 37181c2a25 jpg

재작성의 목표

아키텍처에 내재된 이러한 문제들을 고려할 때, 재작성이 최선이라고 판단했습니다. 새로운 구현은 다음을 충족해야 했습니다.

  • 미리 최적화하지 않으면서도 공유 상태를 최소화하고 기본적인 병렬성만 사용하는 단순한 구조를 유지할 것
  • 범위를 좁게 잡고, 가능한 한 많은 작업을 다른 서비스로 위임할 것(예: OHLCV 바 계산 등)
  • 초당 1,400만 메시지 처리량과 예측 가능한 100μs 미만의 지연을 안정적으로 달성할 만큼 충분히 빠를 것

네이티브 코드로 컴파일되는 언어는 필수 조건이었습니다.

Rust가 매력적으로 보였던 이유

우리는 Rust를 처음부터 시작한 것이 아닙니다. Databento에서는 여러 핵심 시스템에 Rust를 성공적으로 도입했습니다.

Rust에는 설득력 있는 장점이 있습니다. 내장 도구인 cargo 덕분에 그린필드 프로젝트의 시작이 훨씬 수월합니다. 간단한 빌드 시스템, 통합 의존성 관리, 테스트 하네스, 주석 기반 문서 생성까지 제공합니다. 이는 C++와 뚜렷이 대비됩니다. 예를 들어 CMake를 다루는 건 늘 상수 같은 좌절이라 대부분은 다시는 쓰고 싶지 않을 정도니까요.

Clang과 GCC가 많은 발전을 이루었지만, Rust의 컴파일러 에러 메시지는 여전히 한 수 위입니다. 특히 언어를 처음 배울 때와 소유권 모델을 다룰 때 큰 도움이 됩니다. 때로는 수정 제안까지 해주며, 이는 개발 속도를 끌어올립니다.

금융 애플리케이션에서는 Rust의 안전성과 정합성을 위한 설계가 특히 매력적입니다. 초기화되지 않은 데이터, 수명(lifetime) 오류, 데이터 레이스 등 전체 버그 클래스를 원천적으로 방지합니다.

Rust 컴파일러와 맞붙었던 지점들

과거에 우리가 Rust 컴파일러와 씨름했던 지점이 세 군데 있었습니다. 이 경험이 피드 핸들러의 언어 선택에 영향을 미쳤습니다.

Rust 컴파일러는 엄격하기로 유명합니다. 종종 더 낫고 더 정확한 해법으로 안내해 주기도 합니다. 하지만 소유권 모델은 어디까지나 "모델"입니다. 모든 안전한 패턴을 이해할 수는 없습니다.

때로는 모델과 맞지 않는 안전한 패턴을 거부하기도 합니다. Rust가 설계를 어렵게 만들거나 아예 선호하는 설계를 막았던 구체적 사례를 살펴보겠습니다.

사례 1: 버퍼 재사용

대량의 데이터를 읽을 때 흔한 최적화는 버퍼 재사용입니다. 데이터를 메모리의 한 위치에 읽고 처리한 뒤, 다음 청크를 같은 메모리 위치로 읽어 할당을 줄이는 방식입니다. Rust에서 복사를 없애려고 참조를 전달하기 시작하면 수명(lifetime) 문제가 따라옵니다. 안타깝게도, 이 둘이 Rust에서 만나면 충돌할 수 있습니다.

이미지 2: 슬라이드 12 b83ecef41b jpg

간단한 최적화로, 버퍼 할당을 루프 바깥으로 옮겨 반복 횟수 전반에 재사용한다고 가정해 봅시다.

이미지 3: 슬라이드 13 5aa16365b5 jpg

이는 컴파일되지 않습니다. Rust는 use-after-free(해제 후 사용) 버그를 막기 위해 참조의 수명을 추적합니다. 지역 변수 data는 for 루프 스코프 안에서 생성되어 buffer보다 짧은 수명을 가지므로, Rust는 이를 거부합니다. 우리는 매 반복마다 버퍼를 항상 비우기 때문에 버퍼의 내용이 반복의 스코프를 절대 넘지 않는다는 사실을 borrow 검사기가 충분히 정교하게 이해하지 못합니다.

이미지 4: 슬라이드 14 749fbabfb7 jpg

C++에서는 동등한 코드가 잘 컴파일됩니다. 대가로, 합법적인 use-after-free 버그를 컴파일러가 잡아주지 않기 때문에 참조의 수명을 직접 관리해야 합니다.

사례 2: 자기 참조 구조체

또 다른 흔한 패턴은 클래스의 여러 하위 컴포넌트가 상태를 공유하는 것입니다. 보통 클래스 자체가 상태를 소유하고, 하위 컴포넌트는 그 상태에 대한 참조를 보유합니다. 이 기법은 로직을 모듈화하여, 주된 클래스를 더 쉽게 이해할 수 있도록 도와줍니다.

Rust에서는 이를 자기 참조 구조체(self-referential struct)라고 하며, comp 필드가 cache 필드를 참조합니다. 이 패턴은 Rust의 대여(borrow) 모델에서 잘 작동하지 않습니다.

이미지 5: 슬라이드 16 18899dfbf5 jpg

캐시를 박싱(Box)해 메모리 위치를 고정시키더라도(Rust에는 이동 생성자가 없습니다), 컴파일러는 여전히 이를 지역 변수로 간주해 이를 참조하는 값을 반환하는 것을 막습니다.

이미지 6: 슬라이드 17 0800b3aa45 jpg

Rust에서의 우회 방법은 참조 카운팅 포인터를 사용하는 것(오버헤드 추가)이나, 캐시를 필요한 모든 메서드에 인자로 전달하는 것(항상 이상적이진 않음)입니다.

C++에서는 _cache_comp보다 먼저 선언해 먼저 초기화되도록 하고, 컴포넌트를 캐시 멤버 변수에 대한 참조로 초기화하면 됩니다.

이미지 7: 슬라이드 18 9abc8545b5 jpg

포인터 뒤에 캐시를 숨길 필요 없이 잘 컴파일됩니다. 다만 이를 제대로 만들려면 Rule of Five(다섯 가지 규칙)를 따라 이동/복사 생성자 및 연산자를 삭제하거나 정의해, 객체가 이동되었을 때 컴포넌트가 댕글링 참조를 갖지 않도록 해야 합니다.

사례 3: 컴파일 타임 제네릭

컴파일 타임 제네릭은 여러 타입에 걸쳐 재사용 가능한 코드를 작성하게 해주는 핵심 도구입니다. C++ 템플릿은 부분 특수화와 폴드 표현식 등으로 매우 유연하며, constexpr로 컴파일 타임 로직까지 확장됩니다. 단점은 에러 메시지입니다. 템플릿의 애드혹 특성은 에러 또한 구조가 부족함을 의미합니다.

반면 Rust의 제네릭은 트레이트(인터페이스와 유사)에 기반해, 좋은 에러 메시지를 가진 제약된 제네릭을 제공합니다. 그러나 컴파일 타임 로직과 const 제네릭 측면은 여전히 C++에 비해 미성숙합니다.

이미지 8: 슬라이드 20 05ac024aef jpg

우리가 컴파일 타임 제네릭을 사용하는 한 가지는 버전 구조체입니다. Databento의 많은 코드(피드 핸들러 포함)는 정규화가 발전함에 따라, 그리고 시간이 지나며 변경되는 거래소 프로토콜을 다룰 때 구조체의 여러 버전을 지원해야 합니다. 보통 새로운 버전은 기존 필드를 유지하면서 하나 이상의 필드를 추가합니다.

이미지 9: 슬라이드 21 e20c9bfa45 jpg

이를 두 언어에서 어떻게 처리하는지 보여 드리겠습니다.

이미지 10: 슬라이드 22 96c2a3f552 jpg

C++에서는 인터페이스를 정의할 필요가 없습니다. 템플릿은 애드혹하기 때문입니다. is_same_v 같은 가독성 좋은 체크로 새 필드를 인라인으로 처리할 수 있습니다. 인터페이스는 암묵적이며, 단순한 구조에서는 문제가 없고 꽤 읽기 쉽습니다.

Rust에서는 많은 보일러플레이트가 붙는 트레이트 정의가 필요합니다. 필드가 두어 개이고 버전이 두 가지 정도면 감당할 만하지만, 필드가 수십 개에 버전도 여러 개가 되면 반복적인 보일러플레이트가 폭발합니다. 우리는 매크로와 코드 생성으로 이를 줄였지만, 사실상 맞춤형 템플릿을 재현하는 셈이고, 그런 기능은 이미 C++에 기본으로 들어가 있습니다.

결정

이러한 Rust에 대한 좌절감 때문에 피드 핸들러 재작성에는 C++—구체적으로 C++23—을 선택했습니다. 그렇다고 해서 앞으로 Rust를 배제하는 것은 아닙니다. 새로운 컴포넌트와 애플리케이션은 케이스 바이 케이스로 평가할 것이며, Rust와 C++가 각자의 영역이 있다고 믿습니다.

기술적 불만 외에도 C++는 여러 장점을 제공했습니다.

  • 기존 구현으로부터의 코드 재사용: 재작성 속도 가속
  • 리소스 공유와 사용 방식에 대한 더 많은 제어
  • 유연성: 템플릿은 코드 중복 제거를 위한 다양한 패턴을 제공
  • 팀 전문성: 모두 Rust 개발 경험이 있지만, 집합적으로는 C++ 경험이 훨씬 더 많음

이 익숙함 덕분에 모두가 새로운 피드 핸들러에서 즉시 생산성을 낼 수 있었습니다.

C++가 여전히 빛나는 부분

최대한의 제어가 필요한 영역에서 C++는 계속 돋보입니다. 보다 관대한 컴파일러는 개발을 방해하지 않습니다. 이는 잘 정립된 패턴이 있고, Rust의 소유권 모델로는 잘 옮겨지지 않는 성능 고려사항이 있는 영역에서 이점이 됩니다.

C++의 안전성은 Rust 수준에는 못 미치지만, sanitizer와 clang-tidy 같은 도구 덕분에 과거보다 훨씬 많은 메모리/스레딩 이슈를 잡아낼 수 있게 되었습니다.

기존 C/C++ 코드베이스를 다룰 때 C++를 이어 가면 코드 재사용이 가능하고, 빌드와 온보딩 복잡도를 줄일 수 있습니다.

앞으로의 전망

Rust와 C++ 논쟁은 두 언어 모두 계속 진화함에 따라 정적이지 않습니다. C++26은 컴파일 타임 리플렉션을 추가해, 프로그램이 컴파일 타임에 타입의 구조를 조사할 수 있게 합니다. 이는 또 다른 형태의 코드 중복 제거를 가능케 합니다.

Rust도 지속적으로 발전하고 있습니다. 우리가 겪은 문제와 가장 관련이 큰 변화로는, 현재보다 더 관대한 새로운 borrow 검사기 Polonius에 대한 작업이 진행 중입니다. 오늘날의 borrow 검사기가 거부하는 더 많은 안전한 대여 패턴을 허용합니다. 다만 우리의 테스트에서는, 현재 구현이 본문에서 보인 두 가지 수명 문제를 해결하지는 못했습니다.

결론

이 결정은 성능 벤치마크가 주도한 것이 아닙니다. 두 언어로 피드 핸들러를 모두 작성해 비교하지도 않았습니다. 여기 제시한 예시는 우리가 다른 Rust 애플리케이션에서 실제로 겪었던 이슈에서 가져왔습니다. 이는 선호도의 문제이자, 이 특정 사용 사례에서는 C++가 더 전장에서 검증(battle-tested)됐다는 우리의 믿음에 가까웠습니다.

우리는 처음에 Rust를 격리된 영역에 실험적으로 도입했는데, 매우 잘 작동했습니다. 직렬화나 비동기 네트워킹 같은 영역은 Rust가 훨씬 수월합니다. C++에도 비동기 라이브러리가 있지만 언어 차원의 지원이 동일하지 않아, 구현도 이해도 모두 더 어렵습니다.

핵심은 어느 한 언어가 우월하다는 것이 아닙니다. 현대 핀테크 스택에서 Rust와 C++는 각각의 자리가 있습니다. Rust의 안전 보장과 현대적 도구는 많은 애플리케이션에서 탁월합니다. 그러나 메모리 배치와 리소스 공유를 최대한 통제해야 하고, 확립된 패턴이 Rust의 소유권 모델에 깔끔히 대응되지 않으며, 상당한 C++ 코드와 전문성이 이미 있는 경우, C++는 여전히 매력적인 선택입니다.

우리는 앞으로도 Databento에서 두 언어를 모두 사용할 것이며, 작업마다 최적의 도구를 선택할 것입니다. 그리고 두 언어가 진화함에 따라, C++는 더 나은 안전 기능을 추가하고 Rust는 Polonius 같은 프로젝트를 통해 일부 제약을 완화하면서, 지형도는 계속 변할 것입니다. 핵심은 유행을 따르기보다, 자신의 구체적인 요구사항에 기반해 실용적인 결정을 내리는 것입니다.

이 글은 2025년 10월 Databento 시카고 퀀트 밋업에서 발표한 강연을 바탕으로 작성되었습니다.