본문 바로가기
Web/JS

Javascript의 실행 컨텍스트와 호이스팅, 클로저

by 가나닩 2024. 4. 26.
  • 호이스팅 : 변수나 함수의 선언문이 최상단으로 끌어올려진것같이 작동하는 현상
  • 클로저 : 함수와 그 함수가 선언될때 렉시컬 환경이 형성되는데 이러한 환경을 기억하여 스코프 밖에서 실행될때도 내부 스코프에 정상적으로 접근할수 있게 하는 기능

간단한 작동 방식은 어렴풋이 이해하기에 어렵지 않다. 하지만 호이스팅과 클로저의 정확한 작동 원리와 이유를 이해하기 위해서는 실행 컨텍스트의 개념과 함께 이해해야한다.

 

1. Execution Context (실행 컨텍스트)

실행 컨텍스트는 JavaScript 코드가 실행되는 환경을 의미한다. 실행 컨텍스트는 현 자바스크립트 작동방식에 큰 영향을 주므로 매우 중요한 개념이다. 복잡한 구조를 가지고 있지만 해당 문서에서는 VariableEnvironment와 LexicalEnvironment만 다룬다.

 

실행 컨텍스트의 구조를 설명하기에 앞서 간단한 작동 원리를 알아보자. 자바스크립트의 EC(Execution Context)는 스택구조로 쌓여 선입후출 방식으로 작동한다.

var number = "123";

function second() {
    console.log(number);
}

function first() {
    second();
}

first();

  • 코드 실행 : 코드 전체에서 필요한 부분을 담은 Global EC가 생성된다. 코드 전체에 분포되어있는 변수, 함수 선언 등이 포함되어 있다. (number = "123", first() 등등)
  • first 함수 실행 : 코드 실행중 first 함수가 실행되면 first 함수의 실행과 함께 함수 내부의 정보를 가진 EC가 만들어지고 콜스택에 쌓인다. 이때 Global EC의 실행은 일시중단 된다.
  • second 함수 실행 : first 함수 내부에서 second 함수가 실행되었으므로 second 함수 실행과 함께 함수 내부의 정보를 가진 EC가 만들어지고 콜스택에 쌓인다. 이때 first() EC의 실행은 일시중단 된다.
  • second 함수 종료 : second 함수 실행이 완료되면 second() EC가 제거된다. 이후 일시중단 되어있던 first() EC의 실행을 재개한다.
  • first 함수 종료 : first 함수 실행이 완료되면 first() EC가 제거된다. 이후 일시중단 되어있던 Global EC의 실행을 재개한다.
  • 코드 종료 : 모든 코드의 내용이 실행완료된후 Global EC도 제거되며 콜스택이 비워진채로 코드가 종료된다.

위 그림에서 EC에는 코드 실행에 필요한 정보들이 포함되어있다고 설명했다. EC의 내부를 보면 VE와 LE로 나뉘어있는데 이 구조를 자세히 살펴보자.

 

 

2. VE와 LE의 구조

먼저 모든 VE와 LE는 Environment Record와 Outer Environment Reference를 갖는다.

  • Environment Record (환경 레코드) : 해당 환경에 포함된 식별자와 바인딩된 값을 저장한다. 쉽게말해 변수, 함수 등 코드 실행에 필요한 정보들이 모두 저장된다.
  • Outer Environment Reference (외부환경에 대한 참조) : 상위 EC의 내용물을 참조할수 있게 한다. (스코프 체인)

먼저 Environment Record에 대해 알아보자.

 

3. Environment Record

VE와 LE는 둘다 Environment Record를 가지고 있다. Environment Record는 EC가 생성될때 해당 코드의 선언문만을 실행하여 기록해두는 공간이다. 그러나 저장되는 내용물의 종류에서 차이가 발생한다.

  • VE : var 변수, 함수선언식, var 함수표현식
  • LE : let / const 변수, 함수표현식 

왜 이런 차이를 두었을까? 이러한 차이는 호이스팅, 스코프 범위의 차이와 연관이 있다.

 

4. Hoisting (호이스팅)

