본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Frontend

실행 컨텍스트

작성 2026. 10. 21.추가
목차11개 항목
출처와 참고

「Must-Know-About-Frontend: 실행 컨텍스트」를 바탕으로, ECMA-262 명세의 「Execution Contexts」, 「Environment Records」 절로 최신화해 재구성한 노트다. 환경 레코드의 종류, 매개변수 스코프, funarg 문제, ES3 용어와의 대응은 Dmitry Soshnikov의 「JavaScript. The Core」, ECMA-262-3 시리즈(「Execution Contexts」, 「Variable object」, 「Scope chain」, 「Closures」), ECMA-262-5 「Lexical environments」 두 편(Common Theory, ECMAScript implementation), 「Default values of parameters」을 바탕으로 보강했다.

실행 컨텍스트란

실행 컨텍스트(execution context)는 엔진이 코드 한 덩어리를 실행하는 동안 필요한 정보(어떤 이름이 보이는지, this가 무엇인지, 어디까지 실행했는지)를 담아 두는 명세상의 단위다. 코드에서 직접 꺼낼 수 없는 내부 개념이지만, 호이스팅·스코프 체인·클로저·this가 모두 이 구조에서 나온다. 각각의 자세한 동작은 스코프와 클로저, 함수와 this에 있고, 이 노트는 그 공통 뼈대를 다룬다.

종류만들어지는 때
전역 실행 컨텍스트스크립트를 평가하기 시작할 때 하나
모듈 실행 컨텍스트ES 모듈 파일을 평가할 때 파일마다 하나. this는 undefined다
함수 실행 컨텍스트함수를 호출할 때마다 하나. 정의할 때가 아니다
eval 실행 컨텍스트eval(code)를 실행할 때(strict mode의 「eval과 strict mode」)

실행 컨텍스트 스택(콜 스택)

컨텍스트는 실행 컨텍스트 스택, 흔히 말하는 콜 스택에 쌓인다. 맨 위에 있는 것이 지금 실행 중인 컨텍스트(running execution context)다.

function inner() { console.log('inner') }
function outer() { inner() }
outer()
flowchart LR
  e0["[전역]"] -->|"outer() 호출"| e1["[전역, outer]"]
  e1 -->|"inner() 호출"| e2["[전역, outer, inner]"]
  e2 -->|"inner 반환"| e3["[전역, outer]"]
  e3 -->|"outer 반환"| e4["[전역]"]
  e4 -->|"스크립트 끝"| e5["[]"]
  • 함수를 부르면 현재 컨텍스트는 멈춘 채 스택에 남고, 새 컨텍스트가 위에 올라간다. 반환하면 꺼내지고 멈춘 곳부터 이어 간다.
  • 스택 깊이에는 한도가 있어, 끝나지 않는 재귀는 RangeError: Maximum call stack size exceeded로 끝난다(함수와 this의 「재귀와 콜 스택」).
  • 스택이 비어야 이벤트 루프가 다음 태스크를 꺼낸다(이벤트 루프).

구성 요소

명세의 실행 컨텍스트는 코드 평가 상태, 실행 중인 함수, Realm(전역 객체와 내장 객체 묶음), 스크립트·모듈 정보와 함께 아래 세 환경 슬롯을 가진다.

슬롯담는 것
LexicalEnvironmentlet·const·class 선언. 블록에 들어갈 때마다 새 환경으로 바뀐다
VariableEnvironmentvar와 함수 선언. 함수(또는 스크립트) 단위로 하나이고 블록에서 바뀌지 않는다
PrivateEnvironment클래스 본문의 #private 이름(ES2022에 추가)

각 환경은 환경 레코드(Environment Record) 와 바깥 환경 참조(outer) 로 이뤄진다. 레코드에서 이름을 못 찾으면 outer를 따라 올라가고, outer가 null인 전역 환경에서도 없으면 ReferenceError다. 이 연결이 스코프 체인이다(스코프와 클로저).

환경 레코드의 종류와 식별자 해석

환경 레코드는 이름을 무엇에 저장하느냐에 따라 둘로 나뉜다.

종류저장 방식쓰이는 곳
선언적(declarative) 레코드엔진 내부 구조. 이름 목록이 코드에서 정적으로 정해진다함수(함수 환경 레코드), 블록, catch, 모듈(모듈 환경 레코드)
객체(object) 레코드실제 JavaScript 객체의 속성전역 객체, with (obj) 블록

선언적 레코드는 어떤 이름이 어느 칸에 있는지 코드만 보고 알 수 있어서, 엔진이 이름을 해시 조회 대신 "몇 단계 바깥의 몇 번째 칸" 같은 고정 위치로 바꿔 빠르게 접근한다. 클로저가 바깥 변수 중 실제로 쓰는 것만 붙잡도록 최적화할 수 있는 것도 이 덕분이다.

