Deep JavaScript

WeakRef와 FinalizationRegistry (메모리 관리)

GC에 의해 수거되는 리소스에 대해 개발자가 처리해야할 때 알아두면 좋은 코드이다. 바로 WeakRef와 FinalizationRegistry인데 관련된 코드는 다음과 같다.

// 무거운 데이터 객체
let bigData = { payload: new Array(1000000) };

// 약한 참조 생성
// WeakRef: 객체를 참조하되, 가비지 컬렉션의 대상이 되는 것을 막지 않는 '약한 참조'를 만듭니다. 
// 객체가 메모리에서 해제되면 참조도 자동으로 끊깁니다.
const ref = new WeakRef(bigData);

// 사용처에서 꺼내 쓰기
const derefData = ref.deref();
if (derefData) {
  console.log("아직 메모리에 남아있음:", derefData.payload);
}

// 특정 객체가 메모리에서 해제될 때 정리 작업을 해주는 레지스트리
// FinalizationRegistry: 객체가 가비지 컬렉터에 의해 메모리에서 완전히 지워지는 시점에 특정 콜백 함수를 실행하도록 등록합니다.
const registry = new FinalizationRegistry((heldValue) => {
  console.log(`${heldValue} 객체가 가비지 컬렉션되었습니다.`);
});
registry.register(bigData, "무거운 데이터");

// 참조를 끊으면 가비지 컬렉터가 수거해감
bigData = null;

코드 실행을 일정 시간 블로킹시키는 함수

Node 환경과 웹브라우저 환경에 따라 다르긴 한데 Node에서는 다음 방법을 추천한다.

