성능을 개선한 워드클라우드 (Web Worker)

예리에르·2023년 6월 14일
1

Frontend

목록 보기
7/10
post-thumbnail

🔍워드클라우드?

워드클라우드는 데이터를 시각적으로 보여줌으로써 키워드와 개념을 직관적으로 파악할 수 있게 해줄 수 있는 기법입니다. 가중치가 크거나 빈도가 높은 단어는 크게 표현해 한 눈에 들어 올수있게 하는 대표적인 기법이 있습니다.

이러한 특징으로 컴포넌트를 만들 때 고려해야 하는 부분들이 많고 복잡할 수 있습니다. 하지만 직접 만들어보면 배울 점이 많은 과제라고 생각하여 직접 만들어 보았습니다.

🧐 첫 워드클라우드 아쉬운 완성

초기에 구현했던 워드클라우드는 BFS로 전체 캔버스를 탐색하고 DFS로 글자크기만큼 비었는지 확인 후 단어를 놓았습니다. 그러고 나서 완전 탐색으로 다시 전체 canvas 빈자리를 확안하고 단어를 놓는 로직으로 구현하였습니다.

보라색 색깔 글자를 보게 되면 3가지의 알고리즘이 사용하였습니다. 이중배열, while, 재귀까지 복잡성을 높이는 여러 요소가 포함되어 있습니다.

그 결과 코드의 복잡성연산 시간이 늘어남과 동시에 만족스럽지 않은 결과물을 얻었습니다. 🥲🥲

구체적인 문제점과 개선방안

다시 한번 코드를 확인하면서 로직 외에도 다양한 관점에서 코드를 분석해 봤습니다.

문제점

1. 연산시 필요하지 않은 this 참조 array
2. 확장이 어려운 구조
3. 연산 위치의 정확성
4. 싱글스레드(메인스레드) 연산으로 스레드를 점유하는 상태
5. 재귀로 인해 연산의 사이즈가 커지면 `Maximum call stack size` 발생

개선해보자~~!

➊. 연산시 필요하지 않은 this 참조 array

➡ this 참조는 최대한 지양하고 순수함수로 구성.

private readonly visitedBuffer = Array.from(Array(this.originalCanvasWidth), () => new Array(this.originalCanvasHeight).fill(0));

visitedBuffer는 캔버스 크기만큼의 이중 배열이 필요할 때마다 사용하기 위해 this 참조 변수입니다.

대체로 방문을 확인하는 용도로 사용하였습니다.


    const visited = new Set();
    ...
    while(queue) {
        visited.has();
        ...
        visited.add();
    }

필요할 때마다 배열을 만드는 것이 아니라 순수 함수로 외부 상태에 의존하지 않는 방향으로 코드를 변경하였습니다.

전에는 이중 배열로 방문을 확인했다면 해당 함수의 Set 자료구조를 사용하여 연산을 진행하는 함수 안에서만 사용되게 하였습니다.



❷. 확장이 어려운 구조

➡ 옵션 정리 (width, height, minFontSize, maxFontSize ...)

<WordCloud words={WordCloudValues} size={5}
                   width={50}
                   height={50}/>

기본으로 width, height, 단어리스트, 크기 등 사용자는 크기만 변경 할 수 있었습니다.

하지만 단어 리스트의 value가 달라질 수 있고 그에 따라 fontsize 또한 고정된 느낌으로 단어들의 반도 나오지 않는 최악의 상황이 있었습니다.


   <WordCloud  words={hangulData}
                             width={530}
                             height={530}
                             opt={{
                                     minFontSize: 16,
                                     maxFontSize: 74,
                                     debugMode: true,
                                     sorted: false,
                                     maskingImage: BackgroundBlueCIRCLE
                             }}/>
//WordCloud.tsx
private getFontSize = (weight: number) => {
        const {minFontSize = 14, maxFontSize = 64} = this.props.opt ?? {};
        const {min, max} = this.minMaxValue;
        const weightPerFontSize = (maxFontSize - minFontSize) / (max - min);
        return Math.floor((weight - min) * weightPerFontSize) + minFontSize;
    };

기본으로 width, height에서 더 나아가 원하는 fonstSize로 설정 가능하게 추가하였습니다. getFontSize는 fontSize에서 가중치에 맞는 fontSize를 얻게 해주는 함수입니다.

또한 debugMode를 통해 글자가 놓인 캔버스의 모습을 보여줌으로써 위치와 간격을 그림으로 나타내어 사용자의 이해도를 높이는 옵션도 추가하였습니다.

이렇게 추가된 여러 개의 옵션은 사용자가 원하는 워드클라우드를 나타낼 수 있게 하면서 확장 가능성을 가지게 되었습니다.



➌. 연산 위치의 정확성

