JavaScript 핵심 개념 — 클로저 · this · 이벤트 루프 · 프로토타입
면접에서 "말로 설명한다"는 가정으로 작성. 답을 외우기보다 "왜"와 "트레이드오프"를 설명할 수 있는 것이 목표입니다.
1-1. 클로저(Closure)
함수가 선언될 당시의 렉시컬 환경(스코프)을 기억하여, 외부 스코프 밖에서 실행되어도 그 변수에 접근할 수 있는 특성. 즉 함수 + 그 함수가 선언된 환경의 조합입니다.
function counter() {
let count = 0;
return () => ++count; // count를 기억함
}
const c = counter();
c(); // 1
c(); // 2
- 활용: 데이터 은닉(private 변수), 상태 유지, 콜백/이벤트 핸들러에서 값 캡처.
- 주의: 반복문에서
var로 클로저를 만들면 모든 클로저가 같은 변수를 참조해 버그 →let(블록 스코프)으로 해결.
1-2. 호이스팅(Hoisting)
변수·함수 선언이 스코프 최상단으로 끌어올려진 것처럼 동작하는 현상. 실제로 코드가 이동하는 게 아니라, 실행 컨텍스트 생성 단계에서 선언이 먼저 메모리에 등록되기 때문입니다.
var: 선언이 호이스팅되고undefined로 초기화됨let/const: 호이스팅은 되지만 초기화 전 접근 시 에러 → TDZ(Temporal Dead Zone)- 함수 선언문: 함수 전체가 호이스팅됨 / 함수 표현식은 변수 규칙을 따름
화살표 함수는 호이스팅이 안 되나? (정확한 표현)
자주 틀리는 포인트. **"화살표 함수가 호이스팅 안 된다"가 아니라 "할당받는 변수(const/let)가 TDZ에 걸린다"**가 정확합니다. 차이를 만드는 건 "화살표 함수냐"가 아니라 **"함수 선언문이냐, 변수 할당이냐"**입니다.
// 함수 선언문 → 함수 전체 호이스팅
sayHi(); // ✅ 동작
function sayHi() {}
// 함수 표현식(화살표) → const 변수라 TDZ
greet(); // ❌ ReferenceError (Cannot access before initialization)
const greet = () => {};
// var면 화살표 무관하게 동일하게 실패 (undefined로 초기화되므로)
foo(); // ❌ TypeError: foo is not a function
var foo = () => {};
| 형태 | 호이스팅 | 선언 전 호출 |
|---|---|---|
function f(){} | 함수 전체 | ✅ 가능 |
const f = () => {} | 선언만 (TDZ) | ❌ ReferenceError |
var f = () => {} | 선언만 (undefined) | ❌ TypeError |
면접 멘트: "화살표 함수는 함수 표현식이라 변수에 할당되는 형태이고, const/let의 TDZ 때문에 선언 전 접근이 불가능합니다."
1-3. this 바인딩
this는 함수가 어떻게 호출되는가에 따라 결정됩니다.
- 일반 함수 호출 → 전역 객체(strict mode에서는
undefined) - 메서드 호출 → 메서드를 소유한 객체
- 생성자 호출(
new) → 새로 생성된 인스턴스 call/apply/bind→ 명시적으로 지정한 객체- 화살표 함수 → 자신의
this가 없고, **선언된 위치의 상위 스코프this**를 그대로 사용(렉시컬)
call / apply / bind 차이
셋 다 this를 명시적으로 지정합니다.
call(thisArg, arg1, arg2): 인자를 나열해 즉시 호출apply(thisArg, [args]): 인자를 배열로 받아 즉시 호출bind(thisArg): 호출하지 않고this가 고정된 새 함수를 반환
1-4. 이벤트 루프 / 마이크로태스크 vs 매크로태스크
JS는 싱글 스레드지만 이벤트 루프 덕분에 비동기를 처리합니다. 콜 스택이 비면 이벤트 루프가 큐에서 작업을 꺼내 실행합니다. 비동기 콜백은 두 개의 분리된 큐로 관리됩니다.
매크로태스크(Macrotask): setTimeout, setInterval, I/O, UI 렌더링, 이벤트 콜백, MessageChannel, setImmediate(Node)
마이크로태스크(Microtask): Promise.then/catch/finally, async/await(await 이후 코드), queueMicrotask, MutationObserver, process.nextTick(Node, 최우선)
핵심 규칙 (우선순위)
콜 스택이 비면 → 마이크로태스크 큐를 전부 비운다 → 매크로태스크를 하나만 처리한다 → 다시 마이크로태스크 전부 → 매크로태스크 하나 … 반복.
마이크로태스크가 항상 우선이고, 한 사이클에 큐를 통째로 비웁니다.
console.log('1');
setTimeout(() => console.log('2'), 0); // 매크로
Promise.resolve()
.then(() => console.log('3')) // 마이크로
.then(() => console.log('4')); // 마이크로
console.log('5');
// 출력: 1 → 5 → 3 → 4 → 2
setTimeout 0ms인데도 2가 3,4보다 늦게 나오는 게 포인트.
async/await
async function f() {
console.log('a');
await null; // 여기서 함수 양보
console.log('b'); // await 이후는 마이크로태스크
}
console.log('start'); f(); console.log('end');
// 출력: start → a → end → b
마이크로태스크 기아(starvation)
마이크로태스크가 계속 새로 추가되면 큐를 "전부" 비우는 동안 매크로태스크 차례가 영영 오지 않을 수 있습니다.
| 마이크로태스크 | 매크로태스크 | |
|---|---|---|
| 대표 예 | Promise.then, await | setTimeout, I/O |
| 한 사이클 처리량 | 큐 전체 | 하나만 |
| 우선순위 | 높음 | 낮음 |
1-5. 프로토타입(Prototype)
JS는 프로토타입 기반 언어입니다. 모든 객체는 [[Prototype]] 내부 슬롯으로 다른 객체(프로토타입)를 가리키며, 프로퍼티를 찾을 때 없으면 프로토타입 체인을 따라 올라가며 탐색합니다. 이를 통해 상속과 메서드 공유를 구현합니다.
1-6. 얕은 복사 vs 깊은 복사
- 얕은 복사: 한 단계만 복사, 중첩 객체는 참조 공유 (
{...obj},Object.assign,slice) - 깊은 복사: 모든 단계를 새로 복사, 참조 끊김 (
structuredClone(),JSON.parse(JSON.stringify())— 단 함수·undefined·순환참조 처리 불가)
1-7. var / let / const
| var | let | const | |
|---|---|---|---|
| 스코프 | 함수 | 블록 | 블록 |
| 재선언 | O | X | X |
| 재할당 | O | O | X |
| 호이스팅 | undefined 초기화 | TDZ | TDZ |
1-8. 디바운스 vs 스로틀
둘 다 이벤트가 너무 자주 발생할 때 함수 실행 횟수를 줄이는 기법. 전략이 다릅니다.
- 디바운스: 이벤트가 멈추고 일정 시간이 지나야 실행 → "마지막 한 번". 호출마다 타이머를 리셋. ex) 엘리베이터
- 스로틀: 일정 시간 간격마다 최대 한 번 실행 → "진행 중에도 주기적으로". 쿨다운 동안 무시. ex) 버스, 지하철
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer); // 리셋
timer = setTimeout(() => fn(...args), delay);
};
}
function throttle(fn, interval) {
let blocked = false;
return (...args) => {
if (blocked) return; // 쿨다운 중 무시
fn(...args);
blocked = true;
setTimeout(() => (blocked = false), interval);
};
}
이벤트: X X X X X X X X X X (멈춤)
디바운스: 🔴 ← 멈춘 뒤 마지막 1번
스로틀: 🔴 🔴 🔴 🔴 ← 일정 간격마다
| 상황 | 선택 |
|---|---|
| 검색 자동완성 / 입력 검증 / resize | 디바운스 |
| 스크롤 추적 / 무한 스크롤 / 버튼 연타 방지 / 마우스 이동 | 스로틀 |
결정적 차이: 이벤트가 계속되는 도중에 실행되는가. 디바운스는 멈춰야 실행, 스로틀은 진행 중에도 간격마다 실행.
플러스 멘트: 실무에선 lodash
_.debounce/_.throttle을 쓰며leading(첫 호출 즉시)·trailing(마지막 호출) 옵션으로 세밀하게 제어합니다.
1-9. 이벤트 위임(Event Delegation)
자식 각각에 핸들러를 다는 대신, 부모에 하나만 등록하고 버블링을 이용해 처리하는 패턴.
이벤트 전파 3단계
1. 캡처링 : window → document → div → button (위→아래)
2. 타깃 : button (실제 클릭 요소)
3. 버블링 : button → div → document → window (아래→위) ← 기본 핸들러는 여기서 동작
el.addEventListener('click', handler); // 버블링(기본)
el.addEventListener('click', handler, { capture: true }); // 캡처링
패턴과 핵심 보정
const ul = document.querySelector('ul');
ul.addEventListener('click', (e) => {
const li = e.target.closest('li'); // 실제 클릭 지점에서 가장 가까운 li
if (!li || !ul.contains(li)) return; // 여백 등 무시
console.log(li.textContent);
});
| 프로퍼티 | 의미 |
|---|---|
e.target | 이벤트가 실제로 시작된 요소 (li 안 span일 수도) |
e.currentTarget | 핸들러가 등록된 요소 (여기선 ul) |
- 장점: ① 메모리 절약(핸들러 1개) ② 동적 추가 요소 자동 대응 ③ 관리 단순
- 자식 엘리먼트 때문에
e.target대신closest()로 보정해야 안전.
주의
- 버블링되지 않는 이벤트(
focus,blur,mouseenter,mouseleave)는 위임이 안 됨 →focusin/focusout(버블링 O)이나 캡처링 사용. e.stopPropagation()(전파 중단) vse.preventDefault()(기본 동작만 차단)는 다른 것.
플러스 멘트: React의 합성 이벤트(SyntheticEvent)도 내부적으로 루트에 이벤트를 위임하는 방식으로 동작합니다.