콘텐츠로 이동

C# 개발자를 위한 Rust 입문

C#으로 일하고 있다면 좋은 소식이 있어요. Rust의 많은 부분이 익숙하게 느껴질 거예요. Rust에도 제약 조건이 있는 제네릭, async/await, 강력한 패턴 매칭, LINQ와 아주 비슷하게 읽히는 반복자 체인, 그리고 dotnet이 하는 일을 해 주는 훌륭한 커맨드 라인 도구(Cargo)가 있어요. 타입, 인터페이스, 람다로 생각하는 법은 이미 알고 있잖아요. 그 지식은 그대로 가져갈 수 있어요.

크게 달라지는 건 메모리와 에러 쪽이에요. Rust에는 가비지 컬렉터가 없어요. 대신 모든 값에는 주인(owner)이 하나씩 있고, 컴파일러가 프로그램을 실행하기 전에 소유권(ownership)과 대여(borrowing)를 검사해요. 또 Rust에는 null이 없고(Option을 써요), 예외도 없어요(Result를 써요). 공부 시간은 대부분 이 세 가지 변화에 쓰게 될 거예요. 이 페이지는 C#과 .NET에서 알던 것을 Rust와 짝지어 보여 줘서, 정말 새로운 부분에 시간을 쓸 수 있게 도와드려요.

C++, Go, Python과의 일반적인 비교가 궁금하다면 Rust vs C++, Go, Python을 보세요.

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

C# Rust 더 배우기
필드와 메서드가 있는 class 데이터는 struct, 메서드는 impl 블록 구조체
interface trait 트레이트
추상 클래스, 상속 상속은 없어요. 컴포지션(구조체 안에 구조체) + 기본 메서드가 있는 트레이트를 써요 트레이트, 디자인 패턴
여러 클래스를 담는 List<IShape> Vec<Box<dyn Shape>> (트레이트 객체) 트레이트 객체
확장 메서드 직접 만든 트레이트를, 내가 작성하지 않은 타입(String 같은)에 구현 트레이트
where T : IComparable<T>가 붙은 제네릭 트레이트 바운드가 붙은 제네릭: T: Ord 또는 where T: Ord 제네릭
null, Nullable<int>, nullable 참조 타입 Option<T>: Some(value) 아니면 None Option
??와 ?. .unwrap_or(...), .map(...), .and_then(...), 그리고 ? Option
예외, throw, try/catch Result<T, E> + ? 연산자 Result, 사용자 정의 에러
진짜 버그를 알리는 예외(InvalidOperationException 같은) panic! (스레드를 멈춰요) panic
IDisposable + using Drop: 주인이 스코프를 벗어나면 자동으로 실행돼요(RAII) 스코프, 스마트 포인터
가비지 컬렉터 소유권과 대여, 컴파일할 때 검사 소유권, 대여
record #[derive(Debug, Clone, PartialEq)]를 붙인 struct 구조체, 자주 쓰는 트레이트
record의 with 식 구조체 갱신 문법: Order { total: 5, ..old } 구조체
enum (이름 붙은 정수만 가능) 배리언트마다 자기 데이터를 담을 수 있는 enum 열거형
switch 식, is 패턴 match (모든 경우를 다뤘는지 컴파일러가 검사해요) match, 패턴 매칭
튜플 (int, string) 튜플 (i32, String) 튜플
ref / out 매개변수 &mut T, 또는 튜플이나 Option을 반환 대여
람다, Func<T>, Action<T>, 델리게이트 클로저. Fn, FnMut, FnOnce 트레이트로 표현해요 클로저, Fn, FnMut, FnOnce
event 채널, 또는 박스에 담은 클로저(콜백) 목록 채널, Fn, FnMut, FnOnce
LINQ: Where, Select, Sum, OrderBy, ToList 반복자 어댑터: filter, map, sum, sort_by(Vec에서), collect 반복자, 정렬과 검색
IEnumerable<T>와 yield return impl Iterator<Item = T>, 또는 Iterator를 구현한 직접 만든 타입 사용자 정의 반복자
async Task<T> / await async fn은 Future를 반환해요. 실행하려면 tokio 같은 런타임이 필요해요 async/await
Task.Run, Thread, lock std::thread::spawn 또는 tokio::spawn. Mutex<T>는 보호할 데이터를 감싸요 동시성, 공유 상태
프로퍼티 (get; set;) 프로퍼티는 없어요. pub 필드나 getter/setter 메서드를 써요 구조체
string과 StringBuilder &str(빌려 온 텍스트 뷰)과 String(소유하고, 늘릴 수 있음) 문자열
문자열 보간 $"Hi {name}" format!("Hi {name}")과 println!("Hi {name}") 출력
var let (기본은 불변이고, 바꾸려면 let mut) 변수
const, readonly const, 그리고 그냥 let (이미 읽기 전용) 변수
static readonly 필드 static, 또는 시작할 때 만드는 값은 LazyLock 변수
namespace와 using 지시문 모듈(mod)과 use 모듈
어셈블리 / 프로젝트 크레이트(라이브러리 또는 바이너리) Cargo와 크레이트
[Serializable] 같은 어트리뷰트 #[derive(Serialize)] 같은 어트리뷰트와 derive 매크로 매크로, Serde로 JSON 다루기
리플렉션 런타임에는 거의 없어요. 대신 매크로와 derive가 컴파일할 때 코드를 만들어요 매크로
unsafe와 포인터 unsafe 블록과 원시 포인터 unsafe Rust
Span<T>, ReadOnlySpan<char> 슬라이스: &[T]와 &str 슬라이스
ToString() 재정의, IFormattable Display 구현 (그러면 .to_string()은 공짜로 따라와요) 자주 쓰는 트레이트
IEquatable<T>, IComparable<T>, GetHashCode PartialEq/Eq, PartialOrd/Ord, Hash (보통 derive로) 자주 쓰는 트레이트
implicit / explicit 변환 연산자 From/Into (실패할 수 있으면 TryFrom) 타입 변환 (From, Into)
연산자 오버로딩 (operator +) Add 같은 std::ops의 트레이트를 구현 고급 트레이트
checked / unchecked 연산 checked_add, wrapping_add, saturating_add 정수 오버플로
decimal 내장 decimal 타입은 없어요. rust_decimal 크레이트를 쓰거나, 정수로 센트 단위를 저장해요 데이터 타입

