shrinkto.app 만든 과정: 브라우저에서 목표 용량에 맞춰 영상·이미지를 줄이는 도구
이 글의 핵심
디스코드나 이메일 용량 제한에 맞춰 영상을 줄이려면 보통 서버에 올려야 합니다. shrinkto.app은 모든 처리를 브라우저 안에서 합니다. 3일 동안 74개 커밋으로 만든 과정에서 확인된 사실만 골라, 목표 용량 계산 방식, 인코더 설정에서 틀렸던 점, 배포 한도, 테스트 방식을 정리했습니다.
shrinkto.app은 “이 영상을 20MB 이하로”처럼 목표 용량을 정하면 그 크기에 맞춰 영상과 이미지를 줄여 주는 웹 도구다. 파일을 서버에 올리지 않고 브라우저 안에서만 처리한다. 2026년 11월 7일에 첫 커밋을 하고, 이 글을 쓰는 11월 9일까지 74개 커밋이 쌓였다. 개발은 AI 코딩 도구(Claude Code)와 함께 했고, 그 작업 방식은 바이브 코딩에서 지켜 둔 작업 규칙에 따로 정리했다. 이 글은 만들면서 실제로 확인한 기술적 사실을 모은 기록이다. 숫자는 프로젝트의 작업 기록에 남은 측정값이며, 측정 환경은 macOS의 Chrome 헤드리스다.
만든 이유와 제약
디스코드 무료 한도, 이메일 첨부 한도처럼 “크기가 정해져 있고 그 안에 들어가야 하는” 상황이 흔하다. 기존 도구는 대부분 파일을 서버로 올려야 하거나, 화질 슬라이더만 있어서 결과 용량을 예측하기 어렵다. 그래서 정한 조건은 세 가지였다.
- 목표 용량을 입력하면 그 안에 들어가는 파일을 만든다.
- 파일은 업로드하지 않는다.
- 정적 사이트로 만들어 Cloudflare Pages에 올린다.
스택은 Astro와 TypeScript의 정적 빌드, 영상 처리는 WebCodecs와 mediabunny 라이브러리, 폴백은 ffmpeg.wasm(싱글스레드 코어)이다. 테스트는 Vitest와 Chrome을 띄우는 e2e 스크립트를 썼다.
애드센스를 붙일 계획이라 한 가지 제약이 따라붙었다. 교차 출처 격리(COOP/COEP 헤더)를 쓰면 외부 광고 스크립트가 막히므로 SharedArrayBuffer와 멀티스레드 ffmpeg 코어를 쓰지 않기로 했다. 처음부터 정한 제약이라 설계가 단순해졌다.
목표 용량을 맞추는 계산
핵심은 비트레이트를 정하는 계산이다.
- 예산(bit)을
목표MB × 8 × 1,000,000 × 0.97로 잡는다. 3%는 컨테이너 오버헤드 여유분이다. - 오디오를 먼저 정한다. 원본이 AAC이고 비트레이트가 예산의 15% 이하면 재인코딩 없이 그대로 복사한다. 아니면 브라우저의 AAC 인코더로 96kbps, 예산 대비 비중이 크면 64kbps 모노로 낮춘다.
- 영상 비트레이트는
(예산 − 오디오 비트) ÷ 길이(초)다. - 픽셀당 비트(bpp)가 0.05보다 낮으면 해상도와 프레임을 사다리처럼 낮춘다(1080p → 720p → 480p, 60fps → 30fps). 업스케일은 하지 않는다.
- 인코딩 뒤 실제 크기를 재서 초과하면 비트레이트를
목표 ÷ 실제 × 0.97배로 낮춰 최대 2번 다시 인코딩한다.
이 계산은 순수 함수로 분리해서 단위 테스트를 달았다. 인코더가 실제로 어떻게 동작하는지에 상관없이 계산만 따로 검증할 수 있어서 이후 수정이 쉬웠다.
실제로 부딪힌 문제
frameRate를 안 쓰면 정확히 2배 커진다
첫 동작 확인에서 1080p 60fps 20초 영상을 4Mbps 목표로 돌렸더니 출력이 8.0Mbps로 나왔다. 정확히 2배였다. frameRate를 지정하지 않으면 인코더가 30fps 기준으로 비트를 배분한다. 값을 명시하자 목표 대비 1.000배(3.9996Mbps)로 맞았다. 이후 5·10·20MB 목표로 돌린 기록에서 채움률은 0.971~0.973이었다.
작은 파일이 오히려 커지는 문제
8초 360p 1.5MB 영상을 20MB 예산으로 돌렸더니 3.7MB로 커졌다. 예산이 남으면 인코더가 비트를 더 쓰기 때문이다. 원본 비트레이트보다 높게 인코딩하지 않는 상한을 넣어 1.38MB로 해결했다.
보정 루프가 실제로 수렴하는지
첫 시도를 일부러 1.3배, 2.0배, 3.0배 초과하도록 강제해도 모두 재인코딩 1회로 목표 안에 들어왔다(예: 28.5MB → 9.92MB, 목표 10MB). “최대 2회”라는 설계가 실제로 필요한 경우가 드물다는 것도 이때 알았다.
VBR과 고정 비트레이트의 차이는 이 환경에서 측정할 수 없었다
같은 입력에서 가변과 고정 모드의 출력 바이트가 완전히 같았다. 이 환경(맥의 Chrome 하드웨어 인코더)이 모드를 구분하지 않는 것으로 보인다. 그래서 두 모드의 편차는 비교하지 못했고, 다른 플랫폼에서는 다시 측정해야 한다고 기록해 두었다.
취소 처리와 워커 종료
취소는 워커를 terminate()로 종료하는 방식이다. e2e로 확인하니 변환 중에는 VideoEncoder 1개, VideoDecoder 1개가 있었고 취소 뒤에는 코덱 4종이 모두 0이 되었으며 재실행도 됐다. 이 과정에서 버그 세 가지가 e2e에서 걸렸다. 취소 뒤 워커가 없어서 시작 버튼이 반응하지 않는 문제, 프로덕션 빌드에서 클래스 이름이 압축되어 취소 오류를 일반 오류로 오판하는 문제(이름 비교 대신 instanceof로 바꿨다), 종료된 워커의 늦은 메시지가 UI 상태를 되돌리는 문제였다.
ffmpeg 코어가 파일 한도를 넘는다
ffmpeg.wasm 코어 파일은 32MB다. Cloudflare Pages의 개별 파일 한도가 25MiB라 사이트에 포함할 수 없었다. 코어는 고정 버전(0.12.10)을 CDN에서 불러오고, 한 CDN이 실패하면 다른 CDN으로 다시 시도하도록 했다. 폴백 경로로 8초 360p 영상을 목표 0.8MB로 줄인 시험에서 784KB, 3.0초가 걸렸다.
테스트와 품질 확인 방식
작업 현황 기록 기준(11월 9일)으로 단위 테스트가 211개, e2e 스크립트가 30개다. e2e는 단순히 화면이 뜨는지가 아니라 출력 파일의 실제 내용을 검사한다. 예를 들어 MP4의 박스 순서가 ftyp → moov → mdat인지(인터넷에서 바로 재생되려면 moov가 앞에 있어야 한다), 가림 기능이 켜졌을 때 해당 화소가 정말 가려졌는지를 본다. 시각적 문제는 캡처 이미지를 직접 열어 확인한다는 규칙도 두었다. 이 방식으로 PNG 저장 확장자 오류, 숨김 속성이 CSS에 가려지는 문제, “처음부터” 버튼이 원본으로 돌아가지 않는 문제 같은 버그를 찾아 고쳤다.
접근성은 axe로 라이트·다크·모바일 68개 화면 상태를 검사해 위반 0건을 기록했고, Lighthouse는 실제 도메인의 모바일 기준으로 홈 페이지 성능 92, 접근성·권장사항·SEO 각 100(11월 8일 측정), 다른 페이지 성능은 93~94로 기록했다.
만들고 나서 알게 된 점
- 제약을 먼저 정하면 설계가 단순해진다. COOP/COEP 금지와 “정적 사이트”라는 조건이 선택지를 줄여 주었다.
- 계산과 인코더를 분리하면 테스트가 쉽다. 비트레이트 계산을 순수 함수로 빼 두어 인코더 동작과 무관하게 검증했다.
- 인코더 옵션은 추측하지 않고 작은 실험으로 확인한다. frameRate 오버슈트는 문서가 아니라 실행해서 발견했다.
- 검증하지 못한 범위는 그대로 적는다. 데스크톱 Chrome만 확인했고 아이폰 사파리는 실기기 확인을 하지 못했다.
한계
- 데스크톱 Chrome 외 브라우저는 감지 로직만 있고 동작을 검증하지 않았다.
- VBR 대비 고정 비트레이트 비교는 이 환경에서 불가능해서 비어 있다.
- 시험에 쓴 영상은 대부분 합성 영상이다. 실제 촬영 영상으로 GIF 변환 품질을 조정하는 작업이 남아 있다.
- 위 숫자는 모두 개발 환경에서 잰 값이고, 사용자의 기기에서는 속도와 결과가 다를 수 있다.