[알쓸신자] 2. 인터프리터와 컴파일러

Eddy·2021년 10월 5일
6
post-thumbnail

자바스크립트 엔진이 무엇인지 알았어.
이제 안을 좀 들여다보자. 어떻게 작동하는지 간단하게 설명해볼게.

자바스크립트 엔진 안에는 여러가지 부품이 있어.

inside

파서(parser)

파서는 쭉 늘어놓은 보통 텍스트를, 특정한 의미 규칙에 따라서 구조화하는 걸 말해.

우리가 엔진에게 보여주는 텍스트(소스 코드)는 굉장히 광범위하고 많은 정보를 담고 있어.
이 변수는 어떤 키워드로 선언했다든지, 이 함수는 몇번째 행에서 시작했다든지 등등...

먼저 엔진은 이런 언어적 정보를 잘 이해하기 위해서, 구조화된 데이터로 만들어.

우리 고등학교 영어 독해할 때 기억나? 영어가 잘 안 읽히면 쉽게 읽히려고 잘 안 읽히면 문장 끊고, 주어 동사 표시하고 그랬잖아. 그거랑 비슷한 거지. 구조화를 해야 잘 읽히니까.

파서에 들어간 코드는 트리(Tree) 구조로 바뀌어. 이걸 AST라고 해.

대략 이런 모양으로 생겼지.

AST

그 다음에 핵심적인 부분이 등장해. 인터프리터와 컴파일러.

인터프리터와 컴파일러는 코드를 읽고 기계가 이해할 수 있는 언어로 바꿀 수 있는 프로그램이야. 하지만 특성이 조금 달라.

인터프리터 (Interpreter)

인터프리터는 가장 기본적인 프로그램이야. 한 줄씩 주어진대로 읽어서 기계가 이해할 수 있는 코드로 만들어.

첫번째 줄 읽고, 두번째 줄 읽고, 세번째 줄 읽고. 아 이거 함수구나. 오케이.
네번째 줄, 아 이거 반복문이구나. 오케이.

이런 식으로 계속 읽어나가는 거야. 가장 기본적인 해석 방법이지.
원래 자바스크립트 엔진에는 인터프리터만 있었어.

하지만 인터프리터는 치명적인 단점이 있어.

한줄씩 주어진 대로 읽기 때문에, 같은 코드를 계속 실행한다고 해도 똑같이 연산을 반복해.

for (let i = 0; i < 100; i++) {
	const message = "반복중입니다."
	console.log(message)
}

인터프리터가 이 코드를 봤다고 생각해봐. 그러면 100번씩 루프를 돌면서 저 안의 코드를 100번 다 실행한다는 거야.

생각해보자. 엔진이 코드의 전체 맥락을 이해할 수 있다면?

지금 같은 작업이 100번 반복되고 있다는 걸 알고 있다면, 사실 저 변수 선언이나 할당을 여러번 할 필요가 없겠지? 어차피 결과가 똑같을 테니까. 이걸 유식한 말로 '최적화'라고 해.

하지만 인터프리터는 그냥 시킨대로 한줄 한줄 바로 읽기 때문에 그런 생각을 못해. 코드가 엄청 많으면 느릴 수 밖에.

그래서! 바로 컴파일러에 인터프리터를 더한 새로운 엔진이 등장하는데...

JIT 컴파일러 (Just In Time Compiler)

컴파일러는 인터프리터처럼 한줄씩 코드를 읽지 않아. 한번에 주어진 코드를 모두 읽어. 전체를 파악하는 거지.

덕분에 컴파일러는 '최적화'라는 걸 할 수 있어. 굳이 안해도 되는 작업들은 단순하게 만들어서 효율성과 속도를 올리는 거지.

다만 컴파일러는 먼저 전체 코드를 읽어야하고, 실행하기 전에 중간 번역을 하는 과정을 거쳐야 해. 그래서 시작하는 속도가 좀 느려. 바로 실행할 수는 없거든.

현대 자바스크립트 엔진은 둘을 같이 조합해서 쓰고 있어. 인터프리터와 컴파일러의 장점을 둘 다 활용하기 위해서지.

간단히 말하면 이래.

먼저 인터프리터가 일단 실행을 시작해. 동시에 실행에서 얻은 정보를 컴파일러에게 넘겨.

컴파일러는 정보를 받아서 여러가지 '가정'을 해. 이 가정을 바탕으로 코드를 효율적으로 바꾸지. 이 작업이 끝나면 인터프리터가 실행 중인 코드를 최적화된 코드로 바꿔줘.

이렇게 작동하는 컴파일러를 JIT(Just in Time) 컴파일러라고 불러.

2008년 이전까지만 해도, 브라우저는 단순한 인터프리터만 가지고 있었어.

