setTimeout(() => console.log('A'), 0)보다 Promise.resolve().then(() => console.log('B'))가 먼저 실행됩니다. 둘 다 비동기인데 왜 순서가 다를까요?

자바스크립트의 핵심 개념들 — 실행 컨텍스트, 클로저, 프로토타입, this, 이벤트 루프 — 은 서로 연결되어 있어요. 이 글에서는 각 개념의 동작 원리와 연결 고리를 코드와 함께 정리해 봅니다.

실행 컨텍스트 (Execution Context)

자바스크립트 엔진이 코드를 실행할 때, 각 실행 단위마다 실행 컨텍스트 라는 환경을 만들어요. 이 컨텍스트 안에 변수, 함수 선언, this, 스코프 체인 정보가 들어있고, 콜 스택(Call Stack) 에 LIFO 구조로 쌓입니다.

JS
function outer() {
  const a = 1;

  function inner() {
    const b = 2;
    console.log(a + b); // 3
  }

  inner();
}

outer();

위 코드의 콜 스택 변화를 보면:

  1. Global Execution Context 생성 → 스택에 push
  2. outer() 호출 → outer의 실행 컨텍스트 push
  3. inner() 호출 → inner의 실행 컨텍스트 push
  4. inner 실행 완료 → pop
  5. outer 실행 완료 → pop

실행 컨텍스트는 크게 두 가지 구성 요소를 갖습니다.

  • Variable Environment: var 선언과 함수 선언이 저장되는 공간
  • Lexical Environment: let, const 선언이 저장되며 스코프 체인을 형성

이게 왜 중요하냐면, 호이스팅이나 클로저 같은 동작이 전부 이 실행 컨텍스트의 구조로부터 나오기 때문이에요.


호이스팅 (Hoisting)

호이스팅은 "선언이 코드 상단으로 끌어올려진다"라고 흔히 설명하는데, 실제로 코드가 물리적으로 이동하는 건 아닙니다. 실행 컨텍스트가 생성되는 ** 생성 단계(Creation Phase)**에서 변수와 함수 선언이 먼저 메모리에 등록되기 때문에 그렇게 보이는 거예요.

var vs let/const

JS
console.log(x); // undefined
var x = 10;

console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;

var는 선언과 동시에 undefined로 초기화됩니다. 그래서 선언 전에 접근해도 에러가 아니라 undefined가 나와요. 반면 letconst는 선언은 되지만 초기화되지 않은 상태로 남습니다. 이 구간을 TDZ(Temporal Dead Zone) 라고 불러요.

TDZ (Temporal Dead Zone)

JS
{
  // --- TDZ 시작 ---
  console.log(name); // ReferenceError
  // --- TDZ 끝 ---
  let name = "JavaScript";
  console.log(name); // "JavaScript"
}

TDZ는 변수가 스코프에 진입한 시점부터 실제 선언문을 만나는 시점까지의 구간이에요. 이 구간에서 해당 변수에 접근하면 ReferenceError가 발생합니다.

var와 let의 차이가 뭔가요? 단순히 "var는 함수 스코프, let은 블록 스코프"만 아는 것으로는 부족해요. TDZ까지 이해하는 게 중요합니다.

함수 호이스팅

JS
sayHi(); // "안녕!"

function sayHi() {
  console.log("안녕!");
}

sayBye(); // TypeError: sayBye is not a function

var sayBye = function () {
  console.log("잘 가!");
};

함수 선언문 은 통째로 호이스팅됩니다. 하지만 함수 표현식 은 변수 호이스팅 규칙을 따르기 때문에, var sayByeundefined로 초기화되어 호출하면 TypeError가 발생해요.


스코프 (Scope)

함수 스코프 vs 블록 스코프

JS
// 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 같은 블록은 신경 쓰지 않습니다. letconst{}로 감싸진 블록마다 별도 스코프를 만들어요. 이게 좀 더 직관적이라 요즘은 대부분 let/const만 씁니다.

렉시컬 스코프 (Lexical Scope)

JS
const value = "global";

function printValue() {
  console.log(value);
}

function wrapper() {
  const value = "local";
  printValue(); // "global" — 호출 위치가 아닌, 정의 위치 기준
}

wrapper();

자바스크립트는 렉시컬 스코프(정적 스코프) 를 따릅니다. 함수가 어디서 호출 됐는지가 아니라 어디서 정의 됐는지에 따라 상위 스코프가 결정돼요.

이 특성이 바로 클로저가 동작하는 근거입니다.


클로저 (Closure)

클로저는 함수가 자신이 정의된 렉시컬 환경을 기억하고, 그 환경 밖에서 실행되더라도 해당 환경에 접근할 수 있는 것을 말합니다.

JS
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 변수

JS
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)

JS
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)

JS
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을 가리킵니다.
JS
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를 반환해요.

JS
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 키워드 없이도 상속 구조를 만들 수 있어서, 프로토타입 체인을 직접 구성할 때 유용합니다.

JS
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. 기본 바인딩

JS
function showThis() {
  console.log(this);
}

showThis(); // 브라우저: window / strict mode: undefined

아무 조건 없이 일반 함수로 호출하면, this는 전역 객체를 가리킵니다. strict mode에서는 undefined가 돼요.

2. 암시적 바인딩

JS
const obj = {
  name: "JavaScript",
  greet() {
    console.log(this.name);
  }
};

obj.greet(); // "JavaScript" — obj가 this

메서드로 호출하면 점(.) 왼쪽의 객체가 this가 됩니다.

주의할 점은, 메서드를 변수에 할당하면 바인딩이 풀린다는 거예요.

JS
const fn = obj.greet;
fn(); // undefined — 기본 바인딩으로 돌아감

3. 명시적 바인딩 (call / apply / bind)

