ECMAScript 사양서 읽는 방법

실시간 문서,

현재 버전:
https://timothygu.me/es-howto/
이슈 추적:
GitHub
명세 내 인라인
저자:
Timothy Gu

요약

ECMAScript 언어 명세서(즉, JavaScript 명세서 또는 ECMA-262)는 JavaScript가 어떻게 작동하는지에 대한 복잡한 내용을 배우기 위한 훌륭한 자료입니다. 하지만 이 명세서는 매우 방대한 텍스트로, 처음 접할 때 혼란스럽고 부담스러울 수 있습니다. 이 문서는 최고의 JavaScript 언어 참조서를 읽기 시작하는 데 도움이 되도록 작성되었습니다.

1. 서문

매일 ECMAScript 명세서를 조금씩 읽는 것이 건강에 좋다고 결심하셨군요. 아마 새해 결심이었거나, 아니면 의사의 처방이었을 수도 있습니다. 이유가 무엇이든, 환영합니다!

참고: 이 문서에서는 "ECMAScript"는 명세서 자체를 지칭할 때만 사용하고, 그 외에는 "JavaScript"라는 용어를 사용합니다. 하지만 두 용어 모두 같은 대상을 의미합니다. (ECMAScript와 JavaScript 사이에는 역사적 차이가 있지만, 그 이야기는 이 문서의 범위를 벗어나며 구글에서 쉽게 검색할 수 있습니다.)

1.1. 왜 ECMAScript 명세서를 읽어야 할까요?

ECMAScript 명세서는 모든 JavaScript 구현의 동작을 명확하게 정의하는 권위 있는 자료입니다. 브라우저 [WHATISMYBROWSER]에서든, Node.js [NODEJS]를 통한 서버에서든, IoT 기기 [JOHNNY-FIVE]에서든 모두 해당됩니다. 모든 JavaScript 엔진 개발자들은 자신들의 새로운 기능이 의도대로 동작하는지, 다른 엔진과 동일하게 동작하는지를 확인하기 위해 명세서를 참고합니다.

하지만 명세서의 유용성은 "JavaScript 엔진 개발자"라는 신화적인 존재들만을 위한 것이 아닙니다. 사실 평범한 JavaScript 개발자인 여러분에게도 유용하며, 아직 그 사실을 깨닫지 못했을 뿐입니다.

어느 날 직장에서 다음과 같은 이상한 상황을 발견했다고 가정해 봅시다.

> Array.prototype.push(42)
1
> Array.prototype
[ 42 ]
> Array.isArray(Array.prototype)
true
> Set.prototype.add(42)
TypeError: Method Set.prototype.add called on incompatible receiver #<Set>
    at Set.add (<anonymous>)
> Set.prototype
Set {}

그리고 왜 어떤 메서드는 prototype에서 동작하고, 다른 메서드는 자신의 prototype에서 동작하지 않는지 혼란스러울 수 있습니다. 구글은 이런 때 항상 실패합니다, 그리고 늘 도움이 되는 Stack Overflow도 마찬가지입니다.

명세서를 읽으면 도움이 됩니다.

또는 악명 높은 느슨한 동등 연산자 (==)가 실제로 어떻게 동작하는지 궁금할 수도 있습니다. (여기서 "동작"이라는 단어를 느슨하게 사용합니다 [WAT].) 공부하는 개발자라면 MDN에서 찾아보겠지만, 설명 문단이 오히려 더 혼란스럽게 만들 수 있습니다 [MDN].

명세서를 읽으면 도움이 됩니다.

반면, JavaScript를 처음 접하는 개발자에게는 ECMAScript 명세서 읽기를 권장하지 않습니다. 만약 JavaScript가 처음이라면 웹에서 여러 가지를 시도해 보세요! 웹앱을 만들어보고, JavaScript 기반의 기기 제어도 해보고, 충분히 JavaScript의 특징을 경험하거나 JavaScript 걱정 없이 살 수 있을 만큼 부자가 되면 이 문서로 다시 돌아오세요.

이제 명세서가 언어나 플랫폼의 복잡한 부분을 이해하는 데 매우 도움이 되는 도구임을 알게 되셨습니다. 그렇다면 ECMAScript 명세서의 범위에는 정확히 무엇이 포함될까요?

1.2. ECMAScript 명세서에 포함되는 것과 포함되지 않는 것

이 질문에 대한 교과서적인 답은 "언어 기능만이 ECMAScript 명세서에 포함된다"입니다. 하지만 이는 "JavaScript 기능은 JavaScript다"라는 말과 비슷해서 별 도움이 되지 않습니다. 저는 그런 동어반복을 좋아하지 않습니다 [XKCD-703].

대신, JavaScript 앱에서 흔히 볼 수 있는 것들을 몇 가지 나열하고, 각각이 언어 기능인지 아닌지 알려드리겠습니다.

구문 요소의 문법(예: for..in 반복문이 어떻게 생겼는지)
구문 요소의 의미(예: typeof null 또는 { a: b }의 반환값)
import a from 'a'; [1]
Object, Array, Function, Number, Math, RegExp, Proxy, Map, Promise, ArrayBuffer, Uint8Array, globalThis, ...
console, setTimeout(), setInterval(), clearTimeout(), clearInterval() [2]
Buffer, process, global* [3]
module, exports, require(), __dirname, __filename [4]
window, alert(), confirm(), the DOM (document, HTMLElement, addEventListener(), Worker, ...) [5]
[1] ECMAScript 명세서는 이러한 선언의 문법과 의미를 정의하지만, 모듈이 어떻게 로드되는지는 정의하지 않습니다.

