JavaScript: 자바스크립트 엔진의 실행 흐름

ε( ε ˙³˙)з ○º·2025년 6월 25일
post-thumbnail

Intro

자바스크립트는 매일 사용하는 언어지만 작성한 코드가 브라우저에서 어떻게 실행되고 최적화되는지 엔진의 관점에서 깊게 생각해 본적이 많지 않다. 🤔

이 글에서는 자바스크립트 엔진을 중심으로 실행 흐름을 깊게 파고들어 보고자 한다.


자바스크립트 엔진이란?

자바스크립트 엔진(JavaScript Engine)은 우리가 작성한 자바스크립트 소스 코드를 해석하고 실행하는 프로그램이다. 우리가 작성한 고수준의 자바스크립트 코드를 브라우저나 Node.js가 이해할 수 있는 저수준 기계어로 변환하고 실행하는 것이 엔진의 역할이다.

자바스크립트 엔진의 종류

대표적으로 크롬의 V8, 사파리의 JavaScriptCore, 파이어폭스의 SpiderMonkey가 있다.

자바스크립트 엔진의 목적

  • 코드 실행: JS 코드를 읽고, 기계어로 변환해 실제로 실행
  • 최적화: 빠른 실행 속도를 위해 코드 패턴을 분석하고 성능을 점진적으로 개선
  • 메모리 관리: 가비지 컬렉션(Garbage Collection)을 통해 불필요한 메모리를 해제

자바스크립트 엔진의 주요 구성 요소

자바스크립트 엔진은 JS 코드를 읽고 실행하는 데 필요한 여러 기능들이 모여 있는 시스템이다. 각각의 기능은 코드 분석 - 실행 - 성능 최적화 - 메모리 관리까지 코드가 잘 실행될 수 있도록 순서대로 중요한 역할을 맡는다.

파서 (Parser)

자바스크립트 엔진이 코드를 읽고, 분석하고, 실행 가능한 데이터 구조로 변환하는 단계

파서는 자바스크립트 코드를 문자열 상태에서 구조화된 데이터로 해석하는 모듈이다.
여기서 코드가 정확한 문법인지 검증하고, 실행 가능한 형태로 변환하는 전처리 과정을 담당한다.

파서의 내부 동작 과정은 크게 4단계로 구성된다.

1. 토큰화
토큰(Token)은 코드를 의미 단위로 쪼갠 최소 단위

  • let a = 5 + 10; → let / x / = / 5 / + / 10 / ;

파서 앞 단계의 스캐너(Scanner)가 문자 단위로 코드를 읽고 스캐너는 문자를 예약어, 식별자, 연산자, 구분자, 리터럴 같은 토큰으로 분리한다. 이 과정에서 주석, 공백, 줄바꿈은 버린다. (렉서가 토큰을 만들 때 필요 없는 정보)

2. 렉싱 (Lexing)
렉서(Lexer)는 토큰화된 결과를 문법적으로 더 정교하게 분류하는 역할

  • 예를 들어 5는 '숫자 리터럴', 'apple'은 '문자열 리터럴'로 구분.

렉서는 토큰이 어떤 의미를 가지는지를 결정 (let은 변수 선언 키워드, =는 할당 연산자 등으로 정리)
렉싱이 제대로 동작하지 않으면 구문 분석이 불가능해지고 SyntaxError를 발생된다.

3. 파싱 (Parsing)
렉싱 결과를 읽어 코드의 문법적 구조를 계층적으로 분석하는 단계
문법에 맞는 코드인지 확인하며 코드의 계층 관계를 Parse Tree라는 트리 구조로 생성 (파서가 문법 오류를 발견하는 시점이 바로 이 단계)

let a = 5 + 10;
// 파싱 결과
VariableDeclaration
 ├── Identifier (a)
 └── BinaryExpression (+)
       ├── NumericLiteral (5)
       └── NumericLiteral (10)

4. AST (Abstract Syntax Tree) 생성
파싱에서 만든 Parse Tree를 더 추상화하고 간결하게 만든 트리로 실행에 필요 없는 세부 구조(괄호, 세미콜론 등)를 제거하고, 코드의 핵심적인 구조만 남긴 트리

AST는 무엇을 하라는 코드인가를 엔진이 이해할 수 있도록 정리된 최종 데이터 구조로 이후 인터프리터가 이 AST를 기반으로 바이트코드를 생성하게 된다.

  • let a = 5 + 10;의 AST
{
  "type": "VariableDeclaration",
  "identifier": "a",
  "value": {
    "type": "BinaryExpression",
    "operator": "+",
    "left": { "type": "NumericLiteral", "value": 5 },
    "right": { "type": "NumericLiteral", "value": 10 }
  }
}

인터프리터 (Interpreter)

파서가 생성한 AST를 바탕으로 바이트코드를 생성하고 즉시 실행하는 모듈

