본문 바로가기
JS&TS

Node.js 버퍼를 이용하여 이미지를 만들어보자

by Luke K 2023. 7. 11.

이미지를 브라우저에 보여주거나 저장하거나 가공할 때가 있다.

그렇지만 이미지를 컴퓨터가 어떻게 이해하는지, 색상 데이터에 어떤 정보를 추가해야 완전한 이미지 파일이 되는지 모르고 있었다.
이 글은 이미지를 잘 이해하기 위해 또 이미지를 직접 가공해야 할 때를 대비해 코드로 이미지를 만들며 학습한 기록이다.

 

Table Of Contents

- Node.js에서 이미지를 읽어보며 버퍼 이해해보기

- 비트맵 이미지를 만들어보며 이미지 파일의 구성요소 알아보기

- 사용하기 쉬운 인터페이스를 고민하며 npm에 패키지 배포하기

이미지를 읽어보자.

const fs = require('fs');
fs.readFile('./asset/groot.png',  (err, data) => {
    if (err) {
        console.error(err)
        return
    }
    console.log(data)
// <Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 09 60 00 00 09 60 08 06 00 00 00 0c 4d 0c 6e 00 00 00 19 74 45 58 74 53 6f 66 74 77 61 72 65 00 ... 1324033 more bytes>
})

data를 콘솔에 찍어봤더니 16진수 형태의 데이터가 연속되고 있는 Buffer라는 형태가 나온다.

Buffer는 무엇일까?

Node.js 공식문서는 다음과 같이 정의한다.

바이너리 데이터들의 스트림을 읽거나, 조작하는 메커니즘.

이 Buffer클래스는 Node.js의 일부로 도입되어 TCP 스트림이나 파일시스템 같은 작업에서의 octet 스트림과의 상호작용을 가능하기 위해 만들어졌습니다.

바로 이해하기에는 말이 어렵다.

일단 설명에서 재료를 얻어보자.

바이너리 데이터: binary(2진의)

Buffer클래스: Node.js 내장 클래스인듯하다.

TCP 스트림이나 파일시스템 같은 파일이나 네트워크로 받아오는 데이터들을 읽는 방법으로 제공하는 API인가 보다.

octet스트림: octet은 컴퓨팅에서 8개의 비트가 한 데 모인 것을 말한다. (출처: 위키백과)

8개 비트가 모인 집합으로 들어온 데이터를 읽는 방식인가 보다.

조금 더 자세히 정리해 보자.

바이너리 데이터

컴퓨터는 0과 1밖에 읽지 못한다.

우리가 이미지라고 부르는 데이터도 처리 혹은 저장하려면 0과 1로 될 것이다.

컴퓨터는 데이터를 표현하기 위한 약속을 정하고 그 약속에 따라 데이터를 0과 1의 집합으로 표현해 주면 또 이 집합을 약속에 따라 다시 그 데이터를 표현할 수 있게 복구한다.

약속에 따라 데이터를 컴퓨터가 이해할 수 있게 바꾸는 것을 인코딩이라고 한다.

또 반대 과정인 컴퓨터가 이해할 데이터를 사람이 활용하기 편한 형태의 데이터로 바꾸는 것을 디코딩이라고 한다.

데이터를 전달하는 방식 (스트림)

100gb 정도의 고화질 영상 파일이 있다.

우린 이 파일을 압축할 것이다.

파일을 처리하기 위해서는 메모리에 올려야 하지만 기기의 메모리는 16gb이다.

파일을 적당한 단위로 쪼갠 후의 압축한 내용들을 합쳐야 할 것이다.

그러기 위해 스트림(연속된 데이터의 형태)으로 데이터가 오게 되는데 이를 이용하여 조금씩 데이터를 처리하는 것이다.

버퍼

공식문서를 살펴보면 버퍼는 고정된 길이의 데이터들의 연속값을 위해 존재한다.

데이터들을 처리하는 임시 공간으로 버퍼라는 클래스를 이용하는 것이다.

여기까지 정리하면 이미지 파일을 읽어오고 전송할 때는 이미지의 용량이 메모리보다 많을 수 있으므로 적당한 단위로 이미지를 나눠서 저장하기 위해 버퍼를 사용하고 버퍼를 채우고 채운 버퍼를 전송시키기 위해 스트림을 이용한다.

octet 스트림

