jongkwan.dev
개발 · Essay №022

Rust 개요: 안전성과 성능을 겸비한 시스템 언어

소유권, 차용, 라이프타임, 트레이트, async/await 등 Rust 핵심 개념 총정리

이종관2025년 1월 30일55 min read
Contents

Rust는 컴파일 타임 소유권 검사로 GC 없이 메모리 안전성과 C/C++급 성능을 동시에 얻는 시스템 언어다.

Rust는 메모리 안전성을 컴파일 타임에 보장하면서도 C/C++ 수준의 성능을 제공하는 시스템 프로그래밍 언어이다. Rust의 핵심은 소유권 시스템, 빌림 검사기, 생명주기를 통한 메모리 안전성 보장이다.

언어 개요

Rust는 2006년 Graydon Hoare의 개인 프로젝트로 시작해 2009년 Mozilla가 후원을 맡았고 2010년 공개된 프로그래밍 언어이다. 안정판 1.0은 2015년에 나왔다. 주된 목표는 "안전성(safety)", "성능(performance)", "동시성(concurrency)"을 모두 만족하는 시스템 프로그래밍 언어를 만드는 것이었다.

C/C++과 유사한 시스템 프로그래밍 분야를 지향하지만, 러스트 특유의 메모리 안전성(Ownership/Borrowing 개념)과 빌드 시점의 철저한 검사(compiler checks)로 인해 런타임 에러(예: 널 포인터 참조, 데이터 레이스 등)를 효과적으로 방지할 수 있다.

주요 특징은 세 가지로 요약된다.

  1. 안전성(safety)
    러스트는 컴파일 타임에 많은 검사(예: 소유권, 생명주기, 데이터 레이스 검사)를 수행하여, 런타임에 발생할 수 있는 메모리 오류나 동시성 버그를 방지한다.
  2. 성능(performance)
    시스템 프로그래밍 언어답게 C/C++과 유사한 수준의 퍼포먼스를 기대할 수 있다. GC(Garbage Collector)를 사용하지 않고, 필요한 경우 저수준 메모리 제어도 가능하다.
  3. 동시성(concurrency)
    러스트는 스레드 안전성을 보장해주는 Ownership/Thread-Safety 규칙을 통해, 안전하고 효율적인 동시성 프로그래밍을 지원한다.

개발 환경

Rust 설치와 에디터 설정은 한 번만 해두면 이후 작업이 편해진다.

  • Rust 설치

    • rustup을 사용하는 것이 일반적이며, 한 번에 Rust 컴파일러(rustc)와 패키지 매니저(cargo)를 설치할 수 있다.
    • Windows, macOS, Linux 모두 간단한 스크립트 실행으로 설치 가능하다.
    • 설치 완료 후 rustc --version, cargo --version 커맨드로 버전 확인이 가능하다.
  • IDE/에디터 선택

    • Visual Studio Code
      • Rust용 확장(예: "rust-analyzer")을 설치하여 코드 자동 완성, 린트, 디버깅을 지원받을 수 있다.
    • IntelliJ IDEA / CLion
      • JetBrains의 Rust 플러그인을 사용하면, 유사한 코드 인사이트와 린트 기능을 제공한다.
    • 이외 Vim, Emacs, Neovim 등 에디터용 플러그인도 다양하게 존재한다.

기본 도구: rustc와 cargo

단일 파일은 rustc로 직접 컴파일하고, 실제 프로젝트는 cargo로 관리하는 것이 일반적이다.

  • rustc

    • Rust 컴파일러로, 단일 파일을 직접 컴파일할 수 있다. 예:

      bash
      rustc main.rs
      ./main
  • cargo

    • Rust 생태계의 패키지 매니저이자 빌드 시스템.
    • 프로젝트 생성: cargo new 프로젝트이름
    • 빌드: cargo build (디버그 빌드)
    • 실행: cargo run (빌드 후 자동 실행)
    • 배포용 빌드: cargo build --release (최적화 빌드)
    • 의존성 관리: Cargo.toml 파일을 통해 라이브러리(크레이트) 종속성을 설정하고, cargo build 시 자동으로 다운로드/빌드한다.

프로그램 구조

Rust 프로그램은 진입점 함수와 모듈, 파일 구성으로 짜인다.

  • main 함수

    • C 계열 언어처럼 프로그램의 진입점으로서 main 함수가 필요하다.

    • 예:

      rust
      fn main() {
          println!("Hello, Rust!");
      }
  • 모듈 구조

    • Rust에서는 mod 키워드를 사용해 모듈을 정의하고, use 키워드로 가져와 사용할 수 있다.
    • 프로젝트 규모가 커지면, 여러 파일로 나누어 모듈 트리를 구성할 수 있다.
  • 파일 구조

    • src 디렉터리 안에 main.rs가 있는 경우, cargo run 시 해당 파일을 실행 진입점으로 인식한다.
    • 라이브러리 크레이트(lib.rs)인 경우는, main.rs 대신 라이브러리 형태로 구성할 수도 있다.

기본 자료형

Rust는 정수와 부동소수점에 비트 폭별 타입을 두고, 문자열은 슬라이스와 소유 문자열로 구분한다.

  • 정수형
    • 부호 있는 정수: i8, i16, i32, i64, i128, isize
    • 부호 없는 정수: u8, u16, u32, u64, u128, usize
    • 디폴트는 i32
  • 부동소수점형
    • f32, f64 (기본은 f64)
  • 불리언
    • bool, 값은 true 또는 false
  • 문자
    • char, 유니코드 스칼라 값을 표현 (예: 'a', '한', '😊')
  • 문자열
    • &str: 문자열 슬라이스(immutable, 스택에 참조)
    • String: 힙에 저장되며 가변(mutable) 가능

변수와 가변성

변수는 기본적으로 불변이며, 가변성과 상수는 키워드로 명시한다.

  • let

    • 변수 선언 시 사용, 기본적으로 불변(immutable)이다.

    • 예:

      rust
      let x = 5; // x는 불변
  • mut

    • 변수 앞에 mut 키워드를 붙여주면 가변(값 변경 가능) 변수가 된다.

    • 예:

      rust
      let mut y = 10;
      y = 20; // 값 변경 가능
  • 상수(const)

    • 런타임이 아닌 컴파일 타임에 결정되는 상수를 정의한다.

    • 반드시 타입 어노테이션이 필요하다.

    • 예:

      rust
      const MAX_POINTS: u32 = 100_000;