[2] 이러한 것들은 브라우저와 Node.js 모두에서 사용할 수 있지만, 표준은 아닙니다. Node.js의 경우 공식 문서에 정의되어 있습니다. 브라우저에서는 console 이 Console Standard [CONSOLE]에서, 나머지는 HTML Standard [HTML]에서 정의되어 있습니다.

[3] 모두 Node.js 전용 글로벌 객체로, 공식 문서에 정의되어 있습니다. * 참고로 global과 달리 globalThis 는 ECMAScript의 일부이며 브라우저에도 구현되어 있습니다.

[4] 모두 Node.js 전용 모듈 범위의 "글로벌" 객체로, 공식 문서에 정의되어 있습니다.

[5] 모두 브라우저 전용 객체입니다.

1.3. 더 나아가기 전에, ECMAScript 명세서는 어디에 있나요?

"ECMAScript specification"을 구글에서 검색하면 정말 많은 결과가 나오며, 모두가 진짜 명세라고 주장합니다. 어느 것을 읽어야 할까요?

요약: 대부분의 경우 tc39.es/ecma262/에 게시된 명세서를 보면 됩니다 [ECMA-262].

자세한 설명:

ECMAScript 언어 명세서는 다양한 배경을 가진 사람들이 모인 Ecma International Technical Committee 39(일명 TC39 [TC39])에서 개발합니다. TC39는 ECMAScript 언어의 최신 명세를 tc39.es [ECMA-262]에 유지합니다.

문제가 되는 부분은, TC39가 매년 한 시점을 정해 그 시점의 명세를 ECMAScript 언어 표준으로 지정하고, 버전 번호를 붙인다는 점입니다. 예를 들어, ECMAScript® 2019 Language Specification (ECMA-262, 10) [ECMA-262-2019] (보통 ES10 또는 ES2019로 알려짐)은 2019년 6월 tc39.es [ECMA-262]에서 볼 수 있는 명세를 그대로 보존, 압축, PDF로 만들어 영구 보관한 것입니다.

따라서, 웹 애플리케이션이 2019년 6월 당시에만 동작하는 브라우저(보존되고, 압축되고, PDF로 보관된)에서만 실행되기를 원하지 않는다면, 항상 tc39.es [ECMA-262]에 최신 명세서를 참고해야 합니다. 하지만 오래된 브라우저나 Node.js 버전을 지원해야 한다면, 구버전 명세서도 참고하면 도움이 됩니다.

참고: ISO/IEC도 ECMAScript 언어 표준을 ISO/IEC 22275 [ISO-22275-2018]로 다시 발행합니다. 하지만 걱정할 필요는 없습니다. 이 표준은 사실상 [ECMA-262]로 연결되는 하이퍼링크일 뿐입니다.

ECMAScript 명세서는 엄청나게 많은 내용을 다룹니다. 저자들이 논리적으로 분리하려고 노력했지만, 여전히 방대한 텍스트입니다.

개인적으로 명세서를 다섯 부분으로 나눠서 이해합니다:

하지만 명세서는 이렇게 구성되어 있지 않습니다. 첫 번째 항목은 §5 표기 규칙부터 §9 일반 및 특수 객체 동작까지, 다음 세 항목은 §10 ECMAScript 언어: 소스 코드부터 §15 ECMAScript 언어: 스크립트와 모듈까지 엇갈려서 배치되어 있습니다. 예를 들면,

  • §13.6 if 문법 생성 규칙

    • §13.6.1-6 정적 의미론

    • §13.6.7 런타임 의미론

  • §13.7 반복문 문법 생성 규칙

    • §13.7.1 공통 정적런타임 의미론

    • §13.7.2 do-while

      • §13.7.2.1-5 정적 의미론

      • §13.7.2.6 런타임 의미론

    • §13.7.3 while

      • ...

API는 §18 글로벌 객체부터 §26 Reflection까지에 걸쳐 분산되어 있습니다.

이쯤에서 아무도 명세서를 처음부터 끝까지 읽지 않는다는 점을 강조하고 싶습니다. 대신, 찾아보고 싶은 주제에 해당하는 섹션만 찾아보고, 그 안에서도 필요한 부분만 읽으세요. 자신의 질문이 위 다섯 가지 중 무엇과 관련 있는지 먼저 판단해보고, 판단하기 어렵다면 "이것(확인하려는 것)은 언제 평가되는가?"라는 질문을 해보면 도움이 될 수 있습니다. 걱정 마세요, 명세서 탐색은 연습할수록 쉬워집니다.

2. 런타임 의미론

언어와 API의 런타임 의미론은 명세서에서 가장 큰 부분이며, 보통 사람들이 가장 궁금해하는 부분입니다.

전체적으로 명세서의 이 부분을 읽는 것은 꽤 직관적입니다. 하지만 명세서에서는 처음 접하는 사람에게는 약간 까다로운 여러 약어를 사용합니다(적어도 저에게는 그랬습니다). 몇 가지 관례를 설명하고, 실제 워크플로에 적용해 보겠습니다.

2.1. 알고리즘 단계

ECMAScript의 대부분 런타임 의미론은 일련의 알고리즘 단계로 명시되어 있으며, 이는 의사코드와 비슷하지만 훨씬 더 정확한 형식을 따릅니다.

알고리즘 단계 예시:

  1. a1을 할당한다.

  2. ba+a의 값을 할당한다.

  3. b2이면,

    1. 축하합니다! 산술이 정상입니다.

  4. 그렇지 않으면

    1. 으악!

