Rust의 Array와 Vector 비교

Rust에서 array와 vector는 모두 여러 개의 값을 순차적으로 저장하는 자료구조입니다. 하지만 두 타입은 메모리 관리, 크기, 사용 목적 등에서 중요한 차이점이 있습니다. 이 글에서는 각 자료구조의 특징, 사용법, 예제, 그리고 언제 어떤 것을 선택해야 하는지에 대해 자세히 알아보겠습니다.

1. Array(배열)

1.1. 기본 개념

  • 고정 크기: 배열은 선언 시 크기가 고정되며, 이후 변경할 수 없습니다.
  • 동일 타입: 배열의 모든 원소는 같은 타입이어야 합니다.
  • 스택 메모리: 배열은 스택에 저장되어 빠른 접근이 가능합니다.

1.2. 배열 선언과 사용 예제

fn main() {
let arr: [i32; 5] = [1, 2, 3, 4, 5];
println!("첫 번째 원소: {}", arr[0]);
println!("배열의 길이: {}", arr.len());

// 배열 반복
for elem in arr.iter() {
println!("{}", elem);
}
}
  • [i32; 5]는 5개의 i32 타입 값을 가지는 배열을 의미합니다.
  • 배열의 크기는 타입의 일부이므로, arr은 항상 5개의 요소만 가질 수 있습니다.
  • arr.iter()로 반복(소유권 이전이 안되며, arr.iter()를 &arr로 바꿔도 됨)해서 요소를 elem에 할당한 후 그 값을 화면에 표시합니다.

    전체적인 출력 결과는 아래와 같습니다.

1.3. 배열의 주요 특징

  • 정적 크기: 크기가 컴파일 타임에 결정됩니다.
  • 빠른 접근: 인덱스를 통한 접근이 가능합니다.
  • 복사: 크기가 작을 경우, 배열 전체가 복사될 수 있습니다.

1.4. 배열의 한계

  • 크기를 동적으로 변경할 수 없습니다.
  • 대용량 데이터에는 적합하지 않을 수 있습니다.

2. Vector(벡터)

2.1. 기본 개념

  • 동적 크기: 벡터는 런타임에 크기를 자유롭게 변경할 수 있습니다.
  • 동일 타입: 모든 원소는 동일한 타입이어야 합니다.
  • 힙 메모리: 벡터는 힙에 저장되어 대용량 데이터 처리에 적합합니다.

2.2. 벡터 선언과 사용 예제

fn main() {
let mut vec: Vec<i32> = Vec::new();
vec.push(10);
vec.push(20);
vec.push(30);

println!("두 번째 원소: {}", vec[1]);
println!("벡터의 길이: {}", vec.len());

// 벡터 반복
for elem in &vec {
println!("{}", elem);
}

// 벡터에서 값 제거
vec.pop();
println!("마지막 원소 제거 후: {:?}", vec);
}
  • Vec::new()로 빈 벡터를 생성하면 type을 추론할 수 없으므로 Vec 다음에 <i32>가 반드시 있어야 합니다.
  • push로 값을 추가하고, pop으로 마지막 값을 제거합니다.
  • 벡터는 mut로 선언해야 원소 추가/삭제가 가능합니다.
  • vec 요소를 소유권 이전없이 참조로 하나씩 꺼내서 elem에 넣은 다음 출력합니다. 배열과 마찬가지로 &vec을 vec.iter()로 바꿔도 됩니다.

    전체적인 출력은 아래와 같습니다.
    두 번째 원소: 20(인덱스가 0부터 시작하기 때문에 1이 두번째 원소가 됩니다)
    벡터의 길이: 3
    10
    20
    30
    마지막 원소 제거 후: [10, 20]

2.3. 벡터의 주요 특징

  • 가변 크기: 필요에 따라 자동으로 크기가 늘어남.
  • 유연성: 다양한 상황에 맞게 사용할 수 있음.
  • 힙 저장: 대용량 데이터에도 적합.

2.4. 벡터의 한계

  • 배열에 비해 약간의 오버헤드가 발생할 수 있음.
  • 힙에 저장되므로 스택보다 접근 속도가 느릴 수 있음.

3. Array vs Vector 비교

특징Array(배열)Vector(벡터)
크기고정동적
선언 위치스택
선언 방식[T; N]Vec<T>
값 추가/제거불가가능 (push, pop 등)
반복.iter().iter()
사용 예시소규모, 고정 데이터대규모, 가변 데이터

4. 실전 예제: 배열과 벡터 변환

4.1. 배열을 벡터로 변환

fn main() {
let arr = [1, 2, 3];
let vec = arr.to_vec();
println!("{:?}", vec); // [1, 2, 3]
}

4.2. 벡터를 배열로 변환

  • 벡터의 길이가 고정되어 있고, 크기를 알고 있을 때만 가능합니다.
fn main() {
let vec = vec![4, 5, 6];
let arr: [i32; 3] = vec.try_into().unwrap();
println!("{:?}", arr); // [4, 5, 6]
}

5. 벡터의 다양한 메서드

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

// 값 삽입
v.insert(2, 99); // 2번 인덱스에 99 삽입

// 여러 개의 값 추가
v.extend([6, 7, 8, 9]);

// 값 삭제
v.remove(1); // 1번 인덱스 값 제거

// 슬라이스
let slice = &v[1..3];
println!("{:?}", slice);

// 반복자 메서드
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
println!("{:?}", doubled);
}
  • v.insert(2, 99)
    => 2번 인덱스에 99를 추가하니 벡터 v는 [1, 2, 99, 3, 4, 5]가 됩니다.
  • v.extend([6, 7, 8, 9]);
    => push는 맨 뒤에 값 하나를 추가하는데, extend를 한 번에 여러 개의 값을 추가할 수 있습니다. 따라서, v는 [1, 2, 99, 3, 4, 5, 6, 7, 8, 9]가 됩니다.
  • v.remove(1);
    => 1번 인덱스인 2를 제거하므로 v는 [1, 99, 3, 4, 5, 6, 7, 8, 9]가 됩니다.
  • let slice = &v[1..3];
    println!(“{:?}”, slice);
    => v 벡터에서 1번 인덱스부터 3미만인 2번 인덱스까지 참조 형식으로 가져오는 slice는 [99, 3]이 됩니다.
    Vector는 일반 포맷인 {}로는 출력이 안되므로 디버그 포맷인 {:?}으로 출력해야 합니다.
  • let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
    println!(“{:?}”, doubled);
    => v 벡터의 요소 들에 2를 곱한 후 새로운 벡터로 변환한 후 doubled에 저장합니다. doubled의 타입도 i32타입의 Vector이므로 디버그 포맷으로 출력하면
    [2, 198, 6, 8, 10, 12, 14, 16, 18]가 출력됩니다.

6. 성능 차이의 실제 사례

6.1. 반복문에서의 성능

fn main() {
let arr = [1; 1000000];
let vec = vec![1; 1000000];

let mut sum = 0;
for i in 0..arr.len() {
sum += arr[i];
}

let mut sum2 = 0;
for i in 0..vec.len() {
sum2 += vec[i];
}
}
  • 실행 시간: 배열이 벡터보다 약간 더 빠른 경우가 많습니다.
  • 이유: 배열은 스택에 연속적으로 저장되어 CPU 캐시 효율이 높고, 컴파일러가 최적화를 더 적극적으로 적용할 수 있습니다.

위와 같이 배열의 개수를 1백만개로 숫자가 1인데도 array의 경우 overflow가 발생하므로 1십만으로 바꾸는데, 시간을 체크하는 부분을 추가하고, 천단위 쉼표를 추가하도록 아래와 같이 수정한 후

[Cargo.toml] -num-format 크레이트를 사용하기 위해 필요

[dependencies]
num-format = "0.4"

[main.rs]

use std::time::Instant;
use num_format::{Locale, ToFormattedString};

fn main() {
let arr = [1; 100_000];
let vec = vec![1; 100_000];

// 배열 합계 시간 측정
let start = Instant::now();
let mut sum = 0;
for i in 0..arr.len() {
sum += arr[i];
}
let duration = start.elapsed();
println!("Array sum: {}, elapsed: {:?}", sum.to_formatted_string(&Locale::ko), duration);


// 벡터 합계 시간 측정
let start = Instant::now();
let mut sum2 = 0;
for i in 0..vec.len() {
sum2 += vec[i];
}
let duration = start.elapsed();
println!("Vector sum: {}, elapsed: {:?}", sum2.to_formatted_string(&Locale::ko), duration);
}

실행하면 결과는 아래와 같습니다.

Array sum: 100,000, elapsed: 806.2µs
Vector sum: 100,000, elapsed: 2.3802ms

1㎳가 1000㎲이므로, Vector가 약 3배정도 오래 걸립니다.

[프로그램 설명]

  • Rust의 표준 라이브러리에서 제공하는 std::time::Instant을 사용해 각 합계 연산의 소요 시간을 측정합니다.
  • Instant::now()로 현재 시각을 기록하고, 반복문이 끝난 후 elapsed()로 소요 시간을 구합니다.
  • for i in 0..arr.len()라고 0부터 배열의 길이전까지 i를 반복하면 sum에 arr[i]를 더하도록 했는데, for i in arr.iter()라고 하고, sum += i;이라고 해도 되는데, iter()를 이용한 것이 훨씬 빠릅니다. 특히 Vector 속도가 많이 빨라졌습니다.
    Array sum: 100,000, elapsed: 728.6µs
    Vector sum: 100,000, elapsed: 875.7µs
    ※ 그런데 매번 속도가 다르기 때문에 위 수치가 절대적인 것은 아닙니다. 어느 때는 Vector가 빠른 경우도 있습니다.
  • sum 또는 sum2 다음에 num_format의 ToFormattedString(&Locale::ko)를 추가해서 숫자에 천단위마다 쉼표를 추가합니다.

6.2. 크기 변경 및 데이터 추가

  • 배열은 크기가 고정되어 있어, 데이터 추가/삭제가 불가능합니다.
  • 벡터는 push, pop, extend 등으로 동적으로 크기를 조절할 수 있지만, 이 과정에서 메모리 재할당이 발생할 수 있습니다. 대량의 데이터를 추가할 때는 재할당 오버헤드가 성능 저하 요인이 됩니다.

