실행 컨텍스트란?

식별자를 등록하고 관리하는 스코프와 코드 실행 순서 관리를 구현한 내부 메커니즘으로
자바스크립트 코드가 실행되는 환경을 말한다
즉, 모든 자바스크립트 코드는 실행 컨텍스트를 통해 실행되고 관리된다.
그렇다면 실행 컨텍스트는 언제 생성될까?




소스코드의 평가와 실행

자바스크립트 엔진은 소스코드를
소스코드의 평가와 소스코드의 실행으로 2개의 과정을 나누어 처리한다.

평가 과정에서 바로 이 실행 컨텍스트가 생성되는데
이 과정에서는
변수, 함수 등의 선언문만 먼저 실행해서
생성된 변수나 함수 식별자를
실행 컨텍스트 내부에 렉시컬 환경의 환경 레코드에 등록한다.

평가 과정이 끝나면
선언문을 제외한 소스코드가 순차적으로 실행이 되는 런타임이 시작된다.
이 과정에서는
소스코드 실행에 필요한 변수나 함수를 찾기위해
평가 과정에서 등록했던 실행 컨텍스트 내부의 환경레코드를 검색해서
필요한 변수나 함수를 찾아서 사용한다.
그리고 변수값 할당이나 변경과 같은 소스코드의 실행 결과는
다시 실행 컨텍스트가 관리하는 환경 레코드에 등록한다.




실행 컨텍스트의 역할

그렇다면 실행 컨텍스트가 하는 역할이 무엇인지 대충 감이 올 것이다.
평가 과정에서 등록했던 환경레코드를 통해 식별자를 검색할 수 있다는 것을.
선언에 의해 생성된 모든 식별자를 환경레코드 안에 등록하고
바인딩된 값의 변화(할당, 재할당)에 따라 지속적으로 렉시컬 환경을 관리한다는 것을.
즉, 실행 컨텍스트는 소스코드를 실행하는 데 필요한 환경을 제공하고
코드의 실행 결과를 실제로 관리하는 역할을 하는 것이다.




실행 컨텍스트의 자세한 과정

소스코드는

전역 코드(global code): 전역에 존재하는 소스코드
함수 코드(function code): 함수 내부에 존재하는 소스코드
eval 코드(eval code): eval 함수를 통해 문자열 형태의 자바스크립트 코드를 동적으로 실행하는 코드
모듈 코드(module code): 모듈 내부에 존재하는 코드

총 4가지 타입으로 구분이 되는데
소스코드를 4가지 타입으로 구분하는 이유는 소스코드의 타입에 따라
실행 컨텍스트를 생성하는 과정과 관리 내용이 다르기 때문이다.
이 중에서 일반적으로 사용되는 소스 코드인 전역 코드와 함수 코드를 통해
실행 컨텍스트가 어떻게 동작하는지 알아보자.

var x = 10;

function foo(y) {
    var z = 20;
    return x + y + z;
}

var result = foo(5);
console.log(result);


1. 전역 코드 평가