추가 읽을거리: §5.2 알고리즘 관례

2.2. 추상 연산

명세서에서 함수처럼 보이는 것이 호출되는 것을 볼 때가 있습니다. Boolean() 함수의 첫 번째 단계는 다음과 같습니다:

Booleanvalue 인수로 호출될 때, 다음 단계가 수행됩니다:

  1. b에 ! ToBoolean(value)의 결과를 할당한다.

  2. ...

여기서 "ToBoolean" 함수는 추상 연산이라고 부릅니다. 추상적이라는 것은 실제로 JavaScript 코드에서 함수로 노출되지 않는다는 뜻입니다. 명세서 작성자들이 반복적인 설명을 줄이기 위해 고안한 표기 방식입니다.

참고: 당분간 ToBoolean 앞의 !는 신경 쓰지 마세요. § 2.4 완료 레코드; ?와 !에서 자세히 설명합니다.

추가 읽을거리: §5.2.1 추상 연산

2.3. [[This]]란?

가끔 [[표기]]가 "Let proto be obj.[[Prototype]]."처럼 사용되는 것을 볼 수 있습니다. 이 표기는 등장하는 맥락에 따라 여러 가지 의미를 가질 수 있지만, JavaScript 코드에서는 관찰할 수 없는 내부 속성을 나타낸다는 점을 이해하면 많은 도움이 됩니다.

정확히는, 이 표기는 세 가지 의미로 사용될 수 있습니다. 명세서에서 예시로 설명하겠습니다. 지금은 건너뛰셔도 괜찮습니다.

2.3.1. Record의 필드

ECMAScript 명세서에서는 Record라는 용어를 고정된 키 집합을 가진 키-값 맵(구조체와 비슷)으로 사용합니다. Record의 각 키-값 쌍은 필드라고 부릅니다. Record는 명세서에서만 등장하며 실제 JavaScript 코드에서는 사용할 수 없으므로, [[표기]]필드를 참조하는 것이 합리적입니다.

특히 Property DescriptorRecord로 모델링되며, 필드 [[Value]], [[Writable]], [[Get]], [[Set]], [[Enumerable]], [[Configurable]]를 가집니다. IsDataDescriptor 추상 연산에서는 이 표기를 많이 사용합니다:

추상 연산 IsDataDescriptor가 Property Descriptor Desc로 호출될 때, 다음 단계가 수행됩니다:

  1. Descundefined이면, false를 반환한다.

  2. Desc.[[Value]]와 Desc.[[Writable]]가 모두 없으면 false를 반환한다.

  3. true를 반환한다.

Record의 또 다른 구체적 예시는 다음 섹션 § 2.4 완료 레코드; ?와 !에서 볼 수 있습니다.

추가 읽을거리: §6.2.1 List와 Record 명세 타입

2.3.2. JavaScript 객체의 내부 슬롯

JavaScript 객체는 명세서에서 데이터를 저장하기 위해 내부 슬롯을 가질 수 있습니다. Record 필드처럼, 이러한 내부 슬롯도 JavaScript 코드로는 관찰할 수 없지만, 일부는 Chrome DevTools 같은 구현별 도구에서 노출될 수 있습니다. 따라서 [[표기]]를 사용해 내부 슬롯을 설명하는 것이 합리적입니다.

내부 슬롯의 구체적인 내용은 § 2.5 JavaScript 객체에서 다룹니다. 지금은 용도에 대해 너무 신경 쓰지 마세요. 아래 예시만 참고하세요.

대부분의 JavaScript 객체는 자신이 상속받는 객체를 참조하는 [[Prototype]] 내부 슬롯을 가집니다. 이 내부 슬롯의 값은 보통 Object.getPrototypeOf() 가 반환하는 값과 같습니다. OrdinaryGetPrototypeOf 추상 연산에서는 이 내부 슬롯의 값을 참조합니다:

추상 연산 OrdinaryGetPrototypeOf가 객체 O로 호출될 때, 다음 단계가 수행됩니다:

  1. O.[[Prototype]]을 반환한다.

참고: 객체의 내부 슬롯Record 필드는 겉보기에는 동일하지만, 점 앞에 등장하는 부분(객체인지 Record인지)에 따라 구분할 수 있습니다. 주변 맥락에서 보통 쉽게 알 수 있습니다.

2.3.3. JavaScript 객체의 내부 메서드

JavaScript 객체는 내부 메서드를 가질 수도 있습니다. 내부 슬롯과 마찬가지로, 이러한 내부 메서드는 JavaScript 코드로 직접 관찰할 수 없습니다. 따라서 [[표기]]를 사용해 내부 메서드를 설명하는 것이 합리적입니다.

내부 메서드의 구체적인 내용은 § 2.5 JavaScript 객체에서 다룹니다. 지금은 용도에 대해 너무 신경 쓰지 마시고, 다음 예시를 참고하세요.

모든 JavaScript 함수는 해당 함수를 실행하는 [[Call]] 내부 메서드를 가집니다. Call 추상 연산에는 다음과 같은 단계가 있습니다:

  1. Return ? F.[[Call]](V, argumentsList).

여기서 F는 JavaScript 함수 객체입니다. 이 경우 F의 [[Call]] 내부 메서드가 VargumentsList 인수로 호출됩니다.

참고: [[표기]]의 세 번째 의미는 함수 호출처럼 보인다는 점으로 구분할 수 있습니다.

2.4. 완료 레코드; ?!

