원문 : https://medium.com/bekk/how-javascript-engines-achieve-great-performance-fb0b36601557
자바스크립트는 인상적인 기술입니다. 특별히 잘 설계되었기 때문이 아닙니다(그렇지 않습니다). 인터넷 접근이 가능한 대부분의 단말기에서 자바스크립트 프로그램이 실행되기 때문도 아닙니다. 대신, 자바스크립트 언어의 대부분 기능이 최적화를 끔찍하게 만들지만 여전히 빠르므로 인상적입니다.
생각해봅시다. 자바스크립트에는 타입 정보가 없습니다. 각각의 객체는 프로그램이 구동될 동안에 계속해서 속성을 얻거나 잃을 수 있습니다. 무려 6종류의 서로 다른 거짓 같은 값(falsy value)을 가지며, 모든 숫자는 64비트 실수형입니다. 이것으로도 부족한지 자바스크립트는 빠르게 동작할 것이라고 예상되므로 분석 및 최적화에도 많은 시간을 할애할 수 없습니다.
그런데도, 자바스크립트는 여전히 빠릅니다.
어떻게 이게 가능할까요?
이 글에서는 다양한 자바스크립트 엔진이 우수한 런타임 성능을 달성하기 위해 사용하는 몇 가지 기술을 자세히 살펴보겠습니다. 의도적으로 몇 가지 세부 사항을 생략하고, 단순화했다는 점을 명심하세요. 이 글의 목적은 정확한 작동 방식을 설명하려는 것이 아니라, 뒤에서 설명할 예제들에 대해 뒷받침되는 이론들을 충분히 이해할 수 있도록 하려는 것입니다.
브라우저가 자바스크립트를 다운로드할 때, 최우선순위는 가능한 한 빠르게 자바스크립트를 실행시키는 것입니다. 브라우저는 코드를 바이트코드(가상 기계 명령어)로 변환하고 변환된 바이트코드는 이를 실행할 수 있는 인터프리터나 가상 기계로 전달됩니다.
왜 브라우저가 자바스크립트를 실제 기계 명령어가 아닌, 가상 기계 명령어로 변환하는지 궁금하실 수도 있습니다. 좋은 질문입니다. 사실 기계 명령어로 바로 전환하는 동작은 최근까지 V8(크롬의 자바스크립트 엔진)이 사용하던 방식입니다.
특정 프로그래밍 언어의 가상 기계는 일반적으로 컴파일하기 더 쉬운 대상입니다. 왜냐하면 프로그래밍 언어와 밀접한 관련이 있기 때문입니다. 실제 기계 명령어는 훨씬 일반적인 명령어 집합을 갖기 때문에 프로그래밍 언어를 명령어와 잘 동작하도록 변환하기까지 많은 작업이 필요합니다. 이는 컴파일 시간이 더 오래 걸리고, 자바스크립트를 실행하기까지 많은 시간이 소요된다는 의미입니다.
예를 들어, 자바스크립트를 이해하는 가상 기계는 자바스크립트의 객체를 이해할 수 있습니다. 이러한 특성 덕분에, object.x
와 같은 구문을 실행하기 위해 한두 개 정도의 가상 명령어가 필요합니다. 반면에 자바스크립트 객체 동작에 대한 이해가 없는 실제 기계의 경우, .x
의 메모리상 위치와 어떻게 가져오는지를 파악하기 위해 훨씬 많은 명령어가 필요합니다.
가상 기계의 문제점은 가상이라는 점입니다. 즉, 존재하지 않습니다. 명령어는 바로 실행될 수 없고, 반드시 런타임에 해석되어야 합니다. 코드를 해석하는 것은 바로 실행하는 것보다 항상 느릴 수밖에 없습니다.
빠른 컴파일 시간 vs 빠른 런타임이라는 트레이드오프가 있습니다. 대부분의 경우에 빠른 컴파일 시간이 좋은 선택지입니다. 사용자들은 버튼을 클릭 후 동작하기까지의 시간이 20밀리초이든 40밀리초이든 크게 상관하지 않습니다. 특히 한 번만 사용된 버튼이라면 더욱 그렇습니다. 코드의 실행 속도가 느리더라도 자바스크립트를 빠르게 컴파일하면 사용자들은 페이지에서의 상호 작용을 더 빠르게 시작할 수 있습니다.
게임이나, 구문 하이라이팅 또는 천 개의 숫자로 이뤄진 피즈버즈 문자열 계산과 같이 컴퓨팅 비용이 많이 드는 상황들이 있습니다. 이럴 때, 컴파일 시간과 기계 명령어를 실행하는 시간을 합치면 총 실행 시간을 줄일 수 있습니다. 그러면 자바스크립트는 이러한 상황을 어떻게 처리할까요?
자바스크립트 엔진은 함수가 핫(hot) 상태(즉, 여러 번 실행된 상태)임을 감지할 때마다 해당 함수를 최적화 컴파일러에 넘겨줍니다. 이 컴파일러는 가상 기계 명령어를 실제 기계 명령어로 변환합니다. 이에 더해, 이미 함수가 여러번 실행된 상태이기 때문에 최적화 컴파일러는 이전 실행에 근거하여 여러 상황을 추측합니다. 즉, 더 빠른 코드 실행을 위해 추측에 기반한 여러 최적화 기법을 시행할 수 있습니다.
만약 나중에 이 추측이 잘못된 것으로 판명되면 어떻게 될까요? 자바스크립트 엔진은 잘못된 방식으로 최적화된 함수를 지우고, 기존에 최적화되지 않은 버전으로 되돌릴 수 있습니다. 함수가 몇 번 더 실행되면, 자바스크립트 엔진은 추측에 기반한 최적화 정보와 함께 다시 해당 함수를 최적화 컴파일러에 넘겨주려고 시도합니다.
이제 자주 실행되는 함수는 최적화 과정에서 이전 실행 정보를 사용한다는 사실을 알았습니다. 다음으로 탐색할 것은 이것이 어떤 종류의 정보인지입니다.
자바스크립트 내의 거의 모든 것은 객체입니다. 안타깝게도 자바스크립트 객체를 기계가 다룰 수 있도록 가르치는 것은 까다로운 일입니다. 아래의 코드를 봅시다.
function addFive(obj) {
return obj.method() + 5;
}
함수로부터 반환하고 있는 형태이기 때문에 함수를 기계 명령어로 변환하는 것은 꽤 간단해 보입니다. 그러나 기계는 객체
가 무엇인지 모르는데, 어떻게 obj
의 method
속성에 접근하려는 것을 변환할 수 있을까요?
obj
가 어떤 형태인지 아는 게 도움 될 테지만, 자바스크립트 내에서는 절대 확신할 수 없습니다. 어느 객체라도 method
속성을 포함하거나 제거할 수 있습니다. 심지어 해당 속성이 존재하더라도 우리는 이 속성이 함수인지, 더 나아가 속성을 호출하면 무엇을 반환하는지를 확신할 수 없습니다.
기계 명령어로 변환하면 어떤 모습일지에 대한 아이디어를 얻을 수 있도록 위의 코드를 객체가 없는 자바스크립트로 변환해보겠습니다.
먼저, 객체를 나타낼 방법이 필요합니다. 또한 객체에서 값을 가져올 방법도 필요합니다. 배열은 기계 언어에서 쉽게 지원되기 때문에 다음과 같이 표현할 수 있습니다.
// { method: function() {} } 와 같은 객체는 다음과 같이 나타날 수 있습니다.
// [ [ "method" ], [ function() {} ] ] // [속성 이름, 속성 값]
function lookup(obj, name) {
for (var i = 0; i < obj[0].length; i++) {
if (obj[0][i] === name) return i;
}
return -1;
}
위의 코드를 기반으로 addFive
를 대략적으로 구현해보겠습니다.
function addFive(obj) {
var propertyIndex = lookup(obj, "method");
var property = propertyIndex < 0
? undefined
: obj[1][propertyIndex];
if (typeof(property) !== "function") {
throw NotAFunction(obj, "method");
}
var callResult = property(/* this */ obj);
return callResult + 5;
}
위의 코드의 경우 obj.method()
가 숫자가 아닌 다른 값을 반환할 경우 제대로 동작하지 않습니다. 따라서 구현 방식을 약간 변형해보겠습니다.
function addFive(obj) {
var propertyIndex = lookup(obj, "method");
var property = propertyIndex < 0
? undefined
: obj[1][propertyIndex];
if (typeof(property) !== "function") {
throw NotAFunction(obj, "method");
}
var callResult = property(/* this */ obj);
if (typeof(callResult) === "string") {
return stringConcat(callResult, "5");
} else if (typeof(callResult !== "number") {
throw NotANumber(callResult);
}
return callResult + 5;
}
이런 식으로 하면 동작합니다. 그러나, 저는 미리 obj
구조와 method
의 타입을 알아서 위의 코드가 몇 단계를 건너뛰고 더 빨라질 수 있기를 원합니다.
모든 주요 자바스크립트 엔진들은 어떤 방식으로든 객체의 형태를 추적합니다. 크롬에서는 이러한 개념을 히든 클래스라고 합니다. 이 글에서도 히든 클래스라는 용어로 칭할 것입니다.
아래의 코드 스니펫을 보면서 시작하겠습니다.
var obj = {}; // 빈 객체
obj.x = 1; // `x` 속성을 갖도록 형태가 변경됩니다.
obj.toString = function() { return "TODO"; }; // 형태가 변경됩니다.
delete obj.x; // 형태가 또 다시 변경됩니다.
위 코드를 기계 명령어로 변환한다면, 새로운 속성이 추가되거나 제거될 때 어떻게 객체의 형태를 추적할 수 있을까요? 이전 예제에서 객체를 배열의 형태로 나타낸 아이디어를 활용하여 다음과 같이 나타낼 수 있을 것입니다.
var emptyObj__Class = [
null, // 부모 히든 클래스가 없음.
[], // 속성 이름
[] // 속성 타입
];
var obj = [
emptyObj__Class, // `obj`의 히든 클래스
[] // 속성 값
];
var obj_X__Class = [
emptyObj__Class, // 빈 객체와 동일한 속성을 갖습니다.
["x"], // `x` 라는 속성과 함께
["number"] // 여기서 `x`는 숫자입니다.
];
obj[0] = obj_X__Class; // 형태가 변경됩니다.
obj[1].push(1); // `x`의 값
var obj_X_ToString__Class = [
obj_X__Class, // 이전 형태와 동일한 속성을 갖습니다.
["toString"], // 그리고 `toString`라는 또 다른 속성을 갖습니다.
["function"] // 여기서 `toString`는 함수입니다.
];
obj[0] = obj_X_ToString__Class; // 형태가 변경됩니다.
obj[1].push(function() { return "TODO"; }); // `toString`의 값
var obj_ToString__Class = [
null, // `x`를 제거할 때 처음부터 다시 시작합니다.
["toString"],
["function"]
];
obj[0] = obj_ToString__Class;
obj[1] = [obj[1][1]];
위와 같은 가상 기계 명령어를 생성한다면 주어진 시간에 객체의 형태를 추적할 방법을 갖게 됩니다. 그러나 히든 클래스만으로는 부족합니다. 우리는 이 정보를 가치 있는 곳에 저장해야 합니다.
자바스크립트 코드가 객체 속성에 접근할 때마다 자바스크립트 엔진은 캐시에 해당 객체의 히든 클래스와 함께 조회 결과(속성 이름과 인덱스의 맵핑)를 저장합니다. 이를 인라인 캐시라고 하며 이들은 두 가지 중요한 목적을 수행합니다.
인라인 캐시가 저장하는 히든 클래스의 개수에는 제한이 있습니다. 이는 메모리를 보존할 수 있을 뿐만 아니라 캐시 조회가 빠르게 진행될 수 있도록 해줍니다. 만약 인라인 캐시에서 인덱스를 가져오는 것이 히든 클래스에서 인덱스를 가져오는 것보다 오래 걸린다면 캐시는 아무 소용이 없습니다.
제가 알고 있는 바로는 인라인 캐시는 적어도 크롬에서 히든 클래스를 최대 4개까지 추적합니다. 그 후에는 인라인 캐시가 비활성화되고 대신에 전역 캐시에 정보가 저장됩니다. 전역 캐시 또한 크기가 제한되며, 최대치에 도달했을 경우 새 항목이 이전 항목을 덮어쓰게 됩니다.
인라인 캐시와 최적화 컴파일러를 최대한 잘 활용하기 위해서는 단일 타입의 객체에 대해서만 접근을 수행하는 함수를 작성하도록 해야 합니다. 그렇지 않다면 코드의 성능이 최적화된 상태라고 말하기는 어려울 것입니다.
개별적이지만 중요한 최적화 유형은 바로 인라이닝입니다. 간략히 설명하자면 인라이닝은 함수 호출을 호출된 함수의 구현으로 대체합니다. 예를 들어 다음과 같습니다.
function map(fn, list) {
var newList = [];
for (var i = 0; i < list.length; i++) {
newList.push(fn(list[i]));
}
return newList;
}
function incrementNumbers(list) {
return map(function(n) { return n + 1; }, list);
}
incrementNumbers([1, 2, 3]); // [2, 3, 4]를 반환
인라이닝 후에 코드는 다음과 같습니다.
function incrementNumbers(list) {
var newList = [];
var fn = function(n) { return n + 1; };
for (var i = 0; i < list.length; i++) {
newList.push(fn(list[i]));
}
return newList;
}
incrementNumbers([1, 2, 3]); // [2, 3, 4]를 반환
인라이닝의 한가지 이점은 함수 호출이 제거됐다는 것입니다. 더 큰 이점은 자바스크립트 엔진이 함수가 어떤 일을 하는지에 대해 더 많은 정보를 갖게 되었다는 것입니다. 이 새 버전을 기반으로 자바스크립트 엔진은 인라이닝을 다시 수행할 것입니다.
function incrementNumbers(list) {
var newList = [];
for (var i = 0; i < list.length; i++) {
newList.push(list[i] + 1);
}
return newList;
}
incrementNumbers([1, 2, 3]); // [2, 3, 4]를 반환
또 다른 함수 호출이 제거됐습니다. 이에 더해 옵티마이저는 이제 incrementNumbers
가 오직 숫자 배열을 인수로 가진 채 호출된다고 추측할 수 있습니다. 또한 incrementNumbers([1, 2, 3])
호출 자체를 인라이닝하기로 결정하고, list.length
가 3임을 발견하면 다음과 같이 이어질 수 있습니다.
var list = [1, 2, 3];
var newList = [];
newList.push(list[0] + 1);
newList.push(list[1] + 1);
newList.push(list[2] + 1);
list = newList;
요약하자면, 인라이닝은 함수 경계를 넘어 수행할 수 없었던 최적화를 가능하게 합니다.
하지만 인라이닝을 수행할 수 있는 것에는 제한이 있습니다. 인라이닝은 추가적인 메모리를 필요로 하는 코드 복사 때문에 함수의 크기를 증가시킬 수 있습니다. 자바스크립트 엔진은 인라이닝을 건너뛰기 전에 함수가 얼마나 커질 수 있는지에 대한 예산을 갖습니다.
몇몇 함수 호출은 인라이닝하기 어렵습니다. 함수가 인수로 전달된 호출일 때에 특히 그렇습니다.
또한, 항상 같은 함수가 아니라면 인수로 전달된 함수도 인라이닝이 어렵습니다. 이상한 일이라고 생각될 수 있지만, 인라이닝에 의해 결국 그렇게 될 수 있습니다.
자바스크립트 엔진이 런타임 성능을 개선하기 위한 방안으로는 이 글에서 다룬 것보다 훨씬 더 많은 기술이 있습니다. 그래도 이 글에 다룬 최적화 기법은 대부분의 브라우저에 적용되며, 적용됐을 경우 쉽게 확인할 수 있습니다. 이 때문에 Elm 의 런타임 성능을 개선하려고 할 때 주로 이러한 최적화에 중점을 둘 것입니다.
하지만 최적화 이전에 개선할 코드를 식별하는 방법이 필요합니다. 다음 글의 주제는 바로 이러한 정보를 제공하는 도구에 관한 것입니다.
자바스크립트 동작 방식을 설명한 것은 제가 처음이 아닙니다. 다음은 비슷한 개념을 다른 방법으로 설명한, 보다 심층적인 몇 가지 글입니다.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!