Go에서 Rust로 마이그레이션하기

3 days ago 8
  • Go에서 Rust로의 전환은 속도 향상보다 nil, 오류 처리, 데이터 레이스, 리소스 수명 문제를 컴파일 타임 보장으로 옮기는 선택에 가까움
  • Go는 빠른 컴파일, 단순한 goroutine, 강한 백엔드 생태계가 장점이지만 Rust는 Option, Result, Send/Sync로 더 많은 실수를 타입 시스템에서 막음
  • Rust의 빌림 검사기와 async/await는 학습 곡선과 사용성 비용을 만들며, 컴파일 시간도 Go보다 확실한 퇴보로 받아들여야 함
  • 전환은 전체 재작성보다 핫패스 서비스, 워커, 게이트웨이 뒤의 일부 엔드포인트처럼 경계가 명확한 컴포넌트부터 적용하는 전략이 적합함
  • 기대 효과는 CPU 20~60% 감소, 메모리 30~50% 감소, 더 평평한 P99 지연 시간, nil 역참조와 레이스성 장애 감소로 요약됨

전환의 초점

  • Go에서 Rust로의 전환은 “Rust가 더 빠른가”보다 정확성 보장, 런타임 절충, 개발자 경험의 차이를 따지는 문제에 가까움
  • 비교의 중심은 백엔드 서비스이며, Go가 강한 작은 정적 바이너리, 네트워킹 중심 표준 라이브러리, HTTP 서버·gRPC·데이터베이스 생태계를 기준으로 삼음
  • CLI 도구, 임베디드 펌웨어, 게임 엔진에도 일부 내용이 적용될 수 있지만 최적화된 대상은 아님
  • 관련 배경 자료로 2017년 “Go vs Rust? Choose Go.”와 Shuttle 팀의 “Rust vs Go: A Hands-On Comparison”이 제시됨
  • Go는 성공한 언어지만 nil의 광범위한 사용, 타입이 아닌 규율에 의존하는 오류 처리, 오랫동안 없었던 제네릭 같은 설계 선택은 Rust와 비교할 때 주요 쟁점이 됨
  • JetBrains Developer Ecosystem Survey에서 Go는 작업 개발자 비중이 17~19% 수준을 유지하는 언어로 제시되고, Rust는 꾸준히 성장하지만 더 작은 비중으로 나타남

도구 체계

  • Go와 Rust는 모두 빌드, 테스트, 포맷, 린트, 의존성 관리를 일관된 인터페이스로 제공하는 배터리 포함 도구 체계를 갖춤
  • cargo는 Go 도구와 대응되는 기능을 1차 도구로 더 넓게 제공함
    • go.mod / go.sum → Cargo.toml / Cargo.lock: 프로젝트 설정과 의존성 매니페스트
    • go get / go mod tidy → cargo add / cargo update: 의존성 추가와 해석
    • go build → cargo build: 컴파일
    • go run . → cargo run: 빌드 후 실행
    • go test ./... → cargo test: 테스트
    • go vet ./... → cargo clippy: 린터이며, Clippy는 vet보다 훨씬 더 의견이 강함
    • gofmt / goimports → cargo fmt: 무설정 자동 포매터
    • golangci-lint run → cargo clippy -- -D warnings: 엄격한 린트 모드
    • go doc → cargo doc --open: API 문서 생성과 열람
    • pprof → cargo flamegraph / samply: CPU 프로파일링
    • govulncheck → cargo audit: 권고 데이터베이스 기반 취약점 검사
  • Go에서는 golangci-lint, mockgen, air, goreleaser 같은 서드파티 도구로 빈틈을 메우는 경우가 많지만, Rust는 1차 생태계가 더 많은 기능을 기본으로 포괄함
  • 외부 크레이트가 필요한 경우에도 cargo watch, cargo nextest처럼 cargo install cargo-nextest 한 번으로 설치되고 cargo nextest처럼 네이티브 도구처럼 동작함
  • gofmt와 rustfmt는 세부 스타일 취향보다 코드 리뷰의 스타일 논쟁을 없애는 이점이 더 큼
    • Rob Pike의 Go Proverbs 인용: “Gofmt’s style is no one’s favorite, yet gofmt is everyone’s favorite.”

