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가 여전히 맞는 답일 수 있음
마이그레이션의 목적은 각 문제를 가장 잘 해결하는 언어에 배치하는 것임
Homepage
개발자
Go에서 Rust로 마이그레이션하기
🔉 볼륨 줄이기
🔊 볼륨 키우기
🔇 음소거
⏭️ 다음 곡