React를 오래 사용하다 보면 어느 순간 프레임워크의 사용법보다 그 아래에서 동작하는 도구들이 더 궁금해지는 순간이 온다. 나에게는 React Compiler가 그 계기였다.

React Compiler와 react-hook-form, tanstack-virtual을 함께 사용하던 중 컴파일러 최적화가 기대한 대로 적용되지 않는 문제를 발견했다. 원인을 추적하기 위해 React Compiler의 내부 구현을 살펴봤고, 컴파일러가 ESLint 억제 주석을 감지하면 해당 훅을 최적화 대상에서 제외한다는 사실을 알게 되었다. tanstack-virtual 내부에서는 force rerender 패턴이 사용되고 있었고, 이로 인해 컴파일러가 조용히 최적화를 건너뛰는 상황이 발생했다.

단순히 “이 라이브러리가 안 된다”로 끝낼 수도 있었지만, 내부 구현을 읽어가며 컴파일러가 컴포넌트를 어떻게 분석하고 최적화하는지 이해하게 되었다. 이 경험은 이후 AST 변환 도구를 만들고, 더 나아가 Rust로 포팅하는 과정의 출발점이 되었다.

Rust를 도입하게 된 이유

처음에는 React에만 익숙했다. TypeScript와 React를 잘 쓰는 것만으로도 충분하다고 생각했다. 하지만 React Compiler, SWC, Turbopack, Deno, Biome 같은 도구들을 조사하면서 공통점을 발견했다.

많은 현대 JavaScript 생태계 도구들이 Rust로 작성되어 있었다.

처음에는 단순히 “빠르기 때문이겠지”라고 생각했다. 하지만 조금 더 들여다보니 Rust는 단순히 성능만을 위한 선택이 아니었다. 정적 분석, 파서, 컴파일러, 번들러처럼 코드 자체를 다루는 도구에서는 안정성, 명확한 타입 시스템, 메모리 안전성, 병렬 처리 가능성이 모두 중요했다. Rust는 이 영역에 잘 맞는 언어였다.

마침 내가 만들고 있던 i18nexus-tools라는 도구가 있었다.

i18nexus-tools는 React 프로젝트의 다국어 처리를 자동화하는 정적 분석 도구다. 기존에는 다국어 처리를 위해 JSX 안의 텍스트를 직접 t() 함수로 감싸고, 별도의 JSON 파일에 키와 값을 추가해야 했다.

예를 들어 다음과 같은 코드가 있다면,

<h1>안녕하세요</h1>

수동으로 아래처럼 바꿔야 했다.

<h1>{t("hello")}</h1>

그리고 ko.json에는 이런 값을 추가해야 했다.

{
  "hello": "안녕하세요"
}

텍스트가 몇 개 없을 때는 괜찮지만, 실제 프로젝트에서는 이런 작업이 수백 번 반복된다. 사람이 직접 처리하면 시간도 오래 걸리고 실수도 잦다.

그래서 i18nexus-tools를 만들었다. 코드를 AST로 파싱한 뒤 JSX 텍스트나 문자열 리터럴 중 한국어가 포함된 부분을 자동으로 찾고, 이를 t() 함수 호출로 감싼다. 추출된 텍스트는 자동으로 JSON 파일에 저장한다. Google Sheets와 연동하는 CLI 도구도 만들어, 기획자가 시트에서 관리한 번역 데이터를 프로젝트에 반영할 수 있도록 했다.

처음에는 TypeScript와 Babel을 사용했다. npm에 배포한 뒤 코어 라이브러리는 약 3,500회, CLI 도구는 약 2,300회 이상 다운로드되었다. 작게 시작한 도구였지만 실제로 누군가 사용하고 있다는 사실이 큰 동기부여가 되었다.

하지만 점점 한계도 보였다. Babel 기반의 AST 변환은 구현하기 편했지만, 대규모 프로젝트에서는 성능과 의존성 관리 측면에서 부담이 있었다. SWC가 Rust로 작성되어 Babel보다 훨씬 빠른 성능을 보여준다는 점도 자연스럽게 Rust 포팅을 고민하게 만든 이유였다.

결국 Rust를 배우기 위한 목적이 아니라, 내가 만들고 있는 도구를 더 좋은 방향으로 발전시키기 위해 Rust를 도입해보기로 했다.

