Devin.KR

Buffer 와 인코딩 - 바이트와 문자열 사이

개발자KR 조회 14

이 장에서 배우는 것

네트워크를 통해 들어오는 데이터나 파일에서 읽어 들인 내용은 본질적으로 바이트(byte)의 묶음이다. 자바스크립트는 기본적으로 유니코드 기반의 문자열만 다루기 때문에, 원시 바이트 데이터를 다루려면 중간 매개체가 필요하다. Node.js는 이 역할을 위해 Buffer를 제공한다.

앞 장에서 스트림을 통해 메모리 한계를 극복하고 배압을 조절하는 방법을 다루었다. 이 과정에서 데이터를 청크(chunk) 단위로 쪼개어 처리하게 되는데, 바이트 묶음을 함부로 문자열로 변환하면 예기치 않은 글자 깨짐 현상을 겪게 된다. 이 장에서는 Buffer의 동작 원리를 이해하고 스트림 환경에서 안전하게 데이터를 다루는 방법을 알아본다.

  • Buffer의 메모리 할당 방식과 내부 구조 이해
  • 청크 단위 처리 시 발생하는 UTF-8 문자 깨짐 원인 파악
  • StringDecoder를 활용한 안전한 텍스트 변환
  • 바이너리 프로토콜 헤더를 다루기 위한 바이트 조작과 엔디안(endianness) 이해
  • hex와 base64 형식 인코딩 변환

문제 상황

로그 수집 서버를 운영하다 보면 클라이언트에서 보내는 JSON이나 텍스트 로그를 청크 단위로 나누어 받게 된다. 이때 각 청크가 들어올 때마다 진행 상황을 기록하거나 특정 단어를 검사하려고 chunk.toString()을 호출하는 코드를 작성했다.

대부분의 영문 로그는 문제없이 처리되었지만, 사용자가 남긴 한글 메시지 등 다바이트 문자가 포함된 로그가 스트림을 통해 들어올 때 간헐적으로 알 수 없는 특수 기호()가 섞여 출력되는 문제가 발생했다. 또한 로그 전송 효율을 높이기 위해 클라이언트 개발팀에서 텍스트 기반 HTTP 요청 대신, 첫 16바이트에 메타데이터(타임스탬프, 사용자 ID 등)를 꽉 채워 넣은 이진 헤더를 함께 전송하겠다고 통보해 왔다. 이제 서버는 문자열이 아닌 바이트 배열 자체를 쪼개고 분석해야 한다.

Buffer와 바이트 데이터

Buffer는 자바스크립트 엔진의 힙(heap) 메모리 외부에 할당된 고정 크기의 메모리 공간을 가리키는 객체다. 자바스크립트의 배열과 비슷해 보이지만, 정수나 객체 대신 오직 0부터 255 사이의 바이트 값만 담을 수 있다.

Buffer를 생성할 때는 주로 Buffer.alloc()을 사용한다. 이 메서드는 지정한 크기만큼 메모리를 할당하고 모든 바이트를 0으로 초기화한다. 보안에 민감한 정보가 메모리에 남아 있는 것을 방지하기 위해서다. 초기화 과정 없이 빠르게 메모리를 확보하려면 Buffer.allocUnsafe()를 쓸 수 있지만, 이전 작업에서 썼던 데이터 잔여물이 남아 있을 수 있으므로 반환된 버퍼의 모든 영역을 덮어쓸 확신이 있을 때만 써야 한다.

기존 문자열이나 배열에서 Buffer를 만들 때는 Buffer.from()을 사용한다. 문자열을 넣으면 기본적으로 UTF-8 형식으로 인코딩하여 바이트 배열로 바꾼다.

UTF-8 경계와 글자 깨짐

UTF-8 인코딩에서 영문이나 숫자는 1바이트를 차지하지만, 한글은 한 글자당 보통 3바이트를 차지한다. 스트림은 데이터의 논리적인 의미를 알지 못한 채 정해진 내부 버퍼 크기에 따라 무작위로 바이트를 뭉텅 잘라서 전달한다.

만약 '안녕'이라는 6바이트 길이의 문자열을 스트림으로 받는다고 가정해 보자. 스트림이 한 번에 4바이트씩 읽는다면, 첫 번째 청크에는 '안'(3바이트) 그리고 '녕'의 앞부분 1바이트가 담긴다. 이 4바이트를 그대로 문자열로 변환하려고 하면, 자바스크립트는 마지막 1바이트를 완전한 문자로 해독하지 못하고 대체 문자(Replacement Character, )로 바꿔버린다.