표준 라이브러리가 System.Collections.Generic에 있는 거의 모든 걸 갖추고 있어요. 이름은 달라도 개념은 같아요.

C# Rust 더 배우기
List<T> Vec<T> 벡터
T[] (고정 길이) 컴파일할 때 길이를 알면 [T; N], 아니면 Vec<T> 배열
Dictionary<K, V> HashMap<K, V> HashMap
SortedDictionary<K, V> BTreeMap<K, V> (키가 정렬된 상태로 유지돼요) HashMap
HashSet<T> HashSet<T> HashSet과 BTreeMap
SortedSet<T> BTreeSet<T> HashSet과 BTreeMap
Queue<T> VecDeque<T> (push_back / pop_front) VecDeque와 BinaryHeap
Stack<T> push / pop을 쓰는 Vec<T> 벡터
PriorityQueue<TElement, TPriority> BinaryHeap<T> (큰 것부터 나와요. 작은 것부터 꺼내려면 Reverse로 감싸요) VecDeque와 BinaryHeap
LinkedList<T> LinkedList<T>도 있지만 쓸 일은 드물어요. 보통 VecDeque가 더 빨라요 VecDeque와 BinaryHeap
ConcurrentDictionary<K, V> Mutex<HashMap<K, V>>, 또는 dashmap 크레이트 공유 상태

한 가지 주의할 점: Dictionary처럼 Rust의 HashMap에도 정해진 순서가 없어요. 내용을 출력할 때는 먼저 정렬하거나 BTreeMap을 쓰세요.

C# 개발자 대부분이 바로 마음에 들어 하는 부분이에요. Cargo 하나가 dotnet CLI, MSBuild, NuGet이 하던 일을 모두 해요.

