CDN을 타는 리포트 이미지는 0개였다 — 지면 368장 이미지 주소 전수

칼라톤에는 이미지 주소에 관한 규약이 한 줄로 적혀 있다. 데이터베이스에는 S3 원본 주소를 저장하고, 화면에 그릴 때 toCdn()이 호스트만 cdn.colortone.kr로 바꾼다. 같은 객체를 가리키는 두 개의 주소를 두고, 저장은 원본 쪽, 전달은 CDN 쪽으로 역할을 나눈 것이다. 이렇게 하면 CDN을 끄고 싶을 때 상수 한 개를 비우면 되고 저장된 데이터는 손댈 필요가 없다.

그 규약에는 조용한 전제가 하나 있다. '화면에 그릴 때'가 반드시 온다는 것. 브라우저에서 자바스크립트가 목록을 받아 카드를 찍는 지면이라면 맞는 전제다. 그런데 칼라톤에는 서버에서 미리 찍혀 그대로 배포되는 정적 지면이 368장 있다 — 공개 시즌 리포트다. 거기에는 치환이 일어날 '렌더 시점'이 없다.

그래서 그 368장이 품은 이미지 주소를 하나씩 세어 봤다. 질문은 하나다. 리포트 이미지는 CDN을 타는가.

호스트 전수 — 1,378개 중 CDN 0개

Frontend/public/reports/ 아래 HTML 368장에서 <img src>를 전부 뽑으면 1,746개다. 호스트별로 가르면 세 번째 칸이 없다.

이미지 주소의 호스트참조 수비율무엇인가
childhood-friend.s3.ap-northeast-2.amazonaws.com1,37878.9%시즌 생성물(인물·유머 이미지)
/images/ct_logo.png(같은 출처 상대주소)36821.1%상단 내비 로고, 지면당 1개
cdn.colortone.kr00.0%—
합1,746100%—

0개라는 말을 더 강하게 적을 수 있다. 368장 전체에서 cdn이라는 글자가 한 번도 나오지 않는다. CSS background-image도, og:image도, <video>의 poster도 없으니 이미지를 끌어오는 경로는 <img> 하나뿐이고, 그 1,378개가 전부 버킷 직주소다. 주소의 모양도 완전히 균일했다 — 호스트 표기 한 가지, 쿼리스트링 0개, 확장자는 PNG 1,376개와 JPG 2개.

왜 0인가 — 치환할 자리가 없다

원인은 금방 나온다. 리포트를 찍는 웹 요약 생성기는 여섯 개 파일인데, 그 여섯 개 전부에서 toCdn이 0회 등장한다. 이미지 태그를 만드는 자리는 셋뿐이고(인물 요약의 대표 이미지와 하이라이트 카드, 유머 요약의 라운드 이미지), 세 자리 모두 데이터베이스에서 읽은 주소를 HTML 이스케이프만 거쳐 그대로 넣는다.

대조군은 분명하다. 같은 저장소의 프런트엔드 자바스크립트에서는 toCdn이 16개 파일 48군데에서 호출된다 — 홈 갤러리, 인물, 유머, 숏폼, 롱폼, 매칭, 로또, 관리, 진지 게임까지. 규약을 모르고 지나친 것이 아니라, 규약이 적용되는 층이 다르다. 저장된 데이터 쪽은 일관되게 S3다. 홈 갤러리가 읽는 정적 스냅샷 data/gallery-feed.json에도 S3 주소가 80개 들어 있고 CDN 주소는 0개다 — 그 지면은 브라우저에서 toCdn을 거치므로 문제가 되지 않는다.

리포트에는 그 단계가 없다. 368장이 불러오는 스크립트는 네 종류뿐인데 — 애널리틱스 인라인, 애널리틱스 로더, JSON-LD 블록, AdSense 로더 — type="module" 스크립트도, js/config.js를 가져오는 줄도 0장이다. 런타임에 호스트를 바꿀 수 있는 코드가 지면에 아예 실려 있지 않다. 그러니 이 0%는 '어쩌다 빠뜨린 몇 장'이 아니라 구조적으로 전부 혹은 전무인 값이다.

생성기 쪽 주석은 규약을 정확히 알고 있었다. 그리드 썸네일 유틸리티에 이렇게 적혀 있다.

썸네일 키 → 공개 S3 URL (DB 에는 원본 S3 호스트로 저장하고, 렌더 시점에 toCdn 이 CDN 으로 치환).

맞는 설명이다. 다만 그 문장이 전제하는 렌더 시점이 리포트에는 오지 않는다.

CDN이 없는 것은 아니다 — 같은 키를 이미 서비스한다

