Published on

[Javascript] 실행 컨텍스트 제대로 이해하기

Authors
  • avatar
    Name
    이주영
    Twitter

들어가기 앞서

이전에 실행 컨텍스트에 대해 학습했음에도 불구하고 시간이 지나서 다시 면접을 준비하는 과정에서 말로 설명하기 어려움을 느꼈고 이번 기회에 제대로 이해하고 정리하려고 합니다.

해당 포스트를 읽고 난 후 아래의 4가지를 아실 수 있습니다.

  1. 우선 실행 컨텍스트가 무엇이며 구성 요소를 알 수 있습니다.
  2. 실제로 코드의 흐름에 따라 실행 컨텍스트가 어떻게 생성되고 제거되는지 알 수 있습니다.
  3. 스코프 기반으로 변수를 관리하는 방식을 이해할 수 있습니다.
  4. 호이스팅이 발생하는 이유도 알 수 있습니다.

본론

사전 지식

먼저 실행 컨텍스트를 이해하기 위해 소스코드의 타입평가/실행 단계를 살펴보겠습니다.

첫번째, ECMAScript의 소스 코드 타입을 통한 인사이트

딥다이브 교제를 통해 ECMAScript는 소스 코드를 4가지 타입으로 구분한다는 것을 알 수 있었습니다.

  1. 전역 코드 : 전역에 존재하는 소스 코드를 말합니다. (전역에 정의된 함수, 클래스 내부 코드는 미포함)
  2. 함수 코드 : 함수 내부에 존재하는 소스코드를 말합니다. (함수 내부에 중첩 함수, 클래스는 내부 코드는 미 포함)
  3. eval 코드 : 빌트인 전역 함수인 eval 함수에 인수로 전달돼 실행되는 코드
  4. 모듈 코드 : 모듈 내부에 존재하는 코드

이렇게 4가지의 소스 코드 타입가 있습니다. 위의 소스 코드의 타입에 따라 실행 컨텍스트를 생성하는 과정과 관리하는 콘텐츠가 다르기에 소스코드의 타입을 살펴보려고 합니다. eval과 모듈은 우선적으로 제외하고 전역과 함수 소스코드를 살펴봅시다.

전역 코드

  • Q. 무엇을 관리하는가?

A. 전역 변수, 함수 등을 관리합니다.

  • Q. 어떻게 관리하는가?

A. 전역 스코프를 생성함으로써 관리합니다.

변수나 함수는 스코프를 통해 관리되는 것을 알 수 있습니다.

함수 코드

  • Q. 무엇을 관리하는가?

A. 지역 변수, 중첩함수, 매개 변수, arguments 객체를 관리

  • Q. 어떻게 관리하는가?

A. 지역 스코프를 생성하고 전역과 연결하여 관리

전역 코드와 다르게 매개변수, arguments 객체가 추가되어 있는 것을 알 수 있고 지역 스코프를 생성하고 전역 스코프와 연결해서 관리하는 것을 알 수 있습니다. 지역 스코프와 전역 스코프를 연결해서 관리하는 이유도 뒤에서 정확히 알 수 있으며 해당 개념은 렉시컬 스코프와 연관 있습니다.

두번째, 평가 단계와 실행 단계로 이루어진 JS 엔진

JS 엔진은 평가와 실행으로 소스코드를 처리합니다. 이는 실행 컨텍스트의 개념을 이해하는데 필수적인 사전 지식입니다. 소스코드를 처리하는 과정에서 평가 단계와 실행단계에 어떤 일이 일어나는지 정리해 봅시다.

평가 단계

  1. 실행 컨텍스트를 생성합니다.
  2. 변수, 함수와 선언문만 먼저 실행합니다.
  3. 생성된 변수와 함수 식별자를 키로 삼고 렉시컬 환경의 환경 레코드에 등록합니다.

3단계로 정리해 볼 수 있습니다. 아직 명확히 그림이 그려지지 않아도 끝까지 살펴보시면 이해가 될 겁니다. 우선 이렇게 3단계가 끝나면 실행 단계로 넘어가게 됩니다.

실행 단계