빠른 코드 실행을 최우선으로 한다. 복잡한 최적화를 생략하고, 일단 코드를 빠르게 돌리기 위해 설계된 모듈이다.

🔍 인터프리터의 핵심 흐름 (V8: Ignition 기준)

1. AST를 바이트코드로 변환
인터프리터는 파서가 생성한 AST를 읽고 엔진이 바로 실행할 수 있는 바이트코드를 생성한다.

바이트코드란?
바이트코드는 자바스크립트 엔진 내부에서만 사용하는 일종의 엔진 전용 명령어이다. CPU는 직접 바이트코드를 실행할 수 없지만, 엔진 내 인터프리터가 빠르게 해석할 수 있는 포맷이다.

2. 빠른 초기 실행 (First Execution Priority)
인터프리터는 "일단 코드를 빠르게 돌려!"를 최우선 목표로 한다. 바이트코드를 생성하자마자 곧바로 실행하며, 이 시점에서는 아직 최적화된 머신 코드가 존재하지 않는다. (순수 바이트코드 실행)

3. 실행 프로파일링 (Profiling)
인터프리터는 단순히 실행만 하는 게 아니라 핫 코드(자주 실행되는 코드)를 감지하는 역할도 한다. 어떤 함수가 반복 실행되는지, 변수의 타입은 주로 무엇인지 등을 기록하여 JIT 컴파일러에게 넘긴다.

핫 코드 감지 기준?
반복문에서 자주 도는 코드, 재귀 호출, 짧은 시간 내 다수 호출된 함수 패턴은 인터프리터가 최적화 대상으로 간주한다.

* 이 데이터를 기반으로 JIT 컴파일러가 최적화할 함수와 최적화 전략을 결정

4. 바이트코드 캐시
V8은 바이트코드를 캐싱하여 같은 JS 파일을 다음에 실행할 때 빠르게 재사용할 수 있도록 한다. 이를 Code Caching이라고 하며, 페이지 로딩 성능에도 긍정적인 영향을 준다.

인터프리터가 중요한 이유

자바스크립트는 페이지 로딩 후 인터프리터가 무조건 가장 먼저 실행된다. 초기 로딩 속도에 민감한 웹 앱에서는 인터프리터가 코드를 얼마나 빠르게 돌리는지가 UX에 직접적인 영향을 준다. 바이트코드 캐시 전략을 잘 활용하면 재방문 시 로딩 속도를 더 줄일 수 있다. 디옵트가 발생할 경우, JIT가 버려지고 다시 인터프리터 실행으로 롤백된다.

🧐 디옵트 (Deoptimization) 정의
JIT 컴파일러가 최적화된 머신 코드를 버리고 다시 인터프리터의 바이트코드 실행 단계로 되돌아가는 현상.


JIT 컴파일러 (Just-In-Time Compiler)

실행 중인 코드(런타임)를 감시하고, 자주 실행되는 코드(핫 코드)를 최적화된 머신 코드로 변환하는 엔진 모듈

자바스크립트는 기본적으로 인터프리터가 바이트코드를 실행하지만, JIT 컴파일러가 개입하면 기계어로 직접 실행되기 때문에 속도가 비약적으로 빨라진다.

🔍 JIT 컴파일러 동작 흐름 (V8 기준: TurboFan)

1. 핫 코드 감지
인터프리터가 코드를 실행하면서 어떤 함수가 자주 호출되는지 통계 수집, 주로 반복문, 재귀 함수, 다수 호출된 짧은 함수가 JIT 대상이 된다

2. 타입 프로파일링
인터프리터가 "이 변수는 대부분 숫자 였어"같은 타입 정보를 수집한다. JS는 동적 타입 언어라 JIT 컴파일러가 대부분의 실행 흐름을 보고 가장 자주 사용된 타입을 가정한다.

3. JIT 컴파일 트리거
특정 호출 임계치에 도달하면 TurboFan JIT 컴파일러가 바이트코드를 머신 코드로 재컴파일한다. 여기서 최적화 가정을 적극적으로 적용한다.
("이 변수는 무조건 숫자", "이 객체는 항상 이런 구조다" 같은 가정)

4. 최적화 코드 생성 (고급 컴파일 기술 적용)

TurboFan의 최적화 기법

  • 타입 추론(Type Feedback) 변수의 타입을 고정적으로 추론
  • 인라인 캐시(Inline Cache) 반복되는 객체 접근을 캐시하여 메모리 접근 속도 향상
  • 인라이닝 (Inlining) 자주 호출되는 짧은 함수를 호출하지 않고 본문을 직접 삽입
  • 상수 폴딩 (Constant Folding) 실행 전 미리 계산 가능한 값들은 컴파일 시점에 처리
  • 데드 코드 제거 (Dead Code Elimination) 실행되지 않는 코드 블록을 제거

5. 머신 코드 실행
JIT 컴파일된 머신 코드는 CPU가 직접 실행하여 매우 빠른 성능 달성

