개발일지/🙂 Boarlog

실시간 화이트보드 공유 구현하기

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

 

 

실시간 화이트보드 공유 서비스를 구현하기까지의 과정을 정리한 글입니다.

Fabric.js로 화이트보드를 구현하는 과정은 이전 게시글에 정리했습니다.


💭 1. 강의자 canvas의 내용을 어떻게 참여자에게 전달해서 표시해야 하는가?

 

실시간으로 강의자의 화이트보드(canvas) 내용을 참여자들에게 전달하는 것을 목표로 프로젝트를 시작했습니다.

 

강의자의 화이트보드는 Fabric.js 라이브러리를 이용해서 구현을 마쳤고,

음성 브로드캐스팅은 WebRTC를 사용한 미디어 서버를 통해 완성한 상황이었습니다.

 

프로젝트 발표까지는 2주가 남은 상황이었고, 남은 기간 내에 실시간 공유 구현을 맡아 구현하기 위해

총 3가지 방법을 두고 간단하게 장단점을 적어가며 고민해 보았습니다.

 

  1. 로그 기반 변경사항 전달
    • 장점 : 변경사항을 전달하는데 필요한 데이터가 적다. (변경된 사항만 전달하면 된다.)
    • 단점 : 구현 방식이 난해하다 (변경 사항만 어떻게 전달해야 하는지가 어려움). 비동기적으로 변경사항을 전달해야 함 -> (채팅 서버와 비슷한 방식으로 구현해야 함), 중간에 사용자가 입장하면 그동안의 로그를 다 반영해야 함
  2. canvas의 내용을 JSON으로 만들어 실시간으로 전달
    • 장점 : loadFromJSON 메서드로 JSON 데이터를 Fabric.canvas에 불러오기 가능 (구현이 간단해진다), 데이터 전송 과정에서 더 많은 기술적 도전 가능(변경사항 없으면 전달 x), canvas의 내용을 참여자에게 똑같이 표현할 수 있음, 중간에 들어오는 참여자도 이전의 내용을 확인 가능
    • 단점 : 화이트보드 정보를 전송하고 불러오는 과정이 복잡함, 데이터가 많아졌을 때 오버헤드가 발생할 가능성이 있다, 다시보기 기능을 구현하기 어려움(시간 단위로 데이터를 저장하고 불러와야 함), 비동기적으로 변경사항 전달해야 함(1번과 동일하게 서버의 지원 필요)
  3. canvas의 내용을 미디어 스트림(비디오 형태)으로 만들어 전달
    • 장점 : 이미 사용하고 있는 미디어 서버를 활용할 수 있다, 강의자가 보낸 데이터를 렌더링하는 과정이 간단하다, 다시보기 서비스 구현이 쉬움 (비디오 형태로 저장 가능)
    • 단점 : 비디오 형태의 전달이 부담이 될 수 있다. 캔버스의 내용을 비디오로 만드는 과정에서 화질 손실이 일어날 수 있다.

 

이 세가지 방법 가운데에서 먼저 3번 방법을 선택하였습니다. 3번을 선택한 이유는 남은 2주 안에 서비스를 완성하는 것이 목표였기 때문이었습니다.

 

3번 방법으로 구현하면 이미 사용하고 있는 미디어 서버를 활용할 수 있기 때문에 시간을 절약할 수 있고, 비디오 형태로 저장이 가능하기 때문에 다시보기 서비스를 빠르게 구현하기에 적합하다고 판단하여 3번 방법으로 구현을 시작하였습니다.


🌊 2. 미디어 스트림 형태로 전달하기

 

1) 구현 과정

 

3번 방법의 핵심 아이디어는 HTMLCanvasElement의 captureStream() 메서드를 사용하는 것입니다.

captureStream()을 이용하면 canvas 객체의 내용이 미디어 스트림으로 만들어지고,

이를 음성 브로드캐스팅을 담당하는 미디어 서버에 음성 스트림과 같이 전달하는 식으로 구현을 했습니다.

 

canvas의 내용을 미디어 스트림 (비디오 형태)로 만들어 전달하는 구체적인 과정은 다음과 같습니다.

 

  • canvs 객체가 있는 컴포넌트와 서버에 미디어 스트림을 전달하는 컴포넌트가 구분되어 있기 때문에, recoil을 이용하여 canvas 객체의 ref를 저장할 canvasRefState를 생성합니다.
  • CanvasSection 컴포넌트에서 fabric.js와 canvas 객체를 연결한 후, 연결이 끝난 canvas 객체를 canvasRefState에 저장합니다.
  • HeaderInstructorControls 컴포넌트(미디어 스트림 전달 컴포넌트)에서 canvasRefState를 참조한 다음 captureStream()을 이용하여 스트림으로 만들어 줍니다.
  • 생성된 스트림에서 canvas의 내용이 {kind:video}로 저장된 트랙을 찾아 updatedStream에 추가합니다.
  • updatedStream의 track들을 RTCPeerConnection에 addTrack()으로 추가합니다.
  • 참여자 페이지에서는 canvas의 내용이 담긴 트랙을 찾아 video 태그의 srcObject에 연결하면 해당 내용이 출력됩니다.

