Rust 소유권 (Ownership) 이해하기
Rust에서 소유권은 단순한 문법이 아니라 메모리를 관리하는 기본 규칙.
Rust는 GC가 메모리를 정리하는 방식도 아니고, 개발자가 직접 free를 호출하는 방식도 아님. 대신 컴파일 시점에 값의 owner, scope, 참조 관계를 검사해서 메모리 안전성을 보장.
목차
- Rust가 메모리를 관리하는 방식
- 소유권 규칙
- Stack과 Heap
- Move, Clone, Copy
- 함수 호출과 소유권 이동
- 참조와 빌림
- Mutable reference 규칙
- Dangling reference를 막는 방식
- Slice 타입
Rust가 소유권을 사용하는 이유
프로그램은 실행 중에 메모리를 사용함.
문제는 사용이 끝난 메모리를 언제 정리할지임. 언어마다 이 문제를 푸는 방식이 다름.
- GC가 있는 언어는 런타임이 더 이상 쓰지 않는 메모리를 찾아서 정리
- C/C++ 같은 언어는 개발자가 직접 메모리 할당과 해제를 관리
- Rust는 컴파일러가 소유권 규칙을 검사해서 메모리 안전성을 보장
Rust의 특징은 런타임이 아니라 컴파일 타임에 최대한 많이 확인해 소유권 규칙을 어기면 프로그램이 실행 중에 터지는 게 아니라 컴파일 자체가 되지 않음.
즉, 소유권은 Rust가 GC 없이 메모리 안전성을 얻기 위한 규칙.
Stack과 Heap
소유권을 이해하려면 Stack과 Heap의 차이를 먼저 알아야 함.
Stack은 크기를 컴파일 타임에 알 수 있는 값을 빠르게 넣고 빼는 공간. 함수 호출, 지역 변수, 정수처럼 크기가 고정된 값이 주로 Stack에 올라감.
Heap은 실행 중에 크기가 달라질 수 있는 데이터를 저장하는 공간. 예를 들어 String은 문자열 길이가 실행 중에 바뀔 수 있으므로 실제 문자열 데이터는 Heap에 저장됨.
대신 Stack에는 Heap 데이터를 가리키는 포인터, 길이, 용량 같은 메타데이터가 들어감.

소유권 규칙
Rust Book에서는 소유권 규칙을 세 가지로 정리함.
- Rust의 각 값은 owner라고 부르는 변수를 가진다.
- 한 번에 owner는 하나만 존재할 수 있다.
- owner가 scope 밖으로 벗어나면 값은 버려진다.
예를 들어 아래 코드에서 s는 String 값의 owner.
{
let s = String::from("hello");
}
s가 scope를 벗어나는 순간 Rust는 drop을 호출해서 String이 사용하던 Heap 메모리를 정리함.
핵심은 메모리를 정리하는 시점이 명확하다는 점. owner가 사라지면 값도 함께 정리됨.
Move
Rust에서는 값을 다른 변수에 대입할 때 소유권이 이동할 수 있음.
let s1 = String::from("hello");
let s2 = s1;
println!("{s2}");
이 경우 s1이 가지고 있던 String의 소유권이 s2로 이동함. 이후에는 s1을 사용할 수 없음.
String의 Stack 데이터에는 Heap을 가리키는 정보가 들어있음. 이 값이 단순히 복사되어 두 변수가 같은 Heap 데이터를 정리하려고 하면 double free 문제가 생길 수 있음.

Rust는 이런 상황을 막기 위해 s1을 더 이상 유효하지 않은 값으로 봄.
그래서 아래 코드는 컴파일되지 않음.
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}");
s1의 값은 이미 s2로 move 되었기 때문.

Clone
Heap 데이터까지 실제로 복사하고 싶다면 clone을 사용.
let s1 = String::from("hello");
let s2 = s1.clone();
println!("{s1}, {s2}");
clone은 명시적인 깊은 복사임. 그래서 비용이 발생할 수 있음.

