ECMAScript 명세 이해, 2부


원글 : Marja Hölttä, Understanding the ECMAScript spec, part 2

명세 읽는 기술을 좀 더 다듬어 보자. 이전 글을 보지 않았다면 지금 보길 추천한다!

2부를 시작할 준비가 되었나요?

이미 아는 것에서 출발하는 것은 뭔가를 재밌게 배우는 방법 중 하나이다. 여기서 우리가 이미 알 만한 JavaScript 기능을 어떻게 명세해 두었는지부터 살펴보자.

경고! 이 글에는 2020년 2월 ECMAScript 명세에서 그대로 가져온 알고리즘을 포함한다. 그러므로 시간이 지나면 실제 명세와 예제가 다를 수 있다.

이 글을 읽는 여러분은 아마 속성을 조회할 때 프로토타입 체인을 이용한다는 것을 알고 있다. 만약 해당 객체에 우리가 찾는 속성이 없다면, 자바스크립트 엔진은 해당 속성을 찾을 때까지(또는 프로토타입이 더 이상 이어지지 않을 때까지) 프로토타입 체인을 따라 이동한다.

예를 들면

const o1 = { foo: 99 };
const o2 = {};
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 99

프로토타입 동작은 어디에 정의되어 있을까?

이 동작이 정의된 위치를 살펴보자. 객체 내부 메서드 목록에서부터 시작해보자.

[[GetOwnProperty]][[Get]]이 있다. 우리는 소유 속성(own properties)으로 제한되지 않는 버전에 관심이 있으므로 [[Get]]을 사용하자.

불행히도 속성 설명자 명세 타입에는 [[Get]]이라는 필드도 있으므로 [[Get]] 명세을 보는 동안 두 가지가 다름을 염두에 두자.

[[Get]]은 주요 내부 메서드이다. 일반(ordinary) 객체는 주요 내부 메서드에 대한 기본 동작을 구현한다. 특수(exotic) 객체는 기본 동작에서 벗어나는 자체 내부 메서드 [[Get]]를 정의할 수 있지만, 이 글에서는 일반 객체만 살펴보기로 하자.

[[Get]]은 기본적으로 OrdinaryGet에 동작을 위임해 구현한다.

[[Get]] ( P, Receiver )

속성 키 P와 ECMAScript 언어 값 Receiver를 사용하여 O[[Get]] 내부 메서드를 호출하면 다음 동작이 수행된다.

  1. ? OrdinaryGet(O, P, Receiver)를 반환한다.

위 명세의 Receiver가 접근자 속성의 getter 함수를 호출할 때 this 값으로 사용하는 값임을 알 수 있다.

OrdinaryGet은 다음과 같이 정의된다.

OrdinaryGet ( O, P, Receiver )

추상 연산 OrdinaryGet이 Object O, 속성 키 P 및 ECMAScript 언어 값 Receiver로 호출되면 다음 동작이 수행된다.

  1. 단언: IsPropertyKey(P) 는 true이다.
  2. desc 를 ? O.[[GetOwnProperty]](P)로 한다.
  3. desc 가 undefined라면,

    1. parent 를 ? O.[[GetPrototypeOf]]()로 한다.
    2. parent 가 null이면, undefined를 반환한다.
    3. ? parent.[[Get]](P, Receiver)를 반환한다.
  4. IsDataDescriptor(desc) 가 true라면, desc.[[Value]]를 반환한다.
  5. 단언: IsAccessorDescriptor(desc) 는 true이다
  6. getter 를 desc.[[Get]]로 한다.
  7. getter 가 undefined라면, undefined를 반환한다.
  8. ? Call(getter, Receiver)를 반환한다.

프로토타입 체인 동작은 3단계로 이루어져 있다. 속성을 자체 속성으로 찾지 못하면 프로토타입의 [[Get]] 메서드를 호출하여 OrdinaryGet에 다시 위임한다. 그래도 속성을 찾지 못하면 해당 속성을 찾거나 프로토타입이 없는 객체에 도달할 때까지 해당 프로토타입의 [[Get]] 메서드를 호출하여 다시 OrdinaryGet에 위임한다.

o2.foo에 접근할 때 이 알고리즘이 어떻게 작동하는지 살펴보자. 먼저 Oo2이고 P"foo"OrdinaryGet을 호출한다. O.[[GetOwnProperty]]("foo")o2"foo"라는 자체 속성이 없기 때문에 undefined 을 반환하므로 3단계에서 if 분기를 사용한다. 3.a 단계에서 o1o2의 프로토타입에 parent를 설정한다. parentnull이 아니므로 3.b 단계에서는 반환하지 않는다. 3.c 단계에서 속성 키 "foo"로 부모의 [[Get]] 메서드를 호출하고, 반환한다.

부모(o1)는 일반 객체이므로, [[Get]] 메서드는 OrdinaryGet을 다시 호출하며, 이번에는 Oo1이고 P"foo"이다. O1"foo"라는 자체 속성을 가지고 있으므로, 2단계에서 O.[[GetOwnProperty]]("foo")는 관련 속성 설명자를 반환하고 desc에 저장한다.

