최근 Rust를 공부해보고 싶다는 생각이 들었다.
이 글에서는 Rust를 본격적으로 공부하기 전에, C/C++과 Java와 비교하면서 Rust가 어떤 문제를 해결하려는 언어인지 정리해보려 한다.
프로그래밍 언어는 오랫동안 성능과 안전성 사이에서 타협해왔다.
C/C++는 빠르다.
메모리를 직접 제어할 수 있고, 시스템 프로그래밍이나 게임 엔진, 임베디드, 운영체제처럼 성능이 중요한 영역에서 강력하다.
하지만 그만큼 위험하다.
개발자가 메모리를 잘못 다루면 프로그램은 언제든지 비정상 동작할 수 있다.
반대로 Java는 비교적 안전하다.
GC(Garbage Collector)가 메모리 관리를 대신해주기 때문에 C/C++에서 자주 발생하는 메모리 해제 문제를 직접 신경 쓸 필요가 없다.
하지만 GC는 런타임에 동작한다.
즉, 메모리 관리를 위해 실행 중에 추가 비용이 발생할 수 있고, 상황에 따라 GC Pause 같은 예측하기 어려운 지연이 생길 수 있다.
Rust는 이 둘 사이에서 다른 접근을 택한다.
C/C++에 가까운 성능을 유지하면서도,
Java처럼 안전한 개발 경험을 제공하려 한다.
단, GC 없이.
이를 가능하게 하는 핵심이 바로 Ownership, 즉 소유권 시스템이다.
Rust를 이해하려면 먼저 Ownership, 즉 소유권 개념을 알아야 한다.
Ownership은 간단히 말하면 다음 질문에 대한 Rust의 답이다.
이 값은 누가 책임지고 관리하는가?
프로그램에서 값은 메모리에 저장된다.
그리고 언젠가는 그 메모리를 정리해야 한다.
C/C++에서는 개발자가 직접 메모리를 할당하고 해제한다.
Java에서는 GC가 더 이상 사용하지 않는 객체를 찾아 정리한다.
Rust는 다른 방식을 사용한다.
각 값마다 소유자(owner)를 하나씩 정하고, 그 소유자가 범위를 벗어나면 Rust가 자동으로 값을 정리한다.
fn main() {
let name = String::from("Rust");
println!("{}", name);
} // 여기서 name이 scope를 벗어나며 메모리가 정리된다
C/C++의 강점은 개발자가 메모리를 세밀하게 제어할 수 있다는 점이다.
하지만 이 자유는 동시에 큰 책임을 요구한다.
대표적인 문제가 다음과 같다.
- 이미 해제된 메모리를 다시 사용하는 Use After Free
- 이미 해제한 메모리를 또 해제하는 Double Free
- 유효하지 않은 주소를 참조하는 Dangling Pointer
- 배열 범위를 넘어 접근하는 Buffer Overflow
Microsoft MSRC는 과거 Microsoft가 CVE로 지정한 취약점 중 약 70%가 메모리 안전성 문제에서 비롯된다고 설명한 바 있다.
C/C++에서는 이런 문제가 컴파일 단계에서 항상 잡히지 않는다.
일반적인 컴파일은 통과하지만, 실제 실행 중 특정 상황에서 문제가 터질 수 있다.
예를 들어 다음 C++ 코드를 보자.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3};
int* ptr = &v[0]; // 첫 번째 요소의 주소를 저장
v.push_back(4); // 내부 배열이 재할당될 수 있음
// ptr이 여전히 유효하다는 보장이 없음
std::cout << *ptr << std::endl;
return 0;
}
std::vector는 내부적으로 연속된 메모리 공간을 사용한다.
그런데 push_back으로 요소를 추가하다가 기존 공간이 부족해지면, 더 큰 메모리 공간을 새로 할당하고 기존 요소들을 옮길 수 있다.
그 경우 ptr은 더 이상 유효하지 않은 주소를 가리킬 수 있다.
문제는 이런 코드가 일반적인 컴파일 단계에서는 통과할 수 있다는 점이다.
즉, 개발자는 실행 중 문제가 발생하고 나서야 원인을 추적해야 할 수 있다.
Rust는 이런 종류의 문제를 런타임이 아니라 컴파일 타임에 막으려 한다.
Rust의 핵심 규칙 중 하나는 다음과 같다.
어떤 값에 대한 불변 참조가 살아있는 동안,
그 값을 동시에 가변으로 변경할 수 없다.
비슷한 상황을 Rust 코드로 보면 다음과 같다.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // 불변 참조
v.push(4); // 가변 변경 시도
println!("{}", first);
}
이 코드는 컴파일되지 않는다.
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
Rust 컴파일러는 first가 v의 요소를 참조하고 있다는 사실을 알고 있다.
그리고 v.push(4)가 내부 메모리 재할당을 일으킬 가능성이 있다는 것도 고려한다.
그래서 Rust는 이 코드를 아예 컴파일 단계에서 거부한다.
C++에서는 런타임에 문제가 될 수 있는 코드가 Rust에서는 실행되기 전에 막힌다.
이게 Rust의 중요한 특징이다.
Rust는 개발자를 불편하게 만든다.
하지만 그 불편함은 런타임 버그를 컴파일 타임으로 끌고 오기 위한 장치에 가깝다.
Java 개발자 입장에서 Rust가 낯선 이유는 단순히 문법이 달라서가 아니다.
Java에서는 객체를 만들고, 참조를 넘기고, 필요 없어지면 GC가 정리해준다.
개발자는 일반적으로 “이 객체의 소유자가 누구인가?”를 명시적으로 고민하지 않는다.
하지만 Rust에서는 다르다.
Rust에서는 모든 값에 소유자(owner)가 있다.
그리고 하나의 값은 기본적으로 하나의 소유자만 가진다.
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1); // 컴파일 에러
}
위 코드에서 s1의 값은 s2로 이동(move)된다.
그래서 이후에는 s1을 사용할 수 없다.
Java 개발자 입장에서는 처음에 이 부분이 상당히 어색하다.
Java라면 다음 코드가 자연스럽다.
String s1 = "hello";
String s2 = s1;
System.out.println(s1);
Java에서 s1과 s2는 같은 문자열 객체를 참조할 수 있다.
하지만 Rust는 값의 소유권 이동을 엄격하게 추적한다.
이 규칙 덕분에 Rust는 메모리를 언제 해제해야 하는지 컴파일 타임에 결정할 수 있다.
GC가 없어도 메모리 안전성을 확보할 수 있는 이유가 여기에 있다.

