개발일지/🙂 Boarlog

실시간 화이트보드 공유 지연 최소화하기

무딘붓 2024. 3. 10. 20:56

 

기록으로 남기는 실시간 화이트보드 강의 서비스, 🙂 Boarlog 를 개발하며 구현한 내용을 담고 있습니다.
게시글의 기능을 이용한 최종 서비스는 GitHub 링크 에서 확인하실 수 있습니다. 

 

실시간 화이트보드 공유 서비스를 운영하며 생긴 지연 문제의 해결 과정을 정리한 글입니다.

해당 서비스를 처음 구현한 과정은 이전 개발 일지에 정리했습니다.

 

💭 0. 문제 상황

 

 

화이트보드에 쓴 글씨가 많아지면 참여자 페이지에서 느리게 보이는 문제가 발생했습니다.

 

이러한 문제점이 생기는 원인은 아래와 같이 세 가지를 추정해 봤습니다.

  • 강의자 페이지에서 화이트보드 정보를 저장하는 시간이 오래 걸린다.
  • 강의자가 보내는 화이트보드 데이터의 용량이 커서 서버를 통한 전송이 오래 걸린다.
  • 참여자 클라이언트에서 처리해야 할 데이터가 커서 화이트보드 정보의 렌더링 시간이 오래 걸린다.

 

정확한 원인을 찾기 위해 디버깅을 위한 코드를 작성했습니다. 디버깅 코드의 목표는 다음과 같습니다.

  • 정확히 지연이 발생하는 원인 (발생 위치) 찾기
  • 개선 후, 얼마나 지연이 감소했는지 개선 결과를 수치로 나타내기

 


🔍 1. 지연 원인 분석하기

 

1) 전송 과정 살펴보기

 

원인 분석을 하기 전, 글의 이해를 돕기 위해 몇 가지 배경을 정리했습니다.

 

우선 강의자가 화이트보드(canvas)에서 할 수 있는 행동은 크게 3가지입니다.

  • [1] 화이트보드 내부 객체 편집
    • = 글씨 그리기, 지우기, 크기 변경, 메모지 위치 변경
    • (Fabric.js에서는 canvas에 그린 내용이 객체 형태로 저장됩니다.)
  • [2] 화이트보드 뷰포트 정보 (= 강의자가 보는 canvas 시점) 변경
    • = 화이트보드의 위치 변경, 줌 인, 줌 아웃
  • [3] 강의자 화이트보드의 크기 변경
    • (강의자 화이트보드(canvas) 크기는 강의자의 브라우저 크기와 동일합니다)

 

구분을 쉽게 하기 위해서 앞으로 위의 3가지 이벤트(행동)에는 [1], [2], [3] 기호를 붙이겠습니다.

 

강의자 페이지에서는 위의 3가지 내용을 아래와 같은 ICanvasData 타입의 객체로 만들어 전송합니다.

 

더 자세한 내용은 이전 개발 일지를 참고해 주세요.

 

 

2) 전송 지연시간 측정하기

 

디버깅은 위 객체의 속성 중 eventTime을 사용했습니다.

  • 강의자 클라이언트에서 화이트보드 정보를 저장하는 시점을 eventTime 속성에 저장하면
  • 참여자 클라이언트에서 화이트보드 정보를 canvas에 그리는 시점과 eventTime의 차이를 비교해서 전송 지연을 측정합니다.

 

 

객체가 단 한 개일 때의 모습입니다. 강의자가 데이터를 전송한 지 대략 400ms 후에 참여자 페이지에 이벤트가 반영되고 있습니다.

 

 

객체를 대략 100개 정도 만든 후의 모습입니다. 400ms 정도였던 지연 시간이 최대 1,300ms 가까이 늘어났습니다. 눈으로 보기에도 지연이 보이기 시작합니다.

 