➡ 정확한 글자의 사이즈 측정

const textWidth = ctx.measureText(w.word).width * 0.85;

javascript의 내장함수 measureText()를 사용하여 글자의 크기를 측정하였습니다. 간편한 방법이지만 글자의 높이를 측정하는 데에 한계가 있었습니다.

여러 케이스를 적용하면서 짐작으로 0.85라는 숫자를 사용하여 글자 크기를 설정하였습니다.

그러다 보니 언어에 따라, OS에 따라 환경이 바뀌면 워드클라우드도 같이 변하는 문제가 있었습니다.



모든 글자의 높이의 시작과 끝을 빈칸 없이 채우는 문자를 사용했습니다.

0) 글자 크기 측정에 필요한 버퍼 캔버스를 만들자.

측정 무대가 되는 가로 1000 세로 100 크기의 버퍼 캔버스를 만들었다. 이 캔버스의 가로 끝과 세로 끝을 이용해서 글의 크기를 측정할 것입니다.

사진을 보면 ▉은 글자가 가질 수 있는 최대의 높이를 가지고 있는 것을 확인할 수 있었습니다.

1) ▉ 너비

getImageData의 data는 rgba 값으로 4개의 값이 픽셀 한 개를 나타냅니다.

fontsize 보다 조금 여유 있는 위치 인덱스에서 0 인덱스로 조금씩 다가가다 보면 rgba의 값과 markingColorSum(▉의 색깔) 이 같은 경우를 발견할 수 있습니다.

이 경우는 글자가 있다는 의미이므로 해당 인덱스의 값을 통해 ▉의 너비를 알 수 있습니다.

            textCtx.font = `${fontSize}px arial`;
            textCtx.textAlign = 'left';
            textCtx.textBaseline = 'top';
            textCtx.fillStyle = this.markingColorRGBA;

            textCtx.clearRect(0, 0, textBufferSize.w, textBufferSize.h);
            textCtx.fillText(separator, 0, 0);
            const {data: rgbBeforeData = []} = textCtx.getImageData(0, 0, textBufferSize.w, textBufferSize.h);

            //separator 에 대한 크기를 산출
            let separatorSize = 0;
            for (let i = 2 * Math.round(fontSize) * 4; i >= 0; i -= 4) {
                const rowIdx = textBufferSize.w * 4 * 2 + i;
                const r = rgbBeforeData[rowIdx];
                const g = rgbBeforeData[rowIdx + 1];
                const b = rgbBeforeData[rowIdx + 2];
                const a = rgbBeforeData[rowIdx + 3];
                if (r + g + b + a === this.markingColorSum) {
                    separatorSize = Math.floor(i / 4);
                    break;
                }
            }

            textCtx.clearRect(0, 0, textBufferSize.w, textBufferSize.h);

2) 글자가 포함된 너비

같은 방법으로 이번에는 ▉모바일 인덱스▉ 글자의 너비를 측정합니다.

여유로운 측정을 위해 글자의 길이에 2를 더해주었습니다.

            textCtx.fillText(separator + text + separator, 0, 0);

            const {data: rgbData = []} = textCtx.getImageData(0, 0, textBufferSize.w, textBufferSize.h);

            //너비를 산출
            let ex = 0;
            for (let i = Math.min(textBufferSize.w * 4, (text.length + 2) * Math.round(fontSize) * 4); i >= 0; i -= 4) {
                const rowIdx = textBufferSize.w * 4 * 2 + i;
                const r = rgbData[rowIdx];
                const g = rgbData[rowIdx + 1];
                const b = rgbData[rowIdx + 2];
                const a = rgbData[rowIdx + 3];
                if (r + g + b + a === this.markingColorSum) {
                    ex = Math.floor(i / 4);
                    break;
                }
            }
3) 글자의 높이

방법은 같지만 반복문의 시작점이 다릅니다. 목적이 높이로 버퍼 canvas의 높이 textBuffer.h (100) 가 시작점입니다.

        let ey = 0;
        for (let i = textBufferSize.h - 1; i >= 0; i--) {
            const rowIdx = textBufferSize.w * i * 4;
            const r = rgbData[rowIdx];
            const g = rgbData[rowIdx + 1];
            const b = rgbData[rowIdx + 2];
            const a = rgbData[rowIdx + 3];
            if (r + g + b + a === this.markingColorSum) {
                ey = Math.floor(i);
                break;
            }
    }
4) 글자의 최종 크기

너비에서 측정에만 필요한 ▉ 너비를 빼면 워드클라우드에 들어갈 글자의 정확한 너비를 알 수 있습니다. 👍👍

     return {w: ex - separatorSize * 2, h: ey};


➍. 싱글스레드(메인스레드) 연산으로 스레드를 점유하는 상태

➡ Web Worker로 멀티스레드 구현하기


