탱구탱구 개발자 일기

자바스크립트 코드의 실행 과정을 알기 위해선 자바스크립트 내부 구조를 공부할 필요가 있다.

 

[실행 가능한 코드]

 

자바스크립트 엔진은 실행 가능한 코드(Executable Code)를 만나면 그 코드를 평가해서 실행 문맥(Executable Context)으로 만든다.

  • 전역 코드 : 전역 객체인 window 아래 정의된 함수
  • 함수 코드 : 사용자 정의 함수 등
  • eval 코드 : eval 함수

위 실행 가능한 코드에 따라서 실행 문맥을 초기화하는 환경과 과정이 다르다. 특히 eval 코드는 렉시컬 환경(Lexical Environment)이 아닌 별도의 동적 환경에서 실행된다.

 

여기서 eval 함수에 대해 짚고 넘어가도록 하겠다. 사실 eval 함수는 모던 자바스크립트에선 잘 쓰이지 않는다. eval 함수는 하나의 parameter를 받아서 그것을 자바스크립트 코드로 바꿔주는 역할을 하는데 과거에 자바스크립트를 지원하지 않는 환경에서 자바스크립트를 처리하는데 쓰였다. 그러나 요즘은 자바스크립트가 대중적인 언어로 바뀌었기 때문에 eval 함수를 대체할 수 있는 문법이나 모듈들이 많이 나왔다. 심지어 eval 함수로 무언가 받아서 사용자의 악의적인 목적의 코드가 실행될 수 있기 때문에 웬만한 실무에서는 사용하지 않고 있다. 또한 eval로 실행된 코드들은 속도도 느리다.

 

[실행 문맥의 구성]

 

실행 문맥(Execution Context)은 실행 가능한 코드가 실제로 실행되고 관리되는 영역을 말한다. 해당 문맥에는 실행에 필요한 모든 정보를 컴포넌트 여러 개가 나눠 관리하도록 만들어져 있다. 이 컴포넌트들 중 몇 가지 중요한 컴포넌트들이 있는데 다음과 같다.

  • 렉시컬 환경 컴포넌트(Lexical Environment Component) : 렉시컬 환경 타입
  • 변수 환경 컴포넌트(Variable Environment Component) : with 문을 사용할 때를 제외하면 렉시컬 환경 컴포넌트와 내부 값이 동일하다.
  • 디스 바인딩 컴포넌트(This Binding Component) : 그 함수(this)를 호출한 객체의 참조가 저장되는 곳

 

자바스크립트는 객체로 이루어져 있다고 말했듯이 이것을 객체 구조로 표현한다면 다음과 같다.

 

// 실행 문맥의 구조(실행 코드가 아님)
ExecutionContext = {
    // 렉시컬 환경 컴포넌트
    LexicalEnvironmentComponent: {
    	// 환경 레코드
        EnvironmentRecord : {
            // 선언적 환경 레코드
            DeclarativeEnvironmentRecord : {},
            // 객체 환경 레코드
            ObjectEnvironmentRecord : {}
        },
        // 외부 렉시컬 환경 참조
        OuterLexicalEnvironmentReference : {}
    },
    // 변수 환경 컴포넌트
    VariableEnvironmentComponent: {},
    // 디스 바인딩 컴포넌트
    ThisBindingComponent: {},
}

 

[렉시컬 환경 컴포넌트의 구성]

 

렉시컬 환경 컴포넌트는 자바스크립트 엔진이 자바스크립트 코드를 실행하기 위해 자원이 저장되는 곳이다. 이 때 자원은 함수 또는 블록의 유효 범위 안에 있는 식별자와 그 결괏값을 말한다. 자바스크립트 엔진은 해당 코드의 유효 범위 안에 있는 식별자(key), 식별자의 값(value)의 쌍으로 bind 해서 렉시컬 환경 컴포넌트에 기록한다. 이 컴포넌트는 환경 레코드와 외부 렉시컬 환경 참조로 구성되어 있다.

 

  • 환경 레코드(Environment Record) : 유효 범위 안에 포함된 식별자를 기록하고 실행하는 영역. 앞서 말한 식별자와 식별값을 바인딩한 데이터가 환경 레코드에 기록된다.
  • 외부 렉시컬 환경 참조(Outer Lexical Environment Reference)  : 자바스크립트는 여러 개의 함수가 중첩될 수 있다. 즉 엔진은 유효 범위 너머의 유효 범위도 검색할 수 있어야 한다. 외부 렉시컬 환경 참조에는 함수를 둘러싸고 있는 코드가 속한 렉시컬 환경 컴포넌트의 참조가 저장된다. 아래 코드로 살펴보도록 하자.
// 외부 렉시컬 환경 참조 예제
function f1() {
    let a = 10;

    function f2() {
        a = a + 1;
    }
    f2();
    return a;
};

