콘텐츠로 이동

Go 개발자를 위한 Rust 입문

Go를 쓰고 있다면, Rust에서 생각보다 익숙한 부분을 많이 발견할 거예요. 두 언어 모두 빠른 바이너리 하나로 컴파일돼요. 둘 다 클래스 대신 메서드가 있는 구조체를 쓰고, 상속이 없고, 에러를 예외가 아니라 값으로 반환하고, 모두가 쓰는 표준 포매터가 있고, 빌드와 테스트와 패키지 받기를 모두 하는 커맨드 라인 도구 하나가 있어요. 메서드 몇 개가 달린 Go struct는 거의 한 줄 한 줄 그대로 Rust로 옮길 수 있어요.

새로운 건 주로 메모리, 그리고 명시적으로 쓰는 것에 관한 부분이에요. Rust에는 가비지 컬렉터가 없어요. 모든 값에는 주인(owner)이 하나씩 있고, 컴파일러가 프로그램을 실행하기 전에 소유권(ownership)과 대여(borrowing)를 검사해요. nil도 없어요(Option을 써요). 그리고 if err != nil은 Result와 ? 연산자가 돼요. 인터페이스는 여전히 있지만(트레이트라고 불러요), 타입이 그걸 구현한다고 직접 밝혀야 해요. 공부 시간은 대부분 소유권에 쓰게 될 거예요. 나머지는 더 엄격하고 더 꼼꼼한 Go처럼 느껴질 거예요.

Go 개발자는 왜 Rust를 배울까요? 보통 이런 이유 중 하나예요. 가비지 컬렉션 멈춤이나 메모리 사용량이 신경 쓰이기 시작한 서비스, CPU를 많이 쓰는 작업, 운영체제 없이 돌아가는 코드(임베디드)나 브라우저에서 돌아가는 코드(WebAssembly), 아니면 그냥 데이터 경쟁과 nil 같은 크래시를 운영 환경에 가기 전에 컴파일러가 잡아 주길 원해서예요. Go, C++, Python과의 더 넓은 비교가 궁금하다면 Rust vs C++, Go, Python을 보세요. 이 페이지는 더 깊이 들어가고, Go 개발자인 여러분을 위해 쓰였어요.

이 표를 회화 사전처럼 써 보세요. 마지막 열은 Rust 쪽을 설명하는 이 사이트의 강좌로 연결돼요.

