실시간 스트리밍 렌더링 최적화 - 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를 사용하면 캔버스의 제어권 자체를 워커로 넘길 수 있습니다.

메인 스레드가 블락되어도(왼쪽) 워커에서 동작하는 이미지 처리는 중단 없이 실시간으로 반영됩니다. (출처: 카카오 테크 블로그)
렌더링 부하 0%를 향하여
transferControlToOffscreen()을 통해 제어권을 넘긴 후, 워커 내부에서 직접 렌더링을 수행합니다.
// @src/entities/devices/components/stream/mjpeg.worker.ts
const bitmap = await createImageBitmap(blob);
if (ctx && canvas) {
// 워커가 직접 캔버스에 드로잉 (메인 스레드 간섭 0%)
ctx.drawImage(bitmap, 0, 0);
if (config.showArea && config.area) {
drawPolygonArea(ctx, config.area); // 영역 표시 로직도 워커에서 수행
}
}
bitmap.close();
이 구조에서는 메인 스레드에 아무리 무거운 작업이 걸려도, 스트리밍 영상은 별도의 스레드에서 끊김 없이 독립적으로 재생됩니다.
🌐 브라우저 호환성 및 자동 분기 처리
OffscreenCanvas는 강력한 기능을 제공하지만, 브라우저마다 지원 여부가 다릅니다. EVA 서비스에서는 사용자 환경을 고려하여 이를 자동으로 감지하고 분기 처리하도록 구현되었습니다.
| 브라우저 | 지원 버전 | 비고 |
|---|---|---|
| Chrome | 69+ | 최우선 지원 |
| Edge | 79+ | Chromium 기반 버전부터 지원 |
| Firefox | 105+ | 105 버전부터 기본 활성화 |
| Safari | 16.4+ | 최신 macOS/iOS 환경 권장 |
| Opera | 56+ | - |
EVA의 맞춤 렌더링 전 략:
- 최신 브라우저:
OffscreenCanvas를 활성화하여 메인 스레드 부하를 0%로 유지합니다. - 하위 브라우저(예: Safari 15 이하): 기능을 감지하여 1차 개선안인 메인 스레드 Canvas 렌더링 방식으로 자동 전환(Fallback)합니다.
이를 통해 어떤 브라우저 환경에서도 끊김 없는 스트리밍 경험을 보장합니다.
6. 추가 최적화: 버퍼 재사용과 파싱 속도 향상
성능은 디테일에서 결정됩니다. 워커 내부 로직에서도 몇 가지 최적화를 더했습니다.
- 고정 버퍼 재사용: 매번 새로운
Uint8Array를 생성하는 대신, 고정된 크기의 버퍼를 재사용하고copyWithin을 사용하여 데이터를 관리했습니다. 이는 가비지 컬렉션(GC) 발생 빈도를 대폭 줄여줍니다. indexOf기반 고속 파싱: 바이너리 데이터에서 매칭 바이트를 찾을 때 단순 루프 대신 내장indexOf를 활용하여 불필요한 바이트 검사를 건너뛰도록 구현했습니다. 단순 연산만으로도 프레임 드랍을 획기적으로 줄일 수 있었습니다.
7. 결론: 더 견고해진 EVA 모니터링 환경
이번 최적화 작업을 통해 EVA 서비스는 다음과 같은 결과를 얻었습니다.
- 메모리 안정성: 장시간 구동 시에도 메모리 점유율이 일정하게 유지되며 OOM 오류가 사라졌습니다.
- UI 반응성: 고해상도 스트리밍 중에도 메뉴 이동, 버튼 클릭 등 UI 조작이 네이티브 앱 수준으로 부드러워졌습니다.
- 안정적인 프레임: 스레드 분리를 통해 네트워크 지연이나 메인 스레드 부하와 무관하게 일정한 프레임 레이트를 확보했습니다.
프론트엔드 성능 최적화의 핵심은 **"브라우저의 메인 스레드를 얼마나 자유롭게 유지하느냐"**에 있다는 것을 다시 한번 체감할 수 있는 프로젝트였습니다.
긴 글 읽어주셔서 감사합니다!
참고 기술 요약
- Web Workers API: 백그라운드 스레드에서 연산 수행
- OffscreenCanvas: 메인 스레드로부터 독립된 렌더링
- createImageBitmap: 비동기 이미지 디코딩 및 명시적 메모리 관리
- Transferable Objects: 복사 비용 없는 고속 데이터 전송
