내가 보려고 만든 개발 공부 일지

자바스크립트 - 실행 컨택스트(Execution Context) 와 렉시컬 환경(Lexical Environment) 본문

Javascript

자바스크립트 - 실행 컨택스트(Execution Context) 와 렉시컬 환경(Lexical Environment)

kwangsunny 2022. 3. 9. 02:12

자바스크립트를 잘 다루기 위해선 알아야 할 개념들이 정말 많다.

기본적인 문법부터 시작해서 자료형, 함수, 프로토타입, 모듈, 비동기 처리 등등... 

자바스크립트 공부는 끝이 없는것 같다.

 

이런 여러 개념들중 자바스크립트가 어떻게 동작하는지 이해하기 위해 반드시 알고 있어야 할

가장 핵심적인 개념이 있는데, 바로 이번 글의 주제인 실행 컨택스트(Excution Context) 이다.  

 

 

실행 컨택스트란 ?

실행 컨택스트는 함수의 실행, 호이스팅, 렉시컬 환경, 클로저 같은 개념들을 관통하는 하나의 큰 개념이다.

그래서 실행 컨택스트란 무엇인가?

실행 컨택스트는 현재 실행중인 코드에 대한 세부 정보(제어 흐름의 위치, 선언된 변수와 함수, this, arguments 등) 를 담고있는 데이터 구조이다. (이 구조에 대해선 조금 후에 더 살펴볼것이다)

 

실행 컨택스트의 종류는 아래와 같이 두 가지가 있다.

1. Global Execution Context  -  스크립트가 처음 실행될때 생성된다. 

2. Function Execution Context  -  함수가 호출될때 생성된다.

 

(eval 함수로 코드를 실행했을때도 해당 코드의 실행 컨택스트가 만들어지지만 eval 함수는 보안상 이유로

사용하지 않도록 권고되고, 오래된 레거시에서만 가끔 볼 수 있는 코드이므로 제외한다.) 

 

이렇게 생성된 Global Execution Context 와 Function Execution Context는 실행 컨택스트 스택 이라는 자료구조에

저장 되고 관리된다.

이 스택은 호출스택(call stack) 이라고도 부르고, '스택' 이므로 후입선출 방식으로 컨택스트를 push / pop 한다.

 

Global Execution Context 는 스크립트 실행 시 가장 처음 생성되므로 항상 스택 가장 아랫부분에 들어가게 된다.

이후에 코드를 실행하면서 함수가 호출되면 해당 함수의 실행 컨택스트가 만들어지고

Global Execution Context 위로 push 된다.

 

그럼 이제 제어의 흐름은 Global Execution Context 에서 함수의 실행 컨택스트로 넘어가고,

함수가 리턴되어 끝나게 되면 해당 함수의 실행 컨택스트가 스택에서 pop 되고 메모리에서 제거된다.

Global Execution Context 는 앱이 종료되면 스택에서 pop 되고 메모리에서 제거된다.

 

아래 코드를 보면서 실행 컨택스트 생성부터 스택에 push, pop 되는 과정을 살펴보자.

<script>
    // 스크립트 실행 -> Global Execution Context 가 생성되고 스택에 push된다.
    
    function A(){
    	B(); // 함수B 의 실행 컨택스트가 생성되고 스택에 push.
    }
    
    function B(){
    	console.log('done!'); 
    }   
    
    A(); // 함수A 의 실행 컨택스트가 생성되고 스택에 push.
</script>

 코드의 진행 순서를 그림으로 표현하면 아래와 같다.

실행 컨택스트와 호출 스택

 

 

실행 컨택스트와 재귀함수

스택은 한계가 있어서 이 한계를 초과해서 실행 컨택스트가 push 되면, 

Maximum call stack 에러가 발생한다.

이 에러는 주로 재귀함수를 구현할때 종료 조건을 잘못 설정해줘서 맞딱뜨리곤 하는 녀석이다.

 

재귀함수란 호출된 함수가 자기 자신을 또 호출하면서 반복작업을 수행하는 함수를 말한다.

