본문으로 건너뛰기

JavaScript에서 이진 데이터 이해하기 : ArrayBuffer, TypedArray, Blob, File

· 약 25분
MinjiKim
MinjiKim
FrontEnd Engineer

Intro

업무에서 이미지를 다루기 시작하면서 이진 데이터를 표현하고 처리하는 방법에 대해 알아보는 시간을 가졌습니다. 이진 데이터는 보통 파일을 생성, 업로드, 다운로드, 전송할 때 사용됩니다.

JavaScript에는 이런 데이터를 표현하고 다루기 위한 다양한 타입이 제공됩니다. File, Blob, ArrayBuffer, TypedArray와 같은 타입이 그 예로, 각각의 방식은 조금씩 다른 특성과 용도로 사용 되고 있습니다. 이 글에서는 각 타입이 어떤 상황에서 가장 잘 맞는지, 또 어떤 차이점이 있는지 알아보려고 합니다.

TL;DR

자바스크립트에서 다양한 이진 데이터 타입을 이해하면 효율적으로 데이터를 처리할 수 있습니다. 각 타입은 다음과 같은 특징과 용도를 갖고 있습니다:

  • ArrayBuffer: 고정된 크기의 메모리 공간을 참조하며 기본적인 이진 데이터 저장에 사용됩니다. 대용량 파일 데이터를 직접 조작해야 할 때, 필요한 부분만 메모리에 로드하여 효율적으로 관리할 수 있습니다.

  • TypedArray: ArrayBuffer 위에서 특정 바이트 단위로 데이터를 해석할 수 있는 view를 제공합니다. 예를 들어, Uint8Array, Int16Array 등 다양한 정수와 실수 타입으로 해석할 수 있어 연산에 강점이 있습니다. 이미지 픽셀 데이터와 같이 숫자 값으로 저장된 데이터를 배열 형태로 처리할 때 유용합니다.

  • DataView: ArrayBuffer 데이터를 다양한 형식으로 유연하게 해석할 수 있도록 하는 view입니다. 여러 형식의 데이터가 혼합된 버퍼를 처리할 때 유리하며, 네트워크 패킷 데이터와 같이 혼합된 형식으로 저장된 데이터를 순서에 따라 해석하는 데 적합합니다.

  • Blob: MIME 타입을 포함하여 이진 데이터를 관리하며, 파일 업로드와 다운로드 시 자주 사용됩니다. 서버에서 받은 Blob 데이터를 URL로 변환하여 사용자에게 표시하거나 다운로드할 수 있습니다.

  • File: Blob 객체에 파일 시스템 메타 데이터가 추가된 타입으로, 파일 선택 및 파일 읽기 작업을 위한 <input> 요소와 함께 주로 사용됩니다. 사용자가 로컬 파일을 업로드할 때 File 객체를 통해 서버로 전송하거나 파일 내용을 읽을 수 있습니다.

JavaScript에서 이진 데이터 이해하기

바이너리 데이터의 기본 구조, ArrayBuffer

자바스크립트에서 이진 데이터를 처리하는 데 가장 기본이 되는 객체는 ArrayBuffer입니다. 모든 이미지, 비디오, 오디오와 같은 파일의 기저에는 ArrayBuffer가 있다고 볼 수 있습니다.

// 16 바이트 연속적 메모리 공간이 0으로 채워진다.
let buffer = new ArrayBuffer(16);

ArrayBuffer는 고정된 길이의 연속적인 메모리 영역에 대한 참조로, 가공되지 않은(raw) 이진 데이터입니다. 이름만 봤을 때는 ArrayBuffer와 자바스크립트의 일반 배열 Array가 헷갈릴 수 있습니다. 하지만 ArrayBuffer는 배열과 무관합니다. ArrayBuffer의 특징은 다음과 같습니다.

  • 고정된 길이를 가지며, 이를 늘리거나 줄일 수 없습니다.
  • 메모리에서 정확히 고정된 길이만큼의 공간을 차지합니다.
  • 각 바이트에 접근하려면 또 다른 ‘view’ 객체가 필요합니다. 일반 배열처럼 buffer[index]로 접근할 수 없습니다.

