[JavaScript] JavaScript Engine Essentials: 컨텍스트에서 동시성까지

windowook·2025년 2월 20일
post-thumbnail

🌱 자바스크립트 엔진을 씹.뜯.맛. 하며 제대로 알아보자

약 1.5년 동안 바닐라와 자바스크립트로 만들어진 언어와 라이브러리, 프레임워크를 사용해 온 프론트엔드 개발자이지만 과연 내가 자바스크립트에 대해서 얼마나 잘 알고 있는 걸까? 하는 의문이 떠올랐습니다. 개발을 하면서, 혹은 코딩테스트 문제를 풀면서 함수(메서드), 자료구조 등에 대해서는 익숙하지만 내가 짠 코드가 어디서 어떻게 돌아가는 건지에 대해서는 개발하면서 의식적으로 인지하고 짜지는 않지...않나요?(그렇다고 해주시면 감사하겠습니다)
그래서 아래와 같은 두 개의 질문을 스스로 던져보았습니다.

  • 자바스크립트 메모리 힙과 콜 스택에 어떻게 데이터가 관리되는지, 실행 컨텍스트가 코드를 어떻게 동작시키는지 완벽히 설명할 수 있나?
  • 이벤트 루프와 브라우저 런타임 환경이 어떻게 싱글 스레드 언어인 자바스크립트를 멀티 스레드 언어처럼 동작하게 해주는지 메커니즘을 이해하고 있는가?

위와 같은 질문은 최근에 모의 기술 면접 스터디에서 질문에 대한 대답을 외우기 위해 다시 찾아봐서 알고는 있지만, 이전까지는 머릿속에서 꺼내 상대방에게 제대로 얘기할 수 없었던 것 같습니다. 그래서 이번 포스트에서는 자바스크립트를 깊게 알기 위해서 필요한 자바스크립트 엔진(브라우저)이 자바스크립트를 동작시키는 방법에 대해 깊이 알아보려고 합니다.

각 챕터의 순서는 다음과 같습니다.

  • 실행 컨텍스트
  • 메모리 관리
  • 동시성 처리

실행 컨텍스트를 먼저 이해하면 메모리를 어떻게 관리하는지 이해하기 쉬워집니다. 그리고 마지막으로 브라우저에서 자바스크립트가 동시성 처리가 가능한 이유까지 이해할 수 있게 됩니다.

🌱 실행 컨텍스트 Execution Context

우선 어떻게 돌아가는지부터 알아야 하지 않겠습니까? 왜 자바스크립트가 비동기 작업을 수행할 수 있는지 이해하려면 실행 컨텍스트가 무엇인지, 어떤 식으로 코드를 동작시키는지 알아야 합니다.

실행 컨텍스트란 자바스크립트 코드가 실행되는 환경을 의미합니다. 즉, 현재 코드가 어디서 실행되는지, 어떤 변수와 함수에 접근할 수 있는지에 대한 정보를 담고 있는 환경입니다.

ECMAScript의 공식 정의에 따르면

“실행 가능한 코드를 형상화하고 구분하는 추상적인 개념”

이라고 합니다. 그래서 자바스크립트 엔진은 실행 컨텍스트를 기반으로 코드를 실행하고 스코프를 관리합니다. 그럼 실행 가능한 코드라는 것은 무엇일까요?

실행 가능한 코드

자바스크립트에서 실행 가능한 코드는 3가지 종류가 있습니다.

1. 전역 코드

자바스크립트 코드가 처음 실행될 때 생성됩니다. 전역 변수, 전역 함수, 그리고 브라우저에서는 window 객체도 포함합니다. 전역 코드는 페이지가 종료될 때까지 유지됩니다.

2. 함수 코드

함수가 호출될 때마다 새로운 실행 컨텍스트가 생성됩니다. 함수 내부의 지역 변수, 인자, 내부 함수, this 등을 포함합니다. 함수 실행이 끝나면 컨텍스트는 콜 스택에서 제거됩니다.

3. eval 코드