현재 워드 클라우드를 보게 된다면 모든 단어가 자리를 찾기 전까지 컴포넌트는 멈추게 됩니다.(Block 상태)

캔버스 크기가 작거나 나타낼 단어가 적다면 컴포넌트가 나타나지 않는 기간이 짧아서 중요하지 않게 생각할 수 있습니다.

하지만 단어의 개수가 많아지고 나타낼 캔버스가 크다면 사용자 입장에서 불편함을 느낄 수 있다고 생각합니다.


javascript는 싱글스레드 기반으로 작동합니다. 이런 단점을 보완하기 위해 코드들이 비동기로 실행됩니다.

비동기 실행이 너무 많이 쌓이게 된다면 속도가 느려질 수 있습니다. 이를 위해 나온 것이 웹 워커(Web Woker)입니다.

🔍 웹 워커(Web Worker)?
스크립트 연산을 메인 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다. 무거운 작업을 분리된 스레드에서 처리하면 주 스레드가 멈추거나 느려지지 않고 동작할 수 있습니다.

하지만 몇 가지 예외가 존재합니다. :(

워커는 DOM을 직접 조작할 수 없고, window의 일부 메서드와 속성은 사용할 수 없습니다.

그래서 스크립트를 작성할 때 DOM을 직접 조작하는 작업보다 원하는 연산 값만 도출할 수 있는 작업을 작성해야 합니다.

대표적인 작업

• 매우 많은 문자열의 Encoding/Decoding

• 복잡한 수학 계산(소수prime numbers, 암호화 등)

• 매우 큰 배열의 정렬

• 네트워크를 통한 데이터 처리

• local storage 데이터 처리

• 이미지 처리• 비디오나 오디오 데이터 처리

• Background I/O

• 기타 백그라운드에서 오랜 시간 작업해야 하는 경우

• UI 쓰레드를 방해하지 않고 지속적으로 수행해야 하는 작업

두 스레드가 메시지를 주고받는 방식은 postMessage를 통해 데이터나 결과를 전달하고 onmessage로 정보를 받습니다.

  • 메인스레드에서 워커를 시작. start
  • 워커스레드는 작업을 시작하기 위해 메인스레드에 메시지를 보냅니다. next
  • 작업 메시지를 받은 메인스레드는 자리를 찾는데 필요한 데이터들을 보냅니다. process
  • 워커스레드는 받은 데이터를 기반으로 단어가 들어갈 자리를 찾습니다.
    • find = true : 단어가 들어갈 좌표 보내줍니다. result
    • find = false : 들어갈 자리가 없기 때문에 다음 단어를 달라고 요청합니다. next

웹 워커를 사용하기 전 워드클라우드와 비교한다면 단어들이 자리를 찾아가는 동안 사용자는 컴포넌트가 멈춰있는 화면(Block 상태)을 보는 시간이 줄어들게 됩니다.



➎. 재귀로 인해 연산의 사이즈가 커지면 Maximum call stack size 발생

➡ Somtimes 단순 is answer (이중for문, 탐색거리 옵션)

1) 이중for 문


BFS의 while 문도 복잡하지만 그것보다 더 복잡한 case를 뽑으라면 재귀문이라고 생각합니다.

    private checkPositionDFS = (canvasMap: any, startX: number, startY: number, currentX: number, currentY: number, height: number, width: number, visitedChecker: number[][]) => {
        if (currentY < startY ||
            currentY >= startY + height ||
            currentX < startX ||
            currentX >= startX + width) {
            return true;
        }
        // 글자 존재 할 때 return
        if (this.props.shape) {
            if (canvasMap[currentY][currentX] < this.modeColor) {
                return false;
            }
        } else if (canvasMap[currentY][currentX] > 0) {
            return false
        }


        // 글자가 없고 방문하지 않았다면
        if (visitedChecker[currentY][currentX] === 0) {
            // 방문표시
            visitedChecker[currentY][currentX] = 1;

            // 다음 좌표를 재귀
            // Todo : 탐색 길이? 1에 대한 옵션만들기
            if (!this.checkPositionDFS(canvasMap, startX, startY, currentX + 1, currentY, height, width, visitedChecker) ||
                !this.checkPositionDFS(canvasMap, startX, startY, currentX, currentY + 1, height, width, visitedChecker)) {
                return false;
            }
        }
        return true;
    };

글자가 들어갈 수 있는 빈공간이지 확인하는 방법은 DFS를 사용했습니다. 하지만 DFS는 운이 좋다면 빠르게 탐색할 수있지만 최악의 경우 재귀가 깊어지고 복잡해지는 문제가 발생했습니다.



