5 분 소요

Rust를 어느 정도 익히고 나면 문법 하나하나보다 “코드를 어떻게 나눌지”, “값을 어떻게 안전하게 공유할지”, “여러 작업을 동시에 어떻게 처리할지”가 더 중요해진다. 이때 자주 마주치는 주제가 module, smart pointer, concurrency, async다.

이번 글에서는 네 가지를 초급자 기준으로 정리한다. module은 코드를 구조화하는 도구이고, smart pointer는 값의 소유와 접근 방식을 더 정교하게 제어하게 해 준다. concurrency는 여러 작업을 동시에 진행하는 방법이고, async는 대기 시간이 많은 작업을 효율적으로 다루는 방식이다.

실습 프로젝트 만들기

아래처럼 새 Cargo 프로젝트를 만든 뒤 src/main.rs에서 예제를 하나씩 실행해 보면 된다.

cargo new rust-modules-concurrency
cd rust-modules-concurrency
code .

예제를 붙여 넣은 뒤에는 아래 명령으로 실행하면 된다.

cargo run

Modules: 코드를 의미 단위로 나누기

Rust에서 module은 코드를 역할별로 나누고 경로(path)로 접근하게 해 주는 구조다. 작은 예제에서는 한 파일에 다 넣어도 되지만, 코드가 커질수록 mod, pub, 경로 사용법이 중요해진다.

가장 단순한 예제는 아래와 같다.

mod greeting {
    pub fn say_hello() {
        println!("hello from module");
    }
}

fn main() {
    greeting::say_hello();
}

여기서 핵심은 아래 두 가지다.

  • mod greeting으로 모듈을 만든다.
  • 바깥에서 사용할 함수는 pub으로 공개해야 한다.

module을 파일로 분리하면 구조가 더 명확해진다.

src/
  main.rs
  greeting.rs

src/greeting.rs는 아래처럼 둘 수 있다.

pub fn say_hello() {
    println!("hello from greeting.rs");
}

그리고 src/main.rs는 아래처럼 연결한다.

mod greeting;

fn main() {
    greeting::say_hello();
}

프로젝트가 커지면 module은 단순한 문법이 아니라 코드 탐색성과 유지보수성을 크게 좌우하는 구조가 된다.

Smart Pointers: 값 관리 방식을 더 정교하게 다루기

Rust의 기본 참조와 소유권만으로도 많은 문제를 해결할 수 있지만, 더 복잡한 상황에서는 smart pointer가 필요하다. smart pointer는 단순한 주소값이 아니라 추가 메타데이터와 동작을 함께 가지는 타입이다.

Box

Box<T>는 값을 힙(heap)에 저장하고 싶을 때 쓰는 가장 기본적인 smart pointer다.

fn main() {
    let number = Box::new(100);
    println!("number = {}", number);
}

이 예제는 겉보기에는 단순하지만, 값이 스택이 아니라 힙에 저장된다는 점이 다르다. 재귀 타입이나 큰 데이터를 다룰 때 Box<T>가 자주 등장한다.

Rc

Rc<T>는 single-thread 환경에서 여러 owner가 같은 값을 공유할 수 있게 해 준다.

use std::rc::Rc;

fn main() {
    let name = Rc::new(String::from("rust"));

    let a = Rc::clone(&name);
    let b = Rc::clone(&name);

    println!("a = {}", a);
    println!("b = {}", b);
    println!("count = {}", Rc::strong_count(&name));
}

보통 clone()이라는 이름 때문에 데이터를 복사한다고 오해하기 쉽지만, Rc::clone은 실제 문자열을 깊게 복사하는 것이 아니라 reference count를 늘려 공유 ownership을 만든다.

RefCell

RefCell<T>는 immutable 바인딩 안에서도 내부 값을 바꿀 수 있게 해 주는 interior mutability 패턴에 쓰인다.

use std::cell::RefCell;

fn main() {
    let value = RefCell::new(10);

    *value.borrow_mut() += 5;

    println!("value = {}", value.borrow());
}

보통 borrow 규칙은 컴파일 시점에 검사되지만, RefCell<T>는 일부 규칙을 런타임에 검사한다. 그래서 더 유연하지만, 잘못 쓰면 실행 중 panic이 날 수 있다.

Concurrency: 여러 작업을 동시에 다루기

Rust의 concurrency는 안전성이 강점이다. thread를 만들 때도 데이터 레이스를 쉽게 허용하지 않고, 공유 방식이 안전한지 컴파일 단계에서 많이 확인한다.

가장 기본적인 예제는 thread 생성이다.

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("spawned thread = {}", i);
            thread::sleep(Duration::from_millis(100));
        }
    });

    for i in 1..=2 {
        println!("main thread = {}", i);
        thread::sleep(Duration::from_millis(100));
    }

    handle.join().unwrap();
}

실행 결과는 아래와 같다.

concurrency 예제 결과 1

thread::spawn은 새 스레드를 만들고, join()은 해당 스레드가 끝날 때까지 기다린다.