ECMAScript 명세의 모든 런타임 의미론은 명시적으로 또는 암시적으로 결과를 보고하는 완료 레코드를 반환합니다. 이 완료 레코드는 세 가지 필드를 가질 수 있는 Record입니다:

참고: 두 개의 대괄호는 Record필드를 나타냅니다. § 2.3.1 Record의 필드에서 관련 표기법을 참고하세요.

Completion Record[[Type]]normal일 경우 이를 정상 완료라고 부릅니다. 정상 완료가 아닌 모든 Completion Record비정상 완료라고도 합니다.

대부분의 경우 비정상 완료[[Type]]throw인 것만 다루게 됩니다. 나머지 세 종류의 비정상 완료는 특정 구문 요소의 평가 과정에서만 필요하며, 내장 함수 정의에서는 등장하지 않습니다. break/continue/return은 함수 경계를 넘지 않기 때문입니다.

추가 읽을거리: §6.2.3 완료 레코드 명세 타입


완료 레코드의 정의 때문에, JavaScript의 에러 버블링(예: try-catch 블록까지 에러를 전파하는 것) 같은 동작은 명세서에는 존재하지 않습니다. 실제로 에러(좀 더 정확히 말하면 비정상 완료)는 명시적으로 처리됩니다.

약어 없이, 계산 결과 또는 에러를 반환할 수 있는 추상 연산의 일반적인 호출 명세는 다음과 같이 작성됩니다:

약어 없이 추상 연산을 호출하는 단계 예시:

  1. resultCompletionRecord에 AbstractOp()의 결과를 할당한다.

    참고: resultCompletionRecord완료 레코드입니다.

  2. resultCompletionRecord가 비정상 완료라면, resultCompletionRecord를 반환한다.

    참고: 여기서 resultCompletionRecord비정상 완료라면 바로 반환합니다. 즉, AbstractOp에서 발생한 에러가 전달되고, 이후 단계는 중단됩니다.

  3. resultresultCompletionRecord.[[Value]]를 할당한다.

    참고: 정상 완료임을 확인한 뒤, 완료 레코드에서 실제 계산 결과를 꺼낼 수 있습니다.

  4. result는 우리가 원하는 결과입니다. 이후 필요한 작업을 수행할 수 있습니다.

이 방식은 C 언어의 수동 에러 처리와 비슷하게 느껴질 수 있습니다:

int result = abstractOp();              // Step 1
if (result < 0)                         // Step 2
  return result;                        // Step 2 (continued)
                                        // Step 3 is unneeded
// func() succeeded; carrying on...     // Step 4

이렇게 반복되는 작업을 줄이기 위해, ECMAScript 명세서 편집자들은 몇 가지 약어를 추가했습니다. ES2016부터는 같은 명세를 다음 두 가지 동등한 방식 중 하나로 더 간단하게 표현할 수 있습니다:

ReturnIfAbrupt 약어 사용시 추상 연산을 호출하는 단계 예시:

  1. result에 AbstractOp()의 결과를 할당한다.

    참고: 위 예시 1단계와 마찬가지로 result완료 레코드입니다.

  2. ReturnIfAbrupt(result).

    참고: ReturnIfAbrupt는 비정상 완료를 처리하여 전달하고, result를 [[Value]]로 자동 해제합니다.

  3. result는 우리가 원하는 결과입니다. 이후 필요한 작업을 수행할 수 있습니다.

또는, 특별한 물음표(?) 표기로 더 간단하게 표현할 수 있습니다:

물음표(?) 약어 사용시 추상 연산을 호출하는 단계 예시:

  1. result에 ? AbstractOp()의 결과를 할당한다.

    참고: 이 표기법에서는 완료 레코드를 직접 다루지 않습니다. ? 약어가 모든 것을 처리하며, result는 바로 사용할 수 있습니다.

  2. result는 우리가 원하는 결과입니다. 이후 필요한 작업을 수행할 수 있습니다.


특정 AbstractOp 호출이 절대 비정상 완료를 반환하지 않는다는 사실이 명세의 의도를 더 잘 전달하는 경우가 있습니다. 이런 경우에는 느낌표(!)를 사용합니다:

느낌표(!) 약어 사용시 절대 예외가 발생하지 않는 추상 연산을 호출하는 단계 예시:

  1. result에 ! AbstractOp()의 결과를 할당한다.

    참고: ?는 발생 가능한 에러를 전달하지만, !는 이 호출에서 비정상 완료가 절대 발생하지 않는다고 단정합니다. 만약 발생한다면 명세의 버그입니다. ?처럼 완료 레코드를 직접 다루지 않으며, result는 바로 사용할 수 있습니다.

  2. result는 우리가 원하는 결과입니다. 이후 필요한 작업을 수행할 수 있습니다.

주의

!는 유효한 JavaScript 표현식처럼 보이면 혼란스러울 수 있습니다:

  1. b에 ! ToBoolean(value)의 결과를 할당한다.

Boolean()에서 발췌.

여기서 !ToBoolean 호출에서 예외가 발생하지 않는다는 의미일 뿐, 결과를 반전한다는 뜻은 아닙니다!

추가 읽을거리: §5.2.3.4 ReturnIfAbrupt 약어.

2.5. JavaScript 객체

ECMAScript에서 모든 객체는 명세의 다른 부분에서 특정 작업을 수행하기 위해 호출되는 내부 메서드 집합을 가집니다. 모든 객체가 가지는 내부 메서드 중 일부는 다음과 같습니다:

(전체 목록은 §6.1.7.2 객체 내부 메서드와 내부 슬롯에서 볼 수 있습니다.)

