TypeScript에서의 `DataView`

TypeScript에서의 `DataView`

이 글은 TypeScript에서의 DataView에 대해 설명합니다.

TypeScript에서 DataView를 기초부터 실전 활용까지 단계별로 설명합니다.

YouTube Video

TypeScript에서의 DataView

TypeScript에서 DataView를 사용하면 ArrayBuffer에 대해 바이트 단위의 세밀한 읽기 및 쓰기 작업을 수행할 수 있습니다. DataView는 프로토콜 구현, 바이너리 파일 분석, WebSocket을 통한 바이너리 데이터 송수신 등의 낮은 수준의 바이너리 처리에 매우 유용합니다.

기본 개념: ArrayBuffer, TypedArray, DataView의 차이

ArrayBuffer는 고정 길이의 바이트 시퀀스를 저장하는 기본 데이터 구조입니다. Uint8Array와 같은 TypedArray는 버퍼를 특정 타입의 배열처럼 다루며, 각 요소가 고정된 타입을 갖습니다.

반면에 DataView는 임의의 오프셋 위치에서 어떤 데이터 타입의 값도 읽고 쓸 수 있는 유연한 뷰입니다. 고정 타입을 전제로 하지 않고 바이트 단위의 세밀한 제어가 가능하므로 바이너리 프로토콜 분석 및 저수준 처리에 적합합니다.

다음은 ArrayBuffer를 생성한 후, 그로부터 Uint8ArrayDataView를 생성하는 예제입니다.

 1// Create an ArrayBuffer (8 bytes)
 2const buffer = new ArrayBuffer(8);
 3
 4// Create a Uint8Array view (easy to manipulate individual bytes)
 5const u8 = new Uint8Array(buffer);
 6u8[0] = 0x12;
 7u8[1] = 0x34;
 8
 9// Create a DataView (allows reading/writing any type at any offset)
10const dv = new DataView(buffer);
11
12// Read a 16-bit unsigned integer (big-endian assumed if second argument is omitted)
13console.log(dv.getUint16(0)); // 0x1234
  • 이 코드에서는 하나의 버퍼에서 두 가지 다른 타입의 뷰인 Uint8ArrayDataView를 동시에 사용합니다. DataView를 사용하면 임의의 오프셋과 엔디언을 지정하여 값을 유연하게 읽고 쓸 수 있습니다.

DataView의 주요 메서드

DataView는 바이트 단위로 ArrayBuffer를 조작하는 인터페이스이며, 정수 및 부동소수점 등 다양한 유형의 값을 읽고 쓸 수 있는 메서드를 제공합니다.

주요 메서드는 다음과 같습니다:.

  • 읽기: getUint8, getInt8, getUint16, getInt16, getUint32, getInt32, getFloat32, getFloat64
  • 쓰기: setUint8, setInt8, setUint16, setInt16, setUint32, setInt32, setFloat32, setFloat64

모든 메서드는 첫 번째 인수로 '바이트 오프셋'을 받으며, get은 해당 위치에서 값을 읽고, set은 값을 기록합니다. 16, 32, 64비트 데이터를 다룰 때 두 번째 인수로 엔디언을 지정할 수도 있습니다. 실제에서는 데이터 명세에 따라 항상 엔디언을 지정하는 것이 가장 안전합니다.

다음 예제는 32비트 정수와 32비트 부동소수점 값을 리틀 엔디언 형식으로 버퍼에 기록하고, 동일한 형식으로 다시 읽는 방법을 보여줍니다.

 1const buf = new ArrayBuffer(12); // Enough space for integer + float
 2const view = new DataView(buf);
 3
 4// Write a 32-bit unsigned integer at offset 0 (little-endian)
 5view.setUint32(0, 305419896, true); // 305419896 = 0x12345678
 6
 7// Read the same 32-bit unsigned integer back from offset 0 (little-endian)
 8const intVal = view.getUint32(0, true);
 9console.log(intVal); // 305419896
10
11// Write a 32-bit floating number at offset 4 (little-endian)
12view.setFloat32(4, 3.1415926, true);
13
14// Read the 32-bit floating number back from offset 4 (little-endian)
15const floatVal = view.getFloat32(4, true);
16console.log(floatVal); // 3.1415926 (approx)
  • 엔디언을 명시적으로 지정함으로써 다양한 플랫폼 및 바이너리 명세와의 호환성을 보장할 수 있습니다.

