[실전 미디어 06] 긴 이미지가 뭉개진 이유

지난 편에서 움직이는 이미지를 보존하는 문제를 해결했다. 이번에는 크기, 정확히는 세로로 아주 긴 이미지가 말썽이었다. 사용자들은 뉴스 기사나 대화 화면을 통째로 캡처해 올리곤 했는데, 이런 이미지는 폭에 비해 높이가 수십 배에 달했다. 이걸 압축하자 큰 제목은 흐려지고 작은 본문 글씨는 아예 판독 불가로 뭉개졌다. 멀쩡한 원본이 왜 저장하고 나면 못 읽게 되는지, 그 원인을 파고든 기록이다. 사용자는 분명히 읽을 수 있는 이미지를 올렸는데 사이트에 올라오면 못 읽게 되니, 이건 단순한 화질 저하가 아니라 사용자가 전달하려던 정보 자체가 사라지는 문제였다.


1. 세로로 긴 캡처의 등장

문제를 처음 체감한 건 어떤 세로 캡처 이미지였다. 폭은 팔백 픽셀이 채 안 되는데 높이가 이만 픽셀을 넘었다. 비율로 치면 대략 일 대 이십팔에 달하는, 국수 가락처럼 긴 이미지였다. 화면을 위에서 아래로 길게 이어 붙인 스크롤 캡처였으니 당연한 형태였다.


이런 이미지는 폭 자체는 작아서 그대로 봐도 부담이 없었다. 문제는 총 면적이었다. 폭이 작아도 높이가 어마어마하니 픽셀 총량, 즉 면적은 십칠 메가픽셀을 넘겼다. 앞선 편들에서 여러 번 나온 그 캔버스 면적 한도를 정면으로 초과하는 크기였다.


브라우저에서 압축하려면 이미지를 캔버스에 그려야 하는데, 면적 한도를 넘으면 캔버스가 제대로 그려지지 않는다. 특히 모바일, 그중에서도 아이폰 계열의 면적 한도가 가장 빡빡했고, 그 한도를 넘으면 캔버스가 비어버려 내보내기가 실패했다. 사용자 다수가 아이폰이었으니 이 한도를 무시할 수 없었다.


그래서 코드에는 안전장치가 들어 있었다. 이미지가 너무 길면 긴 쪽 변을 일정 픽셀에서 잘라 축소하는 상한이었다. 캔버스가 비어버리는 최악은 막았지만, 바로 이 안전장치가 예상 못 한 부작용을 일으키고 있었다. 최악을 막으려던 코드가 차악을 만들어낸 셈이었다. 캔버스가 비어버리는 완전한 실패는 눈에 띄어 금방 고치지만, 이미지가 흐릿해지는 부분적 실패는 그럴듯하게 저장되어 넘어가기 때문에 오히려 발견이 늦었다. 완전한 실패보다 어정쩡한 성공이 더 위험했다.


2. 한도 회피 코드가 가로를 죽이다

안전장치의 논리는 단순했다. 긴 변이 정해진 상한을 넘으면 그 상한에 맞춰 전체를 비례 축소한다. 문제는 비례 축소라는 데 있었다. 세로를 상한에 맞춰 크게 줄이면, 같은 비율만큼 가로도 함께 줄어든다는 점을 놓친 것이다.


구체적으로 따라가 보면 이랬다. 높이 이만 픽셀을 상한인 육천 픽셀에 맞추려면 대략 사분의 일 이하로 줄여야 한다. 그런데 그 비율이 가로에도 똑같이 적용되니, 원래 팔백 픽셀이던 가로가 이백 픽셀 근처로 쪼그라들었다. 읽어야 할 텍스트의 가로 해상도가 사분의 일로 날아간 것이다.


가로가 이백 픽셀로 줄면 그 안에 담긴 글씨는 몇 픽셀 굵기밖에 남지 않는다. 큰 제목은 그나마 흐릿하게라도 보였지만, 뉴스 캡션이나 대화 속 작은 본문 글씨는 획이 뭉개져 완전히 읽을 수 없게 됐다. 정작 사용자가 그 이미지를 올린 이유인 텍스트가 죽어버린 것이다.


핵심은 세로를 줄이는 게 목적이었는데 애먼 가로가 희생됐다는 데 있었다. 세로가 길어서 생긴 문제를 세로만 손대서 풀었어야 했는데, 비례 축소가 가로까지 끌고 내려간 것이다. 문제의 원인과 상관없는 축이 함께 망가지는, 전형적인 잘못된 해결의 형태였다. 세로 길이라는 한 변수만 통제하면 되는 문제에 전체 비율을 건드리는 도구를 쓴 게 실수였다. 다루려는 축과 손대는 축이 다르면 반드시 어딘가 엉뚱한 곳이 망가진다는 걸 이때 절감했다.


3. 열화를 부른 세 겹의 원인

추적해보니 긴 이미지의 열화는 한 가지가 아니라 세 겹으로 겹쳐 있었다. 첫째가 방금 본 가로 축소였다. 면적 한도를 피하려던 상한이 가로 해상도를 사분의 일로 깎았다. 이게 가장 치명적이었고 텍스트 판독 불가의 주범이었다.