자바스크립트의 EC가 생성되고 실행되는 과정은 크게 두 과정으로 나뉜다.

  • 생성 단계 : EC를 생성하며 코드의 선언문만을 실행하여 Environment Record에 기록한다.
  • 실행 단계 : 선언문 외의 코드를 순차적으로 실행하며 필요에 따라 Environment Record에 기록된 정보를 참조하거나 업데이트한다.

위 두 단계를 코드로 살펴보자.

console.log(fruit);  // undefined

var fruit = "apple";

console.log(fruit);  // apple

 

  1. 생성단계에서 fruit는 선언만 실행하여 저장된다. 즉 apple은 저장되지 않고 var로 선언되었으므로 fruit = undefined로 초기화된다.
  2. 생성단계가 종료되고 실행단계에서 첫번째 console.log를 실행한다. fruit는 undefined로 초기화되어 있으므로 undefined가 출력된다.
  3. 실행단계이므로 var fruit = "apple" 전체가 실행되어 undefined로 초기화 되어있는 fruit변수의 값은 apple로 업데이트된다.
  4. 마지막 console.log가 실행되고 fruit값은 apple로 출력된다.

 

코드 순서상 변수가 선언되고 초기화되기전 변수를 사용해도 마치 문서 최상단에서 선언만 먼저 이루어진것처럼 동작하여 undefined가 출력되는 현상을 호이스팅 이라고 한다.

 

여기서 var이 아닌 let이나 const로 변수선언을 하면 어떻게 될까?

console.log(fruit);  // Reference Error

let fruit = "apple";

console.log(fruit);  // apple
  1. 위와 동일하게 생성단계에서 fruit는 선언만 실행하여 저장된다. 그러나 let, const로 선언된 변수는 식별자(fruit)를 저장해두긴 하지만 값을 초기화 하지는 않는다.
  2. 생성단계가 종료되고 실행단계에서 첫번째 console.log를 실행한다. fruit에는 초기화된 값이 없으므로 참조할 값이 없어 Reference Error가 발생한다.
  3. 실행단계이므로 var fruit = "apple" 전체가 fruit변수의 값은 apple로 초기화된다.
  4. 마지막 console.log가 실행되고 fruit값은 apple로 출력된다.

이처럼 let 혹은 const 변수 선언과 초기화 전에 사용되어 값을 참조할수 없는 곳을 TDZ(Temporal Dead Zone) 라고 부른다.

 

과거 자바스크립트는 var만 사용할 수 있었다. 하지만 var는 초기화 위치와 상관없이 사용할수 있다는 특징을 가져 에러가 발생하지 않는다. 이는 코드 예측가능성을 떨어트릴 수 있다. 그로 인해 ES6에서는 let과 const가 도입되었으며 조금 더 엄격한 프로그래밍을 할 수 있도록 해줬다. var과 let, const 사이의 호이스팅 특성 차이는 VE와 LE를 구분짓는 한가지 이유이다.

 

5. 스코프 범위

var과 let, const는 스코프범위의 차이도 가지고 있다. var는 함수 스코

5-1. var의 함수 스코프

var fruit = "apple";

if (true) {
    var fruit = "banana";
    var name = "kim";
}

console.log(fruit);  // banana
console.log(name);  // kim

 

대부분의 C-family language는 블록스코프(중괄호 기준)를 따르는 반면 자바스크립트의 var는 함수스코프를 따른다. if문이 중괄호로 닫혀있는 블록형태임에도 apple로 초기화된 fruit변수는 banana로 재할당 되었으며 if 블록 내부에 있는 name변수는 블록 바깥에서 출력해도 정상출력되는것을 알수있다. 이는 일정 함수 내부에서 실행시켜도 동일하게 동작한다.

 

5-2. let, const의 블록 스코프

let fruit = "apple";

if (true) {
    let fruit = "banana";
    let name = "kim";
}

console.log(fruit);  // apple
console.log(name);  // ReferenceError

 

반면 let, const는 블록스코프를 따른다. 중괄호로 닫힌 if 블록 내부에서 fruit는 독립적인 변수로 취급되어 외부에서 출력해도 apple이 그대로 출력되는것을 알수있으며 블록 내부의 name변수는 참조하지 못해 ReferenceError가 발생하는것을 알 수 있다.

 

앞서 설명한 호이스팅 특성 차이와 더불어 스코프 범위에서도 var과 let, const는 차이가 나기 때문에 VE와 LE로 나누어서 관리한다.

 