eval( ) 함수로 실행되는 코드를 말합니다. eval은 사용을 지양해야 합니다. 왜냐하면 문자열을 숫자 값을 리턴하는 코드로 실행할 수 있기 때문이죠.

실행 컨텍스트의 구조

실행 컨텍스트는 추상화 개념이지만 물리적으로 객체이며, 3가지 속성이 담겨 있습니다.

1. 변수 객체

함수에서 선언된 변수, 함수 선언, 매개변수 정보를 저장합니다. 함수 컨텍스트의 경우에는 arguments도 포함됩니다.

2. 스코프 체인

현재 실행 중인 코드의 스코프 참조 목록을 말합니다. 렉시컬 환경outerEnvironmentReference를 따라서 외부 스코프를 참조합니다. 렉시컬 환경은 현재 실행 중인 코드의 스코프 체인을 포함하므로, 변수 검색 시 스코프 체인을 따라 상위 스코프까지 탐색하게 됩니다.

3. this 바인딩

실행되는 코드에 따라 this가 참조하는 객체를 결정합니다. 전역 컨텍스트에서는 this가 전역 객체를 참조합니다. 함수 컨텍스트에서는 함수 호출 방식에 따라 this의 참조가 달라집니다.

실행 컨텍스트의 생성과 실행

먼저 자바스크립트 엔진은 변수와 함수 선언문을 스캔하여 변수 객체에 등록합니다.

  • 변수 선언: undefined로 초기화됩니다.
  • 함수 선언: 메모리에 함수 전체가 할당됩니다 (함수 호이스팅).
  • 인자 값: 함수 컨텍스트인 경우, 전달받은 인자가 변수 객체에 초기화됩니다.

그리고 스코프 체인을 설정합니다.

  • 현재 실행 컨텍스트의 변수 객체 + 외부 스코프 참조가 설정됩니다.

마지막으로 this 바인딩을 설정합니다.

  • 실행되는 환경(전역/함수/생성자/엄격 모드)에 따라 this가 바인딩됩니다.

그 후 코드가 한 줄씩 실행되면서 변수에 값이 할당됩니다. 실행 컨텍스트는 스택으로, 자바스크립트 엔진 구성 요소 중 콜 스택에 쌓입니다. 코드를 실행하면, 실행 컨텍스트를 쌓는 콜 스택이 생성되고 컨텍스트가 모두 제거되면 콜 스택은 소멸됩니다. 실행 중인 컨텍스트에서 관련 없는 코드가 실행되면 새로운 컨텍스트가 생성됩니다. 생성된 컨텍스트는 콜 스택에 쌓이며 제어권이 콜 스택으로 이동됩니다.

*제어권: 어떤 코드나 함수가 실행될지 결정할 수 있는 권한

이해를 돕기 위해 함수를 이용해서 예시 코드를 보여드리겠습니다.

  function func1() {
    console.log('첫 번째 함수');
    func2();
  }

  function func2() {
    console.log('두 번째 함수');
  }

  func1();

위의 함수 func1, func2는 어떻게 컨텍스트가 생성되고 실행되며 소멸될까요?

콜 스택

우선 자동으로 전역 컨텍스트가 생성된 후 함수 호출시마다 함수 컨텍스트가 생성되고, 컨텍스트 생성이 완료된 후에 함수가 실행됩니다. 함수 실행 중에 사용 되는 변수들을 변수 객체 안에서 먼저 찾고, 값이 존재하지 않는다면 렉시컬 환경outerEnvironmentReference를 통해 스코프 체인을 따라 올라가면서 탐색합니다. 함수 실행이 마무리가 되면 해당 컨텍스트는 사라지고, 페이지가 종료되면 전역 컨텍스트도 사라집니다.

