Rust 시작하기 | 메모리 안전한 시스템 프로그래밍 언어
이 글의 핵심
Rust 시리즈 첫 편으로, 설치부터 첫 프로젝트 실행까지의 흐름을 잡는 데 집중합니다. 다른 언어에서 넘어온 사람이 가장 먼저 부딪히는 불변 기본 변수와 소유권 개념을 짧게 맛보고, 다음 편에서 소유권을 본격적으로 다루기 전에 Cargo 프로젝트 구조와 기본 문법을 손에 익히도록 구성했습니다.
시리즈 안내
들어가며
Rust란?
Rust는 원래 Mozilla에서 개발이 시작된 메모리 안전을 컴파일 타임(소스를 빌드할 때)에 잡는 시스템 프로그래밍 언어입니다. 힙 메모리는 소유권 규칙으로 다루는데, 값마다 열쇠가 하나라고 이해하시면 이후 글(소유권)로 자연스럽게 이어집니다. 특징:
- 메모리 안전: 컴파일 타임 보장
- 제로 코스트 추상화: 반복자·제네릭 같은 추상화를 써도 런타임(프로그램이 실제로 실행되는 때) 비용이 손으로 짠 코드 수준에 머물도록 설계
- 동시성: 안전한 멀티스레딩
- 성능: C/C++ 수준
- 패키지 관리: Cargo 내장 Rust vs C++: | 특징 | Rust | C++ | |------|------|-----| | 메모리 안전 | 컴파일 타임 | 런타임 (선택) | | Null 포인터 | 없음 (Option) | 가능 | | 패키지 관리 | Cargo | CMake, vcpkg | | 학습 곡선 | 가파름 | 매우 가파름 |
Cargo는 빌드·의존성·워크스페이스를 한 도구에 묶습니다. C++ 쪽에서는 CMake가 빌드 생성을, Conan·vcpkg가 라이브러리 공급을 나눠 담는 경우가 많고, npm·Go 모듈·Python pip·uv·Poetry와 “의존성 선언·락”을 대응시켜 보면 생태계 차이가 분명해집니다. C++ 빌드 시스템 완전 비교에서 언어별 철학을 더 깊게 다룹니다.
Rust와의 첫 만남
“빌려주기 검사기(Borrow Checker)와 싸우는 게 프로그래밍의 반”이라는 농담이 있을 정도로, Rust는 처음에 정말 어렵습니다. 저도 첫 프로젝트에서 컴파일러 에러와 씨름하며 “이게 정말 생산성이 높은 언어인가?” 의심했습니다. 하지만 몇 주간 고생 끝에 컴파일이 통과된 코드는 런타임 에러가 거의 없다는 걸 깨달았습니다. C++에서는 세그멘테이션 폴트가 프로덕션에서 터지는 악몽을 자주 겪었는데, Rust에서는 그런 종류의 버그를 컴파일러가 미리 잡아 주므로 걱정이 크게 줄었습니다(물론 panic이나 논리 오류까지 사라지는 것은 아닙니다). 특히 멀티스레드 코드를 작성할 때 이 차이가 극명합니다. C++에서는 데이터 레이스를 찾느라 디버거와 씨름했지만, Rust는 컴파일 단계에서 “이 코드는 스레드 안전하지 않아”라고 알려줍니다. 처음엔 답답했지만, 지금은 이 엄격함이 감사합니다.
rustup으로 툴체인 설치하기
rustup 설치
Windows:
- rustup.rs 다운로드
- 설치 프로그램 실행 Mac/Linux:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
설치 확인
rustc --version
cargo --version
rustup은 Rust 컴파일러(rustc), 빌드 도구(cargo), 표준 라이브러리를 툴체인 단위로 설치·관리하는 도구입니다. 배포판 패키지 매니저(apt install rustc)로 설치하면 버전이 오래된 경우가 많아, 최근 문법을 쓰는 크레이트가 requires rustc 1.xx or newer 같은 오류로 빌드되지 않는 일이 흔합니다. rustup으로 설치했다면 rustup update로 최신 안정 버전에 맞출 수 있고, 프로젝트 루트에 rust-toolchain.toml을 두면 팀 전체가 같은 버전을 쓰게 고정할 수도 있습니다. 설치 직후 command not found가 나오면 ~/.cargo/bin이 PATH에 추가되도록 셸을 새로 열거나 source "$HOME/.cargo/env"를 실행하세요. Windows에서는 기본 툴체인이 Microsoft 링커를 쓰므로 Visual Studio Build Tools의 C++ 워크로드가 필요하며, 없으면 첫 빌드에서 linker 'link.exe' not found 오류가 납니다.
cargo new로 Hello World 실행하기
프로젝트 생성
cargo new hello_rust
cd hello_rust
src/main.rs
fn main() {
println!("Hello, Rust!");
}
실행
cargo run
println! 끝의 느낌표는 이것이 함수가 아니라 매크로라는 표시입니다. 매크로는 컴파일 시점에 코드를 생성하므로, println!("{} {}", a)처럼 자리표시자와 인자 개수가 맞지 않으면 실행 전에 컴파일 에러가 납니다. C의 printf에서 런타임 버그가 되던 문제를 컴파일러가 막아 주는 것입니다. Rust 2021 이후에는 println!("{x}")처럼 변수 이름을 중괄호 안에 직접 쓸 수도 있습니다. cargo run은 먼저 빌드를 하고 target/debug/hello_rust 실행 파일을 실행하는데, target 폴더는 빌드 산출물이라 저장소에 커밋하지 않습니다. cargo new가 만들어 주는 .gitignore에 이미 들어 있습니다.
Cargo 프로젝트 구조와 자주 쓰는 명령
프로젝트 구조
hello_rust/
├── Cargo.toml
├── Cargo.lock
└── src/
└── main.rs
Cargo.toml
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2024" # 최신 cargo new 기본값 (예전 튜토리얼에서는 2021)
[dependencies]
Cargo.toml은 사람이 쓰는 의존성 선언이고, Cargo.lock은 cargo가 계산한 정확한 버전 목록입니다. [dependencies]에 serde = "1.0"이라고 쓰면 cargo는 이를 “1.0 이상 2.0 미만의 호환 버전”으로 해석하고, 실제로 선택한 버전을 Cargo.lock에 기록합니다. 그래서 같은 코드를 다른 사람이 빌드해도 같은 버전을 쓰게 됩니다. 실행 파일(바이너리) 프로젝트는 Cargo.lock을 커밋하는 것이 원칙이고, 라이브러리도 지금은 커밋을 권장하는 쪽으로 가이드가 바뀌었습니다. Cargo.lock은 첫 빌드 때 생성되므로 cargo new 직후에는 보이지 않을 수 있습니다. 의존성은 파일을 직접 고치는 대신 cargo add serde로 추가하면 최신 호환 버전이 자동으로 들어갑니다.
명령어
cargo new project_name # 프로젝트 생성
cargo build # 빌드
cargo run # 빌드 + 실행
cargo test # 테스트
cargo check # 빠른 체크
cargo build --release # 릴리스 빌드
개발 중에 가장 자주 쓰게 되는 것은 cargo check입니다. 타입 검사와 빌림 검사까지만 하고 기계어를 만들지 않아 cargo build보다 훨씬 빠르므로, 코드를 고치면서 컴파일 에러를 확인하는 반복에 적합합니다. 디버그 빌드(cargo build)와 릴리스 빌드(--release)의 성능 차이는 C++보다 훨씬 커서, 디버그 빌드로 성능을 재고 “Rust가 느리다”고 결론 내리는 실수가 흔합니다. 반복자와 제네릭 같은 추상화가 인라이닝과 최적화를 거쳐야 비로소 손으로 짠 코드 수준이 되기 때문입니다. 성능을 비교하거나 배포할 때는 반드시 --release로 빌드하세요. 산출물은 target/release/에 생깁니다.
변수와 함수 선언 문법
변수
fn main() {
// 불변 (기본)
let x = 5;
// x = 6; // 에러!
// 가변
let mut y = 5;
y = 6; // OK
// 타입 명시
let z: i32 = 10;
}
변수가 기본적으로 불변인 이유는 “값이 바뀌는 곳”을 코드에서 눈에 띄게 만들기 위해서입니다. 다른 언어에서 넘어온 사람이 가장 먼저 만나는 오류가 x = 6에서 나는 cannot assign twice to immutable variable 'x'(E0384)인데, 컴파일러는 친절하게 let mut x로 바꾸라는 제안까지 보여 줍니다. 이와 비슷해 보이지만 다른 개념이 섀도잉입니다. let x = 5; let x = x + 1;처럼 같은 이름으로 새 변수를 선언하면 이전 x를 가리게 되는데, 새 변수이므로 타입도 바꿀 수 있습니다(let input = "42"; let input: i32 = input.parse().unwrap();). mut는 같은 변수의 값을 바꾸는 것이고 섀도잉은 새 변수를 만드는 것이라, 변환 단계마다 이름을 새로 짓지 않아도 되는 관용구로 자주 쓰입니다. 타입 명시(: i32)는 대부분 생략해도 컴파일러가 추론하지만, 정수 리터럴의 기본 타입은 i32, 실수는 f64라는 점은 알아 두면 좋습니다.
함수
fn add(a: i32, b: i32) -> i32 {
a + b // return 생략 가능
}
fn main() {
let result = add(10, 20);
println!("결과: {}", result);
}
a + b 뒤에 세미콜론이 없다는 점이 중요합니다. Rust에서 블록의 마지막 식은 그 블록의 값이 되므로, 세미콜론 없이 끝나면 함수의 반환값이 됩니다. 여기에 습관처럼 세미콜론을 붙여 a + b;로 쓰면 문장이 되어 값이 사라지고, 함수는 ()(빈 튜플)을 반환하게 되어 mismatched types: expected i32, found () 오류가 납니다. 컴파일러가 “remove this semicolon”이라는 도움말을 함께 주므로 금방 고칠 수 있지만, 처음 몇 번은 누구나 겪는 실수입니다. 함수 매개변수의 타입은 추론되지 않고 반드시 적어야 한다는 점도 지역 변수와 다릅니다. 함수 시그니처가 곧 계약이기 때문입니다.
소유권과 빌림 맛보기
기본 개념
Rust의 소유권(Ownership) 시스템은 메모리 안전성을 보장하는 핵심 개념입니다:
fn main() {
// String::from: 힙에 문자열 할당
let s1 = String::from("hello");
// 소유권 이동 (Move)
// s1의 소유권이 s2로 이동
// 이제 s1은 무효화됨 (더 이상 사용 불가)
let s2 = s1;
// println!("{}", s1); // 컴파일 에러!
// "value borrowed here after move"
// s1은 이미 소유권을 잃어서 사용할 수 없음
println!("{}", s2); // ✅ OK - s2가 소유권을 가짐
}
왜 이렇게 동작하나?
- C++의
std::string은 대입 시 내용을 통째로 복사(깊은 복사)해서 안전하지만 비용이 들고, 원시 포인터를 복사하면 두 변수가 같은 메모리를 가리켜 이중 해제(double free) 위험이 생김 - Rust는 기본 동작을 “이동”으로 정해, 비용 없이 한 번에 하나만 소유하게 함 → 안전하고 복사 비용도 없음
실제 오류 메시지는 borrow of moved value: 's1'(E0382)이고, 컴파일러는 이동이 일어난 줄(let s2 = s1;)과 이후 사용한 줄을 함께 표시해 줍니다. 두 변수가 모두 필요하다면 let s2 = s1.clone();으로 명시적으로 복사하면 됩니다. 이 설계의 핵심은 비용이 드는 복사가 코드에 .clone()으로 드러난다는 것입니다. 반대로 i32, f64, bool, char 같은 스칼라 타입은 Copy 트레이트를 구현하고 있어서 대입해도 이동이 아니라 복사가 일어나므로, let y = x; 후에도 x를 계속 쓸 수 있습니다. 힙 메모리를 소유하는 타입만 이동 규칙이 적용된다고 이해하면 됩니다.
참조 (Borrowing)
소유권을 이동하지 않고 빌려서 사용하는 방법입니다:
fn main() {
let s1 = String::from("hello");
// &s1: s1을 빌려줌 (소유권은 유지)
// 불변 참조 (Immutable Reference)
let len = calculate_length(&s1);
// s1은 여전히 유효 (소유권을 빌려줬다가 돌려받음)
println!("{} 길이: {}", s1, len); // ✅ s1 사용 가능
}
fn calculate_length(s: &String) -> usize {
// s는 참조일 뿐, 소유권이 없음
// 함수가 끝나도 s가 가리키는 데이터는 해제되지 않음
s.len()
}
// s가 스코프를 벗어나도 아무 일도 일어나지 않음 (소유권이 없으므로)
참조의 규칙:
- 여러 개의 불변 참조 가능 (읽기만)
- 가변 참조는 단 하나만 가능 (쓰기)
- 불변 참조와 가변 참조는 동시에 존재 불가
이 규칙은 “읽는 사람이 여럿이거나, 쓰는 사람이 하나”라는 원칙으로 요약됩니다. 누군가 값을 읽는 동안 다른 곳에서 값을 바꾸면, 예를 들어 벡터의 원소 참조를 들고 있는 동안 push로 벡터가 재할당되면 참조가 해제된 메모리를 가리키게 됩니다. C++에서는 반복자 무효화로 알려진 이 버그를 Rust는 컴파일 시점에 cannot borrow 'v' as mutable because it is also borrowed as immutable(E0502) 오류로 막습니다. 참조의 “존재 기간”은 스코프 끝까지가 아니라 마지막으로 사용된 곳까지로 계산되므로(NLL, Non-Lexical Lifetimes), 불변 참조를 다 쓴 뒤라면 같은 스코프 안에서 가변 참조를 만들어도 괜찮습니다.
가변 참조
데이터를 수정하려면 가변 참조(Mutable Reference)를 사용합니다:
fn main() {
// mut: 변수를 가변으로 선언
let mut s = String::from("hello");
// &mut s: 가변 참조로 빌려줌
change(&mut s);
// 수정된 값 확인
println!("{}", s); // hello, world
}
fn change(s: &mut String) {
// s는 가변 참조이므로 수정 가능
// push_str: 문자열 끝에 추가
s.push_str(", world");
}
가변 참조의 제약:
let mut s = String::from("hello");
let r1 = &mut s;
// let r2 = &mut s; // 컴파일 에러!
// "cannot borrow `s` as mutable more than once at a time"
// 가변 참조는 동시에 하나만 허용 → 데이터 경쟁 방지
r1.push_str(" world");
Rust의 안전성 보장:
- 동시에 여러 가변 참조 불가 → 데이터 경쟁 방지
- 참조가 있는 동안 원본 수정 불가 → 댕글링 포인터 방지
- 컴파일 타임에 모두 검사 → 런타임 오버헤드 없음
스칼라 타입과 튜플·배열
스칼라 타입
// 정수
let a: i8 = 127;
let b: i32 = 2147483647;
let c: u32 = 4294967295;
// 실수
let x: f32 = 3.14;
let y: f64 = 3.14159;
// 불리언
let t: bool = true;
let f: bool = false;
// 문자
let c: char = 'A';
let emoji: char = '😀';
복합 타입
// 튜플
let tup: (i32, f64, char) = (500, 6.4, 'A');
let (x, y, z) = tup;
println!("{}, {}, {}", x, y, z);
// 배열
let arr = [1, 2, 3, 4, 5];
let first = arr[0];
정수 타입은 크기와 부호를 이름에 담고 있어서(i8부터 i128, u8부터 u128, 포인터 크기의 isize/usize) C처럼 플랫폼마다 int 크기가 달라지는 문제가 없습니다. 오버플로 처리는 빌드 모드에 따라 다르다는 점이 중요합니다. 디버그 빌드에서는 let a: i8 = 127; a + 1이 attempt to add with overflow panic으로 멈추고, 릴리스 빌드에서는 기본적으로 검사 없이 wrapping되어 -128이 됩니다. 의도적으로 넘침을 다루고 싶다면 wrapping_add, checked_add(실패 시 None), saturating_add 같은 메서드로 동작을 명시합니다. char는 C의 1바이트 문자와 달리 4바이트 유니코드 스칼라 값이라 이모지도 담을 수 있지만, 문자열(String, &str)은 UTF-8 바이트열이라 s[0]처럼 인덱스로 글자를 꺼낼 수 없습니다. 배열 인덱스가 범위를 벗어나면 C처럼 조용히 다른 메모리를 읽는 대신 index out of bounds panic이 납니다.
예제: 간단한 계산기
fn main() {
println!("=== 계산기 ===");
let a = 10;
let b = 5;
println!("{} + {} = {}", a, b, add(a, b));
println!("{} - {} = {}", a, b, subtract(a, b));
println!("{} * {} = {}", a, b, multiply(a, b));
println!("{} / {} = {}", a, b, divide(a, b));
}
fn add(a: i32, b: i32) -> i32 { a + b }
fn subtract(a: i32, b: i32) -> i32 { a - b }
fn multiply(a: i32, b: i32) -> i32 { a * b }
fn divide(a: i32, b: i32) -> i32 { a / b }
이 계산기에서 b가 0이면 divide는 attempt to divide by zero panic으로 프로그램을 멈춥니다. 정수 나눗셈 10 / 3의 결과는 3(소수점 버림)이라는 점도 다른 동적 언어와 다릅니다. Rust다운 방식은 실패할 수 있는 연산의 반환 타입을 Option<i32>로 바꿔 호출하는 쪽이 실패를 처리하게 만드는 것입니다. fn divide(a: i32, b: i32) -> Option<i32> { if b == 0 { None } else { Some(a / b) } }로 바꾸면, 호출하는 쪽은 match나 if let으로 None인 경우를 처리하지 않으면 값을 꺼낼 수 없습니다. 표준 라이브러리의 a.checked_div(b)가 정확히 이 동작을 제공합니다. 이 “실패를 타입으로 표현하는” 방식은 4편의 에러 처리에서 Result와 함께 본격적으로 다룹니다.
첫 Rust 코드에서 기억할 것
- Rust: 메모리 안전한 시스템 언어
- Cargo: 빌드 도구 + 패키지 관리자
- 소유권: 메모리 안전성의 핵심
- 불변: 기본적으로 불변
- 성능: C/C++ 수준
다음 단계
자주 묻는 질문 (FAQ)
Q. cargo check와 cargo build는 무엇이 다른가요?
A. cargo check는 타입 검사와 빌림 검사까지만 수행하고 실행 파일을 만들지 않기 때문에 cargo build보다 훨씬 빨리 끝납니다. 코드를 고치면서 컴파일 에러만 확인할 때는 check를, 실제로 실행해 보려면 build나 run을 씁니다. 배포하거나 성능을 측정할 때는 최적화가 켜진 cargo build —release로 빌드해야 합니다.
같이 보면 좋은 글
- C++ 초보자가 자주 하는 실수 Top 15 | 컴파일 에러부터 런타임 크래시까지
- C++와 Rust: 두 언어의 상호 운용성과 Memory Safety 논쟁의 실체 [#44-2]
- C++ vs Rust: 소유권, 메모리 안전성, 에러 처리, 동시성, 성능 비교
- C++ 개발자가 보는 Rust 메모리 안전성
- Rust 소유권 | Ownership, Borrowing, Lifetime
- C++ 개발 환경 구축 | ‘C++ 어디서 시작하죠?” 컴파일러 설치부터 Hello World까지
- CMake 3.28+ 프리셋과 모듈로 크로스 플랫폼 빌드 구성하기
- Conan 기초: conanfile로 의존성 선언, 프로필, Remote, CMake 연동
- vcpkg 기초: 설치, Manifest 모드, Triplet, 버전 고정, 오버레이 커스텀 포트
- Node.js 모듈 시스템