Rust 기반 풀스택 UI 프레임워크 Dioxus의 역사적 맥락, 개발 경험, WASM 디버깅, 핫 패칭, 그리고 저자의 애증을 유머러스하게 다룬 글.
주의: 이 글은 제가 Rust Paris Meetup에서 했던 발표를 바탕으로 각색한 것입니다 — 그래서 평소와 조금 다르게 들릴 수 있어요. 재밌게 보세요!
좋은 저녁입니다! 오늘 밤 저는 이런 질문에 답해 보려 합니다. Dioxus는 기쁨을 줄까요? 적어도, 약간의 기발함 정도는 줄까요?
Dioxus가 뭐냐고요? 우선 가장 먼저, 인용하자면 “법적으로 어떤 포켓몬에서도 영감을 받지 않은” 이름입니다.
물론 저자는 Hacker News 댓글에서 “Deoxys” 포켓몬이, 다시 인용하자면, “존나 멋지다(awesome)”는 건 인정했습니다.
혹시라도 닌텐도가 저 탄생 비화를 못 믿어서 나중에 법률 비용이 필요해질 상황에 대비하기 위해, Dioxus는 2023년 여름 기준으로 Y Combinator 스타트업이기도 합니다.
단골 독자분들은 여기서 이런 생각을 하실 수 있습니다. “이게 Rust랑 대체 무슨 상관이야?” 걱정 마세요, 제가 대신 확인해 봤습니다. Dioxus는 실제로 화웨이에게서 돈을 받고 있습니다. 그러니까 다른 Rust 프로젝트랑 똑같이, Rust 프로젝트가 맞습니다.
이 다이어그램에서 빨간색으로 표시된 부분이 화웨이, 혹은 그 외 각종 -wei로부터 지원을 받는 모든 Rust 프로젝트입니다.
Dioxus는 하나의 코드베이스로 모바일 앱, 웹 앱, 데스크톱 앱을 모두 만드는 걸 약속합니다. React Native나 PhoneGap이 떠오르죠. PhoneGap을 들어본 적 있다면, 스트레칭을 꼭 하세요. 우리 나이에는 정말 중요합니다.
Dioxus의 “풀스택”은 한 발 더 나아가서, 클라이언트와 서버를 모두 아우르는 단일 코드베이스를 표방합니다. 그런데 그게 정확히 무슨 뜻일까요? 우리가 어떻게 여기까지 오게 된 걸까요? 한 1분에서 12분 정도만 과거로 돌아가 봅시다.
웹 앱에는 정말 다양한 패러다임이 있어 왔고, 여기에서 전부 다루지는 않을 겁니다.
간단히 말해, 1세대에서는 “HTML을 생성하는 일”이 서버의 역할이었습니다. 약간의 JavaScript(혹은, 상상도 하기 싫지만 Visual Basic Script)를 뿌려서 뭔가를 움직이게 할 수는 있지만, 그게 전부였죠.
그 다이어그램에서 제가 “HTML 생성하기”를 두고 “렌더링(render)”이라 부르고 있는 것에 대해, Servo나 Skia 등에 종사하시는 분들께 미리 사과드립니다. 그냥 React 쪽 용어를 가져다 쓰고 있을 뿐이에요, 미안(sowwy)합니다.
2세대 웹 앱 시대로 오면, 세상은 이미 임계량 이상의 JavaScript를 쌓아 올렸습니다. DevTools라고 부를 만한 것들도 생기기 시작하죠. Firebug를 기억하신다면, 그게 가장 초기 중 하나였습니다. 그리고… 유언장을 한 번쯤은 진지하게 고민해 보셔야 할지도 모르겠습니다.
이제는 진짜 애플리케이션이 웹 브라우저 안에서 돌아간다는 아이디어에 익숙해지기 시작합니다. 서버에서 오는 구조화된 데이터를 바탕으로 HTML을 렌더링하는 식이죠. 그리고 그 뒤로 개발자들은 10년 가까이 “XMLHttpRequest”라는 것을 사용해 JSON을 주고받게 됩니다. 그렇습니다.
웹 앱이 오프라인에서도 동작하기 시작합니다. 하지만 초기 로딩 경험은 형편없죠. 방문자는 먼저 앱 전체가 로드되기를 기다려야 합니다. 그다음 앱이 API 호출을 하는 걸 기다리고, 그다음 HTML을 렌더링하는 걸 기다립니다. 이 과정은 꽤 오래 걸릴 수 있고, 지구는 점점 스피너에 대한 집단적인 혐오감을 키워 갑니다.
그리고 SPA(single-page app, 단일 페이지 앱)에는 해결해야 할 문제가 이것 말고도 더 있습니다. 접근성, 내비게이션 히스토리, 검색 엔진 최적화, 그리고 물론 데이터 로딩도요. 페이지의 모든 컴포넌트가 각자 API 요청을 날려서 데이터를 가져오고 렌더링하게 되면, 페이지당 요청 수가 엄청나게 늘어납니다.
특히 React를 잘못 쳐다보는 바람에 컴포넌트가 API 호출을 무한 루프로 돌리고 있다면, 정말정말 많은 API 호출이 발생합니다. 그리고 네, 실제로 그게 최근에 Cloudflare를 다운시킨 원인이기도 합니다.
그래서 3세대, 풀스택, 양쪽 세계의 장점만 모아 보자는 접근이 등장합니다. 예전처럼 서버에서 먼저 렌더링을 수행하고, 그 결과를 스트리밍으로 클라이언트에 보냅니다. 클라이언트는 수신되는 대로 곧바로 화면에 보여 줄 수 있죠. 동시에, 서버는 렌더링에 사용했던 구조화된 데이터도 함께 전송합니다.
실용적인 예시를 하나 봅시다. Dioxus로 작성한 카운터입니다:
rust#[component] fn Counter() -> Element { let mut x = use_signal(|| 0_u64); let inc = move |_| x += 1; let dec = move |_| x -= 1; rsx! { "{x}" button { onclick: inc, "+" } button { onclick: dec, "-" } } }
서버 사이드 렌더링을 할 때는 “좋아, x라는 변수가 있고 0에서 시작하네. RSX 매크로 안에서 쓰였고, 버튼이 두 개 있구나” 정도만 알면 됩니다.
그 두 버튼은 클릭했을 때 뭔가를 합니다. 그런데 그걸 서버 쪽에서 어떻게 할 수 있을까요? 못 합니다. 그 이벤트 핸들러는 클라이언트 쪽에서 등록되어야 합니다. 서버에서 할 수 있는 일은 힌트를 보내는 것뿐이죠.
다음은 서버가 보낸 HTML 마크업입니다:
html<!--node-id0 -->0<!--#--> <button data-node-hydration="1,click:1">+</button> <button data-node-hydration="2,click:1">+</button>
button 태그에는 onclick 속성이 직접 붙어 있지 않습니다. 대신 구조화된 데이터를 가리키는 정보만 있을 뿐입니다(여기서는 텍스트가 너무 길어지는 걸 막기 위해 그 데이터는 생략했습니다).
클라이언트는 서버가 가지고 있던 것과 동일한 데이터를 받습니다. 서버가 했던 것과 같은 렌더를 수행하고, 서버가 보낸 것과 클라이언트가 렌더한 것 사이에 매핑을 만듭니다. 그다음 문서를 장악해서 이벤트 핸들러를 설치하고, 모든 걸 인터랙티브하게 만듭니다. 이 과정을 우리는 hydration이라고 부릅니다.
서버가 마크업을 스트리밍하는 핵심 이유는, 클라이언트 쪽 앱이 로드되기도 전에 최대한 빨리 그걸 보여 주기 위해서입니다. 그런데 hydration 도중에 버튼을 클릭한다면 어떻게 될까요?
이론적으로는, 서버 마크업에 실제 링크나 폼을 넣어서 일반적인 브라우저 동작을 트리거하게 만들 수도 있습니다. 하지만 실제로 그런 걸 신경 써서 구현하는 사람은 요즘 거의 못 본 것 같습니다.
그다음 질문입니다. hydration 중에 클라이언트 렌더 결과가 서버 렌더 결과와 일치하지 않는다면 어떻게 될까요? 그러면 매핑을 만들 수 없고, 전부 깨집니다. 최선의 경우라고 해도 클라이언트가 렌더한 버전으로 모든 걸 통째로 갈아끼우는 정도일 텐데, 그래도 온갖 게 다 튀고 깜박거리게 되니 꽤 나쁜 상황입니다.
그리고 만약 데이터를 가져오는 데 시간이 오래 걸린다면 어떨까요? 예를 들어 데이터베이스나 외부 API에서 데이터를 가져오고 있다면? 이건 업계가 수년간 해결하려고 애써 온 문제 계열입니다. 그리고 Dioxus는 여기에 대해 여러 가지 훅(hook)으로 해결책을 제시합니다:
try_use_contextuse_after_suspense_resolveduse_callbackuse_contextuse_context_provideruse_coroutineuse_coroutine_handleuse_effectuse_futureuse_hookuse_hook_did_runuse_hook_with_cleanupuse_routeuse_routeruse_navigatoruse_memouse_memouse_on_unmountuse_reactiveuse_resourceuse_root_contextuse_set_compareuse_set_compare_equaluse_signaluse_signal_syncuse_reactive!use_server_futureuse_server_cacheduse_dropuse_before_renderuse_after_render그래서 누구에게나 맞는 게 하나쯤은 있습니다. 동기 훅도 있고, 비동기 훅도 있습니다. 리액티브 훅도 있고, 결과를 캐시하는 훅도 있습니다. 서버에서만 돌아가는 훅, 클라이언트에서만 돌아가는 훅도 있죠.
솔직히 말하면 조금 겁이 납니다. 제가 Rust에 익숙해지면서 좋아하게 된 “컴파일만 되면 돌아간다”랑은 꽤 먼 세계입니다.
훅의 규칙을 어기더라도 빌드 에러나 런타임 에러가 나는 게 아닙니다. 그냥 이상한 동작을 하게 되는데, 이건 디버깅하기가 꽤 어렵습니다.
그런데 사실, 그럴 만한 이유가 있기도 합니다.
풀스택이라는 건 원래 복잡합니다. 정말 그래요. Dioxus가 쓸데없이 복잡성을 추가한 게 아니라, 애초에 이 문제가 본질적으로 복잡한 겁니다.
여기서… 하나 고백을 해야 할 것 같습니다.
처음에는 Dioxus를 조금 호되게 다룰 생각이었습니다. 제가 스스로에게 과제를 줘서, 파리에서 열린 Eurorust에서 사용했던 퀴즈 소프트웨어를 Dioxus로 만들어 보자고 했거든요. 그리고 그 경험은, 솔직히 말해서 꽤나 좌절스러웠습니다.
하지만 파고들수록, 제가 가졌던 불만 대부분은 사실 그냥 오해이거나, 이미 메인 브랜치에서 해결 중이거나, 혹은 지금의 WebAssembly/Rust 생태계가 가진 한계였다는 걸 깨달았습니다.
원래는 dx라는 툴을 통해 cargo를 감싸고 WebAssembly 컴파일을 알아서 처리해 주는 Dioxus의 개발 경험을 칭찬하는 것부터 시작하려고 했습니다. 브라우저에서 컴파일하는 동안 보이는 로딩 화면도 칭찬하려 했고요…
그러고 나서, 앱에서 panic이 나면 앱이 그냥 반응하지 않게 되어 버린다는 걸 욕하려 했습니다! 뭔가 잘못됐다는 걸 보여 주는 것도 없고, 앱 전체가 망가져 버리죠!
그런데 메인 브랜치에는, 그런 게 있습니다! 당연히 추가했겠죠! 그게 말이 되니까요. 자기들 걸 직접 쓰는 똑똑한 사람들이에요.
다음으로는, 스택 트레이스가 완전히 쓸모없다고 불평하려 했습니다. 보이는 건 숫자랑 16진수 오프셋뿐인 함수 이름들뿐이었거든요. 전부 그냥 $func 따위.
하지만 그 이후에, C/C++ DevTools Support (DWARF)라는 Chrome 확장 프로그램을 찾았습니다. 딱 봐도 제가 쓰라고 만든 멀웨어처럼 생겼더군요.
그런데 이게, 웬걸, 실제로 잘 동작합니다. 함수 이름을 보여 주지는 못하지만, 소스 파일 이름과 라인 번호는 보여 줍니다. 그리고 그걸 클릭하면 DevTools에서 파일이 열리고, 브레이크포인트를 걸고, step in, step over, step out 같은 걸 “진짜 디버거”처럼 할 수 있습니다.
솔직히 제가 상상했던 것보다 훨씬 좋습니다. WASM 디버깅이 이 정도까지 와 있는 줄도 몰랐고, 그걸 위해 DWARF를 쓰기로 했는지도 몰랐지만, 생각해 보면 그게 또 말이 되긴 합니다.
다음으로는 Subsecond, 그러니까 그들의 핫 패칭(hot patching) 기능을 욕하려 했습니다.
핫 패칭이 무엇일까요? 웹 애플리케이션을 개발할 때, React나 Svelte 같은 것들 덕분에 이제는 거의 당연하게 여겨지는 게 있습니다. 특정 컴포넌트에 대응하는 소스 코드를 수정하고, 그 파일을 에디터에서 저장하면, 브라우저에서 애플리케이션의 상태를 유지한 채로 바로 반영되어야 한다는 겁니다.
(여기에는 발표 당시 시연 영상이 있었습니다: 컴포넌트만 교체되고 앱 상태는 유지되는 모습)
그래서 애플리케이션의 깊은 계층까지 내비게이션해서 들어간 상태에서도, 핫 패칭은 처음 페이지로 되돌리지 않습니다. 페이지를 아예 새로고침하지도 않습니다. 대신, 현재 페이지에서 변경된 컴포넌트만 업데이트합니다.
저는 제가 Dioxus에서 그걸 켜 놓고 쓰고 있다고 생각했고, “와, 이거 제대로 안 되네. 상태도 날리고, 1초 안에 반영되는 것도 아니잖아”라고 생각했습니다. 그런데 알고 보니 제가 그걸 켜지 않았더라고요. 깜빡했습니다. --hot-patch 플래그를 넘겨야 하는데… 몰랐던 겁니다.
그걸 제대로 켜고 나니, 굉장히 잘 되는 걸 알게 됐습니다. 뭐, 자주 크래시하긴 합니다. JavaScript 프레임워크가 하는 것보다 훨씬 복잡한 일을 하고 있고, 아직 초기 단계니까요. 그래도 약속 자체는 분명히 존재합니다. 코드를 바꾸고 브라우저에서 결과를 아주 빠르게 볼 수 있다는 약속이요.
재미있는 사실 하나요? 핫 패칭을 켜면 스택 트레이스에 실제로 맹글된(mangled) Rust 함수 이름이 뜹니다. 하지만 그러면 DWARF 디버깅이 깨집니다. 그러니까… 둘 중 하나를 선택해야겠죠.
이제 질문에 답할 시간입니다. Dioxus는 기쁨을 줄까요? 제 답은: 아직은 아니다 입니다.
현재 시점에서, Subsecond 같은 걸 빼고 보면, 전체적인 사용감은 여전히 대체로 불쾌한 편입니다. 제가 기준으로 삼는 건 Svelte 5인데, 거기에 비하면 확실히 그렇습니다. 하지만 Dioxus 팀이 무엇을 지향하는지는 보이고, 그게 정말 기대됩니다.
처음에는 회의적이었습니다. “Rust enum을 쓸 수 있다는 건 좋겠지만, 나머지는 전부 다 엉망 이겠지” 같은 마음이었어요.
하지만 제가 틀렸습니다! 세대별 참조(generational reference) 덕분에 이벤트 핸들러를 쓰는 일이 그렇게까지 괴롭지 않습니다. 웹소켓과 함께 쓰는 서버 사이드 함수도 꽤 잘 동작합니다. 그리고 많은 보일러플레이트를 없애 줍니다.
Dioxus 팀은 많은 힘들고 흥미로운 작업을 하고 있습니다. Flexbox 구현체를 만들어 Servo와도 공유하고 있고, 이제는 풀스택 웹 엔진 없이도 데스크톱 애플리케이션을 만들 수 있도록 HTML/CSS 렌더러도 직접 만들고 있습니다.
Dioxus와 프런트엔드 WASM 전체 생태계가, 개발자 경험 측면에서 JavaScript 기반 솔루션들을 따라잡는 날을 손꼽아 기다리고 있습니다.
그때까지는, 저는 백엔드에서는 Rust, 프런트엔드에서는 TypeScript를 쓸 생각입니다.
이 발표를 한 이후, 저는 어떤 다가오는 프로젝트를 위해 수천 줄에 달하는 Dioxus 코드를 작성했습니다. 그래서… 이 글의 결론은 거짓말이 되어 버렸습니다, 아마도. 여전히 복잡한 마음이 들지만, 동시에 이제는 이쪽에 발을 담근 사람이기도 합니다. 어디까지 가 보게 될지… 같이 지켜봅시다!
스폰서분들께 감사드립니다:
가능하시다면, 감당 가능한 티어로 이 작업을 후원해 주시는 것도 고려해 주세요:
Bronze 티어
Silver 티어
Gold 티어
제가 영상도 만든다는 사실, 알고 계셨나요? PeerTube와 YouTube에서도 확인해 보세요!
당신을 위해 골라 둔 다른 글도 있습니다: