Interpreter and JavaScript Engine

Posted by in Research

 

 

기계어(Machine code)와 어셈블리어(Assembly)


컴퓨터 프로그램은 수 많은 명령어로 구성되어 있다. 컴퓨터의 두뇌격인 CPU는 사람의 언어를 이해하지 못하기 때문에 어떤 작업을 지시하려면 CPU가 이해할 수 있는 언어로 이야기를 해줘야 한다. 컴퓨터에 조금이라도 관심있는 사람이라면 알고 있겠지만, 컴퓨터의 언어는 비트(bit)로 구성되어 있다. 비트는 최소의 정보 저장 단위인데 0 또는 1의 2진수 값을 갖는다. 여러개의 비트를 정해놓은 2진수 패턴에 따라 나열하여 CPU에 신호를 보냄으로써 정보를 전달하거나 작업을 처리하라는 명령을 내리는 것이다. 이렇게 CPU가 직접 해독하고 실행할 수 있는 비트 단위로 쓰인 컴퓨터 언어를 기계어라고 한다.

 

예를 들어, 이러한 수학식이 있다고 하자.

 

     x = 10 + 2
     y = x + 4

 

이것을 2진수 패턴의 기계어로 아래와 같이 표현할 수 있다.

 

001001 11101 11101 1111111111111000
001000 00001 00000 0000000000001010
001000 00001 00001 0000000000000010
101011 11101 00001 0000000000000000
001000 00010 00001 0000000000000100
101011 11101 00010 0000000000000100
001001 11101 11101 0000000000001000

 

최초의 컴퓨터 프로그래밍 언어는 이러한 모습이었다.

인간이 이해하기에는 너무 힘든 언어다. 가독성을 높이기 위해 4자리씩 끊어서 16진수로 표현을 하기도 하지만…

 

7BDFFF8
2020000A
20210002
AFA10000
20410004
AFA20004
27BD0008

 

읽기 어려운 것은 마찬가지다.

기계어 코드를 머리 속에 달달 외우고 있는 사람이 아니라면 저 코드를 읽는 데 엄청난 시간이 걸릴 것이다. 그래서 인간의 언어와는 거리가 먼 기계어로 복잡한 프로그램을 짠다는 것은 너무 어려운 일이었기에 인간이 알아볼 수 있는 다른 프로그래밍 언어가 필요했다.

 

사람들은 복잡하고 어려운 기계어 명령에 니모닉 기호(mnemonic symbol)를 대응하여 조금 더 이해하기 쉬운 짧은 명령어를 만들었는데 이것이 어셈블리어(Assembly)다. 니모닉 기호란 무엇인가를 연상하여 기억하기 위해 만든 짧은 코드를 말한다.어셈블리어는 기계어 명령에 1대1로 대응하는 니모닉 기호로 구성되어 있다.

아래와 같은 기계어 코드가 있다고 하자.

 

10110000 01100001

 

이것을 어셈블리어로 옮겨쓰면,

 

mov al, 016h

 

이러한 형태가 된다. 이 내용은 al 이라는 레지스터에 016h 값을 옮기겠다(move)는 뜻이다.

 

어셈블리어(Assembly)로 작성된 코드는 CPU가 이해할 수 없기 때문에 기계어로 변환을 해야하는데, 이 작업을 수행하는 프로그램을 어셈블러(Assembler)라고 부른다.

어셈블러는 니모닉 기호를 op-code로 변환하고 메모리 위치와 기타 존재물에 따라 식별자를 다시 분석함으로써 기계어로 된 목적 코드를 만들어낸다. 그리고 링커가 이 목적 코드를 하나의 실행 프로그램으로 합쳐주고, 로더가 프로그램을 주기억 장치에 적재하여 실행한다.

 

 

어셈블러

 

 

기계어나 어셈블리어처럼 컴퓨터 내부에서 바로 처리 가능한 언어를 저급 프로그래밍 언어(Low-level programming language)라고 한다. 여기에서 이야기하는 “저급”은 프로그램의 질적인 수준을 뜻하는 것이 아니라 문법의 추상화 수준이 기계어에 가깝다는 의미다.

 