Go와 Rust의 핵심 차이

  • 두 언어는 모두 컴파일 언어, 정적 타입, 단일 바이너리 배포, 강한 동시성 모델을 제공하지만 차이는 컴파일러가 보장하는 범위와 런타임 동작 제어 수준에 있음
  • 주요 비교 항목은 다음과 같음
    • 안정 릴리스: Go 2012년, Rust 2015년
    • 타입 시스템: Go는 정적·구조적 타입이며 1.18부터 제네릭 지원, Rust는 정적·명목적 타입이며 제네릭·트레이트·수명 지원
    • 메모리 관리: Go는 동시·저지연 가비지 컬렉션, Rust는 소유권과 대여 기반이며 GC 없음
    • 널 안전성: Go는 nil이 널리 존재, Rust는 널이 없고 Option<T>가 타입 수준 대체재
    • 오류 처리: Go는 error 인터페이스와 if err != nil { ... }, Rust는 Result<T, E>, ? 연산자, 완전한 패턴 매칭
    • 동시성: Go는 goroutine과 채널 기반 CSP, Rust는 tokio 위의 async/await, 채널, 스레드
    • 취소: Go는 관례 기반 context.Context, Rust는 CancellationToken 등 명시적이고 타입 검사되는 전달
    • 데이터 레이스: Go는 -race로 런타임에 확률적으로 탐지, Rust는 Send/Sync로 컴파일 타임에 탐지
    • 컴파일 시간: Go는 매우 빠르고, Rust는 특히 클린 빌드가 느림
    • 런타임: Go는 약 2MB 런타임과 GC, Rust는 libc 외 런타임이 없거나 MUSL로 완전 정적 빌드 가능
    • 생태계 규모: Go는 약 75만+ 모듈, Rust는 25만+ 크레이트
  • Go에서 관례, 도구, 런타임 탐지에 의존하던 nil 처리, 오류 전파, 데이터 레이스, 리소스 수명, 취소, 제네릭 같은 검사가 Rust에서는 타입 시스템 안으로 들어감
  • Rust의 Mutex<T>는 .lock()으로 얻은 가드를 통해서만 내부 값에 접근하게 만들어, “락을 잊는 경로” 자체를 타입에서 제거함
  • 같은 패턴이 Option, Result, &mut T, Send/Sync, RAII 가드 전반에 반복되며, 익숙해지면 컴파일러가 머릿속 점검을 대신하는 형태가 됨

