1. 자바스크립트의 심장: V8 엔진 파이프라인
구글이 만든 고성능 JS 엔진으로, JIT(Just-In-Time) 컴파일 방식을 사용한다!
JIT 컴파일러 = V8의 최적화 컴파일러?
JIT(Just-In-Time)은 특정 소프트웨어의 이름이 아니라 "코드를 실행하는 바로 그 시점에 컴파일을 수행한다"는 컴파일 전략(방식)을 뜻하는 일반 명사다.
하지만 자바스크립트 맥락에서 질문한다면, "V8 엔진이 JIT 컴파일 방식을 사용하며, 그중 최적화를 담당하는 녀석이 터보팬(TurboFan)이다"라고 이해하면 완벽하다.
⚙️ V8에서의 JIT 구성
V8은 성능을 극대화하기 위해 두 종류의 컴파일러를 섞어서 쓰는 '멀티 스테이지 JIT' 구조를 가진다.
- Ignition (인터프리터): 소스코드를 바이트코드로 빠르게 변환해서 즉시 실행한다. (넓은 의미의 JIT 과정에 포함된다.)
- TurboFan (최적화 컴파일러): 자주 실행되는 코드(Hot Code)를 가져와서 초고속 기계어로 구워낸다. 우리가 흔히 "JS가 왜 이렇게 빨라?"라고 할 때의 주인공이 바로 이 녀석(Optimizing JIT Compiler)이다.
파이프라인 흐름
1단계: 파싱 (Parsing & AST 생성)
우리가 짠 소스 코드(Source Code)는 컴퓨터 입장에서 그냥 텍스트일 뿐!
- Scanner: 코드를 의미 있는 최소 단위인 토큰(Tokens)으로 쪼갠다. (var, a, =, 10 등)
- Parser: 쪼개진 토큰을 문법적으로 분석하여 트리 형태의 설계도인 AST (Abstract Syntax Tree)를 만든다.
이 단계에서 오타가 있으면 그 유명한 Syntax Error가 발생!
2단계: 이그니션 (Ignition - 인터프리터)
- Bytecode 생성: Ignition이 AST를 받아서 자기만의 효율적인 언어인 바이트코드(Bytecode)로 변환한다
- 즉시 실행: 기계어보다는 조금 느리지만, 컴파일을 기다릴 필요 없이 즉시 코드를 실행하기 시작한다. 웹페이지가 빨리 뜨는 비결이라고 한다.
3단계: 프로파일링 (Profiling - 정보 수집)
이그니션(인터프리터)이 바이트코드를 실행하는 동안, V8 엔진은 실시간으로 코드를 감시하며 장부를 작성한다.
- 모니터링: 실행 중인 코드를 지켜보며 "어떤 함수가 자주 호출되는가?", "어떤 데이터 타입이 들어오는가?"와 같은 데이터를 수집한다.
- Hot Code 판별: 유난히 호출 빈도가 높은 코드를 'Hot'한 코드로 분류하고, 최적화가 필요한 대상으로 지정한다.
- 피드백 벡터(Feedback Vector): 각 함수마다 전용 장부를 생성한다. 이 장부에 바로 인라인 캐싱(IC) 정보가 기록된다.
- 예: "이 지점에서 obj.x를 참조할 때 히든 클래스 C1이 들어왔고, 위치는 오프셋 0이었다."
3.5단계: 인라인 캐싱(IC) 피드백 (최적화의 근거)
프로파일링 단계에서 수집된 '히든 클래스 주소' 정보는 터보팬이 코드를 최적화하는 핵심 지표가 된다.
- 지름길의 기록: 이그니션이 코드를 실행하며 얻은 정보를 피드백 벡터에 저장해두면, 터보팬은 이를 바탕으로 "다음에도 동일한 구조의 객체가 들어오겠지"라고 판단하며 추측성 최적화를 준비한다.
4단계: 터보팬 (TurboFan - JIT 컴파일러)
수집된 장부(피드백 벡터)를 바탕으로, V8의 최적화 컴파일러인 터보팬이 고성능 기계어를 생성한다.
- 최적화 컴파일: "이 함수는 항상 특정 타입만 들어온다"는 확신이 들면, 바이트코드를 최적화된 기계어(Optimized Machine Code)로 직접 변환한다.
- 인라인 캐싱의 박제: 이때 인라인 캐싱 정보가 기계어 안에 직접 포함된다. 즉, "속성을 찾기 위해 설계도를 뒤지지 말고, 무조건 메모리 0번 주소를 읽어라"라는 명령을 CPU에 직접 내린다.
- 압도적 성능: 더 이상 통역사(인터프리터)를 거치지 않는다. CPU가 메모리의 Code Space에 저장된 최적화 기계어로 즉시 점프하여 실행한다. 이를 Fast Path라 부른다.
5단계: 베일아웃 (Deoptimization / Bailout)
터보팬의 최적화는 '가정'에 기반하기 때문에, 이 가정이 무너지는 순간 비상 복구 시스템이 작동한다.
- 가정 붕괴: 최적화된 기계어는 "숫자만 들어온다"고 가정했으나, 갑자기 문자열이 입력되는 경우이다.
- 객체 프로퍼티의 순서가 달라지는 것 역시 '가정 붕괴'의 핵심 원인 중 하나다. (인라인 캐싱 무력화하는 주범!!!)
- 긴급 복구: 엔진은 즉시 실행을 중단하고 해당 기계어를 폐기한다. 이를 베일아웃(Bailout)이라 한다.
- 후퇴: 최적화된 경로를 포기하고, 다시 안전한 이그니션(인터프리터) 상태로 돌아가 바이트코드를 한 줄씩 실행하는 방식으로 복귀한다.
+++ 바이트코드 (Bytecode) : Ignition의 언어
이그니션(인터프리터)이 AST를 분석해서 만드는 중간 단계의 언어
- 왜 만드는가? (Memory & Speed): 기계어는 용량이 너무 크다.
모든 코드를 바로 기계어로 바꾸면 메모리가 부족지기 때문이라고 한다. 바이트코드는 훨씬 날씬해서 메모리를 아끼면서도 빠르게 실행을 시작할 수 있게 해준다. - 어떻게 실행되는가? (Software): CPU는 바이트코드를 직접 읽지 못한다!!
Ignition이라는 소프트웨어(통역사)가 바이트코드를 한 줄씩 읽으며 CPU에게 "이거 해!"라고 명령을 전달한다. - 이동성 (Portability): 인텔 CPU든, M1(ARM) CPU든 상관없이 V8 엔진만 깔려있다면 동일한 바이트코드로 돌아간다!
+++ 최적화된 기계어 (Optimized Machine Code) : TurboFan의 언어
터보팬(JIT 컴파일러)이 바이트코드를 재료로 해서 만들어내는 최종 단계의 언어이다.
- 왜 만드는가? (Peak Performance): 통역사(인터프리터)를 거치면 아무래도 느리다!
자주 쓰는 'Hot'한 코드는 통역 없이 CPU가 직접 읽고 실행하게 해서 속도를 극대화한다. - 어떻게 실행되는가? (Hardware): 메모리의 Code Space에 저장된 이 기계어는 CPU가 직접 하드웨어 수준에서 실행한다.
2. 히든 클래스 (Hidden Classes / Shapes)
자바스크립트 객체는 원래 사전(Dictionary)구조이다.
키(Key)와 값(Value)이 메모리 어딘가에 흩어져 있어서 찾을 때마다 "이 키 어디 있어?" 하고 뒤져야 한다.
V8은 이걸 해결하기 위해 히든 클래스라는 일종의 정적 설계도를 도입했다.
작동 방식: 전환(Transition) 시스템
객체에 속성이 하나씩 추가될 때마다 V8은 기존 히든 클래스를 버리고 새로운 히든 클래스로 갈아타는 전환 과정을 거친다.
- C0 (빈 설계도): const obj = {}; 생성.
- C1 (x가 추가된 설계도): obj.x = 10; 수행.
- C1의 정보: "x는 메모리 오프셋(위치) 0번에 있다."
- C2 (x, y가 추가된 설계도): obj.y = 20; 수행.
- C2의 정보: "x는 0번, y는 오프셋 1번에 있다."
왜 이게 빠를까?
1. 전통적인 방식: 해시 테이블 (Hash Table)
자바스크립트 객체는 원래 키(Key)와 값(Value)을 저장하는 해시 테이블 구조이다!
- 동작 원리: obj.x에 접근하려면, 먼저 'x'라는 문자열을 해시 함수에 넣어 숫자로 바꾼다. 그 숫자를 인덱스 삼아 메모리 어딘가에 있는 Bucket를 뒤져 값을 가져온다.
- 시간 복잡도: 평균적으로 O(1)이다.
- 하지만 실제로는 말이 O(1)이지, 매번 해시 함수를 계산해야 하고, 혹시나 같은 해시 값이 나오는 충돌(Collision)이 발생하면 처리하는 비용이 든다
- 무엇보다 메모리 여기저기에 흩어져 있는 데이터를 찾아야 해서 캐시 효율(Cache Locality)이 좋지 않다.
즉, 컴퓨터 입장에선 비싼 O(1)인 셈이다.
- 무엇보다 메모리 여기저기에 흩어져 있는 데이터를 찾아야 해서 캐시 효율(Cache Locality)이 좋지 않다.
2. V8의 혁신: 히든 클래스와 오프셋 (Offset)
V8은 "매번 해시 함수 돌리지 말고, C++처럼 메모리 주소를 고정해버리자!"라는 아이디어를 냈다!
- 동작 원리: 객체가 생성될 때 고정된 설계도(히든 클래스)를 부여한다.
이 설계도에는 "속성 x는 객체 시작점에서 정확히 몇 바이트 뒤(Offset)에 있다"는 정보가 적혀 있다. - 시간 복잡도: 완벽한 O(1)!
- 해시 함수를 계산할 필요가 없다!
- 그냥 객체의 시작 주소 + 오프셋만 계산하면 바로 값이 들어있는 메모리로 점프한다. 이건 CPU가 가장 잘하고 빠르게 처리하는 연산이다.
엔진은 이제 obj.y를 찾을 때 해시 테이블을 뒤지지 않는다.
obj가 현재 가리키는 C2 설계도를 보고 "아, y는 무조건 두 번째 칸(오프셋 1)에 있구나!" 하고 바로 주소로 점프한다.
이게 바로 C++의 클래스 멤버 접근 방식과 똑같은 속도를 내는 비결이다!! 매우 빠르다고 한다!!
3. 인라인 캐싱 (Inline Caching, IC)
히든 클래스가 설계도라면, 인라인 캐싱은 그 설계도를 바탕으로 만든 지름길을 알려주는 지도이다.
반복되는 객체 접근에 대해 조회 결과를 실행 지점에 캐싱하여, 설계도 확인 과정조차 생략한다.
원리: "기억해뒀다가 바로 쓰기"
보통 함수는 같은 구조의 객체를 반복해서 처리하는 경우가 많다.
function getX(obj) {
return obj.x;
}
- 최초 실행: getX({x: 10})이 호출되면 엔진은 obj의 히든 클래스를 확인하고 x가 어디 있는지 찾는다. (꽤 오래걸리는 작업)
- 캐싱: 찾은 결과를 함수 실행 코드 안에 몰래 적어둔다. "이 함수에 C1 클래스가 들어오면, 무조건 0번 오프셋을 읽어라!"
- 이후 실행: 다음번에 똑같은 구조의 객체가 들어오면 설계도를 확인할 필요도 없이 적어둔 주소(0번)를 즉시 읽어버린다!!!
V8 엔진이 각 함수마다 피드백 벡터(Feedback Vector)라는 작은 메모리 테이블을 하나씩 만들어준다.
- 함수: getX(obj)
- 피드백 벡터의 한 칸: [ Slot 0: Property 'x' ]
- 저장되는 내용: "지난번에 보니 히든 클래스 C1인 녀석이 왔고, 그때 x는 오프셋 0번에 있었어."
다음번에 이 함수가 실행될 때, 엔진은 히든 클래스를 처음부터 분석하지 않고 이 피드백 벡터부터 살펴본다.
"오, 아까 적어둔 걔네? 그럼 바로 0번으로 가!" 하고 바로 이동할 수 있다.
히든 클래스만 있다면 ?
자바스크립트 객체는 원래 "어디에 뭐가 있는지" 모르는 뒤죽박죽인 상태다.
V8은 이걸 C++처럼 빠르게 만들려고 "이 객체에서 x는 0번 칸에 있어"라는 정보를 담은 설계도(히든 클래스)를 만든다.
히든 클래스만 있으면 매번 [객체 확인 -> 히든 클래스 확인 -> 'x' 검색 -> 위치 파악]이라는 4단계를 거쳐야 한다.
해시 테이블보다는 빠르지만, 여전히 '검색' 과정이 필요하다.
인라인 캐싱 (The Shortcut)
인라인 캐싱은 이 '검색' 과정조차 아깝다고 생각해서 만든 기술이다. 특정 함수가 실행되는 그 자리에 결과를 박아버리는 것이다.
최적화가 깨지는 순간 (Bailout의 주범)
이 두 기술은 예측 가능성을 기반으로 이루어진다. 이 예측이 빗나가면 엔진은 큰 타격을 입는다ㅠㅠ
속성 생성 순서가 다를 때
const user1 = { name: '덕희', age: 31 }; // C1 -> C2
const user2 = { name: '철수', age: 25 }; // C1 -> C2 (공유!)
function getAge(user) {
return user.age;
}
- getAge(user1) 호출: IC가 기록을 한다. "C2 설계도가 오면 1번 칸을 읽어!"
- getAge(user2) 호출: "오, 또 C2네? 바로 1번 칸 ㄱㄱ!" (개빠름 🚀)
- getAge(obj2) 호출 (순서 다른 놈): "어? 얘는 C4네? C2가 아니네? 설계도 다시 뒤져야겠다..." (느려짐 🐢)
컴퓨터 입장에서 user1과 user2는 완전히 다른 설계도를 가진다.
같은 함수에 이 두 객체를 섞어서 넣으면 인라인 캐싱이 "어? 아까랑 설계도가 다르네?" 하고 혼란에 빠지며 성능이 뚝 떨어진다..
속성을 동적으로 삭제할 때 (delete)
속성을 지워버리면 엔진은 "이제 더 이상 설계도로 관리 못 하겠어!"라고 판단하고, 다시 느린 사전(Dictionary) 모드로 돌아간다.
고성능 자바스크립트를 위해서는 객체의 구조를 일정하게 유지(순서 포함)하고, 가능한 한 객체 리터럴이나 생성자를 통해 한 번에 속성을 정의하는 것이 유리하다.
4. Orinoco 가비지 컬렉션
더 이상 참조되지 않는 메모리를 자동으로 해제하는 V8의 GC 시스템이다.
1. 가설의 시작: 세대별 가설 (Generational Hypothesis)
V8의 GC는 "대부분의 객체는 생성된 후 아주 짧은 시간 안에 쓸모없어진다"는 관찰 결과에서 시작한다
- Young Generation: 갓 태어난 객체들.
- Old Generation: 여러 번의 GC에서도 살아남은 객체들.
이 두 영역을 나누어 관리하는 이유는 전체 메모리를 매번 다 뒤지는 건 너무 비효율적이기 때문이다.
2. Young Generation (Minor GC: Scavenger)
새로운 객체가 생성되면 일단 여기에 할당된다. 공간이 작기 때문에(보통 1~8MB) 청소가 아주 잦고 빠르다.
- 알고리즘: Scavenge
- 공간을 절반으로 나눈다 (From / To).
- From에 객체가 꽉 차면 GC가 시작된다.
- 아직 쓰고 있는(Live) 객체만 골라 To로 복사한다.
- From에 남은 쓰레기들은 한꺼번에 싹 날려버린다.
- From과 To의 이름을 바꾼다.
- 승급(Promotion): 여기서 두 번 이상 살아남은 끈질긴 객체들은 Old Generation으로 보낸다
3. Old Generation (Major GC: Mark-Sweep-Compact)
덩치가 크고 오래 살아남은 객체들이 모여 있다. 공간이 크기 때문에 청소 방식도 더 신중하고 무겁다.
- Mark (마킹): 뿌리(Root)부터 시작해서 연결된 모든 객체를 따라가며 사용 중이라는 표시를 한다.
- Sweep (스윕): 표시가 없는 객체(쓰레기)를 메모리에서 해제한다.
- Compact (압축): 중요! 메모리 여기저기에 구멍이 숭숭 뚫린 상태(단편화)가 되면 큰 객체를 넣을 자리가 없어진다.
그래서 살아남은 객체들을 한쪽으로 차곡차곡 밀어서 한곳에 모은다!
4. Orinoco의 핵심 최적화: "안 멈추게 하기"
과거에는 GC가 돌아가면 자바스크립트 실행이 완전히 멈춰버렸다... 오리노코는 이를 해결하기 위해 3가지 전략을 사용한다
| 방식 | 설명 |
| Parallel (병렬) | 메인 스레드 혼자 안 하고 헬퍼 스레드들과 함께 작업한다. |
| Incremental (증분) | GC 작업을 한 번에 끝내지 않고 조금씩 나누어 처리한다. |
| Concurrent (동시) | 자바스크립트가 돌아가는 동안 백그라운드에서 GC가 일을 한다. |
5. 실무에서는 !?
웹 앱에서 GC가 너무 자주 발생하면 화면이 버벅이는 현상(Jank)이 생긴다
- 메모리 누수(Memory Leak) 방지: 전역 변수를 남발하거나, useEffect에서 이벤트 리스너를 안 지워주면 객체가 계속 Old Generation에 쌓여서 결국 브라우저가 느려진다!!
"V8은 세대별 가설을 바탕으로 효율적인 GC를 수행하며, 특히 오리노코 프로젝트를 통해 Concurrent/Incremental 방식을 도입하여 메인 스레드 멈춤 현상을 최소화한다."
5. 실행 컨텍스트
앞서 V8 엔진이 소스코드를 바이트코드로 변환하고 최적화하는 물리적인 과정을 살펴보았다면, 이제는 그 엔진 위에서 자바스크립트가 논리적으로 코드를 어떻게 관리하고 실행하는지 알아야 한다. 그 핵심이 바로 실행 컨텍스트(Execution Context)이다!
실행 컨텍스트는 코드가 실행되기 위한 '배경 정보'를 담고 있으며, 아래 세 가지 요소를 통해 자바스크립트 특유의 동작들을 만들어낸다.
1. 환경 레코드 (Environment Record) → "호이스팅의 주범"
실행 컨텍스트가 생성되는 평가 단계에서 현재 스코프에 선언된 모든 식별자(변수, 함수 등)를 찾아 기록하는 장부이다.
- 물리적 현상: 코드가 실행되기도 전에 엔진이 이미 변수 이름을 알고 있기 때문에, 선언문보다 위에서 변수를 참조해도 에러가 나지 않는 호이스팅이 발생한다.
- 차이점: var는 선언과 동시에 undefined로 초기화되지만, let/const는 이름만 등록되고 초기화되지 않아 TDZ가 형성된다.
2. 외부 환경 참조 (Outer Environment Reference) → "스코프 체인의 통로"
현재 컨텍스트의 장부(환경 레코드)에 찾는 변수가 없을 때, 상위 스코프로 나가는 비상구 역할을 한다.
- 정적 스코프: 이 참조는 함수가 호출된 위치가 아니라, 함수가 '어디서 선언되었는지'에 따라 결정된다.
- 스코프 체인: 이 참조를 타고 최상위 전역 컨텍스트까지 연결된 리스트를 따라가며 변수를 찾는 과정을 스코프 체이닝이라고 한다.
3. 클로저 (Closure) → "메모리에 남겨진 유산"
실행 컨텍스트의 '논리적 제거'와 렉시컬 환경의 '물리적 보존'이 만났을 때 발생하는 현상이다.
- 물리적 현상: 함수 실행이 끝나 실행 컨텍스트가 스택에서 사라져도(Pop), 내부 함수가 외부 변수를 참조하고 있다면 해당 렉시컬 환경(데이터가 담긴 메모리 객체)은 힙(Heap) 메모리에서 해제되지 않고 살아남는다.
- 활용: 리액트의 useState나 useEffect가 렌더링 사이마다 상태를 기억하고 갱신할 수 있는 물리적 근거가 된다.
좀 더 알아보자!!!
1. 환경 레코드(Environment Record)와 호이스팅
호이스팅은 선언문이 물리적으로 위로 끌어올려지는 것이 아니다.
V8 엔진이 코드를 실행하기 전 평가 단계를 거치면서, 실행 컨텍스트 내의 환경 레코드에 변수와 함수 식별자를 미리 등록해두기 때문에 발생하는 현상이다.
- 함수 선언문: 함수 객체 전체가 즉시 메모리에 할당되어 선언 전에도 호출이 가능하다.
- var 변수: 선언과 동시에 undefined로 초기화되어 기록된다.
- let, const: 선언은 기록되지만 초기화가 되지 않아 실제 선언문에 도달할 때까지 TDZ(Temporal Dead Zone) 상태가 된다.
- TDZ? let이나 const로 선언된 변수가 스코프에 진입했을 때부터, 실제 값이 초기화되는 줄에 도달할 때까지 해당 변수를 참조할 수 없는 영역을 의미한다.
- TDZ가 필요한 이유 ?가장 큰 이유는 런타임 에러를 방지하고 코드의 예측 가능성을 높이기 위해서이다.
- 실수 방지: 변수가 선언되기도 전에 사용하는 것은 논리적으로 어색하다. var처럼 undefined를 반환하며 조용히 넘어가는 대신, 에러를 내서 개발자가 실수를 바로잡게 만든다.
- const의 무결성: const는 선언과 동시에 반드시 할당이 되어야 하고 재할당이 불가능하다. 만약 TDZ가 없다면, const 변수도 선언 전에 접근했을 때 undefined가 나왔다가 나중에 값이 할당되는 모순이 생기게 된다.
console.log(a); // undefined
var a = 1;
foo(); // "Hello"
function foo() { console.log("Hello"); }
2. 외부 환경 참조(Outer Environment Reference)와 스코프 체인
실행 컨텍스트가 생성될 때, 해당 컨텍스트의 렉시컬 환경 안에는 외부 환경 참조라는 칸이 생성된다. 여기에는 함수가 정의(선언)될 당시의 상위 렉시컬 환경이 저장된다.
이 참조는 함수가 어디서 호출되든 변하지 않는다. 즉, 함수는 자신의 '고향' 스코프를 죽을 때까지 기억하는 셈이다.
- 정적 스코프(Lexical Scope): 자바스크립트 함수는 어디서 호출되었는지가 아니라 어디서 선언되었는지에 따라 상위 스코프를 결정한다.
- 소스 코드가 작성된 그 순간, 즉 런타임 이전에 이미 스코프(변수 참조 범위)가 확정된다.
- 코드를 눈으로 읽었을 때 함수가 물리적으로 어디에 적혀 있는지만 보면 상위 스코프가 무엇인지 바로 알 수 있다는 의미에서 정적이라고 부른다.
- 스코프 체인: 외부 환경 참조를 타고 최상위 전역 객체까지 거슬러 올라가며 식별자를 찾는 과정이다.
const name = "Global";
function findName() {
// 2. 여기서 name을 찾음 -> 없네?
// 3. 외부 환경 참조를 타고 '자신이 선언된 곳'인 전역 스코프로 이동!
console.log(name);
}
function wrapper() {
const name = "Wrapper";
findName(); // 1. 여기서 호출됨
}
wrapper(); // 결과: "Global" (Wrapper가 아니다!)
- 실무 포인트 !!
- 스코프 체인이 길어질수록(함수 중첩이 깊을수록) 변수를 찾는 검색 비용이 발생한다.
전역 변수 사용을 지양해야 하는 이유 중 하나는 가장 멀리 있는 스코프 체인의 끝까지 가야만 찾을 수 있기 때문이기도 하다. - 또한, 이 외부 환경 참조 덕분에 클로저가 가능하다. 외부 함수가 종료되어도 내부 함수가 외부 환경 참조를 통해 상위 스코프를 붙잡고 있기 때문에, 메모리에서 해당 데이터가 사라지지 않는 것이다.
- 스코프 체인이 길어질수록(함수 중첩이 깊을수록) 변수를 찾는 검색 비용이 발생한다.
3. 클로저(Closure): 메모리에 남겨진 유산
클로저는 "함수가 선언될 당시의 주변 환경(렉시컬 환경)을 기억하고, 그 환경 밖에서 호출되어도 그 환경에 접근할 수 있는 함수"를 말한다. 이를 엔진의 메모리 관리 관점에서 뜯어보면 왜 이런 현상이 가능한지 알 수 있다.
1. 실행 컨텍스트의 제거 vs 렉시컬 환경의 유지
일반적으로 함수 실행이 끝나면 해당 함수의 실행 컨텍스트는 콜 스택에서 제거(Pop)된다. 하지만 여기서 중요한 점은 실행 컨텍스트가 사라진다고 해서 그 안의 렉시컬 환경(변수들이 담긴 객체)이 즉시 메모리에서 사라지는 것은 아니다라는 사실이다.
- 실행 컨텍스트: 함수를 실행하기 위한 '논리적 흐름'이며, 실행이 끝나면 스택에서 제거된다.
- 렉시컬 환경: 변수들이 실제로 저장된 '데이터 객체'이며, 메모리(Heap) 영역에 존재한다.
2. 가비지 컬렉터(GC)의 판단 근거: "참조가 남아있는가?"
V8 엔진의 가비지 컬렉터는 "어떤 데이터에 접근할 수 있는 경로가 하나라도 남아있는가?"를 기준으로 메모리 해제 여부를 결정한다.
function outer() {
let count = 0;
// inner 함수가 선언됨.
// 이때 inner는 outer의 렉시컬 환경을 자신의 [[Environment]]에 저장함.
function inner() {
count++;
console.log(count);
}
return inner;
}
const counter = outer(); // outer 실행 종료. 컨텍스트는 스택에서 Pop!
counter(); // 1
counter(); // 2
- 위 코드에서 outer 함수가 종료되어 실행 컨텍스트는 사라졌지만, 전역 변수인 counter가 inner 함수 객체를 참조하고 있다. 그리고 inner 함수 객체는 자신이 태어난 고향인 outer의 렉시컬 환경을 참조하고 있다.
- [참조 고리] Global -> counter -> inner 함수 객체 -> outer의 렉시컬 환경 -> count 변수
- 이 연결 고리가 유지되고 있기 때문에, 가비지 컬렉터는 outer의 렉시컬 환경을 "사용 중인 메모리"로 판단하여 지우지 않는다. 이것이 클로저의 물리적인 정체이다.
3. 클로저를 왜 사용하는가? (실무적 관점)
- 데이터 은닉 (Private 변수): 외부에서 직접 접근할 수 없는 변수를 만들어 의도치 않은 수정을 방지한다.
- 상태 유지: 전역 변수를 쓰지 않고도 특정 함수의 상태를 독립적으로 유지할 수 있다. (리액트의 useState가 클로저를 활용한 대표적인 사례다.)
클로저는 강력하지만, 남용하면 메모리 효율이 나빠질 수 있다. 클로저가 참조하는 외부 환경은 GC의 대상이 되지 않기 때문에,
더 이상 필요 없는 클로저는 참조를 끊어주어야(예: counter = null)
메모리가 정상적으로 해제된다.
++ 면접 답변용 ㅋㅋㅋㅋ
"클로저는 함수가 선언될 당시의 렉시컬 환경(Lexical Environment)을 기억하여, 함수가 외부 스코프에서 실행될 때도 그 환경에 접근할 수 있는 함수를 말합니다."
"이는 자바스크립트가 정적 스코프(Lexical Scope)를 따르기 때문입니다. 함수는 호출 시점이 아니라 선언된 시점에 상위 스코프를 결정하며, 실행 컨텍스트의 외부 환경 참조(Outer Environment Reference)를 통해 상위 렉시컬 환경을 가리킵니다. 이를 통해 식별자를 찾는 스코프 체이닝이 가능해집니다."
"물리적으로는 함수 실행이 종료되어 실행 컨텍스트가 스택에서 제거(Pop)되더라도, 내부 함수가 외부 변수를 참조하고 있다면 해당 렉시컬 환경 객체는 힙(Heap) 메모리에 유지됩니다. 가비지 컬렉터가 참조가 남아있는 메모리를 해제하지 않는 원리를 이용해 상태를 보존하는 것입니다."
4. This Binding: 호출 시점에 결정되는 주인공
this는 실행 컨텍스트가 활성화될 때(함수 호출 시) 결정된다. 선언 시점에 고정되는 스코프와 달리 호출 방식에 따라 동적으로 변하는 것이 특징이다.
- 기본 바인딩: 일반 호출 시 전역 객체(window).
- 암시적 바인딩: obj.method()처럼 점 앞의 객체.
- 명시적 바인딩: call, apply, bind를 통한 직접 주입.
- new 바인딩: 생성자 함수가 생성한 인스턴스 그 자체.
- 화살표 함수: 자신만의 this 바인딩이 없으며, 상위 컨텍스트의 this를 그대로 사용하는 Lexical This를 따른다.
기본 바인딩 (Default Binding)
가장 일반적인 함수 호출 방식으로, 어떤 객체의 속성도 아닌 독립적인 함수를 호출할 때 적용된다.
- 전역: 일반 모드에서는 전역 객체(window)를 가리킨다.
- 엄격 모드(strict mode): 실수로 전역 객체를 건드리는 것을 방지하기 위해 undefined가 바인딩된다.
function foo() {
console.log(this);
}
foo(); // window (Node.js 환경에서는 global)
function bar() {
'use strict';
console.log(this);
}
bar(); // undefined
2. 암시적 바인딩 (Implicit Binding)
함수가 객체의 속성(메서드)으로 호출될 때이다. 이때 this는 해당 메서드를 호출한 '점(.) 앞의 객체'에 바인딩된다.
const developer = {
name: '덕희',
greet: function() {
console.log(`안녕하세요, ${this.name}입니다.`);
}
};
developer.greet(); // "안녕하세요, 덕희입니다." (this는 developer 객체)
// 주의: 메서드를 변수에 담아 일반 함수처럼 호출하면 기본 바인딩으로 돌아간다 (소실 현상)
const lostGreet = developer.greet;
lostGreet(); // "안녕하세요, 입니다." (this가 window를 가리켜 name이 없음)
3. 명시적 바인딩 (Explicit Binding)
call, apply, bind 메서드를 사용하여 개발자가 직접 this를 특정 객체로 지정하는 방식이다.
- call / apply: 함수를 즉시 호출하면서 this를 전달한다.
- bind: this가 고정된 새로운 함수를 반환한다.
function showInfo(skill, years) {
console.log(`${this.name}: ${skill} ${years}년차`);
}
const me = { name: '덕희' };
showInfo.call(me, 'React', 3); // "덕희: React 3년차"
showInfo.apply(me, ['Next.js', 3]); // "덕희: Next.js 3년차"
const fixedShow = showInfo.bind(me);
fixedShow('TypeScript', 3); // "덕희: TypeScript 3년차"
4. new 바인딩 (New Binding)
함수를 생성자 함수로 사용하여 new 키워드와 함께 객체를 생성할 때이다. 이때 this는 새로 생성된 인스턴스 그 자체를 가리킨다
function Person(name) {
this.name = name;
}
const person1 = new Person('덕희');
console.log(person1.name); // "덕희" (this는 person1)
5. 화살표 함수의 예외성: Lexical This
화살표 함수는 앞선 4가지 규칙을 따르지 않는다. 화살표 함수는 실행 컨텍스트 생성 시 this 바인딩 단계 자체가 없다.
- 특징: 화살표 함수 내부에서 this를 참조하면, 스코프 체인을 통해 자신을 감싸고 있는 상위 스코프의 this를 그대로 가져다 쓴다. 이를 Lexical This라고 부른다.
const team = {
name: 'Frontend Team',
members: ['덕희', '철수'],
printMembers: function() {
// 여기서 this는 team (암시적 바인딩)
// 일반 함수를 쓰면 기본 바인딩이 일어나 전역 객체를 가리키게 됨
this.members.forEach(function(member) {
console.log(`${this.name}: ${member}`); // this.name이 없음
});
// 화살표 함수를 쓰면 상위인 printMembers의 this(team)를 그대로 물려받음
this.members.forEach((member) => {
console.log(`${this.name}: ${member}`); // "Frontend Team: 덕희" ...
});
}
};
team.printMembers();
실무에서 this 바인딩 이슈는 주로 비동기 콜백 함수나 이벤트 리스너를 등록할 때 발생한다.
this가 예상치 못한 전역 객체나 이벤트를 발생시킨 DOM 요소를 가리켜 버그가 생기곤 하는데, 이때 화살표 함수를 쓰거나 bind를 명시적으로 해줌으로써 컨텍스트를 유지해야 한다
6. 리액트에서 클로저를 사용하는 이유 ?
1. 상태 유지 (State Persistence)
리액트 함수 컴포넌트는 상태가 바뀔 때마다 함수 전체가 다시 호출된다.
일반적인 함수라면 함수가 종료될 때 내부 변수가 모두 사라지겠지만, 리액트는 클로저를 이용해 상태를 외부(React 엔진)에 저장하고 함수가 재실행될 때 그 값을 다시 연결해준다.
- useState의 원리: useState는 호출될 때마다 상태 값을 관리하는 렉시컬 환경에 접근하는 클로저(setState 등)를 반환한다.
덕분에 컴포넌트 함수가 몇 번을 다시 실행되어도, 우리는 이전의 count 값을 안전하게 가져와서 쓸 수 있다.
2. 데이터 은닉과 캡슐화 (Encapsulation)
클로저를 사용하면 특정 상태를 수정할 수 있는 권한을 제한할 수 있다.
- 컴포넌트 내부에서 정의된 비즈니스 로직이나 상태는 외부에서 함부로 접근하거나 수정할 수 없다.
오직 우리가 제공한 set 함수나 특정 핸들러를 통해서만 상호작용이 가능하게 설계할 수 있다.
이는 대규모 프로젝트에서 데이터 무결성을 유지하는 데 결정적인 역할을 해.
3. 이전 렌더링 값의 보존 (Snapshots)
리액트에서 각각의 렌더링은 그 시점의 스냅샷이다.
useEffect나 이벤트 핸들러 내부에서 클로저는 해당 렌더링 시점의 props와 state를 꽉 붙잡고 있다.
function Counter() {
const [count, setCount] = useState(0);
const handleAlert = () => {
// 이 함수가 생성될 때의 count 값을 클로저가 기억하고 있음
setTimeout(() => {
alert(`3초 전의 count는 ${count}였습니다.`);
}, 3000);
};
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
<button onClick={handleAlert}>알림 예약</button>
</div>
);
}
- 동작 방식: 버튼을 눌러 count를 올리더라도, 3초 뒤에 뜨는 알림창은 handleAlert가 클릭되었던 그 시점의 컨텍스트(클로저)에 담긴 count 값을 보여준다.
이런 예측 가능한 동작이 가능한 이유가 바로 클로저 덕분이다.
클로저는 양날의 검이다.
useEffect, useCallback, useMemo에서 의존성 배열(deps)을 잘못 설정하면, 함수가 '과거의 렉시컬 환경'에 갇혀버리는 Stale Closure(오래된 클로저) 현상이 발생한다.
useEffect의 dependency array는 "이전 렌더링의 렉시컬 환경에 갇혀 있는 낡은 함수(Stale Closure)를 버리고, 현재 렌더링의 최신 렉시컬 환경을 참조하는 새로운 함수(Fresh Closure)를 실행하는 것"
분명 상태는 업데이트됐는데, 함수 내부에서는 여전히 옛날 값을 참조하고 있는 버그이다.
'Javascript > Javascript 지식' 카테고리의 다른 글
| Promise에 대한 오해.., await/async 좀 더 알아보기 (0) | 2026.02.17 |
|---|---|
| Javascript 프로토타입 (feat.일급객체) (0) | 2026.02.16 |
| 비동기 vs 병렬 (0) | 2025.09.18 |
| Javascript는 싱글 스레드인데 여러 일을 하네..? (이벤트 루프) (0) | 2025.09.18 |
| type vs interface (0) | 2025.09.18 |
주니어 개발자에욤
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!