JavaScript - postMessage 완벽 정리

postMessage란?

window.postMessage()서로 다른 origin을 가진 window 객체 사이에서 안전하게 메시지를 주고받을 수 있는 Web API입니다.

브라우저의 동일 출처 정책(Same-Origin Policy)은 iframe, 팝업, 다른 탭의 JavaScript가 서로 직접 접근하는 것을 막습니다. postMessage는 이 정책을 우회하지 않고, 명시적으로 허용된 출처 간에만 안전하게 통신하는 채널을 제공합니다.

postMessage를 사용하는 주요 상황

상황 송신 측 수신 측
iframe ↔ 부모 페이지 iframe.contentWindow / window.parent window
팝업 ↔ 오프너 페이지 window.opener window
다른 탭 ↔ 현재 탭 BroadcastChannel 또는 window.open 반환값 window
메인 스레드 ↔ Web Worker worker 인스턴스 self (Worker 내부)
메인 스레드 ↔ Service Worker navigator.serviceWorker self (SW 내부)

1. 기본 사용법

메시지 보내기

targetWindow.postMessage(message, targetOrigin, [transfer]);
파라미터 타입 설명
message any 전송할 데이터. 구조화 복제 알고리즘으로 직렬화됨
targetOrigin string 수신 대상의 origin. '*'이면 모든 origin 허용 (비권장)
transfer Transferable[] (선택) 소유권을 이전할 객체 목록 (ArrayBuffer 등)
// iframe에 메시지 보내기
const iframe = document.getElementById('myFrame');
iframe.contentWindow.postMessage(
{ type: 'GREET', name: '홍길동' },
'https://child-app.com' // 이 origin의 iframe만 수신 가능
);

// 부모 페이지에 메시지 보내기 (iframe 내부에서)
window.parent.postMessage(
{ type: 'READY' },
'https://parent-app.com'
);

// 팝업에 메시지 보내기
const popup = window.open('https://popup-app.com');
popup.postMessage({ type: 'INIT' }, 'https://popup-app.com');

메시지 받기

window.addEventListener('message', (event) => {
// 1. 반드시 origin을 검증해야 합니다
if (event.origin !== 'https://trusted-app.com') return;

// 2. 메시지 처리
const { type, payload } = event.data;

if (type === 'GREET') {
console.log(`안녕하세요, ${payload.name}님!`);
}

// 3. 발신자에게 응답 보내기
event.source.postMessage(
{ type: 'GREET_RESPONSE', message: '반갑습니다!' },
event.origin
);
});

2. MessageEvent 객체

message 이벤트 핸들러가 받는 MessageEvent 객체의 주요 속성입니다.

window.addEventListener('message', (event) => {
console.log(event.data); // 전송된 데이터
console.log(event.origin); // 발신자 origin: "https://sender.com"
console.log(event.source); // 발신자 window 참조 (응답에 사용)
console.log(event.ports); // MessageChannel 포트 배열 (고급 사용)
console.log(event.lastEventId); // Server-Sent Events에서 사용
});

event.origin vs event.source

속성 타입 용도
event.origin string 발신자 origin 검증 ("https://example.com" 형태)
event.source WindowProxy 발신자에게 응답 전송 (event.source.postMessage(...))

event.origin"null"인 경우: file:// 프로토콜, sandbox된 iframe, 일부 리다이렉트 상황에서 발생합니다. 이 경우 검증이 어려우므로 신뢰하지 않는 것이 안전합니다.


3. 보안: origin 검증

postMessage를 쓸 때 가장 중요한 원칙은 수신 측에서 반드시 origin을 검증하는 것입니다.

잘못된 코드 (취약)

// 위험: origin 검증 없이 모든 메시지를 처리
window.addEventListener('message', (event) => {
document.getElementById('output').innerHTML = event.data; // XSS 위험!
eval(event.data); // 절대 안 됨
});

올바른 코드 (안전)

// 허용할 origin을 명시적으로 선언
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);

