[모던JS: 심화] 바이너리 데이터와 파일

KG·2021년 7월 11일
11

모던JS

목록 보기
47/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

바이너리 배열: ArrayBuffer

웹 개발을 진행하다 보면 원시 데이터를 다루어야 할 때를 간혹 마주칠 수 있다. 브라우저에선 주로 파일 생성, 업로드, 다운로드 또는 이미지 처리와 관련이 깊고 만약 서버 사이드인 node.js 환경까지 고려한다면 더 많은 유형의 원시 데이터를 다루는 상황이 있을 수 있다.

자바스크립트를 이용해서도 이러한 원시 바이너리(이진) 데이터에 접근할 수 있다. 보통 이러한 바이너리 데이터에 접근하고 조작하는 작업은 고성능을 위해 필요한 경우가 많다.

앞서 몇몇 챕터에서 자바스크립트의 가비지 컬렉션에 관한 내용과 그 배경에 대해 여러 차례 언급한 바 있다. 주요 내용을 다시 한 번 설명하자면, 자바스크립트는 동적 언어이고 자바스크립트 엔진에 의해 메모리 관리가 이루어진다. 이는 다르게 말하면 개발자가 직접 메모리에 접근할 수 없으며, 따라서 메모리 최적화와 관련한 작업에 권한이 없거나 아주 작은 권한만을 가질 수 있다는 것을 의미한다. 개발자가 메모리를 관리하지 않음으로써 얻는 편리함은 막대하지만, 그로 인해 예상치 못한 시점에서 어떠한 성능 저하 이슈가 발생할 수 있다.

ArrayBuffer를 이용한다면 어느정도 개발자가 수동으로 메모리를 관리할 수 있다. 즉 어느 정도 편리함은 포기하고, 대신 그에 대한 대가로 고성능을 목표하고자 하는 경우에 ArrayBuffer를 이용할 수 있다. 하지만 고성능이라는 키워드가 주는 달콤함에 쉽사리 유혹되지 않도록 주의하자. 사실 대부분의 웹 페이지, 그리고 웹 애플리케이션에서 메모리를 수동으로 관리할 정도로 고성능을 요구하는 경우는 그렇게 흔한 경우가 아니기 때문이다. 혹자는 고성능의 기능 사항을 웹 브라우저에게 기대하는 것 자체가 잘못된 전제라고 이야기하기도 한다. 그렇지만 웹 생태계는 단순히 웹 페이지의 개념을 넘어, 웹 애플리케이션의 단계로 정착한 지 오래되었고 성능 이슈는 모든 엔지니어의 운명과도 같은 숙명이기 때문에 그저 외면하고 있을 수 만은 없다. 때문에 가장 중요한 것은 사용하고자 하는 기술이 오버 엔지니어링이 아닌지에 대한 깊은 고민과 이를 판단할 수 있는 넓은 안목이 필요하겠다.

사설이 길어졌지만 핵심을 요약하자면, ArrayBuffer는 자바스크립트에서 원시 데이터(바이너리 데이터)를 다루는 수단으로 사용되며, 이는 메모리를 개발자가 수동으로 관리할 수 있는 대안을 제시한다. 특히 성능에 민감한 이슈를 다룬다거나, 아니면 추후에 학습할 Blob 등의 큰 용량의 파일 데이터를 다루는 경우에 ArrayBuffer를 사용해 유연하고 효율적으로 작업할 수 있다.

따라서 이번 챕터에서는 ArrayBuffer에 대한 개념과 간단한 사용방법에 대해 살펴보고, 추후 Blob 또는 파일과 관련한 데이터 유형을 살펴보도록 하자.

1) ArrayBuffer

자바스크립트에서 바이너리 데이터는 다른 언어들과 비교하면 비표준 방식으로 구현되어 있다. 자바C언어를 떠올려보자. 가장 대표적인 예시로 각 언어가 숫자를 저장하는 형식을 떠올려볼 수 있을 것이다. 정수형 숫자를 저장하기 위해 int, long, unsigned int 등의 형식을 자바스크립트에서는 전혀 사용하지 않고 오직 number 형만을 사용하는 것은 이미 이전 챕터에서부터 살펴본 자바스크립트의 특징이다. 하지만 ArrayBuffer에서는 다른 언어들과 유사하게 바이너리 데이터 접근을 위해 이러한 형을 사용할 수 있다.

자바스크립트에서 모든 이진 데이터는 기본적으로 모두 ArrayBuffer 객체에 포함된다. ArrayBuffer는 연속된 공간의 메모리에 할당되는 고정 길이에 대한 참조 객체이다. 해당 객체는 다음과 같이 생성할 수 있다.

// 16 바이트 크기의 buffer를 생성
let buffer = new ArrayBuffer(16);
alert(buffer.byteLength);	// 16