이 정의에 따라 함수 객체(혹은 "함수")는 [[Call]] 내부 메서드와 경우에 따라 [[Construct]] 내부 메서드를 추가로 가지는 객체입니다. 이런 이유로 호출 가능한 객체라고도 합니다.

명세는 모든 객체를 일반 객체특수 객체로 나눕니다. 대부분의 객체는 일반 객체로, 내부 메서드§9.1 일반 객체 내부 메서드와 내부 슬롯에서 정의된 기본 구현 그대로입니다.

반면, ECMAScript 명세는 특수 객체도 정의하며, 이들은 기본 구현을 재정의할 수 있습니다. 특수 객체가 할 수 있는 일에는 최소한의 제약만 있으며, 재정의된 내부 메서드는 명세를 위반하지 않는 선에서 다양한 동작을 할 수 있습니다.

Array 객체는 특수 객체의 한 종류입니다. length 프로퍼티와 관련된 특별한 의미론은 일반 객체로는 구현할 수 없습니다.

예를 들어 Array 객체의 length 프로퍼티를 설정하면 객체의 프로퍼티가 삭제될 수 있지만, length는 일반 data 프로퍼티처럼 보입니다. 반면 new Map().sizeMap.prototype에 지정된 getter 함수일 뿐이며, [].length처럼 특별하지 않습니다.

> const arr = [0, 1, 2, 3];
> console.log(arr);
[ 0, 1, 2, 3 ]
> arr.length = 1;
> console.log(arr);
[ 0 ]
> console.log(Object.getOwnPropertyDescriptor([], "length"));
{ value: 1,
  writable: true,
  enumerable: false,
  configurable: false }
> console.log(Object.getOwnPropertyDescriptor(new Map(), "size"));
undefined
> console.log(Object.getOwnPropertyDescriptor(Map.prototype, "size"));
{ get: [Function: get size],
  set: undefined,
  enumerable: false,
  configurable: true }

이 동작은 [[DefineOwnProperty]] 내부 메서드를 재정의하여 구현되었습니다. 자세한 내용은 §9.4.2 Array 특수 객체를 참고하세요.

ECMAScript 명세는 다른 명세가 자신만의 특수 객체를 정의하도록 허용합니다. 이 메커니즘을 통해 브라우저의 크로스 오리진 API 접근 제한 같은 동작이 WindowProxy [HTML]에서 정의됩니다. 또한 JavaScript 개발자는 Proxy API를 통해 자신만의 특수 객체를 만들 수도 있습니다.


JavaScript 객체는 특정 유형의 값을 저장하기 위해 내부 슬롯을 정의할 수도 있습니다. 내부 슬롯은 Object.getOwnPropertySymbols()로도 접근할 수 없는 Symbol 이름의 숨겨진 프로퍼티처럼 생각할 수 있습니다. 일반 객체특수 객체 모두 내부 슬롯을 가질 수 있습니다.

§ 2.3.2 JavaScript 객체의 내부 슬롯에서 대부분의 객체가 가지는 [[Prototype]] 내부 슬롯을 언급했습니다. (실제로 일반 객체특수 객체 중 일부, 예: Array 객체는 이 슬롯을 가집니다.) 그런데 모든 객체에는 [[GetPrototypeOf]] 내부 메서드도 있습니다. 둘의 차이는 무엇일까요?

핵심은 대부분의 객체가 [[Prototype]] 내부 슬롯을 가지지만, 모든 객체는 [[GetPrototypeOf]] 내부 메서드를 구현한다는 점입니다. 예를 들어 Proxy 객체는 [[Prototype]] 슬롯이 없고, [[GetPrototypeOf]] 내부 메서드는 등록된 handler 또는 Proxy 객체의 [[ProxyTarget]] 내부 슬롯의 프로토타입을 참조합니다.

따라서 객체를 다룰 때는 내부 슬롯의 값을 직접 보는 것보다 적절한 내부 메서드를 참조하는 것이 거의 항상 좋습니다.


객체, 내부 메서드, 내부 슬롯의 관계를 객체지향적 관점에서 생각할 수도 있습니다. "객체"는 여러 내부 메서드를 반드시 구현해야 하는 인터페이스와 같고, 일반 객체는 기본 구현을 제공하며 특수 객체는 부분 또는 전체를 재정의할 수 있습니다. 한편 내부 슬롯은 객체의 인스턴스 변수처럼, 구현 세부정보에 해당합니다.

이 모든 관계는 아래 UML 다이어그램에 요약되어 있습니다(클릭하여 확대):

개념을 나타내는 박스와 계층 구조를 나타내는 연결선

2.6. 예시: String.prototype.substring()

이제 명세서의 구조와 작성 방식을 충분히 이해했으니, 연습해봅시다!

지금 다음과 같은 질문이 있다고 가정해봅시다:

코드를 실행하지 않고, 다음 코드 조각은 무엇을 반환할까요?

String.prototype.substring.call(undefined, 2, 4)

꽤 까다로운 질문입니다. 그럴듯한 결과가 두 가지 있을 것 같습니다:

  1. String.prototype.substring() 가 먼저 undefined를 문자열 "undefined"로 변환한 뒤, 그 문자열의 2번과 3번 위치의 문자(즉, [2, 4) 구간)를 추출해서 "de"를 반환할 수 있다.

  2. 반면에 String.prototype.substring()에러를 던질 수도 있으며, undefined를 입력으로 받아들이지 않을 수도 있다.