선언문을 제외한 소스코드가 순차적으로 실행됩니다. 위에서 아래로 한 줄 한 줄 읽히기 시작하는 거죠. 이때 평가 단계에서 생성한 실행 컨텍스트를 기반으로 실행에 필요한 정보, 변수 혹은 함수를 파악합니다. 스코프를 통해 변수나 함수를 파악하고 값이 변경된다면 다시 실행 중인 실행 컨텍스트의 스코프에 갱신이 되는 방식으로 이루어집니다.

실행 컨텍스트가 무엇인가요?!

해당 포스트에서 살펴볼 실행 컨텍스트는 JS 엔진 내부에 존재하며 비동기 처리 방식인 이벤트 루프와 긴밀한 연관관계가 있는 개념입니다. 이번 포스트에서는 콜스텍에 해당하는 개념들을 파헤치려고 합니다.

정의

실행 컨텍스트(Execution Context)는 JavaScript 코드가 실행될 때 생성되는 환경을 의미합니다.

여러 정의를 살펴보았지만 해당 정의가 가장 정확하다고 생각됩니다. JS 코드가 실행될 때 생성되는 환경을 제공해주는 실행 컨텍스트... 왜 이해하기 어려울까요?! 그 이유는 JS 코드의 실행 과정을 모르기 때문이라고 생각합니다.

JS 코드의 실행 과정에 대해 Chat GPT에게 물어봅시다. 아래의 내용은 GPT가 알려준 내용에 제가 학습한 내용을 토대로 이해하기 쉽게 수정한 내용입니다.

  1. 소스 코드 평가(Scanning and Parsing): JS 엔진은 소스 코드를 처음부터 끝까지 읽고 구문 분석을 합니다.
  2. 실행 컨텍스트 생성: 소스 코드 평가 단계 이후에는 실행 컨텍스트가 생성됩니다. 실행 컨텍스트는 코드의 실행을 위해 필요한 환경 정보를 담고 있습니다.
  3. 변수 및 함수의 선언: 소스 코드를 평가하는 과정에서 변수 선언(var, let, const)과 함수 선언문을 만나면 실행 컨텍스트 내의 변수 객체(환경 레코드)에 변수와 함수를 등록합니다.이 과정에서 변수는 메모리에 할당되지만 값을 할당하지는 않습니다. 값은 실행 단계에서 할당됩니다.
  4. 코드 실행: 실행 컨텍스트의 생성과 변수 및 함수의 등록이 완료되면 JavaScript 엔진은 코드를 순차적으로 실행합니다.
  5. 코드 실행 종료: 코드 실행이 완료되면 해당 실행 컨텍스트는 실행 컨텍스트 스택에서 제거됩니다. 이때 함수 호출이 반환되면 반환 값이 호출 지점으로 전달됩니다.

즉 위의 과정인 JS 코드가 실행될때 코드가 실행하기 위한 문맥, 환경을 담고 있는 객체라고 말할 수 있습니다.

그럼 여러 실행 컨텍스트는 어떻게 관리되는거지?

전역 실행 컨텍스트 평가 -> 전역 실행 컨텍스트 실행 도중 함수 호출 코드 확인 -> 함수 평가 -> 함수 실행으로 함수 실행 컨텍스트가 생성되었습니다. 그렇다면 위의 스택들은 어떻게 관리되는 걸까요?!

그것은 바로 실행 컨텍스트 스택이라고 불리는 "콜 스택"입니다. 콜 스택에 대해선 이벤트 루프 개념을 학습할 때 많이 들었던 개념입니다. 콜 스택은 브라우저 내부에 있고 조금 더 자세히 이야기하면 JS엔진의 구성요소이며 실행 컨텍스트의 순서를 관리하는 자료구조라는 것입니다.

JS 엔진은 그럼 무슨 역할을 하는 것일까요?!

JS 엔진 내부에 콜스택이 존재하는 것이니 JS엔진은 콜스택의 제어권을 가지고 있습니다. push와 pop이 가능하겠죠!! 함수가 호출되면 push를 통해 함수 실행 컨텍스트를 생성하고 함수 내부 코드 실행이 종료되면 pop 해서 이전 실행 컨텍스트 (함수 혹은 전역)으로 제어권이 넘어가게 됩니다. 콜스텍은 코드의 실행 순서를 관리합니다.