이때 생성된 buffer 객체는 연속된 16 바이트 메모리 공간을 차지하고 있으며, 초기값은 모두 0으로 채워져 있다. ArrayBuffer 자체는 그 이름에서 혼동이 생길 수 있지만 자바스크립트의 Array가 아니다. 즉 배열과는 전혀 연관관계가 없다. ArrayBuffer는 자바스크립트의 배열과 비교해 다음과 같은 차이점이 있다.

  • 고정길이를 가지고 있고, 이를 늘이거나 줄일 수 없다.
  • 정확히 명시된 크기만큼의 공간을 메모리에서 차지한다.
  • 각각의 바이트에 접근하기 위해서는, 일반 배열처럼 buffer[index]와 같이 인덱스를 통해 접근할 수 없다. 대신에 view라고 불리는 별도의 객체를 생성해서 접근해야 한다.

ArrayBuffer는 그저 메모리의 연속된 공간을 차지하고 있는 추상적인 메모리 계층과도 같다. 그리고 내부에는 연속된 원시 바이트 정보를 저장하고 있다. ArrayBuffer에 대한 접근 및 조작은 항상 view라고 불리는 객체를 생성해서 수행해야 한다.

view 객체는 마치 안경과도 같은 역할을 한다고 볼 수 있다. view 객체는 자기 스스로는 어떠한 데이터도 저장하고 있지 않고, 그저 ArrayBuffer의 내부를 들여다보기 위한 수단으로 사용한다. 이러한 view 객체에는 다음과 같은 유형이 있고, 이들은 보통 TypedArray 라는 용어로 표현한다.

  • Uint8Array : ArrayBuffer에 있는 각 바이트를 개별적으로 다루는 view 객체이다. 숫자 8은 비트를 의미하고 8비트 = 1바이트이기 때문에 각 바이트별로 접근이 가능하다. 1바이트 크기이기 때문에 0 - 255 범위의 수를 다룰 수 있다. Uint8의 의미는 Unsigned 8bit Int와도 같다.
  • Uint16Array : 2바이트 단위 정수형으로 접근이 가능한 view 객체이다. 따라서 다룰 수 있는 범위는 0 - 65535에 해당한다.
  • Uint32Array : 4바이트 단위 정수형으로 접근이 가능한 view 객체이다. 따라서 다룰 수 있는 범위는 0 - 4294967295에 해당한다.
  • Float64Array : 8바이트 단위로 부동 소수점 방식으로 접근이 가능한 view 객체이다. 다룰 수 있는 범위는 5.0x10^-324 부터 1.8x10^308 까지이다.

앞서 ArrayBuffer의 크기를 16바이트로 생성했기 때문에, 해당 객체는 여러 크기를 가진 view 객체를 통해 접근할 수 있다. 이를 그림으로 나타내면 아래와 같다.

ArrayBuffer는 단지 원시 바이너리 데이터를 다루기 위한 가장 최상위 핵심 객체이다. 하지만 이는 고정된 메모리 공간에 대한 추상화 계층에 해당하기 때문에 여기에 값을 새로 쓴다거나, 반복을 돌린다거나 하는 등의 작업은 항상 view 객체를 통해 수행해야 한다.

let buffer = new ArrayBuffer(16);

let view = new Uint32Array(buffer);

console.log(Uint32Array.BYTES_PER_ELEMENT);	// 4 (32비트 = 4바이트 이므로)

console.log(view.length);	// 4 (16바이트 크기에서 해당 view 형식으로 저장된 정수형 개수)
console.log(view.byteLength);	// 16 (16바이트 크기) 

// view를 통해 값을 입력
view[0] = 123456;

for (let num of view) {
  console.log(num);	// 123456, 0, 0, 0
}

2) TypedArray

이러한 view 객체를 일컫는 용어로 보통 TypedArray라는 표현을 사용한다고 했다. 이는 자바스크립트의 객체는 아니고 일반적으로 모든 view 객체를 공통적으로 부르는 용어로만 사용할 뿐이다. 여기서도 view 객체들을 모두 TypedArray 유형이라고 부르도록 하겠다.

모든 view 객체는 TypedArray 유형에 속하기 때문에 같은 메서드와 프로퍼티를 가지고 있다. 또한 TypedArray 유형은 자바스크립트의 일반 배열과 거의 유사하게 동작한다. 이들은 인덱스를 가지고 있고 또한 이터러블 속성 역시 가지고 있다. TypedArray 유형의 생성자는 5가지의 인자를 받아 해당 유형의 TypedArray를 생성할 수 있는데 그 목록은 다음과 같다.

new TypedArray(buffer, [byteOffset], [length]);
new TypedArray(object);
new TypedArray(typedArray);
new TypedArray(length);
new TypedArray();

// 실제로 TypeArray 라는 생성자함수(클래스)는 없다는 것에 주의하자!
// 여기서 TypeArray는 Uint8Array, Uint16Array 등을 의미한다!

1. ArrayBuffer

ArrayBuffer를 인자로 생성하는 경우는 이미 위에서 가장 먼저 살펴본 경우이다. 해당 ArrayBuffer를 커버하는 TypedArray 유형의 view 객체가 생성된다. 옵션값으로 byteOffsetlength를 설정할 수 있다. offset은 별도로 지정하지 않는다면 0이 기본값이며, 시작지점을 의미하고, length는 오프셋으로부터 어디까지를 지정할 지를 나타낸다.

2. Array

