실행 컨텍스트, 호이스팅, 그리고 클로저

35764 단어 JavaScriptJavaScript

시작하며

JS는 인터프리터 언어라서 위에서부터 한 줄 한 줄 실행하는 구조다.

  • 인터프리터란?
    코드를 바로 실행하는 프로그램 또는 환경.
    원시 코드를 기계어로 번역하는 컴파일러와 대비된다.
    인터프리터는 다음의 과정 가운데 적어도 한 가지 기능을 가진다.
    • 소스 코드를 직접 실행한다.
    • 소스 코드를 효율적인 다른 중간 코드로 변환하고, 변환한 것을 바로 실행한다
    • 인터프리터 시스템의 일부인 컴파일러가 만든, 미리 컴파일된 저장 코드의 실행을 호출한다.

아래 코드를 보자.

console.log(num);
const num = 5;

console을 실행할 때 js는 아래 코드에 대해 아무것도 모를까?

“num이 나중에 선언되었으니까 실행이 안 되잖아. 그러면 당연히 아무 것도 모르겠지?”

얼추 이치에 맞는 것 같다. 에러가 나니까.
그럼 아래의 코드를 보자.

prints();

function prints() {
	console.log("이거 출력될까요?");
}
prints();

const prints = () => {
	console.log("이건 어떨까요?");
}

function은 실행되고 화살표 함수는 에러가 난다. 왜?

“hoisting이란 말을 들은 적 있는데. 그거 때문인가보다.”

그게 끝일까? 그렇게 설명하면 끝일까?

Execution context

Javascript engine은 일하기 전에 코드를 ‘가볍게’ 훑어본다.

JS: “흠...이 녀석들 말이 되는 코드인가?”

다음 사례를 보자.

이모씨는 이번에 책을 출판하기로 했다. 아래는 이모씨가 작성한 초고 서문이다.

나는 공부하고 있다 실행. 자바스크립트 컨텍스트를

문장 순서가 개판이다. 이대로 출판하면 욕 한 바가지 얻어먹을 것이다.
담당자는 이모씨를 닦달해서 문장을 고쳤다.

나는 자바스크립트 실행 컨텍스트를 공부하고 있다.

실행 컨텍스트(Execution context)는 담당자와 비슷한 일을 한다.
코드의 맥락을 살피고, 흐름이 이상하면 에러를 낸다.
(컨텍스트는 흐름, 문맥이란 뜻이다. 앞으로 나오는 '~~컨텍스트'는 문맥이란 의미로 치환해서 읽도록 하자.)

실행 컨텍스트의 종류

실행 컨텍스트는 두 종류가 있다. (엄밀히는 더 있지만 일단 두 가지만 보자.)

  1. Global Execution Context (GEC)
    코드 전체 문맥. JS를 실행할 때 생성된다.

  2. Functional execution context (FEC)
    함수 문맥. 함수를 실행할 때 생성된다.

실행 컨텍스트 3요소

실행 컨텍스트는 다음과 같은 세 가지 파츠로 이뤄진다.

  • this
    • 초보들 골머리를 앓게 하는 그 this 맞다.
  • Variable Object (GEC)
    • let, const, var, function 등 우리가 선언한 온갖 변수들이 들어간다.
  • Activation Obejct (FEC)
    • let, const, var, function 등 우리가 선언한 온갖 변수들이 들어간다. 인자 정보 따위가 담기는 arguments object가 곁들여진다.
  • Scope Chain
    • 스코프(scope)는 코드가 현재 실행되는 환경, 문맥을 뜻한다.
    • Scope Chain은 해당 실행 컨텍스트 상위 스코프들의 Variable Object, Activation Object들을 가리킨다.
  • 정리
실행 컨텍스트 구성 요소설명
this해당 메소드가 참조하는 곳
variable object(VO)GEC가 변수 등의 정보를 담는 곳
activation object(AO)FEC가 변수 등의 정보를 담는 곳
scope chain상위 scope들의 VO, AO를 가리키는 배열