Rust를 검토하게 만드는 Go의 한계

  • Go가 대부분의 백엔드 워크로드에서 충분히 빠르기 때문에, Rust 검토의 주된 이유는 속도보다 오류 처리의 장황함, nil 포인터 위험, enum·trait 같은 정교한 타입 시스템 기능 부족에 가까움
  • Go 인터페이스는 Rust 트레이트의 충분한 대체물이 아니며, 표준 라이브러리에 Set 타입이 없어 map[T]struct{} 같은 관용적 우회가 필요함
  • 프로덕션의 nil 패닉

    • Go 서비스는 몇 달간 정상 동작하다가 특정 코드 경로에서 포인터 nil 확인을 놓쳐 goroutine 패닉을 낼 수 있음
    • 예시에서는 Find가 (*User, error)를 반환하고 “not found”에서 error는 nil이지만 user 확인은 호출자 몫으로 남음
    • user.Account.Notify()는 user 또는 Account가 nil일 때 크래시될 수 있음
    • nilaway, staticcheck 같은 린터와 IDE 검사는 일부를 잡지만, 옵트인이고 확률적이며 패키지 경계를 안정적으로 넘지 못함
    • Rust의 Option<T>는 None 경우를 처리하지 않고는 역참조할 수 없게 해 이 범주의 장애를 제거함
  • -race가 잡지 못한 데이터 레이스

    • go test -race는 훌륭한 도구지만 런타임 탐지기이므로 테스트 중 실제 실행된 레이스만 찾음
    • Go에서는 두 goroutine이 락 없이 map을 변경하는 코드도 컴파일되고, 부하가 걸린 프로덕션에서 터질 수 있음
    • Rust에서는 스레드 간 가변 상태 공유에 Send와 Sync 구현 타입이 필요하며, 평범한 HashMap을 스레드 간 공유하려 하면 컴파일되지 않음
    • Arc<Mutex<...>>, Arc<RwLock<...>>, 채널 중 하나를 사용하도록 강제되며, 레이스 조건이 타입 오류가 됨
    • Paul Dix는 InfluxDB 3.0 재작성 동기로 데이터 레이스 제거를 직접 언급함
      • “[The main benefit is] fearless concurrency — eliminating data races essentially, which we had before. Really gnarly bugs in version 1 of Influx due to that.”
      • 출처: Paul Dix, Founder & CTO, InfluxData, Rust in Production
  • 조합 가능한 오류 처리

    • Go의 if err != nil { return err }는 함수의 실제 로직을 희석할 수 있고, fmt.Errorf("doing X: %w", err)로 맥락을 감싸는 일은 컴파일러 규칙이 아닌 규율에 의존함
    • Lobste.rs 스레드에서는 숙련된 Go 개발자들이 errcheck와 golangci-lint가 오류 처리 누락 대부분을 잡고, 명시적 if err != nil이 조밀한 ? 체인보다 읽기 쉽다고 반박함
    • Peter Bourgon은 Go의 명시적 오류 처리를 의도된 문화적 가치로 제시함
      • “I think that error handling should be explicit, this should be a core value of the language.”
      • 출처: Peter Bourgon, GoTime #91, Dave Cheney의 Zen of Go에 인용
    • Rust의 Result<T, E>는 타입 시그니처 자체라서 잊을 수 없고, thiserror::Error로 정의한 enum과 #[from]을 통해 오류 변환과 완전성 검사를 받음
    • 새 오류 variant를 추가하면 컴파일러가 갱신이 필요한 match 위치를 알려줌
  • 박싱하지 않는 제네릭

    • Go 1.18 제네릭은 유용하지만, 타입 파라미터가 있는 메서드 부재, GC shape stenciling, 가끔 놀라운 성능 특성 같은 제약이 있음
    • Rust 제네릭은 단형화되어 각 인스턴스화가 특수화된 코드를 만들고 런타임 비용이 없음
    • 트레이트와 결합하면 제로 비용 추상화가 가능함
    • 핸들러 코드보다는 미들웨어, generic repository, decoder, parser 같은 공유 인프라에서 더 중요하며, Go는 이런 영역에서 interface{}/any와 타입 단언으로 돌아가는 경우가 많음
  • 예측 가능한 지연 시간

    • Go의 GC는 우수하고 동시적이며 저지연이고 일반적인 서비스 워크로드에 잘 튜닝되어 있지만, “low-pause”는 “no-pause”가 아님
    • 할당이 많은 상황에서는 핫패스에서 할당하지 않는 Rust 구현보다 P99 지연 시간 꼬리가 나빠질 수 있음
    • 트레이딩, 실시간 입찰, 네트워크 프록시, 고처리량 수집처럼 지연 시간에 민감한 시스템에서는 GC pause 부재가 실제 장점임
    • Stephen Blum은 PubNub 규모에서 필요한 가격 대비 성능 용량을 얻기 위해 Rust가 필요하다고 말함
      • “Go is great at our scale, but we really need something that is going to give us the price-per-dollar performance capacity that we need, and Rust is going to get us there. That’s why basically everything is heading towards Rust these days.”
      • 출처: Stephen Blum, CTO, PubNub, Rust in Production

