JavaScript 동작 원리: 실행 컨텍스트, 프로토타입 체인, 클로저, 이벤트 루프

이 글의 핵심

ECMAScript 엔진이 코드를 실행할 때 쌓는 실행 컨텍스트와 스코프 체인, [[Prototype]]으로 이어지는 상속, 렉시컬 환경에 기대는 클로저, 태스크·마이크로태스크가 교대하는 이벤트 루프, 그리고 서비스 코드에서 통하는 실무 패턴을 한 문서로 연결합니다.

문법만 알면 JavaScript의 동작을 추측할 수는 있지만, 엔진이 코드를 어떻게 실행하는지 알아야 예상과 다른 동작을 설명할 수 있습니다. 이 글은 실행 컨텍스트와 스코프 체인, [[Prototype]] 상속, 클로저가 붙잡는 것, 이벤트 루프의 태스크·마이크로태스크 순서를 차례로 다룹니다. 브라우저와 Node.js는 모두 ECMAScript 규격을 따르므로, 엔진별 구현 세부는 달라도 여기서 설명하는 모델은 양쪽에 그대로 적용됩니다.

실행 컨텍스트와 스코프 체인

실행 컨텍스트란

실행 컨텍스트(execution context)는 ECMAScript 코드가 실행되는 동안 유지되는 환경 정보의 단위입니다. 전역 코드, 함수 코드, eval(제한적) 등 실행 단위마다 컨텍스트가 만들어지고 콜 스택에 push/pop 됩니다. 각 실행 컨텍스트는 대략 다음을 포함합니다.

  • 렉시컬 환경(Lexical Environment): 식별자와 값의 바인딩(환경 레코드)과 외부 렉시컬 환경 참조(Outer)로 구성됩니다.
  • this 바인딩: 엄격 모드·호출 방식·클래스 구문 등에 따라 결정됩니다(별도 규칙).
  • (함수의 경우) 지역 바인딩과 클로저로 이어지는 정보.

함수가 호출될 때마다 새 함수 실행 컨텍스트가 생성되고, 그 안의 렉시컬 환경은 함수가 정의된 위치에 따라 외부 환경을 참조합니다. “정의된 위치”가 핵심인 이유는, 동적 스코프가 아니라 렉시컬(정적) 스코프이기 때문입니다.

스코프 체인과 식별자 해석

식별자 x를 읽을 때 엔진은 현재 렉시컬 환경에서 x를 찾으며, 없으면 Outer가 가리키는 바깥 환경으로 이동합니다. 이 연쇄가 스코프 체인으로 불리는 이유입니다. with나 catch 같은 예외적인 구조는 별도의 객체 환경을 끼워 넣어 체인을 바꿀 수 있으나, 현대 코드에서는 모듈 스코프·블록 스코프(let/const)·함수 스코프가 중심입니다.

const top = 1;
function outer() {
  const mid = 2;
  return function inner() {
    console.log(top, mid); // 스코프 체인: inner → outer → 전역
  };
}
const f = outer();
f();

inner는 호출 위치와 무관하게 outer가 활성화됐을 때의 환경을 Outer로 기억합니다. 따라서 outer의 지역 변수 mid에 접근할 수 있습니다. 이것이 클로저로 이어지는 렉시컬 기반입니다(아래 클로저 절에서 구현 관점으로 이어짐).

참고로 이 예제에서 전역에 const top을 선언한 것은 모듈 스코프에서만 안전합니다. 브라우저의 클래식 스크립트 최상위에서 var top = 1로 선언하면, top은 이미 window.top(최상위 창 참조)이라는 읽기 전용 프로퍼티라서 값이 바뀌지 않는 이상한 동작을 보게 됩니다. name, status, length 같은 이름도 전역 객체에 이미 있는 프로퍼티라 같은 함정이 있습니다. let/const는 전역 객체의 프로퍼티가 아니라 별도의 선언적 환경 레코드에 바인딩되므로 name이나 status처럼 설정 가능한(configurable) 프로퍼티와는 충돌하지 않습니다. 하지만 top은 설정 불가능한 프로퍼티라서, 클래식 스크립트 최상위에 const top = 1을 쓰면 SyntaxError: Identifier 'top' has already been declared가 납니다. 모듈(type="module")로 작성하면 최상위 선언이 모듈 스코프에 들어가므로 이런 고민 자체가 사라집니다.