UTF-8 한글 문자열이 스트림 청크 크기에 의해 중간 바이트에서 잘려 깨지는 과정

이어서 두 번째 청크에 나머지 2바이트가 도착하지만, 이미 앞쪽 바이트와 분리되었기 때문에 이 역시 해독 불가능한 바이트로 취급되어 또 다른 대체 문자가 된다. 이렇게 쪼개진 채 문자열로 변환하면 영원히 원래 글자를 복원할 수 없게 된다.

이 문제를 해결하기 위해 Node.js는 표준 모듈 string_decoder의 StringDecoder 클래스를 제공한다. StringDecoder는 바이트 배열을 받아 문자열로 바꿀 때, 마지막 문자의 바이트 시퀀스가 불완전하면 변환하지 않고 내부 상태에 따로 보관한다. 그리고 다음 청크가 들어올 때 보관해 둔 바이트와 이어 붙여 완전한 문자를 만들어 낸다.

이진 헤더 파싱과 엔디안

네트워크 통신에서 데이터 크기를 줄이기 위해 문자열 대신 고정된 크기의 이진 형식을 사용하는 경우가 많다. 예를 들어 사용자 ID를 문자열 '12345678'로 보내면 8바이트가 필요하지만, 32비트 정수형으로 보내면 4바이트면 충분하다.

이진 데이터를 다룰 때는 컴퓨터가 여러 바이트를 메모리에 배치하는 순서인 엔디안을 주의해야 한다. 큰 자릿수 바이트를 먼저 배치하면 빅엔디안(Big Endian), 작은 자릿수 바이트를 먼저 배치하면 리틀엔디안(Little Endian)이라 부른다. 네트워크 프로토콜은 관례적으로 빅엔디안을 사용한다. Buffer 객체는 readUInt32BE(), readBigUInt64BE()처럼 형식과 엔디안을 명시하는 메서드를 제공하여 오프셋(offset) 인덱스만 지정하면 쉽게 값을 추출할 수 있도록 돕는다.

16바이트 크기의 이진 헤더에서 오프셋을 통해 타임스탬프와 사용자 ID를 읽는 배치도

완성 코드

클라이언트가 16바이트의 이진 헤더와 가변 길이의 UTF-8 텍스트 본문을 한 번에 전송하는 시스템을 만들어 본다. 서버는 요청 스트림을 읽어 이진 헤더를 파싱하고, 본문은 StringDecoder를 사용해 안전하게 텍스트로 복원한다.

server.mjs

import { createServer } from 'node:http';
import { StringDecoder } from 'node:string_decoder';

const server = createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/log') {
    const decoder = new StringDecoder('utf8');
    let headerBuffer = Buffer.alloc(0);
    let isHeaderParsed = false;
    let receivedHeaderLength = 0;
    const HEADER_SIZE = 16;
    
    let parsedTimestamp = 0n;
    let parsedUserId = 0;
    let parsedLogLength = 0;
    let logBody = '';

    req.on('data', (chunk) => {
      if (!isHeaderParsed) {
        headerBuffer = Buffer.concat([headerBuffer, chunk]);
        receivedHeaderLength = headerBuffer.length;
        
        if (receivedHeaderLength >= HEADER_SIZE) {
          const header = headerBuffer.subarray(0, HEADER_SIZE);
          
          parsedTimestamp = header.readBigUInt64BE(0);
          parsedUserId = header.readUInt32BE(8);
          parsedLogLength = header.readUInt32BE(12);
          
          isHeaderParsed = true;
          
          const remainingChunk = headerBuffer.subarray(HEADER_SIZE);
          if (remainingChunk.length > 0) {
            logBody += decoder.write(remainingChunk);
          }
        }
      } else {
        logBody += decoder.write(chunk);
      }
    });

    req.on('end', () => {
      logBody += decoder.end();
      
      console.log(`[수신] 타임스탬프: ${parsedTimestamp}`);
      console.log(`[수신] 사용자: ${parsedUserId}, 길이: ${parsedLogLength}`);
      console.log(`[본문] ${logBody}`);
      
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('Log received\n');
    });
  } else {
    res.writeHead(404);
    res.end();
  }
});

server.listen(3000, () => {
  console.log('서버가 3000번 포트에서 대기 중입니다.');
});

client.mjs

import { request } from 'node:http';