예시 코드에서는 전역 컨텍스트가 콜 스택에 올라간 후, func1의 컨텍스트가 생성되어 콜 스택에 쌓입니다. 그 후 func1 내부 스코프의 func2 호출 코드를 스캔하여 func1을 실행하지 않고 func2의 컨텍스트를 생성하고 콜 스택에 쌓습니다. 그럼 func2가 콜 스택의 가장 위에 있으므로 먼저 실행됩니다. 이후 func2의 컨텍스트가 제거되면 func1이 실행되고 컨텍스트가 제거됩니다. 모든 함수의 실행이 끝나면 전역 컨텍스트만 남습니다.

스코프 체인

스코프 체인은 체인이라는 말에서 알 수 있듯이 스코프끼리 연쇄적으로 참조하는 것을 말합니다. 예시 코드를 보시죠.

const a = '전역 변수';

function outer() {
	const b = '외부 함수';
	
	function inner() {
		const c = '내부 함수';
		console.log(a, b, c); // 스코프 체인을 따라 a, b, c 탐색하기
	}
	
	inner();
}

outer();

예시 코드에서는 inner 함수가 자신의 스코프에서 c를 찾고, outer 함수의 스코프에서 b를 찾고, 전역 스코프에서 마지막으로 a를 찾아 참조합니다. 내부 스코프에서 값을 찾다가 없을 때 외부로 빠져나와 그 스코프에서 값을 찾는 것이죠. 실행 컨텍스트에서는 이런식으로 스코프 체인을 따라 변수 값을 탐색하게 됩니다.

this 바인딩

this는 기본적으로 전역에서는 window 객체를 가리킨다고 말씀드렸죠. this는 어느 스코프에서 실행되냐에 따라 가리키는 객체가 달라집니다. 예시 코드를 보면서 설명드리겠습니다.

console.log(this); // 전역 컨텍스트에서는 window

function wind() {
  console.log(this); // 일반 함수 호출에서는 window (엄격 모드에서는 undefined)
}

const obj = {
  method: function() {
    console.log(this); // 메서드 호출 시 this는 obj
  }
};

wind();       // window
obj.method(); // obj

wind를 실행하면 가리키는 객체가 없기 때문에 window가 출력됩니다. 하지만 ‘use strict’를 사용하는 엄격 모드에서는 window가 아닌 undefined를 반환합니다. 명시적으로 바인딩하지 않았기 때문에, 전역 객체에 의도치 않은 속성을 추가하거나 변경하는 것을 엄격 모드가 방지해줍니다.

이런식으로 실행 컨텍스트는 변수 객체에 변수, 함수, 매개변수 정보를 저장하고 있으며, 변수 검색시 상위 스코프를 탐색할 수 있도록 스코프 체인을 저장하고 있으며, this 바인딩 정보까지 담고 있는 객체입니다. 이 하나의 컨텍스트가 콜 스택에 쌓이는 것까지는 알아두시면 됩니다. 그럼 이제 함수와 변수가 자바스크립트 엔진에서 실행 컨텍스트로 어떻게 관리되는지 살펴봤으니 이렇게 관리되는 데이터가 메모리에 어떻게 저장되고 제거되는지 다음 챕터에서 알아보겠습니다.

🌱 메모리 관리

메모리 관리의 사전적 의미는 프로그램이 실행되는 동안 데이터를 저장하고 처리하기 위해 메모리를 할당하고 더 이상 필요하지 않은 데이터를 해제하는 과정입니다. 우리는 자바스크립트가 어떤 자료구조를 활용하여 메모리로 사용하는지 알아야 프론트엔드 개발자로써 우리가 개발하고 있는 앱의 성능 최적화와 메모리 누수 방지를 의식적으로 할 수 있습니다.

자바스크립트 메모리 구조

자바스크립트 엔진은 스택과 힙으로 구성되어있습니다. 엔진은 사실 브라우저마다 다른데, 대중적으로 가장 널리 알려지고 사용되는 V8을 기준으로 설명하겠습니다. 크롬이 V8을 사용중입니다.

1. 콜 스택

콜 스택은 함수 호출 시 생성되는 실행 컨텍스트를 저장하고 관리하는 자료구조이자 변수의 참조 주소와 같은 짧고 고정된 크기의 데이터를 저장하는 데 사용되는 자료구조입니다.