octet 스트림은 8비트 단위로 이루어진 스트림을 말하는데 많은 zip 파일들, 실행 파일들이 저장될 때 8비트 이진 데이터로 저장돼 있다. 즉 이진으로 저장돼 있는 파일들(octet 스트림)을 읽기 위해 우리는 버퍼를 저장공간으로 사용하는 것이다.

이를 통해 이미지는 8비트 단위의 이진 데이터로 이루어져 있고 nodeJS는 이를 읽기 위해 버퍼를 사용하여 읽고 있다.

그렇다면 이미지 파일을 직접 만들어보자.

.bmp로 끝나는 비트맵 이미지를 만들어보자.

비트맵은 비압축 형식이기 때문에 제일 쉽게 나타낼 수 있다.

비트맵은 다음과 같은 순서로 이루어져 있다.

파일헤더 (14바이트)

파일 정보 헤더 (최소 40바이트)

(컬러 테이블)

픽셀 데이터

각각을 만들어내는 함수를 만들어보자.


결과물로 만들어진 버퍼를 먼저 보면 다음과 같은 버퍼들이 합쳐져 온전한 이미지 파일이 된다.

fileHeader <Buffer 42 4d 39 00 00 00 00 00 00 00 36 00 00 00>
fileInfoHeader <Buffer 28 00 00 00 01 00 00 00 01 00 00 00 01 00 18 00 00 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00>
imageData <Buffer 00 00 ff>

파일 헤더

const createFileHeader = () => {
    const fileSize = 54 + red.length; // 54바이트는 헤더 크기
    const fileHeader = Buffer.alloc(14);
    fileHeader.write('BM', 0); // 파일 타입
    fileHeader.writeInt32LE(fileSize, 2); // 파일 크기
    fileHeader.writeInt32LE(54, 10); // 픽셀 데이터의 시작 오프셋
    return fileHeader;
};

파일 정보 헤더

const createFileInfoHeader = () => {
    const fileInfoHeader = Buffer.alloc(40);
    fileInfoHeader.writeInt32LE(40, 0); // 파일 정보 헤더 크기
    fileInfoHeader.writeInt32LE(width, 4); // 이미지 너비
    fileInfoHeader.writeInt32LE(height, 8); // 이미지 높이
    fileInfoHeader.writeInt16LE(1, 12); // 계층 수
    fileInfoHeader.writeInt16LE(24, 14); // 비트 수 (24비트 = 3바이트)
    fileInfoHeader.writeInt32LE(0, 16); // 압축 방식 (압축 없음)

    const imageDataSize = width * height * 3; // 이미지 데이터 크기 계산 (너비 x 높이 x 비트 수/8)
    fileInfoHeader.writeInt32LE(imageDataSize, 20); // 픽셀 데이터 크기

    return fileInfoHeader;
};

이미지 데이터

const createImageData = () => {
    const imageData = Buffer.alloc(red.length);
    red.copy(imageData); // 빨간색 데이터 복사
    return imageData;
};

셋을 합쳐 파일 데이터 만들기

const fileData = Buffer.concat([fileHeader, fileInfoHeader, imageData]);

    fs.writeFile('red_image.bmp', fileData, (err) => {
        if (err) throw err;
        console.log('이미지가 생성되었습니다. "red_image.bmp" 파일을 확인해보세요.');
    });

하면 이런 이미지를 확인할 수 있다.

만들어진 이미지

 

bmp 파일 내의 어떤 정보들이 있는지 몰라도 사용자들이 쉽게 bmp 파일을 만들 수 있게 함수를 만들어 배포해 보자.

학습하다 보니 온전한 파일을 만드는 것은 파일의 정보를 알아야 하는 복잡한 일이다.

다른 개발자분들이 색상 데이터들을 이용하여 파일을 만들고 싶은 니즈가 있을 때 쉽게 파일을 만들 수 있도록 인터페이스를 구축하여 배포해 보자.

생각한 인터페이스는 다음과 같다.

사용자들은 너비와 높잇값 그리고 색상 데이터만을 제공한다.

const myBmp = getFullBmp({colorData, width, height});

fs.writeFile('lorem.bmp', myBmp, (err) => {
  if (err) throw err;
  console.log('"lorem.bmp" file created.');
});

이런 인터페이스를 제공하기 위해 bmp의 패딩을 바탕으로 줄을 바꾸어 높이를 만들어주어야 한다.

bmp파일의 가로 크기는 4의 배수여야 하는 규칙을 가지고 있다.

패딩은 가로 픽셀의 크기를 4의 배수로 맞춰주기 위한 보수 값이다.