구현 결과 영상은 아래와 같습니다.

 

2) 구현 결과 생긴 문제점

 

미디어 스트림 형태로 화이트보드를 전달하는 구현을 한 결과, 크게 3가지 문제점을 발견했습니다.

 

  1. 참여자 페이지에서 첫 화면 로딩이 오래 걸림
  2. 참여자 페이지에서 보이는 화질이 저하되어서 보인다.
  3. 화이트보드 비디오 스트림이 영상으로 잘 저장되지 않는다.

가장 먼저 1. 참여자 페이지에서 첫 화면 로딩이 오래 걸리는 문제는 아래와 같이 해결했습니다.

왜 화이트보드 화면이 늦게 나올까

 

하지만 2. 화질 저하 문제, 3. 영상 저장 문제를 해결하기 위해 원인을 찾는 과정에서 해결이 어렵다는 판단을 하게 되었습니다.

 

화질 저하 문제는 captureStream() 메서드의 문제로, canvas를 비디오 형태로 변환하는 과정에서 화질 손실이 일어나기 때문에 개선이 어려웠습니다.

 

영상이 잘 저장되지 않는 문제는 canvas 크기가 변하는 것 때문이었습니다. 현재 canvas 크기를 참여자 브라우저 크기에 맞춰 변경되도록 구현했기 때문에, 영상의 크기가 일정하지 않을 뿐 아니라 강의자가 브라우저 크기를 바꾸면 영상 규격이 변해버려 비디오로 저장하는 과정에서 오류가 발생했습니다.

 

이러한 2가지 문제점을 빠르게 해결하기 어렵다고 판단했고, 마침 미디어 서버를 이용한 socket 통신이 사용 가능해져서 처음에 고민하던 3가지 방식 중 두 번째 방법이었던 화이트 보드 정보를 JSON으로 만들어 socket 통신으로 전달하는 방식으로 전환을 시도하게 되었습니다.

 


📦 3. 화이트보드 정보를 JSON 형태로 전달하기

 

처음 고민했던 과정에서 단점으로 손꼽혔던 아래 3가지 문제를 다시 살펴보겠습니다.

 

  1. 화이트보드 정보를 저장하고 불러오는 과정이 복잡함
  2. 데이터가 많아졌을 때 오버헤드가 발생할 가능성이 있다
  3. 다시보기 기능을 구현하기 어려움

이 3가지 과정을 하나씩 해결해 가며 화이트보드 공유를 구현해 나갔습니다.

 

1) 화이트보드 정보를 저장하고 불러오는 과정

 

참여자가 강의자와 동일한 화이트보드를 보게 하는 과정입니다.

참여자 페이지에서 강의자의 데이터를 받아 로드하는 과정에서 확인해야 할 점은 3가지가 있습니다.

  1. 화이트보드 내부 객체 (펜글씨, 메모지 등)를 참여자의 canvas에 로드하기
  2. 강의자가 보는 canvas 시점 (시점의 좌표, 줌인/줌아웃 정도)을 참여자 canvas에 동일하게 적용하기
  3. 강의자의 화면 비율, 크기를 고려해서 참여자 페이지에 반영하기

1번 화이트보드 내부 객체는 강의자의 fabric.canvas를 JSON.stringify()로 JSON 형태로 만든 후, fabric.canvas의 loadFromJSON 메서드를 이용해서 구현했습니다.

 

2번 canvas 시점의 경우 여러 방식으로 구현을 시도했는데, 중요한 점은 강의자가 보고있는 canvas의 시점 좌표, 그리고 줌인/줌아웃 정도 2가지였습니다. 고정 크기의 canvas를 사용하는 일반적인 프로젝트들과 달리 이번 화이트보드 프로젝트는 hand 툴과 스크롤을 이용해서 줌인 줌아웃이 가능하고, 강의자의 시점을 그대로 공유해주는 것이 중요하기 때문에 꼭 반영을 해주어야 했습니다.
일차적으로 zoomValue와 fabric.Point를 가지고 다시 랜더링하는 것을 고려했습니다. 이 경우 fabric.Point가 zoomValue에 따라 유동적으로 변할 뿐 아니라, fabric.Point를 계산하는 과정도 적당한 메서드가 없어서 강의자의 화면과 동일한 시점을 확인해주기가 매우 어렵습니다.
결과적으로는 zoomValue와 fabric.Point를 계산할 때 사용하는 fabric.canvas의 속성인 viewportTransform 배열을 강의자가 그대로 전송해서, 참여자가 배열의 viewport 정보를 적용하는 방법으로 구현했습니다.

 