.NET Rust 참고
dotnet CLI cargo 빌드, 실행, 테스트, 패키지 추가, 배포까지. Cargo와 크레이트 참고
.csproj Cargo.toml 짧고 손으로 고치기 쉬워요
packages.lock.json Cargo.lock 자동으로 만들어져요
프로젝트 여러 개가 든 .sln Cargo 워크스페이스 맨 위의 Cargo.toml 하나가 멤버 크레이트를 나열해요
NuGet / nuget.org crates.io 공개된 모든 크레이트의 API 문서는 docs.rs(영어)에 있어요
dotnet add package Serilog cargo add tracing
dotnet new console cargo new my_app (라이브러리는 cargo new --lib)
dotnet run / build / test cargo run / cargo build / cargo test
-c Release --release debug 빌드는 실행 속도가 훨씬 느려요. 벤치마크는 release로 하세요
dotnet watch cargo watch 또는 bacon (cargo install로 설치)
dotnet tool install -g cargo install
global.json (SDK 버전) rustup과 rust-toolchain.toml 파일
Roslyn 분석기 cargo clippy 도움이 되는 린트 수백 개, 공식 도구예요
dotnet format cargo fmt 모두가 쓰는 표준 스타일 하나
Visual Studio / Rider VS Code + rust-analyzer, 또는 JetBrains RustRover rust-analyzer가 자동 완성, 정의로 이동, 실시간 에러 표시를 해 줘요
XML 문서 주석 /// 마크다운으로 쓰는 /// 문서 주석 + cargo doc 문서 안의 코드 예제가 테스트로 실행돼요. Rust 문서화 참고
xUnit / NUnit / MSTest 내장된 #[test]와 cargo test (더 빠른 실행기로 cargo nextest가 인기예요) Rust 테스트 참고
Moq mockall, 또는 트레이트를 구현한 직접 만든 가짜 객체
Coverlet (코드 커버리지) cargo llvm-cov 또는 cargo tarpaulin
BenchmarkDotNet criterion 성능 참고
dotnet-trace, 프로파일러 cargo flamegraph, perf, 또는 아무 네이티브 프로파일러

.csproj와 비교해 볼 수 있게 작은 Cargo.toml을 보여 드릴게요.

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

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

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

.NET 팀이 가장 먼저 묻는 질문이에요. “우리는 Entity Framework를 쓰는데, Rust에는 뭐가 있죠?” 가장 가까운 것들을 정리해 볼게요. .NET에 Microsoft가 있는 것처럼 “기본 스택”을 책임지는 회사 하나가 Rust에는 없어서, 좋은 커뮤니티 크레이트 두세 개 중에서 고르는 경우가 많아요.

.NET Rust 참고
Entity Framework Core Diesel: ORM이자 쿼리 빌더예요. 쿼리 타입을 컴파일할 때 검사해요. 마이그레이션은 diesel_cli로 해요 성숙한 라이브러리(2.x). 기본은 동기 방식이에요(diesel-async도 있어요)
Entity Framework Core (비동기, 엔티티 방식) SeaORM: 엔티티, 관계, 마이그레이션(sea-orm-cli)을 지원하는 비동기 ORM EF와 가장 비슷한 느낌이에요. sqlx 위에 만들어졌어요
Dapper sqlx: 순수 SQL을 직접 쓰고, query! 매크로가 컴파일할 때 실제 데이터베이스에 대고 검사해요. 마이그레이션은 sqlx-cli로 해요 비동기, 아주 인기 있음, ORM은 아니에요
ADO.NET sqlx, tokio-postgres, rusqlite (SQLite) 더 저수준의 드라이버
StackExchange.Redis redis

EF가 좋았다면 SeaORM부터 써 보세요. Dapper처럼 SQL을 직접 쓰는 게 좋았다면 sqlx를 써 보세요. 컴파일 타임 보장을 가장 강하게 원한다면 Diesel을 써 보세요. 세 가지 모두 동작하는 코드와 함께 Rust 데이터베이스에서 볼 수 있어요.