Go 패턴의 Rust 대응

  • Rust에 익숙해지는 빠른 방법은 이미 아는 Go 패턴을 Rust의 대응 패턴에 매핑하는 것임
  • 동일한 백엔드 서비스를 두 언어로 구현한 더 긴 예시는 Shuttle comparison에 있음
  • 오류 처리: if err != nil vs Result<T, E>

    • Go는 os.ReadFile(path)와 json.Unmarshal 뒤 if err != nil로 맥락을 감싼 오류를 반환함
    • Rust는 fs::read_to_string(path)?, serde_json::from_str(&data)?, Ok(cfg)로 구성됨
    • ? 연산자는 if err != nil { return err } 패턴을 대신하며, From<E1> for E2가 구현되어 있으면 타입 변환까지 처리함
    • thiserror의 #[from]은 이 변환을 관용적으로 지원함
  • 널: nil vs Option<T>

    • Go의 GetUser(id string) *User는 사용자를 찾지 못하면 nil을 반환하고, 호출자가 fmt.Println(u.Name)을 하면 nil일 때 패닉이 발생함
    • Rust의 get_user(id: &str) -> Option<User>는 Some(User) 또는 None을 반환함
    • let user = get_user("123"); println!("{}", user.name);는 user가 User가 아니라 Option<User>라서 컴파일 오류가 됨
    • match get_user("123")로 Some(u)와 None을 모두 처리해야 함
    • 안전한 Rust에는 nil이 없고, 참조는 null이 될 수 없음
  • 인터페이스 vs 트레이트

    • Go 인터페이스는 구조적이며, 타입이 인터페이스를 암묵적으로 만족함
    • Rust 트레이트는 명목적이며, 명시적으로 구현해야 함
    • Go 방식은 즉석 duck typing에 좋고, Rust 방식은 리팩터링과 discoverability에 좋으며 특정 trait 구현체를 grep으로 찾을 수 있음
    • fn handle<R: Reader>(r: R) 같은 trait bound가 붙은 generic function이 대부분의 경우를 커버하며, 단형화로 런타임 디스패치가 없음
    • 런타임 디스패치가 필요한 이질적 구현체 저장에는 Box<dyn Trait> 또는 Arc<dyn Trait>을 사용함
  • Goroutine vs async task

    • Go의 동시성 모델은 go doWork(ctx, input)처럼 단순하며, goroutine은 저렴하고 런타임이 OS 스레드 위에 스케줄링함
    • Go의 큰 장점은 순차 코드와 병렬 코드 사이에 문법적 구분이 없다는 점
    • Rust는 백엔드 서비스에서 거의 항상 tokio executor 위의 async/await를 사용함
    • async 함수는 Future를 반환하며, await되거나 spawn되기 전에는 실행되지 않음
    • 컴파일러가 .await 지점 전후의 Send/Sync를 추적하고, non-Send 값을 await 너머로 보유하면 컴파일 오류를 냄
    • goroutine식 내장 선점이 없어서 CPU 바운드 작업을 async task 안에서 오래 실행하면 executor가 굶주릴 수 있으며, tokio::task::spawn_blocking 또는 rayon으로 넘겨야 함
  • context.Context vs CancellationToken

    • Go에서는 모든 blocking call에 context.Context를 전달함
    • Rust에는 내장 context.Context가 없으며, 취소에 가장 가까운 대응물은 tokio_util::sync::CancellationToken임
    • timeout은 tokio::time::timeout(dur, fut)로 future를 감쌈
    • deadline과 값은 하나의 context 객체보다 명시적 인자나 tracing span을 통해 전달하는 경우가 많음
    • Dave Cheney의 The Zen of Go 인용:
      • “Go doesn’t have a way to tell a goroutine to exit. There is no stop or kill function, for good reason. If we cannot command a goroutine to stop, we must instead ask it, politely.”
    • Go에서는 이 “정중한 요청”이 관례적으로 전달되는 context.Context이고, Rust에서는 CancellationToken 또는 watch 채널이지만 컴파일러가 누락을 알려줄 수 있음
  • 문자열: string vs String과 &str

    • Go의 string은 UTF-8 byte slice이며, 대입 시 header가 복사되고 underlying bytes는 공유되는 불변 구조임
    • Rust는 이를 두 타입으로 나눔
      • String: 소유하고, heap에 할당되며, growable함
      • &str: 다른 문자열 데이터에 대한 borrowed view이며, 대부분의 경우 Go의 string parameter에 대응함
    • 경험칙은 인자에는 &str을 받고, 새 데이터를 만들 때는 String을 반환하는 것임
    • &str과 String의 분리는 Rust의 “borrow vs own” 모델을 축소해 보여줌

