여러 디바이스 프레임에서 동시에 웹사이트를 미리보기 하는 기능을 만들었다. iPhone, iPad, Galaxy 등 다양한 해상도의 iframe이 나란히 배치되고, 사용자가 하나를 스크롤하면 나머지도 따라 움직여야 한다.
문제는 iframe이 cross-origin이라는 것이다. 보안상 다른 origin의 iframe 내부에 직접 접근할 수 없다. DOM을 읽을 수도, 스크롤 위치를 설정할 수도 없다.
선택지
| 방식 | 설명 | 한계 |
|---|---|---|
| iframe.contentWindow.scrollTo() | 직접 스크롤 제어 | cross-origin 차단 |
| SharedWorker | 탭 간 통신 | iframe에서 사용 불가 |
| BroadcastChannel | 탭 간 통신 | same-origin만 가능 |
| postMessage | 윈도우 간 메시지 전달 | origin 검증 필요 |
cross-origin 환경에서 유일하게 작동하는 방식은 postMessage다.
아키텍처
┌─────────────────────────────────────────────────────┐
│ 웹 애플리케이션 (Hub) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ iPhone │ │ iPad │ │ Galaxy │ │
│ │ iframe │ │ iframe │ │ iframe │ │
│ │ │ │ │ │ │ │
│ │ Extension│ │ Extension│ │ Extension│ │
│ │ (content │ │ (content │ │ (content │ │
│ │ script) │ │ script) │ │ script) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └─────────────┼─────────────┘ │
│ │ │
│ postMessage 통신 │
└─────────────────────────────────────────────────────┘웹앱이 허브 역할을 한다. 각 iframe 내부에는 Chrome Extension의 content script가 주입되어 스크롤 이벤트를 감지하고, 부모 웹앱에게 전달한다. 웹앱은 이를 받아서 다른 모든 iframe에 브로드캐스트한다.
핵심 문제: 해상도가 다르다
iPhone과 iPad의 뷰포트 높이가 다르다. 픽셀 단위로 동기화하면 안 된다.
iPhone (375x812): scrollY = 500px → 전체 컨텐츠의 어디쯤?
iPad (1024x1366): scrollY = 500px → 완전히 다른 위치해결책은 비율 기반 동기화다. 절대 픽셀이 아니라 "전체 스크롤 가능 영역 중 몇 퍼센트 위치"로 표현한다.
function getScrollRatio(): { x: number; y: number } {
const scrollWidth = document.documentElement.scrollWidth - window.innerWidth;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
return {
x: scrollWidth > 0 ? window.scrollX / scrollWidth : 0,
y: scrollHeight > 0 ? window.scrollY / scrollHeight : 0,
};
}
scrollRatio는 0~1 사이 값이다. 0.5면 정확히 중간 위치다. 이 비율을 받은 쪽에서는 자신의 뷰포트에 맞게 실제 픽셀로 변환한다.
function getPositionFromRatio(ratio: { x: number; y: number }): { x: number; y: number } {
const scrollWidth = document.documentElement.scrollWidth - window.innerWidth;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
return {
x: Math.round(ratio.x * Math.max(0, scrollWidth)),
y: Math.round(ratio.y * Math.max(0, scrollHeight)),
};
}
무한 루프 방지
A가 스크롤 → B에 전달 → B가 스크롤 → A에 전달 → A가 스크롤 → ...
양방향 동기화에서 반드시 발생하는 문제다. 두 가지 방어 장치를 사용했다.
1. 동기화 플래그
let isSyncing = false;
// 스크롤 이벤트 발생 시
const handleScroll = () => {
if (isSyncing) return; // 동기화 중이면 무시
// 부모에게 스크롤 위치 전송
window.parent.postMessage({ scrollRatio: getScrollRatio() }, '*');
};
// 동기화 명령 수신 시
function handleScrollTo(scrollRatio) {
isSyncing = true; // 플래그 설정
window.scrollTo({
left: position.x,
top: position.y,
behavior: 'instant',
});
// 쿨다운 후 플래그 해제
setTimeout(() => {
isSyncing = false;
}, 50);
}
명령을 받아서 스크롤하는 동안에는 isSyncing이 true다. 이 상태에서 발생하는 스크롤 이벤트는 무시된다.
2. 쿨다운
let lastSyncTime = 0;
const SYNC_COOLDOWN = 100; // ms
const broadcastScrollCommand = (scrollRatio, sourceDeviceId) => {
const now = Date.now();
if (now - lastSyncTime < SYNC_COOLDOWN) return; // 쿨다운 중
lastSyncTime = now;
// 브로드캐스트 실행
};
100ms 이내 연속 호출은 무시한다. 빠른 스크롤에서 불필요한 메시지 폭탄을 방지한다.
소스 디바이스 제외
스크롤을 시작한 디바이스에게 다시 명령을 보내면 안 된다. 자기 자신은 이미 스크롤된 상태니까.
const broadcastScrollCommand = (scrollRatio, sourceDeviceId) => {
const iframes = document.querySelectorAll('iframe[data-device-iframe="true"]');
iframes.forEach((iframe) => {
// 소스 디바이스는 제외
if (sourceDeviceId && iframe.dataset.deviceId === sourceDeviceId) {
return;
}
iframe.contentWindow?.postMessage(
{ type: 'PIXELDIFF_SYNC_COMMAND', command: 'scrollTo', payload: { scrollRatio } },
'*'
);
});
};
각 iframe에 data-device-id 속성을 부여하고, 메시지에 sourceDeviceId를 포함시킨다. 브로드캐스트 시 이 ID와 일치하는 iframe은 건너뛴다.
iframe 내부 Content Script
Extension의 content script는 iframe 내부에서 실행된다. 스크롤 이벤트를 감지하고 부모에게 전달하는 역할이다.
// iframe 내부에서만 실행
if (window !== window.top) {
const handleScroll = throttle(() => {
if (isSyncing) return;
window.parent.postMessage({
type: 'PIXELDIFF_SYNC',
source: 'pixeldiff-extension',
event: 'scroll',
payload: {
scrollRatio: getScrollRatio(),
deviceId: window.name, // iframe의 name 속성으로 식별
},
}, '*');
}, 16); // ~60fps
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('message', handleMessage);
}
window.name을 deviceId로 사용한다. iframe에 name 속성을 설정하면 내부에서 window.name으로 접근할 수 있다. cross-origin에서도 작동하는 몇 안 되는 속성 중 하나다.
URL 동기화
스크롤뿐 아니라 URL 변경도 동기화해야 한다. 한 디바이스에서 링크를 클릭하면 다른 디바이스도 같은 페이지로 이동해야 한다.
let lastUrl = location.href;
function checkUrlChange(): void {
const currentUrl = location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
window.parent.postMessage({
type: 'PIXELDIFF_SYNC',
event: 'url-change',
payload: { url: currentUrl },
}, '*');
}
}
// 500ms마다 체크 + popstate/hashchange 이벤트 리스너
setInterval(checkUrlChange, 500);
window.addEventListener('popstate', checkUrlChange);
window.addEventListener('hashchange', checkUrlChange);
popstate와 hashchange만으로는 모든 경우를 커버할 수 없다. SPA에서 history.pushState를 직접 호출하면 이벤트가 발생하지 않는다. 그래서 500ms 폴링을 병행한다.
보안 고려사항
postMessage에서 origin 검증은 필수다. 누구나 메시지를 보낼 수 있기 때문이다.
// 메시지 수신 시 검증
function handleMessage(event: MessageEvent): void {
// 유효한 동기화 메시지인지 확인
if (
data?.type !== 'PIXELDIFF_SYNC' ||
data?.source !== 'pixeldiff-extension'
) {
return; // 무시
}
// 처리 로직
}
type과 source 필드로 메시지 출처를 검증한다. 실제 서비스라면 event.origin도 화이트리스트와 대조해야 한다.

핵심은
iframe 동기화의 핵심은 세 가지다.
- 비율 기반 좌표 - 해상도가 달라도 동일한 "상대 위치"로 동기화
- 루프 방지 -
isSyncingRef+ 쿨다운으로 이중 방어 - 소스 제외 - 자기 자신에게 재전송하지 않기
이 패턴은 다중 뷰를 동기화해야 하는 모든 상황에 적용할 수 있다. 코드 에디터의 분할 뷰, 발표 자료의 발표자/청중 뷰, 협업 도구의 실시간 커서 등.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'토이프로젝트' 카테고리의 다른 글
| Chrome Extension Manifest V3에서 웹앱-iframe 간 4단계 메시지 릴레이 구현하기 (0) | 2026.03.14 |
|---|---|
| 사이드 프로젝트 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 |