.NET Rust 참고
ASP.NET Core Web API axum (프로젝트: Axum으로 웹 API에서 직접 만들어 봐요), actix-web, Rocket axum과 actix-web은 실무에서 널리 쓰여요
Minimal API axum: 라우트가 평범한 async fn 핸들러예요 스타일이 아주 비슷해요
미들웨어 파이프라인 tower 레이어와 tower-http 크레이트(CORS, 압축, 트레이싱, 타임아웃) axum은 tower를 그대로 써요
내장 의존성 주입 axum의 State, 평범한 생성자 함수, 트레이트 아래 DI 행을 참고하세요
Swashbuckle / OpenAPI utoipa
SignalR axum의 WebSocket(ws 기능), 또는 tokio-tungstenite SignalR 같은 프로토콜 계층은 내장되어 있지 않아요
gRPC (Grpc.AspNetCore) tonic (protobuf는 prost와 함께)
HttpClient reqwest 기본은 비동기이고, 블로킹 클라이언트도 선택할 수 있어요
JWT bearer 인증 jsonwebtoken
.NET Rust 참고
System.Text.Json / Newtonsoft.Json serde + serde_json 사실상 표준이에요. TOML, YAML 등도 다뤄요. Serde로 JSON 다루기 참고
ILogger / Serilog tracing (+ tracing-subscriber), 또는 log + env_logger 스팬(span)을 지원하는 구조화 로깅. 로깅 참고
IConfiguration / appsettings.json config 또는 figment 파일과 환경 변수를 겹쳐 쌓은 다음 serde로 역직렬화해요
DI 컨테이너 (IServiceCollection) 보통은 아무것도 안 써요. 의존성은 생성자 함수로 넘기고, 테스트에서 바꿔 끼울 부분은 트레이트로 만들어요 shaku도 있지만 컨테이너를 쓰는 프로젝트는 드물어요
AutoMapper From / Into 구현을 직접 작성 명시적이고 컴파일러가 검사해 줘요. 타입 변환 (From, Into) 참고
FluentValidation / DataAnnotations validator 또는 garde (derive 기반 검증)
Polly (재시도) backon, 또는 tower의 retry 미들웨어
MediatR 딱 맞는 대응은 없어요 평범한 트레이트, 또는 태스크 사이의 채널
Hangfire / 백그라운드 작업 프로세스 안의 작업은 tokio::spawn, 영속적인 작업 큐는 apalis
CancellationToken future를 drop하거나, tokio-util의 CancellationToken을 써요
.NET Rust 참고
Blazor WebAssembly Leptos, Yew, Dioxus 모두 1.0 이전이라 API가 아직 바뀌어요. Rust와 WebAssembly 참고
WPF / WinForms / MAUI Tauri (웹 UI + Rust 백엔드), Slint, egui, Iced Tauri 2와 Slint 1.x가 가장 안정적이에요. 공식 GUI 툴킷은 없어요
Unity Bevy, 또는 godot 크레이트로 Godot 안에서 Rust 쓰기 Bevy는 1.0 이전이라 릴리스마다 API가 깨져요
ML.NET candle, burn, linfa Python의 ML 도구보다 훨씬 젊어요
Azure SDK for .NET 공식 Azure SDK for Rust: azure_core, azure_identity, azure_storage_blob, … .NET SDK보다 지원하는 서비스가 적어요

나란히 보기: 클래스는 struct + impl로

섹션 제목: “나란히 보기: 클래스는 struct + impl로”

실제 코드를 볼게요. C#으로 작성한 작은 은행 계좌예요.

var account = new BankAccount("Ada", 100);
account.Deposit(50);
Console.WriteLine($"{account.Owner}의 잔액: {account.Balance}");
public class BankAccount
{
public string Owner { get; }
private decimal balance;
public BankAccount(string owner, decimal balance)
{
Owner = owner;
this.balance = balance;
}
public void Deposit(decimal amount) => balance += amount;
public decimal Balance => balance;
}

Rust로는 이렇게 써요.

struct BankAccount {
owner: String,
balance: i64,
}
impl BankAccount {
// `new`는 관례일 뿐, 키워드가 아니에요.
fn new(owner: &str, balance: i64) -> Self {
BankAccount { owner: owner.to_string(), balance }
}
// `&mut self`: 이 메서드는 계좌를 바꿔요.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// `&self`: 이 메서드는 읽기만 해요.
fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new("Ada", 100);
account.deposit(50);
println!("{}의 잔액: {}", account.owner, account.balance());
}

실행 결과:

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

데이터와 동작이 서로 다른 두 블록에 들어 있어요. 생성자는 없고, Self를 반환하는 함수만 있어요. 메서드마다 값을 읽기만 하는지(&self) 바꾸는지(&mut self)를 처음부터 밝혀요. 그리고 deposit이 계좌를 바꾸기 때문에 변수는 let mut이어야 해요. C#에서는 어떤 메서드든 어떤 필드든 바꿀 수 있지만, Rust에서는 무엇이 바뀌는지가 시그니처에 드러나요.

나란히 보기: 인터페이스는 트레이트로

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

C#에서는 IShape 인터페이스와 클래스 두 개, 그리고 List<IShape>를 이렇게 만들 수 있어요.

var shapes = new List<IShape> { new Circle { Radius = 1 }, new Rectangle { Width = 2, Height = 3 } };
foreach (var s in shapes) Console.WriteLine($"{s.Area():F2}");
public interface IShape { double Area(); }
public class Circle : IShape
{
public double Radius { get; init; }
public double Area() => Math.PI * Radius * Radius;
}
public class Rectangle : IShape
{
public double Width { get; init; }
public double Height { get; init; }
public double Area() => Width * Height;
}

