자바스크립트 함수 (3) - Lexical Environment


지난 글을 통해 함수의 생성함수의 호출 과정에 대해 간략히 알아보았다.

앞선 글(함수의 호출)에서 간단히 설명한 Execution Context에는 LexicalEnvironment와 VariableEnvironment라는 컴포넌트가 있다. 기본적으로 두 컴포넌트는 Lexical Environment에 대한 참조이며 처음에는 같은 Lexical Environment를 참조한다.

executionContext.LexicalEnvironment = executionContext.VariableEnvironment;

그리고 자바스크립트 코드에 따라 VariableEnvironment나 LexicalEnvironment의 참조가 바뀌기도 한다.

이제 Lexical Environment가 무엇인지 조금 더 자세히 알아보자.

참고로 이 글에서는 LexicalEnvironment와 VariableEnvironment의 차이를 더 다루진 않을 것이다. 혹시 궁금하다면 이 글을 꼭 읽어보길 바란다.

Lexical Environment

Lexical Environment는 자바스크립트 코드에서 변수나 함수 등의 식별자를 정의하는데 사용하는 객체로 생각하면 쉽다. Lexical Environment는 식별자와 참조 혹은 값을 기록하는 Environment Recordouter라는 또 다른 Lexical Environment를 참조하는 포인터로 구성된다. outer는 외부 Lexical Environment를 참조하는 포인터로, 중첩된 자바스크립트 코드에서 스코프 탐색을 하기 위해 사용한다.

Environment Recordouter를 조금 더 이해하기 쉽게 아래 구조를 살펴보자(물론 실제로 이렇게 단순하게 동작한다는 것은 아니지만 개념적으로 쉽게 이해할 수 있다).

function foo() {
  const a = 1;
  const b = 2;
  const c = 3;
  function bar() {}

  // 2. Running execution context

  // ...
}

foo(); // 1. Call
// Running execution context의 LexicalEnvironment

{
  environmentRecord: {
    a: 1,
    b: 2,
    c: 3,
    bar: <Function>
  },
  outer: foo.[[Environment]]
}

위 구조에서는 단순히 함수 호출 한 번에 하나의 Lexical Environment를 나타내고 있지만 실제로는 함수, BlockStatement, catch, with 등과 같은 여러 코드 구문과 상황에 따라 생성됐다 파괴되기도 한다.

함수의 Lexical Environment는 언제 만들어질까?

이전 글에서 함수의 호출 - F.[[Call]]에는 크게 3가지 단계가 있다고 설명했다.

  1. PrepareForOrdinayCall
  2. OrdinaryCallBindThis
  3. OrdinaryCallEvaluateBody

그리고 PrepareForOrdinayCall에서 Executon Context를 새로 만든다고만 하였는데, 사실은 Lexical Environment 역시 함께 만들어서 Execution Context에 저장한다. 그럼 이제 F.[[Call]]에서 PrepareForOrdinayCall을 조금 더 자세히 살펴보자.

// PrepareForOrdinayCall(F, newTarget)

callerContext = runningExecutionContext;
calleeContext = new ExecutionContext();
calleeContext.Function = F;

// 바로 여기, Execution Context를 만든 직후 Lexical Environment를 생성한다.
localEnv = NewFunctionEnvironment(F, newTarget);

// --- LexicalEnvironment와 VariableEnvironment의 차이는 서두에 있는 링크를 참고하자.
calleeContext.LexicalEnvironment = localEnv;
calleeContext.VariableEnvironment = localEnv;

executionContextStack.push(calleeContext);
return calleeContext;

NewFunctionEnvironment

이제 NewFunctionEnvironment의 동작을 살펴보자.

// NewFunctionEnvironment(F, newTarget)

env = new LexicalEnvironment;
envRec = new functionEnvironmentRecord;
envRec.[[FunctionObject]] = F;

if (F.[[ThisMode]] === lexical) {
  envRec.[[ThisBindingStatus]] = 'lexical';
} else {
  envRec.[[ThisBindingStatus]] = 'uninitialized';
}

home = F.[[HomeObject]];
envRec.[[HomeObject]] = home;
envRec.[[NewTarget]] = newTarget;

env.EnvironmentRecord = envRec.
env.outer = F.[[Environment]];

return env;

단순하다. Environment Recordouter를 가진 Lexical Environment를 만들어 반환한다. 여기에 함수 환경으로 this, super, new.target등의 정보를 Environment Record에 함께 초기화했다. 그럼 다음으로 Environment Record를 살펴보자.

Environment Record - Identifier bindings

