Choose a default theme

You can change your theme from the menu

Rust

Rust는 소유권·빌림·타입 검사를 통해 메모리 안전성과 실행 성능을 함께 추구하는 프로그래밍 언어다. 이 문서는 주요 특징이 실제 코드에서 어떻게 작동하는지, 컴파일 오류를 어떻게 해석하고 수정하는지, 어떤 비용과 제약을 고려해야 하는지를 예제와 함께 설명한다.

프로그래밍 언어

언어

Python · Java · JavaScript · TypeScript · Rust

개요와 설계 목표

Rust는 실행 성능과 메모리 안전성을 함께 다루는 정적 타입 프로그래밍 언어다. 운영체제와 가까운 저수준 구성 요소부터 서버, 명령줄 도구, 다른 언어에서 호출할 라이브러리까지 작성할 수 있다. 언어가 널리 쓰이는 이유를 단순히 “빠르고 안전하다”로 요약하면 실제 사용 경험을 설명하기 어렵다. Rust에서는 어떤 값의 자원을 누가 정리하는지, 함수가 값을 가져가는지 잠시 빌리는지, 실패가 어떤 타입으로 전달되는지가 API에 드러난다. 이 선택이 구현 방식과 함수 간 관계를 바꾼다.

예를 들어 파일 내용을 읽는 함수가 String을 돌려주면 호출자는 그 문자열을 소유한다. 이미 가진 문자열을 검사하는 함수가 &str을 받으면 내용을 빌려 읽을 수 있어 복사가 필요하지 않을 수 있다. 이 차이는 단순한 문법 차이가 아니라 데이터의 수명과 변경 책임을 정하는 계약이다. 컴파일러는 그 계약에 어긋나는 사용을 실행 전에 찾아낸다. 대신 개발자는 처음부터 소유 구조를 생각해야 하므로 간단한 기능도 익숙해지기 전에는 구현 시간이 길어질 수 있다.

일반적인 Rust 프로그램은 메모리 회수를 위해 추적형 가비지 컬렉터를 반드시 필요로 하지 않는다. 그렇다고 모든 메모리 관리가 공짜이거나 실행 중 작업이 사라진다는 뜻은 아니다. 힙 할당과 해제, 복제, 참조 횟수 변경, 잠금 같은 작업에는 비용이 있다. 소유권 검사는 많은 잘못된 사용을 컴파일 단계에서 배제하는 장치이고, 실제 성능은 자료 구조와 알고리즘, 할당 패턴, 입출력에 따라 달라진다.

안정 버전, 에디션과 학습 범위

Rust 1.0은 2015년 5월 15일 발표되었다. 이후의 발전에서 중요한 구분은 컴파일러 버전과 에디션이다. 버전은 사용하는 도구의 릴리스를 가리키고, 에디션은 일부 문법과 이름 해석 등의 호환성 경계를 지정한다. 새로운 키워드를 도입할 때 옛 코드의 식별자와 충돌할 수 있으므로, 에디션을 통해 새 규칙을 선택하는 방식이다. 새로운 에디션이 나왔다고 모든 패키지를 동시에 다시 써야 하는 것은 아니다.

프로젝트를 읽을 때는 Cargo.toml의 edition, 지원하는 최소 Rust 버전, 의존 라이브러리가 요구하는 조건을 함께 살핀다. 컴파일러만 최신으로 설치했다고 의존성이나 에디션 설정까지 자동으로 프로젝트의 의도와 같아지는 것은 아니다. 반대로 에디션 번호가 같다고 모든 컴파일러 버전에서 모든 라이브러리를 사용할 수 있는 것도 아니다.

이 문서의 실행 예제는 외부 크레이트 없이 Rust 표준 라이브러리로 구성했다. 각 fn main이 있는 코드 블록은 독립된 프로그램이다. 여러 블록의 main을 한 파일에 이어 붙이지 않고 하나씩 교체해서 실행한다. 실패를 설명하기 위한 코드는 의도적으로 컴파일되지 않는다고 따로 표시한다. 컴파일러 진단의 줄 번호와 문구는 버전에 따라 달라질 수 있으므로 메시지의 원인을 중심으로 읽는다.

첫 프로그램과 실행 흐름

다음 프로그램은 공책 세 권의 합계 가격을 계산한다. fn main은 이 실행 파일의 시작점이고 let은 이름을 값에 연결한다. 콜론 뒤 u32는 부호 없는 32비트 정수 타입이다. 수량도 곱셈의 문맥을 통해 같은 정수 타입으로 추론된다. 숫자 사이의 밑줄은 읽기 편하게 쓰는 구분자이며 값에는 영향을 주지 않는다.

println!의 느낌표는 일반 함수 호출과 매크로 호출을 구별한다. 문자열 안의 {total}은 계산 결과를 넣는 서식이다. 출력은 total=3600이다. 이 예제에서 컴파일러가 확인하는 것은 숫자 타입과 표현식 사용의 적합성이지, 상품 가격이 업무적으로 올바른지는 아니다. 세금 포함 여부나 할인 정책은 프로그램이 별도로 정해야 한다.

실행은 실습용 폴더에서 cargo new rust_notes로 프로젝트를 만든 다음 src/main.rs를 이 코드로 교체하고 cargo run을 사용하는 방식으로 시작할 수 있다. Cargo는 빌드와 실행을 연결한다. 원본 파일을 고친 뒤 이전에 만들어 둔 실행 파일만 직접 실행하면 변경 전 결과가 나올 수 있으므로 빌드 단계도 구분한다.

예제 코드

fn main() {
    let unit_price: u32 = 1_200;
    let quantity = 3;
    let total = unit_price * quantity;
    println!("total={total}");
}

실행 결과

total=3600

가변성, 재대입과 섀도잉

let으로 만든 바인딩은 기본적으로 재대입할 수 없다. 프로그램을 읽는 사람은 mut이 없는 이름이 다른 값으로 바뀌는지 계속 추적할 필요를 줄일 수 있다. 누적 합이나 상태처럼 실제로 바뀌는 값에는 let mut을 사용한다. 반면 let을 다시 쓰는 섀도잉은 같은 이름으로 새 바인딩을 만드는 것이며, 기존 변수의 타입을 임의로 바꾸는 재대입과 다르다.

