JS 실행 컨텍스트 이해하기

JS 실행 컨텍스트 이해하기

올해 초 회사 내 신입사원들이 진행하는 세미나에서 발표한 내용을 git blog에 옮겨보았다.
해당 발표에서는 execution context의 동작 방식과 js가 어떻게 execution context를 바탕으로 closure 환경을 제공하는지를 다루었다.

1. 도입

  • 실행 결과 예측해보기
1
2
3
4
5
6
7
8
9
10
11
12
13
const x = 1;
 
function foo() {
    const y = 2;
 
    function bar () {
        const z = 3;
        console.log(x + y + z);
    }
    bar();
};
 
foo(); // 실행 결과는?
cs


→ function foo 는 어떻게 동작하는가?


Javascript는 script 언어이다. script언어 특성 상 언어의 해석과 실행은 메모리 위에서 런타임에 이루어 진다. c와 java와 같은 compile 언어는 기계어 혹은 바이트 코드로 번역된 결과물이 실행되지만 Javascript는 텍스트 파일이 해석되고 실행된다. 그렇다면 javascript 코드의 해석과 실행은 어떤 과정으로 이루어 질까? js engine은 javascript 문법으로 작성된 텍스트 파일을 parsing & interpreting 하는데 소스코드라는 단위로 실행한다.


소스코드라는 단어 자체는 우리가 굉장히 흔하게 쓰는 단어이지만 ECMAScript spec에서는 소스코드를 별도로 정의하고 있다. ECMAScript spec을 보면 다음 4개의 타입의 코드를 소스 코드라고 정의하며 javascript engine은 다음 4가지의 소스 코드를 실행의 단위로 생각한다.


type 설명
전역 코드   전역에 존재하는 소스코드, 전역에 정의된 함수, 클래스 등의 내부 코드는 포함되지 않는다.

(Javascript는 로드와 동시에 바로 해석과 실행이 되기 때문에 c와 Java와 같은 실행 시작 점(main 함수)과 같은 역할을 전역 코드가 담당한다. )
함수 코드   함수 내부에 존재하는 소스코드(function body)를 말한다. 함수 내부에 중첩된 함수, 클래스 등의 내부 코드는 포함되지 않는다.
eval 코드   빌트인 전역 함수인 eval 함수에 인수로 전달되어 실행되는 소스코드
모듈 코드   모듈 내부에 존재하는 소스코드를 말한다. 모듈 내부의 함수, 클래스 등의 내부 코드는 포함되지 않는다.

즉, 위의 소스 코드들은 javascript 엔진에 의해 텍스트가 해석(parsing & interpreting)되면서 동시에 실행(evaluating & executing)되는 대상이다.

이 소스코드가 실행될 때 실행컨택스트라고도 부르는 콜 스택이 생성된다. 실행 컨텍스트는 javascript의 콜 스택이다.

따라서 우리가 실행 컨텍스트가 콜 스택이라는 것을 생각한다면 실행 컨텍스트의 개념이 어렵게 다가오지는 않을 것이다. (물론 이렇게 단순하지는 않고 독특한 특징을 가지고 있다. 앞으로 설명할 것이다.)

실행 컨텍스트를 콜 스택이라고 생각한다면 앞에서 나온 예제 코드의 실행 컨텍스트의 변화를 쉽게 그릴 수 있다.


출처.https://meetup.toast.com/posts/123

4가지 타입에 따라서 실행 컨텍스트 내부의 구성(정확하게는 실행 컨텍스트가 가리키는 렉시컬 환경(Lexical Environment) 내부의 구성)이 달라지는데,

오늘은 중요한 내용인 전역 코드와 함수 코드의 실행 컨텍스트에 대해서만 설명하려고 한다.



❓ Javascript의 콜 스택, 실행 컨텍스트를 이해하는 게 유용한 이유는?


Javascript의 호이스팅, 클로저와 같은 특성들이 어떤 동작으로 인해 생기는 지 이해할 수 있다. → 코드의 동작 이해하는 데 도움이 되고, 특히 변수의 스코프를 예상할 수 있다.

오늘 다루지는 않지만 generator의 동작의 기반도 실행 컨텍스트와 Lexical Environment이다. 제네레이터의 동작을 이해하는 데도 도움이 된다.

