JavaScript

실행 컨텍스트 톺아보기: 기본 개념, 메모리 사용 방식

GoJay 2024. 10. 19. 14:35

실행 컨텍스트란?

실행 컨텍스트는 자바스크립트 코드를 실행하기 위해 필요한 다양한 환경 정보와 데이터들의 상태를 관리하기 위한 자바스크립트 엔진의 동작 메커니즘이다. 자바스크립트로 작성된 모든 코드는 자바스크립트 엔진에서 관리하는 '실행 컨텍스트'를 통해서만 동작할 수 있고, 그런 의미에서 실행 컨텍스트를 제대로 알아야지만 자바스크립트의 핵심적인 개념들(호이스팅, 클로저, 스코프 체인 등)을 제대로 이해할 수 있다.

가장 먼저 실행 컨텍스트에 대한 이해를 방해했던 요인 중 하나가 바로 '실행 컨텍스트에 대한 정의'였다. 많은 자료에서 실행 컨텍스트를 '코드 실행을 위한 환경 정보들을 모아놓은 객체'라고 표현한다. 여기서 '객체'라는 표현이 처음 공부할 때 참 많은 오해를 불러일으켰다. 대표적으론 '자바스크립트의 객체 데이터 타입인 거면 직접 접근할 수 있고, 실행 컨텍스트의 프로퍼티와 메서드에 접근해서 활용이 가능한 건가?'라는 궁금증이 생겼었다.

결론부터 말하면, 이건 실행 컨텍스트에 대한 잘못된 이해였다. 실행 컨텍스트는 데이터 타입을 갖는 실제 데이터인 게 아니라, 자바스크립트 엔진에서 코드 실행을 관리하는 방법론에 좀 더 가까운 것 같다. 해당 과정에서 관리해야 하는 환경 정보와 데이터 상태를 키-값을 대응시키는 구조로 관리하고, 이러한 자료구조가 객체의 데이터 관리 구조와 유사해서 '객체'라는 표현이 사용된 것으로 예상된다. 하지만, 자바스크립트의 실제 데이터 타입으로 존재해서 직접 값에 접근할 수 있는 건 아니다.

다시 돌아와서, 실행 컨텍스트는 '다양한 환경 정보와 데이터들의 상태를 관리하는 메커니즘'이고, 여기서 관리되는 환경 정보와 데이터들로는 활용 가능한 식별자(변수, 함수 등)의 참조 및 상태에 대한 정보, 실행 컨텍스트에서 참조 가능한 상위 스코프에 관한 정보(스코프 체인), 함수 객체가 가리키는 this에 대한 정보 등이 있다.

실행 컨텍스트 개념 이해하기

실제 코드로 실행 컨텍스트를 살펴보면서 개념을 확장해 보겠다.

실행 컨텍스트는 전역 실행 컨텍스트와 함수 실행 컨텍스트, 두 가지로 나뉜다. 전역 실행 컨텍스트는 전역 상태가 시작할 때 생성되고, 함수 실행 컨텍스트는 함수가 호출돼서 동작을 시작할 때 생성된다. 아래 코드를 살펴보자.

let x = 10;

function myFunc1() {
    // 전역과 myFunc1()은 다른 실행 컨텍스트에서 동작하기 때문에 x는 재선언된 게 아니다
    let x = 20; 

    function myFunc2() {
        // myFunc1()과 myFunc2()는 다른 실행 컨텍스트에서 동작하기 때문에 x는 재선언된 게 아니다
        let x = 30; 
        console.log(`myFunc2의 x값: ${x}`); // 30
        x++; // myFunc2()에 선언한 x의 값이 31로 올라감
    }

    myFunc2();
    // myFunc2()의 실행 컨텍스트와 상관 없고, myFunc1()에 선언한 x에 할당한 20이 출력됨
    console.log(`myFunc1의 x값: ${x}`); 
}

myFunc1();
console.log(`전역의 x값: ${x}`); // 10

x의 값이 실행 컨텍스트에 따라 다르게 할당되었다.

