비동기 프로그래밍 (async/await)

Rust는 성능과 안전성을 동시에 추구하는 언어입니다. 이런 철학은 비동기 프로그래밍(asynchronous programming)에도 그대로 적용됩니다. Rust의 async/await 문법은 동시성(concurrency)을 효과적으로 다루기 위한 강력한 도구로, 네트워크 프로그래밍이나 고성능 IO 처리에 자주 사용됩니다.


1. 비동기 프로그래밍이란?

비동기 프로그래밍이란, 어떤 작업이 완료될 때까지 기다리는 대신 다른 작업을 먼저 수행하도록 코드를 구성하는 방식입니다. 예를 들어 웹 서버가 여러 클라이언트의 요청을 동시에 처리할 때, 각각의 요청마다 새로운 스레드를 만들기보다, 하나의 스레드에서 여러 요청을 비동기적으로 처리하면 더 적은 리소스로 높은 성능을 얻을 수 있습니다.


2. async/await 개념

Rust의 비동기 프로그래밍은 크게 세 가지로 나뉩니다:

  • async fn: 비동기 함수를 정의하는 키워드
  • await: 비동기 함수의 결과를 기다리는 키워드
  • Future: 아직 완료되지 않은 비동기 작업을 나타내는 타입
async fn say_hello() {
println!("Hello!");
}

이 함수는 호출해도 바로 실행되지 않고, Future를 반환합니다. 실제로 실행되려면 .await를 사용해야 합니다.

say_hello().await;

3. 비동기 실행을 위한 런타임 (tokio)

Rust 표준 라이브러리는 자체적인 비동기 런타임을 제공하지 않습니다. 따라서 일반적으로 tokio 같은 서드파티 런타임을 사용합니다. tokio는 가장 널리 사용되는 비동기 런타임이며, 다양한 네트워크, 타이머, 채널 등 유틸리티를 제공합니다.

[dependencies]
tokio = { version = "1", features = ["full"] }

Cargo.toml의 dependencies에 tokio를 추가하고 cargo run을 하면 관련 라이브러리들이 자동으로 설치됩니다.

비동기 main 함수를 사용하려면 다음과 같이 작성합니다:

#[tokio::main]
async fn main() {
say_hello().await;
}

Cargo.toml과 별도로 main.rs의 코드는 아래와 같습니다.

#[tokio::main]
async fn main() {
    say_hello().await; 
}

async fn say_hello() {
    println!("Hello!");
}

#[tokio::main] 어트리뷰트

이 부분은 Tokio 런타임을 자동으로 시작해주는 매크로 어트리뷰트입니다.

  • Rust에서 async fn main()을 그냥 실행할 수는 없습니다. 왜냐하면 Rust는 기본적으로 비동기 실행 환경(런타임)을 제공하지 않기 때문이에요.
  • [tokio::main]은 main 함수에 Tokio 런타임을 삽입해서 비동기 코드를 실행할 수 있게 만들어줍니다.

async fn main()

이 함수는 비동기 함수입니다.

  • 비동기 함수는 실행될 때 Future를 반환합니다.
  • 이 Future는 .await될 때까지는 실제로 실행되지 않습니다.
  • 하지만 #[tokio::main] 덕분에 main() 함수도 비동기 함수로 정의할 수 있게 되었고, 프로그램은 say_hello().await를 실행하면서 say_hello 함수의 Future를 기다립니다.

say_hello().await

  • .await를 사용하면 이 Future의 실행을 기다립니다.
  • 즉, say_hello() 함수의 본문이 실행될 때까지 main 함수는 멈춰서 기다립니다.

async fn say_hello()

비동기 함수이지만 내부에 특별한 비동기 작업은 없습니다.

async fn say_hello() {
println!("Hello!");
}
  • 이 함수는 단지 “Hello!”를 출력합니다.
  • 비록 내부에 await는 없지만, 비동기 함수로 작성된 이유는 비동기 구조의 연습 또는 나중에 비동기 작업 (예: 네트워크 요청) 을 넣기 위함입니다.

위 코드를 실행하면 Hello!가 출력되는데,

