걸어서 딴 영토를 8초짜리 영상으로 만들어 공유하는 기능을 붙이고 있었습니다. 240프레임을 캔버스에 그리고 WebCodecs로 H.264 인코딩을 하는, 개념상 단순한 파이프라인입니다.
처음 돌렸더니 25.8초가 나왔습니다. 8초 영상을 만드는 데 25.8초. 사용자가 "자랑하기"를 누르고 26초를 기다리게 할 수는 없습니다.
당신의 파이프라인이 느릴 때, 가장 먼저 의심하는 건 무엇입니까?
저는 픽셀을 의심했습니다. 1080×1920은 프레임당 200만 픽셀이고, 240프레임이면 5억 픽셀입니다. 게다가 프레임마다 지도 이미지를 통째로 drawImage하고 있었습니다. 설계 문서에 리스크로 적어둔 것도 정확히 이거였습니다 — "8초 초과 시 720×1280으로 낮추거나 6초로 단축".
낮춰봤습니다
720×1280은 픽셀 수가 원래의 44%입니다. 절반 이하입니다.
1080x1920 25,761 ms
720x1280 25,368 ms0.4초. 픽셀을 절반 이하로 줄였는데 시간은 1.5%밖에 안 줄었습니다.
이 순간이 중요합니다. 이건 "덜 개선됐다"가 아닙니다. 픽셀이 병목이 아니라는 증거입니다. 픽셀 작업이 시간의 대부분을 차지했다면 44%로 줄일 때 시간도 그만큼 따라 움직였어야 합니다. 안 움직였다는 건 시간이 픽셀과 무관한 어딘가에 있다는 뜻입니다.
240프레임에 25.4초면 프레임당 약 106ms입니다. 딱 떨어지는 고정값. 이건 계산이 아니라 대기의 냄새입니다.
범인
인코더에 프레임을 무한정 밀어넣으면 원본 프레임이 메모리에 쌓입니다. 1080×1920 240프레임이면 대략 1.5GB고, 폰에서는 그냥 죽습니다. 그래서 배압을 걸어뒀습니다.
if (encoder.encodeQueueSize > 8) {
await new Promise((r) => setTimeout(r, 0));
}setTimeout(0). 무해해 보입니다. 다음 매크로태스크까지만 양보하는 코드니까요.
문제는 이 페이지가 어디서 도는가입니다. 이 렌더러는 앱 안의 화면 밖 WebView에서 돌거나, 브라우저에서 검수할 때는 백그라운드 탭에서 돕니다. 둘 다 타이머를 강하게 스로틀합니다. 포그라운드에서 1ms짜리였던 양보가 여기서는 100ms짜리가 됩니다.
즉 저는 인코더를 기다린 게 아니라 타이머를 기다리고 있었습니다.
고치기
WebCodecs에는 이 용도의 이벤트가 이미 있습니다.
encoder.ondequeue = () => notifyDequeue?.();
while (encoder.encodeQueueSize > MAX_QUEUE && !encodeError) {
await waitForDequeue();
}인코더 이벤트라 스로틀 대상이 아닙니다. 결과는:
1080x1920 1,167 ms (draw 44 / submit 47 / wait 976 / flush 54)25,761ms → 1,167ms. 22배. 해상도는 1080×1920 그대로고, 출력 mp4는 1,723,122바이트로 바이트 단위까지 동일했습니다. 화질을 한 톨도 안 내주고 22배를 벌었습니다.
그리고 구간 계측을 결과에 실어두니 남은 976ms가 실제 인코딩이라는 게 바로 보입니다. 다음에 이게 다시 느려지면 추측할 필요가 없습니다. 물론 계측을 붙였다고 끝은 아닙니다 — 제 에러 모니터는 3일 전에 끝난 장애를 매일 신고하고 있었습니다.
왜 설계 문서가 틀렸나
제 스펙에는 이렇게 적혀 있었습니다. "인코딩 시간이 8초를 넘으면 720×1280으로 낮추거나 6초로 단축한다."
계획으로서는 합리적입니다. 하지만 그 문장은 병목이 픽셀이라고 이미 가정하고 있었습니다. 만약 제가 측정을 건너뛰고 계획대로 해상도를 낮췄다면, 25.4초짜리 파이프라인을 화질만 깎은 채로 출시했을 겁니다. 그리고 "역시 폰에서는 느리네"라고 결론 내렸겠죠.
값싼 수정이 준비돼 있을 때가 가장 위험합니다. 그게 진짜 원인을 안 찾게 만듭니다. 도구의 기본값이 결론을 대신 써준 적도 있었습니다 — 상위 10개만 담긴 리포트를 보고 "수요가 없다"고 결론냈던 글과 같은 종류의 실수입니다.
자가진단 3가지
지금 느린 파이프라인이 있다면 이것부터 해보세요.
- 입력 크기를 절반으로 줄여보십시오. 시간이 비례해서 안 줄면 병목은 그 입력이 아닙니다. 5분이면 확인됩니다.
- 총 시간을 항목 수로 나눠보십시오. 프레임당·행당·요청당 시간이 깔끔한 고정값이면 계산이 아니라 대기입니다.
- 루프 안에
setTimeout·sleep이 있는지 보십시오. 특히 백그라운드 탭, 화면 밖 WebView, 비활성 창처럼 타이머가 스로틀되는 환경에서 도는 코드라면 더욱.
솔직한 부분
이건 제가 만든 버그입니다. 배압 자체는 필요했고 지금도 필요합니다 — 없으면 폰에서 죽습니다. 다만 그 배압을 잡는 도구로 타이머를 고른 게 틀렸습니다. setTimeout(0)이 "즉시"를 뜻한다고 무의식적으로 믿고 있었는데, 그건 포그라운드 탭에서만 참입니다.
그리고 이 발견은 순전히 해상도를 바꿔본 5분 덕분입니다. 그 대조군이 없었다면 저는 지금도 drawImage를 최적화하고 있었을 겁니다.
여러분의 렌더 루프에는 타이머가 들어 있습니까? 그 코드가 도는 곳은 정말 포그라운드입니까?