만약 인자로 Array 또는 유사 배열 객체가 넘어오는 경우엔, 해당 객체와 같은 값을 가진 TypedArray 유형의 view 객체가 생성된다. 즉 생성된 TypedArray 유형의 view가 바라보는 ArrayBuffer는 전달된 배열의 값을 동일하게 저장한다.

let arr = new Uint8Array([0, 1, 2, 3]);
console.log( arr.length ); // 4 (4개의 값을 1바이트씩 저장 => 총 4바이트 크기)
console.log( arr[1] );	// 1

3. TypedArray

만약 또 다른 TypedArray가 인자로 전달되면 이와 동일한 크기와 값을 지닌 지정된 TypedArray 유형의 view 객체를 생성한다. 이때 값은 지정된 TypedArray 유형에 따라 전환되는데, 만약 해당 유형에서 처리할 수 없는 큰 값의 경우에는 가용 범위내의 숫자로 변환된다.

let arr16 = new Uint16Array([1, 1000]);
let arr8 = new Uint8Array(arr16);

console.log( arr8[0] );  // 1
console.log( arr8[1] );  // 232 (1000은 8비트로 표현이 불가)

4. length

크기값 length가 인자로 전달되면 해당 크기만큼의 요소를 포함하는 TypedArray 유형의 view 객체를 생성한다. 이때 ArrayBuffer가 가지는 총 바이트는 length에 지정된 숫자와 하나의 요소에 할당되는 크기의 곱과 같다. 즉 length x TypedArray.BYTES_PER_ELEMENT와 동일하다.

let arr = new Uint16Array(4); // 4개 정수를 포함하는 TypedArray view 생성
console.log(Uint16Array.BYTES_PER_ELEMENT);  // 2바이트
console.log(arr.byteLength);  // 4x2 = 8

5. 아무 인자도 없는 경우

아무 인자도 없는 경우에는 모든 값이 0으로 초기화 된 해당 TypedArray 유형의 view 객체가 생성된다.

위에서 살펴본 것처럼 ArrayBuffer를 먼저 생성하지 않고도 TypedArray를 생성할 수 있다. 그러나 기본적으로 모든 TypedArrayArrayBuffer 위에 생성되는 view 객체이기 때문에 첫 번째 경우를 제외하면, 자동으로 그에 대응하는 ArrayBuffer가 생성된다. 이때 생성된 ArrayBuffer에는 다음과 같이 접근할 수 있다.

  • arr.buffer : ArrayBuffer에 대한 참조
  • arr.byteLength : ArrayBuffer의 바이트 크기

따라서 다음과 같이 생성된 ArrayBuffer를 기준으로 얼마든지 또다른 view 객체를 생성할 수 있다.

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

let arr16 = new Uint16Array(arr8.buffer);

위에서 잠깐 살펴보았지만, 사용할 수 있는 모든 TypedArray 유형은 아래와 같다.

  • Uint8Array, Uint16Array, Uint32Array : 정수형 숫자 (8bit, 16bit, 32bit)
  • Uint8ClampedArray : 8bit 정수형 숫자로 범위 내 숫자형태로만 값을 저장
  • Int8Array, Int16Array, Int32Array : 부호가 있는 정수형 숫자
  • Float32Array, Float64Array : 부동소수점 방식의 32bit와 64bit 크기의 숫자

Array 라는 표현에 항상 현혹되지 말도록 하자. Int8Array라는 클래스를 통해 간혹 자바스크립트에서도 Int8과 같은 데이터 유형을 지정할 수 있을 것이다라는 착각을 할 수 있다. 하지만 알다시피 자바스크립트에서는 정수형의 유형을 지정할 수 있는 타입은 지원하지 않는다.

3) 범위를 넘어간 값이 할당되는 경우

앞서 각 TypedArray 유형에 해당하는 값이 넘어가는 경우 이상한 숫자가 저장되는 예시를 잠깐 살펴보았다. 그러한 숫자가 저장되는 원리는 매우 간단하다. 컴퓨터에서 모든 숫자는 2진법으로 표현이 가능하다. 따라서 바이너리 데이터는 비트(bit)를 사용해 모두 표현할 수 있다.

가장 먼저 알아야 할 점은 범위를 벗어나는 숫자를 저장하는 것 자체는 어떠한 에러를 발생시키지 않는다는 점이다. 범위를 벗어나면 해당 벗어난 범위는 해당하는 비트 크기에 의해 자동으로 잘려나가 저장된다.

예를 들어 256이라는 숫자를 Uint8Array에 저장하려 한다고 가정해보자. 8bit 까지 저장할 수 있는 TypedArray이지만 256을 이진법으로 나타내면 100000000₂와 같다. 이는 총 9bit이기 때문에 가장 앞단에 있는 1은 자동으로 날라가게 되어 결국 0이 저장된다. 10진법으로 생각하면 Uint8Array의 범위는 0 - 255이기 때문에 당연히 256255 다음에 위치한 처음 숫자로 돌아가 0으로 전환된다고 볼 수 있다. 이를 그림으로 나타내면 아래와 같다.

257을 저장한다고 생각해보자. 이는 2진법으로 나타내면 100000001₂이고 아래 그림과 같이 맨 앞의 1이 날라가기 때문에 1로 저장된다.