소스코드가 로드되면 자바스크립트 엔진은 전역 코드를 평가하는데
전역 코드 평가순서는 아래와 같다.

  1. 전역 실행 컨텍스트 생성: 먼저 비어있는 전역 실행 컨텍스트를 생성해서 실행 컨텍스트 스택에 푸시한다.
  2. 전역 렉시컬 환경 생성: 전역 렉시컬 환경을 생성하고 전역 실행 컨텍스트에 바인딩 한다.

    2.1) 전역 환경 레코드 생성: 전역 환경 레코드를 생성한다.
    var 키워드로 선언한 전역 변수와 let, const 키워드로 선언한 전역 변수를 구분해서 관리하기 위해
    전역 환경 레코드는 객체 환경 레코드와 선언적 환경 레코드로 구성되어있다.

    2.1.1) 객체 환경 레코드 생성: 객체 환경 레코드를 생성하고
    var 키워드를 사용하여 선언된 전역 변수와 함수는 객체 환경 레코드에 등록되고 관리된다.
    객체 환경 레코드는 BindingObject라고 불리는 객체와 연결이 되는데
    var 키워드를 사용하여 선언된 전역 변수와 함수는
    BindingObject를 통해 전역 객체의 프로퍼티와 메서드가 된다.
    따라서, window.x 와 같이 전역 객체의 프로퍼티로서 참조를 할 수 있게 된다.

     // 객체 환경 레코드
     {
         x: undefined,
         foo: function foo(y) {...},
         result: undefined
     }
    

    2.1.2) 선언적 환경 레코드 생성: 선언적 환경 레코드를 생성하고
    let, const 키워드로 선언한 전역 변수는 선언적 환경 레코드에 등록되고 관리된다.

    2.2) this 바인딩: 전역 환경 레코드 [[GlobalThisValue]]내부 슬롯에 this가 바인딩된다.
    이 this는 일반적으로 전역 객체를 가리키기 때문에 전역 객체가 바인딩 된다.

    2.3) 외부 렉시컬 환경에 대한 참조 결정: 현재 평가 중인 소스코드를 포함하는 외부 소스코드의 렉시컬 환경을 참조하게 되는데
    전역 렉시컬 환경은 최상위 스코프이므로 외부 렉시컬 환경에 대한 참조에 null이 할당된다.

💡 실행 컨텍스트 스택

식별자와 스코프는 실행 컨텍스트의 렉시컬 환경으로 관리하고,
실행 순서는 실행 컨텍스트 스택으로 관리한다.
실행 컨텍스트 스택을 통해 코드의 실행 순서가 관리된다.


2. 전역 코드 실행

전역 코드에 대한 평가가 끝났다면 전역 코드를 실행하게된다.
전역 변수들에는 값이 할당되고 함수가 호출된다.
변수를 할당하거나 함수를 호출할 때는 식별자인지 확인을 하는 과정이 필요한데
이 과정을 위해 실행 중인 전역 실행 컨텍스트의 렉시컬 환경에서 식별자를 검색한다.
만약 해당 렉시컬 환경에서 식별자를 찾을 수 없다면 상위 스코프의 렉시컬 환경으로 이동해서 식별자를 찾는데
전역 렉시컬 환경은 최상위 스코프이기 때문에 식별자를 찾지 못한다면
상위 스코프가 없기 때문에 해당 식별자를 찾을 수 없다는 참조 에러를 띄우게 된다.

x = 10;

var result = foo(5);


3. 함수 코드 평가

함수가 호출되면 전역 코드의 실행을 일시 중단하고
함수 내부로 코드의 제어권이 이동되고 함수 코드를 평가하기 시작한다.
함수 코드 평가순서는 아래와 같다.

전역 코드 평가와 달리
렉시컬 환경을 생성할때 함수 환경 레코드만을 생성한다.

  1. 함수 실행 컨텍스트 생성: 함수 실행 컨텍스트를 생성하고 실행 컨텍스트 스택에 푸시한다.
  2. 함수 렉시컬 환경 생성: 함수의 렉시컬 환경을 생성하고 렉시컬 환경에는 환경 레코드와 외부 렉시컬 환경에 대한 참조로 구성이 된다.

    2.1) 함수 환경 레코드 생성: 함수 환경 레코드를 생성하고
    매개변수와 매개변수 객체 그리고 함수 내부에서 선언된 식별자들을 환경 레코드에 등록하고 관리한다

     // 함수 환경 레코드
     {
         y: 5, 
         z: undefined 
     }
    

    💡 arguments 객체

    함수 환경 레코드가 생성되면,
    arguments 객체가 생성되어 함수 환경 레코드에 등록된다.
    arguments 객체는 전달된 모든 인자를 관리하고,
    동적으로 함수 매개변수를 다룰 수 있도록 하기위해 생성되는 것이다.

    arguments 객체에는 다음과 같은 속성들이 포함되어있다.

    ◾ 인덱스 기반의 매개변수 값 (0, 1, 2, …)
    ◾ length 속성: 전달된 인자의 개수를 나타낸다.
    ◾ callee 속성: 현재 실행 중인 함수 자체를 가리킨다. (strict mode에서는 사용이 불가하다.)

    2.2) this 바인딩: 함수 환경 레코드 [[ThisValue]]내부 슬롯에 this가 바인딩된다.
    일반 함수로 호출 되었을 경우 this는 전역 객체를 가리킨다.

    2.3) 외부 렉시컬 환경에 대한 참조 결정 : 외부 렉시컬 환경에 대한 참조를 결정한다. 전역 코드에 정의된 전역 함수이기 때문에 전역 실행 컨텍스트를 참조하게 된다.