그래서 단어들의 크기는 전체 캔버스보다 작은 크기의 탐색이므로 이중반복문으로 충분히 가능하기 때문에 변경하였습니다.👍

    private isEmpty = (imageData: ImageData,
                       direction: Direction,
                       fgColor: number,
                       x: number, y: number, w: number, h: number) => {
        const {data, width} = imageData;
        const dw = direction === Direction.landscape ? w : h;
        const dh = direction === Direction.landscape ? h : w;
        for (let dx = Math.floor(x - dw / 2); dx < Math.floor(x + dw / 2); dx++) {
            for (let dy = Math.floor(y - dh / 2); dy < Math.floor(y + dh / 2); dy++) {
                const idx = dx * 4 + (width * 4 * dy);
                const sumRgb = data[idx] + data[idx + 1] + data[idx + 2] + data[idx + 3];
                if (sumRgb != fgColor || sumRgb === this.markingColorSum) {
                    return false;
                }
            }
        }
        return true;
    };

4방으로 탐색하고 글자의 중간을 기준으로 직사각형을 그리면서 반복문을 실행합니다.

fgColor : 나타내고 싶은 svg의 대표 색깔 파란색.

markingColorSum : 글자 영역을 나타내는 하늘색.

다음 좌표의 색깔이 fgColor와 다르거나(나타내고 싶은 배경에서 벗어남) markingColorSum과 같다면(이미 다른 글자가 있다) 비어있는 공간이 아니기 때문에 false를 return 합니다.

2) 탐색거리 옵션

1 거리만큼 캔버스를 탐색하면 좀 더 정확하고 많은 단어를 클라우드에 담을 수 있습니다.

하지만 그렇게 되면 캔버스가 커질수록 연산이 느려지기 때문에 탐색하는 거리를 조절하였습니다.

    private findXy = (imageData: ImageData,
                      direction: Direction,
                      w: WordTypeInner,
                      searchLength: number,
                      fgColor: number,
                      startX: number,
                      startY: number) => {
        const {width, height} = imageData;
        const visited: Set<string> = new Set();
        const queue = [{x: startX, y: startY}];

        while (queue.length) {
            const {x, y} = queue.shift();

            for (let dx = -searchLength; dx <= searchLength; dx += searchLength) {
                for (let dy = -searchLength; dy <= searchLength; dy += searchLength) {
                    const nextX = x + dx;
                    const nextY = y + dy;
                    const wordWidth = direction === Direction.landscape ? w.width : w.height;
                    const wordHeight = direction === Direction.landscape ? w.height : w.width;

                    if (nextY < 0 || nextX < 0 ||
                        (nextX + wordWidth) >= width || (nextY + wordHeight) >= height ||
                        visited.has(`${nextX},${nextY}`)) {
                        continue;
                    }

                    visited.add(`${nextX},${nextY}`);

                    const isEmpty = this.isEmpty(imageData, direction, fgColor, nextX, nextY, w.width, w.height);
                    if (isEmpty) {
                        return {x: nextX, y: nextY};
                    } else {
                        queue.push({x: nextX, y: nextY});
                    }
                }
            }
        }
    }
🔍탐색거리 1 vs 탐색거리 5


미묘한 차이로 보일 수 있겠지만 5 거리가 좀 더 여유 있게 배치되어 있는 것을 확인할 수 있습니다.

단어 하나가 들어갈 공간보다 적기 때문에 연산속도를 올리면서 단어를 배치하게 적당한 거리라고 생각할 수 있습니다.

워드클라우드가 나타나기까지 걸린 기간을 성능탭을 통해서 확인해 봤습니다.

탐색 거리1의 경우 700 ~ 1000 까지 기간이 걸립니다. 탐색거리의 5의 경우 200 ~ 400 까지 기간이 걸렸습니다.

추가로 위치를 찾는 횟수를 카운트했을 때 평균 2번 횟수만 차이가 났지만 기간을 보면 5의 경우 빠르게 탐색이 가능해 횟수에 비해 기간이 월등히 좋은 걸 느낄 수 있었습니다!

개선점들을 관통하면서 특히 5번째 해결할 때는 시니어와 주니어의 차이를 확실하게 느끼게 되었습니다.

처음 컴포넌트를 개선할 때 어떻게 어디를 건들어야 할지 감이 오지 않았습니다. 하지만 다양한 분들의 도움을 받아 문제점을 발견하고 해결하면서 코드는 점점 간결해졌습니다.

(밑에 사진을 보면 처음 보는 사람도 바로 이해할 수 있는 단순, 읽기 쉬운 코드 작성이 중요한 이유를 알 수 있습니다... 😂)

샘플 코드

https://github.com/IGAWorksDev/dmp.mi.devlog.publicSample/tree/feature/word-cloud/sample-wordcloud

profile
비전공 프론트엔드 개발자의 개발일기😈 ✍️

0개의 댓글