thread 사이에 데이터를 직접 공유하기보다 message passing을 쓰는 것도 Rust에서 매우 흔한 패턴이다.

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

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

    thread::spawn(move || {
        let message = String::from("hello from thread");
        tx.send(message).unwrap();
    });

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

이 방식은 한 스레드가 다른 스레드에 값을 보내고, 받는 쪽이 안전하게 처리하게 만든다.

shared state가 꼭 필요할 때는 Arc<Mutex<T>> 조합이 많이 쓰인다.

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

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..3 {
        let counter = Arc::clone(&counter);

        let handle = thread::spawn(move || {
            let mut number = counter.lock().unwrap();
            *number += 1;
        });

        handles.push(handle);
    }

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

    println!("counter = {}", *counter.lock().unwrap());
}

여기서 Arc<T>는 multi-thread용 shared ownership이고, Mutex<T>는 한 번에 한 스레드만 값에 접근하도록 보호한다.

Async: 기다리는 동안 다른 작업하기

concurrency가 여러 작업을 동시에 처리하는 큰 개념이라면, async는 특히 I/O처럼 기다리는 시간이 긴 작업을 효율적으로 다루는 데 강하다. 핵심 키워드는 async, await다.

async 예제는 보통 runtime이 필요하다. 가장 많이 쓰는 방식 중 하나는 Tokio를 사용하는 것이다. 새 Cargo 프로젝트에서 아래 예제를 그대로 실행하려면 먼저 Cargo.toml에 Tokio 의존성을 추가해야 한다.

[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
async fn get_message() -> String {
    String::from("hello async")
}

#[tokio::main]
async fn main() {
    let message = get_message().await;
    println!("message = {}", message);
}

이 코드는 아래 흐름으로 읽으면 된다.

  • async fn은 바로 값을 반환하는 것이 아니라 future를 만든다.
  • .await는 future가 완료될 때까지 기다린 뒤 결과를 꺼낸다.
  • #[tokio::main]은 async main을 실행할 runtime을 준비해 준다.
  • runtime은 여러 async task를 스케줄링한다.

async의 장점은 “작업이 쉬는 동안 스레드도 같이 묶어 두지 않아도 된다”는 점이다. 그래서 네트워크 요청, 파일 I/O, 서버 처리 같은 상황에서 특히 강력하다.

여러 async 작업을 함께 기다리는 예제는 아래처럼 볼 수 있다.

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

async fn task(name: &str, delay_ms: u64) -> String {
    sleep(Duration::from_millis(delay_ms)).await;
    format!("done: {}", name)
}

#[tokio::main]
async fn main() {
    let first = task("A", 200);
    let second = task("B", 100);

    let (a, b) = tokio::join!(first, second);
    println!("{}, {}", a, b);
}

이 예제는 각 task 안에 실제 .await 지점이 있어서, tokio::join!이 여러 future를 함께 진행시키고 모두 끝날 때까지 기다리는 모습을 더 잘 보여 준다.

초반에는 thread와 async를 같은 개념으로 느끼기 쉽지만, thread는 운영체제 스레드를 직접 활용하는 방식이고, async는 future와 runtime 위에서 많은 작업을 효율적으로 스케줄링하는 방식이라고 구분해 두면 이해가 쉬워진다.

한 번에 보는 종합 예제

이번 주제는 한 파일로 모두 완벽하게 합치기보다, 연결 고리를 보는 것이 중요하다. 아래 예제는 module, smart pointer, concurrency를 함께 묶은 예제다.

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

mod logger {
    pub fn print_status(message: &str) {
        println!("status = {}", message);
    }
}

fn main() {
    let shared = Arc::new(Mutex::new(Box::new(0)));
    let mut handles = vec![];

    for _ in 0..3 {
        let shared = Arc::clone(&shared);

        let handle = thread::spawn(move || {
            let mut value = shared.lock().unwrap();
            **value += 1;
        });

        handles.push(handle);
    }

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

    logger::print_status("all threads finished");
    println!("final = {}", **shared.lock().unwrap());
}

이 예제에는 아래 요소가 모두 들어 있다.

  • mod logger로 코드 역할 분리
  • Box<i32>로 힙에 값 저장
  • Arc<Mutex<_>>로 안전한 shared state 구성
  • thread::spawnjoin()으로 concurrency 처리

async는 runtime이 필요한 경우가 많기 때문에, 보통은 별도 예제로 이해하는 편이 더 자연스럽다.

정리

이번 글에서는 module, smart pointer, concurrency, async를 한 흐름으로 정리했다. module은 코드를 더 읽기 좋게 나누는 구조이고, smart pointer는 ownership과 접근 방식을 더 유연하게 제어하게 해 준다. concurrency는 여러 스레드나 작업을 안전하게 다루는 방법이고, async는 기다림이 많은 작업을 효율적으로 처리하는 방식이다.

이 단계까지 오면 Rust는 단순 문법이 아니라 설계 도구처럼 느껴지기 시작한다. 다음 단계에서는 crate 구조, testing, lifetimes 심화, trait object, macros 같은 주제로 넘어가면서 Rust 스타일의 설계 감각을 더 넓혀 가면 좋다.

댓글남기기