이 문서는 로드맵 2단계를 코드 예제와 함께 상세히 풀어놓은 교재입니다. Rust가 다른 언어와 가장 다른 지점이 여기입니다. 각 절은 "개념 → 코드 예제 → 설명 → 체크포인트" 순서로 구성했습니다. 컴파일 에러를 일부러 내보고 메시지를 읽는 것이 이 단계의 핵심 학습법입니다.
선수: 1단계 기본 문법 — 특히
Copy를 붙인 신호등 예제와 슬라이스 개념.
- 모든 값은 소유자(owner)가 하나다.
- 소유자가 스코프를 벗어나면 값이 drop된다 (메모리 해제).
- 소유권을 넘기면(move) 이전 변수는 더 이상 쓸 수 없다.
이 세 규칙이 GC도 없고 free도 수동으로 안 해도 메모리 안전성이 보장되는 이유입니다.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1의 소유권이 s2로 이동(move)
// println!("{s1}"); // 컴파일 에러!
println!("{s2}"); // OK
}에러 메시지:
error[E0382]: borrow of moved value: `s1`
|
| let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
| let s2 = s1;
| -- value moved here
| println!("{s1}");
| ^^ value borrowed here after move
String은 힙에 데이터를 들고 있어서, 단순 대입이 "포인터 복사 + 두 변수가 같은 힙을 가리킴"이 되면 이중 해제(double free)가 됩니다. Rust는 그걸 막기 위해 대입 시 소유권을 옮기고, 이전 이름은 무효화합니다.
내부적으로 s1은 대략 이런 구조입니다:
s1: { ptr ──► [h][e][l][l][o] (힙)
len: 5
cap: 5 }
let s2 = s1;은 스택의 {ptr, len, cap}만 s2로 옮기고, s1은 무효화합니다. 힙 데이터는 복사하지 않습니다 (얕은 이동).
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 힙 데이터까지 복사 — 비용 있음
println!("s1 = {s1}, s2 = {s2}"); // 둘 다 OK
}.clone()은 "의도적으로 비싸다"는 신호를 코드에 남깁니다. 실무에서 무분별한 clone()은 성능 냄새이지만, 소유권 문제를 일단 통과시키기 위해 clone을 남발하는 단계는 학습 초기에 흔합니다. 나중에 빌림으로 고치면 됩니다.
스택에만 있고 크기가 작으며, drop 시 특별한 정리가 필요 없는 타입은 Copy trait을 구현합니다. 이런 타입은 대입해도 이전 변수가 무효화되지 않습니다.
fn main() {
let x = 5; // i32는 Copy
let y = x; // 비트 복사 — x는 여전히 유효
println!("x = {x}, y = {y}");
let t = (1, 2.0, true); // 모든 필드가 Copy면 튜플도 Copy
let t2 = t;
println!("{t:?}, {t2:?}");
}대표적인 Copy 타입:
| 타입 | Copy? | 이유 |
|---|---|---|
| 정수/부동소수/bool/char | ✅ | 스택만, drop 불필요 |
| 튜플/배열 (원소가 전부 Copy) | ✅ | 위와 동일 |
&T (불변 참조) |
✅ | 참조 자체는 복사 가능 (빌림 규칙으로 안전성 보장) |
&mut T |
❌ | 가변 참조는 유일해야 하므로 Copy 불가 |
String, Vec<T>, Box<T> |
❌ | 힙 소유, drop 시 해제 필요 |
| 직접 만든 struct (기본) | ❌ | #[derive(Copy, Clone)]를 붙여야 함 |
#[derive(Debug, Clone, Copy)] // Copy를 쓰려면 Clone도 함께 필요
struct Point {
x: i32,
y: i32,
}
// String 필드는 Copy 불가 — 컴파일 에러
// #[derive(Copy, Clone)]
// struct Person { name: String }
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // Copy
println!("{p1:?}, {p2:?}");
}1단계 신호등 예제에서 TrafficLight에 Copy를 붙인 이유가 바로 이것입니다. enum variant에 힙 데이터가 없으니 Copy가 가능했고, fn next(self)처럼 값으로 받아도 호출 측 변수가 죽지 않았습니다.
fn take_ownership(s: String) {
println!("받음: {s}");
} // 여기서 s가 drop
fn make_copy(n: i32) {
println!("복사본: {n}");
}
fn main() {
let s = String::from("hello");
take_ownership(s);
// println!("{s}"); // 에러 — 이미 이동됨
let n = 42;
make_copy(n);
println!("{n}"); // OK — i32는 Copy
}소유권을 함수에 넘긴 뒤 다시 돌려받고 싶으면 반환하면 됩니다:
fn take_and_give_back(s: String) -> String {
s // 소유권을 호출자에게 반환
}
fn main() {
let s1 = String::from("hello");
let s2 = take_and_give_back(s1);
println!("{s2}");
}하지만 이런 "받았다가 다시 돌려주기"는 장황합니다. 그래서 빌림(borrow)이 있습니다.
-
String을 대입한 뒤 이전 변수를 출력해서 에러 메시지를 직접 읽어보기 -
Vec<i32>도String과 같은지 확인 (move vs Copy) -
#[derive(Copy, Clone)]를String필드가 있는 struct에 붙여보고 에러 확인 - 함수에
String을 넘긴 뒤 반환으로 소유권을 되찾는 코드 작성
값을 소유하지 않고 참조만 하는 것이 빌림입니다.
&T— 불변 참조 (공유 빌림). 동시에 여러 개 가능.&mut T— 가변 참조 (배타적 빌림). 동시에 딱 하나만 가능.- 불변 참조와 가변 참조는 동시에 존재할 수 없다.
이 규칙이 데이터 레이스와 use-after-free를 컴파일 타임에 막는 핵심입니다.
fn calculate_length(s: &String) -> usize {
s.len()
// s.push_str("!"); // 에러 — &String으로는 수정 불가
} // s는 참조일 뿐이라 drop하지 않음. 소유권은 호출자에게 그대로.
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("'{s1}' 의 길이: {len}"); // s1 여전히 유효!
}불변 참조는 여러 개를 동시에 가져도 됩니다:
fn main() {
let s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1}, {r2}"); // OK
}fn append_world(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello"); // 원본도 mut이어야 &mut을 빌릴 수 있음
append_world(&mut s);
println!("{s}"); // "hello, world"
}가변 참조를 두 개 동시에 만들면:
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 컴파일 에러!
println!("{r1}, {r2}");
}error[E0499]: cannot borrow `s` as mutable more than once at a time
불변 + 가변을 섞어도:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &mut s; // 에러 — 불변 참조가 살아있는 동안 가변 참조 불가
println!("{r1}, {r2}");
}error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
예전 Rust는 참조의 수명이 중괄호 스코프 전체였습니다. 지금은 실제로 마지막으로 쓰인 지점까지입니다.
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1}, {r2}");
// 여기서 r1, r2의 "수명"이 끝남 (이후 사용 없음) — NLL
let r3 = &mut s; // OK! 불변 참조는 이미 끝난 상태
println!("{r3}");
}NLL 덕분에 "참조를 쓴 뒤에는 다시 가변으로 빌릴 수 있는" 자연스러운 코드가 컴파일됩니다. 다만 같은 줄에서 불변·가변을 동시에 쓰면 여전히 막힙니다.
fn dangle() -> &String { // 수명 문제로 컴파일 불가
let s = String::from("hello");
&s // s는 함수 끝에서 drop → 반환된 참조는 허공
}
// 올바른 방법: 소유권을 반환
fn no_dangle() -> String {
let s = String::from("hello");
s
}error[E0106]: missing lifetime specifier
또는 (시그니처를 억지로 맞추려 하면):
error[E0515]: cannot return reference to local variable `s`
// ❌ 소유권을 뺏어감 — 호출자가 이후 s를 못 씀
fn bad_print(s: String) {
println!("{s}");
}
// ✅ 읽기만 하면 됨 → &str (더 일반적, 아래 2.3 참고)
fn good_print(s: &str) {
println!("{s}");
}
// ✅ 수정이 필요 → &mut
fn good_append(s: &mut String) {
s.push('!');
}- 불변 참조 2개 + 가변 참조 1개를 같은 스코프에서 만들어 에러를 유도한 뒤, NLL로 통과되게 고쳐보기
-
fn push_exclamation(s: &mut String)작성 후 호출 - 지역 변수의 참조를 반환하는 함수를 작성해 dangling 에러 메시지 읽어보기
슬라이스(&[T], &str)는 소유하지 않는 뷰입니다. "어딘가에 있는 연속 데이터의 일부(또는 전체)를 가리키는 참조 + 길이"입니다.
&str / &[T] = { data_ptr, length } ← fat pointer (보통 2워드)
// String만 받음 — &String, &str, &"리터럴" 모두 받고 싶을 때 불편
fn first_word_narrow(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b == b' ' {
return &s[0..i];
}
}
&s[..]
}
// &str을 받으면 String, &str, 리터럴 전부 수용 (Deref coercion)
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let owned = String::from("hello world");
let w1 = first_word(&owned); // &String → &str (자동)
let w2 = first_word(&owned[..]); // 명시적 슬라이스
let w3 = first_word("hello world"); // 리터럴도 OK
println!("{w1}, {w2}, {w3}");
}관용구: 함수 인자로 문자열을 "읽기만" 할 거면 String/&String 말고 **&str**을 받으세요. 컬렉션도 비슷하게 Vec<T> 대신 **&[T]**를 받는 게 일반적입니다.
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word는 s를 빌림 중 (&str)
// s.clear(); // 에러! word가 가리키는 데이터가 날아가면 안 됨
println!("첫 단어: {word}");
}word가 s의 일부를 빌리고 있는 동안 s.clear()로 내용을 지울 수 없습니다. 슬라이스도 빌림이므로 & / &mut 규칙이 그대로 적용됩니다.
fn sum(slice: &[i32]) -> i32 {
slice.iter().sum()
}
fn main() {
let arr = [1, 2, 3, 4, 5];
let v = vec![10, 20, 30];
println!("{}", sum(&arr)); // 배열 → &[i32]
println!("{}", sum(&v)); // Vec → &[i32]
println!("{}", sum(&arr[1..4])); // 부분 슬라이스
}fn main() {
let s = String::from("안녕하세요");
// let bad = &s[0..2]; // panic! — '안'은 3바이트, 2에서 자르면 문자 중간
let ok = &s[0..3]; // "안" — 한 글자 단위로 맞춰야 함
println!("{ok}");
// 안전하게 문자 단위로:
let first_char: String = s.chars().take(1).collect();
println!("{first_char}");
}-
fn average(nums: &[f64]) -> f64를 작성하고 배열/벡터/부분 슬라이스에 모두 호출 -
first_word결과를 들고 있는 동안clear를 시도해 빌림 에러 확인 - 한글 문자열을 바이트 인덱스로 잘못 잘랐을 때 panic을 한 번 경험해보기
참조는 "얼마나 오래 유효한가"라는 수명을 가집니다. 대부분의 경우 컴파일러가 추론하지만, 함수가 참조를 반환하거나 여러 참조 인자를 받을 때 "반환값이 어느 입력의 수명을 따라가는지"를 사람이 알려줘야 할 때가 있습니다.
// 컴파일 에러 — 반환된 &str이 x를 가리키는지 y를 가리키는지 모름
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}error[E0106]: missing lifetime specifier
|
| fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value,
but the signature does not say whether it is borrowed from `x` or `y`
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}의미: "x와 y, 그리고 반환값 모두 최소 'a만큼은 살아있는 영역에서만 유효하다."
실제로는 둘 중 더 짧은 쪽의 수명에 반환값이 묶입니다.
fn main() {
let s1 = String::from("long string");
{
let s2 = String::from("xyz");
let result = longest(s1.as_str(), s2.as_str());
println!("더 긴 쪽: {result}"); // s2가 아직 살아 있으므로 OK
}
// 여기서 result를 밖으로 빼내려면? s2가 이미 죽었을 수 있어서 불가한 경우가 생김
}바깥 스코프로 빼내려다 실패하는 예:
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str());
// result의 수명은 s1과 s2 중 짧은 쪽(= s2)에 묶임
} // s2 drop
// println!("{result}"); // 에러 — s2가 이미 죽음
}'a는 타입이 아닙니다. 참조들의 관계에 대한 계약입니다. 컴파일러가 그 계약을 위반하는 코드를 거부합니다.
매번 'a를 쓸 필요는 없습니다. 아래 규칙으로 컴파일러가 자동으로 채웁니다.
규칙 1. 각 참조 입력 인자는 각자의 라이프타임을 갖습니다.
// 작성한 것
fn foo(x: &str, y: &i32);
// 컴파일러가 보는 것
fn foo<'a, 'b>(x: &'a str, y: &'b i32);규칙 2. 입력 라이프타임이 하나뿐이면, 그 라이프타임이 모든 출력 참조에 부여됩니다.
// 작성한 것
fn first_word(s: &str) -> &str;
// 컴파일러가 보는 것
fn first_word<'a>(s: &'a str) -> &'a str;규칙 3. 메서드에서 입력이 여러 개여도, 그 중 하나가 &self / &mut self이면 self의 라이프타임이 모든 출력에 부여됩니다.
impl MyStruct {
// 작성한 것
fn get_name(&self, _hint: &str) -> &str;
// 컴파일러가 보는 것
fn get_name<'a, 'b>(&'a self, _hint: &'b str) -> &'a str;
}이 세 규칙으로 해결되지 않으면 직접 명시해야 합니다. longest가 그 대표 사례입니다 (입력 참조가 둘, self도 없음 → 규칙 2·3 해당 없음).
fn main() {
let s: &'static str = "I live forever"; // 문자열 리터럴은 바이너리에 박혀 있음
let leak: &'static str = Box::leak(String::from("leaked").into_boxed_str());
// Box::leak은 의도적 누수 — 정말 필요할 때만
}'static은 "절대 죽지 않는다"는 뜻이지만, 실무에서 모든 참조에 'static을 요구하는 건 (특히 async/스레드에서) 설계가 잘못된 신호인 경우가 많습니다. 진짜 전역/리터럴이 아니면 일반 라이프타임이나 owned 타입(String, Arc<str> 등)을 쓰세요.
-
longest에서 라이프타임 어노테이션을 지워 에러를 재현하고, 도움말 문구를 읽어보기 - 입력 참조가 하나인 함수에서는
'a없이도 되는지 확인 (elision 규칙 2) -
longest결과를 짧은 쪽 스코프 밖으로 빼내려다 실패하는 코드 작성
구조체 필드가 참조이면, 그 참조가 가리키는 데이터가 구조체보다 오래 살아야 합니다. 그래서 구조체 정의에 라이프타임 매개변수가 필요합니다.
struct Novel<'a> {
title: &'a str,
body: &'a str,
}
fn main() {
let title = String::from("The Rust Book");
let body = String::from("Once upon a time...");
let novel = Novel {
title: title.as_str(),
body: body.as_str(),
};
println!("{} — {}", novel.title, &novel.body[..20.min(novel.body.len())]);
} // novel, body, title 순으로 drop — 참조 대상이 novel보다 오래 삶 ✓필드마다 다른 수명을 줄 수도 있습니다:
struct Excerpt<'a, 'b> {
part: &'a str,
note: &'b str,
}보통은 하나로 통일('a)하는 편이 읽기 쉽고, 정말 수명이 갈라질 때만 여러 개를 씁니다.
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
// elision 규칙 3: 반환 &str은 self의 수명을 따름
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("주목! {announcement}");
self.part
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = ImportantExcerpt {
part: first_sentence,
};
println!("{}", excerpt.announce_and_return_part("발췌문"));
}| 선택 | 타입 예 | 언제 |
|---|---|---|
| 소유 | String, Vec<T>, PathBuf |
구조체가 데이터를 책임져야 할 때, API 경계를 넘길 때 |
| 빌림 | &str, &[T], &Path |
짧은 스코프의 뷰, zero-copy 파싱, 수명이 명확할 때 |
| 공유 소유 | Arc<str>, Rc<String> |
여러 소유자가 필요하지만 라이프타임 연결은 싫을 때 (7단계) |
실무(axum 핸들러, 설정 구조체 등)에서는 소유하는 타입(String)을 필드에 두는 편이 훨씬 흔합니다. 라이프타임을 구조체에 달기 시작하면 시그니처가 전염병처럼 퍼지기 때문입니다. "zero-copy로 파싱 결과를 들고 있어야 한다"는 요구가 명확할 때만 struct Foo<'a>를 쓰세요.
-
struct Config<'a> { name: &'a str }를 만들고, name이 가리키는String을 Config보다 먼저 drop되게 배치해 에러 유도 - 같은 구조를
name: String으로 바꿔 라이프타임이 사라지는 것 확인 -
impl<'a>블록에 메서드 하나를 직접 추가
/// 두 문자열 슬라이스 중 더 긴 쪽을 반환한다.
/// 반환된 참조는 `a`와 `b` 중 **더 짧은 수명**에 묶인다.
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let a = "short";
let b = "much longer";
println!("{}", longest(a, b));
}아래 코드를 그대로 컴파일해보고, 메시지를 읽어 고치세요.
// ① 라이프타임 누락
fn pick(x: &str, y: &str) -> &str {
if x.len() > 3 { x } else { y }
}
// ② dangling
fn make_ref() -> &str {
let s = String::from("temp");
&s
}
// ③ 빌림 규칙 위반
fn main() {
let mut data = String::from("hello");
let r = &data;
data.push_str("!");
println!("{r}");
}예상되는 에러 코드:
| 번호 | 대략적인 에러 | 고치는 방향 |
|---|---|---|
| ① | E0106 missing lifetime specifier |
fn pick<'a>(x: &'a str, y: &'a str) -> &'a str |
| ② | E0515 / E0106 |
&str 대신 String을 반환 |
| ③ | E0502 cannot borrow as mutable |
println을 push_str 앞으로 옮기거나, r 사용을 끝낸 뒤 수정 |
#[derive(Debug)]
struct Token<'a> {
kind: TokenKind,
lexeme: &'a str, // 원본 소스의 일부 — zero-copy
}
#[derive(Debug, PartialEq)]
enum TokenKind {
Ident,
Number,
Plus,
}
fn tokenize(src: &str) -> Vec<Token<'_>> {
// Token<'_> 의 '_ 는 "src에서 추론된 라이프타임"을 의미
let mut tokens = Vec::new();
for part in src.split_whitespace() {
let kind = match part {
"+" => TokenKind::Plus,
p if p.chars().all(|c| c.is_ascii_digit()) => TokenKind::Number,
_ => TokenKind::Ident,
};
tokens.push(Token { kind, lexeme: part });
}
tokens
}
fn main() {
let source = String::from("x 42 + y");
let tokens = tokenize(&source);
for t in &tokens {
println!("{t:?}");
}
// source를 여기서 clear하면? tokens가 source를 빌리므로 불가
}포인트:
Token<'a>가src의 슬라이스를 들고 있어 복사 없이 토큰화합니다.Vec<Token<'_>>의'_는 "명시적으로 이름 붙이기 싫고 추론에 맡긴다"는 뜻입니다.tokens가 살아있는 동안source를 변경·drop할 수 없습니다 — 2.2·2.3 규칙의 연장입니다.
-
longest를 세 문자열 버전longest3으로 확장 -
Token에span: std::ops::Range<usize>(바이트 오프셋)를 추가해, 슬라이스 대신 인덱스로도 원본을 다시 잘라보기 - axum에서 흔히 쓰는
State(Arc<AppState>)패턴을 떠올리며: "공유 상태가 왜'a참조가 아니라Arc인가?"를 한 문단으로 정리
값 대입/함수 전달
├─ Copy 타입 → 비트 복사, 양쪽 유효
└─ non-Copy → move, 이전 이름 무효
└─ 필요하면 .clone() (힙 복사)
빌림
├─ &T → 다수 가능, 읽기만
├─ &mut T → 하나만, 읽기+쓰기
└─ &와 &mut 동시 → 불가 (NLL: 마지막 사용 이후면 OK)
슬라이스 &[T], &str = 빌림(뷰) + 길이
라이프타임 'a
├─ 참조 사이의 "이 관계가 유지되는 구간" 계약
├─ elision 3규칙으로 대부분 생략
└─ struct에 참조 필드 → struct Foo<'a>
3단계 타입 시스템 심화에서는 struct / enum / Option / Result를 언어의 일부처럼 체화하고, 트레잇·제네릭으로 "동작하는 타입"을 설계하는 법을 다룹니다. 2단계에서 배운 빌림과 라이프타임은 트레잇 객체(dyn Trait)와 제네릭 시그니처 곳곳에 다시 등장합니다.