변수 환경과 선언의 “끌어올림”

var는 함수 스코프이며, let/const는 블록 스코프입니다. 엔진은 선언을 실행 시점에 맞춰 환경 레코드에 바인딩을 준비하며, let/const는 일시적 사각지대(temporal dead zone, TDZ) 동안 접근 시 참조 오류가 납니다. “끌어올림(hoisting)”은 직관적 비유이며, 정확히는 컴파일/인스턴스화 단계에서 바인딩이 만들어지는 방식의 차이로 이해하는 편이 안전합니다.

console.log(a); // undefined — var 바인딩은 만들어질 때 undefined로 초기화
var a = 1;

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

let x = 'outer';
function f() {
  console.log(x); // ReferenceError — 바깥 x가 아니라 아래 선언된 지역 x의 TDZ
  let x = 'inner';
}

마지막 예제가 TDZ를 이해하는 핵심입니다. f 안의 let x는 선언 줄에 도달하기 전에도 이미 함수 스코프 전체에 존재하는 바인딩이라, 첫 줄의 x는 바깥의 'outer'로 해석되지 않고 초기화 전 상태의 지역 x를 가리킵니다. let/const도 “끌어올려지긴 하지만 초기화되지 않는다”고 표현하는 이유입니다. 리팩터링하면서 함수 아래쪽에 같은 이름의 지역 변수를 추가했더니 위쪽의 멀쩡하던 코드가 ReferenceError를 내기 시작하는 상황이 바로 이 규칙 때문에 생깁니다. 함수 선언문(function g() {})은 본문까지 통째로 초기화되어 선언보다 위에서 호출할 수 있지만, 함수 표현식을 const g = function () {}로 담으면 TDZ 규칙을 따릅니다.

실무에서의 함정

  • 루프와 비동기: var로 루프 변수를 잡으면 같은 바인딩을 공유해 고전적인 버그가 납니다. let은 반복마다 새 바인딩을 만들어 setTimeout 콜백이 기대대로 동작하기 쉽습니다.
  • 동적 this: 메서드를 콜백으로 넘기면 this가 빠지는 경우가 많습니다. bind, 화살표 함수(렉시컬 this), 또는 명시적 첫 인자 전달로 해결합니다.

this가 빠지는 이유는 this가 스코프 체인과 달리 정의 위치가 아니라 호출 방식으로 정해지기 때문입니다. obj.method()로 부르면 this는 obj지만, const fn = obj.method; fn()처럼 참조만 떼어 내 호출하면 점(.) 앞의 객체가 없으므로 엄격 모드에서는 undefined가 됩니다. 클래스 본문은 자동으로 엄격 모드라서, 클래스 메서드를 addEventListener나 setTimeout에 그대로 넘기면 TypeError: Cannot read properties of undefined (reading 'state') 같은 에러가 납니다. 반대로 화살표 함수는 자기 this가 없고 바깥 렉시컬 환경의 this를 쓰므로, 객체 리터럴의 메서드를 화살표 함수로 정의하면 this가 객체가 아니라 바깥(모듈이라면 undefined)을 가리키는 역방향 실수가 생깁니다.


프로토타입 체인과 상속

[[Prototype]]과 __proto__

ECMAScript 객체는 내부 슬롯 [[Prototype]](구현체에 따라 __proto__ 접근자로 노출)을 통해 다른 객체를 가리킬 수 있습니다. 프로퍼티를 읽을 때 자기 자신에 없으면 [[Prototype]]을 따라가며 찾습니다. 이 연쇄가 프로토타입 체인입니다. 최종적으로 null에 도달하면 “없음”입니다.

