Rust의 이터레이터(Iterator)

Rust에서 이터레이터(iterator)는 값을 순회(iterate)할 수 있도록 해주는 강력하고 유연한 추상화입니다. 이터레이터는 반복 가능한 값을 하나씩 꺼내면서 작업을 수행할 때 사용되며, 지연 평가(lazy evaluation)를 통해 성능도 뛰어납니다.

Rust에서 지연 평가(lazy evaluation)는 계산이 필요한 시점까지 연산을 연기하는 전략입니다. 즉, 값을 즉시 계산하는 대신, 해당 값이 필요할 때까지 계산을 미루는 방식입니다. 이를 통해 불필요한 계산을 방지하고 성능을 향상시킬 수 있습니다.

1. 기본 개념

Rust에서 이터레이터는 Iterator 트레잇을 구현한 타입입니다. 이 트레잇은 next() 메서드를 정의합니다.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
  • next()는 Option Enum 형식으로 반환하므로, Some(value)를 반환하다가, 더 이상 값이 없으면 None을 반환합니다.
  • for 루프는 내부적으로 이 next()를 호출하여 동작합니다.

가. 예제 1: 기본 사용

fn main() {
    let v = vec![10, 20, 30];
    let mut iter = v.iter(); // 불변 참조로 이터레이터 생성

    while let Some(x) = iter.next() {
        println!("값: {x}");
    }
}
let v = vec![10, 20, 30]
  • v는 정수형 벡터입니다.
  • 즉, Vec 타입이고 [10, 20, 30]이라는 세 개의 요소를 가지고 있습니다.
let mut iter = v.iter()
  • v.iter()는 벡터 v의 각 요소에 대한 불변 참조 (&i32)를 반환하는 이터레이터를 생성합니다.
  • 즉, iter는 &10, &20, &30을 순서대로 반환할 준비가 된 상태입니다.
  • iter는 가변 변수로 선언되었습니다(mut) → .next()를 호출할 때 이터레이터 내부 상태를 바꾸기 때문입니다.

while let Some(x) = iter.next() { … }
  • .next()는 이터레이터에서 다음 값을 하나씩 꺼냅니다.
  • 반환값은 Option<&i32>입니다.
  • 값이 있으면 Some(&값)
  • 끝나면 None
  • while let Some(x)는 Some일 때 루프를 돌고, None이면 종료됩니다.
println!(“값: {x}”);
  • x는 &i32 타입이므로 10, 20, 30이 참조 형태로 출력됩니다.
  • println!은 참조를 자동으로 역참조해서 출력해주기 때문에 따로 *x를 쓰지 않아도 됩니다.

나. 예제 2: for 루프 사용

fn main() {
    let v = vec![1, 2, 3];

    for num in v.iter() {
        println!("num = {num}");
    }
}

while문과 아래가 다릅니다.

for num in v.iter()
  • v.iter()는 불변 참조 이터레이터를 생성합니다.
    • 즉, &1, &2, &3을 순서대로반환합니다.
  • for 루프는 이터레이터의 .next()를 자동으로 반복 호출하여 값을 하나씩 꺼냅니다.
  • 변수 num의 타입은 &i32입니다 (참조).
  • v.iter()는 벡터를 소유하지 않고 참만 하므로, v는 이루프 이후에도 여전히 사용 가능합니다.
  • println!(“num = {num}”);에 따라 1,2,3이 출력됩니다.

2. 소비(consuming) 어댑터

이터레이터를 사용해 데이터를 소모하는 메서드입니다.

  • .sum(): 합계 반환
  • .count(): 요소 개수
  • .collect(): 컬렉션으로 변환
fn main() {
    let v = vec![1, 2, 3, 4, 5];
    let sum: i32 = v.iter().sum();
    println!("합계: {}", sum);
}

여기서 특이한 점은 sum 다음에 i32라고 타입이 명시되어 있다는 점입니다.

: i32를 빼고 실행하면 “type annotations needed”라고 에러가 발생하는데,

iter().sum()의 반환 형식을 지정하지 않으면 에러 발생

sum이 &i32를 받아서 더할 수는 있지만, 반환값의 형식을 추론할 수 없기 때문에 안정성과 명확성을 추구하는 Rust가 에러를 발생시키는 것입니다.

3. 변형(transforming) 어댑터

이터레이터에서 새로운 이터레이터를 생성하지만 실제 순회는 for, collect 등으로 실행되기 전까지 지연 평가됩니다.

  • .map(): 각 요소를 변형
  • .filter(): 조건에 맞는 요소만 남김

가. map() 예제

fn main() {
    let v = vec![1, 2, 3, 4];

    let doubled: Vec<i32> = v.iter()
        .map(|x| x * 2)
        .collect();

    println!("{doubled:?}"); // [2, 4, 6, 8]
}
v.iter()
  • 벡터 v에 대해 불변 참조 이터레이터를 생성합니다.
  • 반환 타입은 impl Iterator<Item = &i32> → 각 요소는 &1, &2, &3, &4.
.map(|x| x * 2)
  • map은 이터레이터의 각 항목에 closure를 적용해 새로운 이터레이터를 만듭니다.
  • 여기서 x는 &i32이므로, x * 2는 실제로는 *x * 2와 같은 의미입니다.
  • 즉, 값은 다음과 같이 변합니다:
  • &1 → 1 * 2 → 2
  • &2 → 2 * 2 → 4
  • x는 &i32이기 때문에 직접 곱하려면 *x * 2라고 해야 하지만, Rust는 x * 2를 보면 자동으로 역참조(*x) 해주기 때문에 생략 가능합니다.

.collect()
  • 이터레이터 결과를 컨테이너 타입(여기서는 Vec)으로 수집합니다.
  • 이 부분에서 타입 추론이 불가능할 수 있기 때문에, doubled: Vec<i32>로 타입을 명시했습니다.

println!(“{doubled:?}”);
  • {:?}는 벡터를 디버그 형식으로 출력해줍니다.
  • 출력 결과는 [2, 4, 6, 8]입니다.

나. filter() 예제

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let even: Vec<_> = v.into_iter()
        .filter(|x| x % 2 == 0)
        .collect();

    println!("{:?}", even); // [2, 4]
}

다른 것은 같고 filter 부분만 다른데, x를 2로 나눴을 때 나머지가 0인 것을 % 연산자(나머지 연산자)로 구해서 해당되는 것만 collect(수집)하는 것입니다.

4. 소유권과 이터레이터

이터레이터는 다음 세 가지 방식으로 만들 수 있습니다.

메서드설명
.iter()불변 참조 이터레이터
.iter.mut()가변 참조 이터레이터
.into_iter()소유권을 이동하는 이터레이터
fn main() {
    let mut v = vec![1, 2, 3];

    for x in v.iter_mut() {
        *x *= 10;
    }

    println!("{:?}", v); // [10, 20, 30]
}

vector 변수 v를 가변 참조로 선언한 다음,
값을 하나씩 꺼내서 10을 곱한 다음 x에 저장하므로 v 변수가 변경됩니다.
이후 벡터 변수 v를 디버그 포맷으로 출력합니다.

5. 사용자 정의 이터레이터

직접 구조체에 Iterator 트레잇을 구현하여 사용자 정의 이터레이터를 만들 수도 있습니다.

struct Counter {
    count: usize,
}

impl Counter {
    fn new() -> Self {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = usize;

    fn next(&mut self) -> Option<Self::Item> {
        self.count += 1;
        if self.count <= 5 {
            Some(self.count)
        } else {
            None
        }
    }
}

fn main() {
    let c = Counter::new();
    for i in c {
        println!("{}", i);
    }
}
가. 구조체 정의
struct Counter {
    count: usize,
}

usize 타입의 count 필드를 가진 Counter 구조체를 정의합니다.

나. Counter의 new 메소드 구현
impl Counter {
    fn new() -> Self {
        Counter { count: 0 }
    }
}

Counter 구조체의 fn new() 메소드를 정의하는데, count 필드의 값을 0으로 초기화합니다.

다. Counter를 위한 Iterator 트레잇 구현
impl Iterator for Counter {
    type Item = usize;

    fn next(&mut self) -> Option<Self::Item> {
        self.count += 1;
        if self.count <= 5 {
            Some(self.count)
        } else {
            None
        }
    }
}

Counter 구조체를 위해 Iterator 트레잇을 구현하는데
fn new() 메소드에서 반환값은 Item인데 usize 형식이고,
매번 next()가 호출될 때 count를 1씩 증가시키고, 5보다 작거나 같으면 Some(count)을 반환하고, 그렇지 않으면 None을 반환하여 반복을 종료합니다.

라. 메인 함수
fn main() {
    let c = Counter::new();
    for i in c {
        println!("{}", i);
    }
}

Count 구조체의 count 필드를 0으로 초기화한 후 c에 넣고
c를 1씩 증가시키면서 실행하는데 5까지만 출력하고 종료합니다.
Counter::new()로 만든 c는 Iterator를 구현하고 있기 때문에 for 루프에서 사용 가능합니다.

6. 정리