4. 함수 코드 실행

함수 평가가 끝나고 나면 함수를 실행하게 된다.

var z = 20;
return x + y + z;


5. 함수 코드 종료

실행할 코드가 없어지면 함수 코드의 실행이 종료되면서
함수 실행 컨텍스트가 실행 컨텍스트 스택에서 팝된다.

// 객체 환경 레코드
{
    x: undefined,
    foo: function foo(y) {...},
    result: 35
}


6. 전역 코드 종료

더 이상 실행할 전역 코드가 없어지면 전역 코드의 실행이 종료되고
전역 실행 컨텍스트도 실행 컨텍스트 스택에서 팝되어
실행 컨텍스트 스택에는 아무것도 남아있지 않게 된다.

console.log(result);


실행 컨텍스트와 블록 레벨 스코프

실행 컨텍스트와 함수 레벨 스코프에 대해 알아봤다면,
실행 컨텍스트와 블록 레벨 스코프는 어떤 차이가 있을까?

실제 코드를 통해 살펴보자

let x = 1;

if (true) {
  let x = 2;
  console.log(x); // 2
}
console.log(x); // 1
  • if 문의 코드 블럭이 실행되면 if문의 코드 블록을 위한 블록 레벨 스코프를 생성한다.
  • 이를 위해서 선언전 환경 레코드를 갖는 렉시컬 환경을 새롭게 생성해서,
    기존의 전역 레시컬 환경을 교체한다.

  • 이때 새롭게 생성된 if문의 코드 블록을 위한 렉시컬 환경의 외부 렉시컬 환경에 대한 참조는 if문이 실행되기 이전의 전역 렉시컬 환경을 가리킨다.
  • 그 다음에 if문이 종료되면 원래의 렉시컬 환경으로 되돌리게 된다.

💡 기존의 전역 레시컬 환경을 교체하는 이유?

만약 if 블록이 실행될 때 기존의 전역 렉시컬 환경을 유지한 채 변수를 추가한다면,
기존 스코프에서 변수가 즉시 접근 가능하게 되어 버린다.
다시 말해,
변수 선언 전에 해당 변수를 접근하지 못하도록 차단하는 환경이 필요하기 때문에
기존 전역 환경을 변경하지 않고,
새로운 렉시컬 환경을 만들어 기존 환경을 임시로 대체하는 것이다.

그리고 렉시컬 환경을 그대로 유지하면
불필요한 변수를 계속 저장되어 있으면서 메모리를 낭비할 가능성이 있기 때문에
if 문이 끝나면 새롭게 생성된 블록 렉시컬 환경을 제거하고,
원래의 환경으로 복귀하는 것이다.




클로저

클로저는
외부 함수에서 반환된 내부 함수가
정의된 곳을 기준으로 [[environment]] 내부 슬롯을 통해 상위 스코프를 참조한 상태에서
가비지 컬렉터에 의해 외부 함수의 지역변수가 도달 가능한 값으로 취급되어
외부 함수의 생명주기가 종료되어 콜스택에서 외부 함수가 제거되더라도
렉시컬 환경은 제거되지 않아서 내부 함수가
외부 함수의 지역변수에 대한 참조를 잃지 않는 것을 의미한다.

