JavaScript 개발자를 위한 Rust 입문
JavaScript나 TypeScript를 쓰고 있다면, 생각보다 Rust를 많이 알고 있는 거예요. Rust에도 화살표 함수와 아주 비슷하게 동작하는 클로저, async/await, map, filter, find처럼 익숙한 이름의 반복자 메서드, 구조 분해, 그리고 npm이 하는 일을 해 주는 커맨드 라인 도구 하나(Cargo)가 있어요. TypeScript를 쓴다면 코드를 실행하기 전에 실수를 잡아 주는 컴파일러에도 이미 익숙하겠죠. Rust 컴파일러도 같은 일을 해요. 다만 더 엄격해요.
그럼 정말 새로운 건 뭘까요? 세 가지예요. Rust에는 가비지 컬렉터가 없어요. 모든 값에는 주인(owner)이 하나씩 있고, 컴파일러가 프로그램을 실행하기 전에 소유권(ownership)과 대여(borrowing)를 검사해요. Rust에는 undefined도 null도 없어요. 없을 수도 있는 값의 타입은 Option이에요. 그리고 Rust에는 예외가 없어요. 실패할 수 있는 함수는 Result를 반환해요. 공부 시간은 대부분 이 세 가지 개념에 쓰게 될 거예요.
JavaScript 개발자는 왜 Rust를 배울까요? 보통 이런 이유 중 하나예요. 파일 하나로 배포되는 빠른 웹 백엔드나 커맨드 라인 도구를 만들고 싶거나, 브라우저의 무거운 부분을 WebAssembly로 빠르게 하고 싶거나, 네이티브 Node.js 애드온을 만들고 싶거나, 이미 쓰고 있는 도구가 궁금해서예요. SWC, Biome, Oxc, Rolldown, Rspack, Turbopack, Lightning CSS, Deno가 모두 Rust로 만들어졌어요.
이 페이지는 JavaScript와 TypeScript에서 알던 것을 Rust와 짝지어 보여 줘서, 정말 새로운 부분에 시간을 쓸 수 있게 도와드려요. C++, Go, Python과의 일반적인 비교가 궁금하다면 Rust vs C++, Go, Python을 보세요.
언어 개념: JavaScript와 TypeScript에서 Rust로
섹션 제목: “언어 개념: JavaScript와 TypeScript에서 Rust로”이 표를 회화 사전처럼 써 보세요. 마지막 열은 Rust 쪽을 설명하는 이 사이트의 강좌로 연결돼요.
| JavaScript / TypeScript | Rust | 더 배우기 |
|---|---|---|
let과 const |
let(바꿀 수 없음)과 let mut(바꿀 수 있음). JS의 const 객체는 안쪽을 바꿀 수 있지만, Rust의 let 값은 바꿀 수 없어요 |
변수 |
number |
크기를 직접 골라요: i32, i64, u32, usize, f64, … 정수와 실수는 서로 다른 타입이에요 |
데이터 타입 |
bigint |
i128 / u128, 크기 제한이 없어야 하면 num-bigint 크레이트 |
데이터 타입 |
string |
String(소유하고, 늘릴 수 있음)과 &str(빌려 온 텍스트 뷰) |
문자열 |
boolean |
bool |
불리언 |
| 템플릿 리터럴 | format!("Hi {name}")과 println!("Hi {name}") |
출력 |
any, unknown |
any는 없어요. 제네릭, 열거형, 또는 모양을 모르는 JSON에는 serde_json::Value를 써요 |
제네릭, Serde로 JSON 다루기 |
객체의 모양을 설명하는 interface나 type |
struct |
구조체 |
메서드를 설명하는 interface |
trait |
트레이트 |
| 구조적 타이핑 (모양이 같으면 같은 타입) | 명목적 타이핑: 필드가 같아도 구조체가 다르면 다른 타입이에요 | 구조체 |
| 유니언 타입과 판별 유니언(discriminated union) | 배리언트마다 자기 데이터를 담을 수 있는 enum |
열거형 |
"small"이나 "large" 같은 문자열 리터럴 타입 |
데이터 없는 배리언트로 만든 enum: enum Size { Small, Large } |
열거형 |
undefined, null, 선택적 프로퍼티 x?: T |
Option<T>: Some(value) 아니면 None |
Option |
?.와 ?? |
? (Option을 반환하는 함수 안에서), .map(...), .and_then(...), .unwrap_or(...) |
Option |
non-null 단언 x! |
.unwrap() 또는 .expect("이유"). 값이 None이면 프로그램을 멈춰요 |
panic |
throw, try/catch, 거부된 Promise |
Result<T, E> + ? 연산자 |
Result |
class MyError extends Error |
직접 만든 에러 열거형(보통 thiserror와 함께). 애플리케이션에서는 anyhow |
사용자 정의 에러, anyhow와 thiserror |
필드와 메서드가 있는 class |
데이터는 struct, 메서드는 impl 블록 |
구조체 |
extends (상속) |
상속은 없어요. 컴포지션(구조체 안에 구조체) + 기본 메서드가 있는 트레이트를 써요 | 트레이트, 디자인 패턴 |
class Dog implements Animal |
impl Animal for Dog |
트레이트 |
| 인터페이스가 같은 여러 클래스를 담은 배열 | Vec<Box<dyn Animal>> (트레이트 객체) |
트레이트 객체 |
T extends HasId 같은 제네릭 |
트레이트 바운드가 붙은 제네릭: T: HasId |
제네릭 |
| 화살표 함수와 콜백 | 클로저. Fn, FnMut, FnOnce 트레이트로 표현해요 |
클로저, Fn, FnMut, FnOnce |
this |
명시적인 self, &self, &mut self 매개변수. this가 뭘 가리키는지 헷갈릴 일이 없어요 |
구조체 |
구조 분해 const { x, y } = point |
패턴: let Point { x, y } = point; (match와 함수 매개변수에서도 써요) |
패턴 매칭 |
스프레드 { ...user, admin: true } |
구조체 갱신 문법: User { admin: true, ..user } |
구조체 |
switch, 또는 typeof / kind로 분기하는 if 체인 |
match (모든 경우를 다뤘는지 컴파일러가 검사해요) |
match |
=== |
==. 값을 비교해요(PartialEq 트레이트를 통해서). 타입 강제 변환은 절대 없어요 |
자주 쓰는 트레이트 |
for...of |
for item in &items |
for |
map, filter, reduce, find, some, every |
반복자 메서드: map, filter, fold, find, any, all, 그다음 collect |
반복자 |
제너레이터(function*)와 Symbol.iterator |
Iterator 트레이트 구현 |
사용자 정의 반복자 |
Promise<T> |
Output = T인 Future. Promise와 달리 await하기 전까지 아무것도 하지 않아요 |
async/await |
async / await |
async fn과 .await, 그리고 이걸 실행할 tokio 같은 런타임 |
async/await, async 깊이 알아보기 |
Promise.all |
tokio::join!, 또는 futures 크레이트의 join_all |
async 깊이 알아보기 |
setTimeout |
async 코드에서는 tokio::time::sleep, 그 밖에는 std::thread::sleep |
async/await |
Web Worker, worker_threads |
진짜 스레드: std::thread::spawn, 병렬 반복문에는 rayon |
동시성 |
ES 모듈: import, export |
모듈: mod, use, pub |
모듈 |
JSON.parse / JSON.stringify |
#[derive(Serialize, Deserialize)]와 함께 쓰는 serde_json |
Serde로 JSON 다루기 |
console.log |
println!, 빠른 디버깅에는 dbg!. Debug를 derive한 타입은 {:?}로 출력해요 |
출력 |
readonly, as const, Object.freeze |
필요 없어요. mut라고 쓰지 않으면 모두 읽기 전용이에요 |
변수 |
| 데코레이터 | #[derive(Debug)] 같은 어트리뷰트와 매크로 |
매크로 |
Number("abc")는 NaN을 줘요 |
"abc".parse::<i32>()는 꼭 처리해야 하는 Err를 반환해요 |
타입 변환, 타입 변환 (From, Into) |
| 가비지 컬렉터 | 소유권과 대여, 컴파일할 때 검사 | 소유권, 대여 |
컬렉션
섹션 제목: “컬렉션”| JavaScript | Rust | 더 배우기 |
|---|---|---|
Array |
Vec<T> (모든 항목의 타입이 같아요) |
벡터 |
길이가 고정된 배열, 또는 TS 튜플 [string, number] |
고정 길이는 [T; N], 타입이 섞이면 튜플 (String, i32) |
배열, 튜플 |
Map, 또는 딕셔너리로 쓰는 객체(Record<string, T>) |
HashMap<K, V> |
HashMap |
| 키가 정렬된 맵 | BTreeMap<K, V> |
HashMap |
Set |
HashSet<T> (정렬된 순서가 필요하면 BTreeSet<T>) |
HashSet과 BTreeMap |
큐로 쓰는 배열 (push + shift) |
VecDeque<T> (push_back / pop_front, 둘 다 빨라요) |
VecDeque와 BinaryHeap |
스택으로 쓰는 배열 (push + pop) |
push / pop을 쓰는 Vec<T> |
벡터 |
Uint8Array, Buffer |
Vec<u8>, 또는 바이트 슬라이스 &[u8] |
슬라이스 |
WeakMap, WeakRef |
Weak<T> (Rc나 Arc에서 만들어요) |
Weak와 Cow |
한 가지 주의할 점: JavaScript의 Map, Set, 평범한 객체는 넣은 순서를 기억해요. Rust의 HashMap과 HashSet은 기억하지 않아요. 정렬된 순서가 필요하면 BTreeMap을, 넣은 순서가 필요하면 바로 그걸 해 주는 indexmap 크레이트를 쓰세요.
도구: npm에서 cargo로
섹션 제목: “도구: npm에서 cargo로”Cargo는 집처럼 편하게 느껴질 거예요. 패키지 매니저, 빌드 도구, 테스트 실행기, 스크립트 실행기가 하나로 합쳐져 있고, 모든 Rust 프로젝트가 똑같은 방식으로 써요.
| JavaScript | Rust | 참고 |
|---|---|---|
node + npm (또는 pnpm, yarn) |
cargo |
빌드, 실행, 테스트, 패키지 추가, 배포까지. Cargo와 크레이트 참고 |
package.json |
Cargo.toml |
|
package-lock.json |
Cargo.lock |
자동으로 만들어져요 |
node_modules/ |
프로젝트마다 생기는 폴더는 없어요. 다운로드는 ~/.cargo에 한 번만 캐시되고, 빌드 결과는 target/에 들어가요 |
|
| npmjs.com | crates.io | 공개된 모든 크레이트의 API 문서는 docs.rs(영어)에 있어요 |
npm install zod |
cargo add serde |
|
npm init |
cargo new my_app (라이브러리는 cargo new --lib) |
|
npm run dev / build / test |
cargo run / cargo build / cargo test |
최적화된 빌드는 --release를 붙여요. debug 빌드는 훨씬 느려요 |
package.json의 "scripts" |
scripts 섹션은 없어요. .cargo/config.toml의 cargo 별칭이나 just 같은 작업 실행기를 써요 |
|
npx some-tool |
cargo install some-tool로 설치한 다음 실행 |
cargo binstall은 컴파일하지 않고 미리 빌드된 바이너리를 내려받아요 |
nvm과 .nvmrc |
rustup과 rust-toolchain.toml 파일 |
Rust 도구와 개발 환경 참고 |
| npm / pnpm 워크스페이스 (모노레포) | Cargo 워크스페이스 | 맨 위의 Cargo.toml 하나가 멤버 크레이트를 나열해요 |
tsc |
rustc. cargo가 대신 호출해 줘요 |
|
tsc --noEmit |
cargo check |
빌드하지 않고 타입만 검사해요. 훨씬 빨라요 |
| ESLint | cargo clippy |
도움이 되는 린트 수백 개, 공식 도구예요 |
| Prettier | cargo fmt |
모두가 쓰는 표준 스타일 하나 |
nodemon, tsx watch |
cargo watch 또는 bacon |
cargo install로 설치해요 |
| Jest / Vitest / Mocha | 내장된 #[test]와 cargo test (더 빠른 실행기로 cargo nextest가 인기예요) |
Rust 테스트 참고 |
| Jest 스냅샷 | insta |
테스트 더 알아보기 참고 |
jest.mock() |
mockall, 또는 트레이트를 구현한 직접 만든 가짜 객체 |
|
| fast-check | proptest |
속성 기반 테스트 |
| c8 / Istanbul (커버리지) | cargo llvm-cov |
|
| JSDoc / TypeDoc | 마크다운으로 쓰는 /// 문서 주석 + cargo doc |
문서 안의 코드 예제가 테스트로 실행돼요. Rust 문서화 참고 |
| VS Code + TypeScript 서버 | VS Code + rust-analyzer, 또는 JetBrains RustRover | rust-analyzer가 자동 완성, 정의로 이동, 실시간 에러 표시를 해 줘요 |
npm publish |
cargo publish |
package.json과 비교해 볼 수 있게 작은 Cargo.toml을 보여 드릴게요.
[package]name = "orders-api"version = "0.1.0"edition = "2021"
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }"1" 같은 버전은 package.json의 "^1"과 같은 뜻이에요. 호환되는 1.x 릴리스라면 무엇이든 괜찮다는 거예요. features는 크레이트에서 켜고 끌 수 있는 선택적인 부분이에요. Cargo 피처와 cfg를 참고하세요.
라이브러리와 프레임워크: “그럼 대신 뭘 쓰나요?”
섹션 제목: “라이브러리와 프레임워크: “그럼 대신 뭘 쓰나요?””JavaScript 개발자라면 누구나 가장 먼저 묻는 질문이에요. “우리는 Express와 Prisma를 쓰는데, Rust 버전은 뭐예요?” 가장 가까운 것들을 정리해 볼게요. npm처럼 crates.io에도 대부분의 일에 선택지가 많아요. 여기 있는 건 인기 있고 관리가 잘되는 것들이에요.
웹과 네트워크
섹션 제목: “웹과 네트워크”| JavaScript | Rust | 참고 |
|---|---|---|
| Express, Fastify, Koa | axum (프로젝트: Axum으로 웹 API에서 직접 만들어 봐요), actix-web, Rocket | axum과 actix-web은 실무에서 널리 쓰여요 |
| Express 미들웨어 | tower 레이어와 tower-http 크레이트(CORS, 압축, 트레이싱, 타임아웃) |
axum은 tower를 그대로 써요 |
fetch, axios |
reqwest |
기본은 비동기이고, 블로킹 클라이언트도 선택할 수 있어요. 네트워킹 참고 |
ws, Socket.IO |
axum의 WebSocket(ws 기능), tokio-tungstenite, 또는 socketioxide(Socket.IO 서버) |
|
| Apollo Server (GraphQL) | async-graphql |
|
gRPC (@grpc/grpc-js) |
tonic (protobuf는 prost와 함께) |
|
| Swagger / OpenAPI 생성기 | utoipa |
|
jsonwebtoken |
jsonwebtoken |
이름도 같고 아이디어도 같아요 |
bcrypt |
argon2 또는 bcrypt |
데이터 접근
섹션 제목: “데이터 접근”| JavaScript | Rust | 참고 |
|---|---|---|
| Prisma, TypeORM | SeaORM(비동기, 엔티티, 마이그레이션) 또는 Diesel(컴파일할 때 쿼리 타입을 검사) | Prisma와 TypeORM에 가장 가까운 느낌은 SeaORM이에요 |
Knex, pg, postgres.js |
sqlx: 순수 SQL을 직접 쓰고, query! 매크로가 컴파일할 때 실제 데이터베이스에 대고 검사해요 |
비동기, 아주 인기 있음, ORM은 아니에요 |
better-sqlite3 |
rusqlite, 또는 SQLite와 함께 쓰는 sqlx |
|
| Mongoose, MongoDB 드라이버 | mongodb (공식 드라이버) |
Mongoose 스타일의 스키마는 없어요. serde 구조체를 써요 |
| ioredis | redis |
데이터베이스에서 sqlx, Diesel, SeaORM을 동작하는 코드와 함께 볼 수 있어요.
매일 쓰는 기본 부품
섹션 제목: “매일 쓰는 기본 부품”| JavaScript | Rust | 참고 |
|---|---|---|
JSON.parse / JSON.stringify |
serde + serde_json |
사실상 표준이에요. TOML, YAML 등도 다뤄요. Serde로 JSON 다루기 참고 |
| Zod, Yup | 대부분은 구조체의 타입과 serde가 해결해요. “최소 8글자” 같은 규칙에는 validator나 garde |
JSON이 구조체와 맞지 않으면 파싱이 에러로 실패해요 |
| Lodash | 대부분은 표준 라이브러리의 반복자로 충분해요. unique, sorted_by, chunk_by 같은 추가 기능은 itertools |
|
| date-fns, Day.js, Luxon | chrono 또는 jiff |
날짜와 시간 참고 |
| dotenv | dotenvy |
환경 변수와 프로세스 참고 |
| Commander, yargs | clap |
프로젝트: clap으로 CLI 도구 참고 |
| chalk | colored 또는 owo-colors |
|
| winston, pino | tracing (+ tracing-subscriber), 또는 log + env_logger |
로깅 참고 |
uuid |
uuid |
|
EventEmitter |
채널 (std::sync::mpsc, 리스너가 여럿이면 tokio::sync::broadcast) |
채널 참고 |
| BullMQ (작업 큐) | 프로세스 안의 작업은 tokio::spawn, 영속적인 작업 큐는 apalis |
|
| 정규 표현식 | regex |
전방 탐색(lookahead)과 역참조는 일부러 지원하지 않아요. 정규 표현식 참고 |
프런트엔드, 데스크톱, Node.js
섹션 제목: “프런트엔드, 데스크톱, Node.js”| JavaScript | Rust | 참고 |
|---|---|---|
| 무거운 브라우저 코드 빠르게 하기 | wasm-bindgen과 wasm-pack으로 Rust를 WebAssembly로 컴파일 |
Rust와 WebAssembly와 아래 섹션 참고 |
| React, Vue, Svelte | Leptos, Yew, Dioxus (모두 WebAssembly로 컴파일돼요) |
모두 1.0 이전이라 API가 아직 바뀌어요 |
| Electron | Tauri: 웹 프런트엔드 + Rust 백엔드, 앱 크기가 훨씬 작아요 |
Tauri 2는 안정 버전이에요. GUI 앱 참고 |
| 네이티브 Node.js 애드온 (node-gyp와 C++) | napi + napi-derive (napi-rs), 또는 neon |
미리 빌드한 바이너리를 npm으로 배포해요. C++는 필요 없어요 |
| Phaser, Three.js 게임 | Bevy, macroquad |
Bevy는 1.0 이전이라 릴리스마다 API가 깨져요. 게임 개발 참고 |
나란히 보기: 인터페이스는 구조체로
섹션 제목: “나란히 보기: 인터페이스는 구조체로”인터페이스, 메서드 같은 함수, 스프레드 복사를 쓰는 TypeScript 객체예요.
interface User { name: string; age: number; admin: boolean;}
const greeting = (u: User) => `Hi, I'm ${u.name} (${u.age})`;
const ada: User = { name: "Ada", age: 36, admin: false };const promoted = { ...ada, admin: true };
console.log(greeting(ada));console.log(promoted);Rust에서는 모양이 구조체(struct)가 되고, 함수는 impl 블록 안의 메서드가 돼요.
#[derive(Debug, Clone)]struct User { name: String, age: u32, admin: bool,}
impl User { // `&self`는 "이 메서드는 사용자를 읽기만 하고 바꾸지 않아요"라는 뜻이에요. fn greeting(&self) -> String { format!("안녕하세요, 저는 {}이고 {}살이에요", self.name, self.age) }}
fn main() { let ada = User { name: "Ada".to_string(), age: 36, admin: false };
// JavaScript의 { ...ada, admin: true }와 같아요. let promoted = User { admin: true, ..ada.clone() };
println!("{}", ada.greeting()); println!("{:?}", promoted);}실행 결과:
안녕하세요, 저는 Ada이고 36살이에요User { name: "Ada", age: 36, admin: true }몇 가지가 달라요. 모든 필드에는 정확한 타입이 있어요. age는 막연한 number가 아니라 u32(음수가 될 수 없는 정수)예요. #[derive(Debug)] 덕분에 console.log(obj)처럼 {:?}로 구조체 전체를 출력할 수 있어요. 그럼 .clone()은 왜 있을까요? 스프레드 ..ada는 name 문자열을 ada 밖으로 이동시켜서, 그 뒤로는 ada를 쓸 수 없게 돼요. 먼저 복제하면 ada가 그대로 남아요. 이게 바로 소유권이 동작하는 모습이고, 앞으로 자주 보게 될 거예요.
나란히 보기: 판별 유니언은 열거형으로
섹션 제목: “나란히 보기: 판별 유니언은 열거형으로”TypeScript 개발자는 판별 유니언을 좋아해요. kind 필드가 어떤 모양인지 알려 주고, switch가 타입을 좁혀 줘요.
type Shape = | { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; case "rect": return s.width * s.height; }}
const shapes: Shape[] = [ { kind: "circle", radius: 1 }, { kind: "rect", width: 2, height: 3 },];for (const s of shapes) console.log(area(s).toFixed(2));Rust에는 이게 언어에 내장되어 있고, 이 언어의 가장 좋은 기능 중 하나예요. 열거형의 배리언트마다 자기 데이터를 담고, match가 그걸 분해해요.
enum Shape { Circle { radius: f64 }, Rect { width: f64, height: f64 },}
fn area(shape: &Shape) -> f64 { match shape { Shape::Circle { radius } => std::f64::consts::PI * radius * radius, Shape::Rect { width, height } => width * height, }}
fn main() { let shapes = vec![ Shape::Circle { radius: 1.0 }, Shape::Rect { width: 2.0, height: 3.0 }, ];
for shape in &shapes { println!("{:.2}", area(shape)); }}실행 결과:
3.146.00kind 문자열은 필요 없어요. 배리언트 자체가 태그예요. 그리고 match는 모든 배리언트를 다뤄야 해요. 나중에 Triangle을 추가하면, 그걸 빠뜨린 match는 모두 컴파일 에러가 돼요. TypeScript의 never를 이용한 완전성 검사 트릭과 비슷하지만, 항상 켜져 있어요. 자세한 내용은 패턴 매칭에서 다뤄요.
나란히 보기: undefined와 null은 Option으로
섹션 제목: “나란히 보기: undefined와 null은 Option으로”TypeScript에서는 없을 수도 있는 값이 undefined, null, 선택적 프로퍼티로 나타나고, ?.와 ??로 다뤄요.
interface User { name: string; email?: string;}
function findUser(id: number): User | undefined { if (id === 2) return { name: "Grace" }; return undefined;}
for (const id of [1, 2, 3]) { console.log(`${id}: ${findUser(id)?.email?.toUpperCase() ?? "no email"}`);}Rust에서는 없을 수도 있는 값의 타입이 Option<T>예요. Some(value) 아니면 None이고, None인 경우를 처리하기 전에는 평범한 값처럼 쓸 수 없어요.
struct User { name: String, email: Option<String>,}
fn find_user(id: u32) -> Option<User> { match id { 2 => Some(User { name: "Grace".to_string(), email: None }), _ => None, }}
// Option을 반환하는 함수 안에서는 `?`가 `?.`처럼 동작해요.// 값이 None이면 여기서 멈추고 None을 반환해요.fn email_domain(id: u32) -> Option<String> { let email = find_user(id)?.email?; let at = email.find('@')?; Some(email[at + 1..].to_string())}
fn main() { for id in [1, 2, 3] { // findUser(id)?.email?.toUpperCase() ?? "no email"과 같아요. let email = find_user(id) .and_then(|user| user.email) .map(|email| email.to_uppercase()) .unwrap_or("이메일 없음".to_string()); println!("{id}: {email}"); }
println!("{:?}", email_domain(1)); println!("{:?}", email_domain(2));
if let Some(user) = find_user(2) { println!("찾았어요: {}", user.name); }}실행 결과:
2: 이메일 없음3: 이메일 없음Some("example.com")None찾았어요: GraceRust에는 “cannot read properties of undefined”가 없어요. 읽으려고 할 undefined 자체가 없으니까요. 컴파일러는 Option<String>에 .to_uppercase()를 호출하는 걸 아예 허락하지 않아요. 그리고 null과 undefined의 차이를 기억할 필요도 없어요. None 하나뿐이에요. 도우미 메서드는 Option 페이지에서, 짧게 쓰는 방법은 if let / while let에서 배워 보세요.
나란히 보기: try/catch는 Result와 ?로
섹션 제목: “나란히 보기: try/catch는 Result와 ?로”JavaScript에서는 함수가 예외를 던지고, 호출 스택 위쪽 어딘가에서 그 에러를 잡아요. 함수 시그니처에는 예외를 던질 수 있다는 표시가 전혀 없어요.
function parseAge(input) { const age = Number(input); if (!Number.isInteger(age)) throw new Error(`'${input}' is not a whole number`); if (age > 150) throw new Error(`${age} is too old`); return age;}
function totalAge(a, b) { return parseAge(a) + parseAge(b);}
for (const [a, b] of [["36", "41"], ["36", "abc"], ["36", "200"]]) { try { console.log(`Total: ${totalAge(a, b)}`); } catch (e) { console.log(`Error: ${e.message}`); }}Rust에서는 실패할 수 있는 함수가 Result를 반환해요. Ok(value) 아니면 Err(error)예요. ? 연산자는 “이게 실패했으면 지금 바로 호출한 쪽으로 에러를 돌려줘”라는 뜻이에요. 예외가 위로 전파되는 것과 같은 일을 하지만, 코드에 눈에 보이게 드러나요.
fn parse_age(input: &str) -> Result<u32, String> { let age: u32 = input .parse() .map_err(|_| format!("'{input}'은(는) 정수가 아니에요"))?;
if age > 150 { return Err(format!("나이가 너무 많아요: {age}")); } Ok(age)}
fn total_age(a: &str, b: &str) -> Result<u32, String> { // `?`마다 예외처럼 에러를 호출한 쪽으로 넘겨요. let first = parse_age(a)?; let second = parse_age(b)?; Ok(first + second)}
fn main() { for (a, b) in [("36", "41"), ("36", "abc"), ("36", "200")] { match total_age(a, b) { Ok(total) => println!("합계: {total}"), Err(e) => println!("에러: {e}"), } }}실행 결과:
합계: 77에러: 'abc'은(는) 정수가 아니에요에러: 나이가 너무 많아요: 200-> Result<u32, String>이라는 시그니처가 모든 호출자에게 “이건 실패할 수 있어요”라고 알려 주고, 컴파일러는 그걸 무시하지 못하게 해요. async 코드도 마찬가지예요. JavaScript라면 거부된 Promise가 나올 곳에서 Rust의 async 함수는 Result를 반환하고, 여러분은 .await?라고 써요. 직접 에러 타입을 만들려면 사용자 정의 에러를 보세요. 애플리케이션에서는 anyhow 크레이트가 “어떤 에러든 잡기”에 가까운 방법을 제공해요(anyhow와 thiserror 참고).
나란히 보기: 배열 메서드는 반복자로
섹션 제목: “나란히 보기: 배열 메서드는 반복자로”매일 쓰는 배열 메서드에는 Rust에 바로 대응하는 것이 있어요.
const orders = [ { customer: "Ada", total: 120, paid: true }, { customer: "Grace", total: 80, paid: false }, { customer: "Linus", total: 200, paid: true },];
const paidNames = orders.filter((o) => o.paid).map((o) => o.customer);const revenue = orders.filter((o) => o.paid).reduce((sum, o) => sum + o.total, 0);const firstBig = orders.find((o) => o.total > 150);
console.log(paidNames.join(", "));console.log(`Revenue: ${revenue}`);console.log(`First big order: ${firstBig?.customer ?? "none"}`);console.log(`Anything unpaid? ${orders.some((o) => !o.paid)}`);Rust 버전은 반복자와 클로저를 써요. 클로저 문법은 (o) => o.paid 대신 |o| o.paid예요.
struct Order { customer: String, total: u32, paid: bool,}
fn main() { let orders = vec![ Order { customer: "Ada".to_string(), total: 120, paid: true }, Order { customer: "Grace".to_string(), total: 80, paid: false }, Order { customer: "Linus".to_string(), total: 200, paid: true }, ];
// filter + map, 그다음 collect()가 결과를 Vec으로 만들어요. let paid_names: Vec<&str> = orders .iter() .filter(|o| o.paid) .map(|o| o.customer.as_str()) .collect();
// 시작값이 있는 reduce는 fold라고 불러요. let revenue = orders .iter() .filter(|o| o.paid) .fold(0, |sum, o| sum + o.total);
// 맞는 게 없을 수도 있어서 find는 Option을 반환해요. let first_big = orders.iter().find(|o| o.total > 150);
println!("{}", paid_names.join(", ")); println!("매출: {revenue}"); match first_big { Some(o) => println!("첫 번째 큰 주문: {}", o.customer), None => println!("첫 번째 큰 주문: 없음"), } // some()은 any()라고 불러요. println!("미결제 주문이 있나요? {}", orders.iter().any(|o| !o.paid));}실행 결과:
Ada, Linus매출: 320첫 번째 큰 주문: Linus미결제 주문이 있나요? trueJavaScript와 큰 차이가 하나 있어요. JS의 배열 메서드는 호출할 때마다 새 배열 전체를 바로 만들어요. Rust 반복자는 게을러요(lazy). filter와 map은 그 자체로는 아무것도 하지 않고, collect(), sum(), fold(), for 반복문 같은 걸 호출할 때 한 번에 처리돼요. 중간에 임시 배열이 만들어지지 않아요. 합계만 필요하다면 .map(|o| o.total).sum::<u32>()라고 써도 돼요. 정렬은 반복자가 아니라 Vec에 있어요(sort, sort_by). 정렬과 검색을 참고하세요.
나란히 보기: Promise는 future + tokio로
섹션 제목: “나란히 보기: Promise는 future + tokio로”async JavaScript 코드예요. "fetching 1"이 언제 출력되는지 잘 보세요.
async function fetchUser(id) { console.log(`fetching ${id}`); await new Promise((resolve) => setTimeout(resolve, 100)); return `user ${id}`;}
const promise = fetchUser(1); // "fetching 1"이 바로 출력돼요console.log("promise created");console.log(await promise);
const [a, b] = await Promise.all([fetchUser(2), fetchUser(3)]);console.log(`${a}, ${b}`);JavaScript에서는 async 함수를 호출하는 순간 실행이 시작돼요. Rust는 달라요. async fn을 호출하면 future, 즉 할 일에 대한 설명만 만들어져요. .await하기 전까지는 아무것도 실행되지 않아요. 그리고 Rust에는 내장 이벤트 루프가 없어서 런타임을 추가해요. 가장 인기 있는 건 tokio(영어)예요.
use std::time::Duration;
async fn fetch_user(id: u32) -> String { println!("{id}번 사용자를 가져오는 중"); tokio::time::sleep(Duration::from_millis(100)).await; format!("사용자 {id}")}
#[tokio::main]async fn main() { // future를 만든다고 실행이 시작되지는 않아요. 아직 아무것도 출력되지 않아요. let future = fetch_user(1); println!("future를 만들었어요"); println!("{}", future.await);
// Promise.all처럼 둘을 동시에 실행하고 둘 다 기다려요. let (a, b) = tokio::join!(fetch_user(2), fetch_user(3)); println!("{a}, {b}");}실행 결과:
future를 만들었어요1번 사용자를 가져오는 중사용자 12번 사용자를 가져오는 중3번 사용자를 가져오는 중사용자 2, 사용자 3순서가 보이나요? JavaScript에서는 “fetching 1”이 “promise created”보다 먼저 나와요. Rust에서는 “1번 사용자를 가져오는 중”이 “future를 만들었어요” 뒤에 나와요. future는 .await에서야 시작되기 때문이에요. #[tokio::main]은 런타임(여러분의 이벤트 루프)을 준비하고 그 위에서 main을 실행해요. tokio::join!은 Promise.all처럼 두 future를 함께 기다려요. tokio::spawn은 태스크를 백그라운드에서 시작하는데, async 함수를 await하지 않고 호출하는 것과 가장 비슷해요. 자세한 내용은 async/await와 async 깊이 알아보기 강좌에서 설명해요.
JavaScript 프로젝트 안의 Rust: WebAssembly와 Node 애드온
섹션 제목: “JavaScript 프로젝트 안의 Rust: WebAssembly와 Node 애드온”Rust를 쓰기 시작하려고 뭔가를 다시 작성할 필요는 없어요. JavaScript 프로젝트에 Rust를 섞는 흔한 방법이 두 가지 있어요.
브라우저의 WebAssembly. Rust 함수에 #[wasm_bindgen]을 붙이고, wasm-pack으로 빌드하고, 결과를 다른 ES 모듈처럼 import해요. wasm-pack은 .d.ts 파일까지 만들어 줘서 TypeScript가 함수의 타입을 알 수 있어요.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]pub fn slugify(title: &str) -> String { title .to_lowercase() .split_whitespace() .collect::<Vec<_>>() .join("-")}import init, { slugify } from "./pkg/my_lib.js";
await init(); // .wasm 파일을 내려받아 시작해요console.log(slugify("Hello Rust World")); // "hello-rust-world"Rust와 WebAssembly 강좌에서 전체 설정을 차근차근 따라 할 수 있고, 어떤 값이 경계를 싸게 넘나드는지도 설명해요. 팁 하나: Wasm은 무거운 계산(이미지 처리, 파싱, 시뮬레이션)에서 이득을 봐요. 수천 번 호출하는 작은 함수에는 맞지 않아요. JS와 Wasm 사이의 호출마다 작은 비용이 들기 때문이에요.
네이티브 Node.js 애드온. napi-rs(영어)(napi와 napi-derive 크레이트)를 쓰면 Rust 함수에 #[napi]를 붙이는 것만으로 Node에서 require나 import할 수 있는 함수가 돼요. 인기 있는 npm 패키지 중 많은 수가 이런 식으로 미리 빌드한 Rust 바이너리를 배포해서, 사용자는 Rust 컴파일러가 전혀 필요 없어요.
JavaScript 개발자가 자주 걸려 넘어지는 부분
섹션 제목: “JavaScript 개발자가 자주 걸려 넘어지는 부분”JavaScript에서 온 사람은 누구나 같은 곳에서 걸려요. 미리 알고 있으면 훨씬 수월해요.
함수에 값을 넘기면 이동해요. JavaScript에서 함수에 배열을 넘기면 참조가 넘어가고, 그 뒤에도 배열을 계속 쓸 수 있어요. Rust에서 Vec이나 String을 값으로 넘기면 소유권이 함수 안으로 이동하고, 원래 변수는 더 이상 쓸 수 없어요.
fn print_all(names: Vec<String>) { for name in names { println!("{name}"); }}
fn main() { let names = vec![String::from("Ada"), String::from("Grace")]; print_all(names); println!("{} names", names.len());}error[E0382]: borrow of moved value: `names` --> src/main.rs:10:26 | 8 | let names = vec![String::from("Ada"), String::from("Grace")]; | ----- move occurs because `names` has type `Vec<String>`, which does not implement the `Copy` trait 9 | print_all(names); | ----- value moved here10 | println!("{} names", names.len()); | ^^^^^ value borrowed here after move컴파일러 메시지는 영어로 나와요. “names의 값은 9번째 줄에서 print_all로 이동했는데, 10번째 줄에서 이동한 값을 다시 빌리려고 했다”는 뜻이에요.
보통은 대신 빌리게 해서 해결해요. 함수가 names: &[String]을 받게 하고 print_all(&names)로 호출하세요. 소유권과 대여를 먼저 읽어 보세요. 이 규칙을 설명하는 페이지이고, 매일 마주치게 될 거예요.
숫자에는 타입이 있고, 서로 섞이지 않아요. JavaScript에는 number 하나뿐이에요. Rust에서 3은 정수이고 2.5는 실수라서, 변환하지 않고는 함께 계산할 수 없어요.
fn main() { let items = 3; let price = 2.5; let total = items * price; println!("{total}");}error[E0277]: cannot multiply `{integer}` by `{float}` --> src/main.rs:4:23 |4 | let total = items * price; | ^ no implementation for `{integer} * {float}`“정수에 실수를 곱할 수 없다”는 뜻이에요. items as f64 * price라고 쓰거나, 처음부터 items를 실수(3.0)로 만드세요. 또 정수 나눗셈은 나머지를 버린다는 것도 기억하세요. 7 / 2는 3.5가 아니라 3이에요. 데이터 타입과 타입 변환을 참고하세요.
마주치게 될 것들이 몇 가지 더 있어요.
- future는 게으르고 런타임이 필요해요. 위에서 봤듯이
.await전까지는 아무 일도 일어나지 않고, async 코드를 실행하려면 tokio(또는 다른 런타임)가 꼭 필요해요..await를 깜빡하면 실행되는 태스크가 아니라 컴파일러 경고를 얻어요. - 문자열에 인덱스로 접근할 수 없어요.
name[0]은 컴파일되지 않아요. Rust의String은 UTF-8이고 한 글자가 여러 바이트일 수 있기 때문이에요.name.chars().next()나.chars().nth(i)를 쓰세요. 그리고String과&str의 차이를 일찍 익혀 두세요. 함수는 보통&str을 받고, 구조체는 보통String을 저장해요. 문자열을 참고하세요. - 구조적 타이핑도,
any도 없어요. TypeScript에서는 필드만 맞으면 어떤 객체든 인터페이스에 들어맞아요. Rust에서User를 받는 함수는User만 받아요. 공통 동작에는 트레이트를 쓰세요. 모양을 모르는 JSON에는serde_json::Value를 쓰세요. - 공유하면서 바꾸는 객체 그래프는 어려워요. JavaScript에서는 객체 하나를 여기저기 넘기고 모두가 바꾸게 하거나, 객체가 부모를 다시 가리키게 하는 게 평범해요. 대여 검사기(borrow checker)는 읽는 쪽 여럿 아니면 쓰는 쪽 하나만 허락하고, 둘을 동시에 허락하지는 않아요. 이런 패턴에는 스마트 포인터(
Rc<RefCell<T>>,Arc<Mutex<T>>)가 필요하거나, 아예 다른 설계가 더 나아요. 몇 주는 답답할 거예요. 그래도 금방 훨씬 쉬워져요. HashMap에는 순서가 없어요. JavaScript의Map과 객체는 넣은 순서를 지켜서, 코드가 자기도 모르게 거기에 기대는 경우가 많아요. Rust의HashMap은 순서를 지키지 않아요. 출력하기 전에 정렬하거나,BTreeMap이나indexmap크레이트를 쓰세요.- 예외가 없어요. 에러는 값이고,
?를 아주 많이 쓰게 될 거예요. 배우는 동안.unwrap()을 써도 괜찮지만, 이건 TypeScript의!에 해당하는 Rust판 “믿어 봐, 값 있어”예요. 틀리면 프로그램이 패닉해요. - 컴파일 시간.
node app.js같은 즉시 실행은 없어요. 크레이트가 많으면 첫 빌드에 시간이 좀 걸리고, 그다음 빌드는 증분 빌드라 더 빨라요. 코드를 고치는 동안에는 코드 생성을 건너뛰는cargo check를 쓰세요.
JavaScript 개발자를 위한 학습 경로
섹션 제목: “JavaScript 개발자를 위한 학습 경로”이 사이트를 순서대로 읽을 필요는 없어요. JavaScript나 TypeScript 개발자라면 이 경로로 이미 아는 건 건너뛰고, Rust가 다른 부분에 시간을 쓸 수 있어요.
- Rust 시작하기와 Rust 도구와 개발 환경: Rust를 설치하고, VS Code에 rust-analyzer를 설정하고,
cargo new를 실행해요. - 변수와 데이터 타입: 제대로 읽어 보세요. 숫자 타입이 새로울 거예요.
- 소유권, 대여, 슬라이스 순서로: 여러분에게 가장 중요한 페이지예요. 천천히 읽으세요.
- 문자열:
String과&str. - 구조체와 열거형, 그다음 match와 패턴 매칭: 여러분의 인터페이스와 판별 유니언이에요.
- Option과 Result, 그다음 사용자 정의 에러.
- 클로저와 반복자: 여러분의 배열 메서드예요.
- 벡터, HashMap, HashSet과 BTreeMap.
- 트레이트, 제네릭, 자주 쓰는 트레이트.
- 모듈, Cargo와 크레이트, 테스트.
- 라이프타임과 스마트 포인터.
- async/await와 async 깊이 알아보기.
- Serde로 JSON 다루기, 그다음 웹 API 프로젝트를 만들거나 WebAssembly를 써 봐요.
연습 문제
섹션 제목: “연습 문제”두 문제 모두 JavaScript나 TypeScript 코드를 주고, 그걸 Rust로 옮기는(포팅하는) 문제예요.
1. Map으로 단어 세기
섹션 제목: “1. Map으로 단어 세기”이 JavaScript 코드는 단어마다 몇 번 나오는지 센 다음, 많이 나온 단어부터 출력해요(개수가 같으면 알파벳순). Rust로 옮겨 보세요. 힌트: HashMap<&str, u32>와 *counts.entry(word).or_insert(0) += 1을 쓰세요. 정렬하려면 맵을 (단어, 개수) 쌍의 Vec으로 모은 다음 sort_by를 쓰세요.
const text = "the cat and the hat and the bat";
const counts = new Map();for (const word of text.split(" ")) { counts.set(word, (counts.get(word) ?? 0) + 1);}
const sorted = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));for (const [word, n] of sorted) console.log(`${word}: ${n}`);정답 보기
use std::collections::HashMap;
fn main() { let text = "the cat and the hat and the bat";
let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split(' ') { *counts.entry(word).or_insert(0) += 1; }
// [...counts]처럼 맵을 (단어, 개수) 쌍의 목록으로 바꿔요. let mut sorted: Vec<(&str, u32)> = counts.into_iter().collect(); // 많이 나온 것부터, 개수가 같으면 알파벳순으로. sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (word, n) in sorted { println!("{word}: {n}"); }}실행 결과:
the: 3and: 2bat: 1cat: 1hat: 1entry(word).or_insert(0)은 counts.get(word) ?? 0 부분과 set을 한 번에 해요. 개수를 가리키는 가변 참조를 돌려주고, 없으면 0으로 만들어 줘요. 여기서 정렬은 있으면 좋은 정도가 아니라 꼭 필요해요. 정렬하지 않으면 HashMap은 실행할 때마다 다른 순서로 출력해요. 이 예제는 알파벳순 정렬이 결과에 영향을 주기 때문에 단어를 영어로 두었어요.
2. 기본값이 있는 선택적 설정 옮기기
섹션 제목: “2. 기본값이 있는 선택적 설정 옮기기”이 TypeScript 함수는 모든 필드가 선택적인 옵션 객체를 받고, ??로 기본값을 채워요. Rust로 옮겨 보세요. 힌트: Option 필드를 가진 구조체를 만들고 #[derive(Default)]를 붙이세요. 그러면 ServerOptions { port: Some(3000), ..Default::default() }가 { port: 3000 }을 넘기는 것처럼 동작해요. bool의 기본값은 false예요.
interface ServerOptions { host?: string; port?: number; verbose?: boolean;}
function describe(opts: ServerOptions): string { const host = opts.host ?? "localhost"; const port = opts.port ?? 8080; const mode = opts.verbose ? "verbose" : "quiet"; return `${host}:${port} (${mode})`;}
console.log(describe({}));console.log(describe({ port: 3000 }));console.log(describe({ host: "example.com", verbose: true }));정답 보기
#[derive(Default)]struct ServerOptions { host: Option<String>, port: Option<u16>, verbose: bool,}
fn describe(opts: &ServerOptions) -> String { let host = opts.host.as_deref().unwrap_or("localhost"); let port = opts.port.unwrap_or(8080); let mode = if opts.verbose { "verbose" } else { "quiet" }; format!("{host}:{port} ({mode})")}
fn main() { println!("{}", describe(&ServerOptions::default())); println!("{}", describe(&ServerOptions { port: Some(3000), ..Default::default() })); println!( "{}", describe(&ServerOptions { host: Some("example.com".to_string()), verbose: true, ..Default::default() }) );}실행 결과:
localhost:8080 (quiet)localhost:3000 (quiet)example.com:8080 (verbose)unwrap_or가 여러분의 ??예요. as_deref()는 Option<String>을 Option<&str>로 바꿔서, 아무것도 복사하지 않고 평범한 텍스트 "localhost"로 대신할 수 있게 해 줘요. 그리고 ..Default::default()는 “언급하지 않은 필드는 모두 기본값으로”라는 Rust식 표현이에요. JavaScript에서 프로퍼티를 빼먹는 것과 정확히 같은 일을 해요. 포트를 u16으로 두면 70000 같은 포트는 실수로라도 저장할 수가 없다는 장점도 있어요.
- JavaScript와 TypeScript의 많은 부분을 그대로 가져갈 수 있어요. 클로저, async/await, 구조 분해, 그리고
map,filter,find같은 배열 스타일 메서드 모두 Rust에 아주 비슷한 것이 있어요. - 크게 달라지는 건 세 가지예요. 가비지 컬렉터 대신 소유권,
undefined와null대신Option,throw와try/catch대신Result+?. - TypeScript의 인터페이스는 구조체(데이터)나 트레이트(동작)가 되고, 판별 유니언은
match와 함께 쓰는 열거형이 돼요. - Rust의 future는 게으르고 tokio 같은 런타임이 필요해요.
Promise.all역할은tokio::join!이 해요. - Cargo가 npm,
package.json, 스크립트를 대신해요. clippy와 rustfmt가 ESLint와 Prettier를 대신하고, 테스트는 기본으로 들어 있어요. - Express 대신 axum이나 actix-web,
fetch대신 reqwest, Prisma 대신 SeaORM이나 Diesel, Electron 대신 Tauri를 살펴보세요. WebAssembly나 napi-rs로 JS 프로젝트에 Rust를 들여올 수도 있어요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. Rust는 undefined와 null 대신 무엇을 쓰나요?
2. JavaScript에서는 async 함수를 호출하면 바로 실행이 시작돼요. Rust에서 async fn을 .await 없이 호출하면 어떻게 될까요?
3. TypeScript 코드에서 kind 필드가 있는 판별 유니언을 쓰고 있어요. 가장 가까운 Rust 기능은 무엇일까요?
4. 팀의 백엔드가 Express를 쓰고 있어요. 가장 비슷한 Rust 크레이트는 무엇일까요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”- The Rust Programming Language 한국어판: 공식 책의 한국어 번역이에요. JavaScript 개발자에게는 4장(소유권), 6장(열거형과 패턴 매칭), 9장(에러 처리)이 가장 중요해요.
- Tokio 튜토리얼 (영어): tokio로 하는 async Rust의 공식 가이드예요. 첫 태스크부터 채널과 I/O까지 다뤄요.
- The wasm-bindgen Guide (영어): WebAssembly에서 Rust와 JavaScript가 서로 대화하는 방법을 많은 예제와 함께 설명해요.
- Rust를 WebAssembly로 컴파일하기 (MDN): MDN Web Docs의 단계별 튜토리얼이에요. npm에 배포하는 방법까지 다뤄요.
- napi-rs (영어): Rust로 Node.js 애드온을 만드는 방법을 담은 문서예요.