함수와 스코프

함수는 fn으로 선언하고, 블록 스코프가 끝나면 그 안의 값이 해제된다.

  • 함수 정의

    • 함수는 fn 키워드를 사용하여 선언한다.

    • 매개변수 타입은 반드시 명시해야 한다.

    • 예:

      rust
      fn add(a: i32, b: i32) -> i32 {
          a + b // 세미콜론이 없으면 이 값이 반환값으로 간주
      }
  • 반환 값

    • 함수는 마지막 표현식 혹은 return 키워드로 값을 반환한다.
  • 스코프

    • 중괄호 {} 블록이 하나의 스코프를 형성한다.
    • 스코프가 끝나면 해당 스코프 내에서 생성된 변수는 메모리에서 해제(drop)된다(Ownership 규칙).

소유권 규칙

러스트의 핵심 철학인 메모리 안전성은 '소유권' 개념으로 구현된다. 이를 통해 컴파일 타임에 메모리 관련 오류를 방지하고, 런타임 비용 없이 안전성을 확보한다.

소유권은 세 가지 규칙으로 동작한다.

  1. 모든 값은 하나의 소유자(owner)를 가진다.
  2. 소유자가 스코프를 벗어나면, 그 값은 메모리에서 해제된다(자동 drop).
  3. 단일 소유권이 이동(move)될 수 있다.
  • 스택과 힙

    • 스택: 크기가 컴파일 시점에 고정된 자료(예: 정수, 부동소수점, bool 등)
    • 힙: 런타임에 동적으로 할당되는 자료(예: String, Vec<T> 등)
  • Move

    • 힙 데이터를 가진 변수를 다른 변수에 대입하면, 원래 변수의 소유권이 이동하며, 원래 변수는 더 이상 유효하지 않아 사용 불가능해진다.

    • 예:

      rust
      let s1 = String::from("hello");
      let s2 = s1; // s1의 소유권이 s2로 이동
      // s1은 더 이상 유효하지 않음
  • Copy

    • 스택에 저장되는 단순 값 타입(예: i32, bool 등)은 Copy 트레잇을 구현하여, 대입 시 값이 비트 단위로 복사된다(깊은 복사는 Clone이 담당한다).

    • 예:

      rust
      let x = 5;
      let y = x; // x와 y 모두 사용 가능 (복사)

참조와 빌림

소유권을 옮기지 않고 값을 빌려 쓰려면 참조(&)를 사용한다.

  • 참조(Reference)
    • & 연산자를 통해 다른 변수의 값을 빌려올 수 있다.

    • 소유권은 옮기지 않고 읽기 전용 권한만 빌려서 사용할 수 있다.

    • 예:

      rust
      fn main() {
          let s1 = String::from("hello");
          let len = calculate_length(&s1); // s1을 참조로 넘김
          println!("길이: {}, 값: {}", len, s1);
      }
       
      fn calculate_length(s: &String) -> usize {
          s.len()
      }
    • 함수에 &s1을 전달해도 s1 자체의 소유권은 변하지 않는다.

가변 참조

값을 빌려 수정하려면 가변 참조(&mut)를 쓰며, 동시에 하나만 허용된다.

  • &mut 키워드를 사용하면 가변 참조가 가능해진다.

  • 단, 특정 스코프 내에서 "단 하나의 가변 참조만" 존재할 수 있다(데이터 레이스 방지).

  • 예:

    rust
    let mut s = String::from("hello");
    change(&mut s);
     
    fn change(some_string: &mut String) {
        some_string.push_str(", world");
    }

스마트 포인터 기초: Box<T>

힙에 데이터를 두고 스택에는 포인터만 남기고 싶을 때 가장 단순한 스마트 포인터가 Box<T>이다.

  • Box<T>
    • 힙에 데이터를 저장하고, 스택에는 포인터(주소)만 유지하는 스마트 포인터이다.

    • 트레이트 객체 등을 다룰 때도 자주 사용된다.

    • 간단한 예:

      rust
      let b = Box::new(5);
      println!("b = {}", b);
    • Box는 가리키는 데이터가 스코프를 벗어날 때 자동으로 drop된다.

제어문

Rust의 분기와 반복은 다른 C 계열 언어와 비슷하되, match가 더 강력하다.

  • if / else if / else

    rust
    let number = 7;
    if number < 5 {
        println!("작다");
    } else if number > 10 {
        println!("크다");
    } else {
        println!("중간이다");
    }
  • match

    • 패턴 매칭을 통한 분기, switch와 유사하면서도 더 강력하다.
    rust
    let x = 3;
    match x {
        1 => println!("1!"),
        2 | 3 => println!("2나 3!"),
        _ => println!("그 외"),
    }
  • while

    • 조건이 true인 동안 반복.
    rust
    while condition {
        // ...
    }
  • loop

    • 무한 루프. breakreturn으로 빠져나온다.
    rust
    loop {
        println!("반복 중");
        break;
    }
  • for

    • 주로 컬렉션 순회 시 사용.
    rust
    let arr = [10, 20, 30];
    for element in arr.iter() {
        println!("값: {}", element);
    }

컬렉션

표준 라이브러리는 가변 리스트, 문자열, 키-값 맵 같은 기본 컬렉션을 제공한다.

  • Vector<T> (Vec<T>)

    • 가변 길이 리스트. 배열과 달리 런타임에 크기를 늘리거나 줄일 수 있다.

    • 예:

      rust
      let mut v = Vec::new();
      v.push(1);
      v.push(2);
  • String

    • 힙에 저장되는 가변 문자열.
    • push_str, push 등을 통해 문자열을 이어붙일 수 있다.
  • HashMap<K, V>

    • 키-값 쌍을 저장하는 자료구조.

    • std::collections에서 제공.

    • 예:

      rust
      use std::collections::HashMap;
       
      let mut scores = HashMap::new();
      scores.insert("Blue", 10);
      scores.insert("Red", 50);

열거형과 패턴 매칭