계속 반복해도 지나치지 않은 내용으로 JS는 평가실행 단계를 거칩니다. 평가 단계에서 실행컨텍스트를 생성했죠. 그러다 함수를 만나면 함수를 호출하고 실행 단계를 일시 중단하고 함수 내부로 진입하게 됐습니다. 이게 어떻게 가능한 것일까요?! 어떻게 하면 더 구체적으로 이해할 수 있을까요?!

구현된 자료구조를 살펴보면 이해가 쉽습니다. 실행 컨텍스트는 "STACK"으로 구현되어있습니다.

Stack이 뭔가요?!

Stack은 우선 명사로는 쌓여있는 더미 혹은 쌓여있는 그 자체를 표현할 때 많이 사용합니다. 동사로는 쌓다, 포개다라는 뜻이 있습니다.

Stack의 가장 대표적인 특징으론 후입 선출을 말할 수 있습니다. 말 그대로 "나중에 들어온 것이 먼저 나간다" 입니다. 문이 한쪽만 있는 엘리베이터를 생각해보면 이해가 쉽습니다. 입구는 하나이기에 마지막에 들어온 사람이 문이 열렸을때 가장 먼저 나가게 되는데 이것이 후입 선출의 개념입니다.

엘리베이터를 상상하여 정리해보고자 합니다. 엘리베이터 문이 열렸을때 전역 실행 컨텍스트가 먼저 들어갑니다. 그리고 함수 실행 컨텍스트가 연이어 들어갑니다. 문이 열리면 무엇부터 나오게 되나요???

함수 실행 컨텍스트죠!

전역과 함수 실행 컨텍스트의 생성 과정

지금까지 실행 컨텍스트에 대해 학습해 보았습니다. 그럼 이제 어떻게 콜스택 안에서 실행 컨텍스트가 생성이 되고 코드 실행 결과가 관리되는지 흐름을 파악해 보겠습니다.

딥 다이트 예제를 활용하겠습니다.

var x = 1
const y = 2

function foo(a) {
  var x = 3
  const y = 4

  function bar(b) {
    const z = 5
    console.log(a + b + x + y + z)
  }
  bar(10)
}
foo(20) //42

순서는 대략 이렇게 진행됩니다.

  • 전역 객체 생성
  • 전역 코드 평가 및 실행
  • foo 함수 호출을 만나 foo 함수 평가 및 실행
  • bar 함수 호출을 만나 bar 함수 평가 및 실행
  • 후입 선출로 실행을 종료

한 단계씩 살펴보시죠.

전역 객체가 생성

평가 단계 이전에 생성되는 것이 있는데 바로 전역 객체입니다. 전역 객체라 하면 일반적으로 var 키워드를 통해 선언한 변수가 전역 객체에 포함이 된다고 알고 있습니다. 이때 전역 객체에는 빌트인 전역 프로퍼티, 함수가 포함되어 있고 런타임 환경 (브라우저, 노드)에 따라 다른 객체를 포함할 수 있습니다.

전역 코드 평가

소스코드가 로드되면 JS는 전역 코드를 평가하는데 한 단계씩 살펴봅시다. 시작하기 전 궁금한 게 '소스코드가 로드되면'을 실제 개발하면서 코드로 어떻게 표현되는지 궁금해서 찾아보니 script 태그를 만나 로드하는 것을 의미합니다. HTML 파서가 html을 파싱 하는 도중 script 태그를 만나면 JS 소스 코드를 로드한다고 이해할 수 있겠네요.

그럼 한 단계씩 살펴봅시다.

  1. 전역 실행 컨텍스트 생성 우선 JS엔진이 콜스텍에 전역 실행 컨텍스트를 푸시합니다.

  2. 전역 렉시컬 환경 생성 전역 렉시컬 환경(변수와 상위 스코프 체인을 저장하는 역할을 수행)을 생성하고 전역 실행 컨텍스트에 연결합니다. 연결한다는 의미는 렉시컬 환경 객체의 주소값을 실행 컨텍스트와 연결한다는 의미입니다. 즉 실행 컨텍스트와 렉시컬 환경은 독립적으로 존재한다는 것을 의미합니다. 그로 인해 클로저가 가능한 것이죠.