위 코드를 보면 함수 f1 안에 함수 f2가 중첩되어 있다. 함수 f2은 a라는 변수에 1을 증가시키는 역할을 한다. 이때 해당 함수 유효 범위 밖에 정의된 함수 f1의 a라는 변수를 읽어야 하는데 이때 외부 렉시컬 환경 참조를 따라 한 단계씩 렉시컬 환경을 거슬러 올라가 해당 변수를 검색한다.

 

[환경 레코드의 구성]

 

앞서 환경 레코드는 렉시컬 환경 안의 식별자와 식별 값의 쌍이 실제로 저장되는 공간이라고 말했다. 이 환경 레코드는 선언적 환경 레코드와 객체 환경 레코드로 구성되어 있는데 저장하는 값의 유형에 따라 쓰임새가 다르다.

 

  • 선언적 환경 레코드(Declarative Environment Record) : 실제로 함수와 변수, catch 문의 식별자와 실행 결과(식별 값)가 저장되는 영역이다.
  • 객체 환경 레코드(Object Environment Record) : 실행 문맥 외부에 별도로 저장된 객체의 참조에서 데이터를 읽거나 쓴다. with 문의 렉시컬 환경이나 전역 객체처럼 별도의 객체에 저장된 데이터는 그 객체가 가진 키와 값의 쌍을 복사해 오는 것이 아닌 그 객체 전체의 참조를 가져와서 객체 환경 레코드의 bindObject라는 프로퍼티에 바인드하도록 프로그래밍되어 있다.

[전역 환경과 전역 객체의 생성]

 

자바스크립트 인터프리터는 시작하자마자 렉시컬 환경 타입의 전역 환경을 생성한다. 웹 브라우저의 내장된 자바스크립트 인터프리터는 새로운 웹 페이지를 읽어 들인 후 전역 환경을 생성한다. 그 후 전역 객체를 생성한 다음 전역 환경의 객체 환경 레코드에 전역 객체의 참조를 대입한다. 전역 객체에 대한 프로퍼티는 자바스크립트 객체의 기초편을 살펴보자. 앞에 다른 객체가 없는 최상위 레벨(함수 바깥에 있는 코드)의 this는 전역 객체(기본적으로 window)를 가리킨다.

 

// 웹 브라우저의 전역 환경
GlobalEnvironment = {
    ObjectEnvironmentRecord : {
        bindObject : window
    },
    OuterLexicalEnvironmentReference : null
}

// 전역 실행 문맥
ExecutionContext = {
    // 렉시컬 환경 컴포넌트
    LexicalEnvironmentComponent: GlobalEnvironment,
    // 디스 바인딩 컴포넌트
    ThisBindingComponent: window
}

위 코드는 웹 브라우저 자바스크립트 실행 환경에서는 전역 환경과 전역 객체 생성을 나타낸 것이다. 순서대로 설명하면 다음과 같다.

  1. 웹 페이지가 시작될 때 인터프리터는 전역 환경을 생성한다. 이 때 객체 환경 레코드의 bindObject에는 전역 객체인 window 객체의 참조가 할당된다. 이로 인해 전역 환경의 변수와 함수를 window 객체 안에서 검색할 수 있다.
  2. 전역 객체인 window 객체 바깥에는 다른 렉시컬 환경이 없기 때문에 외부 렉시컬 환경 참조에는 null이 할당된다.
  3. 마지막으로 전역 실행 문맥의 디스 바인딩 컴포넌트에도 전역 객체의 참조가 할당되어서 전역 실행 문맥에서의 this는 바로 window 객체를 가리키게 되는 것이다.

[프로그램의 평가와 전역 변수]

 

전역 환경, 전역 객체를 생성한 후에는 자바스크립트 프로그램을 해석하게 된다. 자바스크립트 프로그램을 다 읽어 들인 후에는 프로그램을 평가하며, 최상위 레벨에 var 문으로 작성한 전역 변수는 전역 환경의 환경 레코드(객체 환경 레코드)의 프로퍼티로 추가된다. 한번 코드로 살펴보도록 하자.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<script>
    var globalVariable = {
        x: 10,
        y: 20
    }

    console.log(window.globalVariable); // Object {}

    function f1(params) {
        return params.x + params.y;
    }

    console.log(window.f1); // f1(params)
</script>
<body>

</body>
</html>

globalVariable 변수와 f1 함수 모두 window 전역 객체 안의 프로퍼티로 저장되어 참조할 수 있는 것 console에서 확인할 수 있다.

 

주의할 점은 최상위 레벨(전역 스코프)에서 let이나 const로 선언된 전역 변수는 var 변수와 달리 window 객체에 저장되지 않는다. 위에서 var로 선언한 것을 let이나 const로 변경 후 실행해보면 undefined을 반환하는 것을 알 수 있다. 실제로 프로퍼티를 찾아보면 아래와 같은 결과가 나온다.

