- Published on
Rust 소유권에서 self, &self, &mut self 선택 기준
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 처음 배울 때는 let x = ...; 수준에서 소유권 규칙이 이해되는 듯하다가, 타입에 메서드를 붙이기 시작하면 곧바로 막히는 지점이 생깁니다. 바로 메서드 첫 인자(receiver)인 self, &self, &mut self를 어떤 기준으로 선택하느냐입니다.
이 선택은 단순한 스타일 문제가 아니라 API의 의미(소유권 이동/읽기/변경), 호출 가능성(체이닝, 재사용), 동시성 안전성(공유/배타 대여), 그리고 **성능(복사/할당/드롭 타이밍)**에 직접 영향을 줍니다.
아래에서는 “무엇을 하고 싶은가”를 기준으로 receiver를 고르는 실전 규칙을 정리하고, 자주 쓰는 패턴을 코드로 보여드립니다.
1) receiver 3종의 의미를 한 문장으로
&self: 읽기 전용으로 빌려 메서드를 실행한다. 호출 후에도 원본은 그대로 남고 재사용 가능.&mut self: 가변으로 빌려 내부 상태를 변경한다. 호출 중에는 배타적 접근(동시에 다른 대여 불가).self: 소유권을 가져가서(move) 메서드를 실행한다. 호출 후 원본은 더 이상 사용할 수 없으며, 보통 결과로 새 값을 반환하거나 내부 자원을 “소비”한다.
이 3가지만 정확히 붙잡으면, 대부분의 설계가 정리됩니다.
2) 가장 먼저 확인할 질문: “이 메서드는 호출 후에도 객체가 살아야 하나?”
호출 후에도 계속 써야 한다면: &self 또는 &mut self
- 단순 조회:
&self - 내부 상태 변경:
&mut self
호출 자체가 객체를 소비하는 의미라면: self
- 예:
into_*변환(소유권 이전),build()로 최종 산출 후 빌더 폐기,finish()로 스트림/해시 상태를 소모하고 결과 반환 등
이 질문 하나로 1차 분류가 됩니다.
3) &self를 선택하는 기준 (읽기 전용)
&self는 가장 보수적이고 호출자에게 친절한 선택입니다. 메서드가 객체 상태를 바꾸지 않는다면 기본값으로 &self를 고려하세요.
예: 조회 메서드
#[derive(Debug)]
struct User {
id: u64,
name: String,
}
impl User {
fn id(&self) -> u64 {
self.id
}
fn name(&self) -> &str {
&self.name
}
}
name()이String을 복제해서 반환하면 불필요한 할당이 생깁니다.&str로 빌려주면 호출자가 비용 없이 읽을 수 있습니다.
&self인데 내부 캐시를 갱신하고 싶다면?
읽기 메서드처럼 보이지만 내부적으로 캐시를 채우고 싶을 때가 있습니다. 이때 무작정 &mut self로 바꾸면 호출성이 나빠집니다(동시 접근, 공유 참조 사용 불가).
Rust에서는 이런 경우를 위해 Cell, RefCell, Mutex, RwLock 같은 내부 가변성(interior mutability) 패턴을 씁니다.
use std::cell::RefCell;
struct Page {
raw: String,
cached_words: RefCell<Option<usize>>,
}
impl Page {
fn word_count(&self) -> usize {
if let Some(n) = *self.cached_words.borrow() {
return n;
}
let n = self.raw.split_whitespace().count();
*self.cached_words.borrow_mut() = Some(n);
n
}
}
- 시그니처는
&self라서 호출자는 “읽기”로 느끼지만, 내부적으로는 캐시가 채워집니다. - 단,
RefCell은 런타임 borrow 체크이므로 남용하면 패닉 가능성이 있습니다.
4) &mut self를 선택하는 기준 (상태 변경)
객체의 필드를 바꾸거나, 내부 컬렉션을 수정하거나, 커서를 이동시키는 등 “호출 후 상태가 달라져야” 한다면 &mut self가 정석입니다.
예: 컬렉션에 추가/삭제
#[derive(Default)]
struct Counter {
n: u64,
}
impl Counter {
fn inc(&mut self) {
self.n += 1;
}
fn add(&mut self, x: u64) {
self.n += x;
}
fn get(&self) -> u64 {
self.n
}
}
&mut self는 “이 순간만큼은 내가 배타적으로 이 값을 쓴다”는 선언입니다. 덕분에 데이터 레이스 같은 문제가 원천적으로 차단됩니다.
체이닝을 원한다면 &mut self 반환
빌더나 플루언트 API에서 흔합니다.
#[derive(Default)]
struct Request {
url: String,
headers: Vec<(String, String)>,
}
impl Request {
fn url(&mut self, url: impl Into<String>) -> &mut Self {
self.url = url.into();
self
}
fn header(&mut self, k: impl Into<String>, v: impl Into<String>) -> &mut Self {
self.headers.push((k.into(), v.into()));
self
}
}
fn main() {
let mut req = Request::default();
req.url("https://example.com")
.header("Accept", "application/json");
}
- receiver는
&mut self - 반환도
&mut Self - 호출자가
mut바인딩을 준비해야 하지만, 의미가 명확하고 비용이 없습니다.
5) self를 선택하는 기준 (소유권 소비)
self는 “이 메서드는 객체를 소비한다”는 강한 신호입니다. 대표적으로 아래 상황에서 사용합니다.
5.1 into_* 변환: 소유권을 다른 타입으로 옮기기
struct Token(String);
impl Token {
fn into_inner(self) -> String {
self.0
}
}
String을 복사하지 않고 그대로 꺼내려면self가 필요합니다.&self로는 내부String을 move 할 수 없습니다.
5.2 빌더의 build(self): 빌더를 폐기하고 결과를 얻기
#[derive(Default)]
struct ClientBuilder {
endpoint: String,
timeout_ms: u64,
}
struct Client {
endpoint: String,
timeout_ms: u64,
}
impl ClientBuilder {
fn endpoint(mut self, s: impl Into<String>) -> Self {
self.endpoint = s.into();
self
}
fn timeout_ms(mut self, t: u64) -> Self {
self.timeout_ms = t;
self
}
fn build(self) -> Client {
Client {
endpoint: self.endpoint,
timeout_ms: self.timeout_ms,
}
}
}
여기서 핵심은 빌더의 setter들이 mut self를 받고 Self를 반환한다는 점입니다.
- 장점: 호출자가
mut바인딩 없이도 체이닝 가능 - 단점: 매 호출마다 move가 일어나지만, 보통 최적화되고(또는 구조체가 작으면) 실무에서 문제 되지 않는 경우가 많습니다
5.3 “한 번만 호출되어야 하는” 종료/완료 메서드
예: finish(self)가 결과를 반환하고 더 이상 업데이트가 불가능하도록 강제하고 싶을 때.
struct HasherLike {
state: u64,
}
impl HasherLike {
fn update(&mut self, x: u64) {
self.state = self.state.wrapping_mul(131).wrapping_add(x);
}
fn finish(self) -> u64 {
self.state
}
}
finish(&self)로 만들면 호출자가 계속 finish()를 부를 수 있고, “완료 후 사용 금지”라는 의미를 표현하기 어렵습니다.
6) 실전 선택 규칙: API 설계 체크리스트
규칙 A: 가능하면 &self부터 시작
- 조회/검사/포맷팅/직렬화 등은 대부분
&self Clone을 강요하지 않는 반환 타입(예:&str,&[T])을 우선 고려
규칙 B: “상태 변화”가 본질이면 &mut self
- 카운터 증가, 캐시가 아닌 실제 상태 변화, 큐 pop/push, 커서 이동
&mut self는 호출 제약이 생기므로, 정말로 외부에서 관측 가능한 상태 변화인지 점검
규칙 C: 자원을 꺼내거나 변환해서 “원본을 비우는” 동작이면 self
into_*,build,finish,shutdown류- 소유권 소비를 통해 오용을 컴파일 타임에 금지하는 효과가 큼
규칙 D: 체이닝 UX를 먼저 정하고 receiver를 맞춘다
&mut self체이닝: 호출자가mut를 준비해야 함self체이닝: 호출자는 깔끔하지만, 값이 계속 move 됨
7) 자주 하는 실수와 고치는 법
실수 1: 읽기 메서드가 &mut self를 요구함
// 안 좋은 예: 단순 조회인데 &mut self를 요구
impl User {
fn display_name(&mut self) -> &str {
&self.name
}
}
이렇게 되면 &User만 가진 코드(예: 여러 곳에서 공유 참조로 읽는 코드)에서 호출이 막힙니다. 내부에서 정말 변경이 없다면 &self로 바꾸세요.
실수 2: 소유권이 필요한데 &self로 우회하려다 clone() 남발
struct Job {
id: String,
}
impl Job {
// 안 좋은 예: String을 소유로 반환하고 싶어서 clone
fn id_owned(&self) -> String {
self.id.clone()
}
}
정말 소유 문자열이 필요하다면, 호출자에게 선택지를 줍니다.
- 기본은
&str제공 - 필요 시
to_owned()는 호출자 책임 - 또는
self를 소비하는into_id(self) -> String제공
impl Job {
fn id(&self) -> &str {
&self.id
}
fn into_id(self) -> String {
self.id
}
}
실수 3: self를 받아놓고 결과를 반환하지 않아 사용성이 급락
self receiver는 호출 후 원본을 쓸 수 없게 만듭니다. 그런데도 반환이 ()이면 호출자는 “값을 잃고 끝”이 됩니다. 정말 의도한 것이 아니라면 &mut self로 바꾸거나, 최소한 Self 또는 유의미한 결과를 반환하세요.
8) 고급: receiver와 수명/반환 타입의 조합 감각
&self -> &T는 매우 강력한 패턴
- 할당 없이 내부 데이터를 빌려줌
- 수명은
self에 묶여 안전
struct Config {
path: String,
}
impl Config {
fn path(&self) -> &str {
&self.path
}
}
&mut self -> &mut T는 “부분 가변 대여”
struct Buffer {
data: Vec<u8>,
}
impl Buffer {
fn bytes_mut(&mut self) -> &mut [u8] {
&mut self.data
}
}
이렇게 반환하면 호출자가 슬라이스를 잡고 있는 동안 Buffer 전체는 다시 빌릴 수 없습니다. 이는 Rust의 배타 대여 규칙에 따른 자연스러운 제약이며, API 설계 시 의도한 제약인지 확인해야 합니다.
9) 팀/운영 관점에서의 팁: “제약을 줄여야 디버깅이 쉽다”
receiver 선택은 단지 컴파일을 통과시키는 기술이 아니라, 시스템 전체의 결합도를 바꿉니다. 예를 들어 공유 참조(&self) 기반으로 설계된 컴포넌트는 테스트 더블을 넣거나, 여러 스레드/태스크에서 읽기 접근을 허용하는 방향으로 확장하기 쉽습니다.
운영 환경에서는 작은 설계 차이가 장애 대응 속도에 영향을 줍니다. 예컨대 병목이나 타임아웃을 추적할 때도 “공유 가능한 읽기 API”는 관측 도구를 붙이기 쉽습니다. 인프라 이슈를 다루는 글이지만, 원인 규명 체크리스트를 촘촘히 만드는 접근은 Rust API 설계에도 비슷하게 적용됩니다: K8s CrashLoopBackOff 즉시 원인 찾는 법
또한 CI에서 컴파일/테스트 시간이 늘어나면 피드백 루프가 망가지는데, 캐시 전략을 갖추면 생산성이 크게 올라갑니다: Docker BuildKit 캐시로 CI 빌드 10배 줄이기
10) 결론: 한 줄 규칙으로 정리
- 기본은
&self: “읽기만 한다” - 상태 변화면
&mut self: “내부를 바꾼다” - 자원 이동/완료/변환이면
self: “소유권을 소비한다”
마지막으로, receiver는 단독으로 결정하지 말고 반환 타입과 함께 보세요.
&self+ 빌려주는 반환(&T)은 비용과 제약의 균형이 좋음- 체이닝 UX가 중요하면
&mut self -> &mut Self또는self -> Self중 팀 스타일에 맞춰 선택 self를 쓸 때는 “소비한다는 의미”가 API 이름(into_*,finish,build)에 드러나도록 하는 것이 유지보수에 유리
이 기준으로 기존 코드의 메서드 시그니처를 한 번씩만 점검해도, borrow checker와 싸우는 시간이 눈에 띄게 줄어듭니다.