window.addEventListener('message', (event) => {
// 1. origin 화이트리스트 검사
if (!ALLOWED_ORIGINS.has(event.origin)) {
console.warn(`허용되지 않은 origin: ${event.origin}`);
return;
}

// 2. 데이터 구조 검증 (타입/스키마 확인)
if (typeof event.data !== 'object' || !event.data.type) return;

const { type, payload } = event.data;

// 3. 메시지 타입별 처리 (허용된 타입만)
switch (type) {
case 'UPDATE_THEME':
applyTheme(payload.theme); // 안전한 처리
break;
default:
console.warn(`알 수 없는 메시지 타입: ${type}`);
}
});

보내는 쪽에서도 targetOrigin 지정

// 나쁜 예: '*'는 어느 사이트에서도 메시지를 가로챌 수 있음
iframe.contentWindow.postMessage(sensitiveData, '*');

// 좋은 예: 특정 origin만 수신 가능
iframe.contentWindow.postMessage(sensitiveData, 'https://trusted-child.com');

4. 활용 예제

예제 1: iframe ↔ 부모 페이지 통신

마이크로 프론트엔드나 써드파티 위젯을 iframe으로 임베드하고 통신하는 패턴입니다.

<!-- 부모 페이지 (https://parent.com) -->
<iframe id="widget" src="https://widget.com/embed"></iframe>

<script>
const iframe = document.getElementById('widget');

// iframe이 로드된 후 메시지 전송
iframe.addEventListener('load', () => {
iframe.contentWindow.postMessage(
{ type: 'INIT', userId: 'user-123', theme: 'dark' },
'https://widget.com'
);
});

// iframe으로부터 이벤트 수신
window.addEventListener('message', (event) => {
if (event.origin !== 'https://widget.com') return;

const { type, payload } = event.data;

if (type === 'PURCHASE_COMPLETE') {
// 위젯에서 결제가 완료되었을 때 부모가 처리
updateOrderSummary(payload.orderId);
trackAnalyticsEvent('purchase', payload);
}
});
</script>
// iframe 내부 (https://widget.com/embed)
window.addEventListener('message', (event) => {
if (event.origin !== 'https://parent.com') return;

const { type, payload } = event.data;

if (type === 'INIT') {
initializeWidget(payload.userId, payload.theme);
}
});

// 부모에게 이벤트 알림
function onPurchaseComplete(orderId) {
window.parent.postMessage(
{ type: 'PURCHASE_COMPLETE', payload: { orderId } },
'https://parent.com'
);
}

예제 2: 팝업 ↔ 오프너 통신 (OAuth 흐름)

OAuth 같은 인증 플로우에서 팝업 창에서 결과를 받아 원본 창에 전달하는 패턴입니다.

// 오프너 페이지 (https://myapp.com)
function loginWithOAuth(provider) {
const popup = window.open(
`https://auth.myapp.com/oauth/${provider}`,
'oauthPopup',
'width=500,height=600'
);

return new Promise((resolve, reject) => {
const handler = (event) => {
if (event.origin !== 'https://auth.myapp.com') return;
if (event.source !== popup) return; // 이 팝업에서 온 메시지만 처리

window.removeEventListener('message', handler);

if (event.data.type === 'OAUTH_SUCCESS') {
resolve(event.data.token);
} else if (event.data.type === 'OAUTH_FAILURE') {
reject(new Error(event.data.error));
}
};

window.addEventListener('message', handler);

// 팝업이 닫히면 reject
const timer = setInterval(() => {
if (popup.closed) {
clearInterval(timer);
window.removeEventListener('message', handler);
reject(new Error('팝업이 닫혔습니다'));
}
}, 500);
});
}

// 사용
loginWithOAuth('google')
.then((token) => {
console.log('로그인 성공, 토큰:', token);
initUserSession(token);
})
.catch((err) => console.error('로그인 실패:', err));
// 팝업 창 내부 (https://auth.myapp.com/oauth/google)
// 인증 완료 후 결과를 오프너에 전달하고 팝업 닫기
function sendResultAndClose(token) {
window.opener.postMessage(
{ type: 'OAUTH_SUCCESS', token },
'https://myapp.com'
);
window.close();
}

function sendErrorAndClose(error) {
window.opener.postMessage(
{ type: 'OAUTH_FAILURE', error },
'https://myapp.com'
);
window.close();
}

예제 3: Web Worker 통신

CPU를 많이 쓰는 작업을 Web Worker로 분리할 때 postMessage로 데이터를 주고받습니다.

// main.js (메인 스레드)
const worker = new Worker('./image-processor.worker.js');

// Worker로부터 결과 수신
worker.addEventListener('message', (event) => {
const { type, payload } = event.data;

if (type === 'PROCESS_COMPLETE') {
displayProcessedImage(payload.imageData);
} else if (type === 'PROGRESS') {
updateProgressBar(payload.percent);
}
});

// Worker에 작업 요청
async function processImage(file) {
// ArrayBuffer로 변환 후 Worker에 전달
const buffer = await file.arrayBuffer();

// transfer: 복사 없이 소유권을 Worker로 이전 (성능 최적화)
worker.postMessage(
{ type: 'PROCESS_IMAGE', buffer, filter: 'grayscale' },
[buffer] // transfer 목록: 이후 메인 스레드에서 buffer 접근 불가
);
}
// image-processor.worker.js (Worker 스레드)
self.addEventListener('message', (event) => {
const { type, buffer, filter } = event.data;

if (type === 'PROCESS_IMAGE') {
// 무거운 이미지 처리 (메인 스레드 블로킹 없음)
const result = applyFilter(buffer, filter);

// 처리 완료 후 결과를 메인 스레드로 전달
// 마찬가지로 transfer로 소유권 이전
self.postMessage(
{ type: 'PROCESS_COMPLETE', imageData: result },
[result.buffer]
);
}
});

function applyFilter(buffer, filter) {
const data = new Uint8ClampedArray(buffer);

if (filter === 'grayscale') {
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = data[i + 1] = data[i + 2] = avg;
}
}

// 진행률 보고
self.postMessage({ type: 'PROGRESS', payload: { percent: 100 } });

return new ImageData(data, 1920, 1080); // 예시
}

