웹 성능 측정 — curl·sips·Python 만으로 TTFB·페이지 무게·이미지 audit 다 잡기

결론 먼저: Lighthouse·WebPageTest 띄우지 않아도 페이지 속도·무게·이미지 비대를 30 줄 스크립트로 정량 측정 가능. CI 에 박아두면 회귀 자동 잡힘.

왜 직접 측정해야 하나

브라우저 직감으로 "느린 거 같아…" 라고 끝나면 fix 가 우선순위 안 잡힘. 다음 세 가지를 숫자로 잡아야 함:

  1. TTFB (Time To First Byte) — 서버가 응답 시작까지. 인프라·CDN·SSR 지표.
  2. 페이지 총 무게 — HTML + JS + CSS + 이미지 합산. LCP·INP 의 원료.
  3. 이미지 가중 — 거의 모든 느린 사이트의 진짜 범인. 카드 200×200 표시인데 원본 1024×1024 다운로드 식.

Lighthouse 는 종합 지표 좋지만 로컬 환경 ↔ 네트워크 가변성 때문에 매번 다른 점수. curl + sips 기반 측정은 결정론적·ms 단위 정확·cron·CI 자동화 가능. Lighthouse 는 PR 마지막 점검용, 이쪽은 일상 audit.

도구 — 다 macOS·Linux 기본 또는 표준

도구 설치 용도
curl macOS·Linux 기본 TTFB·total·전송 속도·페이지 size
sips macOS 기본 (없으면 identify from ImageMagick) 이미지 해상도·bytes
python3 macOS·Linux 기본 HTML 파싱·이미지 src 추출·집계

별도 설치 없음.


Part 1 — curl -w 로 TTFB·total·size

핵심: -w (write-out) format string

curl -w 는 요청 끝나면 timing·response 정보를 templated string 으로 출력. 7 개 변수가 핵심:

curl -w "
  DNS:        %{time_namelookup}s
  Connect:    %{time_connect}s
  TLS:        %{time_appconnect}s
  TTFB:       %{time_starttransfer}s
  Total:      %{time_total}s
  Size:       %{size_download} bytes
  Speed:      %{speed_download} bytes/s
" -o /tmp/out.html -s "https://taystudios.com/blog/ko/"

-o /tmp/out.html 으로 body 를 파일에 저장, -s 로 progress bar 끔.

변수 의미

변수 무엇을 측정
time_namelookup DNS 해석 끝까지
time_connect TCP 핸드셰이크 끝까지 (namelookup 포함)
time_appconnect TLS 핸드셰이크 끝까지 (HTTPS only)
time_pretransfer 요청 보내기 직전 (협상 완료)
time_starttransfer TTFB — 응답 첫 바이트 받은 시점
time_total 응답 body 완전히 받은 시점
size_download 받은 body 바이트 (응답 본문 size)
speed_download 평균 다운로드 속도 (bytes/s)

각 값은 누적이라 단계별 시간 = 다음 단계 - 이전 단계:

DNS:    time_namelookup
TCP:    time_connect - time_namelookup
TLS:    time_appconnect - time_connect
서버:   time_starttransfer - time_appconnect   ← 실제 서버 처리 + 응답 시작
전송:   time_total - time_starttransfer        ← body 전송

실측 예시 (taystudios.com/blog/ko/)

  DNS:        0.014286s     ← 14ms DNS
  Connect:    0.048982s     ← 35ms TCP
  TLS:        0.089744s     ← 41ms TLS
  TTFB:       0.337152s     ← 247ms 서버 응답 (GitHub Pages CDN)
  Total:      0.337994s     ← 0.8ms body 전송
  Size:       21702 bytes   ← HTML 22KB

해석: TTFB 247ms 가 시간의 73% — CDN edge 응답. 개선 여지는 같은 edge 에서 caching warm-up. HTML 자체 22KB body 는 0.8ms — 무시 가능.

캐시 우회 — 매번 fresh 측정

CDN·브라우저 캐시 때문에 두 번째 요청은 빨라짐. baseline 측정엔 매번 cache miss 강제:

# Cache busting: URL 에 timestamp 쿼리
curl -s "https://taystudios.com/blog/ko/?ts=$(date +%s)"