속성 설명자는 명세 타입이다. 데이터 속성 설명자는 속성의 값을 [[Value]] 필드에 직접 저장한다. 접근자 속성 설명자는 접근자 함수를 [[Get]] 및/또는 [[Set]] 필드에 저장한다. 이 경우, "foo"와 관련된 속성 설명자는 데이터 속성 설명자이다.

2단계에서 desc에 저장한 데이터 속성 설명자는 undefined이 아니므로 3단계에서 if 분기를 사용하지 않는다. 다음으로 우리는 4단계를 실행한다. 속성 설명자는 데이터 속성 설명자이므로 4단계에서 [[Value]] 필드(99)를 반환하고 완료된다.

Receiver은 무엇이며 어디에서 전달될까?

Receiver 매개변수는 8단계에서 접근자 속성인 경우에만 사용된다. 접근자 속성의 getter 함수를 호출할 때 this 값으로 전달된다.

OrdinaryGet은 변경되지 않은 상태로 반복되는 동안 원본 Receiver를 전달한다(3.c 단계). Receiver가 원래 어디에서 전달되었는지 알아보자!

[[Get]]를 호출하는 위치를 찾아보면 참조(References)에서 동작하는 추상 연산 GetValue를 찾을 수 있다. 참조(Reference)는 기본 값, 참조한 이름 및 엄격한 참조 플래그(strict reference flag)로 구성된 명세 타입이다. o2.foo의 경우 기본 값은 객체 o2이고 참조된 이름은 문자열 "foo"이며 엄격한 참조 플래그는 예제 코드가 엉성하기 때문에 false이다.

번외: 참조(Reference)가 Record가 아닌 이유는 무엇일까?

번외: 참조는 Record 일 것 같지만 Record가 아니다. 여기에는 세 개의 구성요소가 포함되어 있으며, 이는 동일하게 세 개의 명명된 필드로 표현될 수 있다. 참조는 역사적 이유 때문에 Record가 아니다.

GetValue로 돌아와서

명세에서 GetValue를 어떻게 정의하는지 살펴보자.

GetValue ( V )

  1. ReturnIfAbrupt(V).
  2. Type(V)Reference가 아니라면, V를 반환한다.
  3. baseGetBase(V)로 한다.
  4. IsUnresolvableReference(V)true라면, ReferenceError예외가 발생한다.
  5. IsPropertyReference(V)true라면,

    1. HasPrimitiveBase(V)true라면,

      1. 단언: 이런 경우,base는 절대undefined또는null가 되지 않는다.
      2. base! ToObject(base)로 설정한다.
    2. ? base.[[Get]](GetReferencedName(V), GetThisValue(V))를 반환한다.
  6. 그 외,

    1. 단언:base은 Environment Record 이다.
    2. ? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V)) 를 반환한다.

이 예에서 참조는 속성 참조인 o2.foo이므로 5단계를 확인한다. base(o2)가 원시 값(Number, String, Symbol, BigInt, Boolean, Undefined 또는 Null)이 아니기 때문에 5.a에서 분기하지 않는다.

다음으로 5.b 단계에서 [[Get]]을 호출한다. 전달하는 ReceiverGetThisValue(V)이며, 이 경우 이 값은 참조의 기본 값이다.

GetThisValue( V )

  1. 단언:IsPropertyReference(V)true이다.
  2. IsSuperReference(V)true라면,

    1. 참조 VthisValue 구성 요소 값을 반환한다.
  3. GetBase(V)를 반환한다.

o2.foo의 경우 슈퍼 참조(예: super.foo)가 아니기 때문에 2단계에서 분기를 수행하지 않지만 3단계를 수행하여 참조의 기본 값인 o2를 반환한다.

모든 과정을 종합하면 Receiver를 원본 참조의 base로 설정한 다음 프로토타입 체인 동작 동안 변경하지 않음을 알 수 있다. 마지막으로 찾은 속성이 접근자 속성이면 호출할 때 Receiverthis 값으로 사용한다.

특히 getter 내부의 this 값은 프로토타입 체인 동작 동안 속성을 찾은 것이 아니라 속성을 가져오려고 했던 원래 개체를 나타낸다.

위에서 배운 내용을 사용해보자!

const o1 = { x: 10, get foo() { return this.x; } };
const o2 = { x: 50 };
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 50

위 예제에서는 foo라는 접근자 속성과 이에 대한 getter를 정의했다. 그리고 이 getter는 this.x를 반환한다.

이제 o2.foo에 접근해보자. - getter는 무엇을 반환할까?

getter를 호출할 때 this 값은 원래 속성을 가져오려고 했던 객체이지 찾은 객체가 아니다.

이 경우 this 값은 o1이 아니라 o2이다. getter가 o2.x 또는 o1.x를 반환하는지 확인해보면 실제로 o2.x를 반환하는 것을 확인할 수 있다.

제대로 동작한다! 명세에서 읽은 내용을 기반으로 이 코드 조각의 동작을 예측할 수 있었다.

속성 접근 - [[Get]] 을 호출하는 이유는 무엇일까?