열거형(enum)은 관련된 여러 종류의 값을 하나의 타입으로 묶고, match로 분기한다.

  • 열거형(Enums)

    • 관련된 여러 종류의 값을 하나의 타입으로 묶어서 표현 가능.

    • 예:

      rust
      enum IpAddrKind {
          V4,
          V6,
      }
       
      fn route(ip_kind: IpAddrKind) { /* ... */ }
       
      fn main() {
          let four = IpAddrKind::V4;
          let six = IpAddrKind::V6;
          route(four);
          route(six);
      }
  • match를 사용한 분기

    • 각 variant에 따라 다르게 동작시킬 수 있다.
    rust
    enum Coin {
        Penny,
        Nickel,
        Dime,
        Quarter,
    }
     
    fn value_in_cents(coin: Coin) -> u8 {
        match coin {
            Coin::Penny => 1,
            Coin::Nickel => 5,
            Coin::Dime => 10,
            Coin::Quarter => 25,
        }
    }

Option과 Result

Rust는 널 대신 Option으로 값의 유무를, 예외 대신 Result로 에러를 타입 시스템에 담는다.

  • Option<T>

    • Rust의 널(null) 개념 대체로, 값이 있거나 없음을 타입 시스템으로 표현한다.
    • Some(T) 또는 None 두 가지 variant를 가진다.
    rust
    let some_number = Some(5);
    let absent_number: Option<i32> = None;
  • Result<T, E>

    • 오류(Error)를 처리하는 열거형.
    • Ok(T) 또는 Err(E) variant를 가진다.
    • ? 연산자를 사용해 간단하게 에러 전파가 가능하다.
    rust
    fn read_file() -> Result<String, io::Error> {
        let mut s = String::new();
        File::open("hello.txt")?.read_to_string(&mut s)?;
        Ok(s)
    }
    • 에러 처리를 통해 안전하게 런타임 예외를 방지하고, 예측 가능한 방법으로 흐름을 제어할 수 있다.

라이프타임 개념

라이프타임은 참조가 안전하게 쓰이는 범위를 컴파일러가 추적하는 장치이다.

  • Rust에서 라이프타임(lifetime) 은 "참조가 유효한 범위(scope)"를 의미한다.
  • 러스트 컴파일러는 서로 다른 라이프타임을 가진 참조들이 안전하게 쓰이는지 검사한다. 추론이 가능하면 라이프타임을 코드에서 자동으로 유추하고, 어려우면 명시적으로 지정하도록 요구한다.
  • 목표: 유효하지 않은 참조(dangling pointer)나 이중 해제(double free) 등을 컴파일 타임에 방지.

함수 파라미터의 라이프타임

참조를 받는 함수에서 컴파일러가 생존 기간을 추론하지 못하면 'a 같은 라이프타임 파라미터를 직접 적는다.

rust
// 라이프타임 파라미터 'a를 사용하여, 입력 참조 x, y와 반환값이 모두 같은 라이프타임 'a 범위 내에 있음을 표기
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
 
fn main() {
    let s1 = String::from("long string");
    let s2 = "short";
 
    let result = longest(s1.as_str(), s2);
    println!("더 긴 문자열: {}", result);
}

'a는 관습적으로 많이 사용하는 라이프타임 파라미터 이름이며, 여러 개가 필요하면 'b, 'c 등을 사용할 수 있다.

구조체와 라이프타임

구조체가 참조 필드를 가지면, 그 참조의 유효 범위를 라이프타임 파라미터로 표시해야 한다.

rust
struct ImportantExcerpt<'a> {
    part: &'a str,
}
 
impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}
 
fn main() {
    let novel = String::from("Rust is fun. Really fun!");
    let first_sentence = novel.split('.').next().expect("문장이 없습니다.");
    let i = ImportantExcerpt { part: first_sentence };
    println!("Excerpt: {}", i.part);
}

구조체 메서드를 정의할 때에도, 구조체에 선언된 라이프타임 파라미터를 사용하거나, 메서드 자체에 별도로 라이프타임을 정의해야 하는 경우가 있다.

라이프타임 추론

컴파일러는 추론 규칙(elision)으로 명시 없이도 흔한 경우를 처리하고, 복잡한 경우에만 명시를 요구한다.

  • 러스트 컴파일러는 라이프타임 추론(elision) 규칙을 통해, 명시하지 않아도 충분히 추론 가능한 경우라면 에러 없이 빌드한다. 대표적으로 아래 상황에서 자동으로 추론한다.
    1. 입력 참조가 하나뿐일 때 -> 반환 값에 자동으로 동일 라이프타임을 부여
    2. 메서드의 첫 번째 파라미터가 &self (or &mut self) 인 경우 등
  • 그러나 참조가 여러 개이거나, 스코프가 복잡하게 얽혀 있으면 컴파일러가 추론 불가능하므로 오류를 내고, 직접 'a 등을 써서 라이프타임 관계를 표현해야 한다.

제네릭

타입 파라미터(<T>)를 쓰면 함수나 구조체를 여러 타입에서 동작하도록 일반화할 수 있다.

rust
// 제네릭 함수 예시
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut max = &list[0];
    for item in list {
        if item > max {
            max = item;
        }
    }
    max
}
fn main() {
    let numbers = vec![1, 2, 3, 10, 4];
    println!("{}", largest(&numbers)); // 10
}

<T>, <U> 등으로 다양한 타입 파라미터를 받을 수 있고, 여러 제네릭 파라미터를 선언할 수 있다. impl<T>로 구조체나 메서드를 제네릭하게 선언하는 것도 가능하다.

트레이트 정의와 구현

트레이트(Trait)는 어떤 타입이 제공해야 하는 메서드의 집합을 정의하며, C++/자바의 인터페이스와 유사한 개념이지만 약간의 차이가 있다.

rust
pub trait Summary {
    fn summarize(&self) -> String;
    
    // 디폴트 메서드
    fn summarize_author(&self) -> String {
        String::from("(저자 정보 없음)")
    }
}

이를 구현(impl)하여 각 타입마다 구체적인 메서드를 정의한다.

rust
struct NewsArticle {
    headline: String,
    author: String,
}
 
impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{} by {}", self.headline, self.author)
    }
    // summarize_author는 디폴트 구현이 있으므로, 재정의하지 않아도 됨
}

트레이트 바운드

트레이트 바운드는 제네릭 함수나 구조체에서 특정 트레이트를 구현한 타입만 쓰도록 제한할 때 사용한다.