6. 디옵트 감시
실행 중에 타입이 바뀌거나, 객체 구조가 바뀌면 디옵트 발생하여 최적화된 머신 코드가 버려지고 다시 인터프리터로 롤백


가비지 컬렉터 (Garbage Collector, GC)

가비지 컬렉터는 자바스크립트 엔진이 더 이상 필요하지 않은 메모리를 자동으로 해제하는 시스템

C, C++ 같은 언어는 free 같은 명령어로 개발자가 메모리를 직접 해제해야 하지만, 자바스크립트는 메모리를 직접 해제할 수 없다. 그래서 자바스크립트 엔진은 가비지 컬렉터를 통해 필요 없는 데이터를 스스로 찾아 지우며 만약 이 시스템이 없으면 메모리 누수가 쉽게 발생할 수 있다.

가비지 컬렉터의 핵심 원리: 도달 가능성(Reachability)
가비지 컬렉터가 메모리를 지우는 기준은 현재 코드에서 여전히 참조할 수 있는 데이터는 살려두고 이제 더 이상 접근할 방법이 없는 데이터는 삭제한다. (도달가능성)

let product = { name: 'PC' }; // 도달 가능 (참조 O)
product = null; // 도달 불가능 (참조 끊김 → 삭제 대상)
// 변수에서 객체를 더 이상 참조하지 않으면 GC가 메모리에서 삭제

V8 엔진의 세대별 가비지 컬렉션 (Generational GC)
V8 엔진은 메모리 청소를 더 빠르게 하기 위해 메모리 공간을 세대로 구분하고 대부분의 객체는 "금방 죽는다"는 통계적 사실을 이용해서 금방 사라질 객체는 자주 빠르게 청소하고 오래 살아남은 객체는 천천히 청소를 한다.

메모리 공간 구성

  • New Space (Young Generation)

    • 새로 생성된 객체는 모두 여기 저장
    • Young GC가 자주 빠르게 실행되며 대부분 객체는 금방 죽기 때문에 이 공간에서 바로 삭제
  • Old Space (Old Generation)

    • Young GC에서 여러 번 살아남은 객체는 Old Space로 이동
    • 이 공간은 잘 안 죽는 객체가 모이며 GC가 가끔만 실행

Old Space로 이동하는 기준: 생존 횟수
새로 생성된 객체는 Young GC가 실행될 때 살아남으면 생존 카운트 +1 되며 보통 2~3번 Young GC를 버티면 Old Space로 이동해서 오래 살아남은 객체는 중요한 데이터라고 판단한다.

자주 발생하는 메모리 누수 패턴

1. 전역 변수 (Global Variables)
전역 변수는 앱이 종료될 때까지 절대 가비지컬렉션의 수거 대상이 되지 않는다. 실행 컨텍스트가 사라져도 전역 객체(window 또는 global)에 남아있는 데이터는 계속 메모리를 점유한다.

  • 전역 변수 사용을 최소화하고 모듈 스코프나 클로저 내부에서 지역적으로 관리하기
  • 필요하면 전역 참조를 null로 명시적으로 해제하기

2. 클로저 (Closure)
클로저는 외부 변수에 대한 참조를 계속 유지할 수 있어서 의도하지 않게 필요 없는 변수를 메모리에 남아있을 수 있다.

  • 클로저 내부에서 불필요한 참조는 null 처리하거나 별도 분리
  • 이벤트 핸들러 등록 시 removeEventListener로 반드시 해제

3. 이벤트 리스너 (Event Listeners)
DOM 요소에 이벤트 리스너를 등록하고 DOM을 삭제했지만 리스너를 해제하지 않으면
브라우저가 DOM 요소를 메모리에 계속 남겨놓는다.

  • DOM 요소를 삭제할 때 removeEventListener로 리스너도 함께 제거
  • AbortController를 활용한 이벤트 관리하여 깔끔하게 자동 해제 가능

4. 타이머 (Timers)
setInterval을 등록하고 타이머를 해제하지 않으면 타이머가 참조하는 변수는 GC 대상이 되지 않아요. (DOM이 삭제돼도 타이머는 메모리를 계속 점유)

  • 타이머는 clearInterval, clearTimeout으로 반드시 해제
  • 페이지 이동, 컴포넌트 언마운트 시 타이머 정리하기

💭 마무리하며

자바스크립트 엔진의 구조와 실행 흐름을 이해하면 코드가 어떻게 동작하는지 더 명확하게 볼 수 있다.
퍼포먼스 최적화나 메모리 관리의 문제를 해결하는 방법을 찾아가기 위해, 엔진의 과정을 이해하는 것도 중요하다. 💪🏻


📚 Reference


이 글은 공식 문서를 기반으로 내용을 정리한 포스팅입니다.
혹시 내용 중 틀린 부분이나 보완할 부분이 있다면 댓글로 남겨주시면 감사하겠습니다. 🙏🏻

profile
차곡차곡 쌓아두기 💭

0개의 댓글