[TIL] JavaScript의 실행 컨텍스트

August 27, 2021

그동안 스코프, 호이스팅, 클로저, this 바인딩, 비동기 처리 등에 대해서 알아보았다. 실행 컨텍스트는 이 모든 개념들의 동작 원리를 담고 있는 자바스크립트의 핵심이라고 할 수 있다. 실행 컨텍스트에 대해 알아보며 그동안 공부했었던 개념들의 동작 원리를 제대로 이해해보자.

자바스크립트 엔진의 소스코드 처리

자바스크립트 엔진은 개발자가 작성한 소스코드를 평가실행이라는 두 단계로 나누어 처리한다. 각 단계에서는 어떤 일이 일어나고, 그로 인해 자바스크립트는 어떤 특징을 갖게 될까.

전역 객체 생성

코드가 평가되기 전에 가장 먼저 전역 객체 window가 생성된다. 전역 객체에는 빌트인 프로퍼티와 빌트인 함수, 표준 빌트인 객체 등이 추가된다. 브라우저 환경인 경우에는 Web API(DOM, BOM, canvas, fetch 등등)도 포함된다.

평가 단계

평가 단계에서는 실행 컨텍스트가 생성된다. 또한 변수 선언문과 함수 선언문이 실행되어 해당 실행 컨텍스트가 관리하는 스코프에 등록된다.

  • 변수 선언문 var, let, const
    변수 선언문은 평가 단계에서 실행되어 스코프에 해당 변수가 등록된다. 이때 var 키워드로 선언된 변수는 등록 후 ‘undefined’로 초기화 된다. 이것이 바로 변수 할당문의 실행 이전에 var 키워드로 선언한 변수를 참조했을 때 undefined를 반환하는 변수 호이스팅이 발생하는 원인이다. 💡 var 키워드로 선언된 변수와 함수는 전역 객체 window의 프로퍼티와 메서드로 등록된다.

    반면 let, const 키워드로 선언된 변수는 평가단계에서 스코프에 등록은 되지만 초기화 되지는 않는다. 따라서 할당문 이전에 해당 변수를 참조하는 경우 ‘일시적 사각지대’에 놓여있기 때문에 참조에러가 발생한다. (참고: var, let, const 정리)

  • 함수 선언문
    함수 선언문으로 정의된 함수도 평가 단계에서 실행된다. 이때 자바스크립트 엔진은 암묵적으로 함수 이름과 동일한 식별자를 스코프에 등록한다. 그리고 평가 단계에서 생성된 ‘함수 객체’를 해당 식별자에 할당한다. 이처럼 함수 선언문으로 정의된 함수의 식별자와 함수 객체가 이미 등록되어 있기 때문에, 함수 선언문 이전에 해당 함수를 호출해도 정상적으로 동작하는 함수 호이스팅이 발생하게 되는 것이다. (참고: Function)

    💡 단, 함수 내부의 코드는 평가 단계에서 평가되지 않는다. 함수 내부의 코드는 실행 단계에서 함수 호출이 발생하면, 그제서야 평가되고 그 이후에 실행이 된다.

실행 단계

평가가 끝나고 소스코드가 실행되는 시점을 런타임이라고 한다. 이때 식별자가 평가 단계에서 스코프에 등록되었는지를 확인하여 코드를 실행한다.

  • 변수 할당
    앞서 변수 선언은 이미 평가 단계에서 실행되어 스코프에 등록되었다. 변수의 값은 실행 단계인 런타임에 비로소 해당 변수에 할당된다.

    💡 단, 선언하지 않은 식별자에 값을 할당하면 전역 객체의 프로퍼티로 등록이 된다. 에러가 발생하지 않고 전역 ‘변수처럼’ 기능하는 것이다. 이를 암묵적 전역이라고 하는데, 오류를 발생시킬 가능성이 높기 때문에 지양해야 한다. 이 경우 해당 식별자는 변수가 아니기 때문에 변수 호이스팅이 발생하지 않는다.

  • 함수 평가와 실행
    런타임에 코드를 순차적으로 실행해나가다가 함수의 호출을 만나면, 자바스크립트 엔진은 코드의 실행을 일시 중단하고 해당 함수의 내부로 이동하여 코드 평가를 시작한다. 이때 함수의 실행 컨텍스트가 실행되고, 함수 내부의 식별자들이 해당 실행 컨텍스트가 관리하는 지역 스코프에 등록된다. 이때 arguments 객체this 바인딩도 결정된다. 함수 내부의 평가가 끝나면, 함수 내부의 코드가 실행된다. 이때 함수 내부에 존재하지 않는 식별자를 참조하면, 스코프 체인을 따라 탐색을 실행하게 되는 것이다. 스코프 체인을 관리하는 것 또한 실행 컨텍스트의 역할이다.

