Navigation

JavaScript Execution Model

React 코드에서 겪는 이상한 동작은 라이브러리보다 언어 쪽에 원인이 있을 때가 많습니다. 첫 렌더링의 값만 보는 effect, 콜백으로 넘기자 사라진 this, setTimeout(fn, 0)보다 먼저 찍히는 Promise 로그, 지워도 줄지 않는 메모리는 각각 실행 컨텍스트와 환경 레코드, this 결정 규칙, 프로토타입 체인, 이벤트 루프로 설명됩니다. 이 네 가지를 ECMAScript 명세와 HTML 표준의 용어로 정리해 두면 증상을 만났을 때 어느 규칙을 의심할지가 먼저 정해집니다.

실행 컨텍스트는 환경 레코드를 가리킵니다

함수가 호출되면 실행 컨텍스트가 만들어져 실행 컨텍스트 스택에 쌓이고 맨 위가 실행 중인 컨텍스트가 됩니다. 컨텍스트에는 식별자를 찾는 데 쓰는 LexicalEnvironment와 var 선언을 담는 VariableEnvironment가 있고 명세는 둘 다 항상 환경 레코드(Environment Record)라고 정합니다. 환경 레코드는 식별자와 변수, 함수의 연결을 코드의 중첩 구조에 따라 담는 명세상의 자료형이며 모든 레코드는 바깥 레코드를 가리키는 [[OuterEnv]]를 가집니다. 스코프 체인이라고 부르던 것은 이 [[OuterEnv]]를 따라가는 탐색입니다. ECMAScript Environment Records

예전 설명에 나오던 렉시컬 환경 객체와 환경 레코드의 두 층 구조는 현재 명세에 없습니다. 환경 레코드 하나가 바인딩과 바깥 참조를 함께 가집니다. 종류는 함수와 블록의 선언형, with와 전역 객체 부분의 객체형, 전역, 함수, 모듈 레코드로 나뉘고 함수와 모듈 레코드는 선언형의 하위 종류입니다.

호이스팅은 이 레코드를 만드는 시점의 규칙입니다. 코드를 평가하기 전에 레코드를 인스턴스화하면서 선언 종류마다 다른 상태의 바인딩을 만듭니다.

선언바인딩이 생기는 시점선언 전 접근근거
function 선언문레코드 인스턴스화. 함수 객체까지 만들어 둡니다호출 가능MDN Hoisting의 value hoisting
var레코드 인스턴스화. 값은 undefinedundefined명세 14.3.2
let, const, class레코드 인스턴스화. 초기화되지 않은 상태ReferenceError. typeof도 던집니다명세 14.3.1, MDN의 TDZ
함수 표현식, 화살표 함수실행 흐름이 그 식에 도달했을 때담은 변수의 선언 규칙을 따릅니다
같은 블록 안에서 선언 종류마다 다르게 보입니다ts
{
  console.log(typeof hoisted) // "function". 함수 객체가 이미 있습니다
  console.log(early) // undefined. 바인딩은 있고 값만 없습니다
  console.log(typeof later) // ReferenceError. 바인딩은 있지만 초기화 전입니다

  function hoisted() {}
  var early = 1
  const later = 2
}

let과 const가 끌어올려지지 않는다고 말하기보다 바인딩은 블록 시작에 만들어지고 선언문을 지나기 전까지 접근이 막힌다고 보는 편이 정확합니다. 명세는 블록을 평가할 때 블록 안의 변수, 상수, 함수, 클래스 선언의 바인딩을 먼저 만든다고 적고 let과 const는 LexicalBinding이 평가되기 전까지 어떤 방식으로도 접근할 수 없다고 못 박습니다. 접근 시점이 선언보다 앞서는 이 구간이 일시적 사각지대(TDZ)입니다. Let and Const Declarations, MDN let

반복문의 콜백이 모두 마지막 값만 보던 고전적인 문제도 같은 규칙입니다. var는 함수 레코드 하나에 바인딩 하나를 만들어 모든 콜백이 그 하나를 공유하고 let은 반복마다 새 바인딩을 만듭니다. 블록 안의 함수 선언문은 strict 모드 기준이며 비strict 코드에서는 부록 B의 호환 규칙이 더해집니다.

클로저는 변수가 아니라 환경을 붙잡습니다

클로저는 함수와 그 함수가 만들어질 때의 렉시컬 환경을 가리키는 참조를 묶은 것입니다. 함수는 만들어질 때마다 클로저가 되므로 클로저를 쓰는지보다 어느 환경을 붙잡았는지를 묻는 편이 맞습니다. MDN Closures

