![[실전 미디어 03] 클라이언트 압축의 함정](https://img.thenullpage.com/posts/5776/5776_1_03a3c0.webp)
지난 편에서 모든 이미지를 webp로 강제 변환한다고 했다. 그 변환을 어디서 할 것인가. 나는 가벼운 이미지는 서버가 아니라 브라우저 안에서, 즉 사용자의 기기에서 압축하기로 했다. 서버 비용도 대역폭도 아끼는 합리적 선택처럼 보였다. 그런데 이 브라우저 압축이 조용히 다른 포맷을 뱉어내며 나를 며칠간 헤매게 만들었다. 이번 편은 그 함정과 원인을 추적한 기록이다.
1. 왜 브라우저에서 압축하나
서버리스 스택에서 서버는 얇게 유지하는 게 이득이다. 이미지 압축을 서버에서 하면 함수 실행 시간과 메모리를 잡아먹고, 무엇보다 원본 전체가 일단 서버로 올라와야 한다. 반대로 브라우저에서 미리 압축하면 이미 작아진 파일만 업로드되니 업로드 대역폭도 줄고 서버 부담도 없다.
브라우저에는 마침 이미지를 그리고 내보내는 표준 기능이 있다. 이미지를 화면 밖 캔버스에 원하는 크기로 그린 다음, 캔버스 내용을 원하는 포맷의 파일로 뽑아내는 방식이다. 코드로는 캔버스에 이미지를 그리고 canvas.toBlob(cb, 'image/webp', 0.85) 같은 호출로 webp 파일을 얻는 흐름이었다.
이론상 완벽했다. 사용자가 사진을 고르면 브라우저가 적당한 크기로 줄이고 webp로 인코딩해서, 원본보다 훨씬 작은 파일만 서버로 보낸다. 서버는 시그니처만 확인하고 저장한다. 처음 테스트에서는 정말 잘 돌았고, 나는 이 경로를 기본으로 삼았다. 내 데스크톱 브라우저 하나로만 확인했으니 잘 될 수밖에 없었다는 걸, 나중에야 뼈저리게 깨달았다.
문제는 다양한 기기와 다양한 이미지가 들어오면서 드러났다. 특정 조건에서 저장된 파일이 webp가 아니라 PNG였던 것이다. 코드는 분명 webp를 요청했는데 결과물은 PNG였고, 그마저도 용량이 커서 서버측 재변환마저 실패하는 이중고가 벌어졌다.
2. 인코더의 무음 fallback
원인을 파고드니 표준 명세 자체에 함정이 있었다. 캔버스에서 파일을 뽑는 그 함수는, 요청한 포맷을 지원하지 못할 경우 실패를 알리는 게 아니라 조용히 PNG로 대체해서 돌려주도록 명세되어 있었다. 즉 webp를 달라고 했는데 인코더가 webp를 못 만들면, 오류 대신 PNG 파일을 아무 말 없이 건네준다.
이게 왜 무서운가 하면, 호출하는 쪽에서는 성공과 실패를 구분할 방법이 겉으로 없기 때문이다. 파일 객체는 정상적으로 돌아왔고, 나는 그걸 webp라고 믿고 이름표만 webp로 붙여 서버에 보냈다. 파일 내용은 PNG인데 이름과 선언된 타입만 webp인, 속을 알 수 없는 물건이 만들어진 것이다. 서버의 시그니처 검증이 없었다면 이 파일은 webp라는 이름으로 스토리지에 그대로 눌러앉았을 테고, 나는 한참 뒤에야 목록 어딘가에서 커다란 PNG가 webp 행세를 하는 걸 발견했을 것이다.
실제로 여러 브라우저에서 webp를 요청해도 PNG가 반환되는 사례가 보고되어 있었다. 어떤 브라우저는 특정 상황에서 webp 인코딩을 포기하고 PNG로 떨어졌다. 내 코드가 잘못 짠 게 아니라, 표준이 실패를 조용한 대체로 숨기도록 되어 있었고 나는 그 조용한 대체를 감지하지 못했던 것이다.
정리하면 근본 원인은 두 겹이었다. 하나는 인코더가 실패를 PNG 대체로 숨긴다는 명세, 다른 하나는 그 대체를 검사하지 않고 결과를 무조건 webp로 취급한 내 코드였다. 명세는 못 바꾸니 내가 대체를 감지하도록 코드를 고쳐야 했다.
3. 실제로 벌어진 사건
사건은 특정 글 몇 개에서 재현됐다. 사진 여러 장을 연속으로 올린 글이었는데, 첫 이미지는 webp로 잘 변환됐고 둘째 이미지가 PNG로 떨어졌다. 면적이 브라우저 한도를 넘은 것도 아니었다. 십몇 메가픽셀 정도로 한도 안이었는데도 실패했다.
단서는 순서였다. 첫 장은 성공, 둘째 장부터 실패라는 패턴은 연속 변환에서 메모리가 누적되며 인코더가 압박받는 정황을 가리켰다. 큰 이미지를 연달아 캔버스에 그리고 내보내는 동안 메모리가 제때 회수되지 않으면, 인코더가 특정 시점에 webp 생성을 포기하고 PNG로 떨어지는 것이었다. 즉 어떤 이미지가 실패하느냐는 그 이미지 자체의 속성만이 아니라, 그 이미지가 몇 번째로 처리되는지, 그 직전에 어떤 큰 이미지가 있었는지 같은 맥락에 좌우됐다. 재현이 어려운 버그의 전형이었다.
더 나쁜 건 뒷수습마저 막힌다는 점이었다. 잘못 저장된 PNG는 대개 원본급으로 컸고, 서버측 인코더로 다시 webp로 바꾸려 하면 이번엔 그 인코더가 큰 PNG를 디코드하다 메모리 한도에 걸려 실패했다. 그래서 그 파일들은 webp도 아니고 재변환도 안 되는 상태로 남아, 재처리 대상으로 따로 표시해 두어야 했다. 결국 그 몇 건은 내 PC에서 별도 도구로 webp로 바꿔 스토리지의 파일을 교체하고 관련 기록을 손보는 수작업으로 마무리했다. 자동화가 뚫린 자리를 사람이 메운, 전형적인 편법 뒷수습이었다.
이 사건이 준 교훈은 분명했다. 실패를 감추는 API를 쓸 때는 성공을 가정하지 말고 결과물을 반드시 검증해야 한다는 것이다. 그리고 연속 처리에서는 자원 회수를 명시적으로 챙기지 않으면 뒤로 갈수록 실패율이 올라간다는 것이다.
4. 브라우저 캔버스의 숨은 한계
이 문제를 계기로 브라우저 캔버스의 한계를 제대로 조사했다. 캔버스는 무한히 큰 이미지를 다루지 못한다. 브라우저마다 처리 가능한 최대 면적이 정해져 있고, 이를 넘으면 캔버스가 비어버려 내보내기가 빈 값이나 대체 포맷을 돌려준다.
브라우저별 편차도 컸다. 데스크톱 크롬 계열은 면적 한도가 아주 넉넉한 편이지만, 문제는 모바일이었다. 특히 아이폰 사파리 계열의 면적 한도가 가장 빡빡했다. 그런데 내 사이트 사용자의 대다수가 모바일, 그중에서도 아이폰이었으니, 가장 빡빡한 한도를 기준으로 삼을 수밖에 없었다.
면적 한도만 문제가 아니라 앞서 본 메모리 누적도 겹쳤다. 한 번의 변환은 한도 안이어도, 여러 장을 연속으로 처리하면 누적 압박으로 사실상 더 낮은 지점에서 실패했다. 그래서 단일 이미지의 이론적 한도보다 훨씬 보수적으로 잡아야 안전했다. 문서에 적힌 최대치를 그대로 믿었다가는, 그 근처에서 간헐적으로 실패하는 재현 어려운 버그에 시달리게 된다. 나는 여유를 두고 한도를 낮춰 잡는 쪽을 택했다.
이 조사는 뒤에 나올 긴 이미지 편의 복선이기도 했다. 세로로 아주 긴 캡처 이미지는 면적 한도를 정면으로 때렸고, 그걸 회피하려던 코드가 엉뚱하게 이미지를 뭉개는 또 다른 사건으로 이어진다. 캔버스 한도는 이 시리즈에서 여러 번 되돌아오는 주제가 됐다.
5. 1차 방어선을 세우다
당장 급한 것은 무음 대체를 감지하는 일이었다. 그래서 내보내기 결과물의 실제 타입을 확인해서, 요청한 webp가 아니면 성공으로 치지 않고 실패로 간주하도록 바꿨다. 조용한 PNG 대체가 더는 조용하지 않게, 즉 드러나게 만든 것이다. API가 실패를 숨기도록 설계되어 있다면, 그 숨김을 걷어내는 검증을 호출하는 쪽에서 반드시 붙여야 한다는 게 이 사건의 첫 교훈이었다.
다음은 메모리 회수였다. 각 이미지를 변환한 뒤 캔버스 크기를 0으로 만들고 디코딩에 쓴 비트맵 자원을 명시적으로 닫아, 연속 업로드에서 메모리가 쌓이지 않게 했다. 이것만으로도 뒷장으로 갈수록 실패하던 패턴이 눈에 띄게 줄었다. 자원을 명시적으로 놓아주지 않으면 브라우저가 알아서 회수해 주길 기대하게 되는데, 인코딩처럼 순간적으로 메모리를 크게 쓰는 작업에서는 그 기대가 자주 어긋났다.
감지에 걸린 경우에는 곧바로 포기하지 않고 단계적으로 재시도하게 했다. 잠깐 쉬어 자원 회수를 유도한 뒤 한 번 더 시도하고, 그래도 안 되면 면적을 더 보수적으로 줄여 다시 인코딩하고, 최종적으로 실패하면 사용자에게 명확한 안내로 재업로드를 유도하는 순서였다.
이 1차 방어선으로 PNG가 조용히 저장되는 사고 자체는 막혔다. 실패가 숨지 않고 드러나니 최소한 나쁜 파일이 쌓이지는 않았다. 하지만 이건 어디까지나 증상을 드러낸 것이지 변환 성공률 자체를 끌어올린 근본 해결은 아니었다. 다음 편에서는 브라우저 기본 인코더를 아예 신뢰하지 않고, 별도의 WASM 인코더로 갈아탄 이야기를 다룬다.