이 고정된 길이의 데이터를 조작하려면 view 객체가 필요합니다. view 객체는 스스로는 어떠한 데이터도 저장하지 않으며, ArrayBuffer의 데이터를 해석하는 다른 방식을 제공하는 객체입니다. 바로 다음에 살펴볼 내용인 Uint8Array, Uint16Array등의 TypedArray가 이 view에 해당합니다.

예를 들어, Uint8Array는 이름에서 알 수 있듯이 ArrayBuffer를 읽을 때 1바이트(=8비트)를 하나의 정수로 해석합니다. 즉, ArrayBuffer의 각 바이트는 0~255(2^8개)까지의 숫자로 읽을 수 있고, 이를 8비트의 부호 없는(unsigned) 정수라고 합니다. 그렇다면 Uint16Array는 어떨까요? Uint16Array는 ArrayBuffer의 2바이트를 하나의 정수로 해석합니다. 따라서 0~65535(2^16개)까지 표현이 가능하며, 이를 32 비트의 부호 없는 정수라고 합니다.

간단한 예제를 살펴보겠습니다. 16바이트의 ArrayBuffer에 저장된 바이너리 데이터를 이러한 view 들을 통해 읽어보겠습니다. 이 데이터는 각 바이트마다 하나의 숫자로 읽을 수도 있고, 2바이트 또는 4바이트를 하나의 숫자로 해석할 수 있습니다.

arraybuffer

그림에서 볼 수 있듯이 16바이트의 ArrayBuffer를 Uint8Array view로 보는 경우, 각 바이트를 하나의 숫자로 해석하고, Uint16Array view로 보는 경우, 각 2바이트를 하나의 숫자로 해석할 수 있습니다.

ArrayBuffer는 개발자가 메모리를 직접 관리할 수 있기 때문에 성능에 민감한 이슈를 다루거나 매우 큰 용량의 파일 데이터를 다루는 경우에 효율적으로 작업할 수 있습니다.


ArrayBuffer를 해석하는 방법, TypedArray

위에서 살펴본 Uint8Array와 Uint32Array와 같은 view를 일반적으로 TypedArray라고 부릅니다. 이들을 동일한 메서드와 프로퍼티를 공유하며, 일반적인 배열처럼 동작합니다. 즉, 인덱스가 존재하며, 순회가 가능합니다.

let arr8 = new Uint8Array([0, 1, 2, 3]);

// 동일한 데이터를 다른 view로 해석
let arr16 = new Uint16Array(arr8.buffer);

주의할 점은 TypedArray는 ArrayBuffer를 해석할 수 있는 view 가운데 하나를 나타내기 위한 포괄적인 용어로, 따로 TypedArray라는 생성자가 존재하는 것은 아니라는 것입니다. TypeArray의 종류는 다음과 같습니다.

  • 이진 데이터를 부호가 없는 정수로 해석하는 Uint8Array, Uint16Array, Uint32Array, Uint8ClmampedArray. 각각 몇 바이트를 하나의 정수로 해석할지를 결정.
  • 이진 데이터를 부호가 있는 정수로 해석하는 Int8Array, Int16Array, Int32Array
  • 이진 데이터를 부호가 있는 실수로 해석하는 Float32Array, Float64Array

TypedArray 종류의 생성자들은 인자 타입에 따라 다르게 동작합니다. ArrayBuffer를 참조하지 않고 TypedArray를 직접 생성할 수 있지만, view는 데이터 없이 존재할 수 없기 떄문에 ArrayBuffer를 미리 만들어 전달하는 경우 이외에는 자동으로 생성됩니다. arr.buffer 를 통해 ArrayBuffer를 읽을 수 있고, arr.byteLength를 통해 ArrayBuffer의 길이를 읽을 수 있습니다. 이를 통해 동일한 ArrayBuffer를 다른 방식으로 해석할 수 있습니다.

// 자동으로 4바이트의 ArrayBuffer를 생성하고, buffer를 8비트 정수의 연속으로 보겠다.
let arr8 = new Uint8Array([0, 1, 2, 3]);

console.log(arr8.buffer.byteLength); // 4(바이트)

// 동일한 데이터를 다른 view로 해석
let arr16 = new Uint16Array(arr8.buffer);

