WeakRefs와 Finalizers 위주로 정리해본 ES2021

Posted on 2021-08-30

Image by Blake Connally from Unsplash

몇 달 전에 ECMAScript 2021 최종 스펙이 공개되었어요. 바로 쓸만한 것도 있고 언어를 조금 더 깊게 활용할 수 있는 기능들도 생겼습니다. 매해 이렇게 달라지고 개선되는 것을 보면서 좋기도 하지만 점점 이것도 무뎌지는진 건지 아니면 개선점이 와닿지 않는 건지 예전만큼 관심이나 파급효과가 적어진 것 같아요. 스펙을 보자마자 조만간 정리해야지 했지만 많은 시간이 흘렀네요. 벌써 9월이라니... 다소 생소할 수 있는 WeakRefsFinalizers를 조금 더 많이 다뤄봤습니다.

일단 요약을 하면 이래요.

  1. 객체를 향한 약한 참조를 만들 수 있는 WeakRefs
  2. 객체가 가비지 컬렉트 되는 시점을 알 수 있는 Finalizers
  3. 한놈만 걸리던가 혹은 모두 나가리 Promise.any
  4. 드디어 replaceAll 메서드를 갖게된 String
  5. 꽤 유용할 수 있는 논리 할당 연산자
  6. 조금은 어색한 숫자 구분자
  7. Array.prototype.sort의 스펙 안정화

요약 내용만으로 이해할 수 있는 내용도 있어서 관심 있는 것만 보시면 될 것 같아요.

약한 참조를 위한 WeakRefs

자바스크립트도 드디어 약한 참조를 사용할 수 있게 되었습니다. 저는 스위프트를 배우면서 그 유용성을 조금 알게 됐는데요. 정확히 일치하진 않겠지만 요즘 모던 언어들도 대부분 비슷한 컨셉을 지원할 겁니다. 자바스크립트에게 주는 의미는 가비지 컬렉션 매커니즘을 프로그램 안에서 필요에 따라 선택적으로 적용할 수 있게 되었다는 점이라고 볼 수 있겠네요.

기존에 자바스크립트의 참조 형태는 강한 참조밖에 없었죠. 그래서 어떤 객체나 데이터가 아무튼 누군가에 의해 참조가 되고 있다면 절대 가비지 컬렉션의 대상이 되지 않습니다. 그래서 종종 필요 없게 된 큰 용량의 정보를 계속 메모리상에 들고 있는 실수를 했었어요. 이를 메모리 누수라고 하죠. 반대로 이야기하면 아무도 모르는 객체는 언젠가 가비지 컬렉션 될 수 있습니다. WeakRefs는 여기에 한 가지 참조 방식을 추가합니다. 알고는 있지만 가비지 컬렉션이 될 수 있는 약한 참조입니다. 객체가 약한 참조로만 참조되고 있다면 아무도 모르는 것과 동일하게 언젠가 사라질 수 있습니다. 가비지 컬렉션의 구현은 보통 레퍼런스 카운팅에 의존하는데 이 카운트를 증가시키지는 않으면서 참조하는 것이죠.

const map = new Map();

const obj =  {data: new Array(10000).join('*')};

map.set('someData', obj);

setInterval(() => {
    console.log(map.get('someData').data);
 }, 1000);

위 코드에서 obj.data로 참조하고 있는 1만 개의 배열은 일반적인 자바스크립트의 참조 형태인 강한 참조이기 때문에 배열이 가비지 컬렉션되서 메모리상에서 사라지는 경우는 절대 없습니다. setInterval에 전달된 콜백에 의해 클로저가 만들어지고 클로저에서는 map을 참조하고 있고 map은 obj가 참조했던 객체를 참조하기 때문에 콜백이 실행될 때마다 데이터의 존재가 보장됩니다.

자 이제 위 코드에 WeakRef를 적용해서 obj 가 참조하고 있는 객체가 가비지 컬렉션 대상이 되어 제거될 수 있도록 수정해볼게요. 한 줄만 수정하면 됩니다.

const map = new Map();

const obj = {data: new Array(10000).join('*')};

map.set('someData', new WeakRef(obj));

setInterval(() => {
    console.log(map.get('someData').deref().data);
 }, 1000);

WeakRef의 실체는 약한 참조를 만드는 객체의생성자였어요. new키워드와 함께 사용하면 됩니다. WeakRef의 메서드인 deref()를 사용해 참조하는 대상에 접근할 수 있습니다. 참조하는 대상이 가비지 컬렉션 됐다면 deref()undefined를 리턴합니다.