const header = Buffer.alloc(16);
const now = BigInt(Date.now());
const userId = 42001;
const logMessage = '스트림에서 다바이트 문자가 잘려도 깨지지 않습니다.';
const body = Buffer.from(logMessage, 'utf8');

header.writeBigUInt64BE(now, 0);
header.writeUInt32BE(userId, 8);
header.writeUInt32BE(body.length, 12);

const payload = Buffer.concat([header, body]);

const req = request(
  {
    hostname: 'localhost',
    port: 3000,
    path: '/log',
    method: 'POST',
  },
  (res) => {
    let resData = '';
    res.on('data', (c) => resData += c.toString());
    res.on('end', () => {
      console.log(`응답 상태 코드: ${res.statusCode}`);
      console.log(`응답 본문: ${resData}`);
    });
  }
);

const CHUNK_SIZE = 7;
for (let i = 0; i < payload.length; i += CHUNK_SIZE) {
  const end = Math.min(i + CHUNK_SIZE, payload.length);
  req.write(payload.subarray(i, end));
}

req.end();

줄별 해설

server.mjs

  • new StringDecoder('utf8'): 입력 스트림이 잘려 들어올 경우를 대비해 UTF-8 기반의 디코더를 초기화한다.
  • headerBuffer = Buffer.concat([headerBuffer, chunk]): 헤더를 구성하는 16바이트가 여러 번의 이벤트에 걸쳐 쪼개 들어올 수 있으므로, 요구 길이를 만족할 때까지 버퍼를 이어 붙인다.
  • headerBuffer.subarray(0, HEADER_SIZE): 모인 버퍼에서 딱 16바이트만 잘라낸다. subarray는 기존 메모리를 복사하지 않고 참조만 하므로 메모리 효율이 좋다.
  • readBigUInt64BE(0): 인덱스 0부터 8바이트를 읽어 자바스크립트의 BigInt형으로 반환한다. 큰 정수 값을 잃어버리지 않게 해준다.
  • decoder.write(remainingChunk): 헤더 뒤에 붙어온 남은 바이트를 StringDecoder에 넘겨 문자열로 반환받는다. 완전하지 않은 문자는 이 메서드가 알아서 보관한다.
  • decoder.end(): 스트림이 끝날 때 내부에 보관 중이던 남은 바이트가 있다면 털어내어 마지막 문자열로 변환해 반환한다.

client.mjs

  • Buffer.alloc(16): 16바이트 크기의 공간을 0으로 초기화하여 할당한다.
  • Buffer.from(logMessage, 'utf8'): 문자열의 글자 수가 아니라 실제 UTF-8 형식의 바이트 길이를 구하기 위해 버퍼로 변환한다. 이진 데이터 구조에서는 글자 수가 아닌 바이트 길이를 적어 주어야 수신 측이 정확히 공간을 계산할 수 있다.
  • writeUInt32BE(userId, 8): 인덱스 8 위치에 사용자 ID를 4바이트 빅엔디안 형식으로 기록한다.
  • for 반복문과 req.write(): 데이터를 한 번에 보내지 않고 의도적으로 7바이트씩 아주 작게 쪼개어 서버로 전송한다. 7바이트 단위로 자르면 한글(3바이트) 바이트의 경계를 무작위로 자르게 되므로 글자 깨짐 방어 로직을 시험하기 좋다.

실행 결과

터미널 하나에서 서버를 실행하고 다른 터미널에서 클라이언트를 실행한다.

$ node server.mjs
서버가 3000번 포트에서 대기 중입니다.

클라이언트를 실행하면 7바이트 단위로 잘려 전송되었음에도 한글 본문이 전혀 깨지지 않고 출력된다.

$ node client.mjs
응답 상태 코드: 200
응답 본문: Log received

클라이언트 전송 후 서버 측 터미널의 출력 결과는 다음과 같다.

[수신] 타임스탬프: 1730000000000
[수신] 사용자: 42001, 길이: 68
[본문] 스트림에서 다바이트 문자가 잘려도 깨지지 않습니다.

실무에서 자주 틀리는 것

할당되지 않은 메모리의 유출

성능을 높이기 위해 Buffer.allocUnsafe()를 썼다가 보안 문제가 생기는 경우가 흔하다. 이 메서드로 만든 버퍼는 초기화되지 않아 예전에 다른 객체가 쓰던 암호나 인증 토큰이 그대로 남아있을 수 있다.

// 틀린 코드
const buf = Buffer.allocUnsafe(100);
buf.write('hello'); 
// 남은 95바이트에는 이전에 사용된 쓰레기값이 들어 있다. 이대로 전송하면 정보 유출이 일어난다.