실행 컨텍스트의 구성과 역할

이제 본격적으로 실행 컨텍스트의 동작 원리와 역할에 대해 알아보자. 앞서 코드의 평가 이전에 가장 먼저 전역 객체가 생성된다고 했다. 이후 자바스크립트 엔진이 전역 코드를 평가하면서 실행 컨텍스트를 구성하는데 그 순서는 다음과 같다. 각 단계를 자세히 알아보자.

  1. 실행 컨텍스트 생성
  2. 렉시컬 환경 생성

    • 환경 레코드 생성

      • 객체 환경 레코드 생성
      • 선언적 환경 레코드 생성
    • this 바인딩
    • 외부 렉시컬 환경에 대한 참조 결정

실행 컨텍스트 스택 Execution Context Stack

먼저 비어있는 전역 실행 컨텍스트가 실행 컨텍스트 스택에 푸시된다.

실행컨텍스트스택

실행 컨텍스트는 스택stack 자료구조로 관리된다. 자바스크립트 엔진은 가장 먼저 전역 코드를 평가하여 실행 컨텍스트를 생성한다. 그리고 전역 코드를 실행하는 과정에서 함수 호출을 만나면, 해당 함수의 내부 코드를 평가하여 함수 실행 컨텍스트를 생성한 뒤 스택에 푸시한다. 만약 해당 함수의 내부 코드에 또다른 함수 호출이 있다면, 코드의 실행이 일시 중단되고 다시 스택에 새로운 함수 실행 컨텍스트가 푸시된다.

함수 내부 코드의 실행이 완료되면, 해당 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거된다. 이후 기존 함수에서 실행이 중단된 코드부터 이어서 실행되고, 실행이 끝나면 마찬가지로 해당 함수의 실행 컨텍스트도 제거된다. 마지막으로 전역 코드에서도 일시 중단된 시점부터 코드가 이어서 실행되고, 모든 코드가 실행 완료되면 실행 컨텍스트가 스택에서 팝되어 실행 컨텍스트 스택에는 아무 것도 남아있지 않게 되는 것이다.

즉, 실행 컨텍스트 스택의 최상위가 언제나 실행 중인 실행 컨텍스트인 것이다. 이처럼 실행 컨텍스트를 통해 코드 실행의 제어권, 실행 순서가 관리된다.

렉시컬 환경 Lexical Environment

그 다음으로 렉시컬 환경이 생성되어 실행 컨텍스트에 바인딩 된다.

앞서 코드의 평가 단계에서 변수와 함수 선언문이 실행되어 실행 컨텍스트가 관리하는 스코프에 등록된다고 했다. 이를 실행 컨텍스트를 구성하는 컴포넌트인 렉시컬 환경이 담당한다. 렉시컬 환경은 식별자와 식별자에 할당된 값, 그리고 상위 스코프에 대한 참조를 기록한다. 식별자를 key로, 할당 값을 value로 저장하는 객체 형태의 자료구조인 것이다.

렉시컬 환경은 다시 ‘환경 레코드 Environment Record’와 ‘외부 렉시컬 환경에 대한 참조 Outer Lexical Environment Reference’ 컴포넌트로 구성된다.

환경 레코드

환경 레코드는 해당 실행 컨텍스트 스코프의 식별자와 값을 등록하고 관리하는 저장소다.