  • Iterator는 next()를 통해 요소를 하나씩 반환합니다.
  • .map, .filter 등은 지연 평가(lazy evaluation) 방식으로 동작합니다.
  • .collect()나 for 루프 등을 통해 실제로 실행됩니다.
  • 반복 가능한 자료형은 대부분 이터레이터를 제공합니다 (Vec, HashMap, Range 등)
  • Rust의 함수형 프로그래밍 스타일을 구성하는 핵심 개념입니다.

러스트에서 클로저(Closure)의 개념

러스트의 클로저(Closure)함수형 프로그래밍(Functional Programming)의 핵심 개념 중 하나로서, 변수에 저장하거나 다른 함수에 인자로 넘길 수 있는 익명 함수이며, 정의된 위치의 외부 변수를 자동으로 사용할 수 있습니다. 이것이 일반 함수와 큰 차이점입니다.

함수형 프로그래밍은 다음과 같은 특성을 중시합니다:

특징설명
일급 함수 (First-class function)함수를 값처럼 변수에 할당하거나 인자로 전달 가능
고차 함수 (Higher-order function)함수를 인자로 받거나 함수를 반환하는 함수
불변성 (Immutability)상태를 변경하지 않고 새로운 값을 생성
순수 함수 (Pure Function)동일한 입력에 대해 항상 동일한 출력을 반환, 부작용 없음

👉 클로저는 이러한 특징, 특히 일급 함수고차 함수 개념을 구현하는 데 매우 적합합니다.


1. 클로저(Closure) 란?

클로저는 익명 함수로 fn과 함수이름이 없습니다.
변수에 저장하거나 다른 함수에 인자로 넘길 수 있습니다.

fn main() {
    let add = |a, b| a + b;
    println!("{}", add(2, 3)); // 5
}
  • |a, b| a + b는 익명 함수(anonymous function) 형태이며, 입력값 a와 b를 받아서 a + b를 반환합니다.
  • 이 클로저는 변수 add에 저장됩니다. 다시 말해 add는 클로저를 가리키는 변수(바인딩된 이름)입니다.
  • 따라서, 이후에는 add(2, 3)처럼 add를 통해 클로저를 호출할 수 있습니다.
  • println!(“{}”, add(2, 3));은 add(2, 3)을 호출하여 2 + 3을 계산하고 결과는 5입니다. 이 결과를 println!을 사용해 출력합니다.

가. function으로 만들었다면?

아래와 같은 코드가 됩니다. 함수는 입력 값과 반환 형식의 타입을 반드시 명시해야 하는데, 클로저는 타입이 추론되는 점도 다릅니다.

fn add(a:i32,b:i32) -> i32 {
    a + b
} 

fn main() {    
    println!("{}", add(2, 3));
}

2. 클로저의 환경 캡처(Capturing the Environment)

클로저는 정의된 위치의 외부 변수를 자동으로 사용할 수 있습니다. 이게 일반 함수와 큰 차이점입니다.

fn main() {
    let x = 5;

    let add_x = |a| a + x;

    println!("{}", add_x(3)); // 8}
  • 여기서 x는 클로저 외부에서 정의된 변수지만, 클로저 내부에서 자동으로 캡처(capture) 됩니다.
  • 함수에서는 이런 일이 불가능합니다. 다시 말해 x가 함수의 입력 인수로 지정돼야 합니다.
    fn add_x(a: i32, x: i32) -> i32 {
    a + x
    }
  • 또한 클로저의 인수로 a만 있기때문에 add_x의 입력 값으로 a만 입력하면 되고, x는 입력할 필요가 없습니다.
  • 실행 결과는 3 + 5 = 8입니다.

3. 캡처 방식에 따른 트레잇

Rust는 클로저가 외부 변수를 어떻게 사용하는지에 따라 다음 3가지 트레잇 중 하나로 자동 구현됩니다:

트레잇설명예시
Fn불변 참조로캡처 (&T)읽기만 하는 클로저
FnMut가변 참조로 캡처 (&mut T)외부 값을 수정하는 클로저
FnOnce값을 소유하여 캡처 (T)한 번만 호출 가능한 클로저

가. Fn (읽기만 함)

fn main() {
    let x = 1;
    let closure = |a| a + x;
    println!("{}", closure(2)); // 3
}
  • x를 읽기만 하므로 Fn 트레잇으로 작동합니다.
  • 실행 결과는 2 + 1 = 3입니다.

나. FnMut (수정)

fn main() {
    let mut x = 1;
    let mut closure = |a| {
        x += a;
        x
    };

    println!("{}", closure(2)); // 3
    println!("{}", closure(2)); // 5
}
  • x += a처럼 외부 값인 x를 수정하므로 FnMut 트레잇이 필요합니다. 내부적으로 사용되고 FnMut는 어디에도 없습니다.
  • 한번 실행하면 x가 2 + 1 = 3이 되고, 두번째 closure(2)는 2 + 3 = 5를 반환합니다.
  • 당연히 x 변수는 mut(가변 변수)로 선언되었습니다.

다. FnOnce (이동)

fn main() {
    let s = String::from("hello");

    let closure = move || {
        println!("{}", s);
    };

    closure();
    // closure(); // ❌ 두 번 호출하면 에러: FnOnce
}
  • let closure = move || {
    println!(“{}”, s);
    };
    이 줄에서 클로저를 정의하고 closure라는 변수에 저장합니다.
  • move 키워드는 클로저가 외부 변수 s의 소유권을 가져오게 만듭니다.
  • 즉, s는 더 이상 main 함수 안에서는 사용할 수 없습니다.
  • 클로저 내부에서 s를 사용하므로 소유권이 클로저로 이동(move) 됩니다.
  • 이 클로저는 FnOnce 트레잇만 구현됩니다.
    이유: s의 소유권을 가져오면, 클로저는 단 한 번만 호출할 수 있기 때문입니다.
  • s가 외부 변수이므로 ||안에 넣지 않고, 실행문 안에 들어가 있습니다.

4. 클로저 vs 함수 비교 표

항목클로저 (Closure)함수 (Function)
정의 방식`let c =x
이름익명 함수(Anonymous) 형태, 변수에 저장명시적으로 이름을 정의
타입 명시 여부생략 가능 (`x
리턴 타입대부분 추론됨 (→ 생략 가능)명시하거나 생략 가능 (하지만 복잡한 경우 명시 권장)
외부 변수 접근가능 (환경 캡처: by ref, mut ref, move)불가능 (오직 인자로만 데이터 전달)
사용 가능 위치함수 내에서 정의, 변수처럼 전달모듈 내 어디든 정의 가능
함수 포인터와의 호환fn 타입으로 직접 전달하려면 명시 필요기본적으로 fn 타입 (e.g. fn(i32) -> i32)
트레잇 구현Fn, FnMut, FnOnce 자동 구현됨일반 함수는 Fn 트레잇으로 처리 가능
호출 방식변수명으로 호출 (c(3))함수명으로 호출 (c(3))
Move 가능 여부move 키워드로 명시적 이동 가능소유권 개념 없음 (독립된 스코프)
유연성매개변수와 환경 캡처를 자유롭게 조합환경 변수와는 독립적
실행 성능최적화 후 함수 수준의 성능 가능고정된 바이너리 코드로 일반적으로 더 최적화
사용 예일시적인 로직, 고차 함수 인자, iterator재사용 가능한 명령 블록, API 정의
중괄호 생략 가능 여부

단일 표현식이면 생략 가능생략 불가
항상 중괄호 {} 필요

5. 요약 정리

항목설명
클로저외부 환경 캡처 가능한 익명 함수
Fn, FnMut, FnOnce클로저의 호출 방식에 따른 트레잇 분류

스마트 포인터(Smart Pointer)

Rust는 메모리 안전성을 보장하기 위해 소유권 시스템을 사용하며, 여기에 스마트 포인터라이프타임이 핵심 역할을 합니다. 스마트 포인터는 데이터를 가리키는 기능이외에 소유권, 참조 카운팅 등 추가 기능을 포함하며, Box<T>, Rc<T>, RefCell<T>에 대해 설명하겠습니다.


1. 스마트 포인터(Smart Pointer)란?

  • 일반 포인터처럼 데이터를 가리키지만, 추가적인 기능(소유권, 참조 카운팅 등)을 포함합니다.
  • 표준 스마트 포인터의 종류에는 아래 세 가지가 있습니다.
    1. Box<T>: 일반 포인터는 스택에 저장되는데, Box는 힙에 데이터를 저장합니다.
    2. Rc<T>: 참조 카운팅(reference counting)을 통한 공유
    3. RefCell<T>: 런타임 시점 가변성 검사

2. 스마트 포인터의 종류

가. Box<T> – 힙에 저장

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}
  • let b = Box::new(5);
    5는 i32 타입이므로 스택에 저장되는 것인데,
    Box::new를 통해 힙에 저장하고, 변수 b에 할당하므로
    변수 b는 Box<i32> 타입이 되며, 힙에 있는 5를 가리킵니다.
  • println!(“b = {}”, b);
    Box는 Deref(역참조) trait을 구현하고 있어서,
    자동으로 Box 안의 값을 꺼내서 5를 출력해 줍니다.

변수 b는 Box 타입이 되며, 힙에 있는 5를 가리킵니다.