2-1. 전역 환경 레코드 생성 전역 환경 레코드는 결국 전역 변수를 관리하는 전역 스코프, 전역 객체를 제공하는 역할을 합니다. 그런데 여기서 var는 전역 객체에 속하지만 ES6 이후 등장한 let과 const는 블록 스코프로 전역 객체의 프로퍼티가 되지 않습니다. 이를 가능한 이유는 전역 환경 레코드는 2가지로 구성되어 있기 때문입니다.

첫번째는 객체 환경 레코드, 두번째는 선언적 환경 레코드

  • 객체 환경 레코드는 var 키워로 선언한 변수와 함수와 전역 객체를 관리합니다.
  • 선언적 환경 레코드는 let과 const 키워드로 선언한 전역 변수를 관리합니다.

2-1-1. 객체 환경 레코드 (var) 전역 환경 레코드의 var 키워드와 전역 객체 및 빌트인 객체를 관리하는 객체 환경 레코드의 실제 구현은 BindingObject인 객체와 바인딩됩니다. BindingObject 객체는 1번 단계인 평가 이전 단계에서 생성된 전역 객체입니다.

2-1-2. 선언적 환경 레코드(let, const) let, const 키워드로 선언한 변수와 함수 표현식은 선언적 환경 레코드에 등록되고 관리됩니다. let과 const 키워드로 변수나 함수를 선언하면 전역 객체에 포함되지 않죠. 그 이유는 바로 선언적 환경 레코드에 의해 등록되고 관리되기 때문에 객체 환경 레코드로부터 분리되어 있기 때문입니다.

2-2. this 바인딩 전역 환경 레코드 안에 내부 슬롯인 GloablThisValue에 this가 바인딩됩니다.

2-3. 외부 렉시컬 환경에 대한 참조 결정 스코프 체인으로, 단방향 링크드 리스트로 구현되어 있으며 현재 평가중인 실행 컨텍스트의 상위 스코프에 연결합니다.

위의 내용을 그림을 통해 알아 정리해봅시다. 전역 소스 코드가 평가 단계를 알아본 것이고 전역 실행 컨텍스트가 어떻게 생성되는지 그리고 어떤 구성요소가 있는지 알아보았습니다.

전역 실행 컨텍스트 안에 전역 렉시컬 환경이 있었죠. 해당 환경은 독립적인 객체로 존재하며 참조로 실행 컨텍스트에 존재하게 됩니다. 전역 렉시컬 환경은 변수 객체라 불리는 전역 환경 레코드, this값을 저장하는 슬롯인 GlobalThisValue 그리고 상위 스코프가 연결된 외부 렉시컬 환경 참조로 구성되어 있습니다.

전역 실행 컨텍스트는 함수 실행 컨텍스트와 환경 레코드 구성 요소에 차이가 있습니다. 전역 환경 레코드에는 내부에는 두 가지 환경 레코드가 있습니다. 첫 번째는 객체 환경 레코드, var 키워드로 만들어진 변수나 함수, 그리고 전역 객체, 프로퍼티, 빌트인 전역 객체를 관리합니다. 두 번째는 선언적 환경 레코드입니다. let과 const 키워드로 만들어진 변수나 함수는 이곳에서 식별자가 등록되고 값이 관리됩니다. 그로 인해 let, const로 선언한 변수 혹은 함수는 전역 객체의 프로퍼티로 바인딩되지 않았던 것입니다. 반면 함수의 실행 컨텍스트 내부의 함수 렉시컬 환경에 속한 환경 레코드는 하나로 주로 매개변수, arguments 객체, 스코프 내의 변수 혹은 중첩함수를 등록하고 관리합니다.

3. 전역 코드 실행