이러한 저급 프로그래밍 언어는 아주 강력하고 빠르지만, 인간이 너무 상세한 부분까지 책임져야해서 사용하기가 어렵다. 게다가 어셈블리어의 경우 컴퓨터 구조에 따라 사용하는 기계어가 달라지다보니, 기계어에 대응하는 어셈블리어도 각각 달라져야한다. 서로 다른 CPU 아키텍처가 등장하면 매번 똑같은 프로그램을 다른 어셈블리어로 작성하는 비용도 발생한다.

 

그래서 인간이 이해하기 쉽고 어떤 CPU에서도 동작 가능한 새로운 패러다임의 프로그래밍 언어가 필요하게 되었다.

 

 

컴파일러(Compiler)와 인터프리터(Interpereter)


고급 프로그래밍 언어(High-level programming language)는 기계어를 사람이 알기 쉽게 높은 수준으로 추상화한 언어로 Fortran, Basic, C, Pascla, Java 등이 이에 속한다.

고급 프로그래밍 언어는 저급 프로그래밍 언어에 비해 자연어에 가까운 문법을 갖고 있으며, 원시 소스 코드를 저급 프로그래밍 언어로 번역함으로써 특정 컴퓨터의 하드웨어 구조에 좌우되지 않는 프로그램을 작성할 수 있게 해준다.

 

고급 프로그래밍 언어로 작성한 코드를 저급 프로그래밍 언어로 번역 하는 방식은 크게 컴파일러 방식과 인터프리터 방식 두 가지가 있다.

 

컴파일러는 특정 프로그래밍 언어로 작성한 소스 코드를 다른 프로그래밍 언어로 변환하는 프로그램이다. 컴파일 언어의 대표적인 예로는 C, C++ 등이 있다. 주로 원시 소스 코드(본래 작성한 코드)를 어셈블리어나 기계어로 변환한다. 직접 기계어를 생성하면 부담이 크기 때문에 어셈블러 형식의 목적 파일로 생성하는 경우가 많다.

 

일반적으로 컴파일러는 아래의 과정을 거쳐 소스 프로그램을 목적 프로그램으로 변환한다. 이 과정은 절대적인 것은 아니며 컴파일러나 프로그래밍 언어의 특성에 따라 일부 단계는 생략되거나 더 세부적인 단계로 나뉠 수도 있다.

 

 

컴파일러구성

 

 

컴파일러는 목적 프로그램을 만들고, 이 목적 프로그램을 실행하는 명령을 지시함으로써 실제 프로그램을 실행한다. 이에 반해 인터프리터는 소스 프로그램을 컴파일하여 목적 프로그램을 생성하지 않고 라인 단위로 해석하면서 바로 실행한다. 그래서 인터프리터 언어는 별도의 컴파일 과정없이 프로그램을 수행할 수 있는 장점이 있는 반면에 런타임에 코드를 해석해야하므로 속도가 느리다.

 

인터프리터는 크게 2종류로 나눌 수 있다.

 

첫 번째는 “컴파일러와 인터프리터 결합형”이다. 이는 컴파일러와 인터프리터를 절충한 형태로 소스 코드를 기계어나 어셈블리어가 아닌 중간 코드 형태로 변환한 후, 이것을 인터프리터가 읽어들여 메모리에 적재하여 해석하면서 실행한다. 요즘 국내에 거의 대세처럼 자리잡고 있는 객체지향언어인 Java가 이 유형에 속한다. Java 컴파일러는 원시 코드를 JVM이 해석할 수 있는 Bytecode로 변환하여 이를 class 파일에 저장한다. 그 후에 JVM이 class 파일을 메모리에 읽어들인 후 Bytecode를 해석하면서 실행하는 방식으로 프로그램이 작동한다.

 

JVM.png 이미지 파일

 

 

