19 4월 2025

러스트 튜토리얼: 초보자 가이드

러스트는 개발자들의 관심을 꾸준히 받으며 스택 오버플로우 설문조사에서 여러 해 연속 “가장 사랑받는” 프로그래밍 언어로 선정되었습니다. 이는 단순한 과장이 아닙니다. 러스트는 다른 시스템 프로그래밍 언어에서 흔히 발견되는 문제점들을 해결하는 성능, 안전성, 그리고 현대적인 언어 기능들의 매력적인 조합을 제공합니다. 러스트가 특별한 이유가 궁금하고 러스트 여정을 시작하고 싶다면, 이 초보자 가이드가 시작에 필요한 기초 지식을 제공할 것입니다. 핵심 문법, 소유권과 같은 독특한 개념, 그리고 러스트 생태계를 구동하는 필수 툴링을 탐구할 것입니다.

왜 러스트 프로그래밍을 배워야 할까요?

러스트는 안정적이고 효율적인 소프트웨어를 구축하기 위한 언어로 자리매김하고 있습니다. 주요 장점은 가비지 컬렉터에 의존하지 않는 메모리 안전성과 두려움 없는 동시성을 가능하게 하는 것입니다. 러스트 학습을 고려해야 하는 이유는 다음과 같습니다:

  1. 성능: 러스트는 네이티브 기계 코드로 직접 컴파일되어 C 및 C++에 필적하는 성능을 제공합니다. 안전성을 희생하지 않으면서 이러한 속도를 달성하므로 게임 엔진, 운영 체제, 브라우저 구성 요소, 고성능 웹 서비스와 같은 성능이 중요한 애플리케이션에 적합합니다.
  2. 메모리 안전성: 러스트의 대표적인 기능은 소유권 시스템이며, 이는 빌림라이프타임으로 보완됩니다. 이 시스템은 컴파일 타임에 메모리 안전성을 보장합니다. C/C++와 같은 언어를 종종 괴롭히는 댕글링 포인터, 버퍼 오버플로우, 데이터 경쟁 등을 잊으세요. 러스트 컴파일러는 엄격한 문지기 역할을 하여 코드가 실행되기 전에 이러한 일반적인 오류를 방지합니다.
  3. 동시성: 동시성 프로그래밍(여러 작업을 동시에 실행하는 것처럼 보이는 것)은 올바르게 구현하기가 매우 어렵습니다. 러스트는 이 문제를 정면으로 해결합니다. 소유권 및 타입 시스템이 함께 작동하여 컴파일 타임에 데이터 경쟁을 방지하므로 멀티스레드 애플리케이션을 훨씬 쉽고 안전하게 작성할 수 있습니다. 이 "두려움 없는 동시성"은 개발자가 일반적인 함정 없이 최신 멀티코어 프로세서를 효과적으로 활용할 수 있게 해줍니다.
  4. 현대적인 툴링: 러스트는 핵심 경험에 통합된 뛰어난 패키지 매니저이자 빌드 도구인 Cargo와 함께 제공됩니다. Cargo는 의존성 관리, 테스팅, 빌드, 그리고 크레이트(러스트에서 패키지나 라이브러리를 의미하는 용어) 게시를 원활하게 처리하여 전체 개발 워크플로우를 간소화합니다.
  5. 성장하는 생태계 및 커뮤니티: 러스트 커뮤니티는 활기차고 활동적이며 신규 사용자에게 우호적인 것으로 알려져 있습니다. 라이브러리(크레이트) 생태계는 웹 개발(Actix, Rocket, Axum과 같은 프레임워크), 네트워킹(비동기 작업을 위한 Tokio 등), 임베디드 시스템, 데이터 과학, 명령줄 도구 등 다양한 분야를 포괄하며 빠르게 확장되고 있습니다.

러스트 프로그래밍 배우기: 필수 도구