Rust로 옮기기 전에 먼저 한 일

바로 Rust 코드를 작성하지는 않았다. 먼저 TypeScript 코드베이스를 정리해야 했다.

초기 버전은 빠르게 기능을 구현하는 데 집중한 코드였다. 특히 translation-wrapper.ts 파일 하나에 500줄이 넘는 로직이 몰려 있었다. AST 조작, import 관리, 변환 로직, 텍스트 추출 로직이 한 파일에 섞여 있어 유지보수가 어려웠다.

그래서 기능별로 모듈을 분리했다.

ast-helpers는 AST 노드 생성과 탐색을 담당하게 했다. ast-transformers는 실제 변환 로직을 담당하게 했다. import-manageruseTranslation이나 t 함수 import를 추가하는 역할을 맡았다. translation-wrapper는 전체 흐름을 조율하는 쪽으로 역할을 줄였다.

테스트 방식도 바꿨다. 처음에는 AST의 내부 노드 구조까지 세세하게 검증하는 테스트를 작성했다. 하지만 이런 테스트는 리팩토링에 너무 취약했다. 예를 들어 CallExpression의 내부 구조나 arguments 배열이 조금만 달라져도 테스트가 깨졌다. 실제 사용자 입장에서는 입력 코드가 원하는 출력 코드로 변환되는지만 중요했는데, 테스트는 구현 세부사항에 너무 강하게 묶여 있었다.

그래서 주요 시나리오는 E2E 테스트로 고정하고, 단위 테스트는 핵심 순수 함수 위주로 최소화했다. 입력 코드와 출력 코드를 비교하는 방식으로 바꾸니 리팩토링이 훨씬 자유로워졌다.

이 과정에서 테스트에 대한 생각도 바뀌었다. 테스트는 많을수록 좋은 것이 아니라, 변경에 강해야 좋다. 구현 세부사항을 검증하는 테스트보다 사용자가 기대하는 동작을 검증하는 테스트가 더 오래 살아남는다.

Rust 도입 과정에서 다시 고민한 설계

Rust로 포팅하면서 단순히 TypeScript 코드를 그대로 옮기고 싶지는 않았다. 언어만 바꾸는 것이 아니라 도구의 설계 자체를 다시 생각하고 싶었다.

가장 먼저 고민한 것은 런타임 의존성이었다.

다국어 처리를 할 때 흔히 훅을 사용한다. 하지만 많은 경우 언어 정보는 URL에 포함된다. 예를 들어 /ko/dashboard, /en/dashboard처럼 경로에 언어가 들어간다면, 굳이 모든 텍스트 처리를 훅에 의존할 필요가 있을까 하는 의문이 들었다.

또 다른 고민은 라이브러리의 역할이었다. 결국 번역은 key-value를 매핑해서 보여주는 작업이다. 그렇다면 복잡한 런타임 라이브러리를 무조건 사용하는 것이 최선일까? 빌드 타임에 최대한 많은 것을 처리하고, 런타임에서는 최대한 가볍게 가져가는 방향이 더 낫지 않을까?

이 고민은 WrappedT 컴포넌트 방식에서 더 분명해졌다.

처음에는 빌드 시점에 모든 JSX 텍스트를 WrappedT 컴포넌트로 감싸는 방식을 생각했다. 이렇게 하면 서버 컴포넌트에서도 번역을 처리할 수 있을 것 같았다. 하지만 문제가 있었다. WrappedT는 React 컴포넌트다. 서버 컴포넌트에서 사용하면 클라이언트 경계가 생길 수 있다. 페이지에 텍스트가 100개 있다면 100개의 클라이언트 경계가 생길 수 있고, 각각 번들링과 Hydration 비용을 증가시킨다.

서버 컴포넌트의 장점인 서버 렌더링과 번들 크기 감소를 해치는 방식이었다.

결국 컴포넌트 래핑 방식이 아니라 함수 호출 방식으로 전환했다.

<h1>{t("hello")}</h1>

이 방식은 단순하지만 예측 가능하다. 런타임 비용도 적고, 서버 컴포넌트와의 충돌도 줄일 수 있다. 처음부터 이 결론을 낸 것은 아니었다. 컴포넌트 방식의 한계를 직접 고민했기 때문에 함수 호출 방식이 왜 더 나은지 이해할 수 있었다.

namespace 설계에 대한 고민