두번째는 “소스 코드 유지형’이다. 이 유형은 읽어들인 소스 코드를 그대로 메모리에 유치한 채 라인 단위로 해석해서 실행한다. 소스 코드를 유지한다고 해서 아무런 변환도 하지 않는 것은 아니다. 성능 향상을 위해서 실행 시점에 원시 코드나 중간 코드를 네이티브 코드로 변환하는데 이러한 기법을 JIT Compile이라고 한다. JavaScript Engine이 이러한 유형의 인터프리터다. 좀 전에 언급한 Java의 경우도 이러한 JIT Compile 기법(bytecode -> 기계어)을 적용하고 있다.

 

 

JIT Compiler, Method JIT, Tracing JIT

 

JIT(Just-in-time) Compiler는 프로그램을 실제 실행하는 시점에 기계어로 번역하는 컴파일러다. JIT 컴파일 방식에는 2가지가 있는데 하나가 Method JIT이고, 다른 하나가 Tracing JIT이다.

 

Method JIT 은 메소드 단위로 프로그램을 실행할 때마다 매번 기계어 코드로 변환하는 방식이다.

 

이에 반해 Tracing JIT은 인터프리터가 코드를 실행하다가 자주 실행하는 부분만 기계어 코드로 변환하는 방식이다. TracingJIT은 프로그램이 일부 루프 안에서 대부분의 시간을 소비하며, 반복 루프는 유사한 경로를 갖는다고 가정한다. 그래서 코드를 실행하는 중간에 기록(trace)을 해두었다가, 자주 실행하는 부분으로 판단이 되면 기계어로 변환하여 실행한다. 모든 코드를 기계어로 변환하지 않아도 되는 장점이 있지만, 인터프리터와 기계어 사이를 왔다 갔다 해야 하는 비용이 적지 않은 것이 단점이다. 

 

Tracing JIT을 채택한 인터프리터는 종종 Method JIT 방식을 혼합하기도 한다.

 

 

JavaScript를 이해하기 위해 인터프리터를 알아보는 것이 이 글이 목적이므로 여기에서는 소스 코드 유지형 인터프리터에 대해서만 정리할 생각이다. 이제부터 나오는 인터프리터란 용어는 소스 코드 유지형 인터프리터를 의미한다고 생각하자.

 

 

인터프리터의 구성


인터프리터의 구성은 아래 보이는 것처럼 기본적으로 목적 코드를 생성하지 않기 때문에 컴파일 언어에 비해 단순하다.

 

 

인터프리터구성

 

 

우선 인터프리터는 소스 프로그램을 메모리에 로딩하고 난 후에, 가장 먼저 어휘 분석에 들어간다. 어휘 분석은 읽어 들인 소스 텍스트를 토큰 단위로 구분하여 추출하는 과정이다.

 

 

위와 같은 소스 텍스트가 있다고 가정했을 때, 토큰은 for, i, =, 1, to, max로 총 6개다.

그 다음에 인터프리터는 추출한 토큰을 미리 정한 규칙에 따라 아래와 같이 바이트 기호로 이루어진 내부 코드로 변환한다.

 

내부 코드

 

이렇게 함으로써 소스 코드를 줄일 수 있고 정형화 된 처리를 할 수 있다.

 

내부 코드를 생성한 후에는 구문 분석을 수행하면서 코드를 실행한다. 이 때 분석한 토큰열이 구문과 일치하는지 확인하고, 토큰이 변수 선언이나 함수 정의일 때는 필요한 정보를 심볼 테이블(symbol table)에 등록한다. 심볼 테이블은 주소 기반으로 변수의 타입과 이름, 주소를 저장하는 공간이다.

 

 

심볼테이블

 

 

또한 형 변환이나 간단한 최적화 같은 작업도 이 단계에서 수행한다. 인터프리터는 이렇게 구문 분석이 끝난 코드를 별도의 파일로 생성하지 않고 바로 실행한다.

위의 내용은 추상적인 인터프리터의 동작을 설명한 것이다. JavaScript Engine같은 구현체들은 여러가지 복잡한 최적화 과정을 갖고 있으므로 이 보다는 훨씬 복잡한 구조를 가지고 있다.

 

 

JavaScript Engine(JavaScript Engine)