아래에서 raw는 처음에 문자열 조각이고, 다음 let raw에서는 공백을 정리하고 숫자로 변환한 u32다. count는 이 숫자에서 시작해 2 증가하므로 출력은 9다. 변환 결과를 같은 이름으로 부르면 단계 이후에 옛 문자열을 사용할 수 없다는 의도가 드러난다. 그러나 서로 다른 의미의 값을 같은 이름으로 계속 가리면 디버깅이 어려워지므로 섀도잉을 많이 사용하는 것 자체를 목표로 삼지 않는다.

expect는 이 예제의 고정 문자열이 정수라는 전제에서 사용했다. 입력이 사용자가 작성한 문자열이라면 실패가 정상적으로 발생할 수 있으므로 뒤에서 설명할 Result 처리로 바꾸어야 한다. mut이 없는 바인딩도 RefCell이나 Mutex 같은 타입을 통해 내부 상태가 변하는 경우가 있다. 바인딩의 재대입, 참조를 통한 변경, 내부 가변성은 같은 개념이 아니다.

예제 코드

fn main() {
    let raw = " 7 ";
    let raw: u32 = raw.trim().parse().expect("fixed integer");
    let mut count = raw;
    count += 2;
    println!("{count}");
}

실행 결과

9

소유권: 값을 이동한 뒤 사용할 수 없는 이유

String은 문자열 데이터의 저장 공간을 소유한다. 다음 실패 예제에서 label을 moved에 대입하면 문자열 값의 소유권이 moved로 이동한다. label이라는 옛 바인딩으로 내용을 다시 읽으려 하므로 컴파일러가 거부한다. 만약 두 바인딩이 아무 제한 없이 같은 저장 공간의 소유자 역할을 한다면 어느 쪽이 언제 정리할지 모호해질 수 있다. Rust는 이런 책임을 타입과 이동 규칙으로 정리한다.

여기서 이동을 “문자열 전체를 무조건 새 주소로 복사한다”라고 이해하지 않는다. 언어가 정하는 소유권 이동과 실제 기계 명령이 데이터를 옮기는 방식은 구별된다. 최적화와 값의 형태에 따라 실제 복사 비용은 달라질 수 있다. 핵심은 이 지점 이후 사용 가능한 소유자가 바뀐다는 것이다.

실패 원인을 없애는 방법은 목적에 따라 다르다. 새 이름으로만 계속 쓰려면 label 대신 moved를 출력한다. 원본 내용을 잠시 읽기만 하려면 &label로 빌린다. 독립적으로 수정할 문자열 두 개가 정말 필요하면 clone을 사용한다. 오류가 나올 때마다 무조건 clone을 붙이면 컴파일은 되더라도 필요 없는 할당과 데이터 복제가 늘어날 수 있다.

컴파일 실패 예제

fn main() {
    let label = String::from("report");
    let moved = label;
    println!("{label}");
    println!("{moved}");
}

빌림과 복제: 같은 결과라도 다른 비용

아래의 byte_len은 &str을 받아 UTF-8 바이트 길이를 계산한다. 호출할 때 &label을 전달하면 String 전체를 넘겨 소유권을 잃지 않고 문자열 내용을 빌려 읽는다. 함수가 끝난 뒤에도 label을 사용할 수 있다. 읽기만 하는 문자열 함수에서 &str을 받으면 String에서 빌린 문자열뿐 아니라 문자열 리터럴도 받기 쉽다.

이어서 label.clone()은 이 String의 내용을 가진 독립적인 문자열을 만든다. copied에 접미사를 붙여도 label은 report로 남는다. 출력의 첫 줄은 6, 둘째 줄은 report / report-copy다. 이 예제에서는 읽기 전용 함수 호출에는 빌림을, 독립적인 편집에는 복제를 선택했다. “복제를 하지 않는 코드” 자체가 목적이 아니라 필요한 소유 관계를 표현하는 것이 목적이다.

정수 같은 Copy 타입의 대입은 이 String 예제와 다르다. u32 값을 다른 이름에 대입해도 원래 값을 다시 쓸 수 있다. Copy는 암묵적 복사가 가능한 타입의 성질이고 Clone은 명시적으로 복제하는 동작을 제공한다. 특히 Rc와 Arc의 clone은 내부 데이터 전체를 복제하는 것이 아니라 같은 데이터를 공유할 소유 핸들을 늘리는 동작이므로, clone이라는 메서드 이름만 보고 모든 타입의 비용과 독립성을 같다고 판단하지 않는다.

예제 코드

fn byte_len(text: &str) -> usize {
    text.len()
}
fn main() {
    let label = String::from("report");
    println!("{}", byte_len(&label));
    let mut copied = label.clone();
    copied.push_str("-copy");
    println!("{label} / {copied}");
}

실행 결과

6
report / report-copy

가변 빌림: 변경할 권한을 잠시 맡기는 방식

&mut T는 값을 변경할 수 있는 빌림을 표현한다. 같은 데이터를 대상으로 서로 충돌하는 접근을 동시에 허용하지 않는 규칙이 중요하다. 같은 범위 안에 &mut라는 표현이 두 번 등장하면 언제나 오류라는 뜻은 아니다. 앞선 빌림을 더 이상 사용하지 않고 끝낸 뒤 새 빌림을 만드는 것은 가능하다. 컴파일러는 실제 사용 범위에 근거해 유효성을 검사한다.

아래 append_mark는 호출자가 소유한 String을 잠시 빌려 뒤에 느낌표를 붙인다. 호출자는 mut으로 원본을 선언하고 &mut title을 전달해 변경을 허용한다. 함수가 반환한 뒤에는 다시 title을 읽을 수 있으며 출력은 draft!다. 값을 새로 만들어 돌려주는 함수와 달리 호출자의 상태가 직접 변경되므로 함수 이름과 문서에서도 그 의도를 드러내는 편이 좋다.