이제 평가를 마치고 실행 단계가 진행됩니다. 식별자 결정이 이루어지며 변수나 함수의 값을 찾기 위해 스코프 체인을 따라 검색하는 과정을 거칩니다.

4. foo 함수 코드 평가

전역 소스 코드가 실행되면서 함수 호출을 만나 실행을 멈추고 함수 내부로 들어와 평가를 시작합니다.

  1. 함수 실행 컨텍스트 생성 전역에서 평가를 거치고 실행 도중 함수 호출문을 만나게 돼, 전역 실행이 중단되고 함수 평가단계로 돌입하게 됐다. 먼저 foo 함수 실행 컨텍스트를 생성한다. 그리고 함수 렉시컬 환경이 만들어지면 콜스텍에 추가된다.

  2. 함수 렉시컬 환경 생성 foo 함수의 렉시컬 환경을 생성하고 함수 실행 컨텍스트와 연결합니다. 함수 렉시컬 환경에서도 함수 환경 레코드(변수 객체)와 외부 참조로 구성됩니다.

2-1. 함수 환경 레코드 생성
함수 레코드에는 함수의 매개변수와 arguments 객체 그리고 함수 스코프 내에 있는 변수나 중첩 함수를 등록하고 관리합니다.

2-2. this 바인딩 전역과 다르게 함수 렉시컬 환경에 속해있는 ThisValue 슬롯에 this가 바인딩된다. 특징으로는 함수의 호출 방식에 따라 동적으로 결정되는 것이 특징이며 기본적으로 일반 함수로 호출이 될 경우 this는 전역 객체를 가르킵니다.

2-3. 외부 렉시컬 환경에 대한 참조 결정 foo 함수가 평가되는 시점에 실행 중인 실행 컨텍스트의 렉시컬 환경의 참조가 할당됩니다.

Q. foo 함수가 평가되면서 전역 실행 컨텍스트가 동시에 실행되는것인가?!

A. 기본적인 개념을 다시 살펴보면, 실행 중 함수 호출을 만나면 실행이 일시적으로 중단됨을 알 수 있습니다. 함수 내부를 평가하기에 foo 함수가 평가되는 시점에 임시로 멈춘 상위 실행 컨텍스트의 렉시컬 환경의 참조가 foo함수의 외부 렉시컬 환경에 할당된다는 것을 이해할 수 있게 됩니다.

Q. 렉시컬 스코프가 무엇이죠?!

A. 함수의 호출 위치가 아닌 정의된 위치에 따라 상위 스코프를 결정한다는 의미의 개념입니다. 이것이 어떻게 가능할까요?!

우선 함수는 변수가 렉시컬 환경에 등록될 때 차이가 기억나시나요? 변수는 환경 레코드에 값을 저장하는 데에 반해 함수는 함수 객체를 저장한다는 차이가 있었습니다. 그래서 JS 엔진은 함수 평가 단계에 함수 식별자를 키로 등록하고 함수 객체를 생성하는데 그때 실행 중인 실행 컨텍스트의 렉시컬 환경을 함수 객체의 내부 슬롯에 저장합니다. 즉 실행 중인 실행 컨텍스트는 상위 실행 컨텍스트이므로 상위 스코프를 함수의 내부 슬롯에 저장한다고 이해할 수 있습니다.

위의 내용을 그림을 통해 알아 정리해봅시다. 이번엔 전역 실행 컨텍스트의 실행이 중단되고 함수 코드를 평가하는 과정을 살펴보았습니다.

함수 실행 컨텍스트에는 마찬가지로 렉시컬 환경이 존재합니다. 내부에 환경 레코드, this 바인딩, 상위스코프를 알 수 있는 요소들이 존재합니다. 전역과 다르게 환경 레코드가 단순합니다. 객체 환경 레코드와 선언적 환경 레코드로 분리되어있지 않습니다. 함수 환경 레코드는 매개변수, arguments 객체와 변수 및 함수등을 관리합니다. 렉시컬 스코프는 함수 평가 단계에서 정의된 위치에 따라 상위 스코프가 결정되는 방식을 말하며 동작 원리는 함수 내부 슬롯에 상위 실행 컨텍스트의 스코프를 저장하는 방식으로 이루어집니다.

