비유로 시작하기

JavaScript는 싱글 스레드 언어입니다. 레스토랑에 비유하면 웨이터가 한 명인 것과 같습니다. 그런데 이 웨이터는 손님이 주문하면 주방에 전달하고(비동기 작업 요청), 다른 손님을 응대하고(다른 코드 실행), 주방이 완성했을 때 다시 그 손님에게 갑니다(콜백/Promise 처리). 이것이 이벤트 루프입니다.


실행 컨텍스트 (Execution Context)

JavaScript 코드가 실행되는 환경입니다. 코드가 실행될 때 엔진은 실행 컨텍스트를 생성합니다.

구성요소

실행 컨텍스트 = {
  Variable Environment: 변수, 함수 선언 저장
  Lexical Environment: 스코프 체인 관리
  this Binding: this가 가리키는 객체
}

콜 스택 (Call Stack)

function multiply(a, b) {
    return a * b;  // 3. multiply 실행 컨텍스트 (스택 최상단)
}

function square(n) {
    return multiply(n, n);  // 2. square 실행 컨텍스트
}

function printSquare(n) {
    const result = square(n);  // 1. printSquare 실행 컨텍스트
    console.log(result);
}

printSquare(5);

// 콜 스택 순서:
// [printSquare] → [printSquare, square] → [printSquare, square, multiply]
// → multiply 반환 → [printSquare, square] → square 반환 → [printSquare] → 반환

호이스팅 (Hoisting)

변수와 함수 선언이 실행 컨텍스트 생성 단계에서 메모리에 미리 등록됩니다.

// 함수 선언식: 완전히 호이스팅
console.log(greet("Kim"));  // "Hello, Kim" (정상 작동)
function greet(name) {
    return `Hello, ${name}`;
}

// var: 선언만 호이스팅, 초기화는 undefined
console.log(x);  // undefined (에러 아님)
var x = 10;
console.log(x);  // 10

// let/const: 선언은 호이스팅되지만 TDZ(Temporal Dead Zone) 존재
console.log(y);  // ReferenceError: Cannot access 'y' before initialization
let y = 20;

이벤트 루프 (Event Loop)

JavaScript의 비동기 처리 메커니즘입니다.

graph TD CS[Call Stack
현재 실행 중인 코드] WA[Web APIs
setTimeout, fetch, DOM] CQ[Callback Queue
Task Queue
setTimeout 콜백] MQ[Microtask Queue
Promise.then, MutationObserver] EL[Event Loop] CS -->|비동기 요청| WA WA -->|완료 시| CQ WA -->|Promise 완료| MQ EL -->|Call Stack 비면| MQ EL -->|Microtask 없으면| CQ MQ --> CS CQ --> CS
console.log('1');  // 동기

setTimeout(() => console.log('2'), 0);  // Task Queue

Promise.resolve().then(() => console.log('3'));  // Microtask Queue

console.log('4');  // 동기

// 출력 순서: 1 → 4 → 3 → 2
// 이유:
// - 동기 코드 먼저 (1, 4)
// - Microtask Queue 우선 (3)
// - Task Queue 마지막 (2)

중요: Microtask Queue(Promise)는 Task Queue(setTimeout)보다 항상 먼저 처리됩니다.

// 실무 함정: 무한 Microtask
function recursivePromise() {
    Promise.resolve().then(recursivePromise);
    // Call Stack이 비어도 Microtask가 계속 추가됨 → UI 블로킹!
}
// 해결: setTimeout으로 Task Queue에 넣어 UI 업데이트 기회 제공

this 바인딩

this함수가 호출되는 방식에 따라 결정됩니다.

// 1. 일반 함수 호출: window (strict mode에서 undefined)
function show() {
    console.log(this);
}
show();  // window

// 2. 메서드 호출: 메서드를 소유한 객체
const obj = {
    name: 'Kim',
    greet() {
        console.log(this.name);
    }
};
obj.greet();  // 'Kim'

