Go to Rust: #3. 소유권과 빌림 (Ownership & Borrowing)
이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
소유권(Ownership)은 Rust의 가장 독특하고 중요한 개념입니다. Go의 가비지 컬렉션과 완전히 다른 접근 방식으로, 컴파일 타임에 메모리 안전성을 보장합니다. 이 섹션은 Rust 학습에서 가장 중요한 부분이므로 충분한 시간을 들여 이해하시기 바랍니다.
3.1 소유권이란 무엇인가?#
메모리 관리의 세 가지 방식#
프로그래밍 언어들은 메모리를 관리하는 방식이 다릅니다:
1. 가비지 컬렉션 (Go, Java, Python, JavaScript)
// Go: GC가 알아서 정리
func example() {
slice := make([]int, 1000000)
// 함수 종료 후 GC가 언젠가 정리
}
- 장점: 개발자가 메모리 관리 신경 안 써도 됨
- 단점: GC 일시 정지, 예측 불가능한 지연, 메모리 오버헤드
2. 수동 관리 (C, C++)
// C: 직접 할당/해제
int* arr = malloc(sizeof(int) * 1000000);
// 사용
free(arr); // 잊으면 메모리 누수
// arr 사용 -> use-after-free 버그!
- 장점: 완전한 제어, 오버헤드 없음
- 단점: 메모리 누수, use-after-free, 이중 해제 버그
3. 소유권 시스템 (Rust)
// Rust: 컴파일러가 관리
fn example() {
let vec = vec![0; 1000000];
// 함수 종료 시 자동으로 해제 (결정론적)
}
- 장점: GC 없이 메모리 안전, 예측 가능한 성능
- 단점: 학습 곡선, 컴파일러와의 싸움
Go의 GC vs Rust의 소유권#
| 측면 | Go (GC) | Rust (소유권) |
|---|---|---|
| 메모리 해제 시점 | GC가 결정 (비결정론적) | 스코프 종료 시 (결정론적) |
| 일시 정지 | GC 일시 정지 발생 | 없음 |
| 메모리 오버헤드 | GC 메타데이터 필요 | 최소 |
| 지연 시간 | 예측 불가능할 수 있음 | 예측 가능 |
| 개발 복잡도 | 낮음 | 높음 (학습 필요) |
Discord의 사례: Discord는 Go에서 Rust로 읽기 상태 서비스를 마이그레이션했습니다. Go 버전에서는 GC로 인한 지연 스파이크가 있었지만, Rust 버전에서는 일관된 성능을 얻었습니다.
스택(Stack) vs 힙(Heap)#
소유권을 이해하려면 메모리 구조를 알아야 합니다:
스택 (Stack):
- LIFO (Last In, First Out)
- 빠른 할당/해제 (포인터 이동만)
- 크기가 컴파일 타임에 알려진 데이터
- 기본 타입, 고정 배열, 튜플 등
힙 (Heap):
- 동적 할당
- 느린 할당/해제 (메모리 관리자 개입)
- 크기가 런타임에 결정되는 데이터
- String, Vec, Box 등
// 스택에 할당
let x: i32 = 42; // 4바이트, 스택
let arr: [i32; 5] = [1,2,3,4,5]; // 20바이트, 스택
// 힙에 할당
let s: String = String::from("hello"); // 힙에 문자열 데이터
let v: Vec<i32> = vec![1, 2, 3]; // 힙에 배열 데이터
// String 내부 구조 (스택에 저장):
// - ptr: 힙 데이터를 가리키는 포인터
// - len: 길이
// - capacity: 용량
3.2 소유권 규칙#
세 가지 핵심 규칙#
- Rust의 각 값은 소유자(owner)라고 불리는 변수를 가진다.
- 한 번에 하나의 소유자만 존재할 수 있다.
- 소유자가 스코프를 벗어나면, 값은 드롭(drop)된다.
fn main() {
// s가 스코프에 들어옴
let s = String::from("hello"); // s가 "hello"의 소유자
// s 사용 가능
println!("{}", s);
} // s가 스코프를 벗어남, "hello" 메모리 해제
소유권 이동(Move)#
Go에는 없는 개념입니다.
힙에 할당된 데이터를 다른 변수에 대입하면 소유권이 이동합니다:
let s1 = String::from("hello");
let s2 = s1; // 소유권이 s1에서 s2로 이동
println!("{}", s1); // 컴파일 에러! s1은 더 이상 유효하지 않음
println!("{}", s2); // OK
왜 이런 설계일까요?
// 만약 복사가 기본이라면...
let s1 = String::from("hello");
let s2 = s1; // 깊은 복사? 얕은 복사?
// 얕은 복사면: 두 변수가 같은 메모리를 가리킴
// -> 두 번 해제 시도 -> 이중 해제 버그!
// 깊은 복사면: 항상 전체 복사 -> 성능 문제
// Rust의 해결책: 이동
// s1 무효화, s2만 유효 -> 단일 소유자 보장
Go에서는:
// Go: 슬라이스는 참조 타입
s1 := []int{1, 2, 3}
s2 := s1 // 같은 배열을 가리킴 (얕은 복사)
s2[0] = 999
fmt.Println(s1[0]) // 999 - s1도 변경됨!
// Rust: 이동으로 소유권 명확
let mut v1 = vec![1, 2, 3];
let v2 = v1; // 이동!
// v1[0] = 999; // 컴파일 에러! v1 무효
v2[0]; // OK, v2가 소유자
함수와 소유권#
함수에 값을 전달하면 소유권도 이동합니다:
fn main() {
let s = String::from("hello");
takes_ownership(s); // s의 소유권이 함수로 이동
// println!("{}", s); // 컴파일 에러! s는 무효
let x = 5;
makes_copy(x); // x는 Copy 타입이라 복사됨
println!("{}", x); // OK, x는 여전히 유효
}
fn takes_ownership(some_string: String) {
println!("{}", some_string);
} // some_string이 드롭됨
fn makes_copy(some_integer: i32) {
println!("{}", some_integer);
} // some_integer 스코프 종료, 특별한 일 없음
Copy 트레이트: 이동 대신 복사#
일부 타입은 이동 대신 복사됩니다:
let x = 5;
let y = x; // 복사! x도 여전히 유효
println!("{}, {}", x, y); // OK
Copy가 구현된 타입:
- 모든 정수 타입 (
i32,u64등) - 불리언 (
bool) - 부동소수점 (
f32,f64) - 문자 (
char) - Copy 타입만 포함하는 튜플 (예:
(i32, i32))
Copy가 없는 타입:
StringVec<T>Box<T>- 힙 할당이 필요한 모든 타입
// Copy 여부 확인
fn is_copy<T: Copy>() {}
is_copy::<i32>(); // OK
is_copy::<String>(); // 컴파일 에러! String은 Copy가 아님
Clone 트레이트: 명시적 깊은 복사#
소유권을 유지하면서 복사하고 싶다면 clone()을 사용합니다:
let s1 = String::from("hello");
let s2 = s1.clone(); // 깊은 복사
println!("{}, {}", s1, s2); // 둘 다 유효
// 비용이 있음을 명시적으로 표현
// Go에서는 암묵적이지만, Rust에서는 명시적
3.3 참조와 빌림 (References & Borrowing)#
소유권을 넘기지 않고 값을 사용하고 싶다면 참조를 사용합니다.
불변 참조: &T#
fn main() {
let s = String::from("hello");
let len = calculate_length(&s); // s를 빌려줌
println!("'{}' has length {}", s, len); // s 여전히 유효!
}
fn calculate_length(s: &String) -> usize {
s.len()
} // s는 빌림일 뿐, 드롭되지 않음
빌림(Borrowing): 소유권을 가져가지 않고 참조로 접근하는 것
let s = String::from("hello");
let r1 = &s; // 불변 참조
let r2 = &s; // 또 다른 불변 참조 - OK!
println!("{}, {}", r1, r2); // 여러 불변 참조 동시 가능
가변 참조: &mut T#
값을 수정하려면 가변 참조가 필요합니다:
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{}", s); // "hello, world"
}
fn change(s: &mut String) {
s.push_str(", world");
}
빌림 규칙#
핵심 규칙: 어떤 시점에든 다음 중 하나만 가질 수 있습니다:
- 여러 개의 불변 참조 (
&T) - 또는 하나의 가변 참조 (
&mut T)
let mut s = String::from("hello");
// OK: 여러 불변 참조
let r1 = &s;
let r2 = &s;
println!("{}, {}", r1, r2);
// OK: 하나의 가변 참조
let r3 = &mut s;
r3.push_str(" world");
// 컴파일 에러: 불변과 가변 동시
let r1 = &s;
let r2 = &mut s; // 에러! 불변 참조가 있는 동안 가변 참조 불가
// 컴파일 에러: 여러 가변 참조
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 에러! 이미 가변 빌림 중
println!("{}, {}", r1, r2);
왜 이런 규칙이 필요할까요?
데이터 레이스 방지:
- 두 포인터가 같은 데이터에 접근
- 적어도 하나가 쓰기 작업
- 접근이 동기화되지 않음
// Rust가 방지하는 버그 (Go에서는 가능한 버그)
// Go 예시:
// var slice = []int{1, 2, 3}
// go func() { slice[0] = 100 }() // 동시 수정
// fmt.Println(slice[0]) // 데이터 레이스!
참조의 스코프와 NLL (Non-Lexical Lifetimes)#
참조의 스코프는 마지막 사용 지점까지입니다:
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{}, {}", r1, r2);
// r1, r2는 여기서 더 이상 사용되지 않음
let r3 = &mut s; // OK! r1, r2의 스코프는 끝남
println!("{}", r3);
Go 포인터와 Rust 참조의 차이#
// Go: 포인터
var x int = 10
var p *int = &x
*p = 20
fmt.Println(x) // 20
// nil 가능
var q *int = nil
// *q = 10 // 런타임 패닉!
// Rust: 참조
let mut x: i32 = 10;
let p: &mut i32 = &mut x;
*p = 20;
println!("{}", x); // 20
// null 불가능!
// let q: &i32; // 초기화 없이 사용 불가
// 선택적 참조는 Option<&T> 사용
let q: Option<&i32> = None;
| 특성 | Go 포인터 | Rust 참조 |
|---|---|---|
| null 가능 | 예 (nil) |
아니오 (Option 사용) |
| 항상 유효 | 아니오 | 예 (컴파일러 보장) |
| 자동 역참조 | 아니오 | 예 (일부 상황) |
| 빌림 검사 | 없음 | 있음 |
3.4 슬라이스 타입#
문자열 슬라이스: &str#
let s = String::from("hello world");
// 문자열 슬라이스 생성
let hello: &str = &s[0..5]; // "hello"
let world: &str = &s[6..11]; // "world"
// 전체 슬라이스
let whole: &str = &s[..]; // "hello world"
// 처음부터
let start: &str = &s[..5]; // "hello"
// 끝까지
let end: &str = &s[6..]; // "world"
String vs &str:
| 타입 | 소유권 | 저장 위치 | 가변성 | 용도 |
|---|---|---|---|---|
String |
소유 | 힙 | 가변 가능 | 문자열 생성/수정 |
&str |
빌림 | 어디든 | 불변 | 문자열 참조/전달 |
// 문자열 리터럴은 &str
let literal: &str = "hello"; // 프로그램 바이너리에 저장
// String 생성
let owned: String = String::from("hello");
let owned: String = "hello".to_string();
let owned: String = literal.to_owned();
// String에서 &str로 (빌림)
let borrowed: &str = &owned;
let borrowed: &str = owned.as_str();
// 함수 매개변수로는 보통 &str 사용
fn greet(name: &str) {
println!("Hello, {}!", name);
}
greet("Alice"); // &str 직접
greet(&owned); // String에서 자동 변환
배열/벡터 슬라이스: &[T]#
let arr = [1, 2, 3, 4, 5];
let vec = vec![1, 2, 3, 4, 5];
// 슬라이스 생성
let slice1: &[i32] = &arr[1..4]; // [2, 3, 4]
let slice2: &[i32] = &vec[1..4]; // [2, 3, 4]
// 함수에서 슬라이스 받기
fn sum(numbers: &[i32]) -> i32 {
numbers.iter().sum()
}
sum(&arr); // 배열 전체
sum(&arr[1..4]); // 배열 일부
sum(&vec); // 벡터 전체
sum(&vec[1..4]); // 벡터 일부
Go slice와의 비교#
// Go: slice는 동적 배열 (힙)
slice := []int{1, 2, 3, 4, 5}
// 부분 슬라이스 (같은 배열 공유)
sub := slice[1:4]
// append로 확장
slice = append(slice, 6)
// Rust: Vec<T>가 동적 배열
let mut vec = vec![1, 2, 3, 4, 5];
// 부분 슬라이스 (빌림)
let sub: &[i32] = &vec[1..4];
// push로 확장
// vec.push(6); // 에러! sub가 vec를 빌리고 있음
// 빌림이 끝나면 OK
drop(sub); // 명시적 드롭 (또는 스코프 종료)
vec.push(6);
핵심 차이:
- Go: slice가 배열을 소유하거나 공유
- Rust:
Vec<T>가 소유,&[T]는 빌림
3.5 댕글링 참조 방지#
댕글링 참조(Dangling Reference): 해제된 메모리를 가리키는 참조
// C: 댕글링 포인터 가능
int* dangle() {
int x = 10;
return &x; // x는 함수 종료 시 해제됨
} // 반환된 포인터는 유효하지 않은 메모리를 가리킴
// Rust: 컴파일 에러로 방지
fn dangle() -> &String {
let s = String::from("hello");
&s // 에러! s는 함수 종료 시 드롭됨
} // 참조가 가리킬 대상이 없어짐
// 해결: 소유권을 반환
fn no_dangle() -> String {
let s = String::from("hello");
s // 소유권 이동
}
Go에서의 댕글링 문제:
// Go: 클로저에서 변수 캡처 주의
func createFuncs() []func() int {
var funcs []func() int
for i := 0; i < 3; i++ {
funcs = append(funcs, func() int {
return i // 모든 클로저가 같은 i를 참조!
})
}
return funcs
}
// funcs[0](), funcs[1](), funcs[2]() 모두 3 반환
// Rust: 컴파일러가 캡처 방식 명확히
fn create_funcs() -> Vec<impl Fn() -> i32> {
let mut funcs: Vec<Box<dyn Fn() -> i32>> = Vec::new();
for i in 0..3 {
funcs.push(Box::new(move || i)); // 각 클로저가 i를 소유
}
funcs
}
// 0, 1, 2 반환
3.6 라이프타임 기초#
라이프타임이 필요한 이유#
참조가 유효한 범위를 라이프타임이라고 합니다:
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("{}", r); // 에러! 'b < 'a
} // ---------+
컴파일러는 r의 라이프타임 'a가 x의 라이프타임 'b보다 길다는 것을 감지합니다.
함수에서의 라이프타임#
참조를 반환하는 함수는 라이프타임을 명시해야 할 수 있습니다:
// 컴파일 에러! 어떤 참조를 반환하는지 불명확
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
// 라이프타임 명시
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// 의미: 반환값의 라이프타임은 x와 y 중 짧은 쪽과 같다
fn main() {
let string1 = String::from("long string");
{
let string2 = String::from("xyz");
let result = longest(&string1, &string2);
println!("Longest: {}", result); // OK
}
// println!("{}", result); // 에러! string2가 드롭됨
}
라이프타임 생략 규칙#
많은 경우 라이프타임을 생략할 수 있습니다:
// 생략 가능
fn first_word(s: &str) -> &str {
// ...
}
// 완전한 형태
fn first_word<'a>(s: &'a str) -> &'a str {
// ...
}
생략 규칙 (Elision Rules):
- 각 입력 참조는 고유한 라이프타임을 받음
- 입력 라이프타임이 하나면 출력에 적용
&self나&mut self가 있으면 그 라이프타임이 출력에 적용
// 규칙 1 & 2 적용
fn foo(x: &str) -> &str { x }
// fn foo<'a>(x: &'a str) -> &'a str { x }
// 규칙 3 적용
impl MyStruct {
fn method(&self, x: &str) -> &str { x }
// fn method<'a, 'b>(&'a self, x: &'b str) -> &'a str { x }
}
구조체에서의 라이프타임#
참조를 포함하는 구조체는 라이프타임 명시 필수:
// 컴파일 에러!
struct Excerpt {
part: &str, // 라이프타임 없음
}
// 올바른 형태
struct Excerpt<'a> {
part: &'a str,
}
// 사용
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = Excerpt {
part: first_sentence,
};
println!("{}", excerpt.part);
}
// excerpt는 novel보다 오래 살 수 없음
‘static 라이프타임#
프로그램 전체 기간 동안 유효한 라이프타임:
// 문자열 리터럴은 'static
let s: &'static str = "I live forever!";
// 'static 데이터는 바이너리에 포함됨
static GREETING: &str = "Hello";
흔한 라이프타임 오류와 해결법#
// 오류 1: 반환값이 로컬 변수 참조
fn bad() -> &str {
let s = String::from("hello");
&s // 에러!
}
// 해결: 소유권 반환
fn good() -> String {
String::from("hello")
}
// 오류 2: 구조체가 참조보다 오래 살려고 함
fn bad2() {
let excerpt;
{
let novel = String::from("...");
excerpt = Excerpt { part: &novel };
}
// println!("{}", excerpt.part); // 에러!
}
// 오류 3: 가변 빌림 중 불변 빌림
fn bad3() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4); // 에러! v를 가변 빌림하려는데 first가 불변 빌림 중
println!("{}", first);
}
// 해결: 빌림 순서 조정
fn good3() {
let mut v = vec![1, 2, 3];
v.push(4);
let first = &v[0];
println!("{}", first);
}
3.7 실전 연습: 소유권 사고방식 익히기#
일반적인 소유권 패턴#
패턴 1: 소유권 가져오기 vs 빌리기
// 소유권 가져오기 - 값을 소비
fn consume(s: String) {
println!("{}", s);
} // s 드롭
// 빌리기 - 읽기만
fn borrow(s: &String) {
println!("{}", s);
} // 빌림 끝
// 가변 빌리기 - 수정
fn modify(s: &mut String) {
s.push_str("!");
}
패턴 2: 반환으로 소유권 돌려주기
fn process_and_return(mut s: String) -> String {
s.push_str(" processed");
s // 소유권 반환
}
let s = String::from("data");
let s = process_and_return(s); // s 재바인딩
패턴 3: 클론으로 독립적인 복사본 만들기
fn needs_ownership(s: String) { /* ... */ }
let original = String::from("hello");
needs_ownership(original.clone()); // 복사본 전달
println!("{}", original); // 원본 사용 가능
Go 코드를 Rust로 변환할 때 주의점#
// Go: 참조 타입의 암묵적 공유
type User struct {
Name string
}
func updateName(u *User, name string) {
u.Name = name
}
func main() {
user := &User{Name: "Alice"}
updateName(user, "Bob")
fmt.Println(user.Name) // Bob
}
// Rust: 명시적 가변 빌림
struct User {
name: String,
}
fn update_name(u: &mut User, name: &str) {
u.name = name.to_string();
}
fn main() {
let mut user = User { name: String::from("Alice") };
update_name(&mut user, "Bob");
println!("{}", user.name); // Bob
}
컴파일러 에러 메시지 읽는 법#
Rust 컴파일러는 매우 친절합니다:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);
}
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:4:20
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`
3 | let s2 = s1;
| -- value moved here
4 | println!("{}", s1);
| ^^ value borrowed here after move
|
help: consider cloning the value
|
3 | let s2 = s1.clone();
| ++++++++
에러 메시지가 알려주는 것:
- 어디서 이동이 발생했는지
- 왜 이동이 발생했는지 (타입 때문)
- 어디서 이동된 값을 사용하려 했는지
- 해결 방법 제안
소유권 문제 해결 전략#
-
클론 사용: 가장 간단, 성능 비용 있음
let s2 = s1.clone(); -
참조 사용: 소유권 이동 없이 접근
let s2 = &s1; -
구조 변경: 소유권 흐름에 맞게 코드 재구성
// 전: 여러 곳에서 소유권 필요 // 후: 한 곳에서 소유, 나머지는 빌림 -
스마트 포인터 사용:
Rc,Arc(나중에 다룸)use std::rc::Rc; let s = Rc::new(String::from("shared")); let s2 = Rc::clone(&s);
3.8 요약#
소유권 핵심 개념#
| 개념 | 설명 |
|---|---|
| 소유권 | 각 값은 하나의 소유자를 가짐 |
| 이동 | 대입 시 소유권 이동 (힙 데이터) |
| 복사 | Copy 타입은 이동 대신 복사 |
| 빌림 | 참조로 소유권 없이 접근 |
| 라이프타임 | 참조의 유효 범위 |
Go vs Rust 비교#
| 상황 | Go | Rust |
|---|---|---|
| 대입 | 얕은 복사 (참조 공유) | 이동 또는 복사 |
| 함수 전달 | 값/포인터 선택 | 소유권/참조 선택 |
| null 참조 | 가능 (nil) | 불가능 (Option 사용) |
| 데이터 레이스 | 런타임 감지 (race detector) | 컴파일 타임 방지 |
연습 문제#
-
소유권 이동: 다음 코드가 컴파일되지 않는 이유를 설명하고 수정하세요:
let v = vec![1, 2, 3]; let v2 = v; println!("{:?}", v); -
빌림 규칙: 다음 코드가 컴파일되지 않는 이유를 설명하고 수정하세요:
let mut s = String::from("hello"); let r1 = &s; let r2 = &mut s; println!("{}, {}", r1, r2); -
라이프타임: 다음 함수에 적절한 라이프타임을 추가하세요:
fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[0..i]; } } &s[..] } -
Go → Rust 변환: 다음 Go 코드를 Rust로 변환하세요:
func processStrings(strings []string) []string { result := make([]string, len(strings)) for i, s := range strings { result[i] = strings.ToUpper(s) } return result } -
구조체와 라이프타임: 문자열 슬라이스를 포함하는
Book구조체를 정의하고, 제목과 저자를 출력하는 메서드를 작성하세요.