GEC는 JS를 실행할 때 생성된다고 얘기했다.
아래의 코드를 보자.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>

</body>

</html>

js 파일 연결도 없는 텅텅 빈 html이다.
이걸 실행하고 콘솔창에 this를 입력하면 아래와 같이 뜬다.

코드가 없어도 js는 뭔가를 하고 있다.

코드 유무에 상관없이 JS는 실행하는 순간 무조건 GEC를 만든다.

“흠...여기엔 아무 코드도 없군.”
“코드가 없으니까, 아무 코드도 없다고 처리를 해야겠네?”

실행 컨텍스트의 2단계

실행 컨텍스트는 다음과 같이 두 단계를 거쳐서 만들어진다.

  1. 생성 단계(Creation phase)
    변수의 '선언'을 읽는다.
  2. 실행 단계(Execution phase)
    변수의 '값'을 읽는다. (입문자가 흔히 헷갈리는, 한 줄 한 줄 읽는다는 것이 이 과정이다.)

1. 생성 단계(creation phase)

호이스팅을 이해하려면 이 부분을 잘 알아야 한다.
아래 코드를 살펴보자.

let ten = 10;
const two = 2;
var one = 1;

실행하면 GEC가 아래와 같이 생성될 것이다.
(필자가 임의로 보기 편하게 작성한 코드다. 실제로 저렇게 똑같이 구성된다는 얘기는 아니다.)

globalExecutionObj = {
    scopeChain: [],
    variableObject: {
        ten: <uninitialized>,
     	two: <uninitialized>,
      	one: undefined,
    },
    this: window
}

variable object가 ten, two, one 변수의 선언을 담는데, 이 때 ten, two가 'uninitialized'라는 점을 주목하자.

참고 : JS에서 변수는 Declaration(선언), Initialization(초기화), Assignment(할당) 3단계 과정을 거쳐 값을 부여받는다.

실행 컨텍스트 생성 단계에서 var는 선언과 초기화를 동시에 해서 undefined가 주어진다.
let과 const는 선언만 하고 초기화를 안 한다.

이 때문에 “var는 hoisting이 일어난다.”는 표현을 많이 쓴다.
하지만 MDN에선 다음과 같이 소개한다.

In ECMAScript 2015, let and const are hoisted but not initialized
let과 const는 호이스팅된다. 초기화가 안 될 뿐이다.

JavaScript Hoisting refers to the process whereby the interpreter appears to move the declaration of functions, variables or classes to the top of their scope, prior to execution of the code.
호이스팅은 인터프리터가 코드를 실행하기 전에 함수, 변수, 클래스 등의 선언을 스코프에서 참조하는 작업이다(마치 그것들을 해당 스코프 맨 위에 끌어올린 것처럼).

hoist : 들어올리다, 끌어올리다. -사전-

모든 변수는 실행 컨텍스트 생성 과정에서 참조를 위해 끌어올려(hoisted)진다.
따라서 호이스팅은 어떤 요소에서든 일어난다. 정리하자면 다음과 같다.

Q) 호이스팅이 뭔가?
A) 인터프리터가 함수, 변수 등 여러 요소의 선언해당 스코프에서 참조하는 작업.
(변수의 값이 아니라 변수의 선언을 가져온다는 점을 특기한다.)

Q) var는 호이스팅이 되고 let, const는 안 되는 이유가 뭔가?
A) 호이스팅 자체는 var 뿐 아니라 let과 const, 함수 등 모든 요소에서 일어난다.
JS에선 변수를 Declaration(선언), Initialization(초기화), Assignment(할당) 3단계 과정을 거쳐 읽어낸다.
var는 실행 컨텍스트 생성 단계에서 선언과 초기화를 동시에 해서 undefined이 되고,
let과 const는 선언만 해서 uninitialized로 처리된다.