이 최적화를 깨는 것이 둘 있다.

  • with: 객체 레코드를 스코프 체인 맨 앞에 끼운다. 블록 안의 x가 obj.x인지 바깥 변수인지 실행해 봐야 안다. 그래서 strict mode에서 금지됐다.
  • 직접 eval: 문자열 안에서 var를 선언해 바깥 환경에 이름을 더할 수 있다(sloppy mode). 엔진은 eval이 보이는 함수의 환경을 통째로 남겨 둬야 한다. strict mode의 eval은 자기 환경을 따로 만든다(strict mode).

위의 outer 사슬을 따라 이름을 찾는 일(식별자 해석)의 결과는 값이 아니라 참조(Reference: 어느 레코드·객체의 어떤 이름인가) 다. 참조를 읽을 때 값을 꺼내고, 대입할 때 그 칸에 쓴다. obj.method() 호출에서 this가 obj가 되는 것도 이 참조의 「어느 객체」를 쓰기 때문이다(함수와 this).

전역 환경은 두 칸이다

전역 환경 레코드는 위 두 종류를 함께 가진다. 객체 레코드는 전역 객체 window에 연결되고, 선언적 레코드는 엔진 내부에 있다. var와 함수 선언은 객체 레코드에 들어가 window의 속성이 되고, let·const·class는 선언적 레코드에 들어가 전역이지만 window에는 붙지 않는다.

var a = 1
let b = 2
window.a // 1
window.b // undefined

this는 환경 레코드에 있다

ES2015부터 this는 실행 컨텍스트의 별도 칸이 아니라 환경 레코드가 들고 있는 값이다.

환경 레코드this
전역전역 객체(globalThis)
모듈undefined
일반 함수호출 방식에 따라 정해진 값(함수와 this의 「this」)
화살표 함수자기 this가 없다. 레코드에 바인딩이 없어서 outer를 따라 바깥의 this를 찾는다

화살표 함수의 this가 "렉시컬"인 이유가 이것이다. 변수를 찾듯 바깥 환경에서 찾는다.

생성 단계와 실행 단계

컨텍스트는 두 단계를 거친다.

  1. 생성 단계: 코드를 한 줄도 실행하기 전에 그 범위의 선언을 모두 찾아 환경 레코드에 등록한다. 함수 선언은 본문까지 함수 객체로 바로 초기화하고, var는 undefined로 초기화하고, let·const·class는 초기화하지 않은(uninitialized) 상태로 둔다. 함수 컨텍스트라면 매개변수와 arguments도 이때 채운다.
  2. 실행 단계: 위에서부터 코드를 실행하며 대입문을 만날 때 값을 넣는다. let·const는 선언 줄에 닿는 순간 초기화된다.
var a = 3
let b = 4
 
function sum(num) {
  var t = 9
  return a + b + num + t
}
 
var r = sum(4)
flowchart TB
  g1["전역 생성 단계<br/>VariableEnvironment: a: undefined, r: undefined, sum: #lt;function#gt;<br/>LexicalEnvironment: b: #lt;uninitialized#gt;"]
  g2["전역 실행 단계(sum 호출 직전)<br/>VariableEnvironment: a: 3, r: undefined, sum: #lt;function#gt;<br/>LexicalEnvironment: b: 4"]
  f1["sum(4) 생성 단계<br/>num: 4, arguments: [4]<br/>t: undefined<br/>outer: 전역 환경"]
  f2["sum(4) 실행 단계<br/>num: 4, arguments: [4]<br/>t: 9"]
  g3["전역의 r에 20이 들어간다"]
  g1 --> g2 --> f1 --> f2
  f2 -->|"반환 · 20을 반환하고 스택에서 빠진다"| g3

이 두 단계에서 익숙한 현상들이 나온다.

현상실행 컨텍스트로 보는 원인
호이스팅생성 단계에서 선언을 먼저 등록하므로, 선언 위쪽에서도 이름이 이미 있다
var를 선언 전에 읽으면 undefined생성 단계에서 undefined로 초기화했다
TDZlet·const는 등록만 되고 초기화되지 않은 상태라, 그 사이 접근은 ReferenceError다
함수 선언은 선언 전에 호출된다생성 단계에서 본문까지 초기화했다. 같은 이름의 var가 있어도 대입문이 실행되기 전까지는 함수다
var가 블록을 무시한다블록은 LexicalEnvironment만 새로 만들고 VariableEnvironment는 그대로 두기 때문이다
클로저함수는 만들어질 때의 환경을 기억하고, 호출 때 새 환경의 outer로 그 환경을 쓴다

선언 종류별 호이스팅 표와 TDZ 예시는 스코프와 클로저에, 엔진 파이프라인에서 컨텍스트가 만들어지는 위치는 JavaScript 엔진과 V8에 있다.