React 함수 컴포넌트는 렌더링마다 함수를 다시 호출합니다. 호출마다 새 실행 컨텍스트와 새 환경 레코드가 생기고 그 렌더링의 useState 값은 그 레코드의 바인딩입니다. effect 콜백은 자기가 만들어진 렌더링의 레코드를 붙잡으므로 의존성 배열을 비워 두면 첫 렌더링의 값을 계속 봅니다.

첫 렌더링의 count만 보는 intervalts
function Timer() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    // 이 콜백이 붙잡은 count는 첫 렌더링의 바인딩이라 항상 0입니다
    const id = setInterval(() => setCount(count + 1), 1000)
    return () => clearInterval(id)
  }, [])

  return <span>{count}</span>
}

고치는 방법은 붙잡을 값을 없애거나 최신 값을 읽는 통로를 따로 두는 것입니다. 갱신 함수 setCount((c) => c + 1)은 React가 대기 중인 상태를 인자로 넘겨주므로 콜백이 count를 붙잡을 필요가 없습니다. React는 갱신 함수를 큐에 넣고 다음 렌더링에서 순서대로 적용합니다. useState 최신 props와 state를 읽어야 하는 콜백이라면 React 19.2에서 정식으로 들어온 useEffectEvent로 분리합니다. Effect Event는 항상 최신 값을 보고 의존성 배열에 넣지 않으며 lint 플러그인을 최신으로 올려야 합니다. React 19.2

붙잡는 값을 없앤 갱신 함수ts
useEffect(() => {
  const id = setInterval(() => setCount((c) => c + 1), 1000)
  return () => clearInterval(id)
}, [])

클로저가 환경을 붙잡는다는 사실은 메모리에도 그대로 적용됩니다. 가비지 컬렉터는 전역 객체 같은 루트에서 닿을 수 있는 객체를 살아 있는 것으로 보고 닿지 않는 객체를 회수합니다. 살아 있는 함수가 붙잡은 환경은 루트에서 닿으므로 그 환경의 바인딩도 회수되지 않습니다. MDN Memory management

V8은 스코프에 들어갈 때 Context를 하나 만들고 그 스코프에서 만들어진 모든 클로저가 이를 공유하며 중첩된 Context는 부모를 가리킵니다. 이 구조에서는 오래 사는 콜백 하나가 자기는 쓰지 않는 큰 값을 같은 스코프의 다른 클로저가 참조한다는 이유로 붙잡아 둘 수 있습니다. 큰 값을 만드는 코드와 오래 사는 콜백을 한 스코프에 두지 않고 다 쓴 참조는 null로 끊으며 키가 사라지면 값도 회수되어야 하는 캐시는 WeakMap에 둡니다. Grokking V8 closures

this는 선언이 아니라 호출이 정합니다

this는 함수가 어디에 적혀 있는지가 아니라 어떻게 호출됐는지로 정해집니다. 명세의 OrdinaryCallBindThis는 함수의 [[ThisMode]]를 보고 셋 중 하나를 고릅니다. lexical이면 아무것도 바인딩하지 않아 바깥 함수의 this가 그대로 보이고 strict면 넘어온 값을 그대로 쓰며 그 밖에는 undefined와 null을 전역 객체의 this 값으로 바꾸고 원시 값은 객체로 감쌉니다. OrdinaryCallBindThis

호출 형태this비고
f()strict undefined, 비strict globalThis클래스 본문과 모듈은 항상 strict
obj.f()obj함수를 소유한 객체가 아니라 호출에 쓴 객체
new F()새로 만든 인스턴스파생 클래스는 super() 전에 this를 읽을 수 없습니다
f.call(o), f.apply(o), f.bind(o)()o화살표 함수에는 인자가 무시됩니다
화살표 함수바깥 함수의 this객체 리터럴의 메서드로 쓰면 그 객체를 가리키지 않습니다
콜백API가 정합니다. 대개 undefinedforEach의 두 번째 인자, DOM 이벤트 핸들러의 요소

MDN this

React에서 이 규칙이 문제로 보이는 곳은 클래스 컴포넌트의 핸들러입니다. onClick={this.increment}는 메서드를 떼어내 넘기는 것이라 호출 시점에 객체가 없고 클래스 본문은 strict라 전역 객체로 치환되지도 않아 this는 undefined입니다. 생성자에서 bind하거나 클래스 필드에 화살표 함수를 두면 인스턴스가 만들어질 때 this가 고정됩니다. 다만 클래스 필드 화살표 함수는 인스턴스마다 함수 객체를 하나씩 만들고 프로토타입에는 남지 않습니다.