이제 시간이 흐르면 공허하게 떠돌며 실낱같이 약하게 연결(참조)되었던 객체가 무시무시한 가비지 컬렉터에게 잡아 먹힙니다. setInterval의 콜백은 더 이상 객체의 존재는 보장받지 못합니다. 한 7초쯤 지나면 콜백에서 에러가 발생합니다. 가비지 컬렉션의 구현에 따라 브라우저별로 객체가 제거되는 시점은 다를 수 있습니다. 위 코드는 특정 함수안에서 실행됐다고 가정합니다. 전역에서 실행하는 경우 obj가 전역에 계속 상주하며 강한 참조를 갖고 있기 때문에 객체가 사라지지 않습니다. 전역에서 확인하고 싶으신 분은 map.set 이후 objnull 이나 다른 값을 할당하면 됩니다.

객체로의 약한 참조를 프로그램에서 응용하려면 객체가 아직 존재하는지를 확인하는 방법도 중요하겠죠. 있을 수도 있고 없을 수도 있는 구성물을 사용하니까요. Finalizer라는 컨셉을 활용하면 객체가 가비지 컬렉션 되는 시점을 모니터링할 수 있습니다. 바로 다음으로 가시죠.

객체가 가비지 컬렉트 되는 시점을 알 수 있는 Finalizers

파이널라이저(Finalizers)는 WeakRefs와 쌍둥이 같은 기능이라고 보시면 됩니다. 항상 같이 쓰일 필요는 없지만 말이죠. WeakRefs가 있기에 파이널라이저가 필요하게 된 거죠. WeakRefs는 자바스크립트에 새로운 참조 컨셉을 제공했어요. 기존에는 참조를 하는 순간부터 참조를 직접 끊지 않는 이상 존재가 보장됐다면 존재를 보장받지 못하지만 참조는 할 수 있는 방법이 생긴겁니다. 자 있을 수도 있고 없을 수도 있음이라는 개념을 우리의 프로그램에서 활용하고자 한다면 대상이 언제 없어졌는지에 대해서도 조금 더 알 필요가 있어요. 사용할 때마다 확인하는 것 이상으로 언젠가 외부 요인에 의해 사라질 수 있는 대상이 기어코 사라졌을 시점을 잡아내서 필요한 작업을 해야 할 수도 있는 거죠. 그 시점을 파이널라이저를 사용해 잡아낼 수 있습니다.

Finalizers를 사용하는 방법은 간단해요. DOM API가 흔히 사용하는 옵저버패턴 혹은 구독패턴이라고 불리는 패턴을 사용하고 있습니다. 즉 이벤트처럼 사용할 수 있어요.

const 가비지컬렉션되면해야할작업 = (value) => { console.log(value); }

const 파이널라이저그룹 = new FinalizationRegistry(가비지컬렉션되면해야할작업);

이해를 돕기위해 변수명을 한글로 사용해봤어요. 한글이지만 실제로 돌아가는 코드입니다. FinalizationRegistry로 파이널라이저를 만들면서 특정 객체들이 가비지 컬렉션되면 해야할 작업인 콜백을 등록했구요. 이 콜백은 등록한 관심객체들이 가비지 컬렉션되면 실행됩니다.

const 관심객체 = {};

파이널라이저그룹.register(관심객체, "관심 객체가 사라졌다!", 관심객체)

register 메서드의 첫 번째 인자로 가비지 컬렉션 시점을 알고 싶은 관심객체를 전달하고 두 번째 인자는 가비지 컬렉션이 되었을 때 가비지컬렉션되면해야할작업 콜백에 인자로 전달할 값입니다. 이 값은 스트링 외에도 자바스크립트의 모든 타입이 허용됩니다. 세 번째 인자는 관심객체에 대해 더 이상 관심이 없어져 파이널 라이저를 해제할 때 전달할 토큰입니다. 일반적으로 관심 객체를 토큰으로 하는데요 이것도 뭐든 상관 없고 객체기만 하면 됩니다. 이 토큰을 이용해 unregister를 할 수 있습니다. DOM 이벤트 핸들러 해제할 때 사용하는 함수 같은 거죠.

finalizationGroup.unregister(관심객체)

몇 개를 그룹지어 일괄적으로 해제하고 싶다면 그 용도의 토큰 객체 하나를 만들어 재사용하면 편하겠죠?

register할 때 두 번째 인자였던 콜백에 전달하는 값은 내부에서 강한 참조를 갖습니다. 항상 전달될 값이 보장되죠. 토큰은 약한 참조를 갖습니다. 이렇게 다르게 적용되는 참조 전략은 효율적인 것 같아요. WeakRef의 좋은 예이기도 하구요.

