JavaScript는 인터프리터 언어이지만 컴파일도 합니다!

박민우·2024년 4월 11일
1

JavaScript

목록 보기
13/14
post-thumbnail

일반적으로 JavaScript는 고전적인 컴파일 언어와는 다르게 코드의 실행과 동시에 한 줄씩 해석하는 인터프리터 언어로 분류됩니다. 하지만 그렇다고해서 컴파일 과정을 거치지 않는 것은 아닙니다. 이에 대해 더 자세히 알아보겠습니다.


📌 컴파일러와 인터프린터의 차이

개발자가 작성한 소스코드를 기계어로 번역하는 방식은 크게 2가지가 있습니다.

컴파일러 방식

컴파일러는 프로그램 전체를 스캔하여 이를 모두 기계어로 번역하고 이를 디스크에 저장합니다. 전체를 스캔하기 때문에 대부분 컴파일러는 초기 스캔 시간이 오래 걸립니다. 하지만 전체 실행 시간만 따지고 보면 인터프리터 보다 빠릅니다. 왜냐하면 컴파일러는 초기 스캔을 마치면 실행파일을 만들어 놓고 다음에 실행할때 이전에 만들어 놓았던 실행파일을 실행하기 때문입니다.

하지만 단점도 존재합니다. 컴파일러는 고급언어로 작성된 소스를 기계어로 번역하고 이 과정에서 오브젝트 코드(Object Code)라는 파일을 만드는데 이 오브젝트 코드를 묶어서 하나의 실행 파일로 다시 만드는 링킹(Linking) 이라는 작업을 해야합니다. 따라서 컴파일러는 통상적으로 인터프리터 보다 많은 메모리를 사용해야 합니다.

또한 컴파일러는 오류 메시지를 생성할때 전체 코드를 검사한 후에 오류 메시지를 생성합니다. 그래서 실행 전에 오류를 발견 할 수 있습니다. 대표적인 언어로 C,C++,JAVA 등이 있습니다.


인터프리터 방식

컴파일러와는 반대로 인터프리터는 프로그램 실행시 한 번에 한 문장씩 번역합니다. 그렇기 때문에 한번에 전체를 스캔하고 실행파일을 만들어서 실행하는 컴파일러보다 실행시간이 더 걸립니다. 한 문장 읽고 번역하여 실행시키는 과정을 반복하는게 만들어 놓은 실행파일을 한번 실행시키는 것보다 빠르긴 힘들 것입니다.

하지만 인터프리터는 메모리 효율이 좋습니다. 컴파일러처럼 목적코드를 만들지도 않고, 링킹 과정도 거치지 않기 때문입니다. 이 때문에 인터프리터는 메모리 사용에 컴파일러 보다 더 효율적입니다.

인터프리터는 오류 메시지 생성과정이 컴파일러와는 다릅니다. 인터프리터는 한 번에 한 문장씩 번역하기 때문에 프로그램을 실행시키고 한 문장씩 번역될때 오류를 만나게 되면 바로 프로그램을 중지합니다 그래서 프로그램을 실행해봐야지만 오류 발견이 가능합니다. 대표적인 언어로 Python, Ruby, Javascript 등이 있습니다.


📌 인터프리터 언어였던 JavaScript

초기에 JavaScript가 개발될 때, 웹 브라우저 상에서 동적인 기능을 제공하기 위한 목적으로 설계되었습니다. 이는 사용자의 브라우저에서 바로 실행될 수 있어야 했기 때문에 인터프리터 방식이 적합했습니다.

위에서 설명했듯이 인터프린터란 한 번에 한 줄씩 번역하여 실행하는 방식입니다. 하지만 JS 코드는 결국 개발자가 작성한 코드이므로, JS 엔진의 인터프리터는 우리가 작성한 console.log("Hello World!"); 같은 코드를 그 자체로 이해할 수는 없습니다. 자바스크립트가 인터프리터에 전해지기 위해서는 일련의 과정을 거쳐야 합니다.

소스코드 => 바이트코드 => 기계어

위와 같이 자바스크립트는 기계에게 전달되기전에 바이트 코드로 변환되어야 합니다. 이 바이트 코드는 가상머신에 의해 기계어로 변환되고 이를 기계가 해석하여 실행하는 것입니다.

그렇다면 JS엔진이 소스 코드를 어떻게 바이트코드로 변환하는지 알아보겠습니다.


소스코드 => Token => AST => 바이트 코드