rust
// 함수 파라미터 T가 Summary 트레이트를 구현해야 함
fn notify<T: Summary>(item: &T) {
    println!("Breaking news: {}", item.summarize());
}

바운드가 여러 개인 경우, + 로 연결하거나 where 구문을 사용해 가독성을 높일 수 있다.

rust
fn some_function<T, U>(t: T, u: U) 
    where T: Display + Clone,
          U: Clone + Debug
{
    // ...
}

트레이트 오브젝트

트레이트 오브젝트를 사용하면 컴파일 시점이 아닌 런타임에 "어떤 트레이트를 구현한 타입"인지를 결정할 수 있다(동적 디스패치). Box<dyn Trait> 형태로 많이 사용한다.

rust
fn main() {
    let article = NewsArticle { /* ... */ };
    let tweet = Tweet { /* ... */ };
 
    // 서로 다른 타입이지만, 둘 다 Summary 트레이트 구현
    let items: Vec<Box<dyn Summary>> = vec![
        Box::new(article),
        Box::new(tweet),
    ];
 
    for item in items {
        println!("요약: {}", item.summarize());
    }
}

컴파일 시점에 타입이 고정되지 않으므로, 런타임 오버헤드(가상 메서드 테이블 사용)가 발생한다는 점 유의.

Result 활용과 에러 전파

Rust는 예외(Exception)이 아닌 Result<T, E>를 통한 함수 반환으로 에러 처리를 권장한다.

  • 에러가 발생하면 Err를, 정상 동작이면 Ok를 반환하여, 호출자가 이를 처리할 수 있도록 한다.

  • ? 연산자를 사용해 함수 내부에서 에러를 간결히 전파할 수 있다.

    rust
    fn read_file(path: &str) -> Result<String, std::io::Error> {
        let mut s = String::new();
        std::fs::File::open(path)?.read_to_string(&mut s)?;
        Ok(s)
    }
  • 에러 처리를 꼼꼼히 함으로써, 런타임 에러를 안전하게 제어하고 프로그램이 예측 불가능하게 중단되는 상황을 방지한다.

thiserror와 anyhow

프로젝트 규모가 커질수록 에러 처리를 체계적으로 관리하는 것이 중요하며, 이를 돕는 대표 라이브러리가 두 가지 있다.

  • thiserror: 사용자 정의 에러 타입을 쉽게 만들 수 있도록 도와주는 매크로 기반 라이브러리

  • anyhow: 여러 종류의 에러를 단일 타입(anyhow::Error)으로 래핑하여 간단히 전파하고, 원인을 추적하기 쉽게 만들어 준다.

    rust
    use thiserror::Error;
     
    #[derive(Error, Debug)]
    pub enum MyError {
        #[error("파일을 열 수 없습니다: {0}")]
        FileOpenError(std::io::Error),
        #[error("유효하지 않은 입력 데이터")]
        InvalidInput,
    }
     
    // anyhow 사용 예시
    use anyhow::{Context, Result};
     
    fn do_something() -> Result<()> {
        let content = std::fs::read_to_string("config.toml")
            .with_context(|| "config.toml 파일을 읽는 중 오류 발생")?;
        // ...
        Ok(())
    }

패닉과 복구 가능/불가능 에러

에러는 복구 가능한 것과 불가능한 것으로 나뉘며, 후자는 panic!으로 처리한다.

  • panic!: 프로그램이 더 이상 정상 동작할 수 없는 치명적 상황에서 호출된다.
  • 복구 불가능한 에러: 논리적으로 계속 진행이 불가능한 경우(예: out of bound, 치명적 버그)
  • 복구 가능한 에러: Result로 처리 가능한 I/O 에러, 네트워크 에러 등
  • unwrap, expect: 에러 시 즉시 panic!을 일으켜 실행 중단. 데모나 간단한 테스트에서는 유용하지만, 실제 프로덕션 코드에서는 에러 처리가 필요하다.

표준 라이브러리 주요 모듈

표준 라이브러리는 컬렉션, 파일, 입출력 등 자주 쓰는 기능을 모듈로 제공한다.

  • std::collections:
    • Vec<T>, HashMap<K, V>, HashSet<T>, BTreeMap<K, V>, LinkedList<T> 등 다양
  • std::fs:
    • 파일 열기/읽기/쓰기(File, read_to_string, write 등)
  • std::io:
    • 입출력 스트림(stdin, stdout), 버퍼(BufReader, BufWriter), 오류(Error) 등
  • 그 외 std::thread, std::sync(동시성), std::time(시간 측정) 등 유용한 모듈이 많다.

Cargo: 의존성과 빌드

Cargo는 의존성 관리, 릴리스/디버그 빌드, 빌드 스크립트를 한 도구로 처리한다.

  • 의존성 관리: Cargo.toml 파일의 [dependencies] 섹션에서 크레이트를 추가하면, cargo build 시 자동으로 필요한 라이브러리를 다운로드 및 빌드한다.

  • 릴리스/디버그 빌드:

    • 디버그: 기본 모드, 빠른 빌드 속도, 최적화 최소화

      bash
      cargo build
    • 릴리스: 최적화 최대화, 빌드 속도 느림

      bash
      cargo build --release
  • 스크립트(build.rs): 빌드 시 특정 코드를 실행해야 하는 경우(예: 프로토콜 버퍼 생성, C 라이브러리 빌드 등)에 사용. Cargo가 빌드 전에 build.rs를 실행해 필요한 작업을 수행할 수 있다.

커뮤니티와 생태계

라이브러리 검색과 예제, 인기 프로젝트 목록을 제공하는 자원들이 있다.

  • crates.io:
    • Rust의 공식 패키지 저장소. 라이브러리를 쉽게 검색, 설치 가능.
    • cargo add crate_name (Cargo 1.62+에서 지원) 명령어로 편리하게 의존성을 추가할 수 있다.
  • Rust Cookbook:
    • Rust 언어 공식 문서에서 제공하는 예제 모음. 흔히 하는 작업(파일 I/O, 문자열 파싱, HTTP 요청 등)에 대한 예시 코드가 정리되어 있다.
  • Awesome Rust:
    • GitHub에서 유지되는 인기 라이브러리, 예제, 프로젝트 목록. 생태계를 파악하거나 유용한 라이브러리를 찾기 좋다.

모듈 시스템과 가시성