※ VE에 저장되는 var과 함께 함수선언식, var 함수표현식은 호이스팅의 영향을 받아 코드 최상단에서 실행한것같은 효과를 준다. (ReferenceError가 발생하지 않음, TypeError는 발생 가능) 이로인해 코드 실행도중 스코프의 변경이 없는 VE의 특성을 정적이라고 표현하는 경우도 있다. 반대로 코드 실행도중 let, const의 선언으로 새로운 렉시컬 환경과 새로운 스코프 체인이 형성되며 계속 변하는 환경을 동적이라고 표현하기도 한다.

 

다음으로 Outer Environment Reference 이다.

 

6. Outer Environment Reference

클로저의 개념을 이해하기 위해서는 OuterEnvironmentReference와 렉시컬 스코프의 개념을 이해하는것이 좋다. 앞서 실행 컨텍스트의 원리에서 코드의 각 실행 컨텍스트는 스택의 형태로 쌓이며 순차적으로 실행과 일시정지를 반복한다고 설명하였다.

 

또다른 코드로 다시 실행 컨텍스트의 작동원리를 보면

const fruit = "apple";

const print = () => {
    const person = {
        name: "kim",
        age: 20,
    };
    console.log(fruit);
    console.log(person);
};

print();  // apple { name: 'kim', age: 20 }
console.log(fruit);  // apple
console.log(person);  // ReferenceError

  • 코드 실행 : 코드 전반의 선언문을 먼저 실행하고 저장한다(생성단계). 이후 코드를 순차적으로 실행하며 변수값이 저장되고 함수의 정보가 저장된다(실행단계).
  • print() 함수 실행 : 코드에서 print(); 라는 함수 실행명령을 발견하여 함수를 실행한다. 함수는 개별적인 실행 컨텍스트(EC)가 생성되며 함수 내부에서 필요한 정보들을 Global EC때와 마찬가지로 생성->실행 단계를 거쳐 저장한다.
  • fruit 정보 찾기 : print 함수 내에서 console.log(fruit)를 발견한다. 이때 함수 내부에는 fruit라는 변수에 대한 정보가 없다.
    모든 EC는 선언될 당시 선언되는 곳(외부)의 LE를 참조하고 있다. print함수 내에서 fruit 변수 정보를 찾지못하면 참조하고 있는 외부 EC의(여기서는 Global EC) LE를 참조하여 정보를 찾는다. Global EC에 저장되어있는 fruit 변수를 발견하고 정상적으로 apple을 출력할 수 있게 된다.
    실행컨텍스트(EC)의 OuterEnvironmentReference는 상위 실행컨텍스트(EC)의 LexicalEnvironment(LE)를 참조하도록 한다. 이것이 OuterEnvironmentReference의 역할이다.
  • person 객체까지 정상출력한 print함수는 종료되고 실행컨텍스트가 제거된다.
  • 코드 마지막의 두 console.log를 실행하는데 fruit 변수는 같은 EC에 있으므로 정상 출력되지만 person객체는 출력하지 못하고 ReferenceError가 발생한다. 실행 컨텍스트는 하위 실행컨텍스트의 내용을 참조할 수 없다.

OuterEnvironmentReference의 역할과 렉시컬 스코프의 개념을 정리해 보면

  • 스코프 : 변수, 함수 등이 어떻게 사용될지 코드 내에서 찾아내기 위한 규칙
  • OuterEnvironmentReference : 함수의 선언 위치에 따라 상위 함수의 내용물을 참조할수 있도록 해줌
  • 렉시컬 스코프 : OuterEnvironmentReference에 의해 함수 선언 위치에 따라 상위 스코프가 결정되는데 이를 렉시컬 스코프 라고 부름 (함수 호출 위치와 상관없이 선언 위치에 따라 고정되어 결정되므로 정적 스코프라고 부르기도 함)

이제 클로저의 개념을 정확히 이해할 수 있다.

 

7. Closure (클로저)

클로저는 함수가 선언될때의 렉시컬 스코프를 기억하여 호출위치에 상관없이 원래의 렉시컬 스코프에 따라 작동하는 함수를 말한다.