  • 박스는 여러분이 데이터를 스택이 아니라 힙에 저장할 수 있도록 해줍니다.
  • 구조체 간 재귀 타입 정의 시 유용합니다.
  • 아래와 같이 재귀 타입의 enum 정의시, 다시 말해 List가 List를 포함하고 잇으므로, 컴파일 타임에서는 List 타입의 크기를 알 수 없으므로 아래를 실행하면
enum List {
    Cons(i32, List),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

error[E0072]: recursive type ‘List’ has infinite size
(에러[E0072]: 재귀 타입 List가 무한한 크기를 가지고 있습니다)
란 에러 메시지가 나옵니다.

재귀 타입 열거형 라이프타임 오류

따라서, 아래와 같이 List를 Box로 감싸야 합니다.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

Box를 사용해서 완성한 코드는 아래와 같습니다.

#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1,
        Box::new(Cons(2,
            Box::new(Cons(3,
                Box::new(Nil))))));
    println!("list = {:?}", list);
}
  • List 열거형을 출력하기 위해서 #[derive(Debug)]를 추가해야 하며, println!에서는 {:?}로 출력 포맷을 지정해야 합니다.
  • Box::new로 List를 구현합니다.

나. Rc<T> – 여러 소유자 공유

어떤 값이 계속 사용되는지 아니면 그렇지 않은지를 알기 위해 해당 값에 대한 참조자의 갯수를 계속 추적하는 것입니다. 만일 값에 대한 참조자가 0개라면, 그 값은 어떠한 참조자도 무효화하지 않고 메모리에서 정리될 수 있습니다.

우리 프로그램의 여러 부분에서 읽을 데이터를 힙에 할당하고, 어떤 부분이 그 데이터를 마지막에 이용하게 될지 컴파일 타임에는 알 수 없는 경우 Rc 타입을 사용합니다. 만일 어떤 부분이 마지막으로 사용할지 컴파일 타임에 알 수 있다면 보통의 소유권 규칙이 적용됩니다.

use std::rc::Rc;

fn main() {
    let a = Rc::new(10);
    let b = Rc::clone(&a);
    println!("a = {}, b = {}", a, b);
}
  • use std::rc::Rc;
    Rc는 프렐루드(prelude, 프렐루드는 Rust가 모든 Rust 프로그램에 자동으로 가져오는 항목들의 목록입니다. )에 포함되어 있지 않으므로 use 구문을 추가했습니다. Box는 use를 사용하지 않는 것과 대비됩니다.
  • let a = Rc::new(10);
    10을 Rc에 저장한 후 a에 할당합니다.
    따라서, a는 Rc<i32> 타입이 되고, 다시 말하면 a는 10을 가리키는 참조 카운팅 스마트 포인터입니다.
    이 경우 참조 카운트는 1입니다.
  • let b = Rc::clone(&a);
    a.clone()을 호출할 수도 있지만, 러스트의 관례는 Rc::clone(&a)를 사용합니다. Rc::clone은 clone 이 하는 것처럼 깊은 복사 (deep copy) 를 만들지 않고, 오직 참조 카운트만 증가 시켜 큰 시간이 들지 않습니다.
    이제 참조 카운트는 2가 됩니다.
  • println!(“a = {}, b = {}”, a, b);
    Rc는 Deref(역참조) trait을 구현해서 자동으로 &T처럼 동작하므로 내부 값이 출력됩니다.
    출력 값은 a = 10, b = 10입니다.
스마트 포인터(Rc)
스마트 포인터(Rc)
  • 단일 스레드 환경에서만 사용가능합니다.
  • 참조수는 Rc::strong_count(&a)로 확인 가능합니다.

다. RefCell<T> – 런타임 내부 가변성

내부 가변성 (interior mutability) 은 어떤 데이터에 대한 불변 참조자가 있을 때라도 여러분이 데이터를 변형할 수 있게 해주는 러스트의 디자인 패턴입니다. 보통 이러한 동작은 빌림 규칙에 의해 허용되지 않습니다.

불변 및 가변 참조자를 만들때, 우리는 각각 & 및 &mut 문법을 사용합니다. RefCell을 이용할때는 borrow와 borrow_mut 메소드를 사용하는데, 이들은 RefCell이 소유한 안전한 API 중 일부입니다. borrow 메소드는 스마트 포인터 타입인 Ref<T>를 반환하고, borrow_mut는 스마트 포인터 타입 RefMut<T>를 반환합니다. 두 타입 모두 Deref를 구현하였으므로 우리는 이들을 보통의 참조자처럼 다룰 수 있습니다.

만일 런타임 시 빌림 규칙을 위반한다면, RefCell의 구현체는 panic!을 일으킵니다.

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(1);
    *data.borrow_mut() += 1;
    println!("data = {:?}", data.borrow());
}
  • let data = RefCell::new(1);
    1이라는 값을 감싼 RefCell<i32>를 생성한 후 data에 할당하므로, data는 RefCell<i32> 타입이며, 내부에 1을 가지고 있습니다.
    data는 mut가 없으므로 불변 참조입니다.
  • *data.borrow_mut() += 1;
    .borrow_mut()은 RefCell<i32>인 data의 내부 값을 가변 참조(mutably borrow) 하겠다는 의미입니다.

    * 연산자는 실제 값을 꺼내는 역할을 합니다.
    따라서, 이 코드는 data의 내부값을 꺼내서 1을 더한다는 의미입니다.

    이 함수는 런타임에 현재 가변 참조가 가능한지 검사해서
    이미 다른 가변/불변 참조가 있으면 panic!이 발생합니다.
  • println!(“data = {:?}”, data.borrow());
    .borrow()는 내부 값을 불변 참조(immutably borrow)하겠다는 뜻입니다.
    {:?}는 Debug 포맷 출력입니다.
    이 시점에서 내부 값은 2입니다.
    따라서, 출력 결과는
    data = 2 입니다.

Box와 Rc는 컴파일 타임 불변성, RefCell은 런타임 가변성


3. Box<T>, Rc<T>, RefCell<T> 비교

  • Rc<T>는 동일한 데이터에 대해 복수개의 소유자를 가능하게 하지만, Box<T>와 RefCell<T>는 단일 소유자만 갖습니다.
  • Box<T>는 컴파일 타임에 검사된 불변 혹은 가변 빌림을 허용하고, Rc<T>는 오직 컴파일 타임에 검사된 불변 빌림만 허용하지만, RefCell<T>는 런타임에 검사된 불변 혹은 가변 빌림을 허용합니다.
  • RefCell<T>가 런타임에 검사된 가변 빌림을 허용하기 때문에, RefCell<T>가 불변일 때라도 RefCell<T> 내부의 값을 변경할 수 있습니다.

4. 요약 정리

항목설명
Box<T>힙에 값 저장, 단일 소유자
Rc<T>다중 소유자 참조 카운팅, 단일 스레드만 지원
RefCell<T>내부 가변성 제공, 런타임시 체크

라이프타임(Lifetime)

Rust는 메모리 안전성을 보장하기 위해 소유권과 함께 라이프타임이라는 개념을 도입합니다. Rust는 라이프타임을 자동으로 추론하지만, 필요한 경우 참조가 유효한 범위인 라이프타임을 컴파일러에게 명시적으로 알려주어 댕글링 참조(dangling reference)를 방지합니다.

댕글링 참조는 메모리가 해제된 이후에도 해당 메모리 위치를 가리키는 참조를 말합니다. 즉, 참조자가 가리키는 메모리가 더 이상 유효하지 않은 상태를 의미합니다. 이러한 댕글링 참조는 프로그램 충돌이나 데이터 손상을 유발할 수 있는 잠재적인 위험을 가지고 있습니다.

1. 라이프타임이 필요한 이유

다음 코드를 보세요.

fn main() {
    let r;
    {
        let x = 5;
        r = &x; // ❌ x는 여기서 소멸됨
    }
    println!("{}", r); // 컴파일 에러!
}
  • x는 내부 블록에서 생성되어 그 블록이 끝나면 해제되는데,
  • r은 그보다 오래 살아 남으므로 유효하지 않은 x를 참조가 됩니다. 이것을 댕글링 참조라고 합니다.
  • Rust는 댕글링 참조를 컴파일 타임에 잡아냅니다.
댕글링 참조(dangling reference)

2. 기본 라이프타임 추론

위와 같이 대부분의 경우, Rust는 라이프타임을 자동으로 추론합니다. 그러나 두 개 이상의 참조가 관련되면 명시적 표기가 필요합니다.

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("hello");
    let s2 = String::from("world");
    let result = longest(&s1, &s2);
    println!("The longest string is: {}", result);
}