모듈 시스템은 소스 코드를 논리적으로 분리하고, pub로 각 부분의 공개 여부를 정한다.

  • 정의: mod my_module 로 선언, 또는 별도의 파일 my_module.rs를 사용

  • 가져오기: use crate::my_module::SubModule;

  • 가시성 제어: pub 키워드를 통해 외부 모듈에서 접근 가능 여부를 설정

    rust
    // src/lib.rs
    pub mod network {
        pub fn connect() {
            println!("네트워크 연결 시도");
        }
        
        fn private_helper() {
            println!("네트워크 내부 헬퍼");
        }
    }

패키지와 크레이트 구조

패키지는 하나 이상의 크레이트로 구성되며, 크레이트는 라이브러리와 바이너리로 나뉜다.

  • 패키지(package): Cargo가 관리하는 하나 이상의 크레이트(crate)로 구성된 단위
    • Cargo.toml이 있는 루트 디렉터리가 패키지의 시작점
  • 크레이트(crate):
    • 라이브러리 크레이트(lib.rs): 다른 프로그램에서 불러다 쓸 수 있는 라이브러리
    • 바이너리 크레이트(main.rs): 실행 가능한 바이너리(프로그램 진입점)
  • 하나의 패키지 안에 src/main.rs (바이너리)와 src/lib.rs (라이브러리)를 동시에 둘 수도 있음.

워크스페이스

대규모 프로젝트에서 여러 패키지를 하나의 루트로 묶을 때 워크스페이스를 사용한다.

  • workspace를 사용하면, 각 패키지별로 Cargo.toml을 두면서도, 공통된 Cargo.lock과 빌드 출력을 공유해 효율적으로 관리할 수 있다.

    toml
    # 루트의 Cargo.toml
    [workspace]
    members = [
        "core-lib",
        "cli-tool",
    ]
  • core-lib 디렉터리와 cli-tool 디렉터리가 각각 패키지가 되며, 이 둘을 전체 workspace로 묶어 빌드/의존성 관리를 함께 할 수 있다.

연관 타입

연관 타입은 트레이트를 구현하는 타입이 내부에서 사용할 타입을 지정하도록 하는 기능이다.

대표적인 예시로, Iterator 트레이트type Item이 있다.

rust
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

제네릭 파라미터(<T>) 대신 연관 타입을 사용하면, 트레이트 메서드 간에 연관된 타입 정보를 묶어서 표현하기가 수월하다.

디폴트 제네릭 파라미터와 고급 바운드

Rust는 제네릭 파라미터에 디폴트 타입을 지정하거나, 특정 조건일 때만 타입을 요구하는 방식을 지원한다.

rust
// 제네릭 파라미터에 디폴트 타입 부여 예시
trait MyTrait<T = i32> {
    fn do_something(&self, x: T);
}

또한 where 절, 고급 트레이트 바운드 등을 통해 여러 타입 제약을 깔끔하게 명시할 수 있다.

rust
fn complex_function<T, U>(arg: T, data: U)
    where T: MyTrait + AnotherTrait,
          U: Debug + Clone,
{
    // ...
}

이러한 문법을 활용하면 복잡한 타입 제약을 명확하고 유지보수 쉽게 표현할 수 있다.

추상화 패턴

Rust는 객체 지향 스타일과 함수형 스타일을 모두 지원해 다형성을 여러 방식으로 구현할 수 있다.

  • Rust는 전통적인 객체 지향 패턴(트레이트 오브젝트, Box<dyn Trait>)을 지원하는 동시에, 함수형 스타일로 고차 함수를 통한 추상화(Iterators, Closures)도 지원한다.
  • 고급 트레이트 설계를 통해 다형성(polymorphism) 을 다양한 방식으로 구현할 수 있다.
    • 정적 디스패치(static dispatch) vs 동적 디스패치(dynamic dispatch)
    • 제네릭 + 트레이트 바운드 vs 트레이트 오브젝트

서브타이핑과 변성

라이프타임 사이에는 상하위 관계가 있고, 참조 종류마다 변성(variance)이 다르다.

  • Rust 라이프타임 체계에서, 특정 라이프타임 'a'b하위타입(subtype)으로 간주될 수 있는 경우가 존재한다. 예: 'static 라이프타임은 모든 라이프타임의 상위가 된다.
  • 역변성(invariance), 공변성(covariance), 반공변성(contravariance) 등의 개념이 있어, 복잡한 참조 구조에서 왜 컴파일러가 특정 라이프타임 에러를 내는지 이해할 때 필요하다.
    • &'a mut T는 대부분 역변성
    • &'a T는 대부분 공변성

'static 라이프타임과 포인터

'static은 프로그램 전체 기간 동안 유효한 참조를 뜻하며, 잘못 쓰면 UB로 이어진다.

  • 'static 라이프타임: 프로그램이 시작부터 끝날 때까지 유효한 참조를 의미한다. 예: 문자열 리터럴("hello")은 'static 라이프타임을 가진다.
  • 포인터 연산: &'static str, Box<T> 등이 'static 라이프타임으로 묶여 있을 경우, 메모리가 절대 해제되지 않는(또는 전역적으로 존재하는) 것으로 컴파일러가 이해한다.
    • 주의: 'static 참조를 잘못 사용하면, 실제로는 해제될 메모리를 'static으로 참조하게 되어 Undefined Behavior가 발생할 수 있으므로, Unsafe Rust 영역에서 주의 깊게 다뤄야 한다.

RAII와 Drop 트레이트

Rust는 객체가 스코프를 벗어날 때 리소스를 자동 해제하며, 정리 로직은 Drop 트레이트로 커스터마이즈한다.

  • RAII (Resource Acquisition Is Initialization): 객체가 생성될 때 리소스를 획득하고, 스코프를 벗어날 때 자동으로 해제되는 개념. C++의 RAII와 유사하다.

  • Rust에서는 Drop 트레이트를 구현하여, 객체가 스코프를 벗어날 때(혹은 소유권이 이동되어 해제될 때) 커스텀 정리 로직(cleanup)을 수행할 수 있다.

    rust
    struct Resource;
     
    impl Drop for Resource {
        fn drop(&mut self) {
            println!("리소스 정리 로직 실행!");
        }
    }

스레드

