뷰 타입을 위한 ‘최대한으로 최소화한’ 제안을 설명하며, 메서드가 접근하는 필드를 명시해 borrow checker가 더 정확히 동작하도록 하는 방법을 다룹니다.
이 블로그는 제가 여러 가지 덜 다듬어진 아이디어를 올려 두는 곳입니다.
선정 글:
2026년 3월 21일
참고. 이 페이지는 "View-Types" 시리즈의 일부입니다.
이 블로그 글은 view types에 대한 _최대한으로 최소화한 제안_을 설명합니다. 이 아이디어는 RustNation에서 lcnr와 Jack Huey와 나눈 대화에서 나왔습니다. 우리는 언어에 대한 여러 개선점들, 즉 “공기 중에 떠다니는” 것으로서 사실상 모두가 하고 싶어 하는 것들, 그리고 그것들을 실제로 도입하려면 무엇이 필요한지에 대해 이야기하고 있었습니다.
간단한 예시부터 시작해 봅시다. 메시지 집합으로 생성되는 MessageProcessor라는 구조체가 있다고 가정합시다. 이 구조체는 메시지들을 처리하면서 동시에 몇 가지 간단한 통계를 수집합니다:
pub struct MessageProcessor {
messages: Vec<String>,
statistics: Statistics,
}
#[non_exhaustive] // 예시와는 관련 없고, 그냥 좋은 관례입니다!
pub struct Statistics {
pub message_count: usize,
pub total_bytes: usize,
}
메시지 프로세서의 기본 작업 흐름은 다음과 같습니다.
self.messages 벡터에 push해서 메시지를 누적합니다메시지를 누적하는 것은 쉽습니다:
impl MessageProcessor {
pub fn push_message(&mut self, message: String) {
self.messages.push(message);
}
}
단일 메시지를 처리하는 함수는 메시지 문자열의 소유권을 가져갑니다. 다른 스레드로 보내기 때문입니다. 그러기 전에 통계를 갱신합니다:
impl MessageProcessor {
fn process_message(&mut self, message: String) {
self.statistics.message_count += 1;
self.statistics.total_bytes += message.len();
// ... 여기에 메시지를 어딘가로 보내는 동작이 추가됩니다
}
}
마지막으로 필요한 함수는 누적된 메시지를 비워 내며 처리하는 함수입니다. 이것을 작성하는 일은 당연히 간단해야 할 것 같지만, 실제로는 그렇지 않습니다:
impl MessageProcessor {
pub fn process_pushed_messages(&mut self) {
for message in self.messages.drain(..) {
self.process_message(message); // <-- 오류: `self`가 borrow됨
}
}
}
문제는 self.messages.drain(..)가 self.messages에 대해 가변 borrow를 취한다는 점입니다. self.process_message를 호출하면, 컴파일러는 여러분이 self.messages를 포함한 어떤 필드든 수정할 수 있다고 가정합니다. 따라서 오류를 보고합니다. 논리적으로는 맞지만, 답답합니다.
경험 많은 Rust 프로그래머들은 여러 가지 우회 방법을 알고 있습니다. 예를 들어 messages 필드를 빈 벡터와 교체할 수 있습니다. 또는 self.messages.pop()을 호출할 수도 있습니다. 혹은 process_message를 Statistics 타입의 메서드로 다시 작성할 수도 있습니다. 하지만 솔직히 말해, 이들 모두 최선은 아닙니다. 위 코드는 정말로 꽤 타당하며, 구조를 다시 짜지 않고도 곧바로 동작하게 만들 수 있으면 좋겠습니다.
핵심 문제는 borrow checker가 process_message가 statistics 필드에만 접근한다는 사실을 모른다는 데 있습니다. 이 글에서는 명시적이고 꽤 제한적인 표기법에 초점을 맞추겠지만, 앞으로 이를 어떻게 확장할 수 있을지도 이야기해 보겠습니다.
뷰 타입의 기본 아이디어는 구조체 타입 문법을 확장하여 접근 가능한 필드 목록을 선택적으로 포함하도록 만드는 것입니다:
RustType := StructName<...>
| StructName<...> { .. } // <-- 우리가 추가하는 것
| StructName<...> { (fields),* } // <-- 우리가 추가하는 것
MessageProcessor { statistics } 같은 타입은 “statistics 필드만 접근할 수 있는 MessageProcessor 구조체”를 뜻합니다. MessageProcessor { .. }처럼 ..도 포함할 수 있는데, 이는 모든 필드에 접근할 수 있음을 뜻하며, 오늘날의 구조체 타입 MessageProcessor와 동등합니다.
뷰 타입은 프라이버시를 존중합니다. 즉, 원래 그 필드 이름을 쓸 수 있는 문맥에서만 MessageProcessor { messages }를 쓸 수 있습니다.
self 인자와 그 밖의 위치에서 이름 붙일 수 있다이를 이용하면 process_message가 statistics 필드에만 접근하면 된다고 정의할 수 있습니다:
impl MessageProcessor {
fn process_message(&mut self {statistics}, message: String) {
// ----------------------
// 다음의 축약형: `self: &mut MessageProcessor {statistics}`
// ... 이전과 동일 ...
}
}
물론 이 표기법은 다른 인자에도 사용할 수 있습니다:
fn silly_example(.., mp: &mut MessageProcessor {statistics}, ..) {
}
borrow 식도 확장해서, borrow를 통해 정확히 어떤 필드에 접근할 수 있을지 지정할 수 있게 합니다:
let messages = &mut some_variable {messages}; // 문법이 모호한가요? 아래를 보세요.
이렇게 하면 borrow checker는 &mut MessageProcessor {messages} 타입의 값을 만들어 냅니다.
눈썰미 좋은 독자라면 이것이 모호하다는 점을 알아차렸을 것입니다. 위 식은 오늘날에도 some_variable { messages } 같은 구조체 식에 대한 borrow, 혹은 더 장황하게는 some_variable { messages: messages }로 파싱될 수 있습니다. 이 점을 어떻게 할지는 저도 확신이 없습니다. 아래에서 몇 가지 대체 문법을 언급하겠지만, 이름 해석 결과를 알고 난 뒤에야 구분하도록 컴파일러가 AST를 모호하게 파싱하는 것도 가능은 하다는 점도 덧붙이고 싶습니다.
하지만 이 예시에서 사용자는 &mut borrow를 명시적으로 쓰지 않습니다. 이것은 메서드 호출의 일부로 컴파일러가 추가한 auto-ref의 결과입니다:
pub fn process_pushed_messages(&mut self) {
for message in self.messages.drain(..) {
self.process_message(message); // <-- 여기서 auto-ref가 발생함
}
}
컴파일러는 내부적으로 self.process_message(message) 같은 메서드 호출을, process_message에 선언된 시그니처를 바탕으로 완전 수식 형식으로 다시 씁니다. 오늘날에는 이것이 다음과 같은 코드가 됩니다:
MessageProcessor::process_message(&mut *self, message)
하지만 이제 process_message가 &mut self { statistics }를 선언하므로, 대신 필드 집합을 지정하는 borrow로 디슈거링할 수 있습니다:
MessageProcessor::process_message(&mut *self { statistics }, message)
뷰를 borrow checker에 통합하는 일은 꽤 간단합니다. borrow checker는 borrow 식을 보면 내부적으로 “loan”을 기록하는데, 여기에는 borrow된 place, borrow 방식(mut, 공유), 그리고 borrow된 _lifetime_이 추적됩니다. 우리가 해야 할 일은 뷰를 사용하는 각 borrow에 대해 하나의 loan 대신 여러 loan을 기록하는 것뿐입니다.
예를 들어 &mut self가 있다면 self에 대한 하나의 mut loan을 기록합니다. 하지만 &mut self {field1, field2}가 있다면 mut loan 두 개, 즉 self.field1 하나와 self.field2 하나를 기록하게 됩니다.
좋습니다, 이제 모두 합쳐 봅시다. 아래는 처음의 예시를 한데 모은 것입니다:
pub struct MessageProcessor {
messages: Vec<String>,
statistics: Statistics,
}
#[non_exhaustive]
pub struct Statistics {
pub message_count: usize,
pub total_bytes: usize,
}
impl MessageProcessor {
pub fn push_message(&mut self, message: String) {
self.messages.push(message);
}
pub fn process_pushed_messages(&mut self) {
for message in self.messages.drain(..) {
self.process_message(message); // <-- 오류: `self`가 borrow됨
}
}
fn process_message(&mut self, message: String) {
self.statistics.message_count += 1;
self.statistics.total_bytes += message.len();
// ... 여기에 메시지를 어딘가로 보내는 동작이 추가됩니다
}
}
오늘날에는 process_pushed_messages가 오류를 일으킵니다:
pub fn process_pushed_messages(&mut self) {
for message in self.messages.drain(..) {
// ------------- `self.messages`를 borrow함
self.process_message(message); // <-- 오류!
// --------------- `self`를 borrow함
}
}
이 오류는 두 borrow 사이의 충돌에서 발생합니다:
self.messages.drain(..)는 Iterator::drain(&mut self.messages, ..)로 디슈거링되며, 보시다시피 self.messages를 mut-borrow합니다.self.process_message(..)는 MessageProcessor::process_message(&mut self, ..)로 디슈거링되는데, 보시다시피 self 전체를 mut-borrow하므로 self.messages와 겹칩니다.하지만 “용감한 신세계”에서는 프로그램을 한 곳만 수정합니다:
- fn process_message(&mut self, message: String) {
+ fn process_message(&mut self {statistics}, message: String) {
그러면 그 결과 process_pushed_messages 함수는 이제 borrow check를 성공적으로 통과합니다. 두 loan이 이제 서로 다른 place에 대해 발행되기 때문입니다:
self.messages.drain(..)는 Iterator::drain(&mut self.messages, ..)로 디슈거링되어 self.messages를 mut-borrow합니다.self.process_message(..)는 MessageProcessor::process_message(&mut self {statistics}, ..)로 디슈거링되어 self.statistics를 mut-borrow하며, 이는 self.messages와 겹치지 않습니다.제가 강조하고 싶은 한 가지는 “뷰 타입”이 순전히 정적 구성물이라는 점이며, 이것이 컴파일 방식을 바꾸지는 않는다는 것입니다. 뷰 타입은 단지 borrow checker에 어떤 데이터가 어떤 참조를 통해 접근될지에 대한 더 많은 정보를 줄 뿐입니다. 예를 들어 process_message 메서드는 여전히 self에 대한 단일 포인터 하나를 받습니다.
이 점은 오늘날 존재하는 우회 방법들과 대비됩니다. 예를 들어 제가 위 코드를 작성한다면 process_message를 아마 &mut Statistics를 받는 연관 함수로 다시 쓸 수도 있을 것입니다:
impl MessageProcessor {
fn process_message(statistics: &mut Statistics, message: String) {
statistics.message_count += 1;
statistics.total_bytes += message.len();
// ... 여기에 메시지를 어딘가로 보내는 동작이 추가됩니다
}
}
이 경우 물론 성가실 것입니다. self.process_message() 대신 Self::process_message(&mut self.statistics, ..)라고 써야 하기 때문입니다. 하지만 borrow check 오류는 피할 수 있습니다.
성가신 것에 더해, 이는 코드가 컴파일되는 방식도 바꿉니다. 이제 MessageProcessor에 대한 참조를 받는 대신 Statistics에 대한 참조를 받게 됩니다.
이 예시에서는 한 타입에서 다른 타입으로 바뀌는 것이 무해하지만, 여러 필드에 접근해야 하는 다른 예시들도 있습니다. 그런 경우 각각을 따로 전달하는 것은 효율이 더 떨어집니다.
솔직히 말하면, 그리 어렵지 않습니다. 이 일을 맡고 싶어 하는 좋은 기여자를 찾을 수 있다면 올해 안에도 배포할 수 있다고 생각합니다.
뷰 타입에 등장하는 필드는 그것을 이름 붙이는 코드에서 ‘보이는’ 필드여야 한다고 요구할 것입니다. 여기에는 auto-ref를 통해 삽입된 뷰 타입도 포함됩니다. 따라서 다음 코드는 오류가 됩니다:
mod m {
#[derive(Default)]
pub struct MessageProcessor {
messages: Vec<String>,
...
}
impl MessageProcessor {
pub fn process_message(&mut self {messages}, message: String) {
// ----------
// 여기서 private 필드를 참조하는 것은 *합법적*이지만,
// lint가 발생합니다. 현재도 public 메서드가 private 타입의
// 인자를 받는 것이 *합법적*이지만
// lint 대상인 것과 같습니다. 이 lint가 필요한 이유는
// 이렇게 하면 사실상 이 메서드를 이 모듈 밖에서
// 호출할 수 없게 되기 때문입니다.
self.messages.push(message);
}
}
}
fn main() {
let mut mp = m::MessageProcessor::default();
mp.process_message(format!("Hello, world!"));
// --------------- 오류: field `messages`는 여기서 접근할 수 없음
//
// 이것은 다음으로 디슈거링됩니다:
//
// ```
// MessageProcessor::process_message(
// &mut mp {messages}, // <-- private 필드의 이름을 씀!
// format!("Hello, world!"),
// )
// ```
//
// 여기서는 private 필드 `messages`의 이름을 씁니다. 이는 오류입니다.
}
대체로 그렇습니다. 다만 뷰 타입이 public 필드를 참조한다면 사용할 수 있습니다:
#[non_exhaustive]
pub Statistics {
pub message_count: usize,
pub average_bytes: usize,
// ... 나중에 더 많은 필드가 추가될 수도 있습니다 ...
}
impl Statistics {
pub fn total_bytes(&self {message_count, average_bytes}) -> usize {
// ----------------------------
// 우리가 이 두 필드만 읽는다고 선언합니다.
self.message_count * self.average_bytes
}
}
맞습니다! 제한적일 것입니다! 하지만 좋은 출발점입니다. 제 경험상 이 문제는 여기서 보여 드린 것 같은 private 헬퍼 메서드에서 가장 자주 발생합니다. public 문맥에서도 생길 수는 있지만 훨씬 드물고, 그런 상황에서는 사용자에게 그룹화를 더 잘 드러내도록 타입을 리팩터링하는 편이 더 받아들여지기 쉽습니다. 그렇다고 public 경우도 고치고 싶지 않다는 뜻은 아닙니다. 단지 MVP에서는 잘라 내기 좋은 사용 사례라는 뜻입니다. 미래에는 예전에 설명했던 것처럼 abstract fields를 통해 public 필드를 다루고 싶습니다.
맞습니다! 반복적일 것입니다! 앞으로는 제 abstract fields 블로그 글에서 설명했던 것 같은 일종의 ‘ghost’ 또는 ‘abstract’ 필드를 보고 싶습니다. 하지만 이것도 역시 제게는 “MVP 이후”의 문제처럼 보입니다.
제가 설명한 문법에서는 &mut place {field1, field2}를 명시적으로 써야 합니다. 하지만 문헌에는 이런 종류의 것을 추론하는 여러 접근법이 있으며, 그중 row polymorphism이 아마 가장 직접적으로 적용 가능할 것입니다. 저는 이런 종류의 추론을 얼마든지 도입할 수 있다고 생각하며, 사실 아마 이것을 기본으로 만들었을 것입니다. 그래서 &mut place가 항상 뷰 타입을 도입하지만, 실제로는 보통 “모든 필드”로 추론되도록 말입니다. 다만 이것은 Rust의 추론 시스템에 대한 만만치 않은 확장으로, 오늘날 우리가 하지 않는 새로운 종류의 추론을 도입합니다. MVP를 위해서는, 가장 흔한 경우는 auto-ref가 압도적으로 많이 덮어 준다는 점에 기대고, 나머지 경우를 위해 명시적 문법을 두는 쪽으로 갈 것 같습니다.
많은 응용에서는, 특히 private 메서드의 경우, 접근할 필드 목록을 직접 적는 것이 좀 우스워 보인다는 점을 이해합니다. 컴파일러가 알아낼 수 있어야 하지 않나 싶지요.
반대로, 이것은 Rust에서 여러 이유로 피하려고 하는 종류의 절차 간 추론입니다:
Send 주변에서 우리가 겪는 혼란을 떠올려 보세요.저에게 핵심은 _단계적 접근_입니다. 무엇을 하든, 정확히 어떤 필드가 어디서 접근되는지 명시할 수 있는 방법은 필요하다고 생각합니다. 그러므로 먼저 그것을 추가해야 합니다. 추론은 나중에 더할 수 있습니다.
또 다른 흔한 대안으로, 한동안 제가 고려하기도 했던 방식은 단일 참조 대신 필드에 대한 참조들을 넘기도록 일종의 “디슈거링”을 추가하는 것입니다. 저는 이것이 마음에 들지 않습니다. 이유는 두 가지입니다. 첫째, 솔직히 더 복잡하다고 생각합니다! 이것은 borrow checker에 대한 꽤 직선적인 변경이지만, 그런 디슈거링은 컴파일러 곳곳에 코드를 남기게 되고 진단 등도 훨씬 더 복잡해질 것입니다.
둘째, 런타임에서 일어나는 일 자체를 바꿔야 하며, 이 예시에서 그것이 왜 필요한지 저는 모르겠습니다. 참조 하나를 전달하는 쪽이 제게는 더 자연스럽습니다.
아, 맞습니다, 문법의 모호성 문제요. 솔직히 문법에 대해서는 아직 깊이 생각해 보지 않았습니다. 저는 Struct { field1, field 2 } 같은 타입이 구조체 생성자 문법을 반영하길 바랐습니다. 일반적으로 타입이 식을 반영하도록 하려 하기 때문입니다. 하지만 물론 그러다 보니 borrow 식에서 문제를 일으키는 모호성이 생깁니다:
let foo = &mut some_variable { field1 };
// ------------- 이것은 변수인가요, 필드 이름인가요?
제가 보기에는 다음과 같은 선택지가 있습니다:
None 같은 패턴의 해석이 그렇습니다. 이것이 변수 None에 대한 바인딩인지, 아니면 enum variant인지 말입니다.&mut {field1} in some_variable 같은 식입니다. 명시적 borrow 형태를 직접 타이핑하는 일은 드물 테니, 괜찮아 보입니다.요약하면, 적어도 lang experiment 수준에서는 여기서 앞으로 나아가는 것을 가로막는 무언가가 딱히 보이지 않습니다.