WeakRefs파트에서 사용했던 예제를 조금 변경해서 파이널라이저를 활용해서 obj 객체가 가비지컬렉션 되어 사라졌을 때 무언가를 하도록 해볼게요.

const map = new Map();

map.set('someData', new WeakRef(obj));

const finalizer = new FinalizationRegistry((v) => console.log(v));

finalizer.register(obj, 'obj 어디갔니!', map);

간단하죠? 여기서는 맵을 토큰으로 활용했어요. 나중에 finalizer.unregister(map) 이렇게 해제할 수 있겠죠?

WeakRef + Finalizer 조합의 예

WeakRef 과 Finalizer 조합으로 무엇을 만들어보면 좋을까 고민해 봤어요. TC39 제안 문서에도 예제가 있고 또 검색하면 좋은 예제가 나올 수도 있겠지만 그냥 직접 생각해 보고 싶었습니다. 적지 않은 시간 고민 끝에 생각해낸 것이 Weak Linked List입니다. 이름에서 알 수 있듯 저장할 데이터를 약한 참조로 들고 있는 링크드 리스트입니다. WeakMap과 비슷한데 약한 참조가 키가 아닌 데이터인거죠.

여기에 파이널라이저를 이용하면 좀 더 그럴싸하게 만들 수 있을 것 같아요. 가비지 컬렉션에 의해 특정 데이터가 삭제되면 파이날라이저를 이용해서 데이터가 사라진 아이템을 리스트에서 제거하고 링크드 리스트가 끊기지 않게 복구하는 겁니다. 항상 유효한 데이터의 아이템들만 유지하는 것이죠. 코드를 한번 볼게요. 링크드 리스트 구현 코드는 편의상 조금 간소하게 구현했습니다.

const weakLinkedList = ()=>{
  const head = {
    next: null
  }

  let tail = head;

  // (2)
  const finalizer = new FinalizationRegistry(({prevItem, removedItem})=>{
    prevItem.next = removedItem.next

    if (removedItem === tail) {
      tail = prevItem;
    }
  }
  );

  const add = (newData)=>{
    const item = {
      next: null,
      data: new WeakRef(newData)
    };

    tail.next = item;

    // (1)
    finalizer.register(newData, {
      prevItem: tail,
      removedItem: item
    }, head);

    tail = item;
  }

  return {
    head,
    add
  }
}

(1) 에서 데이터를 링크드 리스트에 집어넣으면서 파이널 라이저 콜백에 전달할 데이터로 이전 아이템(prevItem)과 데이터가 사라진 아이템(removedItem)을 객체로 만들었어요. 토큰으로 head를 사용했습니다. (2)의 콜백에서는 아이템 두 개를 전달받아서 데이터가 사라진 아이템을 제거하기 위해 next를 서로 연결했습니다. 이때 지워진 아이템이 tail이라면 add가 정상동작하지 않을 수 있으니 이전 아이템으로 tail을 변경했어요.

링크드리스트의 어딘가에 있는 아이템에서 들고 있던 데이터가 가비지 컬렉션 되면 파이널라이저 콜백에 의해 빈 데이터를 들고 있는 아이템이 없도록 해당 아이템을 빼고 연결을 하는 거죠. 단일 링크드리스트를 구현했지만 이중 링크드리스트도 충분히 만들 수 있을 것 같습니다. 예제를 테스트해보니 정상 동작했습니다. 다만 WeakRef를 다뤘던 더 단순한 예제와는 조금 다른 양상을 보이더군요. 조금 더 오래 가비지 컬렉션을 견뎌냅니다. 엔진 내부 구현에 따라 조금씩 다르게 동작할 것으로 생각됩니다.

WeakRefFinalizer 조합이 많이 사용되고 사용 사례가 공유돼서 더 다양한 방법으로 어플리케이션에서 활용될 수 있었으면 좋겠네요.

한 놈만 걸려라 Promise.any

여러 개의 프로미스를 다루는 새로운 메서드가 추가됐습니다. 바로 Promise.any()입니다. Promise.all()이 주어진 프로미스가 모두 해결(resolve) 되어야 해결되는 반면 Promise.any()는 프로미스가 몇 개든 아무튼 하나만 해결되면 됩니다. all()AND조건이라면 any()OR조건이라고 볼 수 있겠네요.

const 선수A = new Promise((resolve) => {
   setTimeout(() => resolve('선수A가 1등'), Math.floor(Math.random() * 10));
});

const 선수B = new Promise((resolve) => {
   setTimeout(() => resolve('선수B가 1등'), Math.floor(Math.random() * 10));
});


(async () => {
    console.log(await Promise.any([선수A, 선수B]));
})();

