본문 바로가기
빌드노트 검색

빌드노트

structuredClone 뒤 원래 ArrayBuffer 길이가 0이 됐다

structuredClone의 transfer에 넣은 ArrayBuffer는 원본에서 분리된다. 4바이트 버퍼의 일반 복제와 이전을 실행해 길이, 기존 뷰의 undefined, 새 뷰 생성의 TypeError를 비교하고 원본을 다시 쓸지에 따라 transfer 여부를 고른다.

structuredClone을 호출한 뒤 원래 ArrayBuffer의 byteLength가 0이 되고 다시 읽을 수 없다면, 두 번째 인자의 transfer 배열부터 확인한다. 그 배열에 넣은 버퍼는 복제와 함께 소유권이 이전된다. 원본을 계속 읽어야 한다면 structuredClone(source)처럼 해당 버퍼의 transfer를 빼야 한다.

MDN의 transfer 설명 (새 탭에서 열림)은 이전한 원본을 더 이상 사용할 수 없다고 명시한다. 여기서 원본의 바이트가 모두 0으로 바뀌었다는 뜻은 아니다. 원본 버퍼와 바이트 저장소의 연결이 끊기는 분리(detached) 상태가 된다. 반환된 버퍼에서는 이전한 데이터를 읽을 수 있다. HTML 표준의 이전 절차 (새 탭에서 열림)도 ArrayBuffer의 데이터를 넘기고 원본을 분리하는 단계를 정의한다.

기존 코드에서는 structuredClone 호출 위치와 그 뒤에 원본 버퍼를 읽는 위치를 함께 본다. 버퍼를 가리키는 변수를 하나 더 만들어 두거나 호출 전에 Uint8Array 뷰를 만들어 두어도 원본 데이터가 보존되는 것은 아니다. 뷰는 버퍼의 바이트를 읽고 쓰는 통로이며, 별도 복사본이 아니다.

같은 4바이트를 복제하거나 이전하면

아래 코드는 매번 새 4바이트 버퍼에 10, 20, 30, 40을 넣고 일반 복제와 이전을 비교한다. 원본과 결과의 길이, 양쪽의 읽기 결과를 확인한 뒤 결과의 첫 바이트만 바꿔 본다.

Node.js가 설치된 환경에서 reader-demo.mjs 파일에 아래 코드를 저장하고, 그 파일이 있는 폴더에서 node reader-demo.mjs를 실행한다. 이번에는 Windows의 Node.js 22.23.2에서 실행했다. 해당 버전의 공식 문서 (새 탭에서 열림)는 전역 structuredClone을 WHATWG 메서드로 설명한다.

for (const mode of ["clone", "transfer"]) {
  const source = new ArrayBuffer(4);
  const oldView = new Uint8Array(source);
  oldView.set([10, 20, 30, 40]);
  const result = mode === "transfer"
    ? structuredClone(source, { transfer: [source] })
    : structuredClone(source);

  console.log(mode, "lengths", source.byteLength, result.byteLength);
  console.log(mode, "result", [...new Uint8Array(result)].join(","));
  console.log(mode, "old-view[0]", oldView[0]);
  try {
    console.log(mode, "source", [...new Uint8Array(source)].join(","));
  } catch (error) {
    console.log(mode, "source", error.name);
  }
  new Uint8Array(result)[0] = 99;
  console.log(mode, "after-write", oldView[0], new Uint8Array(result)[0]);
}

실제 출력은 다음과 같았다. lengths의 숫자는 원본, 결과 순서이고, after-write는 기존 뷰와 결과에서 각각 읽은 첫 바이트다.

clone lengths 4 4
clone result 10,20,30,40
clone old-view[0] 10
clone source 10,20,30,40
clone after-write 10 99
transfer lengths 0 4
transfer result 10,20,30,40
transfer old-view[0] undefined
transfer source TypeError
transfer after-write undefined 99

일반 복제에서는 원본과 결과 모두 4바이트였다. 결과의 첫 바이트를 99로 바꿔도 원본은 10이었다. 이 버퍼의 복제본은 원본과 바이트 저장소를 공유하지 않았다.

이전에서는 원본 길이가 0, 결과 길이가 4였다. 결과에는 처음 넣은 네 바이트가 그대로 있었다. 원본 데이터가 0으로 덮인 상황이라면 길이 4와 값 0,0,0,0이 나와야 하지만, 여기서는 원본으로 새 뷰를 만드는 단계에서 실패했다.

원본을 다시 쓸지 결정한다

transfer source TypeError는 new Uint8Array(source)에서 발생한 예외다. 분리된 버퍼를 건드리는 모든 연산이 예외를 던지는 것은 아니다. 같은 실행에서 source.byteLength는 0을 반환했고, 호출 전에 만든 oldView[0]는 예외 없이 undefined를 반환했다. 기존 뷰의 인덱스를 한 번 읽어서 예외가 없었다고 원본이 유효하다고 판단하면 안 된다.

ECMAScript의 byteLength 규칙 (새 탭에서 열림)은 분리된 버퍼의 길이를 0으로 반환하도록 정한다. 다만 길이가 0이라는 사실만으로 분리를 판정할 수도 없다. 별도로 만든 new ArrayBuffer(0)은 길이가 0이어도 new Uint8Array를 만드는 데 성공했다. 이 비교에서는 처음 4바이트였다는 조건, 이전 직후의 길이 변화, 새 뷰 생성 실패를 함께 확인했다.

  • 원본이나 기존 뷰를 뒤에서 계속 읽어야 한다면 해당 버퍼를 transfer에 넣지 않는다. 위 예제의 structuredClone(source) 경로처럼 복제하면 원본과 결과를 각각 사용할 수 있다.
  • 원본 쪽 사용을 끝내고 결과 버퍼만 사용할 계획이라면 structuredClone(source, { transfer: [source] })를 선택할 수 있다. 이후 읽기와 쓰기의 대상은 반환값으로 바꾼다. 원본을 참조하는 다른 변수나 기존 뷰도 더 이상 원래 바이트를 읽는 데 사용할 수 없다.

예제에서 structuredClone 자체가 없다는 오류가 나면 먼저 typeof structuredClone과 런타임 버전을 확인한다. 이는 이전 뒤의 TypeError와 다른 문제다. 자기 코드에서 원본 길이가 이미 호출 전부터 0이었다면, 이 비교만으로 그 원인을 transfer라고 단정할 수 없다. 길이가 바뀌는 호출을 먼저 좁혀야 한다.

이번 실행은 새로 만든 작은 고정 길이 ArrayBuffer와 Uint8Array만 다뤘다. 원본의 별도 참조도 이전 후 길이가 0인 같은 객체를 가리키는 것을 확인했다. 브라우저별 실행이나 Worker 사이의 전달, 다른 객체의 이전 가능성은 검사하지 않았다. 속도와 메모리 사용량도 측정하지 않았으므로 성능을 이유로 transfer를 권하지 않는다. 여기서 확인한 선택 기준은 호출 뒤에도 원본 바이트를 사용할 필요가 있는가다.

← 전체 글 보기