이 함수는 x, y 두 개 중 어떤 것이 수명이 긴지 알 수가 없어서 컴파일 에러 발생 가능성이 있으므로 라이프타임 명시가 필요합니다.

lifetime 에러

3. 라이프타임 명시하기

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
  • ‘a는 라이프타임 매개변수입니다.
  • x, y, 반환값에 모두 ‘a를 붙여 “동일한 수명을 가진다”는 것을 명시해야 합니다.
  • 입력 참조가 없어진 상태에서 반환값이 살아 있으면, 다시 말해 반환값이 입력 참조보다 수명이 길면 오류가 발생하기 때문입니다.

4. 구조체에서의 라이프타임

아래와 같이 구조체 필드의 형식이 String인 경우는 Lifetime을 명시하지 않아도 되나,

struct Excerpt {
    part: String,
}

fn main() {
    let text = String::from("Rust는 안전하다.");
    let excerpt = Excerpt { part: text };
    println!("{}", excerpt.part);
}

구조체 필드의 타입이 참조인 경우, 여기서는 &str(문자열 슬라이스), 반드시 라이프타임을 명시해야 합니다.

라이프타임 지정시 구조체명 오른쪽의 <> 안에 라이프타임 매개변수를 ‘a라고 명시했고, 필드의 형식에도 라이프타임 매개변수 ‘a를 추가했습니다.

struct Excerpt<'a> {
    part: &'a str,
}

fn main() {
    let text = String::from("Rust는 안전하다.");
    let excerpt = Excerpt { part: &text[0..4] };
    println!("{}", excerpt.part);
}
  • Excerpt<‘a>는 part 필드가 ‘a 동안 유효함을 의미합니다. 이 말은 결국, “Excerpt 구조체는 자신이 가리키는 문자열(&str)보다 더 오래 살아있을 수 없다”는 제약을 의미하며, “Excerpt 구조체 내의 part 필드는 text가 살아 있는 동안만 유효하다”는 의미도 됩니다.
  • 출력 결과는 Rust입니다.

5. 함수 내 수명 충돌 예시

가. 문제

fn return_ref<'a>(x: &'a str, y: &str) -> &'a str {
    // x // ✔ OK
    return y; // ❌ y는 'a보다 짧은 수명일 수 있음
}
fn main() {
    let s1 = String::from("hello");
    let s2 = String::from("world");
    let r = return_ref(&s1, &s2);
    println!("r = {}", r);
}
  • 함수의 반환값이 ‘a 수명을 가진 참조여야 하므로,
    라이프타임 매개변수 ‘a를 갖지 않은 y는 반환할 수 없습니다.

나. 해결

fn return_ref<'a>(x: &'a str, y: &'a str) -> &'a str {
    // x // ✔ OK
    return y; // ❌ y는 'a보다 짧은 수명일 수 있음
}
  • y를 출력하고자 하는 경우는 y: &str을 y: &’a str로 수정해야 합니다.
  • x를 출력하려고 하는 경우는 x 앞의 주석을 제거하고, return y;를 주석 처리하면 됩니다.

6. ‘static 라이프타임

가. 반환 형식이 String인 경우 문제 없음

아래와 같이 반환 타입이 String인 경우는 문제가 없는데, 출력이 잘 됩니다.

fn get_static_str() -> String {
    String::from("나는 프로그램 전체에서 살아있다!")
}

fn main() {
    let msg = get_static_str();
    println!("{}", msg);
}

나. 반환 형식이 &str인 경우 문제 있음

아래와 같이 반환 형식이 &str인 경우는 실행 시

fn get_static_str() -> &str {
    "나는 프로그램 전체에서 살아있다!"
}

fn main() {
    let msg = get_static_str();
    println!("{}", msg);
}

“라이프타임 매개변수 지정이 기대된다”고 하면서 빌려올 값이 없으므로 ‘static을 추가하거나, 반환 형식을 String으로 하라고 제안합니다.

'static 라이프타임 에러
  • 'static프로그램 전체 수명을 의미
  • 문자열 리터럴 등 컴파일 타임 상수에 주로 사용

다. 수정 코드

&str을 &’static str로 수정하면 문제 없이 출력됩니다.

fn get_static_str() -> &'static str {
    "나는 프로그램 전체에서 살아있다!"
}

fn main() {
    let msg = get_static_str();
    println!("{}", msg);
}
'static을 추가하여 정상적으로 출력된 화면

7. 요약 정리

개념설명
라이프타임 ‘a참조가 얼마나 오래 유효한지 표시하는 표기
함수 수명 명시여러 참조 중 어느 것이 반환되는지(라이프타임)를 명확히 지정
구조체 + 참조참조 필드가 있으면 명시적 라이프타임 필요
‘static프로그램 전체 수명 (전역 문자열 등)
오류 예방 목적댕글링 참조를 컴파일 타임에 방지함

8. 결론

라이프타임은 Rust의 가장 강력하면서도 헷갈릴 수 있는 개념입니다. 하지만 “누가 누구보다 오래 살아야 하는가”를 생각하면서 설계하면, 오히려 런타임 오류 없이 안전한 코드를 보장받을 수 있습니다.

제네릭(Generic)과 트레잇(Trait)

Rust는 코드의 재사용성과 타입 안전성을 동시에 보장하기 위해 제네릭(Generic)과 트레잇(Trait)을 사용합니다. 이 개념은 Rust의 함수, 구조체, 열거형 등 다양한 곳에 적용되는데, 제네릭은 일반화된 타입을 의미하고, Trait은 공통된 행위 또는 특성을 의미합니다.


1. 제네릭(Generic)

가. 형식별 별도 함수

제네릭은 “특정 타입에 얽매이지 않고 다양한 타입에 대해 작동할 수 있도록 하는 일반화된 타입”을 의미합니다. 따라서, 타입에 의존하지 않는 유연한 코드를 작성할 수 있도록 도와줍니다.

아래 코드는 정수 타입 i32에 대해서는 largest_i32 함수를 사용하고, char에 대해서는 largest_char 함수를 따로 적용하고 있습니다.

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&numbers);
    println!("The largest number is {}", result);

    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&chars);
    println!("The largest char is {}", result);
}

위 코드를 실행하면 아래와 같이 “가장 큰 수는 100, 가장 큰 문자는 y(번역)”라고 화면에 표시됩니다.

i32 정수와 char에 따라 다른 함수 적용

나. 제네릭 타입의 하나의 함수

위와 같이 i32와 char라는 데이터 타입에 따라 다른 함수를 하나의 함수로 합치기 위해 T라는 제네릭을 이용했습니다.

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];
    for &item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];
    let result = largest(&numbers);
    println!("The largest number is {}", result);
    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest(&chars);
    println!("The largest char is {}", result);
}

위 코드를 실행하면
The largest number is 100
The largest char is y
가 화면에 출력되는데,

Generic T를 정의하는 구문에서 : PartialOrd + Copy를 제거하고 실행하면

제너릭 사용시 PartialOrd 트레잇을 사용하지 않아 에러 발생

error[E0369]: binary operation > cannot be applied to type T
(에러 E0369: 이진 동작인 > 는 T 타입에 적용될 수 없습니다)
란 에러 메시지가 표시되고,

그 아래 T 다음에 “: std:cmp::PartialOrd를 추가하라고 합니다.

PartialOrd는 두 값을 비교하여 크거나 작거나 같은지 부분적으로만 결정할 수 있는 경우를 위해 설계되었습니다.
예를 들어 부동 소수점 숫자(floating-point numbers)의 경우 NaN (Not a Number) 값이 존재하기 때문에 완전한 비교가 불가능합니다.
Ord는 두 값을 항상 비교할 수 있는 경우를 위해 설계되었습니다.

그래서 T 다음에 PartialOrd만 추가하고 Copy는 빼고 실행하면

제너릭 사용시 Copy 트레잇을 사용하지 않아 에러 발생

let mut largest = list[0];의 list[0]에서
error[E0508]: cannot move out of type [T], a non-copy slice
(복사할 수 없는 슬라이스인 `[T]` 유형에서 이동할 수 없습니다)
란 에러가 발생하고,

let mut largest = &list[0];에서는
type ‘T’가 Copy trait를 구현하지 않았기 때문에 move가 일어났는데, 여기서 data가 move될 수 없다고 합니다.

정수나 문자 타입에 공통적으로 적용하기 위해 제네릭 타입을 적용하는 것은 좋은데, 적용하기 위해 추가해야 할 요소들이 많습니다. 그리고, 두 개의 Trait을 연결하기 위해서는 +를 사용헸습니다.