그렇다면 TypedArray의 범위를넘어가는 값을 쓰려고 하면 어떻게 될까요? 에러는 발생하지 않지만, 넘어가는 부분이 잘리게 됩니다. 이를 Out-of-bounds라고 합니다.

예를 들어, Uint8Array는 0~255까지의 수를 표현할 수 있는데, 256을 넣는다고 가정해보겠습니다. 이 경우, 가장 오른쪽 8비트가 저장되고 나머지를 잘립니다. 따라서 256을 넣으면 00000000, 즉 0을 얻고, 마찬가지로 257을 넣으면 00000001, 즉 1을 얻게 됩니다. 이를 통해 Uint8Array가 해석할 수 있는 개수(2^8)를 넘어가는 수를 넣는 경우, 2^8으로 나눈 나머지 값이 된다는 것을 알 수 있습니다.

// 16바이트 ArrayBuffer 생성 -> 1바이트를 정수로 읽겠다.
let uint8Array = new Uint8Array(16);

let num = 256;
alert(num.toString(2)); // 100000000(바이너리 표현)

// 해석 가능한 범위(255)를 넘어가는 숫자를 넣으면?
uint8Array[0] = 256;
uint8Array[1] = 257;

alert(uint8Array[0]); // 0
alert(uint8Array[1]); // 1

아까 살펴봤던 TypedArray의 종류 가운데 Uint8ClampedArray를 기억하시나요? clamp는 보통 어떤 범위 안으로 숫자를 제한할 때 사용됩니다. 이와 유사하게 해석 가능한 가장 큰 숫자인 255보다 큰 경우, 255를 저장하고, 음수는 0을 저장하는 방식으로 값을 제한합니다.


유연한 데이터 해석을 위한 DataView

DataView는 TypedArray와 마찬가지로 ArrayBuffer를 해석하는 하나의 방식입니다. 차이점은 모든 형식의 모든 위치에서 데이터에 접근이 가능한 매우 유연한 view라는 점입니다.

TypedArray의 경우, 생성자로 생성할 때 형식을 지정합니다. 모든 Array는 하나의 타입이어야하고, i번째 숫자는 array[i]여야 합니다. 이에 반해, DataView는 데이터에 .getUint8(i)과 같은 메서드로 접근이 가능하며, 생성할 때가 아니라 메서드를 호출하는 시점에 해석의 기준이 될 타입을 결정합니다. 또한 기반이 되는 ArrayBuffer를 스스로 생성하지 않으므로 미리 만들어 전달해야한다는 차이점이 있습니다. DataView는 유연하기 때문에 동일한 버퍼에 혼합된 타입의 데이터를 저장한 경우, 적합합니다.

// 1바이트=1정수인 ArrayBuffer 생성
let buffer = new Uint8Array([255, 255, 255, 255]).buffer;

// 해당 ArrayBuffer를 해석하는 DataView 생성
let dataView = new DataView(buffer);

// 0 위치에서 8 비트 숫자 가져오기
alert(dataView.getUint8(0)); // 255
// 0 위치에서 16비트 숫자 가져오기
alert(dataView.getUint16(0)); // 65535(16비트 부호 없는 숫자 중 가장 큰 숫자)
// 0 위치에서 32 비트 숫자 가져오기
alert(dataView.getUint32(0)); // 4294967295(32비트 부호 없는 숫자 중 가장 큰 숫자)

dataView.setUint32(0, 0); // 4바이트 숫자를 0으로 설정, 즉 모든 숫자를 0으로 설정

위에서 살펴본 ArrayBuffer에 대한 view를 통틀어 ArrayBufferView라고 부릅니다. 또한, ArrayBuffer와 이를 해석하는 방식인 ArrayBufferView를 합쳐 BufferSource라고 합니다. BufferSource는 모든 형태의 이진 데이터를 뜻하며 널리 통용되는 용어입니다.


타입이 있는 바이너리 데이터, Blob

BufferSource가 이진 데이터라면, Blob은 타입이 있는 이진 데이터를 나타냅니다. 주로 이미지, 오디오, 비디오와 같은 멀티미디어 파일을 객체 형태로 저장하고, 타입을 기반으로 동작하는 파일 업로드/다운로드 동작에 사용됩니다.