// 3. 생성자 함수: 새로 생성된 객체
function Person(name) {
    this.name = name;
}
const p = new Person('Lee');
console.log(p.name);  // 'Lee'

// 4. 화살표 함수: 상위 스코프의 this (렉시컬 this)
const timer = {
    count: 0,
    start() {
        setInterval(() => {
            this.count++;  // this = timer 객체 (상위 스코프)
            console.log(this.count);
        }, 1000);
    }
};

// 5. 명시적 바인딩: call, apply, bind
function greet(greeting) {
    return `${greeting}, ${this.name}`;
}
const user = { name: 'Park' };
console.log(greet.call(user, 'Hello'));     // 'Hello, Park'
console.log(greet.apply(user, ['Hi']));    // 'Hi, Park'
const boundGreet = greet.bind(user);
console.log(boundGreet('Hey'));            // 'Hey, Park'

클로저 (Closure)

함수가 자신이 생성될 때의 렉시컬 환경을 기억하는 것입니다.

function makeCounter(initial = 0) {
    let count = initial;  // 외부에서 직접 접근 불가 (private)

    return {
        increment() { return ++count; },
        decrement() { return --count; },
        getCount() { return count; }
    };
}

const counter = makeCounter(10);
console.log(counter.increment());  // 11
console.log(counter.increment());  // 12
console.log(counter.decrement());  // 11
// count 변수는 외부에서 직접 수정 불가 → 캡슐화

실무 활용: 함수 팩토리

// 다른 배율의 곱셈 함수 생성
function makeMultiplier(factor) {
    return (num) => num * factor;  // factor를 클로저로 기억
}

const double = makeMultiplier(2);
const triple = makeMultiplier(3);

console.log(double(5));   // 10
console.log(triple(5));   // 15

// 메모이제이션 (캐싱)
function memoize(fn) {
    const cache = {};
    return function(...args) {
        const key = JSON.stringify(args);
        if (cache[key] !== undefined) {
            console.log('캐시 히트:', key);
            return cache[key];
        }
        cache[key] = fn.apply(this, args);
        return cache[key];
    };
}

const expensiveCalc = memoize((n) => {
    // 복잡한 계산...
    return n * n;
});

expensiveCalc(10);  // 계산 실행
expensiveCalc(10);  // 캐시 히트

프로토타입 (Prototype)

JavaScript는 프로토타입 기반 상속을 사용합니다.

// 모든 함수는 prototype 프로퍼티를 가짐
function Animal(name) {
    this.name = name;
}

// 프로토타입에 메서드 추가 (인스턴스가 공유)
Animal.prototype.speak = function() {
    return `${this.name}이 소리를 냅니다`;
};

const dog = new Animal('멍멍이');
console.log(dog.speak());  // "멍멍이이 소리를 냅니다"

// 프로토타입 체인
console.log(dog.hasOwnProperty('name'));  // true (자체 프로퍼티)
console.log(dog.hasOwnProperty('speak')); // false (프로토타입의 것)

// 프로토타입 체인: dog → Animal.prototype → Object.prototype → null

Class 문법 (ES6+) - 프로토타입의 문법적 설탕

class Animal {
    #name;  // Private field (ES2022)

    constructor(name) {
        this.#name = name;
    }

    speak() {
        return `${this.#name}이 소리를 냅니다`;
    }

    get name() { return this.#name; }

    static create(name) {  // 정적 메서드
        return new Animal(name);
    }
}

class Dog extends Animal {
    #breed;

    constructor(name, breed) {
        super(name);  // 부모 생성자 호출
        this.#breed = breed;
    }

    speak() {
        return `${super.speak()} - 멍멍!`;  // 부모 메서드 호출
    }