3번 화면 비율, 크기의 경우는 강의자의 화이트보드 크기와 참여자의 화이트보드 크기가 브라우저 크기에 따라 달라져서 생기는 문제입니다.
강의자가 (1920x1080)크기의 화이트보드를 사용하는데, 참여자가 (1280x720) 크기의 화이트보드를 사용하면 꽤 많은 영역이 참여자 페이지에서 보이지 않고, 스크롤 바가 생기는 문제가 있습니다.
fabric.canvas에서는 setDimensions 메서드로 크기를 조절하고, 옵션으로 backstoreOnly:true를 줘서 참여자 페이지 크기에 맞춰서 강제로 캔버스 내용을 조절할 수 있습니다. 다만 이 경우에는 원본 비율을 유지하지 못해서 캔버스 내용이 찌그러든다는 단점이 있습니다.
이를 해결하기 위해서 참여자 페이지에서 보여질 캔버스 크기원본 비율에 맞춰 계산해서 만들어준 후, 해당 크기에 맞춰 backstoreOnly:true를 주는 방식으로 해결했습니다. 캔버스 크기를 새로 계산하는 과정은 아래의 과정이 필요합니다.

 

  1. 먼저 새로운 높이를 현재 참여자 브라우저 너비를 기준으로 원본 비율에 맞춰 계산합니다. 계산 식은 (새 높이)=(참여자 화이트보드 너비)*(강의자 화이트보드 높이/강의자 화이트보드 너비) 입니다.
  2. 새 높이가 현재 참여자 브라우저 너비보다 크면 높이는 참여자 브라우저 높이에 맞추고, 너비를 새로 계산합니다. 계산 식은 (새 너비)=(참여자 화이트보드 높이)*(강의자 화이트보드 너비/강의자 화이트보드 높이) 입니다.
  3. 새 화이트보드 크기에 맞춰서 강의자의 화이트보드 내용을 변형합니다.

 

강의자 페이지에서는 위의 3가지 내용을 아래와 같은 ICanvasData 객체로 저장합니다.

 

 

  • canvasJSON : fabric canvas 내부 객체
  • viewport : fabric canvas 뷰포트(확대, 축소, 이동)
  • width, height : canvas 크기(강의자 브라우저 크기)
  • eventTime : 강의 시작 후 해당 이벤트까지의 소요 시간 → 다시보기 서비스에 필요

 

 

이러한 저장 과정을 requestAnimationFrame()을 이용해 1frame 단위로 반복하고, socket통신으로 참여자에게 전달합니다.

 

참여자 페이지에서는 전달받은 데이터를 socket 통신으로 전달받아 참여자 canvas에 해당 내용을 렌더링 하는 식으로 화이트 보드 공유를 구현했습니다.

 

변경된 전송 과정은 다음과 같습니다.

2) 데이터가 많아졌을 때 최적화 과정

socket 통신으로 데이터를 주고 받을 때 생긴 문제는 주고 받는 데이터의 양이 많아졌을 때 서버의 부담도 크고, 렌더링 시간도 오래 걸려서 화이트보드 내용이 늦게 보인다는 문제가 있었습니다.

 

이를 해결하기 위해 화이트보드가 변경되었을 때만 전송하는 방식을 이용했습니다.

 

화이트보드를 저장하는 과정에서, 이전 frame의 정보를 저장하고 있다가 크게 3가지 (캔버스 내부 객체, 뷰포트, 캔버스 크기) 중 변경 사항이 있는 경우에만 데이터를 전송하도록 했습니다.

 

참여자 페이지에서는 렌더링 시간을 절약하기 위해 3가지 (캔버스 내부 객체, 뷰포트, 캔버스 크기)정보 중 변경 사항만 캔버스에 반영하는 방식으로 렌더링 시간을 아꼈습니다.

 

3가지 (캔버스 내부 객체, 뷰포트, 캔버스 크기)정보 중 변경 사항만 서버에 전송하지 않는 이유는 중간에 강의에 참여하는 참여자를 고려했기 때문입니다. 일정 시간 단위로 스냅샷을 찍어 전송하는 방식도 고려하고 있으나, 서버 로직이 복잡해진다는 문제가 있어 이점은 추후 개선 사항으로 남겨놓았습니다.

 