다르게 표현하면 Uint8Array에 저장되는 모든 값은 8-modulo연산 (나머지연산)이 적용된다고 볼 수 있다.

위에서 또 하나의 TypedArray 유형 중에 Uint8ClampedArray가 있었는데, 이는 위와 같이 숫자를 날리는 연산을 적용하지 않는다. 즉 최대값인 255 보다 큰 값이 들어오는 경우에는 그냥 최대값 255를 저장한다. 만약 음수값이 들어오면 무조건 0으로 값을 저장한다. 이와 같은 처리는 이미지 데이터를 처리할 때 유용하게 사용할 수 있다.

4) TypedArray 메서드

TypedArrayArrayBuffer와 달리 Array와 상당 부분 유사하며 때문에 대부분의 메서드와 프로퍼티를 공유하고 있다. 따라서 TypedArray는 배열처럼 이터러블하며, 그 외에 map, slice, find, reduce 등과 같은 내장 메서드 역시 동일하게 사용할 수 있다.

그렇지만 사용할 수 없는 메서드 역시 존재하는데 대표적으로 다음과 같은 메서드가 있다.

  • splice : 어떠한 값도 제거할 수 없다. 이는 당연한 소리인데, ArrayBuffer의 가장 큰 특징 중에 하나는 바로 고정된 크기로 메모리 공간에 할당된다는 것이기 때문이다. 즉 크기 자체는 고정이기 때문에 크기를 조작하는 모든 메서드는 사용할 수 없다.
  • concat : 해당 메서드 역시 지원되지 않는다.

또는 기본 배열에서 지원하지 않는 특수한 메서드를 사용할 수 있다.

  • arr.set(fromArr, [offset]) : fromArr로 전달된 배열의 모든 요소를 arr로 복사한다. 이때 시작 지점을 offset으로 설정할 수 있다.
  • arr.subarray([begin, end]) : 동일한 TypedArray 유형의 view 객체를 begin에서 end 이전 범위까지로 지정해 생성한다. 해당 메서드는 slice(begin, end) 내장메서드와 동작하는 방식이 같지만, 얕은 복사가 아닌 새로운 view 객체를 생성한다는 점이 다르다.

5) DataView

TypedArray 유형은 모두 해당하는 타입이 정해져있다. 이보다 조금 더 유연하게 ArrayBuffer에 접근할 수 있는 view 객체가 있는데, 바로 DataView 유형이다. 해당 view 객체는 어떤 위치의 어떤 유형의 데이터라도 모두 접근할 수 있다.

예를 들어 TypedArray의 경우에는 타입이 지정되어 있고, 그 타입에 해당하는 형태로만 값을 저장하고 접근할 수 있다. 따라서 전체 TypedArray는 모두 동일한 유형의 값을 가진다. 즉 i번째 값에 접근하고자 하면 arr[i]로 접근이 가능하다.

그러나 DataView는 기본적으로 지원되는 모든 TypedArray 유형으로 값에 개별적인 접근이 가능하다. 이때는 메서드를 이용해 접근하는데, 이는 .getUint8(i) 또는 .getUint16(i)과 같이 그 유형으로 값을 가져올 수 있다. 형식은 해당 메서드를 호출하는 시점에 결정되기 때문에 TypedArray와 달리 생성 시점에 결정되지 않는다.

DataView를 생성하기 위한 문법은 아래와 같다.

new DataView(buffer, [byteOffest], [byteLength]);
  • buffer : 기준이 되는 ArrayBufferTypedArray와는 달리 스스로 생성할 수는 없다. 때문에 해당 ArrayBuffer는 미리 준비되어 있어야 한다.
  • byteOffset : view 객체에서 시작할 위치
  • byteLength : view 객체에 할당할 바이트 크기 (기본값은 buffer의 크기와 동일)

아래 예시는 DataView를 이용해 각각 다른 형식으로 동일한 ArrayBuffer에서 값을 가져오는 코드이다.

// 4바이트 크기의 모두 255 값을 가진 ArrayBuffer 및 그에 대한 view 객체 생성
let buffer = new Uint8Array([255, 255, 255, 255]).buffer;

// 생성된 ArrayBuffer에 대한 dataView 객체 생성
let dataView = new DataView(buffer);

// offset 0에 해당하는 값을 8비트로 접근 
console.log( dataView.getUint8(0) );	// 255

// offset 0에 해당하는 값을 16비트로 접근
// 2바이트를 한 번에 읽으므로 11111111 x 2
// 따라서 1111111111111111
console.log( dataView.getUint16(0) );	// 65535

// offset 0에 해당하는 값을 32비트로 접근
// 4바이트를 한 번에 읽으므로 11111111 x 4
// 따라서 1111111111111111
console.log( dataView.getUint32(0) );	// 4294967295

// 4바이트의 값을 0으로 설정
// 따라서 모든 값이 0이 됨
dataView.setUint32(0, 0);

DataView는 동일한 ArrayBuffer에 다양한 유형이 혼합된 값을 저장하는 경우에 유용하다. 예를 들어 하나의 버퍼에 16bit32bit float 유형이 저장된 경우에 DataView를 이용해 손쉽게 해당 값에 접근할 수 있다.

6) ArrayBufferViewBufferSource