프로토타입 메서드와 클래스 필드의 차이ts
class Counter extends React.Component<unknown, { n: number }> {
  state = { n: 0 }

  // 프로토타입 메서드. 떼어내 넘기면 this가 undefined입니다
  increment() {
    this.setState((s) => ({ n: s.n + 1 }))
  }

  // 클래스 필드. 인스턴스가 만들어질 때 this가 인스턴스로 고정됩니다
  decrement = () => {
    this.setState((s) => ({ n: s.n - 1 }))
  }

  render() {
    return (
      <>
        <button onClick={this.increment}>+</button> {/* 클릭하면 TypeError */}
        <button onClick={this.decrement}>-</button> {/* 동작합니다 */}
      </>
    )
  }
}

함수 컴포넌트에는 this가 없습니다. 그 자리를 앞 절의 클로저 규칙이 차지합니다.

프로토타입 체인은 읽기와 쓰기가 비대칭입니다

프로퍼티를 읽으면 객체 자신, 그 프로토타입, 그 프로토타입의 프로토타입 순으로 찾고 null에서 멈춥니다. 쓰기는 다릅니다. 체인 어딘가에 같은 이름이 있어도 대입은 객체 자신에 자체 프로퍼티(own property)를 만듭니다. 예외는 체인의 setter가 가로채거나 쓰기 불가 프로퍼티가 막는 경우입니다. MDN Inheritance and the prototype chain

읽기는 체인을 타고 쓰기는 자기에게 만듭니다ts
const parent = { value: 2, list: [] as number[] }
const child = Object.create(parent) as typeof parent

child.value = 4 // child에 own 프로퍼티가 생깁니다
console.log(parent.value) // 2

child.list.push(1) // 읽기라서 parent.list를 찾아 그 배열을 바꿉니다
console.log(parent.list) // [1]

class는 생성자 함수와 프로토타입 위의 문법입니다. 메서드는 Box.prototype에 한 번 만들어져 인스턴스가 공유하고 extends는 프로토타입 연결을 바꾸는 것뿐이라 새 상속 모델이 아닙니다. 생성자 안에서 this.getName = function () {}처럼 메서드를 만들면 인스턴스마다 함수 객체가 생기므로 공유할 동작은 프로토타입에 둡니다. 없는 프로퍼티를 읽으면 체인 끝까지 모두 탐색하고 자체 프로퍼티 여부는 Object.hasOwn으로 확인합니다.

배열도 이 규칙 위의 객체입니다. 인덱스는 숫자처럼 보이지만 문자열 키이고 '2'는 인덱스지만 '02'는 일반 프로퍼티라 배열 반복에서 빠집니다. 명세는 모든 배열이 설정 불가능한 length를 가지며 그 값이 2^32보다 작은 음이 아닌 정수이고 언제나 모든 인덱스보다 크다는 불변식을 유지한다고 정합니다. arr[5] = x가 length를 6으로 늘리고 arr.length = 2가 뒤의 요소를 지우는 동작은 이 불변식에서 나옵니다. Array Exotic Objects, MDN Array

키가 문자열과 심벌로 제한되고 프로토타입의 기본 키와 충돌할 수 있는 객체 대신 Map을 쓰는 기준도 여기서 나옵니다. Map은 어떤 값이든 키로 받고 삽입 순서로 순회하며 size를 바로 주고 잦은 추가와 삭제에서 더 나은 성능을 보입니다. 명세는 Map의 접근 시간이 평균적으로 요소 수보다 느리게 늘어야 한다(sublinear)고만 요구하므로 해시 테이블일 수도 탐색 트리일 수도 있습니다. MDN Map

이벤트 루프는 태스크 하나마다 마이크로태스크를 비웁니다

HTML 표준의 처리 모델은 한 번의 반복에서 태스크 큐 하나를 고르고 그 큐에서 가장 오래된 실행 가능한 태스크를 꺼내 실행한 뒤 마이크로태스크 체크포인트를 수행합니다. 마이크로태스크 큐는 태스크 큐가 아니며 체크포인트는 큐가 빌 때까지 반복합니다. 체크포인트 안에서 새로 넣은 마이크로태스크도 같은 체크포인트에서 실행됩니다. 렌더링 갱신은 렌더링 기회가 있을 때 렌더링 태스크 소스의 태스크로 따로 큐에 들어갑니다. HTML event loop processing model, MDN Microtask guide

