Rust

Rust 처음 배우기: TypeScript 개발자의 도전기

드리프트2 2024. 10. 21. 20:40

Rust 처음 배우기: TypeScript 개발자의 도전기

안녕하세요!

 

프론트엔드 개발을 주로 하며 TypeScript를 사용하는 개발자입니다.

 

최근에 Rust를 배우기 시작했는데, 처음에는 어디서부터 시작해야 할지 막막했어요.

 

그래서 Rust를 처음 접하는 분들, 특히 TypeScript를 주로 사용하는 분들을 위해 이 글을 작성하게 되었는데요.

 

Rust의 기초를 다루고 TypeScript와의 비교를 통해 더 쉽게 이해할 수 있도록 돕고자 합니다.

 

이 글이 Rust를 배우고자 하는 분들에게 도움이 되기를 바랍니다.

 


Rust란 무엇인가?

Rust는 메모리 안전성과 병행성을 보장하는 시스템 프로그래밍 언어인데요.

 

Rust는 C/C++ 같은 저수준 언어의 성능을 가지면서도, 메모리 관리에서 발생할 수 있는 여러 문제를 해결할 수 있도록 설계되었습니다.

 

처음 Rust 문서를 읽을 때, 문법이 조금 생소하게 느껴지기도 했지만, Rust의 장점이 잘 설명된 부분이 있어 많은 도움이 되었어요.

 

Rust는 성능과 안전성을 동시에 추구하는 언어로, 많은 개발자들이 주목하고 있습니다.

 


Rust의 디렉토리 구조

Rust 프로그래밍을 시작할 때는 보통 VSCode와 같은 편리한 IDE를 사용하여 Rust의 이미지를 포함한 컨테이너를 띄우게 되는데요.

 

컨테이너가 잘 열리면, 다음 명령어로 최소 구성의 프로젝트를 생성할 수 있습니다.

 

$ cargo init

 

이 명령어를 실행하면 생성되는 디렉토리 구조는 다음과 같습니다.

├── src/
│   └── main.rs
├── Cargo.toml
└── Cargo.lock  (프로젝트에서 `cargo build` 등의 작업을 수행하면 생성됩니다)
  • src/: Rust 소스 코드가 위치하는 디렉토리입니다.
  • Cargo.toml: 프로젝트의 메타데이터와 의존성을 정의하는 파일입니다.
  • Cargo.lock: 의존성의 정확한 버전을 기록하는 파일입니다.

크레이트(Crate)

크레이트는 Rust에서의 코드 모음으로, 최소의 컴파일 단위인 것인데요.

 

크레이트는 크게 두 가지로 나뉘어요.

  • 바이너리 크레이트: main.rs를 엔트리 포인트로 하여 실행 가능한 바이너리를 생성하는 크레이트입니다.
  • 라이브러리 크레이트: lib.rs를 엔트리 포인트로 하여 다른 크레이트에서 재사용할 수 있는 라이브러리를 제공합니다.

이러한 구조 덕분에 Rust는 코드의 재사용성과 모듈화를 쉽게 이룰 수 있습니다.


모듈(Module)

모듈은 하나의 크레이트 내에서 코드를 정리하고 네임스페이스를 제공하는 단위인데요.

 

Rust에서는 mod 키워드를 사용하여 모듈을 정의합니다.

 

기본적으로 Rust에서 정의된 함수나 구조체, 상수는 비공개(private) 상태이며, pub 키워드와 mod(및 use)를 사용하여 공개할 수 있습니다.

 

이는 TypeScript의 exportimport와 유사한 개념입니다.

 

pub과 mod의 사용법

간단한 예제를 통해 pubmod의 사용법을 살펴보겠습니다.

command.rs

pub fn command_a() {
    println!("이 명령은 A 타입입니다.");
}

 

TypeScript로 작성하면 다음과 같습니다.

export function commandA() {
    console.log("이 명령은 A 타입입니다.");
}

 

이 파일을 다른 파일에서 사용하려면 다음과 같이 작성합니다.

main.rs

mod command;

fn main() {
    command::command_a();
}

 

TypeScript로 작성하면 다음과 같습니다.

import { commandA } from "./command";

function main() {
    commandA();
}

디렉토리 구조와 mod.rs

디렉토리 구조가 깊어질 경우, mod.rs를 사용하여 모듈을 정리할 수 있는데요.

 

예를 들어, 다음과 같은 디렉토리 구조를 가질 수 있습니다.

src
├── commands
│   ├── mod.rs
│   ├── command_a.rs
│   └── command_b.rs
└── main.rs

mod.rs

pub mod command_a;
pub mod command_b;

 

TypeScript로 작성하면 다음과 같습니다.

export * from './command_a';
export * from './command_b';

 

이렇게 하면 main.rs에서 여러 파일을 한 줄로 호출할 수 있습니다.

main.rs

mod commands;

fn main() {
    commands::command_a::command_a();
    commands::command_b::command_b();
}

 