Blob은 type과 blobParts로 구성됩니다.

new Blob(blobParts, options);
  • type: Blob의 종류. 주로 MIME-type. 예. image/png
  • blobParts:

Blob 객체는 불변합니다. string 타입의 불변성을 생각해 보면 됩니다. 문자열의 문자 자체를 변경할 수는 없지만, 이를 기반으로 새로운 문자열을 생성해 사용할 수 있는 것처럼, Blob도 직접적으로 데이터를 변경할 수 없지만 일부를 잘라내어 새로운 Blob 객체를 만들어 활용할 수 있습니다.

Blob은 URL로 활용할 수 있습니다. 이 URL을 활용해 <a>, <img>와 같은 태그에서 Blob의 내용을 표시할 수 있습니다. 이때 Blob이 가진 type을 기반으로 객체를 다운로드, 업로드하며, 네트워크 요청에서는 이 type값이 Content-Type이 됩니다. Blob을 URL 형태로 만들어 사용할 수 있는 방법은 크게 2가지입니다.

  • URL.creatObjectURL(blob)

    • URL.createObjectURL은 blob을 받아 blob:<origin>/<uuid> 형태의 고유한 URL을 생성합니다.
    • 브라우저는 내부적으로 생성된 이 URL에 대해 URL → Blob 매핑을 합니다. 이매핑을 통해 Blob에 접근하기 때문에 현재 문서 내에서만 유효합니다.
    • 주의할 점은 Blob 자체는 메모리에 있기 때문에 앱이 오랫동안 지속되어 이 매핑이 계속 Blob을 참조하는 경우 Blob이 더 이상 필요하지 않음에도 메모리가 해제되지 않는다는 점입니다.
    • 따라서 URL을 생성할 경우, URL.revokeObjectURL(url)을 통해 내부적인 매핑으로부터 참조를 제거하고, 이에 대한 메모리를 해제해야 합니다.
  • data url

    • Blob을 FileReader를 사용하여 Base64 인코딩된 data url로 사용하는 방법입니다. Base64 인코딩은 이진 데이터를 안전한 읽을 수 있는 형태의 문자열로 나타내는 것입니다.
    • data url은 data:[<mediatype>][;base64]<data>의 형태를 가지며, 이러한 url을 일반적인 url과 동일하게 어디에든 사용할 수 있습니다.

let link = document.createElement("a");
link.download = "hello.txt";

let blob = new Blob(["hello world"], { type: "text/plain" });

let reader = new FileReader();
// blob -> base64로 변환하여 onload 호출
reader.readAsDataUrl(blob);

reader.onload = function () {
link.href = reader.result; // data url
link.click();
};
  • URL.createObjectURL(blob)의 경우, 매핑을 통해 Blob에 직접적으로 접근하고, 인코딩/디코딩 작업이 필요 없다는 장점이 있지만, 메모리를 해제해야한다는 단점이 있습니다. 반면, Base64 인코딩된 data url로 변환하는 경우, 따로 메모리를 해제하지 않아도 되지만, 용량이 큰 Blob 객체를 인코딩하는 경우, 성능과 메모리에 손실이 간다는 단점이 있습니다. 두 API모두 Blob을 사용하는 데 유용하지만, 보통 URL.createObjectURL(blob)이 더 빠르고 간단하기에 흔히 사용됩니다.

Blob이 사용되는 또 다른 경우는 이미지를 캔버스에 그리고 업로드/다운로드하는 경우입니다. 이미지 처리는 <canvas> 요소를 통해 할 수 있습니다.

  1. canvas.drawImage를 통해 이미지를 canvas에 그립니다.
  2. 캔버스 메서드인 .toBlob(callback, format, quality)를 호출하여 Blob을 생성한 후 callback을 호출합니다.
let img = document.querySelector("img");

let canvas = document.createElement("canvas");
canvas.width = img.clientWidth;
canvas.height = img.clientHeight;

let context = canvas.getContext("2d");

// 캔버스에 이미지를 복사한다.
context.drawImage(img, 0, 0);