6.3. 벤치마크 및 실제 사용 조언

  • 고정 크기, 빠른 반복/접근이 필요하다면 배열이 유리합니다.
  • 크기가 가변적이거나, 데이터 추가/삭제가 빈번하다면 벡터가 적합합니다.
  • 대용량 데이터 처리에서 벡터는 힙 할당 및 재할당 비용이 있으므로, 성능이 민감한 경우 벡터의 용량을 미리 예약(with_capacity)하는 것이 좋습니다.

※with_capacity란?

  • Vec::with_capacity는 Rust의 벡터(Vec)를 생성할 때 초기 용량(capacity) 을 미리 지정하는 메서드입니다.
  • with_capacity(n)은 최소 n개의 요소를 저장할 수 있는 공간을 미리 할당한 빈 벡터를 생성합니다.
  • 이렇게 하면, 벡터에 요소를 추가할 때마다 메모리를 재할당하는 비용을 줄일 수 있어 성능이 향상됩니다.
  • 사용 예시
fn main() {
let mut vec = Vec::with_capacity(10);
assert_eq!(vec.len(), 0);
assert!(vec.capacity() >= 10);

for i in 0..10 {
vec.push(i);
}

assert_eq!(vec.len(), 10);
assert!(vec.capacity() >= 10);

// 11번째 push 시 재할당 발생 가능
vec.push(11);
assert_eq!(vec.len(), 11);
}
  • 위 예제에서 vec은 처음부터 10개 이상의 요소를 저장할 수 있도록 메모리를 할당받아, 10개까지는 재할당 없이 push가가능합니다.
  • 11번째 요소를 추가하면 내부 용량을 초과하므로 재할당이 발생할 수 있습니다.

7. 요약 표

구분배열 (Array)벡터 (Vector)
저장 위치스택
크기고정동적
접근 속도매우 빠름빠르나 배열보다 느림
데이터 추가/삭제불가가능
성능반복/접근에서 우위대용량·가변성에 우위
  • 배열은 크기가 고정되고, 반복/접근 성능이 최상입니다.
  • 벡터는 크기 가변성, 데이터 추가/삭제의 유연성이 강점이지만, 힙 할당과 재할당에서 오버헤드가 발생할 수 있습니다.
  • 실제 성능 차이는 데이터 크기와 사용 패턴에 따라 다르므로, 벤치마크를 통해 최적의 자료구조를 선택하는 것이 중요합니다.

구조체와 튜플을 조합한 데이터 모델링

Rust에서 구조체(struct)와 튜플(tuple)을 조합해 복잡한 데이터 모델링을 하는 방법은, 각 자료구조의 장점을 살려 중첩(nesting)하거나, 서로 포함시켜 계층적인 구조를 만드는 것입니다. 이렇게 하면 의미 있는 필드(구조체)와 위치 기반 데이터(튜플)를 효과적으로 결합할 수 있습니다.

실제 프로젝트에서는 구조체로 주요 엔티티(예: 사용자, 상품, 센서 등)를 정의하고,
구조체의 일부 필드를 튜플로 선언해 위치, 좌표, 설정값 등 간단한 데이터를 묶어 표현하거나, 구조체로 선언해서 의미를 명확히 합니다.

1. 구조체 안에 튜플을 포함하는 예시

struct Employee {
name: String,
age: u32,
// (연, 월, 일) 생년월일을 튜플로 표현
birth_date: (u16, u8, u8),
}

fn main() {
let emp = Employee {
name: String::from("Kim"),
age: 28,
birth_date: (1997, 5, 14),
};
println!("{}의 생년월일: {}-{}-{}", emp.name, emp.birth_date.0, emp.birth_date.1, emp.birth_date.2);
}
  • 구조체는 필드의 의미를 명확히하고, 튜플은 간단한 데이터 묶음에 적합합니다.
  • Emplyee라는 구조체를 선언(정의)하면서 필드명과 형식을 지정하는데, birthdate는 생년월일의 연,월,일을 튜플 형식으로 지정한 것입니다.
  • let 문을 이용해서 Employee의 instance를 생성하고, 여기서는 emp, 출력할 때는 emp를 이용해서 emp.필드명 식으로 하면 되는데, 튜플 타입은 emp.필드명 다음에 튜플이므로 index를 붙여서 emp.birth_date.0, .1, .2식으로 표현합니다.
  • 출력값은 Kim의 생년월일: 1997-5-14입니다. 두 자릿수로 출력하려면 {:02}로 수정하면 됩니다. 두 자릿수로 출력하는데, 부족하면 0으로 채우라는 의미입니다.

2. 튜플 안에 구조체를 포함하는 예시

struct Product {
id: u32,
name: String,
}

fn main() {
// (상품, 수량) 형태로 장바구니 항목 표현
let cart_item: (Product, u32) = (
Product { id: 1, name: String::from("Book") },
3,
);
println!("{}: {}개", cart_item.0.name, cart_item.1);
}
  • 튜플로 여러 정보를 임시로 묶되, 각 요소가 구조체라면 의미를 명확히 할 수 있습니다.
  • cart_item을 튜플 형식으로 지정해서 Product와 수량을 받는데, Product를 구조체와 연결해서 id와 name으로 의미를 명확히하는 것입니다.
  • 튜플 속에 구조체가 들어있으므로 출력할 때 cart_item 다음에 인덱스를 적고, 구조체의 필드명을 적어서 표시합니다. 예) cart_item.0.id, cart_item.0.name, cart_item.1
  • 출력 결과는 ‘Book: 3개’입니다.

3. 중첩 구조체와 튜플을 활용한 복합 모델

struct Address {
city: String,
zip: String,
}

// (위도, 경도) 위치 정보를 튜플로 표현
struct Store {
name: String,
address: Address,
location: (f64, f64),
}

fn main() {
let store = Store {
name: "Rust Mart".to_string(),
address: Address {
city: "Seoul".to_string(),
zip: "12345".to_string(),
},
location: (37.5665, 126.9780),
};
println!("{} ({}, {}) - 위치: ({}, {})",
store.name, store.address.city, store.address.zip, store.location.0, store.location.1
);
}
  • Address 구조체를 정의한 다음 Address 구조체를 Store의 address 필드의 type으로 사용하고, Store의 location은 위도와 경도를 튜플 형식으로 정의했습니다.
  • 따라서, Store 구조체의 인스턴스를 만들 때도 address를 Address 구조체로 입력하고, location은 위도와 경도를 튜플 형식으로 입력했습니다.
  • 그리고, 출력할 때는 인스턴스명.필드명인데, address는 구조체이므로 다시 한번 더 필드명을 적어주었고, tuple 타입은 필드명 다음에 인덱스를 추가했습니다.
  • 출력 결과는 ‘Rust Mart (Seoul, 12345) – 위치: (37.5665, 126.978)’입니다.

4. 튜플 구조체와 일반 구조체 조합

struct Point(i32, i32, i32);

struct Sensor {
id: u32,
position: Point,
}

fn main() {
let sensor = Sensor { id: 101, position: Point(10, 20, 30) };
println!("센서 {} 위치: ({}, {}, {})", sensor.id, sensor.position.0, sensor.position.1, sensor.position.2);
}
  • 이번에는 튜플 구조체를 정의한 다음, 일반 구조체의 타입으로 사용한 예입니다.
  • 일반 구조체의 타입이 튜플이냐 아니냐만 다를 뿐 표현하는 방식은 위와 동일합니다.

이처럼 구조체와 튜플을 조합하면 복잡한 데이터도 명확하고 효율적으로 모델링할 수 있습니다.

  • 구조체는 필드의 의미와 계층 구조를,
  • 튜플은 간단한 값 묶음이나 위치 기반 데이터를 담당하게 하여,
  • 코드의 가독성과 확장성을 모두 높일 수 있습니다

5. 튜플 구조체로 타입 구분

struct Point(i32, i32, i32);
struct Color(i32, i32, i32);

fn draw_sphere(center: Point, color: Color) {
// center와 color가 같은 (i32, i32, i32) 구조지만, 타입이 달라 혼동 방지
// This function would contain logic to draw a sphere at the given center
// with the specified color.

println!("Drawing sphere at center: ({}, {}, {}) with color: ({}, {}, {})",
center.0, center.1, center.2,
color.0, color.1, color.2);
}

fn main() {
let center = Point(0, 0, 0);
let color = Color(255, 0, 0); // Red color

draw_sphere(center, color);
}
  • 위와 같이 구조체를 튜플 형식으로 지정하면 draw_sphere 함수에서 입력 타입이 구조체 형식과 맞는지 체크하는데,
  • 아래와 같이 함수의 인수를 튜플 형식으로 지정하면 둘 다 튜플 형식이기 때문에 center 자리에 Point 구조체 타입이 아닌 color 튜플을 넣어도 맞는 타입인지 체크를 못합니다.
  • 튜플 구조체(예: struct Point(i32, i32, i32);)를사용하면,
    동일한 데이터 구조라도 타입별로 구분할 수 있어 실수 방지 및 타입 안전성을 높입니다.
fn draw_sphere(center: (i32, i32, i32), color: (i32, i32, i32)) {
...
}

fn main() {
let center = (0, 0, 0);
let color = (255, 0, 0); // Red color

draw_sphere(color,center);
}

6. 함수 반환값 및 임시 데이터

함수에서 여러 값을 반환할 때 튜플을 사용하고,
이 반환값을 구조체의 필드로 저장하거나, 여러 구조체 인스턴스를 튜플로 묶어 일시적으로 처리할 수 있습니다.

fn min_max(numbers: &[i32]) -> (i32, i32) {
let min = *numbers.iter().min().unwrap();
let max = *numbers.iter().max().unwrap();
(min, max)
}

struct Stats {
min: i32,
max: i32,
}

fn main() {
let numbers = [3, 7, 2, 9, 4];
let (min, max) = min_max(&numbers);

let stats = Stats { min, max };
println!("최솟값: {}, 최댓값: {}", stats.min, stats.max);
}
  • let (min, max) = min_max(&numbers);
    => numbers 배열을 참조로 가져와서 min_max 함수를 처리한 다음 결괏값을 min, max 튜플에 넣고,
  • let stats = Stats { min, max };
    => min과 max를 Stats 구조체에 넣어 stats 인스턴스(또는 변수)를 만듭니다.
    min: min, max: max라고 쓰는 것이 정석이지만 필드명과 변수명이 같기 때문에 필드명만 적으면 됩니다.
  • 그리고, 인스턴스의 min과 max를 출력하는 것입니다.