# 또는 Cache-Control 헤더로 명시
curl -s -H "Cache-Control: no-cache" "https://taystudios.com/blog/ko/"

# 둘 다 — 가장 안전
curl -s -H "Cache-Control: no-cache" "https://taystudios.com/blog/ko/?ts=$(date +%s)"

?ts= 같은 dummy query 는 CDN 캐시 키를 바꿔 새 응답 강제. Cache-Control 헤더는 상위 proxy 에 신호.


Part 2 — sips 로 이미지 해상도·bytes

sips 는 macOS 기본 image 도구. 1 줄로 해상도 query:

sips -g pixelWidth -g pixelHeight cover.jpg

# 출력:
#   /path/cover.jpg
#     pixelWidth: 1024
#     pixelHeight: 1024

bytes 는 stat:

stat -f %z cover.jpg          # macOS
stat -c %s cover.jpg          # Linux

한 폴더 전체 audit (Python)

import subprocess
from pathlib import Path

for f in sorted(Path('blog/assets/posts').rglob('cover.*')):
    sz = f.stat().st_size
    out = subprocess.check_output(
        ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', str(f)],
        stderr=subprocess.DEVNULL
    ).decode()
    dims = [l.split(':')[1].strip() for l in out.split('\n') if 'pixel' in l]
    w, h = dims[0], dims[1]
    flag = '🔴' if sz > 200_000 else '🟡' if sz > 50_000 else '✓'
    print(f'{sz/1024:>7.1f}KB  {w}x{h}  {flag}  {f}')

실측 (이 블로그 audit 결과 일부)

416.5KB  1332x2088  🔴  aws-summit-2024/cover.jpg     ← og:image 비례 X
375.0KB  1024x1024  🔴  hypothesis-testing/cover.jpg
375.0KB  1024x1024  🔴  uncertainty-variance/cover.jpg
375.0KB  1024x1024  🔴  t-test/cover.jpg
  4.5KB   200x200   ✓  career-ktds-newgrad-2021/cover.jpg    ← 회사 로고
  4.1KB   225x225   ✓  kaist-grad-mech-written-set1/cover.png

진단: 통계 시리즈 7편 모두 1024×1024 / 375KB — 카드 썸네일은 200×200 만 표시되는데 25배 큰 원본 다운로드. 즉시 fix 대상.


Part 3 — 페이지별 이미지 가중 (HTML 파싱 + 집계)

curl -s URL 로 받은 HTML 에서 <img src> 다 뽑아 총합:

import re
from pathlib import Path

# 빌드된 HTML 에서 페이지별 cover 총합
for page in ['blog/ko/index.html', 'blog/ko/category/data/index.html']:
    h = open(page).read()
    imgs = re.findall(r'src="([^"]*cover\.[a-z]+)"', h)
    total = 0
    for src in imgs:
        f = Path('blog') / src
        if f.exists():
            total += f.stat().st_size
    print(f'{page}: {len(imgs)} covers · {total/1024:.1f} KB')

실측 (현 블로그)

페이지 covers total
/blog/ko/ (홈 첫 페이지 10글) 10 1524 KB
/blog/ko/category/data/ (통계 7편) 7 2625 KB 🔴
/blog/ko/category/reviews/career/ (회사 로고 6글) 6 250 KB ✓

통계 카테고리만 따로 2.6 MB. 모바일 4G 환경 (~50Mbps) 에서 0.4 초 추가 다운로드 = LCP 직격.

💡 HTML 자체는 빠른데 (TTFB 247ms) 이미지 때문에 체감 느림 같은 케이스를 1 분 안에 진단할 수 있는 게 이 방법의 핵심 가치.


Part 4 — 전체 페이지·자원 합산 (live URL 기준)

빌드된 HTML 이 없는 환경 (live URL audit) 에선 curl 로 받아서 자원 URL 추출 → 각자 curl 로 size 측정:

#!/bin/bash
URL="https://taystudios.com/blog/ko/"
TMPDIR=$(mktemp -d)

# 1. HTML 받기
curl -s "$URL?ts=$(date +%s)" -o "$TMPDIR/page.html"
HTML_SIZE=$(stat -f %z "$TMPDIR/page.html")