기존에는 추출된 모든 번역 텍스트를 하나의 ko.json 파일에 저장했다. 작은 프로젝트에서는 괜찮지만, 프로젝트가 커지면 문제가 생긴다.

첫 번째 문제는 번들 크기다. dashboard 페이지를 열었는데 settings, profile, admin 페이지의 번역 텍스트까지 모두 로드된다. 코드 스플리팅을 적용해도 번역 파일이 하나로 묶여 있다면 초기 로딩 비용은 계속 증가한다.

두 번째 문제는 키 충돌이다. 예를 들어 업로드라는 텍스트가 있다고 하자. dashboard에서는 제목으로 쓰여 Upload가 되어야 하고, profile에서는 버튼으로 쓰여 upload가 되어야 할 수 있다. 하지만 하나의 JSON 파일 안에서는 같은 키를 서로 다른 의미로 관리하기 어렵다.

그래서 파일 경로 기반 namespace 분리를 고민했다.

예를 들어 다음 파일이 있다면,

src/app/dashboard/page.tsx

자동으로 dashboard namespace를 사용하게 하고, 번역 파일은 아래처럼 분리하는 방식이다.

locales/ko/dashboard.json

이렇게 하면 각 페이지는 자신에게 필요한 번역 파일만 로드할 수 있다. 같은 키라도 namespace가 다르면 독립적으로 관리할 수 있다. 번역 파일도 페이지 단위로 자연스럽게 분리된다.

이 기능은 TypeScript 버전에서는 아직 구현하지 못했다. Babel 기반 구조에 뒤늦게 추가하기에는 복잡도가 컸다. Rust로 포팅하면서 처음부터 namespace를 고려한 구조로 다시 설계하려고 한다.

상수 자동 추출 기능을 단순화한 이유

처음에는 외부 파일에서 import된 상수도 자동으로 분석하려고 했다.

예를 들어 constants.ts에 이런 값이 있다고 하자.

export const NAV_ITEMS = [
  { label: "대시보드", href: "/dashboard" },
  { label: "설정", href: "/settings" }
];

그리고 어떤 컴포넌트에서 이를 import하면, 도구가 자동으로 NAV_ITEMS 내부의 텍스트까지 찾아 번역 키를 추출하는 방식이었다.

아이디어는 좋아 보였다. 하지만 실제로 구현해보니 문제가 많았다. 모든 export를 상수로 착각해 API 응답이나 동적 데이터까지 분석하려 했다. import된 파일을 재귀적으로 따라가다 보니 프로젝트 전체를 뒤지게 되었고, 속도도 느려졌다. 무엇보다 사용자가 어떤 파일까지 분석되는지 예측하기 어려웠다.

결국 이 기능은 제거했다. 현재 파일 내부의 상수만 분석하도록 단순화했다.

이 결정은 꽤 중요했다. 자동화는 강력할수록 좋아 보이지만, 예측 가능하지 않으면 도구를 사용하는 사람이 불안해진다. 복잡한 자동화보다 명확하고 예측 가능한 단순함이 더 나을 때가 있다.

가장 오래 걸린 트러블슈팅: Wtf8Atom 변환 문제

Rust 포팅 과정에서 가장 오래 막혔던 문제는 Wtf8Atom 문자열 타입을 일반 문자열처럼 다루는 것이었다.

AST에서 문자열 리터럴을 가져오면, 해당 값에 한국어가 포함되어 있는지 확인해야 한다. TypeScript 버전에서는 노드의 값이 이미 문자열이었기 때문에 정규식으로 바로 검사할 수 있었다.

하지만 Rust 버전에서는 값이 Wtf8Atom 타입이었다. 정규식 매칭 메서드는 str 타입을 기대하는데, 내가 가진 값은 Wtf8Atom이었다.

처음에는 단순히 참조를 넘겨보았다.

contains_korean(&value)

그러자 expected str, found Atom 계열의 타입 에러가 발생했다.

다음에는 to_string()을 시도했다.

value.to_string()

하지만 원하는 방식으로 동작하지 않았다. 그다음에는 as_ref()를 시도했다. 그래도 타입이 맞지 않았다. Wtf8Atom이 내가 기대한 방식으로 ToString이나 AsRef<str>을 제공하지 않았기 때문이다.