따라서 오늘 설명의 목표는 실행 컨텍스트의 구성과 동작을 대략적으로 이해해서 Javascript에서의 변수, 함수의 스코프를 예상할 때 도움을 얻는 것이다.


2. 소스코드의 실행

javascript 소스코드의 실행은 소스코드의 평가와 소스코드의 실행이라는 2개의 과정으로 나뉜다. 아래의 예제를 가지고 2개의 과정을 살펴보겠다.

1
2
3
4
5
6
7
8
function foo() {
  var a = 1;
  let b = 2;
  const c = 3;
  function bar() {}
}
 
foo();
cs

그 전에 Quiz

❓ 위의 코드는 앞의 소스코드 정의에 따르면 2개 종류의 소스코드가 존재한다. 어떤 코드들이 존재하는가?


정답은?

  1. 전역코드
  2. foo 함수 코드

따라서 위의 코드의 실행에서는 2개의 실행 컨텍스트가 생성될 것이다.




그러면 소스코드의 실행 시점 중에서 실행 컨텍스트는 언제 생성될까?

실행 컨텍스트는 소스 코드의 평가 이전에 생성된다.


실행컨텍스트 생성 → 소스코드 평가 → 소스코드 실행 의 순서로 진행된다.


자바스크립트 엔진은 소스코드를 평가 하기 전에 실행 컨텍스트(Execution Context)와 렉시컬 환경(Lexical Environment)이라는 것을 생성한다. 그 다음 실행 컨텍스트가 렉시컬 환경의 참조 값을 가지도록 한다. 간단하게 그림으로 그리면 아래와 같다.



실제 lexical environment와 environment record는 위의 그림과 조금 다르지만 거의 유사하다. Execution Context Stack이 콜 스택이다. Lexical Environment는 EnvironmentRecord에 식별자와 값의 쌍을 저장하는 Environment Record와 This값이 저장된 ThisValue를 가지고 있다.

OuterLexicalEnvironment Reference에는 자신을 생성한 Lexical Environment의 참조 값을 저장된다. 만약 전역 코드라면 OuterLexicalEnvironment Reference이 뭐가 될까? 전역 코드는 자신을 생성한 Lexical Environment가 없기 때문에 항상 null 값을 저장한다.

1) 소스 코드의 평가

실행 컨텍스트와 렉시컬 환경이 생성되면 소스코드의 평가가 시작 된다. 소스코드 평가 때는 소스코드에서 변수, 함수등의 선언문만 먼저 실행하여 렉시컬 환경의 환경 레코드에 저장한다.

바로 이 지점이 hoisting이 발생하는 지점이다. 자바스크립트 소스코드에서는 실행 전 평가 시점에 모든 변수들이 이미 environment record에 등록된다.

그래서

1
2
3
console.log(x)
 
var x = 1;
cs

위의 코드를 실행해 보면 ‘x is not defined’ error 가 뜨는 것이 아니라 undefined가 출력 된다. 소스코드 상 var x = 1; 이 console.log(x) 보다 뒤에 있지만 실행 전 평가 시점에 이미 x : undefined 로 등록이 되어 있기 때문에 이런 결과가 나온다.

소스코드의 평가가 끝난 시점에서는 var로 생성된 변수는 등록과 동시에 undefined로 값이 초기화 되고 let과 const으로 선언된 변수의 경우 초기화가 이루어 지지 않고 uninitialized 상태가 된다.

다시 예제 코드로 돌아가보면

1
2
3
4
5
6
7
8
9
function foo() {
  var a = 1;
  let b = 2;
  const c = 3;
  function bar() {}
 
}
 
foo(); // 1. Call
cs

전역 코드가 실행 되면서 평가 전에 전역 실행 컨텍스트와 전역 렉시컬 환경이 생성될 것이다. 그 다음 평가가 시작되고 전역 코드 내의 모든 변수, 함수 선언 문이 전역 렉시컬 환경의 환경 레코드에 저장된다.

foo함수의 경우 평가 후, 실행 전에는 아래와 같은 실행 컨텍스트와 렉시컬 환경이 만들어 진다. (물론 실제로 아래와 같이 구성되는 것은 아니다.)

1
2
3
4
5
6
7
8
9
10
11
// Running execution context의 LexicalEnvironment
 