즉, 클로저는 함수와 그 함수가 정의된 렉시컬 환경과의 조합을 의미한다.
클로저는 함수가 외부 함수의 변수에 접근할 수 있도록 해주는 기능을 제공하고
이를 통해 데이터 은닉, 상태 유지, 그리고 함수의 재사용성을 높일 수 있다.

그렇다면 어떻게 외부 함수의 지역변수에 대한 참조를 잃지 않는 것일까?


[[Environment]] 내부 슬롯과 가비지 컬렉션 여부

전역 코드 평가가 이루어지는 과정 중에 객체 환경 레코드 생성될 때
함수 정의도 평가되어 함수 객체(Function Object)가 생성되는데
함수를 어디서 정의했는지에 따라 상위 스코프의 참조를 함수 객체 자신의 내부 슬롯 [[Environment]] 에 저장한다.
마찬가지로, 함수 코드 평가가 이루어지는 과정 중에 함수 환경 레코드가 생성될때
함수 정의도 평가되어 함수 객체를 생성하고 상위 스코프의 참조를 함수 객체 자신의 내부 슬롯 [[Environment]] 에 저장한다.

즉, 외부 환경에서 코드 평가가 이루어지거나 코드 실행이 이루어지는 시점에
실행 중인 실행 컨텍스트의 렉시컬 환경인 외부 렉시컬 환경의 참조가 저장되는 것이다.
전역 코드 평가 시점에는 전역 렉시컬 환경의 참조가,
함수 내부에서 정의된 함수가 함수 표현식인 경우에는 외부 함수 코드가 실행되는 시점에 외부 함수 렉시컬 환경의 참조가,
함수 내부에서 정의된 함수가 함수 선언식인 경우에는 외부 함수 코드가 평가되는 시점에 외부 함수 렉시컬 환경의 참조가 저장되는 것이다.

그리고 함수가 실행 될때
함수 객체의 내부 슬롯 [[Environment]]에 저장된 렉시컬 환경의 참조가 외부 렉시컬 환경에 대한 참조로 할당된다.
가비지컬렉터는 함수 객체의 [[Environment]] 슬롯을 직접 검사하는 것이 아니라,
해당 슬롯이 가리키는 외부 렉시컬 환경 참조를 기반으로 참조 여부를 확인해서 가비지 컬렉션 여부를 결정한다.
[[Environment]]는 외부 렉시컬 환경이 무엇인지 결정하는 역할만 한다.

💡 렉시컬 스코프

자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디에 정의했는지에 따라 상위 스코프를 결정한다.
함수의 스코프가 정의된 위치(렉시컬 환경)에 의해 결정되는 방식을 렉시컬 스코프 또는 정적 스코프(Static Scope) 라고 한다.

렉시컬 스코프
함수 정의가 평가되어 함수 객체가 생성되는 시점에 상위 스코프를 결정한다.

실제 코드를 통해 살펴보자

function outer() {
  var a = 1;

  const inner = function () {
    return ++a;
  };

  return inner();
};

const innerFunc = outer();
console.log(innerFunc); // 2

  1. outer 함수가 평가되어 함수 객체를 생성할 때
    전역 렉시컬 환경을 outer 함수 객체의 [[Environment]] 내부 슬롯에 저장한다.
  2. outer 함수를 호출하면 outer 함수의 렉시컬 환경이 생성되고
    outer 함수 객체의 [[Environment]] 내부 슬롯에 저장된 전역 렉시컬 환경을 outer 함수 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 할당한다.
  3. 그리고 중첩 함수 inner가 평가되고
    inner는 자신의[[Environment]] 내부 슬롯에 outer 함수의 렉시컬 환경을 저장한다.
  4. outer 함수의 실행이 종료하면 inner 함수를 반환하면서 outer 함수의 생명 주기가 종료되면서 outer 함수의 실행 컨텍스트가 실행 컨텍스트 스택에서 제거된다.
  5. outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거되지만 outer 함수의 렉시컬 환경까지 소멸하는 것은 아니다.
    outer 함수의 렉시컬 환경은 inner 함수의 외부 렉시컬 환경 참조에 의해 참조되고 있고
    inner 함수는 전역 변수 innerFunc에 의해 참조되고 있으므로 가비지 컬렉션의 대상이 되지 않기 때문이다.