객체 수와 지연의 상관관계는 어느 정도 확인했으니, 이제 정확한 지연 발생 시점을 찾기 위해 지연 구간을 3개로 분할해서 확인해 보겠습니다.

  • (1) 저장 지연 : 화이트보드 이벤트 발생 ~ 화이트보드 데이터를 저장하고 전송하기까지의 시간
  • (2) 전송 지연 : 강의자 클라이언트가 데이터를 전송 ~ 참여자 클라이언트가 데이터를 수신하기까지의 시간
  • (3) 불러오기 지연 : 참여자 클라이언트가 데이터를 수신 ~ 데이터를 참여자 canvas에 렌더링 하기까지의 시간

 

객체 수 증가는 곧 전송 데이터의 크기 증가를 의미하므로, 화이트보드 데이터 크기도 함께 확인할 수 있도록 하겠습니다.

앞으로 동일한 조건에서의 비교를 위해 전송 데이터 크기가 약 700kb (일반적으로 화이트보드를 글씨로 가득 채웠을 때보다 더 큰 크기)가 되도록 화이트보드 내부 객체를 미리 만들어 불러온 후, 화이트보드 공유를 시작하도록 했습니다.

 


3) 측정 결과 분석

 

전송 데이터 크기가 약 700kb 일 때, 저장 / 전송 / 불러오기 지연 시간을 확인해 본 결과는 다음과 같습니다.

 

 

저장 지연은 짧은 편이지만, 전송 지연이 크다는 것을 확인할 수 있습니다.

 

불러오기 지연은 이벤트 [1] 에서만 지연이 큰 것을 확인할 수 있는데, 이는 이전에 [2], [3] 이벤트는 변경된 화이트보드 내부 객체 데이터를 canvas에 반영하지 않도록 최적화했기 때문입니다.

 

결과적으로 개선해야 할 지연 구간은 아래 세 구간입니다.

  • 1) 이벤트 [2], [3] 전송 지연
  • 2) 이벤트 [1] 불러오기 지연
  • 3) 이벤트 [1] 전송 지연

 



🛠️ 2. 개선하기

 

이제 지연 문제를 해결하기 위한 방안을 고려해 보겠습니다.

  • 1) 화이트보드 내부 객체가 변화하지 않는 [2], [3] 이벤트는
    • 전송 시 화이트보드 내부 객체 데이터 필드(canvasJSON)를 비워 놓기
    • → 전송되는 데이터의 크기를 줄여 전송 지연을 최소화할 수 있다.
    • + (참여자 페이지에서 이미 최적화를 위해 이러한 이벤트에서는 내부 객체 필드를 참조하지 않음)
  • 2) 불러오기 지연 원인을 파악하고, 개선하기
    • 사용하는 함수별 실행시간 확인
  • 3) 화이트보드 내부 객체가 변화할 때 데이터를 압축해서 보내기
    • 적절한 압축 알고리즘을 찾기

가장 먼저 1) 번 해결방안부터 시도해 보겠습니다.

 

 

1) 전송 데이터 크기를 최소화하기 (이벤트 [2], [3] 전송 지연 줄이기)

 

1) 번 해결 방안의 핵심은, 화이트보드 데이터 중 가장 용량이 큰 canvasJSON이 필요하지 않은 경우, canvasJSON을 비워서 보내는 것입니다.

 

이를 위해 강의자 클라이언트에서 ICanvasData 형태의 데이터를 만들 때, 기존 내부 객체 정보를 저장하고 만약 canvasJSON의 값이 변경되지 않은 경우에는 canvasJSON 값을 비워서 전송하도록 개선했습니다.

 

 

화이트보드 내부 객체 데이터 필드를 비워 놓는 방식을 적용한 후의 결과입니다.

 

[1] 객체 내부 편집이 아닌 화면 이동, [2], [3] 줌 인/줌 아웃과 같은 작업에서의 전송 지연이 상당히 감소했습니다. (화이트보드가 비었을 때와 비슷해짐)

 

 

이제 해결해야 할 지연은 2가지입니다.

  • 이벤트 [1]의 불러오기 지연
  • 이벤트 [1]의 전송 지연

 

2) fabric.canvas 불러오기 시간 단축하기 (이벤트 [1] 불러오기 지연 줄이기)

 

이제 이벤트 [1]의 불러오기 지연을 줄이기 위해, 불러오기에 사용하는 코드를 살펴보며 지연이 발생하는 원인을 찾아보겠습니다.

 