const base = { a: 1 };
const derived = Object.create(base);
derived.b = 2;
console.log(derived.a); // 1 — base에서 상속
console.log(Object.prototype.hasOwnProperty.call(derived, 'a')); // false

in 연산자는 체인까지 포함한 존재 여부를 보므로 'a' in derived는 true입니다. 자기 고유 프로퍼티인지만 확인하려면 Object.hasOwn(derived, 'a')(ES2022)나 위 예제처럼 Object.prototype.hasOwnProperty.call을 씁니다. derived.hasOwnProperty('a')처럼 직접 호출하지 않는 이유는, Object.create(null)로 만든 객체에는 hasOwnProperty 메서드 자체가 없고, 외부 입력으로 만든 객체라면 hasOwnProperty라는 키가 덮어쓰여 있을 수도 있기 때문입니다. 새 코드에서는 Object.hasOwn이 가장 간결합니다.

생성자·prototype 객체·new의 연결

함수 F를 new F()로 호출하면, 인스턴스의 [[Prototype]]은 F.prototype 객체를 가리키는 것이 일반적입니다. F.prototype.constructor는 보통 F를 되돌아가리킵니다(재할당 시 깨질 수 있어 주의).

function Person(name) {
  this.name = name;
}
Person.prototype.greet = function () {
  return `Hello, ${this.name}`;
};
const p = new Person('Ada');
console.log(p.greet()); // 프로토타입에서 위임

클래스 구문은 “문법 설탕”이 아니다

class는 프로토타입 기반 모델 위에 명확한 초기화 순서와 상속 체인을 제공합니다. 서브클래스는 super() 호출 규칙으로 부모 생성자와 프로토타입 연결을 보장합니다. 내부적으로는 여전히 프로토타입 체인이며, “클래스가 JavaScript에 클래스 기반 상속을 새로 도입했다”기보다 표현과 안전장치를 표준화했다고 생각하는 편이 정확합니다.

“문법 설탕이 아니다”라고 말할 수 있는 구체적인 차이는 여러 가지입니다. 클래스 생성자는 new 없이 호출하면 TypeError: Class constructor Person cannot be invoked without 'new'로 실패하지만, 함수 생성자는 조용히 전역(또는 undefined)에 프로퍼티를 붙이다 망가집니다. 클래스 본문은 항상 엄격 모드이고, 클래스 선언은 let처럼 TDZ를 가져 선언 전에 쓸 수 없으며, 메서드는 열거 불가능(non-enumerable)이라 for...in에 나타나지 않습니다. #private 필드는 프로토타입 체인이나 프록시로 우회할 수 없는 진짜 비공개 슬롯이고, 서브클래스 생성자에서 super()를 호출하기 전에 this를 쓰면 ReferenceError가 납니다. Array나 Error를 상속할 때도 class extends를 써야 내장 객체의 특수한 동작(배열의 length 자동 갱신 등)이 제대로 이어집니다.

실무에서 주의할 점

  • 프로토타입 오염: Object/Array 등의 프로토타입을 라이브러리가 수정하면 전역에 영향을 줍니다. 불변성·키 검증·구조화된 직렬화가 필요합니다. 보안 쪽에서 더 흔한 형태는 외부 JSON을 재귀적으로 병합하는 코드입니다. JSON.parse('{"__proto__": {"isAdmin": true}}')로 만든 객체를 순진한 deepMerge로 기존 객체에 합치면, target["__proto__"]가 Object.prototype을 가리키므로 결국 모든 객체에 isAdmin이 생깁니다. 병합 시 __proto__·constructor·prototype 키를 거부하고, 키-값 저장용으로는 Map이나 Object.create(null)을 쓰는 것이 기본 방어책입니다.
  • extends와 Object.setPrototypeOf: 런타임에 체인을 자주 바꾸면 엔진 최적화를 방해할 수 있어, 초기 설계에서 확정하는 편이 낫습니다.
  • 프레임워크와 호환: React 등은 props를 얕게 비교합니다. 프로토타입에만 있는 필드를 믿지 말고 명시적 데이터를 넘깁니다.