JS
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("안녕하세요");
  • callapply는 즉시 실행합니다. 차이는 인자 전달 방식뿐이에요.
  • bindthis가 고정된 새로운 함수를 반환해요. 나중에 호출할 수 있다는 게 핵심입니다.

4. new 바인딩

JS
function Person(name) {
  this.name = name;
}

const p = new Person("심정훈");
console.log(p.name); // "심정훈"

new를 붙여서 호출하면 빈 객체가 생성되고, this는 그 객체를 가리킵니다. 생성자 함수가 명시적으로 다른 객체를 반환하지 않는 한, 생성된 객체가 반환돼요.

5. 화살표 함수

JS
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, bindthis를 바꿀 수 없습니다.

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 등의 콜백이 대기하는 큐

동작 순서

  1. Call Stack 에서 동기 코드를 실행합니다
  2. 비동기 작업을 만나면 Web APIs 에 위임해요
  3. Web API 처리가 끝나면 콜백을 해당 큐 에 넣습니다
  4. Call Stack이 비면, Microtask Queue 를 먼저 전부 비워요
  5. 그 다음 Callback Queue 에서 하나를 꺼내 실행합니다
  6. 3~5를 반복해요

핵심은 마이크로태스크가 매크로태스크보다 항상 먼저 처리된다는 점입니다.


Promise vs setTimeout 실행 순서

가장 자주 헷갈리는 출력 순서 문제예요.

JS
console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

Promise.resolve().then(() => {
  console.log("3");
});

console.log("4");

출력 순서: 1432

왜 이렇게 되는지 단계별로 보면:

  1. console.log("1") — 동기, 바로 실행
  2. setTimeout — 콜백을 Callback Queue(매크로태스크) 에 등록
  3. Promise.resolve().then(...) — 콜백을 Microtask Queue 에 등록
  4. console.log("4") — 동기, 바로 실행
  5. Call Stack이 비었으니 Microtask Queue 먼저 처리 → "3" 출력
  6. 그 다음 Callback Queue 처리 → "2" 출력

좀 더 복잡한 예제도 볼게요.

JS
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");

출력 순서: startendpromise 1promise 2timeout 1timeout 3timeout 2

promise 1.then 체인에서 등록한 promise 2도 마이크로태스크이므로 매크로태스크보다 먼저 실행돼요. timeout 2promise 1 콜백 안에서 등록됐으므로 가장 나중에 처리됩니다.


타입 변환

== vs ===

JS
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개 예요.

JS
// falsy 값 목록
false
0
-0
0n        // BigInt
""        // 빈 문자열
null
undefined
NaN

이것들을 제외한 나머지는 전부 truthy입니다. 주의할 점 몇 가지:

JS
Boolean([]);           // true  — 빈 배열은 truthy!
Boolean({});           // true  — 빈 객체도 truthy!
Boolean("0");          // true  — 문자열 "0"도 truthy!
Boolean("false");      // true  — 문자열 "false"도 truthy!

빈 배열이 truthy라는 거, 처음 보면 꽤 당황스러워요.

암묵적 변환의 함정

JS
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로 변환

+ 연산자는 피연산자 중 하나가 문자열이면 문자열 연결로 동작하고, -는 항상 숫자 연산을 시도해요. 이 비대칭성이 많은 혼란을 일으킵니다.

[] == falsetrue인 이유는 뭘까? 과정을 따라가 보면:

JS
[] == false
// 1단계: false → 0
[] == 0
// 2단계: [] → "" (ToPrimitive)
"" == 0
// 3단계: "" → 0
0 == 0
// 결과: true

이런 변환 규칙을 전부 외울 필요는 없지만, 왜 ===를 써야 하는지 설명할 수 있으면 충분합니다.


심화 주제

여기서부터는 깊이 있게 파고들 때 나오는 주제들이에요.

가비지 컬렉션 (Garbage Collection)

자바스크립트 엔진은 Mark-and-Sweep 알고리즘으로 메모리를 관리합니다.

  1. **Mark 단계 **: 루트(전역 객체, 현재 실행 중인 함수의 지역 변수 등)에서 출발해서 참조 가능한 모든 객체에 "도달 가능" 표시를 해요
  2. **Sweep 단계 **: 표시되지 않은 객체를 메모리에서 해제합니다

예전에 쓰이던 Reference Counting 방식은 순환 참조 문제가 있었어요.

JS
function circularRef() {
  const a = {};
  const b = {};
  a.ref = b;
  b.ref = a;
  // 함수 종료 후 a, b는 서로만 참조 → Reference Counting에서는 회수 불가
  // Mark-and-Sweep에서는 루트에서 도달 불가하므로 정상적으로 회수
}

WeakRef / WeakMap

JS
// 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()로 아직 살아있는지 확인합니다.

JS
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

JS
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

JS
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도 그 위에서 동작한다는 걸 알 수 있어요.

JS
// 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 요소들이 왜 특정 메서드를 가지고 있는지도 설명할 수 있어요.

PLAINTEXT
EventTarget ← Node ← Element ← HTMLElement ← HTMLDivElement

document.querySelector("div")로 얻은 요소에서 addEventListener를 호출할 수 있는 건, 프로토타입 체인을 따라 EventTarget까지 올라가서 해당 메서드를 찾기 때문입니다.

JS
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가 어떻게 결정되는지 맥락이 잡힙니다. 이벤트 루프는 비동기 코드의 실행 순서를 결정짓는 핵심 메커니즘이에요.

이런 질문에 키워드만 나열하는 것보다, 개념 간의 연결을 보여주는 게 훨씬 중요합니다. "클로저는 렉시컬 스코프의 결과물이고, 렉시컬 스코프는 실행 컨텍스트의 생성 규칙에서 비롯됩니다"처럼요.

댓글 로딩 중...