Rust로 웹 애플리케이션을 만들고 운영하며 겪은 장점과 한계, 그리고 결국 Node.js로 이전하기로 한 이유를 정리한다.
내가 중학교 3학년(고등학교 직전 마지막 해)이었을 때, 가장 친한 친구가 나를 설득해서 그와 함께 학교 프로그래밍 동아리에 들어가게 했다. 처음에는 망설였지만, 결국 동의했다. 이 일에 대해서는 지금도 그 친구에게 무척 감사한다.
그곳에서 우리는 Turbo Pascal로 Pascal을 한 단계씩 배웠다. 조금씩 언어의 기초를 익혔다. 변수, 연산자, 문자열 조작, 자료구조 — 그리고 마침내 Conway’s Game of Life를 다시 만들어 보기까지. 그러다 여름 방학이 왔다. 프로그래밍 언어가 아닌 HTML을 제외하면, Pascal은 내가 처음으로 배워서 실제로 사용해 본 ‘진짜’ 프로그래밍 언어였다.
하지만 Pascal을 오래 쓰지는 않았다. 여름 방학이 끝나고 고등학교에 들어가서는 “Software Engineering” 계열을 선택했고, 그곳에서 C를 배웠다. Pascal 때와 마찬가지로 우리는 단계적으로 시작했다. 기본 연산자, 문자열 조작, 메모리 할당, 자료구조, 그리고 최종 보스인 void *.
나는 C에 빠져들었다. 메모리를 정밀하게 제어할 수 있다는 점, 참조나 포인터로 변수를 전달할 수 있다는 점, 만들고 싶은 모든 자료구조에 대해 메모리를 직접 할당해야 한다는 점.
그 뒤로 3년 더 공부한 끝에 고등학교를 졸업했다. 그동안 C 실력을 키우고, PHP를 조금 배우고, C++도 익혔고, 다양한 프로그램을 만들어 보려 했다(너무 심하게 판단하지는 말아 달라, 이것들은 약 20년 전의 것이다): BattleCity 클론, 3D 소프트웨어 렌더러, IRC 봇, 미완성 운영체제 커널, 미완성 게임 엔진, OpenGL용 TTF 렌더러.
고등학교 이후에는 2년짜리 전문대 과정에 등록해서 Practical Software Engineering Degree를 따는 길로 갔다. 1학년과 2학년 사이 여름 방학에 선택을 해야 했다. McDonald’s에서 일할 것인가(고등학교 여름 방학 동안 웨이터로 일한 경험이 있었다), 아니면 소프트웨어 엔지니어링 직무를 구할 것인가. 2초 정도 망설인 뒤, 이력서를 만들고 온라인에서 보이는 모든 포지션에 지원하기 시작했다. 하지만 C나 C++로 소프트웨어 개발 직무를 정말 원했음에도 아무도 나를 채용하지 않았다. 결국 PHP로 웹 개발 자리를 얻게 됐다(기회를 준 첫 고용주에게 정말 감사한다).
그리고 이것이 내가 C나 C++를 마지막으로 만진 순간이 됐다. PHP, Python, Ruby 같은 동적 고수준 언어는 웹 개발의 동적인 특성에 더 잘 맞는다. 하드웨어에서 최대 성능을 쥐어짜야 하는 경우는 드물다. 왜냐하면 C에서 자료구조 할당을 최적화해 1초를 벌어도, 네트워크나 디스크 요청이 끝나기를 기다리느라 10배 더 잃기 때문이다. 그래서 우리는 집단적으로 “웹은 동적 언어로 쓰는 게 더 낫다”는 데 동의하게 됐다.
하지만 첫사랑처럼, C는 나를 계속 따라다녔다. 나는 미세 최적화에 집착했고, 변수가 언제 할당되는지 내가 통제할 수 없다는 것에 화가 났으며, 불필요한 복사 없이 참조나 포인터로 전달하는 방법을 제대로 쓸 수 없다는 점이 답답했다. 그러다 Rust가 등장했다.
다른 모든 소프트웨어 엔지니어처럼, 나도 Rust를 ‘내 게임 엔진 만들기’로 시작했다. 게임 엔진을 한 번도 만들어 보지 않았다면 스스로를 소프트웨어 엔지니어라고 부를 수 있나? 그리고 나는 Rust를 사랑하게 됐다. C는 늙어가고 있었고, 힙스터 개발의 빠르게 움직이는 세계(현대적 툴링, 린터, 포매터, 패키지 관리)를 따라잡지 못했다. Rust는 두 세계의 장점을 함께 제공했다. 메모리 할당과 변수 생명주기를 저수준으로 제어하면서도, 현대적인 개발 관행을 갖추고 있었다. 사용하기 쉬운 컴파일러, 내장 린터와 포매터, 그리고 물론 현대적인 패키지 관리 도구와 저장소.
커뮤니티는 Rust를 사랑했고, 언어는 매우 빠르게 성장했다. 그리고 처음으로 저수준 시스템 프로그래밍 언어로 웹 애플리케이션을 작성할 수 있게 됐다. 1 언어의 기본을 익히고 게임 엔진을 포기한 뒤, 나는 Rust로 웹 애플리케이션을 만들기로 했다. 그리고 2023년 말, 나는 성공했다. 돈을 벌어다 주는 완전히 동작하는 웹 애플리케이션을 Rust로 만들고 출시했다.
수년 동안 나는 실수에서 배우고, 진행 상황을 여기, 여기, 그리고 여기에 공유했다. 결국 애플리케이션은 Frankenstein의 괴물처럼 변했다. 백엔드는 순수 Rust였고, 프론트엔드는 정적 생성된 Astro 웹사이트였다. 그러다 마침내 막다른 길에 다다랐고, 더 이상 애플리케이션을 유지보수하거나 확장할 수 없게 됐다.
나는 모든 것을 Node.js로 마이그레이션하고 Rust 실험을 끝내기로 고통스러운 결정을 내렸다. 이 결정을 내리기는 정말 어려웠다. 나는 Rust로 일하는 것을 정말 좋아했고, 어떻게든 성립시키고 싶었다. Rust로 일하면서 소중한 인간관계와 기회를 얻었고, 두 번의 컨퍼런스와 한 번의 밋업에서 내 지식을 발표할 기회도 있었다. Rust에 베팅한 것이 기쁘지만…
Rust의 긍정적인 면은 Building a web app in Rust나 One year of Rust in production 같은 다른 글에서 다뤘으니 여기서는 반복하지 않겠다. 대신 내가 겪었던 어려움 몇 가지를 이야기하고 싶다. 이는 미래의 나 자신에게 남기는 기록이기도 하고(내게서 C는 빼앗아도, 저수준 프로그래밍에 대한 사랑은 빼앗을 수 없다), 다른 모두에게는 경고담이기도 하다.
내가 공유하는 모든 것은 내 관점에 기반한다는 점을 기억해 달라. 나는 솔로 파운더로서 웹 애플리케이션을 만들고 운영한다. 비디오 처리 같은 무거운 워크로드도 없고, 초저지연이 필요하지도 않다 — 이 두 가지는 Rust가 특히 뛰어난 영역이다. 내 경험과 내 제품에서 병목은 항상 데이터베이스, 디스크, 또는 네트워크였다. 또한 나는 어느 정도 Rust 경험이 있으니, “Rust로 웹 앱을 만들려 했는데 borrow checker를 이해 못하겠어서 Rust가 나쁘다” 같은 타입의 사람은 아니다. 자, 그럼 시작해 보자.
앞서 말했듯이, 순수 Rust + tera로 서버 사이드 템플릿을 생성하던 방식에서, Rust API 서버 + API를 호출하는 Astro 정적 웹사이트로 전환했다. Rust에서 HTML 렌더링을 멀리하게 된 이유는 내가 타입 안전한 템플릿에 너무 익숙해졌기 때문이다. Astro에서는 타입 안전한 .astro 컴포넌트를 사용할 수 있다. 나는 Typescript를 사용하고 지지하기 때문에 템플릿이 모두 타입 안전하며, 변수를 잘못 입력하거나 누락할 가능성이 매우 낮다. 아래 같은 컴포넌트가 있으면, 잘못된 타입으로는 컴파일되기 어렵다(물론 props as any 같은 바보 같은 짓을 하면 예외지만).
interface Props {
username: string;
email: string;
}
export function UserCard(props: Props) {
return (
<div>
Hello {props.username} ({props.email})
</div>
);
}
tera, handlebars, mrml 같은 라이브러리를 쓰면 뷰는 사실상 props와 분리된다. 그래서 템플릿에서 필드 이름을 바꾸면 모델에서도 그걸 바꿔야 한다는 사실을 기억해야 한다. CI/CD 파이프라인이 길어지는 비용을 감수하고 테스트로 해결할 수는 있지만, 컴파일 시간 이야기는 조금 뒤에 하겠다. 템플릿 안의 함수들은 사실상 문자열로 된 무법지대다. 그런데 함수는 필요하다. 함수 없이 풍부한 템플릿을 만드는 건 어렵다.
하지만 maud나 askama 같은 라이브러리가 있지 않느냐고 말할 수도 있다. 맞다, 있다. 이것들은 Rust의 매크로 메커니즘을 이용한 타입 안전 HTML 템플릿이다. Rust 매크로로 사실상 HTML DSL을 구축하면, 컴파일 타임에 타입 안전한 템플릿을 만들 수 있다. 다만 다시 말하지만 컴파일 비용이 크다. 이 부분도 잠시 후에.
나는 2015년에도 로컬라이제이션에 대해 이야기한 적이 있다. Node.js는 완전한 icu 지원과 함께 숫자, 리스트, 통화, 국가명 등 각종 포매팅을 위한 Intl.* API들을 제공한다. 게다가 i18next 같은 라이브러리를 사용하면 번역 키에 대해 타입 안전한 자동완성을 얻을 수 있는데, Rust에서는 이를 구현하지 못했다. 물론 Rust도 Mozilla가 개발한 fluent 번역 파일을 지원하고 숫자 포매팅에 대한 최소한의 지원은 있다. 하지만 Node.js는 완전히 로컬라이즈되고 번역된 웹 애플리케이션을 만들고 출시하는 데 필요한 모든 것을 제공한다. Rust에서 i18n이 부족하다는 것은 널리 알려진 사실이다(참고: AWWY: Internationalization). icu4c 바인딩도 진행 중이지만, 그 측면에서 Node.js가 제공하는 수준에는 한참 못 미친다.
좋든 싫든, 웹은 본질적으로 동적이다. 대부분의 작업은 서로 다른 시스템 사이에서 데이터를 직렬화/역직렬화하는 일이다. 데이터베이스든, Redis든, 외부 API든, 템플릿 엔진이든 말이다. Rust에는 내 기준으로 최고의 (역)직렬화 라이브러리 중 하나인 serde가 있다. 그럼에도 Rust의 안전성 특성 때문에 .unwrap()을 피하려고 보일러플레이트 코드를 쓰게 되는 일이 잦았다. .ok_or에 이어 .map_err가 뒤따르는 긴 체인 호출이 나왔고, 에러를 올바르게 처리하고자 커스텀 에러 enum을 수십 개 정의했는데, 그중 일부는 다른 enum을 포함하기도 했다. 함수가 아무 에러나 반환할 수는 없으니 말이다.
SQL 작성도 비슷하다. 컴파일 타임에 SQL 쿼리를 검사해 주는 crate인 sqlx를 사용해서 정말 만족스러웠다. Rust의 매크로에 기대어 sqlx는 실제 데이터베이스 인스턴스에 쿼리를 실행해 쿼리의 유효성과 매핑이 올바른지 확인해 준다. 하지만 sqlx로 동적 쿼리를 작성하는 건 정말 고통스럽다. 동적으로 문자열을 조립하면 컴파일 중에 검증할 수가 없어서, 결국 비검증 SQL 쿼리를 사용하게 된다. 솔직히 Node.js의 kysely를 쓰면, DB 연결 없이도 비슷한 결과를 얻으면서도, 컴파일 시간 오버헤드 없이 동적 쿼리를 만들기 좋은 인체공학적 쿼리 빌더를 사용할 수 있다.
좋다, 알겠다. 그럼 컴파일 시간 이야기를 해 보자.
Rust는 안전성을 어느 정도 긴 컴파일 시간이라는 비용으로 달성한다. 최신 하드웨어에서는 컴파일 시간이 그렇게 나쁘지 않다. 끊김 없는 change-f5-preview 사이클을 보장할 정도로 좋지는 않지만, 증분 컴파일을 쓰면 충분히 괜찮다.
하지만 crate가 많아지고 매크로를 많이 사용할수록 컴파일 시간은 느려진다. 그래서 문제는 CI/CD에서 어떻게 빠른 컴파일을 달성하느냐가 된다. 웹의 동적 특성 때문에 종종 “프로덕션 에러 발생 → 수정 → 배포” 루프에 들어가고, 이 루프는 가능한 한 빨라야 한다. 앱을 쓰는 고객이 일을 못 하고 있기 때문이다.
나는 CI/CD 워커를 돌릴 내 하드웨어를 갖고 있다. Intel Core i5-7500과 RAM 32GB가 달린 전용 VM이다. VM은 4개 CPU 코어 전체에 접근할 수 있었고, master에 푸시한 순간부터 도커 컨테이너가 서버에 배포될 때까지 약 14분이 걸렸다(그중 약 12분이 컴파일이 포함된 도커 단계). 멀티스테이지 도커 파일과 캐시된 빌더 레이어를 쓴 결과가 이 정도였고, 캐시가 없다면 아마 20~25분쯤 걸렸을 것이다. 그리고 이 시간에는 CI/CD에서 테스트나 clippy 실행을 포함하지도 않았다. 테스트, clippy, 컴파일이 동일한 캐시를 사용할 수 있도록 제대로 된 캐싱을 설정하려다가 그냥 포기했다.
반면 Node.js는 린팅과 테스트를 포함해 평균 5분이 걸린다. 그리고 Node.js로 옮긴 뒤에는 백오피스 서비스 하나를 더 추가해서 배포할 코드가 더 늘었는데도, 실제로 CI/CD 파이프라인에 테스트와 린트를 포함시키면서도 3배 더 빠르게 배포하고 있다.
Rust는 대부분의 측면에서 매우 성숙한 생태계를 갖고 있지만, 웹 측면에서는 부족하다. 생소한 서드파티 API가 필요하다고? 아마 없을 것이다. 그러면 핵심 비즈니스 로직 대신 API 구현을 하게 된다. API 하나하나가 테스트하고 유지보수해야 할 추가 코드가 된다.
REST API 구현 자체가 어렵지는 않지만, 성숙도 부족은 속도를 확실히 늦춘다. 나는 서드파티 API들, PostgreSQL 위에 얹은 큐 메커니즘, 웹훅 서명 검증 코드 등을 구현하고 테스트해야 했다. 재미는 있었지만, 다른 “표준” 언어들에서는 이런 것들이 기본으로 제공된다.
그리고 솔직히 Node.js면 충분하다. 사람들은 Node.js나 npm 생태계를 비판하면서 JavaScript에 근본적인 문제가 있는 것처럼 말하곤 한다. 물론 JavaScript는 좋은 언어와는 거리가 멀다. 경험, 고통, 눈물로만 배울 수 있는 기이한 점이 많다. 그리고 Rust의 Result와 Option 타입이 그립고, match 문도 그립고, enum도 그립다. 하지만 솔직히 말해 Node.js 생태계는 웹 애플리케이션을 작성하기에 충분히 성숙했고 안정적이다. 요청/응답 JSON을 검증하기 위한 zod, kysely, @kitajs/html, 그리고 타입 안전한 SQL, HTML, MJML을 작성할 수 있게 해 주는 내 프로젝트 @mjmx/core 같은 라이브러리가 있다. 그리고 async/await는 Rust보다 Node.js에서 여전히 더 낫다. trait 안에 async 메서드를 두고 싶어서 서드파티 async-trait crate를 들여와야 하는 상황은 언제나 이상하게 느껴진다.
Node.js에도 여전히 문제가 있다. esm을 위해 cjs를 끝없이 deprecated하는 흐름은 대체로 좋지만, cjs 패키지를 만나는 순간 지옥이 된다. clippy를 기본 제공하는 Rust와 달리, Node.js에서는 기본적인 기능을 얻기 위해 eslint와 prettier 설정에서 두 개의 파일과 수십 개의 플러그인을 저글링해야 하는 번거로움이 있다. 나는 biome가 격차를 메우고 표준 도구가 되는 날을 기다린다. 워크스페이스는 여전히 완전히 해결되지 않았고, pnpm이 그나마 낫긴 하지만 cargo의 워크스페이스와는 비교가 되지 않는다. 그리고 TypeScript 런타임이 너무 자주 바뀌는 듯한 혼란도 있다. ts-node인가? tsx인가? tsm인가? node의 내장 typescript 런타임인가? deno인가? bun인가?
아, 그리고 우리 모두 그냥 snake_case가 프로그래밍에서 가장 좋고, 미적으로도 가장 보기 좋고, 읽기 쉬운 표기라는 데 동의했으면 좋겠다. JavaScript가 camelCase를 채택한 것이 정말 싫다.
혼자 만드는 건 어렵다. 소프트웨어 엔지니어로서 10개의 모자를 쓰고, 기업가로서 또 수십 개의 모자를 쓴다. 나는 Rust가 성공하길 정말 바랐지만, 앞으로 나아가야 한다. 긴 컴파일 시간을 다시 겪어야 한다는 의미였기 때문에 Sentry의 버그를 무시하는 나 자신을 발견했다. 느린 반복 속도 때문에 기능 개발을 미뤘고, 백엔드 REST API와 프론트엔드를 동기화하려 애쓰거나, 템플릿에서 변수 이름을 바꾼 뒤 모든 페이지를 시각적으로 재테스트해야 하는 필요 때문에 지쳤다. 내가 버릇이 없다고 불러도 좋다. 하지만 나는 개발팀도 없고, 내 코드를 검토해 줄 사람도 없다.
Rust는 비시각적 작업, 즉 테스트로 감싸서 작성할 수 있는 것들에 뛰어나다. 하지만 UI를 개발해야 하면 고통스러워진다. 재컴파일을 기다리고, 뷰에 잘못된 변수를 전달하지 않았을까 걱정한다.
순수 Rust에서 출발해, 정적 생성 HTML + 그 위에 Alpine을 얹는 형태로 가야 했다. 이메일 템플릿을 안전하게 만들기 위해 react.email(또는 내 @mjmx/core)을 쓰고 약간의 타입 안전성을 얻고자, 전용 메일링 서비스를 NodeJS로 만들까도 고민했다. 마치 뒤로 가는 느낌이었다. Rust 모놀리스를 쪼개고, 웹에 더 잘 맞는 동적 언어로 일부를 다시 쓰는 꼴이었으니까.
Node.js가 완벽하다는 말은 아니다. 하지만 적어도 나는 하나의 스택을 갖게 됐고, 웃기게도 Rust 때보다 더 많은 타입 안전성을 얻었다. 물론 이것은 가짜 타입 안전성이다. JavaScript는 동적 언어니까. 하지만 더 이상 뷰 파일과 모델 사이를 오가며 동기화를 맞추느라 애쓰지 않아도 된다. 더 이상 번역 키를 틀려서 빈 단어가 나오지 않는다. 이메일에 Hello {{dearCustomer}} 같은 문구가 실려 오지도 않는다.
내 문제 대부분은 동적인 것들로 귀결되는 듯하다. 템플릿, i18n, SQL. 오늘 API 서비스만 작성한다면 아마 Rust를 다시 선택할 것이다. API 서비스는 뷰나 번역을 다루지 않아도 되니까. 다만 SQL은 여전히 어떻게 할지 모르겠다. ORM은 내 취향이 아니고, sqlx를 제외하면 Rust에는 좋은 타입 안전 쿼리 빌더가 부족해 보인다.
애플리케이션의 작은 풋프린트도 그립다. Rust에서는 컨테이너가 RAM 60~80MB를 사용했지만, Node.js에서는 부하가 전혀 없어도 백오피스가 최소 117MB를 쓴다. 그리고 사람들이 말하듯, 일에는 맞는 도구를 써야 한다. Rust는 CPU 집약적인 작업에서 빛나며, 그런 작업이 생기면 나는 확실히 Rust를 사용할 것이다.
하지만 그때까지는, 안녕 Rust.