Go Rust 더 배우기
가비지 컬렉터 소유권과 대여, 컴파일할 때 검사. 메모리는 주인이 스코프를 벗어나면 해제돼요 소유권, 대여
struct + 메서드 데이터는 struct, 메서드는 impl 블록 구조체
값 리시버 func (a Account) &self(읽기, 복사 없음) 또는 self(소유권을 가져감) 구조체
포인터 리시버 func (a *Account) &mut self (값을 바꿀 수 있어요) 구조체
NewAccount(...) 생성자 함수 연관 함수. 관례상 Account::new(...) 구조체
대문자 = 외부 공개 pub 키워드 모듈
제로 값 (var c Config) 암묵적인 제로 값은 없어요. 모든 필드를 채워야 해요. #[derive(Default)] + Config::default() 구조체, 자주 쓰는 트레이트
구조체 임베딩 임베딩은 없어요. 구조체를 필드에 넣고 작은 전달 메서드를 쓰거나, 트레이트로 동작을 공유해요 트레이트, 디자인 패턴
interface (암묵적으로 충족) trait (impl Trait for Type으로 명시적으로 구현) 트레이트
여러 타입을 담는 []Shape Vec<Box<dyn Shape>> (트레이트 객체) 트레이트 객체
any / interface{} + 타입 스위치 경우마다 배리언트가 하나씩 있는 enum, 그다음 match 열거형, match
제네릭 func Max[T cmp.Ordered](a, b T) T 트레이트 바운드가 붙은 제네릭: fn max<T: PartialOrd>(a: T, b: T) -> T 제네릭, 고급 제네릭
nil 포인터, nil 맵, nil 인터페이스 Option<T>: Some(value) 아니면 None Option
v, ok := m[k] m.get(&k)는 Option을 반환해요 HashMap
(T, error) + if err != nil { return err } Result<T, E> + ? 연산자 Result
errors.New, 센티널 에러, errors.Is / errors.As match로 구분하는 에러 enum (thiserror 크레이트가 도와줘요) 사용자 정의 에러, anyhow와 thiserror
fmt.Errorf("...: %w", err) anyhow의 .context("..."), 또는 map_err anyhow와 thiserror
panic / recover 진짜 버그에는 panic!. 복구는 드물어요(std::panic::catch_unwind) panic
defer Drop: 값이 스코프를 벗어나면 실행돼요(RAII) 스코프, 스마트 포인터
go f() std::thread::spawn(...), async 태스크에는 tokio::spawn(...) 동시성, async/await
chan T, make(chan T, n) std::sync::mpsc::channel()(크기 제한 없음) 또는 sync_channel(n). async 코드에서는 tokio::sync::mpsc 채널
select async 코드에서는 tokio::select!, 또는 crossbeam-channel의 select! async 깊이 알아보기
데이터 옆에 둔 sync.Mutex Mutex<T>가 데이터를 감싸요. 가드가 drop되면 스스로 잠금이 풀려요 공유 상태
sync.WaitGroup JoinHandle::join(), 또는 std::thread::scope 동시성
sync.Once std::sync::OnceLock / LazyLock 표준 라이브러리 활용 팁
context.Context 딱 맞는 대응은 없어요. future를 drop해서 취소하거나, tokio::time::timeout, 또는 tokio-util의 CancellationToken을 써요 async 깊이 알아보기
func 값과 클로저 클로저. Fn, FnMut, FnOnce 트레이트로 표현해요 클로저, Fn, FnMut, FnOnce
for i, v := range s for (i, v) in s.iter().enumerate() for 반복문, 반복자
하나뿐인 for 반복문 loop, while, for 반복문
range-over-func 반복자 (iter.Seq) filter, map, sum, collect 같은 어댑터가 있는 Iterator 트레이트 반복자, 사용자 정의 반복자
switch match (모든 경우를 다뤘는지 컴파일러가 검사해요) match, 패턴 매칭
const + iota enum (원하면 숫자를 직접 지정해요) 열거형
:= let (기본은 불변이고, 바꾸려면 let mut) 변수
string (바이트, 보통 UTF-8) String(소유)과 &str(대여). 항상 올바른 UTF-8이에요 문자열
rune, for _, r := range s char, for c in s.chars() 문자열
[]byte Vec<u8> 또는 &[u8] 슬라이스
fmt.Println, %v, %+v println!, {}(Display), {:?}와 {:#?}(Debug, #[derive(Debug)]와 함께) 출력
String() string (Stringer 인터페이스) Display 구현 (그러면 .to_string()은 공짜로 따라와요) 자주 쓰는 트레이트
구조체 태그 `json:"user_name"` 어트리뷰트: #[serde(rename = "user_name")] Serde로 JSON 다루기
int(x), float64(n) 변환 숫자에는 as, From/Into, 실패할 수 있으면 TryFrom 타입 변환, 타입 변환 (From, Into)
정수 오버플로는 조용히 한 바퀴 돌아요 debug 빌드에서는 패닉, release에서는 한 바퀴 돌아요. checked_add, wrapping_add, saturating_add로 의도를 밝혀요 정수 오버플로
*T와 &x &T와 &mut T 참조(절대 null이 아니고, 값보다 오래 살 수 없어요). 소유하는 힙 값에는 Box<T> 대여, 스마트 포인터
패키지 크레이트 안의 모듈(mod). import 대신 use 모듈
go generate 매크로와 build.rs 빌드 스크립트 매크로, 절차적 매크로
unsafe 패키지 unsafe 블록과 원시 포인터 unsafe Rust
cgo extern "C"와 FFI (bindgen이 C 헤더에서 바인딩을 만들어 줘요) FFI (C 호출하기)
빌드 태그 //go:build linux #[cfg(target_os = "linux")]와 Cargo 피처 Cargo 피처와 cfg

Go에는 슬라이스와 맵이 언어에 내장되어 있어요. Rust에서는 표준 라이브러리에 있고, 몇 가지가 더 있어요.

Go Rust 더 배우기
내가 소유하고 append하는 []T Vec<T> 벡터
읽기만 하는 함수에 넘기는 []T &[T] (Vec이나 배열에서 빌려 온 슬라이스) 슬라이스
[N]T [T; N] 배열
append(s, x) / append(s, other...) v.push(x) / v.extend(other) 벡터
s[a:b] &v[a..b] 슬라이스
len(s), cap(s), make([]T, 0, n) v.len(), v.capacity(), Vec::with_capacity(n) 벡터
map[K]V HashMap<K, V> HashMap
집합으로 쓰는 map[K]struct{}나 map[K]bool HashSet<T> HashSet과 BTreeMap
출력하기 전에 맵의 키를 모아서 정렬하기 BTreeMap<K, V> (키가 정렬된 상태로 유지돼요) HashMap
container/list VecDeque<T> (push_back / pop_front) VecDeque와 BinaryHeap
container/heap (메서드 5개를 구현) BinaryHeap<T>: push와 pop만 있으면 돼요. 큰 것부터 나오고, 작은 것부터 꺼내려면 Reverse로 감싸요 VecDeque와 BinaryHeap
slices.Sort, sort.Slice v.sort(), v.sort_by(...), v.sort_by_key(...) 정렬과 검색
slices.Contains, slices.Index v.contains(&x), v.iter().position(...) 벡터
strings.Builder push_str을 쓰는 String, 또는 write! 문자열
sync.Map Mutex<HashMap<K, V>>, RwLock, 또는 dashmap 크레이트 공유 상태

알아 둘 게 두 가지 있어요. 첫째, Go의 맵처럼 Rust의 HashMap에도 정해진 순서가 없어요. 출력하기 전에 정렬하거나 BTreeMap을 쓰세요. 둘째, 유명한 Go 슬라이스 함정(두 슬라이스가 하나의 배열을 공유하다가, 한쪽의 append가 다른 쪽을 바꿔 버리는 일)은 Rust에서 일어날 수 없어요. 코드의 한 부분이 Vec의 &mut 대여를 들고 있는 동안에는, 다른 누구도 그걸 볼 수 없어요.

좋은 소식이에요. 여러분은 모든 걸 하는 도구 하나에 익숙하고, Rust에도 그런 도구가 있어요. Cargo가 go build, go run, go test, go mod, go install을 모두 다뤄요.

Go Rust 참고
go 명령 cargo 빌드, 실행, 테스트, 패키지 추가, 배포까지. Cargo와 크레이트 참고
go mod init cargo new my_app (라이브러리는 cargo new --lib) 폴더, Cargo.toml, src/main.rs, git 저장소까지 만들어 줘요
go.mod Cargo.toml
go.sum Cargo.lock 자동으로 만들어지고 갱신돼요
go get github.com/gin-gonic/gin cargo add axum
go get -u cargo update Cargo.toml에 적힌 버전 범위 안에서 업데이트해요
pkg.go.dev 패키지는 crates.io, API 문서는 docs.rs(영어)
go build / go run / go test cargo build / cargo run / cargo test 최적화된 빌드는 --release를 붙여요. debug 빌드는 실행 속도가 훨씬 느려요
go vet, staticcheck, golangci-lint cargo clippy 공식 도구이고, 도움이 되는 린트가 수백 개 있어요
gofmt / goimports cargo fmt (rustfmt) gofmt처럼 표준 스타일 하나
go doc cargo doc --open 문서 주석 안의 코드 예제가 테스트로 실행돼요. Rust 문서화 참고
go test -bench criterion (cargo bench로 실행) 성능 참고
go test -cover cargo llvm-cov 또는 cargo tarpaulin
go test -race 안전한 코드에는 필요 없어요. 데이터 경쟁은 컴파일 에러예요. unsafe 코드는 Miri가 검사해요
go test -fuzz cargo fuzz cargo install cargo-fuzz로 설치해요
testify 내장된 assert!와 assert_eq!. 더 보기 좋은 diff에는 pretty_assertions Rust 테스트와 테스트 더 알아보기 참고
gomock / mockery mockall, 또는 트레이트를 구현한 직접 만든 가짜 객체
go install pkg@latest cargo install name
go work (워크스페이스) Cargo 워크스페이스 맨 위의 Cargo.toml 하나가 멤버 크레이트를 나열해요
go.mod의 toolchain 줄 rustup과 rust-toolchain.toml 파일 Rust 도구와 개발 환경 참고
GOOS=linux GOARCH=arm64 go build rustup target add aarch64-unknown-linux-gnu + cargo build --target ... 그 타깃용 링커가 필요할 때가 많아요. cross나 cargo-zigbuild를 쓰면 쉬워져요
govulncheck cargo audit (RustSec 권고), cargo deny
gopls rust-analyzer VS Code, Neovim, Helix, Zed 등에서 동작해요
GoLand JetBrains RustRover
Delve rust-gdb / rust-lldb, 또는 VS Code의 CodeLLDB
pprof cargo flamegraph, perf, 또는 아무 네이티브 프로파일러
air (라이브 리로드) bacon 또는 cargo watch

익숙한 go.mod 옆에 작은 Cargo.toml을 놓아 볼게요.

module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0
[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

바로 눈에 띄는 차이가 하나 있어요. Go의 표준 라이브러리에는 HTTP 서버, JSON, 템플릿, 암호화가 들어 있어요. Rust의 표준 라이브러리는 일부러 작게 만들어서, JSON, HTTP, async, 난수 같은 건 크레이트에서 가져와요. 추가는 cargo add 한 번이면 되고, 몇몇 크레이트(serde, tokio, clap, regex, rand)는 거의 모두가 써요.

라이브러리와 프레임워크: “그럼 대신 뭘 쓰나요?”

섹션 제목: “라이브러리와 프레임워크: “그럼 대신 뭘 쓰나요?””

가장 먼저 나오는 현실적인 질문이에요. “우리는 Gin과 GORM을 쓰는데, Rust 버전은 뭐예요?” 가장 가까운 것들을 정리해 볼게요. Go처럼 Rust에도 공식 정답 하나 대신, 좋은 커뮤니티 선택지가 두세 개씩 있는 경우가 많아요.

Go Rust 참고
Gin, Echo, Chi, Fiber axum (프로젝트: Axum으로 웹 API에서 직접 만들어 봐요), actix-web, Rocket axum과 actix-web은 실무에서 널리 쓰여요
net/http 서버 hyper (저수준). 보통은 axum을 통해서 써요 표준 라이브러리에 HTTP 서버가 없어요
http.Handler 미들웨어 tower 레이어와 tower-http(CORS, 압축, 트레이싱, 타임아웃) axum은 tower를 그대로 써요
net/http 클라이언트 reqwest 기본은 비동기이고, 블로킹 클라이언트도 선택할 수 있어요. 네트워킹 참고
grpc-go tonic (protobuf는 prost와 함께)
gorilla/websocket tokio-tungstenite, 또는 axum의 ws 기능
html/template, text/template askama (컴파일할 때 검사), tera, minijinja
net (TCP/UDP) std::net, async에는 tokio::net 네트워킹 참고
errgroup tokio::task::JoinSet
Go Rust 참고
encoding/json + 구조체 태그 serde + serde_json TOML(toml 크레이트)과 다른 형식도 다뤄요. Serde로 JSON 다루기 참고
database/sql + pgx sqlx (비동기, 순수 SQL을 컴파일할 때 데이터베이스에 대고 검사), tokio-postgres 이름은 같지만 Go의 jmoiron/sqlx와는 관계없어요
sqlc sqlx의 query! 매크로 둘 다 여러분이 쓴 SQL에서 출발해서 타입이 붙은 결과를 줘요
GORM SeaORM (비동기, 엔티티 방식), Diesel (타입을 검사하는 쿼리 빌더) 데이터베이스 참고
go-sqlite3 rusqlite
go-redis redis
Go Rust 참고
cobra, urfave/cli, flag clap (구조체에서 인자를 derive해요) 명령줄 인자와 프로젝트: clap으로 CLI 도구 참고
viper config 또는 figment 파일과 환경 변수를 겹쳐 쌓은 다음 serde로 역직렬화해요
log/slog, zap, zerolog tracing + tracing-subscriber, 또는 log + env_logger 구조화 로깅. 로깅 참고
regexp regex Go의 RE2와 같은 아이디어예요. 선형 시간 매칭, 역참조 없음. 정규 표현식 참고
time 시간 간격에는 std::time, 날짜와 시간대에는 chrono나 jiff 날짜와 시간 참고
embed include_str! / include_bytes!, 폴더 전체에는 rust-embed
bubbletea ratatui 터미널 UI
Prometheus client_golang prometheus 또는 metrics
sync/atomic std::sync::atomic
Go Rust 참고
Wails Tauri (웹 UI + Rust 백엔드) 아이디어가 아주 비슷해요. GUI 앱 참고
Fyne egui, Iced, Slint 공식 GUI 툴킷은 없어요
Ebitengine macroquad, Bevy Bevy는 1.0 이전이라 릴리스마다 API가 바뀌어요. 게임 개발 참고
TinyGo embedded-hal, Embassy Rust는 운영체제 없이, 힙 없이도 돌아가요. 임베디드 Rust 참고
GOOS=js GOARCH=wasm wasm-bindgen, Leptos, Yew, Dioxus 바이너리가 작고, 함께 실어 보낼 GC도 없어요. Rust와 WebAssembly 참고

나란히 보기: 구조체와 메서드 (거의 똑같아요)

섹션 제목: “나란히 보기: 구조체와 메서드 (거의 똑같아요)”

Go 개발자가 집처럼 편하게 느끼는 부분이에요. Go로 만든 작은 계좌 타입이에요.

type Account struct {
Owner string // 외부 공개: 대문자
balance int // 비공개
}
func NewAccount(owner string, balance int) *Account {
return &Account{Owner: owner, balance: balance}
}
func (a *Account) Deposit(amount int) { a.balance += amount }
func (a *Account) Balance() int { return a.balance }
func (a *Account) String() string { return fmt.Sprintf("%s: %d", a.Owner, a.balance) }
func main() {
account := NewAccount("Ada", 100)
account.Deposit(50)
fmt.Println(account)
fmt.Println("balance is", account.Balance())
}

Rust로는 이렇게 써요.

use std::fmt;
// `pub`이 Go의 대문자 역할을 해요.
pub struct Account {
pub owner: String,
balance: i64, // 비공개. Go의 소문자 필드와 같아요
}
impl Account {
// Go의 NewAccount처럼 평범한 함수예요. `new`는 관례일 뿐이에요.
pub fn new(owner: &str, balance: i64) -> Self {
Account { owner: owner.to_string(), balance }
}
// 포인터 리시버처럼, 이 메서드는 계좌를 바꿔요.
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// 읽기만 해요. Go의 값 리시버와 달리 복사가 일어나지 않아요.
pub fn balance(&self) -> i64 {
self.balance
}
}
// Go의 String() 메서드(Stringer 인터페이스)는 Display가 돼요.
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}: {}", self.owner, self.balance)
}
}
fn main() {
let mut account = Account::new("Ada", 100);
account.deposit(50);
println!("{account}");
println!("잔액: {}", account.balance());
}

실행 결과:

Ada: 150
잔액: 150
▶ 직접 실행해 보기

모양은 같아요. 데이터는 구조체에, 메서드는 거기에 붙어 있고, 생성자는 그냥 함수예요. 차이는 작지만 중요해요. 메서드는 impl 블록 안에 함께 모여 있어요. 리시버가 메서드가 하는 일을 정확히 알려 줘요. &self는 읽고, &mut self는 바꾸고, 그냥 self는 값을 가져가서 다 써 버려요. 그리고 deposit이 계좌를 바꾸기 때문에 변수는 let mut으로 선언해야 해요. Go에서는 *Account를 가진 사람이라면 누구나 그걸 바꿀 수 있지만, Rust에서는 무언가를 바꿀 권리가 코드에 눈에 보이게 드러나요.

나란히 보기: 암묵적인 인터페이스는 명시적인 트레이트로

섹션 제목: “나란히 보기: 암묵적인 인터페이스는 명시적인 트레이트로”

Go에서는 타입이 알맞은 메서드를 갖고 있기만 하면 인터페이스를 충족해요.

type Shape interface {
Area() float64
Name() string
}
type Circle struct{ R float64 }
type Rect struct{ W, H float64 }
// 어디에도 "implements"가 없어요. 메서드가 있으면 충분해요.
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func (c Circle) Name() string { return "circle" }
func (r Rect) Area() float64 { return r.W * r.H }
func (r Rect) Name() string { return "rect" }
func describe[T Shape](s T) {
fmt.Printf("%s: %.2f\n", s.Name(), s.Area())
}
func main() {
describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}}
total := 0.0
for _, s := range shapes {
total += s.Area()
}
fmt.Printf("total area: %.2f\n", total)
}

