캔버스로 6초짜리 세로 영상 연출을 만들고 있었습니다. 지도가 깔리고, 걸어서 딴 영역이 차오르고, 숫자가 올라가고, 마지막에 카드가 뜹니다.
코드를 다 쓰고 브라우저 자동화로 페이지를 열어 스크린샷을 찍었습니다. 빈 화면이었습니다.
에러는 없었습니다. 요소도 다 있었습니다. 캔버스 엘리먼트는 DOM 에 정확한 크기로 있었습니다. 그냥 아무것도 안 그려져 있었습니다.
스로틀은 버그가 아니라 규격입니다
원인은 한 줄로 끝납니다. requestAnimationFrame 은 탭 가시성에 묶인 API 입니다. 탭이 백그라운드면 브라우저가 콜백을 스로틀하거나 아예 안 부릅니다. 배터리를 아끼려고 그렇게 설계된 겁니다.
그리고 자동화 스크린샷 도구는 탭을 포그라운드로 만들지 않습니다. 페이지를 열고, 렌더 트리를 받아 이미지로 굽습니다. 사람이 그 탭을 보고 있지 않습니다.
두 사실이 만나면 이렇게 됩니다. 연출은 멀쩡히 구현돼 있는데 rAF 가 한 번도 안 돌고, 첫 프레임조차 안 그려지고, 캡처에는 빈 캔버스가 찍힙니다. 코드는 정상인데 판정이 불가능한 상태 입니다.
이게 일반 버그와 다른 점이 있습니다. 보통은 "안 되는 것"을 고치면 됩니다. 여기서는 되는지 안 되는지를 볼 수단 자체가 없습니다. 디자인 판정 — 색이 맞나, 타이밍이 늘어지나, 텍스트가 안전영역을 넘나 — 을 할 근거가 0 이 됩니다.
당신이라면 어떻게 하시겠습니까
선택지가 몇 개 있습니다.
사람이 직접 봅니다. 됩니다. 대신 프레임을 바꿀 때마다 사람이 필요하고, 두 플랫폼을 눈으로 비교해야 하고, 6초 안의 특정 순간을 잡으려면 계속 되감아야 합니다.
탭을 강제로 활성화합니다. 도구에 따라 됩니다. 다만 캡처 파이프라인이 창 관리에 의존하게 되고, CI 나 헤드리스에서는 다시 깨집니다.
rAF 를 타이머로 바꿉니다. 스로틀은 피하지만 실제 재생 품질이 나빠집니다. 검수 편의를 위해 제품을 나쁘게 만드는 교환입니다.
저는 넷째를 골랐습니다.
시각을 밖에서 주입한다
연출을 순수 함수 로 뽑았습니다.
export function drawFrame(ctx: Ctx2D, scene: ReelScene, t: number): voidt 는 초 단위 시각입니다. 이 함수는 시계를 안 봅니다. 상태도 안 들고 있습니다. t 를 주면 그 순간의 화면을 그립니다. 재생은 rAF 가 t 를 흘려보내는 얇은 껍데기일 뿐입니다.
그 다음 공유 페이지에 쿼리 하나를 열었습니다.
// ?t=<초> 는 한 프레임에서 정지합니다 — 검수용 경로입니다.
const freezeAt = t !== undefined && Number.isFinite(Number(t)) ? Number(t) : undefined;이제 /t/<id>?t=2.4 를 열면 2.4초 시점 화면 한 장 이 그려지고 멈춥니다. rAF 가 안 돌아도 상관없습니다. 애초에 안 씁니다.
이걸로 자동화 캡처가 되살아났습니다. 그리고 부수 효과가 더 컸습니다.
- 결정적 비교 — 같은
t는 항상 같은 그림입니다. 팔레트를 바꾸고 전후를 나란히 놓을 수 있습니다. - 플랫폼 간 대조 — iOS 와 안드로이드의 같은
t를 픽셀로 비교합니다. 눈으로는 못 잡는 차이가 잡힙니다. - 썸네일 — "가장 예쁜 순간"이 몇 초인지 알면 그 프레임이 곧 공유 카드 이미지입니다.
- 회귀 테스트 — 특정
t에서 텍스트가 안전영역을 안 넘는지 단정할 수 있습니다.
들인 비용은 함수 시그니처 하나와 쿼리 파싱 두 줄입니다. drawFrame(ctx, scene, t) 로 이미 잘라놨다면 정지 경로는 정말 몇 줄입니다.
순서가 핵심입니다
이걸 나중에 붙이면 비쌉니다. 연출 코드가 rAF 콜백 안에서 자기 시각을 들고 있으면 — elapsed += delta 같은 게 클로저에 숨어 있으면 — 순수 함수로 뽑는 일이 리팩터링이 됩니다.
처음부터 시각을 인자로 받으면 공짜입니다. 그래서 이건 디버깅 요령이 아니라 설계 규칙에 가깝습니다: 캔버스 연출을 만들 때는 한 프레임만 그리는 경로를 같은 커밋에 만든다.
같은 화면을 놓고 콘솔은 조용했고, 화면은 비어 있었습니다 를 쓴 적이 있습니다. 그때 저는 rAF 스로틀을 용의선상에 올렸다가 "그것만으로는 첫 프레임조차 안 그려지는 걸 설명 못 한다"며 내렸습니다. 그 판단은 그 사건에서는 맞았습니다 — 진짜 원인은 하이드레이션이 통째로 안 된 것이었습니다. 다만 rAF 스로틀도 동시에 참 이었고, 하이드레이션을 고친 뒤에 그게 남아 있었습니다. 원인 하나를 찾았다고 다른 하나가 사라지지 않습니다.
자가진단 3가지
- 연출 코드가 시각을 스스로 셉니까, 받습니까?
elapsed += delta가 클로저 안에 있으면 그 연출은 사람 눈으로만 검수할 수 있습니다. - 애니메이션 한 프레임을 URL 하나로 재현할 수 있습니까? 없다면 회귀 테스트도, 플랫폼 대조도, 썸네일 자동 선정도 전부 못 합니다.
- 캡처 도구가 탭을 포그라운드로 만든다고 가정하고 있습니까? 대부분 아닙니다. 빈 캔버스가 찍혔다면 코드보다 이걸 먼저 의심하세요.
솔직한 부분
저는 이걸 설계로 넣은 게 아니라 막혀서 넣었습니다. 연출을 다 만들고, 캡처가 빈 화면을 주고, 한참 코드를 의심한 다음에야 "볼 방법이 없다"는 게 문제라는 걸 알았습니다.
그리고 이 경로가 모든 걸 해결해주지도 않았습니다. 링크 미리보기에 쓰는 OG 카드는 캔버스를 못 돌립니다 — 이미지 생성기 안에는 브라우저 캔버스가 없습니다. 그래서 거기는 같은 카메라 계산을 재사용해 영역을 SVG path 로 다시 그렸습니다. 정지 프레임을 그대로 갖다 쓸 수 있을 줄 알았는데 아니었고, 대신 모양이 어긋나지 않도록 카메라를 공유하는 쪽으로 풀었습니다. 미리보기가 영상과 다른 모양을 보여주면 없느니만 못하니까요.
지금 당신 화면에서 움직이는 것 중에, 멈춰서 볼 수 없는 것 이 있습니까?