Victor

[연재] 이미지 1,063MB를 315MB로 줄이기

연재 · 2026-08-20

제가 두 번 쓴 문장이 틀렸습니다

 

이 글을 쓰려고 사진 3,116장의 가로 폭을 전부 재 봤어요. 한 장씩 열어서 픽셀 수를 세는 코드를 돌린 겁니다.

 

제일 큰 게 966픽셀이었습니다. 1,400픽셀을 넘는 사진은 한 장도 없었어요.

 

그런데 저는 이 연재에서 두 번이나 이렇게 적었습니다.

 

"올리는 사본만 가로 1,400픽셀로 줄이고 품질을 82로 낮췄습니다." (3편)

 

"홈페이지에 올릴 때 사진을 가로 1400픽셀로 줄여서 내보냅니다." (17편)

 

코드에도 그렇게 적혀 있고요.

 

MAXW, Q = 1400, 82
...
if im.width > MAXW:
    im = im.resize((MAXW, round(im.height * MAXW / im.width)), Image.LANCZOS)

 

if 문은 한 번도 실행된 적이 없습니다. 3,116장 중에 조건에 걸리는 사진이 0장이니까요.

 

 

웃긴 건, 그런데도 결과 숫자는 맞았다는 거예요. 1,063MB가 315MB가 된 것도 사실이고, 사진이 선명한 것도 사실입니다. 틀린 건 결과가 아니라 이유였어요.

 

오늘은 그 이유를 다시 캔 이야기입니다.

 

먼저 지금 숫자부터 다시 쟀습니다

 

글 쓰기 전에 두 폴더의 용량을 직접 다시 쟀어요. 예전에 적어둔 숫자를 그대로 옮기지 않으려고요.

 

원본  3,116장  1,063.0MB  (한 장 평균 349KB)
배포  3,116장    315.2MB  (한 장 평균 104KB)
       → 70.4% 감소

 

장수는 똑같습니다. 한 장도 버리지 않았어요. 3,116장이 그대로 3,116장으로 올라갑니다. 줄어든 건 오직 파일 하나하나의 크기예요.

 

 

네이버 블로그에서 옮겨온 글이 278편인데, 그중 사진이 붙어 있는 글이 235편입니다. 한 글에 평균 13장, 중앙값 12장이에요. 글 하나당 사진 용량이 평균 4.5MB쯤 됩니다.

 

글자만 놓고 보면 이 사이트는 아주 가볍습니다. 글 본문 페이지를 통째로 다 합쳐도 12MB예요. 사진 한 폴더가 그 스물여섯 배입니다. 무거운 건 처음부터 끝까지 사진이었어요.

 

그럼 748MB는 어디로 갔을까요

 

폭을 안 줄였다면 뭘 줄인 걸까요. 답은 코드 바로 아랫줄에 있었습니다.

 

im.convert("RGB").save(d, "JPEG", quality=Q, optimize=True, progressive=True)

 

다시 구운 겁니다. 사진 크기는 그대로 두고, JPEG 품질만 82로 낮춰서 새로 저장한 거예요.

 

사진 파일은 원래 "이 정도는 눈에 안 보이니까 버리자"를 이미 한 번 하고 저장된 결과물입니다. 그 정도를 얼마나 세게 하느냐가 품질 값이고요. 100에 가까울수록 원본에 충실하고 무거워지고, 낮출수록 가벼워지는 대신 뭉개집니다. 82는 "눈으로는 거의 구분이 안 되는데 용량은 확 떨어지는" 구간이에요.

 

거기에 두 가지를 더 붙였습니다. optimize=True는 파일 안의 표를 한 번 더 다듬어서 몇 %를 더 깎는 거고요, progressive=True는 사진을 위에서부터 한 줄씩 그리는 대신 흐릿한 전체 → 점점 선명하게 그리도록 저장하는 방식이에요. 느린 회선에서 "뭐가 뜨긴 뜨는구나"가 훨씬 빨리 보입니다.

 