Rust에서는 impl Shape for Circle로 그 연결을 직접 적어요.

use std::f64::consts::PI;
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle {
r: f64,
}
struct Rect {
w: f64,
h: f64,
}
// Go가 생략하는 부분이 바로 이 줄이에요.
impl Shape for Circle {
fn area(&self) -> f64 {
PI * self.r * self.r
}
fn name(&self) -> &str {
"원"
}
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
fn name(&self) -> &str {
"사각형"
}
}
// Go의 describe[T Shape](s T)처럼 제네릭이에요.
fn describe<T: Shape>(s: &T) {
println!("{}: {:.2}", s.name(), s.area());
}
fn main() {
describe(&Circle { r: 1.0 });
// Go의 []Shape처럼, 트레이트 하나 뒤에 서로 다른 타입이 있어요.
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
let total: f64 = shapes.iter().map(|s| s.area()).sum();
println!("전체 넓이: {total:.2}");
}

실행 결과:

원: 3.14
전체 넓이: 9.14
▶ 직접 실행해 보기

명시적으로 쓰는 데는 타입마다 한 줄이 들고, 그 대가로 Go가 못 하는 것들을 얻어요. 트레이트에는 기본 메서드가 있을 수 있어서, 타입들이 공통 동작을 공짜로 얻어요. 내가 만들지 않은 타입, 심지어 i32나 String에도 내 트레이트를 구현할 수 있어요(Go는 자기 패키지 안의 타입에만 메서드를 추가할 수 있어요). 그리고 제네릭 describe는 구체 타입마다 한 번씩 컴파일돼서, 호출할 때 런타임 조회가 없어요. Box<dyn Shape>가 Go 인터페이스 방식이에요. “Shape를 구현한 어떤 타입”을 가리키는 값 하나이고, 메서드는 런타임에 찾아요. 트레이트와 트레이트 객체를 참고하세요.

Go에서는 없을 수도 있는 포인터가 nil이고, 검사하는 걸 직접 기억해야 해요.

type User struct {
Name string
Manager *User // nil은 "상사 없음"이라는 뜻이에요
}
func findUser(users []*User, name string) *User {
for _, u := range users {
if u.Name == name {
return u
}
}
return nil
}
func main() {
ada := &User{Name: "Ada"}
users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} {
u := findUser(users, name)
if u == nil {
fmt.Println(name + ": no such user")
continue
}
boss := "nobody"
if u.Manager != nil {
boss = u.Manager.Name
}
fmt.Println(name, "reports to", boss)
}
}

Rust에서는 “없을 수도 있다”는 게 타입의 일부예요. Option<&User>는 Some(user) 아니면 None이고, None을 처리하기 전에는 컴파일러가 사용자를 쓰지 못하게 해요.

struct User {
name: String,
manager: Option<String>, // Go: nil일 수 있는 Manager *User
}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> {
users.iter().find(|u| u.name == name)
}
fn main() {
let users = vec![
User { name: "Ada".to_string(), manager: None },
User { name: "Bob".to_string(), manager: Some("Ada".to_string()) },
];
for name in ["Bob", "Ada", "Eve"] {
match find_user(&users, name) {
Some(user) => {
let boss = user.manager.as_deref().unwrap_or("없음");
println!("{name}의 상사: {boss}");
}
None => println!("{name}: 그런 사용자가 없어요"),
}
}
}

