Compression Streams API를 이용한 텍스트 데이터에 대한 압축 및 해제

Compression Streams API는 웹 표준 API로 데이터에 대한 압축 및 해제를 위한 API이다. 파일 단위가 아닌 데이터에 대한 압축으로 그 목적이 zip과는 다르지만 데이터 압축에 대한 알고리즘은 유사하다. 즉, zip은 여러개의 파일을 압축하면서 압축 해제시에는 파일 명과 디렉토리 구조까지 복원해주지만 Compression Sterams API는 데이터에 대한 압축으로 압축을 해제하면 해제된 데이터가 나오며 그 데이터를 하나의 파일로 저장한다.

Compression Streams API로 압축할 수 있는 데이터는 텍스트 든 바이너리든 모두 가능한데, 아래의 코드는 텍스트에 대한 압축과 압축된 데이터를 다시 원래의 텍스트 데이터로 복원(압축 해제)하는 코드이다.

async function compressAndDecompressString(text) {
  // 1. 텍스트를 Uint8Array로 변환
  const textEncoder = new TextEncoder();
  const encodedData = textEncoder.encode(text);

  console.log("원본 데이터:", text);
  console.log("원본 데이터 크기 (바이트):", encodedData.byteLength);

  // 2. 데이터를 압축 (gzip 형식)
  // ReadableStream.from()은 Node.js에서 사용 가능하며, 
  // 브라우저에서는 ArrayBuffer를 직접 ReadableStream으로 변환해야 함.
  // 여기서는 편의를 위해 Blob을 사용하여 ReadableStream을 생성합니다.
  const originalStream = new Blob([encodedData]).stream();
  const compressedStream = originalStream.pipeThrough(new CompressionStream("gzip"));

  // 3. 압축된 데이터를 ArrayBuffer로 읽기
  const compressedResponse = new Response(compressedStream);
  const compressedBuffer = await compressedResponse.arrayBuffer();

  console.log("압축된 데이터 크기 (바이트):", compressedBuffer.byteLength);
  console.log("압축률:", 
    ((1 - compressedBuffer.byteLength / encodedData.byteLength) * 100).toFixed(2) + "%");

  // 4. 압축된 데이터를 압축 해제
  const decompressedStream = new Blob([compressedBuffer]).stream()
    .pipeThrough(new DecompressionStream("gzip"));

  // 5. 압축 해제된 데이터를 ArrayBuffer로 읽기
  const decompressedResponse = new Response(decompressedStream);
  const decompressedBuffer = await decompressedResponse.arrayBuffer();

  // 6. 압축 해제된 Uint8Array를 다시 텍스트로 변환
  const textDecoder = new TextDecoder();
  const decompressedText = textDecoder.decode(decompressedBuffer);

  console.log("압축 해제된 데이터:", decompressedText);
  console.log("압축 해제된 데이터 크기 (바이트):", decompressedBuffer.byteLength);

  // 원본 데이터와 압축 해제된 데이터가 동일한지 확인
  console.log("원본과 압축 해제된 데이터 일치 여부:", text === decompressedText);
}

// 예제 실행
const longText = `The quick brown fox jumps over the lazy dog.`;

compressAndDecompressString(longText);

SharedArrayBuffer

SharedArrayBuffer는 웹에서 스레드 간의 공유 메모리로 사용된다. 메인 스레드에서 4바이트 크기의 메모리 만들고 이를 공유해 웹워커에서 이 4바이트의 데이터를 정수값으로 해석해 값을 1 증가시키는 예제다. 먼저 vite로 프로젝트를 구성했으며 main.js의 코드는 다음과 같다.

if (!crossOriginIsolated) {
  throw new Error('SharedArrayBuffer를 사용하려면 crossOriginIsolated 환경이어야 합니다.');
}

const worker = new Worker('./worker.js');
const buffer = new SharedArrayBuffer(4);
const view = new Int32Array(buffer);

view[0] = 42;

worker.postMessage({ buffer });

setTimeout(() => {
  console.log('메인에서 읽기:', view[0]);
}, 500);