클로저 구현: 렉시컬 환경의 수명

클로저의 정의(실무적)

클로저는 외부 함수가 반환한 내부 함수가, 이미 종료된 외부 함수의 렉시컬 환경에 있는 바인딩을 참조하는 관계입니다. 내부 함수가 살아 있는 한, 엔진은 그 환경 레코드가 가비지 컬렉션 대상이 되지 않도록 유지할 수 있습니다.

최소 예제와 “무엇이 붙잡히는가”

function makeCounter() {
  let n = 0;
  return {
    inc() {
      n += 1;
      return n;
    },
    read() {
      return n;
    },
  };
}
const c = makeCounter();
console.log(c.inc(), c.read()); // 1, 1

makeCounter의 활성화 레코드에 있던 n은 inc/read가 같은 외부 환경을 공유합니다. 엔진은 내부 함수가 참조하는 환경 셀을 유지합니다. 중요한 점은 “지역 변수 하나”만 붙잡는 것이 아니라, 그 환경에 속한 바인딩 전체가 수명이 연장될 수 있다는 것입니다.

V8 같은 엔진은 실제로 모든 지역 변수를 붙잡지는 않고, 내부 함수들이 참조하는 변수만 힙의 컨텍스트 객체로 옮겨 둡니다. 그런데 이 컨텍스트는 같은 스코프에서 만들어진 모든 클로저가 공유합니다. 그래서 다음과 같은 누수가 생깁니다. 한 함수 안에서 큰 배열 bigData를 참조하는 콜백 A와, bigData와 무관한 작은 콜백 B를 만들었다고 해 봅시다. A는 금방 버려지고 B만 오래 살아남아도, A가 bigData를 참조했다는 이유로 bigData는 공유 컨텍스트에 들어가 있고 B가 그 컨텍스트를 붙잡고 있으므로 수거되지 않습니다. 코드만 보면 B는 bigData와 관계가 없어 보이기 때문에 찾기 어려운 누수입니다. Chrome DevTools의 힙 스냅샷에서 retainer 경로에 (context)나 system / Context가 보이면 이런 경우를 의심해 볼 수 있고, 큰 데이터를 쓰는 코드를 별도 함수로 분리해 스코프를 나누면 해결됩니다.

루프·비동기에서의 클로저

for (let i = 0; i < 3; i++) {
  queueMicrotask(() => console.log(i));
}

let은 반복마다 새 렉시컬 환경을 만들므로, 각 마이크로태스크는 자기 i 셀을 캡처합니다. var였다면 하나의 i를 공유해 결과가 달라집니다.

프로덕션 관점의 트레이드오프

  • 은닉(encapsulation): 모듈 패턴·팩토리로 공개 API만 노출하고 상태를 숨깁니다.
  • 메모리: 큰 객체를 클로저가 들고 있으면 의도치 않게 오래 살 수 있습니다. 필요 시 null 할당, WeakMap·WeakRef처럼 약한 참조를 쓰는 구조, 또는 상태를 밖으로 빼는 설계를 검토합니다. 가장 흔한 실제 원인은 클로저 자체보다 해제하지 않은 이벤트 리스너와 타이머입니다. setInterval 콜백이나 window에 등록한 리스너는 명시적으로 해제하기 전까지 클로저와 그 안의 컴포넌트 상태를 계속 붙잡습니다. SPA에서 화면을 오갈 때마다 메모리가 계단식으로 늘어난다면 removeEventListener나 AbortController의 signal 옵션으로 리스너를 정리하는지부터 확인합니다.
  • 디버깅: 스택 트레이스만으로는 렉시컬 환경이 보이지 않아, 이름 있는 함수 표현식과 작은 단위 테스트가 도움이 됩니다.

