Go 개발자의 눈으로 본 Rust (1/4): 다른 철학, 새로운 규칙 - 소유권과 생명주기

Go 개발자의 눈으로 본 Rust (1/4): 다른 철학, 새로운 규칙 - 소유권과 생명주기

안녕하세요.

 

Go의 간결함과 강력한 동시성 지원은 매력적인 개발 경험을 선사하는데요.

 

하지만 기술 생태계는 끊임없이 변화하고, 새로운 도구와 패러다임이 등장합니다.

 

최근 몇 년간 시스템 프로그래밍 언어로서, 그리고 웹 개발을 포함한 다양한 영역에서 Rust가 주목받는 것을 보며 저 역시 자연스럽게 관심을 갖게 되었습니다.

 

약 2년간 Rust를 학습하고 개인 프로젝트에 적용해보면서, Go와는 확연히 다른 철학과 매력을 느낄 수 있었습니다.

 

이 시리즈는 저와 같이 Go 언어에 익숙한 개발자(Gopher)의 관점에서 Rust를 바라보며 느낀 주요 차이점들을 공유하기 위해 기획되었습니다.

 

언어의 기본적인 철학부터 메모리 관리, 문법, 동시성, 생태계에 이르기까지, 총 4편에 걸쳐 두 언어를 비교 분석해 볼 예정입니다.

 

오늘은 그 첫 시간으로, Go와 Rust가 가진 근본적인 언어 철학의 차이와, Rust를 이해하는 데 있어 가장 핵심적인 개념이라 할 수 있는 소유권(Ownership)과 생명주기(Lifetime) 에 대해 자세히 알아보겠습니다.

 

1. 언어 철학: 간결성과 명시성 vs. 제어권과 안전성

모든 프로그래밍 언어는 저마다의 설계 철학을 가지고 있습니다.

 

Go와 Rust는 현대적인 언어라는 공통점이 있지만, 추구하는 목표와 접근 방식에서 뚜렷한 차이를 보입니다.

  • Go: 단순함, 명시성, 그리고 생산성
    Go는 Google에서 대규모 소프트웨어 개발의 복잡성을 해결하기 위해 탄생했습니다. 핵심 철학은 '단순함(Simplicity)' 입니다. 언어 명세가 간결하고, 문법이 직관적이며, 기능의 수를 의도적으로 제한하여 개발자가 쉽게 배우고 일관된 코드를 작성하도록 유도합니다. 가비지 컬렉터(GC)를 통한 자동 메모리 관리, 고루틴(Goroutine)과 채널(Channel)을 이용한 쉬운 동시성 프로그래밍은 개발 생산성을 크게 향상시키는 요소입니다. Go는 "Less is more" 철학을 통해, 협업 환경에서의 코드 가독성과 유지보수성, 그리고 빠른 컴파일 속도를 중시합니다.
  • Rust: 제어권, 안전성, 그리고 성능
    Rust는 Mozilla에서 시작되었으며, 시스템 프로그래밍 언어가 가져야 할 메모리 안전성(Memory Safety)높은 성능(Performance) 을 동시에 달성하는 것을 목표로 합니다. 가장 큰 특징은 가비지 컬렉터 없이 컴파일 시점에 엄격한 규칙(소유권, 빌림, 생명주기)을 적용하여 메모리 오류(예: 댕글링 포인터, 데이터 경쟁)를 원천적으로 방지한다는 점입니다. 이는 개발자에게 메모리 관리에 대한 더 많은 제어권을 부여하는 동시에, 그에 따른 책임과 학습 곡선을 요구합니다. 또한, 풍부한 타입 시스템과 제로 비용 추상화(Zero-Cost Abstraction)를 통해 성능 저하 없이 높은 수준의 추상화를 가능하게 합니다.

비유하자면, Go는 잘 정돈된 전문가용 공구 세트와 같습니다. 꼭 필요한 도구들이 명확한 용도로 구비되어 있어 빠르게 작업을 시작하고 효율적으로 결과물을 만들 수 있습니다. 반면, Rust는 수많은 기능과 안전장치가 내장된 고성능 멀티툴과 같습니다. 다양한 작업을 정교하게 수행할 수 있지만, 모든 기능을 제대로 활용하고 안전하게 사용하려면 사용법을 충분히 숙지해야 합니다.

 

2. Rust의 핵심: 소유권 시스템 (Ownership System)

Go 개발자가 Rust를 접했을 때 가장 생소하고 중요하게 다뤄야 할 개념이 바로 소유권 시스템입니다.

 

이는 Rust가 GC 없이 메모리 안전성을 보장하는 근간이 되는데요. 세 가지 핵심 규칙으로 요약할 수 있습니다.

  • 규칙 1: 모든 값은 '소유자(owner)'라고 불리는 변수를 갖습니다.
  • 규칙 2: 특정 시점에 값의 소유자는 단 하나뿐입니다.
  • 규칙 3: 소유자가 스코프(scope)를 벗어나면, 해당 값은 메모리에서 해제(drop)됩니다.

이 규칙들이 실제 코드에서 어떻게 작동하는지 Go와 비교하며 살펴보겠습니다.

  • 소유권 이동 (Move Semantics)

