ECMAScript 명세 읽는 방법

현행 문서,

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

초록

ECMAScript 언어 명세(JavaScript 명세 또는 ECMA-262라고도 합니다)는 JavaScript가 작동하는 방식의 세부 사항을 학습하는 데 매우 유용한 자료입니다. 그러나 분량이 방대하여 처음에는 혼란스럽고 어렵게 느껴질 수 있습니다. 이 문서는 현재 이용할 수 있는 최고의 JavaScript 언어 참조 문서를 더 쉽게 읽기 시작할 수 있도록 하는 것을 목표로 합니다.

1. 서문

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

참고: 이 문서에서는 명세 자체를 가리킬 때만 "ECMAScript"라는 용어를 사용하고, 그 밖의 모든 경우에는 "JavaScript"를 사용하겠습니다. 하지만 두 용어 모두 같은 것을 가리킵니다. (ECMAScript와 JavaScript 사이에는 역사적으로 몇 가지 차이가 있지만, 이를 논의하는 것은 이 문서의 범위를 벗어나며, 그 차이는 Google에서 매우 쉽게 검색하실 수 있습니다.)

1.1. 왜 ECMAScript 명세를 읽어야 하나요?

ECMAScript 명세는 브라우저 [WHATISMYBROWSER]에서든, Node.js를 통해 서버 [NODEJS]에서든, IoT 기기 [JOHNNY-FIVE]에서든 모든 JavaScript 구현의 동작을 규정하는 권위 있는 출처입니다. 모든 JavaScript 엔진 개발자는 새롭게 만든 기능이 다른 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 {}

그리고 왜 한 메서드는 자신의 프로토타입에서 작동하는데 다른 메서드는 자신의 프로토타입에서 작동하지 않는지 매우 혼란스러울 수 있습니다. 안타깝게도 Google은 가장 필요할 때 항상 도움이 되지 않고, 언제나 도움이 될 것 같은 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(), 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 명세는 어디에 있나요?

Google에서 "ECMAScript specification"을 검색하면 모두 자신이 정식 명세라고 주장하는 수많은 결과를 볼 수 있습니다. 어느 것을 읽어야 할까요??

요약하면, 대부분의 경우 tc39.es/ecma262/에 게시된 명세가 여러분이 원하는 것입니다 [ECMA-262].

자세한 설명:

ECMAScript 언어 명세는 다양한 배경을 가진 사람들로 구성된 Ecma International Technical Committee 39(더 익숙한 이름으로는 TC39 [TC39])라는 그룹이 개발합니다. TC39는 tc39.es에서 최신 ECMAScript 언어 명세를 유지합니다 [ECMA-262].

