클로저와 실행 컨텍스트
실행 컨텍스트와 호출 스택, 렉시컬 환경을 따라 변수 탐색을 이해하고 클로저가 외부 상태를 기억하는 원리를 익힙니다.
5장에서는 자바스크립트의 문법부터 함수, DOM 조작과 이벤트까지 페이지에 동작을 붙이는 데 필요한 내용을 익혔습니다. 6장에서는 한 걸음 더 나아가, 코드가 왜 그렇게 동작하는지를 살펴봅니다. 코드가 어떤 순서로 실행되고 변수의 이름을 어떻게 찾는지 알면, 예상과 다르게 동작하는 코드의 원인을 훨씬 빨리 찾을 수 있습니다.
첫 주제는 실행 컨텍스트(execution context)와 클로저(closure)입니다. 자바스크립트는 렉시컬 스코핑(lexical scoping)을 따르므로, 함수를 어디에 선언했는지가 그 함수의 스코프를 정합니다. 이 특성과 함수 실행의 흐름이 합쳐져 클로저가 만들어지며, 클로저는 비동기 처리, 모듈, 데이터 숨기기의 바탕이 됩니다.
클로저와 렉시컬 환경의 수명 — 상태 추적
counter1/2의 반환 함수와 독립 환경 count가 호출 뒤에도 남고 각각 다르게 변한다. 함수 frame 종료와 환경 보존/독립 변경을 고정 객체로 추적할 가치가 있다.
LIFETIME · LEXICAL SCOPE
클로저: 호출이 끝나도 남는 바인딩
makeCounter는 호출마다 독립적인 count를 만든다. 반환된 함수를 계속 참조하면 바깥 호출이 끝난 뒤에도 해당 count에 접근할 수 있다.
counter1 → 함수 1 → 환경 1
makeCounter 첫 호출이 만든 count를 사용한다. counter1()의 반환값은 1 → 2 → 3이다.
counter2 → 함수 2 → 환경 2
makeCounter 둘째 호출은 별도의 count를 만든다. counter2()는 1을 반환하고 counter1의 count를 바꾸지 않는다.
실행 컨텍스트 종료
makeCounter 호출이 끝나면 그 실행 컨텍스트는 호출 스택에서 제거된다. 반환된 함수의 외부 바인딩 접근은 계속 가능하다.
마지막 유지 경로 제거
함수와 필요한 환경으로 이어지는 모든 도달 경로가 없어지면 회수 대상이 될 수 있다. 참조 해제가 즉시 GC 실행을 뜻하지는 않는다.
스택의 수명과 바인딩의 수명은 다르다
클로저가 종료된 실행 컨텍스트를 스택에 남겨 두는 것은 아니다. 반환된 함수는 자신이 만들어진 렉시컬 환경을 통해 필요한 바인딩에 접근한다. 그림은 의미 관계를 나타내며 엔진의 실제 메모리 배치를 지정하지 않는다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 본문의 실제 호출 순서 counter1(), counter1(), counter2(), counter1()을 따릅니다. 각 함수가 보존한 count는 독립적이며 호출 횟수를 다른 순서로 재배열하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | counter1에서 함수 1, 환경 1로 이어지는 참조와 counter2에서 함수 2, 환경 2로 이어지는 참조는 독립적이다. 첫 환경 count는 0부터 1, 2, 3으로 바뀌고 둘째는 0부터 1로 바뀐다. 화살표는 실행 순서가 아닌 함수와 환경의 의미상 참조다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| counter1 두 번 호출 | counter1: 2 / counter2: 0 | 독립 count=0 환경을 만든 뒤 counter1을 두 번 호출한다 | counter1이 보존한 count만 0→1→2로 증가하고 counter2는 아직 호출되지 않았다 |
| counter2 첫 호출 | counter1: 2 / counter2: 1 | 다음 호출은 counter2의 독립 count를 증가시킨다 | 같은 함수 정의여도 독립 환경을 참조하므로 counter2의 첫 결과는 1이다 |
| counter1 다시 호출 | counter1: 3 / counter2: 1 | 마지막 counter1 호출은 이전 count=2에서 계속한다 | 외부 생성 함수의 frame 종료 뒤에도 반환 함수가 참조한 환경은 남아 counter1만 3으로 증가한다 |
클로저와 렉시컬 환경의 수명 — 상태 추적
counter1/2의 반환 함수와 독립 환경 count가 호출 뒤에도 남고 각각 다르게 변한다. 함수 frame 종료와 환경 보존/독립 변경을 고정 객체로 추적할 가치가 있다.
LIFETIME · LEXICAL SCOPE
클로저: 호출이 끝나도 남는 바인딩
makeCounter는 호출마다 독립적인 count를 만든다. 반환된 함수를 계속 참조하면 바깥 호출이 끝난 뒤에도 해당 count에 접근할 수 있다.
counter1 → 함수 1 → 환경 1
makeCounter 첫 호출이 만든 count를 사용한다. counter1()의 반환값은 1 → 2 → 3이다.
counter2 → 함수 2 → 환경 2
makeCounter 둘째 호출은 별도의 count를 만든다. counter2()는 1을 반환하고 counter1의 count를 바꾸지 않는다.
실행 컨텍스트 종료
makeCounter 호출이 끝나면 그 실행 컨텍스트는 호출 스택에서 제거된다. 반환된 함수의 외부 바인딩 접근은 계속 가능하다.
마지막 유지 경로 제거
함수와 필요한 환경으로 이어지는 모든 도달 경로가 없어지면 회수 대상이 될 수 있다. 참조 해제가 즉시 GC 실행을 뜻하지는 않는다.
스택의 수명과 바인딩의 수명은 다르다
클로저가 종료된 실행 컨텍스트를 스택에 남겨 두는 것은 아니다. 반환된 함수는 자신이 만들어진 렉시컬 환경을 통해 필요한 바인딩에 접근한다. 그림은 의미 관계를 나타내며 엔진의 실제 메모리 배치를 지정하지 않는다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 본문의 실제 호출 순서 counter1(), counter1(), counter2(), counter1()을 따릅니다. 각 함수가 보존한 count는 독립적이며 호출 횟수를 다른 순서로 재배열하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | counter1에서 함수 1, 환경 1로 이어지는 참조와 counter2에서 함수 2, 환경 2로 이어지는 참조는 독립적이다. 첫 환경 count는 0부터 1, 2, 3으로 바뀌고 둘째는 0부터 1로 바뀐다. 화살표는 실행 순서가 아닌 함수와 환경의 의미상 참조다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| counter1 두 번 호출 | counter1: 2 / counter2: 0 | 독립 count=0 환경을 만든 뒤 counter1을 두 번 호출한다 | counter1이 보존한 count만 0→1→2로 증가하고 counter2는 아직 호출되지 않았다 |
| counter2 첫 호출 | counter1: 2 / counter2: 1 | 다음 호출은 counter2의 독립 count를 증가시킨다 | 같은 함수 정의여도 독립 환경을 참조하므로 counter2의 첫 결과는 1이다 |
| counter1 다시 호출 | counter1: 3 / counter2: 1 | 마지막 counter1 호출은 이전 count=2에서 계속한다 | 외부 생성 함수의 frame 종료 뒤에도 반환 함수가 참조한 환경은 남아 counter1만 3으로 증가한다 |
위 그림은 makeCounter()를 두 번 호출해 만든 두 카운터가 각자의 count를 가지는 모습을 보여 줍니다. counter1은 첫 호출에서 돌려받은 함수 1을 가리키고, 함수 1은 그 호출에서 만들어진 환경 1의 count를 씁니다. counter2는 둘째 호출의 함수 2와 환경 2를 씁니다. 그래서 counter1()을 세 번 부르면 환경 1의 count가 1, 2, 3으로 늘고, counter2()는 환경 2의 count를 1로 만들 뿐 환경 1에는 영향을 주지 않습니다.
그림 아래쪽은 두 가지 수명을 구분합니다. makeCounter()의 호출이 끝나면 그 실행 컨텍스트는 호출 스택에서 빠지지만, 돌려받은 함수가 남아 있는 동안에는 그 함수가 쓰는 count도 계속 접근할 수 있습니다. 함수와 그 환경을 가리키는 모든 참조가 사라져야 비로소 메모리에서 회수될 수 있으며, 참조가 사라졌다고 바로 회수되는 것도 아닙니다. 그림은 이 관계를 나타낼 뿐 엔진의 실제 메모리 배치를 뜻하지는 않습니다.
실행 컨텍스트
자바스크립트 코드가 실행되려면 지금 어디를 실행하고 있는지, 변수의 이름을 어디서 찾을지 같은 정보가 필요합니다. ECMAScript 명세는 이런 실행 상태를 실행 컨텍스트라는 모델로 설명합니다. 실행 컨텍스트는 동작을 설명하기 위한 개념이라, 엔진이 반드시 이런 모양의 객체를 메모리에 만든다는 뜻은 아닙니다. 이 글의 그림과 표도 호출과 복귀의 순서, 이름을 찾는 경로를 따라가기 위한 모델입니다.
실행 컨텍스트의 종류
이 글의 예제처럼 일반 <script>(classic script)에서 동기적으로 실행되는 코드는 두 종류의 실행 컨텍스트로 나눠 볼 수 있습니다. 모듈이나 제너레이터 같은 경우에는 규칙이 더 있으므로, 이 두 가지를 모든 경우의 목록으로 외우지는 않습니다.
전역 실행 컨텍스트(global execution context)는 스크립트가 처음 실행될 때 만들어지는 맨 바깥의 컨텍스트입니다. 브라우저의 일반 스크립트에서 전역 코드의 this는 전역 객체인 window이고, Node.js의 전역 객체는 globalThis로 가리키는 global입니다. 전역 코드를 실행하는 동안 이 컨텍스트가 스택에 있으며, 스크립트의 실행이 끝나 스택에서 빠진 뒤에도 전역 변수의 값은 남아 있습니다.
함수 실행 컨텍스트(function execution context)는 함수를 호출할 때마다 따로 만들어집니다. 호출마다 매개변수와 지역 변수가 새로 만들어지고, 바깥 범위와의 연결은 함수를 정의한 위치를 따릅니다. 동기 함수가 끝나면 그 실행 컨텍스트는 스택에서 빠지지만, 클로저가 쓰는 변수까지 그 순간 없어지는 것은 아닙니다.
호출 스택
호출 스택의 push와 pop — 상태 추적
first/second 호출·종료에서 스택이 push/pop되고 현재 실행 함수가 바뀐다. 고정 스택 칸으로 6개 snapshot을 전환하면 복귀 순서를 명확히 읽는다.
SEQUENCE · WEB PLATFORM CONTRACT
호출 스택의 push와 pop
first가 second를 호출하면 second가 먼저 끝나고 first로 복귀한다. 각 스냅샷은 호출과 종료 뒤의 스택을 아래 전역 프레임부터 위 현재 프레임까지 보여 준다.
- PUSH global
script 시작 — stack [global]
- CALL first · PUSH first
first activation 시작 — stack [first, global]
- CALL second · PUSH second
second activation 시작 — stack [second, first, global]
- RETURN second · POP second
second activation 종료 — stack [first, global]
- RETURN first · POP first
first activation 종료 — stack [global]
- script end · POP global
stack [empty]. 호출한 역순으로만 frame이 제거된다.
가장 위에 있는 실행 컨텍스트가 실행한다
본문의 first·second는 명시적 return 없이 끝나므로 undefined를 반환한다. 이 동기 호출 예시에서는 second → first → script 순으로 스택에서 제거된다. script가 끝나도 전역 바인딩이 모두 없어지는 것은 아니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 시간 순서 1부터 6으로 전역 코드, first 호출, second 호출, second 종료, first 종료, script 종료가 이어진다. 각 영역의 아래쪽부터 전역과 호출된 함수의 프레임이 쌓이며 맨 위 프레임에서 현재 실행한다. 종료하면 호출 역순으로 제거된다. 마지막 스택은 비지만 전역 바인딩의 삭제를 뜻하지 않는다. |
| 원도표 조건·주석 | PUSH global: script 시작 — stack [global] |
| 원도표 조건·주석 | CALL first · PUSH first: first activation 시작 — stack [first, global] |
| 원도표 조건·주석 | CALL second · PUSH second: second activation 시작 — stack [second, first, global] |
| 원도표 조건·주석 | RETURN second · POP second: second activation 종료 — stack [first, global] |
| 원도표 조건·주석 | RETURN first · POP first: first activation 종료 — stack [global] |
| 원도표 조건·주석 | script end · POP global: stack [empty]. 호출한 역순으로만 frame이 제거된다. |
| 원도표 설명 | 가장 위에 있는 실행 컨텍스트에서 코드가 실행된다 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 전역에서 first | stack: global → first / 현재: first | global 실행 위에 first 호출 frame이 올라간다 | 호출은 새 실행 frame을 쌓는다 |
| second 호출과 복귀 | second 실행 중 stack: global → first → second / second 반환 후 stack: global → first | first가 second를 호출해 쌓은 frame은 second가 반환하면 제거된다 | second 실행 중에는 맨 위 second frame이 실행되고, 반환 후 first frame에서 이어 실행한다 |
| 모두 반환 | stack: 비어 있음 / global binding: 자동 삭제 아님 | first와 script 실행 frame이 사라진다 | 콜 스택의 frame과 도달 가능한 전역 값의 수명은 다르다 |
호출 스택의 push와 pop — 상태 추적
first/second 호출·종료에서 스택이 push/pop되고 현재 실행 함수가 바뀐다. 고정 스택 칸으로 6개 snapshot을 전환하면 복귀 순서를 명확히 읽는다.
SEQUENCE · WEB PLATFORM CONTRACT
호출 스택의 push와 pop
first가 second를 호출하면 second가 먼저 끝나고 first로 복귀한다. 각 스냅샷은 호출과 종료 뒤의 스택을 아래 전역 프레임부터 위 현재 프레임까지 보여 준다.
- PUSH global
script 시작 — stack [global]
- CALL first · PUSH first
first activation 시작 — stack [first, global]
- CALL second · PUSH second
second activation 시작 — stack [second, first, global]
- RETURN second · POP second
second activation 종료 — stack [first, global]
- RETURN first · POP first
first activation 종료 — stack [global]
- script end · POP global
stack [empty]. 호출한 역순으로만 frame이 제거된다.
가장 위에 있는 실행 컨텍스트가 실행한다
본문의 first·second는 명시적 return 없이 끝나므로 undefined를 반환한다. 이 동기 호출 예시에서는 second → first → script 순으로 스택에서 제거된다. script가 끝나도 전역 바인딩이 모두 없어지는 것은 아니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 시간 순서 1부터 6으로 전역 코드, first 호출, second 호출, second 종료, first 종료, script 종료가 이어진다. 각 영역의 아래쪽부터 전역과 호출된 함수의 프레임이 쌓이며 맨 위 프레임에서 현재 실행한다. 종료하면 호출 역순으로 제거된다. 마지막 스택은 비지만 전역 바인딩의 삭제를 뜻하지 않는다. |
| 원도표 조건·주석 | PUSH global: script 시작 — stack [global] |
| 원도표 조건·주석 | CALL first · PUSH first: first activation 시작 — stack [first, global] |
| 원도표 조건·주석 | CALL second · PUSH second: second activation 시작 — stack [second, first, global] |
| 원도표 조건·주석 | RETURN second · POP second: second activation 종료 — stack [first, global] |
| 원도표 조건·주석 | RETURN first · POP first: first activation 종료 — stack [global] |
| 원도표 조건·주석 | script end · POP global: stack [empty]. 호출한 역순으로만 frame이 제거된다. |
| 원도표 설명 | 가장 위에 있는 실행 컨텍스트에서 코드가 실행된다 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 전역에서 first | stack: global → first / 현재: first | global 실행 위에 first 호출 frame이 올라간다 | 호출은 새 실행 frame을 쌓는다 |
| second 호출과 복귀 | second 실행 중 stack: global → first → second / second 반환 후 stack: global → first | first가 second를 호출해 쌓은 frame은 second가 반환하면 제거된다 | second 실행 중에는 맨 위 second frame이 실행되고, 반환 후 first frame에서 이어 실행한다 |
| 모두 반환 | stack: 비어 있음 / global binding: 자동 삭제 아님 | first와 script 실행 frame이 사라진다 | 콜 스택의 frame과 도달 가능한 전역 값의 수명은 다르다 |
자바스크립트 엔진은 여러 실행 컨텍스트를 스택(stack)으로 관리하며, 이를 호출 스택(call stack)이라고 합니다. 함수를 호출할 때마다 새 실행 컨텍스트가 스택의 맨 위에 올라가고(push), 함수가 끝나면 맨 위에서 빠집니다(pop). 언제나 맨 위에 있는 컨텍스트가 지금 실행 중인 코드입니다.
위 그림은 다음 코드가 실행될 때의 스택을 여섯 시점으로 나눠 보여 줍니다.
function first() {
console.log('첫 번째 함수 실행');
second(); // second 함수 호출
console.log('첫 번째 함수 종료');
}
function second() {
console.log('두 번째 함수 실행');
console.log('두 번째 함수 종료');
}
first(); // first 함수 호출
console.log('전역 코드 종료');스크립트가 시작되면 전역 컨텍스트가 스택에 올라갑니다. first()를 호출하면 first의 컨텍스트가 그 위에, first 안에서 second()를 호출하면 second의 컨텍스트가 다시 그 위에 올라가 스택은 위에서부터 second, first, 전역이 됩니다. second가 끝나면 맨 위에서 빠져 first로 돌아가고, first가 끝나면 다시 빠져 전역으로 돌아가며, 스크립트가 끝나면 전역도 빠져 스택이 빕니다. 그래서 출력은 “첫 번째 함수 실행”, “두 번째 함수 실행”, “두 번째 함수 종료”, “첫 번째 함수 종료”, “전역 코드 종료” 순서입니다.
그림 아래의 설명처럼 컨텍스트는 호출한 순서의 반대로만 빠지며, 두 함수는 return이 없어 undefined를 돌려줍니다. 스택이 비었다고 전역 변수가 모두 사라지는 것은 아닙니다. 이렇게 스택에 쌓아 두는 덕분에 자바스크립트는 함수가 끝났을 때 어디로 돌아갈지 기억합니다.
더 깊이: 호출 스택이 넘치면
함수가 끝나지 않은 채 다른 함수를 계속 부르면 호출 스택에 컨텍스트가 계속 쌓이고, 엔진이 정한 한도를 넘는 순간 오류가 납니다. 멈추는 조건인 기저 사례(base case)가 없는 재귀가 대표적이지만, 언젠가 끝나는 재귀라도 너무 깊으면 같은 일이 생깁니다. 이때의 오류는 엔진마다 다릅니다. MDN에 따르면 Chrome과 Safari는 RangeError: Maximum call stack size exceeded를, Firefox는 표준이 아닌 InternalError: too much recursion을 던집니다.
그래서 catch 블록에서 e instanceof RangeError로 스택 초과를 가려내는 코드는 Firefox에서 기대대로 동작하지 않습니다. 명세가 스택의 깊이를 정하지 않으므로 몇 번째 호출에서 한도에 닿는지도 엔진과 상황에 따라 달라질 수 있고, 특정 숫자에 기대면 안 됩니다. 깊이를 미리 알 수 없는 트리나 목록을 다룬다면, 재귀 대신 반복문과 직접 관리하는 배열 스택으로 바꾸는 편이 안전합니다.
ECMAScript 2015는 엄격 모드에서 함수의 마지막 동작으로 하는 호출, 즉 꼬리 위치(tail position)의 호출이 호출한 쪽의 스택 공간을 다시 쓰도록 정한 적절한 꼬리 호출(proper tail call)을 명세에 넣었습니다. WebKit 블로그의 2016년 글처럼 Safari의 엔진은 이를 구현했지만, 모든 엔진이 같은 동작을 한다고 가정할 수는 없으므로 꼬리 재귀로 썼다고 스택이 넘치지 않으리라고 기대하지 않습니다.
이름을 찾는 환경
실행 컨텍스트에는 이름을 찾을 때 쓰는 환경이 연결되어 있습니다. 환경 레코드(environment record)는 그 범위에서 선언한 변수와 함수의 이름을 기록하고, 바깥 환경 참조(outer environment reference)는 그 환경이 만들어질 때의 바깥 환경을 가리킵니다. 함수는 자기가 정의된 곳의 환경을 바깥 환경으로 기억하므로, 함수를 어디서 호출하든 이름은 정의한 위치를 기준으로 찾습니다.
명세는 이 가운데 var 선언을 관리하는 환경을 변수 환경(variable environment)으로, 지금 이름을 찾기 시작할 환경을 렉시컬 환경(lexical environment)으로 나눠 부릅니다. 블록에 들어가면 그 블록의 let과 const를 기록할 환경이 새로 생기고, 블록을 나오면 다시 바깥 환경에서 찾습니다. 선언이 스코프의 맨 위로 끌어올려진 것처럼 동작하는 호이스팅(hoisting)을 볼 때도 이 차이가 드러나는데, var는 선언 전에 undefined로 읽히지만 let과 const는 선언 전에 읽으면 오류가 납니다. 호이스팅은 8장의 let, const와 블록 스코프에서 자세히 다룹니다.
브라우저의 일반 스크립트에서 전역에 선언한 var와 함수는 window의 속성이 되지만, 전역의 let과 const는 별도의 환경 레코드에 기록되어 window의 속성이 되지 않습니다. this가 어떤 값이 되는지는 함수를 어떻게 호출했는지에 따라 달라지며, 다음 글에서 다룹니다.
이름을 찾을 때 엔진은 먼저 지금의 환경 레코드에서 이름을 찾고, 없으면 바깥 환경 참조를 따라 한 단계씩 올라가며 전역 환경까지 찾습니다. 이렇게 이어진 환경의 연결이 스코프 체인입니다.
let a = 10; // 전역 렉시컬 환경의 Environment Record에 저장
function outer() {
let b = 20; // outer 함수의 렉시컬 환경의 Environment Record에 저장
// outer 함수의 Outer Environment Reference는 전역 렉시컬 환경을 가리킴
function inner() {
let c = 30; // inner 함수의 렉시컬 환경의 Environment Record에 저장
// inner 함수의 Outer Environment Reference는 outer 함수의 렉시컬 환경을 가리킴
console.log(a + b + c); // 10 + 20 + 30 = 60
// c는 현재 스코프에서 찾음
// b는 Outer (outer 함수) 스코프에서 찾음
// a는 Outer (전역) 스코프에서 찾음
}
inner();
}
outer();inner 안에서 c는 자기 환경에서, b는 바깥 환경 참조를 따라 outer의 환경에서, a는 한 단계 더 올라가 전역 환경에서 찾아 60을 출력합니다. 이 연결 덕분에 안쪽 함수는 바깥 함수의 변수를 쓸 수 있습니다.
클로저
클로저는 함수가 자기가 정의된 렉시컬 환경을 기억해, 나중에 다른 곳에서 호출되어도 그 환경의 변수를 쓸 수 있는 것을 말합니다. 간단히 말하면, 바깥 함수의 호출이 끝난 뒤에도 바깥 함수의 변수에 접근할 수 있는 함수입니다.
function makeCounter() {
let count = 0; // 이 변수는 makeCounter의 렉시컬 환경에 포함됩니다.
return function() { // 이 익명 함수가 클로저입니다.
count++; // 외부 함수(makeCounter)의 count 변수에 접근
return count;
};
}
const counter1 = makeCounter(); // makeCounter 호출, 새로운 count=0을 가진 클로저 반환
const counter2 = makeCounter(); // makeCounter 또 호출, 또 다른 count=0을 가진 클로저 반환
console.log(counter1()); // 결과: 1 (counter1만의 count 변수를 증가시킴)
console.log(counter1()); // 결과: 2
console.log(counter2()); // 결과: 1 (counter2만의 count 변수를 증가시킴)
console.log(counter1()); // 결과: 3makeCounter()를 호출하면 그 호출의 환경에 count가 만들어지고, makeCounter()는 이 환경을 바깥 환경으로 기억하는 함수를 돌려줍니다. 호출이 끝나 makeCounter의 실행 컨텍스트는 스택에서 빠지지만, 돌려받은 함수가 count를 쓰고 있으므로 count는 남아 있습니다. counter1과 counter2는 따로 호출해 만든 함수라 각자 다른 환경의 count를 기억하므로, counter1()이 1, 2, 3으로 늘어나는 동안 counter2()는 1부터 시작합니다.
클로저는 정의될 때의 값을 복사해 두는 것이 아니라, 그 변수 자체를 계속 읽고 바꿉니다. 그래서 같은 클로저를 여러 번 호출하면 바뀐 값이 이어집니다.
클로저를 쓰는 곳
클로저로 외부에서 직접 바꿀 수 없는 변수를 만들 수 있습니다. 다음 지갑의 balance는 함수 밖에서 이름으로 접근할 수 없고, 돌려준 메서드를 통해서만 읽고 바꿀 수 있습니다.
function createWallet(initialBalance) {
let balance = initialBalance; // 외부에서 직접 접근 불가
return {
getBalance: function() {
return balance;
},
deposit: function(amount) {
if (!(amount > 0)) { // 0 이하나 숫자가 아닌 값은 거부
console.log("입금액은 0보다 커야 합니다.");
return;
}
balance += amount;
console.log(`${amount}원 입금. 현재 잔액: ${balance}`);
},
withdraw: function(amount) {
if (!(amount > 0)) {
console.log("출금액은 0보다 커야 합니다.");
return;
}
if (balance >= amount) {
balance -= amount;
console.log(`${amount}원 출금. 현재 잔액: ${balance}`);
} else {
console.log("잔액이 부족합니다.");
}
}
};
}
const myWallet = createWallet(10000);
myWallet.deposit(5000); // 5000원 입금. 현재 잔액: 15000
myWallet.withdraw(7000); // 7000원 출금. 현재 잔액: 8000
console.log(myWallet.getBalance()); // 8000
console.log(myWallet.balance); // undefined (balance는 객체의 속성이 아님)변수를 숨긴다고 규칙이 저절로 지켜지는 것은 아닙니다. 검사가 없다면 withdraw(-500)이 오히려 잔액을 늘리므로, 이 예제는 금액이 0보다 큰지 메서드마다 확인합니다. !(amount > 0)은 0 이하의 수뿐 아니라 NaN이나 문자열처럼 비교가 거짓이 되는 값도 함께 거부합니다.
setTimeout()에 넘긴 함수나 이벤트 핸들러처럼 나중에 실행되는 함수도 클로저입니다.
function delayedMessage(message, delay) {
setTimeout(function() {
console.log(message); // 외부 함수의 message 변수에 접근
}, delay);
}
delayedMessage("5초 뒤에 출력될 메시지", 5000);delayedMessage()는 곧바로 끝나지만, 등록된 함수는 5초 뒤에도 message를 읽어 출력합니다. 5000은 최소한 기다릴 시간이므로, 다른 작업이 실행 중이거나 탭이 백그라운드에 있으면 실제 출력은 더 늦어질 수 있습니다.
함수의 인수 일부를 미리 정해 새 함수를 만드는 부분 적용(partial application)에도 클로저를 씁니다.
function multiply(a) {
return function(b) { // 이 내부 함수가 클로저
return a * b; // 외부 함수의 a 변수에 접근
};
}
const multiplyBy5 = multiply(5); // a에 5를 고정시킨 새로운 함수 생성
console.log(multiplyBy5(10)); // 결과: 50
console.log(multiplyBy5(20)); // 결과: 100multiply(5)는 a가 5인 환경을 기억하는 함수를 돌려주므로, multiplyBy5(10)은 50, multiplyBy5(20)은 100입니다.
정리
- 실행 컨텍스트는 지금 실행 중인 위치와 이름을 찾을 환경을 설명하는 모델이며, 전역 코드와 함수 호출마다 만들어집니다.
- 호출 스택은 함수를 호출하면 컨텍스트를 맨 위에 쌓고, 끝나면 호출한 역순으로 빼며, 맨 위의 컨텍스트가 실행 중인 코드입니다.
- 이름은 지금의 환경 레코드에서 먼저 찾고, 없으면 바깥 환경 참조를 따라 정의된 위치 기준으로 전역까지 찾습니다.
- 클로저는 함수가 정의된 환경을 기억해, 바깥 함수의 호출이 끝난 뒤에도 그 변수를 읽고 바꿉니다.
- 같은 함수를 여러 번 호출해 만든 클로저는 각자 다른 환경을 가지며, 클로저는 값을 복사하지 않고 변수 자체를 공유합니다.
- 스택에서 빠지는 것과 변수가 사라지는 것은 다르며, 참조가 모두 없어져야 회수될 수 있습니다.
연습 문제
1. 같은 함수와 새 클로저
다음 코드의 네 출력을 실행하기 전에 적어 보세요. a와 alias가 같은 함수인지, b가 따로 만들어졌는지를 기준으로 생각합니다.
function makeCounterForPractice() {
let count = 0;
return () => ++count;
}
const a = makeCounterForPractice();
const alias = a;
const b = makeCounterForPractice();
console.log(a());
console.log(alias());
console.log(b());
console.log(a());해설 보기
1, 2, 1, 3입니다. const alias = a;는 함수를 새로 만들거나 makeCounterForPractice()를 다시 호출하지 않으므로, a와 alias는 같은 함수이고 같은 count를 늘립니다. b만 둘째 호출에서 만든 다른 count를 씁니다.
그래서 a()가 1, alias()가 같은 count를 2로, b()가 자기 count를 1로, 마지막 a()가 다시 3으로 만듭니다.
2. 별칭을 새 카운터로
앞 코드에서 const alias = a;만 const alias = makeCounterForPractice();로 바꿨습니다. 네 출력과, 남아 있는 count의 개수는 어떻게 될까요?
해설 보기
1, 1, 1, 2이고, count는 세 개입니다. a, alias, b가 모두 따로 호출해 만든 함수라 각자의 count를 가지므로, 처음 세 호출은 모두 1을 돌려줍니다. 마지막 a()만 자기 count를 두 번째로 늘려 2가 됩니다.
3. 스택에 쌓이는 순서
first와 second 예제의 second 안에서 console.log('두 번째 함수 실행'); 다음 줄에 third();를 넣고, third는 console.log('세 번째');만 하는 함수로 정의했습니다. third가 실행되는 순간 스택에는 위에서부터 무엇이 쌓여 있을까요? 출력의 순서는 어떻게 될까요?
해설 보기
third가 실행되는 순간 스택은 위에서부터 third, second, first, 전역입니다. third가 끝나면 빠져 second로 돌아가고, 이어서 second, first가 차례로 빠집니다.
출력은 “첫 번째 함수 실행”, “두 번째 함수 실행”, “세 번째”, “두 번째 함수 종료”, “첫 번째 함수 종료”, “전역 코드 종료” 순서입니다. 함수가 호출한 함수가 모두 끝나야 그다음 줄이 실행되기 때문입니다.
실행 컨텍스트와 클로저의 정의는 ECMAScript 명세의 실행 컨텍스트와 MDN의 클로저 문서에서 확인할 수 있습니다.
다음 글 This 키워드와 바인딩에서는 함수를 호출하는 방식에 따라 this가 무엇을 가리키는지 살펴봅니다.