이 시점에서는 2가지 문제가 발생해야 한다. 첫번째는 조건문 if에 대한 crossOriginIsolated를 통과하지 못한다. 이는 SharedArrayBuffer를 이용하기 위해서는 crossOriginIsolated가 설정되어야 한다. 두번째는 worker.js 파일이 존재하지 않는다. 첫번째 문제를 해결하기 위해서 먼저 다음과 같은 개발환경을 위한 패키지를 설치해야 한다.

npm install -D vite-plugin-cross-origin-isolation

그리고 vite.config.js 파일을 열어, 없다면 생성해서 다음처럼 플러그인을 추가한다.

import { defineConfig } from 'vite'
import crossOriginIsolation from "vite-plugin-cross-origin-isolation";

export default defineConfig({
  plugins: [
    crossOriginIsolation(),
  ],
})

두번째 문제인 worker.js 파일을 public 폴더에 생성하고 다음처럼 작성한다.

self.onmessage = (event) => {
  const buffer = event.data.buffer;
  const view = new Int32Array(buffer);

  console.log('워커에서 읽기:', view[0]);

  // view[0] += 1;
  Atomics.add(view, 0, 1);

  console.log('워커에서 변경:', view[0]);
}

값을 1 더하는 코드는 Atomics API를 이용해 데이터 경쟁이 발생하지 않도록 하는 것이 바람직하다.

Offscreen Canvas 샘플 코드

웹은 기본적으로 단일 스레드이지만 WebWork를 통해 멀티 스레드를 사용할 수 있다. 별도의 스레드를 통해 어떤 그림을 그릴 수 있다면 그림이 그려지는 동안에도 다른 작업을 처리할 수 있다.

시나리오는 다음과 같다. 사용자는 자신의 PC에서 이미지 파일을 읽고 이미지 파일에 대한 데이터를 웹 워커에게 전달한다. 웹워커에서 이 이미지 데이터를 캔버스에 그린다. (사실 이 예제는 흐름상 묘한데, 이미지 데이터를 캔버스에 그린다라는 것을 캔버스에 원하는 도형들을 그린다라고 하는게 더 자연스럽다.)

먼저 UI로 이미지 파일을 읽어올 DOM이 필요하다. 이 코드는 main.js 파일에 존재한다.

document.querySelector('#app').innerHTML = /* html */ `
  <input type="file" /> 
`;

웹워크를 기동한다. 이 코드는 main.js 파일에 존재한다.

const worker = new Worker('./worker.js');

input DOM을 클릭해서 파일을 선택했을때의 이벤트는 다음과 같다.

const handleFile = (event) => {
  const file = event.target.files[0];
  const reader = new FileReader();
  reader.onload = (event) => {
    const canvas = document.createElement("canvas");
    const offsetScreenCanvas = canvas.transferControlToOffscreen();
    const dataUrl = event.target.result;
    worker.postMessage(
      { offsetScreenCanvas, dataUrl },
      [ offsetScreenCanvas ]
    );
  }
  reader.readAsDataURL(file);
}

const fileInput = document.querySelector("input");
fileInput.addEventListener('change', handleFile);

canavs를 만들고 이 캔버스를 웹워커로 전달하기 위해 offsetScreen으로 만든다. offsetScreen은 공유 메모리로 전달할 수 있지만 읽은 파일의 데이터는 복사해서 전달하고 있다. work.js 파일의 내용은 다음과 같다.

self.onmessage = async (event) => {
  const { offsetScreenCanvas, dataUrl } = event.data;

  // const response = await fetch(dataUrl);
  // const blob = await response.blob();

  const blob = await fetch(dataUrl).then((r) => r.blob());
  const imageBitmap = await createImageBitmap(blob);
  const context = offsetScreenCanvas.getContext('2d');

  const width = imageBitmap.width / 2;
  const height = imageBitmap.height / 2;

  offsetScreenCanvas.width = width;
  offsetScreenCanvas.height = height;

  context.drawImage(imageBitmap, 0, 0, width, height);
}

이미지를 캔버스에 다 그렇다면 캔버스에 그려진 결과를 Blob 데이터르 만들어 메인 스레드에 전달해야 한다. 관련 코드는 다음과 같다.