Go에서 기본 타입(int, float, bool, string 등)이나 구조체는 변수에 할당되거나 함수 인수로 전달될 때 값이 복사됩니다.

 

슬라이스나 맵, 포인터 등은 참조(내부 포인터)가 복사되어 데이터를 공유하는 방식입니다.

// main.go
package main

import "fmt"

func main() {
    s1 := "Hello" // 기본 타입 string
    s2 := s1      // s1의 값이 s2로 복사됨

    fmt.Println("Go - s1:", s1) // "Hello" 출력
    fmt.Println("Go - s2:", s2) // "Hello" 출력 (s1과 s2는 독립적인 값)

    v1 := []int{1, 2} // 슬라이스
    v2 := v1          // v1의 내부 포인터 등이 v2로 복사됨 (데이터 공유)
    v2[0] = 99

    fmt.Println("Go - v1:", v1) // [99 2] 출력 (v2의 변경이 v1에도 영향)
    fmt.Println("Go - v2:", v2) // [99 2] 출력
}

 

Rust에서는 String (힙에 할당되는 가변 문자열)과 같은 타입의 경우, 기본적으로 소유권 이동(move) 이 발생합니다.

 

값이 복사되는 것이 아니라, 값에 대한 소유권 자체가 다른 변수로 이전되는 것입니다.

// main.rs
fn main() {
    let s1 = String::from("Hello"); // s1이 "Hello" 데이터의 소유권을 가짐
    let s2 = s1; // s1의 소유권이 s2로 이동(move)됨. 이제 s1은 유효하지 않음!

    // println!("Rust - s1: {}", s1); // 컴파일 에러! error[E0382]: use of moved value: `s1`
    println!("Rust - s2: {}", s2); // 출력: Hello (s2가 소유권을 가짐)

    let v1 = vec![1, 2]; // 벡터 (힙 할당)
    let v2 = v1; // v1의 소유권이 v2로 이동됨

    // println!("Rust - v1: {:?}", v1); // 컴파일 에러!
    println!("Rust - v2: {:?}", v2); // 출력: [1, 2]
}

 

s1의 소유권이 s2로 이동했기 때문에, s1을 다시 사용하려고 하면 컴파일러는 "use of moved value" 에러를 발생시킵니다.

 

이는 이중 해제(double free)와 같은 메모리 오류를 원천적으로 방지하기 위한 Rust의 핵심적인 안전장치입니다.

 

만약 값을 복사하고 싶다면, 명시적으로 clone() 메서드를 호출해야 합니다.

fn main() {
    let s1 = String::from("Hello");
    let s2 = s1.clone(); // s1의 데이터를 깊은 복사(deep copy)하여 s2가 새로운 소유권을 가짐

    println!("Rust - s1: {}", s1); // 출력: Hello
    println!("Rust - s2: {}", s2); // 출력: Hello (s1과 s2는 독립적)
}

(참고: i32, f64, bool과 같이 스택에 할당되고 크기가 고정된 단순 타입들은 Copy 트레이트를 구현하고 있어, 할당 시 소유권 이동 대신 Go의 기본 타입처럼 값이 복사됩니다.)

 

3. 빌림과 참조 (Borrowing & References)

소유권을 매번 이동시키는 것은 비효율적일 수 있습니다.

 

단지 값을 읽거나 잠시 수정하기 위해 소유권 전체를 넘겨받을 필요는 없는데요.

 

이를 위해 Rust는 빌림(borrowing) 이라는 개념을 제공합니다.

 

빌림은 값의 소유권을 넘기지 않고 값에 접근할 수 있는 참조(reference) 를 만드는 행위입니다.

 

Rust의 참조에는 두 종류가 있습니다.

  • 불변 참조 (Immutable Reference): &T
    • 값을 읽을 수만 있고, 변경할 수는 없습니다.
    • 하나의 값에 대해 동시에 여러 개의 불변 참조를 만들 수 있습니다.
  • 가변 참조 (Mutable Reference): &mut T
    • 값을 읽고 변경할 수 있습니다.
    • 하나의 값에 대해 특정 시점에는 단 하나의 가변 참조만 존재할 수 있습니다.
    • 가변 참조가 유효한 동안에는 해당 값에 대한 다른 참조(불변 또는 가변)를 만들 수 없습니다.

이 규칙을 빌림 규칙(Borrowing Rules) 이라고 하며, 컴파일러는 이 규칙을 엄격하게 강제하여 데이터 경쟁(Data Race)을 컴파일 시점에 방지합니다.

 

Go에서는 포인터(*T)를 사용하여 값의 주소를 전달하고, 이를 통해 값을 읽거나 수정할 수 있습니다.

 

데이터 경쟁 방지는 주로 뮤텍스(Mutex)와 같은 동기화 메커니즘을 통해 런타임에 관리됩니다.

// main.rs
fn calculate_length(s: &String) -> usize { // &String: String의 불변 참조를 빌림
    s.len()
} // 여기서 s 참조는 스코프를 벗어나지만, 원본 String의 소유권에는 영향 없음

