6 분 소요

Rust를 처음 공부할 때 가장 먼저 익혀야 하는 문법은 변수, 타입, 제어흐름, 함수다. 이 4가지를 이해하면 이후에 struct, enum, ownership, borrow 같은 개념을 배울 때도 훨씬 수월하다. 이 글에서는 Cargo 프로젝트 하나를 기준으로 각 문법을 하나씩 설명하고, 바로 실행해 볼 수 있는 예제까지 함께 정리한다.

실습 프로젝트 만들기

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

cargo new rust-basic-syntax
cd rust-basic-syntax
code .

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

cargo run

변수

Rust의 변수는 기본적으로 변경 불가능하다. 즉, let으로 만든 값은 다시 대입할 수 없고, 값을 바꾸려면 mut를 붙여 let mut 형태로 선언해야 한다.

아래 예제는 변경 불가능한 변수, 변경 가능한 변수, 그리고 섀도잉(shadowing)을 함께 보여 준다.

fn main() {
    let count = 10;
    println!("count = {}", count);

    let mut level = 1;
    level = level + 1;
    println!("level = {}", level);

    let spaces = "   ";
    let spaces = spaces.len();
    println!("spaces length = {}", spaces);
}

위 코드에서 핵심은 아래 3가지다.

  • countlet으로 선언했기 때문에 다시 값을 넣을 수 없다.
  • levellet mut으로 선언했기 때문에 값을 바꿀 수 있다.
  • spaces는 섀도잉(shadowing) 예제다. 같은 이름을 다시 let으로 선언해서 문자열을 길이 값으로 덮어쓴다.

예를 들어 아래처럼 let으로 선언한 변수에 다시 값을 넣으려고 하면 컴파일 에러가 난다.

fn main() {
    let count = 10;
    count = 20;
}

Rust는 이 경우 count가 불변 변수이므로 재할당할 수 없다고 알려 준다.

불변 변수 재할당 에러

값을 바꾸고 싶다면 아래처럼 mut를 붙여 선언해야 한다.

fn main() {
    let mut count = 10;
    count = 20;

    println!("count = {}", count);
}

실행 결과는 아래와 같다.

변수 예제 결과

섀도잉(shadowing)은 mut와 다르다. mut는 같은 변수의 값을 변경하는 것이고, 섀도잉(shadowing)은 같은 이름의 새 변수를 다시 만드는 것이다. 그래서 섀도잉(shadowing)은 타입까지 바꾸는 데 쓸 수 있다.

타입

Rust는 타입 추론이 꽤 강한 편이라 많은 경우 타입을 직접 쓰지 않아도 된다. 하지만 Rust에 어떤 타입들이 있는지 한 번에 보고 넘어가면 이후 문법을 이해하기가 훨씬 편하다. 먼저 초반에 자주 보게 되는 기본 타입들을 표로 정리하면 아래와 같다.

분류 타입 설명
부호 있는 정수 i8, i16, i32, i64, i128, isize 음수와 양수를 모두 저장할 수 있는 정수 타입이다. 일반적으로 i32를 가장 자주 본다.
부호 없는 정수 u8, u16, u32, u64, u128, usize 0 이상만 저장하는 정수 타입이다. 인덱스에는 usize가 자주 등장한다.
부동소수점 f32, f64 소수점을 포함하는 실수 타입이다. 보통 f64를 더 많이 사용한다.
논리값 bool true 또는 false 값을 가진다.
문자 char 작은따옴표를 사용하는 문자 하나를 저장한다.
문자열 슬라이스 &str 읽기 전용 문자열 참조다. 문자열 리터럴이 여기에 해당한다.
문자열 String 소유권을 가지는 가변 문자열 타입이다.
튜플 (T1, T2, ...) 여러 값을 하나로 묶는다. 서로 다른 타입도 함께 넣을 수 있다.
배열 [T; N] 같은 타입의 값을 고정 길이로 저장한다.

처음에는 위 타입을 다 외우려고 하기보다, 자주 쓰는 타입부터 예제로 익히는 편이 좋다.