7. 설정값, 좌표, 범위 등 불변 데이터 관리

고정된 설정값이나 좌표와 같이, 변경되지 않는 데이터는 튜플로 관리하고,
이 값을 구조체의 일부로 포함시켜 사용합니다.

struct DbConfig { 
host: String,
port: u16,
credentials: (String, String), // (username, password)
}

fn main() {
let db_config = DbConfig {
host: String::from("localhost"),
port: 5432,
credentials: (String::from("user"), String::from("password")),
};

println!("DB 호스트: {}", db_config.host);
println!("DB 포트: {}", db_config.port);
println!("DB 사용자명: {}", db_config.credentials.0);
println!("DB 비밀번호: {}", db_config.credentials.1);
}

8. 튜플과 달리 Struct는 메서드와 함께 활용

구조체에 메서드를 구현하여 데이터와 동작을 결합할 수 있습니다.
예를 들어, 2차원 평면상의 점(Point)에 대해 특정 축 위에 있는지 판별하는 메서드를 추가할 수 있습니다.

struct Point(f32, f32);

impl Point {
fn on_x_axis(&self) -> bool {
self.0 == 0.0
}
fn on_y_axis(&self) -> bool {
self.1 == 0.0
}
}

fn main() {
let point = Point(0.0, 0.0);
if point.on_x_axis() && point.on_y_axis() {
println!("원점에 있습니다.");
}
}
  • 구조체에 메서드를 추가해 객체 지향적으로 사용할 수 있습니다.
  • 구조체의 메서드를 만들려면 impl 구조체라고 명명하고, 그 안에서 함수(fn, 메소드)를 작성하는데, 첫번째 인수는 &self, 구조체 자체입니다.
  • fn on_x_axis(&self) -> bool은 구조체를 인수로 받아 bool 형식인 True, False를 반환합니다.
  • self.0 == 0.0
    => 세미콜론으로 끝나지 않으므로 반환값인 표현식으로 첫번째 튜플 값이 0.0인지 비교해서 같다면 True를 반환하고, 아니면 False를 반환하는 것입니다.
  • self.1 == 0.0는 튜플의 두번째 값이 0.0인지 비교하는 것입니다.
  • 출력값은 튜플의 값이 모두 0.0이므로 ‘원점에 있습니다.’입니다.

9. 요약

  • 튜플: 간단한 값 묶음, 여러 값 반환, 임시 데이터에 적합
  • 구조체: 명확한 의미의 데이터 구조, 필드 이름, 가독성·유지보수성 강조
  • 튜플 구조체: 같은 구조이지만 다른 의미의 타입 구분으로 타입 안전성을 강화

fmt::Display trait

Debug는 구조체 정보를 그대로 출력하고, fmt::Display는 원하는 방식으로 표현을 커스터마이징할 수 있게 해줍니다. Debug는 #[derive(Debug)]로 자동 구현이 되고, fmt::Display는 impl fmt::Display를 통해 수동 구현이 필요합니다.

구분DebugDisplay
목적디버깅용 출력사용자 친화적 출력
매크로println!(“{:?}”, x)println!(“{}”, x)
구현 방법#[derive(Debug)]로 자동 구현수동 구현 필요 (impl fmt::Display)
포맷 예시Matrix(1,0, 2.0, 3.0, 4.0)(1.0, 2.0)
(3.0, 4.0)
문자열화format!(“{:?}”, x)x.to_string() 또는
format!(“{}”, x)

1. tuple 구조체

가. 선언

#[derive(Debug)]
struct Matrix(f32, f32, f32, f32);
  • [derive(Debug)]는 Rust에서 자동으로 Debug 트레이트를 구현해 달라는 뜻입니다. 이를 통해 해당 구조체(또는 열거형 등)를 {:?} 형식으로 출력할 수 있게 됩니다.
  • Matrix 구조체는 f32 타입의 값 4개를 가지며, 필드에 이름이 없어서 튜플처럼 순서로 접근 합니다.

나. instance 생성 및 Debug 포맷 출력

fn main() {
    let matrix = Matrix(1.1, 1.2, 2.1, 2.2);
    println!("{:?}", matrix);
}
  • Matrix라는 튜플 구조체의 새 인스턴스(instance)를 생성하는 코드입니다.
  • (1.1, 1.2, 2.1, 2.2) 라는 4개의 f32 값을 순서대로 Matrix 구조체에 넣어 새 변수 matrix에 저장합니다.
  • println!(“{:?}”, matrix);는 Debug 트레잇을 이용해 구조체 내부 값을 출력하는 것입니다.
    출력값은 Matrix(1.1, 1.2, 2.1, 2.2)입니다.

2. fmt::Display 트레잇 구현

위는 Debug 트레잇으로 출력하는 것이고, fmt::Display 트레잇을 통해 다양한 포맷으로 출력할 수 있습니다.

use std::fmt;

impl fmt::Display for Matrix {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        // 원하는 포맷으로 출력 구성
        write!(
            f,
            "( {} {} )\n( {} {} )",
            self.0,self.1, self.2,self.3
        )
    }
}
  • use std::fmt;
    => Rust 표준 라이브러리(std) 안에 있는 fmt 모듈을 현재 스코프(scope)로 가져온다는 뜻입니다.
    fmt는 그 안에 문자열 포맷(formatting) 관련 함수와 트레이트들이 모여있는 모듈(module)입니다.
    use std::fmt;를 하면, 나중에 fmt::Display나 fmt::Formatter 같은 타입이나 트레이트를 쓸 때, std::fmt 전체를 계속 적지 않고 그냥 fmt만 써도 됩니다.

  • impl fmt::Display for Matrix
    => Matrix에 대해 Display 트레이트를 수동으로 구현합니다.
  • fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
    => 출력 형식을 어떻게 할지를 정의하는 함수입니다.
    메소드명은 fmt이고, 인수로 자기 자신인 &self와 가변 참조 형식의 fmt::Fomatter 형식(구조체)의 f를 받고, 반환 형식은 fmt::Result입니다.

    fmt::Result는 Result<(), fmt::Error>의 별칭으로,
    성공하면 Ok(()), 실패하면 Err(fmt::Error)를 반환합니다.
  • write! 매크로의 입력 포맷은 write!(f, “{}”, value)입니다. “{}”라는 포맷에 value를 넣고, 그 결과를 f에 기록합니다.
  • “( {} {} )\n( {} {} )”는 괄호안에 값 2개씩을 넣어 출력하는데, \n은 줄 바꿈을 의미합니다.
  • self.0,self.1, self.2,self.3는 포맷에 들어갈 값을 순서대로 나열한 것입니다.

3. main 함수

fn main() {
    let matrix = Matrix(1.1, 1.2, 2.1, 2.2);

    // Debug 포맷
    println!("{:?}", matrix);

    // Display 포맷
    println!("{}", matrix);
}

위에서 첫번째 println! 매크로는 Debug 트레잇으로 출력하는 것으로 위에서 봤고,

두번째 println! 매크로는 fmt::Display 트레잇으로 출력하는 것으로 2번에서 구현한 바 있습니다.

출력하면 아래와 같이 튜플 구조체의 값이 두 개씩 괄호안에 싸여 두 줄로 표시됩니다.

( 1.1 1.2 )
( 2.1 2.2 )

4. fmt::Formatter의 주요 메서드

가. width(): Option<usize>

  • 출력 시 최소 너비를 설정하는 값입니다.
  • Some(n)이면, 출력 문자열이 최소 n칸은 차지해야 함을 의미합니다.
  • 예: println!(“{:5}”, “hi”) → ” hi” (공백 3개 + hi)

사용 예:

fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let width = f.width().unwrap_or(0);
write!(f, "{:width$}", "hi", width = width)
}

나. precision(): Option<usize>

  • 소수점 이하 자리수 또는 최대 출력 길이를 설정합니다.
  • Some(n)이면, 최대 n자리 또는 n글자까지만 출력합니다.

사용 예:

fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let prec = f.precision().unwrap_or(2);
write!(f, "{:.prec$}", 3.14159, prec = prec)
}
// precision이 2이면 → 3.14

또는 문자열에 적용하면:

write!(f, "{:.5}", "Hello, world!") // → Hello

다.align(): Option<fmt::Alignment>

  • 정렬 방식 설정입니다. 리턴값은 Option이고, Alignment는 열거형입니다.
의미
Some(Left)왼쪽 정렬 (:<)
Some(Right)오른쪽 정렬 (:>)
Some(Center)가운데 정렬 (:^)
None정렬 지정 없음

사용 예:

use std::fmt::{self, Alignment};

fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match f.align() {
Some(Alignment::Left) => write!(f, "왼쪽 정렬됨"),
Some(Alignment::Right) => write!(f, "오른쪽 정렬됨"),
Some(Alignment::Center) => write!(f, "가운데 정렬됨"),
None => write!(f, "기본 정렬"),
}
}

라. Matrix 구조체에 width, precision 적용하기

impl fmt::Display for Matrix {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let w = f.width().unwrap_or(5);
let p = f.precision().unwrap_or(2);
write!(
f,
"( {:>w$.p$} {:>w$.p$} )\n( {:>w$.p$} {:>w$.p$} )",
self.0, self.1, self.2, self.3,
w = w, p = p
)
}
}

사용:

let m = Matrix(1.2345, 12.3456, 123.4567, 1234.5678);
println!("{:8.1}", m); // 최소 너비 8칸, 소수점 아래 1자

출력:

(     1.2    12.3 )
( 123.5 1234.6 )

바. format!와 write!

방식장점
format!() 사용문자열을 먼저 만들어 두기 편함
write!() 직접 사용메모리 절약, 포맷 버퍼 덜 생성함

Rust에서는 둘 다 가능하지만, 성능이나 메모리를 조금 더 아끼고 싶다면 write!()를 직접 쓰는 방식이 더 낫습니다.

5. 튜플 위치 변경(reverse)