첫 러스트 코드를 작성하기 전에 러스트 툴체인을 설정해야 합니다. 러스트를 설치하는 표준적이고 권장되는 방법은 러스트 툴체인 설치 프로그램인 rustup을 사용하는 것입니다.

  1. rustup: 이 명령줄 도구는 러스트 설치를 관리합니다. 이를 통해 다양한 러스트 버전(예: 안정 버전, 베타 버전, 나이틀리 빌드)을 설치, 업데이트하고 쉽게 전환할 수 있습니다. 사용 중인 운영 체제에 맞는 설치 지침은 공식 러스트 웹사이트(https://www.rust-lang.org/tools/install)를 방문하세요.
  2. rustc: 이것은 러스트 컴파일러입니다. .rs 파일에 러스트 소스 코드를 작성하면, rustc는 이를 컴퓨터가 이해할 수 있는 실행 바이너리나 라이브러리로 컴파일합니다. 중요하지만, 일상적인 워크플로우에서 rustc를 직접 호출하는 경우는 드뭅니다.
  3. cargo: 이것은 러스트의 빌드 시스템이자 패키지 매니저이며, 가장 자주 상호작용하게 될 도구입니다. Cargo는 많은 일반적인 개발 작업을 조율합니다:
    • 새 프로젝트 생성 (cargo new).
    • 프로젝트 빌드 (cargo build).
    • 프로젝트 실행 (cargo run).
    • 자동화된 테스트 실행 (cargo test).
    • 의존성 관리 (Cargo.toml 파일에 나열된 외부 라이브러리, 즉 크레이트를 자동으로 가져오고 컴파일).
    • 자신의 크레이트를 다른 사람들이 사용할 수 있도록 crates.io(중앙 러스트 패키지 레지스트리)에 게시.

로컬 설치 없이 빠른 실험을 원한다면, 공식 Rust 플레이그라운드Replit과 같은 통합 개발 환경과 같은 온라인 플랫폼이 훌륭한 옵션입니다.

당신의 첫 러스트 프로그램: Hello, Rust!

새로운 언어를 배울 때 통과 의례인 전통적인 “Hello, world!” 프로그램부터 시작해 봅시다. main.rs라는 파일을 만들고 다음 코드를 추가하세요:

fn main() {
    // 이 줄은 콘솔에 텍스트를 출력합니다
    println!("Hello, Rust!");
}

기본 도구를 사용하여 이 간단한 프로그램을 컴파일하고 실행하려면:

  1. 컴파일: 터미널이나 명령 프롬프트를 열고 main.rs가 포함된 디렉토리로 이동한 다음 실행합니다:
    rustc main.rs
    
    이 명령은 러스트 컴파일러(rustc)를 호출하여 실행 파일(예: 리눅스/macOS에서는 main, 윈도우에서는 main.exe)을 생성합니다.
  2. 실행: 터미널에서 컴파일된 프로그램을 실행합니다:
    ./main
    # 윈도우에서는 다음을 사용: .\main.exe
    

그러나 단일 파일을 넘어서는 모든 작업에는 Cargo를 사용하는 것이 표준적이고 훨씬 편리한 접근 방식입니다:

  1. 새 Cargo 프로젝트 생성: 터미널에서 다음을 실행합니다:
    cargo new hello_rust
    cd hello_rust
    
    Cargo는 hello_rust라는 새 디렉토리를 생성하며, 이 디렉토리에는 main.rs(이미 “Hello, Rust!” 코드로 채워져 있음)가 있는 src 하위 디렉토리와 Cargo.toml이라는 구성 파일이 포함됩니다.
  2. Cargo로 실행: 간단히 다음을 실행합니다:
    cargo run
    
    Cargo는 컴파일 단계를 처리한 다음 결과 프로그램을 실행하여 콘솔에 "Hello, Rust!"를 표시합니다.

코드 스니펫을 분석해 보겠습니다:

  • fn main(): 메인 함수를 정의합니다. fn 키워드는 함수 선언을 의미합니다. main은 특별한 함수 이름입니다. 모든 실행 가능한 러스트 프로그램이 실행을 시작하는 진입점입니다. 괄호 ()는 이 함수가 입력 매개변수를 받지 않음을 나타냅니다.
  • {}: 중괄호는 코드 블록 또는 스코프를 정의합니다. 함수에 속하는 모든 코드는 이 중괄호 안에 들어갑니다.
  • println!("Hello, Rust!");: 이 줄은 콘솔에 텍스트를 출력하는 작업을 수행합니다.
    • println!은 러스트 매크로입니다. 매크로는 함수와 유사하지만 중요한 차이점이 있습니다: 느낌표 !로 끝납니다. 매크로는 컴파일 타임 코드 생성을 수행하여 일반 함수보다 더 많은 기능과 유연성을 제공합니다(println!이 하는 가변 개수의 인수를 처리하는 것 등). println! 매크로는 제공된 텍스트를 콘솔에 출력하고 끝에 자동으로 줄 바꿈 문자를 추가합니다.
    • "Hello, Rust!"문자열 리터럴입니다 – 텍스트를 나타내는 고정된 문자 시퀀스로, 큰따옴표로 묶습니다.
    • ;: 세미콜론은 구문의 끝을 표시합니다. 대부분의 실행 가능한 러스트 코드(구문)는 세미콜론으로 끝납니다.

초보자를 위한 러스트 튜토리얼: 주요 개념

이제 러스트 프로그래밍 언어의 기본적인 구성 요소들을 자세히 살펴보겠습니다.

변수와 가변성

변수는 데이터 값을 저장하는 데 사용됩니다. 러스트에서는 let 키워드를 사용하여 변수를 선언합니다.

let apples = 5;
let message = "Take five";

러스트의 핵심 개념 중 하나는 변수가 기본적으로 **불변(immutable)**이라는 것입니다. 이는 일단 값이 변수 이름에 바인딩되면 나중에 그 값을 변경할 수 없음을 의미합니다.

let x = 10;
// x = 15; // 이 줄은 컴파일 타임 에러를 발생시킵니다! 불변 변수 `x`에 두 번 할당할 수 없습니다.
println!("x의 값: {}", x); // {}는 x의 값에 대한 플레이스홀더입니다

이 기본 불변성은 버그의 일반적인 원인이 될 수 있는 데이터의 우발적 수정을 방지하여 더 안전하고 예측 가능한 코드를 작성하는 데 도움이 되는 의도적인 설계 선택입니다. 값 변경이 필요한 변수가 정말로 필요하다면, 선언 시 mut 키워드를 사용하여 명시적으로 가변(mutable)으로 표시해야 합니다.

let mut count = 0; // 'count'를 가변으로 선언
println!("초기 count: {}", count);
count = 1; // 'count'가 'mut'으로 선언되었기 때문에 허용됩니다
println!("새로운 count: {}", count);

러스트는 **섀도잉(shadowing)**도 허용합니다. 동일한 스코프 내에서 이전 변수와 같은 이름으로 새로운 변수를 선언할 수 있습니다. 새 변수는 이전 변수를 "가립니다(shadows)". 즉, 이후에 해당 이름을 사용하면 새 변수를 참조하게 됩니다. 이는 완전히 새로운 변수를 생성하는 것이기 때문에 변경(mutation)과는 다릅니다. 새 변수는 심지어 다른 타입을 가질 수도 있습니다.

let spaces = "   "; // 'spaces'는 처음에 문자열 슬라이스(&str)입니다
let spaces = spaces.len(); // 'spaces'는 이제 길이를 보유하는 새 변수(정수, usize)로 섀도잉됩니다
println!("공백 수: {}", spaces); // 정수 값을 출력합니다

기본 데이터 타입

러스트는 정적 타입(statically typed) 언어입니다. 이는 모든 변수의 타입이 컴파일 타임에 컴파일러에 의해 알려져야 함을 의미합니다. 그러나 러스트는 뛰어난 타입 추론(type inference) 기능을 갖추고 있습니다. 많은 상황에서 타입을 명시적으로 작성할 필요가 없습니다. 컴파일러는 종종 값과 사용 방식을 기반으로 타입을 알아낼 수 있습니다.

let quantity = 10;         // 컴파일러는 i32 (기본 부호 있는 정수 타입)를 추론합니다
let price = 9.99;          // 컴파일러는 f64 (기본 부동소수점 타입)를 추론합니다
let active = true;         // 컴파일러는 bool (불리언)을 추론합니다
let initial = 'R';         // 컴파일러는 char (문자)를 추론합니다

명시적으로 지정하고 싶거나 필요할 때(예: 명확성을 위해 또는 컴파일러의 도움이 필요할 때), 콜론 : 다음에 타입 이름을 사용하여 **타입 명시(type annotations)**를 제공할 수 있습니다.

let score: i32 = 100;       // 명시적으로 부호 있는 32비트 정수
let ratio: f32 = 0.5;       // 명시적으로 단정밀도 부동소수점
let is_complete: bool = false; // 명시적으로 불리언
let grade: char = 'A';      // 명시적으로 문자 (유니코드 스칼라 값)

러스트에는 여러 내장 스칼라(scalar) 타입(단일 값을 나타냄)이 있습니다:

  • 정수: 부호 있는 정수 (i8, i16, i32, i64, i128, isize)는 양수와 음수 정수를 모두 저장합니다. 부호 없는 정수 (u8, u16, u32, u64, u128, usize)는 음수가 아닌 정수만 저장합니다. isizeusize 타입은 컴퓨터 아키텍처(32비트 또는 64비트)에 따라 다르며 주로 컬렉션 인덱싱에 사용됩니다.
  • 부동소수점 숫자: f32(단정밀도) 및 f64(배정밀도). 기본값은 f64입니다.
  • 불리언: bool 타입은 true 또는 false 두 가지 가능한 값을 갖습니다.
  • 문자: char 타입은 단일 유니코드 스칼라 값을 나타내며(단순한 ASCII 문자보다 포괄적임), 작은따옴표로 묶습니다(예: 'z', 'π', '🚀').

문자열: String&str

러스트에서 텍스트를 처리하는 것은 종종 신규 사용자에게 혼란스러울 수 있는 두 가지 기본 타입을 포함합니다:

  1. &str (문자열 슬라이스): 이것은 메모리 어딘가에 저장된 UTF-8 인코딩 바이트 시퀀스에 대한 불변 참조입니다. 문자열 리터럴(예: "Hello")은 &'static str 타입이며, 이는 프로그램 바이너리에 직접 저장되고 프로그램 전체 기간 동안 존재함을 의미합니다. 슬라이스는 문자열 데이터를 소유하지 않고 해당 데이터에 대한 를 제공합니다. 크기가 고정되어 있습니다.
  2. String: 이것은 소유권이 있고, 크기 조절이 가능하고, 가변적인, UTF-8 인코딩 문자열 타입입니다. String 데이터는 에 저장되므로 크기를 조절할 수 있습니다. 일반적으로 문자열 데이터를 수정해야 하거나, 문자열이 자체 데이터를 소유하고 자체 라이프타임을 관리해야 할 때(종종 함수에서 문자열을 반환하거나 구조체에 저장할 때) String을 사용합니다.
// 문자열 리터럴 (프로그램 바이너리에 저장, 불변)
let static_slice: &'static str = "나는 불변입니다";

// 리터럴로부터 소유권이 있는, 힙 할당된 String 생성
let mut dynamic_string: String = String::from("시작");

// String 수정 (가변이고 데이터를 소유하기 때문에 가능)
dynamic_string.push_str("하고 성장하기");
println!("{}", dynamic_string); // 출력: 시작하고 성장하기

// String의 일부를 참조하는 문자열 슬라이스 생성
// 이 슬라이스는 dynamic_string으로부터 데이터를 빌립니다
let slice_from_string: &str = &dynamic_string[0..7]; // "시작하고"를 참조 (주의: 바이트 인덱스 기준)
println!("슬라이스: {}", slice_from_string);

함수

함수는 코드를 이름이 지정된 재사용 가능한 단위로 구성하는 데 기본적입니다. 우리는 이미 특별한 main 함수를 만났습니다.

// 함수 정의
fn greet(name: &str) { // 하나의 매개변수 'name'을 받으며, 이는 문자열 슬라이스(&str)입니다
    println!("안녕하세요, {}님!", name);
}

// 두 개의 i32 매개변수를 받아 i32를 반환하는 함수
fn add(a: i32, b: i32) -> i32 {
    // 러스트에서는 함수 본문의 마지막 표현식이 세미콜론으로 끝나지 않으면
    // 자동으로 반환됩니다.
    a + b
    // 이는 return a + b; 라고 쓰는 것과 동일합니다.
}

fn main() {
    greet("앨리스"); // 'greet' 함수 호출

    let sum = add(5, 3); // 'add'를 호출하고 반환된 값을 'sum'에 바인딩
    println!("5 + 3 = {}", sum); // 출력: 5 + 3 = 8
}

함수에 대한 주요 사항:

  • fn 키워드를 사용하여 함수를 선언합니다.
  • 괄호 안에 매개변수 이름과 해당 타입을 지정합니다.
  • 화살표 -> 뒤에 함수의 반환 타입을 지정합니다. 함수가 값을 반환하지 않으면 반환 타입은 암시적으로 () (빈 튜플, 종종 "유닛 타입"이라고 함)입니다.
  • 함수 본문은 일련의 구문(일반적으로 ;로 끝나는, 작업을 수행하는 명령어)으로 구성되며 선택적으로 표현식(값으로 평가되는 것)으로 끝날 수 있습니다.
  • 함수 블록의 마지막 표현식 값은 세미콜론이 없는 경우 자동으로 반환됩니다. return 키워드는 함수 내 어디에서든 명시적인 조기 반환에 사용할 수 있습니다.

제어 흐름

러스트는 코드가 실행되는 순서를 결정하기 위한 표준 제어 흐름 구조를 제공합니다:

  • if/else/else if: 조건부 실행에 사용됩니다.
let number = 6;

if number % 4 == 0 {
    println!("숫자는 4로 나누어 떨어집니다");
} else if number % 3 == 0 {
    println!("숫자는 3으로 나누어 떨어집니다"); // 이 분기가 실행됩니다
} else {
    println!("숫자는 4 또는 3으로 나누어 떨어지지 않습니다");
}

// 중요하게도, 'if'는 러스트에서 표현식이며, 이는 값으로 평가됨을 의미합니다.
// 이를 통해 'let' 구문에서 직접 사용할 수 있습니다.
let condition = true;
let value = if condition { 5 } else { 6 }; // value는 5가 됩니다
println!("값은: {}", value);
// 참고: 'if' 표현식의 양쪽 분기는 모두 동일한 타입으로 평가되어야 합니다.
  • 루프(Loops): 반복 실행에 사용됩니다.
    • loop: 무한 루프를 생성합니다. 일반적으로 break를 사용하여 루프를 종료하며, 선택적으로 값을 반환할 수 있습니다.
    • while: 지정된 조건이 참인 동안 루프를 실행합니다.
    • for: 컬렉션의 요소나 범위에 대해 반복합니다. 이는 러스트에서 가장 일반적으로 사용되며 종종 가장 안전한 루프 타입입니다.
// loop 예제
let mut counter = 0;
let result = loop {
    counter += 1;
    if counter == 10 {
        break counter * 2; // 루프를 종료하고 'counter * 2'를 반환합니다
    }
};
println!("루프 결과: {}", result); // 출력: 루프 결과: 20

// while 예제
let mut num = 3;
while num != 0 {
    println!("{}!", num);
    num -= 1;
}
println!("발사!!!");

// for 예제 (배열 반복)
let a = [10, 20, 30, 40, 50];
for element in a.iter() { // .iter()는 배열 요소에 대한 반복자를 생성합니다
    println!("값은: {}", element);
}

// for 예제 (범위 반복)
// (1..4)는 1, 2, 3을 포함하는 범위를 생성합니다 (4는 제외)
// .rev()는 반복자를 뒤집습니다
for number in (1..4).rev() {
    println!("{}!", number); // 3!, 2!, 1! 출력
}
println!("다시 발사!!!");

주석

주석을 사용하여 컴파일러가 무시할 설명과 메모를 코드에 추가하세요.

// 이것은 한 줄 주석입니다. 줄 끝까지 이어집니다.

/*
 * 이것은 여러 줄, 블록 주석입니다.
 * 여러 줄에 걸쳐 작성할 수 있으며
 * 긴 설명에 유용합니다.
 */

 let lucky_number = 7; // 줄 끝에 주석을 달 수도 있습니다.

소유권 이해하기: 러스트의 핵심 개념

소유권은 러스트의 가장 독특하고 핵심적인 기능입니다. 이는 러스트가 가비지 컬렉터 없이 컴파일 타임에 메모리 안전성을 보장할 수 있게 하는 메커니즘입니다. 소유권을 파악하는 것이 러스트를 이해하는 열쇠입니다. 이는 세 가지 핵심 규칙을 따릅니다:

  1. 소유자: 러스트의 각 값에는 해당 값의 소유자로 지정된 변수가 있습니다.
  2. 단일 소유자: 특정 값의 소유자는 언제든지 하나만 있을 수 있습니다.
  3. 스코프 및 해제(Drop): 소유자 변수가 스코프를 벗어나면(예: 해당 변수가 선언된 함수가 종료될 때) 소유한 값은 *해제(dropped)*됩니다. 이는 해당 메모리가 자동으로 해제됨을 의미합니다.
{ // s는 여기서 유효하지 않습니다. 아직 선언되지 않았습니다.
    let s = String::from("hello"); // s는 이 지점부터 유효합니다;
                                    // s는 힙에 할당된 String 데이터를 '소유'합니다.
    // 여기서 s를 사용할 수 있습니다
    println!("{}", s);
} // 스코프가 여기서 끝납니다. 's'는 더 이상 유효하지 않습니다.
  // 러스트는 's'가 소유한 String에 대해 자동으로 특별한 'drop' 함수를 호출하여
  // 힙 메모리를 해제합니다.

소유권이 있는 값(String, Vec 또는 소유 타입이 포함된 구조체 등)을 다른 변수에 할당하거나 값으로 함수에 전달하면 소유권이 *이동(moved)*됩니다. 원래 변수는 유효하지 않게 됩니다.

let s1 = String::from("original");
let s2 = s1; // String 데이터의 소유권이 s1에서 s2로 이동(MOVE)됩니다.
             // s1은 이 지점 이후로 더 이상 유효한 것으로 간주되지 않습니다.

// println!("s1은: {}", s1); // 컴파일 타임 에러! 이동 후 여기서 값을 빌렸습니다.
                            // s1은 더 이상 데이터를 소유하지 않습니다.
println!("s2는: {}", s2); // s2가 이제 소유자이며 유효합니다.

이 이동 동작은 두 변수가 스코프를 벗어날 때 실수로 동일한 메모리 위치를 해제하려고 시도하는 “이중 해제(double free)” 오류를 방지합니다. 정수, 부동소수점, 불리언, 문자와 같은 기본 타입은 Copy 트레이트를 구현하므로 할당되거나 함수에 전달될 때 이동되는 대신 단순히 복사됩니다.

빌림과 참조

소유권을 이전하지 않고 함수가 값을 사용하게 하려면 어떻게 해야 할까요? *참조(references)*를 만들 수 있습니다. 참조를 만드는 것을 **빌림(borrowing)**이라고 합니다. 참조를 사용하면 소유권을 가져오지 않고 다른 변수가 소유한 데이터에 접근할 수 있습니다.

// 이 함수는 String에 대한 참조(&)를 받습니다.
// String을 빌리지만 소유권을 가져가지 않습니다.
fn calculate_length(s: &String) -> usize {
    s.len()
} // 여기서 s(참조)는 스코프를 벗어납니다. 그러나 String 데이터를 소유하지 않으므로
  // 참조가 스코프를 벗어날 때 데이터는 해제(drop)되지 않습니다.

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

    // '&' 기호를 사용하여 s1에 대한 참조를 전달합니다.
    // s1은 여전히 String 데이터를 소유합니다.
    let len = calculate_length(&s1);

    // 소유권이 절대 이동되지 않았기 때문에 s1은 여기서 여전히 유효합니다.
    println!("'{}'의 길이는 {}입니다.", s1, len);
}

