"Figma 이미지와 스크린샷 크기가 안 맞아서 비교가 안 돼요."
문제의 시작
pixelDiff는 디자인(Figma)과 실제 구현(스크린샷)을 픽셀 단위로 비교하는 도구다. 그런데 개발 초기부터 골치 아픈 문제가 있었다.
- Figma에서 내보낸 이미지: 5760×3600px
- 크롬 익스텐션으로 캡처한 스크린샷: 2880×1800px
- 사용자가 직접 업로드한 이미지: 1440×900px
세 이미지 모두 "1440×900 프레임"을 캡처한 건데, 실제 픽셀 크기는 전부 다르다.
왜 이런 일이 발생하는가
Figma API의 고해상도 내보내기
Figma API로 이미지를 요청할 때 scale 파라미터를 지정할 수 있다. scale=4로 요청하면 원래 크기의 4배 해상도로 내보내진다.
// Figma API 이미지 요청
const response = await figma.getImage(nodeId, {
format: 'png',
scale: 4 // 4x 해상도로 내보내기
});
1440×900 프레임을 scale=4로 요청하면 5760×3600px 이미지가 된다.
브라우저 DPR과 스크린샷
브라우저에서 스크린샷을 찍으면 모니터의 DPR이 적용된다.
// Chrome Extension - 스크린샷 캡처 서비스
export async function captureAndCrop(
tabId: number,
bounds: Bounds,
devicePixelRatio: number, // 2 (일반 레티나), 3 (아이폰), 1 (일반 모니터)
viewport: Viewport
): Promise<CaptureResult> {
// ...
const dpr = devicePixelRatio;
const cropX = bounds.x * dpr;
const cropY = bounds.y * dpr;
const cropWidth = bounds.width * dpr;
const cropHeight = bounds.height * dpr;
// ...
}
DPR=2인 맥북에서 1440×900 영역을 캡처하면 2880×1800px 이미지가 된다.
수동 업로드는 원본 그대로
사용자가 직접 올린 이미지는 별도 처리 없이 원본 크기 그대로 저장된다.
선택지 분석
이 불일치를 해결하는 방법은 크게 세 가지가 있다.
| 방식 | 장점 | 한계 | 적합한 상황 |
|---|---|---|---|
| 저장 시 정규화 | 비교가 단순해짐 | 원본 품질 손실, 되돌릴 수 없음 | 저장 공간이 제한적일 때 |
| 비교 시 정규화 | 원본 품질 유지, 유연함 | 비교 로직이 복잡해짐 | 고품질 원본이 필요할 때 |
| 메타데이터로 분리 관리 | 표시/비교 목적별 최적화 가능 | 스키마 변경 필요 | 다양한 소스를 다룰 때 |
선택: 메타데이터 기반 정규화
pixelDiff의 핵심 요구사항은 두 가지다.
- 사이드바에는 "논리적 크기"를 표시해야 한다 (1440×900)
- 비교 시에는 정확한 픽셀 매칭이 필요하다
저장 시 정규화를 선택하면 Figma의 4x 고해상도 이미지를 다운스케일해야 한다. 나중에 "고해상도로 비교하고 싶다"는 요구가 생기면 원본이 없어서 대응할 수 없다.
반면 메타데이터로 scale 값을 따로 저장하면:
- 원본은 최대 해상도로 유지
- 표시할 때는 scale로 나눠서 논리적 크기 계산
- 비교할 때는 scale이 다르면 다운스케일로 맞춤
model Layer {
width Int // 논리 픽셀 (표시용)
height Int // 논리 픽셀 (표시용)
scale Float @default(1) // 배율 (Figma=4, 스냅샷=DPR, 수동=1)
// 실제 이미지 크기 = width × scale, height × scale
}
구현: 소스별 처리
Figma 이미지
Figma API는 absoluteBoundingBox로 프레임의 논리적 크기를 알려준다. 이 값을 그대로 저장하고, scale=4를 기록한다.
논리 크기: 1440×900 (DB 저장)
scale: 4
실제 이미지: 5760×3600
브라우저 스크린샷
익스텐션에서 캡처할 때 window.devicePixelRatio를 함께 전송한다.
// 익스텐션 → 웹앱 메시지
{
imageData: "data:image/png;base64,...",
width: 2880, // 실제 이미지 크기
height: 1800,
devicePixelRatio: 2
}
서버에서는 논리 크기로 변환해서 저장한다.
// 저장 로직
const logicalWidth = Math.round(width / devicePixelRatio); // 1440
const logicalHeight = Math.round(height / devicePixelRatio); // 900
const scale = devicePixelRatio; // 2
수동 업로드
사용자가 올린 이미지는 scale=1로 가정한다. 논리 크기 = 실제 이미지 크기.
구현: Diff 비교 로직
두 이미지의 scale이 다를 때가 핵심이다.
Figma (scale=4): 5760×3600
스냅샷 (scale=2): 2880×1800업스케일 vs 다운스케일 중 어떤 걸 선택해야 할까?
업스케일의 문제: 2880×1800 이미지를 5760×3600으로 키우면 없던 픽셀을 보간으로 생성해야 한다. 이 보간된 픽셀이 원본과 미세하게 달라서 "가짜 차이"가 발생할 수 있다.
다운스케일 선택 이유: 5760×3600을 2880×1800으로 줄이면 원본 정보만 사용한다. 정보 손실은 있지만, 없던 정보를 만들어내지는 않는다.
// useDiffCalculation.ts - 이미지 영역 추출
async function extractImageData(
imageUrl: string,
layerPosition: { x: number; y: number },
layerSize: { width: number; height: number }, // 논리 크기
intersectionRect: Rect
): Promise<ImageData> {
// ...
const img = new Image();
img.onload = () => {
// 실제 이미지 크기와 논리 크기의 비율 = scale
const scaleX = img.naturalWidth / layerSize.width;
const scaleY = img.naturalHeight / layerSize.height;
// 출력 크기는 논리적 교차 영역 크기 (다운스케일 대상)
const outputWidth = Math.round(intersectionRect.width);
const outputHeight = Math.round(intersectionRect.height);
canvas.width = outputWidth;
canvas.height = outputHeight;
// 실제 이미지에서 추출할 영역 (고해상도)
const srcWidth = Math.round(intersectionRect.width * scaleX);
const srcHeight = Math.round(intersectionRect.height * scaleY);
// 고해상도 → 논리 크기로 다운스케일하며 그리기
ctx.drawImage(
img,
srcX, srcY, srcWidth, srcHeight, // 고해상도 소스
0, 0, outputWidth, outputHeight // 논리 크기 대상
);
};
}
scale이 다른 두 이미지를 비교할 때:
- 둘 다 논리 크기(교차 영역)로 다운스케일
- 같은 크기가 된 후 픽셀 비교
구현: 분할 캡처와 DPR
전체 페이지 캡처는 뷰포트 단위로 여러 타일을 찍어서 이어붙인다. 이때도 DPR 처리가 필요하다.
// tile-stitcher.ts
export async function stitchTiles(
tiles: TileData[],
viewport: Viewport,
fullWidth: number,
fullHeight: number
): Promise<string> {
// 첫 번째 타일에서 DPR 역산
const devicePixelRatio = tileImages[0].image.height / viewport.height;
// 전체 캔버스 크기 = 논리 크기 × DPR
const actualWidth = fullWidth * devicePixelRatio;
const actualHeight = fullHeight * devicePixelRatio;
// 타일 간 겹침 영역 계산도 DPR 적용
const overlapPhysical = overlap * devicePixelRatio;
// ...
}
타일 스티칭에서 DPR을 잘못 처리하면 이미지가 어긋나거나 경계선이 보인다.
구현: Pixi.js 캔버스 렌더링
캔버스에 이미지를 렌더링할 때도 DPR을 고려해야 선명하게 보인다.
// usePixiApp.ts
const app = new Application();
await app.init({
resolution: window.devicePixelRatio || 1, // DPR 적용
autoDensity: true, // CSS 크기와 캔버스 해상도 자동 매칭
});
autoDensity: true가 핵심이다. 이 옵션이 없으면 레티나 디스플레이에서 이미지가 흐릿하게 보인다.
핵심은 "논리 픽셀"과 "물리 픽셀"의 분리
DPR 문제를 다루면서 깨달은 건, 결국 두 개념을 명확히 분리하는 게 핵심이라는 것이다.
| 개념 | 용도 | 예시 |
|---|---|---|
| 논리 픽셀 | UI 표시, 좌표 계산, 사용자 인식 | 1440×900 |
| 물리 픽셀 | 실제 이미지 저장, 픽셀 비교 | 5760×3600 |
이 둘을 연결하는 게 scale 값이다.
물리 픽셀 = 논리 픽셀 × scale
논리 픽셀 = 물리 픽셀 ÷ scale
이 관계만 일관되게 유지하면 어떤 해상도의 이미지가 들어와도 대응할 수 있다. Figma가 scale=8을 지원하게 되어도, 새로운 디바이스가 DPR=4를 사용하게 되어도, scale 값만 제대로 기록하면 된다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd' 카테고리의 다른 글
| Turborepo, 작은 프로젝트에서 쓸 이유가 있을까? (0) | 2026.03.19 |
|---|---|
| Spring Physics로 오뚜기 애니메이션 구현하기 (0) | 2026.03.10 |
| shadcn/ui 도입기: Headless UI로 컴포넌트 제어권 되찾기 (0) | 2026.02.24 |
| Marquee Selection (범위 선택) 구현하기 (0) | 2026.02.21 |
| Pixi.js 다중 선택 드래그 구현하기 (0) | 2026.02.19 |