// Tuples can be used as function arguments and as return values.
fn reverse(pair: (i32, bool)) -> (bool, i32) {
    // `let` can be used to bind the members of a tuple to variables.
    let (int_param, bool_param) = pair;

    (bool_param, int_param)
}

fn main() {
    let pair = (1, true);
    println!("Pair is {:?}", pair);

    println!("The reversed pair is {:?}", reverse(pair));
}
  • fn reverse(pair: (i32, bool)) -> (bool, i32) {
    => reverse 함수는 pair라는 인수를 받는데, i32와 bool 타입의 튜플이며, 반환 형식은 bool, i32 타입의 튜플입니다.
  • let (int_param, bool_param) = pair;
    => pair라는 튜플에서 값을 튜플 형식으로 받는데 int_param과 bool_param은 튜플의 멤버와 묶입니다.
  • (bool_param, int_param)은 표현식으로 reverse 함수의 return 값입니다. 인수는 i32, bool 타입이었는데, 순서가 바뀌어서 bool, i32 타입으로 반환됩니다.
  • let pair = (1, true);
    => pair란 변수에 1, true를 멤버로 하는 튜플을 대입합니다.
  • println!(“Pair is {:?}”, pair);
    => pair란 튜플을 debug 포맷으로 출력합니다.
  • println!(“The reversed pair is {:?}”, reverse(pair));
    => reverse 함수를 이용해 pair 튜플의 순서를 바꿔서 출력합니다.
  • cargo run으로 실행하면
    입력한 값대로 (1, true)로 출력되고,
    그 다음은 reverse 함수가 적용되어 순서가 바뀌어 (true, 1)로 출력됩니다.
Tuple 필드 reverse

6. 구조체 위치 변경(transpose)

use std::fmt;

#[derive(Debug)]
struct Matrix (f32, f32, f32, f32);

impl fmt::Display for Matrix {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "( {} {} )\n( {} {} )",
            self.0,self.1, self.2,self.3
        )
    }
}

fn transpose(matrix: Matrix) -> Matrix {
    Matrix(matrix.0, matrix.2, matrix.1, matrix.3)
}

fn main() {
    let matrix = Matrix(1.1, 1.2, 2.1, 2.2);
    println!("{:?}", matrix);
    println!("Matrix:\n{}", matrix);
    println!("Transpose:\n{}", transpose(matrix));
}
fn transpose(matrix: Matrix) -> Matrix {
Matrix(matrix.0, matrix.2, matrix.1, matrix.3)
}
  • fn transpose(matrix: Matrix) -> Matrix
    => 이 부분을 함수 signature라고 부르며,
    의미는 transpose 함수를 만드는데, 인수는 matrix이고, matrix의 타입은 Matrix 구조체입니다. 또한 반환 형식도 Matrix 구조체입니다.
  • { Matrix(matrix.0, matrix.2, matrix.1, matrix.3) }
    => 중괄호 안에 있는 것은 함수 본문으로 세미콜론이 없기 때문에 반환값을 나타내는 표현식입니다.

    Matrix(matrix.0, matrix.2, matrix.1, matrix.3)는 matrix라는 인수의 필드에 접근하는데, 인덱스가 0, 1, 2, 3이지만 순서를 바꾸기 위해 0, 2, 1, 3으로 표시했습니다. 이들 필드를 결합해서 Matrix 구조체를 반환합니다.
    println!("Matrix:\n{}", matrix);
println!("Transpose:\n{}", transpose(matrix));

fmt::Display trait을 이용해 matrix와 transpose(matrix)를 출력합니다.

출력 결과는
Matrix:
( 1.1 1.2 )
( 2.1 2.2 )
Transpose:
( 1.1 2.1 )
( 1.2 2.2 )
입니다.

1.2와 2.1의 위치가 달라졌습니다.

literal과 variable, value, const, static

Rust에서 리터럴(literal)은 소스 코드에 직접 값을 표현한 것이며(예: 5, “hello”, true), 변수(variable)는 프로그램 실행 중에 값을 저장하고 변경할 수 있는 이름이 있는 저장 공간입니다(예: let x = 5;).

1. 리터럴과 변수

가. 리터럴(Literals)

프로그래밍에서 리터럴은 변수(variable)나 상수(const)와 같이 특정 데이터 유형의 값을 직접 나타내는 표현 방식입니다. 예를 들어, 숫자 10, 문자 ‘a’, 문자열 “hello” , true, false 등이 모두 리터럴입니다. 

  • 직접적인 값:리터럴은 프로그램 실행 중에 변경될 수 없는 고정된 값 자체를 의미합니다. 
  • 불변성: 리터럴은 정의된 이후에 그 값을 변경할 수 없습니다.
  • 표현식:때로는 코드에서 특정 값을 계산하는 표현식 자체가 리터럴로 사용되기도 합니다. 예를 들어, (2 + 3)과 같이 계산 결과가 고정된 값으로 나타나는 경우. 

나. 변수(Variables)

  • 이름이 있는 저장소: 변수는 메모리에 값을 저장하는 이름이 붙은 공간입니다.
  • 가변성 또는 불변성: Rust에서는 let x = 5;처럼 불변 변수도 만들 수 있고, let mut x = 5;처럼 가변 변수도 만들 수 있습니다. 가변 변수는 값을 변경할 수 있습니다.
  • 모든 타입 가능: 변수는 기본 타입은 물론, 구조체나 참조 등 다양한 데이터 타입의 값을 저장할 수 있습니다.
  • 데이터 저장과 조작: 변수는 프로그램에서 필요에 따라 데이터를 저장하거나 변경하는 데 사용됩니다.

다. 비교표

항목리터럴(Literal)변수(Variable)
표현 방식값을 직접 표현이름이 있는 저장 공간
변경 가능 여부변경 불가 (immutable)변경 가능 또는 불가능 (mutable / immutable)
※ Rust에서는 기본값이 immutable
저장 방식변수처럼 메모리에 저장되지 않음메모리에 저장됨
사용 목적상수값(변하지 않는 값) 표현데이터 저장 및 조작
예시5, “hello”, truelet x = 5;에서 x
let mut y = “hello”;에서 y

라. 예제 1

fn main() {
let x = 10; // 10은 정수 리터럴
let y = "hello"; // "hello"는 문자열 리터럴

println!("x = {}", x);
println!("y = {}", y);
}

여기서:

  • 10은 숫자 리터럴 (미리 정의된 값)
  • “hello”는 문자열 리터럴 (미리 코드에 써 넣은 값)

즉, 이런 리터럴 값은 코드에서 바로 보이고 바뀌지 않는 상수 같은 값입니다.


마. 예제 2

fn main() {
let a = 5; // 5는 리터럴
let b = a + 3; // 3도 리터럴, b는 변수

println!("b = {}", b); // 출력: b = 8
}
  • 여기서 5와 3은 literal 또는 literal value (미리 정해진 값)로 바뀔 수 없는데,
  • a와 b는 변수로 나중에 바뀔 수 있습니다. 그러나, Rust에서는 mut가 없으면 불변이 기본임

바. 요약

Rust에서 10, “hello”, true 같은 값들은 literal로서
코드에 직접 써 넣은, 미리 정의된 값들입니다.

2. 리터럴과 값(value)

가. Literal (리터럴)

“소스 코드에 직접 쓰인 값”
즉, 고정된 형태의 값을 코드에 명시한 것입니다.

42       // 정수 리터럴
3.14 // 부동소수점 리터럴
"hello" // 문자열 리터럴
true // 불리언 리터럴
'b' // 문자 리터럴

리터럴은 컴파일 타임에 결정되며 변하지 않습니다.


나. Value (값)

프로그램이 실행될 때 메모리에 존재하는 데이터입니다.
리터럴, 변수, 연산 결과 등 다양한 방식으로 생성될 수 있습니다.

let x = 5;            // 리터럴 5를 변수 x에 저장 → value는 5
let y = x + 2; // 연산 결과 7도 value
let s = String::from("hi"); // value는 "hi"라는 힙에 저장된 문자열

value는 runtime의 개념이며, 리터럴은 value를 만들기 위한 한 방법입니다.


다. 비교표

항목LiteralValue
정의코드에 직접 적힌 고정 값실행 중 사용되는 데이터
예시“hello”, 3.14, true변수에 저장된 값, 함수의 반환값 등
생성 시점컴파일 타임런타임
변경 가능성불변변경 가능 (가변 변수에 저장된 경우)
위치코드에 직접 작성됨메모리에 존재

라. 관계 요약

  • 리터럴은 value를 만드는 수단 중 하나입니다.
  • 모든 리터럴은 value지만, 모든 value가 리터럴은 아닙니다.

예:

let a = 1 + 2;  
// 1과 2는 리터럴 → 3은 value (리터럴이지만 연산 결과이기도 함)

let b = a * 2;
// b의 값 6은 value지만 literal은 아님 (런타임 연산 결과)

3. literal과 const, static

구분의미특징
literal코드에 직접 써 넣은 값미리 정의된 값. 변수나 상수에 저장 가능
const컴파일 시점 상수항상 값이 고정, 함수 밖/안 모두 사용 가능, 메모리 없음
static정적 변수 (전역 상수)프로그램 전체에서 공유, 고정 메모리 공간 사용

가. 예제

const CONST_VALUE: i32 = 10;
static STATIC_VALUE: i32 = 20;

fn main() {
let literal = 5; // 5는 literal

println!("literal: {}", literal);
println!("const: {}", CONST_VALUE);
println!("static: {}", STATIC_VALUE);
}

나. 설명

  1. 5는리터럴(literal)
    → 그냥 코드에 직접 쓴 고정된 값
  2. CONST_VALUE는 const
    → 컴파일 시간에 값이 확정됨. 메모리에 저장되지 않음.
    → const는 let처럼 지역적으로도 가능하지만 항상 값이 바뀌지 않음
  3. STATIC_VALUE는 static
    → 프로그램이 끝날 때까지 살아있는 고정 메모리 주소에 저장됨
    → 전역 변수처럼 사용 가능
    → 다만static mut은 unsafe하게 써야 함

다. 비교표

항목값이 고정됨변경 가능성메모리 위치사용 시기
literal스택 또는 인라인코드 내 직접
const없음 (값 인라인)컴파일 시
static❌ (기본)전역 메모리런타임 전체 기간