우선, 현재 사용하는 코드는 다음과 같습니다.

const handleWhiteboardUpdate = (data: any) => {
  loadCanvasData({
    fabricCanvas: fabricCanvasRef!,
    currentData: canvasData,
    newData: data.content,
  });
  canvasData = data.content;
};

/*---------------------------------------------------------*/

export const loadCanvasData = ({
  fabricCanvas, // 현재 참여자 페이지의 fabric.Canvas
  currentData, // 현재 참여자 페이지의 캔버스 데이터
  newData, // 강의자 페이지에게 받은 캔버스 데이터
}: {
  fabricCanvas: fabric.Canvas;
  currentData: ICanvasData;
  newData: ICanvasData;
}) => {
  const isCanvasDataChanged = currentData.canvasJSON !== newData.canvasJSON;
  const isViewportChanged = JSON.stringify(currentData.viewport) !== JSON.stringify(newData.viewport);
  const isSizeChanged = currentData.width !== newData.width || currentData.height !== newData.height;

  // [1] 캔버스 데이터 업데이트
  if (isCanvasDataChanged) fabricCanvas.loadFromJSON(newData.canvasJSON, () => {});
  // [2] 캔버스 뷰포트 업데이트
  if (isViewportChanged) fabricCanvas.setViewportTransform(newData.viewport);
  // [3] 캔버스 크기 업데이트
  if (isSizeChanged) updateCanvasSize({ fabricCanvas, whiteboardData: newData });
};

 

현재 불러오기 과정은 다음과 같이 이뤄집니다.

  1. 소켓 통신으로 강의자에게 화이트보드를 받으면 handleWhiteboardUpdate 실행
  2. handleWhiteboardUpdate는 loadCanvasData를 실행시키고, 수신 정보를 canvasData에 저장
  3. loadCanvasData 함수는 현재 canvas와 수신된 정보를 비교해서 [1]~[3] 이벤트 발생 여부를 확인
  4. 발생된 이벤트에 맞춰 canvas를 강의자와 동일하게 그리는 작업을 수행

 

크롬 개발자 도구 성능 탭을 이용하여 이벤트 [1]이 발생했을 때, loadCanvasData의 실행 시간을 확인한 결과입니다.

 

확인해 본 결과, 불러오기 지연의 대부분은 바로 loadFromJSON 메서드 때문임을 확인할 수 있습니다.

loadFromJSON 메서드는 객체 정보가 700 kb일 때, 약 80~100ms의 시간이 소요되는 것을 확인했는데, 이점을 해결하기 위해서는 개선이 필요합니다.

 

loadFromJSON: function (json, callback, reviver) {
    if (!json) {
      return;
    }
    // serialize if it wasn't already
    var serialized = (typeof json === 'string')
      ? JSON.parse(json)
      : fabric.util.object.clone(json);
    var _this = this,
        clipPath = serialized.clipPath,
        renderOnAddRemove = this.renderOnAddRemove;
    this.renderOnAddRemove = false;
    delete serialized.clipPath;
    this._enlivenObjects(serialized.objects, function (enlivenedObjects) {
      _this.clear();
      _this._setBgOverlay(serialized, function () {
        if (clipPath) {
          _this._enlivenObjects([clipPath], function (enlivenedCanvasClip) {
            _this.clipPath = enlivenedCanvasClip[0];
            _this.__setupCanvas.call(_this, serialized, enlivenedObjects, renderOnAddRemove, callback);
          });
        }
        else {
          _this.__setupCanvas.call(_this, serialized, enlivenedObjects, renderOnAddRemove, callback);
        }
      });
    }, reviver);
    return this;
  },

 