처음에는 문서를 계속 찾았다. SWC 문서와 GitHub 이슈를 뒤졌다. 하지만 명확한 답을 찾기 어려웠다. 이 문제가 가능한 문제인지조차 의심되기 시작했다.

그러다 접근을 바꿨다.

문서가 부족하다면 소스코드를 직접 보면 된다.

swc_atoms 크레이트의 소스코드를 열고 lib.rs를 읽기 시작했다. 그러다 중요한 사실을 발견했다. Wtf8AtomDeref 트레이트를 구현하고 있었고, 내부적으로 Wtf8 타입으로 역참조될 수 있었다. 그리고 Wtf8 타입에는 as_strto_string_lossy 메서드가 있었다.

결국 해결책은 이것이었다.

let text = atom.to_string_lossy();

Wtf8Atom이 직접 to_string_lossy를 구현한 것은 아니지만, Deref coercion 덕분에 내부 타입인 Wtf8의 메서드를 사용할 수 있었다.

as_str은 유효한 UTF-8일 때만 Some(&str)을 반환한다. 복사가 발생하지 않는다는 장점이 있다. to_string_lossy는 항상 성공한다. 유효한 UTF-8이면 빌린 값을 반환하고, 유효하지 않은 경우에만 새로운 문자열을 생성한다.

한국어는 유효한 UTF-8이기 때문에, 내가 처리하려는 대부분의 케이스에서는 효율적으로 동작할 수 있었다.

이 문제를 해결하면서 Rust의 타입 시스템을 조금 더 현실적으로 이해하게 되었다. 단순히 “타입이 엄격하다”가 아니라, 타입이 어떤 변환을 허용하고 어떤 변환을 허용하지 않는지 정확히 알아야 했다. Deref, AsRef, Into, From은 모두 비슷해 보이지만 역할이 다르다. 이 차이를 이해하지 못하면 타입 변환에서 계속 막히게 된다.

무엇보다 크게 배운 것은 소스코드의 중요성이었다.

문서와 예제를 아무리 찾아도 답이 없을 때가 있다. 하지만 소스코드는 실제로 동작하는 구현이다. 문서에 드러나지 않은 사용법도 소스코드에는 남아 있다. 이번 문제도 결국 공식 문서가 아니라 실제 구현을 읽으면서 해결할 수 있었다.

Rust 도입 후 얻은 결과

아직 Rust 버전이 완전히 끝난 것은 아니다. 핵심 문자열 변환과 추출 로직은 구현했지만, 실제 AST 노드 교체, i18n-ignore 주석 처리, namespace 기반 파일 출력 같은 기능은 더 고도화해야 한다.

그래도 Rust를 도입하면서 이미 몇 가지 분명한 결과를 얻었다.

첫째, 도구의 구조를 다시 설계할 수 있었다.

TypeScript 버전에서는 빠르게 기능을 추가하다 보니 구조가 뒤늦게 복잡해졌다. Rust로 옮기는 과정에서는 처음부터 parser, extractor, transformer, writer의 역할을 나누어 생각하게 되었다. 단순 포팅이 아니라 도구의 책임을 다시 정의하는 과정이었다.

둘째, 불필요한 기능을 걷어낼 수 있었다.

외부 import 상수까지 자동으로 추적하려던 기능은 제거했다. 모든 것을 자동화하려는 욕심보다 사용자가 예측할 수 있는 동작을 우선하기로 했다. 도구는 똑똑해야 하지만, 사용자를 놀라게 해서는 안 된다.

셋째, 런타임보다 빌드 타임에 더 많은 일을 처리하는 방향이 명확해졌다.

다국어 처리는 런타임 라이브러리만으로 해결할 수도 있다. 하지만 반복적인 텍스트 래핑, 키 생성, JSON 파일 관리, namespace 분리는 빌드 타임 정적 분석으로 처리하는 편이 더 적합하다. Rust 기반 정적 분석 도구는 이 방향에 잘 맞았다.

넷째, 타입 안전한 런타임 라이브러리의 방향도 분명해졌다.

정적 분석 도구는 Rust로 만들고, 런타임 라이브러리는 TypeScript로 만들 계획이다. 런타임 라이브러리에서는 번역 키를 타입으로 검증하고 싶다. 예를 들어 t("hello")를 호출했을 때 실제 JSON 파일에 hello 키가 없다면 TypeScript 컴파일 단계에서 에러가 나도록 만드는 것이다.