{
  environmentRecord: {
    a: undefined,
    b: <uninitialized>,
    c: <uninitialized>,
    bar: <Function>
  },
  outer: foo.[[Environment]]
}
cs
출처.https://meetup.toast.com/posts/129

3. 실행 컨텍스트와 Lexical Environment의 구성


앞에서는 전역 실행 컨텍스트와 함수 실행 컨텍스트를 구분하지 않고 간단하게 설명을 했는데,

이번에는 예시 코드와 그림을 이용해서 전역 실행 컨텍스트와 함수 실행 컨텍스트의 구성을 더 자세히 살펴보겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var x = 1;
const y = 2;
 
function foo(a) {
  var x = 3;
  const y = 4;
 
  function bar (b) {
    const z = 5;
    console.log(a + b + x + y + z);
  }
  bar(10);
}
 
foo(20); // foo 함수 평가 후 실행 전의 실행 컨텍스트와 Lexical Environment
cs

출처.모던 자바스크립트 Deep Dive. 23.6 실행 컨택스트의 생성과 식별자 검색 과정 이웅모 저

전역 객체(브라우저의 경우 window, node의 경우 global) 생성

전역 코드 실행

  • 전역 실행 컨텍스트 생성
  • 전역 렉시컬 환경 생성
    • 전역 환경 레코드 생성
      • 객체 환경 레코드 생성 : 전역 환경을 전역 객체와 연결하기 위해 사용한다.(import 없이 dom과 Math와 같은 빌트인 객체를 사용할 수 있는 이유), var로 선언되는 변수들을 전역 객체의 property로 저장된다 → 콘솔에서 확인할 수 있다.
      • 선언적 환경 레코드 생성 : let, const와 같이 block scope 변수들의 저장
    • this 바인딩 : this에 전역 객체 바인딩
    • 외부 렉시컬 환경에 대한 참조 결정

foo 함수 실행

  • 함수 실행 컨텍스트 생성
  • 함수 렉시컬 환경 생성
    • 함수 환경 레코드 생성
    • this 바인딩 (this 바인딩은 이후에 함수에서 따로 설명)
    • 외부 렉시컬 환경에 대한 참조 결정

4. 렉시컬 스코프 vs 다이나믹 스코프

오늘 closure에 대해서도 설명을 하려고 하는데 그 전에 잠깐 scope에 대해서 정리해보려고 한다.

실행 컨텍스트는 컴파일 언어의 콜 스택과는 조금 다른 특징을 가진다. 여태까지 설명을 들었을 때는 일반적인 콜스택과 크게 다른 점이 없어 보였는데 어떤 부분이 다르다는 것일까?

바로 실행 컨텍스트가 생성될 때 같이 생성하는 렉시컬 환경 때문에 다른 점이 발생한다. 렉시컬 환경은 실행 컨텍스트 생성 때 같이 생성되는 것은 맞지만 렉시컬 환경을 사용하는 대상이 실행 컨텍스트만 있는 것은 아니다.

아래의 예시를 보자


1
2
3
4
5
6
7
8
9
10
11
function foo() {
  var x = 15;
  console.log(x);
}
 
function bar() {
  console.log(x);
}
 
foo(); // 실행 결과는?
bar(); // 실행 결과는?
cs

정답은?

15

Uncaught ReferenceError: x is not defined



우리가 선언하는 변수는 2가지 특성을 가진다. 하나는 변수가 사용되는 범위인 스코프이고, 다른 하나는 변수가 사용되는 기간인 라이프사이클이다.(두 특성은 잘 혼동되지만 다른 특성이다.) js는 함수 스코프와 블록 스코프 두가지를 제공한다. 함수 스코프는 변수가 사용되는 범위가 함수 내부라는 의미이고, 블록 스코프는 변수가 사용되는 범위가 블록 내부라는 의미이다. 위의 예제를 보면서 scope를 이해해 보자. bar는 변수 x를 사용할 수 없다. 자신의 함수 body 내부에서 x가 선언되지 않았기 때문이다. 반면 foo는 함수 body 내부에 x가 선언되었기 때문에 사용가능하다.

그러면 이제 아래 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
  console.log(x);
}
 
function bar() {
  var x = 15;
  foo();
}
 