그러니까 제가 한 달 내내 "폭을 줄여서 가벼워졌다"고 믿었던 건 사실 화질을 조금 내려서 가벼워진 거였어요. 결과는 같은데 설명이 틀렸던 겁니다.

 

원본은 이미 남이 줄여준 것이었습니다

 

그럼 왜 966픽셀이 최대였을까요. 여기서 진짜 원인이 나왔습니다.

 

네이버 글을 긁어오는 수집기 코드를 열어 봤어요.

 

u = re.sub(r"\?type=\w+$", "?type=w966", u)

 

네이버는 사진 한 장을 여러 크기로 만들어두고, 주소 뒤에 붙는 값으로 어느 크기를 줄지 정합니다. 제 수집기는 그 값을 전부 w966, 즉 가로 966픽셀짜리로 바꿔서 요청하고 있었어요. 이 줄은 수집기 첫 커밋(7월 22일 밤 9시 54분)부터 들어 있었습니다.

 

줄이는 일은 이미 끝나 있었던 거예요. 제가 받아온 "원본"은 원본이 아니라 네이버가 한 번 줄여준 사본이었습니다. 그러니 14분 뒤(그날 밤 10시 8분)에 만든 1,400픽셀 규칙은 처음부터 걸릴 대상이 없었죠. 두 코드는 같은 날 밤, 십몇 분 차이로 만들어졌는데도 서로를 몰랐습니다.

 

실제로 폭 분포를 세어 보면 이렇습니다.

 

900픽셀 이상   2,756장  (95%)
700픽셀 초과   2,855장  (98%)
1400픽셀 초과       0장

 

대부분이 딱 900픽셀입니다. 966은 네이버가 정한 상한이고, 세로로 찍은 사진은 그보다 작게 오고요.

 

눈으로 보면 똑같습니다

 

말로만 하면 안 믿기실 것 같아서, 같은 사진을 두 파일로 나란히 열어 봤어요.

 

먼저 제 컴퓨터에 있는 원본입니다. 577KB.

 

 

그리고 사이트에 올라간 사본이에요. 174KB.

 

 

가로 900픽셀, 세로 675픽셀. 크기는 완전히 같습니다. 무게만 3분의 1이 안 돼요. 접시 위 소금 알갱이도, 아스파라거스 껍질 결도 그대로 보입니다.

 

이걸 정하는 게 사람 몫이었어요. 82라는 숫자는 계산으로 나오는 값이 아니거든요. 더 내리면 용량은 줄지만 어느 지점부터 뭉개지기 시작하고, 더 올리면 원본과 별로 안 줄어듭니다. 그 경계는 사진마다, 보는 사람마다 달라요.

 

그래서 결정할 때 뭘 보고 정했는지를 기록에 남겨뒀습니다.

 

표본 300MB → 84MB(72% 감소), 작은 글씨까지 선명한 것을 확인했다.

 

숫자만 적으면 나중에 "그때 뭘 보고 정한 거지?"가 안 남거든요. "작은 글씨까지 선명"이 제 판정 기준이었습니다. 사진에서 제일 먼저 티가 나는 게 글씨라서요.

 

900픽셀 사진이 700픽셀 자리에 들어갑니다

 

여기서 이 편의 각도가 나옵니다.

 

제 사이트 본문 폭은 CSS에 이렇게 잡혀 있어요.

 

article{max-width:44em}
article p{font-size:15.5px}

 

44 곱하기 15.5는 682. 본문이 700픽셀 남짓이라는 뜻입니다. 사진도 그 폭에 맞춰 줄여서 보여줘요.

 

그런데 내려받는 건 900픽셀짜리입니다. 보이는 크기와 내려받는 크기가 다른 거죠. 브라우저는 900픽셀 사진을 다 받은 다음에 700픽셀로 축소해서 화면에 그립니다. 이건 화면 어디에도 안 적혀 있어요. 아무도 안 알려줍니다.

 

 