아래와 같이 main과 say_hello함수의 순서를 바꾸고 실행하니 에러가 발생합니다.


4. 비동기 예제: 타이머

다음은 두 개의 비동기 작업을 동시에 실행하는 예제입니다:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
let task1 = async {
sleep(Duration::from_secs(2)).await;
println!("2초 작업 완료");
};

let task2 = async {
println!("즉시 실행");
};

tokio::join!(task1, task2);
}

위 코드에서 sleep은 비동기적으로 대기하는 함수입니다. tokio::join! 매크로는 두 작업을 동시에 실행하고, 둘 다 완료될 때까지 기다립니다.

위 코드를 실행하면 “즉시 실행”을 먼저 출력하고, 2초 후에 “2초 작업 완료”를 출력합니다.


5. Future의 본질

Rust의 비동기 함수는 내부적으로 Future 트레잇을 구현하는 구조체를 반환합니다. 이 구조체는 “언제 실행될지 모르는 작업”을 표현하며, .await가 호출되었을 때에만 실행이 시작됩니다.

간단한 예시:

use std::future::Future;

async fn return_num() -> u32 {
10
}

fn main() {
let fut = return_num(); // 실행되지 않음
// fut는 Future 타입, 여기선 실행되지 않음
}

async 블록이나 함수는 Future를 만들기 위한 “공장(factory)” 역할만 하며, 실제 실행은 .await 또는 런타임에 의해 수행됩니다.

  • async fn return_num() -> u32:
    이 함수는 u32를 비동기로 반환하는 함수입니다.
    하지만 실제로는 u32인 10을 바로 리턴하는 단순한 함수입니다. Rust의 async fn은 항상 Future를 반환합니다.
  • let fut = return_num();
    여기서 return_num()을 호출했지만 실제 10을 반환하지 않습니다.
    대신 미래에 10을 반환할 수 있는 준비 상태의 Future 객체만 생성했을 뿐입니다.
  • fut는 실행 가능한 비동기 작업을 담고 있는 Future 타입입니다.
    하지만 실제로 실행(await)하지 않았기 때문에 아무 일도 일어나지 않습니다.
    실제 실행하려면 아래와 같이 .await를 사용해서 실행해야 합니다.
#[tokio::main]
async fn main() {
    let fut = return_num(); // fut: Future
    let result = fut.await; // 이제 실행됨!
    println!("결과: {}", result); // 출력: 결과: 10
}

6. 동시성(concurrency) vs 병렬성(parallelism)

Rust에서 async는 동시성을 위한 기능입니다. 하나의 스레드에서 여러 작업을 번갈아가며 처리하는 구조입니다. 반면, 병렬성은 여러 CPU 코어에서 동시에 작업을 수행하는 것으로, std::thread 등을 통해 구현합니다. 둘은 목적이 다르지만 상호 보완적으로 사용될 수 있습니다.


7. 실전 예제: HTTP 요청

비동기의 진가는 네트워크 작업에서 드러납니다. 예를 들어, reqwest 라이브러리를 이용한 HTTP GET 요청은 다음과 같습니다.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let resp = reqwest::get("https://httpbin.org/get").await?;
let body = resp.text().await?;
println!("응답 본문:\n{}", body);
Ok(())
}
async fn main() -> Result<(), Box<dyn std::error::Error>>
  • async fn main(): main 함수 자체가 비동기 함수로 정의되어 있습니다.
  • Result<(), Box<dyn std::error::Error>> :
    ? 연산자를 사용하기 위해 오류 처리를 Result 타입으로 합니다.
    다양한 종류의 오류(reqwest::Error, std::io::Error, 등)를 포괄하기 위해 Box<dyn std::error::Error>>를 사용합니다.

