Execution Context & Call Stack

Execution Context & Call Stack
생애 첫 취업 준비. 세상에 이런 일이…
면접에 도움이 될, 가장 기본이 되는 지식을 정리해보려 한다.
근데 이게 TIL이야?

실행 컨텍스트(Execution Context)란 무엇일까? 직역하면 (코드가) 실행되는 환경이다. JavaScript 코드를 실행하는 데 필요한 정보를 담고 있는 하나의 환경을 실행 컨텍스트라 한다.

환경이라고 해서 브라우저 같은 걸 뜻하는 건 아니고, 그저 코드 블록이다. 블록이면 토막 또는 네모난 덩어리인데, 코드 덩어리를 실행하려면 당연히 코드에 쓰인 변수나 함수 등이 무슨 값인지 알아야 하겠다.

그렇다고 한들 아무 코드 덩어리는 아니다. 아래 시점에 실행되는 코드 덩어리만이 새로운 실행 컨텍스트를 생성한다.

  1. 처음 전체 코드가 실행될 때 (전역 실행 컨텍스트, Global Execution Context)
  2. 함수가 호출될 때
  3. eval 함수가 호출될 때
  4. {} 안의 코드가 실행될 때 (Block scope, ES2015에서 const, let과 함께 구현 됨)

이렇게 생성된 실행 컨텍스트는 콜 스택(Call Stack)이라는 곳에 쌓인다. 잘못된 재귀 함수로 무한 루프에 빠지면 콘솔에 Maximum call stack size exceeded를 띄우며 브라우저가 터지는 모습을 보았을 텐데 바로 그 call stack이다. (이때를 그 유명한 Stack Overflow라고 한다)

실행한 코드 블록은 콜 스택에 쌓이고 실행이 끝나면 비워지는데, 스택이란 이름에서 알 수 있듯 LIFO(Last In, First Out) 방식을 따른다.

콜 스택에 실행 컨텍스트가 쌓이고 비워지기를 반복한다.

다른 자료구조도 있는데 왜 스택일까? sub routine의 실행이 끝났을 때 특정 지점으로 제어를 반환하기 위해 routine 생성 시점에 return address를 메모리에 담고 자시고 간에, 그냥 코드를 차례대로 읽어보면 stack이 적절해 보일 것이다.

console.log(first()); // 6

function first () {
    let value = 0;
    const one = 1; // 1
    return second() + one; // 5 + 1
    
    function second () {
        const two = one + 1; // 2
        return third() + two; // 3 + 2
        
        function third () {
            const three = two + 1; // 3
            return value + three; // 0 + 3
        }
    }
}
  1. 처음 전체 코드가 실행되고, first를 호출한다.
  2. first block이 실행되고, value 변수를 선언, second를 호출한다.
  3. second block이 실행되고, third를 호출한다.
  4. third block이 실행되었다.
  5. third block에서 value에 3을 더한 값을 반환한다.
  6. second block으로 돌아와서, third의 반환 값에 2를 더한 값을 반환한다.
  7. first block으로 돌아와서, second의 반환 값에 1을 더한 값을 반환한다.
  8. 결과적으로 first가 반환한 6이 콘솔에 작성된다.

목록의 첫 단어(변수명)만 읽어보면 first, second, third / third, second, first 순인데 LIFO와 딱 맞아 떨어진다. first에서 second를 호출했다고 해서 first의 실행이 끝나지 않고, second가 끝날 때까지 기다렸다 second의 반환 값과 함께 다음 코드 + 30을 실행해야 하기 때문에 스택을 사용하는 것이다.

고개를 우측으로 90도 돌려 코드를 보면, first 위에 second, 그 위에 third가 있는 게 마치 스택처럼 보인다. 일부러 4 spaces 들여쓰기 했는데 아님 말고 ㅎ

콜 스택도 당연히 메모리를 차지하기에 많이 쌓이는 게 좋진 않은데 이는 가능하면 반복문으로 최적화하거나, PTC(Proper tail calls) 등의 방법으로 해결할 수 있다. 다만 후자는 2015년에 언어 사양으로 포함되었으나, 내가 이를 배운 2018년으로부터 5년이 지난 지금도 사파리만 지원한다. (구현하기 어렵나 보다)