fabric.js에 구현된 loadFromJSON 코드입니다. (http://fabricjs.com/docs/fabric.js.html#line14096)

작동 과정을 요약하면, loadFromJSON 메서드는 현재 canvas의 내용을 모두 비운 후, 입력된 JSON을 파싱 하여 JSON의 objects를 전부 화면에 그리는 방식으로 작동합니다.

 

이 과정에서의 문제점은 객체 수가 많아지면, 해당 객체들을 모두 다시 처음부터 그려야 한다는 것입니다.

현재 강의자 페이지에서 JSON.stringify(fabricCanvas) 를 이용해 만들어서 보내는 캔버스의 데이터는 다음과 같이 저장되어 있습니다.

 

{"version":"5.3.0",
"objects":[
  {"type":"path","version":"5.3.0","originX":"left","originY":"top","left":608.99,"top":380.84,"width":82.02,"height":39,"fill":null,"stroke":"rgb(0, 0, 0)","strokeWidth":10,"strokeDashArray":null,"strokeLineCap":"round","strokeDashOffset":0,"strokeLineJoin":"round","strokeUniform":false,"strokeMiterLimit":10,"scaleX":1,"scaleY":1,"angle":0,"flipX":false,"flipY":false,"opacity":1,"shadow":null,"visible":true,"backgroundColor":"","fillRule":"nonzero","paintFirst":"fill","globalCompositeOperation":"source-over","skewX":0,"skewY":0,"path":[["M",613.99,424.8462235067437],["Q",614,424.8362235067437,615,423.8366088631984],["Q",616,422.83699421965315,617.5,421.8373795761079],["Q",619,420.83776493256266,620.5,419.3383429672447],["Q",622,417.8389210019268,624.5,416.83930635838146],["Q",627,415.8396917148362,630.5,413.84046242774565],["Q",634,411.8412331406551,636.5,410.34181117533717],["Q",639,408.8423892100193,644.5,406.3433526011561],["Q",650,403.8443159922928,653.5,401.84508670520233],["Q",657,399.8458574181118,659.5,398.84624277456646],["Q",662,397.84662813102113,668,395.8473988439306],["Q",674,393.8481695568401,677.5,392.34874759152217],["Q",681,390.84932562620423,685.5,389.3499036608863],["Q",690,387.8504816955684,691,387.35067437379575],["Q",692,386.8508670520231,694,386.3510597302504],["L",696.01,385.8412524084778]]},
  {"type":"path","version":"5.3.0","originX":"left","originY":"top","left":695,"top":492.8,"width":3,"height":18.01,"fill":null,"stroke":"rgb(0, 0, 0)","strokeWidth":10,"strokeDashArray":null,"strokeLineCap":"round","strokeDashOffset":0,"strokeLineJoin":"round","strokeUniform":false,"strokeMiterLimit":10,"scaleX":1,"scaleY":1,"angle":0,"flipX":false,"flipY":false,"opacity":1,"shadow":null,"visible":true,"backgroundColor":"","fillRule":"nonzero","paintFirst":"fill","globalCompositeOperation":"source-over","skewX":0,"skewY":0,"path":[["M",700,497.79809248554915],["Q",700,497.80809248554914,700,499.3075144508671],["Q",700,500.80693641618495,700,501.3067437379576],["Q",700,501.8065510597303,700,502.80616570327555],["Q",700,503.8057803468208,700,504.80539499036604],["Q",700,505.8050096339113,700.5,506.80462427745664],["Q",701,507.80423892100197,701,508.80385356454724],["Q",701,509.80346820809245,701.5,510.8030828516377],["Q",702,511.802697495183,702.5,512.8023121387283],["Q",703,513.8019267822737,703,514.8015414258189],["L",703,515.8111560693642]]},
  {"type":"path","version":"5.3.0","originX":"left","originY":"top","left":640.99,"top":525.79,"width":76.02,"height":21,"fill":null,"stroke":"rgb(0, 0, 0)","strokeWidth":10,"strokeDashArray":null,"strokeLineCap":"round","strokeDashOffset":0,"strokeLineJoin":"round","strokeUniform":false,"strokeMiterLimit":10,"scaleX":1,"scaleY":1,"angle":0,"flipX":false,"flipY":false,"opacity":1,"shadow":null,"visible":true,"backgroundColor":"","fillRule":"nonzero","paintFirst":"fill","globalCompositeOperation":"source-over","skewX":0,"skewY":0,"path":[["M",645.99,530.7853757225433],["Q",646,530.7953757225433,647,532.2947976878613],["Q",648,533.7942196531792,649,535.7934489402697],["Q",650,537.7926782273603,651.5,539.2921001926782],["Q",653,540.7915221579962,654.5,542.2909441233141],["Q",656,543.790366088632,657.5,544.7899807321774],["Q",659,545.7895953757226,660,546.7892100192678],["Q",661,547.7888246628131,661.5,547.7888246628131],["Q",662,547.7888246628131,662.5,548.2886319845857],["Q",663,548.7884393063583,664,549.288246628131],["Q",665,549.7880539499037,666.5,550.2878612716763],["Q",668,550.7876685934489,669,551.2874759152215],["Q",670,551.7872832369942,672.5,551.7872832369942],["Q",675,551.7872832369942,677,551.7872832369942],["Q",679,551.7872832369942,682.5,551.7872832369942],["Q",686,551.7872832369942,687,551.7872832369942],["Q",688,551.7872832369942,692,550.7876685934489],["Q",696,549.7880539499037,699.5,548.7884393063584],["Q",703,547.7888246628131,705.5,546.2894026974952],["Q",708,544.7899807321772,711,543.790366088632],["Q",714,542.7907514450867,718,541.2913294797688],["L",722.01,539.7819075144508]]}
],
"background":"white"}

 

여기서 중요한 것은 “objects” 속성인데, 이 속성 내부에 캔버스 내부의 객체들이 저장되어 있습니다.

 

따라서, 아래와 같이 개선을 시도했습니다.

  • 강의자 페이지는 canvas의 “objects”만 참여자에게 보낸다.
    • ICanvasData의 canvasJSON: string 필드가 objects: fabric.Object[] 필드로 변경됩니다.
  • 참여자 페이지는 현재 canvas의 objects수신된 objects를 비교하여
    • 현재 canvas에만 있는 object는 삭제하고 (=수신된 objects에 없으면 삭제된 것이므로)
    • 수신된 objects에만 있는 object를 canvas에 추가한다 (=수신된 objects에 없으면 추가된 객체)

개선한 코드는 다음과 같습니다.

 

export const loadCanvasData = ({
  fabricCanvas, // 현재 참여자 페이지의 fabric.Canvas
  currentData, // 현재 참여자 페이지의 캔버스 데이터
  newData, // 강의자 페이지에게 받은 캔버스 데이터
}: {
  fabricCanvas: fabric.Canvas;
  currentData: ICanvasData;
  newData: ICanvasData;
}) => {  
  const isCanvasDataChanged =
    JSON.stringify(currentData.objects) !== JSON.stringify(newData.objects) && newData.objects.length !== 0;
  const isViewportChanged = JSON.stringify(currentData.viewport) !== JSON.stringify(newData.viewport);
  const isSizeChanged = currentData.width !== newData.width || currentData.height !== newData.height;

  // [1] 캔버스 데이터 업데이트
  if (isCanvasDataChanged) {
    const receiveObjects = newData.objects;
    const currentObjects = fabricCanvas.getObjects();

    const findUniqueObjects = (a, b) => {
      const aSet = new Set(a.map(JSON.stringify));
      const bSet = new Set(b.map(JSON.stringify));

      const uniqueInA = a.filter((obj) => !bSet.has(JSON.stringify(obj)));
      const uniqueInB = b.filter((obj) => !aSet.has(JSON.stringify(obj)));

      return [uniqueInA, uniqueInB];
    };
    const [deletedObjects, newObjects] = findUniqueObjects(currentObjects, receiveObjects);

    const deleteObject = () => {
      for (var i = 0; i < deletedObjects.length; i++) {
        fabricCanvas.remove(deletedObjects[i]);
      }
    };
    const addObject = () => {
      fabric.util.enlivenObjects(
        newObjects,
        (objs: fabric.Object[]) => {
          objs.forEach((item) => {
            fabricCanvas.add(item);
          });
        },
        ""
      );
    };
    deleteObject();
    addObject();

    fabricCanvas.renderAll();
  }
  // [2] 캔버스 뷰포트 업데이트
  if (isViewportChanged) fabricCanvas.setViewportTransform(newData.viewport);
  // [3] 캔버스 크기 업데이트
  if (isSizeChanged) updateCanvasSize({ fabricCanvas, whiteboardData: newData });
};

 

개선 후 실행 결과는 다음과 같습니다.

 

불러오기 지연 시간이 80~110ms 정도로 소폭 감소된 것을 확인했습니다.

 

 

개발자도구로 분석한 결과입니다. 대상 객체의 개수가 크게 감소하기 때문에 canvas에 객체를 지우고 그리는 시간(deleteObjects, addObjects,renderAll)은 크게 감소한 것을 확인했지만,

아래 작업을 해주는 findUniqueObjects 연산이 오래 걸려 불러오기 지연이 커지는 것을 확인했습니다.

  • 현재 canvas에만 있는 object 찾기
  • 수신된 objects에만 있는 object 찾기

Javascript에서 객체 비교 연산이 쉽지 않은 관계로, 두 객체가 같은지 확인하려면 JSON.stringify를 사용해서 문자열로 바꾼 후 비교하게 되는데, 이 과정에서도 시간이 오래 걸립니다.

현재는 각 objects를 JSON.stringify을 이용한 string Set 자료구조로 만들어 원하는 object를 찾고 있지만 더욱 개선이 필요해 보입니다.

 


 

3) 압축해서 보내기 (이벤트 [1] 전송 지연 줄이기)

 