가변 참조를 얻은 상태에서 원본을 따로 읽고, 뒤에서 그 가변 참조를 다시 사용하는 코드는 접근이 겹쳐 거부될 수 있다. 해결할 때는 변경 단계와 읽기 단계를 나누거나, 빌림의 범위를 줄이거나, 데이터를 분리해 서로 다른 부분을 다루도록 구조를 바꾼다. 안전 규칙을 피해 가기 위해 unsafe를 붙이는 것은 해결이 아니다.

예제 코드

fn append_mark(text: &mut String) {
    text.push('!');
}
fn main() {
    let mut title = String::from("draft");
    append_mark(&mut title);
    println!("{title}");
}

실행 결과

draft!

수명: 참조를 언제까지 사용할 수 있는가

수명은 참조가 유효한 관계를 표현한다. 함수 안에서 만든 String을 참조로 돌려주고 함수가 끝난 뒤에도 쓰려고 하면 그 문자열은 이미 정리될 수 있어 허용되지 않는다. 이때 수명 표기를 추가한다고 지역 변수의 실제 생존 기간이 늘어나는 것은 아니다. 값 자체를 반환해 소유권을 넘기거나 호출자가 소유한 데이터에서 빌린 참조를 반환해야 한다.

아래 choose는 두 문자열 중 어느 하나를 반환한다. 반환값이 어느 입력에서 왔는지 함수 시그니처에서 구별하지 않으므로, 두 입력과 반환값에 공통 수명 매개변수 a를 사용해 관계를 명시했다. 이것은 입력 문자열들의 실제 수명이 같아야 한다는 선언이 아니다. 호출 시 두 입력 참조가 모두 유효한 범위 안에서 반환 참조를 사용할 수 있도록 제약을 전달한다.

예제는 left와 right가 모두 살아 있는 동안 결과를 출력하므로 alpha가 나온다. 만약 right를 작은 내부 블록에서 만들고 반환 참조를 블록 밖에서 쓰려 하면, 실제 분기에서 left를 선택한다고 해도 이 시그니처가 제공하는 관계로는 거부될 수 있다. 이런 경우 항상 특정 인자만 반환하는 별도 함수로 계약을 좁히거나, 독립적인 String을 반환하는 API를 고려한다. 수명 오류를 이해하려면 표기를 무작정 추가하기보다 데이터의 소유자가 누구인지부터 그려 보는 것이 도움이 된다.

예제 코드

fn choose<'a>(left: &'a str, right: &'a str, use_left: bool) -> &'a str {
    if use_left { left } else { right }
}
fn main() {
    let left = String::from("alpha");
    let right = String::from("beta");
    let selected = choose(&left, &right, true);
    println!("{selected}");
}

실행 결과

alpha

문자열: 바이트 수와 글자 수가 다른 이유

String과 str은 UTF-8 문자열을 다룬다. String은 소유하는 가변 길이 문자열이고 &str은 유효한 UTF-8 문자열 영역을 빌리는 형태다. UTF-8에서 한 문자가 항상 한 바이트를 차지하지 않으므로 문자열에 정수 인덱스를 사용해 “몇 번째 글자”를 꺼내는 방식은 제공되지 않는다. 바이트 위치가 문자 경계가 아닐 수도 있기 때문이다.

아래 문자열 가A의 len은 4이고 chars().count()는 2다. 가는 UTF-8에서 3바이트, A는 1바이트를 사용하기 때문이다. chars는 유니코드 스칼라 값 단위로 순회하므로 가와 A가 각각 출력된다. 그러나 화면에서 보이는 한 글자가 여러 스칼라 값으로 이루어질 수도 있다. 이모지나 결합 문자를 고려하는 편집기의 커서 이동은 chars만으로 충분하지 않을 수 있다.

문자열 슬라이스를 바이트 범위로 만들 때도 UTF-8 경계에 맞아야 한다. 외부에서 받은 숫자를 그대로 문자열 인덱스 범위에 넣으면 실패할 수 있다. 바이너리 데이터는 문자열로 억지 해석하기보다 바이트 배열로 다루고, 텍스트로 변환할 때 인코딩의 유효성을 확인한다. “파일 크기”, “문자 수”, “사용자가 보는 글자 수” 중 무엇을 세려는지 먼저 정해야 결과의 의미가 분명해진다.

예제 코드

fn main() {
    let text = "가A";
    println!("{} {}", text.len(), text.chars().count());
    for ch in text.chars() {
        println!("{ch}");
    }
}

실행 결과

4 2
가
A

Option과 열거형: 없는 값도 타입으로 표현

검색 결과가 없거나 선택적 설정이 제공되지 않은 상황을 임의의 특수 숫자나 빈 문자열로 표현하면 실제 데이터와 충돌할 수 있다. Option<T>는 Some(T)와 None으로 값의 유무를 표현한다. 호출자는 반환된 Option 자체를 T처럼 바로 쓰는 대신 두 경우를 어떻게 처리할지 정해야 한다. 이것이 반환 타입을 통해 놓치기 쉬운 경우를 드러내는 효과다.

아래 first는 빈 벡터에서 첫 원소를 구하려 한다. 반환값은 None이므로 match의 None 분기가 실행되어 empty가 출력된다. numbers[0]처럼 직접 인덱싱하면 빈 벡터라는 전제에서 panic이 발생할 수 있다. 입력 크기가 달라질 수 있다면 get이나 first처럼 Option을 반환하는 API와 함께 없는 경우를 정상 경로로 처리할 수 있다.

Rust의 enum은 이름 있는 상수 목록에만 그치지 않고 변형마다 서로 다른 데이터를 담을 수 있다. 예를 들어 작업 상태를 Waiting, Running { progress: u8 }, Failed(String)으로 모델링하면 대기 상태에 오류 메시지가 따로 붙는 모순된 조합을 줄일 수 있다. match의 전체 경우 검사는 새 상태를 추가했을 때 누락된 처리 위치를 찾는 데도 도움이 된다. 다만 모든 분기를 밑줄 패턴 하나로 처리하면 추가된 상태에 특별한 처리가 필요해도 눈에 띄지 않을 수 있다.