결과는 선수A나 선수B중 1등인 것 하나만 출력됩니다.

Promise.any() 가 주어진 프로미스중 하나라도 해결되면 해결로 판정하기 때문에 예외 혹은 거절(reject) 되는 조건은 마지막 하나까지 기다리다가 결국 모두 거절되었을 때 거절로 판정됩니다. reject을 거절이라고 변역하니까 이상하네요.

일반적으로 서버 API를 호출해 인터렉션과 연동 하는 상황에서는 글쎄요 아직 어떻게 사용하게 될지 잘 모르겠네요.

드디어 String replaceAll 을 내장한

String에는 문자열의 일부를 변경할 수 있는 replace라는 메서드가 있죠. 그런데 이 메서드는 단점이 있었어요. 문자열을 변경하는 건 좋은데 딱 첫 번째 만나는 한 개만 변경됩니다.

const message = 'What will be, will be';

message.replace('will', 'it'); // 'What it be, will be';

네 아마 대부분 아실거에요. 그동안의 설움은 이루 말할 수 없었죠. 그래서 우리는 어떻게 했습니까? split-join 꼼수를 사용하거나 정규식을 사용했죠.

const message = 'What will be, will be';

message.split('will').join('it');  // 'What it be, it be';
message.replace(/will/g, 'it');  // 'What it be, it be';

다른 언어에 익숙하셨던 분들이 자바스크립트를 다루실 때 자주 당황하는 부분이기도 해요. "네, 리플레이스올 없어요" 라고 대답해 주면서 이상하게 조금 부끄러웠죠. 나와 자바스크립트는 동체가 아닌데 말이죠.

자 이제 자신 있게 말할 수 있습니다. "리플레이스 올을 쓰세요!"

message.replaceAll('will', 'it');  // 'What it be, it be';

별것 아니지만 이 별것 아닌 것을 갖게 되는데 많은 시간이 걸렸네요. 그래서 더 눈에 띕니다.

기타 등등

기타 등등은 짧게 요약할게요.

논리 할당 연산자가 생겼어요. 할당 연산자의 종류가 늘어난 셈이죠.

a &&= b // a = a && b
a ||= b // a = a || b
a ??= b // a = a ?? b

이렇게 세 가지의 논리 할당 연산자가 생겼습니다. 참과 거짓을 다루는 논리 연산에도 사용할 수 있지만 그보다는 값의 유무를 판단해 새로운 값을 할당하는 방법으로 자주 사용될 것 같아요. a &&=b는 아래와 같은 의미로 사용될 수 있겠죠.

if (a) {
   a = b
}

그리고 숫자 구분자가 생겼어요. 숫자를 코드상에서 인식하기 쉽도록 숫자 리터럴에 구분자를 사용할 수 있게 됐습니다. 구분자는 언더바입니다. 콤마였으면 더 좋았을테지만 언어적인 문제가 있었다네요.

const separatedNumber = 1_000_000_000;

// 10진수 외의 다른 진법의 리터럴도 OK
const binary = 0b1010_1010
const hex = 0xB0_A0_00

마지막으로 Array.prototype.sort의 스펙이 좀 더 정교해졌습니다. sortimplementation-defined 로 기본적인 스펙만 제공하고 나머지는 브라우저에게 맡겼기 때문에 브라우저마다 구현 방식이 달랐습니다. 그래서 브라우저마다 정렬 결과가 달랐던 경우가 있었는데요. 이 스펙을 좀 더 정교하게 정의해서 브라우저마다 다를 수 있는 경우의 수를 줄였습니다.

끝으로

자 이렇게 해서 뒤늦게 ECMAScript 2021을 정리해봤습니다. 개인적으로는 WeakRefsFinalizers가 제일 흥미로웠습니다. 어떻게 이것들을 잘 활용해볼 수 있을까 행복한 고민 중입니다. 이놈의 코로나는 언제 가비지 컬렉션 될까요? 빨리 사라졌으면 좋겠습니다.

최근에 제가 이직하게 된 회사인 카카오 엔터테인먼트에서는 현재 다양한 개발자들을 많이 채용하고 있습니다. 관심 있거나 궁금한점이 있으신분은 저에게 이메일(shirenbeat@gmail.com) 주시면 제가 아는 선에서 답변드리고 도와드리겠습니다. 참고로 올해 개편된 복지제도는 다방면에서 많이(월등히) 좋아졌습니다.

Buy Me A Coffee

크리에이티브 커먼즈 라이선스이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-변경금지 4.0 국제 라이선스에 따라 이용할 수 있습니다.

© Sungho Kim2021