self.onmessage = async (event) => {
  ...

  context.drawImage(imageBitmap, 0, 0, width, height);

  offsetScreenCanvas.convertToBlob().then(blob => {
    const reader = new FileReader();
    reader.onload = () => {
      self.postMessage({ dataUrl: reader.result });
    };
    reader.readAsDataURL(blob);
  });
}

위의 코드와 관련된 메인 스레드의 코드는 다음과 같다.

worker.onmessage = (e) => {
  const img = new Image();
  img.src = e.data.dataUrl;

  document.querySelector('#app').appendChild(img);
}

앞서 언급했듯 위의 예제에서 가장 큰 문제는 메인 스레드에서 읽은 이미지 데이터 원본에 대한 동일한 크기의 데이터를 만들어 웹워커에 전달하고 있다. 이 부분에 대한 개선은 공유 메모리로 해결이 가능하다. 단, 공유 메모리를 사용하기 위해서는 서버 측 보안 수준이 가장 높은 상태여야 한다. 끝으로 최종 결과는 아래와 같다.

ES6의 Fetch API

ES6에서 지원하는 fetch API는 ES6의 Promise와 함께 AJAX를 Wrapping 해주는 매우 가독성이 뛰어난 방식입니다. fetch API은 내부적으로 AJAX API와 Promise를 사용하면서 외부로 들어내지 않습니다. 이 글은 fetch API에 대한 매우 기초적이지만 가장 일반적으로 많이 사용되는 내용을 정리합니다.

먼저 아래의 코드는 이미지를 fetch API를 통해 가져와 표시하는 코드 중 먼저 DOM 요소에 대한 코드입니다.


언급할 필요조차 없을 정도로 간단합니다. 다음은 실제 관심이 집중되는 fetch API 코드입니다.

fetch('lion.jpg')
    .then(function (response) {
        return response.blob();
    })
    .then(function (blob) {
        var objUrl = URL.createObjectURL(blob);
        myImage.src = objUrl;
    });

먼저 1번 코드의 fetch 함수를 통해 인자로 lion.jpg라는 상대 경로의 URL을 통해 (네트워크에서) 이미지 데이터를 가져와 2번 코드의 첫번째 then에 지정된 함수의 response에 그 결과를 인자로 담아 함수를 호출합니다. 호출된 함수에서 반환하는 결과에 대해 5번 코드의 then에 지정된 또 다른 함수의 인자에 담아 호출하게 됩니다. 하나의 fetch는 이처럼 2개의 연속된 then으로 구성됩니다.

API의 실행에 있어 중요한 것은 해당 API의 실패에 대한 처리입니다. 만약 lion.jpg에 대한 URL이 올바르지 않을 경우에 대한 처리가 필요합니다. 아래의 코드는 이러한 예외에 대한 경우의 코드까지 담고 있습니다.

fetch('lion.jpg')
    .then(function (response) {
        if (response.ok) {
            return response.blob();
        } else {
            alert('네트워크 오류');
        }
    })
    .then(function (blob) {
        var objUrl = URL.createObjectURL(blob);
        myImage.src = objUrl;
    })
    .catch(function (err) {
        alert(err)
    });

첫번째 then은 네트워크 통신 및 인자로 지정한 URL에 대한 문제가 있는지를 검사해야 하는데, 3번 처럼 response 인자의 ok 속성을 통해 확인 가능합니다. 두번째 then은 첫번째 then이 성공했던 실패했든지간에 두번째 then에서 지정한 함수의 실행 시에 어떤 문제가 발생하면 13번 코드의 catch의 함수가 호출됩니다.

아래는 POST 방식에 대한 fetch API 사용예입니다.

var params = {
    '_id': 'test.getPlayersWhere2',
    'age': 10,
    'name': '%'
};

fetch('http://localhost:8080/Bang?query',
    {
        method: 'POST',
        body: JSON.stringify(params)
    })
    .then(function (response) {
        if (response.ok) return response.text();
    })
    .then(function (text) {
        if (text) div.innerText = text;
        else alert('error');
     });

위의 사용예에 대한 jQuery 방식은 아래와 같습니다.

var params = {
    '_id': 'test.getPlayersWhere2',
    'age': 10,
    'name': '%'
};

$.ajax({
    url: 'http://localhost:8080/Bang?query',
    type: 'POST',
    data: JSON.stringify(params),
    dataType: "text",

    success: function (response) {
        div.innerText = response;
    },

    error: function (xhr, status) {
        alert('Error: ' + status);
    }
});