fn change_string(s: &mut String) { // &mut String: String의 가변 참조를 빌림
    s.push_str(", world"); // 가변 참조를 통해 원본 String 변경 가능
}

fn main() {
    let mut my_string = String::from("hello"); // 가변 변수로 선언해야 가변 참조 생성 가능

    let len = calculate_length(&my_string); // 불변 참조 전달
    println!("Length: {}", len);

    // let r1 = &mut my_string; // 첫 번째 가변 참조 생성 (OK)
    // let r2 = &mut my_string; // 두 번째 가변 참조 생성 시도 (컴파일 에러!)
    // println!("{}, {}", r1, r2); // error[E0499]: cannot borrow `my_string` as mutable more than once at a time

    change_string(&mut my_string); // 가변 참조 전달
    println!("Changed string: {}", my_string); // 출력: Changed string: hello, world

    // let r3 = &my_string; // 불변 참조 생성 (OK)
    // let r4 = &mut my_string; // 가변 참조 생성 시도 (컴파일 에러! 기존 불변 참조 r3가 유효한 동안 불가)
    // error[E0502]: cannot borrow `my_string` as mutable because it is also borrowed as immutable
}

 

빌림 규칙은 처음에는 다소 까다롭게 느껴질 수 있지만, 데이터 경쟁 없는 안전한 코드 작성을 컴파일러가 보장해준다는 강력한 이점을 제공합니다.

 

4. 생명주기: 참조의 유효 기간 보증 (Lifetimes)

소유권과 빌림 규칙만으로는 해결되지 않는 문제가 있습니다.

 

바로 댕글링 참조(Dangling Reference), 즉 이미 해제된 메모리를 가리키는 유효하지 않은 참조 문제입니다.

 

Go에서는 GC가 이러한 문제를 런타임에 처리해주지만 (완벽하지는 않음), GC가 없는 Rust는 컴파일 시점에 이 문제를 해결해야 합니다. 여기서 등장하는 개념이 생명주기(Lifetime) 입니다.

 

생명주기는 참조가 유효한 스코프를 의미합니다. Rust 컴파일러는 모든 참조가 항상 유효한 데이터를 가리키도록 보장하기 위해 생명주기를 검사합니다.

 

대부분의 경우 컴파일러는 생명주기 생략 규칙(Lifetime Elision Rules) 에 따라 생명주기를 자동으로 추론하지만, 때로는 개발자가 명시적으로 생명주기를 지정해주어야 하는 경우도 있습니다. (명시적 생명주기는 다음 편에서 더 자세히 다루겠습니다.)

 

가장 기본적인 생명주기 규칙은 "참조의 생명주기는 참조 대상이 되는 값의 생명주기보다 길 수 없다" 는 것입니다.

 

앞서 '생명주기' 섹션에서 본 예시를 다시 살펴보겠습니다.

// main.rs
fn main() {
    let r: &i32; // r의 생명주기는 main 함수 스코프 전체

    { // 내부 스코프 시작
        let x = 5; // x의 생명주기는 이 내부 스코프
        r = &x; // r이 x를 참조. 하지만 r의 생명주기가 x의 생명주기보다 길다!
    } // x는 여기서 해제됨. r은 이제 댕글링 참조가 됨

    // println!("r: {}", r); // 컴파일 에러! `x` does not live long enough
}

 

컴파일러는 r이 내부 스코프를 벗어나면 해제될 x를 참조하려고 한다는 것을 감지하고, xr만큼 오래 살지 못한다는("x does not live long enough") 에러를 발생시킵니다.

 

이처럼 생명주기 검사를 통해 Rust는 댕글링 참조 문제를 컴파일 시점에 원천적으로 차단합니다.

5. 1편을 마치며: 새로운 규칙에 적응하기

오늘은 Go와 Rust의 근본적인 철학 차이와 Rust의 핵심 메커니즘인 소유권, 빌림, 그리고 생명주기의 기본 개념을 살펴보았습니다.

 

Go의 GC 기반 자동 메모리 관리와 비교했을 때, Rust의 소유권 시스템은 분명 더 많은 규칙과 제약을 요구합니다.

 

처음에는 이 규칙들이 다소 생소하고 불편하게 느껴질 수 있습니다.

 

하지만 이 엄격한 규칙들은 메모리 안전성데이터 경쟁 방지를 컴파일 시점에 보장하여, 런타임 오류 가능성을 크게 줄여주고 결과적으로 더 안정적이고 예측 가능한 프로그램을 만들 수 있게 돕습니다.

 

마치 숙련된 장인이 정교한 도구를 다루듯, Rust의 규칙에 익숙해지면 개발자는 메모리 관리에 대한 확신을 가지고 성능과 안전성 모두를 추구할 수 있게 됩니다.

 

다음 편에서는 Go와 Rust의 문법적 차이(가변성, 패턴 매칭, if 표현식 등), 타입 시스템(Enum, Option/Result), 그리고 추상화 메커니즘(interface vs. trait)에 대해 더 자세히 비교하며 Rust의 또 다른 매력을 탐구해보겠습니다.

 

기대해주십시오!