Rust는 비용이 있는 복사를 조용히 처리하지 않음. 복사가 필요하다면 개발자가 직접 clone을 호출해서 의도를 드러내야 함.
Copy
정수처럼 Stack에만 저장되는 단순한 값은 대입 후에도 기존 변수를 계속 사용할 수 있음.
let x = 5;
let y = x;
println!("{x}, {y}");
이 경우 x와 y 모두 사용 가능.
이런 타입은 Copy trait을 구현함. Copy가 가능한 값은 move가 아니라 복사가 일어남.
대표적으로 아래 타입들이 Copy 가능.
- 정수 타입
- 부동소수점 타입
- bool
- char
- Copy 가능한 타입만 담은 tuple
반대로 String처럼 Heap 리소스를 관리하는 타입은 기본적으로 Copy가 아님.
함수와 소유권
함수에 값을 넘길 때도 대입과 비슷하게 move 또는 copy가 일어남.
fn main() {
let s = String::from("hello");
takes_ownership(s);
}
fn takes_ownership(value: String) {
println!("{value}");
}
s의 소유권은 takes_ownership 함수의 매개변수 value로 이동함. 함수가 끝나면 value가 scope를 벗어나고 String도 정리됨.
반면 i32 같은 Copy 타입은 함수에 넘겨도 기존 변수를 계속 사용할 수 있음.
fn main() {
let x = 5;
makes_copy(x);
println!("{x}");
}
fn makes_copy(value: i32) {
println!("{value}");
}
함수에서 값을 반환하면 소유권이 다시 호출한 쪽으로 이동함.
fn main() {
let s1 = gives_ownership();
let s2 = String::from("hello");
let s3 = takes_and_gives_back(s2);
}
fn gives_ownership() -> String {
String::from("hello")
}
fn takes_and_gives_back(value: String) -> String {
value
}
이 방식만 쓰면 매번 소유권을 넘기고 다시 돌려받아야 해서 코드가 번거로워짐.
그래서 Rust에는 참조와 빌림이 있음.
참조와 빌림
참조는 값을 소유하지 않고 접근하는 방법.
fn main() {
let s = String::from("hello");
let len = calculate_length(&s);
println!("{s}의 길이는 {len}");
}
fn calculate_length(value: &String) -> usize {
value.len()
}
&s는 s의 소유권을 넘기지 않음. s를 잠깐 참조할 뿐.
이처럼 소유권을 가져오지 않고 참조로 사용하는 것을 borrowing이라고 함. 한국어로는 보통 빌림이라고 표현.
다만 빌린 값은 기본적으로 수정할 수 없음.
fn change(value: &String) {
value.push_str(", world");
}
위 코드는 컴파일되지 않음. immutable reference로는 값을 수정할 수 없기 때문.
Mutable Reference
빌린 값을 수정하려면 mutable reference를 사용해야 함.
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{s}");
}
fn change(value: &mut String) {
value.push_str(", world");
}
mutable reference에는 제한이 있음.
같은 값에 대한 mutable reference는 동시에 하나만 만들 수 있음.
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{r1}, {r2}");
이 코드는 컴파일되지 않음.
Rust는 한 시점에 하나의 mutable reference만 허용해서 같은 데이터를 동시에 수정하는 상황을 막음.
immutable reference와 mutable reference를 동시에 사용할 수도 없음.
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s;
println!("{r1}, {r2}, {r3}");
읽는 참조가 사용되는 동안에는 쓰는 참조를 만들 수 없기 때문.
다만 참조의 scope는 마지막으로 사용되는 지점까지로 판단됨.
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1}, {r2}");
let r3 = &mut s;
println!("{r3}");
r1, r2는 println! 이후 더 이상 사용되지 않음. 그래서 그 뒤에는 mutable reference를 만들 수 있음.
Dangling Reference
Dangling reference는 이미 사라진 값을 가리키는 참조.
C/C++에서는 잘못된 포인터 사용으로 이런 문제가 생길 수 있음. Rust는 컴파일 단계에서 dangling reference를 막음.
fn dangle() -> &String {
let s = String::from("hello");
&s
}
이 코드는 컴파일되지 않음.
s는 함수가 끝나면 사라짐. 그런데 &s를 반환하면 호출한 쪽은 이미 사라진 값을 참조하게 됨.
Rust는 이런 코드를 허용하지 않음.
해결하려면 참조가 아니라 값을 직접 반환.
fn no_dangle() -> String {
let s = String::from("hello");
s
}
값을 반환하면 소유권이 호출한 쪽으로 이동하므로 문제가 없음.
참조 규칙
참조 규칙은 이렇게 정리할 수 있음.
- 한 시점에 여러 immutable reference를 만들 수 있음.
- 한 시점에 하나의 mutable reference를 만들 수 있음.
- immutable reference와 mutable reference는 동시에 사용할 수 없음.
- 참조는 항상 유효해야 함.
이 규칙을 검사하는 것이 Rust의 borrow checker.
Slice 타입
Slice는 컬렉션 전체가 아니라 연속된 일부를 참조하는 타입.
대표적인 예가 string slice인 &str.
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
hello와 world는 문자열 데이터를 소유하지 않음. 기존 String의 일부를 참조할 뿐.
Rust Book에서는 first_word 예제로 slice가 왜 필요한지 설명함.
처음에는 첫 번째 단어의 끝 위치를 usize로 반환할 수 있음.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
하지만 이 방식은 문제가 있음.
반환된 숫자는 원래 문자열과 분리되어 있음. 문자열이 바뀌면 이전에 구한 index는 더 이상 의미가 없을 수 있음.
slice를 반환하면 문자열의 일부를 참조하는 값이 됨. 원본 문자열과 관계가 유지되는 셈.
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
여기서 매개변수를 &String이 아니라 &str로 받으면 String과 string literal을 모두 받을 수 있음.
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[..]
}
&str은 문자열 데이터를 소유하지 않는 참조. 문자열 전체나 일부를 가리킬 때 자연스럽게 사용됨.
String Literal과 Slice
문자열 리터럴의 타입은 &str.
let s = "hello";
s는 프로그램 바이너리에 저장된 문자열 데이터를 참조함.
그래서 문자열 리터럴은 기본적으로 immutable.
다른 Slice
Slice는 문자열에만 있는 개념이 아님. 배열에서도 slice를 만들 수 있음.
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
이때 slice의 타입은 &[i32].
즉 slice는 어떤 컬렉션의 일부를 소유하지 않고 참조하는 방식.
정리
Rust의 소유권은 값을 누가 책임지는지 정하는 규칙.
소유권 덕분에 Rust는 값이 언제 정리되어야 하는지 알 수 있음. move는 하나의 Heap 데이터를 여러 owner가 동시에 정리하는 문제를 막고, borrowing은 소유권을 넘기지 않고도 값을 읽거나 수정할 수 있게 해줌.
용어 정리
- ownership: 값을 정리할 책임을 가진 상태
- owner: 값을 소유한 변수
- move: 소유권이 다른 변수나 함수로 이동하는 것
- borrow: 소유권을 가져오지 않고 참조로 빌려 쓰는 것
- immutable reference: 읽기 전용 참조
- mutable reference: 수정 가능한 참조
- dangling reference: 이미 사라진 값을 가리키는 참조
- slice: 컬렉션의 일부를 소유하지 않고 참조하는 값
참고
'Project > 개발 업무' 카테고리의 다른 글
| Rust의 Copy와 Clone 차이점 정리 (0) | 2026.07.04 |
|---|---|
| defer로 인해 lock의 범위가 넓어져 발생한 문제 분석 (0) | 2026.03.26 |
| 기기별 RAM 용량에 따른 이미지 동적 캐시 사이즈 설계 (0) | 2026.03.25 |
| HTTPURLResponse.allHeaderFields 파싱 시 NSNumber 헤더 누락 문제 (0) | 2026.02.04 |
| UNUserNotificationCenter `requestAuthorization`에서 발생하는 희귀한 버그 현상 분석 (0) | 2025.10.27 |