근데 그때 구글이 보니까 갑갑-한 거야. 구글은 구글 맵 같은 엄청나게 큰 웹앱을 돌리고 있었거든. 이렇게 복잡한 코드를 인터프리터한테만 시키니까 너무 느린 거지.

그래서 JIT 컴파일러를 적용한 새로운 엔진을 발표했어. 그게 바로 V8 엔진.

(예~전에 크롬이 처음 나왔을 때, 인터넷 익스플로러 쓰던 사람들이 다 환호했던 기억이 어렴풋이 나지 않아?)

덕분에 속도가 엄청 빨라져서, 요즘에는 모든 엔진이 다 JIT 컴파일러를 적용하고 있지.

컴파일러가 최적화를 하는 방법

우리가 그럼 이걸 왜 알아야 하느냐?

그건 컴파일러가 어떻게 최적화를 하는지 알면, 그 최적화를 최대한 활용하는 방향으로 코드를 쓸 수 있기 때문이야.

최적화 중에서도 우리가 자바스크립트를 쓸 때 가장 중요한 2가지만 알아보도록 하자.
'히든 클래스'와 '인라인 캐싱'이야.

히든 클래스와 인라인 캐싱

우리가 코드에서 가장 많이 쓰는 작업 중 하나가 바로, 특정한 객체의 프로퍼티를 불러오는 작업이야. 예를 들면 이런 코드.

console.log(obj1.value)

그런데 자바스크립트에서 이 객체의 프로퍼티 값을 읽어오는 건 시간이 꽤 많이 들어.
다음 설명이 어렵다면 일단 비효율적인 작업이다.. 라는 것만 기억하자.


[참고]

처음부터 데이터 타입을 지정해주는 정적 타이핑 언어는, 컴파일 단계에서 데이터 타입을 확실히 알 수 있어. 메모리 상에서 데이터가 저장되어있는 위치가 바뀌지 않지.

또 동적 타이핑 언어라 하더라도, 클래스 기반 언어에서는 같은 클래스에 속하면 모두 같은 구조를 가져. 하나의 클래스 구조만 파악하면, 하나의 주소값을 기준으로 '차이'(offset)만 저장해. 공간도 효율적으로 쓰고, 값을 검색해서 가져오는 것도 간단해져.

하지만 자바스크립트는 데이터 타입을 미리 지정하지 않는 동적 타이핑 언어야. 게다가 클래스 기반 언어도 아니지.

즉, 늘 프로퍼티 값이 저장된 주소값이 바뀔 수 있어. 객체의 구조(프로퍼티 이름과 순서)도 중간에 바뀔 수 있지.

따라서 자바스크립트에서 객체는 자칫하면 공간도 많이 잡아먹고, 값을 불러오는 데도 많은 시간이 들 수 있다는 말이야.


아무튼 엄청 많이 쓰는데다가 비효율적일 수 있으니까, 이 작업을 최적화하는 게 매우 중요하겠지?

자바스크립트 엔진은 이 비용과 시간을 줄이기 위해 '히든 클래스'와 '인라인 캐싱'이라는 방법을 사용해.

히든 클래스

'히든 클래스'는 쉽게 말해 자바스크립트 엔진이 공통 구조를 가지는 객체들을 묶어서 '공통 분모'를 만드는 거야. 객체 안에 프로퍼티를 다 저장하지 않고, 객체 프로퍼티가 등장했을 때 '공통 분모'에 가서 참조값을 가져오도록 하는 거지.

인라인 캐싱

만약 우리가 맨 처음 어떤 객체의 프로퍼티를 호출했다고 하자.

엔진은 처음엔 시간이 많이 드는 방법으로 메모리 탐색을 해. 대신 불러왔을 때 미리 그 프로퍼티 값을 별도로 저장을 해둬. 이걸 '캐싱(Caching)'이라고 하지.

그 다음, 특정 객체 프로퍼티를 불러오는 코드가 또 나오잖아? 해당 객체가 아까 캐싱해둔 객체와 같은 '히든 클래스'를 가지는 객체인지 확인해.

만약 같은 '히든 클래스'다? 그러면 메모리 주소값을 일일이 찾지 않고, 그냥 캐싱해둔 값을 붙여넣어버리는 거야. 이런 작업 덕분에 자바스크립트에서 객체의 프로퍼티 값을 불러오는 작업이 쉽게 최적화가 되는 거지!

다시 말해, '같은 구조다'라고 판단된 객체의 프로퍼티는 기억해두었다가 바로 답을 가져오는 거지! 이게 바로 '인라인 캐싱'이야.

히든 클래스와 인라인 캐싱이 만드는 성능 차이

인라인 캐싱이 만드는 성능 차이를 한번 볼까?