ArrayBuffer를 다룰때 흔히 사용하는 또 다른 용어가 있다.

  • ArrayBufferView : 앞서 살펴본 TypedArray 유형의 view 와 동일한 개념이다.
  • BufferSource : ArrayBuffer 또는 ArrayBufferView 모두 포함하는 용어이다.

해당 용어는 다음 챕터에서 등장할 것이다. BufferSource는 가장 흔하게 사용되는 용어중에 하나로 보통 바이너리 데이터를 어떠한 형태로든 저장하고 있는 ArrayBuffer 또는 그에 대한 view를 의미한다. 이를 그림으로 나타내면 다음과 같다.

텍스트 디코더와 텍스트 인코더

1) 텍스트 디코더

이진 데이터가 문자열인 경우에는 어떻게 처리할 수 있을까? 예를 들어 텍스트 데이터가 있는 파일 자체를 수신했다고 가정해보자. 이때 내장 객체인 TextDecoder를 이용하면 주어진 버퍼와 인코딩을 통해 값을 실제 자바스크립트 문자열로 변환해 읽을 수 있게 만들어준다.

먼저 디코딩을 위핸 객체를 생성해야 한다.

let decoder = new TextDecoder([label], [options]);
  • label : 인코딩 방식을 지정하며, 기본적으로 utf-8을 가장 많이 사용하지만 big5, windows-1251 등의 인코딩 방식도 지원된다.
  • options : 선택 항목으로 다음 설정값이 있다.
    • fatal : boolean 값으로 true인 경우 디코딩이 불가능한 잘못된 글자를 대상으로 예외를 던진다. false라면 예외는 던지지 않고, 해당 문자를 \uFFFD로 대체한다.
    • ignoreBOM : boolean 값으로 true인 경우 사용되지 않는 바이트 순서 표식(Byte Order Mark, BOM)을 무시한다.

그리고 생성한 decoder 객체를 이용해서 디코딩을 진행할 수 있다. 디코딩을 위해 decode 라는 메서드를 지원한다.

let str = decoder.decode([input], [options]);
  • input : 디코딩 할 BufferSource
  • options : 선택 항목
    • stream : 많은 양의 데이터를 받아들여 decoder를 반복적으로 호출할 때도 디코딩이 반복적으로 실행된다. 이런 경우에 멀티 바이트 문자가 많은 데이터로 분할될 수 있다. 해당 옵션은 데이터 분할을 방지하기 위해 TextDecoder"unfinished"문자를 입력시키고 다음 데이터가 오면 디코딩하도록 지시한다.
let uint8Array = new Uint8Array([72, 101, 108, 108, 111]);

console.log( new TextDecoder().decode(uint8Array) );  // Hello
let unit8Array = new Uint8Array([228, 189, 160, 229, 165, 189]);

console.log( new TextDecoder().decode(uint8Array) );  // 你好

버퍼의 하위 배열 뷰를 생성하여 버퍼 일부만 디코딩하는 것도 가능하다.

let uint8Array = new Uint8Array([0, 72, 101, 108, 108, 111, 0]);

// 문자열을 나타내는 배열 요소는 중간에 존재
// subarray를 이용해 배열 복사없이 문자열 부분 추출
let binaryStr = uint8Array.subaray(1,-1);

console.log( new TextDecoder().decode(binaryStr) );  // Hello

2) 텍스트 인코더

TextEncoder를 이용해 텍스트 디코더와 반대되는 기능인 인코딩을 진행할 수 있다. 이를 통해 문자열을 바이트로 변환한다. 문법은 아래와 같다.

let encoder = new TextEncoder();

TextEncoder는 인코딩 시 오직 utf-8만 지원한다. 이때 다음 2가지 메서드가 있다.

  • encode(str) : Uint8Array에 문자열을 이진 데이터로 변환하여 반환
  • encodeInto(str, desination) : Uint8Array 구조 형태로 문자열 strdestination에 인코딩하여 반환
let encoder = new TextEncoder();

let uint8Array = encoder.encode('Hello');
console.log(uint8Array);  // 72,101,108,108,111

BLOB

BLOBBinary Large OBject의 약자로 주로 파일과 같은 객체를 바이너리 형태로 저장한 것을 의미한다. 이처럼 바이너리 형태로 저장하는 이유는 이미지, 오디오, 비디오와 같은 멀티미디어 파일들은 용량이 큰 경우가 많고 이를 효과적으로 데이터베이스에 저장하기 위해 고안된 자료형이라 볼 수 있다. 멀티미디어 파일 그 자체를 데이터베이스에 그대로 저장하기는 어렵기 때문!

브라우저 환경에서도 자바스크립트를 이용해 이러한 BLOB 데이터에 접근하고 사용할 수 있다. 브라우저의 File 객체 역시 BLOB의 확장형이다. 앞서 다룬 ArrayBuffer를 통해서 바이너리 데이터를 BLOB의 형태로 변환하고, 이를 다시 원본 데이터로 변환하는 등의 작업이 가능하다.

BLOB은 옵션값으로 주로 MIME type을 지정할 수 있고, blobParts 라고 불리는 BLOB, ArrayBuffer, ArrayBufferView, BufferSource, DOMString 등을 인자로 받아 BLOB 객체를 생성할 수 있다. 문법은 아래와 같다.

