정보 통신에 대한 표준을 관리하는 비영리기구 Ecma 인터내셔널이 지난 6월 22일, ECMAScript의 제13판을 최종 승인했습니다. ECMAScript는 2015년의 제6판을 기점으로 매년 새롭게 발표가 되고 있는데요, 따라서 이번에 발표된 ECMAScript 제13판은 ECMAScript 2022 또는 ES2022 라는 이름으로도 불립니다.
JavaScript는 ECMAScript를 준수하는 언어이기 때문에, 이번 발표는 JavaScript 사양의 변경으로도 해석할 수 있죠. 사실 표준이 되기 전에도 이미 대부분의 브라우저가 이를 지원하고 있었기에, 여러분도 이미 해당 제안(Proposal)들을 써봤을 수도 있습니다.
저처럼 JavaScript를 주력 언어로 쓰시는 분들께는 이러한 ECMAScript의 발표가 굉장히 흥미로운(?) 이벤트인데요, 그래서 오늘은 ECMAScript 2022를 기점으로 새롭게 표준이 된 제안들을 정리해보고자 합니다.
이번 포스트를 통해 ECMAScript 2022가 궁금하신 분들께 도움이 되었으면 좋겠습니다.
혹시라도 ECMAScript와 JavaScript가 정확히 어떤 것을 의미하는지 헷갈리시는 분들은 『JavaScript와 ECMAScript는 무슨 차이점이 있을까?』를 참고해주세요.
클래스 필드
첫 번째 항목은 바로 클래스 필드와 관련된 개선입니다. 클래스 필드는 접근 제어자가 부실한 JavaScript의 태생적 한계를 극복하고, 클래스 선언과 관련된 코드들의 가독성과 지역성(locality)을 높이기 위해 제안되었습니다.
ES6부터 JavaScript에서도 객체 지향 프로그래밍의 클래스 개념을 적용할 수 있도록 class
키워드가 추가되었는데요, 애초에 JavaScript 자체가 객체 지향 프로그래밍을 위한 언어가 아니다보니 최초 설계가 완벽하지는 않았습니다. 그래서 클래스 필드와 관련된 여러 부분의 개선이 필요했고 이들 중 일부가 표준으로 지정되었습니다.
클래스 필드라는 이름으로 뭉뚱그려 표기하긴 했지만, 여기에는 세 가지 세부 항목이 있습니다.
- 언어 자체에서 지원하는 프라이빗 접근 제어자 추가
- 퍼블릭 필드 및 정적 필드 선언 방식 개선
- 정적 초기화 블록 추가
아무래도 글로 적는 것보다는 코드를 보는 게 나을 것 같네요. 기존 방식의 클래스 선언 코드를 봅시다.
class OldClass {
constructor() {
// 기존에는 생성자 내부에 this 를 사용하여 퍼블릭 필드를 설정했다.
this.publicField = 0;
// 기존에는 프라이빗 필드를 자체를 언어에서 지원하지 않았다.
// 변수 명 앞에 언더스코어를 붙이는 관행이 있었지만 실제로는 퍼블릭하게 접근 가능하다.
this._privateField = '비밀?';
}
// 메서드는 클래스 바디에서 선언한다.
publicMethod() {}
// 프라이빗 메서드...로 선언한 것 같지만 실제로는 퍼블릭 메서드다.
_privateMethod() {}
}
// 원래 정적 필드와 메서드는 이렇게 클래스 외부에서 설정해야 했다.
OldClass.staticField = '정적 필드';
OldClass.staticMethod = function () {};
뭔가 중구난방이죠? 새 문법을 이용하면 보다 깔끔하게 작성할 수 있습니다.
class NewClass {
// 새로 추가된 문법에서는 클래스 내부에 바로 퍼블릭 필드를 선언할 수 있다.
publicField = 0;
publicMethod() {}
// 새로 추가된 문법에서는 언어 자체에서 프라이빗을 지원한다.
// 변수, 메서드 이름 앞에 해시(#)를 붙이면 된다.
#privateField = '비밀!';
#privateMethod() {}
// getter와 setter도 프라이빗하게 만들 수 있다.
get #privateGetter() {}
set #privateSetter(value) {}
// 정적 필드도 클래스 바디 안에 작성할 수 있다.
static staticField = '정적 필드';
static staticMethod() {}
// 위의 것들을 막 조합할 수도 있다.
static #staticPrivateField = '정적 프라이빗 필드!';
static #staticPrivateMethod() {}
static get #staticPrivateGetter() {}
static set #staticPrivateSetter(value) {}
// 정적 초기화 블록을 선언할 수 있다.
static {
console.log('정적 초기화 블록에서 딱 한 번만 실행됨');
window.getPrivateField = (myClass) => myClass.#privateField;
}
}
우선 JavaScript 언어에서 지원하는 프라이빗 필드를 사용할 수 있게 됐습니다. 혹시라도 학부생 때 객체 지향 프로그래밍 수업을 들어보셨다면 private
, protected
, public
과 같은 **접근 제어자(Access Modifier)**를 들어보셨을텐데요, 이번 표준에서는 필드 이름 앞에 해시(#
) 접두사를 붙여 프라이빗 필드를 만들 수 있습니다. 혹시라도 접근 제어자를 명시적으로 써본 기억이 있다면, 그건 아마 TypeScript에서 지원해주는 것일 겁니다.
또한 클래스 바디에 직접 퍼블릭 필드와 정적 필드를 명시할 수 있습니다. 기존에는 퍼블릭 필드를 설정할 때 생성자 내에서 this.
를 이용해 필드를 입력해야 했고, 정적 필드를 설정할 때는 클래스 밖에서 점 표기법으로 속성을 설정해야 했습니다. 새 표준에서는 두 방식의 필드 모두 클래스 바디 내에서 바로 선언이 가능합니다. 물론 정적 필드는 앞에 static
키워드를 붙여야 합니다.
정적 초기화 블록은 생성자와 비슷하면서도 약간 다릅니다. 생성자는 각 인스턴스가 생성될 때마다 실행되는 코드지만, 정적 초기화 블록은 클래스가 선언될 때 딱 한 번만 실행됩니다. 일반적으로 어떤 연산을 통해 정적 필드의 값을 초기화하는 용도로 주로 사용됩니다. 정적 초기화 블록은 선언된 순서에 따라 실행되며, 여러 개가 올 수도 있습니다. 또한 상속된 클래스의 경우는 부모의 코드 블록이 먼저 실행되고, 자식의 코드 블록이 다음으로 실행됩니다.
정적 초기화 블록 문법을 이용하면 클래스와 관련된 모든 코드들을 클래스 바디 내부에 담을 수 있으며, 해당 블록 내에서는 프라이빗 필드에 접근이 가능하다는 장점이 있습니다.
in
연산자를 활용한 프라이빗 필드 체크
클래스에서 프라이빗 필드를 in
연산자로 체크하는 기능은 좀 더 가독성 좋은 코드를 위한 문법으로 확장된 경우입니다. 사실 클래스 필드와 연관된 것이긴 한데, 문서가 분리되어 있어 별도로 작성했습니다.
기존에도 객체에서 특정 속성의 존재 여부를 검사하는 in
연산자가 있었지만 이를 이용해 클래스의 프라이빗 필드의 존재 여부를 체크할 수 있게 되었다는 점에서 의의가 있습니다. 기존 방식으로 코드를 작성하려면 try
, catch
문을 사용해야 하지만, in
연산자를 이용하면 보다 직관적인 문법으로 사용이 가능합니다.
class OldClass {
#field;
// 프라이빗 필드에 접근하려 할 때 예외가 발생하는 것을 이용해야 한다.
static isMyField(myClass) {
try {
myClass.#field;
return true;
} catch {
return false;
}
}
}
class NewClass {
#field;
static isMyField(myClass) {
// `in` 연산자를 쓰면 편-안.
return #field in myClass;
}
}
당연하게도 in
연산자는 프라이빗 필드가 있는지의 여부만 반환하며, 그 값은 명시적인 메서드를 통해 반환하지 않는 한 외부에서 읽을 수 없습니다.
정규표현식 플래그 d
d
플래그를 추가하면 일치하는 인덱스 정보(indices
)를 함께 반환해준다
정규표현식 플래그로 새롭게 추가된 d
옵션은 매칭된 문자열의 인덱스 정보를 얻기 위해 추가된 속성입니다.
RegExp
객체는 exec
, match
등의 메서드를 호출할 때 매칭된 문자열의 정보를 제공하는데, 기존에는 시작 인덱스 속성(index
)만 조회할 수 있었습니다. 매칭된 문자열의 종료 인덱스를 알 수가 없었기 때문에 일치하는 문자열을 인덱스 기반으로 조작하기가 어려웠습니다. 하지만 이번에 새로 표준이 된 플래그를 사용하면 시작 인덱스와 종료 인덱스가 함께 반환되기 때문에, 보다 쉬운 접근이 가능해졌습니다.
RegExp
객체에서 d
플래그를 추가한 채 exec
, match
메서드를 호출하면, 매칭된 문자열의 시작, 종료 인덱스 배열을 포함하고 있는 2차원 배열 indices
가 반환값에 추가됩니다. 괄호를 이용한 캡처링 그룹이 있다면 해당 그룹의 인덱스 배열도 포함됩니다.
/(a+)(b+)/d.exec('aaaabb');
// ['aaaabb', 'aaa', 'bb', indices: [[0, 6], [0, 4], [4, 6]]]
// indices의 첫 번째 배열은 전체 문자열에 대한 시작, 종료 인덱스
// 두 번째와 세 번째 배열은 괄호로 묶여진 각 캡처링 그룹(괄호)의 시작, 종료 인덱스
모듈에서 최상위 레벨의 await
호출 가능
최상위 레벨 await
호출은, JavaScript 모듈 파일 내에서 async
함수를 선언하지 않고도 await
키워드를 사용하여 비동기 호출을 가능하게 만든 문법의 기능 확장입니다.
기존의 모듈 내에서 비동기 함수를 최상위 레벨에서 호출하기 위해서는 async
, await
함수를 만들거나 즉시 실행 함수(IIFE)를 만들어 호출해야 했는데요, 이러한 방법으로는 모듈을 호출하는 쪽에서 값이 초기화되기 전에 변수 접근이 가능하다는 문제가 있었습니다.
// 우선 모듈 내에서만 동작하기 때문에 확장자는 `*.mjs` 형태여야 한다.
let result;
// 기존에는 비동기 함수를 쓰려면 이렇게 async 함수 자체를 선언해 호출하거나
async function getUser() {
const response = await fetch('http://example.com/foo.json');
result = await response.json();
}
getUser();
export { result };
// 즉시 실행 함수를 호출해야 했다.
(async () => {
await getUser();
})();
export { result };
// 하지만 위의 두 경우 모두 `result` 를 호출하는 쪽에서 값이 초기화 되기 전에 조회하면
// `undefined` 가 뜰 수도 있다. 즉, 기존 문법으로는 값의 초기화 전에 변수 접근이 가능하다.
이번에 추가된 모듈 내 최상위 레벨에서 await
호출 문법은 비동기 호출 후의 로직을 Promise.all()
로 감싸는 역할을 하기 때문에, 모듈의 비동기 작업이 완전히 완료 되기 전에 작업 결과에 접근하지 않도록 합니다.
// 새 문법으로 바꿔서 써보자.
// module.mjs
// 우선 async 함수로 감싸지 않아도 된다.
// 비동기 로직 자체를 모듈 내에서 모두 처리하여 결과만 내보낸다고 생각하자.
const response = await fetch('http://example.com/foo.json');
export const result = await response.json();
// another.mjs
// 이 모듈을 불러오는 쪽에서는 이렇게 쓴다.
import { result } from './module.mjs';
const messages = result;
console.log(messages);
// 위 코드는 실제로 아래와 동등하게 실행된다.
// module.mjs
export let result;
export const promise = (async () => {
const response = await fetch('http://example.com/foo.json');
result = await response.json();
})();
// another.mjs
import { promise as promiseResult, result } from './module.mjs';
export const promise = (async () => {
// 모듈을 호출하는 쪽에서 비동기 코드를 `Promise.all()` 로 묶음으로서
// 이후의 로직이 실행되지 않게 한다.
await Promise.all([promiseResult]);
const messages = result;
console.log(messages);
})();
참고로 최상위 레벨에서 await
을 쓰거나, 또 다른 비동기 모듈을 import
하면 해당 모듈은 비동기 모듈이 됩니다.
또한 비동기 모듈을 호출할 때 아래와 같은 방법으로 응용이 가능합니다.
// 동적 호출 가능
const strings = await import(`/i18n/${navigator.language}`);
// 의존성 폴백 관리
let jQuery;
try {
jQuery = await import('https://cdn-a.com/jQuery');
} catch {
jQuery = await import('https://cdn-b.com/jQuery');
}
.at()
at()
메서드는 문자열, 배열 등에서 음수 인덱싱(negative indexing)을 가능하게 해주는 메서드 로 추가되었습니다. 이러한 인덱싱 기법은 Python에서 가장 흔히 사용되는데, JavaScript는 배열도 객체로 취급하는 JavaScript의 특성 상 이것이 불가능합니다. 이를 지원하고자 이번 표준에 음수 인덱싱을 가능하게 하는 .at()
메서드가 추가되었습니다.
const arr = [1, 2, 3];
// JavaScript에서 배열은 객체다.
typeof arr === 'object'; // true
// `-1` 이라는 키를 가진 값을 찾으려 하기 때문
arr[-1]; // undefined
// 기존 문법으로 마지막 인덱스의 값을 가져오려면 이렇게 해야 했지만
arr[arr.length - 1]; // 3
// 이렇게 하면 훨씬 간편하다.
arr.at(-1); // 3
Object.hasOwn()
Object.hasOwn()
는 객체의 특정 속성이 프로토타입을 거치지 않은 객체 그 자신이 소유한 속성인지를 반환하는 메서드입니다.
사실 기존의 Object.hasOwnProperty()
메서드와 거의 비슷한 역할을 하지만 이 함수가 새롭게 정의된 이유는 .hasOwnProperty()
자체가 Object
의 프로토타입에 종속된 메서드이기 때문에, 프로토타입이 없거나 재정의된 객체에서는 .hasOwnProperty()
의 기능을 사용할 수 없기 때문입니다.
// 기존에는 이런 방법을 주로 활용했다.
let hasOwnProperty = Object.prototype.hasOwnProperty;
hasOwnProperty.call(object, 'foo');
// 이렇게 굳이 메서드를 다시 꺼내고 변수에 저장해 쓴 이유는...
// 프로토타입이 끊기면 메서드를 사용할 수 없었기 때문
Object.create(null).hasOwnProperty('someProp'); // error
// Uncaught TypeError: Object.create(...).hasOwnProperty is not a function
이번에 새로 추가된 Object.hasOwn()
는 정적 메소드로 구현되었기 때문에 특정 인스턴스의 프로토타입 상속 관계에 구애받지 않고 사용 가능하다는 장점이 있습니다.
const proto = {
protoProp: '프로토타입 속성',
};
const object = {
__proto__: proto,
objProp: '객체 속성',
};
// in 연산자는 객체의 속성에 해당 값이 있는지를 반환한다
console.log('protoProp' in object); // true
// hasOwn 메서드는 프로토타입 상속이 아닌 객체 고유의 속성인지를 반환한다
console.log(Object.hasOwn(object, 'protoProp')); // false
console.log(Object.hasOwn(proto, 'protoProp')); // true
Error.prototype.cause
Error.prototype.cause
는 에러 체이닝을 위해 도입된 속성입니다.
일반적으로 메서드 내에서 정의되지 않은 예외 동작이 발생한 경우, 발생한 에러를 근거로 코드를 수정할 수 있죠. 하지만 메서드가 깊게 중첩된 경우에는 단순한 에러 로그만으로는 원인 파악 및 대처가 어려울 수 있는데요, 이번에 추가된 cause
를 활용하면 발생한 오류를 다시 한 번 감싸서, 추가적인 컨텍스트 메시지를 참조하게 만든 새 에러를 throw
하는 방식으로 체이닝할 수 있습니다.
function job1() {
try {
job2();
} catch (e) {
console.log(e);
// Error: job2 Error
throw new Error('job1 Error', { cause: e });
}
}
function job2() {
throw new Error('job2 Error');
}
try {
job1();
} catch (e) {
console.log(e);
// Error: job1 Error
console.log(e.cause);
// Error: job2 Error
}
원본 에러는 e.cause
를 추적해 따라가면 찾을 수 있다
참고 자료
- https://github.com/tc39/proposal-class-fields
- https://github.com/tc39/proposal-private-fields-in-in
- https://github.com/tc39/proposal-class-static-block
- https://github.com/tc39/proposal-regexp-match-indices
- https://github.com/tc39/proposal-top-level-await
- https://github.com/tc39/proposal-relative-indexing-method
- https://github.com/tc39/proposal-accessible-object-hasownproperty
- https://github.com/tc39/proposal-error-cause