이벤트 루프와 마이크로태스크 큐

한 턴의 큰 그림

이벤트 루프는 “작업을 끝낸 뒤에도 계속 들어오는 콜백을 순서 있게 실행하는” 스케줄러입니다. 브라우저와 Node.js는 세부 큐 이름과 단계가 다르지만, ECMAScript 관점의 핵심은 다음과 같습니다.

  1. 호출 스택이 비면, 이벤트 루프는 매크로태스크(브라우저의 태스크 큐에 해당하는 개념)에서 콜백 하나를 꺼내 실행합니다.
  2. 그 콜백이 새로운 마이크로태스크(예: Promise.then)를 예약하면, 현재 매크로태스크 직후에 마이크로태스크 큐를 빌 때까지 처리합니다.
  3. 마이크로태스크 처리 중에 또 마이크로태스크가 들어오면 같은 단계에서 계속 이어집니다(기아(starvation) 위험이 이론상 존재하므로, 마이크로태스크에서 무한 재스케줄은 피합니다).

Promise, queueMicrotask, setTimeout의 순서

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
queueMicrotask(() => console.log('D'));
console.log('E');
// 출력: A, E, C, D, B

이 순서는 우연이 아니라 규칙으로 정해집니다. 스크립트 전체가 하나의 매크로태스크로 실행되므로 동기 코드 A, E가 먼저 찍히고, 스크립트 태스크가 끝나는 즉시 마이크로태스크 큐가 비워집니다. C와 D는 둘 다 마이크로태스크이고 등록된 순서대로 실행됩니다. setTimeout(..., 0)의 B는 다음 매크로태스크이므로 항상 마지막입니다. 지연 0을 줘도 “즉시”가 아니라 “빨라도 다음 턴”이며, 브라우저는 타이머가 5단계 넘게 중첩되면 최소 지연을 약 4ms로 올립니다.

async 함수는 await 지점에서 마이크로태스크로 재개되므로 Promise와 같은 규칙을 따릅니다. 다만 await할 값이 네이티브 프로미스가 아니라 then 메서드를 가진 임의 객체(thenable)라면 재개까지 마이크로태스크 단계가 더 걸립니다. 서로 다른 라이브러리의 프로미스를 섞어 쓰는 코드에서 “마이크로태스크 몇 개 뒤에 실행되는가”에 의존하면 엔진 버전에 따라 순서가 바뀔 수 있으니, 순서가 중요한 로직은 await로 명시적으로 이어 붙이는 것이 안전합니다.

async/await와 마이크로태스크

async 함수는 Promise를 반환합니다. await는 값을 unwrap하며, 이미 이행된 프로미스를 기다릴 때도 재개는 항상 마이크로태스크로 미뤄집니다. 따라서 “동기 코드 → await 이후”의 순서는 직관과 어긋날 수 있어, 상태 머신처럼 단계를 나누어 추적하는 습관이 유효합니다.

브라우저 렌더링과의 관계(개략)

브라우저는 렌더링 업데이트를 이벤트 루프 단계와 맞춥니다. 긴 동기 작업은 메인 스레드를 점유해 입력 지연·프레임 드랍을 유발합니다. 무거운 연산은 Web Worker로 옮기거나, 작업을 잘게 나눠 setTimeout 등으로 중간중간 메인 스레드를 양보하는 방식을 검토합니다. requestIdleCallback은 유휴 시간에 하는 낮은 우선순위 작업용이라 무거운 연산을 맡기기에는 맞지 않습니다. Node.js에서는 I/O·타이머·nextTick(별도 우선순위)가 섞이므로, 서버 코드는 버전 문서로 큐 의미를 확인합니다.


프로덕션 JavaScript 패턴