그럼 900을 700으로 더 줄이면 되지 않냐고요? 그러면 고해상도 화면에서 뭉개집니다. 요즘 노트북이나 폰은 화면 점 하나를 실제 점 두 개로 그리거든요. 700픽셀 자리에 700픽셀 사진을 넣으면 딱 절반 해상도로 보이는 셈이에요. 900픽셀은 그 사이의 타협점이고, 마침 네이버가 준 크기가 거기 맞았습니다.

 

사진 94장짜리 글 하나가 33.5MB였어요

 

제일 무거운 글을 찾아봤습니다.

 

94장  33.5MB → 8.8MB   (2024-10 · 테판야끼 리뷰)
57장  29.3MB → 6.3MB   (2025-04 · 전시회 방문기)
32장  19.8MB → 5.8MB   (2024-04 · 마우스 부품 교체기)
54장   9.3MB → 5.0MB   (2025-01 · 타건샵 방문기)

 

94장짜리 글이 있었어요. 오마카세라 코스가 나올 때마다 찍었더니 그렇게 됐습니다. 원본 그대로 올렸으면 이 글 하나를 여는 데 33.5MB를 받아야 했어요.

 

장수와 용량이 꼭 같이 가지도 않습니다. 54장짜리 타건샵 글은 9.3MB인데, 32장짜리 마우스 글이 19.8MB예요. 어둡게 찍은 사진, 결이 복잡한 사진이 훨씬 무겁거든요.

 

57장짜리 전시회 글은 이렇게 생겼습니다. 29.3MB가 6.3MB가 됐어요.

 

 

폰 화면에서 보면 사진 한 장이 화면을 거의 다 채웁니다.

 

 

물론 한 번에 다 받지는 않습니다. 사진 태그에 이 표시를 붙여뒀거든요.

 

<img src="..." loading="lazy">

 

"화면에 보일 때까지 받지 마"라는 뜻이에요. 위쪽 몇 장만 먼저 받고, 스크롤을 내리는 만큼 이어서 받습니다. 그래도 끝까지 읽으면 결국 다 받아요. 33.5MB가 8.8MB가 되는 건 그래서 의미가 있습니다.

 

제 맥에서는 이 차이가 안 느껴집니다. 파일이 바로 옆에 있으니까요. 셀룰러로 붙은 폰에서야 갈리는 값이에요. 그런데 저는 만드는 내내 제 맥에서만 봤습니다.

 

손대지 않기로 한 것도 있습니다

 

전부 다시 굽지는 않아요. 세 가지를 일부러 비켜갑니다.

 

하나, 움직이는 그림(GIF) 216장. 코드에서 .jpg, .jpeg, .png만 다시 굽고 나머지는 그냥 복사해요. GIF를 JPEG로 구우면 첫 장면 하나만 남고 움직임이 죽습니다. 216장을 합쳐도 0.1MB밖에 안 되니 줄일 이유도 없고요.

 

둘, 투명한 배경이 있는 PNG 20장. 이건 JPEG로 바꾸면 투명한 자리가 검게 찹니다. 그래서 PNG인 채로 표만 다듬어요. 5.1MB가 4.1MB가 됐습니다. 크게 안 줄었지만 안 깨졌어요.

 

셋, 굽다가 실패한 사진. 파일이 깨져 있거나 알 수 없는 형식이면 굽기를 포기하고 원본을 그대로 복사합니다.

 

except Exception:
    done = False
if not done:
    shutil.copy2(f, d)

 

용량이 안 줄 뿐이지 사진은 뜹니다. 저는 이쪽이 맞다고 봤어요. "줄이려다 못 뜨는 것"보다 "안 줄었지만 뜨는 것"이 낫거든요.

 

