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로 기록했다.
영상을 GIF로 줄이는 기능을 만들기 전에 한 스파이크
2026년 11월 8일에는 영상을 GIF로 바꾸는 기능을 넣을지 판단하려고 구현 없이 스파이크부터 했다. 영상 라이브러리(mediabunny)에는 GIF 코덱이 없어서 GIF 인코더를 따로 골라야 했고, 질문은 “목표 용량(KB)에 맞추는 방법이 현실적인가”였다. 아래 숫자는 프로젝트의 스파이크 기록에 적힌 측정값이며, Chrome에서 가로 480px, 10fps, 128색, 같은 프레임으로 잰 것이다(입력은 합성 영상과 화면 녹화).
인코더 후보 비교
| 영상 | 방식 | 크기 | 시간 |
|---|---|---|---|
| 막대+노이즈 640x360, 60프레임 | gifenc 프레임별 팔레트 | 726KB | 1,922ms |
| 같은 영상 | gifenc 전역 팔레트 | 668KB | 611ms |
| 같은 영상 | gifenc 전역 팔레트 + 바뀐 부분만 그리기(투명 처리) | 475KB | 653ms |
| 같은 영상 | modern-gif, 디더링 없음 | 629KB | 1,519ms |
| 같은 영상 | modern-gif, floyd-steinberg 디더링 | 857KB | 2,102ms |
| 화면 녹화 1080p60, 50프레임 | gifenc 전역 팔레트 | 486KB | 178ms |
| 같은 영상 | gifenc 전역 + 변화분 투명 처리 | 259KB | 209ms |
- 바뀐 부분만 그리는 최적화(이전 프레임과 같은 화소를 투명으로 채움)가 화면 녹화에서 약 47%, 일반 영상에서 약 33% 크기를 줄였다. 정지 화면처럼 이미 작은 입력에서는 이득이 없었다.
- 디더링은 용량 맞춤에 불리했다. 크기가 20~40% 커지는 데 비해 화질 이득은 작았다(원본 대비 평균 절대 오차 1.10에서 1.36으로 오히려 높아지기도 했다).
- 가장 빠르고 작은 것은
gifenc였고,modern-gif는 오차가 더 낮지만 2~5배 느리고 컸다. 그래서gifenc+ 전역 팔레트 + 직접 구현한 변화분 투명 처리로 정했다. - 결과 GIF는 Chrome의
ImageDecoder로 다시 해석해 프레임 수가 정확한지 확인했고, 모든 GIF가 정상으로 읽혔다.
재생 시간이 틀어지는 문제
GIF의 프레임 지연은 100분의 1초(센티초) 단위라서 15fps처럼 프레임 간격이 66.7ms인 경우 그냥 반올림하면 전체 길이가 어긋난다. 반올림하고 남은 오차를 다음 프레임의 지연에 이월하면(예: 60ms와 70ms를 번갈아 사용) 5, 8, 10, 12, 15, 20, 24fps 모두 총 재생 시간이 이론값과 정확히 같았다(오차 0ms).
목표 용량에 맞추는 프로토타입
방법은 후보 설정(가로 크기, fps, 색 수)을 화질 점수 순으로 놓고, 영상 중간의 연속 12프레임만 인코딩해 전체 크기를 추정하며 이진 탐색하는 것이다. 추정치로 고른 설정을 전체 인코딩으로 검증하고, 넘치면 두 단계 낮추고, 남으면 더 좋은 설정을 시도하는 “끌어올리기”를 최대 4번 한다.
| 입력 | 목표 | 결과 크기 | 최종 설정 |
|---|---|---|---|
| 막대 6초 | 100 / 250 / 500 / 1000KB | 82 / 190 / 298 / 870KB | 160px·8fps / 360px·5fps / 320px·10fps / 640px·10fps |
| 화면 녹화 8초 | 100 / 250 / 500 / 1000KB | 79 / 155 / 247 / 780KB | 160px·8fps / 360px·5fps / 360px·8fps / 640px·8fps |
모든 경우에 한도 안에 들어왔지만 한도의 40~90%만 썼다. 후보 가로 크기가 듬성듬성해서 다음 단계가 한도를 넘었기 때문이다. 후보를 촘촘히 두고 색 수와 fps를 마지막 미세 조정 축으로 쓰는 것이 구현 때의 개선 과제로 남았다. 이 결과는 합성 영상과 화면 녹화로만 얻은 값이어서 실제 촬영 영상에서는 달라질 수 있다.
이 스파이크의 교훈은 앞의 “추측하지 않고 작게 먼저 해 본다”와 같다. 구현을 시작하기 전에 후보를 직접 재 보았기 때문에 디더링을 넣는 데 시간을 쓰지 않았고, 재생 시간 오차 같은 함정을 미리 알고 시작할 수 있었다.
만들고 나서 알게 된 점
- 제약을 먼저 정하면 설계가 단순해진다. COOP/COEP 금지와 “정적 사이트”라는 조건이 선택지를 줄여 주었다.
- 계산과 인코더를 분리하면 테스트가 쉽다. 비트레이트 계산을 순수 함수로 빼 두어 인코더 동작과 무관하게 검증했다.
- 인코더 옵션은 추측하지 않고 작은 실험으로 확인한다. frameRate 오버슈트는 문서가 아니라 실행해서 발견했다.
- 검증하지 못한 범위는 그대로 적는다. 데스크톱 Chrome만 확인했고 아이폰 사파리는 실기기 확인을 하지 못했다.
한계
- 데스크톱 Chrome 외 브라우저는 감지 로직만 있고 동작을 검증하지 않았다.
- VBR 대비 고정 비트레이트 비교는 이 환경에서 불가능해서 비어 있다.
- 시험에 쓴 영상은 대부분 합성 영상이다. 실제 촬영 영상으로 GIF 변환 품질을 조정하는 작업이 남아 있다.
- 위 숫자는 모두 개발 환경에서 잰 값이고, 사용자의 기기에서는 속도와 결과가 다를 수 있다.