pubuilder 13. 썸네일 화질·성능 정리와 로그인 페이지 자동 캡처
pubuilder의 IA 맵은 프로젝트의 페이지들을 카드로 늘어놓고 연결 관계를 보여주는 화면이다. 각 카드에 박히는 미리보기는 컴패니언 서버(pubuilder가 앱과 나란히 띄우는 로컬 서버)가 Playwright chromium으로 그 페이지를 열어 WebP로 찍어 온 이미지다. Playwright는 브라우저를 코드로 조종하는 자동화 도구다.
이 구조로 캡처는 잘 돌고 있었는데, 정작 맵을 열면 카드가 작고 썸네일 속 글자가 흐렸다. 원본이 문제인지 표시가 문제인지부터 갈라야 했다. 오전은 그 화질·성능 쪽이었고, 오후는 "왜 안 찍히는지"를 사용자에게 설명하는 쪽이었다. 오전 작업은 13시 58분 v0.9.13으로 나갔고, 그 뒤 발견한 연결·실패 처리 문제를 v0.9.14(14:48) · v0.9.15(15:04) · v0.9.16(15:37)으로 연달아 배포했다.
썸네일이 작고 흐려 보이던 건 화질이 아니라 천장 문제였다
세 가지가 겹쳐 있었다.
fitView의 padding이 0.15였는데, 이 값은 상하좌우 각각에 15%씩 붙는다. 캔버스의 30%를 여백으로 버리고 있었다. 0.04로 줄이니 같은 화면에서 카드가 31% 커졌다(배율 0.368 → 0.484, 카드 95px → 125px).
확대 상한이 2배라 카드가 최대 520px였다. 1440px 페이지가 늘 3분의 1로 줄어드니 본문 글자를 읽을 수가 없다. 화질이 아니라 천장이 문제였다. 5로 열어 카드가 1290px까지 커지게 했다. CanvasOverlay가 maxZoom={2}로 다시 덮어쓰고 있던 것도 걷어냈다.
화면 밖으로 나간 썸네일을 보관하는 상한이 12장이라 조금만 움직여도 방금 캡처한 카드가 밀려났다. 40장으로 올렸다.
이 세 값은 playground를 실제 브라우저로 띄워 배율·카드 크기·원본 해상도를 직접 재고 스크린샷으로 확인했다.
대기 상태 표현도 같이 바꿨다. "캡처 중…" 문구 대신 헤더 · 본문 · 아래 줄로 페이지 실루엣을 잡은 스켈레톤을 넣었다. 하이라이트가 훑고 지나가면서 곧 채워진다는 걸 알린다. 치수를 전부 비율로 잡아 줌 배율을 따라가게 했고, prefers-reduced-motion에서는 애니메이션을 끈다. 외부 링크 노드 표시는 건드리지 않았다.
will-change 하나가 크롬의 재래스터를 막고 있었다
확대해도 카드 제목은 멀쩡한데 썸네일 속 작은 글자만 뭉갰다. .pbu-flow-world에 will-change: transform이 항상 걸려 있던 게 원인이었다. 이 속성은 요소를 별도 합성 레이어로 승격시켜 변환을 GPU에 맡기는데, 그 대신 크롬이 처음 래스터한 텍스처를 계속 재사용하고 확대해도 다시 그리지 않는다. 260px짜리 그림을 1300px로 늘려 놓은 상태였다.
카드 제목은 큰 글씨라 늘려도 티가 덜 나고 썸네일 속 작은 글자만 뭉개져 보였다. 그래서 한동안 이미지 화질 문제로 오진했다.
팬·줌이 도는 동안에만 승격하고 180ms 멎으면 걷어내게 바꿨다. 떼는 순간 크롬이 현재 배율로 다시 그린다.
캡처 원본이 멀쩡하다는 것도 이 과정에서 확정했다. /chat을 찍어 무손실 PNG와 비교하니 WebP q88의 픽셀 오차는 최대 26 / 평균 0.23이었고, 2880x1800 크롭을 1:1로 열면 완전히 선명하다.
같이 넣은 두 번째 수정이 타일 반복 깨짐을 만들었다
레이어 승격 수정과 함께 "이미지를 박스의 2배(확대 시 4배)로 레이아웃해 디코딩 해상도를 올린다"는 방식도 한꺼번에 넣었다. 이쪽이 타일 반복 깨짐을 만들었다.
확대하면 래스터 폭이 1032 × 5 × DPR 2 = 10320px가 된다. GPU 최대 텍스처 크기(보통 4096~16384px, 한 장의 텍스처로 올릴 수 있는 한계 픽셀)를 넘으면 합성기가 그림을 타일로 쪼개는데, 그 과정에서 내용이 반복돼 보였다.
걷어내고 박스 크기(100%)로 되돌렸다. 래스터 폭이 10320px에서 2580px로 내려갔다. 선명도는 레이어 승격 수정만으로 확보된다.
원인이 확실하지 않은 상태에서 두 수정을 겹친 대가였다. 어느 쪽이 실제 수정인지 구분하지 못했고, 필요 없던 쪽이 새 버그를 만들었다.
로그인이 필요한 페이지를 사용자 조작 없이 찍기
이전 iframe 방식이 로그인 화면 뒤쪽 페이지를 보여줄 수 있었던 건 앱과 같은 브라우저에서 돌았기 때문이다. 그 이점을 서버 캡처로 옮겼다. 사용자가 아무것도 하지 않아도 되게.
세션이 캡처 브라우저까지 가는 경로는 두 갈래다.
쿠키는 브라우저가 포트를 구분하지 않으므로 앱(:3000)의 세션이 컴패니언 서버(:4816)에 그대로 도착한다. HttpOnly까지 포함되고 same-site라 SameSite=Lax도 통과한다. 실험으로 확인했다.
토큰을 localStorage에 두는 앱은 쿠키만으로 안 된다. PageMap이 앱과 같은 origin에서 도는 덕분에 읽어서 함께 보낼 수 있었다. 32KB를 넘는 값은 건너뛰고 전체 256KB에서 끊는다.
세션 값은 디스크에 남기지 않고 캐시 키에는 지문만 쓴다. 로그아웃이나 계정 변경이면 자동으로 무효화된다. 그래도 못 찍으면 인증이 필요한 페이지 카드로 어디로 튕겼는지 보여준다. 수동 대안으로 pubuilder auth <주소>도 뒀다. 실제 브라우저로 사용자가 직접 로그인하고 결과 세션만 저장하는 방식이라 비밀번호가 pubuilder를 거치지 않는다.
이 세션 전달을 붙이면서 회귀 하나를 만들었다. credentials: 'include'는 서버가 Access-Control-Allow-Credentials를 돌려줘야 성립하는데, 패키지만 올리고 dev 서버를 재시작하지 않으면 CORS 프리플라이트(브라우저가 실제 요청 전에 보내는 허용 여부 확인 요청)에서 막혀 썸네일이 하나도 안 뜬다. 한 번 막히면 쿠키 없이 다시 시도하고, 이후로는 그 방식을 유지하게 했다.
손으로 빌드하는 단계가 하나라도 있으면 언젠가 빠진다
v0.9.13에 담겨 나간 것 중에는 배포 산출물 자동 빌드도 있었다. npm에 올라간 0.9.12의 dist가 0.9.11 빌드였다. npm publish는 소스를 빌드하지 않으니, 빌드를 잊고 배포하면 버전만 올라간 채 나간다. 그 뒤로 "고쳤는데 그대로"라는 오진을 여러 차례 만들었다.
패키지를 묶기 직전에 도는 npm 훅에 빌드를 걸어 막았다.
"prepack": "pnpm build"로케일 프리픽스 때문에 리다이렉트로 오탐하던 것도 함께 줄였다.
썸네일 구현 가이드도 정리했다. 구조·원인·실측을 한곳에 모은 docs/thumbnail-capture.md를 쓰고, 이어서 프로젝트에 매이지 않는 형태로 다시 갈랐다. iframe과 이미지 중 무엇을 고를지의 기준, 캡처가 조용히 실패하는 네 가지, 화질이 표시 단계에서만 손실되는 세 가지(와 하지 말 것), 로그인 세션 전달, 성능 실측, 적용 체크리스트를 담았다. 다른 프로젝트에서 같은 판단을 처음부터 다시 하지 않으려는 목적이다.
오후에 나온 세 버그는 모두 "보통 맞는 값"을 영구히 믿은 결과였다
캡처 브라우저가 한 번 죽으면 그 뒤 모든 썸네일이 실패했다. launch 결과를 영구 캐시해 죽은 핸들을 계속 재사용한 탓이다. 카드에는 playwright 에러 원문이 그대로 노출됐다.
연결이 끊기면 다음 요청에서 새로 띄우고, 브라우저와 함께 죽은 컨텍스트 캐시도 버리게 했다. 이 부류의 실패는 한 번 재시도하고, 그래도 안 되면 사람이 읽을 문구를 남긴다. 인증 필요 카드의 이모지를 svg 아이콘으로 바꾸고, 좁은 카드에서 노이즈였던 이동 안내 문구도 뺐다.
다른 프로젝트의 컴패니언 서버에 붙어 CORS 에러가 나는 경우도 있었다. 서버는 이미 떠 있는 포트를 피해 4817·4818로 잘 떴는데 클라이언트가 따라오지 않았다. sessionToken을 직접 넘긴 앱은 자동 탐색을 건너뛰고 기본값 localhost:4816으로 단정해, 먼저 떠 있던 다른 프로젝트의 서버를 두드렸다. 그 서버는 origin이 다르니 403을 주는데 CORS 헤더가 없어서 콘솔에서는 CORS 에러로 뭉개졌다.
토큰만 있고 주소가 없으면 4816~4831에서 그 토큰이 통하는 서버를 찾아 붙게 했다. 인증된 /api/health로 판별하므로 여러 프로젝트가 섞여도 자기 서버에만 연결된다. pubuilder serve도 --port 없이 부르면 사용 중인 포트를 피한다. 예전엔 4816이 잡혀 있으면 EADDRINUSE로 그냥 죽었다. 회피 로직이 dev에만 있었다.
403 거절에도 CORS 헤더를 실어 FORBIDDEN 본문을 읽게 했다. 남의 서버를 잘못 두드린 것인지 서버가 죽은 것인지 구분된다. pubuilder dev는 실제 주소를 PUBUILDER_SERVER_URL(과 NEXT_PUBLIC_ / VITE_ 접두 버전)로 내보낸다.
4816~4818이 이미 점유된 실제 환경에서 검증했다. 새 서버는 4819로 뜨고, 토큰만 아는 클라이언트가 4819를 찾아 붙었고, 낯선 origin 요청은 CORS 헤더가 실린 403을 받았다.
세 번째는 앱 쪽 포트였다. pubuilder dev는 명령줄만 보고 앱이 localhost:3000에 뜬다고 추측했는데, Next와 Vite는 그 포트가 잡혀 있으면 조용히 다음 포트로 올라간다. 다른 프로젝트가 3000~3002를 쓰고 있으면 앱은 3003에 뜨고, 컴패니언 서버는 3000만 허용한 채 앉아 있어 모든 카드가 "컴패니언 서버가 꺼져 있어요"였다. 4819 서버에 origin 3003으로 요청하면 403, 3000으로 요청하면 200인 걸 재서 확인했다.
추측하지 않고 관측하게 바꿨다. dev 서버 stdout/stderr를 그대로 흘려보내면서 첫 로컬 URL을 읽어 허용 origin을 교체한다. --origin을 직접 준 경우는 존중한다.
허용 목록에 더하는 게 아니라 교체하게 한 이유가 있다. 추측한 3000을 남겨 두면 그 포트의 다른 앱이 이 프로젝트의 세션 토큰을 bootstrap으로 받아갈 수 있다.
실패한 탐색은 캐시하지 않게 했다. 못 찾은 결과를 물고 있으면 서버가 뜬 뒤에도 새로고침 전까지 캡처가 영원히 실패했다.
실패 카드에 playwright 원문 대신 사람 말을
카드에 ANSI 색 코드 잔해([2m)와 call log가 섞인 원문이 그대로 나왔고, 정작 원인은 드러나지 않았다. 본문 없는 404를 만나면 크롬은 net::ERR_HTTP_RESPONSE_CODE_FAILURE를 던지는데, 원문만 보면 "왜 안 찍히지"가 될 뿐 그 경로에 페이지가 없다는 사실은 끝까지 알 수 없다.
메인 프레임 응답 상태를 따로 엿봐 두고, 알면 그걸 앞세우게 했다. localhost:3003/admin(빈 본문 404)으로 확인하니 "이 경로에 페이지가 없어요 (HTTP 404)"가 나왔다. 연결 거부와 타임아웃도 사람 말로 바꾸고, 나머지는 net:: 코드만 남긴다. 예상 밖 실패도 마지막 관문에서 ANSI와 call log를 걷어내고 한 줄로 정리한다(CAPTURE_FAILED).
1,000개 노드에서 재 본 값
1,000개 노드 하네스를 만들어 실제 크롬에서 측정했다.
| 상태 | 배율 | DOM 노드 | 상세 카드(이미지) |
|---|---|---|---|
| 전체 보기 | 0.10 | 1,000 | 0 |
| 카드가 제일 많은 구간 | 0.54 | 1,000 | 144 |
| 최대 확대 | 5.0 | 1,000 | 4 |
가장 나쁜 144장 구간에서 팬·줌 모두 중앙값 8.3ms · p95 9ms · 최악 16.8ms, JS heap 11MB였다.
이날 고친 것들
| 문제 | 원인 | 해결 |
|---|---|---|
| 확대하면 썸네일만 뭉갬 | will-change: transform이 재래스터를 막음 | 팬·줌 중에만 승격, 180ms 뒤 해제 |
| 썸네일이 타일처럼 반복 | 이미지를 박스보다 크게 레이아웃해 GPU 최대 텍스처 크기 초과 | 레이아웃 배율 제거 |
| 전체 보기가 너무 작음 | padding 0.15가 상하좌우 각각 붙어 30% 손실 | 0.04로 축소 |
| 확대해도 글자를 못 읽음 | maxZoom 2, 카드 최대 520px | 5로 상향 |
| 로그인 페이지가 홈으로 찍힘 | 캡처 브라우저에 세션 없음 | 쿠키와 웹 저장소 전달, 실패 시 인증 카드 |
| 업데이트 후 썸네일 전멸 | 구버전 서버에 credentials: 'include' | 막히면 쿠키 없이 재시도 |
| 고쳤는데 반영 안 됨 | npm publish가 dist를 빌드하지 않음 | prepack에 빌드 연결 |
| 브라우저 크래시 후 전부 실패 | launch 결과를 영구 캐시해 죽은 핸들 재사용 | 연결 끊김 감지 후 재기동, 컨텍스트 캐시 폐기, 1회 재시도 |
| 콘솔에 CORS 에러, 실제로는 남의 서버 | 토큰만 넘긴 앱이 기본 포트 4816으로 단정 | 4816~4831에서 토큰이 통하는 서버 탐색, 403에도 CORS 헤더 부여 |
| 앱이 3003에 떴는데 전부 "서버 꺼져 있어요" | dev 서버 포트를 명령줄로 추측 | dev 서버 출력에서 실제 URL을 관측해 허용 origin 교체 |
| 서버가 뜬 뒤에도 캡처가 계속 실패 | 실패한 탐색 결과를 캐시 | 실패는 캐시하지 않고 다음 요청에서 재탐색 |
| 실패 카드에 에러 원문이 새어 나옴 | playwright 에러를 그대로 노출 | 응답 상태를 앞세운 사람 말로 변환, ANSI와 call log 제거 |
헤드리스 스크린샷으로는 GPU 합성 결과를 검증할 수 없다
헤드리스 브라우저는 캡처 시점에 항상 다시 래스터한다. 그래서 고치기 전에도 스크린샷은 선명하게 나왔고, "will-change는 차이 없다"는 잘못된 실험 결과를 한 번 냈다. 이 영역은 실제 브라우저로 눈으로 봐야 한다.
배포 산출물은 소스에서 자동으로 만들어져야 한다. 손으로 빌드하는 단계가 하나라도 있으면 언젠가 빠지고, 그 뒤의 모든 디버깅이 잘못된 전제 위에서 돈다.
추측한 기본값은 조용히 틀린다. 포트 3000과 4816은 둘 다 "보통 맞는" 값이라 틀렸을 때 에러가 아니라 다른 문제처럼 보였다. 카드 전멸, CORS 에러 같은 얼굴로. 관측할 수 있는 값은 관측해야 한다. 그리고 추측한 값을 폴백으로 남기면 보안 문제가 된다. 3000을 허용 목록에 남겨 두면 그 포트의 다른 앱이 이 프로젝트 세션 토큰을 받아갈 수 있다. 교체가 맞았다.
성공 결과의 캐시와 실패 결과의 캐시는 수명이 완전히 다르다. 브라우저 핸들도 같은 부류였다. 살아 있다는 보장이 없는 것을 영구 캐시하면 안 된다.
에러 원문을 사용자에게 보여주는 건 정보 전달이 아니다. ERR_HTTP_RESPONSE_CODE_FAILURE는 "경로에 페이지가 없다"를 감췄다. 원인을 아는 계층에서 사람 말로 번역해야 한다.
하루에 v0.9.13부터 v0.9.16까지 연달아 배포한 것도 같은 패턴을 하나씩 발견한 결과다. 캐시나 기본값을 넣을 때 수명과 무효화 조건을 먼저 적어 두는 편이 낫다. 구현 가이드를 프로젝트 밖으로 뺀 것도 오늘 얻은 판단을 다음 프로젝트로 넘기려고 한 일이다.
다음 할 일
- CHANGELOG에 0.9.14 항목 추가. 현재 0.9.15에서 0.9.13으로 건너뛰어 있다. 브라우저 크래시 캐시 수정과 컴패니언 서버 포트 탐색이 이 버전에 배포된 걸 tarball로 확인했다.
- 0.9.14 버전 상승이 커밋되지 않은 채 배포된 경로 점검. prepack은 빌드만 강제한다.
- 캡처 실패 카드 문구를 실제 사용자 상황(404 · 500 · 인증 · 타임아웃)별로 한 번 더 검수
- 컴패니언 서버 탐색 범위(4816~4831)와 dev 포트 관측을 Vite·Remix 등 다른 dev 서버에서 확인
- 1,000 노드 실측을 회귀 하네스로 고정해 성능 후퇴를 자동 감지