Rust에서는 인터페이스가 트레이트(trait)가 되고, 구조체마다 자기 impl 블록에서 트레이트를 구현해요.

trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
// Box<dyn Shape>가 "어떤 IShape를 가리키는 참조" 역할을 해요.
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 2.0, height: 3.0 }),
];
for shape in &shapes {
println!("{:.2}", shape.area());
}
}

실행 결과:

3.14
6.00
▶ 직접 실행해 보기

C#에서는 인터페이스 호출이 모두 가상 호출을 거쳐요. Rust에서는 고를 수 있어요. Box<dyn Shape>는 가상 호출을 쓰고(트레이트 객체 참고), fn print_area<T: Shape>(s: &T) 같은 제네릭 함수는 타입마다 따로 컴파일되기 때문에 가상 호출이 전혀 없어요. 덤으로, 내가 만들지 않은 타입에도, 심지어 String이나 i32에도 내 트레이트를 구현할 수 있어요. C#의 확장 메서드가 하는 일을 Rust는 이렇게 해요.

nullable 참조 타입을 켠 C#은 null에 대해 경고해 주지만, 런타임에는 여전히 null이 존재해요.

string? FindEmail(int userId) => userId == 1 ? "[email protected]" : null;
var email = FindEmail(2);
if (email is not null) Console.WriteLine(email.ToUpper());
else Console.WriteLine("등록된 이메일이 없어요");
Console.WriteLine(FindEmail(2) ?? "알 수 없음");

Rust에서는 없을 수도 있는 값의 타입이 Option<T>예요. None인 경우를 처리하기 전에는 평범한 값처럼 쓸 수 없어요.

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
match find_email(1) {
Some(email) => println!("{}", email.to_uppercase()),
None => println!("등록된 이메일이 없어요"),
}
// 한 가지 경우만 신경 쓸 때는 짧게 `if let`을 써요.
if let Some(email) = find_email(2) {
println!("찾았어요: {email}");
} else {
println!("등록된 이메일이 없어요");
}
// unwrap_or는 C#의 ?? 연산자처럼 동작해요.
let email = find_email(2).unwrap_or("알 수 없음".to_string());
println!("{email}");
}

실행 결과:

등록된 이메일이 없어요
알 수 없음
▶ 직접 실행해 보기

Rust에는 NullReferenceException이 없어요. 역참조할 null 자체가 없으니까요. 컴파일러는 Option<String>에 .to_uppercase()를 호출하는 걸 아예 허락하지 않아요. 도우미 메서드는 Option 페이지에서, 짧게 쓰는 방법은 if let / while let에서 배워 보세요.

나란히 보기: try/catch는 Result와 ?로

섹션 제목: “나란히 보기: try/catch는 Result와 ?로”

C#에서는 파싱이 예외를 던질 수 있고, 호출 스택 위쪽 어딘가에서 그 예외를 잡아요.

static int TotalSeats(string a, string b) => int.Parse(a) + int.Parse(b);
try
{
Console.WriteLine(TotalSeats("12", "30"));
Console.WriteLine(TotalSeats("12", "thirty"));
}
catch (FormatException e)
{
Console.WriteLine($"에러: {e.Message}");
}

Rust에서는 실패할 수 있는 함수가 반환 타입으로 그 사실을 알려요. ? 연산자는 “이게 실패했으면 지금 바로 호출한 쪽으로 에러를 돌려줘”라는 뜻이에요. 예외가 위로 전파되는 것과 같은 일을 하지만, 코드에 눈에 보이게 드러나요.

use std::num::ParseIntError;
fn total_seats(a: &str, b: &str) -> Result<i32, ParseIntError> {
let first: i32 = a.parse()?;
let second: i32 = b.parse()?;
Ok(first + second)
}
fn main() {
for (a, b) in [("12", "30"), ("12", "thirty")] {
match total_seats(a, b) {
Ok(total) => println!("합계: {total}"),
Err(e) => println!("에러: {e}"),
}
}
}

실행 결과:

합계: 42
에러: invalid digit found in string
▶ 직접 실행해 보기

invalid digit found in string은 Rust 표준 라이브러리가 만드는 에러 메시지라서 영어로 나와요. “문자열에 숫자가 아닌 글자가 있다”는 뜻이에요.