💡 전역 환경 레코드
전역 환경 레코드는 다시 객체 환경 레코드선언적 환경 레코드로 구성되어 있다. ES6 이전에는 모든 전역 변수가 전역 객체의 프로퍼티였기 때문에 전역 객체가 곧 전역 환경 레코드였다. 그러나 let, const 식별자가 등장하면서 var로 선언한 전역 객체의 프로퍼티인 변수와 let, const로 선언한 전역 변수를 구분할 필요성이 생겼다.

‘객체 환경 레코드’는 var 키워드로 선언한 전역 변수와 함수 선언문으로 정의한 함수, 빌트인 전역 프로퍼티, 빌트인 전역 함수, 표준 빌트인 객체를 관리한다. 객체 환경 레코드에는 Binding Object라는 객체가 바인딩 된다. 그리고 var 키워드로 선언된 전역 변수와 함수선언문으로 선언된 함수는 이 Binding Object를 통해 코드 평가 단계에서 전역 객체 window에 등록된다. 따라서 var 키워드로 선언된 변수와 함수가 전역 객체의 프로퍼티와 메서드로서 동작할 수 있는 것이다.

‘선언적 환경 레코드’는 let, const 키워드로 선언한 전역 변수들을 관리한다. 이들은 선언적 환경 레코드에 등록됨으로써 전역 객체의 프로퍼티가 되지 않는 것이다.

this 바인딩

함수의 호출 방식에 따라서 환경 레코드의 내부 슬롯인 [[ThisValue]]에 알맞은 객체가 this 바인딩된다. 예를 들어, 일반 함수로 호출된 함수의 환경 레코드의 내부 슬롯에는 전역 객체가 바인딩 되는 것이다. 마찬가지로 전역 환경 레코드의 내부 슬롯인 [[GlobalThisValue]]에는 전역 객체 window가 바인딩 된다. 따라서 전역 코드에서 this를 참조하면 전역 객체를 반환하게 된다.

외부 렉시컬 환경에 대한 참조

외부 렉시컬 환경에 대한 참조는 상위 스코프, 다시 말해 실행 컨텍스트 스택에서 바로 전에 푸시된 실행 컨텍스트의 렉시컬 환경을 의미한다. 외부 렉시컬 환경에 대한 참조가 있기에 단방향 링크드 리스트인 스코프 체인을 구현할 수 있는 것이다.

클로저를 다루는 글에서 함수의 내부 슬롯 [[Environment]]에는 함수가 정의된 렉시컬 환경이 저장된다고 했다. 외부 렉시컬 환경에 대한 참조에 할당되는 것이 바로 [[Environment]]에 저장된 렉시컬 환경의 참조인 것이다.
한편 전역 실행 컨텍스트는 실행 컨택스트 스택의 가장 아래에 위치한다. 이는 상위 스코프가 존재하지 않는다는 것을 의미한다. 따라서 전역 렉시컬 환경의 외부 렉시컬 환경에 대한 참조는 null 이다.

식별자 결정 Identifier Resolution

코드 실행 단계에서 할당문을 실행하거나 식별자를 참조하기 위해서는 알맞은 식별자를 결정해야 한다. 각기 다른 스코프에서 같은 이름을 가진 식별자가 존재할 수 있고, 다른 스코프에 존재하는 식별자를 참조할 수도 있기 때문이다. 이를 식별자 결정이라고 한다. 식별자 결정은 렉시컬 환경을 통해 이루어진다.

식별자 결정을 위한 탐색은 가장 먼저 실행 중인 실행 컨텍스트에서 이루어진다. 만약 해당 실행 컨텍스트의 렉시컬 환경의 환경 레코드에 일치하는 식별자가 없다면, ‘외부 렉시컬 환경에 대한 참조’가 가리키는 상위 스코프로 넘어가서 탐색을 이어간다. 외부 렉시컬 환경에 대한 참조로 연결된 스코프 체인을 통해 전역 렉시컬 환경까지 탐색이 이루어지고, 만약 전역 렉시컬 환경에도 일치하는 식별자 이름이 없다면 ‘참조 에러’가 발생한다.

클로저 Closure

자바스크립트의 핵심 개념 중 하나인 클로저 또한 실행 컨텍스트의 렉시컬 환경과 직결된다. 클로저의 설명인 “이미 생명 주기가 종료된 외부 함수의 변수를 참조할 수 있다”를 실행 컨텍스트의 관점에서 해석하면 다음과 같다.