예제 코드

fn main() {
    let numbers: Vec<i32> = Vec::new();
    match numbers.first() {
        Some(value) => println!("{value}"),
        None => println!("empty"),
    }
}

실행 결과

empty

Result와 물음표 연산자: 실패를 호출자에게 전달

Result<T, E>는 성공 값 T 또는 오류 E를 담는다. 네트워크 연결, 파일 읽기, 문자열 파싱처럼 예상 가능한 실패에는 오류 정보를 값으로 돌려주고 호출자가 대응하게 하는 방식이 적합하다. 오류를 숨기고 0이나 빈 데이터를 반환하면 정상적인 결과와 실패를 구별할 수 없으므로 후속 계산이 조용히 잘못될 수 있다.

다음 doubled의 parse는 문자열을 u32로 변환하고 실패하면 ParseIntError를 반환한다. 물음표는 성공 값이 있으면 꺼내 계속 실행하고, 오류면 현재 함수에서 조기 반환해 호출자에게 전달한다. 이 함수에서는 같은 오류 타입을 사용하지만 일반적으로 물음표가 쓰이는 위치에서는 반환 타입과 오류 변환 조건도 맞아야 한다. 예외를 보이지 않게 무시하는 기호가 아니다.

입력 21은 Ok(42)로 이어지고 oops는 오류가 되어 invalid가 출력된다. 성공 이후의 곱셈은 u64로 변환한 뒤 수행하므로 u32 범위의 두 배도 이 예제의 반환 타입에 들어간다. 실제 오류 메시지를 사용자에게 보여줄 때는 입력 형식 오류, 권한 오류, 일시적인 통신 실패를 구분하고 재시도 가능 여부를 판단한다. 내부 경로나 비밀 값이 포함된 원시 오류를 무조건 외부에 노출하지 않는 것도 API 경계의 책임이다.

예제 코드

fn doubled(input: &str) -> Result<u64, std::num::ParseIntError> {
    let value: u32 = input.parse()?;
    Ok(u64::from(value) * 2)
}
fn main() {
    for input in ["21", "oops"] {
        match doubled(input) {
            Ok(value) => println!("{value}"),
            Err(_) => println!("invalid"),
        }
    }
}

실행 결과

42
invalid

panic, unwrap과 오버플로의 경계

Result가 오류라는 것과 프로그램이 panic한다는 것은 다르다. unwrap은 성공 값이 있다고 가정하고 꺼내며, Err나 None에서는 panic을 일으킨다. 학습용 고정 데이터나 테스트에서 전제를 분명히 드러내는 데 쓸 수 있지만, 사용자가 입력하는 값이 실패할 수 있는 서버 요청 경로에 무작정 넣으면 한 요청의 오류가 더 큰 실행 실패로 이어질 수 있다.

panic이 발생했을 때 스택을 되감는지 프로세스를 종료하는지는 빌드 설정 등의 영향을 받는다. panic을 모든 업무 오류를 처리하는 일반적인 예외 체계처럼 사용하지 않는다. 복구 가능한 오류는 Result로 표현하고, 프로그램의 불변 조건이 무너진 상황과 구별하는 것이 보통 더 명확하다. 메모리 안전성 검사가 입력의 업무 규칙까지 증명해 주지는 않는다.

정수 연산도 의도를 명시할 수 있다. 아래 u8의 250에 10을 더하는 연산은 범위를 넘는다. checked_add는 None을 돌려주고 wrapping_add는 0부터 255까지의 범위에서 순환한 4를 돌려준다. 일반 덧셈의 오버플로 검사 여부는 컴파일 설정의 영향을 받으므로 디버그 실행에서 본 결과만으로 운영 동작을 가정하지 않는다. 순환이 프로토콜의 요구인지, 범위 초과를 오류로 처리해야 하는지 먼저 정한 뒤 해당 연산을 선택한다.

예제 코드

fn main() {
    let value: u8 = 250;
    println!("{:?}", value.checked_add(10));
    println!("{}", value.wrapping_add(10));
}

실행 결과

None
4

구조체와 메서드: 데이터와 허용 동작 묶기

구조체는 서로 관련 있는 값을 필드로 묶는다. impl 블록은 그 타입의 관련 함수와 메서드를 정의하는 데 쓰인다. 메서드의 self 형태는 소유 관계도 전달한다. &self는 읽기 위주의 빌림, &mut self는 변경 가능한 빌림, self는 소유권을 가져오는 호출을 표현한다. 메서드 이름뿐 아니라 이 수신자 타입을 보면 호출 뒤 객체를 계속 사용할 수 있는지 판단하는 데 도움이 된다.

아래 Rectangle은 너비와 높이를 가지고 area는 &self로 읽어 면적을 돌려준다. 호출해도 rect의 소유권을 넘기지 않으므로 면적을 구한 뒤 width도 출력할 수 있다. 결과는 12 3이다. 계산은 u64로 확장해 두 u32의 곱을 담는다. 실제 길이의 단위까지 타입이 구분하지는 않으므로 픽셀인지 밀리미터인지 혼동하지 않게 별도 설계가 필요하다.

구조체를 사용했다고 자동으로 유효한 상태만 생성되는 것은 아니다. 필드 공개 범위를 줄이고 검증하는 생성 함수를 두면 조건을 유지하는 데 도움이 된다. 예를 들어 너비 0을 금지할 것인지 허용할 것인지는 업무 규칙이다. 생성 단계에서 제한할지, 면적 0을 정상 상태로 다룰지 결정하고 테스트에도 반영한다.

예제 코드

struct Rectangle {
    width: u32,
    height: u32,
}
impl Rectangle {
    fn area(&self) -> u64 {
        u64::from(self.width) * u64::from(self.height)
    }
}
fn main() {
    let rect = Rectangle { width: 3, height: 4 };
    println!("{} {}", rect.area(), rect.width);
}

실행 결과

12 3