표준 라이브러리 std::thread로 스레드를 생성하고 join으로 종료를 기다린다.

rust
use std::thread;
use std::time::Duration;
 
fn main() {
    let handle = thread::spawn(|| {
        for i in 1..5 {
            println!("새 스레드: {}", i);
            thread::sleep(Duration::from_millis(500));
        }
    });
 
    for i in 1..5 {
        println!("메인 스레드: {}", i);
        thread::sleep(Duration::from_millis(500));
    }
 
    // 새 스레드가 끝날 때까지 대기
    handle.join().unwrap();
}

채널

채널은 송신자(Sender)가 여러 개일 수 있고 수신자(Receiver)는 하나인(MPSC) 형태로 메시지를 주고받는 구조이다.

rust
use std::sync::mpsc;
use std::thread;
 
fn main() {
    let (tx, rx) = mpsc::channel();
 
    thread::spawn(move || {
        let val = String::from("안녕하세요");
        tx.send(val).unwrap();
    });
 
    let received = rx.recv().unwrap();
    println!("수신: {}", received);
}

채널을 통해 스레드 간 통신을 안전하게 처리할 수 있다.

동기화: Mutex, RwLock, Arc

여러 스레드가 데이터를 공유할 때는 락과 참조 카운팅을 함께 쓴다.

  • Mutex(Mutual Exclusion)

    • 여러 스레드가 동시에 접근해서는 안 되는 데이터를 보호하기 위한 동기화 primitive.
    • Arc<Mutex<T>> 형태로, 참조 카운팅(Arc)과 락(Mutex)을 함께 사용하여 데이터를 공유/보호하는 것이 일반적이다.
    rust
    use std::sync::{Arc, Mutex};
    use std::thread;
     
    fn main() {
        let counter = Arc::new(Mutex::new(0));
     
        let mut handles = vec![];
        for _ in 0..10 {
            let counter_clone = Arc::clone(&counter);
            let handle = thread::spawn(move || {
                let mut num = counter_clone.lock().unwrap();
                *num += 1;
            });
            handles.push(handle);
        }
     
        for handle in handles {
            handle.join().unwrap();
        }
     
        println!("결과: {}", *counter.lock().unwrap());
    }
  • RwLock: 여러 스레드가 동시에 읽기는 허용하지만, 쓰기는 오직 하나의 스레드만 가능하도록 하는 락

  • Arc(Atomic Reference Counting): 멀티스레드 환경에서 안전하게 참조 카운팅을 수행

비동기 프로그래밍

Rust는 async/await 문법으로 비동기 프로그래밍을 지원한다.

  • async fn, await 키워드를 사용하여, Future 트레이트를 반환하고, 비동기 처리를 간단히 표현할 수 있다.

    rust
    async fn do_work() {
        println!("비동기 작업 중...");
    }
     
    #[tokio::main]
    async fn main() {
        do_work().await;
    }
  • Tokio, async-std 등 런타임(Runtime) 라이브러리를 사용해 이벤트 루프(reactor) 기반의 동시성을 구현한다.

  • HTTP 서버, 네트워킹 등 I/O 병행 처리 상황에서 높은 성능과 안전성을 동시에 제공한다.

unsafe 키워드

unsafe 블록 안에서는 컴파일러의 안전성 규칙을 제한적으로 우회할 수 있다.

  • Rust의 가장 큰 특징은 컴파일러가 메모리 안전성을 강제하지만, unsafe 블록 안에서는 제한적으로 이 규칙들을 우회할 수 있게 한다.
  • unsafe 블록에서 허용되는 작업:
    1. 원시 포인터(*const T, *mut T) 조작
    2. 안전하지 않은 함수나 메서드 호출(FFI 등)
    3. 가변 정적 변수 접근, 정적 변수 초기화
    4. 트레이트 메서드 구현에서 컴파일러 검사를 우회
  • 반드시 필요한 곳에만 최소화하여 사용해야 하며, 사용 시 Undefined Behavior(UB) 발생 가능성을 사전에 차단할 수 있도록 주의해야 한다.

Raw 포인터

Raw 포인터는 빌림 검사나 소유권 검사가 적용되지 않는 저수준 포인터이다.

  • Raw 포인터는 Rust 컴파일러의 빌림 검사나 소유권 검사가 적용되지 않는다.
  • C/C++ 코드와 상호작용(FFI)하거나, 특별히 저수준 메모리 접근이 필요할 때 사용한다.

FFI: 외부 함수 인터페이스

C로 작성된 라이브러리를 호출하거나 Rust를 C에서 호출하려면 FFI를 사용한다.

rust
#[link(name = "mylib")]
extern "C" {
    fn c_function(x: i32) -> i32;
}
 
fn main() {
    unsafe {
        let result = c_function(10);
        println!("C 함수 호출 결과: {}", result);
    }
}

ABI(Application Binary Interface)에 맞춰 함수 시그니처를 일치시켜야 하며, unsafe 블록에서 호출한다.

Undefined Behavior 주의사항

컴파일러는 unsafe 블록 내부의 안전성을 보장하지 않으므로 작성 시 검증이 필수다.

  • Rust 컴파일러는 unsafe 블록 내부에 대한 안전성 보장을 해주지 않는다.
  • 원시 포인터를 잘못 사용하거나, 라이프타임을 무시하고 해제된 메모리에 접근하면 UB가 발생할 수 있다.
  • 따라서 unsafe 코드를 작성할 때는 검증, 리뷰, 테스트 등을 매우 철저히 해야 한다.

Zero-cost Abstractions

Rust의 고수준 문법은 실행 시 오버헤드가 거의 없는 코드로 컴파일된다.

  • Rust는 고수준 문법(제네릭, 트레이트, async 등)을 제공하면서도, 실행 시 오버헤드가 거의 없는(Zero-cost) 코드를 생성하도록 설계되어 있다.
  • 즉, 추상화된 코드가 컴파일러에 의해 최적화되어 런타임 비용 없이 C/C++ 수준의 성능으로 컴파일된다.

Inlining과 SIMD

LLVM 기반 컴파일러는 C/C++와 비슷한 최적화를 수행하고, SIMD로 벡터 연산을 활용한다.

  • Rust 컴파일러(rustc)는 LLVM 기반이므로, C/C++와 유사한 각종 최적화(inlining, loop unrolling, auto-vectorization 등)를 수행한다.
  • SIMD(Single Instruction Multiple Data)를 사용하여 CPU 벡터 명령어로 병렬 연산을 할 수 있다.
    • Rust 표준 라이브러리와 별도 크레이트를 이용해 SIMD를 활용할 수 있다.

