문제 정의
웹앱에서 대상 페이지의 풀페이지 스크린샷이 필요했다. 핵심 제약은 두 가지였다.
- 로그인 세션 유지 - 대시보드, 마이페이지 등 인증이 필요한 페이지를 캡처해야 한다
- 렌더링 정확도 - CSS, Shadow DOM, 동적 콘텐츠가 실제 화면과 동일하게 캡처되어야 한다
캡처 방식 선택
웹에서 스크린샷을 찍는 방식은 크게 세 가지가 있다.
| 방식 | 동작 원리 | 장점 | 한계 |
|---|---|---|---|
| Playwright 서버 캡처 | 서버에서 headless 브라우저 실행 | fullPage: true 한 줄로 구현 |
새 세션 생성 → 로그인 상태 없음 |
| html2canvas | DOM을 Canvas로 재렌더링 | 클라이언트에서 동작, 세션 유지 | iframe DOM 접근불가 (확장 프로그램으로 우회 가능하긴함.) 재렌더링시 css 정확도가 떨어질 가능성이 있음 |
| captureVisibleTab | 브라우저 렌더링 결과 캡처 | 렌더링 정확도 100%, 세션 유지 | 보이는 영역만 캡처 가능 |
로그인 세션이 필수이므로 서버 기반 Playwright는 제외된다. html2canvas는 복잡한 CSS나 Shadow DOM에서 렌더링 오류가 발생한다.
captureVisibleTab은 브라우저가 실제로 렌더링한 화면을 그대로 캡처하므로 정확도가 보장된다. 대신 "보이는 영역만"이라는 제약이 있다.
풀페이지를 얻으려면 스크롤하면서 여러 번 캡처한 뒤 합성해야 한다. 이것이 타일 스티칭이다.
타일 스티칭 구조
1D vs 2D 자동 감지
대부분의 웹페이지는 세로로 스크롤한다. 이 경우 1D(수직) 타일링으로 충분하다.
가로 스크롤이 있는 페이지(대시보드, 넓은 테이블)는 2D 그리드로 타일링해야 한다. X 좌표가 0이 아닌 타일이 하나라도 있으면 2D 모드로 전환한다.
const is2DGrid = tileImages.some((tile) => tile.scrollX > 0);
if (is2DGrid) {
return stitchTiles2D(tileImages, viewport, fullWidth, fullHeight, dpr);
} else {
return stitchTiles1D(tileImages, viewport, fullWidth, fullHeight, dpr);
}
타일 위치 계산
타일 위치는 X축과 Y축 모두 동일한 알고리즘으로 계산한다.
function calculateOptimalPositions(fullSize, viewportSize, overlap) {
if (fullSize <= viewportSize) return [0];
const positions = [0];
const delta = viewportSize - overlap;
let pos = 0;
while (pos + viewportSize < fullSize) {
pos += delta;
if (pos + viewportSize >= fullSize) {
// 마지막 타일은 끝에 정렬
const endPos = fullSize - viewportSize;
if (endPos > positions[positions.length - 1]) {
positions.push(endPos);
}
break;
}
positions.push(pos);
}
return positions;
}
왜 200px 오버랩인가
타일을 딱 맞게 이어붙이면 되지 않을까? sticky/fixed 요소 때문에 그렇지 않다.
스크롤해도 화면에 고정되는 헤더가 있으면, 매 타일마다 헤더가 중복 캡처된다. 이를 처리하려면 타일 간 겹침 영역이 필요하다.
┌─────────────┐
│ Header │ ← sticky (매 타일에 포함)
├─────────────┤
│ │
│ Tile 1 │
│ │
├─────────────┤ ← 200px 겹침
│ │
│ Tile 2 │
│ │
└─────────────┘
200px은 일반적인 헤더 높이(60-80px)의 2-3배로, 대부분의 sticky 요소를 커버한다. 스티칭 시 겹치는 영역을 잘라내면 자연스럽게 이어진다.
// 이전 타일과 현재 타일의 겹침 계산
const overlap = Math.max(0, prevScrollY + viewportHeight - currentScrollY);
const srcY = overlap * devicePixelRatio;
const srcHeight = image.height - srcY;
ctx.drawImage(image, 0, srcY, image.width, srcHeight, 0, currentY, image.width, srcHeight);
스크롤 시뮬레이션: transform 방식
pixelDiff는 웹앱 안에 iframe으로 대상 페이지를 띄운다. 문제는 iframe 내부에서 scrollTo()가 동작하지 않는 경우가 있다는 것이다.
iframe의 scrollWidth === innerWidth인 경우, 스크롤 가능 영역이 없어서 스크롤 명령이 무시된다. 이는 페이지 자체가 overflow: hidden이거나, 부모 컨테이너가 스크롤을 처리하는 경우에 발생한다.
해결책은 CSS transform으로 콘텐츠를 이동시키는 것이다.
// 스크롤 대신 transform으로 이동
document.documentElement.style.transform = `translate(-${x}px, -${y}px)`;
document.documentElement.style.transformOrigin = '0 0';
이 방식은 실제 스크롤이 아니라 시각적으로 콘텐츠를 이동시키므로, 스크롤 불가능한 컨텍스트에서도 동작한다.
렌더링 파이프라인 대기
transform 적용 후 바로 캡처하면 이전 위치가 캡처되는 문제가 있다. 브라우저 렌더링 파이프라인이 완료될 때까지 대기해야 한다.
// 4 RAF 프레임 대기
for (let f = 0; f < 4; f++) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
// 추가 안정화 대기
await sleep(50);
브라우저 렌더링은 Style → Layout → Paint → Composite 단계로 진행된다. CSS transform은 Composite 레이어에서 처리되는데, 각 단계가 별도 프레임에서 실행될 수 있다.
4프레임(약 66ms)은 모든 단계가 완료되기에 충분한 시간이다. 추가 50ms는 복잡한 레이아웃에서의 안전 마진이다.
Chrome API Rate Limit
captureVisibleTab은 초당 2회 호출 제한이 있다. 이를 초과하면 API가 실패한다.
// 타일 캡처 후 500ms 대기
if (i < totalTiles - 1) {
await sleep(500);
}
500ms 간격이면 초당 2회 제한을 준수하면서 안정적으로 캡처할 수 있다.
캔버스 크기 제한: 8000px 분할
캔버스 API에는 최대 크기 제한이 있다. Chrome Extension은 Chrome 브라우저에서만 동작하므로, Chrome의 제한을 기준으로 설계했다.
Chrome Desktop의 캔버스 최대 크기는 약 16,384px다. 8000px는 이 제한의 절반 정도로, 충분한 여유를 둔 값이다. 무한 스크롤 페이지에서 DOM 측정 시점과 캡처 시점 사이에 콘텐츠가 추가로 로드될 수 있어서, 제한에 근접하게 설정하면 런타임 에러 위험이 있다.
const MAX_CAPTURE_HEIGHT = 8000;
if (fullHeight > MAX_CAPTURE_HEIGHT) {
const totalParts = Math.ceil(fullHeight / MAX_CAPTURE_HEIGHT);
// 사용자에게 분할 캡처 확인 요청
}
분할 캡처된 이미지를 다시 하나로 합치지 않는 이유는, 합친 결과물도 캔버스 제한에 걸리기 때문이다. 8000px × 3장 = 24000px는 Chrome에서도 처리할 수 없다.
타일 정렬의 중요성
타일 캡처는 비동기로 진행된다. 네트워크 지연이나 렌더링 시간에 따라 타일이 순서대로 도착하지 않을 수 있다.
스티칭 전에 반드시 scrollY 기준으로 정렬해야 한다. 그렇지 않으면 이미지가 뒤섞여 결과물이 깨진다.
// 1D 스티칭: Y 좌표 순 정렬
const sortedTiles = [...tileImages].sort((a, b) => a.scrollY - b.scrollY);
// 2D 스티칭: Y → X 순 정렬
const sortedTiles = [...tileImages].sort((a, b) => {
if (a.scrollY !== b.scrollY) return a.scrollY - b.scrollY;
return a.scrollX - b.scrollX;
});
브라우저 보안 정책(CSP, X-Frame-Options)을 우회하면서 정확한 스크린샷을 얻으려면, DOM 접근 대신 렌더링 결과물을 캡처하는 방식이 더 범용적이다.
captureVisibleTab의 "보이는 영역만"이라는 제약은 타일 스티칭으로 해결할 수 있다. 핵심 구현 포인트는 다음과 같다.
- 1D/2D 자동 감지 - X 좌표로 판단
- 200px 오버랩 - sticky 요소 처리
- transform 기반 스크롤 - scrollTo 불가 환경 대응
- 4 RAF + 50ms 대기 - 렌더링 파이프라인 완료 보장 (동적 요소 로드 대기)
- 8000px 분할 - 캔버스 크기 제한 대응
- 타일 정렬 - 비동기 수신 순서 보정
이 패턴은 스크린샷 외에도 PDF 생성, 썸네일 추출 등 "현재 렌더링 상태를 캡처"해야 하는 상황에 적용할 수 있다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd' 카테고리의 다른 글
| S3 Presigned URL로 클라이언트 직접 업로드 (0) | 2026.01.22 |
|---|---|
| Pixi.js 줌 레벨별 UI 표시/숨김 구현하기 (0) | 2026.01.17 |
| pixelmatch로 픽셀 비교 알고리즘 구현하기 (0) | 2026.01.15 |
| JavaScript 클로저(Closure) 이해하기 (0) | 2023.05.31 |
