프로덕션에서의 Rust: 우리가 시그널 프로세서를 다시 작성한 이유
우리는 시그널 처리 엔진을 Go에서 Rust로 다시 작성했습니다. 그 이유와 배운 점, 그리고 달성한 4배의 처리량 향상을 소개합니다.
6개월 전, 우리는 원시 브라우저 시그널을 정규화된 해시 가능한 특징 벡터로 변환하는 컴포넌트인 시그널 처리 엔진을 Go에서 Rust로 다시 작성하기로 결정했습니다. 가볍게 내린 결정이 아니었습니다. 우리의 Go 구현은 잘 동작했습니다. 테스트를 거쳤고, 배포되어 있었습니다. 하지만 그것이 파이프라인의 병목이 되었고, 우리는 처리량에서 계단식 도약이 필요했습니다. 무슨 일이 있었는지 소개합니다.
Go의 한계에 다다른 이유
우리의 시그널 프로세서는 계산 집약적인 작업을 수행합니다. JSON 페이로드를 파싱하고, 130개 이상의 시그널에 정규화 함수를 적용하며, 독자적인 해시를 계산하고, 식별 해시 벡터를 구축합니다. Go에서는 이 작업이 CPU 바운드였고, 규모가 커지자 Go의 가비지 컬렉터가 문제가 되었습니다. 시그널 처리 사이클마다 파싱된 JSON 노드, 정규화된 문자열 값, 해시 버퍼 같은 중간 객체가 할당되어 GC 압박을 유발했습니다.
초당 3만 이벤트에서 우리의 Go 시그널 프로세서는 몇 초마다 25ms의 GC 일시 정지를 보였습니다. 이 정도 정지는 허용 가능했습니다. 초당 5만 이벤트에서는 GC 일시 정지가 815ms로 늘어났고 더 자주 발생했습니다. 3분기에 예상되는 부하인 초당 8만 이벤트에서는 GC 일시 정지로 인해 p99 지연 시간이 우리의 SLA를 초과했을 것입니다. 우리에게는 더 많은 서버(비용이 큼)이거나 더 효율적인 구현이 필요했습니다.
Rust를 선택한 이유
우리는 세 가지 선택지를 평가했습니다. Go 구현 최적화(sync.Pool, 아레나 할당, GOGC 튜닝), C++로의 재작성, 그리고 Rust로의 재작성이었습니다. Go 최적화는 30%의 향상을 냈지만 GC 문제를 근본적으로 해결하지는 못했습니다. C++는 보안이 중요한 시스템에서의 메모리 안전성 우려로 인해 배제되었습니다. Rust는 제로 비용 추상화, 가비지 컬렉터 부재, 그리고 컴파일 타임에 강제되는 메모리 안전성 보장을 제공했습니다.
Rust 생태계에는 우리가 필요로 하는 모든 것에 대한 성숙한 라이브러리도 있었습니다. JSON 파싱을 위한 serde, 고성능 해싱 크레이트, 그리고 비동기 I/O를 위한 tokio입니다. 학습 곡선은 실재했습니다. 우리 팀은 Go 경험은 깊었지만 Rust 경험은 제한적이었습니다. 하지만 그 성능 특성은 정확히 우리가 필요로 하던 것이었습니다.
재작성 과정
우리는 시그널 프로세서를 파이프라인의 나머지 부분과 gRPC를 통해 통신하는 독립형 서비스로 다시 작성했습니다. 이를 통해 Go 구현과 나란히 배포하면서 트래픽을 점진적으로 옮길 수 있었습니다. 재작성에는 엔지니어 3명이 4주가 걸렸습니다. 핵심 구현에 2주, 테스트, 벤치마킹, 엣지 케이스 처리에 2주였습니다.
가장 어려운 부분은 언어 자체가 아니라 Go 구현과의 동작 동등성을 보장하는 것이었습니다. 우리는 두 구현을 동일한 입력에 대해 실행하고 동일한 출력을 내는지 검증하는 비교 하네스를 구축했습니다. 이 과정에서 14개의 미묘한 차이를 발견했는데, 대부분 부동소수점 처리, 유니코드 정규화, JSON 파싱 엣지 케이스와 관련된 것이었습니다.
성능 결과
Rust 구현은 시그널을 Go의 3.2ms 대비 평균 0.8ms에 처리합니다. 4배의 향상입니다. 동일한 워크로드에서 메모리 사용량은 2.1GB에서 340MB로 줄었습니다. 가비지 컬렉터가 없으므로 GC 일시 정지도 없습니다. 동일한 처리량에서 CPU 사용률은 60% 감소했으며, 이는 각 서버가 4배 더 많은 트래픽을 처리한다는 의미입니다.
초당 8만 이벤트에서 Rust 구현은 일시 정지 없이 p99 처리 시간 1.4ms를 유지합니다. 이 여유 덕분에 우리는 당분간 시그널 처리 성능을 다시 들여다볼 필요가 없습니다. CPU와 메모리 사용량 감소는 또한 곧바로 인프라 비용 절감으로 이어졌습니다. 우리는 시그널 처리 서버 12대 중 8대를 폐기했습니다.
배운 점
Rust로의 재작성은 우리의 특정 사례, 즉 CPU 바운드이고 할당이 많으며 지연 시간에 민감한 워크로드에 대해서는 그만한 가치가 있었습니다. 우리는 HTTP 수집 계층이나 ClickHouse 쿼리 서비스는 Rust로 다시 작성하지 않을 것입니다. 그 컴포넌트들은 I/O 바운드이고 Go가 효율적으로 처리하기 때문입니다. 교훈은 "모든 것을 Rust로 다시 작성하라"가 아니라 "제로 비용 추상화와 결정론적 성능이 가장 중요한 곳에 Rust를 사용하라"입니다.
가장 놀라웠던 점은 재작성 과정에서 Rust 컴파일러가 얼마나 많은 것을 잡아냈는가였습니다. 공유 버퍼에서의 레이스 컨디션, 해시 계산에서의 정수 오버플로, 잘못된 형식의 입력에 대한 범위 초과 접근 등 Go 구현에 잠재되어 있던 여러 버그가 Rust에서는 컴파일 타임 오류로 잡혔습니다. 컴파일러는 까다롭지만, 정확성으로 그 값어치를 합니다.
솔직히 첫 주는 고통스러웠습니다. Sarah는 화이트보드에 "borrow checker와의 싸움" 횟수를 기록했는데, 팀이 세기를 멈추기 전까지 47회에 이르렀습니다. 하지만 3주 차가 되자 컴파일된 코드는 그냥 잘 동작했습니다. 정체불명의 프로덕션 패닉도, 부하 상황에서의 데이터 레이스도 없었습니다. 핫 패스에 있는 어떤 것에든 그 절충은 값어치가 있습니다.