5. foo 함수 실행

식별자를 우선 함수 렉시컬 환경에서 찾고 없다면 상위 스코프의 렉시컬 환경을 통해 검색하며 실행을 위한 값을 찾는 단계입니다.

6. bar 함수 평가

foo 함수 실행 컨텍스트에서 bar 함수 실행 컨텍스트로 JS 엔진에 의해 제어권이 넘어왔습니다.

출처 : 모던 자바스크립트 딥다이브

딥다이브의 교제의 사진이 이해가 되지 않나요?

우선 콜스텍에 3개의 실행 컨텍스트가 쌓여있고 전역 렉시컬 환경에는 환경 레코드가 두 개가 보입니다. 그리고 this값은 GlobalThisValue 슬롯에 저장이 되어있으며 외부 참조는 null로 초기화가 되어있습니다. 그 외에 지금까지 살펴본 내용을 복습할 수 있는 이미지라고 생각합니다.

7. bar 함수 코드 실행

런타임 실행되고 순차적으로 bar 함수가 실행되기 시작합니다.

8. 후입 선출로 실행을 종료

bar 종료, foo 종료, 전역 종료

여기서 중요한 개념이 있는데 bar 함수 실행 컨텍스트가 종료되고 foo 함수 실행 컨텍스트로 제어권이 넘어온다고 해도 bar의 함수 실행 컨텍스트가 종료되는 것은 아닙니다. 렉시컬 환경은 실행 컨텍스트에 의해 참조되지만 독립적인 객체입니다. 그래서 클로저를 구현할 수 있는 것이죠. 전역 코드 실행이 종료되면 콜스텍에 아무것도 남지 않게 됩니다. 만약 비동기 요청이 있었다면 이벤트 루프가 빈 콜스텍을 보고 태스크큐에 있는 콜백함수를 콜스텍으로 푸시하여 실행하게 됩니다. 이게 비동기 처리의 시작입니다.

마무리하며

이번 포스트에서는 실행 컨텍스트에 대해 학습하였습니다. 실행 컨텍스트가 무엇이며 어떤 역할을 하는지 그리고 실제 코드를 통해 실행 컨텍스트의 생명 주기를 살펴보았습니다.

해당 포스트에서는 아래의 4가지를 알 수 있다고 하였는데 정리하며 마치려고 합니다.

Q. 스코프 기반으로 변수를 관리하는 방식이 무엇인가요? A. 전역 스코프와 지역 스코프가 있으며 전역 실행 컨텍스트와 함수 실행 컨텍스트의 각각의 렉시컬 환경에서 변수를 관리하는 방식을 말합니다.

Q. 호이스팅이 발생하는 이유는 무엇인가요? A. JS엔진에는 평가와 실행 단계로 구성되어 있음을 기억해야 합니다. 평가 단계에 선언문이 먼저 실행돼 실행 컨텍스트가 생성한 스코프에 변수 혹은 함수 식별자를 키로 등록되기에 런타임 이전에 변수가 끌어올려져 보이는 호이스팅이 발생하는 것입니다.

Q. var 키워드로 선언한 변수 이전에 참조할 경우 어떻게 undefined가 뜨는지 아시나요? A. var는 선언과 초기화가 동시에 이루어져 가능하다고 알고 있었지만 조금 더 깊이 있게 정의해 보면, 핵심적으로는 전역 렉시컬 환경 내부에 있는 객체 환경 레코드를 통해 평가 단계에 식별자를 키로 undefined가 암묵적으로 초기화되어 선언하기 이전에 console.log(x)를 통해 확인해 보면 undefined가 뜹니다.

Q. 비동기 처리 방식에 대해 아시나요? A. 비동기 처리는 위의 과정이 모두 진행된 후 콜스텍이 비어있을 때 이벤트 루프에 의해 태스크 큐 안에 있는 함수들이 콜스텍으로 올려져 실행됩니다.

참고 자료

  1. 모던 자바스크립트 딥 다이브
  2. MDN 실행 컨텍스트