엔디언(바이트 순서)란?

일부 네트워크 프로토콜은 빅 엔디언을 사용하지만, 많은 CPU와 파일 포맷은 리틀 엔디언을 주로 사용합니다. DataView에서는 두 번째 인수를 true로 지정하면 리틀 엔디언으로 취급하고, false로 지정하거나 생략하면 빅 엔디언으로 처리합니다.

아래 예제에서는 동일한 숫자를 빅 엔디언과 리틀 엔디언 포맷으로 기록할 때 메모리 내 바이트 배열이 어떻게 변하는지 볼 수 있습니다.

 1const b = new ArrayBuffer(4);
 2const a = new Uint8Array(b);
 3const dv = new DataView(b);
 4
 5// Write in big-endian order
 6dv.setUint32(0, 0x0A0B0C0D, false); // big-endian
 7console.log([...a]); // [10, 11, 12, 13]
 8
 9// Write the same value in little-endian order
10dv.setUint32(0, 0x0A0B0C0D, true); // little-endian
11console.log([...a]); // [13, 12, 11, 10]
  • 빅 엔디언과 리틀 엔디언에 따라 바이트 순서가 어떻게 달라지는지 이해하면 통신 데이터나 바이너리 포맷을 분석하는 데 큰 도움이 됩니다.

문자열 입출력 (TextEncoderDecoder의 활용)

DataView는 숫자의 읽기/쓰기에 뛰어나지만 문자열을 직접 다루지는 않습니다. TextEncoder 또는 TextDecoder (예: UTF-8)를 사용하여 문자열을 인코딩하고, Uint8Array를 통해 버퍼에 복사하는 것이 일반적인 방법입니다. 아래는 UTF-8 문자열을 버퍼에 기록하고, 다시 읽어오는 예제입니다.

 1const encoder = new TextEncoder();
 2const decoder = new TextDecoder();
 3
 4const str = "\u3053\u3093\u306b\u3061\u306f";
 5const encoded = encoder.encode(str); // Uint8Array (UTF-8 encoded bytes)
 6
 7// Create a buffer and write the encoded string bytes into it
 8const buf2 = new ArrayBuffer(encoded.length);
 9const u8v = new Uint8Array(buf2);
10u8v.set(encoded);
11
12// Read the bytes and decode them back into a string
13const decodedStr = decoder.decode(new Uint8Array(buf2));
14console.log(decodedStr);
  • 문자열을 바이너리로 변환하고 필요하다면 길이 정보를 앞에 붙여서 가변 길이 문자열을 저장할 수 있습니다.

실전 예제: 커스텀 바이너리 포맷 인코딩/디코딩

아래에서는 버전, ID, 이름을 포함하는 간단한 메시지 포맷을 정의합니다. 메시지를 바이너리 데이터로 변환하는 인코딩 과정과, 바이너리에서 원래 객체로 복원하는 디코딩 과정을 구현합니다. 이 메시지 포맷에서 첫 번째 바이트는 버전을 저장하고, 다음 4바이트는 리틀 엔디언 ID, 그 다음 바이트는 이름 길이, 마지막으로 UTF-8로 인코딩된 이름이 저장됩니다.

 1// Encode an object into a binary message
 2function encodeMessage(msg: { version: number; id: number; name: string }): ArrayBuffer {
 3  const encoder = new TextEncoder();
 4  const nameBytes = encoder.encode(msg.name);
 5  const total = 1 + 4 + 1 + nameBytes.length;
 6  const buf = new ArrayBuffer(total);
 7  const dv = new DataView(buf);
 8  let offset = 0;
 9
10  dv.setUint8(offset, msg.version); offset += 1;      // write version
11  dv.setUint32(offset, msg.id, true); offset += 4;    // write ID (little-endian)
12  dv.setUint8(offset, nameBytes.length); offset += 1; // write name length
13
14  // write name bytes
15  new Uint8Array(buf, offset).set(nameBytes);
16  return buf;
17}
18
19// Decode a binary message back into an object
20function decodeMessage(buf: ArrayBuffer) {
21  const dv = new DataView(buf);
22  let offset = 0;
23
24  const version = dv.getUint8(offset); offset += 1;   // read version
25  const id = dv.getUint32(offset, true); offset += 4; // read ID (little-endian)
26  const nameLen = dv.getUint8(offset); offset += 1;   // read name length
27
28  // extract name bytes
29  const nameBytes = new Uint8Array(buf, offset, nameLen);
30  const decoder = new TextDecoder();
31  // decode UTF-8 string
32  const name = decoder.decode(nameBytes);
33
34  return { version, id, name };
35}
36
37// Example usage
38const packed = encodeMessage({ version: 1, id: 42, name: "Alice" });
39const unpacked = decodeMessage(packed);
40console.log(unpacked); // { version: 1, id: 42, name: "Alice" }
  • 이는 가변 길이 문자열을 포함한 기본 메시지 포맷을 처리하는 전형적인 구현이며, 네트워크 통신이나 커스텀 바이너리 파일 설계 등 다양한 실무 상황에 적용할 수 있습니다.