참조는 변수와 마찬가지로 기본적으로 불변입니다. 빌린 데이터를 수정하려면 **가변 참조(mutable reference)**가 필요하며, 이는 &mut로 표시됩니다. 그러나 러스트는 데이터 경쟁을 방지하기 위해 가변 참조에 대한 엄격한 규칙을 적용합니다:

빌림 규칙:

  1. 어떤 시점에서든 다음 중 하나만 가질 수 있습니다:
    • 하나의 가변 참조 (&mut T).
    • 임의 개수의 불변 참조 (&T).
  2. 참조는 항상 유효해야 합니다 (참조하는 데이터보다 오래 존재할 수 없습니다 – 이는 종종 암시적으로 관리되는 라이프타임에 의해 관리됩니다).

이러한 규칙은 컴파일러에 의해 강제됩니다.

// 이 함수는 String에 대한 가변 참조를 받습니다
fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

fn main() {
    // 가변 빌림을 허용하려면 's'를 가변으로 선언해야 합니다
    let mut s = String::from("hello");

    // 빌림 규칙 적용 예시 (주석 해제 시 에러 확인 가능):
    // let r1 = &s; // 불변 빌림 - OK
    // let r2 = &s; // 또 다른 불변 빌림 - OK (여러 불변 빌림 허용)
    // let r3 = &mut s; // 에러! 불변 빌림이 활성 상태인 동안 `s`를 가변으로 빌릴 수 없습니다
    //                  // 러스트는 강제: 여러 리더(&T) 또는 단일 라이터(&mut T) 중 하나만 가능, 둘 다는 불가
    // println!("{}, {}", r1, r2); // r1/r2를 사용하면 활성 상태로 유지되어 에러 발생
    //                             // 이 println이 없으면 러스트의 NLL(Non-Lexical Lifetimes)이 r1/r2를 조기에 해제하여
    //                             // 여기서 &mut s가 유효하게 됨

    // 다른 빌림이 활성화되어 있지 않으므로 여기서 가변 빌림이 허용됩니다
    change(&mut s);
    println!("{}", s); // 출력: hello, world
}