트레이트와 제네릭: 공통 동작을 요구하기

트레이트는 어떤 타입이 제공해야 할 동작을 표현한다. 제네릭 함수는 구체적인 타입 하나에만 묶이지 않고, 필요한 트레이트를 만족하는 타입을 받을 수 있다. 중요한 것은 “아무 타입이나 받는다”가 아니라 “이 동작을 제공하는 타입을 받는다”는 제약이다. 이를 통해 기능 재사용과 컴파일 단계의 검사를 함께 얻는다.

아래 Label은 label 메서드를 요구한다. Ticket은 Label을 구현하고 print_label은 T: Label이라는 조건을 가진 제네릭 함수다. 함수 안에서는 구체적인 Ticket의 다른 필드를 알 필요 없이 label을 호출할 수 있다. 출력은 help desk다. 다른 타입도 Label을 구현하면 같은 함수로 표시할 수 있으므로 출력 로직을 타입마다 복사하지 않아도 된다.

이 방식은 기반 클래스의 모든 상태와 구현을 상속받는 구조와 다르다. 서로 관련 없는 타입에도 공통 인터페이스를 제공할 수 있다. 한편 제네릭 정적 디스패치와 &dyn Trait 같은 트레이트 객체의 동적 디스패치는 선택 기준이 다르다. 전자는 구체 타입에 맞춘 코드를 생성하여 최적화하기 좋고, 후자는 실행 중 서로 다른 구현을 같은 인터페이스로 다루기 좋다. 코드 크기, 컴파일 시간, 동적 호출과 할당 여부를 함께 고려하며, dyn을 썼다는 이유만으로 모든 호출에 힙 할당이 생기는 것은 아니다.

예제 코드

trait Label {
    fn label(&self) -> &str;
}
struct Ticket {
    title: String,
}
impl Label for Ticket {
    fn label(&self) -> &str {
        &self.title
    }
}
fn print_label<T: Label>(item: &T) {
    println!("{}", item.label());
}
fn main() {
    let item = Ticket { title: String::from("help desk") };
    print_label(&item);
}

실행 결과

help desk

컬렉션과 이터레이터: 데이터를 어떤 방식으로 순회할까

Vec<T>는 같은 타입의 값을 가변 길이로 보관하는 데 많이 쓰인다. iter는 원소를 빌려 순회하고, iter_mut는 변경 가능한 참조로 순회하며, 컬렉션 값 자체의 into_iter는 보통 원소의 소유권을 넘기는 순회를 제공한다. 동일한 반복문처럼 보여도 이후 원본을 사용할 수 있는지와 원소를 변경할 수 있는지가 다르므로 구별해야 한다.

다음 예제는 점수 중 60 이상만 골라 합계를 낸다. copied는 i32 참조에서 Copy 가능한 정수 값을 얻어 후속 클로저를 간단하게 만든다. filter는 조건을 만족하는 값만 통과시키고 sum이 실제 순회를 소비하여 합계를 구한다. 결과는 218이며 원본 벡터도 그대로 출력할 수 있다. 모든 원소를 새 벡터로 수집한 뒤 합계를 계산할 필요 없이 처리 흐름을 연결한 것이다.

이터레이터 어댑터는 대체로 지연 평가되므로 map이나 filter를 만들기만 하고 소비하지 않으면 기대한 처리가 일어나지 않는다. 반면 collect로 중간 결과를 모으면 그 컬렉션을 저장할 메모리가 필요하다. 루프가 긴 체인보다 더 명확한 경우도 있으므로 짧게 쓰는 것만 목표로 삼지 않는다. 입력이 클 때는 순회 횟수, 중간 할당, 정렬 필요성을 함께 살핀다. HashMap 순회 순서처럼 보장되지 않는 순서를 보고서 출력에 그대로 의존하지 않는 것도 중요하다.

예제 코드

fn main() {
    let scores = vec![42_i32, 67, 91, 60];
    let total: i32 = scores.iter().copied().filter(|v| *v >= 60).sum();
    println!("{total}");
    println!("{scores:?}");
}

실행 결과

218
[42, 67, 91, 60]

스마트 포인터와 내부 가변성

단일 소유 구조가 자연스러운 코드에서는 값과 참조만으로 충분하지만, 트리의 여러 지점에서 같은 데이터를 공유하는 등 더 복잡한 요구가 생길 수 있다. Box<T>는 값을 힙에 소유하고, Rc<T>는 단일 스레드에서 참조 횟수 기반의 공동 소유를 제공한다. Arc<T>는 원자적인 참조 횟수 관리로 스레드 간 소유 핸들 공유에 쓰인다. 각 타입은 문제를 해결하는 대신 비용과 제약을 추가한다.

Rc를 clone하면 같은 값을 가리키는 소유 핸들이 하나 늘어난다. 내부 값 전체가 복사되어 독립된 값이 되는 것은 아니다. 서로가 서로를 강한 Rc로 소유하는 순환 구조는 참조 횟수가 0이 되지 않아 메모리가 회수되지 않을 수 있다. 소유 관계가 아닌 역방향 연결에는 Weak 등을 검토한다. Rust의 메모리 안전성이 모든 메모리 누수를 금지하는 성질은 아니라는 점을 보여 주는 사례다.

RefCell은 일부 빌림 규칙을 실행 중 검사하는 내부 가변성 타입이다. 컴파일러가 정적으로 표현하기 어려운 구조에서 유용할 수 있지만, borrow_mut가 살아 있는 동안 충돌하는 borrow를 요청하면 panic이 생길 수 있다. 즉 규칙을 제거하는 수단이 아니라 검사 시점과 API를 바꾸는 수단이다. 스레드 간 공유 문제는 RefCell을 붙이는 것만으로 해결되지 않는다. 데이터 소유 구조를 단순화할 수 있는지 먼저 검토하고 필요한 동기화 수단을 선택한다.

스레드와 공유 상태: Arc만으로 변경이 안전해지지는 않는다