실행 결과:

Bob의 상사: Ada
Ada의 상사: 없음
Eve: 그런 사용자가 없어요
▶ 직접 실행해 보기

같은 아이디어가 Go의 comma-ok 조회도 대신해요. if email, ok := emails[name]; ok { ... }는 if let Some(email) = emails.get(name) { ... }가 돼요. 한 가지 주의할 점: Go에서 emails["missing"]은 조용히 제로 값 ""을 줘요. Rust에서 emails["missing"]은 패닉이 나니까, 키가 없을 수도 있다면 항상 .get()을 쓰세요. find_user의 'a는 라이프타임이에요. 반환하는 &User가 users 안을 가리킨다고 컴파일러에게 알려 줘요. 곧 만나게 될 거예요.

나란히 보기: if err != nil은 Result와 ?로

섹션 제목: “나란히 보기: if err != nil은 Result와 ?로”

Go의 에러 처리는 세 줄짜리 패턴으로 유명해요.

func parsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("invalid port %q: %w", s, err)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("port %d out of range", n)
}
return n, nil
}
func address(host, port string) (string, error) {
p, err := parsePort(port)
if err != nil {
return "", err
}
return fmt.Sprintf("%s:%d", host, p), nil
}
func main() {
for _, port := range []string{"8080", "abc", "70000", "0"} {
addr, err := address("localhost", port)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("listening on", addr)
}
}

Rust는 아이디어(에러는 반환하는 값이다)는 그대로 두고, 패턴을 ? 한 글자로 접어 넣어요.

fn parse_port(s: &str) -> Result<u16, String> {
// u16에는 70000이 들어가지 않아서, parse()가 "범위 초과"를 대신 잡아 줘요.
let port: u16 = s.parse().map_err(|e| format!("잘못된 포트 {s:?}: {e}"))?;
if port == 0 {
return Err("포트 0은 예약되어 있어요".to_string());
}
Ok(port)
}
fn address(host: &str, port: &str) -> Result<String, String> {
// `?`는 "에러라면 지금 바로 반환해"라는 뜻이에요.
let port = parse_port(port)?;
Ok(format!("{host}:{port}"))
}
fn main() {
for port in ["8080", "abc", "70000", "0"] {
match address("localhost", port) {
Ok(addr) => println!("{addr}에서 대기 중"),
Err(e) => println!("에러: {e}"),
}
}
}

실행 결과:

localhost:8080에서 대기 중
에러: 잘못된 포트 "abc": invalid digit found in string
에러: 잘못된 포트 "70000": number too large to fit in target type
에러: 포트 0은 예약되어 있어요
▶ 직접 실행해 보기

invalid digit found in string과 number too large to fit in target type은 Rust 표준 라이브러리가 만드는 에러 메시지라서 영어로 나와요. 각각 “문자열에 숫자가 아닌 글자가 있다”, “숫자가 너무 커서 대상 타입에 들어가지 않는다”는 뜻이에요.

줄 수를 아끼는 것보다 더 깊은 차이가 있어요. Go에서 n, err := strconv.Atoi(s)는 두 값을 모두 주고, err가 nil이 아닐 때 n을 써도 막을 방법이 없어요. Result는 값 아니면 에러라서, 에러를 먼저 처리하지 않고는 값에 닿을 수 없어요. 그리고 Result를 아예 무시하면 컴파일러가 경고해 줘요.

실제 프로젝트에서는 String 에러를 더 풍부한 것으로 바꿔요. 에러 enum(보통 thiserror 크레이트로 작성해요)이 센티널 에러와 errors.Is 역할을 해요. 호출하는 쪽은 배리언트로 match해요. anyhow 크레이트는 .context("loading config")?를 제공하는데, 이게 Rust판 fmt.Errorf("loading config: %w", err)예요. 사용자 정의 에러와 anyhow와 thiserror를 참고하세요.

나란히 보기: 고루틴과 채널은 스레드, mpsc, 태스크로

섹션 제목: “나란히 보기: 고루틴과 채널은 스레드, mpsc, 태스크로”

전형적인 Go 팬인(fan-in) 패턴이에요. 고루틴 몇 개를 시작하고, 결과를 채널로 보내고, 다 끝나면 채널을 닫아요.

func main() {
results := make(chan int)
var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- n * n
}(n)
}
go func() {
wg.Wait()
close(results)
}()
total := 0
for r := range results {
total += r
}
fmt.Println("total:", total)
}

스레드와 표준 라이브러리 채널을 쓴 Rust 버전은 조금 더 짧아요.

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] {
let tx = tx.clone(); // 스레드마다 자기 송신자를 받아요
thread::spawn(move || {
tx.send(n * n).unwrap();
});
}
// 우리 송신자를 drop해요. 송신자가 모두 사라지면 채널이 닫히고
// 아래 반복문이 끝나요. WaitGroup이 필요 없어요.
drop(tx);
let total: i32 = rx.iter().sum();
println!("합계: {total}");
}

실행 결과:

합계: 14
▶ 직접 실행해 보기

몇 가지는 다르게 대응돼요. mpsc는 “multi-producer, single-consumer”(생산자 여럿, 소비자 하나)라는 뜻이에요. 송신자는 복제할 수 있지만 수신자는 복제할 수 없어요(Go처럼 읽는 쪽이 여럿인 채널이 필요하면 crossbeam-channel을 쓰세요). mpsc::channel()은 크기 제한이 없는데, Go에는 이런 채널이 없어요. mpsc::sync_channel(n)이 Go의 make(chan T, n)이고, sync_channel(0)이 버퍼 없는 채널이에요. 아무도 close를 호출하지 않아요. 마지막 송신자가 drop되면 채널이 닫혀요. move는 스레드마다 자기 n 복사본과 자기 tx를 넘겨줘요. 이렇게 해서 Rust는 스레드가 데이터를 위험하게 공유하는 일이 절대 없도록 해요. 채널과 동시성을 참고하세요.