스택은 고정 크기의 메모리를 가지고 있습니다. 스택은 원시 값과 실행 컨텍스트를 잠시 저장하기 위해서 사용됩니다. 스택은 deep copy, 값을 원본 그대로 복사합니다.

let x = 100;
let y = x;
y = 200;

console.log(x); // 100 (원시 값은 별도의 메모리 공간에 복사됨)

그래서 위처럼 y에 x의 값을 갖도록 하면, 같은 메모리 주소를 참조하지 않고 y를 새로운 주소에 저장하면서 100이 주소에 할당됩니다.

2. 메모리 힙

힙은 동적 크기의 메모리를 가집니다. 비정형 데이터를 저장하죠. 힙은 참조 값(객체, 배열, 함수)를 저장하기 위해서 사용됩니다. 참조형 데이터는 힙에 실제 데이터를 저장하고 스택에는 해당 데이터의 참조 주소만 저장됩니다.

let obj1 = { value: 100 };
let obj2 = obj1;
obj2.value = 200;

console.log(obj1.value); // 200 (참조 값은 Heap의 같은 메모리 주소를 참조함)

그래서 위처럼 obj1의 값을 obj2에 할당하면 shallow copy로 복사하여, 같은 메모리 주소를 참조합니다. 그렇기에 obj2.value를 변경했는데 obj1까지 값이 변경되어버렸죠.

가비지 컬렉터

자바스크립트에서 메모리 관리는 가비지 컬렉터에 의해서 되고 있습니다. 정확히는 메모리 힙을 관리합니다. 더 이상 참조되지 않는 데이터를 힙에서 자동으로 해제합니다. 가비지 컬렉터에 대한 자세한 내용은 아래의 과거 포스트를 참고해주세요.

https://velog.io/@windowook/JS-garbage-collection

메모리 누수를 방지하려면

1. 전역 변수 남용하지 않기

가비지 컬렉터가 제거하지 못하는 값은 선언하지 않는 것이 좋습니다. 대표적으로 전역 변수를 함수 바깥에서 선언해놓고 함수 안에서 이를 참조하는 패턴이 있죠. 전역 변수를 참조중이라 가비지 콜렉팅이 일어나지 않아 계속 메모리의 일정 용량을 잡아먹고 있는 셈입니다. 이를 방지하기 위해서는 반드시 필요한 경우가 아니라면, 함수 안에서 함수 컨텍스트가 실행되고 소멸될 때 변수도 같이 메모리에서 사라질 수 있도록 지역 변수로만 선언하는 것입니다.

ex) 전역 변수 선언

let globalData = []; // 전역 변수로 선언되어 메모리 누수가 발생함

function addData() {
  globalData.push(new Array(1000000).fill('*'));
}

ex) 함수 내부 선언 (지역 변수)

function addData() {
  let globalData = [];
  return globalData.push(new Array(1000000).fill('*'));
}

2. 클로저로 인한 메모리 누수

ex) 클로저인 inner에서 참조 중

function outer() {
  let largeData = new Array(1000000).fill('*');
  return function inner() {
    console.log(largeData[0]);
  };
}

const leakedFunction = outer();

inner 함수는 클로저입니다. 클로저는 자신이 선언된 외부 함수의 스코프에 접근할 수 있는 함수입니다. 이 때 inner에서 outer의 largeData를 참조하면 메모리를 소모하게 됩니다. 그래서 참조한 값이 필요하지 않게 되면 바로 참조를 해제하여 메모리 누수를 막는 것이 좋습니다.

ex) 클로저인 inner에서 참조 중

function outer() {
  let largeData = new Array(1000000).fill('*');

  return function inner() {
		 console.log(largeData[0]); // 힙에 저장되어있는 데이터 참조
     largeData = null; // ✅ 참조 해제
	   console.log('클로저에서 메모리 해제 처리 완료');
  };
}