아래 예제는 1부터 인자로 받은 n까지 모두 더해주는 함수이다.

// 1 ~ n 까지 합을 구하는 함수
function sumAll(n){
    if(n == 1) return n;  // 종료 조건
    return n + sumAll(n - 1);   // 자신 호출(재귀호출)
}

sumAll(3); // 6

만약 위에서 종료조건을 빼고 함수를 실행하면 아래와 같은 에러가 발생할 것이다.

호출 스택 초과 에러

 

그럼 이제 위 코드가 실행될때 실행 컨택스트가 어떤 식으로 쌓이는지 그림으로 살펴보자.

재귀호출 시 실행 컨택스트

예제 코드에서 동일한 sumAll 함수를 계속 호출하고 있다. 

그래서 sumAll 의 실행 컨택스트는 하나만 만들어지는게 아닐까 하는 생각을 할 수 있는데,

동일한 함수를 여러번 호출하는것은 실행 컨택스트와 아무 상관이 없다.

위 그림에서 볼 수 있듯이 sumAll 함수는 호출될때 마다 독립적인 실행 컨택스트가 생성되고 스택에 push 된다.

 

이렇게 실행 컨택스트는 코드가 실행되는 순간의 정보들을 담고 있고 스택에 차례대로 쌓이고 제거되고를 반복하기 때문에 재귀의 깊이가 아무리 깊어져도 자바스크립트 엔진은 현재 함수가 종료되고 다음에 수행할 부분이 어디인지 기억할 수 있는 것이다.     

 

 

참고로 재귀함수는 꼭 필요한 경우에만 사용해야 한다.

아래 예제는 sumAll 함수를 일반적인 반복문으로 작성한 것이다.

function sumAll(n){
    var sum = 0;
    for(var i=1; i<=n; i++){
        sum += i;
    }
    return sum;
}

재귀방식으로 작성하면 sumAll의 실행 컨택스트가 n 의 수만큼 만들어지고 스택에 push 된다.

이는 n 만큼 메모리 공간을 차지하게 된다는 뜻인다. 

반면 위처럼 반복문으로 작성한 sumAll 함수의 실행 컨택스트는 한 번만 만들어지기 때문에 재귀호출보다 메모리 공간이 훨씬 절약된다.

따라서 재귀함수는 깊이를 알 수 없는 트리형태의 자료구조를 순회할때 와 같이 반복문을 쓰기 어려운

상황에서만 사용하는게 좋다. 

 

 

렉시컬 환경 (Lexical Environment)

지금까지는 실행 컨택스트가 뭔지, 또 코드 흐름에 따라 언제 생성되고 사라지는지에 대해 살펴보았는데,

잠시 다른 주제에 대해 얘기할 필요가 있다.

글 초반에, 실행 컨택스트란 현재 실행중인 코드에 대한 세부 정보를 저장해놓은 내부 데이터 구조라 했었다.

이제 이 구조에 대해 살펴보려고 하는데, 그러기 위해선 렉시컬 환경 이라는 개념을 먼저 알고있어야 한다.

 

1. 스크립트 전체

2. 코드블록 {...}

3. 호출된 함수  

 

이 세 녀석들은 렉시컬 환경 이라는 이론상 객체를 가지고 있다.

(여기서 이론상 객체 라는 말은 렉시컬 환경을 설명하기 위해 객체라는 표현을 사용한것일 뿐이므로,

실제 코드로 렉시컬 환경에 접근할 수는 없다.) 

 

그리고 이 렉시컬 환경은 두 부분으로 구성되어있다.

1. 환경 레코드(Environment Record) - 현재 실행중인 코드 환경의 this값과 선언된 모든 변수와 함수가 저장되는 곳

2. 외부 렉시컬 환경 (Outer Lexical Environment) - 외부 렉시컬 환경의 참조값 (외부 변수에 접근할 수 있게 된다)

렉시컬 환경 구성 요소

 

환경 레코드에 선언된 변수가 저장되어 있다는말은 그 변수에 다른 값을 할당 했을때,