예제 4: Service Worker 통신

Service Worker와 페이지 간에 캐시 상태, 업데이트 알림, 백그라운드 동기화 결과 등을 주고받을 때 사용합니다.

// 페이지 측 (app.js)

// Service Worker에 메시지 보내기
async function sendToServiceWorker(message) {
if (!navigator.serviceWorker.controller) return;

navigator.serviceWorker.controller.postMessage(message);
}

// Service Worker로부터 메시지 받기
navigator.serviceWorker.addEventListener('message', (event) => {
const { type, payload } = event.data;

switch (type) {
case 'SW_UPDATED':
// 새 Service Worker가 활성화됨 → 새로고침 유도
showUpdateBanner('새 버전이 있습니다. 새로고침 해주세요.');
break;

case 'SYNC_COMPLETE':
// 백그라운드 동기화 완료
console.log('오프라인 중 저장된 데이터가 서버에 동기화됐습니다');
refreshDataFromServer();
break;

case 'CACHE_STATUS':
console.log(`캐시된 리소스: ${payload.count}개`);
break;
}
});

// 캐시 상태 조회
sendToServiceWorker({ type: 'GET_CACHE_STATUS' });
// service-worker.js
self.addEventListener('message', (event) => {
const { type } = event.data;

if (type === 'GET_CACHE_STATUS') {
caches.keys().then((cacheNames) => {
// 메시지를 보낸 클라이언트에게만 응답
event.source.postMessage({
type: 'CACHE_STATUS',
payload: { count: cacheNames.length, names: cacheNames },
});
});
}

if (type === 'SKIP_WAITING') {
self.skipWaiting();
}
});

// 모든 클라이언트(페이지)에 브로드캐스트
async function notifyAllClients(message) {
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach((client) => client.postMessage(message));
}

// 활성화 시 모든 탭에 알림
self.addEventListener('activate', (event) => {
event.waitUntil(
self.clients.claim().then(() => {
notifyAllClients({ type: 'SW_UPDATED' });
})
);
});

예제 5: MessageChannel로 직접 채널 연결