라. static mut의 대체 수단

static mut COUNTER: i32 = 0;

fn main() {
unsafe {
COUNTER += 1;
println!("counter: {}", COUNTER);
}
}
  • static mut은 동시성 문제 때문에 unsafe로 접근해야 함

Rust의const와 static은 값이 컴파일 타임에 반드시 결정되어야 하는데,

다음처럼 런타임 정보나 복잡한 로직으로 초기화해야 하는 경우에는 문제가 생깁니다:

static MY_STRING: String = String::from("hello"); // ❌ 에러!

🚫String은 컴파일 타임에 생성할 수 없기 때문에 static으로 직접 초기화 불가능

이럴 때 쓰는 게 바로 아래 세 가지입니다:

(1) const fn

  • const fn은 컴파일 타임에 실행 가능한 함수를 정의합니다.
  • const 상수나 const 표현식에서 사용할 수 있음
const fn square(x: i32) -> i32 {
x * x
}

const RESULT: i32 = square(4); // ✅ OK!

📌 단점: const fn은 제한이 많아서, String, Vec, 파일읽기 등은 못 씀


(2) lazy_static (deprecated 경향 있음)

  • 복잡한 값도 런타임 최초 1회 초기화 후 전역처럼 사용 가능
  • macro_use가 필요하고, 내부적으로 unsafe와 Mutex를 씀
#[macro_use]
extern crate lazy_static;

use std::collections::HashMap;

lazy_static! {
static ref CONFIG: HashMap<&'static str, i32> = {
let mut m = HashMap::new();
m.insert("threshold", 10);
m
};
}

fn main() {
println!("threshold = {}", CONFIG["threshold"]);
}

✅ 장점: 복잡한 초기화 가능
⚠️ 단점: 오래된 방식이고 macro 기반, 무거움


(3) once_cell (추천)

  • 최신 Rust 커뮤니티에서는 lazy_static 대신 once_cell을 많이 씁니다.
  • 런타임 최초 1회 초기화, macro 없이, 더 가볍고 안전
use once_cell::sync::Lazy;
use std::collections::HashMap;

static CONFIG: Lazy<HashMap<&'static str, i32>> = Lazy::new(|| {
let mut m = HashMap::new();
m.insert("threshold", 10);
m
});

fn main() {
println!("threshold = {}", CONFIG["threshold"]);
}

✅ 장점: lazy_static보다 깔끔하고 안전
🔒 sync::Lazy: 여러 스레드에서 안전하게 사용 가능
🧵 unsync::Lazy: 단일 스레드용으로 더 빠름


(4) const, static 등 비교표

방식초기화 시점복잡한 타입 가능스레드 안전성비고
const컴파일 타임❌ (기본 타입만)N/A빠르고 간단
static컴파일 타임❌ (기본 타입만)가능전역 메모리 사용
const fn컴파일 타임제한적가능복잡한 초기화 불가능
lazy_static런타임 (1회만)무겁고 오래된 방식
once_cell런타임 (1회만)✅ or ❌최신 방식, 매크로 없이 가능

(5) 요약

  • 간단한 상수는 const
  • 전역 정적 값은 static
  • 복잡한 초기화가 필요한 전역 값은 once_cell::Lazy 추천

Rust 프로젝트 구조 및 배포

Rust 프로젝트가 커짐에 따라 모듈화와 구조화, 그리고 최종적으로 바이너리 또는 라이브러리로 배포하는 과정이 중요해집니다. 이번에는 Rust 프로젝트를 어떻게 구성하고, 빌드하며, 배포하는지에 대해 알아보겠습니다.


1. Rust 프로젝트 구조

Rust는 기본적으로 cargo를 사용하여 프로젝트를 관리합니다. 일반적인 프로젝트 구조는 다음과 같습니다:

my_project/
├── Cargo.toml
├── Cargo.lock
├── src/
│ ├── main.rs
│ └── lib.rs
├── tests/
│ └── integration_test.rs
├── benches/
│ └── benchmark.rs
└── examples/
└── hello.rs

✅ 주요 구성 요소 설명

  • Cargo.toml: 프로젝트의 메타데이터, 의존성 등을 관리하는 설정 파일입니다.
  • src/main.rs: 실행 가능한 바이너리 프로그램의 진입점입니다.
  • src/lib.rs: 라이브러리 crate의 코드가 들어가는 곳입니다. 모듈화된 코드 작성 시 여기에 구현합니다.
  • tests/: 통합 테스트를 위한 디렉터리입니다.
  • benches/: 성능 벤치마크용 테스트가 위치합니다.
  • examples/: 사용 예제를 담는 폴더로, cargo run –example 예제이름으로 실행할 수 있습니다.

2. 모듈과 라이브러리 구조

기본적인 모듈 구성

my_project/
├── Cargo.toml
└── src/
├── main.rs // 실행 가능한 바이너리 프로그램
├── lib.rs // 라이브러리로 정의된 코드
└── utils.rs // lib.rs에서 공개하는 하위 모듈

프로젝트명은 my_project이고, 그 아래 Cargo.toml과 src 폴더가 있고,
src 폴더에 main.rs, lib.rs, utils.rs가 있습니다.

  • main.rs는 fn main()을 통해 프로그램을 실행하는 진입점(entry point)
  • lib.rs는 모듈과 함수들을 정의하고 외부에 노출하는 라이브러리
  • utils.rs는 lib의 서브 모듈로 기능 분리용

가. utils.rs

pub fn add(a: i32, b: i32) -> i32 {
a + b
}
  • add 함수는 두 정수를 더해서 반환하는 간단한 유틸 함수입니다.
  • pub으로 선언했기 때문에 이 함수는 외부 모듈 (lib.rs나 main.rs)에서도 사용할 수 있습니다.

나. lib.rs

pub mod utils;

pub fn greet(name: &str) {
println!("Hello, {}!", name);
}
  • pub mod utils;
    → src/utils.rs 파일을 모듈로 포함함.
    utils 모듈이 공개(pub)되었기 때문에 외부에서 사용 가능.
  • pub fn greet(name: &str)
    → greet 함수도 공개되어 있어서 외부 crate나 main.rs에서 사용할 수 있음.

다. main.rs

use my_project::greet;
use my_project::utils::add;

fn main() {
greet("Rust");
println!("2 + 3 = {}", add(2, 3));
}
  • use my_project::greet;
    → lib.rs에서 정의된 greet 함수를 가져와 사용함.
  • use my_project::utils::add;
    → lib.rs가 공개한 utils 모듈을 통해 add 함수를 가져옴.
  • main() 함수에서는
    greet(“Rust”)를 호출해서 Hello, Rust!를 출력하고,
    add(2, 3)의 결과 5를 출력합니다.

라. Cargo 관점에서

Cargo.toml에는 특별한 설정 없이도 다음이 자동 처리됨:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"
  • lib.rs는 my_project라는 이름의 라이브러리 크레이트로 인식됨
  • main.rs는 실행 파일용 바이너리 크레이트로 인식됨

따라서 main.rs에서 my_project::를 통해 lib.rs의 함수와 모듈을 가져올 수 있는 것임.


마. 빌드 및 실행

cargo run

실행하면 아래와 같이 출력됩니다.

Hello, Rust!
2 + 3 = 5

사. 요약

파일역할주요 내용
lib.rs라이브러리 크레이트greet 함수 정의, utils 모듈 공개
utils.rs하위 모듈add 함수 정의
main.rs실행 크레이트greet와 add 호출하여 메시지 출력

이 구조는 라이브러리를 재사용하거나 다른 프로젝트에 공유하고 싶을 때 매우 적합하며, 기능 분리모듈화에도 강력한 패턴입니다.


3. 패키지와 워크스페이스

여러 crate를 하나의 프로젝트로 묶고싶다면 workspace 기능을 사용할 수 있습니다.

[workspace]
members = [
"core_lib",
"cli_tool",
]

이 구조에서는 core_lib는 공통 로직을 담고, cli_tool은 실행 바이너리를 담당할 수 있습니다. 모노레포(Monorepo) 방식으로 관리하기에 유리합니다.

Cargo.toml 파일에 [workspace] 섹션을 추가하여 workspace를 정의하는데, members 필드에 각 하위 프로젝트의 경로를 지정합니다.

모노레포는 멀티레포와 대비되는 개념으로, 여러 하위 프로젝트들을 하나의 저장소에서 관리하는 것을 말하며, 이렇게 하면 코드 공유, 재사용, 일관된 빌드 및 테스트 설정 등을 효율적으로 관리할 수 있습니다. 일반적으로 Cargo를 사용하여 모노레포를 구성합니다. 
* 레포는 Repository의 약어

4. 빌드 및 최적화

기본 빌드

cargo build
  • 기본 빌드 모드에서는 target/debug/에 실행 파일이 생성됩니다.

릴리즈 빌드 (최적화 포함)

cargo build --release
  • 릴리즈 모드에서는 최적화가 적용되며, target/release/에 실행 파일이 생성됩니다.
  • 실제 배포 시에는 릴리즈 모드를 사용하는 것이 일반적입니다.

5. 바이너리 배포

Rust는 정적 바이너리 생성을 기본으로 하므로, 종속성 없는 실행 파일을 만들 수 있습니다.

가. 윈도우/리눅스/맥용 빌드

# 리눅스용으로 크로스 컴파일
cargo build --release --target x86_64-unknown-linux-gnu

# 윈도우용
cargo build --release --target x86_64-pc-windows-msvc

cargo build –release –target x86_64-unknown-linux-gnu실행 시
note: the x86_64-unknown-linux-gnu target may not be installed
라고 표시되면
rustup target add x86_64-unknown-linux-gnu를 통해 target을 먼저 설치해야 합니다.

나. 크로스 컴파일

  • cross 크레이트를 사용하면 플랫폼별 크로스 컴파일을 간편하게 할 수 있습니다.
    cargo install cross
    cross build –target x86_64-unknown-linux-gnu –release
  • 위 명령어를 실행하면 docker 또는 podman이 설치되어 있는지 묻습니다. 따라서, docker 등을 설치하고 그 안에서 위 명령을 실행해야 합니다.