앞에서 본 엔진 모델은 서비스 코드의 몇 가지 습관으로 이어집니다.

모듈은 공개할 API만 export하고 나머지는 파일 스코프에 둡니다. 모듈 사이에 순환 의존이 생기면 한쪽 모듈이 아직 평가되지 않은 상태에서 다른 쪽이 그 바인딩을 읽게 되어, 앞에서 본 TDZ ReferenceError가 초기화 순서에 따라 나타났다 사라졌다 합니다. JSON 응답, 쿼리스트링, 환경 변수 같은 외부 입력은 Zod 같은 스키마 검증으로 경계에서 확인합니다. TypeScript 타입은 컴파일 후 사라지므로 런타임 값을 보장하지 못하기 때문입니다.

비동기 코드에서는 AbortController의 signal을 fetch와 상위 흐름에 함께 넘겨 화면 전환이나 타임아웃 때 요청을 취소할 수 있게 하고, 대량 요청은 동시 실행 개수를 제한합니다. 재시도는 지수 백오프와 최대 횟수, 그리고 요청이 중복 실행돼도 안전하도록 멱등 키를 함께 설계해야 합니다.

부수 효과는 한곳에 모으고 나머지 로직은 입력에서 출력을 계산하는 함수로 두면 테스트와 추론이 쉬워집니다. 중첩 객체를 불변으로 갱신할 때는 immer 같은 라이브러리나 명시적 복사를 쓰고, window/globalThis에 상태를 붙이는 것은 테스트 격리를 깨므로 피합니다. 오류를 다시 던질 때는 new Error('...', { cause: err })로 원인을 보존하면(ES2022) 로그에서 원래 스택까지 추적할 수 있습니다.

작은 실전 스니펫: 방어적 파서

function toPositiveInt(value, fallback) {
  const n = Number.parseInt(String(value), 10);
  return Number.isFinite(n) && n > 0 ? n : fallback;
}

입력을 문자열→정수로 좁히고, 실패 시 명시적 기본값으로 돌아갑니다. 이런 함수는 경계마다 복사하기보다 공유 유틸로 두는 편이 낫습니다.

이 함수에도 알고 써야 할 한계가 있습니다. parseInt는 앞에서부터 숫자로 읽을 수 있는 만큼만 읽고 멈추므로 '12abc'는 12, '1e3'은 1, '0x1A'를 기수 10으로 읽으면 0이 됩니다. 쿼리스트링의 ?page=2abc를 오류 대신 2로 받아들여도 괜찮다면 문제없지만, 엄격하게 거부해야 한다면 /^\d+$/.test(value)로 형식을 먼저 확인하거나 Number(value)와 Number.isInteger를 조합하는 편이 맞습니다. 또 아주 큰 값은 Number.MAX_SAFE_INTEGER(2⁵³−1)를 넘는 순간 정밀도를 잃으므로, 64비트 ID처럼 큰 정수는 문자열이나 BigInt로 다뤄야 합니다. 처음 이런 유틸을 만들 때 흔히 겪는 문제가 바로 DB의 BIGINT ID를 JSON으로 받아 숫자로 파싱했다가 끝자리가 바뀌는 것입니다.


자주 묻는 질문 (FAQ)

Q. TypeScript를 쓰는데도 런타임 검증이 필요한 이유는 무엇인가요?

A. TypeScript의 타입은 빌드할 때만 검사되고 컴파일 결과에서는 사라지므로, API 응답이나 쿼리스트링, 환경 변수처럼 외부에서 들어오는 값이 실제로 그 타입인지는 보장하지 못합니다. 그래서 외부 입력은 Zod 같은 스키마 검증으로 경계에서 한 번 확인하고, 그 뒤의 내부 코드는 타입을 믿고 작성합니다. 검증 없이 as로 단언하면 타입은 맞아 보여도 엉뚱한 곳에서 undefined 오류가 터집니다.


같이 보면 좋은 글