실시간 스트리밍 렌더링 최적화 - Canvas, Web Worker, OffscreenCanvas 기반 EVA 아키텍처 개선기
안녕하세요. EVA 팀에서 프론트엔드 개발을 담당하고 있는 유준형입니다.
EVA 서비스의 핵심 기능 중 하나는 수십 대의 카메라 영상을 실시간으로 확인하는 실시간 스트리밍입니다. 단순히 영상을 짧게 확인하는 수준을 넘어, 현장을 장시간 관제해야 하는 사용자가 늘어남에 따라 예상치 못한 성능 병목 현상이 발생하기 시작했습니다.
"화면을 오래 켜두면 브라우저가 점점 느려지다가 결국 탭이 죽어버려요."
이 문제를 해결하기 위해 저희 팀이 진행했던 Canvas, Web Worker, 그리고 OffscreenCanvas를 이용한 렌더링 구조 개선 여정을 공유하고자 합니다.
1. 배경: 왜 오래 켜둘수록 문제가 생겼을까?
초기 EVA의 스트리밍 방식은 가장 보편적인 <img> 태그와 Blob(Object URL)의 조합이었습니다.
기존 방식 (Blob 기반 렌더링)
- 서버로부터 MJPEG 스트림 데이터를 Blob 형태로 수신합니다.
URL.createObjectURL(blob)으로 임시 URL을 생성합니다.<img>태그의src에 할당하여 브라우저가 이미지를 그리게 합니다.
구현은 매우 간단했지만, '장시간 관제'라는 특수한 환경에서 두 가지 치명적인 문제가 드러났습니다.
- 메모리 오 버헤드: 매 프레임(초당 30회 내외)마다 고유한 URL 문자열이 생성됩니다.
revokeObjectURL을 호출하더라도 브라우저 내부의 이미지 캐시와 가비지 컬렉터(GC)의 지연으로 인해 메모리 점유율이 우상향하며 결국 Out of Memory(OOM) 오류를 유발했습니다. - 메인 스레드 블로킹: 이미지의 디코딩 과정이 메인 스레드(UI 스레드)에서 발생합니다. 고해상도 영상을 처리할 때 이벤트 루프가 지연되면서 클릭이나 스크롤 같은 UI 반응이 느려지는 Jank 현상이 발생했습니다.
2. 네트워크 탭 분석: MJPEG의 정체
성능 개선을 위해 가장 먼저 분석한 것은 네트워크 레이어였습니다. MJPEG 스트리밍은 일반적인 HTTP 요청과 다릅니다.
multipart/x-mixed-replace
MJPEG은 Content-Type: multipart/x-mixed-replace; boundary=... 헤더를 사용합니다. 이는 단일 HTTP 연결을 통해 서버가 끊임없이 이미지 프레임을 밀어주는 방식입니다.
- 네트워크 탭의 특징: 요청이 완료되지 않고 계속 'Pending' 상태로 유지됩니다. 브라우저는 연결을 끊지 않고 계속해서 들어오는 바이너리 데이터를 받아들입니다.
- 바이너리 데이터 구조: 각 프레임은 특정
boundary문자열로 구분된 JPEG 바이너리 데이터(0xFF 0xD8 ... 0xFF 0xD9)입니다.
기존 방식은 이 거대한 바이너리 덩어리를 통째로 Blob으로 만들어 메인 스레드에서 파싱했기 때문에, 데이터가 쌓일수록 브라우저의 부담은 기하급수적으로 늘어날 수밖에 없는 구조였습니다.
3. 1차 개선: Canvas와 createImageBitmap
저희는 먼저 브라우저의 가비지 컬렉터에 의존하던 메모리 관리 방식을 명시적 관리 방식으로 전환하기 위해 Canvas API를 도입했습니다.
비동기 비트맵 렌더링
createImageBitmap API는 이미지를 화면에 그리기 전에 백그라운드에서 비동기적으로 디코딩할 수 있게 해줍니다.
// @src/entities/devices/components/stream/MJPEGStream.tsx
// 캔버스에 비트맵을 그린 직후 즉시 메모리 해제
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, 0, 0);
bitmap.close(); // 명시적으로 메모리 반환
이 방식의 핵심은 bitmap.close()입니다. 개발자가 직접 사용이 끝난 비트맵 리소스를 파괴함으로써 메모리 점유율을 일정하게 유지할 수 있게 되었습니다. 또한 <img> 태그의 src 변경 시 발생하는 리플로우(Reflow) 를 제거하고 GPU 가속을 활용하는 캔버스 드로잉으로 전환하며 렌더링 효율을 높였습니다.
4. 2차 개선: Web Worker를 통한 연산 분리
렌더링은 가벼워졌지만, 스트림 데이터를 수신하고 바이너리에서 JPEG 프레임을 찾아내는(Boundary Parsing) 작업은 여전히 메인 스레드의 몫이었습니다. 초당 수백만 바이트의 데이터를 실시간으로 문자열 검색하는 것은 CPU에 큰 부담을 줍니다.
이를 해결하기 위해 Web Worker를 도입하여 "데이터 처리는 백그라운드에서, 화면 그리기는 메인에서" 라는 역할 분담을 적용했습니다.
데이터 전송 최적화 (Transferable Objects)
워커에서 메인 스레드로 대량의 이미지를 보낼 때 데이터를 복제(Copy)하면 심각한 성능 저하가 발생합니다. 저희는 Transferable Objects 기능을 사용하여 데이터 복사 없이 메모리의 소유권만 이전하는 Zero-copy 방식을 택했습니다.
5. 최종 개선: OffscreenCanvas의 도입
하지만 여전히 최종 드로잉 작업은 메인 스레드에서 일어나야 했습니다. 마지막 퍼즐 조각은 OffscreenCanvas였습니다. 이 API를 사용하면 캔버스의 제어권 자체를 워커로 넘길 수 있습니다.

메인 스레드가 블락되어도(왼쪽) 워커에서 동작하는 이미지 처리는 중단 없이 실시간으로 반영됩니다. (출처: 카카오 테크 블로그)