function sleep(ms) {
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

console.log("시작 - 1");
sleep(2000); // 2초 블로킹
console.log("2초 후 - 1");

웹 브라우저에서는 다음 방법을 추천하며 Node에서도 가능함.

function sleepWebBrowser(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

console.log("시작 - 2");
await sleepWebBrowser(2000);
console.log("2초 후 - 2");

Object.groupBy() (데이터 그룹화 공식 지원)

배열의 데이터를 특정 기준에 따라 객체로 묶어주는 기능입니다. 과거에는 reduce를 복잡하게 쓰거나 외부 라이브러리(lodash 등)를 썼어야 했는데, 이제 네이티브 문법으로 지원.

const inventory = [
  { name: "asparagus", type: "vegetables", quantity: 5 },
  { name: "bananas", type: "fruit", quantity: 0 },
  { name: "goat", type: "meat", quantity: 23 },
  { name: "cherries", type: "fruit", quantity: 5 },
];

// 'type' 속성을 기준으로 그룹화
const result = Object.groupBy(inventory, ({ type }) => type);

/*
결과:
{
  vegetables: [ 
    { name: "asparagus", type: "vegetables", ... } 
  ],
  fruit: [ 
    { name: "bananas", ... }, 
    { name: "cherries", ... } 
  ],
  meat: [ 
    { name: "goat", ... } 
  ]
}
*/

Proxy와 Reflect (메타 프로그래밍)

자바스크립트의 기본 동작(객체의 속성 조회, 할당, 열거 등)을 가로채서(Intercept) 사용자 정의 동작을 주입하는 강력한 기능임. Vue 3 같은 모던 프레임워크의 상태 변화 감지(Reactivity) 시스템이 바로 이 Proxy를 기반으로 동작함.

const target = { message: "hello" };

const handler = {
  // 객체의 속성을 읽으려고 할 때(get) 가로챔
  get(target, prop, receiver) {
    console.log(`[로그] ${prop} 속성에 읽기로 접근했습니다.`);
    return Reflect.get(...arguments); // 원래 동작 수행
  },
  // 객체의 속성을 바꿀 때(set) 가로챔
  set(target, prop, value, receiver) {
    console.log(`[로그] ${prop} 속성에 쓰기로 접근했습니다.`);

    if (prop === 'age' && value < 0) {
      throw new Error("나이는 음수가 될 수 없습니다.");
    }
    return Reflect.set(...arguments);
  }
};

const proxy = new Proxy(target, handler);
console.log(proxy.message); // [로그] message 속성에 읽기로 접근했습니다. -> "hello"
proxy.message = "안녕하세요." // [로그] message 속성에 쓰기로 접근했습니다. 
proxy.age = 10; // [로그] age 속성에 쓰기로 접근했습니다.
proxy.age = -10; // [로그] age 속성에 쓰기로 접근했습니다. (Error: 나이는 음수가 될 수 없습니다.)

구조적 복제 (Structured Clone)

객체의 깊은 복사(Deep Copy)를 수행하는 내장 API임. 과거에는 JSON.parse(JSON.stringify(obj))라는 편법을 썼지만, 이 방식은 Date, RegExp, Map, Set 같은 특수 객체나 순환 참조가 있으면 깨지는 치명적인 문제가 있음. structuredClone은 이를 완벽하게 복사해 줌.

const original = {
  date: new Date(),
  set: new Set([1, 2, 3]),
  nested: { inner: "value" }
};

// 완벽한 깊은 복사
const clone = structuredClone(original);

console.log(clone.date instanceof Date); // true
console.log(clone.nested === original.nested); // false (참조가 분리됨, 깊은 복사로 객체에 대한 주소값이 다름)

생성기 (Generators)와 이터레이터

함수의 실행을 중간에 멈췄다가(yield), 원하는 시점에 다시 재개(next)할 수 있는 특수 함수. 비동기 스트림을 제어하거나, 메모리를 아끼면서 대용량의 무한한 데이터를 순차적으로 처리할 때 매우 유용하며 필자는 메인 스레드를 얼리지 않고 대량의 데이터를 순차적으로 처리하는 기능에 적용하기도 함.

function* idGenerator() {
  let id = 1;
  while (true) {
    yield id++; // 여기서 실행을 멈추고 값을 반환
  }
}

const gen = idGenerator();
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
console.log(gen.next().value); // 3 (필요할 때만 값을 무한히 생성)

명시적 자원 관리 (Explicit Resource Management – using)

최근 ECMAScript 스펙에 추가되고 있는 매우 획기적인 기능. 파일 핸들러, 네트워크 소켓, 데이터베이스 커넥션, 혹은 그래픽 컨텍스트처럼 사용 후 반드시 닫아주어야(close/dispose) 하는 자원을 다룰 때 씀. try…finally로 일일이 닫지 않아도, 블록을 벗어나면 자동으로 자원이 해제됨.

// 자원 정의 (Symbol.dispose 메서드를 구현해야 함)
const disposableResource = {
  [Symbol.dispose]() {
    console.log("자원이 자동으로 해제되었습니다.");
  },
  action() {
    console.log("작업 수행 중...");
  }
};

{
  // using 키워드로 선언
  using res = disposableResource;
  res.action();
} // 이 블록을 나가는 순간 Symbol.dispose()가 자동으로 호출됨!

using은 최근에 도입된 아직은 시험 중으로 실행시 다음 옵션이 필요함. node --js-explicit-resource-management

ArrayBuffer.prototype.resize() & SharedArrayBuffer.prototype.grow()

메모리 버퍼의 크기를 가변적으로 조절할 수 있는 기능. WebGL/WebGPU를 다루거나 큰 바이너리 데이터를 청크(Chunk) 단위로 읽어 들일 때, 이전에는 버퍼 크기가 모자라면 더 큰 버퍼를 새로 만들고 기존 데이터를 하나하나 복사(ArrayBuffer.transfer)해야 했음. 이제는 내부 메모리 주소를 유지한 채로 최대 크기(maxByteLength) 범위 내에서 버퍼를 동적으로 늘리고 줄일 수 있음.

// 초기 1MB, 최대 10MB까지 커질 수 있는 버퍼 선언
const buffer = new ArrayBuffer(1024 * 1024, { maxByteLength: 10 * 1024 * 1024 });

console.log(buffer.byteLength); // 1MB

// 복사 작업 없이 인라인으로 즉시 2MB로 확장
buffer.resize(2 * 1024 * 1024);
console.log(buffer.byteLength); // 2MB

다중 파일의 드랍에 대한 안전한 처리

웹 API가 계속 발전하고 그에 맞춰 Javascript도 개선되는 과정에서 괴리가 발생하는데요. 그중 한가지 사례로 다중 파일에 대한 드랍처리입니다.

아래의 드랍 처리는 2개 이상의 파일을 드랍했을때 처리되어야할 파일이 누락됩니다.

dropArea.addEventListener('drop', async (e) => {
  e.preventDefault();
  dropArea.classList.remove('highlight');
  fileListElement.innerHTML = '';

  const items = e.dataTransfer.items;
  if (items) {
    for (let i = 0; i < items.length; i++) {
      try {
        const handle = await items[i].getAsFileSystemHandle();

        if (handle.kind === 'file') {
          const file = await handle.getFile();
          displayFile(file);
        } else if (handle.kind === 'directory') {
          await traverseFileSystemHandle(handle);
        }
      } catch (error) {
        console.error(error);
      }
    }
  }
});

위 코드의 문제점에 대한 원인은 비동기 처리를 위해 js에서는 for await .. of 문이 도입되었으나 아직 drop 이벤트 객체의 dataTransfer.items 컬렉션은 이를 지원하지 않기 때문입니다. 차선책으로 이를 개선한 코드가 다음과 같습니다.

dropArea.addEventListener('drop', async (e) => {
  e.preventDefault();
  dropArea.classList.remove('highlight');

  fileListElement.innerHTML = '';

  const items = e.dataTransfer.items;
  if (items) {
    const processingPromises = [];

    for (let i = 0; i < items.length; i++) {
      processingPromises.push((async () => {
        if (items[i].getAsFileSystemHandle) {
          try {
            const handle = await items[i].getAsFileSystemHandle();
            if (handle.kind === 'file') {
              const file = await handle.getFile();
              displayFile(file);
            } else if (handle.kind === 'directory') {
              await traverseFileSystemHandle(handle);
            }
          } catch (error) {
            console.warn(error);
          }
        }
      })()); // 즉시 실행
    }
    await Promise.allSettled(processingPromises);
  }
});

Compression Streams API를 이용한 텍스트 데이터에 대한 압축 및 해제

Compression Streams API는 웹 표준 API로 데이터에 대한 압축 및 해제를 위한 API이다. 파일 단위가 아닌 데이터에 대한 압축으로 그 목적이 zip과는 다르지만 데이터 압축에 대한 알고리즘은 유사하다. 즉, zip은 여러개의 파일을 압축하면서 압축 해제시에는 파일 명과 디렉토리 구조까지 복원해주지만 Compression Sterams API는 데이터에 대한 압축으로 압축을 해제하면 해제된 데이터가 나오며 그 데이터를 하나의 파일로 저장한다.

Compression Streams API로 압축할 수 있는 데이터는 텍스트 든 바이너리든 모두 가능한데, 아래의 코드는 텍스트에 대한 압축과 압축된 데이터를 다시 원래의 텍스트 데이터로 복원(압축 해제)하는 코드이다.

async function compressAndDecompressString(text) {
  // 1. 텍스트를 Uint8Array로 변환
  const textEncoder = new TextEncoder();
  const encodedData = textEncoder.encode(text);

  console.log("원본 데이터:", text);
  console.log("원본 데이터 크기 (바이트):", encodedData.byteLength);

  // 2. 데이터를 압축 (gzip 형식)
  // ReadableStream.from()은 Node.js에서 사용 가능하며, 
  // 브라우저에서는 ArrayBuffer를 직접 ReadableStream으로 변환해야 함.
  // 여기서는 편의를 위해 Blob을 사용하여 ReadableStream을 생성합니다.
  const originalStream = new Blob([encodedData]).stream();
  const compressedStream = originalStream.pipeThrough(new CompressionStream("gzip"));

  // 3. 압축된 데이터를 ArrayBuffer로 읽기
  const compressedResponse = new Response(compressedStream);
  const compressedBuffer = await compressedResponse.arrayBuffer();

  console.log("압축된 데이터 크기 (바이트):", compressedBuffer.byteLength);
  console.log("압축률:", 
    ((1 - compressedBuffer.byteLength / encodedData.byteLength) * 100).toFixed(2) + "%");

  // 4. 압축된 데이터를 압축 해제
  const decompressedStream = new Blob([compressedBuffer]).stream()
    .pipeThrough(new DecompressionStream("gzip"));

  // 5. 압축 해제된 데이터를 ArrayBuffer로 읽기
  const decompressedResponse = new Response(decompressedStream);
  const decompressedBuffer = await decompressedResponse.arrayBuffer();

  // 6. 압축 해제된 Uint8Array를 다시 텍스트로 변환
  const textDecoder = new TextDecoder();
  const decompressedText = textDecoder.decode(decompressedBuffer);

  console.log("압축 해제된 데이터:", decompressedText);
  console.log("압축 해제된 데이터 크기 (바이트):", decompressedBuffer.byteLength);

  // 원본 데이터와 압축 해제된 데이터가 동일한지 확인
  console.log("원본과 압축 해제된 데이터 일치 여부:", text === decompressedText);
}

// 예제 실행
const longText = `The quick brown fox jumps over the lazy dog.`;

compressAndDecompressString(longText);