# 2. 모든 이미지·JS·CSS URL 추출 (절대화)
python3 -c "
import re, sys
from urllib.parse import urljoin
base = '$URL'
html = open('$TMPDIR/page.html').read()
patterns = [
    r'<img[^>]+src=[\"\']([^\"\']+)[\"\']',
    r'<script[^>]+src=[\"\']([^\"\']+)[\"\']',
    r'<link[^>]+href=[\"\']([^\"\']+)[\"\'][^>]+rel=[\"\']stylesheet[\"\']',
]
for p in patterns:
    for m in re.findall(p, html):
        if m.startswith(('http','//')):
            print(m if not m.startswith('//') else 'https:'+m)
        else:
            print(urljoin(base, m))
" > "$TMPDIR/urls.txt"

# 3. 각 자원 size 측정
TOTAL=$HTML_SIZE
while read u; do
  sz=$(curl -o /dev/null -s -w "%{size_download}" "$u")
  echo "$sz $u"
  TOTAL=$((TOTAL + sz))
done < "$TMPDIR/urls.txt"

echo
echo "HTML:  $((HTML_SIZE / 1024)) KB"
echo "Total: $((TOTAL / 1024)) KB ($(echo "scale=2; $TOTAL/1048576" | bc) MB)"

CI 환경에서 매 PR 마다 돌리면 페이지 무게 회귀를 정량 잡음.


Part 5 — CWV 와 연결

측정값 CWV 메트릭 임계
TTFB (FCP 의 일부) 200ms 이하 = good, 600ms+ = poor
페이지 무게 LCP (Largest Contentful Paint) 2.5s 이하 = good (4G/모바일 기준)
이미지 가중 LCP·INP 둘 다 LCP 의 70% 가 이미지인 사이트가 흔함

대략적 환산 (3G slow 시뮬): - HTML 50KB + 이미지 500KB = ~3s LCP (good) - HTML 50KB + 이미지 2MB = ~8s LCP (poor)

이미지 무게가 LCP 의 1차 변수. 이게 이 글의 audit 방법론이 강조하는 이유.


Part 6 — Lighthouse 와 비교

curl + sips audit Lighthouse
측정 종류 정량 (bytes, ms) 종합 점수 (0-100)
환경 결정론적 (네트워크만 가변) 가변 (CPU·throttling 시뮬레이션)
속도 < 5 초 30~60 초/run
CI 적합 ✓ 자동화 쉬움 △ headless Chrome 필요
발견 가능 무거운 자원·TTFB + JS execution·CLS·a11y·SEO 점수

병행 사용: - 일상 audit·CI 회귀 잡기 → curl + sips - 정기 큰 진단 (월/분기) → Lighthouse + PageSpeed Insights


CI 자동화 — bash 한 줄로

# .github/workflows/perf-check.yml 안에 추가
- name: Cover image size guard
  run: |
    fail=0
    for f in blog/assets/posts/*/cover.*; do
      sz=$(stat -c %s "$f")
      if [ "$sz" -gt 100000 ]; then
        echo "::error::$f is $((sz/1024))KB (>100KB) — optimize"
        fail=1
      fi
    done
    exit $fail

PR 에서 cover 가 100KB 넘으면 CI 빨강. 직접 손 안 대도 회귀 차단.


정리

단계 도구 1 줄
TTFB·페이지 시간 curl -w curl -w "%{time_starttransfer}s\n" -o /dev/null -s URL
이미지 audit sips + find + stat find . -name cover.\* -exec sips -g pixelWidth -g pixelHeight {} +
페이지별 가중 Python re.findall HTML → <img src> 모음 → 각 file size sum
CI guard bash + stat -c %s PR 마다 자동 size threshold 검증

진정한 효과: 매 배포 전·후 bash perf-baseline.sh 한 번 돌리면 회귀·개선 정량 비교. 느낌이 아니라 숫자로 토론 가능.


📝 이 글의 실제 측정 데이터는 본 블로그 (taystudios.com/blog/) 의 cover 이미지 audit 결과입니다. 결과적으로 통계 시리즈 7편 cover 가 1024×1024 PNG (각 375KB) 인 게 발견됐고, WebP 800×800 로 일괄 변환 → 페이지당 평균 1MB+ 절감 예정.

이 글 공유𝕏f

댓글