다. 실행 파일 위치

  • 기본 빌드: target/debug/프로젝트명
  • 릴리즈 빌드: target/release/프로젝트명

이 파일을 원하는 디렉토리나 서버로 옮기면 됩니다.


6. Crates.io에 라이브러리 배포

만약 자신이 만든 라이브러리를 다른 사람들과 공유하고 싶다면, crates.io에 등록할 수 있습니다.

가. 준비 단계

  • https://crates.io에 계정을 만들고 API Token을 생성합니다.
  • ~/.cargo/credentials에 토큰을 저장합니다.
  • Cargo.toml에 필수 정보를 입력합니다:
[package]
name = "my_crate"
version = "0.1.0"
authors = ["홍길동 <email@example.com>"]
edition = "2021"
license = "MIT OR Apache-2.0"
description = "간단한 수학 라이브러리"
repository = "https://github.com/username/my_crate"

나. 배포 명령

cargo publish
  • 첫 배포 전에는 cargo package로 정상 패키징이 되는지 테스트해보는 것이 좋습니다.

7. CI/CD 통합

CI/CD는 지속적인 통합(Continuous Integration)과 지속적인 제공/배포(Continuous Delivery/Deployment)를 의미하며, 소프트웨어 개발 및 배포 과정을 자동화하여 효율성과 속도를 높이는 방법론입니다. CI/CD는 개발자가 코드 변경 사항을 주기적으로 메인 저장소에 통합하고, 테스트를 거쳐 자동으로 배포될 수 있도록 합니다. 

프로젝트가 커지면 GitHub Actions나 GitLab CI와 같은 CI 도구를 통해 자동으로 테스트와 빌드, 배포까지 설정할 수 있습니다.

예: .github/workflows/rust.yml

name: Rust

on: [push]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
- run: cargo build --release
- run: cargo test

8. 마무리

Rust 프로젝트는 명확한 구조와 cargo의 강력한 관리 도구 덕분에 규모가 커져도 효율적으로 관리할 수 있습니다. 프로젝트 구조를 잘 나누고, 최적화된 빌드를 통해 빠르고 안정적인 배포 환경을 만드는 것이 중요합니다.

매크로(Macro)와 메타 프로그래밍

Rust는 정적 타입 언어이면서도 매우 강력한 매크로 시스템을 제공합니다. 매크로는 코드를 작성하는 코드를 작성할 수 있게 해 주며, 반복되는 코드의 중복을 줄이고, 컴파일 타임에 코드를 생성하여 성능 저하 없이 유연성을 확보할 수 있습니다.

Rust의 매크로는 크게 두 가지로 나뉩니다:

  • 매크로 by 예시 (macro_rules!)
  • 절차적 매크로 (Procedural Macros)

이번 시간에는 두 매크로의 차이점과 사용법, 그리고 메타 프로그래밍의 개념에 대해 알아보겠습니다.


1. macro_rules! 기본 매크로

Rust에서 가장 널리 사용되는 매크로는 macro_rules!로 작성하는 선언형 매크로(declarative macro)입니다.

가. 예시

macro_rules! say_hello {
() => {
println!("Hello, macro!");
};
}

fn main() {
say_hello!(); // Hello, macro!
}

이 매크로는 함수처럼 보이지만, 실제로는 코드를 치환하는 역할을 합니다. 괄호 안에 아무 인자도 없을때, println! 코드를 삽입하는 구조입니다.

  • macro_rules!는 Rust에서 매크로를 정의하는 키워드입니다. 함수와 비슷해 보이지만, 컴파일 타임에 코드 조각을 만들어주는 메타 프로그래밍 도구입니다.
  • say_hello라는 이름의 매크로를 정의합니다.
    ()는 이 매크로가 인자를 받지 않음을 의미합니다.
    => { … }는 매크로가 어떤 코드로 확장될지를 정의합니다.
    여기서는 println!(“Hello, macro!”);라는 코드를 삽입합니다.
    즉, 이 매크로는 호출되면 println!을 실행하는 코드로 치환됩니다.
  • say_hello!();는 매크로를 호출하는 문법입니다.
    함수 호출과 다르게 ! 기호가 사용됩니다.
  • 결과적으로 Hello, macro!가 출력됩니다.

나. 매크로는 함수보다 더 유연하다.

  • 함수는 특정 타입에 대해 정의되어야 하지만,
  • 매크로는 타입과 관계없이 패턴으로 처리할 수 있습니다.
macro_rules! create_function {
($name:ident) => {
fn $name() {
println!("함수 {} 호출됨", stringify!($name));
}
};
}

create_function!(foo);
create_function!(bar);

fn main() {
foo(); // 함수 foo 호출됨
bar(); // 함수 bar 호출됨
}

위의 매크로는 여러 개의 함수를 자동으로 생성하는 예시입니다.

(1) 매크로 정의

macro_rules! create_function {
($name:ident) => {
fn $name() {
println!("함수 {} 호출됨", stringify!($name));
}
};
}
  • macro_rules! create_function: create_function이라는 이름의 매크로를 정의합니다.
  • ($name:ident) => { … }: 이 매크로는 식별자 하나를 입력 인자로 받아 내부에서 함수 하나를 생성합니다.
    ident: 식별자(identifier) 타입을 의미합니다. 변수명, 함수명 같은 이름을 받을 때 사용합니다.니다.
    • ident: 식별자(identifier) 타입을 의미합니다. 변수명, 함수명 같은 이름을 받을 때 사용합니다.
  • fn $name() { … }: 입력받은식별자 $name을 함수 이름으로 사용하여 새 함수를 만듭니다.
  • stringify!($name): 식별자 $name을 문자열로바꿔줍니다.
    • 예: stringify!(foo) → “foo”

(2) 매크로 호출

create_function!(foo);
create_function!(bar);
  • 이 부분은 위에서 정의한 매크로를 호출하는 부분입니다.
  • create_function!(foo)는 foo를 인수로 받아 아래와 같은 함수를 만들어냅니다:
fn foo() {
println!("함수 foo 호출됨");
}
  • create_function!(bar)도 마찬가지로 bar를 인수로 받아 아래와 같은 함수를 만들어냅니다:
fn bar() {
println!("함수 bar 호출됨");
}

즉, 이 두줄 덕분에 foo와 bar라는 이름의 함수가 자동으로 생성됩니다.


(3) main 함수 – 함수 호출

fn main() {
foo(); // 함수 foo 호출됨
bar(); // 함수 bar 호출됨
}
  • 앞서 매크로를통해 생성된 foo()와 bar() 함수를 호출합니다.
  • 각각의 함수는 다음과 같은 출력을 합니다:
함수 foo 호출됨
함수 bar 호출됨

2. 메타 프로그래밍(Metaprogramming)이란?

Rust에서 매크로를 사용하는 이유는 곧 메타 프로그래밍을 위한 것입니다. 메타 프로그래밍이란 프로그램이 프로그램 코드를 다루거나 생성하는 것을 말합니다.

Rust에서는 컴파일 타임에 코드를 생성하여

  • 코드 반복 제거
  • 에러 감소
  • 성능 저하 없이 추상화 제공

이라는 장점을 누릴 수 있습니다.


3. 절차적 매크로(Procedural Macro)

