Skip to content

Latest commit

 

History

History
754 lines (569 loc) · 23.5 KB

02.md

File metadata and controls

754 lines (569 loc) · 23.5 KB

Rust 문법 로드맵 — 2단계: 소유권 & 빌림 (상세 교재)

이 문서는 로드맵 2단계를 코드 예제와 함께 상세히 풀어놓은 교재입니다. Rust가 다른 언어와 가장 다른 지점이 여기입니다. 각 절은 "개념 → 코드 예제 → 설명 → 체크포인트" 순서로 구성했습니다. 컴파일 에러를 일부러 내보고 메시지를 읽는 것이 이 단계의 핵심 학습법입니다.

선수: 1단계 기본 문법 — 특히 Copy를 붙인 신호등 예제와 슬라이스 개념.


2.1 소유권 이동(move) vs 복사(Copy)

개념 — Rust의 기본 규칙 세 가지

  1. 모든 값은 소유자(owner)가 하나다.
  2. 소유자가 스코프를 벗어나면 값이 drop된다 (메모리 해제).
  3. 소유권을 넘기면(move) 이전 변수는 더 이상 쓸 수 없다.

이 세 규칙이 GC도 없고 free도 수동으로 안 해도 메모리 안전성이 보장되는 이유입니다.

이동(move) — String으로 보기

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은 무효화합니다. 힙 데이터는 복사하지 않습니다 (얕은 이동).

깊은 복사가 필요하면 — .clone()

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // 힙 데이터까지 복사 — 비용 있음

    println!("s1 = {s1}, s2 = {s2}"); // 둘 다 OK
}

.clone()은 "의도적으로 비싸다"는 신호를 코드에 남깁니다. 실무에서 무분별한 clone()은 성능 냄새이지만, 소유권 문제를 일단 통과시키기 위해 clone을 남발하는 단계는 학습 초기에 흔합니다. 나중에 빌림으로 고치면 됩니다.

Copy 타입 — 이동 대신 비트 복사

스택에만 있고 크기가 작으며, 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을 넘긴 뒤 반환으로 소유권을 되찾는 코드 작성

2.2 빌림 규칙 — &T vs &mut T, NLL

개념

값을 소유하지 않고 참조만 하는 것이 빌림입니다.

  • &T — 불변 참조 (공유 빌림). 동시에 여러 개 가능.
  • &mut T — 가변 참조 (배타적 빌림). 동시에 딱 하나만 가능.
  • 불변 참조와 가변 참조는 동시에 존재할 수 없다.

이 규칙이 데이터 레이스와 use-after-free를 컴파일 타임에 막는 핵심입니다.

불변 참조 — 읽기만, 여러 개 OK

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

NLL (Non-Lexical Lifetime) — "마지막 사용 지점"까지가 수명

예전 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 덕분에 "참조를 쓴 뒤에는 다시 가변으로 빌릴 수 있는" 자연스러운 코드가 컴파일됩니다. 다만 같은 줄에서 불변·가변을 동시에 쓰면 여전히 막힙니다.

dangling reference는 컴파일러가 막는다

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 에러 메시지 읽어보기

2.3 슬라이스는 빌림의 한 형태

개념

슬라이스(&[T], &str)는 소유하지 않는 뷰입니다. "어딘가에 있는 연속 데이터의 일부(또는 전체)를 가리키는 참조 + 길이"입니다.

&str / &[T] = { data_ptr, length }  ← fat pointer (보통 2워드)

&String보다 &str을 받는 이유

// 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])); // 부분 슬라이스
}

UTF-8 함정 — 바이트 인덱스로 자르면 panic

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을 한 번 경험해보기

2.4 라이프타임 문법 — 'a와 elision rules

왜 라이프타임이 필요한가

참조는 "얼마나 오래 유효한가"라는 수명을 가집니다. 대부분의 경우 컴파일러가 추론하지만, 함수가 참조를 반환하거나 여러 참조 인자를 받을 때 "반환값이 어느 입력의 수명을 따라가는지"를 사람이 알려줘야 할 때가 있습니다.

// 컴파일 에러 — 반환된 &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는 타입이 아닙니다. 참조들의 관계에 대한 계약입니다. 컴파일러가 그 계약을 위반하는 코드를 거부합니다.

라이프타임 생략 규칙 (elision rules) — 3가지

매번 '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 해당 없음).

'static — 프로그램 전체 수명

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 결과를 짧은 쪽 스코프 밖으로 빼내려다 실패하는 코드 작성

2.5 구조체에 참조 담기 — struct Foo<'a>

개념

구조체 필드가 참조이면, 그 참조가 가리키는 데이터가 구조체보다 오래 살아야 합니다. 그래서 구조체 정의에 라이프타임 매개변수가 필요합니다.

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)하는 편이 읽기 쉽고, 정말 수명이 갈라질 때만 여러 개를 씁니다.

impl에도 라이프타임을 달아야 한다

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("발췌문"));
}

참조를 담기 vs 소유하기 — 언제 뭘 쓸까

선택 타입 예 언제
소유 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> 블록에 메서드 하나를 직접 추가

2.6 종합 실습 — 더 긴 문자열 슬라이스 반환 + 에러 읽기

실습 A: longest 완성

/// 두 문자열 슬라이스 중 더 긴 쪽을 반환한다.
/// 반환된 참조는 `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));
}

실습 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 사용을 끝낸 뒤 수정

실습 C: 미니 파서 — 참조를 담은 구조체

#[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를 빌리므로 불가
}

포인트:

  1. Token<'a>가 src의 슬라이스를 들고 있어 복사 없이 토큰화합니다.
  2. Vec<Token<'_>>의 '_는 "명시적으로 이름 붙이기 싫고 추론에 맡긴다"는 뜻입니다.
  3. 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)와 제네릭 시그니처 곳곳에 다시 등장합니다.