프로파일링

고성능 코드를 작성하려면 병목 지점(bottleneck)을 찾아내는 것이 중요하다.

  • 리눅스에서 perf, macOS에서 Instruments, Windows에서 Visual Studio 프로파일러 등을 활용하거나, Rust 생태계의 cargo profiler 등을 통해 성능 측정 및 최적화를 진행할 수 있다.

Arena Allocation과 Custom Allocators

특수 상황에서는 사용자 정의 할당 전략으로 메모리 할당/해제 비용을 줄인다.

  • 일반적인 경우 Rust의 기본 Allocator 사용만으로도 충분히 효율적이다.
  • 특정 상황(예: 게임 엔진, 실시간 시스템)에서는 Arena AllocatorCustom Allocator 기법을 사용하여 메모리 할당/해제 비용을 줄일 수 있다.
    • 예: bumpalo crate (bump allocator)
    • Rust 1.28+부터는 global_allocator 속성을 통해 사용자 정의 할당기를 전역 설정 가능

단위 테스트와 통합 테스트

Rust는 #[test] 애트리뷰트로 단위 테스트를, tests 디렉터리로 통합 테스트를 작성한다.

  • 단위 테스트(Unit Test)

    • 보통 하나의 함수나 모듈이 원하는 대로 동작하는지 검증하기 위해 작성된다.
    • #[test] 애트리뷰트를 달아놓은 함수는 cargo test 실행 시 테스트로 인식된다.
    rust
    #[cfg(test)]
    mod tests {
        use super::*;
     
        #[test]
        fn it_works() {
            assert_eq!(2 + 2, 4);
        }
    }
  • 통합 테스트(Integration Test)

    • tests 디렉터리 안에 .rs 파일을 두고, 실제 라이브러리나 바이너리 API를 사용해 전체 흐름을 검증한다.
    • 프로젝트 루트에서 cargo test 시 자동으로 실행된다.
  • 테스트 실행

    • cargo test: 모든 테스트를 실행
    • cargo test -- --nocapture: 테스트에서 출력되는 println! 메시지를 확인할 수 있음
    • cargo test test_name: 특정 테스트만 실행

테스트 더블과 TDD

의존성을 트레이트로 추상화하면 테스트용 Mock을 주입하기 쉬워진다.

  • Rust에서 Mock 객체를 만들 때는 트레이트 기반으로 추상화하는 방법이 많이 쓰인다.
    • 예: DB나 HTTP 클라이언트에 접근하는 로직을 트레이트로 추상화하고, 실제 구현 대신 테스트용 Mock 구조체를 주입해 테스트할 수 있다.
  • TDD(Test-Driven Development): 테스트를 먼저 작성하고, 이를 통과하기 위한 최소한의 구현을 하며 개발을 진행하는 방법.
    • Rust에서도 TDD를 활용해 견고한 코드를 작성할 수 있다.

CI/CD 파이프라인