복합 데이터 타입

러스트는 여러 값을 더 복잡한 타입으로 그룹화하는 방법을 제공합니다.

구조체 (Structs)

구조체(structures의 줄임말)를 사용하면 관련된 데이터 필드를 단일 이름 아래에 그룹화하여 사용자 정의 데이터 타입을 정의할 수 있습니다.

// User라는 이름의 구조체 정의
struct User {
    active: bool,
    username: String, // 소유권 있는 String 타입 사용
    email: String,
    sign_in_count: u64,
}

fn main() {
    // User 구조체의 인스턴스 생성
    // 인스턴스는 모든 필드에 대한 값을 제공해야 합니다
    let mut user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    // 점 표기법을 사용하여 구조체 필드에 접근
    // 필드 값을 변경하려면 인스턴스가 가변이어야 합니다
    user1.email = String::from("anotheremail@example.com");

    println!("사용자 이메일: {}", user1.email);

    // User 인스턴스 생성을 위한 헬퍼 함수 사용
    let user2 = build_user(String::from("user2@test.com"), String::from("user2"));
    println!("사용자 2 활성 상태: {}", user2.active);

    // 구조체 업데이트 구문: 기존 인스턴스의 일부 필드를 사용하여
    // 나머지 필드에 대한 새 인스턴스를 생성합니다.
    let user3 = User {
        email: String::from("user3@domain.com"),
        username: String::from("user3"),
        ..user2 // user2로부터 'active'와 'sign_in_count' 값을 가져옵니다
    };
    println!("사용자 3 로그인 횟수: {}", user3.sign_in_count);
}