let resp = reqwest::get(“https://httpbin.org/get”).await?;
  • reqwest::get(…): reqwest 라이브러리로 GET 요청을 보냅니다.
  • .await: 비동기 요청이 완료될 때까지 기다립니다.
  • ?: 요청 중 오류가 발생하면 main 함수에서 곧바로 리턴됩니다.
  • resp는 reqwest::Response 타입입니다.

let body = resp.text().await?;
  • 응답 객체인 resp에서 본문을 텍스트로 변환합니다.
  • text()는 Future를 반환하므로 .await로 기다립니다.
  • ?로 오류 처리합니다.
  • 결과는 String 타입입니다.

println!(“응답 본문:\n{}”, body);
  • 응답 본문 전체를 콘솔에 출력합니다.

Ok(())

함수가 정상적으로 끝났다는 것을 알리는 반환값입니다.reqwest::get()과 resp.text()는 모두 Future를 반환하므로 .await를 사용해야 합니다.


실행했더니 reqwest에서 에러가 발생해서 Cargo.toml에 reqwest = “0.12.20”을 추가해야 합니다.

[dependencies]
reqwest = "0.12.20"
tokio = { version = "1", features = ["full"] }

그리고, 실행하면 관련 라이브러리들을 설치하고, 컴파일하고 run을 하고, get한 결과를 출력합니다.

8. async와 에러 처리

비동기 함수에서도 Result 타입을 반환하여 에러를 처리할 수 있습니다.

#[tokio::main]
async fn main() {
match fetch_data().await {
Ok(body) => println!("응답 본문:\n{}", body),
Err(e) => eprintln!("요청 중 오류 발생: {}", e),
}
}

async fn fetch_data() -> Result<String, reqwest::Error> {
let resp = reqwest::get("https://example.com").await?;
let text = resp.text().await?;
Ok(text)
}
fetch_data().await
  • fetch_data는 비동기 함수이므로 실행하려면 .await를 붙여야 실제로 실행됩니다.
  • Result를 반환합니다.

이줄에서 fetch_data()의 실행이 끝날 때까지 기다리고, 그 결과에 따라 아래 match가 분기됩니다.

성공한 경우 (Ok)
  • HTTP 요청이 성공하면 응답 본문이 String 타입으로 body에 담깁니다.
  • 그것을 println!으로 콘솔에 출력합니다.
실패한 경우 (Err)
  • 요청 도중 네트워크 오류, 타임아웃 등 문제가 발생하면 reqwest::Error가 e에 들어옵니다.
  • println!는 표준 에러 스트림에 메시지를 출력합니다 (보통 터미널에서 빨간 글씨로 나옴).
async fn fetch_data() -> Result <String, reqwest::Error> {
  • async fn: 비동기 함수입니다. 호출 시 바로 실행되지 않고, Future를 반환합니다.
  • Result<String, reqwest::Error>:
    Ok(String): 요청이 성공하면 응답 본문을 String으로 감싸서 반환합니다.
    Err(reqwest::Error): 네트워크 오류, HTTP 오류 등이 발생하면 오류를 반환합니다.

let resp = reqwest::get(“https://example.com”).await?;
  • reqwest::get(…): GET 방식으로 HTTP 요청을 보냅니다.
  • .await: 이 작업이 완료될 때까지 기다립니다. 이때 다른 작업은 블로킹되지 않으며, tokio 런타임이 비동기적으로 대기합니다.
  • ?: 요청 도중 에러가 발생하면 바로 Err를 반환하며, 함수가 종료됩니다.

이 줄의 결과는 resp 변수에 저장되며, 이는 응답(Response) 객체입니다.


let text = resp.text().await?;
  • resp.text(): 응답 본문을 String으로 변환하는 Future를 반환합니다.
  • .await: 이 본문을 받아올 때까지 대기합니다.
  • ?: 변환 과정에서 에러가 나면 역시 Err를 반환하며 함수가 종료됩니다.

Ok(text)
  • 모든 작업이 성공적으로 끝나면 text 값을 Result의 Ok로 감싸서 반환합니다.

성공할 경우의 실행 결과는 아래와 같고,

사이트 주소에서 e를 제거해서 exampl.com으로 수정하고 실행하니 아래와 같이 “요청 중 오류 발생: error sending request” 에러 메시지가 표시됩니다.


9. 정리

개념설명
async fn비동기 함수 정의
.awaitFuture의 완료를 기다림
Future완료되지 않은 작업
tokio비동기 런타임 라이브러리
join!여러 Future를 동시에 실행
sleep()비동기 대기 함수

10. 마무리

Rust의 비동기 프로그래밍은 고성능 서버나 네트워크 애플리케이션을 만들 때 매우 강력합니다. 하지만 컴파일러가 엄격하게 검사하므로 안전한 동시성 코드를 작성할 수 있습니다.

동시성(Concurrency)과 스레드(Thread)

1. 동시성(Concurrency)이란?

동시성(Concurrency)은 프로그램이 여러 작업을 “동시에” 처리하는 능력을 말합니다. 꼭 물리적으로 동시에 실행되는 것(병렬성)이 아니라, 여러 작업을 논리적으로 동시에 실행하는 것입니다. 즉, 동시성은 스레드보다 더 큰 개념이며, 스레드는 그 구현 수단 중 하나입니다.

Rust에서 동시성은 다음과 같은 방식으로 구현할 수 있습니다:

  • 스레드(Thread)
  • 메시지 전달(Channel)
  • 공유 메모리(Arc<Mutex<T>>)
  • 비동기 프로그래밍(async/await)

2. Rust의 동시성 철학

Rust의 동시성은 다음 원칙을 따릅니다:

  1. 안전성 (Safety): 동시성 버그(데이터 경쟁, 데드락 등)를 컴파일 타임에 방지
  2. 명시성 (Explicitness): 공유 자원, 락, 메시지 전달 등이 명확히 드러남
  3. 추론 가능성: 컴파일러가 Send, Sync 트레잇으로 스레드 간 이동 가능 여부를 추론

C++/Java는 런타임 오류가 나야 알 수 있는 동시성 문제를, Rust는 컴파일러가 사전에 차단합니다.


3. 스레드를 이용한 동시성

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        println!("스레드에서 실행 중");
    });

    println!("메인 스레드 실행 중");
    handle.join().unwrap();
}

