- 발행일
디자인 회귀를 잡으려고 페이지 자동 캡쳐 스크립트를 만들었다
디자인 회귀를 잡으려고 페이지 자동 캡쳐 스크립트를 만들었다
Tailwind v3 → v4 마이그레이션을 앞두고 가장 무서웠던 건 "시각적으로 미세하게 어긋나는데 코드는 멀쩡한 회귀" 였습니다. 그래서 메이저 작업에 들어가기 전에 페이지 자동 캡쳐 스크립트를 먼저 만들었어요.
1. 동기 — 침묵 깨짐을 어떻게 잡을 것인가
CSS 라이브러리 메이저 업그레이드 때 가장 흔한 회귀가 클래스가 침묵 깨지는 경우입니다. 클래스 이름은 그대로지만 생성되는 규칙이 미묘하게 달라서 화면이 조금 어색해지는 거예요. 빌드 에러도 없고, 콘솔 경고도 없고, 테스트도 통과합니다.
이런 회귀를 잡는 방법은 정해져 있어요. 변경 전/후 스크린샷을 비교. 다만 사람이 매번 페이지를 돌며 캡쳐하는 게 비현실적이라, 자동화가 필요했습니다.
2. 도구 선정
세 가지 후보를 비교했어요.
| 도구 | 장점 | 단점 |
|---|---|---|
| Selenium | 익숙함 | 셋업이 무겁고 최근 트렌드와 멀어짐 |
| Puppeteer | Chromium 한정인데 빠름 | 로그인 세션 처리에 추가 코드 필요 |
| Playwright | 멀티 브라우저, 세션 관리/대기 기본 제공 | 처음 셋업 학습 비용 |
Playwright로 갔습니다. 가장 큰 이유는 "동적 콘텐츠가 안정될 때까지 자동 대기" 가 기본 동작이라는 점. 우리 페이지는 jstree 같은 동적 렌더 컴포넌트가 많아서, naive하게 캡쳐하면 트리가 그려지기 전 빈 화면이 찍힙니다.
3. 구현 — scripts/capture_pages.py
핵심은 단순합니다. URL 리스트를 받아 순회하면서 캡쳐하면 끝.
# scripts/capture_pages.py
from playwright.sync_api import sync_playwright
from pathlib import Path
import json
OUT = Path("captures")
OUT.mkdir(exist_ok=True)
with open("scripts/capture_targets.json") as f:
targets = json.load(f)
with sync_playwright() as p:
browser = p.chromium.launch()
ctx = browser.new_context(viewport={"width": 1440, "height": 900})
# 1) 로그인 세션 만들어두기
page = ctx.new_page()
page.goto("http://localhost:8000/login/")
page.fill('input[name="username"]', "admin")
page.fill('input[name="password"]', "***")
page.click('button[type="submit"]')
page.wait_for_url("**/dashboard/**")
# 2) 타겟 페이지 순회
for t in targets:
page.goto(t["url"])
# 동적 콘텐츠 안정화 대기
if t.get("wait_for"):
page.wait_for_selector(t["wait_for"])
page.wait_for_load_state("networkidle")
page.screenshot(path=OUT / f"{t['name']}.png", full_page=True)
print(f"✓ {t['name']}")
browser.close()
URL 리스트는 별도 JSON으로 둡니다.
[
{ "name": "dashboard", "url": "http://localhost:8000/dashboard/" },
{ "name": "metaclass-list", "url": "http://localhost:8000/web-admin/std-data/meta-class/", "wait_for": "[data-tree-ready]" },
{ "name": "term-list", "url": "http://localhost:8000/web-admin/std-data/terms/", "wait_for": ".term-row" }
]
4. 실제로 부딪힌 작은 어려움들
4.1 jstree가 렌더 끝나기 전에 캡쳐가 됨
networkidle만으로는 충분하지 않았습니다. jstree는 fetch가 끝나도 잠시 후 트리를 그리기 시작하는 컴포넌트라, "트리가 그려진 시점" 을 별도로 잡아야 했어요.
해결: 트리 컨테이너에 렌더 완료 후 data-tree-ready="1" 속성을 붙이도록 jstree 초기화 콜백에서 표시. Playwright는 그 selector를 기다림.
// metaClassTree.js 초기화 콜백 안에서
$tree.on('ready.jstree', () => {
$tree.attr('data-tree-ready', '1');
});
4.2 호버 메뉴 같은 인터랙션 캡쳐가 어려움
기본 캡쳐는 정적 상태만 잡습니다. 호버 시 펼쳐지는 GNB 같은 건 마우스 위치를 지정해야 해요.
# GNB 호버 상태 캡쳐
page.hover('nav .menu-item:nth-child(2)')
page.wait_for_timeout(200) # CSS transition 대기
page.screenshot(path=OUT / "gnb-hover.png")
4.3 폰트 로드 안 끝난 채로 캡쳐돼서 폰트가 fallback으로 그려짐
이게 가장 미묘했습니다. 회귀 비교 시에 "폰트가 다르게 보이는 게 진짜 회귀인지, 단지 로드가 안 끝나서인지" 판단하기 어려워요.
해결: 폰트 로드 완료를 명시적으로 기다림.
page.evaluate("document.fonts.ready")
5. 사용 흐름
# 변경 전
git checkout main
python scripts/capture_pages.py
mv captures captures-before
# 변경 후
git checkout hjlim/design-refactor
python scripts/capture_pages.py
mv captures captures-after
# 비교 (단순 파일별 diff)
for f in captures-after/*.png; do
name=$(basename "$f")
if ! cmp -s "captures-before/$name" "$f"; then
echo "diff: $name"
fi
done
이렇게 돌리면 변경된 페이지 목록이 나옵니다. 그 다음에는 사람이 직접 두 이미지를 비교해서 "이 변화는 의도된 거다" 인지 "의도 안 된 회귀다" 인지 판단해요.
좀 더 본격적으로 가려면 픽셀 diff 라이브러리(pixelmatch 등)를 붙일 수도 있는데, 우리는 변경 페이지 목록만 줄여줘도 충분히 도움이 됐습니다.
6. 이 도구의 한계 — 알고 쓰는 게 중요했다
만들어놓고 실제로 Tailwind 마이그레이션에 써보니, 이 도구가 무엇을 못 잡는지를 아는 게 잡는 것만큼 중요했습니다.
① 비교가 유효하려면 DB 상태가 같아야 합니다.
5절의 흐름은 브랜치를 바꿔가며 두 번 캡쳐하는 건데, 그사이 데이터가 그대로라는 보장이 없습니다. 브랜치를 바꾸면 마이그레이션이 다르고, 로컬에서 뭔가 등록해봤으면 목록이 달라져요. 그러면 CSS가 멀쩡해도 diff가 뜹니다.
"diff가 떴다" 가 "디자인이 바뀌었다" 를 뜻하려면 데이터가 고정돼야 하는데, 이 흐름에는 그 장치가 없어요. 캡쳐 전에 시드 데이터를 다시 적재하는 단계를 넣었어야 했습니다. 마침 엑셀 시드 스크립트로 같은 데이터를 결정론적으로 만들 수 있는 도구가 이미 있었는데 연결하지 못했어요.
② cmp는 노이즈에 너무 민감합니다.
if ! cmp -s "captures-before/$name" "$f"; then
바이트 단위 비교라 안티앨리어싱 1픽셀 차이, 스크롤 위치, 폰트 힌팅 차이까지 전부 "다름" 이 됩니다. full_page=True라 페이지 높이가 조금만 달라져도 전체가 어긋나고요.
"변경 페이지 목록만 줄여줘도 충분하다"고 썼는데, 노이즈가 많으면 목록이 안 줄어듭니다. 전부 diff로 나오면 도구를 안 쓰게 돼요. 임계값을 둘 수 있는 픽셀 diff(pixelmatch 등)는 선택이 아니라 이 흐름의 필수 부품에 가까웠습니다.
③ 비밀번호가 스크립트 안에 있습니다.
page.fill('input[name="password"]', "***")
글에는 가렸지만 스크립트에는 실제 값이 들어갑니다. 로컬 개발 계정이라 위험이 크진 않아도, 저장소에 자격 증명이 들어가는 패턴 자체가 문제예요. 환경변수로 빼고 .env를 gitignore하는 게 최소한이었습니다. 이건 목업 시드에서 원본은 감췄는데 파생물은 커밋한 것과 같은 종류의 부주의입니다.
④ 뷰포트가 하나뿐입니다.
1440×900 고정이라 반응형 회귀는 못 잡습니다. Tailwind 메이저 업그레이드에서 브레이크포인트 관련 변화가 있었다면 이 도구는 통과시켰을 거예요. 뷰포트 목록을 순회하도록 만들었으면 캡쳐 수는 늘지만 잡는 범위가 넓어졌을 겁니다.
⑤ 판정은 결국 사람이 합니다.
이건 한계라기보다 설계상의 선택인데, 명시해둘 만합니다. 이 도구는 "어디를 봐야 하는지" 까지만 알려주고 "이게 회귀인지" 는 사람이 판단해요. 그래서 캡쳐 대상 목록이 너무 길면 도구가 무의미해집니다. 8개 페이지라 굴러갔지, 80개였으면 diff 목록을 아무도 안 봤을 거예요.
7. 교훈
- 시각 회귀는 테스트로 못 잡습니다. 스크린샷 비교가 정답이고, 자동화 비용은 한 번만 들이면 됩니다.
- 동적 컴포넌트는 "렌더 완료 마커"를 명시적으로 박아두자. 시간 기반 대기는 불안정.
- 폰트 로드, CSS transition 같은 시간 의존 요소는 별도로 명시 대기.
- 캡쳐 스크립트는 코드 한 번, 사용은 두고두고. ROI가 좋은 도구.
이걸 만들어두니 Tailwind 마이그레이션 외에도 사이드 효과로 디자인 검토 회의에서 "현재 상태 캡쳐 좀 공유해 주세요" 같은 요청을 빠르게 처리할 수 있게 됐어요. 작은 도구가 의외로 자주 쓰입니다.