환경 레코드의 변수값이 할당한 값으로 바뀌게 된다는 뜻이다.

 

아래 코드에는 어떤 렉시컬 환경이 존재하는지 살펴보자.

<script>
    // (A)

    let name1 = 'kwang'; // (B)
    var name2 = 'sunny';

    function test(){
        let msg = 'Hi~';
        console.log(`${msg} ${name1} ${name2}`); // (C)
    }
    
    test(); // (D)
</script>

위 코드에는 두 개의 렉시컬 환경이 존재한다.

1. 전체 스크립트의 렉시컬 환경 (Global Lexical Environment)

2. 함수test 의 렉시컬 환경

두 렉시컬 환경의 관계를 그림으로 표현하면 아래와 같다.

예시 코드의 렉시컬 환경

실행중인 함수 내에서 어떤 변수가 나타나면 제일먼저 자신의 렉시컬 환경의 환경 레코드를 찾는다.

환경 레코드 안에 변수가 존재하지 않는다면, 외부 렉시컬 환경의 참조값을 통해 외부의 렉시컬 환경에서

해당 변수를 찾게되고 이 과정을 변수를 찾을때까지 반복한다.

만약 가장 바깥쪽 렉시컬 환경 (Global Lexical Environment) 에서도 이 변수를 찾지 못하면 Reference 에러가 발생한다.

전역 렉시컬 환경은 자신이 가장 바깥이기 때문에 외부 렉시컬 환경 값은 null 이다.

(외부 렉시컬 환경에서 변수값을 찾는다? 클로져의 냄새가 나는데, 그렇다.. 이것은 또 클로져와 관련된 개념이다)

 

 

위 코드의 실행 순서는 다음과 같다.

1. (A) 에서 전역 렉시컬 환경이 만들어진다.

2. (D) 에서 test 함수가 실행되고 test 함수의 렉시컬 환경이 만들어진다.

3. (C) 부분에서 사용되는 변수들을 찾기위해 자바스크립트 엔진은 test의 렉시컬 환경의 환경 레코드를 뒤진다.

4. msg 는 존재하지만, name1과 name2는 존재하지 않는다.

5. Outer Lexical Environment 를 통해 외부 렉시컬 환경에 접근하여 name1, name2 를 찾는다. 

 

만약 (A) 에서 console.log(name1) 혹은 console.log(name2) 을 실행하면 무엇이 출력될까?

1. name1 출력 : reference 에러 발생!

2. name2 출력 : undefined 

 

1과 2의 차이는 변수 선언을 let, var 둘 중 무엇으로 했냐의 차이다.

렉시컬 환경이 만들어질때 모든 변수와 함수가 환경 레코드에 저장된다고 했다.

그래서 자바스크립트 엔진은 let name1 의 존재를 알고는 있다. 하지만, let은 (const도 동일) 변수가 선언되기 이전엔 uninitialized 라는 상태를 가지고 있어서 이때 접근하면 에러가 발생하게되고 변수가 선언된 이후부터 접근이 가능하다.

반면에 var로 선언된 변수는 렉시컬 환경에 올라가자마자 undefined로 초기화가 된다.

그래서 name2는 에러가 나지 않고 undefined 가 출력되는 것이다.

 

참고로 함수 선언문으로 정의된 함수는 렉시컬 환경에 생성되자마자 메모리에 올라가기 때문에 바로 사용할 수 있다. 

(함수 표현식으로 선언된 함수는 제외)

function fn1(){} // 함수 선언문
let fn2 = function(){} // 함수 표현식

이런 현상은 바로 다음에 나올 실행 컨택스트의 생성, 실행 단계 부분에서

호이스팅이 발생하는 이유와 관련해서 더 살펴볼 것이다.  

 

마지막으로 꼭 집고 넘어가야할 것은,

외부 렉시컬 환경은 함수가 실행되는 시점이 아닌 선언된 시점의 외부 환경을 가리킨다는 사실이다.

let a = 'kim';