MessageChannel을 사용하면 두 컨텍스트 사이에 **전용 통신 채널(포트)**을 만들 수 있습니다. 여러 iframe이 있을 때 특정 iframe과만 통신하거나, Worker끼리 직접 통신할 때 유용합니다.

// 부모 페이지
const iframe = document.getElementById('myFrame');

iframe.addEventListener('load', () => {
const channel = new MessageChannel();

// port1은 부모가 보유, port2는 iframe에 전달
const port1 = channel.port1;
const port2 = channel.port2;

// port1으로 메시지 수신
port1.onmessage = (event) => {
console.log('iframe으로부터:', event.data);
};

// port2를 iframe에 전달 (transfer로 소유권 이전)
iframe.contentWindow.postMessage(
{ type: 'INIT_CHANNEL' },
'https://child.com',
[port2]
);

// 이제 port1으로 바로 메시지 전송 (origin 지정 불필요)
port1.postMessage({ text: '포트로 직접 보내는 메시지' });
});
// iframe 내부
window.addEventListener('message', (event) => {
if (event.origin !== 'https://parent.com') return;
if (event.data.type !== 'INIT_CHANNEL') return;

// 전달받은 port2로 통신
const port = event.ports[0];

port.onmessage = (event) => {
console.log('부모로부터:', event.data);
port.postMessage({ reply: '응답입니다' });
};

port.start(); // onmessage 대신 addEventListener를 쓸 경우 필요
});

5. 구조화 복제 알고리즘 (Structured Clone)

postMessage는 데이터를 구조화 복제 알고리즘(Structured Clone Algorithm) 으로 직렬화합니다. JSON.stringify보다 더 많은 타입을 지원하지만, 복사본이 만들어지므로 성능에 주의해야 합니다.

전송 가능한 타입

// 기본 타입
postMessage(42);
postMessage('hello');
postMessage(true);
postMessage(null);

// 복합 타입 (깊은 복사로 전달됨)
postMessage({ a: 1, b: [2, 3] });
postMessage([1, 2, { x: 3 }]);

// 특수 타입 (JSON으로 표현 불가능한 것들도 지원)
postMessage(new Date());
postMessage(new Map([['key', 'value']]));
postMessage(new Set([1, 2, 3]));
postMessage(new Uint8Array([1, 2, 3]));
postMessage(/regex/g); // RegExp
postMessage(new Error('에러')); // Error 객체

// ArrayBuffer - 복사 또는 이전(transfer) 가능
const buffer = new ArrayBuffer(1024);
postMessage(buffer); // 복사
postMessage(buffer, [buffer]); // 이전 (원본 무효화됨)

전송 불가능한 타입

// 함수는 전송할 수 없음
postMessage(() => {}); // 에러 발생

// DOM 노드는 전송할 수 없음
postMessage(document.body); // 에러 발생

// Symbol은 전송할 수 없음
postMessage(Symbol('key')); // 에러 발생

// 순환 참조는 전송할 수 없음 (JSON.stringify와 동일)
const obj = {};
obj.self = obj;
postMessage(obj); // 에러 발생

Transfer vs Copy 성능 비교

const largeBuffer = new ArrayBuffer(100 * 1024 * 1024); // 100MB

// 복사 전송: 메모리 사용량 2배, 시간 오래 걸림
worker.postMessage({ buffer: largeBuffer });

// 이전 전송: 메모리 복사 없음, 거의 즉시 완료
// 단, 이전 후 largeBuffer는 byteLength === 0 이 됨 (무효화)
worker.postMessage({ buffer: largeBuffer }, [largeBuffer]);
console.log(largeBuffer.byteLength); // 0

6. 주의사항 및 트러블슈팅

origin이 null인 경우

event.origin === 'null'(문자열) 인 경우가 있습니다. null(값)과 다르므로 주의하세요.

window.addEventListener('message', (event) => {
// null origin은 file://, sandbox iframe, 일부 리다이렉트에서 발생
if (event.origin === 'null') {
// 신뢰할 수 없으므로 무시
return;
}
// ...
});

iframe이 로드되기 전에 postMessage를 보내는 경우

const iframe = document.getElementById('myFrame');

// 잘못된 예: iframe 로드 전에 메시지를 보내면 수신되지 않음
iframe.contentWindow.postMessage({ type: 'INIT' }, '*');