// toBlob은 비동기적 작업으로, 완료되면 callback을 호출한다.
canvas.toBlob(function (blob) {
// blob이 준비되면 다운로드하세요
let link = document.createElement("a");
link.download = "example.png";

link.href = URL.createObjectURL(blob);
link.click();

// 내부적인 blob 참조를 삭제하여 브라우저가 메모리를 해제한다.
URL.revokeObjectURL(link.href);
}, "image/png");

Blob에 파일 시스템 기능을 더한 File

File 객체는 메타데이터(name, lastModified)를 추가로 가진 일종의 Blob이며, 여기에 자바스크립트가 사용자가 선택한 파일에 안전하게 접근하여 파일을 읽고 처리할 수 있도록 하는 역할을 합니다. Blob을 확장한 객체이기 때문에 Blob이 사용되는 모든 곳에서 사용할 할 수 있습니다.

예를 들어, Blob을 읽을 때 사용하는 FileReader와 URL.createObjectURL, 네트워크 요청을 위해 사용되는 fetch, XMLHttpRequest.send() 와 같은 API에 File 객체를 넣어 동일하게 사용할 수 있습니다.

File 객체를 얻는 데는 두 가지 방법이 있습니다.

  1. 생성자 File

    new File(fileParts, fileName, [options]);
  2. <input file="type">에서 사용자가 선택한 FileList 또는 드래그앤드랍의 DataTransfer 객체(일반적으로 사용되는 방법)

    <input type="file" onchange="showFile(this)" />

    <script>
    function showFile(input) {
    let file = input.files[0];

    console.log(`File name: ${file.name}`); // e.g my.png
    console.log(`Last modified: ${file.lastModified}`); // e.g 1552830408824
    }
    </script>

File 생성자는 Blob과 유사하지만, 추가적인 옵션을 가집니다.

new File(fileBits, fileName, options);

// 파일 만들기
const file = new File(["foo"], "foo.txt", { type: "text/plain" });
  • fileBits는 File 내부에 들어갈 이터러블 객체로, 가능한 값은 Array, BufferSource(Array, TypedArray, DataView), Blob, String 등이 있습니다.
  • fileName은 파일명
  • options
    • type: 파일에 들어갈 내용의 MIME 타입 문자열. 기본은 “”
    • endings: 데이터가 텍스트인 경우, 개행 문자(\n)를 해석하는 방법. 개행을 호스트 시스템의 자체 컨벤션으로 변환하려면 native 를 주면 된다.
    • lastModified: Unix 시간과 파일이 가장 마지막으로 수정된 시간 간의 밀리세컨 숫자. 기본값은 Date.now()
업로드시 사용되는 FileList

HTML <input> 요소의 files 프로퍼티가 반환하는 객체를 나타냅니다. 이를 통해 <input type="file"> 요소로 선택하거나 드래그앤드롭 API로 웹 콘텐츠에 끌어다 놓은 파일 목록에 대한 접근을 제공합니다.

모든 <input> 요소는 FileList 타입의 files 속성을 가지고 있고, 이를 통해 리스트의 파일에 접근할 수 있습니다. (HTMLInputElements.files)

FileList는 수정할 수 없는 리스트를 위해 고안되었으므로 직접적으로 수정하거나 리스트의 내용을 수정할 수 없습니다. 이에 반해 일반적인 자바스크립트 array는 수정이 가능합니다.

브라우저에서 이를 지원하는 이유는 기존의 코드들을 부서지지 않게 하기 위해서, 즉 호환성을 위해서입니다. 그렇다고 해서 FileList가 deprecated된 것은 아닙니다.

개발자가 스스로 이 객체를 만들어서 사용하는 것이 아니라, HTMLInputElements.files와 같은 API에서 가져와서 사용하는데, 이때 실제 array와는 문법이 다름에 유의해야 합니다.

<input id='fileItem' type='file />

// 파일 읽기
const file = document.getElementId('fileItem').files[0]

Outro

결론적으로, 각 이진 데이터 타입은 고유한 목적과 특성이 있어 상황에 맞는 적절한 사용이 필요합니다. 각 타입의 장점을 이해하고 올바르게 활용하면 올바르게 활용하면 성능과 코드의 가독성을 높이고, 웹 애플리케이션의 전반적인 안정성을 보장할 수 있습니다. 이러한 타입들을 잘 이해하고, 적재적소에 활용할 수 있도록 해야겠습니다.

REF