여러 스레드가 같은 값을 사용하려면 수명과 전송 가능성뿐 아니라 변경을 동기화하는 방법도 필요하다. Arc는 공유 소유를 위한 참조 횟수를 안전하게 관리하지만 내부 데이터의 임의 변경을 자동으로 잠그지는 않는다. 흔한 구성인 Arc<Mutex<T>>에서 Arc는 소유 핸들을 공유하고 Mutex는 내부 값에 대한 접근을 조절하는 서로 다른 역할을 한다.

다음 예제는 두 스레드가 각각 100번 카운터를 증가시킨다. lock으로 얻은 guard를 통해 값을 변경하고, 그 guard가 반복문의 해당 블록을 벗어나며 잠금이 해제된다. 메인 스레드는 모든 join이 끝난 뒤 결과를 읽으므로 200을 출력한다. join을 생략하고 너무 빨리 출력하면 모든 증가가 끝나기 전 상태를 볼 수 있다. 잠금과 작업 완료 대기는 다른 책임이다.

이 코드는 고정된 실습 조건에서 expect를 사용했다. 실제 서비스는 잠금 중 panic에 따른 오염과 작업 실패를 어떻게 처리할지 정해야 한다. 또 메모리 안전한 잠금 코드를 작성해도 두 잠금을 서로 다른 순서로 얻는 교착 상태는 생길 수 있다. 잠금을 오래 잡고 외부 통신을 기다리거나 큰 계산을 수행하면 경쟁이 커진다. 단순한 카운터라면 원자적 정수 같은 선택지도 있지만, 복수 필드의 일관된 갱신까지 단일 원자 연산으로 해결된다고 가정하지 않는다.

예제 코드

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
    let count = Arc::new(Mutex::new(0_u32));
    let mut workers = Vec::new();
    for _ in 0..2 {
        let local = Arc::clone(&count);
        workers.push(thread::spawn(move || {
            for _ in 0..100 {
                let mut guard = local.lock().expect("counter lock");
                *guard += 1;
            }
        }));
    }
    for worker in workers {
        worker.join().expect("worker completed");
    }
    println!("{}", *count.lock().expect("counter lock"));
}

실행 결과

200

비동기: await는 병렬 실행을 보장하는 표시가 아니다

async fn을 호출하면 작업의 최종 결과를 곧바로 얻는 대신 Future를 얻는다. Future는 완료될 계산을 표현하며, 실행자가 이를 진행시켜야 실제 작업이 수행된다. await는 그 결과가 준비될 때까지 현재 비동기 흐름이 기다릴 수 있는 지점이다. 대기 중 다른 작업을 진행할 수 있다는 점이 다수의 입출력 작업을 처리하는 데 유용하다.

하지만 두 작업에 차례대로 await를 붙이면 첫 작업이 끝난 뒤 두 번째 작업을 시작하는 구조가 될 수 있다. 동시에 진행시키려면 사용하는 런타임의 작업 생성이나 결합 기능을 의도적으로 사용해야 한다. CPU를 오래 독점하는 계산을 async 함수 안에 넣었다고 다른 코어에서 자동 실행되는 것도 아니다. 실행자 스레드를 막는 동기식 입출력이나 긴 계산은 다른 비동기 작업의 응답성을 떨어뜨릴 수 있다.

실제 비동기 코드는 런타임 선택, 시간 제한, 취소, 실패 전파, 작업 수 제한까지 함께 설계한다. 요청을 무한히 작업으로 생성하면 메모리나 연결 수가 고갈될 수 있다. 잠금 guard를 await 너머까지 유지하는 코드도 점검 대상이다. 일부 API에서는 타입 조건 때문에 컴파일이 거부되고, 컴파일되는 상황에서도 다른 작업이 필요한 자원을 오래 점유할 수 있다. async라는 표면 문법보다 무엇을 기다리며 어떤 자원을 쥐고 있는지가 중요하다.

매크로와 derive: 코드를 생성하는 기능의 역할

println!과 vec!처럼 느낌표로 호출하는 매크로는 입력을 받아 코드를 생성하는 기능이다. 가변 개수의 인자나 반복적인 패턴처럼 일반 함수만으로 표현하기 불편한 작업을 도울 수 있다. 매크로가 생성한 코드도 Rust의 타입 검사와 다른 규칙을 따라야 하므로, 매크로를 썼다는 이유만으로 잘못된 타입 조합이 허용되지는 않는다.

derive는 Debug, Clone 같은 트레이트 구현을 자동 생성하는 대표적인 방식이다. #[derive(Debug)]를 붙인 구조체는 디버그 출력 형식을 쉽게 얻을 수 있지만, 그 출력이 안정적인 저장 형식이나 외부 API 규약을 보장하는 것은 아니다. 로그에 비밀 필드가 그대로 포함되지 않는지도 살펴야 한다. #[derive(Clone)]은 각 필드의 Clone 동작을 사용하므로 필드에 따라 데이터 복사 비용과 공유 의미가 달라진다.

매크로는 반복을 줄이지만 생성된 코드가 길거나 컴파일 오류의 위치가 확장 결과를 가리키면 원인을 찾기 어려워질 수 있다. 작은 함수나 제네릭으로 충분한 작업까지 매크로로 감싸지 않고, 입력 문법과 오류 메시지, 유지보수 비용을 고려한다. 절차적 매크로를 포함한 의존 패키지는 빌드 과정에 참여하는 코드이므로 의존성 선택을 단순한 텍스트 치환 도구 선택으로 여기지 않는다.

unsafe와 외부 코드의 경계

unsafe는 컴파일러가 검증하지 못하는 일부 저수준 연산의 안전 조건을 작성자가 책임지는 영역이다. 원시 포인터 역참조나 unsafe 함수 호출 등이 대표적이다. 블록을 unsafe로 감쌌다고 모든 타입 검사와 빌림 검사가 꺼지는 것은 아니며, 정의되지 않은 동작을 허용해도 된다는 허가도 아니다. 해당 연산이 요구하는 전제 조건을 실제로 만족해야 한다.