jetty를 이용한 WebSocket 서버 구현하기

어플리케이션에 Servlet Container 이외도 여러가지 서버 기능을 내장할 수 있는 jetty 라이브러리를 이용해 WebSocket 서버를 구현하는 코드에 대한 뼈대를 정리한다. WebSocket는 HTML5에서 지원하는 기능으로 서버와 클라이언트는 연결되어 있는 상태에서 상호간에 데이터를 주고니 받거니 할 수 있는 기술이다. WebSocket은 그 막강한 통신 기능에 비해 서버와 클라이언트의 코드가 매우 작다. 그래서인지 뭔가 부족한 느낌이 드는데, 일단 기본적인거 정리하고 부족한 느낌이 드는 이유는 나중에 파악해 보자.

먼저 서버 단의 코드이다. Server를 구동하는 클래스와 WebSocket를 통해 접속하는 클라이언트를 나타내는 Session 범위의 클래스인데, 먼저 Server를 구동하는 클래스는 아래와 같다.

import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.websocket.server.WebSocketHandler;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;

public class test_webSocket {
    public static void main(String[] args) {
        Server server = new Server(8080);
		
        WebSocketHandler wsHandler = new WebSocketHandler() {
            @Override
            public void configure(WebSocketServletFactory factory) {
                factory.register(MyEchoSocket.class);
            };
        };

        ContextHandler context = new ContextHandler();
        context.setContextPath("/echoServer");
        context.setHandler(wsHandler);
		
        server.setHandler(context);

        try {
            server.start();
            server.join();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

그리고 다음은 서버에 접속하는 클라이언트(Session 객체로 구분) 마다 생성되는 Echo 기능을 갖는 MyEchoSocket 클래스이다.

import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WebSocketListener;

public class MyEchoSocket implements WebSocketListener {
    private Session outbound;
	
    public MyEchoSocket() {
        System.out.println("class loaded " + this.getClass());
    }

    @Override
    public void onWebSocketConnect(Session session) {
        this.outbound = session;		
    }

    @Override
    public void onWebSocketError(Throwable cause)
    {
        cause.printStackTrace(System.err);
    }

    @Override
    public void onWebSocketText(String message)
    {
        if ((outbound != null) && (outbound.isOpen()))
        {
            String echoMessage = outbound.hashCode() + " : " + message;
            outbound.getRemote().sendString(echoMessage, null);
        }
    }

    @Override
    public void onWebSocketBinary(byte[] payload, int offset, int len)
    {
        //.
    }

    @Override
    public void onWebSocketClose(int statusCode, String reason)
    {    	
    	System.out.println("Close, statusCode = " + statusCode + ", reasone = " + reason);
        this.outbound = null;
    }	
}

요즘 웹이 그렇듯이 WebSocket 기술은 Binary 데이터도 주고 받을 수 있다는 것을 알 수 있다. 중요한 것은 클라이언트 하나가 접속을 하면 위의 클래스가 매번 생성된다는 것이고, 클라이언트는 Session 객체를 통해 나타내지며, 이 객체를 통해 데이터를 주고 받을 수 있다.

서버를 살펴봤으니, 이제 클라이언트 코드를 살펴보면..




    
    


    

위의 13번 부분에 들어가는 js 코드는 다음과 같다.

        var webSocket = new WebSocket("ws://localhost:8080/echoServer/");
        var msgField = document.getElementById("messageField");
        var divMsg = document.getElementById("msg-box");

        function sendMsg() {
            var msgToSend = msgField.value;

            webSocket.send(msgToSend);

            divMsg.innerHTML += "
Client> " + msgToSend + "
"; msgField.value = ""; } webSocket.onmessage = function (message) { divMsg.innerHTML += "Server> : " + message.data; } webSocket.onopen = function () { alert("connection opened"); } webSocket.onclose = function () { alert("connection closed"); } webSocket.onerror = function (message) { alert("error: " + message); }

서버를 기동한 뒤, 클라이언트를 실행하고 메세지를 날리면, 해당 메세지가 메아리로 돌아오는 것을 볼 수 있다.