Go 제네릭에 대한 평가

  • Go는 1.18, 2022년 3월에 제네릭을 도입했으며, 언어 출시 13년 뒤였음
  • 제네릭은 유용하지만 Rust·Haskell·현대 C++에서 기대하는 장점을 충분히 주지 못하고, 제네릭 타입 시스템의 단점 상당수를 함께 가진 것으로 평가됨
  • 표준 라이브러리가 거의 쓰지 않음

    • 제네릭 도입 3년 뒤에도 Go 표준 라이브러리는 대부분 제네릭을 피함
    • sort.Slice는 여전히 cmp.Ordered constraint 대신 func(i, j int) bool closure를 받음
    • sync.Map은 여전히 any/any로 타입화됨
    • 존재하는 generic helper는 slices, maps, cmp, sync 아래 일부 항목 같은 소수 패키지에 있음
    • Go 1 호환성 약속 때문에 기존 non-generic API를 개조하기 어렵다는 점은 일부 설명이 되지만, Rust처럼 제네릭을 주된 도구로 쓰지는 않음
    • Rust는 초기부터 Option<T>, Result<T, E>, Vec<T>, HashMap<K, V>, Iterator, From/Into, 모든 컬렉션과 스마트 포인터에 제네릭이 스며 있음
  • 트레이트 시스템이 없고 구조적 constraint만 있음

    • Rust 제네릭은 ad-hoc 다형성, supertrait, associated type, blanket impl, coherence를 담당하는 trait와 묶여 있음
    • Go constraint는 type-set membership을 위한 ~ 연산자가 추가된 interface에 가까움
    • Go에는 Rust의 trait Ord: Eq + PartialOrd 같은 supertrait hierarchy, Iterator의 type Item; 같은 associated type, impl<T: Display> ToString for T 같은 blanket impl이 없음
    • Go에서는 타입 파라미터를 가진 메서드를 쓸 수 없어 func (s Set[T]) Map[U](<https://corrode.dev/learn/migration-guides/go-to-rust/f func(T>) U) Set[U] 같은 형태가 불가능함
    • 추상화가 “몇 가지 연산을 가진 임의의 T에 대해 동작하는 함수”를 넘는 순간, Go는 any, 타입 단언, 코드 생성, 런타임 reflection으로 돌아가게 됨
  • 타입 추론과 구현 전략의 차이

    • Rust는 closure, iterator chain, ? 연산자를 포함한 전체 expression에 타입 정보를 전파함
    • Go의 추론은 더 얕고, 보통 함수 인자에서 type parameter를 추론하지만 return-position context에서는 추론할 수 없고, 호출 지점에서 명시적 type argument를 자주 요구함
    • Go는 GCShape stenciling and dictionaries라는 중간 경로를 택해 빠른 컴파일 시간을 유지하지만, type parameter의 메서드 호출마다 indirection이 들어갈 수 있음
    • 이를 보인 자료로 PlanetScale 글이 제시됨
    • Rust는 Vec<i32>와 Vec<String> 각각에 특수화된 기계어 코드를 만들고 런타임 디스패치가 없음
    • 단형화의 대가는 컴파일 시간이며, 두 언어는 서로 다른 목표를 최적화함
  • 타입 시스템의 구멍을 메우지 못함

    • Rust에서는 제네릭과 트레이트가 Box<dyn Any>나 런타임 reflection이 필요할 상황 대부분을 제거함
    • Go 제네릭은 any, reflect, ORM·decoder·mock에서 지배적인 코드 생성 패턴을 제거하지 못함
    • encoding/json은 여전히 reflection을 사용하고, database/sql은 여전히 any를 사용하며, mockgen은 여전히 코드를 생성함
    • Go의 제네릭은 좁은 경우에 유용한 새 도구처럼 느껴지고, Rust의 제네릭은 제거하면 언어가 무너지는 기초처럼 작동함

Rust 백엔드 생태계

  • Rust 생태계도 일반적인 백엔드 서비스에 대해 “기본 선택지”가 어느 정도 수렴해 있음
  • 대표적인 대응 관계:
    • HTTP server: Go net/http, chi, gin, echo, fiber → Rust axum on hyper
    • HTTP client: Go net/http, resty → Rust reqwest
    • gRPC: Go google.golang.org/grpc + protoc-gen-go → Rust tonic + prost
    • SQL: Go database/sql, sqlc, sqlx, gorm → Rust sqlx, sea-orm, diesel
    • Migrations: Go golang-migrate, goose → Rust sqlx migrate, refinery
    • JSON: Go encoding/json, sonic, goccy/go-json → Rust serde + serde_json
    • Logging: Go log/slog, zerolog, zap → Rust tracing + tracing-subscriber
    • Metrics: Go prometheus/client_golang → Rust metrics + metrics-exporter-prometheus
    • Config: Go viper, koanf → Rust config / config-rs, figment
    • CLI: Go cobra, urfave/cli → Rust clap derive
    • Errors: Go errors, pkg/errors → Rust thiserror for libraries, anyhow for binaries
    • Testing: Go testing, testify, gomega → Rust built-in #[test], rstest, assert_matches
    • Mocking: Go mockgen, moq → Rust hand-written fakes가 관용적이며 mockall도 사용
    • Background tasks: Go goroutines + errgroup → Rust tokio::spawn + JoinSet
  • 전형적인 백엔드 서비스에서는 axum + sqlx + tokio + tracing + serde + clap 조합이 필요한 것의 90% 를 커버한다고 제시됨

빌림 검사기와 학습 곡선

  • Go에서 Rust로 오면 벽에 부딪히게 됨이라는 점을 전제로 삼아야 함
  • Go 런타임은 메모리와 별칭(aliasing)을 대신 처리하지만, Rust는 그 결정을 타입 시스템으로 옮기기 때문에 처음 몇 주 동안은 “당연히 동작해야 할” 코드가 컴파일러에 거부될 수 있음
  • Go 개발자가 자주 부딪히는 패턴:
    • 오래 사는 참조: Go에서는 맵에서 꺼낸 *User를 오래 들고 있어도 자연스럽지만, Rust에서는 그 빌림이 살아 있는 동안 맵 변경이 막힘
    • 자기 참조 구조체: Go에서는 데이터와 그 데이터 위의 반복자를 같은 구조체에 담을 수 있지만, Rust에서는 Pin, ouroboros, 또는 재설계가 필요함
    • goroutine 사이의 가변 상태 공유: Go의 mu sync.Mutex; data map[K]V 패턴은 Rust에서 Arc<Mutex<HashMap<K, V>>> 형태가 됨
    • 함수에서 참조 반환: 수명 주석이 등장하며, Go 개발자에게는 새로운 개념임
  • 빌림 검사기는 방해하는 “문지기”가 아니라 실제로 존재하는 버그를 드러내는 장치로 봐야 함
  • 값이 이동된 뒤 다시 사용되거나, 여러 스레드가 같은 데이터를 동시에 만지거나, null·댕글링 포인터를 역참조하거나, 값보다 참조가 오래 사는 경우를 컴파일 시간에 걸러냄
  • 빌림 개념을 내면화하면 싸우는 대상이 아니라 협력자로 바뀌며, 숙련 Rust 개발자들은 보통 4~12주 사이에 빌림 검사기가 조력자가 됐다고 말함
  • PubNub CTO Stephen Blum은 Rustacean Station에서 첫 달을 “프로그래밍을 처음 배울 때 같았다”고 표현하며, 빌림 검사기와 수명을 강제로 다뤄야 했다고 말함
  • clap 메인테이너 Ed Page는 Rustacean Station: clap with Ed Page에서 빌림 검사기가 고수준 문제에 집중할 수 있게 했고, 직접 분석에 실패한 부분도 잡아줬다고 말함

Rust 전환의 주요 난관

  • 컴파일 시간

    • Rust 컴파일 시간은 Go보다 확실한 퇴보로 받아들여야 하며, 중간 규모 서비스의 클린 릴리스 빌드는 Go의 거의 즉각적인 컴파일과 달리 몇 분이 걸릴 수 있음
    • 증분 빌드와 cargo check는 합리적이고 컴파일 시간도 해마다 개선됐지만, Go와의 차이는 체감됨
    • 편집 루프에서는 cargo check를 사용하고, 이득이 생기는 시점에 워크스페이스로 분리하며, 프로시저 매크로가 많은 크레이트는 별도 크레이트로 유지해 변경될 때만 다시 컴파일되게 함
    • 더 자세한 내용은 Rust 컴파일 시간을 줄이는 팁을 참고할 수 있음
  • 비동기 색칠 문제

    • Rust의 async fn / fn 분리는 Go에서 넘어올 때 가장 큰 사용성 퇴행 중 하나임
    • async trait은 Rust 1.75부터 안정화됐지만, 동적 디스패치와 섞을 때 여전히 거친 부분이 있음
    • 일부 상황에서는 이런 부분을 덮기 위해 async-trait 크레이트를 쓰게 됨
  • 더 작은 생태계

    • Rust 크레이트 생태계는 성장 중이고 라이브러리 품질도 전반적으로 높지만, Go는 일부 백엔드 인접 영역에서 앞서 있음
    • Go가 앞선 영역에는 Kubernetes 오퍼레이터, 클라우드 제공자 SDK, 특정 틈새 저장소용 데이터베이스 드라이버가 포함됨
    • 마이그레이션을 확정하기 전에 하루 정도 시간을 들여 의존하는 라이브러리에 사용할 만한 Rust 대안이 있는지 확인해야 함
    • 일부 팀은 방치된 XML 스키마 검증 크레이트를 업데이트하거나 덜 알려진 프로토콜의 클라이언트를 직접 작성해야 할 수 있음

통합 전략

  • Go에서 Rust로의 성공적인 전환은 한 번에 모두 다시 쓰는 방식이 아니라 전술적 선택에 가까움
  • Microsoft Principal Engineer Victor Ciura는 Rust in Production에서 “전부를 재미로 Rust로 다시 쓰는 게 아니라, 새 컴포넌트가 Rust에 더 적합하면 Rust로 하는 전술적 선택”이라고 말함
  • 1. 핫패스를 서비스로 떼어내기

    • 특정 서비스가 계속 문제를 일으키는 경우, 같은 API 계약 뒤에 두고 해당 서비스만 Rust로 다시 쓰는 방식이 가장 낮은 위험의 마이그레이션임
    • 대상은 CPU 사용량이 높거나, 지연 시간에 민감하거나, 안정성 문제가 반복되는 서비스일 수 있음
    • 다른 Go 서비스는 HTTP/gRPC로 계속 통신하므로 내부 구현 언어를 몰라도 됨
    • Radar CTO Jeff Kao는 Rust in Production에서 Discord가 Go에서 Rust로 이동한 글이 Radar에도 같은 시도를 떠올리게 했다고 말함
  • 2. 사이드카나 워커 프로세스 교체

    • 백그라운드 워커, 큐 소비자, 수집 파이프라인, CPU 바운드 배치 작업은 좋은 첫 대상임
    • 보통 큐나 토픽 같은 명확한 입력/출력 경계를 가지며, 시스템의 나머지와 인프로세스 공유 상태가 없음
  • 3. cgo는 가능하지만 고통스러움

    • Go에서 cgo를 통해 Rust를 호출할 수 있고, 이를 다루는 좋은 가이드도 있음
    • 백엔드 서비스에서는 보통 권장되지 않음
    • 빌드 복잡성과 FFI 오버헤드가 “Rust 서비스를 세우고 네트워크 호출 뒤에 두는” 방식보다 이점을 상쇄하는 경우가 많음
    • 라이브러리와 CLI 도구에서는 더 실용적일 수 있음
  • 4. 게이트웨이 뒤에서 Strangler Pattern 적용

    • API 게이트웨이나 리버스 프록시가 있으면 특정 엔드포인트만 새 Rust 서비스로 라우팅하고 나머지는 Go에 남길 수 있음
    • 인증, 검색, 결제처럼 하나의 경계 지어진 컨텍스트가 마이그레이션 단위로 적합할 때 특히 잘 맞음
    • 이 패턴은 새 서비스가 기존 서비스 주변에서 자라 결국 완전히 대체한다는 의미로 “strangler fig”라고 불림

실전 마이그레이션 팁

  • 명확한 경계가 있는 서비스부터 시작해야 하며, 가장 중심적이고 가장 많이 배포되는 서비스를 고르면 안 됨
  • 나머지 시스템과의 계약이 잘 정의돼 있고 영향 반경이 작은 서비스를 선택해야 함
  • 같은 API 계약 유지

    • Go 서비스가 REST API를 노출한다면 Rust 서비스도 같은 경로, 같은 JSON 형태, 같은 오류 래퍼를 유지해야 함
    • 클라이언트에는 마이그레이션이 보이지 않으며, 게이트웨이로 트래픽을 점진적으로 전환할 수 있음
  • 관용구를 문자 그대로 옮기지 않기

    • if err != nil { return err }는 ?가 됨
    • 요청당 goroutine 패턴은 실제로 필요할 때만 tokio::spawn으로 옮김
    • axum은 이미 요청을 동시에 처리함
    • 메서드 하나짜리 인터페이스는 대개 Box<dyn Trait>가 아니라 제네릭의 trait bound가 됨
  • 컴파일러를 페어 프로그래머처럼 사용

    • Rust 컴파일러 오류는 대체로 품질이 좋고, 천천히 읽으면 거의 항상 올바른 답을 알려줌
    • 가장 오래 고생하는 팀원은 컴파일러를 협력자로 보지 않고 싸우는 사람들임
  • 초기에 훈련에 투자

    • Rust 마이그레이션을 “옆에서” 배우며 진행하면 잘 끝나지 않는 경우가 많음
    • 워크숍, 온라인 코스, 실제 코드 기반 페어 세션처럼 학습 시간을 실제로 확보해야 함
    • 팀이 능숙해지면 선투자가 여러 배로 회수됨

Go가 계속 적합한 영역

  • 모든 것을 Rust로 옮길 필요는 없으며, Go가 특히 좋은 영역이 있음
  • Kubernetes 네이티브 도구

    • 오퍼레이터, 컨트롤러, CRD 영역은 생태계가 압도적으로 Go 중심임
  • CLI 유틸리티와 개발 도구

    • 빠른 컴파일, 쉬운 크로스 컴파일, 단순한 배포가 강점임
  • 글루 서비스

    • 얇은 API 계층, 프록시, 형식 변환기에서는 Rust의 보일러플레이트 비율이 그만한 가치가 없을 수 있음
  • 팀 속도가 절대적 정확성 보장보다 중요한 곳

    • 빠르게 움직여야 하는 영역에서는 Go가 계속 적합할 수 있음
    • Canonical VP of Engineering Jon Seager는 Rust in Production에서 Go가 네트워킹 서비스에 매우 좋은 선택이며, Canonical에는 Go가 많고 Juju도 거대한 Go 코드베이스라고 말함
    • 하이브리드 전략은 흔하며, 많은 팀은 “지루한” 서비스에는 Go를, 안정성과 성능이 추가 노력을 회수하는 서비스에는 Rust를 쓰는 다언어 백엔드로 귀결됨

기대할 수 있는 개선

  • 수치는 워크로드에 따라 크게 달라지므로 약속이 아니라 대략적 가이드로 봐야 함
  • Go에서 Rust로의 마이그레이션에서 관찰된 대략적인 개선 범위:
    • CPU 사용량: 20~60% 감소
      • Go가 이미 효율적이므로 Python에서 Rust로 옮길 때보다 극적이지 않음
      • GC 부재와 더 타이트한 루프에서 이득이 나옴
    • 메모리: 30~50% 감소
      • 주로 GC 오버헤드가 없고 런타임이 더 작기 때문임
    • P99 지연 시간: 훨씬 더 일관적임
      • Rust 서비스는 Go 서비스에서 보이는 GC 유발 지터가 줄고 평평해지는 경향이 있음
      • Go의 저지연 GC 도입 이후 Go 쪽도 많이 개선됐지만, 높은 부하에서는 차이가 남아 있음
    • 프로덕션 장애: 팀들이 가장 적극적으로 보고하는 개선 영역임
      • go test -race를 통과하고 프로덕션까지 도달하는 데이터 레이스, nil 역참조, 누락된 오류 경로 같은 버그 종류는 Rust에서 컴파일되지 않음
      • Rust 마이그레이션 뒤 온콜 교대가 대체로 매우 지루해짐
  • InfluxData Staff Engineer Andrew Lamb는 Rustacean Station: Rebuilding InfluxDB with Rust에서 InfluxDB 재작성 뒤 충돌, 이상한 멀티스레드 레이스 조건, 이전에 시간을 많이 잡아먹던 문제를 추적할 필요가 없었다고 말함
  • Go에서 Rust로 옮긴다고 Python에서 Rust로 옮길 때처럼 처리량이 10배 개선될 가능성은 낮음
  • 실제 이점은 “어이없는 오류” 감소, 더 평평한 지연 시간 꼬리, 같은 언어로 임베디드 개발이나 시스템 프로그래밍 같은 다른 영역까지 확장할 수 있는 능력임

보충 주의사항

  • Rust 타입 시스템이 모든 동기화 로직 버그를 없애지는 않지만, 동기화 없이 스레드 사이에서 공유할 수 없는 타입은 컴파일되지 않음
  • “락을 깜빡했다”가 조용한 데이터 손상으로 이어지는 종류의 문제는 Rust 타입 시스템이 막을 수 있음
  • Go string은 불변 바이트 시퀀스이며 관례적으로 UTF-8이지만 타입 수준에서 보장되지는 않음
  • 가장 가까운 대응은 읽기 전용 뷰 기준으로 Go string ↔ Rust &str, 가변 버퍼 기준으로 Go []byte ↔ Rust Vec<u8>임
  • Rust String은 &str의 소유권 있는 확장 가능한 버전이며, 내용이 유효한 UTF-8이라는 추가 보장이 있음
  • 자세한 내용은 Strings, bytes, runes and characters in Go를 참고할 수 있음
  • Go 1.18부터 제네릭 함수와 제네릭 타입은 가능하지만, 메서드 자체의 타입 매개변수는 도입되지 않았음
  • Rust의 (0..100).filter(|n| ...).collect() 같은 반복자 체인은 Go 개발자에게 낯설 수 있지만, Rust에서도 for 루프를 쓸 수 있고 일회성 코드에서는 종종 올바른 선택임

결론

  • Go에서 Rust로의 전환은 Python이나 TypeScript에서 Rust로 가는 전환과 다름
  • Go 출신 개발자는 이미 정적 타입과 컴파일 언어의 장점을 알고 있으므로, 동적 타입이나 느린 런타임을 포기하는 전환이 아님
  • 핵심 교환은 nil을 버리고 더 견고한 코드베이스, 더 적은 함정, 컴파일 시간에 더 많은 실수를 잡는 엄격한 컴파일러를 얻는 것임
  • 대신 학습 곡선은 더 가파름
  • 기반 서비스처럼 조직이 의존하고, 높은 가동 시간이 필요하며, 비즈니스에 중요한 서비스에서는 이 교환이 명확히 가치 있음
  • 다른 서비스에서는 Go가 여전히 맞는 답일 수 있음
  • 마이그레이션의 목적은 각 문제를 가장 잘 해결하는 언어에 배치하는 것임
Read Entire Article