안타깝게도 MDN 에서는 this 값이 문자열이 아닐 때 함수가 어떻게 동작하는지에 대해 설명해주지 않습니다.

명세서가 도와준다! 명세서 [ECMA-262]의 왼쪽 상단 검색창에 substring을 입력해보면 §21.1.3.22 String.prototype.substring ( start, end )에 도달할 수 있는데, 이 부분이 함수의 동작을 규정하는 명세서입니다.

알고리즘 단계를 읽기 전에, 먼저 우리가 알고 있는 것을 생각해봅시다. str.substring()가 일반적으로는 주어진 문자열의 일부를 반환한다는 기본적인 동작은 알지만, this 값이 undefined일 때 어떻게 동작하는지는 확신할 수 없습니다. 따라서 this 값에 관한 알고리즘 단계를 찾아봐야 합니다.

운 좋게도, String.prototype.substring() 알고리즘의 첫 번째 단계가 바로 this 값에 관한 처리입니다:

  1. O에 ? RequireObjectCoercible(this value)를 할당한다.

? 약어를 통해 RequireObjectCoercible 추상 연산이 예외를 던질 수 있는 상황이 있을 수 있음을 알 수 있습니다. 만약 예외가 발생하면 위의 두 번째 가설과 일치하겠죠! 이제 RequireObjectCoercible가 실제로 무엇을 하는지 하이퍼링크를 따라가서 확인해봅니다.

RequireObjectCoercible 추상 연산은 조금 독특합니다. 대부분의 추상 연산과 달리 단계가 아니라 표로 정의되어 있습니다:

Argument Type Result
Undefined TypeError 예외를 던진다.
... ...

어쨌든 — Undefined(우리가 this 값으로 전달한 값)의 행을 보면, RequireObjectCoercible가 예외를 던지도록 명세되어 있습니다. 그리고 함수 정의에 ? 표기가 사용되었기 때문에, 던진 예외가 함수 호출자에게 그대로 전달됨을 알 수 있습니다. 정답!

결론: 주어진 코드 조각은 TypeError 예외를 던집니다.

명세서는 에러의 타입만 명시하고, 메시지는 명시하지 않습니다. 즉, 구현마다 에러 메시지가 다를 수 있으며, 심지어 지역화된 메시지가 나올 수도 있습니다.

예를 들어 Google의 V8 6.4(Chrome 64 포함)에서는 메시지가 다음과 같습니다:

TypeError: String.prototype.substring called on null or undefined

Mozilla Firefox 57.0에서는 다소 덜 친절하게 다음과 같이 나옵니다:

TypeError: can’t convert undefined to object

Microsoft Edge의 ChakraCore 1.7.5.0에서는 V8과 유사하게 다음과 같이 던집니다:

TypeError: String.prototype.substring: 'this' is null or undefined

2.7. 예시: Boolean()String()가 예외를 던질 수 있을까?

중요한 코드를 작성할 때는 예외 처리가 매우 중요합니다. 그래서 "내장 함수가 예외를 던질 수 있을까?"라는 질문은 자주 고민하게 됩니다.

이 예시에서는 두 가지 언어 내장 함수인 Boolean()String()에 대해 이 질문에 답해봅니다. new Boolean()new String()처럼 박싱 객체를 만드는 경우는 제외하며, 이런 형태는 JavaScript에서 권장되지 않는 특징입니다 [YDKJS].

명세서에서 Boolean() 부분을 찾아가 보면 알고리즘이 꽤 짧다는 것을 알 수 있습니다:

Booleanvalue 인수로 호출될 때, 다음 단계가 수행됩니다:

  1. b에 ! ToBoolean(value)의 결과를 할당한다.

  2. NewTarget이 undefined이면, b를 반환한다.

  3. O에 ? OrdinaryCreateFromConstructor(NewTarget, "%BooleanPrototype%", « [[BooleanData]] »)를 할당한다.

  4. O.[[BooleanData]]에 b를 저장한다.

  5. O를 반환한다.

하지만 OrdinaryCreateFromConstructor 등에 약간 복잡한 처리가 있긴 합니다. 더 중요한 것은 3단계에 ? 약어가 있는데, 이것이 예외 발생 가능성을 의미할 수 있다는 점입니다. 좀 더 자세히 살펴봅시다.

1단계는 value를 Boolean 값으로 변환합니다. 흥미롭게도 이 단계에는 ?! 약어가 없는데, 일반적으로 Completion Record 약어가 없다는 것은 !와 동일한 의미입니다. 즉, 1단계에서는 예외가 발생하지 않습니다.

2단계는 NewTargetundefined인지 확인합니다. NewTarget은 ES2015에서 추가된 new.target 메타 프로퍼티에 해당하며, new Boolean() 호출(값: Boolean)과 Boolean() 호출(값: undefined)을 구분하기 위해 사용됩니다. 여기서는 직접 Boolean()만 살펴보므로, NewTarget은 항상 undefined입니다. 따라서 알고리즘은 언제나 b를 바로 반환합니다.

즉, Boolean()new 없이 호출하면 알고리즘의 처음 두 단계만 실행되고, 어느 쪽도 예외를 던질 수 없으므로 Boolean()는 어떤 입력에서도 예외를 던지지 않습니다.


이제 String()를 살펴봅시다:

Stringvalue 인수로 호출될 때, 다음 단계가 수행됩니다:

  1. 이 함수 호출에 인수가 없으면, s""를 할당한다.

  2. 그렇지 않은 경우,

    1. NewTarget이 undefined이고 Type(value)가 Symbol이면 SymbolDescriptiveString(value)를 반환한다.

    2. s에 ? ToString(value)의 결과를 할당한다.

  3. NewTarget이 undefined이면 s를 반환한다.

  4. ? StringCreate(s, ? GetPrototypeFromConstructor(NewTarget, "%StringPrototype%"))를 반환한다.