namespace가 분리되면 다음과 같은 형태도 가능하다.

t("dashboard", "hello")

이때 dashboard namespace가 존재하는지, 그 안에 hello 키가 있는지를 타입 레벨에서 검증할 수 있다. 이를 위해 TypeScript의 Template Literal Types, Conditional Types, Mapped Types 같은 고급 타입 기능을 더 깊게 활용하려고 한다.

실제로 얻은 효과

이번 Rust 도입의 가장 큰 효과는 단순히 “빠른 언어를 배웠다”가 아니었다.

가장 큰 효과는 문제를 바라보는 관점이 바뀐 것이다.

이전에는 React 애플리케이션을 잘 만드는 것에 집중했다. 이제는 애플리케이션을 만드는 개발자의 반복 작업을 어떻게 줄일 수 있을지 고민하게 되었다. JSX 텍스트를 자동으로 감싸고, 번역 키를 생성하고, JSON 파일을 관리하고, 나아가 타입으로 번역 키를 검증하는 일은 모두 개발자 경험을 개선하는 일이다.

또 하나의 효과는 새로운 언어에 대한 두려움이 줄었다는 점이다.

Rust는 처음부터 편한 언어는 아니었다. 소유권, 라이프타임, 엄격한 타입 시스템은 낯설었다. 하지만 TypeScript를 깊게 이해하고 있었기 때문에 비교하면서 배울 수 있었다. OptionResult는 TypeScript의 undefined와 에러 처리를 타입 레벨에서 강제하는 방식으로 이해할 수 있었다. Rust의 제네릭도 TypeScript의 제네릭과 비교하며 받아들일 수 있었다.

하나의 언어를 깊이 이해하면 다른 언어는 완전히 새로운 세계가 아니라 비교 가능한 세계가 된다.

마지막 효과는 개발자 도구를 만들고 싶다는 방향이 더 분명해진 것이다. React Compiler를 들여다본 경험, AST 변환 도구를 만든 경험, Rust로 정적 분석 도구를 포팅한 경험은 모두 하나의 방향을 가리키고 있었다.

나는 개발자의 반복 작업을 줄이는 도구를 만드는 일이 재미있다. 그리고 그 도구가 다른 개발자의 생산성을 높일 때 큰 가치를 느낀다.

앞으로의 계획

현재 i18nexus 생태계는 두 방향으로 확장하려고 한다.

첫 번째는 Rust 기반 정적 분석 도구다. 빌드 타임에 AST를 분석해 텍스트를 자동으로 래핑하고 추출하는 CLI 도구를 만드는 것이다. 특히 파일 경로 기반 namespace 자동 분리 기능을 Rust 버전에 구현하려고 한다. 각 페이지가 자신의 번역 파일만 로드하도록 만들고, 같은 키라도 namespace에 따라 독립적으로 관리할 수 있게 하는 것이 목표다.

두 번째는 TypeScript 기반 런타임 라이브러리다. 여기서는 타입 안전성을 강화하려고 한다. JSON 파일에 존재하지 않는 번역 키를 사용하면 컴파일 타임에 에러가 발생하도록 만들고 싶다. 정적 분석 도구가 추출한 번역 정보를 타입 정의로 연결하면, 런타임 다국어 처리와 컴파일 타임 타입 검증을 함께 제공할 수 있다.

이 두 가지는 모노레포로 관리할 계획이다. 정적 분석 도구는 Rust로 성능과 안정성을 가져가고, 런타임 라이브러리는 TypeScript로 타입 안전성과 사용성을 가져간다. 각 패키지는 독립적으로 관리하되, 타입 정의와 예제 프로젝트는 함께 공유하는 구조를 생각하고 있다.

아직 완성도는 높지 않다. 하지만 이번 Rust 마이그레이션을 통해 분명한 방향을 얻었다.

도구는 단순히 코드를 변환하는 프로그램이 아니다. 반복되는 일을 줄이고, 실수를 줄이고, 개발자가 더 중요한 문제에 집중할 수 있게 만드는 장치다.

Rust 도입은 끝이 아니라 시작이다.

이제는 이 도구를 실제 프로젝트에서 사용할 수 있는 수준까지 끌어올리고, 성능을 측정하고, 엣지 케이스를 하나씩 해결하며 계속 발전시켜 나가려고 한다.