대부분의 사람들은 무언가를 직접 만들면서 JavaScript를 시작합니다. 버튼에 이벤트를 연결하고, 데이터를 가져오고, DOM이 변하는 것을 지켜봅니다. 그러다 어느 순간 추상화의 한계가 드러나기 시작합니다. 변수가 선언되기도 전에 존재하거나, 함수가 접근해서는 안 될 변수를 기억하고, this 키워드가 window를 가리키다가도 버튼을 가리키거나 혹은 아무것도 가리키지 않는 등 이해할 수 없는 버그들이 나타납니다. 바로 그 순간이 엔진이 실제로 어떻게 동작하는지 내부를 들여다보고 이해해야 할 필요성을 느끼는 시점입니다.

실행 컨텍스트: 2단계 설정 과정

브라우저나 Node.js의 JavaScript 엔진이 스크립트를 만나면, 단순히 사람이 페이지를 훑어보듯 파일을 위에서 아래로 읽기만 하는 것이 아닙니다. 대신, 특정 코드 뭉치를 실행하는 데 필요한 모든 것을 담고 있는 컨테이너인 실행 컨텍스트(execution context)를 구축합니다. 모든 실행 컨텍스트는 두 가지 뚜렷한 단계를 거칩니다.

메모리 생성 단계(Memory Creation Phase). 이 초기 단계에서 엔진은 전체 스코프를 스캔하고 발견된 모든 변수와 함수 선언을 위한 메모리를 할당합니다. var를 발견하면 공간을 예약하고 undefined를 자리 표시자(placeholder)로 저장합니다. 함수 선언을 발견하면 함수 본문 전체를 저장합니다. 이것이 바로 전통적인 function 키워드로 선언된 함수를 동일한 스코프 내의 더 앞선 줄에서 호출할 수 있는 이유입니다. 엔진은 실행을 시작하기 전에 이미 해당 함수에 대해 알고 있기 때문입니다.

코드 실행 단계(Code Execution Phase). 이제 엔진은 코드를 한 줄씩 실행합니다. 할당이 이루어지고, 표현식이 평가되며, 함수가 호출됩니다. 만약 var name = "Alice";라고 작성했다면, 첫 번째 단계에서 만든 자리 표시자가 마침내 문자열로 교체됩니다. 이러한 2단계 동작 방식을 이해하면 많은 혼란이 해소됩니다. 엔진이 엉성하게 동작하는 것이 아니라, 엄격한 설정 루틴을 따르고 있는 것입니다.

변수와 Temporal Dead Zone

var, let, const 중 무엇을 선택할지는 단순히 스타일의 문제가 아닙니다. var는 함수 스코프(function-scoped)를 가지며, 이는 중괄호({})를 완전히 무시한다는 의미입니다. if 블록 내부에서 선언하더라도 블록 밖으로 새어 나갑니다. 초기 JavaScript에서는 이러한 동작이 타당했을지 모르지만, 현대적인 애플리케이션에서는 유지보수에 큰 어려움을 초래합니다. 반면 letconst는 블록 스코프(block-scoped)를 가집니다. 이들은 중괄호를 존중하며 블록이 끝나면 사라집니다.

호이스팅(hoisting)이 이들을 다루는 방식에도 미묘하지만 결정적인 차이가 있습니다. var 선언은 호이스팅되어 즉시 undefined로 초기화됩니다. letconst 역시 기술적으로는 호이스팅됩니다. 엔진은 선언문에 도달하기 전에 이들이 존재한다는 사실을 알고 있습니다. 하지만 초기화되지는 않습니다. 이들은 Temporal Dead Zone(TDZ)이라고 불리는 불확실한 상태에 머물게 됩니다. 선언문이 실행되기 전에 이 변수들을 읽으려고 하면, 교묘하게 undefined를 반환하는 대신 명확한 ReferenceError가 발생합니다. 이러한 오류 발생은 사실 도움이 됩니다. 초기화되지 않은 데이터를 바탕으로 로직이 진행되는 것을 방지해주기 때문입니다.

렉시컬 스코프와 클로저

스코프는 "이 변수에 어디까지 접근할 수 있는가?"라는 간단한 질문에 답합니다. JavaScript는 렉시컬 스코프(lexical scope)를 사용하는데, 이는 함수의 접근 권한이 함수가 호출되는 위치가 아니라 소스 코드상에서 물리적으로 작성된 위치에 의해 결정됨을 의미합니다. 함수 내부에서 다른 함수를 정의하면, 내부 함수는 외부로 손을 뻗어 부모 함수의 변수를 읽을 수 있습니다. 하지만 외부 함수는 내부로 들어와 읽을 수 없습니다. 이 관계는 정적입니다. 내부 함수를 다른 모듈로 전달하거나 전역 변수에 저장하여 완전히 다른 파일에서 호출하더라도, 그 함수는 자신이 태어난 스코프의 변수들을 여전히 기억합니다.

이러한 동작은 자연스럽게 클로저(closure)를 만들어냅니다. 클로저는 내부 함수가 외부 스코프의 변수에 대한 참조를 유지할 때 형성됩니다. 외부 함수의 실행이 끝나고 지역 변수들이 가비지 컬렉션(garbage collected)되어야 할 시점에도, 내부 함수가 여전히 그 변수들을 필요로 하기 때문에 JavaScript는 이를 메모리에 보존합니다. 내부 함수는 자신이 생성된 주변 환경을 그대로 품고 다니는 셈입니다.