TypeScript로 작성하면 다음과 같습니다.

import { commandA } from './commands/command_a';
import { commandB } from './commands/command_b';

function main() {
    commandA();
    commandB();
}

use의 사용법

use 키워드를 사용하면 긴 경로를 간결하게 줄일 수 있는데요.

 

다음은 디렉토리 구조의 예시입니다.

src
├── commands.rs
├── commands
│   ├── a.rs
│   ├── a
│   │   └── test1.rs
│   ├── b.rs
│   ├── b
│   │   └── test2.rs
└── main.rs

main.rs에서 use 사용

use commands::a::test1;
use commands::b::test2;

fn main() {
    test1();
    test2();
}

 

TypeScript로 작성하면 다음과 같습니다.

import { test1 } from './commands/a/test1';
import { test2 } from './commands/b/test2';

function main() {
    test1();
    test2();
}

 

mod.rs에서 use 사용

pub mod a;
pub mod b;

pub use a::test1;
pub use b::test2;

 

이렇게 하면 main.rs에서 더 간결하게 호출할 수 있습니다.

main.rs

mod commands;

fn main() {
    commands::test1();
    commands::test2();
}

// 또는
use commands::{test1, test2};

fn main() {
    test1();
    test2();
}

타입 정의

Rust와 TypeScript의 데이터 타입을 비교해보면 다음과 같습니다.