macro_rules!는 구조가 제한적이므로 복잡한 로직을 처리하기엔 어렵습니다. 이를 해결하기 위해 Rust는 절차적 매크로를 제공합니다. 절차적 매크로는 함수처럼 동작하며, 다음과 같이 세 가지 유형이 있습니다:

  1. Derive 매크로 (#[derive])
  2. Attribute 매크로 (#[route], #[test] 등)
  3. Function-like 매크로 (my_macro!(…) 형태)

가. Derive 매크로

Rust는 많은 표준 트레잇을 #[derive(…)]를 통해 자동으로 구현할 수 있습니다.

#[derive(Debug, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}

#[DERIVE(…)]

이 부분은 파생 구현(derive macro)라고 부릅니다. Rust에서는 구조체에 대해 자주 사용하는 trait들을 자동으로 구현할 수 있도록 해줍니다.

* Debug
  • 구조체를 {:?} 형식으로 출력할 수 있게 해줍니다.
  • 예: println!(“{:?}”, p); // 출력: Point { x: 3, y: 5 }
*Clone
  • 구조체를 복사(clone) 할 수 있게 해줍니다.
  • let p2 = p.clone(); 같이 사용 가능하며, 깊은 복사가 이루어집니다.
  • let p2 = p; 이라고 하면 Copy가 아니기 때문에 p는 이동(move)되고, 사용 불가가 되므로 clone을 사용하는 것입니다.
* PartialEq
  • 두 구조체가 같은지 비교할 수 있게 해줍니다 (==, != 사용 가능).
  • 예:
    let a = Point { x: 1, y: 2 };
    let b = Point { x: 1, y: 2 };
    assert_eq!(a, b); // true

나. Attribute 매크로

Attribute 매크로는 함수, 구조체 등 앞에 붙여 동작을 변경합니다.

#[route(GET, "/")]
fn index() {
// 라우트 처리
}
🔸 #[route(GET, “/”)]

Rocket 프레임워크에서 제공하며, 해당 함수가 HTTP 요청을 어떻게 처리할지를 지정합니다.

  • GET → HTTP 메서드 GET, POST, PUT, DELETE 등 중 하나입니다.
  • “/” → 경로. 이 경우 루트 경로(예: http://localhost:8000/)입니다.

🔸 fn index()

이것은 실제 요청을 처리할 함수입니다.
보통 이 함수는 -> &’static str이나 -> Html<String> 같은 반환값을 갖습니다.
index는 함수 이름으로, 자유롭게 바꿀 수 있습니다 (home, root 등).
이 함수는 라우팅된 요청이 들어왔을 때 호출됩니다.

위 코드는 다음 의미를 갖습니다:

  • 클라이언트가 GET / 요청을 보냈을 때,
  • index() 함수를 실행해서 그 요청을 처리한다.

다. Function-like 매크로

마치 함수처럼 사용하는 매크로입니다.

my_macro!(input);

일반적으로 다음처럼 정의합니다.

#[proc_macro]
pub fn my_macro(input: TokenStream) -> TokenStream {
// input을 파싱하고 새로운 코드 생성
}
  • pub fn my_macro(input: TokenStream) -> TokenStream
    my_macro는 매크로 이름입니다. 실제 사용 시에는 my_macro!(…) 형태로 사용됩니다.
    입력: input: TokenStream
    출력: TokenStream
  • 즉, 입력으로 받은 코드를 읽고, 새로운 코드로 변환하는 함수입니다.

4. 매크로의 장단점

가. 장점

  • 코드 중복 제거
  • 성능 저하 없이 추상화 가능
  • 정적 분석 기반으로 안전성 확보

나. 단점

  • 디버깅이 어려움
  • 복잡한 로직일수록 가독성 저하
  • IDE의 지원이 제한적일 수 있음

5. 매크로 관련 도구

  • syn: Rust 코드를 파싱하는 라이브러리
  • quote: 코드 생성을 위한 DSL(Domain Specific Languages)
  • proc-macro2: proc_macro를 확장한 안정적인 인터페이스
[dependencies]
syn = "2"
quote = "1"
proc-macro2 = "1"

6. 마무리

Rust의 매크로 시스템은 다른 언어의 템플릿이나 메타 프로그래밍 기능보다 훨씬 강력하고 안전하게 설계되어 있습니다. macro_rules!로 간단한 코드 생성을 처리하고, 절차적 매크로로 복잡한 로직도 커버할 수 있습니다.

메타 프로그래밍은 러스트의 안전성과 추상화를 동시에 만족시키는 중요한 도구이므로, 초반에는 어렵더라도 반드시 익혀야 할 개념입니다.

Rust에서 테스트(test) 작성하기

Rust는 안정성과 신뢰성을 강조하는 언어답게 테스트를 매우 중요하게 여깁니다. 표준 라이브러리에는 테스트를 쉽게 작성하고 실행할 수 있도록 test 프레임워크가 기본 내장되어 있으며, cargo test 명령어로 손쉽게 실행할 수 있습니다. 오늘은 단위 테스트, 통합 테스트, 그리고 테스트 관련 어노테이션 및 모범 사례에 대해 알아보겠습니다.


1. 기본적인 단위 테스트 구조

Rust의 테스트는 일반적으로 소스 파일 내에 다음과 같은 구조로 작성됩니다.

// 실제 함수
pub fn add(a: i32, b: i32) -> i32 {
a + b
}

// 테스트 모듈
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
  • #[cfg(test)]: 이 모듈은 테스트 시에만 컴파일되도록 지정합니다.
  • mod tests { … }: 테스트용 하위 모듈을 의미합니다. 실제 코드와 분리된 테스트 코드입니다.
  • use super::*;: 상위 모듈(super)의 add 함수를 불러오는 것입니다. 이 줄을 주석처리하면 “add함수가 없다”고 합니다.
  • #[test]: 테스트 함수임을 표시합니다.
  • test_add 함수를 만들어서 add(2, 3)의 결과와 5가 같은지 비교해서 맞으면 ok, 다르면 failed가 발생합니다.
    assert_eq!: 기대값과 실제값이 같은지 검사합니다.

가. 실행 방법

cargo test

cargo는 프로젝트 전체를 빌드하고 #[test] 함수들을 자동으로 실행합니다.

나. lib.rs 파일 작성 및 실행

(1) assert_eq!의 결과 두 값이 같은 경우

src 폴더 아래에 lib.rs란 파일로 작성했습니다.

Rust의 test 모듈 작성 및 실행 방법

day21 폴더에서 cargo test를 실행하니(VS Code를 보면 함수 위에 Run Test가 있으므로 이걸 눌러도 됨)
컴파일되고 실행되는데 먼저 unittests src\lib.rs라고 표시되어 lib.rs의 test annotation을 먼저 실행하고, 그 다음은 main.rs를 실행합니다.

lib.rs의 테스트 결과는
test tests::test_add … ok라고 표시되고,
그 아래 test result: ok. 1 passed; 0 failed… 등 내용이 표시되는데,

main.rs의 테스트 결과는 test 어노테이션이 없어서 0으로 표시됩니다.

그리고, assert_eq! 구문만 실행되고, main.rs에 있는 println!(“Hello, world!”);문은 실행되지 않습니다. lib.rs에 println!문이 있어도 실행되지 않습니다.

cargo test –lib라고 하면 lib.rs의 test annotation만 실행하고, main.rs에 test 어노테이션이 있는지 체크하지 않습니다.

(2) assert_eq!의 결과 두 값이 다른 경우

asserteq!의 실패시 panic 및 살패에 대한 설명문

add(2, 3)의 결과는 5인데, 6과 비교하면 두 값이 다르므로 FAILED라고 결과가 표시되고,

그 아래를 보면 thread ‘tests::test_add’ panicked at day21\src\lib.rs:12:9:라고 panic이 발생했고,
왼쪽은 5, 오른쪽은 6으로 “왼쪽과 오른쪽이 같다는 단언(주장)이 실패했다”고 설명합니다.


2. 다양한 assert 매크로

Rust에서는 다음과 같은 다양한 테스트 도구를 제공합니다.

매크로설명
assert!조건이 true인지 확인
assert_eq!두 값이 같은지 확인
assert_ne!두 값이 다른지 확인(not equal)
dbg!디버깅 용도로 값을 출력
panic!강제로 실패시키기

예제:

#[test]
fn test_assertions() {
let a = 10;
let b = 20;
assert!(a < b);
assert_eq!(a + b, 30);
assert_ne!(a, b);
}

위 코드를 cargo test –lib로 실행하면
모든 결과가 True이므로 test result: ok인데, 세 번이 아니라 한 번만 표시되고,

cargo test --lib

assert!(a > b);
assert_eq!(a + b, 40);
으로 수정해서 2개를 False로 만든 후 실행하면
a > b에서 panic이 되기 때문에 a + b와 40을 비교하는 부분은 가지도 못하고 끝납니다.

assert!가 실패하면 panic이 되므로 그 다음 assert_eq!를 실행하지 못함

따라서, a < b로 수정하고 실행하면 a + b = 40에서 실패가 발생합니다.


3. 실패하는 테스트

테스트가 실패했을 경우에는 어떤 테스트가 어떤 이유로 실패했는지 친절히 알려줍니다. 예를 들어:

#[test]
fn test_fail() {
assert_eq!(1 + 1, 3); // 실패
}

실행 시 다음과 같은 메시지가 출력됩니다.
“왼쪽은 2인데, 오른쪽은 3으로 왼쪽과 오른쪽이 같지 않다”고 합니다.

---- test_fail stdout ----
thread 'test_fail' panicked at day21\src\lib.rs:27:5:
assertion `left == right` failed
left: 2
right: 3

4. 테스트 함수에서 panic이 발생하면?

Rust에서는 테스트 함수가 panic을 일으키면 테스트가 실패한 것으로 간주됩니다. 일부러 panic을 유발하는 테스트는 다음처럼 작성할 수 있습니다:

#[test]
#[should_panic]
fn test_panic() {
panic!("강제로 실패시킴");
}
  • #[should_panic]: 이테스트는 panic이 발생해야 성공으로 간주됩니다.
테스트 함수에서 panic이 발생하면 : 
#[should_panic]: panic이 발생해야 ok

5. 결과 반환형을 이용한 테스트

테스트 함수가 Result<(), E>를 반환할 수도 있습니다.

#[test]
fn test_with_result() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("'2 + 2는 4'가 아닙니다!"))
}
}

가. Ok(성공)인 경우

successes:
test_with_result
라고 test_with_result가 성공임을 표시합니다.

Result를 이용한 결괏값 반환

나. 에러인 경우

failures에 Error: “‘2 + 2는 4’가 아닙니다!”라고 표시됩니다.

Result 결괏값이 Err인 경우

이 방식은 ? 연산자를 활용할 수 있어 더 깔끔하게 작성할 수 있습니다.

다. ? 연산자를 이용한 구문

fn check_math() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("'2 + 2는 4'가 아닙니다!"))
}
}

#[test]
fn test_with_result() -> Result<(), String> {
check_math()?; // 실패하면 즉시 반환됨
Ok(())
}

6. 테스트 분류

가. 단위 테스트 (Unit Test)

  • 하나의 함수나 모듈의 기능을 검사
  • 일반적으로 동일한 파일 내 mod tests 안에 작성

나. 통합 테스트 (Integration Test)

  • 실제 사용 시나리오처럼 여러 모듈이 함께 작동하는지를 테스트
  • tests/ 디렉터리 하위에 별도의 .rs 파일로 작성

예:

my_project/
├── src/
│ └── lib.rs
└── tests/
└── integration_test.rs
// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}

// tests/integration_test.rs
use my_project::add;

#[test]
fn test_add_integration() {
assert_eq!(add(10, 20), 30);
}

위 integration_test.rs의 use문에 my_project가 있는데, day21 등으로 프로젝트명에 맞게 수정해야 합니다.

cargo test를 실행하면 src폴더의 lib.rs와 main.rs뿐만 아니라, tests폴더의 integration_test.rs까지 test가 있는지 찾아서 test를 진행하고, day21 폴더의 Doc-tests도 진행합니다.

통합 테스트 - doctest 포함

그러나, tests 폴더의 integration_test.rs에서 Run Test를 누르면
integration_test.rs의 test만 실행합니다.

vs code의 Run Test 명령

7. #[ignore]와 특정 테스트만 실행하기

테스트가 너무 오래 걸리거나 특별한 조건에서만 실행하고 싶을 때는 #[ignore] 어노테이션을 붙일 수 있습니다.

#[test]
#[ignore]
fn long_running_test() {
// 오래 걸리는 테스트
}

cargo test 실행 시 무시되며,
test long_running_test … ignored

#[ignore] - 테스트시 제외

아래와 같이 실행하면 #[ignore]가 붙은 테스트만 실행합니다.

cargo test -- --ignored

위 코드를 실행하면 1 passed; … 1 filtered out;이라고 표시되는데, long_running_test는 통과되고, test_add_integration은 제외되었다는 뜻입니다.

#[ignore]가 붙은 테스트만 실행

특정 테스트만 실행하려면 이름을 지정합니다:

cargo test test_add