그리고 아직 안 고친 것도 하나 있어요. 글 목록의 작은 썸네일 말인데요.

 

 

이 썸네일은 화면에서 104×78픽셀입니다. 손톱만 하죠. 그런데 거기 들어가는 파일은 본문에 쓰는 900픽셀짜리 그대로예요. 작은 판을 따로 안 만들었습니다. 한 장에 100KB 안팎이니 목록을 조금만 내려도 1MB쯤은 그냥 받는 셈이에요.

 

이건 알면서 안 고쳤습니다. 줄인 사본이라 그럭저럭 버틸 만하거든요. 원본 그대로였으면 목록 화면부터 무거웠을 겁니다.

 

두 번 굽지는 않습니다

 

3,116장을 매번 다시 굽는다면 배포 한 번에 몇 분이 날아갑니다. 그래서 이렇게 걸러요.

 

if d.exists() and d.stat().st_mtime >= f.stat().st_mtime:
    kept += 1
    continue

 

"만들어둔 사본이 원본보다 나중에 만들어졌으면 건너뛴다"는 뜻입니다. 원본을 안 건드렸으면 사본도 그대로 두는 거죠. 새로 들어온 사진만 굽습니다.

 

그리고 사진 주소 뒤에 지문을 붙여둡니다.

 

/img/223613912641/75.jpg?v=41f97c5919

 

뒤의 열 자리는 그 파일 내용을 요약한 값이에요. 사진을 바꾸면 이 값이 바뀌고, 주소가 달라지니까 브라우저가 캐시를 무시하고 새로 받습니다. 안 바꾸면 값도 그대로라 계속 캐시를 씁니다. 사진 하나 고쳤는데 옛날 게 계속 뜨는 짜증을 이 열 자리가 막아줘요.

 

없는 사진을 세 번씩 물어보고 있었습니다

 

사진을 긁어오던 첫날 밤 이야기도 하나 하고 넘어갈게요.

 

수집기는 요청이 실패하면 1.5초, 3초, 4.5초를 쉬면서 세 번까지 다시 물어보게 돼 있었어요. 네트워크가 잠깐 흔들리는 건 그렇게 넘기는 게 맞으니까요.

 

문제는 네이버에서 이미 지워진 사진이었습니다. 없는 걸 세 번 물어봐도 없어요. 그런데 코드는 예외를 구분 안 하고 전부 똑같이 재시도하고 있었습니다.

 

except urllib.error.HTTPError as e:
    if e.code in (403, 404, 410):    # 원본이 없거나 막힘 - 재시도해도 소용없다
        raise

 

7월 23일 아침 8시 1분, "없다"고 하면 바로 포기하도록 바꿨어요. 그 커밋에 그날 기록도 같이 남겼습니다.

 

이미지 3115개 저장 · 건너뜀 0 · 실패 9

 

3,115장을 받고, 못 받은 게 9장. 그 9장은 네이버 쪽에서 원본이 없어진 사진이었어요.

 

고치기 전에는 이 9장 때문에 요청을 27번 보내고, 한 장마다 4.5초씩 기다리고 있었습니다. 크게 손해 본 시간은 아니에요. 다만 없는 걸 세 번 물어보는 코드는 사진 3,000장에서 40초지만 3만 장이면 몇 분이 되죠. 규모가 커질 때 조용히 커지는 종류의 낭비라 그때 잡았습니다.

 

응답은 오는데 사진이 안 떴습니다

 

한글 폴더 이름 때문에 사진이 안 뜨는 일이 있었습니다. '한'이라는 글자를 통으로 저장하는 방식과 'ㅎㅏㄴ'으로 쪼개 저장하는 방식이 있는데, 맥은 뒤쪽으로 저장하고 웹은 앞쪽으로 요청해서 생긴 일이었어요. 그래서 지금은 폴더 이름을 만들 때 앞쪽 방식으로 강제합니다(build.py 의 이미지 복사 자리에 그 한 줄이 있습니다).

 

