웹앱에서 iframe 내부의 정보를 가져오려면 어떻게 해야 할까? 일반적으로 postMessage를 쓰면 되지만, cross-origin iframe이라면 Content Security Policy에 막힌다. pixelDiff는 디자인 시안과 실제 웹사이트를 비교하는 서비스라서, 사용자가 입력한 URL을 iframe으로 띄워야 한다. 당연히 cross-origin이고, iframe 내부의 높이, URL 변화, 스크린샷까지 가져와야 했다.
선택지: 직접 통신 vs 중계자
| 방식 | 동작 | 한계 |
|---|---|---|
| postMessage 직접 | iframe ↔ parent | cross-origin이면 수신 불가 |
| Chrome Extension | content script가 양쪽에 주입 | 구조가 복잡해짐 |
| 서버 중계 | WebSocket으로 연결 | 실시간성 부족, 인프라 부담 |
cross-origin iframe과 통신하려면 중계자가 필요하다. 서버를 거치면 지연이 생기고, 스크린샷처럼 대용량 데이터를 실시간으로 주고받기 어렵다. Chrome Extension은 content script를 모든 페이지에 주입할 수 있어서, iframe 내부와 parent 페이지 양쪽에 "중계자"를 심을 수 있다.
Manifest V3 아키텍처: 왜 4단계인가
MV2에서 MV3로 넘어오면서 background page가 service worker로 바뀌었다. DOM 접근이 불가능해지고, 메시지 기반 통신이 필수가 됐다.
pixelDiff의 메시지 흐름은 이렇다:
[Webapp]
↕ postMessage
[Content Script - Parent]
↕ chrome.runtime.sendMessage / onMessage
[Background (Service Worker)]
↕ chrome.tabs.sendMessage / onMessage
[Content Script - iframe]각 계층이 필요한 이유:
| 계층 | 역할 | 왜 필요한가 |
|---|---|---|
| Webapp | UI, 비즈니스 로직 | React/Next.js 앱 |
| Parent Content Script | webapp ↔ background 중계 | webapp은 chrome API 접근 불가 |
| Background | 탭 간 통신, 스크린샷 | captureVisibleTab은 background에서만 가능 |
| iframe Content Script | iframe 내부 정보 수집 | cross-origin이라 parent에서 접근 불가 |
직접 접근할 수 없는 영역이 있어서 중계가 필요하다. webapp은 chrome API를 못 쓰고, parent는 cross-origin iframe 내부를 못 보고, background는 DOM을 못 읽는다.
manifest.json 설정
{
"manifest_version": 3,
"background": {
"service_worker": "src/background/index.ts",
"type": "module"
},
"permissions": [
"activeTab",
"declarativeNetRequest",
"declarativeNetRequestWithHostAccess"
],
"host_permissions": ["<all_urls>"],
"externally_connectable": {
"matches": [
"https://pixeldiff.turtle-tail.com/*",
"http://localhost:3000/*"
]
},
"content_scripts": [
{
"matches": ["https://pixeldiff.turtle-tail.com/*", "http://localhost:3000/*"],
"js": ["src/content-scripts/parent/index.ts"],
"run_at": "document_end"
},
{
"matches": ["<all_urls>"],
"js": ["src/content-scripts/iframe/index.ts"],
"all_frames": true,
"run_at": "document_idle"
}
]
}
핵심 설정:
all_frames: true: iframe 내부에도 content script 주입externally_connectable: webapp에서 background로 직접 메시지 전송 가능declarativeNetRequest: CSP 헤더 제거용 (나중에 설명)
메시지 타입 중앙화
확장 프로그램 규모가 커지면 메시지 타입이 수십 개로 늘어난다. 타입 안전성을 위해 모든 메시지 타입을 한 곳에서 관리했다.
// shared/types/messages.ts
export const MessageType = {
// Background <-> Content Script
IFRAME_INFO: 'IFRAME_INFO',
IFRAME_UPDATE: 'IFRAME_UPDATE',
// Capture
CAPTURE_WITH_CROP: 'CAPTURE_WITH_CROP',
CAPTURE_REQUEST_BOUNDS: 'PIXELDIFF_CAPTURE_REQUEST_BOUNDS',
CAPTURE_COMPLETE: 'PIXELDIFF_CAPTURE_COMPLETE',
// ... 30개 이상의 메시지 타입
} as const;
export interface IframeInfoMessage {
type: typeof MessageType.IFRAME_INFO;
url: string;
height: number;
timestamp: number;
}
as const로 리터럴 타입을 유지하고, 인터페이스에서 typeof MessageType.IFRAME_INFO로 참조하면 오타를 컴파일 타임에 잡을 수 있다.
iframe 정보 수집: Height Reporter
iframe 내부 content script가 페이지 높이를 측정해서 background로 보낸다.
// content-scripts/iframe/handlers/height-reporter.ts
export function setupHeightReporter(): void {
const reportHeight = () => {
const height = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight
);
chrome.runtime.sendMessage({
type: 'IFRAME_INFO',
url: window.location.href,
height,
timestamp: Date.now(),
});
};
// 초기 보고
reportHeight();
// ResizeObserver로 변화 감지
const observer = new ResizeObserver(debounce(reportHeight, 100));
observer.observe(document.body);
}
Background: 메시지 릴레이
Background가 iframe에서 온 메시지를 parent content script로 전달한다.
// background/handlers/message-handler.ts
export function setupMessageHandler(): void {
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
const tabId = sender.tab?.id;
if (message.type === 'IFRAME_INFO') {
if (!tabId) {
sendResponse({ success: false, error: 'No tab ID' });
return;
}
// Parent content script로 전달
chrome.tabs.sendMessage(tabId, {
type: 'IFRAME_UPDATE',
url: message.url,
height: message.height,
frameId: sender.frameId,
timestamp: message.timestamp,
});
sendResponse({ success: true });
return true;
}
return true;
}
);
}
sender.tab.id로 어느 탭에서 온 메시지인지 알 수 있고, sender.frameId로 어느 iframe인지 구분한다.
Parent Content Script: Webapp으로 전달
Parent content script가 background에서 온 메시지를 webapp으로 postMessage한다.
// content-scripts/parent/handlers/message-relay.ts
export function setupMessageRelay(): void {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'IFRAME_UPDATE') {
// Webapp으로 전달
window.postMessage({
type: 'PIXELDIFF_IFRAME_UPDATE',
source: 'pixeldiff-extension',
url: message.url,
height: message.height,
frameId: message.frameId,
timestamp: message.timestamp,
}, '*');
sendResponse({ success: true });
}
return true;
});
}
source: 'pixeldiff-extension'을 넣어서 webapp이 확장 프로그램에서 온 메시지인지 구분한다.
MV3에서 스크린샷: OffscreenCanvas
MV2에서는 background page에서 document.createElement('canvas')를 쓸 수 있었다. MV3에서는 DOM이 없어서 OffscreenCanvas를 써야 한다.
// background/services/capture-service.ts
export async function captureAndCrop(
tabId: number,
bounds: Bounds,
devicePixelRatio: number
): Promise<CaptureResult> {
return new Promise((resolve) => {
chrome.tabs.captureVisibleTab({ format: 'png' }, async (dataUrl) => {
if (chrome.runtime.lastError) {
resolve({ success: false, error: chrome.runtime.lastError.message });
return;
}
// dataUrl → Blob → ImageBitmap
const res = await fetch(dataUrl);
const blob = await res.blob();
const imageBitmap = await createImageBitmap(blob);
// DPR 고려한 크롭 영역 계산
const dpr = devicePixelRatio;
const cropX = bounds.x * dpr;
const cropY = bounds.y * dpr;
const cropWidth = bounds.width * dpr;
const cropHeight = bounds.height * dpr;
// OffscreenCanvas로 크롭
const canvas = new OffscreenCanvas(cropWidth, cropHeight);
const ctx = canvas.getContext('2d');
ctx.drawImage(
imageBitmap,
cropX, cropY, cropWidth, cropHeight,
0, 0, cropWidth, cropHeight
);
// Blob → base64 dataURL
const croppedBlob = await canvas.convertToBlob({ type: 'image/png' });
const reader = new FileReader();
reader.onloadend = () => {
resolve({ success: true, imageData: reader.result as string });
};
reader.readAsDataURL(croppedBlob);
});
});
}
captureVisibleTab은 전체 화면을 캡처하고, iframe 영역만 잘라내야 한다. Parent content script가 iframe의 getBoundingClientRect()를 보내주면, background에서 해당 영역만 크롭한다.
CSP 우회: declarativeNetRequest
일부 사이트는 Content-Security-Policy: frame-ancestors 'self'로 iframe 삽입을 막는다. 이걸 우회하려면 응답 헤더에서 CSP를 제거해야 한다.
MV2에서는 webRequest API로 실시간으로 헤더를 수정했다. MV3에서는 declarativeNetRequest로 선언적 규칙을 등록해야 한다.
// background/services/csp-analyzer.ts
export async function addCSPRemovalRule(domain: string): Promise<void> {
const ruleId = DYNAMIC_RULE_BASE_ID + ++dynamicRuleCounter;
const rule: chrome.declarativeNetRequest.Rule = {
id: ruleId,
priority: 2,
action: {
type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
responseHeaders: [
{
header: 'Content-Security-Policy',
operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE,
},
{
header: 'Content-Security-Policy-Report-Only',
operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE,
},
],
},
condition: {
urlFilter: `||${domain}`,
resourceTypes: [chrome.declarativeNetRequest.ResourceType.SUB_FRAME],
},
};
await chrome.declarativeNetRequest.updateDynamicRules({
addRules: [rule],
});
}
모든 도메인에 CSP 제거 규칙을 미리 등록하면 보안 위험이 있다. 그래서 webapp이 iframe을 로드하기 전에 해당 도메인의 CSP를 분석하고, 필요한 경우에만 동적으로 규칙을 추가한다.
외부 메시지: externally_connectable
Webapp이 content script를 거치지 않고 background에 직접 메시지를 보내는 방법도 있다.
// background/handlers/external-handler.ts
export function setupExternalHandler(): void {
chrome.runtime.onMessageExternal.addListener(
(message, sender, sendResponse) => {
// Origin 검증
const senderOrigin = new URL(sender.url || '').origin;
if (!ALLOWED_ORIGINS.includes(senderOrigin)) {
console.warn('Rejected message from unauthorized origin:', senderOrigin);
return;
}
if (message.type === 'PIXELDIFF_PING') {
sendResponse({
type: 'PIXELDIFF_PONG',
version: chrome.runtime.getManifest().version,
});
return true;
}
if (message.type === 'ANALYZE_CSP') {
handleCSPAnalysis(message.url).then(sendResponse);
return true;
}
}
);
}
Webapp에서는 이렇게 호출한다:
// webapp에서
chrome.runtime.sendMessage(
EXTENSION_ID,
{ type: 'PIXELDIFF_PING' },
(response) => {
console.log('Extension version:', response.version);
}
);
externally_connectable에 등록된 origin만 이 방식을 쓸 수 있다.
핵심은 계층 분리
MV3 Chrome Extension에서 복잡한 통신 구조를 만들 때 중요한 건 "누가 뭘 할 수 있는가"를 명확히 구분하는 것이다.
- Webapp: chrome API 접근 불가
- Content Script: chrome.runtime만 가능, tabs 접근 불가
- Background: DOM 접근 불가, OffscreenCanvas 사용
- iframe Content Script: cross-origin이라 parent와 직접 통신 불가
각 계층의 한계를 인식하고, 메시지 릴레이로 연결하면 된다. 메시지 타입을 중앙에서 관리하면 규모가 커져도 추적이 가능하다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'토이프로젝트' 카테고리의 다른 글
| postMessage로 다중 iframe 스크롤 동기화 구현하기 (0) | 2026.03.12 |
|---|---|
| 사이드 프로젝트 DB로 Supabase를 선택한 이유 (0) | 2026.03.05 |
| pnpm Workspace로 웹앱과 Chrome Extension 모노레포 구성하기 (0) | 2026.03.03 |
| 서비스 웹앱과 Chrome Extension 간 버전 호환성 관리하기 (0) | 2026.02.28 |
| 서버와 DB 리전, 같이 두면 얼마나 빨라질까? (0) | 2026.02.14 |