가장 큰 차이는 이거예요. C#에서는 어떤 메서드든 예외를 던질 수 있고, 시그니처만 봐서는 알 수 없어요. Rust에서는 Result가 시그니처의 일부라서, 호출하는 쪽이 처리를 잊을 수가 없어요. 직접 에러 타입을 만들려면 사용자 정의 에러를 보세요. 애플리케이션에서는 anyhow 크레이트가 “어떤 예외든 잡기”에 가까운 방법을 제공해요(anyhow와 thiserror 참고).

나란히 보기: LINQ는 반복자 체인으로

섹션 제목: “나란히 보기: LINQ는 반복자 체인으로”

흔히 쓰는 LINQ 쿼리예요.

var orders = new List<Order> { new("Ada", 120), new("Grace", 80), new("Linus", 200), new("Ken", 150) };
var bigSpenders = orders
.Where(o => o.Total > 100)
.OrderByDescending(o => o.Total)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", bigSpenders));
Console.WriteLine($"매출: {orders.Sum(o => o.Total)}");
record Order(string Customer, int Total);

Rust 버전은 반복자 어댑터와 클로저를 써요.

struct Order {
customer: String,
total: u32,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120 },
Order { customer: "Grace".to_string(), total: 80 },
Order { customer: "Linus".to_string(), total: 200 },
Order { customer: "Ken".to_string(), total: 150 },
];
// Where(...).ToList()
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// OrderByDescending(...): 정렬은 반복자가 아니라 Vec에서 해요.
big.sort_by(|a, b| b.total.cmp(&a.total));
// Select(...)
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// Sum(...)
let revenue: u32 = orders.iter().map(|o| o.total).sum();
println!("매출: {revenue}");
}

실행 결과:

Linus, Ken, Ada
매출: 550
▶ 직접 실행해 보기

Rust 반복자는 LINQ의 지연 실행처럼 게을러요(lazy). collect(), sum(), for 반복문 같은 걸 호출하기 전까지는 아무것도 실행되지 않아요. collect()가 여러분의 ToList()예요. LINQ와 달리 반복자 체인은 보통 손으로 쓴 반복문과 똑같은 기계어로 컴파일되기 때문에, 보기 좋은 스타일을 쓴다고 비용을 치르지 않아요. 정렬은 정렬과 검색 페이지에서 다뤄요.

나란히 보기: using/IDisposable은 Drop으로

섹션 제목: “나란히 보기: using/IDisposable은 Drop으로”

C#에서는 자원을 제때 해제하려면 using을 꼭 기억해야 해요.

using (var conn = new Connection("orders-db"))
{
Console.WriteLine($"{conn.Name} 조회 중");
}
Console.WriteLine("블록을 빠져나왔어요");
class Connection : IDisposable
{
public string Name { get; }
public Connection(string name) => Name = name;
public void Dispose() => Console.WriteLine($"{Name} 연결을 닫아요");
}

Rust에서는 정리가 자동이에요. 값의 주인이 스코프를 벗어나면 Rust가 그 값의 Drop 구현을 호출해요. 잊어버릴 게 없어요.

struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("{} 연결을 닫아요", self.name);
}
}
fn main() {
{
let conn = Connection { name: "orders-db".to_string() };
println!("{} 조회 중", conn.name);
} // 여기서 `conn`이 스코프를 벗어나서 drop()이 실행돼요.
println!("블록을 빠져나왔어요");
}

실행 결과:

orders-db 조회 중
orders-db 연결을 닫아요
블록을 빠져나왔어요
▶ 직접 실행해 보기