Q) TDZ를 아는가?
스코프에서 변수들의 값이 Assignment(할당)되기 전의 시간적 구간을 TDZ(Temporal Dead Zone, 일시적 사각지대)라고 한다. 호이스팅했을 때 let과 const 등은 선언 단계이고, var은 초기화 단계이므로 TDZ에 속한다고 볼 수 있다.

Q) 호이스팅을 왜 알아야할까?
A) function으로 작성한 함수는 호이스팅이 일어나도 별 문제 없이 돌아간다. 하지만 그 외에 let, const, 함수 표현식(화살표 함수), class 등은 선언 -> 실행순으로 작성하지 않으면 ReferenceError가 뜬다.

ReferenceError는 두 가지 케이스에서 일어난다.
1. 변수 선언이 제대로 안 됐을 때
2. 변수 스코프가 다를 때(사실 1번과 똑같은 이유라고 봐도 된다.)

function numbers() {
  var num1 = 2,
      num2 = 3;
  return num1 + num2;
}

// var의 scope는 함수 단위라서 함수 바깥에선 인식 못한다.
console.log(num1); // ReferenceError num1 is not defined.

var는 scope 내에서는 순서를 거꾸로 작성해도 에러 대신 undefined라는 '정상적인' 값을 내보낸다. 이 때문에 에러를 찾기 힘들어진다.

Q) var를 안 쓰면 해결되는 일 아닌가?
A) 우리가 안 써도 트랜스컴파일 단계에서 쓰일 수 있다.

BABEL은 트랜스컴파일러다. 최신 문법을 구형 문법으로 변환해준다.
내가 최신 문법으로 작성했어도 구형 문법만 지원하는 환경(오른쪽)에선 let이나 const가 var로 코드가 바뀐다.
(이런 식의 에러가 실제 서비스에서 벌어질지는 의문이지만.)
그러므로 코드를 짤 땐 변수 선언을 상단에서 하고, 실행 코드를 아래에 쓰자.

Q) var를 쓰지 말아야할 이유는 뭐가 있을까?
1. var는 변수 중복 선언도 가능하다.
2. var의 scope는 블록 단위가 아니라 함수 단위라서 마구잡이로 섞일 수 있다.

// 변수의 중복 선언
var a = 1;
var a = 2;

for (var b = 1; b < 10; b++) {
	var c = b;
}

// for문에서만 쓰던 변수가 바깥에서도 사용 가능함.
console.log(b);
console.log(c);

클로저

클로저는 자바스크립트에서 상당히 자주 묻는 개념이다.
과연 클로저란 무엇일까?
우선 유명한 문장 하나를 보여주겠다.

js에서 함수는 1급 객체(first-class-object)이다.

그럼 1급 객체란 뭘까?
보통 아래와 같은 조건을 만족하면 1급 객체라 부른다.

  • 인자로 넘길 수 있고,
  • 함수 return값으로 쓸 수 있고,
  • 변수로 할당할 수 있다.

추가적으로 runtime에 생성되는지를 보기도 한다.
2급 객체도 있을까? 당연히 있다.

  • 인자로 사용 가능
  • return 불가
  • 변수 할당 불가.

3급 객체도 있다.

  • 인자로 못 씀
  • return 불가
  • 변수 할당 불가

조건대로라면 숫자 같은 자료형은 대부분 1급 객체이다.
자바스크립트에서 함수는 변수처럼 쓸 수 있고, 인자로 넘길 수 있고, return값으로 쓸 수 있기 때문에 1급 객체 조건을 만족한다.

클로저는 함수가 1급 객체라는 점을 이용한다.
함수가 생성될 때 함수에서 사용하는 변수가 함수 외부에도 있다면, 그 변수들은 함수의 스코프에도 저장된다.

클로저는 함수, 함수가 사용하는 변수들을 저장하는 공간을 일컫는다.

