ECMAScript 명세 이해, 3부


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

이 글에서는 ECMAScript 언어의 정의와 구문에 대해 좀 더 자세히 살펴보자. 문맥 자유 문법에 익숙하지 않다면, 지금이 기본 사항을 확인하기에 좋은 때이다. 왜냐하면 명세는 문맥 자유 문법을 사용하여 언어를 정의하기 때문이다. "Crafting Interpreters"의 문맥 자유 문법에 대한 장을 참조하여 쉽게 접근하거나 더 많은 수학적 정의를 위해서는 위키백과를 참조하자.

ECMAScript 문법

ECMAScript 명세는 네 가지 문법에 대해서 다음과 같이 정의한다.

어휘(lexical) 문법에서는 Unicode code points가 일련의 입력 요소(토큰, line terminators, 주석, 공백)로 변환되는 방법을 정의한다.

구문(syntactic) 문법은 구문적으로 올바른 프로그램이 토큰으로 구성되는 방식을 정의한다.

정규 표현식 문법은 Unicode code points를 정규 표현식으로 변환하는 방법을 정의한다.

숫자 문자열 문법은 문자열이 숫자 값으로 변환되는 방법을 정의한다.

각 문법은 일련의 프로덕션으로 구성된 문맥 자유 문법으로 정의한다.

문법은 약간 다른 표기법을 사용한다. 구문 문법은 LeftHandSideSymbol : 을 사용하는 반면 어휘 문법과 정규 표현식 문법은 LeftHandSideSymbol ::을 사용하고 숫자 문자열 문법은 LeftHandSideSymbol :::을 사용한다.

다음으로 어휘 문법과 구문 문법에 대해 좀 더 자세히 살펴보자.

어휘 문법

명세는 ECMAScript 원본 텍스트를 일련의 Unicode code points로 정의한다. 예를 들어, 변수 이름은 ASCII 문자로 제한되지 않고 다른 유니코드 문자도 포함할 수 있다. 명세는 실제 인코딩(예: UTF-8 또는 UTF-16)에 대해 정의하지 않는다. 소스 코드가 이미 인코딩에 따라 일련의 Unicode code points로 변환되었다고 가정한다.

사전에 ECMAScript 소스 코드를 토큰화할 수 없으므로 어휘 문법 정의가 약간 더 복잡해진다.

예를 들어, 다음과 같은 상황에서 다음 컨텍스트을 보지 않고 /가 나눗셈 연산자인지 정규 표현식의 시작인지 확인할 수 없다.

const x = 10 / 5;

여기서 /DivPunctuator 이다.

const r = /foo/;

여기서 첫 번째 /RegularExpressionLiteral의 시작이다.