Boolean() 분석에서 배운 경험에 따르면, NewTarget은 여기서도 항상 undefined이므로 마지막 단계는 고려할 필요가 없습니다. TypeSymbolDescriptiveString는 안전하며, 비정상 완료 처리가 필요하지 않습니다. 하지만 ? 표기가 ToString 추상 연산 호출 앞에 붙어 있습니다. 좀 더 살펴봅시다.

앞서 살펴본 RequireObjectCoercible처럼, ToString(argument)도 표로 정의되어 있습니다:

Argument Type Result
Undefined "undefined"를 반환한다.
Null "null"를 반환한다.
Boolean

argumenttrue면, "true"를 반환한다.

argumentfalse면, "false"를 반환한다.

Number NumberToString(argument)를 반환한다.
String argument를 반환한다.
Symbol TypeError 예외를 던진다.
Object

다음 단계를 적용한다:

  1. primValue에 ? ToPrimitive(argument, hint String)를 할당한다.

  2. ? ToString(primValue)를 반환한다.

String()에서 ToString을 호출할 때 value는 심볼을 제외한 아무 값이나 될 수 있습니다(직전 단계에서 심볼은 걸러짐). 다만, Object 행에는 두 번의 ? 표기가 남아 있습니다. ToPrimitive 이하를 따라가 보면, value가 Object일 때 실제로 많은 에러 발생 지점이 있음을 알 수 있습니다:

String() 함수가 에러를 던지는 여러 예시
// 명세서 스택 추적:
//   OrdinaryGet 8단계.
//   일반 객체의 [[Get]]() 1단계.
//   GetV 3단계.
//   GetMethod 2단계.
//   ToPrimitive 2.d단계.

String({
  get [Symbol.toPrimitive]() { 
    throw new Error("Breaking JavaScript");
  }
});
// 명세서 스택 추적:
//   GetMethod 4단계.
//   ToPrimitive 2.d단계.

String({
  get [Symbol.toPrimitive]() { 
    return "Breaking JavaScript";
  }
});
// 명세서 스택 추적:
//   ToPrimitive 2.e.i단계.

String({
  [Symbol.toPrimitive]() { 
    throw new Error("Breaking JavaScript");
  }
});
// 명세서 스택 추적:
//   ToPrimitive 2.e.iii단계.

String({
  [Symbol.toPrimitive]() { 
    return { "breaking": "JavaScript" };
  }
});
// 명세서 스택 추적:
//   OrdinaryToPrimitive 5.b.i단계.
//   ToPrimitive 2.g단계.

String({
  toString() { 
    throw new Error("Breaking JavaScript");
  }
});
// 명세서 스택 추적:
//   OrdinaryToPrimitive 5.b.i단계.
//   ToPrimitive 2.g단계.

String({
  valueOf() { 
    throw new Error("Breaking JavaScript");
  }
});
// 명세서 스택 추적:
//   OrdinaryToPrimitive 6단계.
//   ToPrimitive 2.g단계.

String(Object.create(null));

String()의 결론: 프리미티브 값에는 절대 예외를 던지지 않지만, 객체에는 예외가 발생할 수 있다.

2.8. 예시: typeof 연산자

지금까지는 API 함수만 분석했으니, 이번엔 다른 걸 시도해봅시다.

작성 예정. <https://github.com/TimothyGu/es-howto/issues/2>

용어집

공통 추상 연산

ArrayCreate ( length [ , proto ] ) (명세)

length 길이의 배열 객체를 생성하며, [[Prototype]] 내부 슬롯의 값으로 proto를 사용합니다. proto가 지정되지 않은 경우, %ArrayPrototype%현재 realm에서 사용됩니다. new Array(length)와 동등하지만, Array 생성자와 그 모든 속성이 수정되지 않았고 proto가 지정되지 않았거나 %ArrayPrototype%현재 realm일 경우에만 해당합니다.

Call ( F, V [ , argumentsList ] ) (명세)
Construct ( F [ , argumentsList [ , newTarget ] ] ) (명세)
Get ( O, P ) (명세)
HasProperty ( O, P ) (명세)

F 또는 O에 대해 나머지 인수를 전달하여 해당 내부 메서드를 호출합니다. Reflect 객체의 대응하는 메서드와 동등합니다.

DefinePropertyOrThrow ( O, P, desc ) (명세)
DeletePropertyOrThrow ( O, P ) (명세)

O에 대해 ([[DefineOwnProperty]], [[Delete]] 각각) 해당 내부 메서드를 나머지 인수와 함께 호출하고, 작업이 실패하여 내부 메서드false를 반환하면 예외를 던집니다.

GetV ( V, P ) (명세)

V를 필요한 경우 먼저 ToObject로 객체로 변환한 뒤 Get(V, P)를 반환합니다. V[P]와 동등합니다.

HasOwnProperty ( O, P ) (명세)

OP라는 자신의 프로퍼티를 가지는지 O.[[GetOwnProperty]](P)를 호출하여 확인합니다. Object.prototype.hasOwnProperty.call(O, P)와 동등합니다.

Invoke ( V, P [ , argumentsList ] ) (명세)

V에서 P라는 이름의 메서드를 argumentsList로 호출합니다. V[P](...argumentsList)와 동등합니다. 여기서 PCall과 달리 프로퍼티 키입니다.