둘째는 축소 자체의 품질이었다. 캔버스에서 큰 배율로 한 번에 줄이면, 브라우저의 기본 보간이 쓰인다. 이 보간은 고급 리샘플링 알고리즘이 아니라 속도 위주의 기본 방식이라, 큰 폭으로 축소할 때 디테일과 글씨 획이 뭉개졌다. 여러 단계로 나눠 줄이면 나았겠지만 기본 한 번의 축소로는 손실이 컸다.


셋째는 손실 압축이었다. webp의 손실 모드는 사진에는 티가 안 나지만 텍스트가 많은 이미지에서는 글자 가장자리에 뭉개짐을 남겼다. 사진용으로 적당하던 품질 값이 텍스트 스크린샷에는 부족했던 것이다. 앞의 두 원인만큼 크진 않았지만 마지막 한 겹의 열화를 더했다.


세 원인은 성격이 달랐다. 가로 축소는 논리 버그였고, 보간은 방법의 한계였고, 손실 압축은 파라미터 문제였다. 하나만 고쳐서는 부족했고, 셋을 다 다뤄야 긴 이미지가 원본만큼 선명해질 수 있었다. 다만 셋 중 근본은 첫째, 즉 캔버스 면적 한도를 회피하려는 축소 자체에 있었다. 나머지 둘은 첫째를 해결하는 과정에서 자연스럽게 함께 풀 수 있는 종류였기에, 나는 가장 깊은 원인인 캔버스 의존을 어떻게 걷어낼지에 집중하기로 했다.


4. 왜 클라이언트에서는 못 고치나

처음에는 상한 로직만 손보면 될 줄 알았다. 긴 변을 자르되 가로는 유지하도록, 세로만 상한에 맞추고 가로는 원본을 지키게 바꾸는 것이다. 실제로 이렇게 하면 가로 팔백 픽셀은 살릴 수 있었다. 하지만 이건 근본 해결이 아니라 미봉책이었다.


왜냐하면 캔버스 면적 한도라는 벽이 그대로 남아 있었기 때문이다. 가로를 유지한 채 세로만 줄여도, 여전히 캔버스에 그 큰 이미지를 한 번은 그려야 하는데 그 순간 면적 한도에 부딪힐 위험이 남았다. 한도 근처에서는 내보내기가 간헐적으로 빈 값을 뱉는 재현 어려운 실패가 도사렸다.


보간 품질과 손실 압축 문제도 클라이언트 캔버스 방식에 묶여 있는 한 완전히 벗어나기 어려웠다. 브라우저가 주는 기본 보간을 내가 고를 수 없고, 여러 단계 축소를 직접 구현하는 것도 무겁고 불안정했다. 결국 문제의 뿌리는 이 긴 이미지를 브라우저 캔버스로 압축하려 한다는 접근 자체에 있었다.


그래서 방향을 크게 틀기로 했다. 긴 이미지는 클라이언트에서 압축하려 애쓰지 말고, 원본을 그대로 스토리지에 보존한 다음 표시할 때 서버측 변환에 맡기자는 발상이었다. 이건 앞서 동영상과 애니메이션에서 봤던 패러다임과 같았다. 브라우저가 감당 못 하는 건 서버로 넘긴다는 원칙 말이다. 클라이언트에서 억지로 해결하려 상한과 씨름하는 대신, 이 작업은 애초에 클라이언트가 할 일이 아니라고 인정하는 것이 더 빠른 길이었다. 문제를 옮기는 것도 때로는 훌륭한 해결이었다.


5. 방향 전환의 밑그림

이 방향 전환에는 근거가 있었다. 서버측 이미지 변환 서비스의 면적 한도는 브라우저 캔버스보다 훨씬 넉넉했다. 캔버스가 십육 메가픽셀 근처에서 막히는 데 비해, 서버측 변환은 백 메가픽셀 규모까지 다뤘다. 십칠 메가픽셀짜리 긴 이미지쯤은 여유롭게 처리할 수 있었다.


게다가 서버측 변환은 가로를 유지하며 원하는 폭으로 고품질 리샘플링을 해줬다. 브라우저 기본 보간과 달리 텍스트 획을 잘 살렸고, 출력 포맷과 품질도 세밀하게 지정할 수 있었다. 긴 이미지 열화의 세 원인을 한 번에 걷어낼 수 있는 길이었다. 하나의 근본 원인을 제대로 걷어내면 그 위에 얹혀 있던 부수적 문제들이 함께 사라지는 경우가 많은데, 긴 이미지가 딱 그랬다.


다만 모든 이미지를 이렇게 처리하면 변환 비용과 원본 보관 용량이 늘 수 있으니, 긴 이미지처럼 캔버스 한도가 위험한 경우에만 이 경로를 태우는 하이브리드가 합리적이었다. 일반 이미지는 기존 클라이언트 압축을 유지하고, 위험군만 원본 보존 후 서버 변환으로 표시하는 것이다. 모든 이미지를 서버로 넘기면 비용과 저장 용량이 불필요하게 커지니, 실제로 문제를 일으키는 소수에만 비싼 경로를 적용하는 선별이 핵심이었다.


이렇게 문제의 성격에 따라 처리 경로를 나누는 것이 서버리스 스택에서 비용과 품질을 동시에 잡는 열쇠였다. 다음 편에서는 이 밑그림을 실제로 구현한 이야기, 즉 CDN의 온더플라이 이미지 변환에 짐을 넘겨 긴 이미지를 가로 손실 없이 선명하게 살린 과정을 다룬다.