JavaScript Engine은 JavaScript로 작성한 코드를 해석하고 실행하는 인터프리터다. 주로 웹 브라우저에 이용되지만 최근에는 JavaScript Engine을 장착한 Server side framework가 등장할 정도로 점차 그 영역이 넓어지고 있다.

 

종종 Javascript Engine과 Rendering Engine을 같은 것으로 착각하는 경우가 있는데, 이 둘은 엄연히 다르다. Rendering Engine은 Layout Engine이라고도 하는데, HTML, XML와 같은 웹 콘텐츠와 CSS 같은 포맷 정보를 결합하여 화면에 콘텐츠를 화면에 그리는 역할을 한다. Javascript Engine은 JavaScript 코드를 해석하고 실행하는 역할을 담당한다.

 

다양한 브라우저만큼이나 JavaScript Engine도 다양하다. 그러면 대표적인 브라우저 벤더별로 어떠한 JavaScript Engine이 있는지 알아보자.

 

 

Mozilla


SpiderMonkey

SpiderMonkey는 Brendan Eich가 Netscape Communication Corporation에서 C로 개발한 최초의 JavaScript Engine이다. SpiderMonkey는 여전히 Firefox, Adobe 등의 많은 어플리케이션이 이용하고 있다. SpiderMonkey는 인터프리터(Interpretor), JIT 컴파일러(Just-In-Time Compiler), 가비지 콜렉터(Garbage Collector)로 구성된다.

 

SpiderMonkey

 

 

TraceMonkey

SpiderMonkey는 원래 JIT 컴파일러 방식이 아니었으나 FireFox 3.5 버전부터 처음으로 TraceMonkey라는 JIT 컴파일러를 탑재한다. TraceMonkey는 이름이 말하듯이 Tracing JIT 방식을 채택하고 있다. 이를 통해 당시에 기존 인터프리터보다 20 ~ 40배 정도 빠른 처리 속도를 보일 수 있었다.

 

JagerMonkey

FireFox 4부터는 TraceMonkey를 대신하여 JagerMonkey가 JIT 컴파일러로 들어갔다. JagerMoneky는 Tracing JIT과 MethodJIT의 장점을 조합한 컴파일러다.

 

IonMonkey

Mozilla는 FireFox 18 버전을 출시하면서 IonMonkey라는 이름의 JIT 컴파일러를 발표했다. IonMonkey는 JagerMonkey를 개선하고 최적화하는 것에 목표를 두었다. 이전의 TraceMonkey나 JagerMoneky는 JavaScript 코드를 중간 단계 없이 바로 기계어 코드로 변환했다. 그래서 코드를 최적화 할 수 있는 방법이 없었다.

IonMonkey는 JavaScript 코드를 중간 표현(Intermediate Representation)으로 변환하는 과정을 거침으로써 이러한 문제를 해결했다.

 

  1. JavaScript 코드를 중간 표현(IR)로 변환한다.
  2. 중간 표현을 최적화 한다.
  3. 중간 표현을 기계어 코드로 변환한다.

 

OdinMonkey

최근에 Mozilla가 발표한 Firefox 22 버전에는 OdinMonkey라는 Mozilla의 새로운 최적화 모듈이 추가로 들어갔다. OdinMonkey는 asm.js라는 JavaScript 서브셋을 컴파일한다. asm.js는 고수준의 추상화된 기능은 그대로 이용하면서 산술연산과 같은 부분은 Native 코드처럼 쓸 수 있게 해주는 새로운 JavaScript 문법이다. Mozilla는 asm.js를 이용해서 Unreal Engine 3를 JavaScript로 포팅하기도 했다.

 

 

 

 

asm.js에 대한 더 자세한 내용은 asmjs.org에서 확인할 수 있다.

 

Rhino

Netscape는 Java 버전의 Netscape Navigator(Javagator)를 계획하고 있었기 때문에 Java로 개발한 JavaScript Engine이 필요했다. 그래서 1997년에 Norris Boyd(Netscape의 개발자)가 Java로 개발한 것이 Rhino다. Java로 개발한 Netscape는 나오지 못했지만 Rhino는 그 이후에도 계속해서 개발되고 있다.

 

