경량 복제를 자동화하는 대안을 제시하고, .use 구문 기반 접근과의 비교를 위해 RFC, 야간 빌드 구현, 설계 미팅을 진행하여 사용자 경험과 성능, 문제 탐지 가능성, 그리고 레퍼런스 카운팅을 사용하는 러스트 개발자에게 어떤 접근이 더 나은지 평가한다.
| 메타데이터 | |
|---|---|
| 담당자 | Niko Matsakis |
| 상태 | 제안됨 |
| 대표 과제 | 상위 수준의 Rust |
| 추적 이슈 | rust-lang/rust-project-goals#107 |
| Zulip 채널 | N/A |
| compiler 챔피언 | Santiago Pastorino |
| lang 챔피언 | Niko Matsakis |
| 팀 | compiler, lang |
| 작업 소유자 | Niko Matsakis, Santiago Pastorino |
우리는 경량 복제를 자동으로 수행하는 대안적 RFC를 작성하고, 두 접근을 비교할 수 있도록 언어 팀과 설계 미팅을 진행할 것을 제안합니다. 이 작업은 RFC #3680을 기반으로 합니다. RFC #3680은 클로저(use || ...) 및 x.use와 같은 표현식에서 사용할 수 있는 새로운 키워드 use를 제안하여, Arc<T> 같은 참조 카운팅 데이터 구조를 다루는 일이 장황하고 혼란스럽다는 오랜 문제를 해결하고자 했습니다.
2025H1 작업은 기술적으로 동작하는 .use 문법을 제공했지만, RFC에 대한 커뮤니티 피드백은 긴장을 지적했습니다. 즉, 사용성을 개선하려 한다면 왜 더 많은 필수 문법을 추가하느냐는 것입니다. 우리는 자동 접근을 탐구하는 RFC를 작성하고, 이를 지원하기 위한 구현 작업을 마친 뒤, 언어 팀이 결정을 내릴 수 있도록 설계 미팅을 진행할 것입니다. 이는 사용자 경험과 성능에 미치는 영향, 문제가 될 수 있는 사례를 얼마나 잘 포착할 수 있는지, 그리고 어떤 접근이 참조 카운팅 데이터를 다루는 Rust 개발자에게 더 잘 맞는지 알려줄 것입니다.
참조 카운팅 데이터 구조로 작업하는 것은 대부분의 비사소한 Rust 애플리케이션에 영향을 미치는 장황하고 혼란스러운 패턴을 만듭니다. 이 마찰은 사용자에게 보이는 Arc<T>와 Rc<T>에서만 나타나는 것이 아니라, 참조 카운팅이 보편적이지만 종종 감춰져 있는 Rust 생태계 전반에서 나타납니다.
다음은 async 태스크를 스폰할 때 흔히 보이는 패턴입니다:
#![allow(unused)]
fn main() {
// 현재: 장황하고 반복적임
let config = Arc::new(load_config());
let database = Arc::new(connect_db());
let metrics = Arc::new(MetricsCollector::new());
let config_clone = config.clone();
let database_clone = database.clone();
let metrics_clone = metrics.clone();
spawn(async move {
process_request(config_clone, database_clone, metrics_clone).await;
});
}
이 패턴은 특히 async 및 동시성 코드에서 Rust 코드베이스 전반에 걸쳐 나타납니다. 반복적인 복제는 실제 로직을 가리고, 공유 데이터를 추가하거나 제거할 때 유지보수 부담을 유발합니다.
명시적인 복제의 마찰은 모든 종류의 Rust 프로젝트에 영향을 줍니다. 이는 특히 클로저에서의 명시적 복제 필요성이 가장 큰 고통점 중 하나로 드러나는 상위 수준 애플리케이션에서 두드러지지만, 하위 수준 개발, 특히 async 네트워크 서비스에도 적용됩니다. 참조 카운팅은 Rc나 Arc를 넘어, libstd와 tokio에 있는 채널 핸들처럼 내부적으로 참조 카운팅을 사용하는 수많은 타입에도 해당됩니다. 참조 카운팅을 다루는 사용성을 개선하는 일은 PyO3 같은 상호운용 크레이트, Sycamore와 dioxus 같은 GUI 크레이트, tokio를 사용하는 개발자의 삶을 동시에 개선할 드문 기회입니다. Dioxus labs의 블로그 글에 따르면:
Cloudflare에서 일할 때, Arc로 감싼 데이터 필드가 거의 30개에 달하는 구조체를 다뤄야 했습니다. tokio 태스크를 스폰하는 코드는 다음과 같았습니다:
#![allow(unused)] fn main() { // dns 연결을 수신 let _some_a = self.some_a.clone(); let _some_b = self.some_b.clone(); let _some_c = self.some_c.clone(); let _some_d = self.some_d.clone(); let _some_e = self.some_e.clone(); let _some_f = self.some_f.clone(); let _some_g = self.some_g.clone(); let _some_h = self.some_h.clone(); let _some_i = self.some_i.clone(); let _some_j = self.some_j.clone(); tokio::task::spawn(async move { // 모든 값으로 무언가를 수행 }); }이 코드베이스에서 일하는 건 의욕을 꺾었습니다. 우리는 더 나은 아키텍처를 생각해낼 수 없었습니다 — 사실상 모든 것에 대해 리스너가 필요했고, 그것들이 앱 상태에 따라 업데이트를 필터링해야 했거든요. "그냥 실력을 더 키워라"라고 말할 수도 있지만, 이 팀의 엔지니어들은 제가 함께 일해본 사람들 중 가장 뛰어난 사람들이었습니다. Cloudflare는 Rust에 올인하고 있습니다. 그들은 이런 코드베이스에 돈을 들일 의지가 있습니다. 만약 상태 공유가 이렇게 동작해야 한다면, 핵융합은 Rust로 해결되지 않을 겁니다.
오늘날 프로젝트들은 사용자가 clone을 호출할 필요를 피하도록 API를 구성합니다. Dioxus의 0.5.0 릴리스는 암시적 런타임 아레나를 중심으로 프레임워크를 전환했고; Sycamore도 0.9에서 같은 길을 갔으며, Leptos도 유사한 접근을 사용합니다. 컴파일러에서는, 아레나의 사용으로 대부분의 타입을 Copy로 만들 수 있어서, 더 넓은 재사용을 위해 라이브러리를 분리하려는 시도에 대해 저항이 있었습니다. 이는 로직을 가리는 clone 호출을 추가해야 하기 때문이며, MiniRust와 a-mir-formality 같은 프로젝트는 주로 clone 호출을 피하기 위해 정교한 파사드를 구축했습니다.
명시적 참조 카운팅이 중요한 애플리케이션도 있습니다. 예를 들어, Rc::make_ref 또는 Arc::make_ref 같은 메서드는 참조 카운트가 1인지 2인지에 따라 비용이 매우 다르므로, 참조 카운트가 언제 증가하거나 감소하는지를 아는 것이 중요할 수 있습니다. 원자적 참조 카운팅을 순진하고 널리 사용하는 것은 최적화된 애플리케이션에서 측정 가능한 영향을 줄 수 있으므로, 필요하지 않을 때 참조 카운트를 피하거나(또는 비원자적 참조 카운팅을 선호하는 것) 것은 가치가 있습니다.
이 작업은 RFC #3680과 2025H1 인체공학적 참조 카운팅 목표를 기반으로 하며, 이는 동작하는 야간 구현인 .use 문법을 제공했고, 중요한 설계 질문을 드러낸 폭넓은 커뮤니티 피드백을 생성했습니다.
"경량 복제를 단순화하자(클로저와 async 블록으로의 복제 포함)"라는 제목의 RFC #3680은 경량 복제(예: Arc/Rc의 복제)를 단순화하는 기능을 제안했습니다. 특히 클로저나 async 블록으로 복제할 때 이를 더 쉽게 하되, 그러한 복제가 눈에 보이고 명시적으로 유지되도록 하는 것이었습니다.
이 RFC는 개발자들이 Arc<T> 같은 참조 카운트 객체를 async 블록이나 태스크로 복제해야 할 때 겪는 공통적인 마찰 패턴을 식별하며, 현재 요구되는 장황한 우회 방법의 예시를 제시했습니다. 제안의 목적은 "경량 복제 객체, 특히 객체를 클로저나 async 블록으로 복제하는 작업의 문법적 무게를 최소화하되, 이 동작에 대한 표시를 유지하는 것"이었습니다.
핵심 접근은 Use 트레이트를 도입하여 Arc<T>와 Rc<T> 같은 타입이 경량 복제 동작에 옵트인(opt-in)할 수 있게 하며, 새로운 문법(x.use 및 use || { ... })을 통해 이러한 복제를 더 인체공학적으로(그러면서도 명시적으로) 만들자는 것이었습니다.
야간 Rust에는 이제 RFC #3680의 실험적 구현이 있습니다(단, FAQ의 주의사항 참조):
#![allow(unused)]
fn main() {
let config = Arc::new(load_config());
let database = Arc::new(connect_db());
let metrics = Arc::new(MetricsCollector::new());
spawn(use async {
// config, database, metrics는 블록 안으로 자동 복제됩니다
process_request(config, database, metrics).await;
});
}
RFC #3680은 80개가 넘는 댓글의 폭넓은 논의를 촉발했습니다(요약 참조). 이는 인체공학 문제를 해결하려는 시도에 대한 지지는 물론, 구체적 해결책에 대한 우려도 함께 보여주었습니다. PyO3 같은 프로젝트의 기여자들은 현재 마찰이 실제 현업에 미치는 영향을 강조하며, 특히 참조 카운팅을 많이 사용하는 도메인에서 그 고통이 크다고 했습니다.
논의는 여러 주제를 다뤘습니다. 무엇이 "저렴한(cheap)" 복제인지 정의하는 문제, 컴파일러 최적화가 동작 차이를 초래할 수 있다는 질문, use 키워드의 중의적 사용에 대한 논쟁, 다양한 대체 문법 제안 등이었습니다. 그러나 특히 설득력이 있어 더 깊은 탐구가 필요한 비판이 하나 있었습니다. RFC 스레드 외부에서는, 최소한 순진하게 구현할 경우 새로운 접근이 현재보다 트레이트 시스템을 더 많이 사용하게 되어 컴파일 시간에 영향이 갈 수 있다는 우려도 제기되었습니다.
제기된 여러 우려 중 가장 강력한 비판은 명시적인 .use 문법이 과연 기대하는 인체공학적 목표에 부합하느냐는 점이었습니다. Diggsey의 댓글은 이런 관점을 명확히 표현합니다:
이 낯선 .use 키워드가 난무하는 코드베이스를 헤쳐 나가야 한다는 건, 설명하기도 어렵고(값을 복사합니다... 그런데 그렇지 않을 때도 있고, Use 타입에 사용할 수 있지만 Clone 타입엔 쓸 수 없고, 그런데 Use는 Copy가 아니고, 다릅니다... 언제 어떤 것이 Use인가요? 무엇이 경량인가요? 음... 이 에세이를 읽어보세요. 왜 이 클로저는 use여야 하고 다른 클로저는 그럴 필요가 없나요? 하하, 이건 설명하는 데 오래 걸리겠네요...) clone-into-closure 문제가 유발하던 장애물보다 더 큰 방해물입니다.
이 비판은, 목표가 인체공학이라면 더 많은 명시적 문법을 요구하는 것이 오히려 역효과일 수 있음을 시사합니다. 우리는 기본적으로 경량 복제를 자동으로 수행하고, 비사소한 경우에는 선택적 경고로 보완하는 대안 접근을 탐구할 것을 제안합니다. 이를 통해 언어 팀은 최대한 명시적인 접근(현 RFC)과 매끄럽게 통합된 접근을 모두 평가한 뒤, 인체공학 개선을 위한 Rust의 방향을 결정할 수 있습니다.
자동 대안을 탐구하기 위해 다음을 수행합니다:
인체공학적 참조 카운팅은 여러 도메인에서 마찰 지점입니다. Async Rust 프로그램은 종종 여러 태스크 간에 공유되는 컨텍스트(예: 서버 상태)를 참조 카운팅으로 관리합니다. GUI 애플리케이션 역시 콜백과 데이터 패턴이 스택과 잘 대응되지 않는 경우가 많습니다. 이러한 도메인에서 .clone() 호출(또는 .use 표기)은 본질적인 데이터 흐름 패턴에서 주의를 산만하게 하는 문법적 잡음입니다.
강조: 기존 도메인에서의 장벽 제거: 네트워크 서비스와 Rust가 이미 강점을 가진 async 애플리케이션에서, 명시적 복제 마찰을 없애면 복잡한 아키텍처의 유지보수성과 가독성이 향상됩니다. 개발자들은 인체공학적 API와 성능 중 하나를 선택하거나, 순전히 인체공학적 이유로 아레나 할당 패턴에 의존할 필요가 없게 됩니다.
강조: 새로운 애플리케이션 도메인 개척: 더 중요한 점으로, 이 변화는 Rust가 원래 훌륭한 선택이지만 현재는 복제 마찰 때문에 비용-편익 분석에서 채택이 꺼려지는 도메인을 개척합니다. GUI 애플리케이션, 리액티브 프로그래밍, 데이터 처리 파이프라인, 기타 상위 수준 도메인은 인체공학적 장벽이 제거되면 현실적인 선택지가 됩니다.
강조: 자연스러운 아키텍처 패턴 지원: Dioxus 같은 프로젝트는 참조 카운팅 마찰을 피하기 위해 커스텀 unsafe 추상화를 만들 필요가 없어질 것입니다. 수학 형식화 프로젝트는 산만한 .clone() 호출을 제거하기 위한 전처리기가 필요 없게 됩니다. Rust 컴파일러와 기타 복잡한 애플리케이션은 인체공학적 완화를 위해 주로 아레나에 의존하지 않고도 참조 카운팅 패턴을 더 자연스럽게 사용할 수 있게 됩니다.
이것은 Rust를 초보자에게 더 쉽게 만드는 문제(물론 그런 효과도 있겠지만)가 아니라, 대부분의 비사소한 Rust 애플리케이션에 영향을 미치고, Rust의 성능과 안전성이 매력적인 도메인에서 채택을 가로막고 있는 광범위한 마찰 지점을 제거하는 것입니다. 언어 팀이 명시적 접근과 매끄러운 접근 중에서 내릴 결정은, Rust가 향후 언어 발전에서 인체공학과 명시성의 균형을 어떻게 잡을지에 대한 중요한 선례가 될 것입니다.
이 대안 RFC의 설계 공리는 다음과 같습니다:
| 작업 | 소유자(팀) | 비고 |
|---|---|---|
| 대안 RFC 작성 | Niko Matsakis | 매끄럽게 통합된 접근 |
| 매끄러운 구현 완성 | Santiago Pastorino | 선택적 린트와 함께 x를 x.use와 동일하게 만듦 |
| 표준 리뷰 | ||
| 설계 미팅 | 두 접근을 평가하기 위한 두 번의 미팅 | |
| RFC 결정 | 최대한 추가적인(명시적) 접근 vs 매끄러운 통합 접근 중 선택 |
위에서 사용한 용어의 정의:
피처 게이트 ergonomic_clones(RFC #3680의 실험적 버전)에 대한 구현 계획은 다음과 같습니다. 다음 단계는 기본 기능에 필요하다고 간주되며, 야간에 존재하면 체크 표시가 됩니다.
Rc와 Arc, 그리고 가능하면 다른 타입에도 구현되는 UseCloned 트레이트(타입)를 도입합니다.
Use라고 불렀습니다. 명칭은 정리해야 합니다. 우리는 x.use가 이 트레이트를 구현하지 않은 것까지 포함하여 어떤 타입의 값에도 적용될 수 있기 때문에, 이전 이름이 혼란스럽게 느껴져 달리했습니다. UseCloned의 의도는 "이 타입의 값이 use되면(필요한 경우) 복제된다는 것"입니다.place에 대한 연산자로서 use 키워드를 도입합니다(예: some_place.use).
use || /* body */ 형태의 클로저를 도입합니다. 이는 move 클로저와 동등하지만, 캡처된 각 place place가 f: place 초기화자로 move 클로저에 저장되는 반면, use 클로저는 f: place.use로 초기화된 필드를 포함합니다.
MIR Build에서 place.use는 call_source가 CallSource::Use인 clone 호출로 컴파일됩니다:
컴파일 타임 최적화로서, place.use에 대한 MIR 빌드는 place의 타입 T가 구현한 트레이트에 의존합니다:
T가(라이프타임을 제외하고) Copy를 구현하는 것으로 알려져 있으면, place.use는 place와 같은 복사로 컴파일됩니다.T가(라이프타임을 제외하고) UseCloned를 구현하는 것으로 알려져 있으면, 위에서 설명한 call_source를 가진 호출로 컴파일합니다.place.use는 move로 컴파일합니다.x의 타입이 UseCloned를 구현하는 것으로 알려져 있지 않다면, x는 move로 컴파일됩니다.
some_place.use를 차용 검사에 통합합니다(상대적으로 단순).
"마지막 사용(last-use)" 최적화의 후보를 식별합니다(some_place.use는 이후에 some_place가 다시 사용되지 않는다면 마지막 사용입니다).
코드 생성 시점에 some_place.use의 의미는 some_place의 타입 T에 따라 달라집니다:
T가 Copy를 구현하면, some_place.use는 복사입니다.T가 UseCloned를 구현하고(그리고 마지막 사용이 아니면), some_place.use는 some_place.clone() 호출로 컴파일됩니다.some_place.use는 move입니다.다음 기능들은 미래까지 구현 계획이 없으며, 있으면 좋은(nice to have) 항목입니다:
use 생략과 함수 간(inter-procedural) 최적화 구현
some_place.use가 현재 스택 프레임을 벗어나지 않고, 변경도 일어나지 않는다면, clone() 호출을 생략할 수 있습니다.이 목표는 명시적으로 안정화에 관한 것이 아닙니다 — 명확한 결정 지점에 도달하는 것이 목적입니다. 선택된 접근과 그 구현은 궁극적인 안정화를 향한 한 걸음을 의미하지만, 초점은 2025H1에서 드러난 근본적인 설계 질문을 해결하는 데 있습니다.
언어 팀이 방향을 정하면, 이후 작업은 근본적인 설계 질문의 불확실성 없이 정교화, 커뮤니티 테스트, 안정화 프로세스에 집중할 수 있습니다.
이 목표는 2025H1의 구현 작업과 RFC 피드백을 기반으로 합니다. 현재 구현에는 이미 두 접근 모두에 필요한 인프라 대부분이 포함되어 있으며 — 남은 기술적 작업의 주요 부분은 매끄러운 통합 옵션을 완성하는 것입니다.