ch13. 스코프 — 이름이 보이는 자리
C로 처음 변수를 배웠을 때 신기했던 게 있어요. 컴파일이 끝나면 변수 이름이 사라진다는 거였죠. int x = 1; 이라고 적어도, 컴파일러를 통과한 뒤엔 x라는 글자는 어디에도 없어요. 그냥 [rbp-4] 같은 스택 오프셋만 남아요. 누군가 “x"를 찾으려고 해도 찾을 자리가 없어요 — 이미 번지수로 변환됐으니까.
JS로 넘어와서 처음 충격이었던 건 정반대였어요. let x = 1이라고 쓰면, 런타임에 진짜로 ‘x’라는 이름표가 어딘가에 매달려요. 그리고 누군가 x를 찾으면 시스템은 진짜로 자료구조를 거슬러 올라가며 그 이름표를 찾아요. 변수 조회가 동작인 거예요. 인덱싱이 아니라.
이 글은 13장 스코프를 책의 흐름이 아니라 시스템 레벨에서 다시 봅니다. 스코프가 “런타임 자료구조로 존재하는 언어"와 “컴파일러 안에서만 존재하는 언어"가 어떻게 다른지, 그리고 JS가 왜 전자가 될 수밖에 없었는지 — 이게 13장 본문의 모든 추상적인 문장(“스코프 체인을 따라 검색한다” 같은) 뒤에 숨어있는 진짜 그림이에요.
1. C의 스코프 — 컴파일러의 일
C 프로그램은 변수에 대해 두 가지만 알면 돼요. 어느 주소에 있는가, 그리고 몇 바이트인가. 그 외엔 아무것도 필요 없어요. 이름은? 컴파일러가 다 풀어줬으니까.
같은 개념의 코드를 두 언어로 비교하면 차이가 명확해져요. 탭을 눌러보세요.
int g = 10;
void outer(void) {
int x = 1;
{
int y = 2;
printf("%d\n", x + y + g);
}
}컴파일 후:
mov eax, DWORD PTR [rbp-4] ; x
add eax, DWORD PTR [rbp-8] ; y
add eax, DWORD PTR [rip+g] ; gx, y, g라는 글자는 어디에도 없어요. 그냥 주소만 박혀있죠.
const g = 10;
function outer() {
const x = 1;
{
const y = 2;
console.log(x + y + g);
}
}런타임에 EnvRec 객체:
outerEnv { x: 1, [block]: { y: 2 } }
↓ [[OuterEnv]]
GlobalEnv { g: 10 }x, y, g가 진짜로 키-값 쌍으로 살아있어요.
C에선 컴파일러가 코드를 읽으면서 어떤 이름이 어떤 메모리 위치인지를 결정하고, 결정된 결과(주소)만 어셈블리에 박아 넣어요. 런타임엔 “찾는다"는 동작이 없어요. 그냥 정해진 주소에서 읽을 뿐.
블록 스코프도 똑같아요. { int y = 2; }에서 y는 그 블록 안에서만 보이는데, 이건 컴파일러가 그 글자를 그 블록 바깥의 코드와 매칭시키지 않는다는 약속일 뿐이에요. 런타임엔 y도 그냥 [rbp-8]이고, 블록을 빠져나간다고 해서 그 메모리가 “삭제"되지도 않아요. 다른 변수가 같은 자리를 재활용할 뿐이죠.
그래서 C는 클로저를 못 만들어요. 하지만 그 얘기는 잠시 뒤에.
2. JS의 스코프 — 진짜로 존재하는 이름표
JS는 정반대 설계예요. 모든 스코프마다 Environment Record라는 객체를 만들어요. 이름표를 보관하는 진짜 자료구조예요. 그리고 그 Record는 [[OuterEnv]]라는 포인터로 부모 Record를 가리켜요.
const g = 10;
function outer() {
const x = 1;
function inner() {
const y = 2;
return x + y + g;
}
return inner;
}이 코드가 실행될 때 메모리는 대략 이런 모양이에요.
힙(Heap)
┌──────────────────────────┐
│ GlobalEnv │
│ bindings: { g: 10 } │
│ outer: null │
└────────▲─────────────────┘
│ [[OuterEnv]]
┌────────┴─────────────────┐
│ outer()의 Environment │
│ bindings: { x: 1 } │
│ outer: GlobalEnv │
└────────▲─────────────────┘
│ [[OuterEnv]]
┌────────┴─────────────────┐
│ inner()의 Environment │
│ bindings: { y: 2 } │
│ outer: outerEnv │
└──────────────────────────┘inner에서 x를 참조하면, 명세 알고리즘 ResolveBinding이 돌아요. 한 단계씩 따라가볼게요.
inner의 EnvRec에서 x 찾기
bindings: { y: 2 }만 있음. x는 없음.
→ 실패. [[OuterEnv]]를 따라 한 칸 위로 이동.
outer의 EnvRec 도착
bindings: { x: 1 }을 들고 있음.
outer의 EnvRec에서 x 찾기
발견! 값 1을 반환.
→ 검색 종료. 더 이상 walk하지 않음.
이게 책에서 말하는 *“스코프 체인을 따라 검색한다”*의 실체예요. 비유가 아니라 진짜로 포인터를 따라가는 동작이에요. C에선 컴파일 타임에 한 번 끝나는 작업이, JS에선 런타임마다 일어나요.
그리고 이게 중요한 차이로 이어져요 — JS는 변수가 언제, 어디에 존재할지를 런타임에 결정할 수 있어요. eval로 변수를 추가하거나, with로 스코프 자체를 끼워넣거나, 클로저로 함수가 끝난 뒤에도 변수를 살려두거나. C에선 다 불가능한 일들이에요. 컴파일이 끝난 뒤엔 변수 이름이 사라졌으니까.
3. 클로저는 왜 C에서 불가능한가
C 입장에서 보면 클로저는 마법처럼 보여요. 함수가 끝났는데 그 안의 변수가 어떻게 살아남지?
int* outer(void) {
int x = 1;
return &x; // ⚠️ 위험: outer 끝나면 스택 회수됨
}
int main(void) {
int *p = outer();
printf("%d\n", *p); // undefined behavior — 운 좋으면 1, 보통은 쓰레기값
}outer의 지역 변수 x는 스택에 있어요. 함수가 반환되면 스택 포인터가 원위치로 돌아가고, 그 메모리는 다음 함수 호출에 재사용돼요. &x를 들고 있어봤자 가리키는 자리는 이미 남의 땅이에요.
JS에서 같은 패턴을 쓰면 멀쩡하게 동작해요.
function outer() {
let x = 1;
return function inner() {
return x; // outer 끝나도 x를 본다
};
}
const fn = outer();
console.log(fn()); // 1 — 정상
차이는 단 하나예요. JS의 환경은 스택이 아니라 힙에 살아요. outer가 반환돼도 outer의 Environment Record는 사라지지 않아요. 왜냐면 inner 함수 객체가 [[Environment]] 슬롯에 그 Record를 참조하고 있거든요. GC 입장에선 “아직 누군가 잡고 있으니 못 치움” 상태예요.
fn 함수 객체
├─ code: "return x"
└─ [[Environment]] ──┐
▼
┌──────────────────────┐
│ outer의 Environment │ ← outer 호출은 끝났지만
│ bindings: { x: 1 } │ 이 객체는 살아있음
│ outer: GlobalEnv │
└──────────────────────┘fn이 GC되기 전까진 이 Environment도 안 죽어요. 함수의 [[Environment]] 슬롯이 환경의 생명을 연장하는 거예요.
C가 클로저를 못 만드는 건 언어 설계자가 “안 만들었다"가 아니라, 로컬 변수를 스택에 둔다는 결정이 클로저를 원천적으로 불가능하게 만든 거예요. 클로저를 지원하려면 환경을 힙에 둬야 하고, 그러면 GC가 필요하고, 그러면 C가 추구한 “런타임 없는 시스템 언어"라는 정체성에서 멀어져요.
4. V8은 어떻게 둘의 좋은 점만 가져왔는가
여기까지 보면 의문이 들어요. “JS는 매번 환경 체인을 걸어야 한다면, C보다 압도적으로 느려야 하는 거 아니야?” 명세대로라면 그래야 맞아요. 그런데 실제로는 안 그래요. V8 같은 현대 JS 엔진이 영리하게 둘의 좋은 점만 가져왔거든요.
Escape Analysis — 잡히지 않는 변수는 그냥 스택에
V8은 코드를 보고 “이 변수가 클로저에 잡히는가?“를 분석해요.
function fast() {
let x = 1;
return x + 1; // x는 어디로도 새 나가지 않음
}이런 x는 굳이 힙에 둘 이유가 없어요. V8은 이런 변수를 **그냥 스택(또는 레지스터)**에 둬요. C랑 똑같이.
반대로:
function captured() {
let x = 1;
return () => x; // x가 화살표 함수에 잡힘
}이 x는 함수 반환 후에도 살아남아야 하니까 Context object라는 힙 객체에 모아둬요. 캡처되는 변수만 비용을 치르는 셈이에요.
Inline Cache — 한 번 걸은 길은 외운다
체인을 walk하는 동작도 매번 처음부터 하진 않아요. 같은 코드를 반복 실행할 때, V8은 “이 변수는 outer env에서 한 단계 위에 있더라"를 기억해둬요. 다음번엔 walk 없이 그 자리로 바로 점프해요. 캐시 미스가 나면 그때 다시 찾고요.
TurboFan — 핫한 코드는 인라이닝
자주 실행되는 코드는 JIT 컴파일러(TurboFan)가 인라이닝해서 스코프 체인 자체를 없애버려요. 결과적으로 C 컴파일러가 만든 어셈블리랑 거의 비슷한 코드가 나오기도 해요.
비유하자면 이래요.
| 단계 | C | JS (명세) | V8 (실제) |
|---|---|---|---|
| 비유 | 이름을 미리 번지수로 바꿔놓고 우편 배달 | 매번 이름표 보고 위층 사무실로 올라가 물어봄 | 두 번째부터는 단축번호 다이얼 |
| 비용 | 컴파일 타임에 1회 | 런타임에 매번 | 첫 1~2회만, 이후엔 거의 0 |
5. 그래서 “위로 찾아간다"는 말의 진짜 의미
다시 처음으로 돌아와봐요. 13장 본문에서 자주 나오는 *“스코프 체인을 따라 상위 스코프로 검색한다”*는 문장. 이게 무슨 뜻인지 이제 다르게 들릴 거예요.
- C에서라면 이 문장은 컴파일러가 코드를 읽으며 한 일이에요. 런타임엔 흔적이 없어요.
- JS에서는 이 문장이 실행 중인 프로그램이 진짜로 하는 일이에요. 자료구조가 힙에 있고, 포인터를 따라 객체를 옮겨가며 이름을 찾아요.
이 차이가 13장 전체를 관통해요.
- 렉시컬 스코프가 가능한 건 환경 객체가 함수의 정의 시점에 결정되기 때문이에요. 호출 시점이 아니라.
- 클로저가 가능한 건 환경이 힙에 있어서 함수 종료와 함께 안 죽기 때문이에요.
- 스코프 체인 검색 비용이 의미를 갖는 건 진짜로 walk를 하기 때문이에요. C라면 비용이라는 개념 자체가 성립 안 해요.
- 동적 변경(eval, with)이 가능한 것도 환경이 런타임 객체이기 때문이에요. 컴파일 후에도 만질 수 있으니까.
14장 전역 변수의 문제점도 이 그림 위에 얹히면 자연스럽게 풀려요. 전역 변수가 비싼 건 스코프 체인의 종점에 있어서예요. 진짜로 walk하니까 종점까지 가는 게 길고, 캐시도 글로벌은 다른 코드가 자주 바꿔서 캐시 효과가 약하고. C라면 전역이든 지역이든 둘 다 직접 주소 접근이라 차이가 없죠.
흔한 오해 정리
직접 풀어보기 — 스코프 퀴즈
여기까지 읽었다면 한번 시험해봐요. 정답을 보기 전에 머릿속으로 먼저 답을 떠올려보세요.
직접 실험해보기
이 글에 등장한 코드는 examples/ch13/에 있어요. 한번씩 실행해보면 머리에 그림이 더 잘 박혀요.
001-environment-walk.js— 중첩 함수에서 환경 체인이 어떻게 동작하는지002-closure-keeps-env.js— 함수가 끝나도 환경이 살아남는 모습003-no-closure-in-c.c— C에서 같은 패턴을 시도하면 어떻게 깨지는지 (참고용)
정리
- C에서 스코프는 컴파일러의 기억이고, JS에서 스코프는 런타임의 자료구조다.
- JS의 모든 함수는 자기가 정의된 시점의 환경을
[[Environment]]슬롯에 들고 다니고, 이게 클로저의 정체다. - 환경이 힙에 있다는 한 가지 결정이 — 렉시컬 스코프, 클로저, 동적 변경, 검색 비용 — 13장의 거의 모든 주제를 결정한다.
- V8은 명세의 동적 시맨틱을 유지하면서도 Escape Analysis와 인라인 캐시로 C에 가까운 성능을 낸다. 시맨틱과 구현은 별개다.
다음 글(ch14)에서는 이 그림 위에서 전역 변수가 왜 위험한가를 봅니다. “위로 찾아간다"의 종점이 왜 비싼지가 자연스럽게 이어져요.