위 과정을 간단히 말하면 JS 엔진이 소스 코드를 토크나이징, 파싱 과정을 거쳐 AST로 변환하고, 이 AST를 인터프리터가 바이트 코드로 변환합니다.

💡 정리하면,

  • 초기 JS는 인터프리터 방식으로 번역되었습니다.
  • JS엔진이 소스 코드를 AST로 변환하고, AST를 인터프리터가 바이트 코드로 변환합니다.
  • 가상 머신이 바이트 코드를 기계어로 번역해 기계가 이를 실행합니다.

📌 컴파일 과정이 추가된 JavaScript

초기 자바스크립트는 인터프리터 언어였지만, 점차 웹에서도 다양한 요구사항들이 추가되면서 더 많은 기능들을 갖추어야 했고, 이는 자바스크립트가 점차 성능상 무거워지는 계기가 되었습니다. 이를 통해 자바스크립트 언어에서도 실행 전에 내부적으로 컴파일하는 과정이 추가되었습니다.

JS엔진의 컴파일 과정

위에서 살펴본 것 처럼, JS엔진과 인터프리터가 소스 코드를 바이트 코드로 변환한 후에 바이트 코드를 기계어로 변역해 실행합니다. 생성된 바이트 코드를 실행하는 방법에는 2가지 방법이 있습니다.

  1. 인터프리터 모드

    초기 JS가 사용했던 방식으로, 인터프리터가 바이트 코드를 하나씩 읽어가며 실행합니다.

  2. JIT 모드

    바이트 코드를 native code(기계어)로 컴파일하여 실행하는 방법입니다.

JIT란?

  • 동적 컴파일 과정이라고 불립니다.
  • 인터프리터 + 정적 컴파일 방식 (둘을 섞어놓은 느낌)
  • 프로그램 실행 시점에서 인터프리터 방식으로 기계어 코드를 생성하면서 그 코드를 캐싱하여, 같은 함수가 여러 번 불릴 때마다 매번 기계어 코드를 생성하는 것을 방지합니다.
  • 좀 더 구체적으로 말하면, 실행시점에 바이트 코드를 기계어로 번역하는 역할을 합니다. 바이트코드에서 번역된 기계어는 캐시에 저장되기 때문에 재사용시 다시 번역할 필요가 없습니다. 따라서 코드가 반복된다면 다시 번역하는 과정 없이 재사용할 수 있으므로 시간이 단축됩니다.

정적 컴파일 방식처럼 미리 저장하는 것이 아니라 실행 중에 코드를 번역하고 저장합니다.


인터프리터 VS JIT

💡 그렇다면 JavaScript에서는 인터프리터 모드와 JIT 모드 중에서 어떤 것이 더 효율적일까요?

일반적으로는 JIT 모드가 인터프리터 모드보다 더 효율적입니다. 직관적으로 생각해도 인터프리터 모드로 코드를 한 줄씩 번역해서 실행하는 것보다는 기계어로 미리 번역해둔 것을 수행하는게 더 빠를 것입니다.

정적 컴파일러라면 기계어를 생성하는 도중에 많은 최적화 알고리즘을 사용할 수 있어서 code quality가 높지만, JIT는 컴파일 과정 자체가 실행 중에 발생하기 때문에 이 자체가 오버헤드가 되고, 따라서 컴파일에 많은 시간을 쓸 수 없습니다.

따라서 코드 전체를 읽어서 최적화하는 방식은 당연히 사용할 수 없고, 보통 최소한의 최적화만 적용하여 기계어를 생성합니다. 이렇게 해도 인터프리터 방식보다는 기계어의 수행 성능이 훨씬 낫습니다. 따라서 JIT에 오버헤드가 포함되더라도 인터프리터 모드보다 빠르게 수행되므로 Java VM에서는 JIT를 많이 사용합니다 .

따라서 일반적으로는 인터프리터 모드로 코드를 한줄 한줄 바로 번역해서 실행하는것보다는 JIT모드로 수행하는게 더 빠릅니다.

하지만 JavaScript에서는 그렇지 않습니다. 바로 다음 2가지 이유 때문입니다.

1. JS는 동적 타입 언어이다.

JS는 동적 타입 언어입니다. 즉, 변수의 타입이 실행 중에 변할 수 있고, 프로토타입 기반 방식을 사용하는 등 매우 동적인 특성을 가지기 때문에 JavaScript JIT 컴파일러는 모든 예외적인 케이스를 고려하여 코드를 생성해야합니다.

예를 들어, 2개의 변수를 서로 더하는 코드를 생각해보겠습니다. 이 때에도 모든 예외 케이스를 고려하면 상당히 많은 양의 native code가 생성됩니다.