템플릿은 유사한 모호성을 유발하는데, }`의 해석은 다음과 같은 상황에서 발생하는 컨텍스트에 따라 달라진다.

const what1 = 'temp';
const what2 = 'late';
const t = `I am a ${ what1 + what2 }`;

여기서 `I am a ${`TemplateHead이고 }`TemplateTail이다.

if (0 == 1) {
}`not very useful`;

여기서 }RightBracePunctuator이고 `NoSubstitutionTemplate의 시작이다.

/}의 해석은 "컨텍스트"(코드 구문 구조에서 위치)에 달려 있지만 다음에 설명할 문법은 여전히 컨텍스트가 없다.

어휘 문법은 일부 입력 요소가 허용되는 컨텍스트와 그렇지 않은 컨텍스트를 구별하기 위해 몇 가지 목표 기호(goal symbol)를 사용한다. 예를 들어 목표 기호 InputElementDiv/가 나눗셈이고 /=가 나눗셈 할당인 컨텍스트에서 사용한다. InputElementDiv 프로덕션은 이 컨텍스트에서 생성할 수 있는 토큰을 나열한다.

InputElementDiv ::
  WhiteSpace
  LineTerminator
  Comment
  CommonToken
  DivPunctuator
  RightBracePunctuator

이러한 컨텍스트에서 DivPunctuator 입력 요소가 발생되거나 생성된다. RegularExpressionLiteral을 생성하는 것은 선택 사항이 아니다.

반면, InputElementRegExp/가 정규 표현식의 시작인 컨텍스트에 대한 목표 기호이다.

InputElementRegExp ::
  WhiteSpace
  LineTerminator
  Comment
  CommonToken
  RightBracePunctuator
  RegularExpressionLiteral

프로덕션에서 알 수 있듯이 RegularExpressionLiteral 입력 요소를 생성할 수 있지만 DivPunctuator를 생성할 수는 없다.

마찬가지로 TemplateMiddleTemplateTail이 허용되는 컨텍스트에는 RegularExpressionLiteral외에 InputElementRegExpOrTemplateTail이라는 또 다른 목표 기호가 있다.

마지막으로 InputElementTemplateTailTemplateMiddleTemplateTail만 허용되고 RegularExpressionLiteral은 허용되지 않는 컨텍스트의 목표 기호이다.

내부 구현에서 구문 문법 분석기(parser)는 어휘 문법 분석기(tokenizer 또는 lexer)를 호출하여 목표 기호를 매개 변수로 전달하고 해당 목표 기호에 적합한 다음 입력 요소를 요청할 수 있다.

구문 문법

Unicode code points에서 토큰을 구성하는 방법을 정의하는 어휘 문법을 살펴보았다. 구문 문법은 이를 기반으로 하며, 구문적으로 올바른 프로그램이 토큰으로 구성되는 방식을 정의한다.

예: 레거시 식별자 허용

문법에 새로운 키워드를 도입하는 것은 잠재적인 변경 사항일 수 있다. 기존 코드가 이미 키워드를 식별자로 사용하고 있다면 어떻게 될까?

예를 들어, await가 키워드이기 전에 누군가 다음 코드를 작성했을 수 있다.

function old() {
  var await;
}

ECMAScript 문법은 이 코드가 계속 동작하도록 조심스럽게 await 키워드를 추가했다. 비동기 함수 내에서 await는 키워드이므로 동작하지 않는다.

async function modern() {
  var await; // Syntax error
}

제너레이터가 아닌 경우 식별자로 yield를 허용하고 위와 같이 제너레이터에서는 yield는 키워드이므로 동작하지 않는다.

await 가 식별자로 허용되는 방법을 이해하려면 ECMAScript 고유의 구문 문법 표기법을 이해해야 한다.

프로덕션과 약어

VariableStatement에 대한 프로덕션이 어떻게 정의되는지 살펴보자. 언뜻 보면 문법이 약간 어렵게 보일 수 있다.

VariableStatement[Yield, Await] :
  var VariableDeclarationList[+In, ?Yield, ?Await] ;

첨자([Yield, Await])와 접두사(+In+?Async?)는 무엇을 의미할까?

표기법은 문법 표기법 부분에서 정의한다.

첨자는 왼쪽 기호 집합에 대한 일련의 프로덕션을 한 번에 표현하기 위한 줄임말이다. 왼쪽 기호는 두 개의 매개변수를 가지며, 이는 네 개의 "실제" 왼쪽 기호로 확장된다.

VariableStatement,VariableStatement_Yield,VariableStatement_Await, VariableStatement_Yield_Await

여기서 일반 VariableStatement는 "_Await_Yield가 없는 VariableStatement"를 의미한다. VariableStatement[Yield, Await]와 혼동해서는 안된다.

프로덕션의 오른쪽에는 "_In과 함께 사용"을 의미하는 단축표기법 +In과 "왼쪽 기호에 _Await가 있는 경우에만 _Await와 함께 사용"을 의미하는 ?Await가 있다. (?Yield 와 비슷하게).

세 번째 단축표기법인 ~Foo는 "_Foo를 제외하고 사용"을 의미하며, 이 프로덕션에서는 사용되지 않는다.

이 내용를 통해 다음과 같이 프로덕션을 확장할 수 있다.

VariableStatement :
  var VariableDeclarationList_In ;

VariableStatement_Yield :
  var VariableDeclarationList_In_Yield ;

VariableStatement_Await :
  var VariableDeclarationList_In_Await ;

VariableStatement_Yield_Await :
  var VariableDeclarationList_In_Yield_Await ;

궁극적으로 우리는 두 가지 사실을 알아야 한다.

  1. _Await가 있는지 없는지 여부는 어디에서 결정되는가?
  2. 어디에서 차이를 만들까? 즉, Something_AwaitSomething(_Await 제외)의 프로덕션은 어디에서 나뉘어 질까?

_Awaitor no_Await?

먼저 1번 문제를 풀어보자. 함수 본문과 무관하게 _Await 매개변수를 선택하는지 여부에 따라 비동기가 아닌 함수와 비동기 함수가 다르다고 추측하는 것은 다소 쉽다. 다음은 비동기 함수 선언에 대한 프로덕션 중에서 찾을 수 있다.

AsyncFunctionBody :
  FunctionBody[~Yield, +Await]

AsyncFunctionBody에는 매개 변수가 없다. 매개 변수는 FunctionBody의 오른쪽에 추가된다.

이 프로덕션을 확장하면 다음과 같다.

AsyncFunctionBody :
  FunctionBody_Await

즉, 비동기 함수에는 FunctionBody_Await가 있으며, 이는 await가 키워드로 처리되는 함수 본문을 의미글다.

반면에 비동기가 아닌 함수 내부에 있는 경우 관련 프로덕션은 다음과 같다.

FunctionDeclaration[Yield, Await, Default] :
  function BindingIdentifier[?Yield, ?Await] ( FormalParameters[~Yield, ~Await] ) { FunctionBody[~Yield, ~Await] }

(FunctionDeclaration에는 또 다른 프로덕션이 있지만 코드 예제와 관련이 없다.)

조합 확장을 방지하려면 이 특정 프로덕션에서 사용되지 않는 Default 매개변수를 무시한다.

확장된 형태의 프로덕션은 다음과 같다.

FunctionDeclaration :
  function BindingIdentifier ( FormalParameters ) { FunctionBody }

FunctionDeclaration_Yield :
  function BindingIdentifier_Yield ( FormalParameters ) { FunctionBody }

FunctionDeclaration_Await :
  function BindingIdentifier_Await ( FormalParameters ) { FunctionBody }

FunctionDeclaration_Yield_Await :
  function BindingIdentifier_Yield_Await ( FormalParameters ) { FunctionBody }

이 프로덕션에서는 확장되지 않은 프로덕션에서 [~Yield, ~Await]로 매개변수화되기 때문에 _Yield_Await를 제외한 FunctionBodyFormalParameters를 항상 얻는다.

함수 이름은 다르게 처리된다. 왼쪽 기호에 매개변수가 있으면 _Await_Yield 매개변수를 가져온다.

요약하자면, 비동기 함수에는 FunctionBody_Await가 있고 비동기가 아닌 함수에는 _Await가 제외된 FunctionBody가 있다. 제너레이터가 아닌 함수에 대해 이야기하고 있기 때문에 비동기 예제 함수와 비동기가 아닌 예제 함수는 모두 _Yield 없이 매개 변수화 된다.

어떤 것이 FunctionBody이고 어떤 것이 FunctionBody_Await인지 기억하기 어려울 수 있다. FunctionBody_Awaitawait가 식별자인 함수일까, 아니면 await가 키워드인 함수일까?

_Await 매개변수는 "await는 키워드"라는 의미로 생각할 수 있으며, 이 접근 방식은 미래에도 사용할 수 있다. blob가 추가되지만 "blobby" 함수 내부에만 있는 새로운 키워드를 상상해 보자. non-blobby non-async non-generators는 여전히 현재와 똑같이 FunctionBody(_Await, _Yield 또는 _Blob 제외)를 갖는다. blobby 함수에는 FunctionBody_Blob이 있고, 비동기 blobby 함수에는 FunctionBody_Await_Blob 등이 있다. 우리는 여전히 프로덕션에 Blob 첨자를 추가해야 하지만 이미 존재하는 기능에 대한 FunctionBody의 확장된 형태는 그대로 유지한다.

식별자로 await를 허용하지 않는다.

다음으로, FunctionBody_Await 내부에 있는 경우 식별자로써 await가 어떻게 허용되지 않는지 알아보자.

_Await 매개 변수가 FunctionBody에서 이전에 살펴본 VariableStatement 프로덕션까지 변경되지 않고 전달되는 것을 확인하기 위해 추가로 프로덕션을 추적할 수 있다.

따라서 비동기 함수 내부에는 VariableStatement_Await가 있고 비동기 함수가 아닌 내부에는 VariableStatement가 있다.

프로덕션을 더 추적하고 매개변수를 추적할 수 있다. VariableStatement의 프로덕션은 이미 확인했다.

VariableStatement[Yield, Await] :
  var VariableDeclarationList[+In, ?Yield, ?Await] ;

VariableDeclarationList의 모든 프로덕션은 매개변수를 있는 그대로 전달한다.

VariableDeclarationList[In, Yield, Await] :
  VariableDeclaration[?In, ?Yield, ?Await]

(여기서는 예제와 관련된 프로덕션만 보여준다.)

VariableDeclaration[In, Yield, Await] :
  BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await] opt

opt 단축표기법은 오른쪽 기호가 선택 사항임을 의미한다. 실제로 두 가지 프로덕션이 있다. 하나는 선택적 기호가 있고 다른 하나는 기호가 없다.

예제와 관련된 간단한 사례에서 VariableStatement는 키워드 var와 initializer가 없는 단일 BindingIdentifier로 구성되며 세미콜론으로 끝난다.

BindingIdentifierawait를 허용하지 않거나 허용하기 위해 다음과 같이 되어야 한다.

BindingIdentifier_Await :
  Identifier
  yield

BindingIdentifier :
  Identifier
  yield
  await

이렇게 하면 await가 비동기 함수 내에서 식별자로 허용되지 않고 비동기가 아닌 함수 내에서 식별자로 허용된다.

그러나 명세는 다음과 같이 정의하지 않고, 대신 다음과 같은 프로덕션을 찾는다.

BindingIdentifier[Yield, Await] :
  Identifier
  yield
  await

확장하면 다음과 같은 프로덕션을 의미한다.

BindingIdentifier_Await :
  Identifier
  yield
  await

BindingIdentifier :
  Identifier
  yield
  await

(예제에서는 필요하지 않은 BindingIdentifier_YieldBindingIdentifier_Yield_Await에 대한 프로덕션은 생략한다.)

awaityield가 항상 식별자로 허용되는 것처럼 보인다. 왜 그럴까? 이 글 전체가 의미없는 것일까?

구조(rescue)에 대한 정적 의미론

비동기 함수 내에서 식별자로 await를 금지하려면 정적 의미론이 필요하다는 것을 알게 되었다.

정적 의미론은 정적 규칙, 즉 프로그램이 실행되기 전에 확인되는 규칙을 정의한다.

이 경우 BindingIdentifier에 대한 정적 의미론은 다음과 같은 구문 지향 규칙을 정의한다.

BindingIdentifier[Yield, Await] : await

프로덕션에 [Await] 매개변수가 있으면 구문 오류이다.

사실상 BindingIdentifier_Await : await 프로덕션을 금지한다.

명세는 이러한 프로덕션이 있지만 정적 의미론에 의해 구문 오류로 정의되는 이유는 자동 세미콜론 삽입(ASI) 간섭 때문이라고 설명한다.

ASI는 문법 생성에 따라 코드 라인을 구문 분석할 수 없을 때 시작된다는 것을 기억하자. ASI는 문과 선언이 세미콜론으로 끝나야 한다는 요구 사항을 충족하기 위해 세미콜론을 추가하려고 한다.

(ASI에 대해서는 다음 글에서 자세히 설명한다.)

다음 코드를 자세히 보자 (명세의 예)

async function too_few_semicolons() {
  let
  await 0;
}

문법이 식별자로 await를 허용하지 않는다면, ASI가 동작하여 코드를 다음과 같이 문법적으로 let을 식별자로 사용하는 올바른 코드로 변환한다.

async function too_few_semicolons() {
  let;
  await 0;
}

ASI에 대한 이러한 종류의 간섭은 너무 혼란스럽다고 여겨져서 식별자로 await를 허용하지 않기 위해 정적 의미론이 사용되었다.

허용되지 않는 식별자 StringValues

또 다른 관련 규칙이 있다.

BindingIdentifier : Identifier

프로덕션에 [Await] 매개변수가 있고 IdentifierStringValue"await"인 경우 구문 오류이다.

처음에는 혼란스러울 수 있다. 식별자는 다음과 같이 정의된다.

Identifier :
  IdentifierName but not ReservedWord

awaitReservedWord인데 어떻게 식별자await가 될 수 있을까?

결과적으로 식별자는 await가 될 수 없지만 StringValue"await"인 다른 것일 수 있다. 즉, 문자 시퀀스의 다른 표현이 await이다.

식별자 이름에 대한 정적 의미론은 식별자 이름의 StringValue가 계산되는 방법을 정의한다.

예를 들어 a에 대한 Unicode escape sequence는 \u0061이므로 \u0061waitStringValue "await" 를 뜻한다. \u0061wait는 어휘 문법에 의해 키워드로 인식되지 않고 대신 식별자가 된다.

위 내용이 비동기 함수 내에서 변수 이름으로 사용을 금지하기 위한 정적 의미론이다.

그래서 다음은 정상 동작한다.

function old() {
  var \u0061wait;
}

그리고 다음은 동작하지 않는다.

async function modern() {
  var \u0061wait; // Syntax error
}

요약

이 글에서 우리는 어휘 문법, 구문 문법, 구문 문법을 정의하는 데 사용되는 단축표기법에 익숙해졌다. 예를 들어 await를 비동기 함수 내에서 식별자로 사용하는 것을 금지하지만 비동기가 아닌 함수 내에서는 허용하는 방법을 살펴보았다.

자동 세미콜론 삽입과 커버 문법과 같은 구문 문법의 다른 흥미로운 부분은 다음 글에서 다룰 것이다. 계속 지켜봐 주세요!

김정용2022.11.16
Back to list