new Blob(blobParts, options)
  • blobParts : Blob, BufferSource, DOMString 과 같은 배열
  • options
    • type : 변환될 Blob의 타입으로 주로 image/png와 같은 MIME Type
    • endings : 현재 OS의 newlines에 맞게 end-of-line을 지정하는 옵션 (\r\n 또는 \n). 디폴트는 transparent로 아무것도 하지 않고, native 값을 넘겨주면 변환
// DOMString ---> Blob
let blob = new Blob(['<html>...</html>'], { type: 'text/html' } );
// Typed Array ---> Blob
let hello = new Uint8Array([72, 101, 108, 108, 111]);
let blob = new Blob([hello, ' ', 'world'], { type: 'text/plain' });

또한 특정 범위내에서만 추출하여 Blob 객체를 만들어줄 수 있다.

blob.slice([byteStart], [byteEnd], [contentType]);
  • byteStart : 바이트 시작값 (디폴트는 0)
  • byteEnd : 마지막 바이트값 (디폴트는 바이트 끝값)
  • contentType : 새로운 type 값 (디폴트는 기존 type값과 동일)

Blob 객체 메서드의 slice 는 배열의 메서드와 유사하기 때문에 음수값으로도 범위 지정이 가능하다.

기본적으로 Blob 객체는 불변성을 유지한다. 때문에 해당 객체를 직접 조작하여 값을 바꾸는 행위는 불가한데, 이를 slice 메서드를 통해 일정 부분을 추출하고 새로운 값과 혼합하여 새 Blob 객체를 만드는 방식으로 해결할 수 있다. 이는 사실 자바스크립트의 string 객체와 유사한 매커니즘이다.

Blob as URL

이렇게 만들어진 Blob 객체는 <a>, <img> 태그등에 사용할 수 있는 URL로 쉽게 변환이 가능하다. Blob 객체에 별도로 type 을 명시하기 때문에 Blob 객체를 다운로드/업로드 하는 과정에서 네트워크 요청에서의 Content-Type은 자연스레 명시된 type으로 매칭된다. 다음 예시는 다운로드 문구를 클릭했을 때 동적으로 생성되는 Blob 객체를 hello World 라는 문구가 담긴 텍스트 파일로 다운받는 코드이다.

<a download="hello.txt" href="#" id="link">다운로드</a>

<script>
  let blob = new Blob(['Hello, world!'], {type: 'text/plain'});
  link.href = URL.createObjectURL(blob);
</script>

또는 클릭 이벤트를 통해 위와 동일한 기능의 코드를 작성할 수도 있다.

let link = document.creteElement('a');
link.download = 'hello.txt';

let blob = new Blob(['Hello, world!'], { type: 'text/plain'} );

link.href = URL.createObjectURL(blob);

link.click();

URL.revokeObjectURL(link.href);

URL.createObjectURL 메서드를 이용하면 Blob 객체를 가지고 고유한 URL을 생성할 수 있다. 이때 생성되는 URL의 형태는 blob:<origin>/<uuid>의 형태를 띄게 된다. 그리고 변환된 URL은 source(src)를 속성으로 가지는 모든 HTML 태그와 CSS 속성에서 사용 가능하다.

link.href = `blob:https://javascript.info/1e67e00e-860d-40a5-89ae-6ab0cbee6273`;

이때 변환된 URL은 현재 탭의 브라우저 메모리에 저장되고, 저장된 URL은 매핑된 Blob 객체를 참고하고 있는 형태이다. 따라서 짧은 문자열만으로도 원래의 Blob 객체에 접근이 가능하고 그에 따른 이미지 등의 파일을 가져올 수 있다.

때문에 변환된 URL은 항상 현재 문서에서만 유효하다. 변환된 URL을 현재 문서를 새로고침하거나 아니면 다른 페이지에서 사용하려고 한다면 제대로 사용할 수 없다. 다른 탭의 문서나 새로고침했을 때의 문서는 기존에 변환된 URL을 메모리에서 매핑된 형태로 저장하고 있지 않기 때문이다.

이와 관련한 이슈가 한 가지 더 있다. Blob 객체가 URL로 변환되어 매핑이 이루어진 채 메모리에 저장되게 되면, 명시적으로 해당 URL이 해제되기 전까지 브라우저는 해당 URL이 유효하다고 판단하기 때문에 자바스크립트 엔진에서 가비지 컬렉션이 이루어지지 않는다. 따라서 이러한 사이드 이펙트를 방지하기 위해서는 변환이 일어나고 해당 URL을 사용한 이후 더 이상 사용하지 않을 시점이라고 판단되면 명시적으로 해제해 주는 것이 좋다.

변환은 URL.createObjectURL 메서드를 통해 진행했고, 해제의 경우에는 URL.revokeObjectURL 메서드를 사용한다. 이는 내부적으로 매핑되어 있는 참조를 지우는 메서드로, 메모리에 상주하고 있는 Blob 객체를 지울 수 있다. 위에서 자바스크립트로 작성한 예시에서 마지막 라인에 해당 메서드를 통해 참조를 해제하고 있는 것을 볼 수 있다. 동적으로 생성한 Blob 객체는 오직 다운로드 클릭 순간에만 필요하고 그 이후엔 필요하지 않기 때문에 해제를 통해 메모리 누수를 방지할 수 있다.