thread:spawn을 통해 새로운 스레드를 생성하고, join()을 통해 스레드 종료를 기다립니다.
이 방식은 동시에 여러 작업을 실행하려는 목적, 즉 동시성을 달성하는 가장 기본적인 방식입니다.

use std::thread;
  • Rust 표준 라이브러리의 thread 모듈을 가져옵니다(import).
  • 이 모듈은 스레드 생성 및 제어에 관련된 기능을 제공합니다.

let handle = thread::spawn(|| { … });
  • thread::spawn은 새로운 스레드를 생성하고 그 안에서 클로저(익명 함수)를 실행합니다.
  • 이 예제에서 생성된 스레드는 println!(“스레드에서 실행 중”);을 실행합니다.
  • spawn은 JoinHandle이라는 객체를 반환하며, 이를 handle 변수에 저장합니다.
  • 이 handle은 이후 스레드가 언제 종료됐는지 기다릴 수 있게(join) 도와줍니다.

println!(“메인 스레드 실행 중”);
  • 메인 스레드는 새로 만든 스레드와 동시에 실행됩니다.
  • 따라서 이 줄과 스레드에서 실행 중 메시지 중 어느 것이 먼저 출력될지는 실행 시마다 달라질 수 있습니다.
  • 이는 Rust의 동시성(Concurrency) 특징입니다.

handle.join().unwrap();
  • join()은 새로 생성한 스레드가 끝날 때까지 기다리는 함수입니다.
  • 스레드 실행이 끝나면 Result<T, E>를 반환하고, 성공하면 Ok(()), 실패하면 Err(e)가 반환됩니다.
  • 여기서는 unwrap()을 사용해에러가 발생하면 패닉(panic)하도록 합니다.

4. 메시지 전달로 동시성 구현 (채널)

Rust는 공유 메모리 대신 메시지 전달(Message Passing) 모델을 권장합니다. std::sync::mpsc 모듈을 통해 채널을 만들 수 있습니다.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        tx.send("데이터").unwrap();
    });

    let received = rx.recv().unwrap();
    println!("수신: {}", received);
}

위 코드는 멀티 스레드 환경에서 채널(channel)을 이용한 스레드 간 통신을 보여줍니다.