// User 인스턴스를 반환하는 함수
fn build_user(email: String, username: String) -> User {
    User {
        email, // 필드 초기화 약식: 매개변수 이름이 필드 이름과 일치하는 경우
        username,
        active: true,
        sign_in_count: 1,
    }
}

러스트는 또한 명명된 튜플인 튜플 구조체(예: struct Color(i32, i32, i32);)와 필드가 없고 타입에 트레이트를 구현해야 하지만 데이터를 저장할 필요가 없을 때 유용한 유닛 유사 구조체(예: struct AlwaysEqual;)도 지원합니다.

열거형 (Enums)

열거형(enumerations)을 사용하면 가능한 *배리언트(variants)*를 나열하여 타입을 정의할 수 있습니다. 열거형 값은 가능한 배리언트 중 하나만 될 수 있습니다.

// IP 주소 종류를 정의하는 간단한 열거형
enum IpAddrKind {
    V4, // 배리언트 1
    V6, // 배리언트 2
}

// 열거형 배리언트는 연관된 데이터를 가질 수도 있습니다
enum IpAddr {
    V4(u8, u8, u8, u8), // V4 배리언트는 네 개의 u8 값을 가짐
    V6(String),         // V6 배리언트는 String을 가짐
}

// 러스트 표준 라이브러리의 매우 일반적이고 중요한 열거형: Option<T>
// 값이 존재할 수도 있고 없을 수도 있다는 개념을 인코딩합니다.
// enum Option<T> {
//     Some(T), // 타입 T의 값이 존재함을 나타냄
//     None,    // 값의 부재를 나타냄
// }