배포된 미디어 서버를 이용하여 작동 해본 결과 다음과 같이 잘 동작하는 것을 확인했습니다.

 

3) 다시보기 구현 과정

다시보기는 참여자 페이지에서 렌더링 하는 것과 비슷하게 작동하도록 구현 했습니다.

 

강의 시작 후 이벤트 발생까지의 시간을 eventTime로 저장한 ICanvasData 객체를 socket 통신으로 전달할 때, 서버에서 강의자가 보낸 모든 객체를 저장합니다.

 

다시보기를 위해 사용자가 서버에 데이터를 요청하면, 서버는 저장한 ICanvasData 객체 배열을 반환하고, 다시보기 페이지에서는 해당 배열을 받아서 다시보기를 실행합니다.

 

다시보기는 requestAnimationFrame()을 이용해 현재 시간과 다시보기 시작 시점의 차이를 eventTime과 비교해서 eventTime보다 크면 해당 객체를 화이트보드 페이지에 렌더링 해주는 방식으로 구현했습니다.

 

 


 

🤔 4. 앞으로 더 고민해 볼 사항

 

1) 강의자가 새로고침 등으로 잠시 나갔다 재접속 하는 경우 고려하기 (해결)

 

실제로 서비스를 다른 분들에게 시연해 보고, 사용 후기를 받으면서

“강의 중 새로고침을 하면 화면이 사라져 버려서 아쉽다

“강의자가 실수로 창을 닫았다가 다시 시작하는 경우도 고려해줬으면 좋겠다”

라는 피드백을 받았습니다.

 

이는 처음 강의를 시작한 강의자가 새로고침 등으로 서버와 연결이 해제되어버리면, 기존 화이트보드 데이터와 연결을 다시 시작할 수 없었기 때문에 생긴 문제였습니다.

 

이 문제를 해결하기 위해 BE 팀원분들의 도움을 받아 미디어 서버에서 강의자가 재접속하면 마지막으로 전송된 화이트보드 데이터를 전달받아 강의자 페이지에 렌더링 하는 방식으로 화이트보드 내용을 유지하도록 했습니다.

 

2) 강의자의 네트워크가 잠시 끊겼을 때 고려하기

 

멘토링 시간에 피드백 받은 내용 중 하나입니다. 클로바 노트와 같은 서비스는 녹음 중 네트워크가 끊겨있던 시간의 음성도 저장되도록 서비스 하고 있는데, 저희 서비스도 해당 문제를 반영해보고 싶다는 생각이 들었습니다.

 

참여자에게 데이터가 전송되지 않는 것은 나중의 데이터를 전송해도 크게 불편함이 없으므로 문제가 되지 않았지만, 다시보기 데이터중단된 시점의 데이터가 사라지는 문제가 있었습니다.

 

당장 구현은 하지 못했지만, 다음과 같이 구현해보기로 했습니다.

  • 화이트보드 데이터를 전송할 때, 정상적으로 전송이 되지 않으면 해당 데이터를 로컬 스토리지에 저장한다.
  • 계속해서 데이터를 로컬 스토리지에 저장하다가 다시 전송에 성공하면 로컬 스토리지의 정보를 순차적 다시 서버에 전송한다.

로컬 스토리지의 정보를 새 정보보다 우선해서 보낼 것인지, 아니면 비동기적으로 따로 전송하고 후에 미디어 서버에서 처리할 것인지는 더 논의가 필요합니다. 발표 이후 리팩토링을 해보면서 이 부분은 개선하려 합니다.

 

3) 화이트보드 객체가 많아졌을 때 데이터 압축해서 보내기

현재 겪고 있는 문제점 중 하나는 화이트보드 내부의 객체 (글씨, 메모지 등)가 많아지면 서버에 보내는 JSON 데이터의 크기가 늘어나 미디어 서버의 부담이 커지고, 전송 시간이 오래 걸려서 참여자 페이지에서 확인하기까지의 지연시간이 커진다는 문제가 있었습니다.

 

이를 해결하기 위해서는 화이트보드 객체 정보(canvasJSON)를 압축해서 전송하는 과정을 고민하고 있습니다.

다만, 초기 구현과정에서 테스트 해 본 결과 압축 시간압축 해제시간이 1frame인 16ms보다 큰 경우가 많아서 빠른 데이터 압축/압축 해제 알고리즘 등을 찾아보는 것이 필요합니다.