매개변수 기본값이 있으면 스코프가 하나 더 생긴다

기본값이 하나라도 있는 함수는 매개변수만 담는 환경을 본문 환경과 따로 만든다. 기본값 식은 그 환경에서 왼쪽부터 차례로, 호출할 때마다 평가된다.

function f(a = b, b = 1) {} // f() → ReferenceError: b는 아직 TDZ다
function g(a, b = a * 2) {} // 앞 매개변수는 쓸 수 있다
 
const x = 1
function h(cb = () => x) {
  var x = 2       // 본문의 x. 기본값 식의 클로저는 이 x를 보지 못한다
  return cb()     // 1
}
  • 매개변수도 let처럼 TDZ를 가진다. 오른쪽 매개변수를 왼쪽 기본값에서 쓰면 에러다.
  • 기본값 안의 클로저는 매개변수 환경을 붙잡으므로, 본문의 같은 이름 var와 섞이지 않는다.
  • 기본값이 매 호출 새로 평가된다는 점은 Python(정의할 때 한 번 평가)과 다르다. function push(item, list = [])의 list는 호출마다 새 배열이다.

클로저: funarg 문제의 해법

함수를 값으로 넘기는 언어는 funarg 문제를 풀어야 한다. 함수를 인자로 넘길 때(아래로 가는 funarg) 그 함수 안의 자유 변수를 호출한 쪽이 아니라 정의한 쪽의 환경에서 찾아야 하고(정적·렉시컬 스코프), 함수를 반환할 때(위로 가는 funarg) 만든 함수의 실행 컨텍스트가 스택에서 빠진 뒤에도 그 환경이 살아 있어야 한다. 그래서 환경은 스택이 아니라 힙에 두고, 함수 객체가 만들어질 때 그 환경을 [[Environment]] 슬롯에 저장한다. 이것이 클로저다.

  • 같은 실행 컨텍스트에서 만든 클로저들은 하나의 환경을 공유한다. 그래서 var로 돈 반복문의 콜백들은 모두 마지막 i를 보고, 한 클로저가 쓰는 큰 값은 다른 클로저가 살아 있는 동안 회수되지 않는다(가비지 컬렉션).
  • new Function으로 만든 함수의 [[Environment]]는 언제나 전역 환경이다. 만든 자리의 지역 변수를 볼 수 없다(함수와 this의 「new Function」).

역사: ES3의 변수 객체와 활성 객체

ES5 이전 명세는 같은 구조를 다른 이름으로 설명했다. 옛 글에서 VO·AO·[[Scope]]를 보면 아래처럼 바꿔 읽는다.

ES3 용어지금의 용어
변수 객체(Variable Object, VO)환경 레코드
활성 객체(Activation Object, AO): 함수 컨텍스트의 VO. 매개변수·arguments·지역 변수를 담았다함수 환경 레코드
전역 컨텍스트의 VO = 전역 객체전역 환경 레코드(객체 레코드 + 선언적 레코드)
스코프 체인([[Scope]] + 현재 AO의 목록)LexicalEnvironment의 outer 사슬, 함수의 [[Environment]]
컨텍스트에 들어갈 때(entering) / 코드 실행(execution)생성 단계 / 실행 단계

ES3 모델에서는 VO가 "객체"라 일부 엔진이 __parent__로 들여다보게 해 줬다. ES5가 이를 환경 레코드로 바꾸며 블록 스코프(let·const)와 정적 최적화를 설명할 수 있게 됐다.

정리

  • 실행 컨텍스트는 전역·모듈·함수 호출·eval마다 만들어지고, 콜 스택에 쌓였다가 실행이 끝나면 빠진다.
  • 컨텍스트의 LexicalEnvironment는 let·const·class를, VariableEnvironment는 var와 함수 선언을 담고, 각 환경은 outer로 바깥과 이어진다. 레코드는 정적으로 정해지는 선언적 레코드와 객체 속성을 쓰는 객체 레코드(전역, with)로 나뉜다.
  • this는 환경 레코드에 담긴다. 화살표 함수는 자기 this가 없어 바깥에서 찾는다.
  • 생성 단계에서 선언을 먼저 등록하고 실행 단계에서 값을 채운다. 호이스팅·TDZ·클로저가 모두 이 순서에서 나온다. 기본값 있는 매개변수는 본문과 별도 스코프에서 왼쪽부터 평가된다.
  • 클로저는 함수가 만들어질 때의 환경을 힙에 붙잡아 funarg 문제를 푼다. 같은 컨텍스트의 클로저들은 그 환경을 공유한다.

관련 노트: 스코프와 클로저, 함수와 this, 이벤트 루프, JavaScript 엔진과 V8, 변수와 데이터 타입

원본 기록11

댓글

GitHub 계정으로 댓글을 남길 수 있어요.