원래 Rhino는 모든 JavaScript 코드를 Java bytecode로 컴파일 했다. 당시에는 종종 JIT 방식의 C로 만들어진 Engine보다 좋은 성능을 보이기도 했다고 한다.

 

그러나 2가지 문제가 있었는데, 첫번째는 Java Bytecode로의 컴파일 시간이 너무 오래 걸리고, 컴파일한 클래스 파일을 로딩할 때 리소스를 너무 많이 잡아 먹었다. 그리고 두 번째 문제는 클래스 또는 긴 클래스 파일을 로딩한 결과로 내부 풀(pool)에 저장한 문자열이 필요없는 상황이 되었을 때 JVM이 메모리에서 제거하지 않아 메모리 누수가 발생했다.

 

그래서 1998년 가을에 Rhino는 인터프리트 모드를 추가해서 클래스 파일 생성 코드를 선택적, 동적으로 로딩할 수 있게 했다. 이로써 컴파일은 더 빨라졌고, 더 이상 사용하지 않는 스크립트는 자바 객체처럼 메모리에서 제거될 수 있게 되었다.

 

1998년 5월에 Rhino는 모질라 재단에 공개되어서 지금은 Mozilla 재단이 공식적으로 관리하고 있다. 처음에는 Rhino의 클래스 파일 생성 부분은 공개 범위에서 제외하였으나 지금은 오픈소스로 모두 공개가 되어있다. 현재 Rhino는 JDK 5, JavaScript 1.7을 지원하며, J2SE 6 이후 버전에 기본 Java scripting engine으로 들어가 있다.

 

 

Google


V8

2008년 2월에 처음 구글이 공개한 크롬(Chrome) 웹 브라우저에 탑재된 Open Source JavaScript Engine으로 C++로 개발했다. V8은 ECMA-262-5를 구현하고 있고, Window(XP 이상), Mac OS X(10.5 이상), IA-32나 ARM 프로세스를 이용하는 Linux 시스템에서 실행 가능하다. V8은 단독으로 실행할 수 있으며 다른 C++ 어플리케이션에 내장할 수도 있다. Node.js는 V8을 기반으로 탄생한 Server Side Framework이다.

 

Google은 V8 성능 설계의 핵심으로 3가지를 소개하고 있다.

 

1. 빠른 프로퍼티 접근(Fast Property Access)

모든 JavaScript Engine이 프로퍼티를 저장하기 위해서 사전식 데이터 구조(Dicitionary-like data structure)를 이용하는데 반해, V8은 hidden class를 이용한다. 이 둘의 차이는 단순하게 이야기해서 Hashing과 Pointer의 차이라고 할 수 있다.

V8은 객체에 새로운 프로퍼티를 추가할 때 hidden class를 생성하고, hidden class에 프로퍼티의 정적인 위치(offset)를 저장함으로써 실제 데이터가 저장되어 이는 위치에 대한 Pointer를 제공한다. 이로 인해 동적 룩업이 필요 없어지고, 고전적인 클래스 기반의 최적화를 할 수 있다.

매번 프로퍼티를 추가할 때마다 새로운 hidden class를 생성하는 방식은 상당히 비효율적이지만, 다음 번에 같은 객체를 생성할 때 이전에 생성했던 hidden class를 재사용함으로써 객체 생성 비용을 줄일 수 있다.

 

 

동적 룩업(Dynamic Lookup)과 정적 룩업(Static Lookup)

 

동적 룩업(Dynamic Lookup)은 데이터의 저장, 삭제가 런타임에 동적으로 일어날 수 있는 경우에 사용하는 데이터 접근 방식이다. 데이터를 임의의 위치에 저장한 후, 해당 위치 정보를 식별할 수 있는 별도의 유일값으로 변환한 다음에 이를 따로 보관한다. 그리고 나중에 다시 그 데이터에 접근해야할 때 이 유일값을 찾아서 해석하여 해당 데이터가 저장되어 있는 위치를 찾는다.

 

이에 반해 정적 룩업(Static Lookup)은 컴파일 단계에서 정적으로 정해진 위치에 데이터를 저장하다. 저장 공간의 위치가 정적이므로 필요할 때 이 값을 이용해서 데이터가 있는 위치를 별 다른 해석 과정없이 찾아갈 수 있다. 

 

