취향인 줄 알았다: `&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는 왜 쓰나요
여기까지 와서도 계속 걸렸어요. "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는 반대예요. move가 기본이고, 복사(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에서도 일어나요. - 헷갈리면 자리와 값을 구분해 보면 돼요. 옮겨지는 건 값이고, 자리는 원래 구조체에 남아요.