const outerPrint = () => {
    const fruit = "사과";

    const innerPrint = (i) => {
        console.log(`${fruit}의 개수는 ${i}개 입니다.`);
    };

    return innerPrint;
};

const appleCount = outerPrint();
appleCount(5);  // 사과의 개수는 5개 입니다.
appleCount(10);  // 사과의 개수는 10개 입니다.
innerPrint(10);  // ReferenceError
console.log(fruit);  // ReferenceError

 

innerPrint 함수는 렉시컬 스코프로 outerPrint를 참조하여 fruit 변수를 사용할 수 있다.

 

앞서 설명한 실행 컨텍스트의 작동원리를 생각해보면

  1. const appleCount = outerPrint(); 에서 outerPrint 함수가 실행된후 innerPrint함수를 반환하고 outerPrint함수의 실행 컨텍스트는 콜스택에서 제거된다.
  2. appleCount(5) 를 실행할때 콜스택에서 outerPrint가 제거되었으므로 fruit변수의 정보가 없어야 한다.
  3. 그러나 정상적으로 출력된다. 이는 렉시컬 스코프를 기억하여 실행하는 클로저 함수의 특성 때문이다.

클로저 함수는 참조가 필요한 정보들을 메모리에 따로 저장한다. 콜스택에서 함수의 실행컨텍스트가 제거되는것과 별개로 필요에 의해 메모리에 따로 저장된 정보들은 클로저 함수에서 이용할 수 있게 된다.

 

※ 클로저의 용도

마지막 두줄을 보면 innerPrint(10)과 console.log(fruit)는 에러가 발생된다.

앞서 설명한 렉시컬 스코프의 특징으로인해 함수 내부 변수에는 직접적으로 접근할 수 없다. outerPrint 함수만을 이용해서 접근할 수 있으므로 public, private을 흉내낼 수 있게된다.

이는 변수의 접근 권한을 제어하여 무분별한 변수의 수정을 막고 예기치 않은 접근을 차단할 수 있다.

 

이외에도 클로저의 특성을 이용해 여러가지 용도로 사용할 수 있다.

 

7-1. 스코프 체인

함수가 중첩될때도 동일하게 작동한다.

const fruit = "사과";

const outerPrint = () => {
    const innerPrint = (i) => {
        console.log(`${fruit}의 개수는 ${i}개 입니다.`);
    };

    return innerPrint;
};

const appleCount = outerPrint();
appleCount(5);  // 사과의 개수는 5개 입니다.
appleCount(10);  // 사과의 개수는 10개 입니다.

 

console.log에서 fruit변수가 필요한 innerPrint함수는 innerPrint함수 내부에 정보가 없으므로 상위 실행 컨텍스트인 outerPrint를 살펴본다. 그러나 fruit변수는 outerPrint함수에도 없다. 이럴때에는 outerPrint함수의 상위 실행컨텍스트로 연결해서 찾게 된다. 탐색과정에서 최상위 글로벌 실행 컨텍스트까지 온뒤 fruit 변수를 발견하고 사용할 수 있게된다.

 

만약 fruit변수가 여러개면 어떻게 될까

const fruit = "사과";

const outerPrint = () => {
    const fruit = "바나나";
    const innerPrint = (i) => {
        console.log(`${fruit}의 개수는 ${i}개 입니다.`);
    };

    return innerPrint;
};

const appleCount = outerPrint();
appleCount(5);  // 바나나의 개수는 5개 입니다.
appleCount(10);  // 바나나의 개수는 10개 입니다.

 

innerPrint에서 필요한 fruit변수는 outerPrint에서 바로 발견하여 바나나로 사용된다. 렉시컬스코프를 타고 탐색하는 도중 필요한 정보를 발견하면 해당 정보를 사용하고 상위 컨텍스트에 있는 정보는 가려지게 되는데 이를 변수 은닉화(variable shadowing) 이라고 부른다. 또한 함수나 변수의 선언된 위치, 선언 상태 등에 따라 어떤 값을 사용할지 일정 규칙을 통해 정하게 되는데 이를 식별자 결정(Identifier Resolution) 이라고 부른다.

 

위 예시처럼 실행 컨텍스트끼리의 렉시컬 스코프를 따라 순차적으로 정보를 찾는과정을 스코프 체인 이라고 부른다.