'CDN에 아직 그 파일이 없어서'일 가능성을 먼저 지웠다. 리포트가 참조하는 서로 다른 객체 544개를 S3 쪽에서 전부 조회했더니 544개 전부 200이었고, 세 가지 키 접두(generated/character, generated/humor_owner, humor/studio)마다 하나씩 뽑아 CDN 주소로 다시 물었을 때도 전부 200이었다. 응답 헤더를 나란히 놓으면 차이가 보인다.

같은 객체, 두 주소프로토콜Content-LengthETag캐시
S3 버킷 직주소(리포트가 쓰는 쪽)HTTP/1.17,642,637동일헤더 없음
cdn.colortone.kr 같은 키HTTP/27,642,637동일1회차 Miss → 2회차 Hit from cloudfront

바이트 수도 ETag도 같은, 글자 하나 다르지 않은 같은 객체다. 달라지는 것은 전달 경로뿐이고, 두 번째 요청에서 CloudFront 캐시에 적중했다. 즉 리포트가 지금 쓰는 주소는 '아직 준비되지 않은 길'이 아니라 준비돼 있는데 쓰지 않는 길이다.

그 길로 흘러가는 바이트 — 3,328MB

호스트만 세면 '그래서 뭐'가 된다. 그래서 544개 객체의 실제 크기를 전부 재어 지면별로 합산했다. 참조 1,378개를 중복까지 그대로 합치면 3,328MB, 중복을 제거한 고유 바이트는 1,322MB다.

모듈지면이미지 있는 지면이미지 참조합계 MB지면당 평균 MB
인물 생성3063049922,190.97.21
AI 유머42423861,137.427.08
AI 커플매칭120000
가상 트레이딩40000
마인드게임(홀짝)20000
인덱스 4장40000
합3683461,3783,328.39.62

먼저 눈에 띄는 것은 세 모듈이 0이라는 사실이다. 커플매칭·트레이딩·마인드게임의 공개 요약에는 이미지가 한 장도 없다 — 그 세 모듈의 웹 생성기는 <img>를 만드는 코드 자체가 없고, 지면에 뜨는 그림은 상단 로고뿐이다. 같은 모듈의 PDF 리포트에는 참가자 사진과 라운드 이미지가 들어가므로, 웹 요약과 PDF가 같은 시즌을 다른 밀도로 보여 주고 있는 셈이다.

무게는 두 모듈에 쏠린다. 인물 생성이 전체 바이트의 65.8%, 유머가 34.2%다. 다만 지면 하나를 기준으로 하면 순서가 뒤집힌다 — 유머 지면은 한 장에 평균 27.08MB로 인물(7.21MB)의 3.8배다. 유머 요약이 라운드 이미지를 10장씩 싣고, 그 이미지들이 더 큰 탓이다(평균 2.95MB 대 2.21MB).

지면 단위 분포이미지 수중위 MB최대 MB최소 MB
인물 시즌 리포트 304장2~44.9125.63 (시즌 33)0.97 (시즌 154)
유머 시즌 리포트 42장1·2·1020.3458.93 (시즌 19)5.05 (시즌 38)

가장 무거운 지면은 유머 시즌 19 리포트로, 이미지 10장 합계 58.93MB다. 개별 이미지도 작지 않다 — 544개 중 20개가 6MB를 넘고, 절반 이상(309개, 56.8%)이 2MB 미만이지만 중위값이 1.59MB, 최대가 7.29MB다.

곁가지 하나 — 400px 썸네일은 이미 만들어져 있다

여기서 예상 못 한 것이 나왔다. 칼라톤에는 그리드용 400px JPEG 썸네일 규약이 있다(원본 키 앞에 thumbs/를 붙인다). 유머 이미지 192개의 썸네일 주소를 전부 조회해 보니 192개 전부 존재했다. 인물 이미지는 표본 30개 중 0개였다.

유머 이미지 192개합계 MB중위 크기리포트가 쓰는 쪽
원본 PNG566.7약 2.9MB이쪽
이미 존재하는 400px JPEG 썸네일7.435KB쓰지 않음

같은 그림의 두 판본이 76.9배 차이 난다. 리포트는 큰 쪽을 쓴다. 이게 전부 잘못이라고 말할 수는 없다 — 리포트의 이미지는 갤러리 격자의 축소판이 아니라 본문에서 꽤 크게 보여 주는 그림이고, 400px은 그 자리에 맞지 않을 수 있다. 다만 같은 문제를 PDF 경로에서는 이미 해결해 두었다. PDF 전용 다운스케일 임베더의 머리말이 그 경위를 적고 있다.

