JavaScript 엔진은 소스코드를 실행하기 전에 먼저 "이 코드가 어떤 타입인지"를 구분한다. 왜냐하면 코드의 성격에 따라 어떤 실행 컨텍스트를 만들지, 어떤 규칙을 적용할지가 달리지기 때문이다. JavaScript의 소스코드 타입은 크게 네 가지로 나뉜다.
전역 코드 (Global Code)
전역 코드란 말 그대로 프로그램의 최상위 레벨에서 작성된 코드 전체를 말한다. 전역 코드가 실행되면 전역 실행 컨텍스트가 생성되고, 전역 객체와 연결된다. 이때 var로 선언된 변수는 전역 객체의 프로퍼티가 되고, let이나 const로 선언된 변수는 전역 객체 프로퍼티가 아닌 전역 렉시컬 환경에 기록된다. 비엄격 모드에서 전역 this는 전역 객체를 가리키지만, 모듈이나 엄격 모드에서는 undefined가 된다.
함수 코드 (Function Code)
함수 본문에 작성된 코드가 함수 코드다. 함수가 호출될 때마다 새로운 함수 실행 컨텍스트가 생성된다. 함수 코드에는 매개변수, 지역 변수, 내부 함수, 그리고 arguments 객체가 포함된다. 함수 호출 방식에 따라 this가 어떤 값을 가리킬지도 함께 결정된다.
모듈 코드 (Module Code)
ES6 이후 도입된 모듈 단위의 코드다. 모듈 코드가 실행되면 모듈실행 컨텍스트가 생성되며, 특징은 항상 엄격 모드가 적용된다는 점이다. 모듈 최상위 레벨에서 this는 undefined이고, import나 export 같은 선언문이 사용된다. 모듈은 기본적으로 독립된 실행 환경을 가지며, 전역 객체를 직접 오염시키지 않는다.
eval 코드 (Eval Code)
eval() 함수에 문자열로 전달된 코드가 eval 코드다. eval은 자체 실행 컨텍스트를 생성할 수 있지만, 보안과 성능 문제 때문에 권장되지 않는다. 이론적으로만 알고 넘어가면 된다.
// 전역 코드
var x = 1;
// 함수 코드
function foo() {
var y = 2;
console.log(y);
}
// 모듈 코드 (별도 파일)
export const z = 3;
// eval 코드
eval('const k = 4; console.log(k);');
👩🏻🏫 정리하자면, JavaScript는 코드 타입에 따라 서로 다른 실행 컨텍스트를 생성하고, 그 컨텍스트의 초기 설정이 달라진다. 그래서 실행 컨텍스트를 이해할 때는 먼저 "지금 코드가 전역인지, 함수인지, 모듈인지, eval인지"를 구분하는 게 중요하다.
실행 컨텍스트는 JavaScript 코드가 실행되는 "환경 상자"다.
이 상자 안에는 변수와 함수, 스코프 체인, this 바인딩 같은 실행에 필요한 모든 정보가 들어 있다.
실행 컨텍스트를 이해하지 못하면 변수 참조가 왜 그렿게 되는지, 함수 호출 시 this가 왜 다른 객체를 가리키는지 설명할 수 없다.
한마디로, 실행 컨텍스트는 "JavaScript 엔진이 코드를 실행하기 위해 준비해 둔 무대"라고 볼 수 있다. 배우가 무대 위에 올라 연기를 하려면 배경, 소품, 대사집이 필요하듯이, 코드가 실행되려면 변수, 함수, 스코프, this 같은 맥락이 필요하다. 이 모든 것을 묶어 관리하는 게 실행 컨텍스트다.
JavaScript 엔진은 코드를 바로 실행하지 않는다.
먼저 평가 단계를 거쳐 실행을 준비한 후, 그 다음에 실행 단계에서 실제로 코드를 실행한다.
console.log(a);
var a = 10;
이 코드에서 console.log(a)가 undefined를 출력하는 이유는 평가 단계에서 이미 var a가 전역 환경에 undefined로 등록되었기 때문이다. 만약 let이나 const로 선언했다면 평가 단계에서는 TDZ에만 등록되고 초기화가 되지 않았기 때문에 ReferenceError가 발생한다.
👩🏻🏫 이렇게 평가와 실행을 분리해 이해하면, 우리가 흔히 말하는 호이스팅이 실제로는 "평가 단계에서 선언이 미리 등록되는 동작"임을 이해할 수 있다.
실행 컨텍스트는 코드 실행을 네 가지 흐름으로 나누어 관리한다.
function foo() {
var x = 1;
console.log(x);
}
foo();
이 코드가 실행될 때, 엔진은 먼저 전역 코드 평가 단계에서 foo 함수를 등록한다. 전역 코드 실행 단계에서 foo가 호출되면 함수 코드 평가 단계에서 foo 실행 컨텍스트가 생성되고, 지역 변수 x가 등록된다. 이후 함수 코드 실행 단계에서 x = 1이 할당되고 console.log가 실행되어 1이 출력된다.
👩🏻🏫 즉, 실행 컨텍스트는 평가 → 실행의 사이클을 전역과 함수 단위로 반복하면서 프로그램을 굴려가는 역할을 한다.
실행 컨텍스트는 스택(LIFO) 구조로 관리된다. 이를 콜 스택이라고 한다.
const x = 1;
function foo() {
function bar() {
console.log(x);
}
bar();
}
foo();
실행 순서는 다음과 같다.
전역 코드 평가/실행 → foo 코드 평가/실행 → bar 코드 평가/실행 → bar 종료 후 foo로 복귀 → foo 종료 후 전역 복귀
👩🏻🏫 콜 스택을 이해하면 재귀 함수 호출, 비동기 실행 순서, 에러 스택 트레이스 같은 개념도 자연스럽게 이해된다! (이후에 다룰 예정..~)
렉시컬 환경은 실행 컨텍스트 내부에서 변수와 함수의 식별자 → 값 매핑과 상위 스코프 참조를 관리하는 구조다.
const a = 1;
function outer() {
const b = 2;
function inner() {
const c = 3;
console.log(a, b, c);
}
inner();
}
outer();
inner 함수의 실행 컨텍스트가 만들어지면, c는 inner 환경에서 찾고, b는 outer 환경에서 찾으며, a는 전역 환경에서 찾는다. 이처럼 함수는 선언된 위치에 따라 상위 스코프가 고정되는데, 이것을 렉시컬 스코프라고 한다. (지난 시간에 공부했으니 잘 알고 있)
👩🏻🏫 렉시컬 환경은 실행 컨텍스트가 "변수를 어떻게 찾는지"를 결정하는 핵심 구조다.
실행 컨텍스트는 코드 실행 전과 실행 중에 다른 역할을 한다.
const x = 1;
function foo() {
const y = 2;
function bar() {
const z = 3;
console.log(x, y, z);
}
bar();
}
foo();
bar에서 변수를 참조할 때, z는 bar 스코프, y는 foo 스코프, x는 전역 스코프에서 찾는다. 만약 어디에서도 찾지 못하면 ReferenceError가 발생한다.
👩🏻🏫 이를 "내 방 → 바깥 방 → 제일 큰 방"을 차례로 뒤지는 보물찾기에 비유할 수 있다.
렉시컬 환경의 내부를 더 들여다보면 환경 레코드라는 세부 구조가 있다. 환경 레코드는 상황에 따라 다르게 동작한다.
var x = 1; // 객체 환경 레코드 → window.x
let y = 2; // 선언적 환경 레코드
function f(z) { // 함수 환경 레코드
console.log(z);
}
👩🏻🏫 이 구조 덕분에 var와 let/const의 차이가 생기고, TDZ가 존재한다.
실행 컨텍스트는 생성될 때마다 this도 함께 바인딩된다.
function test() {
console.log(this);
}
test(); // window (비엄격), undefined (엄격)
({ f: test }).f(); // { f: test } 객체
new test(); // test 인스턴스
test.call({ x: 1 }); // { x: 1 }
👩🏻🏫 실행 컨텍스트는 단순히 변수만 관리하는 게 아니라, 실행되는 시점의 this까지 책임진다.
비동기 코드도 결국 실행될 때는 실행 컨텍스트 안에서 실행된다. 다만 차이점은 즉시 콜 스택에 쌓이지 않고 이벤트 루프와 큐를 거친다는 점이다.
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
console.log(4);
실행 순서는 1 → 4 → 3 → 2다.
1과 4는 전역 실행 컨텍스트에서 동기적으로 실행된다.
3은 마이크로태스크 큐에서 먼저 실행되고, 2는 태스크 큐에서 실행된다.
👩🏻🏫 결국 비동기 코드도 실행 컨텍스트와 콜 스택 구조 안에서 처리되며, 이벤트 루프가 그 순서를 조율한다.