thread::spawn은 진짜 OS 스레드를 시작하고, 고루틴보다 무거워요. 작업자 몇 개라면 괜찮아요. 동시 연결이 수천 개라면 Rust는 tokio 같은 런타임 위의 async 태스크를 쓰는데, 이게 고루틴에 훨씬 가까워요.

#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in [1, 2, 3] {
// `go func() { ... }()`와 같지만, 핸들을 돌려받아요.
handles.push(tokio::spawn(async move { n * n }));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("합계: {total}");
}

실행 결과:

합계: 14
▶ 직접 실행해 보기

솔직하게 말하면, 여기는 Go가 더 간단한 곳이에요. Go에서는 어떤 함수든 블로킹할 수 있고 런타임이 알아서 처리해 줘요. Rust에서는 함수에 async를 붙이고, .await로 호출하고, 런타임을 골라야 해요. 그 대가로, 두 스레드가 같은 데이터를 동시에 바꿀 수 있는 코드는 컴파일러가 빌드를 거부해요. 그래서 go test -race가 찾아낼 게 없어요. async/await, async 깊이 알아보기를 보고, sync.Mutex를 데이터 옆에 둔 구조체의 Rust 버전인 Arc<Mutex<T>>는 공유 상태에서 확인하세요.

Go에서는 defer로 정리 작업을 예약하고, 함수가 반환될 때 지연된 호출이 역순으로 실행돼요.

func process() {
fmt.Println("open file")
defer fmt.Println("close file")
fmt.Println("lock mutex")
defer fmt.Println("unlock mutex")
fmt.Println("working")
}
func main() {
process()
fmt.Println("done")
}

Rust에서는 정리 작업이 값 자신에게 속해요. 값이 스코프를 벗어나면 그 값의 Drop 구현이 실행되고, 값은 defer처럼 역순으로 drop돼요.

struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("{}", self.0);
}
}
fn process() {
println!("파일 열기");
let _file = Guard("파일 닫기");
println!("뮤텍스 잠그기");
let _lock = Guard("뮤텍스 풀기");
println!("작업 중");
} // 여기서 _lock이 먼저 drop되고, 그다음 _file이 drop돼요
fn main() {
process();
println!("완료");
}

실행 결과:

파일 열기
뮤텍스 잠그기
작업 중
뮤텍스 풀기
파일 닫기
완료
▶ 직접 실행해 보기

실제 코드에서 Drop을 직접 쓸 일은 드물어요. File은 스스로 닫히고, MutexGuard는 스스로 잠금을 풀고, TcpStream은 스스로 연결을 끊어요. 잊어버릴 게 없어요. defer와 다른 점 두 가지는 알아 두면 좋아요. Drop은 함수가 아니라 블록이 끝날 때 실행돼요. 그래서 반복문 안에서 defer가 쌓이는 Go의 함정이 없어요. 반복할 때마다 스스로 정리해요. 그리고 let _ = Guard(...)는 조심하세요. 그냥 _는 값을 전혀 붙잡아 두지 않아서 바로 drop돼요. 대신 _lock 같은 이름을 쓰세요.

Go 개발자가 자주 걸려 넘어지는 부분

섹션 제목: “Go 개발자가 자주 걸려 넘어지는 부분”

Go에서 온 사람은 누구나 같은 곳에서 걸려요. 미리 알고 있으면 훨씬 수월해요.

  • 값을 넘기면 이동할 수 있어요. Go에서 함수에 슬라이스를 넘기면 작은 헤더가 복사되고, 양쪽 모두 계속 쓸 수 있어요. Rust에서 Vec을 넘기면 함수 안으로 이동해서, 그 뒤로는 쓸 수 없어요.

    fn total(prices: Vec<u32>) -> u32 {
    prices.iter().sum()
    }
    fn main() {
    let prices = vec![3, 4, 5];
    let sum = total(prices);
    println!("{sum} from {} items", prices.len());
    }
    error[E0382]: borrow of moved value: `prices`
    --> src/main.rs:8:37
    |
    6 | let prices = vec![3, 4, 5];
    | ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait
    7 | let sum = total(prices);
    | ------ value moved here
    8 | println!("{sum} from {} items", prices.len());
    | ^^^^^^ value borrowed here after move

    컴파일러 메시지는 영어로 나와요. “prices의 값은 7번째 줄에서 total로 이동했는데, 8번째 줄에서 이동한 값을 다시 빌리려고 했다”는 뜻이에요. 해결 방법은 Go의 습관을 Rust식으로 쓰는 거예요. 함수가 슬라이스를 빌리게 하세요. fn total(prices: &[u32])로 만들고 total(&prices)로 호출하면 돼요. 소유권을 먼저 읽어 보세요. 이 규칙을 설명하는 페이지예요. 이 에러는 E0382에서 자세히 다뤄요.

  • 대여 검사기(borrow checker). 읽는 쪽(&T)은 여럿이어도 되고, 아니면 쓰는 쪽(&mut T)이 딱 하나여야 해요. 둘을 동시에 가질 수는 없어요. 여러 구조체가 공유 구조체 하나를 가리키며 바꾸는 Go 코드는 Rust에서 다른 설계가 필요하거나, Rc<RefCell<T>>나 Arc<Mutex<T>> 같은 스마트 포인터가 필요해요. 몇 주는 답답할 거예요. 그래도 금방 훨씬 쉬워져요.

  • 제로 값이 없어요. let x: i32; 다음에 println!("{x}")를 쓰면 컴파일 에러이고, 구조체를 만들 때는 모든 필드를 채워야 해요. Go 같은 기본값을 원하면 Default를 derive하고 Config { port: 8080, ..Default::default() }라고 쓰세요.

  • 트레이트 구현은 명시적이에요. area 메서드가 있다고 타입이 Shape가 되지는 않아요. impl Shape for Circle이 필요해요. 좋은 점은, 검색만 하면 모든 구현을 찾을 수 있다는 거예요.

  • String과 &str. Go의 문자열 타입은 string 하나지만 Rust에는 두 개가 있어요. String은 텍스트를 소유하고, &str은 빌려 써요. 함수는 보통 &str을 받고, 구조체는 보통 String을 저장해요. 그리고 Go처럼 s[0]으로 문자열에 인덱스로 접근할 수 없어요. s.chars()나 s.as_bytes()[0]을 쓰세요. 문자열을 참고하세요.

  • 안 쓰는 것은 에러가 아니라 경고예요. Go는 안 쓰는 import나 변수가 있으면 컴파일을 거부해요. Rust는 경고만 하고, 이름 앞에 _를 붙이면 “일부러 안 쓰는 거예요”라고 밝힐 수 있어요.

  • 정수 오버플로. Go는 조용히 한 바퀴 돌아요. Rust는 debug 빌드에서는 패닉이 나고 release 빌드에서는 한 바퀴 돌아요. 그러니 오버플로가 날 수 있으면 checked_add, wrapping_add, saturating_add 중에서 고르세요. 정수 리터럴의 기본 타입은 i32이고, 인덱스는 usize예요. 정수 오버플로를 참고하세요.

  • 표준 라이브러리가 더 작아요. std에는 HTTP 서버도, JSON도, async 런타임도 없어요. cargo add로 추가하게 될 거예요. 처음에는 어색하지만, 인기 크레이트들은 안정적이고 문서도 잘되어 있어요.

  • async에는 런타임이 필요하고, 퍼져 나가요. async fn은 tokio 같은 런타임 안에서 await되기 전까지 아무것도 하지 않고, async 코드만 .await할 수 있어요. Go에는 이런 구분이 없어요. 평범한 스레드로 시작하고, 동시 연결이 많이 필요할 때 async를 꺼내 드세요.

  • 컴파일 시간. Go는 아주 빠르게 컴파일되도록 만들어졌어요. Rust는 더 느리게 컴파일되고, 크레이트가 많은 프로젝트를 처음부터 빌드할 때 특히 그래요. 코드를 고치는 동안에는 cargo check를 쓰세요. 코드 생성을 건너뛰어서 훨씬 빨라요.

  • 크로스 컴파일에는 약간의 준비가 필요해요. GOOS=linux go build는 그냥 동작해요. Rust에서는 rustup으로 타깃을 추가하고, 그 타깃용 링커도 필요한 경우가 많아요. cross 같은 도구가 그걸 대신 처리해 줘요.