예를 들어 원시 포인터로 메모리를 읽으려면 포인터가 해당 접근에 유효한지, 정렬이 맞는지, 초기화된 적합한 값인지, 다른 접근과의 별칭 규칙을 위반하지 않는지 등을 확인해야 한다. 길이와 주소를 외부에서 받는다고 그 조합이 자동으로 유효해지는 것은 아니다. Rust로 작성한 함수가 C 라이브러리를 부르는 경계에서도 소유권 이전, 버퍼 크기, 문자열 종료 방식, 콜백 수명 같은 계약이 중요하다.

일반 사용자는 가능하면 검증된 안전 API를 이용하고, 저수준 구현자는 unsafe 범위를 작게 유지하여 전제와 불변 조건을 문서화한다. 안전한 래퍼를 제공하려면 외부 호출자가 안전한 방식으로 사용할 수 있는 모든 경우에도 내부 조건이 유지되어야 한다. 단순히 테스트가 몇 번 통과했다는 사실만으로 모든 메모리 안전성이 증명되지는 않는다. unsafe 코드를 포함한 라이브러리의 잘못된 구현은 안전한 호출 코드에도 영향을 줄 수 있다.

도구 체계: rustc, Cargo, 포매터와 진단

rustc는 컴파일러이고 Cargo는 패키지의 빌드·실행·테스트와 의존성 관리를 다루는 도구다. Cargo.toml은 패키지 설정과 의존성 조건을 표현하며 Cargo.lock은 해결된 의존성 버전 정보를 기록한다. 소스 파일만 복사하면 동일한 빌드가 재현된다고 보지 않고, 도구 버전과 대상 플랫폼, 특징 선택과 잠금 정보를 함께 다룬다.

개발 중에는 cargo check로 실행 파일 생성까지 수행하지 않고 많은 컴파일 오류를 확인할 수 있다. cargo build는 빌드하고 cargo run은 실행까지 연결하며 cargo test는 테스트를 수행한다. cargo fmt는 코드 모양을 일관되게 정리하고 cargo clippy는 추가적인 개선 진단을 제공한다. 진단 제안이 업무 의미를 이해한 완전한 정답은 아니므로, 제안을 적용한 뒤 코드의 동작과 테스트를 확인한다.

컴파일 오류는 첫 메시지 하나가 여러 후속 오류를 유발할 수 있어 앞쪽의 근본 원인부터 해결하는 편이 효율적이다. 소유권 오류에서는 “이동한 위치”, “나중에 사용한 위치”, “빌림이 유지되는 위치”를 함께 읽는다. 개발 환경의 코드 분석 기능은 빠른 피드백에 유용하지만 실제 프로젝트 설정에서의 빌드 결과와 구분한다. 오래된 실행 파일이나 다른 특징 설정을 테스트하고 있지 않은지도 확인한다.

실전 예제: 입력 검증과 집계를 분리한 수량 계산

아래 예제는 공백으로 구분된 수량을 받아 합계를 계산한다. 목표는 고급 문법을 많이 보여 주는 것이 아니라, 입력 해석과 합산의 실패를 어떻게 명시하는지 보여 주는 것이다. total_quantity는 출력이나 파일 접근을 하지 않고 문자열을 받아 Result를 돌려준다. 이처럼 핵심 계산을 입출력과 분리하면 같은 로직을 명령줄, 웹 요청, 테스트에서 재사용하기 쉽다.

split_whitespace는 연속된 공백을 구분자로 처리한다. 각 토큰의 parse::<u32>()는 음수나 숫자가 아닌 값을 거부하며 map_err는 그 실패를 설명 문자열로 바꾼다. 이후 checked_add는 합계가 u32 범위를 넘는지 확인하고, None을 ok_or_else로 오류로 바꾼다. 빈 입력은 이 설계에서 0이다. 수량 목록이 반드시 하나 이상 있어야 한다면 그 조건은 따로 추가해야 한다.

main은 두 입력을 전달해 정상 합계 12와 bad quantity: x를 출력한다. 오류 문자열에는 실습을 위해 잘못된 토큰을 포함했지만, 민감한 입력을 다루는 서비스라면 그대로 로그나 응답에 넣지 않을 수 있다. 또한 합계 범위를 넘었을 때 일부 값의 합을 정상 결과처럼 반환하지 않고 전체 실패를 반환한다. 이런 정책을 함수 이름과 반환 타입만으로 다 설명할 수는 없으므로 문서와 테스트로 보충한다.

예제 코드

fn total_quantity(input: &str) -> Result<u32, String> {
    let mut total = 0_u32;
    for token in input.split_whitespace() {
        let quantity = token.parse::<u32>()
            .map_err(|_| format!("bad quantity: {token}"))?;
        total = total.checked_add(quantity)
            .ok_or_else(|| String::from("total too large"))?;
    }
    Ok(total)
}
fn main() {
    for input in ["2 3 7", "2 x 7"] {
        match total_quantity(input) {
            Ok(value) => println!("{value}"),
            Err(message) => println!("{message}"),
        }
    }
}

실행 결과

12
bad quantity: x

테스트: 정상 출력뿐 아니라 경계 조건 확인

앞의 수량 계산 함수에는 적어도 정상 값, 빈 입력, 숫자가 아닌 입력, 음수, 합계 범위 초과가 서로 다른 검증 대상이다. 코드가 컴파일된다는 것은 타입과 안전 규칙의 일부를 만족한다는 뜻이지 모든 업무 결과가 맞다는 뜻이 아니다. 빈 입력을 0으로 허용할지 오류로 볼지처럼 정책이 필요한 부분은 테스트가 그 결정을 드러내도록 한다.

다음 테스트 모듈을 앞 프로그램과 같은 파일에 추가하고 cargo test를 실행하면 네 가지 조건을 확인할 수 있다. assert_eq는 실제 반환값과 예상값을 비교하고 is_err는 오류가 발생하는지 확인한다. 단순히 함수를 호출하고 아무 검사도 하지 않는 테스트는 결과가 잘못되어도 통과할 수 있다.