원인: 리포트가 원격 S3 원본(1792×2400 ~6MB PNG)을 <img src> 로 그대로 임베드해 시즌당 ~88MB, 최종 PDF 97~192MB 로 비대해졌다. 해결: 렌더될 모든 이미지를 … width≤1440·JPEG q85 로 다운스케일 → data: URI 로 치환한다.

같은 원인 진단이 PDF에서는 코드로 옮겨졌고 웹 요약에서는 옮겨지지 않았다. 웹 쪽이 그 사이에 한 일은 지연 로딩이다 — 원격 이미지 1,378개 전부가 loading="lazy"를 달고 있다(예외 0개). 덕분에 화면 밖 이미지는 즉시 내려오지 않는다. 대신 width·height 속성을 가진 이미지는 0개라 레이아웃이 뒤늦게 밀리는 쪽은 남아 있다.

시기별로는 가벼워지는 중이다

호스트는 시기와 무관하게 전부 S3였지만, 무게는 달라졌다. 인물 리포트를 시즌 순서로 넷으로 나누면 지면당 바이트가 계단처럼 내려온다.

인물 시즌 구간(국문 152편)편수지면당 이미지지면당 평균 MB중위 MB
시즌 1~67383.6313.0013.79
시즌 69~127383.666.464.49
시즌 128~166382.923.513.25
시즌 167~204382.845.855.29

첫 구간의 13.00MB가 세 번째 구간에서 3.51MB까지, 3.7분의 1로 줄었다. 이미지 한 장의 평균 크기도 초기 20시즌 3.83MB에서 최근 20시즌 2.14MB로 내려왔다. 이미지 생성 엔진이 여러 번 바뀐 결과이지 리포트 쪽에서 무엇을 조절한 것은 아니다. 네 번째 구간에서 다시 5.85MB로 올라간 것도 같은 이유로 읽어야 한다 — 리포트는 엔진이 올려 준 파일을 그대로 가리킬 뿐이다.

중복 참조의 모양은 깔끔했다. 544개 객체는 정확히 2회 또는 4회씩만 등장한다(2회 399개, 4회 145개). 2회는 국문·영문 짝이고, 4회는 한 지면에서 대표 이미지와 하이라이트 카드에 같은 그림이 두 번 쓰인 경우의 국·영문 짝이다. 참조 수가 고유 객체 수의 2.52배이므로, 같은 파일이 평균 두 번 반 불려 간다.

한계 — 이 분석이 말할 수 없는 것

첫째, 0%는 독립 측정이 아니다. 치환은 브라우저 렌더 층에서 일어나고 이 368장에는 그 층이 없다. 그래서 '어느 지면이 규약을 지켰는가'를 지면별로 비교할 여지가 애초에 없고, 전부 S3라는 결과는 사실상 한 가지 구조의 다른 표현이다. 이 수치를 '지켜지는 비율'로 읽으면 틀린다.

둘째, 3,328MB는 독자가 내려받는 양이 아니다. 참조된 객체의 Content-Length를 합친 값이고, 1,378개 전부가 지연 로딩이므로 실제로는 화면에 들어온 이미지만 내려온다. 브라우저 캐시·뷰포트·스크롤 깊이에 따라 실제 전송량은 이보다 작다. 국문·영문 중복도 한 독자 기준으로는 한쪽만 센다.

셋째, 속도를 재지 않았다. 측정한 것은 호스트·프로토콜·바이트·캐시 적중 여부이고, 한국 독자의 실제 응답 시간·첫 바이트 지연은 전혀 재지 않았다. CloudFront 경유가 더 빠를 것이라는 통상적 기대는 이 데이터로 증명되지 않는다. 이 글의 조회는 저장소 환경에서 나간 요청이고, 관측된 엣지도 한국이 아니었다.

넷째, 비용도 재지 않았다. S3 직접 전송과 CloudFront 전송의 과금 차이, 캐시 적중률에 따른 실제 절감액은 청구서에서만 나온다. 경로를 바꾸는 것이 돈을 아낀다는 서술은 이 집계에 근거가 없다.

다섯째, 썸네일 쪽 숫자는 비대칭이다. 유머는 192개 전수를 확인했지만 인물은 표본 30개만 보았다. '인물 이미지에는 썸네일이 없다'가 아니라 '표본 30개에서는 0개였다'가 정확한 서술이다. 또한 76.9배라는 절감 폭은 400px 판본을 그 자리에 써도 된다는 전제에서만 의미가 있고, 그 판단은 화면 디자인 쪽 문제다 — 이 글은 측정만 했다.

