Go 개발자의 눈으로 본 Rust (2/4): 문법부터 타입 시스템까지 - 표현력과 안전성의 균형
1편 복습 및 이번 편 미리보기
지난 1편에서는 Go와 Rust의 근본적인 설계 철학 차이와 Rust의 핵심인 소유권 및 생명주기 시스템에 대해 알아보았습니다.
Go의 간결성과 GC 기반 메모리 관리에 익숙한 개발자에게 Rust의 엄격한 규칙은 분명 새로운 도전인데요.
하지만 이러한 규칙들이 컴파일 시점에 메모리 안전성을 보장한다는 강력한 이점을 제공한다는 점도 확인했습니다.
이번 2편에서는 두 언어의 표면으로 드러나는 차이점, 즉 문법(Syntax), 타입 시스템의 표현력(Type System Expressiveness), 그리고 추상화 메커니즘(Abstraction Mechanism) 에 대해 더 깊이 파고들어 보겠습니다.
Go의 간결함에 익숙한 시각에서 Rust의 풍부한 기능들이 어떻게 다르게 느껴지는지, 그리고 각 방식이 가지는 장단점은 무엇인지 비교 분석해 보겠습니다.
1. 문법: 간결한 규칙 vs. 다양한 표현
Go는 의도적으로 문법의 수를 제한하여 언어의 단순성을 유지합니다. 반면 Rust는 다양한 프로그래밍 패러다임을 지원하며 여러 상황에 맞는 풍부한 문법적 도구를 제공합니다.
변수의 가변성 (Mutability)
1편에서 잠시 언급했듯, Go 변수는 기본적으로 가변(값을 변경할 수 있음)이지만, Rust 변수는 기본적으로 불변(immutable)입니다.
Rust에서 변수 값을 변경하려면 mut
키워드를 명시적으로 붙여 가변 변수임을 선언해야 합니다.
let immutable_var = 10;
// immutable_var = 20; // 컴파일 에러!
let mut mutable_var = 10;
mutable_var = 20; // OK
이는 코드를 읽을 때 어떤 변수가 변경될 가능성이 있는지 즉시 파악하게 해주어 인지적 부담을 줄여줍니다.
또한, 불변 참조(&T
)와 가변 참조(&mut T
)를 받는 메서드를 명확히 구분할 수 있게 하여(1편 참조), 상태 변경 여부를 타입 시스템 수준에서 관리할 수 있도록 돕습니다.
패턴 매칭 (match
vs. switch
)
Rust의 match
는 Go의 switch
와 유사한 역할을 하지만 훨씬 강력하고 유연합니다. 가장 큰 차이는 match
가 표현식(expression) 이라는 점입니다. 즉, match
자체가 값을 반환할 수 있습니다.
enum Status { Online, Offline, Away(String) }
fn get_status_message(status: Status) -> String {
match status { // status 값에 따라 매칭되는 팔(arm)의 값을 반환
Status::Online => "User is online".to_string(),
Status::Offline => "User is offline".to_string(),
Status::Away(reason) => format!("User is away: {}", reason), // 연관된 값 사용 가능
// 모든 가능한 Status 값을 처리해야 함 (완전성 검사 강제)
}
}
let current_status = Status::Away("In a meeting".to_string());
let message = get_status_message(current_status);
println!("{}", message); // 출력: User is away: In a meeting
Go의 switch
는 문(statement)이므로 값을 직접 반환하지 않고, 각 case
내에서 return
을 사용하거나 외부 변수에 값을 할당해야 합니다.
또한, Rust의 match
는 값, 범위, 튜플, 구조체 분해 등 훨씬 다양한 패턴을 지원하여 복잡한 조건 분기를 간결하게 표현할 수 있습니다.
match
는 모든 가능한 패턴을 처리하도록 강제하기 때문에 (완전성 검사), 누락된 케이스로 인한 버그를 컴파일 시점에 방지하는 데 매우 효과적입니다.
if
표현식 (if Expression)
match
와 마찬가지로 Rust의 if
역시 표현식입니다. 따라서 if
조건에 따라 다른 값을 변수에 간결하게 할당할 수 있습니다.
let number = 6;
let description = if number % 2 == 0 {
"even" // if 블록의 값
} else {
"odd" // else 블록의 값
}; // 세미콜론으로 let 구문 마무리
println!("Number is {}", description); // 출력: Number is even
Go에서는 유사한 로직을 위해 if-else
문과 별도의 변수 할당 구문이 필요합니다.
클로저 (Closures)
기본적인 클로저 생성 및 사용법은 Go와 Rust가 유사합니다.
하지만 Rust 클로저는 캡처하는 변수의 사용 방식(불변 참조, 가변 참조, 소유권 이동)에 따라 컴파일러가 자동으로 Fn
, FnMut
, FnOnce
라는 세 가지 트레이트 중 하나로 타입을 추론합니다.
이는 함수 시그니처에서 클로저를 파라미터로 받을 때 어떤 종류의 클로저가 필요한지 명시해야 함을 의미합니다.
fn apply_fn<F>(f: F) where F: Fn(i32) -> i32 { // Fn: 불변 캡처 또는 캡처 없음
println!("Result using Fn: {}", f(10));
}
fn apply_fn_mut<F>(mut f: F) where F: FnMut(i32) -> i32 { // FnMut: 가변 캡처 가능
println!("Result using FnMut: {}", f(10));
}
fn main() {
let multiplier = 2;
let multiply = |x: i32| x * multiplier; // Fn으로 추론됨
apply_fn(multiply);
let mut counter = 0;
let increment = |x: i32| { // FnMut으로 추론됨
counter += 1;
x + counter
};
apply_fn_mut(increment);
// apply_fn(increment); // 컴파일 에러! FnMut을 Fn에 전달 불가
}
Go는 클로저 타입에 대한 별도 구분 없이 func(...) ...
형태로 단순하게 처리합니다.
Rust 방식은 클로저가 상태를 변경하는지 여부를 타입 시그니처에서 명확히 알 수 있다는 장점이 있지만, Go에 비해 다소 복잡하게 느껴질 수 있습니다.
리소스 정리 (Drop
vs. defer
)
1편에서 다루었듯이, Go의 defer
는 함수 종료 시점에 리소스 정리를 보장하는 편리한 기능입니다.
Rust는 Drop
트레이트를 통해 스코프 기반의 자동 리소스 관리를 제공합니다.
객체가 스코프를 벗어날 때 drop
메서드가 자동으로 호출되어 리소스(메모리, 파일 핸들 등)가 해제됩니다.
특히 루프 내에서 리소스를 생성하고 해제해야 하는 경우, 스코프 기반인 Drop
이 함수 기반인 defer
보다 실수를 줄여주고 직관적일 수 있습니다.
매크로 (Macros)
Rust의 매크로는 Go에는 없는 강력한 메타프로그래밍 기능입니다.
컴파일 시점에 코드를 생성하거나 변형하여 코드 중복을 줄이고(예: vec!
, println!
), 특정 기능을 자동화하며(예: #[derive(...)]
), 심지어 도메인 특화 언어(DSL)를 만드는 데도 사용됩니다.
선언적 매크로(macro_rules!
)와 절차적 매크로(derive, attribute, function-like)가 있으며, Rust 생태계 전반에서 널리 활용됩니다.
다만, 강력한 만큼 코드의 복잡성을 증가시킬 수 있으므로 신중한 사용이 요구됩니다.
Go는 의도적으로 매크로와 같은 복잡한 메타프로그래밍 기능을 배제하여 언어의 단순성을 유지합니다.
2. 타입 시스템의 표현력: 안전하고 명확하게 상태 표현하기
Rust의 타입 시스템은 Go에 비해 특정 상태나 결과를 더 명확하고 안전하게 표현하는 데 강점을 보입니다.
nil
의 부재와 Option<T>
Go에서 흔히 사용되는 nil
은 편리하지만, 널 포인터 역참조(nil pointer dereference)라는 고질적인 런타임 에러의 원인이 됩니다.
컴파일러가 모든 nil
체크 누락을 잡아주지는 못합니다.
Rust는 null
또는 nil
개념 자체가 없습니다.
대신, 값이 있을 수도 있고 없을 수도 있는 상황을 명시적으로 표현하기 위해 Option<T>
열거형을 사용합니다.
Option<T>
는 값이 존재하는 상태(Some(T)
)와 값이 없는 상태(None
) 두 가지 중 하나입니다.
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some("Alice".to_string())
} else {
None // 사용자를 찾지 못함
}
}
fn main() {
let user_id = 1;
match find_user(user_id) {
Some(name) => println!("Found user: {}", name),
None => println!("User with ID {} not found", user_id),
} // Some과 None을 모두 처리하도록 강제됨
}
Option<T>
를 사용하면 컴파일러가 None
케이스 처리를 강제하므로, Go의 nil
체크 누락과 같은 실수를 원천적으로 방지할 수 있습니다.
에러 처리와 Result<T, E>
Go의 관용적인 에러 처리 방식은 함수가 값과 error
인터페이스 타입의 값을 함께 반환하는 것입니다.
개발자는 if err != nil
체크를 통해 에러를 처리해야 하지만, 이 체크를 생략하는 것도 문법적으로 가능합니다.
Rust는 실패 가능한 연산을 표현하기 위해 Result<T, E>
열거형을 사용합니다.
Result<T, E>
는 성공 상태(Ok(T)
)와 실패 상태(Err(E)
) 중 하나입니다. T
는 성공 시의 값 타입, E
는 실패 시의 에러 타입입니다.
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("username.txt")?; // '?' 연산자: Err이면 즉시 반환
let mut username = String::new();
f.read_to_string(&mut username)?; // '?' 연산자
Ok(username) // 성공 시 Ok로 감싸서 반환
}
fn main() {
match read_username_from_file() {
Ok(name) => println!("Username: {}", name),
Err(e) => println!("Error reading username: {}", e),
} // Ok와 Err를 모두 처리하도록 강제됨
}
Result<T, E>
역시 match
나 다른 메서드(?
연산자 포함)를 통해 Ok
와 Err
케이스를 명시적으로 처리하도록 강제합니다.
이는 Go보다 더 엄격하게 에러 처리를 강제하여 프로그램의 안정성을 높입니다.
강력한 열거형 (Enums)
Go에는 빌트인 enum
타입이 없어 상수와 iota
등을 이용해 유사하게 구현합니다.
Rust의 enum
은 훨씬 강력하고 유연합니다. 각 열거형 멤버는 연관된 데이터(associated data)를 가질 수 있으며, 이는 복잡한 상태를 매우 명확하고 타입 안전하게 모델링하는 것을 가능하게 합니다.
Option
과 Result
자체가 바로 이런 강력한 enum
의 대표적인 활용 사례입니다.
// 웹 요청 상태를 표현하는 Enum 예시
enum WebRequestStatus {
Idle,
Loading,
Success(String), // 성공 시 응답 데이터를 가짐
Error { code: u16, message: String }, // 에러 시 코드와 메시지를 가짐
}
fn process_status(status: WebRequestStatus) {
match status {
WebRequestStatus::Idle => println!("Request is idle."),
WebRequestStatus::Loading => println!("Loading data..."),
WebRequestStatus::Success(data) => println!("Success! Data: {}", data),
WebRequestStatus::Error { code, message } => {
println!("Error {}: {}", code, message);
}
}
}
3. 추상화: 암묵적인 interface
vs. 명시적인 trait
프로그램의 복잡성을 관리하기 위한 추상화 방식에서도 Go와 Rust는 다른 접근법을 취합니다.
- Go:
interface
와 덕 타이핑 (Duck Typing)
Go의 인터페이스는 메서드 시그니처의 집합으로 정의됩니다. 어떤 타입이 특정 인터페이스의 모든 메서드를 구현하면, 별도의 선언 없이도 해당 인터페이스 타입으로 취급됩니다. 이는 유연하고 간결하지만, 때로는 어떤 타입이 어떤 인터페이스를 구현하는지 코드를 탐색해야 알 수 있습니다. - Rust:
trait
와 명시적 구현
Rust의 트레이트(trait)는 Go 인터페이스와 유사하게 공유된 동작(메서드 시그니처)을 정의하지만, 중요한 차이점들이 있습니다. - 명시적 구현:
impl TraitName for TypeName { ... }
구문을 통해 타입이 트레이트를 구현함을 명시적으로 선언해야 합니다. 이는 코드의 명확성을 높여줍니다. - 풍부한 기능: 트레이트는 메서드 시그니처 외에도 연관 타입, 연관 상수, 기본 메서드 구현 등을 포함할 수 있어 Go 인터페이스보다 더 풍부한 추상화 표현이 가능합니다. 예를 들어, 기본 구현을 제공하면 트레이트를 구현하는 타입이 해당 메서드를 반드시 구현하지 않아도 됩니다.
trait Summarizable {
fn author_summary(&self) -> String; // 구현 필수
fn summary(&self) -> String { // 기본 구현 제공
format!("(Read more from {}...)", self.author_summary())
}
}
struct NewsArticle { title: String, author: String, content: String }
impl Summarizable for NewsArticle {
fn author_summary(&self) -> String {
format!("Article '{}' by {}", self.title, self.author)
}
// summary 메서드는 기본 구현을 사용
}
struct Tweet { username: String, content: String }
impl Summarizable for Tweet {
fn author_summary(&self) -> String {
format!("Tweet by @{}", self.username)
}
fn summary(&self) -> String { // 기본 구현 재정의
format!("{}: {}", self.username, self.content)
}
}
fn notify<T: Summarizable>(item: &T) {
println!("New item! {}", item.summary());
}
fn main() {
let article = NewsArticle { /* ... */ title: "Rust News".into(), author: "Gopher".into(), content: "".into() };
let tweet = Tweet { /* ... */ username: "rustacean".into(), content: "Loving traits!".into() };
notify(&article); // 출력: New item! (Read more from Article 'Rust News' by Gopher...)
notify(&tweet); // 출력: New item! rustacean: Loving traits!
}
4. 2편을 마치며: 표현력과 안전성의 트레이드오프
이번 편에서는 Go와 Rust의 문법, 타입 시스템, 추상화 방식의 주요 차이점을 비교 분석했습니다.
Go는 간결함과 명시성을 통해 개발 생산성을 높이는 데 초점을 맞추는 반면, Rust는 풍부한 문법과 강력한 타입 시스템, 명시적인 트레이트를 통해 표현력과 컴파일 타임 안전성을 극대화하는 전략을 취합니다.
Rust의 다양한 문법과 Option
/Result
, 트레이트 등은 처음에는 Go 개발자에게 다소 복잡하게 느껴질 수 있습니다.
하지만 이러한 기능들은 코드의 의도를 더 명확하게 드러내고, 잠재적인 오류를 컴파일 시점에 방지하며, 더 유연하고 안전한 추상화를 가능하게 한다는 강력한 이점을 제공합니다.
다음 3편에서는 두 언어의 제네릭(Generics) 구현 방식과 테스팅(Testing) 환경, 그리고 모듈 시스템(Module System) 의 차이점을 비교하며 탐구를 이어가겠습니다.
두 언어가 코드 재사용성과 프로젝트 관리 측면에서 어떤 다른 접근법을 취하는지 살펴보겠습니다.
계속해서 많은 기대 부탁드립니다!
'Rust' 카테고리의 다른 글
Go 개발자의 눈으로 본 Rust (4/4): 동시성부터 생태계, 빌드까지 최종 비교 (0) | 2025.04.27 |
---|---|
Go 개발자의 눈으로 본 Rust (3/4): 제네릭, 테스트, 모듈 - 재사용성과 구조화의 차이 (0) | 2025.04.27 |
Go 개발자의 눈으로 본 Rust (1/4): 다른 철학, 새로운 규칙 - 소유권과 생명주기 (0) | 2025.04.27 |
인터뷰 번역) Tauri vs 다른 Rust GUI 프레임워크: Arboretum 개발자의 이야기 (1) | 2025.02.09 |
Rust 'unsafe' 제대로 파헤치기: 개발자가 알아야 할 모든 것 (0) | 2025.02.09 |