JavaScript 핵심 — 클로저, 프로토타입, this, 이벤트 루프
setTimeout(() => console.log('A'), 0)보다Promise.resolve().then(() => console.log('B'))가 먼저 실행됩니다. 둘 다 비동기인데 왜 순서가 다를까요?
자바스크립트의 핵심 개념들 — 실행 컨텍스트, 클로저, 프로토타입, this, 이벤트 루프 — 은 서로 연결되어 있어요. 이 글에서는 각 개념의 동작 원리와 연결 고리를 코드와 함께 정리해 봅니다.
실행 컨텍스트 (Execution Context)
자바스크립트 엔진이 코드를 실행할 때, 각 실행 단위마다 실행 컨텍스트 라는 환경을 만들어요. 이 컨텍스트 안에 변수, 함수 선언, this, 스코프 체인 정보가 들어있고, 콜 스택(Call Stack) 에 LIFO 구조로 쌓입니다.
function outer() {
const a = 1;
function inner() {
const b = 2;
console.log(a + b); // 3
}
inner();
}
outer();
위 코드의 콜 스택 변화를 보면:
- Global Execution Context 생성 → 스택에 push
outer()호출 → outer의 실행 컨텍스트 pushinner()호출 → inner의 실행 컨텍스트 pushinner실행 완료 → popouter실행 완료 → pop
실행 컨텍스트는 크게 두 가지 구성 요소를 갖습니다.
- Variable Environment:
var선언과 함수 선언이 저장되는 공간 - Lexical Environment:
let,const선언이 저장되며 스코프 체인을 형성
이게 왜 중요하냐면, 호이스팅이나 클로저 같은 동작이 전부 이 실행 컨텍스트의 구조로부터 나오기 때문이에요.
호이스팅 (Hoisting)
호이스팅은 "선언이 코드 상단으로 끌어올려진다"라고 흔히 설명하는데, 실제로 코드가 물리적으로 이동하는 건 아닙니다. 실행 컨텍스트가 생성되는 ** 생성 단계(Creation Phase)**에서 변수와 함수 선언이 먼저 메모리에 등록되기 때문에 그렇게 보이는 거예요.
var vs let/const
console.log(x); // undefined
var x = 10;
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;
var는 선언과 동시에 undefined로 초기화됩니다. 그래서 선언 전에 접근해도 에러가 아니라 undefined가 나와요.
반면 let과 const는 선언은 되지만 초기화되지 않은 상태로 남습니다. 이 구간을 TDZ(Temporal Dead Zone) 라고 불러요.
TDZ (Temporal Dead Zone)
{
// --- TDZ 시작 ---
console.log(name); // ReferenceError
// --- TDZ 끝 ---
let name = "JavaScript";
console.log(name); // "JavaScript"
}
TDZ는 변수가 스코프에 진입한 시점부터 실제 선언문을 만나는 시점까지의 구간이에요. 이 구간에서 해당 변수에 접근하면 ReferenceError가 발생합니다.
var와 let의 차이가 뭔가요? 단순히 "var는 함수 스코프, let은 블록 스코프"만 아는 것으로는 부족해요. TDZ까지 이해하는 게 중요합니다.
함수 호이스팅
sayHi(); // "안녕!"
function sayHi() {
console.log("안녕!");
}
sayBye(); // TypeError: sayBye is not a function
var sayBye = function () {
console.log("잘 가!");
};
함수 선언문 은 통째로 호이스팅됩니다. 하지만 함수 표현식 은 변수 호이스팅 규칙을 따르기 때문에, var sayBye는 undefined로 초기화되어 호출하면 TypeError가 발생해요.
스코프 (Scope)
함수 스코프 vs 블록 스코프
// var: 함수 스코프
function example() {
if (true) {
var x = 10;
}
console.log(x); // 10 — if 블록 밖에서도 접근 가능
}
// let/const: 블록 스코프
function example2() {
if (true) {
let y = 20;
}
console.log(y); // ReferenceError
}
var는 함수 단위로만 스코프가 잡혀요. if, for 같은 블록은 신경 쓰지 않습니다.
let과 const는 {}로 감싸진 블록마다 별도 스코프를 만들어요. 이게 좀 더 직관적이라 요즘은 대부분 let/const만 씁니다.
렉시컬 스코프 (Lexical Scope)
const value = "global";
function printValue() {
console.log(value);
}
function wrapper() {
const value = "local";
printValue(); // "global" — 호출 위치가 아닌, 정의 위치 기준
}
wrapper();
자바스크립트는 렉시컬 스코프(정적 스코프) 를 따릅니다. 함수가 어디서 호출 됐는지가 아니라 어디서 정의 됐는지에 따라 상위 스코프가 결정돼요.
이 특성이 바로 클로저가 동작하는 근거입니다.
클로저 (Closure)
클로저는 함수가 자신이 정의된 렉시컬 환경을 기억하고, 그 환경 밖에서 실행되더라도 해당 환경에 접근할 수 있는 것을 말합니다.
function makeCounter() {
let count = 0;
return {
increment() { count++; },
getCount() { return count; }
};
}
const counter = makeCounter();
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2
makeCounter가 이미 실행을 끝냈는데도 count에 접근이 가능해요. 반환된 함수들이 makeCounter의 렉시컬 환경을 계속 참조하고 있기 때문입니다.
실용적 사례들
private 변수
function createUser(name) {
let _password = "1234"; // 외부에서 직접 접근 불가
return {
getName() { return name; },
checkPassword(input) { return input === _password; },
changePassword(oldPw, newPw) {
if (oldPw === _password) {
_password = newPw;
return true;
}
return false;
}
};
}
const user = createUser("심정훈");
console.log(user._password); // undefined
console.log(user.checkPassword("1234")); // true
자바스크립트에는 전통적으로 접근 제어자가 없었는데(ES2022에서 # 문법이 추가되긴 했어요), 클로저를 이용하면 private 변수를 흉내낼 수 있습니다.
커링 (Currying)
function multiply(a) {
return function (b) {
return a * b;
};
}
const double = multiply(2);
const triple = multiply(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
인자를 하나씩 받아서 새로운 함수를 반환하는 패턴이에요. 첫 번째 인자 a가 클로저에 의해 캡처됩니다.
디바운스 (Debounce)
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const handleSearch = debounce((query) => {
console.log("검색:", query);
}, 300);
// 빠르게 연속 호출해도 마지막 호출 후 300ms 뒤에 한 번만 실행
handleSearch("J");
handleSearch("Ja");
handleSearch("Jav");
handleSearch("Java");
timer 변수가 클로저에 의해 유지되기 때문에, 이전 타이머를 취소하고 새로 설정하는 게 가능해요. 프론트엔드에서 실제로 정말 많이 쓰이는 패턴입니다.
프로토타입 (Prototype)
자바스크립트는 클래스 기반이 아니라 프로토타입 기반 언어입니다. ES6에서 class 키워드가 도입됐지만, 내부적으로는 여전히 프로토타입 체인이 동작해요.
__proto__ vs prototype
이 둘을 혼동하는 사람이 많은데, 역할이 완전히 달라요.
prototype: 생성자 함수가 가지고 있는 속성.new로 인스턴스를 만들 때, 인스턴스의__proto__가 이 객체를 가리키게 됩니다.__proto__: 모든 객체가 가지고 있는 내부 링크. 자신을 만든 생성자의prototype을 가리킵니다.
function Dog(name) {
this.name = name;
}
Dog.prototype.bark = function () {
console.log(`${this.name}: 멍!`);
};
const dog = new Dog("초코");
console.log(dog.__proto__ === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null — 체인의 끝
프로토타입 체인
객체에서 속성이나 메서드를 찾을 때, 해당 객체에 없으면 __proto__를 따라 올라가며 찾습니다. Object.prototype까지 올라가도 못 찾으면 undefined를 반환해요.
const animal = {
eat() { console.log("먹는다"); }
};
const cat = Object.create(animal);
cat.meow = function () { console.log("야옹"); };
cat.meow(); // "야옹" — cat 자신의 메서드
cat.eat(); // "먹는다" — animal에서 찾음 (프로토타입 체인)
Object.create
Object.create(proto)는 proto를 프로토타입으로 갖는 새로운 객체를 만들어요. new 키워드 없이도 상속 구조를 만들 수 있어서, 프로토타입 체인을 직접 구성할 때 유용합니다.
const base = {
greet() {
return `안녕, ${this.name}`;
}
};
const person = Object.create(base);
person.name = "심정훈";
console.log(person.greet()); // "안녕, 심정훈"
console.log(person.hasOwnProperty("name")); // true
console.log(person.hasOwnProperty("greet")); // false — 프로토타입에서 온 것
this 바인딩
this가 가리키는 대상은 함수가 ** 어떻게 호출되느냐 **에 따라 결정됩니다. 정의 위치가 아니라 호출 방식이 중요하다는 점이 스코프와 다른 부분이에요.
1. 기본 바인딩
function showThis() {
console.log(this);
}
showThis(); // 브라우저: window / strict mode: undefined
아무 조건 없이 일반 함수로 호출하면, this는 전역 객체를 가리킵니다. strict mode에서는 undefined가 돼요.
2. 암시적 바인딩
const obj = {
name: "JavaScript",
greet() {
console.log(this.name);
}
};
obj.greet(); // "JavaScript" — obj가 this
메서드로 호출하면 점(.) 왼쪽의 객체가 this가 됩니다.
주의할 점은, 메서드를 변수에 할당하면 바인딩이 풀린다는 거예요.
const fn = obj.greet;
fn(); // undefined — 기본 바인딩으로 돌아감
3. 명시적 바인딩 (call / apply / bind)
function introduce(greeting) {
console.log(`${greeting}, 저는 ${this.name}입니다.`);
}
const person = { name: "심정훈" };
introduce.call(person, "안녕하세요"); // call: 인자를 하나씩
introduce.apply(person, ["안녕하세요"]); // apply: 인자를 배열로
const bound = introduce.bind(person); // bind: 새 함수 반환
bound("안녕하세요");
call과apply는 즉시 실행합니다. 차이는 인자 전달 방식뿐이에요.bind는this가 고정된 새로운 함수를 반환해요. 나중에 호출할 수 있다는 게 핵심입니다.
4. new 바인딩
function Person(name) {
this.name = name;
}
const p = new Person("심정훈");
console.log(p.name); // "심정훈"
new를 붙여서 호출하면 빈 객체가 생성되고, this는 그 객체를 가리킵니다. 생성자 함수가 명시적으로 다른 객체를 반환하지 않는 한, 생성된 객체가 반환돼요.
5. 화살표 함수
const team = {
name: "프론트엔드팀",
members: ["A", "B", "C"],
printMembers() {
this.members.forEach((member) => {
// 화살표 함수는 자신만의 this를 갖지 않음 → 상위 스코프(printMembers)의 this 사용
console.log(`${this.name}: ${member}`);
});
}
};
team.printMembers();
// 프론트엔드팀: A
// 프론트엔드팀: B
// 프론트엔드팀: C
화살표 함수는 자체적인 this를 만들지 않아요. 대신 ** 정의된 위치의 상위 스코프 **에서 this를 가져옵니다. 이걸 "렉시컬 this"라고 부르기도 해요.
그래서 화살표 함수에는 call, apply, bind로 this를 바꿀 수 없습니다.
this 바인딩 우선순위
new > 명시적(call/apply/bind) > 암시적(메서드 호출) > 기본(일반 호출)
이 우선순위는 자주 헷갈리는 부분이니 기억해두면 좋습니다.
이벤트 루프 (Event Loop)
자바스크립트는 ** 싱글 스레드** 언어입니다. 한 번에 하나의 작업만 처리할 수 있다는 뜻인데, 그런데도 비동기 처리가 가능한 이유가 바로 이벤트 루프 덕분이에요.
구성 요소
- Call Stack: 현재 실행 중인 함수가 쌓이는 곳
- Web APIs:
setTimeout,fetch, DOM 이벤트 등 브라우저가 제공하는 API. 비동기 작업은 여기서 처리돼요. - Callback Queue (Task Queue / Macrotask Queue): Web API에서 처리가 끝난 콜백이 대기하는 큐.
setTimeout,setInterval, I/O 이벤트 등의 콜백이 여기 들어갑니다. - Microtask Queue:
Promise.then,MutationObserver,queueMicrotask등의 콜백이 대기하는 큐
동작 순서
- Call Stack 에서 동기 코드를 실행합니다
- 비동기 작업을 만나면 Web APIs 에 위임해요
- Web API 처리가 끝나면 콜백을 해당 큐 에 넣습니다
- Call Stack이 비면, Microtask Queue 를 먼저 전부 비워요
- 그 다음 Callback Queue 에서 하나를 꺼내 실행합니다
- 3~5를 반복해요
핵심은 마이크로태스크가 매크로태스크보다 항상 먼저 처리된다는 점입니다.
Promise vs setTimeout 실행 순서
가장 자주 헷갈리는 출력 순서 문제예요.
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
Promise.resolve().then(() => {
console.log("3");
});
console.log("4");
출력 순서: 1 → 4 → 3 → 2
왜 이렇게 되는지 단계별로 보면:
console.log("1")— 동기, 바로 실행setTimeout— 콜백을 Callback Queue(매크로태스크) 에 등록Promise.resolve().then(...)— 콜백을 Microtask Queue 에 등록console.log("4")— 동기, 바로 실행- Call Stack이 비었으니 Microtask Queue 먼저 처리 →
"3"출력 - 그 다음 Callback Queue 처리 →
"2"출력
좀 더 복잡한 예제도 볼게요.
console.log("start");
setTimeout(() => console.log("timeout 1"), 0);
Promise.resolve()
.then(() => {
console.log("promise 1");
setTimeout(() => console.log("timeout 2"), 0);
})
.then(() => {
console.log("promise 2");
});
setTimeout(() => console.log("timeout 3"), 0);
console.log("end");
출력 순서: start → end → promise 1 → promise 2 → timeout 1 → timeout 3 → timeout 2
promise 1의 .then 체인에서 등록한 promise 2도 마이크로태스크이므로 매크로태스크보다 먼저 실행돼요. timeout 2는 promise 1 콜백 안에서 등록됐으므로 가장 나중에 처리됩니다.
타입 변환
== vs ===
console.log(0 == ""); // true — 타입 변환 후 비교
console.log(0 === ""); // false — 타입까지 비교
console.log(null == undefined); // true — 이건 명세에서 특별하게 정의
console.log(null === undefined); // false
==는 ** 암묵적 타입 변환(Type Coercion)**을 수행한 뒤 비교합니다. ===는 타입이 다르면 바로 false를 반환해요.
실무에서는 거의 항상 ===를 씁니다. ==는 예측하기 어려운 동작이 너무 많아요.
truthy / falsy
자바스크립트에서 false로 평가되는 값(falsy)은 딱 8개 예요.
// falsy 값 목록
false
0
-0
0n // BigInt
"" // 빈 문자열
null
undefined
NaN
이것들을 제외한 나머지는 전부 truthy입니다. 주의할 점 몇 가지:
Boolean([]); // true — 빈 배열은 truthy!
Boolean({}); // true — 빈 객체도 truthy!
Boolean("0"); // true — 문자열 "0"도 truthy!
Boolean("false"); // true — 문자열 "false"도 truthy!
빈 배열이 truthy라는 거, 처음 보면 꽤 당황스러워요.
암묵적 변환의 함정
console.log([] + []); // "" — 둘 다 빈 문자열로 변환 후 연결
console.log([] + {}); // "[object Object]"
console.log({} + []); // 브라우저에 따라 다름 (0 또는 "[object Object]")
console.log(1 + "2"); // "12" — 숫자가 문자열로 변환
console.log("3" - 1); // 2 — 문자열이 숫자로 변환
console.log(true + true); // 2 — true는 1로 변환
console.log("5" > 3); // true — 문자열 "5"가 숫자 5로 변환
+ 연산자는 피연산자 중 하나가 문자열이면 문자열 연결로 동작하고, -는 항상 숫자 연산을 시도해요. 이 비대칭성이 많은 혼란을 일으킵니다.
[] == false가 true인 이유는 뭘까? 과정을 따라가 보면:
[] == false
// 1단계: false → 0
[] == 0
// 2단계: [] → "" (ToPrimitive)
"" == 0
// 3단계: "" → 0
0 == 0
// 결과: true
이런 변환 규칙을 전부 외울 필요는 없지만, 왜 ===를 써야 하는지 설명할 수 있으면 충분합니다.
심화 주제
여기서부터는 깊이 있게 파고들 때 나오는 주제들이에요.
가비지 컬렉션 (Garbage Collection)
자바스크립트 엔진은 Mark-and-Sweep 알고리즘으로 메모리를 관리합니다.
- **Mark 단계 **: 루트(전역 객체, 현재 실행 중인 함수의 지역 변수 등)에서 출발해서 참조 가능한 모든 객체에 "도달 가능" 표시를 해요
- **Sweep 단계 **: 표시되지 않은 객체를 메모리에서 해제합니다
예전에 쓰이던 Reference Counting 방식은 순환 참조 문제가 있었어요.
function circularRef() {
const a = {};
const b = {};
a.ref = b;
b.ref = a;
// 함수 종료 후 a, b는 서로만 참조 → Reference Counting에서는 회수 불가
// Mark-and-Sweep에서는 루트에서 도달 불가하므로 정상적으로 회수
}
WeakRef / WeakMap
// WeakMap: 키에 대한 약한 참조 → GC 대상이 됨
const cache = new WeakMap();
function process(obj) {
if (cache.has(obj)) {
return cache.get(obj);
}
const result = /* 비용이 큰 연산 */ obj.value * 2;
cache.set(obj, result);
return result;
}
let data = { value: 42 };
process(data); // 캐시에 저장
data = null; // data에 대한 강한 참조가 사라지면, WeakMap 엔트리도 GC 대상
WeakMap은 키가 GC되면 엔트리도 함께 사라져요. DOM 요소에 메타데이터를 붙일 때, 메모리 누수 없이 캐시를 만들 때 유용합니다.
WeakRef는 ES2021에서 도입됐고, 객체에 대한 약한 참조를 직접 만들 수 있어요. deref()로 아직 살아있는지 확인합니다.
let target = { name: "temp" };
const ref = new WeakRef(target);
console.log(ref.deref()); // { name: "temp" }
target = null;
// 다음 GC 사이클 이후
console.log(ref.deref()); // undefined (GC가 회수했다면)
Symbol
const id = Symbol("id");
const anotherId = Symbol("id");
console.log(id === anotherId); // false — 설명이 같아도 다른 값
const user = {
name: "심정훈",
[id]: 12345
};
console.log(Object.keys(user)); // ["name"] — Symbol 키는 나오지 않음
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(id)]
Symbol은 유일무이한 식별자를 만들 때 사용해요. 속성 키 충돌을 방지할 수 있고, Symbol.iterator 같은 Well-Known Symbol은 내장 동작을 커스터마이징할 때 쓰입니다.
Generator
function* fibonacci() {
let [a, b] = [0, 1];
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
console.log(fib.next().value); // 3
제너레이터는 function*으로 정의하고, yield 키워드로 실행을 일시 중지했다가 재개할 수 있는 특수한 함수예요. 이터레이터 프로토콜을 구현하므로 for...of로 순회할 수도 있습니다.
Redux-Saga 같은 라이브러리가 제너레이터 기반으로 동작하고, async/await도 내부적으로 제너레이터의 아이디어에서 발전한 문법이에요.
파생 개념: 여기서 이어지는 질문들
위 개념들을 제대로 이해하고 있으면, 다음 주제들로 자연스럽게 확장할 수 있어요.
비동기: Promise / async-await
이벤트 루프를 이해했다면, Promise와 async/await도 그 위에서 동작한다는 걸 알 수 있어요.
// Promise 체이닝
fetch("/api/user")
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => console.error(err));
// async/await — 같은 동작을 동기 코드처럼 작성
async function getUser() {
try {
const res = await fetch("/api/user");
const data = await res.json();
console.log(data);
} catch (err) {
console.error(err);
}
}
async 함수는 항상 Promise를 반환하고, await은 Promise가 resolve될 때까지 해당 함수의 실행을 일시 중단합니다. 중단된 동안 콜 스택은 비워지므로 다른 작업이 처리될 수 있어요.
async/await은 동기적으로 동작하는 걸까요? 아닙니다. 문법적으로 동기처럼 보이지만 내부적으로는 Promise 기반 비동기예요.
DOM 조작
프로토타입 체인을 이해하면, DOM 요소들이 왜 특정 메서드를 가지고 있는지도 설명할 수 있어요.
EventTarget ← Node ← Element ← HTMLElement ← HTMLDivElement
document.querySelector("div")로 얻은 요소에서 addEventListener를 호출할 수 있는 건, 프로토타입 체인을 따라 EventTarget까지 올라가서 해당 메서드를 찾기 때문입니다.
const div = document.querySelector("div");
console.log(div instanceof HTMLDivElement); // true
console.log(div instanceof HTMLElement); // true
console.log(div instanceof Element); // true
console.log(div instanceof Node); // true
console.log(div instanceof EventTarget); // true
React와의 연결
React를 쓰면서 JavaScript 핵심 개념이 어디에 녹아있는지 짚어보면:
- **클로저 **:
useState의 상태 값이 렌더링 사이에 유지되는 이유.useEffect의 의존성 배열이 클로저와 관련된 stale closure 문제를 방지하기 위한 것. - **this 바인딩 **: 클래스 컴포넌트에서 이벤트 핸들러를
bind해야 했던 이유. 함수형 컴포넌트 + 화살표 함수 조합으로 이 문제가 사라진 것. - ** 이벤트 루프 **:
setState가 비동기적으로 동작하는 것처럼 보이는 이유. React 18의 자동 배칭(Automatic Batching)이 이벤트 루프의 어떤 시점에서 일어나는지.
이런 연결고리를 설명할 수 있으면 깊이 있는 이해를 갖추고 있다는 뜻이에요.
마무리
여기서 다룬 개념들은 전부 서로 연결되어 있어요. 실행 컨텍스트를 알아야 호이스팅을 이해하고, 렉시컬 스코프를 알아야 클로저가 왜 동작하는지 설명할 수 있고, 프로토타입 체인을 알아야 this가 어떻게 결정되는지 맥락이 잡힙니다. 이벤트 루프는 비동기 코드의 실행 순서를 결정짓는 핵심 메커니즘이에요.
이런 질문에 키워드만 나열하는 것보다, 개념 간의 연결을 보여주는 게 훨씬 중요합니다. "클로저는 렉시컬 스코프의 결과물이고, 렉시컬 스코프는 실행 컨텍스트의 생성 규칙에서 비롯됩니다"처럼요.