랜딩 페이지에 6초짜리 히어로 애니메이션을 넣어야 한다. 풍경 사진 위를 천천히 가로지르는 팬(pan) 영상이다. ffmpeg의 2패스 팔레트 방식(palettegen 다음 paletteuse)으로 480 × 270, 초당 10프레임으로 내보냈더니 60프레임에 6.76 MB짜리 파일이 나왔다.
이렇게 커진 데는 두 가지 이유가 있다. 카메라가 움직이므로 모든 프레임에서 모든 픽셀이 바뀌고, 각 프레임은 480 × 270 전체 이미지로 저장된다. 여기에 paletteuse가 기본으로 오차 확산 디더링을 적용하면서 미세한 노이즈가 더해지는데, GIF의 압축 방식으로는 이 노이즈를 줄일 수 없다. GIF는 픽셀을 LZW로 압축하고, LZW는 무손실 방식이다. 노이즈의 점 하나하나를 전부 기록해야 한다.
같은 파일을 GIF 압축기에 기본 설정(손실 압축 40, 256색)으로 넣으면 원본의 34%로 줄어든다. 너비까지 절반으로 줄이면 545 KB, 원본의 8%다. 이 설정들은 마법이 아니며, 모든 GIF에 똑같이 효과가 있지도 않다. 화면 녹화와 사진 팬 영상은 같은 설정에 전혀 다르게 반응한다. 이 가이드는 세 종류의 GIF에서 각 설정이 용량을 얼마나 바꾸는지 실측값으로 보여 주고, 그 차이를 만드는 GIF 포맷의 구조를 설명한 뒤, 실제로 부딪히는 문제를 정리한다.
설정마다 파일 용량이 얼마나 줄어드나
아래 수치는 테스트용 GIF 세 개를 GIF 압축기 엔진으로 처리한 결과다.
- 에디터 녹화: 640 × 360, 92프레임, 20.1 KB. 코드 에디터에 텍스트를 입력하고 커서가 깜박이는 화면이다. 단색 배경과 안티에일리어싱된 텍스트로 이루어져 있고, 프레임마다 바뀐 사각형 영역만 저장하는 ffmpeg로 만들었다.
- 그라데이션: 480 × 270, 80프레임, 480.0 KB. ffmpeg의
gradients테스트 소스로, 부드러운 색 그라데이션이 천천히 흘러간다. - 사진 팬: 480 × 270, 60프레임, 6.76 MB. 도입부의 그 파일로, 1920 × 1080 사진 위를 가로지르며 잘라 낸 영상이다.
각 칸은 출력 파일 크기를 원본 대비 비율로 나타낸 것이다(도구 표시와 같이 1 KB = 1,024 bytes).
| 설정 | 에디터 녹화 | 그라데이션 | 사진 팬 |
|---|---|---|---|
| 손실 압축 0, 256색 (무손실 재인코딩) | 99% | 100.2% | 99% |
| 손실 압축 40 (기본값) | 88% | 52% | 34% |
| 손실 압축 100 | 82% | 49% | 11% |
| 손실 압축 40, 64색 | 72% | 25% | 29% |
| 손실 압축 40, 64색, 디더링 켬 | 73% | 26% | 32% |
| 손실 압축 0, 16색 | 67% | 43% | 37% |
| 손실 압축 0, 16색, 디더링 켬 | 274% | 71% | 39% |
| 손실 압축 40, 너비 절반 | 49% | 24% | 8% |
| 손실 압축 40, 2프레임마다 1장 | 76% | 26% | 18% |
| 손실 압축 80, 128색, 너비 절반, 2프레임마다 1장 | 33% | 5% | 2% |
눈에 띄는 패턴은 세 가지다. 첫째, 손실 압축은 에디터 녹화에는 거의 효과가 없고 사진 팬에는 효과가 크다. 에디터 녹화는 프레임 대부분이 이미 건너뛰어진 상태이고, 사진 팬은 모든 픽셀이 새로 그려지기 때문이다. 둘째, 너비는 어떤 GIF에서든 강력하다. 픽셀 수가 축소 비율의 제곱으로 줄어들기 때문이다. 셋째, 디더링을 켜면 원본보다 커질 수 있다. 16색 에디터 녹화는 디더링을 끄면 67%였지만 켜자 274%로 불어났다.
무엇부터 시도할까
| GIF 종류 | 먼저 시도할 설정 | 이유 |
|---|---|---|
| 화면 녹화, 대부분 멈춰 있는 UI | 너비, 그다음 색상 수 128 또는 64 | 바뀌지 않은 영역은 이미 건너뛰므로 줄일 수 있는 것은 픽셀 수와 색상 수뿐이다 |
| 영상 클립이나 카메라 팬 | 손실 압축 60–100, 그다음 너비 | 프레임마다 모든 픽셀이 바뀐다. 손실 LZW는 픽셀·프레임·색을 지우지 않고 그 데이터를 짧게 만든다 |
| 부드러운 그라데이션이나 색 전환 | 색상 수 64에 손실 압축 40, 디더링 끔 | 색이 적으면 같은 값이 길게 이어진다. 밴딩이 눈에 띌 때만 디더링을 켠다 |
| 프레임 레이트가 높은 긴 GIF | 프레임: 2프레임마다 1장 | 저장할 프레임이 절반이 된다. 남긴 프레임이 빠진 프레임의 시간까지 표시하므로 전체 길이는 그대로다 |
이미 gifsicle -O3로 최적화한 GIF | 색상 수와 너비 | 무손실 재인코딩은 100% 근처에 머문다. 정보를 덜어 내야만 작아진다 |
| 투명 배경 스티커나 로고 | 손실 압축과 색상 수, 디더링 끔 | 손실 압축은 투명 픽셀을 절대 건드리지 않는다. 디더링은 불투명 픽셀에 노이즈를 더한다 |
| 어떻게 해도 너무 크고 포맷을 바꿔도 될 때 | MP4 또는 움직이는 WebP(Bash 절 참고) | 사진 같은 영상이라면 비디오 코덱은 용량 수준이 완전히 다르다 |
국내 개발 현장에서 GIF 용량 줄이기가 필요한 순간
GIF 압축이 자주 필요한 곳은 대략 세 군데다. 위 표에 대입하면 어떤 설정부터 손댈지 바로 정해진다.
- Jira·Confluence 버그 리포트와 GitHub PR: 재현 과정을 녹화한 GIF를 이슈나 PR 설명에 붙이면 리뷰어가 한눈에 파악할 수 있다. 하지만 몇 MB짜리 GIF가 여러 개 붙으면 페이지가 느려진다. 대부분 멈춰 있는 UI 화면이므로 너비부터 줄이고 색상 수를 128이나 64로 낮춘다. 사내 대시보드나 출시 전 화면이 찍혀 있다면 업로드형 온라인 서비스보다 브라우저에서 처리하는 쪽이 안전하다.
- 네이버 블로그·티스토리·velog 기술 글: 작업 과정을 보여 주는 GIF를 본문에 여러 개 넣으면 모바일에서 글이 무겁게 열린다. 영상이나 사진에서 만든 GIF라면 손실 압축을 60–100으로 올리고 너비를 본문 폭에 맞추는 것만으로도 용량이 크게 줄어든다.
- 카카오톡 이모티콘 시안 같은 투명 배경 GIF: 디더링을 끄고 손실 압축과 색상 수로 줄인다. 손실 압축은 투명 픽셀을 바꾸지 않으므로 윤곽이 그대로 유지된다. 가장자리가 거칠어지는 문제는 아래 ‘투명도는 1비트이고 팔레트 한 칸을 차지한다’ 절에서 다룬다.
GIF는 애니메이션을 어떻게 저장하나
GIF 파일은 블록이 차례로 이어진 구조이며, GIF89a 명세에 정의되어 있다. 이 문서는 CompuServe가 발행했고 문서 날짜는 1990년 7월 31일이다. 명세에는 두 가지 버전이 나온다. 1987년 5월의 “87a”와 1989년 7월의 “89a”다. 애니메이션은 89a 블록인 Graphic Control Extension에 의존한다.
파일 크기를 결정하는 요소는 다음과 같다.
- 색상 테이블. 모든 픽셀은 최대 256개 RGB 색을 담은 테이블의 인덱스다. 테이블 항목 수는 2^(N+1)이고 N은 3비트 필드이므로, 2, 4, 8 … 256색을 담을 수 있다. 전역 테이블 하나가 파일 전체에 쓰인다. 프레임은 로컬 테이블을 가질 수 있으며, 로컬 테이블은 그 프레임에서만 전역 테이블을 대신한다.
- Image Descriptor. 각 프레임은 논리 화면(캔버스) 안에서 left, top, width, height 값을 가진다. 프레임이 캔버스 전체를 덮을 필요는 없다.
- Graphic Control Extension. 프레임마다 하나씩 있으며, 1/100초 단위의 지연 시간, disposal 방식, 선택 사항인 투명 색 인덱스를 담는다. 투명 인덱스를 가진 픽셀은 캔버스를 건드리지 않는다.
- 이미지 데이터. 프레임의 색 인덱스를 LZW로 압축해 최대 255바이트짜리 서브 블록에 나눠 담는다.
GIF는 “2번 프레임 빼기 1번 프레임” 같은 차이를 저장하지 않는다. 인덱스로 채운 사각형 하나와, 그 사각형을 어떻게 처리할지 정한 규칙을 저장한다. 용량 줄이기는 모두 이 모델 안에서 이루어진다. 인덱스 수를 줄이거나 인덱스당 비트 수를 줄이고, 사각형을 작게 만들고, LZW가 짧게 줄일 수 있는 반복을 늘리는 것이다.
LZW, 그리고 GIF 압축이 기본적으로 무손실인 이유
LZW는 픽셀을 읽어 나가면서 인덱스 나열의 사전을 만든다. 처음에는 색마다 항목 하나와 제어 코드 두 개로 시작한다. 제어 코드는 Clear(2^(최소 코드 크기))와 End of Information(Clear + 1)이다. 인코더는 사전에 있는 한 이어 가던 나열을 한 픽셀씩 늘려 간다. 다음 픽셀을 붙였을 때 사전에 없는 나열이 되면, 알고 있던 나열의 코드를 기록하고, 늘어난 나열을 새 항목으로 추가한 뒤, 그 픽셀부터 다시 시작한다.
코드 폭은 최소 코드 크기보다 1비트 넓게 시작해 사전이 커질수록 늘어나며, 최대 12비트(4,096개 항목)까지 커진다. 테이블이 가득 차면 인코더는 Clear를 보내고 처음부터 다시 시작할 수 있다. 명세의 표지에는 인코더가 가득 찬 테이블을 계속 쓰는 “지연 클리어(deferred clear)“도 허용한다고 적혀 있지만, 이를 처리하지 못하는 “상당수의 디코더(a large base of decoders)“가 있으니 인코더는 당분간 쓰지 말라고 요청한다. GIF 압축기는 테이블이 가득 차면 항상 Clear를 보낸다.
반복되는 패턴은 긴 사전 항목이 되고, 코드 하나가 여러 픽셀을 대신하게 된다. 노이즈는 정반대다. 새로운 인덱스 조합은 모두 한 번 학습해야 재사용할 수 있으므로, 노이즈가 많은 프레임에서는 짧은 나열이 잔뜩 생긴다.
GIF 특허 논란의 출발점도 LZW다. Terry Welch는 1983년 6월에 특허를 출원했고, 1985년 12월에 미국 특허 4,558,302로 등록되었다. 원래 권리자는 Sperry Corporation이었는데, 이 회사는 1986년 Burroughs와 합병해 Unisys가 되었다. 미국 특허는 2003년 6월 20일에 만료되었다. 영국, 프랑스, 독일, 이탈리아, 일본, 캐나다의 대응 특허도 2004년 6월과 7월에 만료되었고, 그 뒤로 GIF 인코딩에는 LZW 라이선스가 필요 없다.
팔레트 크기와 코드 폭
팔레트 크기가 코드 폭을 정한다. GIF 압축기는 애니메이션 전체에 전역 색상 테이블 하나만 쓰고 로컬 테이블은 쓰지 않는다. 색상 수를 64로 설정하면 보이는 색 63개와 투명용 1칸을 남긴다. 합쳐서 64개 항목이므로 최소 코드 크기는 6이고, 코드는 7비트에서 시작한다. 256색이면 최소 코드 크기는 8이고 코드는 9비트에서 시작한다. 팔레트가 작으면 비슷한 색조가 같은 인덱스로 합쳐지므로 LZW가 찾을 반복도 길어진다.
팔레트는 남기는 프레임 전체를 바탕으로 한 번만 만든다. 프레임들이 쓰는 색을 모두 합쳐도 투명 칸을 뺀 테이블에 들어가면 모든 색을 정확히 그대로 두므로 손실이 없다. 그렇지 않으면 불투명 픽셀로 채널당 5비트(32,768개 버킷) 히스토그램을 만들고 미디언 컷(median cut)을 실행한다. 색 오차가 가장 큰 버킷 그룹을 골라, 값이 가장 넓게 퍼진 채널을 따라 픽셀 가중 중앙값에서 나눈다. 그룹 수가 충분해질 때까지 이를 반복하고, 각 그룹의 평균이 팔레트 색 하나가 된다.
프레임 최적화와 disposal
첫 프레임은 캔버스 전체를 덮는다. 이후 프레임마다 인코더는 새 그림을 그 시점에 뷰어 화면에 보이는 내용과 비교하고, 달라진 픽셀을 감싸는 경계 사각형만 기록한다. 그 사각형 안에서 바뀌지 않은 픽셀은 투명 인덱스로 기록한다. 같은 인덱스가 길게 이어지므로 코드 몇 개로 압축된다. 전혀 바뀌지 않은 프레임은 지연 시간만 유지한 투명 픽셀 1개가 된다.
프레임은 disposal 방식 1(“do not dispose”, 그대로 둠)로 기록하므로, 각 프레임은 이전 프레임 위에 쌓인다. 투명도에는 규칙이 하나 더 필요하다. 보이던 픽셀이 다음 프레임에서 투명해져야 한다면, 그 위에 투명 픽셀을 그려도 아무것도 바뀌지 않는다. 이럴 때 인코더는 직전 프레임에 disposal 방식 2(“restore to background”, 배경으로 복원)를 설정하고, 그 프레임의 사각형을 해당 픽셀까지 덮도록 넓힌다. 브라우저는 다음 프레임을 그리기 전에 disposal 2 사각형을 투명하게 지운다.
입력 프레임이 disposal 3(“restore to previous”, 이전 상태로 복원)이나 로컬 색상 테이블을 써도 문제없다. 도구는 먼저 모든 프레임을 브라우저가 보여 주는 모습 그대로 디코딩·합성한 뒤, 그 결과를 위의 방식으로 인코딩한다.
손실 LZW
손실 LZW는 인코더의 한 단계만 바꾼다. 이어 가던 나열에 다음 픽셀을 붙인 나열이 사전에 없으면, 무손실 인코더는 거기서 나열을 끝내야 한다. 손실 인코더는 먼저 이어 가던 나열을 한 픽셀 늘린 사전 항목들을 살펴본다. 그중 마지막 색이 그 픽셀의 색과 충분히 가까운 항목이 있으면, 원래 색 대신 그 색을 기록하고 나열을 계속 늘린다. 조건에 맞는 항목이 여럿이면 가장 가까운 것을 고른다.
“충분히 가까움”의 기준은 손실 압축 슬라이더(0–100, 기본값 40)로 정한다. 허용하는 최대 변화량은 손실 압축 × 0.6이며, RGB 공간에서 두 색 사이의 거리로 잰다. 검정에서 흰색까지의 거리가 약 441이다. 기본값이면 24, 100이면 60만큼 바뀔 수 있다. 손실 압축 0은 이 탐색을 끄며, 픽셀은 정확히 그대로 기록된다.
프레임 최적화에도 같은 기준이 적용된다. 새 색이 화면에 이미 보이는 색과 손실 거리(손실 압축 × 0.6) 이내인 픽셀은 바뀌지 않은 것으로 보고, 투명으로 기록해 그대로 둔다. 인코더는 이미 치환한 결과까지 포함해 뷰어가 실제로 보는 화면과 항상 비교한다. 그래서 오차가 프레임을 거듭하며 쌓이지 않는다. 어떤 픽셀도 원래 받았을 팔레트 색에서 손실 거리보다 멀어지지 않는다. 투명 픽셀은 치환하지 않는다. 투명이어야 할 픽셀은 항상 투명으로 기록된다.
gifsicle의 --lossy 옵션도 같은 원리로 동작하며, gifsicle 매뉴얼은 이 기능을 Kornel Lesiński의 공으로 밝히고 있다. 다만 두 척도는 다르다. gifsicle은 감마 보정된 색 공간(기본값 sRGB)에서 오차를 재므로, --lossy=40과 이 도구의 손실 압축 40은 같은 설정이 아니다.
크기 조절과 프레임 건너뛰기
너비는 줄이기만 한다. 빈칸, 0, 원래 너비보다 큰 값은 원래 크기를 유지하며, 높이는 가로세로 비율에 맞춰 정해진다. 출력 픽셀 하나는 그 픽셀이 덮는 원본 픽셀들의 평균이며, 겹치는 면적과 불투명도로 가중치를 준다. 불투명한 원본 픽셀이 절반도 덮지 못한 출력 픽셀은 투명이 된다. GIF에는 반투명이 없기 때문이다.
프레임은 2, 3, 4번째 프레임마다 하나씩 남기며, 항상 첫 프레임부터 시작한다. 빠진 프레임의 표시 시간은 바로 앞의 남긴 프레임에 더해지므로, 6초짜리 애니메이션은 여전히 6초다. 움직임은 뚝뚝 끊기게 된다. 빠른 움직임에서는 눈에 띄지만, 슬라이드쇼나 천천히 스크롤되는 페이지에서는 거의 문제가 되지 않는다.
지연 시간은 10 ms 단위로, 20 ms에서 655.35초 사이 값으로 기록한다. 원본 지연이 0 또는 10 ms인 프레임은 명시적으로 100 ms를 기록한다. 브라우저도 어차피 그렇게 재생한다(아래 ‘주의할 점’ 참고).
GIF 압축기 사용법
- GIF를 페이지에 드롭하거나, 클릭해서 고르거나, 복사한 GIF 파일을 Ctrl/Cmd+V로 붙여넣는다. 파일은
GIF87a또는GIF89a로 시작해야 한다. - 페이지에 크기, 프레임 수, 전체 재생 시간, 반복 횟수, 파일 크기가 표시되고, 설정해 둔 값으로 한 번 압축한다.
- 원본과 결과가 나란히 재생되며, 요약 줄에 두 파일 크기, 변화율, 출력 크기, 프레임 수, 색 개수가 나온다.
- 설정을 바꾸고 압축을 누르면 다시 시도한다. 인코딩은 무거운 작업이라 슬라이더를 움직일 때마다 다시 실행하지 않는다.
- GIF 다운로드를 누르면 결과를
{name}-compressed.gif로 저장한다.
작업은 페이지 안에서 짧은 조각으로 나뉘어 실행되며, 디코딩·팔레트·인코딩 단계의 진행률 막대가 표시된다. 파일은 File API를 통해 디스크에서 페이지로 들어오고, 탭 안의 JavaScript가 디코딩과 인코딩을 한다. 도구는 파일이나 결과를 담아 네트워크 요청을 보내지 않는다. 손실 압축, 색상 수, 프레임, 디더링 설정은 로컬 스토리지에 저장한다. 너비는 파일마다 정하므로 저장하지 않는다. 사이트의 다른 도구와 마찬가지로 Google Analytics에는 도구 이름과 동작(“compress” 또는 “download”)만 담은 사용 이벤트를 보낸다.
주의할 점과 예외 상황
디더링은 파일을 키운다
디더링(이 도구에서는 Floyd–Steinberg 오차 확산)은 이웃한 팔레트 색의 픽셀을 섞어 팔레트에 없는 색을 흉내 낸다. 그라데이션은 더 보기 좋아지지만 용량 면에서는 두 번 손해를 본다. 프레임 안에서는 섞인 픽셀이 LZW가 긴 나열로 바꿀 반복을 끊는다. 프레임 사이에서는 그림이 멈춰 있는 곳에서도 패턴이 움직이므로, 바뀌지 않은 것으로 볼 수 있는 픽셀이 크게 줄고 각 프레임에서 저장해야 할 부분은 훨씬 늘어난다.
측정값을 보면 영향이 얼마나 큰지 드러난다. 16색, 손실 압축 0에서 에디터 녹화는 디더링 없이 원본의 67%였지만 디더링을 켜자 274%가 되었다. 디더링 하나로 출력이 네 배로 커져 원본보다 훨씬 커진 것이다. 그라데이션은 43%에서 71%로 늘었다. gifsicle 매뉴얼도 같은 경고를 한다. 디더링은 “보기엔 더 좋지만 파일이 커지고 애니메이션 잡티가 생길 수 있어 기본으로 꺼 둔다”는 것이다.
이 도구도 디더링을 기본으로 꺼 둔다. 색 수가 적은 부드러운 그라데이션처럼, 늘어나는 바이트보다 밴딩이 더 거슬릴 때 켠다. GIF가 이미 팔레트에 들어갈 만큼의 색만 쓴다면 디더링은 아무 효과가 없다. 색이 정확히 유지되므로 퍼뜨릴 오차가 없기 때문이다.
결과가 원본보다 크다
손실 압축 0, 256색으로 다시 인코딩한 그라데이션은 원본의 100.2%가 되었다. 무손실 재인코딩은 원본이 비효율적으로 기록되었을 때만 이길 수 있는데, 그렇지 않은 GIF가 많다. ffmpeg는 이미 바뀐 사각형을 투명도와 함께 저장하고, gifsicle -O3는 여러 최적화 방법을 시도한다. 프레임마다 로컬 색상 테이블을 가진 원본도 커질 수 있다. 이 도구는 최대 255색짜리 공유 팔레트 하나만 쓰므로, 프레임들이 합쳐서 그보다 많은 색을 쓰면 색을 다시 줄여야 하기 때문이다.
결과가 더 작지 않으면 페이지에 그렇게 표시되며, 그래도 내려받을 수는 있다. 해결책은 정보를 덜어 내는 것이다. 손실 압축을 높이거나, 색상 수를 줄이거나, 너비를 줄이거나, 프레임을 건너뛴다. gifsicle 매뉴얼도 자체 최적화기에 같은 한계가 있다고 적는다. 드물게는 “-O3조차 파일 크기를 오히려 키울 수 있다”는 것이다.
요청한 것보다 색이 적게 나온다
색상 수 64로 설정한 그라데이션은 21색으로 나왔다. 미디언 컷은 채널당 5비트 히스토그램에서 동작하므로, 버킷 한 칸(채널당 8단계)보다 차이가 작은 색들은 같은 버킷에 들어가 나눌 수 없다. 131개의 색이 서로 가까이 모인 부드러운 그라데이션은 버킷을 20개 정도만 채운다. 원래 거의 같은 색들이므로 실제 영향은 작지만, 요약 줄에는 실제 색 개수가 표시된다.
아주 짧은 프레임 지연
GIF 지연 값이 0 또는 1(0 또는 10 ms)이어도 브라우저는 그렇게 빨리 재생하지 않는다. Chromium의 deferred_image_decoder.cc에는 “Firefox의 동작을 따라, 재생 시간을 <= 10 ms로 지정한 프레임에는 100 ms를 쓴다”고 적혀 있다. Firefox의 FrameTimeout.h는 0–10 ms의 원래 타임아웃을 100 ms로 정규화한다. “잘못 만든 도구가 사실은 ‘기본값’을 원할 때 이런 값을 생성하기” 때문이다.
GIF 압축기는 파일을 읽을 때 이 브라우저 규칙을 적용하므로, 페이지에 표시되는 재생 시간은 브라우저에서 재생되는 시간과 같다. 이런 프레임은 명시적인 100 ms 지연으로 기록하므로, 원래 값을 그대로 읽는 도구를 포함해 어디서나 똑같이 재생된다. 20 ms 이상인 프레임 지연은 기록된 값 그대로 둔다.
투명도는 1비트이고 팔레트 한 칸을 차지한다
GIF 픽셀은 완전히 불투명하거나 완전히 투명하다. 이 도구는 합성한 픽셀의 알파 값이 128 미만이면 투명으로 처리한다. 투명 GIF를 줄일 때 불투명 픽셀이 절반 이상 덮은 가장자리 픽셀은 평균 색의 불투명 픽셀이 되고, 나머지는 투명이 되므로 가장자리가 딱딱하게 남는다. 스티커가 항상 같은 배경 위에 놓인다면, 먼저 이미지 편집기에서 그 배경 위에 합쳐 두는 편이 딱딱한 투명도보다 가장자리가 부드럽다.
투명 픽셀이 없는 GIF라도 팔레트 한 칸은 항상 투명용으로 남긴다. 프레임 최적화가 바뀌지 않은 픽셀을 투명으로 기록하기 때문이다. 따라서 색상 수 4는 보이는 색 3개를 뜻한다.
크기 한도
이 도구는 디코딩 후 5,000만 픽셀(가로 × 세로 × 프레임 수), 1,000프레임, 프레임당 16,777,216픽셀까지 받으며, 한 변은 16,384 px를 넘을 수 없다. 디코딩한 프레임은 픽셀당 4바이트인 RGBA로 메모리에 보관되므로, 5,000만 픽셀은 약 200 MB다. 휴대폰 브라우저도 감당할 수 있는 수준이다. 60프레임짜리 1280 × 720 화면 녹화는 5,530만 픽셀로 한도를 넘는다. 이런 파일은 디코딩을 시작하기 전에 거부되며, 페이지는 대신 로컬에서 실행할 gifsicle 명령어를 보여 준다.
gifsicle -O3 --lossy=80 --colors 128 input.gif -o output.gif
출력에 남지 않는 것
출력은 전역 색상 테이블 하나에 인터레이스와 로컬 색상 테이블이 없는 평범한 GIF89a다. 반복 횟수는 유지된다. 입력에 NETSCAPE2.0 확장이 있을 때만 이를 기록하므로(ANIMEXTS1.0 반복 설정은 NETSCAPE2.0으로 기록), 한 번만 재생되는 GIF는 여전히 한 번만 재생된다. 주석 블록, XMP 데이터, 그 밖의 애플리케이션 확장은 복사하지 않는다. 파일이 중간에 끝났거나 손상되었으면 디코딩할 수 있었던 프레임까지 압축하고, 마지막 프레임이 불완전할 수 있다고 알려 준다.
GIF가 맞지 않는 포맷일 때
이 도구의 출력은 항상 GIF다. 사진 같은 영상이라면 다른 포맷이 훨씬 작다. 사진 팬의 경우 gif2webp로 만든 손실 움직이는 WebP는 원본의 16%, ffmpeg 기본 설정으로 만든 H.264 MP4는 1.4%였다. 단색 위주의 UI 콘텐츠에서는 결과가 뒤집힌다. 에디터 녹화를 MP4로 만들면 GIF의 137%, 무손실 움직이는 WebP는 201%, 손실 WebP는 674%였다. 단색 위의 안티에일리어싱된 텍스트는 비디오 코덱보다 GIF의 팔레트와 프레임 최적화에 더 잘 맞는다. 명령어는 아래 Bash 절에 있다. 올릴 플랫폼이 동영상을 받는다면 <video autoplay loop muted playsinline>으로 MP4를 GIF처럼 재생할 수 있다.
코드 예제
Python: Pillow로 크기 조절과 팔레트 다시 만들기
이 스크립트는 면적 평균으로 GIF를 절반 너비로 줄이고, 일부 프레임을 샘플링해 미디언 컷 팔레트 하나를 만든 다음, 디더링 없이 모든 프레임을 그 팔레트에 매핑한다. 짧은 지연에는 브라우저의 100 ms 규칙을 적용한다.
import sys
from PIL import Image, ImageSequence
src, dst = sys.argv[1], sys.argv[2]
scale, colors = 0.5, 128
frames, durations = [], []
with Image.open(src) as im:
loop = im.info.get("loop") # None: 한 번만 재생되는 GIF
for frame in ImageSequence.Iterator(im):
rgb = frame.convert("RGB") # 합성된 전체 프레임. 투명도는 버린다
size = (max(1, round(rgb.width * scale)), max(1, round(rgb.height * scale)))
frames.append(rgb.resize(size, Image.Resampling.BOX)) # 면적 평균
ms = frame.info.get("duration", 0)
durations.append(100 if ms <= 10 else ms) # 브라우저는 0-10 ms를 100 ms로 재생
# 애니메이션 전체에 팔레트 하나. 최대 16프레임을 세로로 쌓아서 만든다.
sample = frames[:: max(1, len(frames) // 16)]
w, h = frames[0].size
strip = Image.new("RGB", (w, h * len(sample)))
for i, f in enumerate(sample):
strip.paste(f, (0, i * h))
palette = strip.quantize(colors=colors, method=Image.Quantize.MEDIANCUT)
indexed = [f.quantize(palette=palette, dither=Image.Dither.NONE) for f in frames]
extra = {} if loop is None else {"loop": loop}
indexed[0].save(dst, save_all=True, append_images=indexed[1:],
duration=durations, optimize=False, **extra)
python shrink_gif.py input.gif output.gif로 실행한다. Pillow 12.1에서 사진 팬은 1.49 MB(원본의 22%)가 되었다. Pillow는 각 프레임을 바뀐 사각형으로 잘라 내고 똑같은 프레임을 합친다(에디터 녹화에서 92프레임이 85프레임이 되었다). 손실 LZW는 쓰지 않으며, 에디터 녹화에서는 두 번째 프레임부터 128색 로컬 테이블을 프레임마다 기록해 출력이 43 KB가 되었다. 20.1 KB 입력의 두 배가 넘는다. 이 스크립트는 불투명한 사진형 GIF에 적합하며, 투명도는 의도적으로 버린다. 프레임 최적화와 손실 LZW는 결과를 gifsicle에 한 번 더 통과시켜 처리한다.
JavaScript: sharp로 크기 조절과 재인코딩
sharp(cgif 인코더를 쓰는 libvips)는 animated: true를 넘기면 모든 프레임을 읽는다. interFrameMaxError 옵션은 이전 프레임과 가까운 픽셀을 투명으로 바꾸므로, 앞에서 설명한 손실 프레임 비교와 비슷하게 동작한다.
// shrink-gif.mjs: sharp(libvips + cgif)로 움직이는 GIF를 리사이즈하고 다시 인코딩
import sharp from 'sharp';
const [input, output, width = '240'] = process.argv.slice(2);
const { pages, loop, delay } = await sharp(input).metadata();
console.log(`${pages} frames, loop ${loop}, first delays ${delay?.slice(0, 3).join(', ')} ms`);
const info = await sharp(input, { animated: true }) // 첫 프레임만이 아니라 모든 프레임을 읽는다
.resize({ width: Number(width), withoutEnlargement: true })
.gif({
colours: 128, // 팔레트 항목 수(투명 포함)
dither: 0, // sharp는 기본으로 디더링(1.0). 끄면 바뀌지 않은 픽셀이 그대로 남는다
interFrameMaxError: 8, // 이전 프레임과 이만큼 가까운 픽셀은 투명이 된다
effort: 7,
})
.toFile(output);
console.log(`${info.size} bytes`);
node shrink-gif.mjs input.gif output.gif 240으로 실행한다. sharp 0.34.5에서 너비 240 px인 사진 팬은 484 KB가 되었다. 같은 너비, 128색, 손실 압축 40으로 GIF 압축기를 돌리면 508 KB다. sharp는 기본으로 디더링을 켠다는 점에 주의해야 한다. GIF 압축기나 gifsicle과는 반대다.
Bash: gifsicle, ffmpeg, 그리고 GIF에서 벗어나기
# 1. gifsicle: 프레임 최적화, 손실 LZW, 128색, 너비 240 px
gifsicle -O3 --lossy=80 --colors 128 --resize-width 240 input.gif -o output.gif
# 2. ffmpeg: 팔레트 하나로 GIF를 다시 만들고 오차 확산 디더링은 쓰지 않음
ffmpeg -i input.gif -vf "scale=240:-1:flags=area,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=none:diff_mode=rectangle" output.gif
# 3. GIF에서 벗어나기: 움직이는 WebP(libwebp)와 MP4(H.264)
gif2webp -mixed -q 75 input.gif -o output.webp
ffmpeg -i input.gif -movflags +faststart -pix_fmt yuv420p \
-vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" output.mp4
각 명령어에 대한 참고 사항:
- gifsicle:
-O3는 여러 최적화 방법을 시도한다(느리지만 가끔 더 작다).--lossy에 값을 주지 않으면 20을 쓴다.--colors는 2에서 256까지 받는다.--dither는 밴딩이 보일 때만 추가한다.--batch는 파일을 제자리에서 덮어쓴다. - ffmpeg:
stats_mode=diff는 각 프레임에서 바뀌는 부분으로 팔레트를 만들고,diff_mode=rectangle은paletteuse가 바뀐 사각형만 처리하게 한다. ffmpeg의 GIF 인코더에는 손실 옵션이 없어서, 사진 팬에서 이 방식은 너비 240 px에 1.49 MB로 손실 결과의 세 배였다. 에디터 녹화에서는 9.3 KB, 원본의 46%였다. 단색 위주의 콘텐츠에서 가장 잘 통한다. - gif2webp:
-mixed는 프레임마다 손실·무손실 압축을 골라 쓴다. 에디터 녹화에서는 그냥-lossy -q 75를 쓰면 GIF의 6.7배,-mixed는 6.2배였다. 실제로 쓸 콘텐츠로 둘 다 시험해 보는 것이 좋다. - MP4:
-pix_fmt yuv420p(4:2:0 크로마)는 4:2:0 H.264만 디코딩하는 플레이어를 위한 일반적인 선택이다. 가로세로가 짝수여야 하며,scale식이 짝수로 맞춰 준다.
다른 GIF 압축 도구와 비교
| ZeroTool GIF 압축기 | ezgif GIF optimizer | gifsicle | |
|---|---|---|---|
| 실행 위치 | 브라우저 탭. 파일이 기기 밖으로 나가지 않음 | 파일 또는 URL로 업로드. 업로드 1시간 뒤 파일을 삭제한다고 페이지에 명시 | 로컬 명령줄 |
| 입력 한도 | 디코딩 후 5,000만 픽셀, 1,000프레임 | 200 MB | 컴퓨터 메모리 |
| 손실 LZW | 지원, 0–100 | 지원. Lossy GIF 인코더를 적용한 gifsicle, 슬라이더로 조절 | --lossy, 기본값 20 |
| 색상 줄이기 | 256에서 4까지, 공유 팔레트 하나 | 지원. 여러 결과를 함께 보여 줌 | --colors 2–256, 여러 방식 |
| 프레임 건너뛰기 | 2·3·4프레임마다 1장, 재생 시간 합산 | 2·3·4프레임마다 1장, 중복 프레임 제거 | 프레임 선택과 --delete |
| 크기 조절 | 너비, 축소만 | 별도 리사이즈 페이지 | --resize, --scale 등 |
| 일괄 처리 | 한 번에 파일 하나 | 별도 일괄 최적화 페이지 | --batch |
셋은 각자 맞는 용도가 다르다.
ezgif optimizer는 업로드한 파일을 받아, 페이지 설명대로 Gifsicle과 Lossy GIF 인코더로 압축한다. 이 도구에 없는 방법도 있다. 퍼즈(fuzz) 계수를 쓰는 “Optimize Transparency”, 코얼레싱(coalescing), 중복 프레임 제거, 파일 용량 한도에 맞춰 자동으로 압축하는 목표 크기 압축기 등이다. 200 MB까지의 GIF를 받는다. GIF가 이 도구의 크기 한도를 넘을 때, 목표 용량에 맞는 설정을 찾아 주길 원할 때, 파일을 업로드해도 상관없을 때 쓰면 된다.
gifsicle은 스크립트 작업의 기준이다. 빌드 파이프라인, 일괄 작업, 커밋된 GIF를 작게 유지하는 CI 단계, 아주 큰 파일에 알맞다. 여러 팔레트·디더링 방식과 리사이즈 필터를 포함해 이 도구보다 훨씬 많은 옵션을 제공한다.
GIF 압축기는 그 사이의 경우를 맡는다. 업로드하고 싶지 않은 GIF(사내 대시보드, 출시 전 제품, 고객 화면)를 원하는 설정으로 압축해 나란히 비교할 수 있고, 설치할 것도 없다. GIF를 작게 만드는 일만 한다. 자르기, 프레임 편집, 다른 포맷으로 변환은 이 도구가 다루는 범위 밖이며, ezgif, gifsicle, ffmpeg가 그 일을 한다.
관련 도구와 참고 자료
GIF와 이미지를 다루는 ZeroTool 도구:
- GIF 프레임 추출기: GIF 프레임을 PNG나 JPG로 내보내거나, 프레임 타이밍이 담긴 스프라이트 시트를 만든다.
- 이미지 압축기: JPEG, PNG, WebP를 압축하고 크기를 조절한다.
- WebP 변환기: PNG, JPG 또는 GIF의 첫 프레임을 WebP로 변환한다.
이 가이드에서 참고한 1차 자료:
- GIF89a 명세 (W3C 미러): 블록 구성, 색상 테이블, Graphic Control Extension, disposal 방식, LZW, 지연 클리어 코드
- 미국 특허 4,558,302 (Google Patents): Welch의 LZW 특허, 출원·등록·미국 만료 날짜
- Wikipedia: GIF, “Unisys and LZW patent enforcement”: 미국 외 대응 특허의 만료 날짜
- Gifsicle 매뉴얼:
-O3,--lossy,--colors,--dither, 리사이즈 옵션 - FFmpeg 필터: palettegen과 paletteuse:
stats_mode,dither,diff_mode - gif2webp 문서:
-lossy,-mixed,-q - sharp 출력 옵션: gif:
colours,dither,interFrameMaxError - Chromium
deferred_image_decoder.cc와 FirefoxFrameTimeout.h: 짧은 지연에 대한 100 ms 규칙 - ezgif GIF optimizer: 온라인 비교 대상