문제를 복잡하게 만드는 것은 TC39가 매년 특정 시점을 선택해 명세의 스냅샷을 만들고, 해당 연도의 ECMAScript 언어 표준으로 지정하면서 판 번호도 부여한다는 점입니다. 예를 들어 ECMAScript® 2019 언어 명세(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 리플렉션까지 여러 절에 흩어져 있습니다.

여기서 한 가지 말씀드리고 싶은 것은 정말 아무도 명세를 처음부터 끝까지 읽지 않는다는 점입니다. 대신 찾으려는 내용에 해당하는 절만 보고, 그 절에서도 필요한 부분만 보세요. 여러분의 구체적인 질문이 위의 다섯 큰 부분 중 어느 것과 관련되는지 판단해 보세요. 어느 것인지 판단하기 어렵다면 "내가 확인하려는 이것은 어느 시점에 평가되는가?"라고 스스로 질문해 보면 도움이 될 수 있습니다. 걱정하지 마세요. 명세 탐색은 연습할수록 쉬워집니다.

2. 런타임 의미론

언어와 API의 런타임 의미론은 명세에서 가장 큰 부분을 차지하며, 보통 사람들이 가장 많은 질문을 하는 부분이기도 합니다.

전반적으로 명세의 이러한 절을 읽는 것은 꽤 간단합니다. 하지만 명세는 처음 읽는 사람에게 상당히 난감한 축약 표기를 많이 사용합니다(적어도 저는 그랬습니다). 이러한 규약 중 몇 가지를 설명한 다음, 여러 기능이 어떻게 작동하는지 알아보는 일반적인 과정에 적용해 보겠습니다.

2.1. 알고리즘 단계

ECMAScript의 대부분의 런타임 의미론은 의사 코드와 비슷하지만 훨씬 더 정밀한 형태의 일련의 알고리즘 단계로 규정됩니다.

알고리즘 단계의 예는 다음과 같습니다.

  1. a를 1로 둡니다.

  2. b를 a+a로 둡니다.

  3. b가 2이면,

    1. 만세! 산술 연산이 망가지지 않았습니다.

  4. 그렇지 않으면

    1. 이런!

추가 읽을거리: §5.2 알고리즘 규약

2.2. 추상 연산

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

Boolean이 인수 value로 호출되면 다음 단계를 수행합니다.

  1. b를 ! ToBoolean(value)로 둡니다.

  2. ...

이 "ToBoolean" 함수는 추상 연산이라고 합니다. 실제로 JavaScript 코드에 함수로 노출되지 않기 때문에 추상적입니다. 명세 작성자가 같은 내용을 반복해서 작성하지 않아도 되도록 만든 단순한 표기법입니다.

참고: 지금은 ToBoolean 앞에 오는 !에 대해서는 걱정하지 마세요. 나중에 § 2.4 완료 레코드; ? 및 !에서 살펴보겠습니다.

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

2.3. [[This]]란 무엇인가요?

때때로 [[Notation]] 표기가 "Let proto be obj.[[Prototype]]."처럼 사용되는 것을 볼 수 있습니다. 기술적으로 이 표기는 등장하는 문맥에 따라 여러 가지 다른 의미를 가질 수 있지만, 이 표기가 JavaScript 코드로 관찰할 수 없는 어떤 내부 속성을 가리킨다고 이해하면 대부분의 경우 충분합니다.

정확히는 세 가지 의미를 가질 수 있으며, 명세의 예를 통해 설명하겠습니다. 하지만 지금은 건너뛰셔도 좋습니다.

2.3.1. 레코드의 필드

ECMAScript 명세에서는 고정된 키 집합을 가진 키-값 맵, 즉 C 계열 언어의 구조체와 조금 비슷한 것을 가리킬 때 레코드라는 용어를 사용합니다. 레코드의 각 키-값 쌍을 필드라고 합니다. 레코드는 명세에만 등장할 수 있고 실제 JavaScript 코드에는 등장할 수 없으므로, [[Notation]]을 사용해 필드를 레코드에서 나타내는 것은 자연스럽습니다.

특히 속성 설명자도 [[Value]], [[Writable]], [[Get]], [[Set]], [[Enumerable]], [[Configurable]] 필드를 가진 레코드로 모델링됩니다. IsDataDescriptor 추상 연산은 이 표기법을 광범위하게 사용합니다.

추상 연산 IsDataDescriptor가 속성 설명자 Desc로 호출되면 다음 단계를 수행합니다.

  1. Desc가 undefined이면 false를 반환합니다.

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

  3. true를 반환합니다.

레코드의 또 다른 구체적인 예는 다음 절인 § 2.4 완료 레코드; ? 및 !에서 볼 수 있습니다.

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

2.3.2. JavaScript 객체의 내부 슬롯

JavaScript 객체에는 명세가 데이터를 보관하기 위해 사용하는 이른바 내부 슬롯이 있을 수 있습니다. 레코드 필드와 마찬가지로 이러한 내부 슬롯도 JavaScript를 사용해 관찰할 수 없지만, 일부는 Google Chrome의 DevTools 같은 구현별 도구를 통해 노출될 수 있습니다. 따라서 [[Notation]]을 사용해 내부 슬롯을 설명하는 것도 자연스럽습니다.

내부 슬롯에 대한 자세한 내용은 § 2.5 JavaScript 객체에서 다루겠습니다. 지금은 그 용도를 너무 걱정하지 마시고 다음 예에 주목해 주세요.

대부분의 JavaScript 객체에는 자신이 상속받는 객체를 가리키는 내부 슬롯 [[Prototype]]이 있습니다. 이 내부 슬롯의 값은 일반적으로 Object.getPrototypeOf()이 반환하는 값입니다. OrdinaryGetPrototypeOf 추상 연산에서는 이 내부 슬롯의 값에 접근합니다.

추상 연산 OrdinaryGetPrototypeOf가 객체 O로 호출되면 다음 단계를 수행합니다.

  1. O.[[Prototype]]을 반환합니다.

참고: 객체의 내부 슬롯과 레코드 필드는 겉보기에는 동일하지만, 이 표기 앞에 오는 부분(점 앞의 부분)이 객체인지 레코드인지 살펴보면 구분할 수 있습니다. 이는 일반적으로 주변 문맥을 보면 꽤 명확합니다.

2.3.3. JavaScript 객체의 내부 메서드

JavaScript 객체에는 이른바 내부 메서드도 있을 수 있습니다. 내부 슬롯과 마찬가지로 이러한 내부 메서드는 JavaScript를 통해 직접 관찰할 수 없습니다. 따라서 [[Notation]]을 사용해 내부 메서드를 설명하는 것도 자연스럽습니다.

내부 메서드에 대한 자세한 내용은 § 2.5 JavaScript 객체에서 다루겠습니다. 지금은 그 용도를 너무 걱정하지 마시고 다음 예에 주목해 주세요.

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

  1. ? F.[[Call]](V, argumentsList)을 반환합니다.

여기서 F는 JavaScript 함수 객체입니다. 이 경우 F의 [[Call]] 내부 메서드 자체가 인수 V 및 argumentsList와 함께 호출됩니다.

참고: [[Notation]]의 이 세 번째 의미는 함수 호출처럼 보인다는 점을 통해 다른 의미들과 구분할 수 있습니다.

2.4. 완료 레코드; ? 및 !

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

참고: 두 개의 대괄호는 필드를 레코드에서 나타내는 데 사용됩니다. § 2.3.1 레코드의 필드에서 레코드와 관련 표기법에 대한 입문 설명을 참조하세요.

완료 레코드 중 [[Type]]이 normal인 것을 정상 완료라고 합니다. 정상 완료가 아닌 모든 완료 레코드는 중단 완료라고도 합니다.

대부분의 경우 다루게 되는 것은 중단 완료 중 [[Type]]이 throw인 경우입니다. 나머지 세 가지 중단 완료 타입은 특정 구문 요소가 어떻게 평가되는지 살펴볼 때만 유용합니다. 실제로 내장 함수의 정의에서는 이러한 다른 타입을 볼 일이 없습니다. break/continue/return은 함수 경계를 넘어 작동하지 않기 때문입니다.

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


완료 레코드의 정의 때문에, 오류가 try-catch 블록까지 전파되는 것과 같은 JavaScript의 편리한 동작은 명세에 존재하지 않습니다. 실제로 오류(더 정확하게는 중단 완료)는 명시적으로 처리됩니다.

축약 표기를 전혀 사용하지 않는다면, 계산 결과를 반환하거나 오류를 던질 수 있는 추상 연산의 일반적인 호출에 대한 명세 문장은 다음과 같습니다.

예외를 던질 수 있는 추상 연산을 축약 표기 없이 호출하는 몇 단계는 다음과 같습니다.

  1. resultCompletionRecord를 AbstractOp()로 둡니다.

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

  2. resultCompletionRecord가 중단 완료이면 resultCompletionRecord를 반환합니다.

    참고: 여기서는 resultCompletionRecord가 중단 완료이면 그대로 반환합니다. 다시 말해 AbstractOp에서 던진 오류를 전달하고 나머지 단계를 중단합니다.

  3. result를 resultCompletionRecord.[[Value]]로 둡니다.

    참고: 정상 완료를 얻었다는 것을 확인한 뒤에는 완료 레코드의 래핑을 해제해 필요한 실제 계산 결과를 얻을 수 있습니다.

  4. result는 우리가 필요한 결과입니다. 이제 이것으로 더 많은 작업을 할 수 있습니다.

이 모습은 C에서 수동으로 오류를 처리하는 방식을 어렴풋이 떠올리게 할 수도 있습니다.

int result = abstractOp();              // 1단계
if (result < 0)                         // 2단계
  return result;                        // 2단계 (계속)
                                        // 3단계는 필요하지 않음
// func() 성공; 계속 진행...            // 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 속성이 평범한 데이터 속성처럼 보이는데도 Array 객체의 length 속성을 설정하면 객체에서 속성이 제거될 수 있다는 점입니다. 반면 new Map().size는 Map.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]] 내부 메서드는 등록된 핸들러 또는 대상의 프로토타입으로 위임합니다. 이 대상은 Proxy 객체의 [[ProxyTarget]] 내부 슬롯에 저장됩니다.

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


객체, 내부 메서드, 내부 슬롯 사이의 관계를 생각하는 또 다른 방법은 고전적인 객체 지향 관점에서 보는 것입니다. "Object"는 구현해야 하는 여러 내부 메서드를 규정하는 인터페이스와 같습니다. 일반 객체는 기본 구현을 제공하며, 특수 객체는 이를 부분적으로 또는 완전히 재정의할 수 있습니다. 반면 내부 슬롯은 객체의 인스턴스 변수, 즉 해당 객체의 구현 세부 사항과 같습니다.

이러한 모든 관계는 다음 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 값)로 둡니다.