다. 구조체에 한 가지 제네릭 적용

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let int_point = Point { x: 1, y: 2 };
    let float_point = Point { x: 1.0, y: 2.0 };

    println!("({:.1}, {:.1})", int_point.x, int_point.y);
    println!("({:.1}, {:.1})", float_point.x, float_point.y);
}
  • 위 코드의 경우 타입이 T 하나뿐이 없기 때문에, 타입이 한 가지인 경우만 적용가능합니다. 따라서 Point 구조체를 정의할 때 i32 또는 f64 타입 한 가지로만 지정했습니다.
  • 그리고, {}라고 하면 실수인 경우에도 1로 출력이 돼서 {:.1}로 출력 형식을 변경했습니다.

라. 구조체에 두 가지 제네릭 적용

  • 아래와 같이 하나의 구조체에 여러 타입, T와 U를 지정할 수 있습니다. 이 경우 T나 U는 특별한 의미가 있는 것이 아니고, 타입이 다르다는 것을 의미합니다.
struct Mixed<T, U> {
    x: T,
    y: U,
}

위에서 두 가지 타입을 지정했으므로 아래와 같이 i32와 f64를 같이하거나 달리해서 지정할 수 있습니다.

제너릭으로 두 가지 타입 사용

위 코드를 실행하면
(1, 2)
(1.0, 2)
식으로 화면에 표시됩니다.


2. 트레잇(Trait)

트레잇은 공통된 동작을 정의하는 인터페이스입니다. 특정 타입이 트레잇을 구현하면, 해당 트레잇의 메서드를 사용할 수 있습니다.

가. 예제 코드

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    println!("요약: {}", article.summarize());
}

(1) trait 트레잇 이름 { 메소드 선언 }

  • trait 키워드로 트레잇, 여기서는 Summary 트레잇을 정의하는데, 함수는 함수명과 반환 타입만 지정했고, 구체적인 동작은 정의되지 않았습니다.

(2) 구조체 정의

  • struct Article { }은 Article이란 구조체를 title과 author 모두 String으로 정의했습니다.

(3) impl 트레잇명 for type명

  • Trait을 구현하는 방식은 impl Trait for Type으로서
    impl Summary for Article이라고 적은 다음
    중괄호 안에서 fn summarize로 메소드를 구현하는데,
    인수는 &self로 자기 자신, 여기서는 Article을 받고,
    반환 형식은 위에서 지정했듯이 String 타입이며,
    구체적으로 실행하는 것은
    format!(“{} by {}”, self.title, self.author)으로
    self.title by self.author란 문자열을 생성합니다.

(4) main 함수에서 구조체 생성 및 트레잇 메소드 실행

  • main 함수에서는 구조체를 생성한 다음 aricle.summarize로 title과 author를 이용한 문자열을 만든(format) 후 println!로 화면에 출력합니다.
  • 따라서, 위 코드를 실행하면 아래와 같이 “요약 : Rust 배우기 by 홍길동”이 출력됩니다.
트레잇 정의(trait 트레잇명)와 구현(impl trait for type)을 실행한 결과

나. 트레잇 바운드(Trait Bound)

함수 인자에서 트레잇을 사용하는 방법은 다음과 같이 세 가지가 있습니다.

(1) 케이스 1

item을 impl Summary로 지정하고, main 함수에서 notify 함수의 인수로 aritcle을 입력하는 것입니다.

fn notify(item: impl Summary) {
    println!("알림: {}", item.summarize());
}

예제는 아래와 같습니다.