둘 다 좋은 선택이고, 둘 다 쓰는 팀도 많아요. 공정한 기준을 소개할게요.

Go를 고르세요. 네트워크 서비스와 사내 도구를 빠르게 내놓고 싶을 때, 일주일만 지나면 누구나 읽을 수 있는 코드를 팀이 중요하게 여길 때, 빠른 빌드가 아주 중요할 때, 또는 Go가 공용어인 클라우드 네이티브 세계(Kubernetes, Docker, Terraform)에서 일할 때요. Go의 표준 라이브러리와 단순한 동시성 덕분에 많은 서버 프로그램을 쉽게 쓸 수 있어요.

Rust를 고르세요. 가비지 컬렉션 멈춤 없이 예측 가능한 지연 시간이 필요할 때, 메모리를 적게 써야 할 때, CPU를 한 방울까지 짜내야 할 때(파서, 데이터베이스, 게임 엔진, 영상, 압축)요. 운영체제 없이 도는 코드, WebAssembly, 다른 언어에서 호출할 라이브러리에도 Rust가 더 잘 맞아요. 그리고 데이터 경쟁이나 빠뜨린 nil 검사로 인한 크래시의 대가가 클 때도 그만한 가치가 있어요. Discord는 가비지 컬렉션 때문에 생기는 지연 시간 급증 때문에 서비스 하나를 Go에서 Rust로 옮긴 이야기(영어)를 쓴 것으로 유명해요.

Rust의 비용은 배우는 시간과 더 느린 빌드예요. Go의 비용은 가비지 컬렉터, 그리고 컴파일할 때가 아니라 실행할 때 드러나는 버그예요. 어느 쪽 비용이 더 아픈지는 프로젝트에 따라 달라요.

이 사이트를 순서대로 읽을 필요는 없어요. Go 개발자라면 이 경로로 이미 아는 건 건너뛰고, Rust가 다른 부분에 시간을 쓸 수 있어요.

  1. Rust 시작하기와 Rust 도구와 개발 환경: Rust를 설치하고, rust-analyzer를 설정하고, cargo new를 실행해요.
  2. 변수와 데이터 타입: 가볍게 훑어보면서 let mut과 숫자 타입 위주로 익혀요.
  3. 소유권, 대여, 슬라이스 순서로: 여러분에게 가장 중요한 페이지예요. 천천히 읽으세요.
  4. 문자열: String과 &str.
  5. 구조체와 열거형, 그다음 match와 패턴 매칭. 데이터를 담은 Rust 열거형이 Go의 많은 타입 스위치를 대신해요.
  6. Option과 Result, 그다음 사용자 정의 에러와 anyhow와 thiserror.
  7. 트레이트, 제네릭, 자주 쓰는 트레이트, 트레이트 객체.
  8. 클로저와 반복자: Rust는 Go보다 반복자 체인에 훨씬 많이 기대요.
  9. 벡터, HashMap, HashSet과 BTreeMap.
  10. 모듈, Cargo와 크레이트, 테스트.
  11. 라이프타임과 스마트 포인터.
  12. 동시성, 채널, 공유 상태, 그다음 async/await.
  13. Serde로 JSON 다루기와 로깅, 그다음 웹 API 프로젝트를 만들어 봐요.

두 문제 모두 Go 코드를 주고, 그걸 Rust로 옮기는(포팅하는) 문제예요.

이 Go 프로그램은 단어 수를 세고 알파벳순으로 출력해요. Rust로 옮겨 보세요. 힌트: HashMap<&str, u32>를 쓰고, Go의 counts[w]++ 대신 *counts.entry(word).or_insert(0) += 1을 쓰세요. text.split_whitespace()가 strings.Fields예요.