? 축약 표기를 보면 RequireObjectCoercible 추상 연산이 실제로 예외를 던질 수 있는 경우가 존재할 수 있다고 결론 내릴 수 있습니다. 그렇지 않다면 !가 대신 사용되었을 것입니다. 실제로 오류를 던진다면 위의 두 번째 가설과 일치합니다! 기대를 품고 하이퍼링크를 클릭해 RequireObjectCoercible이 무엇을 하는지 살펴봅니다.

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

인수 타입 결과
Undefined TypeError 예외를 던집니다.
... ...

하지만 문제없습니다. 우리가 substring()에 전달한 this 값의 타입인 Undefined에 해당하는 행에서 명세는 RequireObjectCoercible이 예외를 던져야 한다고 말합니다. 그리고 함수 정의에서 ? 표기를 사용했으므로, 던져진 예외가 함수 호출자까지 전파되어야 한다는 것을 알 수 있습니다. 정답입니다!

따라서 답은 다음과 같습니다. 주어진 코드 조각은 TypeError 예외를 던집니다.

명세는 던지는 Error의 타입만 규정하며 어떤 메시지를 포함해야 하는지는 규정하지 않습니다. 따라서 구현에 따라 서로 다른 오류 메시지를 사용할 수 있으며, 현지화된 메시지일 수도 있습니다.

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

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