위에 예제에서, 전역 - myFunc1() - myFunc2() 세 곳의 블록 내에 각각 let x로 변수를 선언하고 각각 다른 값을 할당했다. let은 재선언이 안 되는 변수이기 때문에 원래는 위의 상황에서 에러가 나야 할 것 같다. 하지만, 전역과 함수로 구분된 세 영역은 서로 다른 실행 컨텍스트에서 동작하기 때문에, x라는 이름의 식별자는 let 변수 선언자로 재선언된 게 아니다(각 실행 컨텍스트에서 최초로 선언됐다). 그래서 에러가 발생하지 않는다(에러가 나진 않지만, 코드 가독성이 나빠지기 때문에 이렇게 네이밍하는 걸 지양해야 한다). 그리고, console.log()로 값을 출력했을 때 각자의 실행 컨텍스트에 맞게 값이 다르게 할당된 것도 확인된다.

이렇게, 실행 컨텍스트는 코드가 실행되는 환경의 구분점을 잡아준다. 전역이라는 환경과 myFunc1()이라는 환경은 자바스크립트 엔진 기준으로는 서로 다른 환경인 셈이다. 그리고, 이렇게 실행 환경을 구분해서 동작함을 통해 개발의 안정성(변수의 중복 선언으로 인한 에러 방지 등), 메모리 관리의 효율성(함수가 호출돼야만 메모리 공간을 점유되고, 실행 컨텍스트가 끝나면 점유한 메모리를 해제해서, 메모리를 효율적을 관리할 수 있음) 등의 장점을 얻을 수 있다.

또, 자바스크립트에서 굉장히 중요한 개념인 비동기 처리, 이벤트 루프 등이 동작하는 데 있어서도 함수 단위의 실행 컨텍스트 관리가 유효하다고 한다. 비동기의 큰 컨셉이 하나의 작업이 처리되는 데 시간이 걸리면 그 작업 완료되기를 기다리는 동안 다른 작업을 먼저 수행하고, 앞선 작업이 완료되면 다시 해당 부분으로 돌아가 작업을 이어가는 것이다. 그리고, 이러한 메커니즘을 구현하는 데 있어서도 비동기 함수의 실행 컨텍스트를 따로 관리하고, 필요시 구분된 실행 컨텍스트를 복원하는 작업이 중요하다고 한다(비동기 처리에 대한 공부가 부족한 관계로, 이후에 추가적인 학습을 해본 후 다시 포스트 해야겠다).

이렇게, 실행 컨텍스트는 전체 코드를 실행 단위를 쪼개서 구분해 주고, 각 실행 단위(스코프라고 표현하는 개념에 해당하는 것 같다)마다 코드 실행에 있어 필요한 정보를 관리하고(저장, 상태 업데이트 등), 실행 컨텍스트의 필요한 동작 수행 완료 시 메모리 해제를 통해 메모리 관리까지 수월하게 해 준다.

실행 컨텍스트의 메모리 사용

실행 컨텍스트가 생성되면 실행 컨텍스트는 콜 스택(Call Stack)의 메모리 공간을 점유한다. 이때, 실행 컨텍스트가 생성되는 순서에 따라 스택의 아래에서위로 차곡차곡 쌓이고, 콜 스택의 가장 위에 있는 실행 컨텍스트가 현재 실행 중인 실행 컨텍스트가 된다. 해당 실행 컨텍스트가 종료되면 콜 스택 메모리에서 팝(pop)되고 그 아래에 위치한 실행 컨텍스트로 코드의 흐름이 이동하게 된다.

실제 실행 컨텍스트가 메모리 사용 과정을 이미지로 자세하게 살펴보자(확인 과정에서 위에서 사용한 예제 코드를 다시 활용하겠다)

let x = 10;

function myFunc1() {
    let x = 20; 

    function myFunc2() {
        let x = 30; 
        console.log(`myFunc2의 x값: ${x}`);
        x++; 
    }

    myFunc2();
    console.log(`myFunc1의 x값: ${x}`); 
}

myFunc1();
console.log(`전역의 x값: ${x}`);

해당 코드가 처음 시작되면 먼저 전역의 실행 컨텍스트가 생성된다. 전역은 모든 프로그램의 동작에서 가장 최초로 생성되는 실행 컨텍스트이다. 실행 컨텍스트가 생성되면 콜 스택의 가작 밑에서부터 메모리 공간을 점유하며, 해당 실행 컨텍스트에 선언된 식별자 및 코드 동작을 위해 필요한 환경 정보를 메모리에 얹는다(식별자 및 환경 관리에 대한 내용은 다른 포스트에서 좀 더 자세히 남겨보겠다).