동적 룩업은 객체의 프로퍼티가 런타임에 동적으로 변할 수 있는 Java의 HashMap과 같은 객체의 프로퍼티를 관리하는 경우에 이용하고, 정적 룩업은 C나 Java 같이 컴파일 단계에서 데이터의 크기를 고정하는 경우에 이용한다. 동적 룩업은 위치 정보를 해석해서 데이터를 저장한 곳을 찾아야하기 때문에 정적 룩업에 비해 느린 것이 단점이다.

 

 

2. 동적인 기계어 코드 생성(Dynamic Machine code Generation) 

V8은 JavaScript 소스 코드를 처음 컴파일 할 때 bytecode가 아닌 기계어 코드로 직접 변환한다. 따라서 중간에 bytecode를 기계어로 변환해 줄 인터프리터가 필요 없다. 기계어로 컴파일 할 때는 인라인 캐싱 코드(Inline caching code) 기법을 이용한다.

 

3. 효율적인 가비지 콜렉션(Efficient Garbage Collection)

V8은 Garbage Collection Cycle을 수행할 때 프로그램 실행을 멈추는데, 이때 객체의 heap 부분만 처리함으로써 프로그램이 멈추는 영향을 최소화한다. 또한 객체와 포인터가 메모리상에 어디에 위치해 있는지 정확히 관리하여 메모리 누수를 피한다.

 

V8에 대한 자세한 내용은 Google Developers Chrome V8에 잘 나와있다.

 

 

Safari


JavaScriptCore – Nitro

JavaScriptCore는 Webkit의 JavaScript Engine이다. Webkit은 Open Source Web Browser Framework다. 원래는 Mac OS 10의 Safari Browser Engine으로 사용하기 위해 KDE의 JavaScript Engine Library에서 가져온 것이었으나, 지금은 Safari 외에도 다양한 곳에서 사용하고 있다. 2008년 6월에 JavaScriptCore를 bytecode Interpreter인 SqurrelFish로 재개발 했다. 이 프로젝트는 Squirrel Extreme로 진화했다. Squirrel Extreme은 JavaScript를 네이티브 기계어로 컴파일 함으로써 바이트코드 인터프리터를 없앴고, 그 결과 JavaScript의 실행 속도를 높일 수 있었다. SquirrelFish는 JavaScriptCore의 코드명이며, 마케팅용으로는 Nitro라는 이름을 사용한다.

 

 

Explorer


Chakra

익스플로러는 9 버전부터 JSCript 엔진인 Chakra를 탑재하고 있다. Chakra는 기본적으로 2가지 원칙에 따라 디자인 되었다.

 

  1. 사용자 환경의 중요한 경로에 대한 작업량을 최소화 한다.
  2. 가능한 모든 하드웨어를 활용한다. – 멀티 코어 프로세싱, 병렬처리, GPU 활용

 

 

6232.aijpiiaw-image5_760x317

 

 

보다 자세한 내용은 “IE10 및 Windows 8에서 개선된 JavaScript 성능” 에서 볼 수 있다.

 

 

 

Opera


Carakan -> v8

오페라는 원래 Presto라는 Web Browser Engine과 Carakan이라는 JavaScript Engine을 가지고 있었다. 그러나 2013년 2월에 Blink와 V8로 교체하였다. 오페라 소프트웨어는 이러한 교체의 이유로 이미 Webkit으로 구현되어 있는 웹표준 렌더링 기술을 놔두고 별도로 표준 엔진을 만드는 일에 기업 역량을 묶어두지 않기 위해서라고 밝혔지만, ZDNet은 Google Chrome과 Apple Safari와의 경쟁에 밀리는 최근 추세에 부담을 느꼈기 때문이라고 평했다.

 

 

 

참고자료


만들면서 배우는 인터프리터

IE10 및 Windows 8에서 개선된 JavaScript 성능

Chrome V8 – Google Developers

JavaScript engine From Wikipedia

SpiderMonkey(JavaScript engine) From Wikipedia