flowchart LR
    Pick["태스크 큐 하나에서<br/>가장 오래된 태스크"] --> Run["실행"]
    Run --> Check["마이크로태스크 체크포인트<br/>큐가 빌 때까지"]
    Check --> Render{"렌더링 기회?"}
    Render -->|예| Paint["렌더링 갱신 태스크"]
    Render -->|아니오| Pick
    Paint --> Pick
큐들어가는 것
태스크setTimeout과 setInterval 콜백, DOM 이벤트, MessageChannel 메시지, 스크립트 실행
마이크로태스크Promise 반응(then, catch, finally), await 뒤의 재개, queueMicrotask, MutationObserver 콜백
로그 순서는 큐의 종류와 넣은 순서로 정해집니다ts
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
queueMicrotask(() => console.log('D'))
;(async () => {
  console.log('E')
  await null
  console.log('F')
})()
console.log('G')
// A E G C D F B

동기 코드인 A, E, G가 먼저 끝납니다. await null은 이미 이행된 Promise를 기다리는 것이라도 함수 실행을 다음 틱까지 멈추고 재개를 마이크로태스크로 넣으므로 F는 G 뒤에 옵니다. 마이크로태스크는 넣은 순서대로 C, D, F이고 B는 다음 태스크입니다. 네이티브 Promise를 await할 때 틱 하나만 쓰는 동작은 명세 변경(tc39/ecma262 PR 1250)과 V8 7.2에서 정해졌고 그전에는 최소 세 틱이었습니다. MDN await, Faster async functions and promises

잡(job)은 스택이 빌 때 끝나고 그때 다음 잡을 꺼냅니다. 잡 하나는 끝까지 실행되며 중간에 선점되지 않습니다. 그래서 마이크로태스크를 재귀로 계속 넣으면 체크포인트가 끝나지 않아 다음 태스크도 렌더링도 돌아오지 않습니다. 동기 반복문이 메인 스레드를 잡고 있는 것과 같은 증상입니다. MDN Execution model

setTimeout(fn, 0)은 즉시가 아니라 다음 기회입니다. 현재 태스크와 마이크로태스크가 끝나야 하고 중첩 깊이가 5를 넘으면 표준이 최소 4ms를 강제하며 비활성 탭에서는 브라우저가 더 긴 최소 지연을 둡니다. 긴 작업을 나눌 때 양보 지점을 어디에 두는지는 Chromium Rendering Pipeline의 긴 작업 절에 있습니다. HTML timers, MDN setTimeout

async 함수의 오류 처리도 같은 모델입니다. 거부된 Promise를 await하면 그 자리에서 예외가 던져져 try와 catch가 잡습니다. return await promise는 네이티브 Promise 최적화 덕분에 return promise보다 느리지 않고 호출자가 스택 추적에 남으므로 이쪽을 씁니다. 처리되지 않은 거부는 마이크로태스크 체크포인트의 뒤쪽 단계에서 보고됩니다.

디버거에서 이 모델은 호출 스택이 끊기는 모습으로 나타납니다. 콜백은 등록한 함수의 스택 위가 아니라 나중 태스크나 마이크로태스크에서 빈 스택 위에 실행되므로 setTimeout 줄을 한 줄씩 넘겨도 콜백 안으로 들어가지 않습니다. Chrome DevTools는 비동기 작업의 두 부분을 연결한 async 프레임을 호출 스택에 보여 주므로 콜백 안에 중단점을 두고 거기서 등록 지점을 거슬러 봅니다. Chrome DevTools JavaScript debugging reference

확인 방법과 한계

명세 인용은 2026-10-26에 읽은 ECMAScript 명세 초안(사이트 제목 ECMAScript 2027)과 HTML Living Standard 기준이고 절 번호는 판마다 바뀔 수 있습니다. await의 틱 수는 V8 블로그(2018-11)가 설명한 V8 7.2 동작이며 다른 엔진은 각자 확인해야 합니다. V8이 스코프마다 Context를 만들어 공유한다는 설명은 2012년 글이라 세부 구현은 바뀌었을 수 있고 특정 값이 회수되는지는 힙 스냅샷으로 확인하는 편이 정확합니다. useEffectEvent는 2025-10-01에 나온 React 19.2 기준입니다. 형 변환 규칙과 배열 메서드 목록은 이 글의 논지 밖이라 MDN 참조로 대신했습니다.

참고 자료