기존 코드에 위 fn notify를 추가하고,
main함수에서 println!(“요약: {}”, article.summarize());을 주석 처리하고, notify(article);을 추가하면 됩니다.

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn notify(item: impl Summary) {
    println!("New notification: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    notify(article);
    // println!("요약: {}", article.summarize());
}

(2) 케이스 2

아래는 “notify 함수를 정의하는데, 제네릭 타입 T는 Summary 트레잇을 구현한 타입으로 제한하고, 함수의 매개변수 item은 T 타입이다”라는 의미입니다. 가장 많이 사용하는 형식입니다.

fn notify<T: Summary>(item: T) {
    println!("알림: {}", item.summarize());
}

위 코드를 활용한 코드는 아래와 같이 Generic에서 설명한 코드와 비슷합니다. 케이스 1과 다른 점은 article을 참조 형식(&article)으로 전달하는 것입니다.

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn notify<T: Summary>(item: &T) {
    println!("New notification: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    notify(&article);
    // println!("요약: {}", article.summarize());
}

(3) 케이스 3

여러 트레잇을 조합할 수도 있습니다.

fn process<T: Summary + Clone>(item: T) {
    println!("처리: {}", item.summarize());
}

구조체가 Clone이 가능하도록 Struct 구조체 선언문 위에
#[derive(Clone)] 문이 있어야 하고,
main함수에서 notify의 인수를 aricle로 지정하면 됩니다.

trait Summary {
    fn summarize(&self) -> String;
}

#[derive(Clone)]
struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn notify<T: Summary + Clone>(item: T) {
    println!("New notification: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    notify(article);
    // println!("요약: {}", article.summarize());
}

다. 트레잇 객체 (Trait Object)

정확한 타입을 모르더라도 트레잇이 구현된 어떤 타입이든 받아들이고 싶을 때는 다음과 같이 dyn 키워드를 사용합니다.

fn notify(item: &dyn Summary) {
    println!("알림: {}", item.summarize());
}
  • 런타임 시 어떤 타입인지 결정됩니다 (동적 디스패치).
  • 반드시 참조(&) 형태로만 사용 가능합니다.
trait Summary {
    fn summarize(&self) -> String;
}

// #[derive(Clone)]
struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn notify(item: &dyn Summary) {
    println!("New notification: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    notify(&article);
    // println!("요약: {}", article.summarize());
}

🧠 요약

개념설명
Generic여러 타입에 대해 재사용 가능한 코드
Trait공통 기능 정의, 타입에 메서드 제공
Trait Bound함수나 구조체에 트레잇 구현 타입만 허용
impl Trait간결하게 바운드 지정 가능
dyn Trait트레잇 객체, 런타임에 타입이 결정됨

모듈(Module), 패키지(Package), 그리고 use 키워드

Rust 프로젝트가 커지면 코드 구조를 깔끔하게 나누는 것이 중요합니다. 따라서, 모듈과 패키지, 그리고 패키지와 크레이트의 개념에 대해서 살펴보고, pub 키워드, 하위 모듈, 파일과 모듈 연동, use 키워드에 대해서도 알아보겠습니다.


1. 모듈(Module)

  • Rust에서 모듈은 코드를 그룹화하는 단위입니다.
  • 파일이나 폴더 단위로 구성할 수 있습니다.
mod greetings {
    pub fn hello() {
        println!("안녕하세요!");
    }
}

fn main() {
    greetings::hello();
}
  • mod 키워드로 모듈을 선언합니다.
  • 함수, 변수 등을 외부에서 사용하려면 pub으로 공개(public)해야합니다.
  • main 함수에서 greetings 모듈의 hello 함수를 사용하려면 ::을 이용해서 모듈명::함수명으로 선언해야 합니다. 따라서, greetings::hello();가 됐습니다.
  • 위 코드를 실행하면 greetings 모듈의 hello 함수가 실행되어
    “안녕하세요!”가 화면에 출력됩니다.
  • fn hello() 앞의 pub를 지우고 실행하면
    hello 함수가 private라는 에러 메시지가 표시됩니다. private이기 때문에 main함수에서 불러서 사용할 수가 없는 것입니다.

2. 하위 모듈

mod greetings {
    pub mod english {
        pub fn hello() {
            println!("Hello!");
        }
    }
}

fn main() {
    greetings::english::hello();
}
  • 모듈 내에 또 다른 모듈을 정의할 수 있음
    위 코드를 보면 greetings안에 english 모듈이 있고, 그 안에 hello 함수가 있습니다.
  • 따라서, main함수에서 불러서 사용할 수 있도록 맨 위 module에만 pub이 없고, 그 안 module에는 pub이 추가되어 있고, hello 함수에 pub이 붙어 있는 것은 위와 같습니다.
  • 또한 mod 안의 mod 안에 hello 함수가 있으므로 ::을 두번 써서, greetings::english::hello();라고 함수를 호출하고 있습니다.
  • 위 코드를 실행하면 “Hello!”라고 화면에 출력됩니다.


3. 파일과 모듈 연동

  • mod로 선언한 모듈은 같은 src 폴더 내에 다른 rs 파일로 분리 가능
src/
 ├── main.rs   // 메인 파일
 └── greetings.rs  // greetings 모듈

(greetings.rs)

pub fn hello() {
    println!("안녕하세요!");
}
  • rs 파일명이 greetings이므로 파일명이 module이 돼서, greetings.rs 파일 안에는 mod 선언이 불필요하며, 함수는 공개되어야 하므로 pub를 앞에 붙여야 하고, println! 문의 내용은 같습니다.

(main.rs)

mod greetings;

fn main() {
    greetings::hello();
}
  • main.rs에서 main 함수 안이 아니라 밖에 greetings module을 불러오기 위해 mod greetings;라고 선언해야 하며, 모듈명::함수명으로 함수를 실행하는 것은 동일합니다.
  • 실행 결과는 “안녕하세요!”라고 동일합니다.
  • 아래와 같이 mod greetings;를 main함수의 아래에 배치해도 실행에 문제가 없습니다.
fn main() {
    greetings::hello();
}

mod greetings;

4. 패키지와 크레이트(Crate)

  • 패키지: 패키지는 크레이트들을 관리하고 빌드, 테스트, 배포하는 역할을 합니다. 패키지 안에는 여러 개의 바이너리 크레이트가 있을 수 있지만, 라이브러리 크레이트는 하나만 포함할 수 있습니다.
    cargo new my_project 명령으로 패키지를 생성하면, Cargo.toml 파일과 src 디렉토리가 생성되는데, src 디렉토리에는 크레이트의 소스 코드가 위치합니다. 
    Cargo.toml에서 패키지를 설정합니다.

  • 크레이트: 크레이트는 라이브러리 또는 실행 가능한 바이너리 코드를 제공하는 모듈 트리입니다. 크레이트는 모듈 시스템을 통해 코드 구조를 관리하고, 기능을 캡슐화하며, 다른 프로젝트에서 재사용될 수 있도록 합니다. 
바이너리 크레이트: 실행 가능한 바이너리 파일로 컴파일되는 크레이트입니다. main 함수를 포함하며, 실행 시 프로그램의 시작점이 됩니다. src/main.rs 파일이 바이너리 크레이트의 루트 역할을 합니다.
라이브러리 크레이트: 다른 프로젝트에서 재사용할 수 있는 코드 모음입니다. main 함수를 포함하지 않으며, 실행 파일로 컴파일되지 않습니다. src/lib.rs 파일이 라이브러리 크레이트의 루트 역할을 합니다.

5. use 키워드

  • 긴 경로를 간단히 하거나 외부 모듈을 가져올 때 사용
mod greetings {
    pub fn hello() {
        println!("안녕하세요!");
    }
}

use greetings::hello;

fn main() {
    hello();
}
  • main 함수 안에 greetings::hello();라고 표기해야 하지만,
    use greetings::hello;라고 main 함수의 밖에서 선언하면, hello();라고만 써서 함수를 간단하게 사용할 수 있습니다. hello는 중복되는 것을 주의해야 합니다.
  • 별칭(alias) 지정도 가능
use greetings::hello as say_hello;

fn main() {
    say_hello();
}
  • 별칭을 지정하면 별칭으로 함수명을 사용하면 됩니다.
  • mod와 use 키워드를 함께 사용 가능합니다.
    아래와 같이 mod greetings;로 greetins.rs 파일을 불러들인 후 use 키워드를 사용해 함수를 호출 할 수 있습니다.

mod greetings;
use greetings::hello as say_hello;


6. 요약

개념설명
mod모듈 선언, 코드 그룹화
pub공개 키워드, 외부 접근 허용(cf. private)
파일 분리mod greetings;와 greetings.rs
패키지/크레이트프로젝트 단위, 컴파일 단위
use모듈 경로 단축 및 별칭 지정

에러 처리 (panic, Result, Option, unwrap, expect, ? 연산자)

Rust는 안전한 시스템 프로그래밍 언어답게, 명시적이고 예측 가능한 에러 처리 방식을 제공합니다. 오늘은 Rust의 주요 에러 처리 도구인 panic, Result, Option, unwrap, expect, ? 연산자에 대해 다루겠습니다.


1. 패닉(panic!)

fn main() {
    panic!("예기치 못한 오류 발생!");
}
  • panic!를 사용하면 프로그램이 즉시 종료되고, panic! 안에 있는 메시지를 보여줍니다.
  • 디버깅 중 주로 사용되며, 복구 불가능한 에러에 적합합니다.

let v = vec![1, 2, 3];
v[99]; // 존재하지 않는 인덱스 → 자동 panic!

Vector v의 요소가 3개이고, index의 최대값이 2인데, 인덱스를 99로 지정하면 “index가 경계를 넘어섰다”는 메시지를 보여주면서 프로그램이 멈춥니다.


2. Result<T, E> 타입

Resut<T, E>는 복구 가능한 에러 처리를 위한 열거형으로, 성공했을 때는 Ok(T), 에러가 발생했을 때는 Err(E)를 반환합니다.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

[예제]

use std::fs::File;

fn main() {
    let result = File::open("hello.txt");

    match result {
        Ok(file) => println!("파일 열기 성공"),
        Err(e) => println!("에러 발생: {}", e),
    }
}
  • use std::fs::File;
    : 표준 라이브러리의 fs 모듈에 정의된 File 구조체를 현재 스코프에서 사용할 수 있도록 가져오는 구문입니다. 
  • let result = File::open(“hello.txt”);
    : hello.txt 파일을 여는데, 그 결과를 result에 저장합니다. result는 Result 열거형으로 File 구조체와 Error라는 2개의 variant(변형)를 가지고 있습니다.
  • match result {
    : match 연산자를 이용해 result의 결과값에 따라 처리합니다.
  • Ok(file) => println!(“파일 열기 성공”),
    : 파일 열기가 성공(Ok)이면 File 구조체 형식의 file을 variant로 갖습니다.
  • Err(e) => println!(“에러 발생: {}”, e),
    : 파일 열기에 실패하면 Err가 발생하는데, e라는 에러 종류를 갖습니다.

프로그램과 같은 폴더에 hello.txt가 없으면
“에러 발생: 지정된 파일을 찾을 수 없습니다. (os error 2)”가 화면에 출력되고,
파일이 있으면 “파일 열기 성공”이 출력됩니다.

Ok(file) => println!(“{:?} 파일 열기 성공”, file), 라고 코드를 수정하고 실행하면, hello.txt 파일이 있을 경우 file 구조체가 출력문(println!)에 전달되어
“File { handle: 0xb4, path: “\\?\D:\rust-practice\day12\hello.txt” } 파일 열기 성공”이 출력됩니다.


3. Option<T> 타입

값이 있을 수도 있고[Some(T)] 없을 수도 있음(None)을 나타냅니다. Result의 경우는 Ok(T), Err(E)인 것과 대비됩니다.

fn main() {
    let some_number = Some(10);
    // let some_number: Option<i32> = None;

    match some_number {
        Some(n) => println!("Some: {n}"),
        None => println!("None"),
    }
}
  • let some_number = Some(10);인 상태에서 위 코드를 실행하면
    Match 제어 흐름 연산자에서 Some(n)에 해당되므로 “Some: 10″이 화면에 출력되고,
  • 첫번째 줄 let some_number = Some(10);을 주석 처리하고, 두번째 줄의 주석을 제거하면 None과 매칭되어 “None”이 하면에 출력됩니다.

4. unwrap, expect

let f = File::open("hello.txt").unwrap(); // 실패 시 panic!
let f = File::open("hello.txt").expect("파일 열기 실패"); // 사용자 메시지 포함
  • unwrap과 expect는 Some(T)이거나, Ok(T)인 경우 내부의 T를 꺼내는(unwrap) 기능을 하고, None이거나, Err(E)인 경우는 빠르게 실패(fail fast)하고 싶을 때 사용하는데, expect는 unwrap와 달리 에러 메시지를 제공합니다.

  • hello.txt가 있을 경우 println!(“{:?} 파일 열기 성공”, f}를 이용해 f를 출력해보면 둘 다 아래와 같이 f를 출력합니다.
    File { handle: 0xb4, path: “\\?\D:\rust-practice\day12\hello.txt” } 파일 열기 성공
    File { handle: 0xb8, path: “\\?\D:\rust-practice\day12\hello.txt” } 파일 열기 성공

  • 그러나, 파일이 없으면 둘다 패닉이 발생하고, 에러 메시지를 표출하는데,
    let f = File::open(“hello.txt”).unwrap();은 사용자 메시지가 없고,

  • let f = File::open(“hello.txt”).expect(“파일 열기 실패”);은 에러 메시지 전에 “파일 읽기 실패”라는 사용자 메시지를 표시하는 것만 다릅니다.

5. ? 연산자(에러 전파)

use std::fs::File;
use std::io::{self, Read};

fn read_file() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt");
    let mut content = String::from("This is a test file.\n");
    f.read_to_string(&mut content)?;
    Ok(content)
}

fn main() {
    match read_file() {
        Ok(content) => println!("파일 내용:\n{}", content),
        Err(e) => eprintln!("파일 읽기 오류: {}", e),
    }
}
  • Err이면, 다시 말해 hello.txt가 없을 경우, ?는 panic을 발생시키지 않고 즉시 리턴을 해서 main문의 Err 분기를 처리합니다.
  • 그러나, ?가 없으면 다음 줄의 f.read_to_string(&mut content)?;으로 넘어가는데, read_to_string 메소드가 없다고 하면서 컴파일 에러가 발생합니다.
  • Result 타입에서만 사용 가능합니다.


6. Option<T>와 Result<T, E> 비교

타입의미실패 시
Option<T>값이 있을 수도[Some(T)], 없을 수도 있음(None)None
Result<T, E>성공[Ok(T)], 또는 실패[Err(E)] 결과 포함Err(E)

둘 다 match, if let, unwrap 등을 통해 사용가능합니다.


7. 요약

도구용도설명
panic!치명적 에러프로그램 즉시 종료
Result<T, E>복구 가능한 에러성공과 실패 구분
unwrap/expect빠르게 실패간결하지만 안전하지 않음
? 연산자에러 전파Result를 간단히 처리
Option<T>존재 여부 표현값이 있을 수도 없을 수도 있음

해쉬맵 (HashMap)

HashMap은 컬렉션의 일종으로, HashMap<K, V> 타입은 K 타입의 키에 V 타입의 값을 매핑한 것을 저장합니다. 해쉬맵은 벡터를 이용하듯 인덱스를 이용하는 것이 아니라 임의의 타입으로 된 키를 이용하여 데이터를 찾기를 원할때 유용합니다.


1. Struct와 HashMap

항목StructHashMap
정의 방식컴파일 타임에 고정된 필드와 타입 정의런타임에 키-값 쌍으로 유동적으로 데이터 저장
고정된 필드 이름임의의 키 (보통 String 등 Eq + Hash 트레잇 필요)
타입 안정성필드 타입이 컴파일 시점에 결정됨모든 값이 동일 타입이거나 제네릭으로 지정됨
사용 목적고정된 구조의 데이터 표현동적으로 키/값 데이터를 저장하고 조회

※ 제네릭(Generic) : 어떤 자료형이든 사용할 수 있도록 유연하고 재사용 가능한 코드를 만드는 기능

2. HashMap 생성과 삽입

HashMap을 사용하려면 먼저 std::collections에서 가져와야 합니다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert("Blue", 10);
    scores.insert("Red", 50);
}

HashMap::new()로 HashMap을 생성합니다.

let mut scores = HashMap::new();
는 가변형 scores란 변수에 빈 HashMap을 대입하는 것입니다. 다시 말하면 빈 HashMap을 가변형 scroes란 이름으로 만드는 것입니다.

insert(key, value)로 값을 추가할 수 있습니다.

scores.insert(“Blue”, 10);
는 scores는 HashMap의 키로 Blue(&str type), 값으로 10을 대입하는 것이고,

scores.insert(“Red”, 50);
는 scores는 HashMap의 키로 Red(&str type), 값으로 50을 대입하는 것입니다.

이때 HashMap은 키와 값의 타입을 모두 제네릭으로 받으며, 보통 자동 추론됩니다.
Blue와 Red는 &str 형식이고, 10과 50은 정수이므로 i32로 해쉬맵의 형식이 추론된 것입니다.

“Blue”와 “Red”를 String::from(“Blue”)와 String::from(“Red”)로 수정하면, Key의 type이 String으로 자동 변경됩니다.

첫번째는 String type이고, 두번째는 &str 타입이면 두번째 key 아래에 빨간색 물결 줄이 표시되는데, 커서를 갖다대면 mismatched types 에러가 표시됩니다.


3. 값 접근 및 소유권

값을 읽을 때는 get() 메서드를 사용합니다.

let team_name = "Blue";
let score = scores.get(team_name);
println!("{:?}", score); // Some(10)

get()은 Option<&V>를 반환하므로, Some(v) 또는 None으로 패턴 매칭하거나 unwrap_or 등을 활용해야 합니다.

    match score {
        Some(score) => println!("{team_name} 팀의 점수: {score}"),
        None => println!("{team_name} 팀의 점수를 찾을 수 없습니다."),
    }

println!(“{:?}”, score); 의 결과는 Some(10)인데, 위와 같이 match 흐름 제어 연산자를 사용하면 점수가 있으면 점수, 여기서는 10이 반환되고,

팀 이름을 이름이 없는 Green으로 변경하면 Option 값은 None이 출력되고, match 연산자의 결과는 “Green 팀의 점수를 찾을 수 없습니다.”란 메시지가 나오게 됩니다.

소유권 이슈

Rust의 HashMap은 키와 값의 소유권을 가져갑니다.

let field = String::from("Favorite color");
let value = String::from("Blue");

let mut map = HashMap::new();
map.insert(field, value);
// println!("{}", field); // 오류! 소유권이 이동됨

따라서, 위 코드에서 println! 앞의 주석을 제거하고 실행하면
value borrowed here after move 에러가 발생합니다.

위 오류를 없애려면 map.insert안 의 field를 &field로 수정하면 됩니다.


4. for 루프로 순회하기

HashMap의 모든 값을 순회하려면 for 루프를 사용합니다.

for (key, value) in &scores {
    println!("{}: {}", key, value);
}

참조로 순회하므로 키나 값을 수정하진 않지만, 읽을 수 있습니다.


5. 조건부 삽입: entry()와 or_insert()

entry()는 키의 존재여부에 따라 다른 동작을 하게 해주는 API입니다. entry 함수의 리턴값은 열거형 Entry인데, 해당 키가 있는지 혹은 없는지를 나타냅니다.

scores.entry("Yellow").or_insert(30);
scores.entry("Blue").or_insert(50);
  • “Yellow”라는 key가 없기 때문에 value 30이 추가되고,
  • “Blue”는 이미 존재하므로 기존 값(10)이 유지됩니다.

or_insert()는 존재하지 않을 때만 값을 삽입하는 매우 유용한 메소드입니다.


6. 값을 업데이트하기

HashMap의 값을 직접 변경하려면 get_mut() 또는 entry()를 활용할 수 있습니다.

기존 값 수정 (1) :

if let Some(score) = scores.get_mut("Red") {
    *score += 10;
}

if let Some(score) = scores.get_mut(“Red”) {
: score가 Red의 score라면 score를 가변 참조로 반환하는데,

*score += 10;
: 참조를 반환하므로 값을 수정하기 위해서는 dereference(역참조, 포인터가 가리키는 메모리 주소에 저장된 값을 읽거나 쓰는 것)하는 연산자 *를 앞에 붙여야 하며, 이 구문은 score에 10을 더하는 것입니다.

기존 값 수정 (2) :

또는 entry() 기반으로도 가능:

scores.entry("Red").and_modify(|v| *v += 1);

Red라는 키가 있으면, value를 v로 받아서, 1을 더하는 것입니다.


7. 예제: 단어 개수 세기

HashMap은 문자열 데이터를 처리할 때 특히 유용합니다. 예를 들어, 문장에서 각 단어의 개수를 세어보겠습니다.

이 코드는 split_whitespace()로 공백 기준으로 단어를 나누고, 각 단어가 등장한 횟수를 계산합니다.

use std::collections::HashMap;

fn main() {
    let text = "hello world hello rust";

    let mut counts = HashMap::new();

    for word in text.split_whitespace() {
        *counts.entry(word).or_insert(0) += 1;
    }

    println!("{:?}", counts);
}

*counts.entry(word).or_insert(0) += 1;
: word란 키가 있으면 value에 1을 더하고, 없으면 value에 0을 추가하는 것입니다.

출력 결과는 아래와 같은데
{“hello”: 2, “world”: 1, “rust”: 1}

key 순으로 정렬하지 않아 표시되는 순서가 매번 달라집니다.

9. HashMap 요약

  • HashMap는 키-값 쌍을 저장하는 자료구조
  • insert()로 값 추가, get()으로 값 조회
  • 키는 고유해야 하며, 같은 키로 다시 삽입하면 덮어씀
  • entry()와 or_insert()로 조건부 삽입 가능
  • 순회는 for (k, v) in &map
  • 소유권은 HashMap으로 이동됨
  • 문자열 처리에 매우 유용함

UTF-8에서도 한글이 2바이트?

UTF-8은 가변 길이 문자 인코딩 방식으로, 각 문자의 유니코드 코드 포인트(Unicode code point)에 따라 1~4바이트를 사용합니다. 영어는 1바이트, 유럽계 언어는 2바이트, 한글, 일본어, 중국어 등을 포함한 대부분의 언어가 3바이트입니다.

1. UTF-8 바이트 수 규칙 요약

바이트 수코드 포인트 범위 (16진수)설명
1바이트0000 0000 ~ 0000 007FASCII 문자 (영문자, 숫자, 특수문자 일부 등)
2바이트0000 0080 ~ 0000 07FF라틴계 문자, 악센트가 있는 유럽 문자 등
3바이트0000 0800 ~ 0000 FFFF대부분의 언어(한글, 일본어, 중국어 등)
4바이트0001 0000 ~ 0010 FFFF이모지, 고대 문자, 특수 기호 등

2. 언어별 문자 바이트 수 (UTF-8)

언어평균 바이트 수설명
영어1바이트ASCII 문자
독일어/프랑스어1~2바이트ñ, é, ü 같은 특수 라틴 문자는 2바이트
러시아어 (키릴 문자)2바이트유니코드 범위 U+0400~U+04FF
아랍어2바이트U+0600~U+06FF
히브리어2바이트U+0590~U+05FF
한글 (가/나/다 등 완성형)3바이트한글 완성형(U+AC00~U+D7A3)
일본어 (히라가나/가타카나/한자)3바이트모든 문자 3바이트
중국어 (간체/번체)3바이트일본어와 같은 한자 범위 사용
이모지4바이트😂, 🧡, 🐍 등

3. 예시

문자언어UTF-8 인코딩 (hex)바이트 수
A영어ox411바이트
é 프랑스어0xC3A92바이트
한국어oxED959C3바이트
중국어0xE4B8AD3바이트
😊이모지0xF09F988A4바이트

4. 정리

  • 영어만 사용하는 텍스트가 가장 효율적 (1바이트/문자).
  • 동아시아 문자(한중일)는 문자당 3바이트가 기본.
  • 이모지나 일부 특수 기호는 4바이트를 차지하므로 저장공간이나 전송량에 유의.

벡터(Vector), 문자열(String), 슬라이스(Slice)

Rust에서는 데이터를 저장하고 조작하기 위해 다양한 컬렉션을 제공합니다. 컬렉션에는 벡터, 문자열, 슬라이스와 해시맵이 있는데, 오늘은 그중 자주 쓰이는 벡터, 문자열, 그리고 슬라이스에 대해 알아보고 다음 편에 해시맵(HashMap)에 대해 알아보겠습니다. 문자열은 Rust의 특이한 요소 중 하나입니다.


1. 벡터 (Vector)

Vec는 가변 길이의 배열로, 가장 자주 쓰이는 컬렉션 중 하나입니다.

fn main() {
    let mut v: Vec<i32> = Vec::new();
    v.push(1);
    v.push(2);
    v.push(3);

    println!("{:?}", v); // [1, 2, 3]
}
  • 고정된 길이의 array와 대비되고, 같은 데이터 타입이어야 하는 것은 동일합니다.
    데이터 형식으로 Vec안에 i32라고 하나만 지정되어 있어서 여러가지 형식 입력이 가능한 tuple과 다릅니다.
  • Vec::new()로 생성하고 push()로 요소 추가. pop()으로 마지막 요소 삭제
    v.pop(); // 마지막 요소 제거
    println!(“{:?}”, v); // [1, 2]
  • 벡터 v는 mut로 가변 변수로 선언해야 데이터 추가, 삭제, 수정 가능
  • println!(“{:?}”, v)로 Debug 포맷으로 벡터 출력.

가. 벡터 초기화

let v = vec![10, 20, 30];
  • vec!를 이용해 여러 요소를 한꺼번에 입력할 수 있습니다. vec!에서 !는 매크로라고 읽습니다.

나. 벡터 접근

fn main() {
    let v = vec![10, 20, 30];
    let third = v[2];
    println!("세 번째 값: {}", third);
    // println!("세 번째 값: {}", v[3]); // panic 발생

    let maybe = v.get(1); // Option 타입 반환

    if let Some(val) = maybe {
        println!("값: {}", val);
    } else {
        println!("없음"); // None일 때 실행
    }
}
  • 벡터의 값을 추출할 때 변수명 다음에 대괄호를 입력하고 그 안에 인덱스를 입력할 수도 있고, .get을 이용할 때는 소괄호를 이용하는데, 둘의 차이점은 대괄호를 이용할 때는 인덱스가 존재하지 않으면 패닉이 발생하나, get을 이용하면 None이 반환됩니다.
  • 위 코드를 실행하면 println!(“세 번째 값: {}”, third);은 실행되는데,
    println!(“세 번째 값: {}”, v[3]);에서 패닉이 발생하므로 이후 코드는 실행되지 않습니다.
  • 따라서, println!(“세 번째 값: {}”, v[3]);을 Ctrl + /를 눌러 주석처리한 다음 실행하면 뒷 부분 get으로 구한 값까지 표시됩니다.
  • get 다음에 index로 범위를 벗어난 5를 입력하고 실행하면 None이 되므로 else문이 실행되어 “없음”이 표시됩니다.

2. 문자열 (String)

가. 문자열의 정의

fn main() {
    let s1 = String::from("Hello");
    let s2 = "World!".to_string();

    println!("{s1}, {s2}");
}
  • String은 가변 문자열 타입으로 Heap에 저장되며,
  • 일반적인 프로그래밍 언어는 큰 따옴표안에 문자열을 입력하는데, Rust는 ① String::from 다음의 괄호안에 큰 따옴표를 이용해 문자열을 입력하거나, ②큰 따옴표 안에 문자열을 입력한 후 .to_string을 추가해서 입력합니다.
  • String::from없이 큰 따옴표 안에 문자열을 넣으면 String이 아니라 다음에 설명하는 문자열 슬라이스가 되어 성격이 다릅니다.
  • 위 코드를 실행하면

나. 문자열 연결

    let s2 = "World!".to_string();
    let s3 = s1 + ", " + &s2;
    println!("{s3}");
    // println!("{s1}");
  • 문자열 연결은 + 연산자를 사용합니다.
  • let s3 = s1 + “, ” + &s2;에서 s2는 빌림(&)을 사용해서 + 후에도 존재하나, s1은 + 후에 s3로 move되었으므로 더 이상 사용할 수 없습니다.

다. 슬라이스 (Slice)

슬라이스는 컬렉션의 일부를 참조하는 타입입니다.

fn main() {
    let s = String::from("hello world");
    let hello = &s[0..5];
    let world = &s[6..11];

    println!("{}, {}", hello, world);
}
  • &s[a..b]는 a부터 b-1까지의 부분 문자열을 참조합니다. 범위 설정과 마찬가지로 b앞에 =을 추가하면 b가 포함됩니다.
  • 슬라이스는 원본이 유효한 동안만 유효합니다.

3. 문자열 리터럴(&str)과 String 비교

Rust에서 &str과 String은 모두 문자열을 나타내는 데 사용되지만, 그 역할과 특징이 다릅니다. &str은 문자열 슬라이스로, 고정 길이이고 값을 직접 소유하지 않습니다. 반면, String은 힙에 할당되어 동적으로 길이를 변경할 수 있으며 값을 소유합니다.

구분&str(문자열 리터럴 )String
저장프로그램 실행 시 정적 메모리(static memory)에 저장됩니다.힙(heap)에 할당되어 동적으로 크기가 변할 수 있습니다.
소유권소유하지 않고 참조만 합니다.데이터를 소유합니다.
가변성변경할 수 없습니다.문자열 내용을 추가, 수정, 삭제할 수 있습니다.
표현&str 또는 “문자열 리터럴” 형태로 표현됩니다. 
예1) let s = “hello world”;
예2) let s = String::from(“hello world”);
let hello = &s[0..5];
String::from(“문자열”) 또는 to_string()과 같은 메서드를 통해 생성합니다. 
예) let s = String::from(“hello world”);

간단하게 말하자면 “hello world”는 문자열 리터럴이고, type은 &str인데, String::from(“hello world”)은 type이 String입니다.
그런데, &str은 &str의 예2처럼 String을 참조하기도 합니다.

Rust의 String은 UTF-8로 인코딩됩니다.

📌 &str과 String 비교 예제 코드

fn main() {
    let s = String::from("hello world");
    let first_word = first_word(&s);
    print!("첫 번째 단어: {}", first_word);
}

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[..]
}
  • let s = String::from(“hello world”); : s란 변수에 hello world란 String을 저장합니다.
  • let first_word = first_word(&s); : 변수 s를 참조 형식으로 받아 first_word 함수의 인수로 전달하고, 반환 값을 다시 first_word란 변수에 저장합니다.
  • print!(“첫 번째 단어: {}”, first_word); : 위에서 구한 first_world를 화면에 출력합니다.
  • fn first_word(s: &str) -> &str { : first_word 함수는 인수 s를 &str(String 참조) 타입으로 받고, &str 형식으로 반환합니다.
  • let bytes = s.as_bytes(); : &str인 s를 string slice를 byte slice로 바꿉니다.
  • for (i, &b) in bytes.iter().enumerate() { : 위에서 구한 bytes를 하나씩 옮겨가면서 처리하는데(iter), 인덱스를 같이 반환하도록 enumerate를 같이 사용합니다.
  • if b == b’ ‘ { : b가 b’ ‘, 다시 말해 byte literal ‘ ‘와 같은 경우, 다시 말해 공백을 만나게 되면
  • return &s[0..i]; : 공백 전까지의 글자를 반환합니다.
  • &s[..] : &s가 공백 전까지의 글자이므로 이 글자 전체를 반환합니다. 세미콜론이 없으므로 표현식(expression)이고 반환값입니다.
  • 따라서, 위 코드를 실행하면 hello가 반환됩니다.

🧠 요약

타입설명
Vec<T>가변크기 배열, push, get, pop 지원
StringUTF-8로 인코딩된 힙 문자열
&str슬라이스 타입, 컬렉션 일부 참조
슬라이스소유권 없이 일부분만 안전하게 사용 가능