Canvas toBlob(): Worker로 이미지 업로드 지연 줄이기
문제 상황
Android WebView에서 이미지 업로드 기능을 구현하고 있었다.
사용자가 이미지를 선택하면 브라우저에서 크기를 줄이고 JPEG로 변환한 뒤 S3에 업로드하는 구조였다. 대부분은 정상적으로 동작했지만 QA용 Android 기기에서 업로드를 반복하면 간헐적으로 처리 시간이 크게 늘어났다.
처음에는 기기 성능 문제로 예상했지만 원인을 확인하기 위해 전체 과정을 구간별로 측정했다.
업로드 과정을 구간별로 측정했다
기존 이미지 업로드 흐름은 다음과 같았다.
이미지 선택
→ 디코딩
→ Canvas 리사이즈
→ JPEG 변환
→ S3 업로드지연이 발생한 실행에서 각 구간을 측정해보니 다음과 같은 값이 나왔다.
이미지 디코딩 217ms
리사이즈 1ms
JPEG 생성 4,172ms
이미지 변환 전체 4,446ms
S3 업로드 621ms
전체 5,066ms각 값은 개별 구간과 전체 구간을 따로 측정한 기록이다.
가장 오래 걸린 구간은 JPEG 생성 4,172ms였다. 여기서 JPEG 생성은 canvas.toBlob()을 호출한 시점부터 콜백을 받을 때까지의 시간이다.
canvas.toBlob(
(blob) => {
// 이 콜백을 받을 때까지의 경과 시간을 측정했다.
},
'image/jpeg',
0.75,
);비동기 API의 시간을 읽는 방법
toBlob() 호출부터 콜백까지 4초가 걸렸다고 해서, 압축 연산에만 4초를 썼다고 해석할 수는 없다. 브라우저 내부의 작업 대기와 결과 전달에 걸린 시간도 함께 측정되기 때문이다.
toBlob() 호출
→ 작업 실행 대기
→ 이미지 인코딩
→ 콜백 전달Promise나 await으로 감싸도 실행 경로가 Worker로 바뀌지는 않는다. 비동기로 결과를 받는 것과 별도 스레드에서 작업하는 것은 구분해야 한다.
따라서 await을 사용하더라도 이미지 변환이 자동으로 별도 스레드에서 실행되지는 않는다.
Chromium의 유휴 인코딩 경로
내부 동작을 확인하기 위해 CanvasAsyncBlobCreator 구현을 살펴봤다.
확인한 Chromium 소스에는 메인 스레드의 JPEG·PNG 인코딩을 idle task, 즉 유휴 작업으로 나누어 진행하는 경로가 있다. 화면 반응성을 위해 여유 시간에 인코딩을 진행하는 방식이다.
Android에 적용되는 타임아웃 값도 있었다.
시작 대기 기준 4,000ms
완료 대기 기준 9,000ms시작 확인 시점까지 인코딩이 시작되지 않았으면 유휴 시간을 기다리지 않는 경로로 전환한다. 이미 시작했으면 완료 확인을 위한 타이머를 추가로 예약하고 그때까지 미완료라면 남은 인코딩을 진행하는 경로로 전환한다. Chromium의 CanvasAsyncBlobCreator 소스
한쪽에는 측정된 약 4초의 지연이 있었고 다른 트레이스에는 업로드 요청 시작 전까지 약 13초가 걸린 기록이 있었다. 측정값과 소스의 타임아웃 값이 비슷했지만 수치가 일치한다는 이유만으로 원인을 확정할 수는 없었다.
이 값들은 모든 호출에 붙는 고정 지연도, 전체 작업의 완료를 보장하는 제한 시간도 아니다. 작업 상태에 따라 적용되고 타이머 자체도 스케줄링의 영향을 받는다. 브라우저 공통 규칙이 아니라 해당 Chromium 구현의 동작이라는 점도 구분해야 한다.
확보한 JavaScript 측정과 DevTools 트레이스에서는 해당 요청에 내부 타임아웃이 실제로 발동했는지까지 확인하지 못했다. 당시 WebView 빌드의 내부 실행 경로까지 입증한 것도 아니다.
이를 바탕으로 다음과 같은 가설을 세웠다.
관측한 지연 패턴과 Chromium 구현을 보면, 메인 스레드의 idle encoding 대기가 유력한 원인 후보다.
가설을 검증하기 위해 이미지 처리 경로를 변경했다.
Worker로 이미지 변환 분리하기
기존에는 메인 스레드에서 디코딩부터 JPEG 생성까지 요청했다.
Main Thread
이미지 디코딩
→ Canvas 리사이즈
→ HTMLCanvasElement.toBlob()
→ S3 업로드이를 다음과 같이 바꿨다.
Main Thread
이미지 파일 전달
↓
Worker
createImageBitmap()
→ OffscreenCanvas에 리사이즈해서 그리기
→ convertToBlob()
↓
Main Thread
변환된 Blob 수신
→ S3 업로드Worker 안에서 OffscreenCanvas.convertToBlob()을 사용하도록 바꾼 것이다.
이 방향을 선택한 근거도 있었다. Chromium에는 Worker에서 이미지 인코딩을 idle task로 예약하지 않도록 변경한 수정이 있다. API 이름만 교체하는 게 아니라 Worker 내부에서 실행하는 게 핵심이었다. Chromium 공식 수정 커밋: Don't schedule idle tasks for workers
출력 설정은 기존과 동일하게 유지했다.
긴 변 최대 2,048px
JPEG quality 0.75기존 해상도 제한과 JPEG 품질 설정을 유지하면서 이미지 처리 경로를 변경했다.
변경 전후 측정 결과
변경 전후 각각 두 건의 트레이스를 비교했다.
파일 선택 이벤트부터 S3 업로드 요청 시작까지는 다음과 같았다.
변경 전 13.45초 / 13.30초
변경 후 0.40초 / 0.39초S3 전송 완료까지 포함한 시간은 다음과 같이 줄었다.
변경 전 약 13.7 ~ 14.0초
변경 후 약 0.97 ~ 1.13초이번에 비교한 기록에서는 전체 업로드가 대략 14초 → 1초 수준으로 개선됐다.
다만 각각 두 건의 결과이므로 전체 사용자의 평균이나 상위 지연 시간을 대표하는 수치는 아니다. 앞서 소개한 약 5초짜리 구간별 로그와도 별개의 측정이다.
Worker로 옮긴 뒤 지연이 크게 줄어든 것은 확인했다. 이 결과는 idle encoding 가설을 뒷받침하지만 디코딩부터 실행 경로를 함께 바꾼 만큼 특정 타임아웃 하나의 영향만 분리해서 증명한 것은 아니다.
호환성과 실패 처리
WebView 환경에서는 기능 지원 여부와 실행 실패를 함께 고려해야 했다.
우선 Worker 경로에 필요한 기능을 확인하도록 했다.
WorkerOffscreenCanvasOffscreenCanvas.prototype.convertToBlobcreateImageBitmap
API 존재 여부는 첫 번째 확인이다. 메인 스레드에서 이름이 보인다고 Worker 내부의 실제 변환까지 성공한다고 보장되지는 않으므로, 실행 중 실패도 처리해야 한다.
다음 상황에서는 기존 메인 스레드 방식으로 다시 처리하도록 했다.
- 필요한 기능을 사용할 수 없는 경우
- Worker 생성 또는 이미지 변환에 실패한 경우
- 결과가 비어 있거나 JPEG Blob이 아닌 경우
- Worker가 10초 안에 응답하지 않은 경우
응답이 없으면 Worker를 종료하고 기존 방식으로 재시도한다. 여기서 10초는 애플리케이션의 복구 기준이다. Chromium 내부 타임아웃과 별개이며 전체 업로드를 10초 안에 끝내준다는 뜻도 아니다. 재시도한 변환과 업로드 시간은 추가로 필요하다.
Worker 사용 여부와 기존 방식으로 돌아간 이유도 로그에 남겼다. 다시 느려졌을 때 어느 경로로 처리됐는지 확인하기 위해서다.
작업이 끝나면 Worker를 종료하고 생성에 사용한 Object URL을 해제하고 ImageBitmap도 정리했다.
실제 사용 환경 확인하기
로그에서는 최근 한 달간 Android Chrome WebView 83 미만 접속이 관측되지 않았다.
너무 오래된 환경에서 수집 코드 자체가 실행되지 않았다면 로그에도 남지 않았을 수 있다.
따라서 이 결과는 수집된 로그에서 발견하지 못했다는 의미다. 전체 사용자에게 해당 환경이 없다고 단정할 수는 없다.
실제 구현에서는 버전 숫자만으로 Worker 사용 여부를 결정하지 않았다. 필요한 기능을 확인하고 실제 처리에 실패하면 기존 방식으로 복구하도록 구성했다.
API 존재 여부만으로 정상 동작을 보장할 수 없으므로, 기능 확인과 실패 처리를 함께 적용했다.
정리
이번 작업에서는 이미지 변환을 Worker로 옮겨, 비교한 트레이스의 전체 업로드 시간을 약 14초에서 1초 수준으로 줄였다.
toBlob() 호출부터 콜백까지 측정한 4,172ms에는 실제 인코딩뿐 아니라 작업 대기와 결과 전달 시간도 포함될 수 있었다. 비동기 API의 전체 대기 시간을 연산 시간으로 해석하지 않는 것이 중요했다.
구간별 측정으로 지연 위치를 좁히고 Chromium 구현에서 가설을 세운 다음, 실행 경로를 바꿔 다시 측정했다. 내부 타임아웃의 실제 발동 여부까지 확인하지는 못했지만 Worker 경로로 변경했을 때 지연이 크게 줄어드는 것은 확인할 수 있었다.
Worker를 사용할 수 없거나 처리에 실패하는 환경에서는 기존 방식으로 복구하도록 했다. 이후에도 어떤 경로로 처리됐는지 확인할 수 있도록 로그를 함께 남겼다.
Enjoyed this article? Check out more projects and posts on my portfolio.
Explore this project