IsArray ( argument ) (명세)

argumentArray 특수 객체인지, 또는 argumentProxy 특수 객체일 경우 innermost [[ProxyTarget]] 내부 슬롯Array 특수 객체인지 반환합니다. Array.isArray(argument)와 동등합니다.

IsCallable ( argument ) (명세)

argument호출 가능한 객체인지(즉, 함수 객체인지) 반환합니다. typeof argument === 'function' 과 유사하나, document.all (여러 특별 동작을 가진 특수 객체, §B.3.7 [[IsHTMLDDA]] 내부 슬롯 참고)은 예외입니다.

IsConstructor ( argument ) (명세)

argument가 [[Construct]] 내부 메서드를 가진 함수 객체인지 반환합니다.

ReturnIfAbrupt ( argument ) (명세)

argument비정상 완료(예: 예외)인지 확인하고, 그런 경우 해당 비정상 완료를 반환합니다(그리고 예외를 상위로 전달). argument정상 완료라면 완료 레코드를 해제하여 argumentargument.[[Value]]로 설정합니다.

추가 참고: § 2.4 완료 레코드; ?와 !.

StringCreate ( value, prototype ) (명세)

String value에 해당하는 박싱된 String 객체를 반환하며, 결과 객체의 [[Prototype]] 내부 슬롯은 prototype입니다. prototype%StringPrototype%이고 현재 realm이면 new String(value)와 동등합니다.

ToBoolean ( argument ) (명세)

argument를 Boolean 값으로 변환하여 반환합니다. !!argument와 동등합니다.

ToInteger ( argument ) (명세)

ToNumber(argument)를 반환한 뒤, 0으로 반올림하여 정수로 만듭니다. Math.trunc(argument)와 동등합니다.

ToInt8 ( argument ) (명세)
ToUint8 ( argument ) (명세)
ToInt16 ( argument ) (명세)
ToUint16 ( argument ) (명세)
ToInt32 ( argument ) (명세)
ToUint32 ( argument ) (명세)

argument를 잘라서 지정된 비트와 부호로 된 정수로 변환하여 반환합니다.

ToUint8Clamp ( argument ) (명세)

argument를 반올림 및 클램프하여 [0, 255] 범위의 정수로 변환합니다.

ToNumber ( argument ) (명세)

argument를 Number로 변환합니다. +argument와 동등합니다.

ToObject ( argument ) (명세)

argument를 객체로 변환하여 반환하며, 필요하면 박싱된 프리미티브 객체를 사용합니다. Object(argument)와 유사하지만, argumentundefined 또는 null일 때는 다릅니다.

ToPrimitive ( input [ , PreferredType ] ) (명세)

input을 프리미티브(즉, 객체가 아닌) 값으로 변환하여 반환하며, PreferredType 힌트를 선택적으로 사용할 수 있습니다. 이 추상 연산의 정확한 의미론은 PreferredType에 따라 다릅니다.

ToString ( argument ) (명세)

argument를 문자열로 변환하여 반환합니다. `${argument}`와 동등합니다.

주의: String(argument)이나 argument + ''ToString과 완전히 동일하지 않습니다. String() 은 Symbol 값을 문자열 설명으로 변환하지만, ToString은 Symbol에 대해 예외를 던집니다. 덧셈 연산자는 값을 문자열로 변환할 때 argument[Symbol.toPrimitive] 같은 함수를 호출합니다.
Type ( argument ) (명세)

argument타입을 반환합니다.

색인

이 명세서에서 정의된 용어

참조로 정의된 용어

참고문헌

정보 제공 참고문헌

[CONSOLE]
Dominic Farolino; Terin Stock; Robert Kowalski. Console Standard. Living Standard. URL: https://console.spec.whatwg.org/
[DOM]
Anne van Kesteren. DOM Standard. Living Standard. URL: https://dom.spec.whatwg.org/
[ECMA-262]
ECMAScript 언어 명세서. URL: https://tc39.es/ecma262/
[ECMA-262-2019]
ECMAScript 2019 언어 명세서. URL: https://ecma-international.org/ecma-262/10.0/
[HTML]
Anne van Kesteren; et al. HTML 표준. Living Standard. URL: https://html.spec.whatwg.org/multipage/
[ISO-22275-2018]
ISO/IEC 22275:2018 - 정보 기술 — 프로그래밍 언어, 환경 및 시스템 소프트웨어 인터페이스 — ECMAScript® 명세서 모음. URL: https://www.iso.org/standard/73002.html
[JOHNNY-FIVE]
Johnny-Five: JavaScript 로보틱스 & IoT 플랫폼. URL: http://johnny-five.io/
[MDN]
Mozilla 개발자 네트워크. URL: https://developer.mozilla.org/en-US/
[NODEJS]
Node.js. URL: https://nodejs.org/
[TC39]
TC39 - ECMAScript. URL: https://www.ecma-international.org/memento/tc39.htm
[WAT]
Gary Bernhardt. Wat. URL: https://www.destroyallsoftware.com/talks/wat
[WHATISMYBROWSER]
내가 사용 중인 브라우저?. URL: https://www.whatsmybrowser.org/
[XKCD-703]
Randall Munroe. xkcd: Honor Societies. URL: https://www.xkcd.com/703/
[YDKJS]
Kyle Simpson. You Don't Know JS (도서 시리즈). URL: https://github.com/getify/You-Dont-Know-JS

이슈 색인

작성 예정. <https://github.com/TimothyGu/es-howto/issues/2>