반면 Mozilla Firefox 57.0은 다소 덜 친절한 다음 메시지를 표시합니다.

TypeError: can’t convert undefined to object

동시에 ChakraCore 버전 1.7.5.0(Microsoft Edge의 JavaScript 엔진)은 V8과 같은 방식을 사용해 다음을 던집니다.

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

2.7. 예: Boolean()과 String()은 예외를 던질 수 있나요?

중요한 시스템의 코드를 작성할 때 프로그래밍에서 예외 처리를 가장 우선적으로 고려해야 합니다. 따라서 "어떤 내장 함수가 예외를 던질 수 있는가?"라는 질문을 자주 생각하게 될 수 있습니다.

이 예에서는 두 언어 내장 함수인 Boolean()과 String()에 대해 이 질문의 답을 찾아보겠습니다. 여기서는 이 함수들의 직접 호출만 살펴보고, 박싱된 객체를 만드는 new Boolean()과 new String()의 경우는 살펴보지 않겠습니다. 이러한 방식은 쉽게 말해 JavaScript에서 가장 바람직하지 않은 기능 중 하나이며, 사실상 모든 JS 스타일 가이드에서 강하게 권장하지 않는 방식입니다 [YDKJS].

명세에서 Boolean() 절로 이동하면 알고리즘이 상당히 짧다는 것을 알 수 있습니다.

Boolean이 인수 value로 호출되면 다음 단계를 수행합니다.

  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 값으로 변환합니다. 흥미롭게도 이 단계에는 ? 또는 ! 축약 표기가 없지만, 일반적으로 완료 레코드 축약 표기가 없다는 것은 !와 같은 의미입니다. 따라서 1단계는 예외를 던질 수 없습니다.

2단계에서는 NewTarget이라는 것이 undefined인지 확인합니다. NewTarget은 ES2015에서 처음 추가된 new.target 메타 속성에 대응하는 명세상의 개념으로, 명세가 new Boolean() 호출(이 경우 값은 Boolean)과 Boolean() 호출 (이 경우 값은 undefined)을 구분할 수 있게 합니다. 지금은 Boolean()의 직접 호출만 살펴보고 있으므로 NewTarget은 항상 undefined이며, 알고리즘은 추가 처리 없이 항상 즉시 b를 반환합니다.

Boolean()을 new 없이 호출하면 Boolean() 알고리즘의 처음 두 단계에만 도달할 수 있고, 둘 다 예외를 던질 수 없으므로 입력이 무엇이든 Boolean()은 절대로 예외를 던지지 않는다고 결론 내릴 수 있습니다.


이제 String()을 살펴보겠습니다.