이걸 RAII라고 하는데, Rust는 파일, 소켓, 락, 그리고 메모리 자체까지 모든 걸 이 방식으로 다뤄요. 파일은 File 값이 drop될 때 닫혀요. Mutex는 가드가 drop될 때 잠금이 풀려요. 파이널라이저도 GC로 인한 멈춤도 없고, 정리는 예측할 수 있는 순간에 일어나요.

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

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

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

  • 대입은 참조를 복사하지 않고 이동(move)해요. C#에서 클래스에 var b = a;를 하면 한 객체를 가리키는 참조가 두 개 생겨요. Rust에서 let b = a;를 하면 String이나 Vec의 소유권이 b로 이동하고, a는 더 이상 쓸 수 없어요.

    fn main() {
    let owner = String::from("Ada");
    let copy = owner;
    println!("{owner} and {copy}");
    }
    error[E0382]: borrow of moved value: `owner`
    --> src/main.rs:4:16
    |
    2 | let owner = String::from("Ada");
    | ----- move occurs because `owner` has type `String`, which does not implement the `Copy` trait
    3 | let copy = owner;
    | ----- value moved here
    4 | println!("{owner} and {copy}");
    | ^^^^^ value borrowed here after move

    컴파일러 메시지는 영어로 나와요. “owner의 값은 3번째 줄에서 copy로 이동했는데, 4번째 줄에서 이동한 값을 다시 빌리려고 했다”는 뜻이에요. 해결하려면 빌리거나(let copy = &owner;) 의도적으로 복제하세요(owner.clone()). 이 규칙은 소유권 페이지에서 설명하니 가장 먼저 읽어 보세요.

  • 대여 검사기(borrow checker). 읽는 쪽(&T)은 여럿이어도 되고, 아니면 쓰는 쪽(&mut T)이 딱 하나여야 해요. 둘을 동시에 가질 수는 없어요. 부모를 다시 가리키는 객체나, 여러 객체가 공유 객체 하나를 바꾸는 것처럼 C#에서는 평범한 패턴도 스마트 포인터나 다른 설계가 필요해요. 몇 주는 답답할 거예요. 그래도 금방 훨씬 쉬워져요.

  • String과 &str. C#의 문자열 타입은 string 하나지만 Rust에는 두 개가 있어요. String은 텍스트를 소유하고, &str은 빌려 써요. 함수는 보통 &str을 받고, 구조체는 보통 String을 저장해요. 문자열을 참고하세요.

  • 상속이 없어요. 공통 필드를 가진 기본 클래스를 만들고 메서드를 재정의하는 방식은 쓸 수 없어요. 공통 동작은 트레이트(기본 메서드 포함)로, 공통 데이터는 다른 구조체가 품는 구조체 하나로 만드세요.

  • 예외가 없어요. 에러는 값이에요. ?를 아주 많이 쓰게 되고, main도 Result를 반환할 수 있어요. 배우는 동안 .unwrap()을 써도 괜찮지만, 이건 Rust판 “예외는 무시하고 그냥 죽어라”라는 걸 기억하세요.

  • null이 없어요. 처음에는 여기저기 Option을 쓰는 게 번거롭게 느껴져요. 하지만 어느새 null 참조 버그가 사라졌다는 걸 알게 될 거예요.

  • 복제는 명시적이에요. Rust는 몰래 힙 값을 복사하지 않아요. .clone()이 보이면 진짜 복사가 일어나는 거예요. 비용이 눈에 보인다는 건 장점이에요.

  • 컴파일 시간. 크레이트가 많은 프로젝트를 처음부터 빌드하면 .NET 빌드보다 오래 걸려요. 증분 빌드와 cargo check(코드 생성을 건너뛰어요)는 훨씬 빠르니, 코드를 고치는 동안에는 cargo check를 쓰세요.

  • async에는 런타임이 필요해요. .NET에는 런타임과 스레드 풀이 항상 있어요. Rust에서는 실행기(executor)가 돌려 주기 전까지 async fn이 아무 일도 하지 않아서, tokio를 추가하고 main에 #[tokio::main]을 붙여요. async/await를 참고하세요.

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

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

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

다음 C# 코드를 Rust로 옮겨 보세요. 재고가 있고 가격이 20보다 싼 상품의 이름을 출력해야 해요. 힌트: #[derive(Debug, Clone, PartialEq)]를 붙인 struct를 만들고, filter, map을 쓴 다음 Vec<&str>로 collect하세요. Vec<&str>에는 .join(", ") 메서드가 있어요.

var products = new List<Product>
{
new("공책", 5, true),
new("헤드폰", 45, true),
new("펜", 2, false),
new("머그컵", 12, true),
};
var cheap = products.Where(p => p.InStock && p.Price < 20).Select(p => p.Name);
Console.WriteLine(string.Join(", ", cheap));
record Product(string Name, int Price, bool InStock);
정답 보기
#[derive(Debug, Clone, PartialEq)]
struct Product {
name: String,
price: u32,
in_stock: bool,
}
fn product(name: &str, price: u32, in_stock: bool) -> Product {
Product { name: name.to_string(), price, in_stock }
}
fn main() {
let products = vec![
product("공책", 5, true),
product("헤드폰", 45, true),
product("펜", 2, false),
product("머그컵", 12, true),
];
let cheap: Vec<&str> = products
.iter()
.filter(|p| p.in_stock && p.price < 20)
.map(|p| p.name.as_str())
.collect();
println!("{}", cheap.join(", "));
}