💡 메모리누수 걱정은?

클로저를 사용하면 가비지 컬렉션의 대상이 되지 않기 때문에
불필요한 메모리 점유를 걱정할 수도 있다.
하지만 자바스크립트엔진은 클로저가 참조하고 있지 않는 식별자는
기억하지않고,
상위 스코프의 식별자 중에서 기억해야할 식별자를 기억한다.
즉, 기억해야할 식별자를 기억하는 것은 불필요한 메모리 낭비라고 할 수 없다.




클로저의 활용


접근 권한 제어

자바, C++, C# 과 같은 언어에서는 접근 제한자라는 키워드가 존재하는데,
public, private, protected 라는 접근 제한자를 통해 접근을 제어할 수 있다.
반면, 자바스크립트에서는 이 public과 private라는 접근 제한자를 지원하진 않지만
이러한 접근 제한자 없이도
클로저로 public한 값과 private한 값을 구분해서 접근권한을 제어할 수 있다

다시 코드를 살펴보자면,
a 변수는 outer 함수 내부에서만 사용되는 private한 값이 된다
하지만 outer 함수에서 반환하고 있는 inner 함수는
스코프 외부에서도 접근이 가능하기 때문에 public한 값이 된다
즉, 외부에 제공하고자하는 정보들은 return하고,
내부에서만 사용할 정보들은 return하지 않는 것으로 접근 권한 제어가 가능하다

function outer() {
  var a = 1;

  const inner = function () {
    return ++a;
  };

  return inner();
};

const innerFunc = outer();
console.log(innerFunc); // 2


상태 유지

클로저는 초기화된 변수의 상태를 유지하는 역할도 한다.
즉, 특정 함수가 실행될 때마다 값을 기억하고 변경할 수 있도록 만든다.
상태를 유지하면, 값이 외부에 노출되지 않으면서도 내부적으로 변경이 가능하다.
React의 useState 훅이 내부적으로 클로저를 사용하여 상태를 관리한다.

function createState(initialState) {
    let state = initialState;

    return {
        getState() {
            return state;
        },
        setState(newState) {
            state = newState;
            console.log("새로운 상태:", state);
        }
    };
}

const counterState = createState(0);
console.log(counterState.getState()); // 0
counterState.setState(10);
console.log(counterState.getState()); // 10


함수 실행 횟수 제한

클로저는 특정 함수가 한 번만 실행되도록 제한하는 역할도 할 수 있다.
함수가 실행된 후 실행 여부를 저장하는 변수를 유지함으로써,
이후의 호출에서는 원래 함수가 실행되지 않도록 제어할 수 있다.
이러한 패턴은 초기화 함수, 이벤트 핸들러 중복 실행 방지, API 호출 방지 등의 상황에서 유용하게 활용될 수 있다.

function once(fn) {
    let executed = false; // 실행 여부를 저장하는 변수 (상태 유지)

    return function (...args) {
        if (!executed) {
            executed = true; 
            return fn(...args);
        } else {
            console.log("이미 실행된 함수입니다.");
        }
    };
}

const initialize = once(() => console.log("앱이 초기화되었습니다!"));

initialize(); // "앱이 초기화되었습니다!"
initialize(); // "이미 실행된 함수입니다."

카테고리:

업데이트:

댓글남기기