Environment Record는 식별자들의 바인딩을 기록하는 객체를 말한다. 간단히 말해 변수, 함수 등이 기록되는 곳이다. 실질적으로 Declarative Environment Record와 Object Environment Record 두 종류로 생각할 수 있으며, 이외에 조금 더 자세히 보면 Global Environment Record, Function Environment Record, Module Environment Record가 있다. 이들은 다음과 같은 상속 관계를 갖는다.

                                           Environment Record
                                                    |
                    -----------------------------------------------------------------
                    |                               |                               |
        Declarative Environment Record     Object Environment Record     Global Environment Record
                    |
            --------------------------------
            |                              |
Function Environment Record     Module Environment Record

우리는 함수에 관심이 있으므로 이제 Function Environment Record를 살펴보자. Declarative Environment Record에 변수나 함수의 정보가 담겨있다면, Function Environment Record는 추가로 NewFunctionEnvironment에서 언급한 new.target, this, super 등에 대한 정보를 갖는 것이다.

{
  environmentRecord: { // = FunctionEnvironmentRecord
    //.... 위와 동일

    [[ThisValue]]: global, // Any
    [[ThisBindingStatus]]: 'uninitialized', // 'lexical' | 'initialized' | 'uninitialized'
    [[FunctionObject]]: foo, // Object
    [[HomeObject]]: undefined, // Object | undefined,
    [[NewTarget]]: undefined // Object | undefined
  },
  outer: foo.[[Environment]]
}

만약 ECMAScript 5까지의 Execution Context를 잘 알고 있었다면 여기에서 한 가지 다른 점을 찾아낼 수 있을 것이다. 바로 this 바인딩이다. 이 전까지는 this 바인딩을 Execution Context에서 관리했다면 ECMAScript 2015(ES6)부터는 Environment Record에서 관리한다. 따라서 this, super, new.target 등 모두 Function Environment Record에서 찾아볼 수 있으며, Record가 식별자들의 정보를 관리하는 객체이기 때문에 이게 더 합리적이라 볼 수 있을 것 같다.

(드디어 첫 번째 목적이었던 this, new.target, super 참조가 어디에서 저장되고 가져오는지 알 수 있게 됐다.)

outer environment reference - 스코프 체인

지금까지의 설명에서 outer에 대한 언급이 종종 있었는데, 정확히 무엇인지 알아보자. 자바스크립트는 Lexical Scope를 갖는 언어다. 그리고 식별자 탐색에 있어서 당연히 스코프 체인을 포함한다. outer는 이 스코프 체인을 위해 존재하는 참조이다. 다만 ECMAScript 3판까지는 Scope Chain 이란 용어를 직접 명시했다면, ES5부터는 Lexical nesting structure 또는 Logical nesting of Lexical Environment values 등으로 표현하고 있다. 아마 3판에서 5판으로 판올림을 하면서 List가 아닌 outer 참조를 활용하는 구현으로 바뀌는데 이에 맞춰 같이 변경한 것이 아닐까 추측한다.

아래 코드에서 outer를 활용해 식별자를 찾는 과정을 보자.

// global
const globalA = "globalA";

function foo() {
  const fooA = "fooA";

  function bar() {
    const barA = "barA";

    console.log(globalA); // globalA
    console.log(fooA); // fooA
    console.log(barA); // barA
    console.log(unknownA); // Reference Error
  }

  bar();
}

foo();

아래는 Environments를 간단히 나타낸다(this와 같은 특별한 값들은 생략한다).

GlobalEnvironment = {
  // Global Environment Record에는
  // Object Environment Record와 Declarative Environment Record 등이 같이 존재하지만 이 글에서는 구분하지 않겠다.
  environmentRecord: {
    globalA: "globalA"
  },
  outer: null
};

fooEnvironment = {
  environmentRecord: {
    fooA: "fooA"
  },
  outer: globalEnvironment // foo는 Global에서 생성됐다.
};

barEnvironment = {
  environmentRecord: {
    barA: "barA"
  },
  outer: fooEnvironment // bar는 foo 안에서 생성됐다.
};

bar의 environment에서는 fooAglobalA를 찾을 수 없기때문에 outer참조를 통해 상위 environment로 올라가 식별자를 찾아간다. outernull 임에도 불구하고 unknownA 처럼 찾을 수 없는 식별자라면 Reference Error가 발생한다.

기본값 매개변수(Default parameter)와 Lexical Environment

드디어 저번 글에서 예고했던 코드를 볼 수 있을 것 같다!

function add(a, b) {
  return a + b;
}

function foo(a, b = add(a, 1)) {
  return `foo ${a + b}`;
}

function bar(a = add(b, 1), b) {
  return `bar ${a + b}`;
}

console.log(foo(1)); // foo 3
console.log(bar(undefined, 1)); // Error