반면 첫 번째 예시의 경우에는 해제할 수 없다는 것에 주의하자. 첫 번째 예시에서 동일하게 URL.rovokeObjectURL 메서드를 호출한다면 스크립트 실행 시점에서 더 이상 유효한 URL이 아니게 되므로 정상적으로 다운로드가 불가하다.

Blob to base64

URL.createObjectURL의 대안으로 또 다른 방법은 Blob 객체를 base64 방식으로 인코딩하는 방식이 있다. base64 인코딩은 바이너리 데이터를 안전하게 읽을 수 있는 아스키 코드 0 - 64까지의 범위의 문자열로 변환한 형태를 반환한다. 예를 들어 이미지 파일의 경우 base64 형태로 문자열로 변환 후에 데이터베이스에 저장할 수 있다.

중요한 것은 Blob 객체 역시 base64의 형태로 변환이 가능하고, 변환된 문자열을 바로 source(src) 로써 사용할 수 있다는 점이다.

이러한 URL은 보통 data url 이라고 부르는데, base64 형태로 인코딩 된 data url의 형태는 다음과 같다: data:[<mediatype>][;base64],<data>. 이렇게 변환된 base64 형태의 URL은 위에서 URL.createObjectURL 메서드를 통해 변환한 형태와는 달리 어디에서나 사용이 가능하다.

// base64로 인코딩 된 data url 예시
<img src="data:image/png;base64,R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7">

브라우저는 이러한 data url을 만나면 내부적으로 디코딩을 진행하고 그에 걸맞은 이미지와 같은 파일을 보여주게 된다.

Blob 객체를 base64로 인코딩 하여 사용하는 방식은 주로 내장 객체 FileReader를 사용할 때 활용한다. 해당 객체를 이용하면 Blob 객체로부터 더 다양한 포맷으로 필요한 정보를 가져올 수 있다. 이는 다음 File API 챕터에서 더 자세히 다루어보도록 하자. 다음 예시는 Blob 객체를 base64 형태로 인코딩 하여 FileReader 객체를 통해 다운로드하는 코드이다.

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

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

let reader = new FileReader();
reader.readAsDataURL(blob);

reader.onload = function() {
  link.href = reader.reulst;
  link.click();
};

Blob 객체를 URL로 변환하는 두 가지 방식 모두 활용도가 높다. 하지만 base64로 인코딩 하는 경우엔 문자열의 길이가 매우 길어질 수 있다는 점과 URL.craeteObjectURL 메서드보다 더 느리다는 점이 있는 반면 변환된 data url은 현재 탭의 문서뿐만 아니라 어디에서도 유효하다는 장점이 있다.

Image to Blob

Blob 객체를 이미지, 이미지의 한 부분 또는 페이지 전체의 스크린샷 이미지로도 생성할 수 있다. 이러한 형태는 어딘가로 업로드 하기 굉장히 편리하다.

이와 같인 이미지 작업은 <canvas> 요소를 통해 수행된다.
1. 캔버스위에 이미지를 그린다 (canvas.drawImage)
2. 캔버스 메서드 toBlob(callback, fromat, quality)을 호출하여 Blob 객체를 생성하고 callback을 실행

From Blob to ArrayBuffer

Blob 생성자를 이용해 거의 BufferSource를 포함해 대부분의 것으로부터 Blob 객체를 생성할 수 있었다. 그러나 로우 레벨(low-level)의 처리를 수행하기 위해서는, FileReader 를 이용해 가장 낮은 레벨의 ArrayBuffer에 접근해야 할 수 있다.

let fileReader = new FileReader();

fileReader.readAsArrayBuffer(blob);

fileReader.onload = function(event) {
  let arrayBuffer = fileReader.result;
};

File and FileReader

File 객체는 Blob 객체를 확장한 객체로 주로 파일시스템과 관련된 기능을 담당한다. 파일시스템은 OS의 영역인데, 브라우저 상에서도 파일을 주고 받는 등의 기능이 가능하기 때문에 이를 지원하기 위한 규격으로 볼 수 있다.

브라우저에서 자바스크립트를 이용해 파일을 다루기 위한 방법으로는 크게 두 가지가 있다.

File

먼저 Blob 객체와 유사하게 File 객체를 생성자로 사용하는 방법이 있다.

new File(fileParts, fileName, [options]);
  • fileParts : Blob / BufferSource / String 과 같은 배열
  • fileName : 파일 이름 (문자열)
  • options : 옵셔널 값
    • lastModified : 마지막 수정이 일어난 때의 timestamp

두 번째 방법으로는 HTML 태그에서 <input type='file'> 등의 속성으로 파일을 간편하게 가져오는 방법이 있다. 그 외에 부가적으로 브라우저에서 드래그&드랍으로 파일을 가져오는 방법도 있을 수 있다.

File 객체는 Blob의 확장 객체이기 때문에 Blob 객체와 동일한 프로퍼티를 가지며, 추가적으로 다음 두 개의 프로퍼티를 가지고 있다.

  • name : 파일 이름
  • lastModified : 마지막 수정이 일어난 때의 timestamp