테스트에서는 정상 합계 12를 기대하고, 빈 문자열을 0으로 취급하며, 잘못된 토큰과 오버플로를 거부한다. 이후 최대 수량 제한이나 입력 길이 제한을 추가하면 해당 경계의 바로 아래·같은 값·바로 위를 확인한다. 외부 파일이나 네트워크가 필요한 부분은 순수 계산과 나누면 재현하기 쉽다. 다만 테스트가 통과해도 검토하지 않은 입력 조합과 동시성 문제는 남을 수 있으므로 테스트와 설계 검토를 함께 사용한다.

예제 코드

#[cfg(test)]
mod tests {
    use super::total_quantity;
    #[test]
    fn sums_valid_values() {
        assert_eq!(total_quantity("2 3 7"), Ok(12));
    }
    #[test]
    fn empty_input_is_zero() {
        assert_eq!(total_quantity(""), Ok(0));
    }
    #[test]
    fn rejects_invalid_value() {
        assert!(total_quantity("2 x 7").is_err());
    }
    #[test]
    fn rejects_overflow() {
        assert!(total_quantity("4294967295 1").is_err());
    }
}

성능을 해석하는 방법과 개발 비용

Rust의 성능을 평가할 때 언어 이름만으로 결론을 내리지 않는다. 같은 기능이라도 벡터를 매번 복제하는 구현과 슬라이스를 빌리는 구현, 자료를 여러 번 정렬하는 구현과 한 번만 순회하는 구현의 차이가 더 클 수 있다. 측정 전에 어떤 입력 크기와 분포, 동시 요청 수, 지연 시간 목표를 비교하는지 정해야 한다.

개발용 빌드는 디버깅 편의를 위한 설정을 사용하므로 최적화된 빌드와 실행 시간이 다를 수 있다. 그렇다고 --release 결과 한 번만 재고 모든 환경에서 빠르다고 단정하지 않는다. 준비 시간과 캐시 상태, 파일 시스템, 데이터 복사와 할당량, 측정 변동을 함께 관찰한다. 이터레이터나 제네릭의 추상화가 잘 최적화될 수 있다는 말도 모든 코드에서 비용이 정확히 0이라는 의미는 아니다.

개발 비용 측면에서는 긴 컴파일 시간, 복잡한 타입 오류, 비동기와 수명이 결합한 API의 학습 부담이 생길 수 있다. 기존 팀이 사용하는 라이브러리와 도구, 배포 대상, 외부 시스템과의 연동도 선택에 영향을 준다. 안전성을 얻는 이점과 설계·검증에 드는 시간을 함께 평가해야 하며, 이미 안정적인 모든 소프트웨어를 Rust로 다시 작성하는 것을 자동적인 개선으로 볼 수는 없다.

적합한 활용과 다른 접근과의 비교

메모리 사용과 지연 시간, 네이티브 코드와의 연동을 세밀하게 다뤄야 하면서 메모리 오용을 줄이고 싶은 구성 요소에서 Rust를 검토할 수 있다. 파일을 분석하는 명령줄 도구, 프로토콜 처리, 시스템 서비스, 라이브러리의 계산 핵심처럼 경계가 명확한 부분부터 적용하면 요구 사항과 비용을 평가하기 쉽다. 모든 부분을 한 언어로 통일해야 하는 것은 아니다.

C나 C++ 기반 코드와 비교할 때는 안전한 Rust API가 소유·빌림 관계를 검사한다는 장점과 기존 라이브러리·ABI·빌드 체계와의 통합 비용을 함께 본다. 언어가 달라도 데이터 배치, 호출 규약, 소유권 계약이 틀리면 연동 오류가 생길 수 있다. 고수준 스크립트 언어와 비교할 때는 배포 형태와 실행 비용뿐 아니라 구현 속도와 생태계의 차이도 중요하다.

간단한 자동화는 이미 익숙한 언어의 표준 라이브러리로 더 빨리 해결할 수 있고, 웹 서비스는 프레임워크와 운영 경험이 선택에 큰 영향을 줄 수 있다. Rust를 선택할 이유를 “유행”이나 단일 벤치마크 순위보다 해당 서비스의 병목, 장애 원인, 유지보수 조건으로 설명할 수 있어야 한다. 컴파일 단계의 검사가 강해도 잘못된 권한 검사나 잘못된 계산식 같은 논리 오류까지 자동으로 사라지지는 않는다.

학습 순서와 자주 하는 오해

처음에는 변수·함수·분기와 Vec·String을 익힌 뒤, 값 전달과 참조 전달을 바꾸어 보면서 소유권을 실험하는 편이 좋다. 일부러 이동 후 값을 사용하는 코드를 만들고 컴파일러가 가리키는 두 위치를 확인하면 규칙의 목적이 드러난다. 이어 Option과 Result로 값의 부재와 실패를 다루고, 구조체와 트레이트로 작은 프로그램을 나누면 여러 기능이 어떻게 연결되는지 이해하기 쉽다.

수명 표기, Rc·RefCell, Arc·Mutex, 비동기와 unsafe는 각각 별도 문제를 해결한다. 컴파일 오류를 없애려는 목적으로 이 도구들을 한꺼번에 도입하면 어디에서 소유·변경·동기화 책임이 생기는지 파악하기 어려워진다. 먼저 데이터를 누가 소유하는지 적고, 참조가 꼭 필요한지, 값을 반환하면 단순해지는지 검토한다. 오류를 재현하는 작은 프로그램을 만드는 습관도 도움이 된다.

대표적인 오해는 “안전한 언어이므로 panic과 교착 상태가 없다”, “Arc면 내부 변경도 안전하다”, “clone은 언제나 깊은 복사다”, “수명 표기가 실제 객체를 오래 살린다”, “async를 붙이면 계산이 병렬화된다” 등이다. 각각 메모리 안전성과 논리적 올바름, 공유 소유와 동기화, 타입별 복제 의미, 유효성 관계와 생존 기간, 동시성과 병렬성을 혼동한 것이다. 개념을 분리해서 작은 예제로 확인하는 것이 긴 문법 목록을 암기하는 것보다 실전 코드 이해에 도움이 된다.

참고 자료