use std::sync::mpsc;
  • mpsc는 “multiple producer, single consumer”의 약자로, 여러 개의 생산자(보내는 쪽)가 하나의 소비자(받는 쪽)를 지원하는, 다시 말해 여러 생산자(스레드)가 하나의 소비자(스레드)에게 데이터를 보낼 수 있는 채널(channel) 시스템입니다.

let (tx, rx) = mpsc::channel();
  • channel() 함수는 전송자(Sender)수신자(Receiver) 한 쌍을 반환합니다.
    • tx: 데이터 전송에 사용 (transmitter)
    • rx: 데이터 수신에 사용 (receiver)

thread::spawn(move || { … })
  • 새로운 스레드를 생성합니다.
  • move 키워드는 클로저 내부로 tx를 소유권 이동(move)시키기 위해 사용됩니다.
  • tx를 다른 스레드에서 사용할 수 있게 하려면 반드시 move가 필요합니다.

tx.send(“데이터”).unwrap();
  • 생성된 스레드에서 “데이터”라는 문자열을 메인 스레드로 전송합니다.
  • 전송 결과가 Result이기 때문에, .unwrap()을 호출하여 에러가 나면 패닉하게 합니다.

let received = rx.recv().unwrap();
  • 메인 스레드에서 recv()로 데이터가 도착할 때까지 블로킹(기다림)합니다.
  • 다른 스레드에서 “데이터”를 보냈기 때문에, 수신이 성공하면 received에 저장됩니다.

println!(“수신: {}”, received);
  • 받은 데이터를 출력합니다.

출력 결과는 “수신: 데이터”입니다.


공유된 메모리에 락을 걸 필요 없이, 스레드 간 메시지를 전달함으로써 동시성을 구현합니다.

이 방식은 Go 언어의 고루틴 + 채널과 비슷하며, 병렬성이 아닌 동시성 컨트롤에 적합합니다.


5. Arc<Mutex<T>>를 통한 공유 메모리 동시성

다수의 스레드가 데이터를 공유해야 한다면, Arc로 참조를 공유하고 Mutex로 락을 걸어야 합니다.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(0));

    let mut handles = vec![];
    for _ in 0..5 {
        let data = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            let mut num = data.lock().unwrap();
            *num += 1;
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("최종 값: {}", *data.lock().unwrap());
}

위 코드는 여러 스레드가 안전하게 하나의 데이터를 공유하고 수정하는 예제입니다. 여기서는 Mutex와 Arc를 조합하여 스레드 간 공유 데이터의 동기화를 구현하고 있습니다.


가. 주요 개념 정리

(1) Mutex<T>: 상호 배제
  • 여러 스레드가 동시에 데이터에 접근하지 못하도록 막아주는 락(lock).
  • .lock().unwrap()을 호출하면 락을 얻고, 반환된 객체(MutexGuard)를 통해 데이터를 읽고 쓸 수 있음.
  • 락을 가진 객체는 스코프를 벗어나면 자동으로 해제됨.
(2) Arc<T>: 원자적 참조 카운트
  • 여러 스레드에서 읽기 전용 또는 Mutex와 결합한 공유 사용이 가능.
  • Rc는 단일 스레드용이고, Arc는 멀티스레드에서도 안전하게 참조를 공유할 수 있도록 설계됨.
  • Arc::clone(&data)는 참조 카운트를 늘릴 뿐, 실제 데이터를 복사하지 않음.

나. 코드 분석

let data = Arc::new(Mutex::new(0));
  • 0이라는 값을 Mutex로 감싸고, 이를 다시 Arc로 감쌈.
  • 이렇게 하면 여러 스레드가 이 데이터를 공유하면서도 동시에 수정하지 못하도록 보장할 수 있음.

let mut handles = vec![];

for _ in 0..5 {
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
let mut num = data.lock().unwrap();
*num += 1;
}));
}
  • 5개의 스레드를 생성.
  • 각 스레드는 Arc::clone(&data)를 통해 공유 데이터를 가리키는 참조를 가짐.
  • data.lock().unwrap()을 통해 락을 획득하고 *num += 1로 값을 증가시킴.
  • unwrap()은 락 획득 실패(예: 패닉) 시 프로그램을 종료시키는 간단한 오류 처리 방식.

