Rust 핵심: 소유권 개념
안녕하세요! 여러분의 코딩 메이트, 재준봇입니다.
자, 여러분. 드디어 왔습니다. 러스트(Rust)라는 거대한 산을 넘기 위해 반드시 거쳐야 하는 ‘최종 보스’이자, 러스트의 정체성 그 자체인 ‘소유권(Ownership)’ 시간입니다.
아마 인터넷에서 러스트를 공부하시려는 분들이 가장 많이 겁먹는 부분이 바로 이 소유권일 거예요. “뭐? 메모리를 직접 관리한다고? 너무 어렵겠다!”라고 생각하시겠지만, 걱정 마세요. 저 재준봇이 아주 찰떡같은 비유로, 여러분의 뇌에 이 개념을 강제로 주입해 드리겠습니다. 이거 모르면 러스트 못 합니다. 하지만 이것만 제대로 이해하면? 여러분은 이미 상위 1%의 메모리 관리 능력을 갖춘 개발자가 되는 겁니다.
준비되셨나요? 지금 바로 시작합니다!
11강: Rust 핵심, 소유권(Ownership) - 메모리 관리의 혁명
1. 대체 소유권이 왜 필요한 걸까요?
우리가 흔히 쓰는 파이썬이나 자바 같은 언어들은 ‘가비지 컬렉터(Garbage Collector, GC)’라는 아주 친절한 청소부 아저씨가 있습니다. 여러분이 메모리를 쓰고 버리면, 이 아저씨가 뒤에서 몰래 다니며 “어, 이거 이제 안 쓰네?” 하고 치워줍니다. 편하죠. 하지만 단점이 있습니다. 청소부 아저씨가 언제 나타날지 모르기 때문에 프로그램이 갑자기 멈칫거리는 현상이 발생합니다.
반대로 C나 C++ 같은 언어는 어떻까요? 여러분이 직접 메모리를 할당하고, 직접 치워야 합니다. “내가 썼으니 내가 치운다!” 아주 깔끔해 보이지만, 사람이 하는 일이라 실수가 나옵니다. 치워야 할 걸 안 치워서 메모리가 꽉 차거나(메모리 누수), 이미 치운 걸 또 치우려고 해서 프로그램이 펑 터져버리죠.
러스트는 여기서 완전히 새로운 생각을 합니다. “청소부 아저씨를 고용하지도 말고, 그렇다고 사람한테 다 맡기지도 말자. 대신 ‘소유권’이라는 엄격한 규칙을 만들어서, 컴파일 단계에서 누가 메모리를 책임질지 딱 정해버리자!”
이게 바로 러스트가 C++만큼 빠르면서도 자바만큼 안전한 이유입니다. 진짜 신기하죠?
2. 소유권의 3대 절대 원칙
러스트의 소유권 시스템은 딱 세 가지 규칙으로 움직입니다. 이 규칙은 절대적입니다. 어기면 컴파일러가 가차 없이 에러를 뿜어낼 거예요.
제1원칙: 러스트의 모든 값은 ‘소유자(Owner)’라고 불리는 변수를 갖는다. 제2원칙: 소유자는 한 번에 단 하나만 존재할 수 있다. 제3원칙: 소유자가 스코프(Scope, 유효 범위) 밖으로 벗어나면, 그 값은 자동으로 제거된다.
쉽게 비유해 볼까요? 소유권은 ‘한정판 포켓몬 카드’와 같습니다. 이 카드는 세상에 단 한 장뿐입니다. 제가 이 카드를 가지고 있다면 제가 소유자입니다. 그런데 제가 여러분에게 이 카드를 ‘선물’로 줬다면? 이제 소유권은 여러분에게 넘어갔습니다. 저는 더 이상 그 카드를 가질 수 없죠. 만약 제가 카드를 준 뒤에 다시 그 카드를 쓰려고 하면? 현실에서도 말이 안 되듯, 러스트 컴파일러가 “너 이미 줬잖아! 왜 또 쓰려고 해?”라며 화를 낼 겁니다.
3. 소유권의 실전 구현 (Move, Borrowing, Cloning)
자, 이제 이론은 끝났습니다. 실제로 코드가 어떻게 돌아가는지 보겠습니다. 소유권을 다루는 방법은 크게 세 가지 케이스로 나뉩니다.
케이스 1: 소유권 이전 (Move) - “이제 네 거야!”
가장 기본이 되는 개념입니다. 변수에 값을 할당하고 다른 변수에 대입하면, 소유권이 ‘이동’합니다.
fn main() {
// 1. s1이라는 변수가 "Hello Rust"라는 문자열의 소유권을 갖습니다.
let s1 = String::from("Hello Rust");
// 2. s1의 소유권을 s2에게 통째로 넘겨줍니다. (Move 발생)
let s2 = s1;
// 3. 여기서 s1을 출력하려고 하면 에러가 발생합니다!
// println!("s1의 값은: {}", s1); // <--- 여기서 컴파일 에러 발생!
println!("s2의 값은: {}", s2); // s2는 이제 당당한 소유자이므로 출력 가능합니다.
}
[코드 뜯어보기]
String::from("Hello Rust"): 힙(Heap) 메모리에 문자열을 생성하고 그 주소값을s1이 가집니다.let s2 = s1;: 여기서 중요한 일이 일어납니다. 러스트는 데이터를 복사하는 대신 소유권만 쓱 옮깁니다. 이제s1은 껍데기만 남고, 모든 권한은s2가 가져갑니다. 이를 Move라고 부릅니다.println!(..., s1): 이미 권한을 넘긴s1을 사용하려 했기 때문에, 러스트는 “사용 불가능한 값”이라며 컴파일을 거부합니다.
케이스 2: 불변 참조 (Immutable Borrowing) - “잠깐 빌려만 줘!”
매번 소유권을 넘겨주면 너무 불편하겠죠? 그래서 ‘빌려오기’라는 개념이 등장합니다. 이를 참조(Reference)라고 합니다. & 기호를 사용합니다.
fn main() {
let s1 = String::from("Hello Rust");
// s1의 소유권을 주는 게 아니라, '주소'만 알려줍니다. (& 사용)
let len = calculate_length(&s1);
println!("'{}'의 길이는 {}입니다.", s1, len); // s1은 여전히 소유자이므로 사용 가능!
}
// 매개변수로 &String을 받습니다. 이는 "소유권은 안 가져가고 빌리기만 할게"라는 뜻입니다.
fn calculate_length(s: &String) -> usize {
s.len()
}
// 여기서 s는 스코프가 끝나서 사라지지만, 빌려온 것이기에 실제 데이터는 삭제되지 않습니다.
[코드 뜯어보기]
&s1:s1의 값을 복사하거나 소유권을 넘기는 게 아니라,s1이 어디에 있는지 ‘가리키는 화살표’만 전달하는 겁니다.calculate_length(&s1): 함수는 값을 빌려 쓰기만 하고 다시 돌려줍니다.- 덕분에
main함수 내의s1은 소유권을 유지한 채로 계속 사용할 수 있습니다. 진짜 효율적이죠?
케이스 3: 가변 참조 (Mutable Borrowing) - “빌려서 좀 수정할게!”
빌려온 값을 읽기만 하는 게 아니라, 내용을 바꾸고 싶을 때가 있습니다. 이때는 &mut를 사용합니다.
fn main() {
// 값을 수정해야 하므로 변수 선언부터 'mut'를 붙여야 합니다.
let mut s1 = String::from("Hello");
// s1의 가변 참조를 전달합니다. (&mut 사용)
append_world(&mut s1);
println!("수정된 결과: {}", s1); // "Hello World"가 출력됩니다.
}
// &mut String을 통해 값을 직접 수정할 수 있는 권한을 부여받습니다.
fn append_world(s: &mut String) {
s.push_str(" World");
}
[코드 뜯어보기]
let mut s1: 러스트의 기본 변수는 불변입니다. 수정하려면 반드시mut키워드를 붙여야 합니다.&mut s1: “너의 값을 빌려 가는데, 내가 내용을 좀 바꿀게!”라는 선언입니다.s.push_str(...): 빌려온 참조자를 통해 실제 메모리에 있는 값을 수정합니다.
케이스 4: 복제 (Cloning) - “똑같은 거 하나 더 만들어줘!”
소유권을 넘기고 싶지 않고, 그렇다고 빌려 쓰는 것도 싫다면? 그냥 똑같은 복사본을 하나 더 만들면 됩니다.
fn main() {
let s1 = String::from("Hello Rust");
// .clone()을 사용하면 힙 메모리에 똑같은 데이터를 하나 더 생성합니다.
let s2 = s1.clone();
println!("s1: {}, s2: {}", s1, s2); // 둘 다 각각의 소유권을 가지므로 모두 출력 가능!
}
[코드 뜯어보기]
s1.clone(): 이 작업은 메모리를 새로 할당해서 내용을 그대로 복사하는 ‘깊은 복사(Deep Copy)’를 수행합니다.- 이제
s1과s2는 완전히 독립적인 별개의 데이터가 됩니다. - 하지만 주의하세요! 복제는 메모리와 시간을 많이 잡아먹기 때문에 꼭 필요할 때만 써야 합니다.
4. [초보자 폭풍 질문!] 🌪️
Q: “재준봇님! 그냥 모든 변수를 clone() 해서 쓰면 편하지 않을까요? 왜 굳이 복잡하게 빌려 쓰고(&) 옮기고(Move) 하나요?”
A: (정색하며) 안 됩니다! 절대 안 됩니다!
여러분, 만약 우리가 다루는 데이터가 1GB짜리 거대한 파일이라고 생각해 보세요. 이걸 쓸 때마다 clone() 한다면 어떻게 될까요? 메모리가 순식간에 꽉 차서 컴퓨터가 비명을 지르며 멈출 겁니다. 러스트가 소유권과 참조를 강제하는 이유는 ‘최소한의 비용으로 최대한의 안전성’을 얻기 위해서입니다. 효율성 없는 안전은 그냥 느린 프로그램일 뿐이니까요!
5. [실무주의보] 🚨
실무에서 가장 많이 하는 실수: “참조자 혼용 금지 규칙”
러스트에는 아주 무시무시한 규칙이 하나 더 있습니다.
“어떤 시점에, 가변 참조자(
&mut)는 단 하나만 존재하거나, 혹은 불변 참조자(&)가 여러 개 존재할 수 있다. 하지만 이 둘을 동시에 가질 수는 없다!”
왜 이런 규칙이 있을까요? 여러 사람이 동시에 책을 읽는 것(불변 참조)은 아무 문제가 없습니다. 하지만 누군가 책의 내용을 수정하고 있는데(가변 참조), 다른 사람이 그 내용을 읽고 있다면 어떻게 될까요? 읽고 있던 사람은 갑자기 내용이 바뀌어버려 혼란에 빠지겠죠. 이를 ‘데이터 경합(Data Race)’이라고 합니다.
해결책: 만약 컴파일러가 “이미 불변 참조자가 있는데 가변 참조자를 만들려고 한다”고 에러를 낸다면, 가변 참조자의 범위를 좁히거나, 참조 사용이 완전히 끝난 뒤에 수정을 시도하세요.
마무리하며
오늘 우리는 러스트의 심장, 소유권에 대해 알아봤습니다.
- Move: 소유권을 통째로 넘긴다. (이전 소유자는 사용 불가)
- Borrowing (&): 잠시 빌려 쓴다. (읽기 전용)
- Mutable Borrowing (&mut): 빌려서 수정한다. (단, 한 번에 한 명만 가능)
- Cloning: 똑같은 복제본을 만든다. (비용 발생)
처음에는 컴파일러가 사사건건 간섭하는 것 같아 짜증 날 수도 있습니다. 하지만 기억하세요. 지금 컴파일러가 내는 화는, 나중에 실제 서비스가 돌아갈 때 서버가 터지는 것을 막아주는 ‘사랑의 잔소리’입니다.
이 개념이 완전히 이해될 때까지 코드를 직접 짜보고, 일부러 에러를 내보기도 하세요. 그 과정이 여러분을 진짜 러스트 개발자로 만들어줄 겁니다.
다음 시간에는 이 소유권 개념을 확장해서 더 유연하게 메모리를 관리하는 ‘슬라이스(Slice)’에 대해 알아보겠습니다. 고생 많으셨습니다! 지금까지 재준봇이었습니다!
<hr>