fn main() {
    let age: i32 = 29;
    let temperature: f64 = 36.5;
    let is_rust_fun: bool = true;
    let grade: char = 'A';
    let language: &str = "Rust";
    let message: String = String::from("hello");

    println!("age = {}", age);
    println!("temperature = {}", temperature);
    println!("is_rust_fun = {}", is_rust_fun);
    println!("grade = {}", grade);
    println!("language = {}", language);
    println!("message = {}", message);
}

실행 결과는 아래와 같다.

타입 예제 결과

위 예제에서 특히 자주 쓰는 타입은 아래처럼 보면 된다.

  • i32: 가장 기본적인 정수 예제로 자주 등장한다.
  • f64: 실수 계산에서 기본처럼 많이 사용된다.
  • bool: 조건문과 함께 거의 항상 사용된다.
  • char: 문자 하나를 저장할 때 사용한다.
  • &str: 문자열 리터럴을 다룰 때 가장 자주 보게 된다.
  • String: 문자열을 소유하고 수정 가능한 형태로 다룰 때 사용한다.

tuple과 array도 기본 타입이지만, 일단 초반에는 위 타입들에 익숙해지는 것만으로도 충분하다. tuple과 array는 이 글의 종합 예제에서 다시 등장한다.

타입 명시가 왜 필요한지 보여 주는 대표적인 예제는 문자열 파싱이다.

fn main() {
    let guess: i32 = "42".parse().expect("숫자를 입력해야 합니다.");
    println!("guess = {}", guess);
}

실행 결과는 아래와 같다.

문자열 파싱 예제 결과

parse()만 보면 문자열을 어떤 숫자 타입으로 바꿔야 하는지 애매하기 때문에, guess: i32처럼 타입을 알려 줘야 컴파일러가 올바르게 처리할 수 있다.

제어흐름

제어흐름은 프로그램이 어떤 순서로 실행될지 결정하는 문법이다. Rust에서는 if, loop, while, for, match를 자주 사용한다.

if

if는 조건에 따라 다른 코드를 실행할 때 사용한다.

fn main() {
    let number = 7;

    if number % 2 == 0 {
        println!("짝수입니다.");
    } else {
        println!("홀수입니다.");
    }
}

실행 결과는 아래와 같다.

if문 예제 결과

Rust의 if는 조건식이 반드시 bool이어야 한다. C 언어나 JavaScript처럼 숫자를 조건문에 바로 넣는 방식은 허용되지 않는다.

또한 if는 표현식으로도 사용할 수 있다.

fn main() {
    let score = 85;
    let result = if score >= 80 { "pass" } else { "retry" };

    println!("result = {}", result);
}

실행 결과는 아래와 같다.

if 표현식 예제 결과

loop

loop는 명시적으로 break를 만날 때까지 계속 반복한다.

fn main() {
    let mut count = 0;

    let result = loop {
        count += 1;

        if count == 3 {
            break count * 10;
        }
    };

    println!("result = {}", result);
}

실행 결과는 아래와 같다.

loop 예제 결과

Rust의 loopbreak와 함께 값을 돌려줄 수 있다는 점이 특징이다. 위 예제에서는 count == 3일 때 30result에 저장된다.

while

while은 조건이 참인 동안 반복할 때 사용한다.

fn main() {
    let mut remaining = 3;

    while remaining > 0 {
        println!("remaining = {}", remaining);
        remaining -= 1;
    }

    println!("start");
}

실행 결과는 아래와 같다.

while 예제 결과

반복 횟수가 조건에 의해 자연스럽게 줄어드는 경우에는 while이 읽기 쉽다.

for

for는 배열, 벡터, 범위 같은 반복 가능한 값을 순회할 때 가장 자주 쓰는 문법이다.

fn main() {
    let tools = ["rustc", "cargo", "clippy"];

    for tool in tools {
        println!("tool = {}", tool);
    }

    for number in 1..=3 {
        println!("number = {}", number);
    }
}

실행 결과는 아래와 같다.

for문 예제 결과

실무에서도 단순 카운팅 반복보다 for를 훨씬 많이 쓴다. 특히 컬렉션을 돌 때는 while보다 for가 더 안전하고 코드도 짧다.