What happened to proper tail calls in JavaScript?
Proper tail calls (PTC) is a programming language feature that enables memory-efficient recursive algorithms. I’m not going to belabor the details of proper tail calls or how it pertains to JavaScript, as Dr. Axel’s article already offers those explanations. Instead, I’m going to discuss the evoluti…

이쯤 하면 콜 스택은 마무리하고, 방금은 그냥 넘어갔지만 사실 코드에 이상한 점이 있다.

  • first(상위)에서 선언한 valuesecondthird가 어떻게 알고 있을까? (스코프 체인, Scope Chain)
  • 왜 함수를 선언하기 전에 실행했는데 오류가 발생하지 않을까? (호이스팅, Hoisting)

이는 실행 컨텍스트가 코드를 실행하기 위한 정보를 담고 있기 때문인데 실행 컨텍스트의 구조를 알면 이해할 수 있다.

실행 컨텍스트에는 ES2022 기준, LexicalEnvironment, VariableEnvironment, PrivateEnvironment 등의 컴포넌트로 구성되어있다. 이 글에서는 이들의 타입(또는 추상 클래스)인 Environment Record로 묶어 부르겠다.

실행 컨텍스트는 특별한 구현체가 아닌 ECMAScript의 사양 메커니즘이며, 강제성도 없고 버전이 업데이트되면 스펙도 변경된다. 때문에 검색하면 나오는 수많은 블로그 포스팅이 최근 글임에도 ES5 스펙을 다루고 있거나, 서로 다른 버전의 ES가 섞여 잘못 작성된 경우가 많다. (이를 알고 ‘실행 컨텍스트’ 를 검색할 때 명세에 있는 키워드와 함께 검색한다면 양질의 포스팅을 찾을 수 있다)
각 Environment Record는 하는 일은 약간 다르지만 결국 같은 목적을 위해 존재하는데, let, const, # 등 새로운 변수 선언 방식이 생기고, 스코프를 생성하는 기준도 다양해지면서 ‘필요에 따라 컴포넌트를 여러 variation으로 나누었구나.’ 하고 생각하면 쉽다. (적절한 설명이 맞나?)

Environment Record는 다음과 같은 데이터를 담고 있다.

  • 현재 실행 컨텍스트 내의 식별자들(= 변수, 함수, 매개변수 등)에 대한 정보 (과거에는 이것만 컴포넌트의 Environment Record 필드로 분리되어 있었으나 지금은 후술할 필드와 합쳐졌다)
  • 외부 환경 정보(상위 컨텍스트)에 대한 참조값 (Outer Environment Reference)
  • this가 참조해야 할 객체 (This Binding, this는 이 글에서 설명하지 않는다)

길게 설명할 필요 없이 위의 예제 코드에서 생성된 실행 컨텍스트를 객체로 표현하면 이해하기 쉽다. (정확한 타입이 아닌 가상의 구현체다)

/** @see {@link} https://tc39.es/ecma262/#sec-environment-record-operations */

GlobalEnvironmentRecord = {
  [[ThisValue]]: null,
  [[OuterEnv]]: null,
  first: <Function first>,
}

FirstEnvironmentRecord = {
  [[ThisValue]]: global,
  [[OuterEnv]]: first.[[Environment]], // GlobalEnvironmentRecord
  value: 0,
  one: 1,
  second: <Function second>,
}

SecondEnvironmentRecord = {
  [[ThisValue]]: global,
  [[OuterEnv]]: second.[[Environment]], // FirstEnvironmentRecord
  two: 2,
  third: <Function third>,
}

ThirdEnvironmentRecord = {
  [[ThisValue]]: global,
  [[OuterEnv]]: third.[[Environment]], // SecondEnvironmentRecord
  three: 3,
}

자바스크립트 엔진이 런타임에 코드를 한줄 한줄 다 실행하지 않아도 미리 변수의 존재 여부를 알 수 있는 이유는 실행 컨텍스트가 이런 메타 데이터를 포함하고 있기 때문이다.

이러한 실행 컨텍스트의 구조에 의해 스코프(Scope)라는 개념이 발생하는데, 전역 스코프에서 first 스코프의 valueone에 접근할 수 없고, 마찬가지로 first, second 또한 하위 스코프의 two, three에 접근할 수 없다. 즉 우리가 알던 스코프의 범위는 실행 컨텍스트에 의해 결정되는 것이다.