용어 Rust 타입 TypeScript 타입
배열 Vec<요소의 타입>, [T;N] Array<T>
옵션 Option<요소의 타입> `T
문자열 char, String, &str string
정수형 i8, u8, i16, u16, i32, u32, i64, u64, isize, usize number
부동 소수점형 f32, f64 number
불리언형 bool boolean
튜플형 (타입명, 타입명...) [T1, T2, ...]

구조체와 함수 정의

Rust에서 프로퍼티를 가진 구조체를 정의하는 방법은 다음과 같습니다.

pub struct Student {
    pub id: i16,
    pub name: String,
    pub address: String,
}

 

함수를 정의하는 방법은 다음과 같습니다.

impl Student {
    pub fn new(id: i16, name: String) -> Self {
        Self {
            id,
            name,
            address: Default::default()
        }
    }
}

 

이렇게 하면 Student 구조체의 인스턴스를 생성할 수 있습니다.


트레잇(Trait)

트레잇은 Rust에서 타입에 공통된 동작을 정의하기 위한 기능인데요.

 

트레잇을 사용하면 다양한 타입에 동일한 인터페이스를 제공할 수 있습니다.

예시

trait Example {
    fn example(&self);
}

struct Student {
    pub id: i16,
    pub name: String,
    pub address: String,
}

impl Example for Student {
    fn example(&self) {
        println!("{}의 학번은 {}입니다", self.name, self.id);
    }
}

 

이렇게 하면 Student 타입은 Example 트레잇을 구현하게 되며, example 메서드를 사용할 수 있습니다.


소유권

Rust의 소유권은 메모리 관리와 안전성을 보장하기 위한 중요한 개념인데요.

 

이를 통해 가비지 컬렉션 없이도 메모리 누수와 데이터 경합을 방지할 수 있습니다.

 

소유권 개념이 생소한 분들도 많을 텐데, Rust에서는 모든 값에는 "소유자"가 있으며, 그 소유자는 소유권을 가지게 됩니다.

 

하나의 값에는 오직 하나의 소유자만 존재할 수 있는데요.

 

소유자가 스코프를 벗어나면 그 소유자가 가진 값은 메모리에서 해제되는 방식입니다.

 

TypeScript를 주로 사용해온 저에게는 스코프와 메모리 해제 개념이 생소했어요.

 

TypeScript는 런타임 환경에서 가비지 컬렉션이 자동으로 처리되기 때문에, 개발자가 직접 메모리 관리에 신경 쓸 일이 없는데요.

 

하지만 Rust에서는 이러한 메모리 관리가 개발자의 몫이라는 점이 큰 차이점입니다.

 

스코프란?

스코프는 특정 변수나 객체가 정의된 블록, 즉 함수, 루프, 조건문 등의 범위를 의미하는데요.

 

Rust에서는 변수가 스코프에 들어가면 유효하고, 스코프를 벗어나면 무효화됩니다.

 

아래의 예제를 통해 스코프에 대해 알아볼까요?

fn main() {
    let x = 5;  // 변수 x가 스코프에 들어감 (이 블록 내에서 유효함)

    {  // 새로운 스코프 시작
        let y = 10;  // 변수 y가 스코프에 들어감 (이 내부 블록 내에서 유효함)
        println!("블록 내부: x = {}, y = {}", x, y);
    }  // 이 내부 스코프가 종료되고, y는 스코프를 벗어남 (y는 여기서 무효화됨)

    // println!("{}", y);  // y는 스코프 밖이므로 에러 발생
    println!("블록 외부: x = {}", x);
}  // 여기서 main 함수가 종료되고, x는 스코프를 벗어남 (x도 무효화됨)

 

이렇게 스코프를 이해하면, Rust의 메모리 관리 방식이 좀 더 명확하게 느껴질 수 있어요.

 

바인딩

변수나 객체를 생성할 때, 변수명이 특정 값에 바인딩됩니다.

 

예를 들어, let x: i32 = 5;라는 코드는 변수 x가 값 5에 바인딩되면서 소유권을 가지게 되는 것을 의미합니다.

 

Rust에서는 이러한 바인딩을 통해 코드에서 누가 소유자인지 쉽게 파악할 수 있습니다.

 

참조

Rust에서는 소유권을 사용해 객체를 전달하는데요.

 

예를 들어, 다음과 같이 작성하면 소유권을 넘기게 됩니다.

let a: i32 = 10;
let b = a; // a의 소유권을 b로 넘김
// println!("{}", a);  // a는 소유권을 넘겼기 때문에 에러 발생
println!("{}", b);

 

소유권을 넘기지 않고 값을 사용하고 싶다면, 임시 소유권을 만들어서 전달할 수 있는데요.

 

그 방법 중 하나가 참조입니다.

 

Rust에서 참조는 &를 사용해서 나타내고, 이는 소유권을 넘기지 않고 값을 읽을 수 있도록 해줍니다.

let a: i32 = 10;
let b = &a; // a의 참조를 b에 할당
println!("{}, {}", a, b);

 

이렇게 하면 a의 소유권은 그대로 유지되며, ba의 값을 참조하게 됩니다.

가변성

Rust에서는 기본적으로 객체를 불변으로 바인딩하는데요.

 

TypeScript의 let과 달리, Rust에서 변수를 변경하려면 그 변수가 가변임을 명시해야 합니다.

 

이렇게 하려면 mut 키워드를 사용해야 해요.

 

let mut a: i32 = 10; // a를 가변으로 선언
let b = &mut a; // a의 가변 참조를 b에 할당
*b += 5; // b를 통해 a의 값을 변경
println!("{}", a); // 15가 출력됨

 

이 경우, ba의 가변 참조를 가지므로 b를 통해 a의 값을 수정할 수 있습니다.

 

대여 검사

Rust에서는 참조와 가변성에 몇 가지 규칙이 있습니다.

 

이 규칙은 컴파일러에 의해 대여 검사라고 불리며, 다음과 같은 특징을 가지고 있어요.

  • 불변 참조(&)는 여러 개 동시에 존재할 수 있습니다.
  • 불변 참조(&)와 가변 참조(&mut)는 동시에 존재할 수 없습니다.
  • 가변 참조(&mut)는 동시에 하나만 존재할 수 있습니다.

 

아래의 예제를 통해 대여 검사가 어떻게 작용하는지 살펴볼까요?

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

    let r1 = &s; // s의 불변 참조 생성
    let r2 = &s; // 여러 개의 불변 참조는 허용됨

    println!("r1: {}, r2: {}", r1, r2); // 참조를 사용해 데이터 읽기
} // r1과 r2가 스코프를 벗어나면 불변 참조도 무효화됨

 

이렇게 대여 검사를 통해 Rust는 메모리 안전성을 보장할 수 있습니다.

 

간단한 퀴즈

다음의 println!을 모두 실행할 수 있도록 순서를 변경해 보세요.

 

r2는 가변 참조여야 합니다.

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

    let r1 = &s;
    let r2 = &mut s; // 이 시점에서 에러 발생
    r2.push_str(", world");
    println!("r1: {}", r1);  // 에러 발생
    println!("r2: {}", r2);
}

 

답변

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

    let r1 = &s; // 먼저 불변 참조 사용
    println!("r1: {}", r1);  // r1을 사용해 출력

    let r2 = &mut s;  // 그 다음에 가변 참조를 생성
    r2.push_str(", world"); // r2를 통해 s를 수정
    println!("r2: {}", r2); // r2를 사용해 출력
}

 

이렇게 스코프와 소유권, 참조, 가변성에 대한 규칙을 이해하면 Rust의 메모리 관리 방식을 더 잘 이해할 수 있습니다.


요약

이번 글에서는 Rust의 기초 개념과 TypeScript와의 비교를 통해 Rust를 배우는 데 도움이 되고자 했어요.

 

Rust는 메모리 안전성과 병행성을 보장하는 언어로, 소유권 개념을 통해 메모리 관리를 직접 할 수 있는 점이 특징입니다.

 

TypeScript와의 유사점과 차이점을 통해 Rust의 기초적인 내용을 살펴보았는데요.

 

Rust를 처음 접하는 개발자에게 이 글이 도움이 되었으면 좋겠고, 앞으로도 더 깊이 있는 내용을 다뤄보고 싶어요.

 

다음 번에는 for문 작성 방법이나 Rust의 고급 기능에 대해 정리해보려고 합니다.

 

많은 기대 부탁드려요!