for handle in handles {
handle.join().unwrap();
}
  • 각 스레드가 종료될 때까지 대기 (join()).
  • 이렇게 해야 모든 스레드가 작업을 마치고 최종 값을 출력함.

println!("최종 값: {}", *data.lock().unwrap());
  • 메인 스레드에서 최종 값을 읽음.
  • 이때도 lock()을 사용하여 락을 획득한 뒤 출력.

다. 결과

  • 각 스레드는 0부터시작한 값을 1씩 증가시킴.
  • 5개의 스레드가 있으므로 최종 값은 5가 됨.

6. 비동기(async/await) 기반 동시성

Rust는 스레드 외에도 async fn, Future, await 등을 이용한 비동기 동시성 모델도 지원합니다. 이 모델은 싱글 스레드에서도 여러 작업을 동시에 처리하는 방식으로, 특히 IO 작업에 적합합니다.

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let f1 = async {
        sleep(Duration::from_secs(1)).await;
        println!("1초 작업 완료");
    };

    let f2 = async {
        println!("즉시 실행");
    };

    tokio::join!(f1, f2);
}
use tokio::time::{sleep, Duration};

tokio 라이브러리의 time 모듈에서 sleep함수와 Duration 타입을 불러옵니다(import).

가. 코드 분석

#[tokio::main]
  • 이 어트리뷰트는 비동기 함수 main을 실행할 수 있도록 tokio 런타임을 자동으로 생성해줍니다.
  • main() 함수가 async fn이므로 일반적으로는 실행할 수 없는데, tokio가 런타임을 초기화하고 실행을 담당합니다.

let f1 = async { … }
  • 비동기 블록입니다.
  • 내부에서sleep(Duration::from_secs(1)).await를 사용하여 1초간 대기한 후, “1초 작업 완료”를 출력합니다.
  • sleep은 스레드를 멈추는 것이 아니라, 해당 Future를 잠시 중단(suspend) 시켜 다른 작업을 실행할 수 있게 합니다.

let f2 = async { … }

  • 이 비동기 블록은 바로 “즉시 실행”을 출력합니다.
  • await나 지연 없이 즉시 완료되는 작업입니다.

tokio::join!(f1, f2);

  • 이 매크로는 f1과 f2를 동시에 실행시킵니다.
  • 두 작업이 모두 끝날 때까지 기다립니다.
  • 즉, f2는 바로 실행되고 “즉시 실행”이 출력됩니다.
  • f1은 1초 후 “1초 작업 완료”를 출력합니다.

나. 실행 결과

실제로 프로그램이 출력하는 순서는 다음과 같습니다:

즉시 실행
1초 작업 완료

두 작업은 병렬이 아닌 동시에 스케줄되는 병행(concurrent) 실행입니다.

비동기 동시성은 Tokio 같은런타임이 필요합니다. 이는 경량 스레드처럼 동작하여 수천 개의 작업을 동시 처리할 수 있습니다.


7. 정리

구분특징사용 예
스레드OS 레벨 병렬 실행CPU 작업 처리
채널메시지 기반 동시성안전한 데이터 전달
Arc<Mutex<T>>메모리 공유 및 제어공유 자원 카운터 등
async/await경량 동시성, 비차단네트워크, 파일 IO

8. 마무리

Rust의 동시성은 단순히 스레드를 사용하는 것에 그치지 않고, 메시지 전달 모델, 공유 메모리 모델, 비동기 실행 모델 등 다양한 방식으로 구현될 수 있습니다. Rust는 이러한 모델을 안전하고 명시적으로 구현할 수 있도록 돕는 언어이며, 컴파일 타임에 동시성 버그를 방지하는 강력한 도구입니다.

Rust에서 동시성은 단지 기술이 아니라, 신뢰성과 안전성을 보장하는 언어적 철학입니다.

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으로 이동됨
  • 문자열 처리에 매우 유용함