‘생명 주기가 종료되었다’는 실행 컨텍스트 스택에서 함수의 실행 컨텍스트가 제거되었음을 뜻한다. 그러나 내부 함수의 ‘외부 렉시컬 환경에 대한 참조’를 통해 외부 함수의 ‘렉시컬 환경’의 ‘환경 레코드’에 등록된 식별자를 참조하고 있기 때문에, 실행 컨텍스트가 제거된 함수의 렉시컬 환경은 소멸하지 않는다. 이는 자바스크립트 가비지 컬렉터의 알고리즘 덕분이다.

다소 비유적으로 표현되었던 클로저에 대한 설명이 실행 컨텍스트에 대한 이해를 통해 한결 명확해졌다. 클로저에 대한 자세한 내용은 이전 글로 대체한다.

블록 레벨 스코프

이쯤 되어서 드는 의문이 하나 있다. 함수가 호출되면 함수의 실행 컨텍스트가 생성되고, 실행 컨텍스트에는 렉시컬 환경이 바인딩 되어 함수의 스코프를 형성한다. 그러나 스코프에 관한 글에서 let, const 키워드로 선언된 변수는 블록 레벨 스코프를 따른다고 했다. 하나의 실행 컨텍스트에서는 하나의 렉시컬 환경이 변수들을 관리하는데, 함수 내부의 블록 레벨 스코프는 어떻게 관리되는 것일까?

결론부터 말하자면 실행 컨텍스트는 하나 이상의 렉시컬 환경을 생성할 수 있다. for문이나 if문 등의 내부에서 실행되는 코드 블록을 위한 새로운 렉시컬 환경이 생성되고, 기존에 실행 컨텍스트에 바인딩되어 있던 렉시컬 환경을 대체한다. 실행 컨텍스트가 블록 레벨의 렉시컬 환경과 바인딩되는 것이다. 단, 새롭게 연결된 블록 레벨 렉시컬 환경의 ‘외부 렉시컬 환경에 대한 참조’가 기존 렉시컬 환경을 가리킨다. 따라서 기존의 렉시컬 환경은 새로운 렉시컬 환경의 상위 스코프로서, 실행 컨텍스트와의 연결을 간접적으로 유지하게 되는 것이다. 그리고 블록 코드가 실행 종료되면 실행 컨텍스트에는 기존의 렉시컬 환경이 다시 연결 된다.

정리하여 말하면, 실행 컨텍스트는 복수의 렉시컬 환경을 가질 수 있지만 한 번에 하나의 렉시컬 환경과 직접적으로 바인딩되는 것이다. 새롭게 바인딩된 렉시컬 환경은 기존의 렉시컬 환경을 참조한다.

💡 반복적으로 변수를 선언하고 할당하는 for문의 경우, 반복이 실행될 때마다 매번 새롭게 렉시컬 환경을 생성한다. 따라서 반복문 내부에서 함수가 정의된다면, 자신이 정의된 렉시컬 환경을 참조함으로써 식별자의 값을 유지하는 것이다. 클로저의 원리와 동일하다.

실행 컨텍스트를 공부하며

실행 컨텍스트에 관해 알아보며, 이제껏 살펴봤던 자바스크립트의 모든 개념들의 동작 원리는 결국 실행 컨텍스트의 동작 원리로부터 기인하는 것임을 알 수 있었다. 특히 클로저, this, 스코프처럼 자주 헷갈리는 개념들은 렉시컬 환경에 대한 이해가 부족했기 때문에 더욱 어렵게 느껴졌던 것 같다.

미처 다루지 못한 부분도 있다. 실행 컨텍스트는 더욱 세부적으로 Lexical Environment와 Variable Environment로 구성되는데, 이 부분을 자세히 살피지 않았다. 또한 Binding Object의 동작 원리에 대해서도 여전히 해소되지 않은 부분이 있다. 향후 이들에 대한 내용을 채워넣도록 하자.

참고

실행 컨텍스트
모던 자바스크립트 Deep Dive | 위키북스 | 이웅모


Profile picture

42KIM A person trying to create something.
👉Github