Java 개발을 하다 보면 NullPointerException은 피하기 어렵다.
물론 Java에도 Optional이 있다.
하지만 모든 코드가 Optional을 강제하는 것은 아니다.
다음 코드를 보자.
public User findUser(String id) {
if (id == null) {
return null;
}
return getUserFromDatabase(id);
}
User user = findUser("123");
System.out.println(user.getName()); // NullPointerException 가능
메서드의 반환 타입은 User다.
하지만 실제로는 null이 반환될 수 있다.
호출하는 쪽에서는 타입만 보고 이 값이 null일 수 있는지 명확히 알기 어렵다.
결국 개발자가 문서, 컨벤션, 코드 흐름을 보고 조심해야 한다.
Rust의 안전한 참조 타입에는 null이 없다.
값이 없을 수 있는 상황은 Option<T>로 표현한다.
fn find_user(id: &str) -> Option<String> {
if id.is_empty() {
None
} else {
Some(format!("User: {}", id))
}
}
fn main() {
let user = find_user("123");
match user {
Some(name) => println!("{}", name),
None => println!("사용자를 찾을 수 없습니다."),
}
}
Rust에서는 값이 있을 수도 있고 없을 수도 있다면, 그 사실이 타입에 드러난다.
Option<String>이 타입을 보는 순간 개발자는 알 수 있다.
이 값은 없을 수도 있구나.
그리고 Some과 None을 처리하지 않으면 값을 안전하게 꺼내기 어렵다.
즉, null 가능성을 개발자의 기억이나 팀 컨벤션에 맡기는 것이 아니라 타입 시스템으로 강제한다.
Java에는 예외(Exception)가 있다.
Java의 checked exception은 호출자가 예외 처리를 하도록 컴파일 단계에서 강제한다.
예를 들어 IOException 같은 예외는 try-catch로 처리하거나 메서드에 throws를 선언해야 한다.
하지만 모든 예외가 checked exception은 아니다.
RuntimeException 계열 예외는 메서드 시그니처에 반드시 드러나지 않는다.
public User findUserOrThrow(String id) {
User user = getUserFromDatabase(id);
if (user == null) {
throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
}
return user;
}
User user = findUserOrThrow("123");
System.out.println(user.getName());
이 코드는 컴파일된다.
하지만 findUserOrThrow 내부에서 IllegalArgumentException이 발생할 수 있다.
물론 Java 개발자가 예외 처리를 잘 설계하면 된다.
하지만 메서드의 반환 타입만 보고는 이 함수가 실패할 수 있는지 명확히 드러나지 않는 경우가 많다.
Rust는 실패 가능성을 Result<T, E>로 표현한다.
fn find_user(id: &str) -> Result<String, String> {
if id.is_empty() {
Err("ID가 비어 있습니다.".to_string())
} else {
Ok(format!("User: {}", id))
}
}
fn main() {
let result = find_user("123");
match result {
Ok(user) => println!("{}", user),
Err(error) => println!("에러: {}", error),
}
}
Result<String, String>이라는 타입은 다음 의미를 가진다.
성공하면 String을 반환하고,
실패하면 String 형태의 에러를 반환한다.
즉, 함수가 실패할 수 있다는 사실이 반환 타입에 드러난다.
Rust에도 panic!은 있다.
따라서 Rust 프로그램이 런타임에서 절대 죽지 않는 것은 아니다.
하지만 일반적으로 복구 가능한 오류는 Result로 표현하고, 호출자가 성공과 실패를 명시적으로 다루도록 유도한다.
Rust에서 변수는 기본적으로 불변이다.
fn main() {
let x = 5;
x = 6; // 컴파일 에러
}
값을 변경하고 싶다면 mut를 명시해야 한다.
fn main() {
let mut x = 5;
x = 6;
println!("{}", x);
}
이 점도 Java와 다르다.
Java에서는 변수 재할당이 기본적으로 가능하다.
int x = 5;
x = 6;
물론 Java에도 final이 있다.
하지만 기본값은 가변이다.
Rust는 반대다.
기본값은 불변이고, 변경이 필요한 경우에만 mut를 붙인다.
이 설계는 코드의 의도를 더 명확하게 만든다.
let count = 10; // 변경되지 않는 값
let mut total = 0; // 변경될 값
변수를 보는 순간 이 값이 변경될 가능성이 있는지 알 수 있다.
작은 차이처럼 보이지만, 상태 변경이 복잡한 코드에서는 꽤 큰 장점이 된다.
| 관점 | C/C++ | Java | Rust |
|---|---|---|---|
| 메모리 관리 | 수동 관리 / RAII | GC 기반 자동 관리 | Ownership / Borrow Checker |
| 메모리 안전성 | 개발자와 도구에 크게 의존 | GC로 일부 문제 완화 | Safe Rust에서 컴파일 타임에 강하게 보장 |
| null 처리 | null pointer 사용 가능 | null 존재, Optional은 선택적 사용 | Option<T>로 명시 |
| 오류 처리 | 반환 코드 / 예외 등 혼재 | checked / unchecked exception | Result<T, E> / panic! |
| 런타임 오버헤드 | 낮음 | GC pause 가능 | GC 없음 |
| 러닝커브 | 높음 | 비교적 낮음 | 높음 |
Rust의 핵심은 단순히 “빠른 언어”가 아니다.
Rust는 C/C++처럼 낮은 수준의 제어권을 제공하면서도, 메모리 안전성과 동시성 문제를 컴파일 단계에서 최대한 잡아내려는 언어다.
Rust가 모든 상황에서 정답은 아니다.
빠른 CRUD API 서버를 만들어야 한다면 Java/Spring Boot가 훨씬 생산적일 수 있다.
머신러닝이나 데이터 분석을 한다면 Python 생태계가 압도적으로 편하다.
프론트엔드 웹 개발에서는 JavaScript/TypeScript가 사실상 표준이다.
Rust는 러닝커브가 높다.
특히 처음에는 Borrow Checker와 자주 싸우게 된다.
하지만 다음과 같은 상황에서는 Rust의 장점이 뚜렷하다.
성능과 안정성이 모두 중요한 시스템 프로그래밍
메모리 사용량과 실행 속도가 중요한 CLI 도구
동시성 처리가 중요한 고성능 서버
WebAssembly 기반 고성능 웹 기능
C/C++로 작성하던 영역을 더 안전하게 대체하고 싶은 경우
실제로 Rust는 Linux Kernel, Windows 일부 컴포넌트, AWS Firecracker 같은 영역에서도 활용되고 있다.
특히 Linux Kernel에는 Rust 관련 공식 문서와 설정이 존재할 정도로, 시스템 프로그래밍 영역에서도 Rust 도입이 계속 진행되고 있다.
Rust는 쉬운 언어는 아니다.
Java처럼 객체를 만들고 참조를 넘기던 방식에 익숙하다면, Ownership과 Borrowing은 처음에 상당히 불편하게 느껴질 수 있다.
하지만 Rust가 요구하는 불편함에는 이유가 있다.
런타임에서 터질 수 있는 메모리 문제를 컴파일 타임으로 끌고 오고,
값이 없을 가능성은 Option으로,
실패할 가능성은 Result로 명확히 드러낸다.
즉, Rust는 개발자가 실수하기 쉬운 부분을 언어 차원에서 강하게 통제한다.
처음에는 컴파일러와 싸우는 느낌이 들 수 있다.
하지만 그 과정을 지나면, 적어도 메모리 안전성 문제의 상당 부분을 실행 전에 제거한 프로그램을 만들 수 있다.
성능과 안전성을 동시에 고민해야 하는 개발자에게 꽤 설득력 있는 선택지라고 생각한다.
앞으로 Rust를 공부하면서 간단한 CLI 도구나 작은 웹 서버부터 직접 만들어볼 생각이다.
참고 자료