아래 예시는 <input type='file'>을 이용하여 File을 가져오는 방법에 대한 코드이다.

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

<script>
  function showFile(input) {
    let file = input.files[0];
    
    alert(`File name: ${file.name}`);
    alert(`Last modified: ${file.lastModified}`);
  }
</script>

이때 input type='file' 의 경우 추가적으로 multiple 옵션을 지정할 수 있는데 이 경우에는 다량의 File이 배열 형태로 전달되게 된다. 이때를 고려하여 단일 파일만 전달되는 경우에도 File은 항상 유사 배열로 전달되기 때문에 하나의 파일만 다루는 경우에도 인덱스로 접근하는 것에 주의하자

FileReader

FileReaderBlob 또는 File과 같은 객체로부터 데이터를 읽어 들이기위한 목적으로 사용되는 객체이다. 읽어들인 데이터는 주로 이벤트를 사용하여 필요한 타이밍에 데이터를 전달한다. 문법은 아래와 같다.

let readr = new FileReader();

생성된 FileReader 객체에서 사용할 수 있는 주요 메서드는 아래와 같다.

  • readAsArrayBuffer(blob) : ArrauBuffer 형태로 데이터를 읽음
  • readAsText(blob, [encoding]) : encoding 방식에 맞게 텍스트 형태로 데이터를 읽음 (기본 인코딩 방식 - utf-8)
  • readAsDataURL(blob) : base64 형태의 data url로 데이터를 읽음
  • abort() : 작업을 즉시 중단

read*로 시작하는 메서드는 우리가 어떤 포맷의 데이터를 더 선호하고, 추후에 어떤 형식으로 데이터를 다룰 것인지에 따라 다양하게 선택할 수 있다. 다음은 각 메서드의 간단한 가이드라인이다.

  • readAsArrayBuffer의 경우 바이너리 파일 대상으로 로우 레벨의 바이너리 작업이 필요한 경우에 유용하다. 대부분 하이레벨에서 하는 작업의 경우엔 File 객체가 Blob을 상속받고 있기 때문에 별도의 읽기 과정없이 즉각적으로 slice 등의 메서드 호출이 가능하다.

  • readAsText의 경우 텍스트 형태의 문자열이 필요한 경우 유용하다.

  • readAsDataURL의 경우 img 태그와 같이 src 속성에 리소스를 다루어야 하는 경우 유용하다. 또는 이전 챕터에서 다룬바와 같이 URL.createObjectURL을 이용하는 방법도 있다.

FileReader 객체를 이용해 데이터를 읽는 과정에서 발생하는 이벤트 목록은 다으과 같다.

  • loadstart : 로딩이 시작될 때
  • progress : 읽기를 수행하고 있는 중
  • load : 에러 없이 리딩이 완료된 때
  • abort : abort() 메서드가 호출된 때
  • error : 에러가 발생한 때
  • loadend : 성공/실패 여부 상관없이 리딩이 완료된 때

만약 읽기가 모두 완료되었다면 그에 대한 결과는 다음과 같은 프로퍼티로 접근이 가능하다.

  • reader.result : 성공 시 읽어들인 결과
  • reader.error : 실패 시 발생한 에러

보통 범용적으로 사용되는 이벤트는 주로 errorload 이다. 다음 예시를 통해 그 쓰임을 간략하게 살펴보자.

<input type='file' onchange='readFile(this)' />

<script>
  function readFile(input) {
    let file = input.files[0];
    let reader = new FileReader();
  
    reader.readAsText(file);
  
    reader.onload = function() {
      console.log(reader.result);
    };
  
    reader.onerror = function() {
      console.log(reader.error);
    };
  }
</script>

이전 챕터에서 언급했던 바와 같이 FileReaderFile 뿐만 아니라 Blob 역시 읽어들일 수 있다. 따라서 FileReader 객체를 통해 Blob 객체를 다른 형태로 전환할 수 있다.

  • readAsArrayBuffer(blob) : blobArrayBuffer
  • readAsText(blob, [encoding]) : blobstring (또는 TextDecoder 사용)
  • readAsDataURL(blob) : blobbase64 data url

FileReaderSync 는 웹 워커(Web Worker)에서 사용할 수 있다. 이는 read* 메서드가 이벤트를 통해 결과에 접근하는 것이 아니라 일반 함수처럼 결과를 바로 반환하는 동기 방식으로 사용할 수 있음을 말한다. 웹 워커는 페이지에 영향을 미치지 않기 때문에 동기 방식에서 읽는 동안 발생하는 딜레이가 크게 중요하지 않다.

References

  1. https://ko.javascript.info/binary
  2. https://developer.mozilla.org/ko/docs/Web/JavaScript/Typed_arrays
  3. http://hacks.mozilla.or.kr/2017/11/a-cartoon-intro-to-arraybuffers-and-sharedarraybuffers/
  4. https://heropy.blog/2019/02/28/blob/
profile
개발잘하고싶다

1개의 댓글

comment-user-thumbnail
2024년 5월 15일

잘보고 갑니다~!😊

답글 달기