// 올바른 예: load 이벤트 이후 또는 준비 완료 메시지를 기다림
iframe.addEventListener('load', () => {
iframe.contentWindow.postMessage({ type: 'INIT' }, 'https://child.com');
});

// 또는 iframe 측에서 준비됐을 때 먼저 알림을 보내는 방식
window.addEventListener('message', (event) => {
if (event.origin !== 'https://child.com') return;

if (event.data.type === 'IFRAME_READY') {
// iframe이 준비됐다고 알려줬을 때 초기화 메시지 전송
event.source.postMessage({ type: 'INIT', config }, event.origin);
}
});

이벤트 리스너 중복 등록

// 잘못된 예: 리렌더링/재호출 시마다 리스너가 누적됨
function setupListener() {
window.addEventListener('message', handler);
}

// 올바른 예 1: 플래그로 중복 방지
let listenerRegistered = false;
function setupListener() {
if (listenerRegistered) return;
listenerRegistered = true;
window.addEventListener('message', handler);
}

// 올바른 예 2: 기존 리스너를 먼저 제거 후 등록
function setupListener() {
window.removeEventListener('message', handler);
window.addEventListener('message', handler);
}

// 올바른 예 3: AbortController로 일괄 제거
const controller = new AbortController();
window.addEventListener('message', handler, { signal: controller.signal });

// 필요 없어지면 한 번에 제거
controller.abort();

React에서 이벤트 리스너 관리

// useEffect로 컴포넌트 생명주기에 맞춰 등록/해제
function PaymentWidget() {
const [status, setStatus] = useState(null);

useEffect(() => {
const handler = (event) => {
if (event.origin !== 'https://payment.example.com') return;
if (event.data.type === 'PAYMENT_RESULT') {
setStatus(event.data.payload.status);
}
};

window.addEventListener('message', handler);

// 컴포넌트 언마운트 시 자동 해제
return () => window.removeEventListener('message', handler);
}, []); // 빈 배열: 마운트/언마운트 시에만 실행

return <div>결제 상태: {status}</div>;
}

7. BroadcastChannel: 같은 origin 내 탭 간 통신

같은 origin의 여러 탭/윈도우끼리 통신할 때는 postMessage 대신 BroadcastChannel이 더 편리합니다.

// 모든 탭에서 동일한 채널 이름으로 연결
const channel = new BroadcastChannel('app-channel');

// 같은 origin의 다른 탭들에 브로드캐스트 (자신 제외)
channel.postMessage({ type: 'USER_LOGGED_OUT' });

// 다른 탭으로부터 메시지 수신
channel.onmessage = (event) => {
if (event.data.type === 'USER_LOGGED_OUT') {
clearLocalSession();
redirectToLogin();
}
};

// 더 이상 필요 없을 때 채널 닫기
channel.close();

postMessage vs BroadcastChannel

항목 postMessage BroadcastChannel
대상 특정 window 참조 필요 이름만 알면 됨
origin 다른 origin 간 통신 가능 같은 origin만
자기 자신 자신에게도 전송 가능 자신은 수신 제외
활용 iframe, 팝업, Worker 같은 사이트 내 다른 탭

요약

  • postMessage는 다른 origin 간 안전한 통신을 위한 Web API로, iframe·팝업·Worker 등 다양한 컨텍스트에서 사용된다.
  • 수신 측에서 반드시 event.origin을 검증해야 하며, 발신 측도 '*' 대신 구체적인 targetOrigin을 지정해야 한다.
  • 데이터는 구조화 복제 알고리즘으로 직렬화되어 깊은 복사본으로 전달되며, ArrayBuffertransfer로 소유권을 이전하면 복사 비용을 없앨 수 있다.
  • MessageChannel을 사용하면 두 컨텍스트 사이에 전용 포트를 만들어 더 구조화된 통신이 가능하다.
  • 같은 origin의 여러 탭 간 통신에는 BroadcastChannel이 더 적합하다.
  • 이벤트 리스너는 사용이 끝나면 반드시 제거해 메모리 누수를 방지한다.
Share