Javascript에서 GPU 연산 활용하기
Dino • Software Engineer / Web / Front
"회의 중인데, 뒤에 빨래가 걸려있어요..."
재택근무가 일상이 되면서, 우리는 집이라는 개인 공간을 업무 공간으로 공유하게 되었습니다. 화상회의를 할 때마다 뒤에 보이는 배경이 신경 쓰이고, 정리되지 않은 방이 화면에 나올까 걱정되는 경험, 한 번쯤은 있으셨을 겁니다.
배경 블러 기능은 이런 고민을 해결해줍니다. 나에게 집중할 수 있도록 배경을 흐리게 처리하여, 사생활을 보호하고 시청자의 집중도를 높일 수 있죠.
이전 글에서 화상회의 참여자들을 화면에 최적으로 배치하는 레이아웃 알고리즘을 개발한 이야기를 나눴습니다. 이번에는 Channel의 Meet 서비스에 이미 출시된 배경 블러 기능의 성능을 개선하면서 겪었던 이야기를 공유하고자 합니다.
배경 블러 기능은 출시되어 사용자들이 쓰고 있었지만, 성능 문제로 일부 사용자들이 불편을 겪고 있었습니다. 특히 저사양 기기에서는 사용이 어려울 정도였죠. 이 글에서는 JavaScript CPU 처리의 한계를 어떻게 발견했고, WebGL과 GPU 병렬 처리로 어떻게 극복했는지 이야기하겠습니다.
배경 블러 기능은 이미 출시되어 있었습니다. 하지만 사용자들로부터 다음과 같은 피드백이 계속 들어왔습니다:
"저사양 노트북에서는 너무 느려요"
"배경 블러를 켜면 화면이 끊겨요"
"노트북이 뜨거워지고 팬이 시끄러워요"
측정해보니 심각한 성능 문제가 있었습니다:
CPU 사용률 50~60%: 배경 블러만으로 CPU의 절반 이상을 사용
저사양 기기에서 렉 발생: 화면이 버벅거리고 마우스도 느려짐
배경 블러를 구현하기 위해서는 먼저 사람과 배경을 분리해야 합니다. 이를 위해 Google의 MediaPipe Selfie Segmentation 모델을 사용하기로 했습니다.
MediaPipe는 Google이 제공하는 머신러닝 기반의 미디어 처리 솔루션입니다. Selfie Segmentation 모델은 이미지에서 사람의 윤곽을 실시간으로 추출해주는데, 브라우저에서도 동작하고 성능도 우수했습니다.
초기 파이프라인:
비디오 프레임 캡처
MediaPipe 세그멘테이션 실행
신뢰도 마스크(confidence mask) 생성
CPU에서 픽셀 순회하며 ImageData 업데이트
Canvas에 합성 및 블러 적용
최종 프레임 출력
MediaPipe가 반환하는 신뢰도 마스크는 각 픽셀이 사람일 확률을 0~1 사이의 값으로 표현한 데이터입니다. 예를 들어, 얼굴 중앙은 0.99, 머리카락 경계는 0.7, 배경은 0.1 같은 식입니다.
문제는 이 마스크 데이터를 어떻게 활용하느냐였습니다.
가장 직관적인 방법: JavaScript 픽셀 순회
초기에는 가장 직관적인 방법을 선택했습니다. JavaScript로 모든 픽셀을 순회하며 ImageData를 업데이트하는 것이죠.
이 방식은 우리만의 선택이 아니었습니다. Google이 제공하는 MediaPipe 공식 가이드 코드에서도 동일한 방식을 사용하고 있었습니다. 가장 이해하기 쉽고, 구현하기도 간단했으니까요.
코드는 단순하고 명확했습니다. 로컬에서 테스트했을 때도 잘 동작하는 것처럼 보였습니다.
실제 화상회의 환경에서 돌려보니 문제가 드러났습니다.
문제의 원인은 JavaScript로 모든 픽셀을 순회하는 부분이었습니다.
1920x1080 해상도는 2,073,600 픽셀입니다. JavaScript 싱글 스레드가 이 모든 픽셀을 하나씩 순차 처리하고 있었습니다.
각 픽셀마다 4개 채널(RGBA)을 처리하므로, 한 프레임에 800만 번 이상의 배열 접근이 발생하고, 30fps를 목표로 하면 초당 약 2억 4천만 번의 연산입니다.
이것이 CPU 사용률 50~60%의 원인이었습니다.
JavaScript는 싱글 스레드 언어입니다. 멀티코어 CPU가 있어도, 한 번에 하나의 작업만 처리할 수 있습니다.
픽셀 처리의 특성
픽셀 처리는 본질적으로 병렬 처리에 이상적인 작업입니다.
각 픽셀의 처리는 독립적입니다 (픽셀A 처리가 픽셀B에 영향을 주지 않음)
모든 픽셀에 동일한 연산을 적용합니다
데이터 의존성이 없습니다
즉, 이론적으로는 207만 개 픽셀을 동시에 처리할 수 있는 작업입니다. 하지만 JavaScript 싱글 스레드에서는 이것이 불가능했습니다.
CPU는 범용 프로세서입니다. 복잡한 로직, 분기 처리, 메모리 관리 등 다양한 작업을 잘 처리합니다. 하지만 단순하고 반복적인 연산을 대량으로 처리하는 데는 비효율적입니다.
반면 그래픽 처리는:
단순한 연산을 엄청나게 많이 반복합니다
대부분의 연산이 독립적입니다
병렬 처리가 가능합니다
우리는 잘못된 도구를 사용하고 있었던 것입니다.
이 문제를 해결하기 위해서는 근본적인 접근 방식을 바꿔야 했습니다. CPU가 아닌 GPU를 활용해야 했습니다.
브라우저에서 GPU를 활용하는 방법은 무엇일까요? 바로 WebGL입니다.
WebGL이란?
WebGL은 Web Graphics Library의 약자로,
OpenGL ES 2.0 기반 (모바일 그래픽 표준)
모든 현대 브라우저에서 지원
플러그인 없이 동작
하드웨어 가속 지원
WebGL을 사용하면:
GPU에서 직접 그래픽 연산 수행
커스텀 쉐이더(Shader) 프로그래밍 가능
고성능 렌더링 가능
WebGL이 최적의 선택이었던 이유
무엇보다 중요한 것은 MediaPipe가 WebGL 텍스처를 직접 지원한다는 점이었습니다.
MediaPipe의 세그멘테이션 결과는 GPU 메모리에 WebGL 텍스처로 저장됩니다. mask.getAsWebGLTexture() 메서드를 통해 이 텍스처를 직접 접근할 수 있죠.
이 메서드 덕분에:
CPU로 데이터를 복사할 필요 없음
GPU 메모리에서 바로 처리 가능
CPU 사용률 최소화
MediaPipe와 WebGL을 함께 사용해서 CPU 부하를 최소화할 수 있었습니다.
CPU에서 하던 픽셀 순회를 GPU 쉐이더로 대체하자.
WebGL 쉐이더는 GPU에서 실행되는 프로그램입니다. 이 프로그램이 모든 픽셀에 대해 병렬로 실행됩니다.
위의 main() 함수가 207만 번 순차 실행되는 것이 아니라, 207만 개의 픽셀에 대해 동시에 실행됩니다.
초기 CPU 기반 파이프라인을 정리하면 다음과 같습니다:
비디오 프레임 캡처
MediaPipe 세그멘테이션 실행 (GPU)
신뢰도 마스크 생성
[병목!] CPU에서 픽셀 순회
GPU 메모리 → CPU 메모리로 데이터 복사
JavaScript로 207만 픽셀 순차 처리
ImageData 업데이트
Canvas 2D에 합성
블러 효과 적용
최종 프레임 출력
문제는 4번 단계에 있었습니다.
MediaPipe는 GPU에서 실행되어 GPU 메모리에 마스크를 생성합니다. 그런데 이를 CPU로 가져와서 처리하고, 다시 Canvas 렌더링을 위해 GPU로 보내야 했습니다.
불필요한 CPU-GPU 전송과 느린 CPU 처리가 병목이었습니다.
WebGL을 도입한 새로운 파이프라인:
1. 비디오 프레임 캡처
2. MediaPipe 세그멘테이션 실행 (GPU)
3. 신뢰도 마스크 생성 (WebGL Texture로 직접 접근)
4. [최적화!] WebGL 쉐이더로 마스크 변환 (GPU)
- GPU 메모리 내에서 직접 처리
- 병렬 처리로 2ms 안에 완료
- ImageBitmap으로 변환
5. Canvas 2D에 합성
6. 블러 효과 적용
7. 최종 프레임 출력
이제 모든 처리가 GPU 내에서 이루어집니다:
CPU를 거치지 않고 GPU 내에서 모든 것을 처리하니, 데이터 전송 오버헤드가 사라지고 CPU 사용률이 대폭 줄어들었습니다.
여기서 중요한 점은 모든 것을 WebGL로 재구현하지 않았다는 것입니다.
WebGL로 처리: 마스크 픽셀 변환 (병목 구간)
Canvas 2D 사용: 이미지 합성 및 블러 효과
Canvas 2D API도 현대 브라우저에서 GPU 가속을 지원하므로, 이미 충분히 빠르게 동작하고 있었습니다. 문제는 JavaScript 픽셀 순회 부분이었습니다.
병목 구간만 최적화하고, 나머지는 기존 API를 활용했습니다. 개발 복잡도는 줄이고, 성능 목표는 달성했습니다.
이제 본격적으로 WebGL 코드를 살펴보겠습니다. 처음 WebGL을 접하시는 분들을 위해 최대한 쉽게 설명하겠습니다.
WebGL에서 그래픽을 그리기 위해서는 쉐이더(Shader)라는 프로그램을 작성해야 합니다. 쉐이더는 GPU에서 실행되는 작은 프로그램입니다.
두 가지 종류의 쉐이더:
버텍스 쉐이더(Vertex Shader)
정점(꼭짓점) 처리를 담당합니다
3D 객체의 위치를 화면 좌표로 변환합니다
프래그먼트 쉐이더(Fragment Shader)
픽셀 색상 계산을 담당합니다
각 픽셀의 최종 색상을 결정합니다
프래그먼트 쉐이더의 main() 함수는 화면의 모든 픽셀에 대해 병렬로 실행됩니다.
이제 우리의 프래그먼트 쉐이더를 봅시다:
이 간단한 코드가 CPU의 복잡한 픽셀 순회를 대체합니다!
코드 설명:
precision highp float;: 높은 정밀도의 부동소수점 연산 사용
varying vec2 texCoords;: 버텍스 쉐이더에서 전달받은 텍스처 좌표
uniform sampler2D textureSampler;: 마스크 텍스처 (MediaPipe 출력)
texture2D(): 텍스처에서 특정 좌표의 값을 읽어옵니다
.r: 빨간색 채널만 사용 (마스크는 단일 채널 데이터)
gl_FragColor: 현재 픽셀의 최종 색상 (출력)
CPU 방식과의 비교:
같은 작업을 하지만, 실행 방식이 완전히 다릅니다.
버텍스 쉐이더는 화면에 무엇을 그릴지 정의합니다. 우리는 전체 화면을 덮는 사각형을 그려야 합니다.
코드 설명:
attribute vec2 position;: 정점 좌표 입력 (-1~1 범위의 NDC 좌표)
varying vec2 texCoords;: 프래그먼트 쉐이더로 전달할 텍스처 좌표
(position + 1.0) / 2.0: 좌표 변환
WebGL 좌표: -1 ~ 1
텍스처 좌표: 0 ~ 1
예: -1 → 0, 0 → 0.5, 1 → 1
texCoords.y = 1.0 - texCoords.y;: Y축 반전 (다음 절에서 설명)
전체 화면을 덮는 사각형:
WebGL에서는 사각형을 2개의 삼각형으로 그립니다:
이 6개의 정점이 화면 전체를 덮는 2개의 삼각형을 만듭니다.
마지막으로 중요한 최적화가 하나 더 있습니다: WebGL 텍스처 직접 접근입니다.
MediaPipe는 GPU에서 세그멘테이션을 수행하고, 결과를 GPU 메모리에 저장합니다. 우리는 이를 CPU로 복사하지 않고 직접 접근할 수 있습니다.
CPU-GPU 간 데이터 전송은 느립니다. 전송을 최소화하고 GPU 내에서 모든 것을 처리하는 것이 핵심입니다.
이제 실제 코드를 단계별로 살펴보겠습니다.
먼저 WebGL 쉐이더 프로그램을 생성합니다:
단계별 설명:
쉐이더 컴파일: 소스 코드를 GPU가 실행할 수 있는 형태로 변환
프로그램 링크: 버텍스와 프래그먼트 쉐이더를 하나의 프로그램으로 결합
위치 가져오기: JavaScript에서 쉐이더 변수에 접근할 수 있도록 위치 저장
정점 데이터를 GPU에 업로드합니다:
STATIC_DRAW는 GPU에게 "이 데이터는 한 번 업로드하면 바뀌지 않아요"라고 알려줍니다. GPU는 이를 최적화에 활용합니다.
모든 준비가 끝났으니, 실제 변환 함수를 만듭니다:
핵심 흐름:
초기화: 쉐이더 컴파일, 버텍스 버퍼 생성 (한 번만)
매 프레임:
마스크 텍스처 바인딩
드로우 콜 실행 (GPU에서 병렬 처리!)
ImageBitmap 반환
gl.drawArrays(gl.TRIANGLES, 0, 6) 한 줄이 GPU에 "이제 처리해!"라고 명령하는 부분입니다. 이 순간 GPU가 수천 개의 코어를 동원하여 207만 픽셀을 동시에 처리합니다.
마지막으로 WebGL로 처리된 마스크를 원본 이미지와 합성합니다:
합성 과정:
[1단계] 마스크만 그리기
[2단계] source-in으로 인물만 추출
[3단계] blur 필터 설정
[4단계] destination-atop으로 배경 합성
globalCompositeOperation은 Canvas 2D API의 블렌딩 기능입니다:
source-in: 겹치는 부분만 표시
destination-atop: 기존 이미지 위에 새 이미지를 뒤쪽에 배치
CPU 사용률 50~60% → 5~10%: 10분의 1 수준으로 감소
JavaScript 픽셀 순회가 사라지면서 메인 스레드가 해방됨
GPU가 병렬 처리를 담당하면서 CPU는 다른 작업에 집중
사용자 경험 개선:
실제 사용 경험도 크게 개선되었습니다:
렉이 완전히 사라짐
UI가 부드럽게 반응 - 채팅, 설정 등 다른 기능도 빠름
팬 소음 감소 - 조용한 환경에서 회의 가능
배터리 걱정 없음 - 장시간 회의 가능
다른 앱 동시 사용 가능 - 멀티태스킹 가능
CPU 사용률이 10분의 1로 줄어든 이유는 병렬 처리 덕분입니다.
JavaScript는 207만 픽셀을 하나씩 순차 처리했습니다. CPU 코어 1개가 메인 스레드를 독점하며 50~60%의 CPU를 사용했죠.
GPU는 수천 개의 코어가 207만 픽셀을 동시에 처리합니다. CPU는 GPU에게 "이거 처리해줘"라고 명령만 하므로, CPU 사용률은 5~15%로 떨어집니다.
또 다른 핵심은 불필요한 CPU-GPU 데이터 전송 제거입니다.
MediaPipe의 mask.getAsWebGLTexture() 메서드 덕분에, GPU 메모리에 있는 마스크를 CPU로 복사하지 않고 GPU에서 바로 처리할 수 있었습니다.
데이터가 CPU를 거치지 않으니 CPU 사용률이 최소화됩니다.
개발 과정에서 몇 가지 흥미로운 문제들이 있었습니다.
WebGL을 처음 적용했을 때, 결과 이미지가 상하 반전되어 나왔습니다.
문제 원인:
WebGL과 일반 이미지는 좌표계가 다릅니다
이미지: 좌상단이 원점 (0, 0), Y축이 아래로
WebGL: 좌하단이 원점 (0, 0), Y축이 위로 (수학적 좌표계)
이는 OpenGL의 역사적 배경 때문입니다. 3D 그래픽에서는 Y축이 위를 향하는 것이 자연스럽습니다.
해결 방법:
버텍스 쉐이더에서 Y축을 반전시킵니다:
1.0 - y는 Y축을 뒤집습니다:
y = 0.0 → 1.0
y = 0.5 → 0.5 (중앙은 그대로)
y = 1.0 → 0.0
이 한 줄로 좌표계 차이 문제를 해결할 수 있습니다.
비디오 스트림은 보통 30fps 또는 60fps로 들어옵니다. 하지만 매 프레임마다 세그멘테이션을 실행하면 불필요한 부하가 발생합니다.
해결: 고정 프레임 레이트
setInterval로 일정한 간격을 유지합니다. 이렇게 하면:
CPU/GPU 부하가 일정하게 유지됩니다
예측 가능한 성능
배터리 소모 최적화
왜 requestAnimationFrame을 사용하지 않았나?
requestAnimationFrame은 브라우저의 렌더링 사이클에 동기화됩니다 (보통 60fps). 하지만:
비디오 처리는 렌더링과 독립적으로 동작해야 합니다
30fps로 충분합니다 (화상회의 표준)
일정한 부하가 배터리 관리에 유리합니다
따라서 setInterval이 더 적합했습니다.
처음 WebGL을 도입하기로 했을 때, "모든 렌더링을 WebGL로 재구현해야 하나?"라는 고민이 있었습니다.
만약 모든 것을 WebGL로 재구현했다면:
블러 효과를 쉐이더로 구현
이미지 합성도 쉐이더로 구현
복잡한 코드베이스
긴 개발 시간
디버깅과 유지보수의 어려움
하지만 실제로는 픽셀 변환만 WebGL로 처리하고, 나머지는 Canvas 2D API를 그대로 사용했습니다.
현대 브라우저의 Canvas 2D:
자동 GPU 가속: Chrome, Firefox, Safari 모두 지원
최적화된 블렌딩: globalCompositeOperation은 GPU에서 처리
빠른 필터: filter: blur()도 GPU 가속
언제 WebGL이 필요한가?
Canvas 2D API로 할 수 없거나, 너무 느린 작업:
픽셀별 커스텀 연산 (우리의 경우!)
복잡한 쉐이더 효과
대량의 오브젝트 렌더링 (게임 등)
Canvas 2D API로 충분한 작업:
이미지 합성
기본 필터 (blur, brightness 등)
도형 그리기
텍스트 렌더링
이번 프로젝트에서 우리가 따른 원칙은 간단했습니다:
1. 측정으로 병목 찾기
Chrome DevTools로 프로파일링한 결과, 픽셀 순회가 전체 처리 시간의 **70%**를 차지했습니다. 명확한 병목이었죠.
2. 비용 대비 효과가 큰 것만 최적화
픽셀 순회를 WebGL로 바꾸는 것은 3일 정도의 개발 비용으로 CPU 사용률을 80% 줄였습니다. 나머지 Canvas 합성이나 블러를 WebGL로 재구현하면 훨씬 오래 걸리지만 효과는 미미했습니다.
3. 코드 가독성 유지
병목 구간은 WebGL로 최적화하고, 충분히 빠른 부분은 Canvas 2D API를 그대로 사용했습니다.
"WebGL은 너무 어려워요. 그래픽스 전공이 아니면 못하는 거 아닌가요?"
사실 프론트엔드 개발자에게 GPU는 그렇게 낯선 개념이 아닙니다.
CSS에서도 이미 GPU를 활용하고 있습니다.
transform과 opacity가 빠른 이유는 GPU에서 처리되기 때문입니다.
브라우저는 자동으로 이런 속성들을 GPU 레이어로 분리하여 처리합니다. 우리는 이미 GPU를 활용하고 있었던 것입니다. 다만 명시적으로 제어하지 않았을 뿐이죠.
CSS로 만족스러운 결과를 얻을 수 없을 때 Canvas 2D로 넘어가듯, Canvas 2D로 해결할 수 없을 때 WebGL로 넘어가면 됩니다.
화상회의 배경 블러 기능의 성능 개선 과정을 정리하면 다음과 같습니다.
문제 발견
"배경 블러를 켜면 화면이 버벅거려요"
"저사양 노트북에서는 너무 느려요"
병목 측정
Chrome DevTools: CPU 사용률 50~60%
JavaScript 픽셀 순회가 원인
근본 원인 분석
JavaScript 싱글 스레드의 한계
207만 픽셀을 CPU에서 순차 처리
해결책 선택
GPU 병렬 처리 (WebGL)
CPU 부하를 GPU로 이전
구현
프래그먼트 쉐이더로 픽셀 변환
Canvas 2D로 합성 (하이브리드)
결과
CPU 사용률 50~60% → 5~15% (80% 감소)
렉 완전히 사라짐
저사양 기기에서도 사용 가능
배운 점:
1. 측정으로 병목을 찾기
2. 독립적인 작업은 병렬 처리 고려하기
3. 픽셀/그래픽 처리는 GPU 활용하기
4. 필요한 부분만 최적화하기
5. WebGL과 Canvas 2D를 적절히 조합하기
WebGL, GPU, 쉐이더 프로그래밍은 처음에는 낯설었지만, 결국 문제를 해결하기 위한 도구일 뿐이었습니다. 프론트엔드 개발자도 저수준 최적화를 할 수 있습니다.
여러분도 CPU 사용률이 높거나 렉이 발생하는 문제를 만났을 때, GPU 활용을 고려해보시길 바랍니다.
궁금한 점이 있으시다면 언제든 메일을 남겨주세요!
dinohan.dev@gmail.com
이 글에서 소개한 코드는 다음 저장소에서 확인하실 수 있습니다:
We Make a Future Classic Product