마지막으로 이벤트 [1]의 전송지연을 줄이기 위한 과정입니다.

이 전송지연은 강의자가 보내는 정보의 크기가 커서 때문에 생기는 지연이므로, 크기를 압축하는 과정이 필요합니다.

다만 이러한 압축 / 압축 해제 과정에서의 시간이 너무 오래 걸리면 오히려 전송 지연이 커질 수 있으므로, 적합한 압축 알고리즘을 선정하는 것이 중요합니다.

 

이를 위해 brotil, gzip, lzma 압축 알고리즘을 비교했고, 평균 압축 및 압축 해제시간이 우수한 gzip 방식으로 압축하기로 결정했습니다.

 

자세한 선정 과정은 아래 링크에 정리했습니다.

압축 알고리즘별 압축 시간, 압축률 비교하기

 

압축을 이용해서 변경된 점은 다음과 같습니다.

  • 강의자 페이지는 ICanvasData의 objects 필드를 gzip으로 압축해서 보냅니다.
    • 따라서, ICanvasData의 objects 필드의 타입이 fabric.Object[]에서 Uint8Array 로 변경됩니다.
  • 참여자 페이지는 압축된 objects 필드를 압축 해제 후 사용합니다.

 

압축을 적용한 결과는 다음과 같습니다.

 

 