그때 제일 헷갈렸던 게 이거였어요. 주소를 직접 확인하면 200, 즉 "정상"이 떴습니다. 없는 주소면 없다고 나와야 하는데요.

 

이번에 그 이유를 찾았습니다. 당시 제 사이트에는 404 페이지가 없었어요. 404 페이지가 없으면 호스팅은 못 찾은 주소에 홈페이지를 대신 내줍니다. 그것도 "정상"이라는 딱지를 붙여서요. /아무거나12345를 쳐도 홈페이지가 나왔습니다.

 

그러니까 사진 주소로 온 요청에 HTML 한 덩어리가 돌아가고 있었던 거예요. 브라우저는 "사진 달라고 했는데 웬 문서가 왔지" 하고 그리기를 포기합니다. 응답은 왔으니 실패도 아니고, 그냥 조용히 빈 자리만 남죠.

 

7월 28일에 404 페이지를 만들어 넣었습니다. 지금 다시 해 보면 이렇게 갈립니다. 쪼개서 쓴 주소로 요청하면요.

 

 

없는 주소라고 제대로 나옵니다. 통으로 쓴 주소로 요청하면 사진이 그대로 뜨고요. 3편 사진도 지금은 멀쩡합니다.

 

 

고친 건 복사할 때 한 줄이에요.

 

rel = unicodedata.normalize("NFC", str(f.relative_to(src)))

 

"어느 쪽으로 저장돼 있든 통으로 쓴 이름으로 맞춰서 복사해라." 이 한 줄이 없으면, 제목에 한글이 들어간 글은 언제든 같은 일을 당할 수 있습니다. 폴더 이름을 눈으로 아무리 비교해도 안 보이고요.

 

원본은 지우지 않았습니다

 

여기까지가 사진을 줄인 이야기인데, 정작 제일 오래 고민한 건 어디에 만들 것인가였어요.

 

줄인 사진은 전부 새 폴더에 만듭니다. 원본 3,116장은 손대지 않고 그대로 둡니다. 그러니까 제 컴퓨터에는 1,063MB짜리 한 벌과 315MB짜리 한 벌이 같이 있어요. 1.4GB를 쓰고 있는 셈입니다.

 

낭비처럼 보이지만 이유가 있어요. 줄이는 건 되돌릴 수 없습니다. 품질 82로 구운 사진에서 원래 화질을 되살릴 방법은 없어요. 만약 원본 자리에 덮어썼는데 품질 값을 잘못 골랐다면, 그 순간 3,116장이 영영 그 상태가 됩니다.

 

20편에서 사진 정리 봇 이야기를 하면서 같은 규칙을 적었죠. 원본은 안 건드리고 복사만 한다. 그 도구는 만드는 이틀 동안 두 번이나 잘못된 정리본을 만들었는데, 둘 다 폴더를 지우고 다시 돌리면 끝이었습니다.

 

여기도 똑같아요. 품질 값이 마음에 안 들면 배포 폴더를 통째로 지우고 다시 구우면 됩니다. 되돌릴 수 없는 일은, 되돌릴 수 있는 쪽에서만 합니다.

 

 

그래서 사람은 어디에 있었나

 

이번 편은 좀 이상한 편입니다. 사람이 잡아낸 게 코드 오류가 아니라 제 설명이었거든요.

 

첫째, 숫자가 맞으면 아무도 안 봅니다. 1,063MB가 315MB가 됐어요. 목표한 대로 됐고, 사진도 선명하고, 사이트도 빨라졌습니다. 이 상태에서 "그런데 정확히 뭐가 줄인 거지?"를 물어볼 이유가 없어요. 저는 한 달 내내 안 물어봤고, 그동안 두 편의 글에 틀린 설명을 적었습니다. 재 보고 나서야 알았어요.

 