왜 클로저(closure)인가? 사전적 의미로의 closure는 폐쇄인데, 직관적으로 이해가 전혀 안 된다. 나는 수학을 떠올렸더니 이해가 좀 됐다.

수학에서 어떤 집합에 속한 원소들이 사칙연산 따위의 특정 연산을 만족하면 그 집합은 닫혀있다(closed)고 표현한다. 이는 1급 객체를 연상시킨다. (변수 사용 가능, 매개 변수 전달, 함수 return)

수학에서 closure(폐포閉包)는 그 집합의 원소와, 그 집합과 관계 있는 원소는 항상 그 집합에 속한다는 성질을 일컫는다.

함수를 집합, 내부 요소를 원소라고 생각하면 둘은 제법 흡사하다. 오히려 프로그래밍에서 말하는 closure가 수학의 closure에서 이름을 따오지 않았을까 싶다.

MDN에서 소개하는 클로저는 다음과 같다.

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).
클로저는 함수와 함수를 둘러싼 참조(함수의 렉시컬 환경)를 묶은 것이다.

a closure gives you access to an outer function's scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.
클로저 덕분에 내부 함수는 외부 함수 스코프에 접근할 수 있다. 자바스크립트에서 클로저는 함수를 만들 때마다 생성된다.

이제 코드를 하나 보자.

function tester() {
  let testing = "outer";
  function innerFunc() {
    console.log(testing);
    let testing = "inner";
    return testing;
  }
  return innerFunc;
}

const inner = tester();
inner();

inner()를 실행하면 console에는 뭐가 찍힐까?
이론적으로 생각해보면 "outer"가 출력되어야 할 기분이다.

하지만 에러가 난다. 왜일까?
innerFunc FEC는 아래와 같이 생성됐을 것이다.

innerFuncExecutionObj = {
    scopeChain: [tester, Global],
    activationObject: {
      	testing: <uninitialized>,
    }
  	this: undefined
}

호이스팅은 변수들을 해당 스코프의 맨 위에 올려두고 참조하는 개념이다.

innerFunc에서 testing은 tester에서 선언한 testing이 아니라 안쪽에서 선언한 testing을 참조하고 있다. 그래서 에러가 난다.

function tester() {
  let testing = "outer";
  function innerFunc() {
    console.log(testing);
    return testing;
  }
  return innerFunc;
}

const inner = tester();
inner();

innerFunc의 let testing 구문을 지우면 "outer"가 출력된다. 이처럼 함수 바깥의 testing 변수를 쓸 수 있는 이유는 closure 때문이다.

innerFunc의 scope chain에는 tester, Global이 있다.
인터프리터는 console.log(testing)을 실행할 때 scope chain에서 testing이란 이름의 변수가 어디에 있는지 탐색한 뒤, 가장 가까운 scope의 VO(변수 객체), 혹은 AO(활성 객체)를 참조한다. 그 말은 tester에서 해당 변수를 못 찾으면 Global까지 올라간다는 얘기다. 실제로 아래 코드를 실행해보면 확인할 수 있다.

let testing = "out of out";
function tester() {
  function innerFunc() {
    console.log(testing);
    return testing;
  }
  return innerFunc;
}

const inner = tester();
inner();

위처럼 함수 바깥에 있지만 사용 가능한 변수를 자유 변수(free variable)라고 표현한다.
예를 들면 모든 전역 변수는 자유 변수라고 할 수 있다. 당연히 역(모든 자유 변수는 전역 변수이다)은 성립하지 않는다.

다시 아까 코드를 보자.

function tester() {
  let testing = "outer";
  function innerFunc() {
    console.log(testing);
    return testing;
  }
  return innerFunc;
}

const inner = tester();
inner();

원래 FEC는 함수가 할 일을 다하고 나면 소멸한다.
즉, inner에 값을 할당함으로서 tester()의 FEC는 소멸한다. 하지만 inner()를 실행하면 tester의 변수값을 정상적으로 가져오고 있다. 왜일까?