커밋마다 자동으로 빌드와 테스트를 수행하고, 통과한 코드를 배포하도록 파이프라인을 구성한다.

  • CI(Continuous Integration): 코드를 커밋할 때마다 자동으로 빌드, 테스트를 수행하고, 결과를 공유

    • GitHub Actions를 예로 들면, .github/workflows/*.yml 파일을 생성해 cargo build, cargo test 등을 자동화할 수 있다.
  • CD(Continuous Deployment): 테스트가 통과된 코드를 스테이징 혹은 프로덕션 환경에 자동 배포

    • Heroku, AWS, Netlify, Vercel 등과 연계 가능
  • 예시(GitHub Actions):

    yaml
    name: Rust CI
     
    on: [push, pull_request]
     
    jobs:
      build_and_test:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v2
          - name: Install Rust
            uses: dtolnay/rust-toolchain@stable
          - name: Build
            run: cargo build --verbose
          - name: Test
            run: cargo test --verbose

코드 스타일: rustfmt와 clippy

공식 포매터와 린트 툴로 일관된 스타일과 코드 품질을 유지한다.

  • rustfmt: 공식 코드 포매터. Rust 코드를 일관된 스타일로 자동 정렬해 준다.
    • cargo fmt 명령어로 손쉽게 적용 가능
    • .rustfmt.toml 파일을 통해 세부 옵션을 설정할 수도 있다.
  • clippy: 러스트용 린트(lint) 툴. 잠재적 오류나 권장되는 코드 스타일, 성능 개선 사항을 알려준다.
    • cargo clippy로 사용, 빠른 피드백을 통해 코드 품질 향상

리팩토링

중복을 줄이고 구조를 단순하게 유지하는 작업이다.

  • 함수 추출: 중복되는 로직이나 너무 복잡한 함수를 적절히 쪼개는 작업
  • 트레이트 추상화: 비슷한 행동을 하는 여러 타입에 공통 트레이트를 정의하고, 코드 중복을 제거
  • **가시성(pub, pub(crate))**과 모듈 구조를 적절히 재설계해, 프로젝트 구조를 간결히 유지

API 디자인과 문서화

공개 API와 내부 구현을 분리하고, /// 주석으로 문서를 자동 생성한다.

  • 라이브러리나 바이너리를 배포할 때, 공개 API와 내부 구현 디테일을 분리해 유지보수성을 높일 수 있다.

  • Rust에서는 /// 주석을 사용해 문서 주석을 작성하면, cargo doc으로 문서 웹페이지를 자동 생성할 수 있다.

    rust
    /// Adds two numbers together.
    ///
    /// # Examples
    ///
    /// ```
    /// assert_eq!(add(2, 3), 5);
    /// ```
    pub fn add(a: i32, b: i32) -> i32 {
        a + b
    }

HTTP 클라이언트와 서버

Rust 생태계는 HTTP 클라이언트와 웹 프레임워크를 모두 제공한다.

  • reqwest:

    • 손쉽게 HTTP 요청을 보낼 수 있는 Rust 라이브러리. GET/POST 등 다양한 메서드를 간단히 호출 가능
    rust
    let body = reqwest::blocking::get("https://www.rust-lang.org")?
        .text()?;
    println!("Body = {}", body);
  • hyper:

    • 저수준 HTTP 라이브러리. 고성능, 비동기 HTTP 서버/클라이언트 기능을 제공
  • actix-web, rocket:

    • Rust에서 웹 서버를 빠르게 만들기 위한 웹 프레임워크. 라우팅, 미들웨어, 세션, 인증 등 편의 기능을 제공

    • 예: actix-web에서 간단한 서버

      rust
      use actix_web::{get, web, App, HttpServer, Responder};
       
      #[get("/")]
      async fn index() -> impl Responder {
          "Hello from Actix!"
      }
       
      #[actix_web::main]
      async fn main() -> std::io::Result<()> {
          HttpServer::new(|| App::new().service(index))
              .bind(("127.0.0.1", 8080))?
              .run()
              .await
      }

직렬화와 역직렬화: serde

serde는 Rust 데이터 구조를 다양한 포맷으로 직렬화/역직렬화한다.

  • serde 라이브러리:

    • Rust 데이터 구조를 JSON/TOML/YAML/MessagePack 등 다양한 포맷으로 손쉽게 직렬화/역직렬화할 수 있게 해준다.
    • #[derive(Serialize, Deserialize)] 매크로를 활용
    rust
    use serde::{Serialize, Deserialize};
     
    #[derive(Serialize, Deserialize)]
    struct Config {
        name: String,
        version: u32,
    }
     
    fn main() {
        let json_str = r#"{ "name": "MyApp", "version": 1 }"#;
        let cfg: Config = serde_json::from_str(json_str).unwrap();
        println!("name: {}, version: {}", cfg.name, cfg.version);
    }

REST API 설계

REST API는 라우팅, 미들웨어, 인증의 조합으로 구성된다.

  • 라우팅: HTTP 경로(path)와 메서드(GET, POST 등)를 엔드포인트 함수에 매핑
  • 미들웨어: 인증, 로깅, 에러 핸들링 등 공통 처리를 위한 레이어
  • 인증: JWT(JSON Web Token)나 OAuth 등을 사용해 토큰 기반 인증을 구현
    • Rust 웹 프레임워크는 인증을 위한 미들웨어/라이브러리를 제공하거나, 직접 구현할 수 있다.

시스템 인터페이스

표준 라이브러리와 FFI로 파일, 소켓, OS 레벨 API에 접근한다.

  • Rust 표준 라이브러리 std::fs, std::net 등을 통해 로우 레벨 시스템 자원(파일, 소켓)에 접근
  • OS 레벨 API(예: Linux syscalls, Windows Win32 API)와 상호작용할 때는 FFI(extern "C")와 unsafe 블록을 사용하기도 한다.

임베디드 러스트

임베디드 환경에서는 no_std 모드와 HAL을 사용해 마이크로컨트롤러를 제어한다.

  • 임베디드 환경에서는 일반적인 표준 라이브러리(std)가 아니라, no_std 모드로 빌드해야 할 수 있다.
    • 이 경우, 힙 할당이나 OS 기능 없이도 동작 가능하도록 최소화된 환경이 제공
  • 임베디드 Rust HAL(Hardware Abstraction Layer)을 사용하면, 특정 마이크로컨트롤러(예: ARM Cortex-M 계열)에서 핀 제어, 인터럽트 처리 등을 Rust로 구현할 수 있다.

WASM: 웹어셈블리

Rust 코드를 웹어셈블리(WASM)로 컴파일해 브라우저나 서버 사이드에서 실행할 수 있다.

  • wasm-bindgen: Rust와 JavaScript 간 상호작용을 돕는 툴
  • wasm-pack: Rust 코드를 빌드해 npm 패키지로 배포하기 쉽게 하는 툴
  • 실시간 성능 요구(예: 게임, 시뮬레이션, 이미지 처리)나, 안전한 샌드박스 환경이 필요한 곳에서 WASM이 유용하다.

애플리케이션 설계

학습한 개념을 묶어 실제로 동작하는 애플리케이션을 만들어 보는 것이 중요하다.

  • 지금까지 학습한 Rust 지식(소유권, 트레이트, 동시성, 웹 프레임워크 등)을 종합해, 실제 서비스를 만들어보는 것이 중요하다.
  • 예) CLI 툴, 웹 서비스, 임베디드 프로젝트, 블록체인 노드, 게임 엔진 등
  • 기획 단계에서 프로젝트 규모, 사용될 라이브러리, 데이터 모델, API 스펙, 테스트 시나리오 등을 구체화

오픈소스 기여

Rust 생태계는 오픈소스로 활발하므로, 기여를 통해 실무 감각을 기를 수 있다.

  • 관심 있는 라이브러리나 프로젝트에 기여하는 방식으로 실무 감각을 기를 수 있다.
  • 작은 문서 수정부터 시작해, 점차 이슈 해결, 신규 기능 PR 등으로 확장한다.

코드 리뷰와 베스트 프랙티스

팀 프로젝트에서는 코드 리뷰로 소유권, 라이프타임, 에러 처리, 스타일을 함께 점검한다.

  • 팀 프로젝트 시 Rust 코드 리뷰를 통해, 소유권과 라이프타임, 에러 처리, 코딩 스타일 등을 점검하고 공유하면 좋다.
  • Rust Best Practices
    • 에러 처리는 가능한 명시적으로
    • 안전성(unsafe 최소화)
    • 성능과 가독성의 균형
    • 문서화와 테스트는 필수
    • clippy 경고를 최대한 해결하여 코드 품질 유지

정리

Rust의 메모리 안전성은 소유권, 빌림 검사기, 라이프타임이라는 세 축으로 컴파일 타임에 보장된다. 제네릭과 트레이트는 런타임 비용 없이 추상화를 제공하고, async/await와 Arc/Mutex는 안전한 동시성을 뒷받침한다. unsafe와 FFI는 저수준 제어가 필요한 경계에서만 제한적으로 쓴다. 표준 라이브러리와 Cargo, crates.io 생태계는 테스트, 직렬화, 웹 서버, 임베디드, 웹어셈블리까지 폭넓게 다룬다. 개념을 실전 프로젝트와 연결해 큰 규모의 코드를 다뤄 보면 소유권과 라이프타임 규칙이 자연스럽게 익는다.