주의점 및 베스트 프랙티스

DataView로 바이너리 데이터를 다룰 때는 단순한 읽기/쓰기 외에도 안전성, 엔디언 일관성, 타입 처리 등 여러 실무적 고려사항이 있습니다. 특히 외부에서 받은 바이너리 데이터나 대용량 정수를 처리할 때는 잘못된 읽기나 버퍼 오버런 방지를 위해 코드를 신중히 설계해야 합니다. 실무에서 기억해야 할 유용한 포인트를 아래에 정리했습니다.

  • 경계 확인 DataView에서 오프셋이나 크기가 버퍼 경계를 초과하면 예외가 발생합니다. 신뢰할 수 없는 바이너리 데이터를 다룰 때 항상 길이를 확인하세요.

  • 엔디언 항상 명시 코드에서 항상 리틀 엔디언 또는 빅 엔디언을 명확하고 일관되게 지정하세요.

  • 타입 일관성 유지 JavaScript의 숫자는 64비트 IEEE-754 부동소수점 값입니다. getUint32와 같은 메서드는 지원되지만, getUint64는 없으므로 64비트 정수는 별도의 처리가 필요합니다.

  • 64비트 정수 처리 64비트 정수를 다루려면 BigInt를 사용하거나 값을 상위 32비트와 하위 32비트로 나누어 처리해야 합니다. 다음은 64비트 부호 없는 정수를 읽는 간단한 예제입니다.

1function getUint64(dv: DataView, offset: number, littleEndian = true): bigint {
2  const low = BigInt(dv.getUint32(offset, littleEndian));
3  const high = BigInt(dv.getUint32(offset + 4, littleEndian));
4  return littleEndian ? (high << 32n) | low : (low << 32n) | high;
5}
  • BigInt를 사용하면 64비트보다 더 큰 정수도 안전하게 처리할 수 있습니다.

Node.js에서의 사용법(Buffer와의 연동)

Node.js에서 Buffer가 주로 사용되지만, ArrayBufferDataView 간 변환도 간단합니다. Buffer 객체의 buffer 속성이나 Uint8Array 생성자를 이용해 변환할 수 있습니다.

1// Node.js: Buffer -> ArrayBuffer
2const nodeBuf = Buffer.from([1,2,3,4]);
3const arrBuf = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.byteLength);
4const dvNode = new DataView(arrBuf);
5console.log(dvNode.getUint16(0));
  • 이 방법은 Node.js와 브라우저 간 바이너리 데이터 교환에 활용할 수 있습니다.

요약

DataView는 엔디언 지정이나 임의 위치 접근 등 유연한 컨트롤이 필요한 상황에서 자유롭게 바이너리 데이터를 읽고 쓸 수 있는 강력한 기능입니다. ArrayBuffer, TypedArray, DataView를 조합해 TypeScript에서 바이너리 데이터를 유연하고 정확하게 다룰 수 있으며, 프로토콜 구현부터 파일 분석까지 폭넓게 활용할 수 있습니다. 문자열 인코딩과 64비트 정수 처리를 함께 활용하면 더욱 실용적인 바이너리 처리가 가능합니다.

위의 기사를 보면서 Visual Studio Code를 사용해 우리 유튜브 채널에서 함께 따라할 수 있습니다. 유튜브 채널도 확인해 주세요.

YouTube Video