NLnet의 NGI Zero Core 보조금을 받아 6개월 동안 Nova JavaScript 엔진 개발에 전념하게 되었습니다. 이 글에서는 인터리브드 GC, ECMAScript 모듈, 누락 기능 보완, 성능 최적화(인라인 캐시 등)와 유지관리 계획, "이성적인 서브셋" 제안과 Rust 언어 확장 아이디어, 그리고 이 여정이 개인에게 미치는 변화까지 다룹니다.
3월에 NLnet이 NGI Zero Core 펀드의 2024년 10월 공모에서 Nova JavaScript 엔진을 보조금 수혜 프로젝트 중 하나로 선정했다는 소식을 들었습니다. 10월 공모 결과 발표는 여기에서 읽을 수 있습니다: https://nlnet.nl/news/2025/20250321-call-announcement-core.html. Nova 외에도 흥미로운 프로젝트가 많으니 꼭 살펴보시길 추천합니다!
간단히 말해, 저는 이제 본업에서 6개월의 휴직을 시작한 지 한 달이 되었고, 더 이상 낮에는 TypeScript 개발자, 밤에는 Rust 개발자가 아닙니다. 이제 전적으로 Nova에 집중하고 있습니다. 그렇다면 이 프로젝트는 무엇을 목표로 하고, 저는 또 무엇을 하며 시간을 보내고 있을까요? 개인적으로는 어떤 변화가 있을까요? 함께 알아봅시다.
저는 Nova를 Test262 준수율 70% 이상으로 끌어올리고, 데이터 지향 힙 설계의 실현 가능성과 그 메모리/성능 이점을 입증하기 위해 NLnet 보조금을 신청했습니다. 신청 과정에서 더 자세한 계획을 작성해 "양해 각서(Memorandum of Understanding)"로 정리했습니다. 주요 내용은 다음과 같습니다.
가장 시급하게 이루고 싶었던 것은 Nova에서 인터리브드(Interleaved) 가비지 컬렉션을 가능하게 하는 것이었습니다. JavaScript가 실행되는 동안 GC를 수행할 수 없는 것은 범용 JavaScript 엔진으로서는 치명적입니다. 예를 들어 오래 실행되는 동기 코드가 사용하지 않는 객체를 전혀 정리하지 못한 채 메모리를 계속 더 많이 사용하는 상황이 벌어지기 때문입니다.
엔진을 인터리브드 GC로 동작시키는 데 약 한 달이 걸릴 것으로 예상했는데, 제 예상이 놀랍도록 정확했다는 사실에 매우 기쁘고 놀랐습니다. GC 트리거 방식에 대한 선택적 변경 한 가지를 제외하면 이 작업은 완료되었고, 오늘 현재 인터리브드 GC가 활성화되어 잘 돌아가고 있습니다!
이제 좋아하는 JavaScript 벤치마크로 Nova를 시운전해 보면 (아마도) 실행되어 실제 결과를 얻을 수 있습니다! 물론 아직 구현되지 않은 부분이 있어 오류를 던지거나 크래시가 날 수도 있지만, 대부분의 일반적인 JavaScript 코드는 문제없이 돌아가야 합니다. JavaScript 엔진의 성능 최적화에 관심이 있다면, 지금이 참여해서 직접 배우기에 아주 좋은 기회입니다! 성능 이야기는 뒤에서 더 하겠습니다.
저는 ECMAScript 모듈, 그리고 전반적으로 strict mode JavaScript의 큰 팬입니다. 그렇기에 모듈이 아직 Nova에 구현되어 있지 않다는 사실은 좀 놀랍기도 합니다. 몇 차례의 시도(https://github.com/trynova/nova/pull/178)가 있었지만 끝까지 완주하지는 못했습니다. 이유 중 하나는 모듈 명세의 범위가 꽤 넓고, 한 번에 전부 구현하려다 보니 PR이 너무 복잡해져서 다시 손대기 어려운 상태가 되었기 때문입니다.
NLnet의 지원을 받는 지금이 제대로 일할 때입니다. 이번에는 더 체계적인 접근으로 이 싸움을 다시 치를 생각에 기대가 큽니다. 다만 그 전에 JavaScript의 남아있는 큰 문법 기능 몇 가지를 먼저 마무리할까 합니다.
Nova는 Test262에서 거의 60%에 가까운 통과율을 보이고 있지만, 테스트와 확장이 계속 추가되면서 기준선도 꾸준히 올라가고 있습니다. 그렇다고 60%가 되었다고 해서 "예전" 기능들이 끝났다는 뜻은 아닙니다. 보조금 계획에 들어간 항목 중에는 비교적 단순한 것들도 있었습니다. 라벨문, super 키워드, 그리고 String 이터레이터 등이 떠오릅니다. 이 중 라벨문과 String 이터레이터는 무거운 엔진 작업 사이사이에 처리할 수 있는 가벼운 일감으로 이미 끝냈습니다.
아직 남아있는 항목들 중에는 훨씬 손이 많이 가는 것들도 있습니다. SharedArrayBuffer는 기술적으로는 비교적 단순합니다(이미 ArrayBuffer가 동작하므로). 하지만 현재 TypedArray와 ArrayBuffer가 동작하는 방식에 의외로 깊게 손을 대야 할지도 모릅니다. WeakRef와 WeakSet을 위한 약한 참조는 비교적 단순해서(어젯밤에 막!) 이미 처리했지만, WeakMap에 필요한 에페메론은 결코 그렇지 않습니다(https://wingolog.org/tags/ephemerons). RegExp 구현에는 상당한 작업량이 필요하고(성능을 끌어올리는 것은 또 다른 문제입니다), 개별적으로는 쉬워 보일 수 있지만 합치면 꽤 험난해지는 내장 메서드들의 누락분도 많이 남아 있습니다.
문법 기능 중 큰 공백은 for-in/of 루프에서의 구조 분해와 for-await 루프입니다. 구조 분해는 대체로 몇 가지 느슨한 실을 잘 연결하면 될 것 같지만, for-await 루프는 전혀 다른 수준입니다. 예를 들어 바이트코드 인터프리터의 명령 집합을 확장해야 할 필요가 있을 수 있습니다. 솔직히 말하자면, 비동기 JavaScript 기능의 구현은 사양이 복잡하고 불연속 지점을 대부분 숨기는 스타일로 쓰여 있어 고통스럽습니다. 우리가 JavaScript 코드 실행을 떠올리는 방식에는 그게 합리적이지만, 구현 관점에서는 추상 연산을 동기 부분으로 나눠 구현 중에 다시 온전한 형태로 엮어야 하므로 괴로운 작업입니다.
그렇다 해도, 이런 작업은 Nova를 하면서 제가 가장 즐기는 일에 속하니(너무) 불평할 생각은 없습니다. 물론 해야 할 일이 산더미처럼 보여 위축되기도 하지만, 그런 게 일이니까요.
이전 블로그 글에서 Nova가 전통적인 엔진 설계에 비해 메모리를 절약하거나 절약할 예정이라는 이야기를 읽었을지도 모릅니다. 그 메모리 절약이 Nova에 성능 면에서 우위를 줄 요소 중 하나라고 기대하지만, 그것만으로는 부족합니다. 현재 우리는 힙 할당 데이터를 저장하는 데 평범한 Rust의 벡터를 사용하며, 구체적으로는 array-of-struct(AoS) 레이아웃을 씁니다. 이는 어디까지나 편리해서 그렇게 하는 것입니다. 제가 원하는 것은 struct-of-arrays(SoA) 벡터이며, 최대 2^32개의 아이템을 수용할 수 있어야 합니다. 이를 제공하는 라이브러리는 지금 존재하지 않으므로 우리가 직접 만들어야 합니다. 다행히 파서로 사용 중인 oxc 프로젝트(https://github.com/oxc-project/)도 같은 것을 원하므로, 이 라이브러리를 현실로 만들기 위해 협업할 예정입니다.
하지만 SoA 벡터만으로 Nova의 성능이 급변하진 않을 것입니다. 아주 이상적인 경우라 해도 최대 80% 정도의 성능 향상을 기대하고, 대부분의 경우 30% 이하일 겁니다. JavaScript 엔진의 성능을 좌우하는 것은 인라인 캐시와, 더 일반적으로는 중복 로드 제거입니다. 다음 루프를 보세요:
const a = {};
for (let i = 0; i < 10000; i++) {
a[i] = i;
}
현재 Nova가 이 루프를 실행하면, 코드에서 언급될 때마다 변수 i와 a를 해시맵에서 다시 읽어옵니다. 이는 같은 문자열에 대해 50,0001 번의 해싱 연산과 해시맵 조회를 반복한다는 뜻입니다. 당연히 좋지 않습니다. 이 경우 a는 재할당될 수 없다는 점을 알아내어, 그 값을 스택 슬롯이나 "레지스터"에 두면 해싱과 해시맵 조회 10,000회를 피할 수 있습니다. 또한 i가 스코프를 벗어나지 않는다는 점을 이용해 스택 슬롯에 두고 그걸로 접근하도록 할 수도 있습니다. 다만 이는 이미 약간은 비자명합니다2. 그래도 이런 종류의 최적화는 성능 좋은 JavaScript 엔진에 필수이며, 이런 작업을 하게 될 생각에 기대가 큽니다.
그와는 별개로, 프로퍼티 조회 인라인 캐시는 엔진이 "성인기"에 접어들 때 아마도 가장 중요한 요소일 것입니다. 위 예시에서는 a[i]의 프로퍼티 조회가 각 반복마다 달라지고(인덱스드 프로퍼티입니다. 다만 Nova의 경우엔 그 점은 중요하지 않습니다), 보다 일반적인 유형의 루프에서는 다음과 같이 그 중요성이 아주 커집니다:
let total = 0;
for (const rect of rects) {
total += rect.width;
}
현재 Nova는 루프 내부에서 total과 rect에 대해 다시 해시맵 조회를 수행하고, 매번 rect 객체의 width 프로퍼티를 선형 탐색합니다. 완벽한 세상에서 바라던 것보다 훨씬 느릴 수 있다는 점은 쉽게 상상할 수 있겠죠. 여기서 인라인 캐시가 등장합니다. 모든 rects의 객체가 동일(또는 유사한) "셰이프(shape)"를 가진다고 가정하고, 이 "셰이프"를 어떻게든 검사할 수 있다면, 조회 지점에 캐시를 두어 객체가 어떤 셰이프일 것으로 기대하는지와 그 셰이프에서 해당 프로퍼티 값이 담긴 메모리 오프셋이 어디인지를 기록할 수 있습니다. 같은 셰이프의 객체에 대해 같은 프로퍼티를 조회할 때는 해당 메모리 오프셋에서 곧바로 값을 읽으면 됩니다. 이렇게 하면 선형 프로퍼티 탐색이 단일 셰이프 값 비교(Nova에서는 32비트 정수일 것으로 예상)와, 그 뒤의 단일 오프셋 메모리 읽기로 바뀝니다.
이런 최적화들의 기초 버전이 모두 갖춰지면, Nova는 데이터 지향 JavaScript 엔진 힙 설계가 무엇을 할 수 있는지 진짜로 보여주기 시작할 것입니다. 정말 볼만한 광경이 되겠죠!
제가 생각하는 모든 것이 프로젝트 계획표에 딱 맞게 정리되어 있는 건 아닙니다. NLnet과 직접 논의한 것조차도 마찬가지죠. 프로젝트 계획 외에도, "언어 발전"이라는 우산 아래 묶을 수 있는 개인적 계획이 두 개, 어쩌면 세 개 있습니다.
이미 진행 중인 첫 번째 개인 계획은, Nova가 JavaScript 지원을 빌드 시 서브셋팅 플래그로 다양하게 제어할 수 있게 하려는 것입니다. JavaScript에는 충분히 숙고되지 않았거나 불가피한 악으로 들어온 기능과 코너 케이스가 많습니다. 어떤 것은 그저 별 영향 없는 기묘한 모서리일 뿐이지만, 다른 것들은 언어의 사용성이나 엔진의 최적화 능력에 실제로 영향을 미칩니다.
브라우저와 Node.js, Deno, Bun 같은 주요 런타임에서는 일방적으로 어떤 기능을 꺼버리고 "그거 쓰지 마세요, 문의 사절"이라고 할 수 없습니다. 얼마나 기묘한 코너 케이스건 누군가는 그 기능에 의존하고 있기 마련입니다. 하지만 수천 개의 Electron과 Tauri 애플리케이션은 어떨까요? 공장에서 돌아가는 커스텀 WebView 기반의 터치 컨트롤 패널은요? 게임의 모딩 스크립트는요? 그런 곳에서는 불필요한 기능을 끄는 대가로 바이너리를 더 작게 하고, 성능을 높이고, 메모리 사용을 줄이고, GC를 더 빠르게 하고, 신뢰성을 높이거나, 혹은 그 모두를 얻는 선택지가 실제로 의미 있을 수 있습니다.
그리고 Nova가 앞장서면, 다른 이들도 따라올 수 있습니다. 저는 이게 Nova만의 커스텀 비공식 기능이라 사용자와 다른 엔진들을 곤란하게 만드는 일을 원하지 않습니다. 제가 원하는 것은 정상 ECMAScript 명세 위에 얹는 대체 명세 또는 패치 집합입니다. 이렇게 하면 JavaScript의 "이성적인 서브셋"이 임베딩하는 쪽에 관심을 끌 경우, 누구나 어떤 엔진에서든 동일한 서브셋을 쉽게 실행하고 테스트할 수 있어 모두에게 이롭습니다.
JavaScript 세계에서 제가 지향하는 것과는 달리, Rust 세계에서는 저는 여전히(약간은) 확장 쪽에 서 있습니다. Rust에 관해서라면, 누구나 주머니에 pre-pre-RFC 하나쯤은 넣어 다니고, 없다면 적어도 하나쯤의 업데이트 구독은 하고 있죠. 저에게는 특히 공을 들이고 있는 RFC가 두 개 있습니다.
첫 번째이자 비교적 가까운 시일 내에 언어에 들어갈 가능성이 높은 것은 "재대여(reborrowing)" 또는 "자동 재대여 트레이트(autoreborrow traits)"입니다. 이는 Nova의 가비지 컬렉터 설정과 관련이 있지만, 본질적으로는 사용자 정의 타입이 &mut T처럼 동작할 수 있게 하자는 이야기입니다. 즉, 일시적으로 "빌려줄" 수 있지만 이후에는 소유자에게 "돌아오는" 이동 전용 타입입니다. 이런 사용자 정의 타입이 가능해지면 Nova의 가비지 컬렉터를 다루기가 훨씬 단순해집니다.
두 번째는 당연히 가비지 컬렉터와 관련되어 있으며, 빌림 검사기(borrow checker)의 핵심을 건드립니다. Rust의 빌림 검사기는 참조가 동작하는 규칙으로 "배타적 1 XOR 공유 N", 즉 "가변 별칭 금지"를 엄격히 따르는 것으로 널리 알려져 있습니다. 하지만 사실 완전히 그렇지는 않습니다. 하나의 배타적 참조에서 여러 공유 참조를 파생하고, 그것들과 원래의 배타적 참조를 동시에 사용할 수 있는데, 단 모두를 공유(불변)로만 사용하는 한에서만 가능합니다. 실질적으로는, 하나의 함수 본문 안에서 배타적 참조를 일시적으로 별칭할 수 있지만, 그 배타적 참조를 배타적으로 사용한 순간부터는 평소의 "별칭 금지" 규칙이 다시 적용됩니다.
제가 하고 싶은 일은, 함수가 가변별칭 가능한 참조 집합(정확히는 별칭 가능한 라이프타임)을 인자로 받는다고 선언할 수 있게 하는 것입니다. 그러면 빌림 검사기는 이 참조들(말이 되려면 적어도 하나는 반드시 배타적이어야 합니다)을 모두 공유로 사용할 수 있는 것으로 간주하다가, 그중 하나라도 배타적으로 사용되는 순간 나머지를 즉시 무효화합니다. 이렇게 하면 그 함수는 별칭 관계의 참조들로도 안전하게 호출할 수 있습니다. 이는 Nova의 가비지 컬렉터를 거의 100% 자동적이고 인체공학적인(개발자 친화적인) 형태로 만들 수 있게 해줄 것입니다. 오늘날의 상태를 두고 이렇게 말할 수는 없죠.
놀라울 수도 있겠지만, 인터넷에서 읽는 사람들이라고 해서 유령이나 구울, 혹은 공중을 떠다니는 NPC(챗봇은 빼고)만은 아닙니다. 저 역시 본업과 Nova 작업 바깥에서 활기차고 때로는 흥미로운 삶을 살아왔습니다. NLnet 보조금과 이 6개월간의 "인터넷을 위해 일하기" 기간이 그 삶에 영향을 줄 것이라는 사실은 놀라울 수 있지만, 놀랄 일은 아닙니다.
지금까지는 재택근무에 다시 꽤 잘 적응하고 있습니다. 저는 코로나 최악의 시기 동안에만 재택으로 일했고 기본적으로는 사무실 복귀파라서, 서재에 틀어박혀 있는 건 약간의 변화입니다. 하지만 제게 가장 큰 변화는 그게 아닙니다.
가장 근본적인 변화는 아주 기본적인 것에서 옵니다. 저는 10년 동안 월급을 받으며 일했고, 규칙적인 일정과 수입에 익숙해졌습니다. 보조금으로 일한다는 건, 이제 제가 평소보다 훨씬 더 자유로운 동시에, 제가 무엇을 하고 무엇을 성취하느냐에 훨씬 더 크게 얽매인다는 뜻입니다. 한 달을 들여 Nova의 힙을 페이지 테이블 레이아웃을 직접 조작하는 방식으로 다시 만들었다고 합시다. 정말 유용한 것을 만들 수는 있겠지만, 그게 보조금 목표 중 하나가 아니라면 그만큼 제 호주머니는 얇아집니다.
비슷하게, 지난 주말에 독감에 심하게 걸려 사흘 내내 뻗어 있었습니다. 핀란드 고용법 하에서 급여 근로자였다면 큰 문제는 아니었을 겁니다. 집에서 쉬고도 어차피 월급을 받았겠죠. 하지만 보조금으로 일할 때는 병이 오래가지 않기를, 잃어버린 시간과 업무량이 뒤늦게 제 뒤통수를 치지 않기를 바랄 수밖에 없습니다.
지금은 아직 크게 걱정하지는 않지만, 이전보다 계획표에서 과제를 꺼내 바로 처리하는 데 더 집중하게 된 것은 부정할 수 없습니다. 대부분은 애초에 그 계획이 제가 원하고 의도한 것이기 때문이고, 일부는 제가 빵… 아니, 밥벌이를 계속하려면 그 길이 앞으로 나아가는 길이기 때문입니다. 물론 NLnet이 융통성 없는, 얼굴 없는 조직은 아닙니다. 제가 계획을 바꿔야 한다고 믿는다면 충분히 제게 맞춰주고 변경을 허락해 줄 수도 있습니다. 그렇다 해도, 그 가능성에 기대고 싶지는 않습니다.
그러니 Nova의 현재는 이렇습니다. NLnet(그리고 유럽연합 집행위원회)의 너그러운 보조금 덕분에, 제가 하루하루 쌓아 올리고 있습니다. 관심 있으시다면, GitHub에 커밋이 매일 올라오고 우리 Discord 서버도 열려 있습니다. 예쁘게 부탁하시면 코드 스트리밍도 시작할지 누가 압니까? 그럼 다음에 또 만나요!
각 루프 반복마다 i는 비교에서 한 번, 증가에서 한 번, 프로퍼티 접근에서 한 번, 대입에서 한 번 총 4번 접근됩니다. a는 프로퍼티 접근에서 한 번 접근되어 총 접근 횟수는 5가 됩니다. ↩
사용자가 디버거에 접근할 수 있고(Nova는 현재 지원하지 않지만 언젠가는 지원해야 합니다), 루프가 실행되는 동안 루프 내부에 브레이크포인트를 추가한다면, 그들은 이름으로 i와 a에 접근할 수 있어야 합니다. a는 불변이므로 문제 없습니다. 해시맵과 스택 슬롯에 있는 a 참조를 중복해도 괜찮습니다. i는 문제가 됩니다. 스택 슬롯과 이름을 어떻게든 연결해야 하기 때문입니다. ↩