// 또 다른 중요한 표준 라이브러리 열거형: Result<T, E>
// 성공(Ok)하거나 실패(Err)할 수 있는 작업에 사용됩니다.
// enum Result<T, E> {
//     Ok(T),   // 성공을 나타내며, 타입 T의 값을 포함
//     Err(E),  // 실패를 나타내며, 타입 E의 에러 값을 포함
// }

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    // 연관된 데이터와 함께 IpAddr 열거형의 인스턴스 생성
    let home = IpAddr::V4(127, 0, 0, 1);
    let loopback = IpAddr::V6(String::from("::1"));

    // Option<T> 예제
    let some_number: Option<i32> = Some(5);
    let no_number: Option<i32> = None;

    // 'match' 제어 흐름 연산자는 열거형 작업에 완벽합니다.
    // 열거형 배리언트에 따라 다른 코드를 실행할 수 있습니다.
    match some_number {
        Some(i) => println!("숫자를 얻었습니다: {}", i), // Some이면 내부 값을 i에 바인딩
        None => println!("아무것도 얻지 못했습니다."),       // None이면
    }

    // 'match'는 철저해야 합니다: 가능한 모든 배리언트를 처리해야 합니다.
    // 밑줄 '_'은 명시적으로 나열되지 않은 모든 배리언트를 포착하는
    // 와일드카드 패턴으로 사용할 수 있습니다.
}