String이 인수 value로 호출되면 다음 단계를 수행합니다.

  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라는 것을 알고 있으므로 마지막 단계는 고려 대상에서 제외할 수 있습니다. 또한 Type과 SymbolDescriptiveString도 안전합니다. 둘 중 어느 쪽에서도 중단 완료가 처리되지 않기 때문입니다. 하지만 ToString 추상 연산 호출 앞에는 여전히 의미심장한 ?가 있습니다. 자세히 살펴보겠습니다.

앞에서 살펴본 RequireObjectCoercible과 마찬가지로 ToString(argument)도 표로 정의되어 있습니다.

인수 타입 결과
Undefined "undefined"를 반환합니다.
Null "null"을 반환합니다.
Boolean argument가 true이면 "true"를 반환합니다.

argument가 false이면 "false"를 반환합니다.

Number NumberToString(argument)을 반환합니다.
String argument를 반환합니다.
Symbol TypeError 예외를 던집니다.
Object

다음 단계를 수행합니다.

  1. primValue를 ? ToPrimitive(argument, 힌트 String)으로 둡니다.

  2. ? ToString(primValue)을 반환합니다.

String()에서 ToString이 호출되는 시점에는 value가 Symbol을 제외한 모든 값일 수 있습니다(Symbol은 바로 앞 단계에서 걸러집니다). 하지만 Object 행에는 여전히 두 개의 ?가 남아 있습니다. ToPrimitive 링크와 그 이후를 따라가 보면 value가 객체일 때 실제로 오류가 던져질 수 있는 기회가 아주 많다는 것을 알 수 있습니다.

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이고 proto를 [[Prototype]] 내부 슬롯의 값으로 갖는 배열 객체를 생성합니다. proto가 지정되지 않으면 현재 렐름의 %ArrayPrototype%을 사용합니다. Array 생성자와 그 모든 속성이 몽키 패치되지 않았고, proto가 지정되지 않았거나 현재 렐름의 %ArrayPrototype%이라면 new Array(length)와 동등합니다.

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 ) (명세)

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

HasOwnProperty ( O, P ) (명세)

O.[[GetOwnProperty]](P)를 호출하여 O에 P라는 이름의 자체 속성이 있는지를 반환합니다. Object.prototype.hasOwnProperty.call(O, P)과 동등합니다.

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

argumentsList를 사용해 V에서 P라는 이름의 메서드를 호출합니다. V[P](...argumentsList)와 동등합니다. Call과 달리 여기서 P는 속성 키입니다.

IsArray ( argument ) (명세)

argument가 Array 특수 객체인지 반환합니다. 또는 argument가 Proxy 특수 객체이면, argument의 가장 안쪽 [[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가 정상 완료라면 해당 완료 레코드의 래핑을 해제하고 argument를 argument.[[Value]]로 설정합니다.

함께 보기: § 2.4 완료 레코드; ? 및 !.

StringCreate ( value, prototype ) (명세)

String value에 대응하는 박싱된 String 객체를 반환하며, 결과 객체의 [[Prototype]] 내부 슬롯은 prototype입니다. prototype이 현재 렐름의 %StringPrototype%이라면 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를 객체로 강제 변환한 값을 반환하며, 필요한 경우 박싱된 원시 객체를 사용합니다. argument가 undefined 또는 null인 경우를 제외하면 Object(argument)와 동등합니다.

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

input을 원시 값(즉, 객체가 아닌 값)으로 강제 변환하여 반환하며, 선택적으로 PreferredType을 통해 주어진 타입 힌트를 사용합니다. 이 추상 연산의 정확한 의미론은 PreferredType에 따라 달라집니다.

ToString ( argument ) (명세)

argument를 String으로 강제 변환한 값을 반환합니다. `${argument}`와 동등합니다.

String(argument)도 argument + ''도 ToString과 완전히 동등하지 않다는 점에 주의해야 합니다. String()은 Symbol 값을 해당 String 설명으로 변환하지만, ToString은 Symbol에서 예외를 던집니다. 덧셈 연산자는 값을 String으로 변환하려고 할 때 argument[Symbol.toPrimitive] 같은 다른 함수도 호출합니다.
Type ( argument ) (명세)

argument의 타입을 반환합니다.

색인

이 명세에서 정의하는 용어

참조를 통해 정의된 용어

참고 문헌

비규범적 참고 문헌

[CONSOLE]
Dominic Farolino; Terin Stock; Robert Kowalski. Console 표준. 현행 표준. URL: https://console.spec.whatwg.org/
[DOM]
Anne van Kesteren. DOM 표준. 현행 표준. 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 표준. 현행 표준. 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: 명예 학회. 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> ↵