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');

메시지 받기

postMessage로 전송된 메시지는 수신 측 컨텍스트에 message 이벤트로 도달합니다. 이 이벤트를 처리하려면 미리 이벤트 리스너를 등록해두어야 합니다.

리스너를 등록하는 대상 (컨텍스트별)

어떤 객체에 리스너를 붙이는지는 내가 누구인지(수신 컨텍스트) 에 따라 달라집니다.

수신 컨텍스트 리스너 등록 대상 설명
일반 페이지 (iframe·팝업으로부터) window 브라우저 탭/페이지의 전역 객체
iframe 내부 (부모로부터) window iframe 안에서도 전역은 window
팝업 창 내부 (오프너로부터) window 동일하게 window
Web Worker 내부 self (또는 this) Worker 내에는 window가 없음
메인 스레드 (Worker 인스턴스로부터) worker 인스턴스 new Worker()로 생성한 객체
메인 스레드 (Service Worker로부터) navigator.serviceWorker SW 전용 이벤트 소스
[부모 페이지]                [iframe / 팝업 / Worker]
window ──postMessage()──▶ window / self
window ◀──message event── window.parent.postMessage()

onmessage vs addEventListener

리스너를 등록하는 방식은 두 가지입니다. 동작 차이를 이해하고 상황에 맞게 선택하세요.

// onmessage: 속성에 직접 할당 — 하나의 핸들러만 유지됨
window.onmessage = handler1;
window.onmessage = handler2; // handler1은 제거되고 handler2만 남음

// addEventListener: 여러 핸들러를 누적 등록 가능
window.addEventListener('message', handler1);
window.addEventListener('message', handler2); // 둘 다 호출됨
구분 onmessage addEventListener
복수 등록 불가 (마지막 할당만 유효) 가능
제거 방법 onmessage = null removeEventListener(handler)
once / signal 옵션 없음 있음
권장 사용처 Worker 내부처럼 단순한 경우 페이지, React 컴포넌트 등

등록 타이밍: 리스너는 postMessage를 호출하기 에 등록되어 있어야 합니다. 특히 페이지 로드 직후에 메시지를 보내는 경우, 수신 측이 아직 준비되지 않았을 수 있으므로 iframe의 load 이벤트나 수신 측의 “준비 완료” 메시지를 기다리는 패턴을 사용합니다(아래 예제 참고).

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