=> 변수가 모두 int형일 경우 / 하나라도 int형이 아닐 경우 / 더하고 나니 int 범위를 벗어나는 등 많은 예외 케이스가 존재합니다 .

이렇게 예외 케이스가 발생하게 되면 slow case로 점프하게 됩니다.

💡 slow case란?

native code로 생성하기 어려운(native code로 표현하면 양이 많아지는) 동작들을 native code로 뽑아내는 대신 미리 엔진 내부에 C로 구현된 helper function을 호출하여 동작을 수행하는 경우를 의미합니다.

그런데 이런 helper function들은 인터프리터 모드로 수행할 때와 동일한 코드를 사용하게 됩니다. JIT 컴파일러로 native code를 수행한다 해도 많은 부분이 인터프리터를 사용할때와 차이가 없게 되는 것입니다. 오히려 컴파일 오버헤드(native code를 생성해야하는)가 더해지므로 JavaScript에서 JIT는 Java에서보다 훨씬 비효율적입니다.

2. JS는 hotspot이 상대적으로 적다.

JavaScript는 주로 웹페이지의 layout을 조작하거나 사용자 입력에 반응하는 방식의 코드가 많습니다. 따라서, JS는 자주 반복되어서 수행되는 구간인 hotspot이 상대적으로 매우 적습니다.

이러한 경우 native code를 수행하는 시간에 비해, native code를 만드는 시간, 즉 컴파일 오버헤드가 상대적으로 커지게 되는 문제가 있다. 코드가 골고루 실행되기 때문에 컴파일해야하는 코드의 양이 많아지는 것입니다.

결과적으로 "컴파일 오버헤드 + native code가 인터프리터보다 빠르다"라는 JIT의 사용이유가 무의미해지는 상황입니다. (실제 JavaScript JIT가 성능 향상에 기여하는 바가 거의 없다는 연구도 진행된 바 있다고 합니다.)

💡 정리하면,

JIT는 정적컴파일에 비해 최적화를 많이 할 수 없어서 정적컴파일보다 느립니다. 하지만 그래도 인터프리터보다 빠르기 때문에 Java에서는 JIT를 사용합니다.

하지만 JavaScript는 1. 동적언어 + 2. hotspot이 적다는 특성 때문에 JIT가 인터프리터보다 빠르다고 할 수 없습니다.

따라서, JavaScript 코드는 JIT 보다 인터프리터로 수행하는 것이 낫습니다.

하지만 이번 문단의 상단에서도 말했듯, 최근에는 JavaScript가 단순히 웹에서 이벤트 처리 용도로만 사용되는 것이 아니라 그 사용 용도가 다양해지면서 점차 연산이 많아지는 프로그램에도 충분히 사용되고 있기에, JIT 방식을 완전히 외면할 수는 없다고 합니다.

따라서 최근에는, 고전적인 방식의 JavaScript코드(hotspot이 많이 없는)와 연산 중심의 코드들의 수행 성능을 모두 만족시키는 방식인 Adaptive JIT 방식을 사용합니다.


📌 Adaptive JIT

Adaptive JIT Compilation

  • 실행 초기에는 인터프리터 방식으로 실행하고 자주 호출되는 메소드나 반복되는 코드를 검출하여 이러한 코드만 컴파일하는 방식

  • JIT 컴파일러의 단점을 보완하기 위한 방식으로, 코드가 사용되었을때 즉시 컴파일하는 것이 아니라 여러번 호출 된 후 지연시켜 컴파일을 수행하는데 이를 Lazy Compilation이라 합니다.

  • 실행시간의 대부분을 소비하는 코드만 컴파일하여 효율적으로 실행속도를 향상시킬 수 있습니다.

JIT는 코드가 실행되면 이를 저장하지만 Adaptive Compiliation은 자주 사용되는 코드만 저장하는 것

기본적으로 모든 코드는 처음에 인터프리터로 해석합니다. 그러다가 자주 반복되는 부분(hotspot)이 발견되면, 그 부분에 대해서만 JIT를 적용하여 native code로 변환합니다.

따라서, 인터프리터가 생성한 바이트 코드에 최적화된 컴파일을 진행하는 방식입니다.


🙇🏻‍♂️ 참고

자바스크립트 엔진의 최적화 기법 (1) - JITC, Adaptive Compilation

자바스크립트는 Compiler / Interpreter 언어다?

컴파일러(compiler)와 인터프리터(interpreter)의 차이

profile
꾸준히, 깊게

0개의 댓글