다만 상위 스코프에서 하위 스코프의 변수에 접근할 수는 없어도 반대로는 가능한데 이를 스코프 체인(Scope Chain)이라 하며, secondthird에서 value에 접근하기 위해 [[OuterEnv]]를 LinkedList처럼 value를 찾을 때까지, 또는 [[OuterEnv]]null일 때까지 탐색하기에 가능한 일이다.

그리고 호이스팅(Hoisting) 또한 이 덕분에 가능하다. 선언문이 뒤에 있어도 이미 존재 여부를 알아 접근할 수 있는 것. 다만 letconst, class는 호이스팅이 불가능한 것처럼 보이는데, 이는 사실 변수가 선언되었으므로 호이스팅도, 접근도 가능하지만, 값이 아직 초기화되지 않아 사용이 불가능한 상태라서 그렇다.

무슨 말일까? 아래 코드를 보자.

function example () {
  // 만약 `const value = 1;` 구문이 실행되기 전,
  // 지금 이 주석 라인에서 실행 컨텍스트의 상태를 들여다 본다면?
  const value = 1;
  return value;
}
example();
exampleEnvironmentRecord = {
  [[ThisValue]]: global,
  [[OuterEnv]]: example.[[Environment]], // GlobalEnvironmentRecord
  value: 응애_나_애기_변수_아직_못써,
}

즉 실행 컨텍스트가 생성되었을 때는, (const를 기준으로) 변수의 값이 아니라 존재 유무만 알고 있다는 것이다.

변수 생성은 Declaration(선언), Initialization(초기화), Assignment(할당), 총 3단계를 거치는데, 키워드별로 변수를 선언문보다 일찍 호출했을 때의 결과는 아래와 같다.

  • function:
    Assignment 단계까지 거치고, 실행 시 새로운 콜 스택에서 함수가 실행됨 (함수 표현식이라면 var, let, const 사용 여부에 따라 달라진다)
  • var:
    Initialization 단계까지 거쳐 undefined를 반환함
  • let, const, class:
    아직 Declaration 단계만 거쳐 오류가 발생함 (Declaration과 Initialization 사이, 오류가 발생하는 이때를 Temporal Dead Zone이라 하며, 여기서 LexicalEnvironment와 VariableEnvironment의 역할이 나뉜다)

이렇게 실행 컨텍스트가 생성되는 시점에 이미 모든 키워드는 선언 단계를 거치지만, 키워드별로 거치는 단계는 서로 다르다.


정리

  • 실행 컨텍스트는 처음 전체 코드가 실행될 때, 함수가 호출될 때, eval 함수가 호출될 때, {} 안의 코드가 실행될 때 생성된다.
  • 실행 컨텍스트는 콜 스택에 쌓인다. 하지만 if, else처럼 스코프 생성만 필요한 실행 컨텍스트는 콜 스택에 쌓지 않는다.
  • 실행 컨텍스트는 ECMAScript의 버전에 따라 다르지만, LexicalEnvironment, VariableEnvironment, PrivateEnvironment 등으로 구성되어있고, 이들은 Environment Record 타입이다.
  • Environment Record는 현재 컨텍스트에서 실행하는 데 필요한 변수, 함수, 매개변수, this 등과 상위 컨텍스트(스코프)에 대한 참조값을 담고 있으며, 이러한 구조 때문에 스코프의 범위가 결정된다.
  • Environment Record에 담긴 정보 덕분에 var, function 등의 선언문이 밑에 있어도 먼저 호출할 수 있다. 다만 letconst로 선언한 변수는 호이스팅이 되었음에도 아직 initialize 되지 않아 오류가 발생한다. (오류가 발생하는 이때를 Temporal Dead Zone이라 한다)
  • Environment Record에 담긴 상위 스코프에 대한 참조값 덕분에 상위 스코프에 선언된 변수에도 접근할 수 있다. (Element.closest()처럼 찾거나 없을 때 까지 탐색한다)

Reference

직접 수강했던 회차인데 채널에서 최신 회차를 보는 것이 좋겠다.