// var로 선언
window.hasOwnProperty("globalVariable"); // true

// let, const로 선언
window.hasOwnProperty("globalVariable"); // false

var, let, const 변수 선언 키워드에 관해서는 이 글을 참고하면 좋을 것 같다.

 

계속해서 설명하면 해당 변수는 전역 환경의 환경 레코드 안의 객체 환경 레코드(Object Environment Record)에 프로퍼티로 기록되었다. 변수명은 프로퍼티 이름으로 되고 프로퍼티 값은 undefined로 할당된다. 이러한 원리는 브라우저의 개발자도구에서 변수를 등록하면 undefined가 반환되는 이유이다.

최초 객체 환경 레코드 프로퍼티 등록

함수의 경우도 함수 객체로 생성하여 마찬가지로 객체 환경 레코드에 기록된다. 즉 전역 변수는 전역 객체 프로퍼티 or 전역 객체 실행 문맥에 있는 환경 레코드의 프로퍼티를 의미하는 것이다. 지역 변수도 해당 변수의 실행 환경의 환경 레코드의 프로퍼티이다. 또한 var 키워드를 사용하지 않고 변수를 선언해 값을 할당하면 프로그램 실행 중 디스 바인딩 컴포넌트가 가리키는 객체의 프로퍼티로 추가된다.

 

쉽게 말하면 자바스크립트의 모든 변수를 어떠한 객체의 프로퍼티로 생각하면 되겠다.

 

[프로그램 실행과 실행 문맥]

 

프로그램이 평가된 다음에는 프로그램이 실행되며, 프로그램은 실행 문맥 안에서 실행된다. 실행 문맥은 앞서 말한 것처럼 실행 가능한 코드별로 생성된다.

 

실행 문맥은 스택(Stack) 구조로 관리된다. 스택은 데이터가 아래서부터 차곡차곡 쌓이며 가장 나중에 추가된 데이터를 먼저 꺼내는 후입 선출(LIFO, Last In First Out) 방식의 자료 구조다. 스택의 가장 윗부분에 데이터를 추가할 때는 push라고 하고 반대로 가장 윗부분에서 데이터를 제거하는 것을 pop이라고 한다.

 

실행 문맥은 프로그램 실행 중에 스택에 push되어 실행되고 해당 실행 문맥이 종료되면 스택에서 pop 된다. 중첩 함수일 경우 외부 함수 실행 문맥이 아닌 해당 중첩 함수의 실행 문맥이 새로 생성돼 스택에 push된다는 것을 알아두자. 이러한 실행문맥 스택을 호출 스택(call stack)이라고 부른다. 코드로 한번 살펴보도록 하자.

 

// 프로그램 실행과 실행 문맥
function multiply(x, y) {
    return x * y;
}

function calculateSquare(a) {
    let result = multiply(a, a);
    console.log(result); // 100
    return result; // 100
}

calculateSquareArea(10);

위 코드는 정사각형 넓이를 구하는 함수가 정의되어 있다. calculateSquare 함수를 실행했을 때 내부에서 외부 함수인 multiply 함수를 호출한다. multiply에서 return문이 호출되어 다시 제어권이 호출한 코드로 돌아갈 때 스택에서 pop 된다. 해당 코드를 아래와 같이 스택 프레임(stack frame)으로 옮겨보았다.

 

호출 스택

[스레드]

 

프로그램을 실행하는 방식에는 싱글 스레드와 멀티 스레드 방식이 있다. 결론부터 말하면 자바스크립트는 싱글 스레드로 처리한다. 싱글 스레드 기반에서는 멀티 스레드 기반처럼 데드락 등과 같은 문제로부터 벗어날 수 있다. 그러나 하나에서 모든 프로그램을 처리하기 때문에 프로그램 실행에 있어 제약사항이 많다.

 

  • 싱글 스레드 : 프로그램 한 개의 처리 흐름으로 프로그램을 순차적으로 실행하는 방식
  • 멀티 스레드 : 프로그램 여러 개의 처리 흐름으로 동시에 작업을 여러 개 병렬로 실행하는 방식

즉, 자바스크립트는 하나의 호출 스택으로 프로그램을 처리하고 호출 스택에 쌓인 실행 문맥(함수 or 코드)을 위에서부터 터 아래로 순차적으로 읽어가면서 실행한다. 하나의 실행 문맥 작업이 끝나서 스택에서 pop 하고 다음 실행 문맥을 처리한다는 뜻이다.

 

그렇다면 이벤트 처리나 API 호출 등과 같이 비동기로 처리해야 할 때는 어떻게 처리할까??