최초 전역이 실행될 때의 콜 스택 메모리

코드의 진행 순서 상, 최초 let x = 10;이 선언된 다음, 함수의 정의가 있는 부분은 건너뛰고 함수 호출이 있는 myFunc1(); 부분을 실행한다.

let x = 10;

// 코드 실행 흐름 상, 함수 부분은 호출이 된 다음 동작하게 된다.
// function myFunc1() {
//     let x = 20; 
// 
//     function myFunc2() {
//         let x = 30; 
//         console.log(`myFunc2의 x값: ${x}`);
//         x++; 
//     }
//
//     myFunc2();
//     console.log(`myFunc1의 x값: ${x}`); 
// }

myFunc1();
// console.log(`전역의 x값: ${x}`);

myFunc1(); 부분이 호출되면, 해당 시점에 myFunc1 함수의 실행 컨텍스트가 생성되고 콜 스택에 추가된다. 이때, myFunc1 함수의 실행 컨텍스트는 전역 실행 컨텍스트의 위에 쌓이게 되고(상위 번호 메모리 주소 공간을 확보하게 되고), 코드 실행 관리의 주체가 전역에서 함수 실행 컨텍스트로 이동한다(참고로, 메모리 공간이 두 개로 나눠지는 게 아니라, 하나의 메모리가 시간의 순서에 따라 아래 이미지와 같이 변하는 걸 표현했다).

myFunc1 함수의 실행 컨텍스트가 코드 흐름 제어를 관리하는 순간부터는, 가장 먼저 해당 실행 컨텍스트의 환경에 기록된 정보와 데이터들을 바탕으로 코드가 실행된다. 그래서, let x라는 변수를 전역과 함수 내에서 공통되고 선언하였지만, myFunc1 함수 입장에선 자신의 실행 컨텍스트에 해당 변수가 없었기 때문에 최초 선언으로 간주한다. 그리고, 함수 내에서 선언한 변수 x와 전역에서 선언한 변수 x는 서로 다른 변수로 간주되어 각각 다른 값을 할당하는 게 가능하다(변수는 메모리 주소를 가리키는 식별자다. 이름이 같더라도, 서로 다른 메모리 주소를 가리키면 다른 변수인 거다).

물론, 실행 컨텍스트 내에 변수 선언이 없더라도 상위 스코프에 변수 선언이 있었으면 스코프 체인을 통해 그 값에 접근하여 참조가 가능하다. 이 또한 실행 컨텍스트 렉시컬 환경에 저장된 환경 정보 outerLexicalEnvironmentReference를 통해 가능한 것이다. 이에 대해선 이후 포스트에서 다시 살펴보겠다.

이제, myFunc1 함수 내에서의 코드 실행이 진행된다. 마찬가지로, 호출되지 않은 함수는 코드 실행 순서에서 건너뛰어지기 때문에, 함수 내부의 코드는 아래 순서로 진행된다.

function myFunc1() {
    let x = 20; 

    // function myFunc2() {
    //     let x = 30; 
    //     console.log(`myFunc2의 x값: ${x}`);
    //     x++; 
    // }

    myFunc2(); // 여기서 다음 함수고 호출되는 시점에 myFunc2 함수의 코드가 실행된다.
    // console.log(`myFunc1의 x값: ${x}`); 
}

myFunc2 함수가 호출되면 해당 함수의 실행 컨텍스트가 생성되고 다시 콜 스택에 쌓이게 된다. 이때 myFunc2 함수의 실행 컨텍스트는 myFunc1 함수의 실행 컨텍스트 위로 쌓이게 된다.

myFunc2 함수의 실행 컨텍스트가 생성되고 콜 스택에 상위 메모리를 점유하게 되면, 그 시점부터의 코드 실행 관리의 주체가 myFunc2 함수의 실행 컨텍스트로 바뀌게 된다. 해당 실행 컨텍스트에서도 x라는 변수의 선언은 최초로 진행됐기 때문에, 해당 실행 컨텍스트도 문제없이 변수를 선언하고 값을 할당한다. 물론, 해당 변수는 상위 스코프의 변수 x와 다른 메모리 주소를 가리키는 완전히 다른 변수이다.

