Ergonomic RC 논의를 이어가며, 핸들/클론을 자동화할지 여부 대신 명시성을 유지하면서도 사용성을 높이는 방향을 1차 목표로 삼아야 한다는 주장을 펼친다. 성능·메모리·정확성에 영향을 주는 사례를 바탕으로, "Rust는 당신을 놀라게 하면 안 된다"와 "러스트의 영혼"이라는 관점에서 명시적 핸들의 가치를 논증한다.
Ergonomic RC에 대한 제 논의를 이어가며 핵심 질문에 집중하고 싶습니다. 사용자가 handle/clone을 명시적으로 호출해야 할까요, 말아야 할까요? 이 “Ergonomic RC” 작업은 원래 Dioxus에서 제안한 것이고, 그들의 답은 간단합니다. 절대 아니다. 그들이 만드는 고수준 GUI 애플리케이션 종류에서는 cx.handle()을 호출해 참조 카운팅된 값을 복제하는 건 순수한 잡음일 뿐입니다. 사실 많은 Rust 앱에서 문자열이나 벡터를 복제하는 것조차 큰일이 아닙니다. 반면 많은 애플리케이션에서는 답이 분명히 예스입니다. 핸들이 어디서 생성되는지 아는 것이 성능, 메모리 사용량, 심지어 정확성에도 영향을 줄 수 있기 때문이죠(걱정 마세요, 뒤에서 예시를 드립니다). 그럼 이걸 어떻게 조화시킬까요?
이 블로그의 주장은 이렇습니다. 명시적으로 쓰는 것을 편리하게 만들자. 예전엔 제 입장이 이렇지 않았지만, Josh Triplett과의 인상 깊은 대화를 계기로 생각이 바뀌었습니다. 이는 제가 예전에 러스트의 영혼이라 부른 것과도 맞닿아 있습니다. 우리는 편의성을 원합니다. 하지만 통제를 가능케 하면서도 편의성을 주고 싶습니다1.
저는 Tyler Mandry의 Clarity of purpose에서 한 말, “좋은 코드는 애플리케이션의 중요한 특성만 당신의 주의를 끈다”를 좋아합니다. 핵심은 이겁니다. 클로닝과 핸들이 중요한 특성인 훌륭한 코드도 분명 존재하며, 그런 코드를 깔끔하게 표현할 수 있어야 합니다. 특히 러스트는 그러한 저수준, 기초적인 코드를 겨냥하는 몇 안 되는 언어 중 하나이기에 더욱 그렇습니다.
이는 자동 클론과 핸들을 (나중에) 지원할 수 없다는 뜻이 아닙니다. 많은 러스트 코드에서 그러한 자동화가 목적의 명료성에 분명 도움이 된다는 점은 논쟁의 여지가 없습니다. 하지만 저는 먼저 더 어려운 경우, 즉 명시성이 필요한 경우에 집중하여 그것을 최대한 좋게 다듬어야 한다고 봅니다. 그 후에 자동화도 지원할지 되짚어 보자는 거죠. 사실 제게는 “완전 명시적” 접근이 충분히 좋아져서 자동 버전이 굳이 필요 없게 될 수 있는지가 하나의 질문입니다. 모든 코드가 대체로 같은 패턴을 따르는 “원 러스트”가 주는 이점이 있습니다. 그 패턴이 때로는 완벽하고, 과한 경우에도 그다지 나쁘지 않다면2 말입니다.
이 글은 Josh Triplett과의 긴 대화3 끝에 나왔습니다. 그 대화에서 제 머리에 남은 핵심 문구는 “Rust는 당신을 놀라게 하면 안 된다”였습니다. 제식으로 말하자면 이렇습니다. 모든 프로그래머는 마라톤 디버깅 세션이 어떤지 압니다. 며칠씩 코드를 들여다보며 “아니… 이게 어떻게 가능한 거지?”라고 중얼거리던 기억요. 그런 버그 사냥은 몇 가지 다른 결말을 맞습니다. 가끔은 논리 속에 아주 미묘하고 만족스러운 버그가 숨어있기도 합니다. 더 흔한 건 if foo를 if !foo로 쓰지 않은 경우죠. 그리고 가끔은 언어가 예상치 못한 무언가를 하고 있었다는 걸 알게 됩니다. 겉보기엔 단순한 코드가 미묘하고 복잡한 상호작용을 감추고 있던 겁니다. 흔히 이런 걸 ‘풋건(footgun)’이라 부르죠.
전체적으로 볼 때 러스트는 그런 풋건을 피하는 데 놀라울 만큼4 뛰어납니다. 우리가 그걸 이뤄낸 방식 중 하나는 당신이 알아야 할 수도 있는 것들이 눈에 보이게, 즉 소스에 명시적으로 드러나도록 한 것입니다. 러스트에서 match를 볼 때마다 “여기서 빠진 경우가 있을까?”라고 자문할 필요가 없습니다. 컴파일러가 모든 경우가 다뤄졌음을 보장하니까요. 러스트 함수 호출을 봐도 그것이 실패 가능성 있는지 스스로 묻지 않아도 됩니다. 실패한다면 ?가 보일 테니까요.5
그렇다면 질문은 이겁니다. 참조 카운트 증가를 알아야 하는 순간이 있을까? 함정은 답이 애플리케이션에 따라 달라진다는 겁니다. 어떤 저수준 애플리케이션에서는 분명 그렇습니다. 원자적 참조 카운트는 측정 가능한 비용이니까요. 솔직히 말해 그런 애플리케이션의 비중은 극히 적을 거라 내기합니다. 게다가 그런 애플리케이션에서도, 러스트는 이미 Rc와 Arc 중에서 선택하게 해주고, 그 선택을 틀리지 않았다는 걸 증명해 주는 점에서 기존 대비 개선되어 있습니다.
하지만 참조 카운트를 추적하고 싶은 다른 이유들도 있고, 그건 쉽게 치부할 수 없습니다. 그중 하나가 메모리 누수입니다. 러스트는 GC 언어와 달리 결정적 소멸을 가집니다. 이는 멋진 일입니다. 소멸자를 활용하여 온갖 자원을 관리할 수 있거든요. Yehuda가 오래전에 RAII를 찬양하며 쓴 고전, “Rust means never having to close a socket”에서도 다룬 바 있습니다. 하지만 핸들이 생성되고 파괴되는 지점은 결정적이지만, 참조 카운팅의 특성상 실제로 기반 리소스가 언제 해제될지 예측하기가 훨씬 어려울 수 있습니다. 그리고 그 증가가 코드에 보이지 않는다면, 그것을 추적하기가 한층 더 어려워집니다.
얼마 전 저는 Swift로 작성한 Symposium을 디버깅했습니다. 하나만 있을 거라 예상했던 IPCManager 인스턴스가 두 개나 있었고, 둘 다 모든 IPC 메시지에 응답해 난장판이 되었죠. 뒤져보니 예상 밖의 곳에 떠돌이 참조가 남아 있어서 벌어진 문제였습니다. 만약 참조 카운트를 증가시키려면 .handle()을 명시적으로 써야 했다면 이 버그가 발생하지 않았을까요? 당연히 발생했을 겁니다. 다만, 사후에 찾기는 더 쉬웠을 겁니다.6
Josh는 “bytes” 크레이트에서 비슷한 예시를 들었습니다. Bytes 타입은 어떤 기반 메모리 버퍼의 슬라이스에 대한 핸들입니다. 그 핸들을 복제하면 전체 백킹 버퍼가 그대로 유지됩니다. 때로는 기반 버퍼가 해제될 수 있도록 슬라이스를 별도 버퍼로 복사하는 편을 선호할 수도 있죠. 커다란 버퍼가 살아있게 만드는 엇나간 핸들을 추적하려 발버둥치며, 그 핸들이 코드 어디에서 생성되는지 명시적으로 볼 수 없어 몹시 답답해하는 장면을 상상하는 건 어렵지 않습니다.
Arc::get_mut 같은 API도 비슷합니다7. get_mut은 &mut Arc<T>를 받아 참조 카운트가 1이면 &mut T를 돌려줍니다. 공유 가능한 핸들이지만 실제로는 공유되고 있지 않음을 당신이 알고 있을 때, 유일성을 되찾게 해주는 겁니다. 이런 API는 자주 쓰이지는 않습니다. 하지만 필요할 때 존재해주는 게 정말 고맙습니다.
Josh와 대화를 시작할 때만 해도, 저는 어떤 형태로든 핸들 클로닝을 자동화하고, 원치 않는 크레이트는 기본 허용 린트를 통해 끌 수 있게 하는 설계를 염두에 두고 있었습니다. 하지만 Josh는 핸들 생성이 편리하면서도 가시적(즉, 소스에 명시적)이어야 하는 애플리케이션 부류가 크다고 저를 설득했습니다. 저수준 네트워크 서비스나 Rust For Linux 같은 것들이 여기에 해당할 공산이 크고, get_mut나 make_mut를 쓰는 어떤 러스트 애플리케이션도 마찬가지일 수 있습니다.
이 얘기를 듣고 예전에 Alex Crichton이 제게 했던 말이 떠올랐습니다. 여기 적은 다른 인용들과 달리, 그 말은 인체공학적 참조 카운팅 맥락이 아니라, 제가 처음으로 “Rustacean 원칙”을 작업하던 때에 나왔습니다. Alex는 러스트가 저수준 코드에 훌륭하면서도 CLI 도구나 간단한 스크립트 같은 고수준 작업에도 잘 맞는 점을 사랑한다고 했습니다.
Alex의 말을 어디에 방점을 찍느냐에 따라 두 가지로 해석할 수 있다고 봅니다. “러스트가 고수준 사용 사례에 잘 맞는 것이 중요하다”로 들을 수도 있습니다. 맞는 말입니다. 그래서 아예 핸들을 보이게 해야 하느냐를 묻게 됩니다.
하지만 “저수준과 고수준 모두에 충분히 잘 맞는 하나의 언어가 있는 것이 중요하다”로 읽을 수도 있고, 그것 또한 진실이라고 봅니다. 러스트가 진가를 발휘하는 순간은 투박한 코드가 필요로 하는 저수준 통제를 고수준 패키지 안에 동시에 담아낼 때입니다. 이것이 제로 코스트 추상의 약속이고, 러스트는 (가장 빛나는 순간들에) 이를 실현합니다.
솔직히 말합시다. 고수준 GUI 프로그래밍은 러스트의 주특기가 아니며, 앞으로도 아닐 겁니다. 사용자가 러스트를 타입스크립트로 착각하진 않을 겁니다. 하지만 타입스크립트는 리눅스 커널에 들어갈 수 없죠.
러스트의 목표는 대체로 두 극단 모두에 “충분히 좋게” 쓰일 수 있는 단일 언어가 되는 것입니다. 목표는 커널 해커에게 필요한 만큼의 저수준 세부를 보이되, GUI에서도 쓸 만할 정도의 사용성을 제공하는 것입니다. 쉽지 않지만, 우리가 해야 할 일입니다.
Josh가 저를 이 깨달음으로 되돌려 놓은 건 이번이 처음이 아닙니다. 지난번은 dyn 트레이트에서의 async fn 맥락이었고, 그때 “러스트의 영혼”에 관한 글과, 더 자세히 파고든 후속 글로 이어졌습니다. “커널에 충분히 저수준, GUI에 충분히 사용성”이라는 캐치프레이즈가 이걸 잘 포착한다고 봅니다.
덧붙이고 싶은 작은 단서가 있습니다. 저는 러스트의 영혼에 또 하나, 인위적 단순화보다 섬세함을 선호한다는 점이 있다고 생각합니다(“가능한 한 단순하게, 그러나 그 이하로는 아니다”라는 말처럼요). 그리고 현실은 이렇습니다. 엄청나게 많은 애플리케이션이 새 핸들을 좌우로 마구 만들어냅니다(특히, 그러나 배타적으로는 아니게도, async 세계에서8). 그런 곳에서 새 핸들을 명시적으로 만드는 건 신호가 아니라 잡음입니다. 예컨대 Swift9가 참조 카운트 증가를 보이지 않게 만든 이유가 여기 있습니다. 그리고 그로부터 큰 이득을 봅니다 아마도 대부분의 Swift 사용자는 Swift가 가비지 컬렉션을 쓰지 않는다는 사실조차 모를 겁니다11.
하지만 핵심은 이겁니다. 설령 핸들 생성을 자동으로 만들어 주는 방식을 추가하더라도, 그것이 명시적이고 가시적인 모드 또한 원합니다. 그렇다면 그걸 먼저 해두는 편이 낫습니다.
좋습니다. 같은 얘기를 벌써 여러 번 했으니 이만 줄이겠습니다. 다음 몇 편에서는 명시성을 유지하면서도 핸들 생성과 클로저를 더 편리하게 만드는 두 가지(이상) 옵션을 파고들 예정입니다.
설계 공리에 넣을 만한 후보가 보이네요… 사악한 깔깔 웃음과 함께 손을 비빈다↩︎
Josh와 제가 자주 나누는 대화 기준으로 보면, 사실 그리 길지도 않았습니다. 길어야 한 시간 남짓.↩︎
적어도 동기(synchronous) 러스트는요. 비동기 러스트에는 특히 취소 주위를 중심으로 풋건이 꽤 있다고 봅니다만, 그건 다른 글의 주제죠.↩︎
물론 패닉은 별개입니다. 패닉을 고려하는 것이 일부 러스트 사용자에게 큰 고통점인 것도 놀랍지 않죠.↩︎
이번 경우엔 애플리케이션이 아주 단순해서 어쨌든 찾기 쉬웠습니다. 하지만 코드베이스 전체에서 증가 지점을 전부 찾아 ripgrep으로 훑는 것이 유용한 상황이 분명히 있고, 명시적 신호가 없다면 그런 작업이 훨씬 어려워질 겁니다.↩︎
혹은 제가 가장 좋아하는 API 중 하나인 Arc::make_mut. Arc<_>를 받아 내부에 대한 가변(즉, 유일) 접근을 항상! 돌려줍니다. 참조 카운트가 1이 아닐 수도 있는데 이게 어떻게 가능하냐고요? 1이 아니면 클론하기 때문입니다. 카피 온 라이트 스타일 코드에 완벽하죠. 너무 아름답습니다. 😍↩︎
제 경험상, 우리가 반드시 고쳐야 할 언어 제약들 때문에 많은 비동기 구성 요소가 당신을 'static 경계로 몰아넣고, 그 결과 원래 &를 쓸 수 있었을 곳에서도 Rc와 Arc를 쓰게 만듭니다.↩︎
요즘 저는 Swift를 더 많이 쓰고 있는데 꽤 마음에 듭니다. 그들이 “크게 간다”는 걸 두려워하지 않는 점이 좋아요. SwiftUI나 비동기 접근 같은 디자인들에서 야심이 느껴집니다. 타율 1.000은 아니겠지만, 담대하게 스윙하는 게 멋집니다. 러스트도 더 많은 것을 요구할 용기가 있었으면 합니다
물론 그것 때문만은 아닙니다. 그들은 별칭(alias)된 상태에서도 클래스 필드를 대입 가능하게 허용하는데, 오래된 참조와 이터레이터 무효화를 피하려면 모든 것을 참조 카운팅 박스로 옮기고 지속적(persistent) 컬렉션을 채택해야 합니다. 이는 성능 비용을 가져오고 Swift를 더 저수준 기초 시스템에 들이기엔 다소 힘들게 만듭니다(제 생각엔 결코 불가능하다는 뜻은 아닙니다).↩︎
다만 많은 이들이 언젠가는 참조 카운트 사이클 앞에서 고개를 긁적이게 될 거라 내기합니다. Swift가 그걸 어떻게 다루는지 제가 깊게 파보진 않았지만, “약한 핸들(weak)” 얘기가 오가는 걸 보면 아직(혹은 아직은?) 사이클 컬렉터를 도입하진 않은 듯합니다. 분명히 하자면, 러스트에서도 참조 카운트 사이클은 생길 수 있습니다! 내부 가변성을 지양하므로 덜하긴 하지만, 아주 어려운 일도 아닙니다.↩︎