const fixedFunction = outer();
fixedFunction();

3. EventListener 미해제

const btn = document.querySelector('button');
btn.addEventListener('click', function() {
  console.log('클릭됨'); // ❌ DOM이 제거되어도 메모리 해제되지 않음
});
btn.removeEventListener('click', handler);

EventListener는 DOM의 요소에 자바스크립트를 사용하여 함수를 할당합니다. 함수를 할당한 것이기 때문에 의도적으로 remove를 하지 않는다면 DOM과는 별개로 자바스크립트는 그대로 남아 메모리에서 해제되지 않습니다. 따라서 add를 했다면 사용하지 않는 시점에 반드시 remove로 이벤트를 제거해줘야 합니다.

이제 메모리 구조와 메모리 관리를 어떻게 해야하는 지도 이해했을 거라 생각합니다. 마지막으로 자바스크립트가 싱글 스레드 환경에서도 어떤 방식을 사용해 효율적으로 작업을 처리하는지 살펴볼 차례입니다.

🌱 브라우저 런타임 환경에서 동시성 처리

싱글 스레드, 하나의 프로세스에서 하나의 스레드만 실행이 가능하죠. 그래서 자바스크립트는 기본적으로 동기적(순차적)인 작업을 할 수 밖에 없습니다. 하지만 브라우저에서는 멀티 스레딩이 가능합니다. 이게 뭔 소리냐구요? 왜 가능한지 천천히 알아봅시다.

이벤트 루프

이벤트 루프는 브라우저 런타임 환경에서 콜 스택, Web APIs, 렌더, 마이크로태스크 큐, 태스크 큐를 1ms도 안 되는 시간으로 한 바퀴씩 순환하는 동작을 반복합니다.

  • 콜 스택이 비어 있는지 확인
  • 마이크로태스크 큐의 작업을 먼저 실행
  • 이후 태스크 큐에서 대기 중인 작업을 실행

이를 통해 동시성을 유지하면서 싱글 스레드 환경에서도 비동기 처리를 가능하게 합니다.
엄청나게 빠른 속도로 순환하기 때문에 1ms마다 브라우저에 Render를 업데이트 해줄 필요는 없습니다.
대부분의 브라우저에서 16.7ms마다 렌더를 한 번 업데이트 해주고(60 fps), 마이크로태스크 큐와 태스크 큐, 콜 스택의 동작을 실행하고 나서 몇 바퀴 돈 뒤에 다시 16.7ms가 되면 렌더를 업데이트하고 이런 식으로 반복이 이루어집니다. 2ms가 되기 전에 렌더를 실행하면 이벤트 루프는 마이크로태스크 큐에서 멈춥니다.

이벤트 루프는 마이크로태스크 큐에 들어온 아이템들을 하나씩 콜 스택으로 가져갑니다. 그래서 콜 스택으로 가져갈 아이템이 마이크로태스크 큐에 없을 때까지 이벤트 루프는 계속 멈춰서 기다리며 마이크로태스크 큐가 비게 되면 태스크 큐로 가게 됩니다. 마이크로태스크 큐가 비어있는데 또 다른 콜백이 들어오게 되면 콜 스택으로 마이크로태스크 큐의 콜백을 들고가서 동작이 완료될 때까지 기다립니다. 콜 스택이 비고 마이크로태스크 큐까지 비면 태스크 큐로 가고, 태스크 큐에 있는 콜백들 중에서 하나만 콜 스택으로 가져옵니다. 콜 스택에서 실행을 완료하고 나면 다시 루프를 돌아 렌더로 가서 업데이트 된 부분이 Request Animation Frame의 Queue에 등록됩니다. 이 RAF 큐의 콜백들을 수행하면 렌더 트리 구성과 레이아웃, 페인트도 순차적으로 수행합니다. 이게 이벤트 루프가 브라우저 런타임 환경에서 콜백을 옮기면서 자바스크립트를 동작하게 하는 원리이며, 이 동작으로 DOM을 업데이트하고 동적으로 웹 페이지를 렌더링 할 수 있게 되는 것이죠. 각 구성 요소에 대해서도 알아보도록 합시다.