즉 가로 크기에 따라서 4의 배수가 되도록 패딩을 맞춰주고 가로가 바뀔 때 더해가며 계산해 주면 된다.

패딩크기를 계산하는 함수는 다음과 같이 만들어줬다.

const calculatePaddingSize = (width, pixelSize = 3) => {
  const bytesPerRow = width * pixelSize;
  return (4 - (bytesPerRow % 4)) % 4;
};

이렇게 완성된 코드로 이미지로 만드는 예를 살펴보자.

const { getFullBmp } = require('image-maker');
const fs = require('fs');

const redBlock = Buffer.from([0, 0, 255]);
const blueBlock = Buffer.from([255, 0, 0]);

const redBlueBlock = Buffer.concat([redBlock, redBlock, blueBlock, blueBlock]);

const myBmp = getFullBmp({ colorsData: redBlueBlock, width: 2, height: 2 });

fs.writeFile('red_blue.bmp', myBmp, (err) => {
  if (err) throw err;
  console.log('"red_blue.bmp" file created.');
});

이 코드를 동작시키면 다음과 같은 이미지가 만들어진다.

너비와 높이가 있는 이미지

 

이 코드들을 합쳐 하나의 getFullBmp라는 하나의 함수로 만들어 다른 사람들도 install 후 import 하여 사용할 수 있도록 배포해 보자.

type도 지원하기 위해 typescript 파일로 바꾼 후 esm 방식과 cjs 방식을 모두 지원할 수 있도록 빌드해서 배포했다.

`package.json` 파일을 살펴보면 다음과 같다.

{
  "name": "image-maker",
  "version": "0.1.2",
  "description": "Make image buffer or file with color data",
  "keywords": [
    "image",
    "bmp",
    "buffer",
    "node",
    "color"
  ],
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  },
  "bugs": {
    "email": "just731731@gmail.com",
    "url": "https://github.com/NaamuKim/image-maker/issues"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/NaamuKim/image-maker.git"
  },
  "main": "lib/index.ts",
}

"main" 부분과 "exports" 부분이 중요했는데 이 부분은 잠시 살펴보자.
main은 라이브러리 사용자가 export 할 package의 진입점이다.

이곳에서 export 한 모듈을 바로 import 하여 사용할 수 있다.
하지만 문제가 있다.

commonJS로 라이브러리를 사용하는 사람과 esmodule 방식으로 라이브러리를 사용하는 사람이 import 할 때는 진입점이 달라야 한다.
따라서 이를 하나씩 지정해 준다.
types 키워드로 타입이 필요할 때 Typescript 선언 파일인 index.d.ts를 참조하도록 지정한다.
import 키워드로 가져올 때 esm 방식으로 빌드된 파일인 index.js
require 키워드로 가져올 때 cjs 방식으로 빌드된 index.cjs
default는 esm방식으로 빌드된 index.js로 지정해 주었다.

 

배포된 npm 페이지 이미지와 주소는 다음과 같다.

 

마무리

이미지가 어떻게 이뤄지는지 알기 위해 세 가지를 해보았다.

nodeJS가 이미지를 읽는 방식을 살펴보고 정리해 보기

bmp 형식의 이미지를 코드로 만들어보기

색상 데이터, 너비, 높이만 알면 파일 구조를 몰라도 파일을 만들 수 있는 함수를 만들어 배포해 보기

 

학습한 내용을 간단히 정리해 보자.

nodeJS에서 이미지는 버퍼로 이루어진다.

그 버퍼는 octet 스트림과의 상호작용을 위해 만들어져 있고 데이터를 다루는 하나의 패러다임이다.

즉 버퍼와 스트림을 잘 사용하여 데이터를 만들어낼 수 있고 이를 저장하거나 전송하는 API 등을 통해 응용이 가능하다.

이미지 파일은 여러 정보들을 담는다.

파일 헤더와 파일 정보 헤더에 파일을 인식할 수 있도록 데이터를 담고 압축 방식과 색상 데이터를 넣으면 온전한 이미지 파일이 되고 기기에서 읽을 수 있다.

또 npm에서 우리가 사용하는 라이브러리들은 package.json안에 진입점이 담겨있고 다양한 환경을 고려하기 위해 여러 가지 방식으로 빌드하여 제공할 수 있다.

 

작성한 코드는 이곳에서 확인할 수 있다.

깃허브 바로가기

 

참고문서

- heropy tech 내 npm 모듈 배포하기

- nodeJS 버퍼 공식문서

댓글