압축 시간으로 인해서 저장 지연이 80~110ms로 증가했지만, 전송 지연이 600~800ms로 크게 감소한 것을 확인했습니다. 불러오기 지연은 크게 변화하지 않았습니다.

개선 전 저장지연이 10~20ms, 전송 지연이 1,000~5,000ms였던 것을 감안하면 두 지연시간을 합친 값은 최대 4,000ms 정도 감소했다고 할 수 있습니다.

 


🤔 3. 결론 및 앞으로 개선할 점

 

 

개선 전, 후의 지연 시간을 대략적으로 비교한 표입니다.

 

개선 전에는 모든 이벤트에서 전체 지연 시간이 약 1,000~5,000ms 정도까지 커졌지만,

개선 후에는 이벤트 [1]은 1,000~1,200ms로, 이벤트 [2], [3]은 200~300ms 정도로 줄어들었습니다.

 

앞으로 추가로 개선해 볼만한 점은 다음과 같이 정리했습니다.

  • 불러오기 지연을 더 줄이기 위해 더 빠른 객체 비교 알고리즘 찾기
  • gzip보다 더 우수한 성능의 압축 알고리즘이 있다면 적용하기
  • 짧은 시간 내에 빠르게 많은 이벤트가 발생할 때를 고려한 스로틀링 적용