콜 스택

콜 스택은 실행 컨텍스트에서 설명했던 것과 같습니다. 좀 더 자세히 설명하기 위해서 html 태그와 EventListener를 활용해보도록 하겠습니다.

개발자 A씨는 브라우저에서 button에 이벤트리스너로 클릭하면 box의 스타일을 변경시키는 이벤트를 등록했습니다. A씨가 의도한건 transform 1s ease-in이 적용되어 뷰포트 기준 left: 0으로부터 800px만큼 수평 이동 후 다시 left: 0에서 500px만큼 떨어진 위치로 수평 이동하는 것이지만, 당연히 A씨가 생각한 대로 애니메이션이 일어나지 않고 실제 box는 left: 0으로부터 500px 떨어진 위치로만 이동하는 애니메이션이 나타납니다. 왜인지 감이 오시죠?

button을 클릭했을 때 발생하는 콜백은 콜 스택 안에서 완료될 때 까지 렌더로 가지 않기 때문에 가장 마지막에 설정한 것이 렌더로 전달되어 가장 마지막의 스타일이 적용이 되는 것입니다. 따라서 결과적으로 translateX(500px)만 렌더로 가기 때문에 A씨가 의도한대로 동작하지 않습니다.

렌더

브라우저가 DOM의 변경 사항을 시각적으로 화면에 업데이트하는 과정입니다. 렌더의 첫 번째 과정인 Request Animation Frame은 브라우저의 렌더링 주기와 동기화하여 콜백을 실행합니다. 런타임 환경에서의 순서는 마이크로태스크 → 매크로태스크 → requestAnimationFrame 콜백 순으로 이루어집니다. 이후에 렌더 트리가 구성됩니다. 그리고 레이아웃, 페인트까지 수행하면 화면에 렌더링 되는 것입니다.

마이크로태스크 큐

Promiseresolve로 끝나서 then에 담긴 콜백을 호출하면, 태스크 큐가 아니라 마이크로태스크 큐에 들어옵니다. 그리고 mutation observer에 등록된 콜백도 마이크로태스크 큐로 들어옵니다. 마이크로태스크 큐는 태스크 큐보다 우선순위가 높기 때문에 이벤트 루프는 마이크로태스크 큐에 먼저 들려 콜백이 있는지 확인합니다.

태스크 큐(= 매크로 태스크 큐)

태스크 큐에는 setTimeout의 timeout 콜백과 EventListener의 click 콜백 등이 들어옵니다. 이벤트 루프는 콜 스택이 비워질 때까지 기다리다가 텅텅 비어 자바스크립트 엔진이 동작하지 않을 때, 태스크 큐에 있는 콜백을 콜 스택으로 가져옵니다. 콜 스택으로 태스크 큐에 있는 콜백을 가져올 때는 한 번에 하나의 콜백만 가져옵니다. 그래서 태스크 큐에서 가져왔던 콜백의 실행이 끝나 콜 스택에서 사라지면 태스크 큐에 있는 그 다음 콜백을 또 콜 스택으로 가져옵니다.

🌱 정리

분명히 자바스크립트를 처음 배울 때 봤던 내용들이지만 머리에 잘 남지 않았습니다. 근데 이번에 찾아보면서 공부하니 개발하면서 경험했던 것들과 연계가 되면서 이해가 쏙쏙되었던 것 같네요. 자바스크립트 엔진의 작동 방식과 실행 컨텍스트에 대한 이해는 단순히 코드를 실행하는 것을 넘어서 성능 최적화, 디버깅, 안티 패턴을 지양하는 코드 작성에 있어 필수적인 지식이 아닐까 생각합니다. 근본 넘치는 프론트엔드 엔지니어가 되기 위해 어렵지만 알아야 하는 지식들을 계속 배워나가야겠습니다:)

profile
안녕하세요

0개의 댓글