tester가 innerFunc라는 함수를 inner 변수에 할당할 때 closure를 생성했기 때문이다.

closure는 함수가 생성되는 순간의 함수 자신과 함수를 둘러싼 유효 환경의 합집합이다. inner는 closure에서 tester의 활성객체(AO)를 참조하고, tester 스코프 내의 원소들을 사용할 수 있다.
이와 같이 동작하기 때문에 FEC에서는 Activation Object(활성 객체)라고 부른다. 사용 여부에 따라 활성화될 때가 있고, 비활성화될 때가 있다.

이 때 tester 스코프 내의 원소들을 복사해서 사용하는 게 아니라 원본 그대로 쓰기 때문에 변경도 자유롭게 가능하다.

처음에 closure와 1급 객체가 상관이 있는 것으로 설명했는데, 만약 js에서 함수가 2~3급 객체였다면 위와 같이 함수 안에서 함수를 return하는 코드를 짤 수 없다.

MDN에서 보여주는 예시들도 함수 안에서 함수를 return하는 것들이다.

렉시컬 스코프(lexical scope)
클로저, 렉시컬 환경
활성 객체

이상이 호이스팅과 scope, closure를 알면 이해가 되는 코드다.

2. 실행 단계(Execution phase)

각 변수에 값을 할당한다.
GEC(실행 단계)는 다음과 같이 생성된다.

globalExecutionObj = {
    scopeChain: [],
    variableObject: {
        ten: 10,
     	two: 2,
      	one: 1,
    },
    this: window
}

추가 예시

GEC, FEC를 같이 예시로 보자.

let firstName = 'Zelda';

function nameMaker(name){
  let lastName = 'Link';
  let fullName = firstName + lastName;
}  
  
nameMaker(firstName);  
// 생성 단계 GEC
globalExecutionObj = {
    scopeChain: [],
    variableObject: {
        firstName: <uninitialized>,
     	nameMaker: func,
    },
    this: window
}
// 실행 단계 GEC
globalExecutionObj = {
    scopeChain: [],
    variableObject: {
        firstName: 'Zelda',
     	nameMaker: pointer to function nameMaker,
    },
    this: window
}

GEC에서 함수는 생성 단계에서 함수라고 인식하고, 실행 단계에서 해당 함수의 포인터를 부여한다.

// 생성 단계 FEC
nameMakerExecutionObj = {
    scopeChain: Global,
    activationObject: {
       arguments: {
            0: name,
            length: 1
        },
        name: 'Zelda',
      
      	lastName: <uninitialized>,
      	fullName: <uninitialized>,
    },
  // 'use strict' 모드로 하면 this가 undefined로, 안 쓰면 Global로 나온다.
    this: Global or undefined
}
// 실행 단계 FEC
nameMakerExecutionObj = {
    scopeChain: Global,
    activationObject: {
       arguments: {
            0: name,
            length: 1
        },
        name: 'Zelda',
      
      	lastName: 'Link',
      	fullName: 'ZeldaLink',
    },
    this: Global or undefined
}

처음 사례를 다시 보자.

prints();

function prints() {
	console.log("이거 출력될까요?");
}
prints();

const prints = () => {
	console.log("이건 어떨까요?");
}

1번은 함수지만 2번은 함수 표현식이다. (함수 표현식엔 const가 붙는 점을 주목하자.)
함수는 FEC가 적용되지만 함수 표현식은 그렇지 않다.

그래서 function일 땐 호이스팅이 유연하게 처리되지만 화살표 함수에선 에러가 난다.
화살표 함수를 쓸 때 주의점은 이것 말고도 또 있지만 다른 챕터에서 써보겠다.

변수 선언 3단계

실행 컨텍스트와 렉시컬

좋은 웹페이지 즐겨찾기