func main() {
text := "the cat and the dog and the bird"
counts := map[string]int{}
for _, w := range strings.Fields(text) {
counts[w]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, counts[k])
}
}
정답 보기
use std::collections::HashMap;
fn main() {
let text = "the cat and the dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
let mut keys: Vec<&str> = counts.keys().copied().collect();
keys.sort();
for key in keys {
println!("{key}: {}", counts[key]);
}
}

실행 결과:

and: 2
bird: 1
cat: 1
dog: 1
the: 3
▶ 직접 실행해 보기

여기서는 기댈 제로 값이 없어서, entry(word).or_insert(0)가 “새 단어면 0에서 시작해”라고 말해 줘요. counts[key]로 인덱스 접근을 해도 안전해요. 모든 키가 맵에서 나왔으니까요. 더 짧게 쓰려면 HashMap 대신 BTreeMap을 쓰세요. 키가 정렬된 채로 나오니까 keys 벡터를 없앨 수 있어요. 알파벳순 정렬이 결과에 영향을 주기 때문에 이 예제의 단어는 영어로 두었어요.

이 Go 코드는 센티널 에러를 반환하고 errors.Is로 검사해요. withdraw가 Result<(), WithdrawError>를 반환하도록 Rust로 옮겨 보세요. WithdrawError는 배리언트가 두 개인 enum이에요. 힌트: 출력할 수 있도록 열거형에 fmt::Display를 구현하고, Go가 errors.Is를 쓰는 곳에서는 배리언트로 match하세요.

var ErrInsufficientFunds = errors.New("insufficient funds")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error {
if n <= 0 {
return fmt.Errorf("invalid amount %d", n)
}
if n > a.Balance {
return ErrInsufficientFunds
}
a.Balance -= n
return nil
}
func main() {
acc := &Account{Balance: 100}
for _, n := range []int{30, 0, 500} {
if err := acc.Withdraw(n); err != nil {
if errors.Is(err, ErrInsufficientFunds) {
fmt.Println("declined:", err)
} else {
fmt.Println("error:", err)
}
continue
}
fmt.Println("ok, balance", acc.Balance)
}
}
정답 보기
use std::fmt;
enum WithdrawError {
InvalidAmount(i64),
InsufficientFunds,
}
impl fmt::Display for WithdrawError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WithdrawError::InvalidAmount(n) => write!(f, "잘못된 금액 {n}"),
WithdrawError::InsufficientFunds => write!(f, "잔액 부족"),
}
}
}
struct Account {
balance: i64,
}
impl Account {
fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> {
if n <= 0 {
return Err(WithdrawError::InvalidAmount(n));
}
if n > self.balance {
return Err(WithdrawError::InsufficientFunds);
}
self.balance -= n;
Ok(())
}
}
fn main() {
let mut acc = Account { balance: 100 };
for n in [30, 0, 500] {
match acc.withdraw(n) {
Ok(()) => println!("성공, 잔액 {}", acc.balance),
Err(e @ WithdrawError::InsufficientFunds) => println!("거절됨: {e}"),
Err(e) => println!("에러: {e}"),
}
}
}

실행 결과:

성공, 잔액 70
에러: 잘못된 금액 0
거절됨: 잔액 부족
▶ 직접 실행해 보기

센티널 에러와 fmt.Errorf 에러는 열거형 하나의 배리언트 두 개가 되고, errors.Is는 match 갈래 하나가 돼요. e @ WithdrawError::InsufficientFunds는 배리언트와 맞춰 보는 동시에, 출력할 수 있도록 값 전체를 e로 받아 줘요. 좋은 덤도 있어요. 나중에 세 번째 배리언트를 추가하면, 고쳐야 할 match를 컴파일러가 모두 알려 줘요. Display 구현은 thiserror 크레이트가 대신 써 줄 수도 있어요.

  • Go의 많은 부분을 그대로 가져갈 수 있어요. 메서드가 있는 구조체, 상속 없음, 값으로서의 에러, 표준 포매터 하나, 빌드와 테스트를 하는 도구 하나까지요.
  • 크게 달라지는 건 세 가지예요. 가비지 컬렉터 대신 소유권, nil 대신 Option, if err != nil 대신 Result + ?.
  • 인터페이스는 트레이트가 되고, 구현은 명시적이에요(impl Trait for Type). defer는 스코프가 끝날 때 자동으로 실행되는 Drop이 돼요.
  • 고루틴과 채널은 작업자가 적으면 thread::spawn + mpsc, 많으면 tokio 태스크가 돼요. 데이터 경쟁은 컴파일러가 막아 줘요.
  • Cargo가 go build, go test, go mod를 대신하고, clippy와 rustfmt가 go vet과 gofmt를 대신해요. Gin 대신 axum이나 actix-web, GORM 대신 SeaORM이나 Diesel, pgx와 sqlc 대신 sqlx, cobra 대신 clap을 살펴보세요.

이해도 점검

1. Go에서는 알맞은 메서드만 있으면 타입이 인터페이스를 충족해요. Rust 타입은 트레이트를 어떻게 구현할까요?

2. Result를 반환하는 호출 뒤에 붙은 ?는 무엇을 할까요?

3. std::sync::mpsc에서 for msg in rx(또는 rx.iter())는 언제 멈출까요?

4. Go 팀이 Gin으로 HTTP API를 만들고 있어요. 가장 비슷한 Rust 크레이트는 무엇일까요?

점수: 0 / 4

  • The Rust Programming Language 한국어판: 공식 책의 한국어 번역이에요. Go 개발자에게는 4장(소유권), 9장(에러 처리), 10장(제네릭과 트레이트), 16장(동시성)이 가장 중요해요.
  • Tokio 튜토리얼 (영어): 대부분의 Rust 서버가 쓰는 async 런타임이에요. Go의 고루틴 스케줄러에 가장 가까운 존재예요.
  • axum 문서 (영어): Gin, Echo, Chi에서 왔다면 가장 먼저 살펴볼 웹 프레임워크예요.
  • Why Discord is switching from Go to Rust (영어): 지연 시간에 민감한 서비스를 Go에서 Rust로 옮긴 실제 팀의 이야기예요.
  • Are We Web Yet? (영어): Rust의 웹 라이브러리와 프레임워크를 정리한 커뮤니티 사이트예요.