var x = 10;
foo(); // 실행 결과는?
bar(); // 실행 결과는?
cs

그렇다면 위의 예시의 결과는 어떨까?

정답은?

foo와 bar의 실행 모두 10이 출력 된다.

bar 함수에서 foo 함수를 실행한 결과는 왜 15가 아니고 10이 될까?

그건 javascript가 lexical scope 언어이기 때문이다.

lexical scope(정적 스코프) 언어는 자신의 스코프에 변수가 없을 때, 자신이 생성된 환경에서 해당 변수를 찾는다.(앞선 예시에서는 bar가 생선된 환경에서도 x가 선언되지 않았기 때문에 에러가 발생했다.) 반대로 dynamic scope(동적 스코프) 언어는 자신의 스코프에 변수가 없을 때, 자신을 호출한 환경에서 해당 변수를 찾는다.

만약에 javascript가 dynamic scope 언어였다면 bar 함수의 실행 결과는 15가 되었을 것이다.

그런데 조금 더 생각해보면 이상하다. var x = 10; 도 실행의 결과인데, dynamic scope 인거 아닌가? 하는 의문이 생길 수도 있는데, 실행 컨텍스트가 생성될 때 실행 컨텍스트는 렉시컬 환경의 참조 값을 가지고 있다는 사실을 기억하자



실행 컨텍스트 생성 이후에 생성하는 렉시컬 환경은 실행 컨텍스트만 참조하는 것이 아니다. 렉시컬 환경 A에서 생성된 함수가 [[Environment]] 에 렉시컬 환경 A의 참조값을 저장하고 있다. 여기서 우리는 실행 컨텍스트가 콜스택일 뿐만 아니라 동시에 렉시컬 환경을 제공해주는 연결점을 얻게 되었다. 렉시컬 환경은 실행 컨텍스트의 입장에서는 실행 환경이 맞다. 그러나 실행 컨텍스트와 렉시컬 환경이 참조 값으로 연결된 다소 느슨한 관계 이기 때문에 실행 환경인 동시에 해당 렉시컬 환경에서 생성된 함수에게는 dynamic scope가 아닌 lexical scope를 제공할 수 있는 것이다.

아래의 예제 코드는 앞의 소스코드의 평가를 설명했던 사용했던 예제인데 우리가 무심코 넘긴 부분이 있다. 바로 outer 값이다. 아래의 렉시컬 환경은 foo 함수 내부의 렉시컬 환경인데 foo 실행 전에 생성된 것이다. foo 함수 내부의 렉시컬 환경의 outer 값을 보면 foo.[[Environment]] 값 즉 foo 함수를 생성했을 때의 렉시컬 환경의 참조 값을 가지고 있다. 즉 예제를 통해 렉시컬 환경이 실행 컨텍스트가 생성 될 때 같이 생성 되지만 생성된 이후에는 실행 컨텍스트에 의해서만 사용되는 것이 아니라 function에 의해서도 사용된다는 것을 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
// Running execution context의 LexicalEnvironment
 
{
  environmentRecord: {
    a: undefined,
    b: <uninitialized>,
    c: <uninitialized>,
    bar: <Function>
  },
  outer: foo.[[Environment]]
}
cs
출처.https://meetup.toast.com/posts/129

정리하면 javascript의 렉시컬 환경은 실행 컨텍스트에게는 실행 환경의 역할을 하고 생성된 함수에게는 lexical environment(정적 환경 - 생성되었을 때의 환경)의 역할을 하게 되는 것이다. javascript 함수가 런타임에 생성되지만 lexical scope을 제공할 수 있는 이유가 여기에 있다. 함수가 렉시컬 환경 값을 [[Environment]]에 저장하고 있기 때문에, 자신의 스코프에 없는 변수를 찾을 때 자신을 호출한 실행 컨텍스트의 렉시컬 환경을 찾지 않고 자신이 생성 되었을 때의 렉시컬 환경을 찾을 수 있는 것이다.

5. Closure


“A Closure is the conbination of a function and the lexical environment within which that function was declared.” - MDN Closures
클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다. - MDN문서 Closures


“클로저는 자바스크립트의 고유의 개념이 아니다. 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 중요한 특성이다.” - 모던 자바스크립트 Deep Dive 24장 클로저