이에 myFunc2 함수 내의 코드가 순서대로 실행되면서 console.log();로 값을 출력하고(myFunc2의 x값: 30이라는 문자열이 출력된다), 그다음 x++;가 연산이 돼서 myFunc2 실행 컨텍스트의 x는 값이 31로 변경된다.

해당 코드까지 실행이 완료되고 }를 만나게 되면 해당 실행 컨텍스트의 모든 코드가 종료됐음을 의미한다(정확한 순서로는, 콘솔에 출력하는 함수를 실행하는 실행 컨텍스트가 추가로 생성됐다가 소멸되고 그 다음 함수의 실행 컨텍스트가 종료된다. 이는 예시에 있는 다른 모든 함수들에서 동일하다). 해당 시점에서는 myFunc2의 실행 컨텍스트가 콜 스택에서 제거되고, 해당 실행 컨텍스트에서 선언된 식별자(변수, 함수 등)가 외부에서 더 이상 참조되지 않을 경우 메모리에서 해제된다(단, 외부에서 특정 실행 컨텍스트의 값을 참조할 경우엔 해당 값은 메모리에서 해제되지 않는다. 해당 현상을 '클로저'라고 한다).

이제, 코드의 실행 흐름 제어 권한은 myFunc1으로 돌아왔다. myFunc1은 함수 myFunc2 함수를 호출하는 부분까지 코드 실행을 진행시켰기 때문에, 바로 이어서 나오는 console.log() 명령을 실행한다. myFunc1의 실행 컨텍스트에서 관리하던 변수 x의 값은 myFunc2 함수에서의 변수 x와 전혀 상관없는 독립 관계이기 때문에, x 값은 그대로 20이고 myFunc1의 x값: 20이 출력된다.

해당 console.log() 명령이 완료되면 다시 해당 실행 컨텍스트의 종료를 의미하는 }와 만나게 되고, myFunc1의 실행 컨텍스트는 콜 스택 메모리에서 제거된다(동시에, 해당 실행 컨텍스트에서 관리하던 다양한 식별자들의 정보도 외부에서 참조가 없는 한 메모리에서 제거된다). 그리고, 마지막 남은 전역 실행 컨텍스트로 돌아오게 된다.

다시 코드 실행의 제어 권한을 넘겨받은 전역 실행 컨텍스트는 마지막 남은 명령인 console.log() 명령을 수행한다. 해당 실행 컨텍스트의 변수 xmyFunc1myFunc2 실행 컨텍스트에 있는 변수 x와는 완전히 다른 변수다(변수명만 같은 거고, 식별자가 가리키는 메모리 주소는 완전히 다르다). 그래서, 전역 실행 컨텍스트에 선언된 변수 x의 값은 10이 되고, 전역의 x값: 10이 잘 출력된다.

이렇게 코드가 차례대로 실행되면서 실행 컨텍스트가 생성되면서 차곡차곡 콜 스택에 쌓이는 식으로 메모리를 점유하고, 가장 상위 메모리에 있는 실행 컨텍스트가 코드 실행에 필요한 환경과 정보를 관리하는 주체가 된다. 이런 방식으로 실행 컨텍스트는 코드의 실행 순서를 관리한다.

참고로, 실행 컨텍스트는 항상 하나(콜 스택 가장 위에 있는 실행 컨텍스트, 즉 실행 중인 실행 컨텍스트)만 동작한다. 그 의미는, 자바스크립트는 싱글 스레드로 동작하고, 동시에 두 개 이상의 태스크를 동작시킬 수 없다는 것이다(두 개 이상의 태스크가 동시에 돌아가려면 한 번에 두 개 이상의 실행 컨텍스트가 생성돼서 돌아가야되고, 멀티 스레드로 동작해야 한다). 그런 이유로, 자바스크립트는 병렬적인 작업 처리를 위해 비동기 작업 처리 방식을 사용한다(해당 내용도 중요한 부분이고, 이후에 별도 포스트로 다뤄보려 한다).

실행 컨텍스트와 데이터 관리

원시 타입과 참조 타입이라는 포스트에서 살펴봤듯이, 자바스크립트의 모든 데이터는 가장 처음 스택에 필요한 크기만큼 메모리 공간을 확보해서 값을 저장한다. 원시 타입이라면 값 자체를 바로 저장하고, 참조 타입 데이터는 힙에 데이터를 저장한 다음 해당 데이터의 메모리 주소를 스택에 저장한다고 했다.