이 객체들을 한번 봐봐. 프로퍼티의 구성과 순서가 모두 다르지? 이 객체들을 대상으로 프로퍼티를 불러오는 반복문을 작성해보자.


(() => {
  const han = {
    firstname: "Han", lastname: "Solo", spacecraft: "Falcon"
	};
  const luke = {
    firstname: "Luke", lastname: "Skywalker",
     job: "Jedi"};
  const leia = {
    firstname: "Leia", lastname: "Organa",
     gender: "female"};
  const obi = {
    firstname: "Obi", lastname: "Wan",
     retired: true};
  const yoda = {
		lastname: "Yoda"
		};

  const people = [ han, luke, leia, obi, yoda, luke, leia, obi];

  const getName = (person) => person.lastname;

  console.time("engine");
    for(var i = 0; i < 1000 * 1000 * 1000; i++) {
      getName(people[i & 7]);
    }
  console.timeEnd("engine");})();

// 실행 시간 : 8.5초

실행 시간이 무려 8.5초나 걸리지.

하지만 객체의 프로퍼티 구성과 순서가 모두 같다면?


(() => {
  const han = {firstname: "Han", lastname: "Solo"};
  const luke = {firstname: "Luke", lastname: "Skywalker"};
  const leia = {firstname: "Leia", lastname: "Organa"};
  const obi = {firstname: "Obi", lastname: "Wan"};
  const yoda = {firstname: "", lastname: "Yoda"};
  
  const people = [ han, luke, leia, obi, yoda, luke, leia, obi];
  const getName = (person) => person.lastname;

  console.time("engine");
    for(var i = 0; i < 1000 * 1000 * 1000; i++) {
       getName(people[i & 7]);
   }
  console.timeEnd("engine"); })();
// 실행 시간 1.2초

실행 시간이 무려 1초대로 줄어들었어.
그만큼 인라인 캐싱과 히든 클래스가 가져오는 성능 차이는 상당히 크다는 것!

인라인 캐싱 최대화하기

자, 우리가 이 복잡한 원리를 배운 건, 우리가 코딩을 하면서 효율적인 코드를 짜기 위해서야.

그러려면 엔진이 최적화를 잘 할 수 있는 코드로, 인라인 캐싱이 최대한 많이 일어날 수 있게 코드를 짜는 게 중요하겠지?

일단 프로퍼티의 이름과 순서는 최대한 통일하는 게 좋아. 실행 중간에 바꾸지 않는 게 좋고. (우리가 객체의 구조를 바꿔버리면 둘의 공통점이 사라지고 히든 클래스는 무효화가 돼.)

사실 객체를 생성할 때, 히든 클래스가 아니라 명시적으로 Class 를 지정해주는 방법이 가장 좋긴 해. 개별 객체를 생성하기보다 Class 키워드를 사용해서 공통되는 구조를 묶어주는 거지. (Class 문법을 모른다면, 일단 넘어가도 좋아. 뒤의 객체 지향 파트에서 다시 나올 거야.)

아까 그 코드는 이렇게 바꿀 수 있을 거야.


// 여러 객체의 구조를 하나의 클래스로 지정해준다.
class Person {
    constructor({
      firstname = '',
      lastname = '',
      spaceship = '',
      job = '',
      gender = '',
      retired = false
    } = {}) 
  
  const han = new Person({
    firstname: 'Han',
    lastname: 'Solo',
    spaceship: 'Falcon'
  });
  const luke = new Person({
    firstname: 'Luke',
    lastname: 'Skywalker',
    job: 'Jedi'
  });
  const leia = new Person({
    firstname: 'Leia',
    lastname: 'Organa',
    gender: 'female'
  });
  const obi = new Person({
    firstname: 'Obi',
    lastname: 'Wan',
    retired: true
  });
  const yoda = new Person({
	 lastname: 'Yoda'
 });

그 외에도 Array 생성자로 미리 배열의 크기를 할당하거나, Delete 키워드로 객체의 프로퍼티를 지워버리거나 하는 코드는 히든 클래스를 무효화시키기 때문에 조심하는 게 좋아.

인터프리터와 컴파일러는 내용은 여기까지.

오늘 메모리 얘기가 많이 나왔었는데, 자바스크립트 엔진이 어디에 데이터를 저장해두는지는 다음 글에서 알아보도록 하자.

profile
개발 지식을 쉽고 재미있게 설명해보자. ▶️ www.youtube.com/@simple-eddy

2개의 댓글

comment-user-thumbnail
2022년 1월 25일

좋은 글 감사합니다

답글 달기
comment-user-thumbnail
2023년 5월 9일

저자가 사용한 예와 일화는 요점을 paper io 매우 효과적으로 설명하는 데 도움이 되었습니다.

답글 달기