명세에서 o2.foo와 같은 속성에 접근할 때 객체 내부 메서드 [[Get]]를 호출한다는 내용이 있었나? 분명 어딘가에는 있어야 할 텐데... 필자의 말을 믿지 말자!

이 글을 통해 객체 내부 메서드 [[Get]]은 참조에서 동작하는 추상 연산 GetValue에서 호출한다는 것을 배웠다. 그렇다면 GetValue는 또 어디에서 호출될까?

MemberExpression에 대한 런타임 의미론 (runtime semantics)

명세의 문법 규칙은 언어의 구문을 정의한다. Runtime semantics는 구문 구성의 "의미"(런타임에 이를 평가하는 방법)를 정의한다.

문맥 자유 문법에 익숙하지 않다면 지금 살펴보자!

이후 글에서 문법 규칙에 대해 더 자세히 알아볼 것이다. 지금은 간단하게 살펴보자! 특히 이 글의 프로덕션에서 사용되는 첨자(Yield, Await 등)는 무시해도 된다.

다음은 프로덕션 MemberExpression이 어떻게 구성되었는지 보여준다.

MemberExpression :
  PrimaryExpression
  MemberExpression [ Expression ]
  MemberExpression . IdentifierName
  MemberExpression TemplateLiteral
  SuperProperty
  MetaProperty
  new MemberExpression Arguments

여기에 MemberExpression에 대한 7개의 프로덕션이 있다. MemberExpressionPrimaryExpression일 수 있다. 또는 MemberExpression은 다른 MemberExpressionExpression을 함께 연결하여 구성할 수 있다(MemberExpression [ Expression ]) , 예를 들면 o2['foo'] 또는 MemberExpression . IdentifierName 일 수 있다, 예를 들면 o2.foo — 이것은 예시와 관련된 프로덕션이다.

프로덕션 MemberExpression : MemberExpression . IdentifierName에 대한 런타임 의미론은 이를 평가할 때 취해야 할 일련의 단계를 정의한다.

Runtime Semantics: Evaluation for MemberExpression : MemberExpression . IdentifierName

  1. baseReferenceMemberExpression를 평가한 결과로 한다.
  2. baseValue? GetValue(baseReference)로 한다.
  3. MemberExpression과 일치하는 코드가 엄격한 모드 코드인 경우 stricttrue 이고, 그렇지 않으면 strictfalse이다.
  4. ? EvaluatePropertyAccessWithIdentifierKey(baseValue, IdentifierName, strict)를 반환한다.

위 알고리즘은 동작을 추상 연산 EvaluatePropertyAccessWithIdentifierKey에 위임하므로 이 명세도 살펴봐야 한다.

EvaluatePropertyAccessWithIdentifierKey( baseValue, identifierName, strict )

추상 작업 EvaluatePropertyAccessWithIdentifierKeybaseValue , Parse Node identifierName 및 Boolean 인수 strict를 인수로 사용한다. 다음 동작을 수행한다.

  1. 단언:identifierNameIdentifierName 이다
  2. bv? RequireObjectCoercible(baseValue)로 한다.
  3. propertyNameStringidentifierNameStringValue 로 한다.
  4. 기본 값 구성 요소가 bv이고 참조된 이름 구성 요소가 propertyNameString이고 엄격한 참조 플래그가 strict인 Reference 타입의 값을 반환한다.

즉, EvaluatePropertyAccessWithIdentifierKey는 제공된 baseValue를 기본으로 사용하고 identifierName의 문자열 값을 속성 이름으로 사용하며 strict를 엄격 모드 플래그로 사용하는 참조를 구성한다.

나중에 이 참조는 GetValue로 전달된다. 이 값은 참조가 사용되는 방식에 따라 명세의 여러 위치에서 정의한다.

매개변수로서의 MemberExpression

이 예제에서는 속성 접근를 매개변수로 사용한다.

console.log(o2.foo);

이 경우 동작은 인수에서 GetValue를 호출하는 ArgumentList 프로덕션의 런타임 의미론으로 정의된다.

Runtime Semantics: ArgumentListEvaluation

ArgumentList : AssignmentExpression

  1. refAssignmentExpression 를 평가한 결과로 한다.
  2. arg? GetValue(ref)로 한다.
  3. 유일한 항목이 arg인 목록을 반환한다.

o2.fooAssignmentExpression처럼 보이지 않지만 하나이므로 이 프로덕션에 해당한다. 이유를 알아보기 위해 이 추가 콘텐츠를 확인할 수 있지만 이 시점에서 꼭 필요한 것은 아니다.

1단계의 AssignmentExpressiono2.foo이다. o2.foo를 평가한 결과인 ref는 위에서 언급한 참조이다. 2단계에서 GetValue를 호출한다. 따라서 객체 내부 메서드 [[Get]]이 호출되고 프로토타입 체인 동작이 발생한다는 것을 알 수 있다.

요약

이 글에서 우리는 명세에서 언어 기능(이 경우 프로토타입 조회)을 모든 다른 계층(기능을 트리거하는 구문 구조와 이를 정의하는 알고리즘)에서 어떻게 정의하는지 살펴보았다.

김정용2022.11.16
Back to list