결론부터 말하면 이러한 비동기 처리도 같은 방식으로 호출 스택에서 실행된다. 다만 이러한 비동기 처리들을 임시로 저장하는 공간인 대기 큐가 존재하는데 이를 Task Queue 또는 Event Queue라고 부른다. 이 대기 행렬은 먼저 들어온 작업이 먼저 나가는 선입 선출(FIFO, First In First Out) 방식이다.

 

여기에 들어있는 처리들은 현재 실행 중인 함수의 작업이 끝나고 호출 스택에서 pop 되면 해당 대기열에 들어온 순서대로 호출 스택에 push 해서 수행된다.

// 호출 스택 비동기 처리
function eventTask() {
    console.log("step 0");
    setTimeout(function () {
        console.log("step 1");
    }, 100);

    setTimeout(function () {
        console.log("step 3");
    }, 50);
    
    console.log("step 2");
}
eventTask();

// step 0
// step 2
// step 3
// step 1

eventTask 함수에는 자바스크립트 대표적인 비동기 처리방식인 setTimeout 함수가 정의되어 있는데 이 비동기 처리는 앞서 말한 것처럼 현재 실행 중인 함수가 끝나고 나서야 비로소 pop 된다. 따라서 eventTask의 마지막 코드인 step 2까지 실행되고 나서야 호출 스택에 push 된다. 위 코드에서 대기큐에 setTimeout의 핸들러로 익명함수 2개가 등록되어있는데 step 3를 찍는 함수가 지연시간이 더 적으므로 step 3 -> step 1 순서로 호출스택에 push된다.

 

[환경 레코드와 지역 변수]

 

함수를 호출하면 현재 실행 중인 코드의 작업을 일시적으로 멈추고 실행 문맥 영역을 생성한다. 그리고 프로그램 실행 흐름이 그 실행 문맥으로 이동한다. 그리고 해당 실행 문맥이 호출 스택에 push 되고 실행 문맥 안에 렉시컬 환경 컴포넌트를 생성한다. 처음에 잠깐 언급했던 실행 문맥의 객체 구성을 갖고 표현하면 아래와 같다.

// 실행 문맥 영역
ExecutionContext = {
    // 렉시컬 환경 컴포넌트
    LexicalEnvironmentComponent: {}
}

렉시컬 환경 컴포넌트 안에는 환경 레코드로 구성되어 있다고 했는데. 이 환경 레코드 안에 그 함수에서 선언된 변수나, 중첩 함수의 참조가 기록된다. 즉 함수 안팎의 환경을 기록한다. 이 환경 레코드는 사용자가 읽거나 쓸 수 없으며 다음과 같은 정보가 기록된다.

  • 함수의 인자
  • 함수 안에서 선언된 중첩 함수의 참조
  • 함수 안에서 var로 선언한 지역변수
  • arguments : 인수 값 전체 목록, length 프로퍼티, callee 프로퍼티

함수가 실행될 때 그 함수의 선언적 환경 레코드에는 이에 대응하는 인수의 값이 설정되며 대응하는 인수가 없으면 undefined가 할당된다. 함수 선언문으로 생성한 함수 안의 지역 변수에는 함수 선언문으로 생성한 함수 객체의 참조가 설정된다. var로 선언한 지역 변수에는 undefined가 설정된다.

 

이렇게 함수 실행 문맥, 렉시컬 환경, 환경 레코드까지 생성되면 실행 문맥에 있는 디스 바인딩 컴포넌트에 그 함수를 호출한 객체의 참조가 저장되며 이것으로 this 값을 결정한다. 이 this값은 함수가 호출하는 상황에 따라 가리키는 객체가 달라지는데 그것은 이 글에서 정리해두었으니 잘 모르겠다면 참고하는 것도 좋을 것 같다.

 

this 값까지 결정되면 함수 안의 코드가 순서대로 실행된다. 함수가 실행되는 시점에는 지역 변수 or 함수 선언문으로 선언한 함수 이름이 함수를 평가하는 시점에 선언적 환경 레코드에 기록된 상태이기 때문에 변수나 함수 선언문으로 선언된 함수가 해당 함수 어느 부분에 위치하더라도 함수 전체에서 사용 가능하다. 이렇게 되기 위해서 바로 변수, 함수 선언문을 호이스팅하는 이유이다.

 

함수가 종료되고 호출한 코드로 제어권이 돌아가면 기본적으로 실행 문맥과 함께 그 안에 있는 렉시컬 환경 컴포넌트가 메모리에서 지워진다. 그러나 그 함수 바깥에 위치한 함수의 참조가 환경 레코드에 유지되는 경우 메모리에 유지되는데 이 부분은 클로저와 관련되어 있다.

 

이 글을 공유합시다

facebook twitter kakaoTalk kakaostory naver band
loading