8. 테스트 모범 사례

  • 작고 독립적인 테스트를 작성하세요.
  • 각 테스트는 부작용이 없어야 합니다 (예: 파일 시스템 접근, DB 쓰기 등).
  • 테스트 함수 이름은 명확하게 지어야 합니다(test_add, test_divide_by_zero 등).
  • 테스트는 문서화 주석과 함께 유지하면 가독성이 높아집니다.

9. 문서화 테스트 (doctest)

Rust는 문서 주석(///) 안에 포함된 코드도 테스트로 실행합니다.

/// 두 수를 더합니다.
///
/// # 예시
/// ```
/// let result = my_crate::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}

cargo test를 실행하면 이 문서 예제도 자동으로 테스트합니다.

my_crate는 프로젝트명에 맞게 수정해야 하는데, day21 프로젝트이므로 day21로 수정했습니다.

위에서는 Doc-tests시 test 개수가 0이었는데, 여기는 1로 표시되고, ‘add (line 48) … ok’라고 표시됩니다.

doctest

10. 마무리

Rust의 테스트 프레임워크는 매우 강력하면서도 간단하게 사용할 수 있습니다. 실수를 미리 잡아내고 코드를 문서화하며 안정성을 높이기 위해 테스트는 필수적인 도구입니다.

외부 라이브러리 사용법 (Crate 활용)

Rust는 Crate(크레이트) 단위로 코드와 라이브러리를 구성합니다.
크레이트는 Rust의 패키지 시스템에서 가장 작은 단위로, 우리가 Cargo.toml에 추가해서 사용하는 외부 라이브러리들도 모두 크레이트입니다.

크레이트의 기본 개념과 사용법, 외부 라이브러리를 프로젝트에 추가하고 사용하는 법, 그리고 버전 관리와 의존성에 대해 알아보겠습니다.


1. Crate란?

  • 크레이트(Crate)는 하나의 컴파일 단위입니다.
  • 두 가지로 나뉩니다:
    Binary Crate: main() 함수를 포함하고, 실행 가능한 프로그램이 됨
    Library Crate: 다른 크레이트에서 가져와 사용할 수 있는 재사용 가능한 코드 집합

예를 들어 우리가 사용하는 tokio, serde, rand 등은 모두 라이브러리 크레이트입니다.


2. Cargo로 외부 크레이트 사용하기

Rust 프로젝트는 Cargo라는 빌드 도구를 중심으로 관리됩니다.

가. Cargo.toml에 의존성(dependency) 추가

예를 들어 난수를 생성하는 rand 크레이트를 사용하려면, 프로젝트 루트의 Cargo.toml 파일의 dependencies 절에 다음과 같이 작성합니다.

처음에 버전을 0.8.0을 적었더니 버전이 0.9.1까지 있으니 여기서 선택하라고 화면이 표시됩니다.

dependency  더 높은 버전 제시
[dependencies]
rand = "0.9.1"

이제 cargo build 또는 cargo run을 실행하면 자동으로 해당 크레이트가 다운로드되고 프로젝트에 포함됩니다.

나. 코드에서 사용하기

use rand::Rng;

fn main() {
let mut rng = rand::rng();
let n: u8 = rng.random_range(1..=10);
println!("1부터 10 사이의 무작위 수: {}", n);
}
use rand::Rng;
  • Rng는 random_range() 같은 메서드를 제공하는 트레잇(trait)입니다.
  • 이걸 use해야 random_range(…) 같은 메서드를 쓸 수 있습니다.

let mut rng = rand::rng();
  • 최신 rand에서는 rand::rng()로 난수 생성기를 가져옵니다.
  • 이전에는 rand::thread_rng()를 사용했지만, 그건 deprecated 되었고 이제는 rng()로 대체됩니다. rand 버전을 0.8로 하면 아래와 같은 에러가 발생합니다.
crate 버전이 낮아 오류 발생
  • 반환 타입은 여전히 내부적으로 ThreadRng입니다.

📝 의미:

“현재 스레드에서 사용할 난수 생성기를 가져와서 rng에 저장하라”


let n: u8 = rng.random_range(1..=10);
  • random_range(1..=10)은 1부터 10까지의 정수 중 무작위 값을 생성합니다.
  • inclusive range (..=)를 사용했으므로, 10도 포함됩니다.
  • 반환되는 값은 u8 타입으로 명시적으로 지정했습니다.
  • random_range는 gen_range() 대신 사용되는 최신 방식입니다.

println!(“1부터 10 사이의 무작위 수: {}”, n);
  • 생성된 난수 n을 콘솔에 출력합니다.
  • 실행할 때마다 1~10 사이의 숫자가 무작위로 나옵니다.


3. 크레이트 문서 확인하기

모든 주요 크레이트는 문서를 잘 갖추고 있습니다.
https://docs.rs에서 크레이트 이름으로 검색하면 API 문서를 확인할 수 있습니다.

예: https://docs.rs/rand

문서에는 모듈 구조, 사용 예제, Trait 설명 등이 포함되어 있어 매우 유용합니다.


4. 크레이트 버전 지정

Cargo는 Semantic Versioning을 따릅니다.

  • rand = “0.9” → 0.9.x까지 자동 업데이트 (1.0은 제외)
  • rand = “=0.9.1” → 정확한 버전
  • rand = “>=0.9, <10.0” → 범위 지정

보통은 “0.9”와 같이 호환 가능한 최신 버전으로 표시하는 것이 일반적입니다.


5. 여러 크레이트 함께 사용하기

Rust 프로젝트는 여러 외부 크레이트를 함께 사용할 수 있습니다.

예:serde와 serde_json을 함께 사용하여 JSON을 파싱:

[Cargo.toml]

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

[main.rs]

use serde::{Deserialize, Serialize};
use serde_json::Result;

#[derive(Serialize, Deserialize, Debug)]
struct Person {
name: String,
age: u8,
}

fn main() -> Result<()> {
let data = r#"{"name": "홍길동", "age": 30}"#;
let p: Person = serde_json::from_str(data)?;
println!("{:?}", p);
Ok(())
}

가. use 키워드

use serde::{Deserialize, Serialize};
use serde_json::Result;
  • Rust의 use 키워드는 코드에서 다른 모듈이나 라이브러리의 특정 항목(함수, 구조체, 열거형 등)을 현재 스코프(범위)로 가져와 사용하기 위해 사용됩니다. 이를 통해 코드를 더 간결하고 읽기 쉽게 만들 수 있습니다. 
  • serde::{Deserialize, Serialize}: 구조체를 직렬화(Serialize) 또는 역직렬화(Deserialize) 하려면 이 트레잇이 필요합니다.
  • serde_json::Result: serde_json이 제공하는 Result 타입(Enum)을 사용합니다. 에러 처리를 쉽게 하기 위해 사용됩니다.

나. 구조체 정의

#[derive(Serialize, Deserialize, Debug)]
struct Person {
name: String,
age: u8,
}
  • [derive(…)]: 구조체에 자동으로 트레잇 구현을 추가합니다.
    – Serialize: 구조체를 JSON으로 변환할 수 있게 함.
    – Deserialize: JSON을 구조체로 변환할 수 있게 함.
    – Debug: println!(“{:?}”, …)으로 구조체 내용을 출력할 수 있게 함.
  • Person 구조체 정의
    – name: 문자열 타입
    – age: u8(0~255 정수 타입)

다. main 함수

fn main() -> Result<()> {
  • 반환 타입이Result<()>인 이유는 serde_json::from_str가 실패할 수 있기 때문입니다. 실패하면 에러를 리턴하고, 성공하면 Ok(())를 반환합니다.

라. JSON 문자열 → 구조체

let data = r#"{"name": "홍길동", "age": 30}"#;
  • r#”…#”는 raw string(원시 문자열) 문법입니다. 문자열 내부에 따옴표가 있어도 별도로 이스케이프할 필요가 없어 편리합니다.
  • {“name”: “홍길동”, “age”: 30}는 JSON 포맷 문자열입니다.
let p: Person = serde_json::from_str(data)?;
  • serde_json::from_str는 JSON 문자열을 파싱해서 Person 구조체로 변환합니다.
  • ? 연산자는 실패 시 에러를 리턴하고, 성공 시 결과값을 반환합니다.

마. 구조체 출력

println!("{:?}", p);
  • 구조체 p를 Debug 포맷으로 출력합니다. 결과는 예를 들어 다음과 같이 나옵니다.
    Person { name: “홍길동”, age: 30 }

6. Crate.io에서 크레이트 찾기

외부 라이브러리는 모두 https://crates.io에서 검색할 수 있습니다.

  • 인기 순, 다운로드 수, 최근 업데이트 순으로 정렬 가능
  • 사용자 리뷰, 문서 링크, GitHub 코드도 확인 가능

예:

  • 웹 요청 라이브러리: reqwest
  • 비동기 실행기: tokio
  • CLI 도구 만들기: clap

7. 크레이트 네임스페이스(namespace)와 모듈(module)

크레이트를 임포트할 때는 보통 최상위 네임스페이스부터 시작합니다.

use regex::Regex; // regex 크레이트 내 Regex 타입 사용

내부 모듈에 접근할 땐 점(::)을 따라 구조를 확인합니다.

예:

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

8. 주의사항

  • 크레이트마다 버전이 다르면 충돌이 생길 수 있음 → Cargo가 자동으로 중복 조율
  • 사용하지 않는 크레이트는 지워주는 게 좋음
  • 일부 크레이트는 feature flag를 통해 기능을 선택적으로 활성화함

9. 정리

항목설명
CrateRust 코드의 재사용 단위, 외부 라이브러리
Cargo.toml의존성을 정의하는 설정 파일
crates.io외부 크레이트 검색/다운로드 플랫폼
docs.rs모든 크레이트의 공식 문서 모음 사이트
use크레이트 또는 모듈에서 항목을 가져오는(import) 키워드

10. 마무리

Rust의 크레이트 생태계는 매우 강력하고 체계적입니다.
외부 라이브러리를 잘 활용하면, 코드의 양을 줄이고 품질을 높일 수 있습니다.
이제는 필요한 기능이 있다면 직접 구현하기보다 crates.io에서 검색해보는 것이 먼저입니다.

비동기 프로그래밍 (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에서 동시성은 단지 기술이 아니라, 신뢰성과 안전성을 보장하는 언어적 철학입니다.