여섯째, 같은 객체가 다른 지면에서 CDN으로 서비스되고 있는지는 확인하지 못했다. 홈 갤러리에 공개된 자산이라면 같은 그림이 한쪽에서는 CDN, 다른 쪽에서는 S3로 두 번 전달되고 캐시도 두 벌이 된다. 어느 객체가 갤러리에 올라 있는지는 데이터베이스 쪽 정보라 공개 지면만으로는 갈라낼 수 없다.

일곱째, 수치는 2026년 10월 30일 기준이다. 리포트는 매일 늘고 늘어나는 쪽은 거의 전부 인물 모듈이므로, 합계 바이트와 모듈 비중은 다음 집계에서 달라진다.

여덟째, 성인 시즌 리포트는 공개 대상이 아니라 368장에 들어 있지 않다. 이 글은 공개 지면의 전량을 셌을 뿐 제작된 시즌의 전량을 센 것이 아니다.

그래서 무엇을 할 것인가

이번 점검에서 '틀린 주소'는 나오지 않았다. 1,378개 전부가 실제로 존재하는 객체를 가리키고, 호스트 표기도 한 가지로 균일하고, 깨진 링크는 0개다. 문제로 남는 것은 같은 파일로 가는 더 나은 길이 이미 열려 있는데 정적 지면만 그 길을 모른다는 한 가지다.

그 간격은 코드로 보면 작다. 웹 요약 생성기의 이미지 태그 생산 지점은 셋이고, 거기서 주소를 한 번 거르면 368장이 전부 따라온다. 지금은 프런트엔드에만 있는 치환 규약이 리포트 생성기에도 필요하다는 뜻이고, 규약의 정본이 한 곳이어야 하는 자리이기도 하다 — 사본이 늘면 CDN_HOST를 비워 되돌리는 킬스위치가 한쪽에만 듣게 된다.

무게 쪽은 더 조심할 일이다. 유머 지면의 27MB는 지연 로딩이 가려 주는 양이 크기 때문에, 먼저 재야 할 것은 실제 전송량이고 그다음이 판본 선택이다. 다만 한 가지는 지금 결정할 수 있다 — width·height 속성이 0개라는 사실이다. 이건 바이트와 무관하게 레이아웃이 밀리는 문제이고, 이미지 비율을 이미 알고 있는 생성기가 두 글자를 더 적어 주면 끝난다.

마지막으로, 커플매칭·트레이딩·마인드게임 요약의 이미지 0개는 이 집계가 꺼낸 별개의 질문이다. PDF에는 있고 웹에는 없는 그림이 독자에게 어떤 차이를 만드는지는 바이트가 아니라 지면을 봐야 알 수 있다.

집계 기준. 2026년 10월 30일(KST) 기준 저장소 Frontend/public/reports/의 HTML 368장 — 공개 시즌 리포트 364개(국문 182 + 영문 182: 인물 생성 152 · 유머 21 · 커플매칭 6 · 트레이딩 2 · 마인드게임 1과 그 영문판)와 인덱스 4장 — 을 전수 파싱했다. 이미지 참조는 각 지면의 <img> 태그에서 src 속성을 뽑아 세었고(총 1,746개), 호스트는 주소의 스킴·도메인까지만 잘라 분류했다. CSS url()·og:image·<video>·이미지 파일을 가리키는 <a href>는 368장에서 0건이라 집계에 포함할 것이 없었다. 바이트 크기는 고유 주소 544개에 HTTP HEAD 요청을 보내 Content-Length를 읽었고, 서로 다른 두 차례의 조회에서 544개 전부 값이 일치한 것만 채택했다(1차 조회에서 한 객체가 0바이트로 읽혔으나 재조회에서 5,786,083바이트로 확인돼 측정 오류로 처리했다). 지면별 합계는 그 지면의 참조를 중복까지 그대로 더한 값이고, '고유 바이트'는 주소 단위로 중복을 제거한 값이다. 1MB는 1,048,576바이트로 계산했다. 썸네일 존재 여부는 원본 키의 확장자를 떼고 thumbs/ 접두와 .jpg를 붙인 주소의 응답 코드로 판정했다(유머 192개 전수, 인물 무작위 표본 30개). CDN 비교는 같은 키를 cdn.colortone.kr로 조회해 ETag·Content-Length·x-cache를 대조했다. toCdn 호출 수는 Frontend/public/js/ 전체에서 임포트 구문과 정의부를 제외하고 센 값이며, 생성기 쪽 0회는 html2pdf/src/web-*-summary-builder.js 6개 파일 기준이다. 시즌 구간 평균은 국문 리포트만(영문은 같은 주소를 쓰므로 중복) 시즌 번호 순으로 38편씩 나눠 계산했다. 비율은 소수점 아래 첫째 자리에서 반올림했다. 성인 시즌 리포트는 공개 대상이 아니므로 집계에 포함되지 않는다.