Option<T>Result<T, E>는 잠재적으로 누락된 값과 오류를 강력하게 처리하는 러스트 접근 방식의 핵심입니다.

Cargo 소개: 러스트 빌드 도구 및 패키지 매니저

Cargo는 러스트 생태계의 필수적인 부분으로, 러스트 프로젝트의 빌드, 테스트 및 관리 프로세스를 간소화합니다. 개발자들이 종종 칭찬하는 기능 중 하나입니다.

  • Cargo.toml: 이것은 러스트 프로젝트의 매니페스트(manifest) 파일입니다. TOML(Tom's Obvious, Minimal Language) 형식으로 작성됩니다. 프로젝트에 대한 필수 메타데이터(이름, 버전, 작성자 등)와 중요하게는 프로젝트의 의존성(프로젝트가 의존하는 다른 외부 크레이트) 목록을 포함합니다.
    [package]
    name = "my_project"
    version = "0.1.0"
    edition = "2021" # 사용할 러스트 에디션 지정 (언어 기능에 영향)
    
    # 의존성은 아래에 나열됩니다
    [dependencies]
    # 예: 난수 생성을 위해 'rand' 크레이트 추가
    # rand = "0.8.5"
    # 빌드 시 Cargo는 'rand'와 그 의존성을 다운로드하고 컴파일합니다.
    
  • cargo new <project_name>: 새 바이너리(실행 가능) 애플리케이션 프로젝트 구조를 생성합니다.
  • cargo new --lib <library_name>: 새 라이브러리(다른 프로그램에서 사용하기 위한 크레이트) 프로젝트 구조를 생성합니다.
  • cargo build: 프로젝트와 해당 의존성을 컴파일합니다. 기본적으로 최적화되지 않은 디버그(debug) 빌드를 생성합니다. 출력은 target/debug/ 디렉토리에 위치합니다.
  • cargo build --release: 배포 또는 성능 테스트에 적합하도록 최적화가 활성화된 상태로 프로젝트를 컴파일합니다. 출력은 target/release/ 디렉토리에 위치합니다.
  • cargo run: 바이너리 프로젝트를 컴파일(필요한 경우)하고 실행합니다.
  • cargo check: 최종 실행 파일을 실제로 생성하지 않고 코드의 컴파일 오류를 빠르게 확인합니다. 이는 일반적으로 cargo build보다 훨씬 빠르며 개발 중 빠른 피드백에 유용합니다.
  • cargo test: 프로젝트 내에 정의된 모든 테스트(일반적으로 src 디렉토리 또는 별도의 tests 디렉토리에 위치)를 실행합니다.

Cargo.toml 파일에 의존성을 추가한 다음 cargo build 또는 cargo run과 같은 명령을 실행하면, Cargo는 중앙 리포지토리 crates.io에서 필요한 크레이트(및 그것의 의존성)를 자동으로 다운로드하고 모든 것을 함께 컴파일하는 작업을 처리합니다.

러스트 여정의 다음 단계

이 시작 가이드는 시작하는 데 필요한 절대적인 기본 사항을 다루었습니다. 러스트 언어는 진행하면서 탐색할 수 있는 훨씬 더 강력한 기능들을 제공합니다:

  • 에러 처리: Result 열거형 마스터하기, ? 연산자를 사용한 에러 전파, 사용자 정의 에러 타입 정의하기.
  • 컬렉션: Vec<T>(동적 배열/벡터), HashMap<K, V>(해시 맵), HashSet<T> 등과 같은 일반적인 데이터 구조 효과적으로 사용하기.
  • 제네릭: 타입 매개변수(<T>)를 사용하여 다른 데이터 타입에서 작동할 수 있는 유연하고 재사용 가능한 코드 작성하기.
  • 트레이트: 타입이 구현할 수 있는 공유 동작 및 인터페이스 정의하기 (다른 언어의 인터페이스와 유사하지만 더 강력함).
  • 라이프타임: 러스트가 참조가 항상 유효함을 보장하는 방법 이해하기, 때로는 명시적인 라이프타임 명시('a)가 필요함.
  • 동시성: 스레드, 메시지 전달을 위한 채널, MutexArc를 사용한 공유 상태, 비동기 프로그래밍을 위한 러스트의 강력한 async/await 문법 탐색하기.
  • 매크로: 고급 컴파일 타임 코드 생성을 위해 자신만의 매크로 작성 배우기.
  • 모듈: 대규모 프로젝트를 논리적 단위로 구성하고 항목의 가시성(public/private) 제어하기.
  • 테스트: 러스트의 내장 테스트 프레임워크를 사용하여 효과적인 단위, 통합 및 문서 테스트 작성하기.

결론

러스트는 독특하고 매력적인 제안을 제시합니다: C++와 같은 저수준 언어에서 기대되는 원시 성능과, 특히 메모리 관리 및 동시성 관련 일반적인 버그 클래스 전체를 제거하는 강력한 컴파일 타임 안전성 보장을 결합합니다. 학습 곡선에는 소유권 및 빌림과 같은 새로운 개념을 내재화하고 (때로는 엄격한) 컴파일러에 귀 기울이는 과정이 포함되지만, 그 대가는 매우 안정적이고 효율적이며 장기적으로 유지 관리가 더 쉬운 소프트웨어입니다. Cargo를 통한 뛰어난 툴링과 지원적인 커뮤니티 덕분에 러스트는 배우기에 보람 있는 언어입니다.

작게 시작하고, Cargo를 사용하여 실험하고, 컴파일러의 도움이 되는 (때로는 장황한) 오류 메시지를 지침으로 받아들이면, 이 강력하고 점점 더 인기를 얻고 있는 언어를 마스터하는 길에 들어설 것입니다. 즐거운 러스트 프로그래밍 되세요!

관련 기사