취향인 줄 알았다: `&self` / `&mut self` / `self`, 그리고 move의 진짜 의미
Rust 메서드를 쓰다 보면 &self, &mut self, self 셋 중 뭘 쓸지 매번 걸립니다. 저는 한동안 이게 취향 문제인 줄 알았어요 — 수정하려면 &mut self, 아니면 &self, self는 그냥 좀 더 세게 쓰는 것쯤으로요. 아니었습니다.
이 글은 receiver 세 종류가 실제로 뭐가 다른지, move가 무슨 일을 하는 건지, 그리고 원본을 못 쓰게 되는데도 self를 쓰는 이유가 뭔지를 다룹니다. 빌더 패턴의 mut self가 정확히 어디에 붙는 건지는 아직 안 팠으니 다루지 않습니다. 코드와 에러 메시지는 전부 로컬 rustc 1.97.0에서 직접 돌려본 결과입니다.
들어가기 전에 — 용어 짚기
- move / 소유권(ownership): Rust에서 모든 값에는 주인이 하나뿐이다. 그 주인이 사라질 때 값도 정리된다. move는 주인이 바뀌는 일 — 값이 새 주인에게 넘어가고, 원래 주인이던 이름은 그 값을 더 못 쓴다.
- 자리(place) / 값(value):
c.name이라고 쓰면 두 가지를 동시에 가리킨다. c 안의 name 슬롯이라는 자리, 그리고 그 자리에 담긴 값. Rust Reference의 정식 용어로도 place expression과 value expression이 갈린다. 이 글의 중심 구분이다.
1. receiver 세 종류는 취향이 아니다
수정하려면 &mut self를 쓴다는 건 알겠는데, &self랑 self의 차이는 취향차이 아니냐고 생각했어요. 아니었습니다.
동작 자체가 다르다. 갈림길은 하나뿐이다 — 호출한 뒤에 인스턴스가 살아있느냐.
struct Counter { count: u32 }
impl Counter {
fn get(&self) -> u32 { self.count } // 읽기만
fn increment(&mut self) { self.count += 1; } // 수정
fn into_count(self) -> u32 { self.count } // 소비 (인스턴스를 삼킨다)
}
| receiver | 할 수 있는 것 | 호출 후 원본 | 호출 측 제약 |
|---|---|---|---|
&self | 읽기만 | 살아있음 (여러 번 호출 가능) | 없음 |
&mut self | 필드 수정 | 살아있음 | 변수가 let mut여야 함 |
self | 값 꺼내가기 / 변환 / 소비 | 무효화 (move) | 호출하는 순간 원본이 봉인됨 |
let c = Config { name: "hello".into() };
let a = c.peek(); // &self → 빌려줄 뿐, c는 살아있다
let b = c.peek(); // 그래서 또 호출해도 된다
let s = c.take(); // self → 소유권이 넘어간다. c는 여기서 move
// c.peek(); // 컴파일 에러: c는 이미 move됨
&mut self도 공짜가 아니다. 호출하는 쪽 변수가 let mut로 선언돼 있어야 한다.
error[E0596]: cannot borrow `c` as mutable, as it is not declared as mutable
7 | c.increment();
| ^ cannot borrow as mutable
help: consider changing this to be mutable
6 | let mut c = Counter { count: 0 };
상태를 바꾸지도 않으면서 &mut self로 만들어두면, 이 타입을 쓰는 사람에게 필요 없는 mut를 강요하는 셈이 된다. 기본은 &self고, 필요할 때만 위로 올린다.
2. move는 파괴가 아니다
self를 받는 메서드를 부르면 이후로 원본에 접근을 못 한다는 걸 보고, 이거 해체시킨 건가 싶었어요. 값이 그냥 없어졌다고 봐야 하나, JS의 undefined 같은 건가 하고요. 둘 다 아니었습니다.
해체되는 건 값이 아니라, 원래 변수(바인딩)의 사용 권한이다.
let s = c.take_name();
// "hello"라는 값 → 멀쩡히 살아있다. 이제 주인이 s일 뿐이다.
// c라는 변수 → 컴파일러가 "사용 금지" 딱지를 붙인다.
값은 주인이 바뀐 것뿐이고 파괴된 게 아니다. Rust가 원본 쪽을 막는 이유는 double free 방지다 — 같은 값을 두 이름이 각자 자기 것이라고 믿으면 정리도 두 번 일어난다. 주인이 하나여야 하니까 나머지 이름을 막는다.
undefined와는 층 자체가 다르다.
JS undefined | Rust에서 move된 변수 | |
|---|---|---|
| 값이 있나 | 실재하는 값이 하나 있다 | 값은 새 주인에게 갔다. 여기서 논할 값이 없다 |
| 언제 정해지나 | 런타임 | 컴파일 타임 |
| 읽으면 | undefined가 나오고 프로그램은 계속 돈다 | 컴파일 거부. 실행 파일이 아예 안 만들어진다 |
| 사고는 | 한참 뒤에 런타임에서 터진다 | 사고 날 코드가 애초에 세상에 못 나온다 |
move된 변수는 "비었음을 나타내는 특수한 값"을 갖는 게 아니다. 컴파일러 장부에 "이 이름은 이 시점부터 무효"라고 적히는 것뿐이다. 그래서 런타임 비용이 0이다. 실행 파일에는 move라는 사건의 흔적이 남지 않는다.
3. 그럼 self는 왜 쓰나
여기까지 오고도 계속 걸렸어요. 원본을 못 쓰게 되는데 굳이 왜 쓰나, 성능 때문에 최적화하려는 건가 싶었고요. 둘 다 틀린 질문이었습니다.
먼저 틀을 하나 바꿔야 한다. Rust에서 move는 최적화 기법이 아니라 기본 대입 의미론이다. 메서드도 self도 없이 그냥 대입만 해도 move가 일어난다.
let a = String::from("plain let binding, no method call at all");
let b = a; // 메서드 아님. 그냥 대입
let n = eat(b); // 함수 인자로 넘기는 것도 move
a ptr = 0x102b2dcb0
b ptr = 0x102b2dcb0 <- 같은 버퍼. 그리고 이 시점부터 a는 E0382
그러니까 fn take(self)는 특별한 기술이 아니라, let b = a에 이미 있던 규칙이 메서드의 첫 인자에 그대로 적용된 것이다. C++이나 JS에서 오면 "복사가 기본이고 zero-copy가 최적화"로 읽히는데 Rust는 반대다. 이사가 기본이고, 복사(clone())가 명시적으로 요청하는 쪽이다.
질문도 뒤집어야 맞다. "왜 파괴하면서까지 쓰냐"가 아니라 "원본 이름을 포기하는 대가로 뭘 얻느냐"다. 셋이다.
복사 없이 데이터를 물려받는다
String::into_bytes가 self를 받는다.
String ptr = 0x1056c5c50, len = 63
Vec<u8> ptr = 0x1056c5c50, len = 63 <- 같은 주소. 힙 버퍼를 그대로 넘겨받았다
&self로는 원리상 불가능하다. 빌린 것에서 훔쳐올 수 없으니 복사밖에 방법이 없다.
s2 ptr = 0x1056c5cd0
to_vec ptr = 0x1056c5d10 <- 새로 할당하고 63바이트를 복사했다
String을 Vec<u8>로 바꾸는 데 힙 할당이 0번이냐 1번이냐가 갈린다. 표준 라이브러리의 into_* 계열이 전부 self를 받는 이유다. 원본 이름을 포기했기 때문에 버퍼를 재활용할 수 있는 것 — 이름이 살아있으면 두 이름이 같은 버퍼를 갖게 되니 컴파일러가 넘겨줄 수 없다.
두 번 하면 안 되는 일을 컴파일러가 막는다
impl Conn { fn close(self) { println!("closed"); } }
c.close();
c.close(); // 두 번째
error[E0382]: use of moved value: `c`
7 | c.close();
| ------- `c` moved due to this method call
8 | c.close();
| ^ value used here after move
note: `Conn::close` takes ownership of the receiver `self`, which moves `c`
닫힌 커넥션을 또 닫는 코드가 컴파일되지 않는다. 런타임 플래그로 if self.closed { panic!() } 하는 대신 타입 수준에서 없앤 것이다. 여기서 "원본이 봉인된다"는 건 부작용이 아니라 원하는 기능 그 자체다.
애초에 원본이 필요 없다
필드를 다 꺼내갔으면 남은 껍데기는 쓸 데가 없고, 빌더도 .build() 뒤엔 다시 볼 일이 없다. 이럴 때 &self로 만들면 안 쓸 원본을 억지로 살려두느라 호출하는 쪽이 clone()을 부르게 된다.
성능으로만 보면 틀린다
move가 공짜는 아니다. String 자체는 24바이트(포인터 + 길이 + 용량)라, move할 때 이 24바이트는 스택에서 복사될 수 있다. 옮겨지지 않는 건 힙에 있는 실제 데이터 쪽이다. 큰 구조체를 계속 move하면 그 스택 복사가 남기도 하는 것으로 보인다(대개 최적화로 지워진다고 알려져 있지만 여기서 확인하지는 않았다). 정확한 표현은 "move는 빠르다"가 아니라 "힙 데이터를 다시 할당하지 않는다"다.
그리고 사실이 아닌데 성능 때문에 self로 만들면 역효과가 난다. 호출한 쪽이 원본을 계속 쓰려고 clone()을 부르게 되니 복사가 오히려 는다.
self는 최적화 선택이 아니라 수명 선언이다. "이 인스턴스는 여기서 역할이 끝난다"가 사실일 때 쓰는 것이고, 성능은 그 선언이 맞을 때 따라오는 보상이다. 원본이 그 뒤로도 필요하면 &self를 쓰면 된다. 강제되는 게 아니라 고르는 것이다.
4. 옮겨지는 건 자리가 아니라 값이다
여기서 감이 왔어요. c.name을 갖는 게 아니라 c.name에 있던 값을 갖는 거구나 싶었는데, 맞았습니다.
c.name에는 두 가지가 섞여 있다.
- 자리(place): c 구조체 안의 name 슬롯. 메모리 위치다.
- 값(value): 그 자리에 담긴 데이터.
"hello"다.
c의 name 자리: [ "hello" ]
│ move — 값만 이사한다. 자리는 c 소속이라 못 옮긴다
▼
s: [ "hello" ] ← 이제 s가 이 값의 주인
Rust의 move는 언제나 자리가 아니라 값의 이동이다. 이 하나를 잡으면 앞 절들이 같은 문장으로 다시 읽힌다.
&self= 자리를 잠깐 보여주는 것. 값은 c에 그대로 있고, 그래서 c도 살아있다.self= 값을 다른 자리로 이사시키는 것. 원래 자리는 비고, c라는 이름은 봉인된다.
3절의 into_bytes가 힙 주소를 그대로 물려받은 것도 같은 얘기다. 옮긴 건 자리가 아니라, 그 자리에 있던 포인터 값이다.
5. mut은 값이 아니라 바인딩에 붙는다
그럼 let mut s = c.take()로 받으면 수정이 되는 건지, 원래 c가 mut이 아니었는데도 되는 건지 궁금했어요. 됩니다. 그리고 c가 원래 뭐였든 상관없습니다.
let c = Config { name: "hi".into() }; // c는 불변
let mut s = c.take(); // 받는 쪽에서 새로 mut을 선언한다
s.push_str("!"); // 통과. 출력: hi!
move는 소유권만 넘긴다. 가변성은 받는 변수가 새로 정한다. let mut s면 수정 가능하고 let s면 불변이다. 데이터에 "나는 수정 가능함"이라는 꼬리표가 붙어서 따라다니는 게 아니다. mut은 타입의 속성도 값의 속성도 아니고, 그 이름이 지금 가진 권한이다.
그리고 s를 고친다고 해서 "c.name을 고친다"는 말이 성립하지도 않는다. 값은 이미 s의 것이고, 고칠 c.name이라는 게 존재하지 않으니까. 4절의 자리와 값 구분이 여기서 다시 쓰인다 — 자리는 c에 남았지만 비어 있고, 값은 s에 있다.
6. self는 필드 하나만 꺼내도 구조체 전체를 삼킨다
c에 name과 port가 둘 다 있는 상태에서 take_name()을 부르면, name만 꺼내갔으니 port는 남아 있는 거 아닌가 싶었어요. 아니었습니다.
struct Config { name: String, port: u32 }
impl Config {
fn take_name(self) -> String { self.name }
}
let c = Config { name: "hi".into(), port: 8080 };
let s = c.take_name();
println!("{}", c.port); // 에러
error[E0382]: borrow of moved value: `c`
7 | let s = c.take_name();
| ----------- `c` moved due to this method call
8 | println!("{} {}", s, c.port);
| ^^^^^^ value borrowed here after move
note: `Config::take_name` takes ownership of the receiver `self`, which moves `c`
메서드가 받는 건 name이 아니라 self, 즉 c 전체다. 단위를 정하는 건 반환값이 아니라 receiver다. name만 돌려줘도 소유권 이전은 구조체 통째로 일어나고, c라는 이름 전체에 사용 금지가 붙으니 port도 같이 막힌다. 안 꺼내진 port는 메서드가 끝날 때 조용히 drop된다.
예외 — 메서드를 안 거치면 필드만 이사한다
let c = Config { name: "hi".into(), port: 8080 };
let n = c.name; // 필드를 직접 move
println!("{} {}", n, c.port); // 통과. 출력: hi 8080
이걸 partial move라고 한다. 나간 필드만 막힌다.
error[E0382]: borrow of moved value: `c.name`
4 | let n = c.name;
| ------ value moved here
5 | println!("{} {}", n, c.name);
| ^^^^^^ value borrowed here after move
에러 메시지가 c가 아니라 c.name을 지목한다. 장부가 필드 단위로 갈린다는 뜻이다.
c.take_name()→ 메서드가self를 삼킨다 → c 전체 move → port도 막힘let n = c.name→ 필드만 직접 이동 → partial move → 나머지 필드는 살아남음
메서드를 거치느냐 필드를 직접 만지느냐가 갈림길이다. 여러 필드를 다 살려서 꺼내고 싶으면 튜플로 묶어서 한 번에 내보내면 된다.
fn into_parts(self) -> (String, String, u32) {
(self.name, self.host, self.port) // 셋 다 각자 새 주인에게
}
예외의 예외 — Drop을 구현하면 partial move가 막힌다
impl Drop for Config { fn drop(&mut self) {} }
let n = c.name;
error[E0509]: cannot move out of type `Config`, which implements the `Drop` trait
5 | let n = c.name;
| ^^^^^^ cannot move out of here
drop(&mut self)는 값이 사라질 때 구조체 전체를 온전한 상태로 넘겨받는다. 필드 하나가 이미 빠져나간 반쪽짜리를 넘길 수 없으니, 컴파일러가 빼가는 쪽을 먼저 막는다. partial move는 이 구조체 전체를 다시 볼 사람이 아무도 없을 때만 허용되는 셈이다.
7. Copy 타입에선 애초에 move가 일어나지 않는다
Copy는 "이 타입은 비트를 그대로 복사하면 완전한 사본이 된다"는 표시다. 대입이나 인자 전달에서 원본을 무효화하는 대신 복사본을 하나 더 만든다. 값의 이사가 아니라 값의 복제다.
같은 구조체 안에서도 필드 타입에 따라 갈린다.
struct Config { name: String, port: u32 }
let c = Config { name: "hi".into(), port: 8080 };
let p = c.port; // u32는 Copy
println!("{} {} {}", p, c.port, c.name); // 통과. 출력: 8080 8080 hi
c.port를 꺼냈는데 c.port도 c.name도 다 살아있다. 6절에서 let n = c.name이 c.name을 봉인했던 것과 정확히 대비된다. 문법은 똑같은데 타입에 따라 이사냐 복제냐가 갈린다.
구조체 전체가 Copy면 self receiver도 원본을 죽이지 않는다.
#[derive(Clone, Copy)]
struct Point { x: i32, y: i32 }
impl Point { fn into_x(self) -> i32 { self.x } }
let p = Point { x: 1, y: 2 };
let a = p.into_x();
let b = p.into_x(); // 두 번 호출해도 통과
println!("{} {} {}", a, b, p.y); // 출력: 1 1 2
그러니까 "self를 받으면 원본이 봉인된다"는 규칙의 정확한 형태는 **"비-Copy 타입일 때"**다. 3절에서 fn close(self)로 두 번 호출을 막는다고 했는데, 그 설계는 Copy 타입에선 성립하지 않는다.
사실 6절의 에러 메시지가 이미 이 조건을 말하고 있었다.
move occurs because `c` has type `Config`, which does not implement the `Copy` trait
다시 정리하면
&self는 "잠깐 볼게"다. 원본이 유지되고, 기본값으로 두면 된다.&mut self는 "고칠게"다. 원본은 유지되지만 호출하는 쪽에let mut를 요구한다.self는 "이제 내가 가져간다"다. 값을 꺼내갈 때, 변환할 때, 두 번 호출을 막고 싶을 때.- move는 값의 이사에 원래 이름의 봉인이 따라붙는 것이다. 파괴도 아니고
undefined도 아니다. 봉인은 컴파일러 장부에만 존재하는 정적 규칙이라 런타임 비용이 없다. - 그리고 move는 예외적인 사건이 아니라 기본값이다.
let b = a에도 이미 들어 있다.
헷갈리면 자리와 값 구분으로 돌아가면 됩니다. 옮겨지는 건 언제나 값이고, 자리는 원래 있던 구조체에 남습니다. 취향인 줄 알았던 게 사실은 "이 인스턴스를 여기서 끝낼 거냐"는 수명 선언이었고, 성능은 그 선언이 맞을 때 따라오는 보상이었네요.