// 고친 코드
const buf = Buffer.allocUnsafe(100);
const length = buf.write('hello');
const finalBuf = buf.subarray(0, length); // 정확히 쓴 만큼만 잘라서 사용한다.
// 또는 애초에 안전하게 Buffer.alloc(100)을 사용한다.

스트림 데이터 이벤트에서의 toString() 직접 호출

청크가 도착할 때마다 데이터를 모으거나 출력할 때 단순하게 toString()을 부르면 간헐적 글자 깨짐 오류를 만난다.

// 틀린 코드
req.on('data', (chunk) => {
  // 청크 경계가 다바이트 문자 중간을 지나가면 '' 문자가 발생한다.
  process.stdout.write(chunk.toString());
});

// 고친 코드
const decoder = new StringDecoder('utf8');
req.on('data', (chunk) => {
  process.stdout.write(decoder.write(chunk));
});
req.on('end', () => {
  process.stdout.write(decoder.end());
});

버퍼 간의 강제 문자열 덧셈

두 개의 버퍼를 합칠 때 무심코 자바스크립트의 덧셈 연산자를 사용하는 실수를 저지르곤 한다.

const buf1 = Buffer.from([0x01, 0x02]);
const buf2 = Buffer.from([0x03, 0x04]);

// 틀린 코드
const combined = buf1 + buf2; 
// 결과는 더 커진 버퍼가 아니라, 바이트가 강제로 문자로 변환된 문자열이 나온다.

// 고친 코드
const combined = Buffer.concat([buf1, buf2]);

한눈에 보기

Buffer 생성 메서드 비교
메서드 설명 메모리 초기화 여부 권장 용도
Buffer.alloc(size) 지정된 크기의 빈 버퍼 생성 전부 0으로 초기화됨 보안이 중요하고 크기가 정해진 버퍼를 만들 때
Buffer.allocUnsafe(size) 지정된 크기의 버퍼 생성 초기화하지 않음 (이전 데이터 잔존) 성능이 극도로 중요하고 내용을 100% 덮어쓸 때
Buffer.from(data) 문자열이나 배열을 복사하여 생성 전달한 데이터 내용으로 채워짐 기존 데이터를 원시 바이트로 다룰 때
바이트 배열을 텍스트로 바꾸는 도구들
도구 동작 방식 스트림 환경(조각난 데이터) 처리
buffer.toString() 전달받은 바이트 전체를 즉시 문자열로 변환 잘린 바이트가 있으면 깨짐 (부적합)
StringDecoder 불완전한 바이트를 내부 상태에 보관하고 지연 처리 잘린 문자를 이어 붙여 줌 (적합)

연습 문제

  1. 스트림으로 들어오는 데이터를 let totalData = '' 변수에 totalData += chunk 형태로 합쳤을 때 발생할 수 있는 문제를 바이트 관점에서 설명하시오.
  2. 16진수 문자열 '48656c6c6f'를 가지고 있는 변수를 Buffer로 변환한 다음, 다시 base64 문자열로 출력하는 코드를 작성하시오.
  3. 네트워크 프로토콜 문서를 보니 헤더의 4번째 바이트 위치부터 8바이트 크기로 리틀엔디안 방식의 숫자값이 들어온다고 한다. 이 값을 읽어내는 코드를 메서드를 사용해 작성하시오.

정답과 해설

  1. totalData += chunk는 내부적으로 totalData = totalData + chunk.toString()과 같이 동작한다. 각 청크가 독립적으로 문자열로 변환된 후 결합되므로, 3바이트를 차지하는 한글이 청크 경계에서 나뉘었을 경우 복구 불가능한 대체 문자로 바뀌어 데이터가 영구적으로 손상된다.
  2. const buf = Buffer.from('48656c6c6f', 'hex');로 먼저 버퍼를 만든 뒤, console.log(buf.toString('base64'));를 호출하여 출력한다. 인코딩 형식을 명시하여 바이트 배열을 다루는 기본 패턴이다.
  3. const value = chunk.readBigUInt64LE(4); 오프셋 인덱스는 0부터 시작하므로 4번째 바이트의 인덱스는 4다. 8바이트 정수이므로 64비트를 뜻하는 BigUInt64를 쓰며, 리틀엔디안이므로 LE가 붙은 메서드를 사용한다. 일반 Number 타입의 한계를 넘을 수 있으므로 안전하게 BigInt를 반환받아야 한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.