실행 결과:

공책, 머그컵
▶ 직접 실행해 보기

작은 도우미 함수가 record의 위치 기반 생성자 역할을 해요. derive 덕분에 C# record가 공짜로 주던 기능을 그대로 얻어요. {:?}로 출력하기, .clone()으로 복사하기, ==로 비교하기요.

2. 예외를 던지는 Dictionary 코드 옮기기

섹션 제목: “2. 예외를 던지는 Dictionary 코드 옮기기”

이 C# 코드는 재고 딕셔너리에서 물건을 하나 꺼내고, 문제가 있으면 예외를 던져요. take가 예외를 던지는 대신 Result<u32, String>을 반환하도록 Rust로 옮겨 보세요. 힌트: stock.get_mut(item)은 Option<&mut u32>를 반환해요. .ok_or(...)로 Result로 바꾼 다음 ?를 쓰세요.

var stock = new Dictionary<string, int> { ["사과"] = 3, ["배"] = 0 };
int Take(string item)
{
if (!stock.TryGetValue(item, out var count))
throw new KeyNotFoundException($"{item} 품목이 없어요");
if (count == 0)
throw new InvalidOperationException($"{item} 재고가 없어요");
stock[item] = count - 1;
return count - 1;
}
foreach (var item in new[] { "사과", "배", "키위" })
{
try { Console.WriteLine($"{item} 하나를 꺼냈어요. {Take(item)}개 남았어요"); }
catch (Exception e) { Console.WriteLine($"에러: {e.Message}"); }
}
정답 보기
use std::collections::HashMap;
fn take(stock: &mut HashMap<String, u32>, item: &str) -> Result<u32, String> {
let count = stock
.get_mut(item)
.ok_or(format!("{item} 품목이 없어요"))?;
if *count == 0 {
return Err(format!("{item} 재고가 없어요"));
}
*count -= 1;
Ok(*count)
}
fn main() {
let mut stock = HashMap::new();
stock.insert("사과".to_string(), 3);
stock.insert("배".to_string(), 0);
for item in ["사과", "배", "키위"] {
match take(&mut stock, item) {
Ok(left) => println!("{item} 하나를 꺼냈어요. {left}개 남았어요"),
Err(e) => println!("에러: {e}"),
}
}
}

실행 결과:

사과 하나를 꺼냈어요. 2개 남았어요
에러: 배 재고가 없어요
에러: 키위 품목이 없어요
▶ 직접 실행해 보기

out 매개변수를 쓰는 TryGetValue는 Option을 반환하는 get_mut이 돼요. 두 개의 throw는 Err 값이 되고, try/catch는 match가 돼요. 호출할 때 쓴 &mut stock을 보면 take가 딕셔너리를 바꾼다는 게 분명히 드러나요.

  • C#의 많은 부분을 그대로 가져갈 수 있어요. 제네릭, async/await, 람다, 패턴 매칭, LINQ 스타일의 반복자 모두 Rust에 아주 비슷한 것이 있어요.
  • 크게 달라지는 건 세 가지예요. 가비지 컬렉터 대신 소유권, null 대신 Option, 예외 대신 Result + ?.
  • 클래스는 struct + impl이 되고, 인터페이스는 트레이트가 되고, 상속은 컴포지션과 트레이트가 돼요. using/IDisposable은 자동으로 실행되는 Drop이 돼요.
  • Cargo가 dotnet CLI, MSBuild, NuGet을 대신해요. clippy와 rustfmt가 분석기와 dotnet format을 대신하고, 테스트는 기본으로 들어 있어요.
  • EF Core 대신 SeaORM이나 Diesel, Dapper 대신 sqlx, ASP.NET Core 대신 axum이나 actix-web을 살펴보세요. GUI, 게임, ML 쪽 선택지는 아직 젊어요.

이해도 점검

1. Rust는 null 대신 무엇을 쓰나요?

2. 팀에서 Dapper를 쓰고 있고, SQL을 직접 쓰는 걸 좋아해요. 가장 비슷한 Rust 크레이트는 무엇일까요?

3. C#에서 IDisposable과 함께 쓰는 using 블록에 가장 가까운 Rust 기능은 무엇일까요?

4. C#에서 클래스에 var b = a;를 하면 참조가 복사돼요. Rust에서 String에 let b = a;를 하면 어떻게 될까요?

점수: 0 / 4