이는 단순히 학술적인 디테일이 아닙니다. 클로저는 명시적인 접근 제어자(access modifiers)가 없는 언어에서 프라이빗 상태(private state)를 생성할 수 있는 실질적인 방법을 제공합니다.

function makeCounter() {
  let count = 0;
  return function() {
    count = count + 1;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2

여기서 count는 숨겨져 있습니다. 반환된 함수 외부에서는 이를 초기화하거나 직접 읽을 수 없습니다. 이것이 바로 스코프 메커니즘만으로 구축된 프라이빗 변수입니다.

실전에서의 호이스팅

JavaScript가 "선언을 위로 끌어올린다"는 말을 흔히 듣곤 합니다. 이는 유용한 사고 모델(mental model)이지만, 실제로 코드가 다시 작성되는 것은 아닙니다. 메모리 생성 단계(memory creation phase) 동안 엔진은 실행이 시작되기 전에 단순히 선언을 등록할 뿐입니다. var x = 5;와 같은 문장은 선언과 초기화가 분리된 것처럼 동작합니다. var x;라는 선언은 초기에 처리되어 undefined로 초기화됩니다. 그리고 x = 5;라는 할당문은 작성한 위치 그대로 남아 실행 단계(execution phase)에서 실행됩니다.

이 때문에 var는 의외의 결과를 초래할 수 있습니다. 함수 하단에 거대한 할당문이 있더라도, 함수 상단 근처에서 사용된 변수는 undefined를 가질 수 있습니다. letconst를 사용하면 이러한 실수를 방지할 수 있는데, 이는 일시적 사각지대(temporal dead zone)가 선언을 사용보다 앞선 위치에 두도록 강제하기 때문입니다.

this의 값이 결정되는 방식

실행 컨텍스트(execution context)와 스코프(scope)가 변수가 어디에 존재하는지를 결정한다면, this는 현재 어떤 객체가 주도권을 쥐고 있는지를 결정합니다. 렉시컬 변수(lexical variables)와 달리, this는 함수가 어디에 작성되었는지에 따라 결정되지 않습니다. 오직 함수가 어떻게 호출되었는지에 따라 결정됩니다.

**기본 바인딩(Default binding)**은 일반적인 독립 함수를 호출할 때 발생합니다. 비엄격 모드(non-strict mode)에서 this는 전역 객체(global object)를 가리킵니다. 브라우저에서는 window가 됩니다. 컨텍스트 없이 함수를 호출하면, 자신도 모르게 전역 상태를 건드리고 있을 수도 있습니다.

**암시적 바인딩(Implicit binding)**은 함수를 객체의 메서드로 호출할 때 발생합니다. user.sayName()이라고 작성하면, 점(.) 연산자가 해당 호출 동안 thisuser로 설정하도록 엔진에 조용히 지시합니다. 정의된 위치보다 호출되는 위치가 더 중요합니다.

**명시적 바인딩(Explicit binding)**을 사용하면 모든 것을 수동으로 재정의할 수 있습니다. call()apply()는 함수를 즉시 호출하면서 this를 사용자가 제공한 특정 객체로 강제합니다. 두 방식의 유일한 차이점은 인자를 전달하는 방식입니다. call은 쉼표로 구분된 목록을 받고, apply는 배열을 받습니다. bind()는 다르게 동작합니다. 함수를 즉시 호출하지 않고, 대신 this가 사용자가 제공한 값으로 영구적으로 고정된 새로운 함수를 반환합니다. 이는 함수가 전달되는 과정에서 컨텍스트를 잃어버릴 수 있는 콜백 함수를 다룰 때 매우 유용합니다.

**new 바인딩(New binding)**은 함수 호출 앞에 new 키워드를 사용할 때 적용됩니다. 엔진은 완전히 새로운 빈 객체를 생성하고, 프로토타입 연결을 설정한 뒤, 생성자 내부의 this가 이 새로운 인스턴스를 가리키도록 합니다.

지속 가능한 코드 작성하기

메커니즘을 이해하는 것은 기술의 절반에 불과합니다. 나머지 절반은 6개월 뒤의 사람도 읽을 수 있는 코드를 작성하는 것입니다.

**DRY (Don't Repeat Yourself)**는 당연하게 들리지만, 끊임없이 위반되곤 합니다. 만약 세 개의 서로 다른 파일에서 동일한 검증 로직이나 API 호출 패턴을 작성하고 있다면, 이를 추출하십시오. 하나의 함수로 만드십시오. 단일 진실 공급원(one source of truth)을 갖는다는 것은 요구 사항이 변경될 때 업데이트할 곳이 한 군데라는 의미이며, 이는 말로 다 표현하기 힘들 정도로 많은 시간을 절약해 줍니다.

**KISS (Keep It Simple, Stupid)**는 자아(ego)를 경계하기 위한 방어 기제입니다. 중첩된 삼항 연산자나 한 줄짜리 클로저는 똑똑해 보일 수 있지만, 디버깅 시 수 시간을 허비하게 만듭니다. 앞서 보여드린 클로저 패턴은 강력하지만, 단순히 할 수 있다는 이유만으로 5단계까지 중첩하는 것은 실수입니다. 단순한 코드는 팀원 교체, 운영 환경의 장애, 그리고 무언가 고장 났을 때 아무도 이유를 기억하지 못하는 한밤중의 알람 속에서도 살아남습니다.

진정한 보상

실행을 공부하는 것은