    get breed() { return this.#breed; }
}

const dog = new Dog('바둑이', '진도');
console.log(dog.speak());  // "바둑이이 소리를 냅니다 - 멍멍!"

Promise / async-await

Promise

비동기 작업의 최종 완료 또는 실패를 나타냅니다.

// Promise 생성
function fetchUser(id) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (id > 0) {
                resolve({ id, name: 'Kim' });  // 성공
            } else {
                reject(new Error('Invalid user ID'));  // 실패
            }
        }, 1000);
    });
}

// Promise 체이닝
fetchUser(1)
    .then(user => {
        console.log(user);  // { id: 1, name: 'Kim' }
        return fetchUserOrders(user.id);  // 다음 비동기 작업
    })
    .then(orders => console.log(orders))
    .catch(err => console.error('에러:', err.message))
    .finally(() => console.log('항상 실행'));

// Promise.all: 병렬 실행, 모두 완료 대기
const [user, posts, comments] = await Promise.all([
    fetchUser(1),
    fetchPosts(1),
    fetchComments(1)
]);

// Promise.allSettled: 일부 실패해도 전체 결과 반환
const results = await Promise.allSettled([
    fetchUser(1),
    fetchUser(-1),  // 실패
]);
// [{ status: 'fulfilled', value: {...} }, { status: 'rejected', reason: Error }]

// Promise.race: 가장 먼저 완료된 것 반환 (타임아웃 구현에 활용)
const result = await Promise.race([
    fetchData(),
    new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))
]);

async/await

Promise를 동기 코드처럼 작성하는 문법입니다.

async function getOrderDetails(orderId) {
    try {
        const order = await fetchOrder(orderId);           // 순차 실행
        const user = await fetchUser(order.customerId);    // 순차 실행

        // 독립적인 요청은 병렬로
        const [items, shipping] = await Promise.all([
            fetchOrderItems(orderId),
            fetchShippingInfo(orderId)
        ]);

        return { order, user, items, shipping };
    } catch (error) {
        if (error instanceof OrderNotFoundException) {
            throw error;  // 다시 던지기
        }
        throw new Error(`주문 상세 조회 실패: ${error.message}`);
    }
}

var / let / const

항목 var let const
스코프 함수 스코프 블록 스코프 블록 스코프
호이스팅 O (undefined) O (TDZ) O (TDZ)
재선언 가능 불가 불가
재할당 가능 가능 불가
전역 객체 등록 등록됨 등록 안 됨 등록 안 됨
// var의 함수 스코프 함정
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// 출력: 3, 3, 3 (클로저가 같은 i를 참조)

// let으로 해결 (블록 스코프: 루프마다 새로운 i)
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// 출력: 0, 1, 2

극한 시나리오

시나리오: Promise Hell (콜백 지옥의 Promise 버전)

// 나쁜 예: 중첩 then
fetchUser(1)
    .then(user => {
        fetchOrders(user.id)
            .then(orders => {
                fetchOrderItems(orders[0].id)
                    .then(items => { /* 더 깊어짐... */ });
            });
    });

// 좋은 예: 체이닝 또는 async/await
async function loadData() {
    const user = await fetchUser(1);
    const orders = await fetchOrders(user.id);
    const items = await fetchOrderItems(orders[0].id);
    return { user, orders, items };
}

시나리오: 메모리 누수 (클로저 + 이벤트 리스너)

// 누수 발생
function setupPage() {
    const largeData = new Array(1000000).fill('data');  // 대용량 데이터

    document.getElementById('btn').addEventListener('click', () => {
        console.log(largeData.length);  // largeData를 클로저로 참조
    });
    // 버튼이 DOM에서 제거되어도 largeData는 GC 안 됨
}

// 해결: 정리(cleanup) 함수 반환
function setupPage() {
    const largeData = new Array(1000000).fill('data');

    const handler = () => console.log(largeData.length);
    document.getElementById('btn').addEventListener('click', handler);

    return () => {
        document.getElementById('btn').removeEventListener('click', handler);
        // largeData도 GC 가능해짐
    };
}
const cleanup = setupPage();
// 나중에
cleanup();