function fn1() {
  let a = 'sunny';
  fn2();
}

function fn2() {
  console.log(a); 
}

fn1(); // (가)

(가) 에서 출력되는 값은 'kim' 이다.

왠지 fn2 가 fn1 에서 호출되었기 때문에 fn1의 a값인 'sunny' 를 참조할 것 같은데 그렇지 않다.

외부 환경 참조는 함수가 선언된 시점의 외부 환경이므로 fn2 의 외부 렉시컬 환경은 전역 렉시컬 환경이 되므로

스크립트의 a 값을 가져오게된다.

 

 

실행 컨택스트의 생성 단계 

다시 실행 컨택스트로 돌아와서, 실행 컨택스트는 스크립트가 처음 실행될때 생성되는

Global Execution Context 와 함수가 호출될때 생성되는 Function Execution Context 가 있다고 했다.

그럼 이 실행 컨택스트들은 어떻게 생성되는 것일까.

 

우선 실행 컨택스트의 내부 구조를 보자.

실행 컨택스트 내부 구조

위 그림과 같이 실행 컨택스트는 두 부분으로 이루어져있다.

바로 이전에 설명했던 렉시컬 환경이 여기에 들어가게 된다. 

사실 Lexical Environment 는 더 Variable Environment 라는 녀석이 하나 더 있는데 Lexical Environment와

큰 차이가 없으므로 그냥 하나의 Lexical Environment로 볼것이다.

 

실행 컨택스트 두 단계를 거쳐 생성된다.

 

1. 생성 단계 (Creation Phase)

렉시컬 환경이 생성되고, this 바인딩이 이뤄진다. 

렉시컬 환경이의 환경 레코드에 변수와 함수가 저장된다.

함수 선언문으로 선언된 함수는 바로 메모리에 올가게 되고, var 로 선언된 변수는 undefined 가 할당되고, let, const 로 할당된 변수는 uninitialized 상태이다.  

Global 실행 컨택스트일 경우엔 window 에 전역 오브젝트 가 할당되고 this엔 window 가 할당된다.

Function 실행 컨택스트일 경우엔 window 할당은 없는 대신 argument 객체가 초기화 된다.

 

이렇게 모든 변수가 생성 단계에서 렉시컬 환경에 초기화 되기 때문에 자바스크립트 엔진은 변수들의 존재를 모두 인지하게 되고, 이것이 호이스팅(hoisting) 이 발생하는 이유가 된다.

(호이스팅 - 변수나 함수가 선언 전에 접근 가능한 현상으로, 그게 마치 선언된 변수들을 모두 최상단으로 끌어올리는것(hoisting) 처럼 보인다 하여 지어진 이름) 

 

2. 실행 단계 (Execution Phase)

생성 단계에서 결정된 렉시컬 환경을 가지고 있는 상태로, 코드를 한줄씩 실행해 내려간다.

그 과정에서 변수에 값을 할당하거나 하면 렉시컬 환경의 해당 변수 값이 변경된다.

 

이렇게 두 단계를 거쳐서 생성된 실행 컨택스트들은 앞서 살펴봤던 대로

코드 흐름에따라 호출 스택에 의해 관리된다. 

 

 

 

주제가 무겁고 어려운 내용이라 완벽하게 이해하려면 시간이 좀 더 필요할것 같다...

그래도 자바스크립트의 여러 개념들을 서로 연관 지어 공부할 수 있는 주제라

마스터하게 된다면 실력적으로 크게 도약할 수 있을것 같다. 

 

 

 

잘못된 내용은 댓글 남겨주세요 :)

 

 

 

참고자료

https://ko.javascript.info/closure

 

변수의 유효범위와 클로저

 

ko.javascript.info

https://betterprogramming.pub/execution-context-lexical-environment-and-closures-in-javascript-b57c979341a5

 

Execution Context, Lexical Environment, and Closures in JavaScript

Advanced JavaScript concepts you should know

betterprogramming.pub

 

 

 

 

 

 

 

 

 

Comments