발표 스터디 2주차 주제로 선정한,
실행 컨텍스트(Execute context)
에 대한 정리 글입니다.
실행 컨텍스트는 어려운 개념이지만 자바스크립트가 어떻게 동작하는지, 어떤 특징들을 가지고 있는지 이해하기 위해서는 필수적으로 알아야 할 개념입니다. scope
, 호이스팅
, 클로저
, this
같은 키워드를 잘 이해하고 싶다면, 실행 컨텍스트를 이해하는 것이 우선일 것 같습니다.
실행 컨텍스트는 실행할 코드에 제공할 환경 정보들을 모아놓은 객체입니다.
개인적으로 처음 이 문장을 보았을 때, 무슨 의미인지 잘 와닿지 않았기 때문에 궁금한 점을 조금 더 풀어서 알아보려고 합니다.
여기서 말하는 환경 정보란, 코드에 선언된 변수
와 함수
, 스코프
, this
, arguments
등을 의미합니다.
자바스크립트 엔진이 코드를 실행하기 위해서는 코드에 대한 정보들이 필요하기 때문입니다.
자바스크립트 엔진은 코드 실행에 필요한 환경 정보들을 모아서 컨텍스트를 구성하고, 이를 콜 스택에 쌓아올립니다. 코드가 실행될 때, 콜 스택의 가장 위에 쌓여있는 컨텍스트와 관련 있는 코드들을 실행하는 식으로 전체 코드의 환경과 순서를 보장합니다.
자바스크립트 엔진이란, 자바스크립트 코드를 실행하는 프로그램 혹은 인터프리터를 말합니다. 주로 웹 브라우저를 위해 사용되며, 가장 잘 알려진 것이 구글의 V8 엔진입니다.
V8 엔진은 크롬 웹 브라우저와 Node.js 등에서 사용되고 있습니다. 또한, V8 엔진은 메모리 힙(Memory Heap)
과 콜 스택(Call Stack)
으로 구성되어 있습니다.
위의 그림에서 콜 스택 안에 쌓여있는 게 바로,
실행 컨텍스트(Execute Context)
입니다.
정리해보면, 전역 실행 컨텍스트가 가장 먼저 콜 스택에 추가되고 이후에 함수가 호출될 때 마다 새로운 실행 컨텍스트가 만들어진 뒤 콜 스택에 추가되며, 실행이 끝나면 콜 스택에서 제거됩니다.
음.. 뭔가 굉장히 복잡한데, 이번 포스팅에서는 Lexical Environment
에 있는 Environment Record와 Outer Environment Reference만 집중해서 보려고 합니다.
console.log(brand); // ?
var brand = 'apple';
console.log(brand); // apple
다음과 같은 코드가 있습니다.
선언문 다음에 brand
를 출력해보면, apple
이라고 찍히는 것은 당연한 것처럼 보입니다.
그런데, 선언 라인 위에서 출력하면 결과가 어떻게 될까요? 다른 언어였다면 에러가 발생할지 모르겠지만, 자바스크립트에서는 에러가 발생하지 않고 undefined
가 출력됩니다.
이렇게 선언 라인 전에도 변수를 참조할 수 있는 현상이 바로 호이스팅(Hosting)입니다.
호이스팅은 선언문이 마치 최상단으로 끌어올려진 듯한 현상인데, 실제로 선언문이 최상단으로 끌어올려진 것은 아닙니다. 자바스크립트 엔진이 전체 코드를 스캔하면서, 변수 같은 정보를 실행 컨텍스트에 있는 Environment Record
에 미리 기록해두었기 때문에 이런 현상이 발생하는 것입니다.
즉, Environment Record는 식별자와 식별자에 바인딩된 값을 기록해두는 객체입니다.
아까 위의 코드가 어떤 과정을 거쳐서, 호이스팅이 발생하는지 알아봅시다.
- 자바스크립트 엔진이 전역 실행 컨텍스트를 생성해서 콜 스택에 넣습니다.
- 전체 코드를 스캔하면서 선언할게 있는지 찾아보고, 있다면 먼저 선언해둡니다.
- Environment Record에 새로운 식별자 brand를 기록합니다.
- var 키워드로 선언했기 때문에 값을 undefined로 초기화 해둡니다.
여기까지, 실행에 앞서 스캔하고 준비하는 단계를 생성 단계(Creation Phase)
라고 부릅니다.
실행 컨텍스트를 생성 한 뒤, 선언문을 먼저 실행해서 Environment Record에 미리 기록합니다.
이후에 선언문을 제외한 나머지 코드를 순차적으로 실행하며 이 단계를 실행 단계(Execution Phase)
라고 부릅니다. 생성 단계에서 Environment Record에 미리 기록해둔 정보들을 참고하거나, 업데이트합니다.
✅ 그렇다면, 생성 단계에 이어서 실행 단계가 어떻게 될지 살펴봅시다.
- 첫 번째 라인의 console.log가 실행됩니다. 자바스크립트 엔진은 현재 활성화된 실행 컨텍스트 내의 Environment Record를 보고, 기록된 brand의 값을 참조해서 undefined를 출력합니다.
- 두 번째 라인의 선언문은 이미 생성 단계에서 실행했으므로, 할당만 실행합니다. brand에 바인딩 된 값을 apple로 업데이트합니다.
- 이후 마지막 라인을 실행하면 1번 과정처럼, Environment Record에 있는 brand의 값을 참조해서 apple을 출력합니다.
console.log(brand); // Reference Error
const brand = 'apple';
console.log(brand); // apple
다시 생성 단계로 돌아가서, 동일하게 선언문을 먼저 찾습니다. 하지만 var와 다르게 const로 선언하면 값을 초기화해두지 않습니다. 따라서, 첫 번째 라인에서 brand의 값을 참조하려고 하면 Reference Error가 발생합니다.
이렇게 let/const를 이용하면 선언 라인 이전에 식별자를 참조할 수 없는데, 이 구역을 일시적 사각지대(Temporal Dead Zone)이라고 부릅니다.
정리해보면, var로 선언한 변수의 경우 선언과 초기화가 동시에 발생합니다. 하지만 let/const로 선언한 변수의 경우 선언만 실행되고, 초기화를 하지 않습니다.
따라서 선언 라인 이전에 변수를 참조할 수 없게 되는 것입니다.
foo(); // ?
var foo = () => {
console.log('hello');
};
자바스크립트에는 다음과 같이, 함수를 변수에 담을 수 있습니다.
Environment Record에 기록되어 있는 foo의 값은 undefined로 초기화되어 있는 상태이므로, undefined는 함수처럼 호출될 수 있는 데이터타입이 아니기 때문에 타입 에러가 발생합니다.
앞서 봤듯이, const 키워드는 값을 초기화해두지 않기 때문에 참조할 수 있는게 없습니다. 따라서, Reference Error가 발생합니다.
이렇게 변수에 함수를 담아서 함수를 선언하는 방식을 함수 표현식(Function Expression)이라고 하는데, 무언가를 변수에 담는다는 점에서 앞서 살펴봤던 변수 호이스팅과 동일하게 동작합니다.
function 키워드로 함수를 선언하는 것을 함수 선언식(Function Declaration)이라고 부릅니다. 함수 선언식은 자바스크립트 엔진이 함수가 선언됨과 동시에 완성된 함수 객체를 Environment Record에 기록해둡니다.
즉, 선언과 동시에 함수가 생성됩니다. 그래서 선언 라인 이전에 함수를 사용할 수 있게 됩니다.
foo(); // hello
function foo() {
console.log('hello');
};
console.log(sum(3, 4)); // 3 + 4 = 7
function sum(x, y) {
return x + y;
}
var a = sum(1, 2);
function sum(x, y) {
return x + ' + ' + y + ' = ' + (x + y);
}
var c = sum(1, 2);
console.log(c); // 1 + 2 = 3
첫 번째 라인에서 다음과 같은 결과가 출력되는 이유는, 동일한 이름의 함수가 2번 선언 되었는데 이와 같은 상황에서 나중에 할당된 함수 즉, 마지막에 할당된 함수로 sum 함수가 덮어씌워지기 때문입니다.
그래서 비교적으로 함수 표현식이 안전하며, 함수 선언식을 지양하자는 의견도 있습니다.
설명하기에 앞서, 스코프 체이닝이 무엇인지 먼저 알아봅시다.
스코프(scope)란 식별자에 대한 유효범위입니다.
var foo = 'a';
function somethingFn() {
var bar = 'b';
console.log(foo); // a
console.log(bar); // b
}
console.log(bar); // error
위의 예제 코드에서 somethingFn 함수 내부에서 외부에서 선언한 변수 foo에 접근이 가능하지만, 내부에서 선언한 bar는 오직 함수 내부에서만 접근할 수 있습니다. 이러한 '식별자의 유효범위'를 안에서부터 바깥으로 차례로 검색해나가는 것을 스코프 체인이라고 합니다.
Outer Environment Reference(외부 환경 참조)는, 바깥 Lexical Environment를 가리킵니다.
여기서 Lexical Environment란 '어휘적 환경' 또는 '정적 환경' 이라고 불리는데, ECMA Script Spec 262에 따르면, Lexical Environment은 자바스크립트 코드에서 변수 또는 함수 식별자를 맵핑(identifier-variable mapping)하는데 사용되는 객체입니다.
쉽게 코드로 예시로 들어보면 다음과 같습니다. (실제 코드로 존재하진 않습니다.)
var a = 10;
var b = 20;
function foo() {
const c = 30;
console.log(a + b + c); // 60
}
// Global Context's Lexical Environment
globalEnvironment = {
environmentRecord: {
a: 10,
b: 20,
},
outer: null, // 부모 Environment가 없다는 뜻.
};
// foo Context's Lexical Environment
fooEnvironment = {
environmentRecord: {
c: 30,
},
outer: globalEnvironment,
};
let wifi = false;
function goTo2F() {
let wifi = true;
console.log(wifi);
}
goTo2F();
자바스크립트 엔진은 어떤 과정을 거쳐서 wifi 값을 출력하는지 정리 해보겠습니다.
5번의 과정에서 wifi를 출력하려고 Environment Record를 보니, wifi가 두 개 있습니다.
자바스크립트 엔진은 여기서 wifi의 값을 어떻게 결정할까요?
이런 상황에서 변수나 함수의 값을 결정해내는 것을 식별자 결정(Identifier Resolution)이라고 합니다. 지금같이 콜 스택 내에 동일한 식별자가 여러 개 있을 때, 자바스크립트 엔진이 Outer Environment Reference를 활용해서 의사결정을 하게 됩니다.
3번 과정을 다시 살펴보면, goTo2F() 함수가 실행될 때 자바스크립트 엔진은 새로 생성된 실행 컨텍스트에 바깥 Lexical Environment로 돌아갈 수 있는 Outer Environment Reference를 남겨 놓습니다. 그래서 필요할 경우, 바깥 Lexical Environment에 기록되어 있는 Environment Record에 저장된 식별자를 참조할 수 있습니다.
넘어가서 5번 과정부터 다시 시작해봅시다. 자바스크립트는 원칙적으로 현재 활성화된 실행 컨텍스트의 Environment Record를 먼저 살펴봅니다. Environment Record에 기록된 wifi의 값이 true이므로 true를 출력합니다.
let wifi = false;
function goTo2F() {
let wifi = true;
console.log(wifi); // ?
function goTo3F() {
let pet = 'puppy';
console.log(pet); // ?
console.log(flower); // ?
}
goTo3F();
}
goTo2F();
이전 상황에서 goTo2F() 함수 내에 goTo3F() 함수를 호출하는 부분이 추가 되었습니다.
이전 과정은 어떻게 되는지 봤으니, goTo3F() 함수가 호출되는 부분부터 시작해보겠습니다.
goTo3F() 함수가 실행되며, 새로운 실행컨텍스트가 생생되며 pet이라는 식별자와 값이 Environment Record에 기록됩니다. 다음으로 console.log()가 실행되며, 현재 활성화된 실행 컨텍스트의 Environment Record에 pet이 있기 때문에 puppy라는 값을 출력합니다.
그 다음으로는, flower를 출력해야 되는데 현재 Environment Record에 flower가 없습니다. 따라서, Outer Environment Reference가 가리키는 바깥 Lexical Environment로 이동합니다. 이동한 Lexical Environment를 살펴보아도, flower가 없기 때문에 다시 바깥 Lexical Environment로 이동합니다. 마지막으로 이동한 전역 컨텍스트의 Lexical Environment를 살펴보아도 flower가 없습니다.
전역 컨텍스트까지 왔기 때문에 더 이상 이동할 수 있는 바깥 Lexical Environment가 없습니다. 때문에 자바스크립트 엔진은 flower가 없다는 결론을 내리고 없는 식별자를 참조할 수 없다는 Reference Error가 발생합니다.
만약, 해당 예제에서 flower가 아닌 wifi를 출력하면 어떻게 될까요?
let wifi = false;
function goTo2F() {
let wifi = true;
function goTo3F() {
console.log(wifi); // ?
}
goTo3F();
}
goTo2F();
현재 활성화된 goTo3F()의 실행 컨텍스트에는 wifi가 존재하지 않기 때문에 바깥 Lexical Environment로 이동합니다. goTo2F()의 실행 컨텍스트에는 wifi가 존재합니다. 식별자를 찾았기 때문에, true를 출력하며 함수가 종료됩니다.
여기서 wifi를 goTo2F()의 Lexical Environment에서 찾았기 때문에 식별자를 찾기 위해서 더 이상 바깥으로 이동하지 않습니다. 전역 컨텍스트에 존재하는 같은 이름의 식별자인 wifi를 찾으러 가지 않기 때문에, goTo2F와 goTo3F에서는 wifi의 값이 어떤 것인지 알 수가 없습니다.
즉, goTo2F() 함수 내부에서 wifi라는 변수를 선언했기 때문에 전역 공간에 있는 동일한 이름의 wifi라는 변수에는 접근할 수 없게 됩니다. 이렇게 동일한 식별자로 인해 상위 스코프에서 선언된 식별자의 값이 가려지는 현상을 변수 은닉화(Variable Shadowing)라고 합니다.
function outerFunc() {
var x = 10;
var innerFunc = function () {
console.log(x);
};
innerFunc();
}
outerFunc(); // ?
결과는 10
이 출력됩니다.
위에 있던 예제 코드랑 거의 똑같이 생겼는데, 굳이 한 번 더 예제를 가져온 이유는 클로저(closure)
라는 개념을 설명하기 위해서입니다.
클로저에 대해서 MDN 문서에서는 다음과 같이 정의하고 있습니다.
“A closure is the combination of a function and the lexical environment within which that function was declared.”
클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.
조금 더 쉽게 말하면, 클로저는 반환된 내부함수가 자신이 선언됐을 때의 환경(Lexical environment)인 스코프를 기억하여 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수를 말합니다. 즉, 자신이 생성될 때의 환경(Lexical environment)을 기억하는 함수입니다.
예제 코드에서, 내부 함수 innerFunc가 어떻게 x를 출력할 수 있는지는 위에서부터 계속 설명했던 내용입니다. 다시 정리해보면, 내부 함수 innerFunc가 Outer Environment Reference를 활용하여 바깥 Lexical Environment를 참조할 수 있기 때문이었습니다.
Outer Environment Reference를 통한 스코프 체이닝 과정에 대해서 충분히 이해했다면, 클로저라는 개념도 쉽게 이해할 수 있을 것 같습니다.
여기까지 실행 컨텍스트가 무엇인지, 실행 컨텍스트의 구조는 어떻게 생겼는지, Lexical Environment의 Environment Record와 Outer Environment Reference를 통해서 호이스팅과 스코프 체이닝에 대해서 알아봤습니다. 단어 자체도 그렇고, 개념도 그렇고 생소한 부분이 많아서 이해하는 데 글로 정리하기까지 많은 시간이 소요됐습니다.
하지만 이전에는 호이스팅은 단순히 '선언문이 가장 최상단으로 끌려져 오는 현상이다'라고만 이해했다면, 지금은 Environment Record를 공부하면서 호이스팅이 '왜' 선언문이 최상단에 있는 것처럼 보이는지 이해할 수 있게 되었습니다.
그리고, 자바스크립트의 스코프, 스코프 체이닝은 무엇이고 어떤 과정을 거치는지 알 수 있게 되었습니다. 특히 클로저는 저에게 계속 어렵고 복잡한 개념으로만 남아있었는데 Outer Environment Reference를 공부하면서 '왜' 클로저 같은게 가능한지, '왜' 클로저를 자신이 생성될 때의 Lexical environment를 기억하는 함수라고 부르는지 이번 포스팅을 통해서 정리된 것 같습니다.
기존보다 조금은 다른 방법으로 공부를 진행하고 있는데, 인풋이 많이 들어가기 때문에 '좋은' 공부 방법이 맞는지에 대해서는 고민을 더 해봐야겠지만 어쨌든 어제보다 더 나아가고 있다고 생각합니다. 조급해하지 않고 어제보다 한 발짝씩만 더 나아가는 개발자가 되고자 합니다. 다음 주에는 또 다른 주제로 찾아오겠습니다. 감사합니다.
10분 테코톡 채널의 '하루의 실행 컨텍스트' 영상을 많이 참고하여 정리했습니다.
https://velog.io/@paulkim/e
https://reese-dev.netlify.app/javascript/execution-context/
https://blog.wanzargen.me/36
https://m.blog.naver.com/dlaxodud2388/222655214381
https://joontae-kim.github.io/2020/10/15/excution-context-2/
https://262.ecma-international.org/5.1/#sec-10.2
https://www.zerocho.com/category/JavaScript/post/609778ad9f879900043a8728
https://www.zerocho.com/category/JavaScript/post/5741d96d094da4986bc950a0
https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf
https://espania.tistory.com/335
https://velog.io/@xedni/JavaScript-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9E%91%EB%8F%99-%EC%9B%90%EB%A6%ACEvent-Loop%EC%99%80-Call-Stack-Web-API-Callback-Queue
https://velog.io/@milkyway/%EB%A9%94%EB%AA%A8%EB%A6%AC-%ED%9E%99heap%EA%B3%BC-%EC%BD%9C%EC%8A%A4%ED%83%9DCall-stack
https://youtu.be/EWfujNzSUmw
https://youtu.be/ZF6aDhBp5r8
https://youtu.be/8aGhZQkoFbQ
https://serzhul.io/JavaScript/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EB%8F%99%EC%9E%91-%EB%B0%A9%EC%8B%9D/
https://velog.io/@dltjsgho/자바스크립트-실행-컨텍스트란
https://velog.io/@edie_ko/js-execution-context
https://www.daleseo.com/js-var-issues/
책) 코어 자바스크립트
설명이 정말 자세해서 이해에 큰 도움이 되었습니다. 감사합니다!