match

Rust에서 분기 처리를 더 명확하게 쓰고 싶을 때는 match가 매우 자주 등장한다.

fn main() {
    let score = 85;

    let grade = match score {
        90..=100 => "A",
        80..=89 => "B",
        70..=79 => "C",
        _ => "D",
    };

    println!("grade = {}", grade);
}

실행 결과는 아래와 같다.

match문 예제 결과

match는 값의 경우를 빠짐없이 처리하도록 강제하는 데 강점이 있다. 그래서 단순한 if/else if보다 더 읽기 쉽고 안전한 경우가 많다.

함수

함수는 반복되는 코드를 묶고, 입력과 출력을 명확하게 나누는 가장 기본적인 단위다. Rust에서는 fn 키워드로 함수를 정의한다.

fn print_user(name: &str, age: u32) {
    println!("name = {}, age = {}", name, age);
}

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn max(a: i32, b: i32) -> i32 {
    if a > b {
        a
    } else {
        b
    }
}

fn main() {
    print_user("K4NUL", 30);

    let sum = add(10, 20);
    let bigger = max(7, 11);

    println!("sum = {}", sum);
    println!("bigger = {}", bigger);
}

실행 결과는 아래와 같다.

함수 예제 결과

이 예제에서 볼 점은 아래와 같다.

  • print_user는 반환 타입을 명시하지 않았기 때문에 ()를 반환한다.
  • addmax-> i32처럼 반환 타입을 명시했다.
  • a + b처럼 줄 끝에 세미콜론이 없으면 그 값이 반환된다.

Rust에서 세미콜론은 중요하다. 아래처럼 마지막 줄에 세미콜론을 붙이면 반환값이 아니라 단순한 statement가 되므로 의도한 타입과 맞지 않아 오류가 날 수 있다.

fn add(a: i32, b: i32) -> i32 {
    a + b;
}

이 경우 마지막 줄의 결과는 i32가 아니라 ()로 처리되기 때문에, 함수 선언의 반환 타입인 i32와 맞지 않아 컴파일 에러가 난다.

즉, 반환값이 필요한 함수에서는 마지막 표현식에 세미콜론을 붙이지 않는 습관이 중요하다.

한 번에 보는 종합 예제

지금까지 본 내용을 한 파일에 모으면 아래처럼 정리할 수 있다.

fn describe_score(score: i32) -> &'static str {
    match score {
        90..=100 => "excellent",
        80..=89 => "good",
        70..=79 => "not bad",
        _ => "keep practicing",
    }
}

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    let user = "rust beginner";
    let mut score = 70;
    score = score + 15;

    let level = if score >= 80 { "intermediate" } else { "starter" };

    let point: (i32, i32) = (10, 20);
    let numbers: [i32; 3] = [1, 2, 3];

    println!("user = {}", user);
    println!("score = {}", score);
    println!("level = {}", level);
    println!("point = ({}, {})", point.0, point.1);

    for number in numbers {
        println!("number = {}", number);
    }

    let mut countdown = 3;
    while countdown > 0 {
        println!("countdown = {}", countdown);
        countdown -= 1;
    }

    let total = add(10, 20);
    println!("total = {}", total);
    println!("description = {}", describe_score(score));
}

이 예제에는 변수, 타입, if, while, for, match, 함수 선언과 반환값이 모두 들어 있다. 처음에는 각각 따로 실행해 보고, 마지막에 종합 예제를 한 번 더 돌려 보면 흐름이 더 잘 잡힌다.

정리

이번 글에서는 Rust의 가장 기본이 되는 변수, 타입, 제어흐름, 함수를 한 번에 정리했다. letlet mut의 차이, 숫자와 문자 같은 기본 타입, if/loop/while/for/match의 용도, 그리고 함수의 인자와 반환값 문법을 이해하면 이후 문법을 익히는 속도가 훨씬 빨라진다.

다음 단계로는 ownership, borrow, reference를 배우면서 왜 Rust가 메모리 안전성과 성능을 동시에 가져갈 수 있는지 이해해 보면 좋다.

댓글남기기