여기서 표현했던 '스택'의 정체가 바로 '실행 컨텍스트가 쌓이는 콜 스택'이다. 자바스크립트의 모든 데이터는 해당 데이터가 선언된 실행 컨텍스트의 렉시컬 환경에서 관리된다. 그러니까, 데이터가 저장되던 스택이라는 공간도 실행 컨텍스트가 쌓인 콜 스택의 어느 부분에 저장된다는 얘기다.

실행 컨텍스트의 콜 스택 사용과, 실행 컨텍스트에 있는 데이터의 메모리 관리를 대략적으로 표현해보면 아래와 같다.

let x = 10;

function myFunc() { 
    let x = 20;
    let myObj = {};

    myObj.myNum = x;

    return myObj;
}

console.log(myFunc()) // {name: 'Jay', age: 20}

그림에서 표현했듯이 모든 데이터들은 자신이 선언된 실행 컨텍스트 안에서 생성되고 관리된다. 해당 과정에서, 실행 컨텍스트 사이의 구분을 철저하게 지키기 때문에 let xmyFunc 실행 컨텍스트의 let x는 서로 완전히 다른 값을 가리키게 된다(실행 컨텍스트의 데이터 관리에 대해서는 렉시컬 환경에 대해 조금 더 자세히 살펴보면서 내용을 기록해보려 한다).

실행 컨텍스트가 실행되는 단계는 두 단계로 나뉜다(실행 컨텍스트는 모든 JavaScript 코드 실행을 관리하기 때문에, JavaScript의 코드 실행 단계가 두 단계로 나뉜다고 해도 무방할 것 같다). 첫 번째는 평가 단계고, 두 번째는 실행 단계다. 실행 컨텍스트는 평가 단계에서 생성되고, 평가 과정에서 실행 컨텍스트가 관리해야 할 데이터(식별자)가 어떤 것들이 있는지에 대해 대략적으로 수집을 해둔다(이 과정에서 호이스팅이 발생한다). 그리고, 해당 실행 컨텍스트를 실행하는 과정에서 대략 어느 정도의 메모리 사용이 있을 것 같은지를 예상하고 실행 컨텍스트의 메모리 크기를 추정해 콜 스택에 쌓는다.

실행 컨텍스트가 평가와 실행 단계가 구분되어 동작함으로써 해당 컨텍스트에서 관리해야 할 데이터에 대한 정보를 사전에 파악하고, 메모리를 효율적으로 사용하기 위한 준비를 한다. 그리고, 그 과정에서 호이스팅이 발생한다. 이것이 호이스팅이 발생되는 큰 맥락이었다(해당 블로그에 있는 내용을 참고하였습니다).

즉, 실행 컨텍스트는 스택에 데이터를 저장하고 관리하는 주체이고, 아무리 같은 이름으로 변수를 선언해도 실행 컨텍스트마다 고유하게 정보를 관리하기 때문에 중복 선언이 되지 않는다. 그리고, 해당 정보 관리 과정을 효율적으로 동작하기 위해 사전에 해당 실행 컨텍스트에서 사용될 데이터들의 정보를 파악하는 과정(호이스팅)을 거친다.

결론

실행 컨텍스트는 코드의 실행을 관리하고, 전역과 함수 단위로 생성된다. 실행 컨텍스트 기준으로 코드 실행을 위해 필요한 환경과 데이터 상태 정보가 관리된다. 실행 컨텍스트는 코드 실행 순서에 따라 콜 스택에 생성되며, 후입선출(LIFO; Last In, First Out) 방식으로 관리된다. 실행 컨텍스트 내부에서 해당 컨텍스트에서 선언된 식별자 정보와 데이터를 관리하기 때문에, 원시 타입과 참조 타입 데이터가 최조에 저장되는 스택이라는 공간의 실체가 바로 실행 컨텍스트의 콜 스택 내부인 것이다.

살펴본 내용은 실행 컨텍스트의 큰 개념 중 아주 일부다. 실행 컨텍스트는 자바스크립트의 동작 방식과 다양한 주요 개념을 이해하는 데 있어 아주 중요한 개념이다. 좀 더 정확하게 이해할 수 있도록 다른 내용들도 잘 공부해 봐야겠다. 끝.