둘째, AI는 자기가 쓴 규칙이 안 도는 걸 모릅니다. 이 연재에서 계속 나오는 패턴이에요. 도구는 시킨 대로 정확히 돌았습니다. if width > 1400 은 정확히 판단했고, 조건에 안 맞으니 안 돌았고, 그건 오류가 아니에요. 다만 "그 조건에 걸리는 게 하나도 없다"고 알려주지는 않습니다. 세어 보라고 시킨 적이 없으니까요.

 

셋째, 두 개의 코드를 붙여놓고 사이를 안 봤습니다. 수집기는 966픽셀로 받아오고, 빌드는 1400픽셀로 줄이고. 각각은 완벽하게 맞는 코드예요. 문제는 같은 날 밤 십몇 분 차이로 만든 두 코드가 서로를 모른다는 거였습니다. 이건 파일 안을 아무리 들여다봐도 안 보여요. 잘못된 자리가 파일 안이 아니라 두 파일 사이였으니까요.

 

넷째, 내 컴퓨터에서 빠른 것과 남의 폰에서 빠른 것은 다릅니다. 이건 코드가 알려줄 수 없는 종류의 값이에요. 로컬에서만 확인하면 33.5MB짜리 글도 순식간에 뜹니다. 셀룰러로 붙은 화면을 떠올리는 건 사람이 해야 했어요.

 

다섯째, 품질 82를 고른 것. 계산으로 안 나오는 값입니다. "작은 글씨까지 선명한가"를 눈으로 보고 정했고, 그 기준을 기록에 적어뒀어요. 이런 값은 앞으로도 계속 사람이 정할 것 같습니다.

 

 

정리하면 이렇습니다. 1,063MB가 315MB가 된 건 맞습니다. 다만 폭을 줄여서가 아니라 화질을 조금 내려서였고, 폭은 애초에 네이버가 줄여서 준 것이었어요. 코드는 아무것도 안 틀렸고, 틀린 건 제가 그걸 이해한 방식이었습니다.

 

이번 글에서 3편과 17편의 설명을 고쳤으니, 이제 이 연재에서 그 부분은 맞는 얘기가 됐네요.

 

다음 편에

 

여기까지가 "가진 걸 전부 올려야 하는" 사이트에서 제일 무거운 짐을 덜어낸 이야기였어요.

 

그런데 정적 사이트에는 아직 안 풀린 게 하나 남아 있습니다. 돌아가는 서버가 없는데 댓글은 어떻게 받나요? 누가 글을 쓰면 그걸 어딘가에 저장해야 하는데, 저장할 데가 없잖아요. 다음 편은 그 이야기입니다. 로그인 없이 댓글을 받으면서 남의 댓글은 못 건드리게 만드는, 생각보다 까다로웠던 문제예요.

 

#이미지최적화 #사진용량줄이기 #JPEG압축 #이미지압축 #웹사이트속도 #페이지속도 #사이트최적화 #정적사이트 #클라우드플레어페이지스 #네이버블로그이관 #블로그이사 #파이썬 #Pillow #PIL #이미지리사이즈 #레이지로딩 #캐시버스팅 #유니코드 #NFC #한글파일명 #404페이지 #웹개발 #개인홈페이지 #홈페이지만들기 #바이브코딩 #AI코딩 #클로드 #휴먼인더루프 #모바일최적화 #연재

댓글

    로그인 없이 쓰는 대신, 등록한 댓글은 직접 지울 수 없습니다. 지워야 할 댓글은 옆의 신고를 눌러주세요.
    네이버 블로그글 원문 보러가기 →유튜브 쇼츠만드는 장면 20초로 보기 →
    관리자 Victor · 문의는 우상단 신고·제안으로 남겨주세요.
    글과 화면은 정적 파일로 만듭니다. 댓글과 조회수만 외부 데이터베이스에 저장합니다 — 개인정보처리방침

    오류 신고 · 제안

    관리자 Victor에게 전달됩니다