“lexical”이란, 어휘적 범위 지정(lexical scoping) 과정에서 변수가 어디에서 사용 가능한지 알기 위해 그 변수가 소스코드 내 어디에서 선언되었는지 고려한다는 것을 의미한다. - MDN문서 Closures


클로저는 자바스크립트만 가지고 있는 특성/개념이 아니다 . 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어, 여태까지 설명한 내용을 가지고 설명을 하자면 런타임에 함수를 생성하는 lexical scoping 언어라면 모두 가지고 있는 특성이다.

클로저에 대한 설명 자체만으로는 의미가 잘 와 닿지 않지만 앞서서 렉시컬 환경과 함수 객체의 Environment 값에 대해서 설명을 했기 때문에, 함수와 그 함수가 선언된 렉시컬 환경과의 조합이라는 말이 어렴풋하게는 이해가 될 것이다. (안된다면 여태까지 설명한 것 실패…)

예제를 살피면서 클로저가 어떤 상황에서 그 특성이 부각되는 지 알아보려고 한다. 아래의 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
const outerFunc = () => {
  let i = 100;
  const innerFunc = () => {
    console.log("In innerFunc, the i is " + i);
    return i + 1;
  };
  i = innerFunc();
  console.log("In outerrFunc, the i is " + i);
};
 
outerFunc();
cs

위의 예제를 보면 outerFunc 함수 내부에 innerFunc가 생성되고 innerFunc의 실행이 outerFunc 실행 내부에서 이루어 진다.
이런 경우에도 클로저라고 할 수 있을까?

클로저라고 할 수 있다. 하지만 클로저로서의 의미가 없는 클로저이다.
앞서 변수에는 2가지 특성이 있다고 했는데 위의 예제에서 만들어진 클로저는 변수의 라이프 사이클이 outerFunc의 실행 종료와 함께 종료되기 때문에 의미 있게 사용되지 못한다.

그러면 이번에는 의미가 있는 클로저의 예제를 보겠다.
아래의 소스코드를 보면 outerFunc를 실행할 경우 outerFunc 함수 내부에서 생성한 innerFunc를 반환한다.

이런 경우는 소스코드의 진행이 어떻게 될까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const outerFunc = () => {
  let i = 100;
  const innerFunc = () => {
    console.log("It's external the i is " + i);
    return (i = i + 1);
  };
  return innerFunc;
};
 
let foo = outerFunc();
 
foo();
foo();
foo();
cs

위의 예제에서는 outerFunc 내부에서 생성한 innerFunc가 변수 i를 가지고 있는 클로저가 외부에 반환된다. 따라서 외부에서 클로저를 사용할 수 있기 때문에 의미 있는 클로저가 만들어졌다. 이번 주제에서는 클로저가 주요 주제가 아니기 때문에 클로저를 사용한 예를 다루지 않았지만 간단히 예를 들어보면 이벤트 핸들러롤 넘기는 함수를 다른 변수들과 함께 넘기고 싶다면 외부 함수를 만들어 매개변수를 받게 하고 해당 매개변수를 사용하는 이벤트 핸들러 함수를 반환하는 클로저 함수를 만들 수도 있다. 그외에도 클로저를 활용하여 클래스의 static 변수를 만드는 예도 있다.

여기서 기억할 점은 다이어그램에서 다 표현하지 못했지만 클로저가 생성되기 위해서는 내부 함수가 자신의 스코프에서 생성하지 않은 변수, 즉 렉시컬 환경에서 생성된 변수를 사용하고 있어야 한다는 것이다. 자신의 스코프에서 생성하지 않았지만 렉시컬 환경을 이용하여 사용할 수 있는 변수들을 자유 변수(free variable)이라고 한다. 클로저는 함수실행이 종료된 이후에도 내부 함수가 외부로 반환되면서 생기는 자유 변수들이 갇힌 공간이다. 만약에 내부 함수가 자유 변수를 사용하고 있지 않다면 자바스크립트 엔진은 최적화를 위해서 내부 함수의 렉시컬 환경을 제거한다. 또한 렉시컬 환경의 변수가 여러 개일 때, 일부의 자유 변수만 사용하는 경우에도 사용하는 자유 변수를 제외하고는 다 제거한다.


참고 자료

댓글