사실 고백하자면, 위 코드에 대한 예고는 사실 큰 의미가 없었다. 단순히 기본값 매개변수를 순서대로 처리하기 때문에 a = add(b, 1) 구문에서 b에 대한 참조를 찾을 수 없어 에러가 발생하는 것이다. 흔히 알고 있는 letconst의 TDZ(Temporal dead zone)와 같은 동작이다.

사실 기본값 매개변수에서 중요한 부분은 따로 있다. 바로 Lexical Environment를 새로 만든다는 것이다. 얼핏 생각해보면 어떤 의미인지 잘 파악되지 않는다. 단순히 매개변수와 일반 변수들 모두 Record에 저장하고 가져다 쓰면 되는 것 아닌가?

아래 코드를 살펴보자.

const str = "outerText";

function foo(fn = () => str) {
  const str = "innerText";

  console.log(fn());
}

foo(); // 'outerText'

코드를 보면 outerText가 출력되는 게 당연한 것으로 느껴진다. 하지만 만약 처음에 단순히 생각했던 것처럼 매개변수나 일반 변수들을 모두 같은 Environment의 Record에 저장하고 사용하면 fn 함수에서 참조해야 하는 str 식별자는 foo 내부의 str, 즉 innerText를 참조해버릴 것이다. 이는 상식적이지 않은 동작이고, 마치 함수 외부에서 내부 스코프를 참조하고 변경시켜버리는 모양이 되어버리고 여러 문제를 일으킬 수 있는 여지가 된다. 이 때문에 기본값 매개변수는 함수의 내부를 참조할 수 없도록 만들어야 한다. 그러기 위해서는 매개변수, 함수 내부 변수들을 Environment부터 분리해서 추가적인 스코프 체인을 만들어야 한다.

따라서 함수가 실행되고 변수들을 초기화할 때는 다음과 같은 동작을 한다(실제로는 더 많은 분기 동작들이 있지만, 단순화했으며, strict mode로 가정한다).

1. env = calleeContext.LexicalEnvironment;
2. envRec = env.environmentRecord;

3. envRec에 매개변수들을 등록하고 초기화한다.

4. If (기본값 매개변수가 없다면)
  4-1. Environment가 구분될 필요가 없으므로, 기존의 envRec에 일반 변수들(VarScoped)도 등록한다.
  4-2. varEnv = env;
  4-3. varEnvRec = envRec;
5. Else(= 만약 기본값 매개변수가 있다면)
  5-1. varEnv = NewDeclarativeEnvironment(env);
  5-2. varEnvRec = varEnv.environmentRecord;
  5-3. calleeContext.VariableEnvironment = varEnv;
  5-4. varEnvRec에 일반 변수(VarScoped)들을 등록한다.

6. lexEnv = varEnv;
7. lexEnvRec = lexEnv.environmentRecord;
8. calleeContext.LexicalEnvironment = lexEnv;
9. lexEnvRec에 4, 5에서 등록하지 못한 변수들(LexicallyScoped)도 모두 등록한다.
10. 내부에 있는 함수 객체들을 초기화한다.

단순화했지만 그래도 조금 복잡해 보일 수 있다. 더 간단히 설명해보면 - 함수가 호출될 때, 매개변수들을 먼저 초기화하고 기본값 매개변수가 있다면 새로운 Environment를 추가로 만들어서 여기에 함수 내부의 변수들을 등록한다. 이렇게 Environment를 새로 만들어버리기 때문에 기본값 매개변수는 함수 내부를 참조할 수 없으면서 함수 내부에서는 매개변수를 참조할 수 있는 중첩 구조를 만들어낼 수 있다.

마치며

지금까지 함수의 생성과 호출, 그리고 Execution context와 Lexical Environment에 대해 알아보았다. 기본적인 함수 호출만 알아보는데도 생각보다 복잡한 내용이 많았지만, this binding, 함수 객체가 갖는 속성들, [[Call]]의 동작, Execution context, this binding, 기본값 매개변수와 Lexical Environment 등 많은 변화와 그 동작들을 직접 확인할 수 있었다. 혹시 이 글이 ECMAScript를 이해하는 데 조금이라도 도움이 됐다면 이제 Generator 함수, Async 함수, Class 등을 ECMAScript 명세를 통해 직접 확인해보자. 직접 명세를 읽고 확인하는 것이 어려울 수도 있지만, 이런 노력을 하다 보면 빠르게 발전하는 ECMAScript를 보다 먼저 이해하는 데 큰 도움이 되지 않을까 생각한다.

Reference

ECMAScript2017 - https://www.ecma-international.org/ecma-262/8.0/index.html


이민규, FE Development Lab2017.10.06Back to list