React와 iframe 사이 postMessage 통신을 실제로 구현해본 이야기
게임 콘텐츠를 iframe으로 띄우고 React와 postMessage로 메시지를 주고받은 구현 기록이다. 양쪽 송수신 코드와 origin 검사, 그리고 이 방식이 임시방편인 이유를 정리했다.
#React #Iframe
React 앱을 운영하다 보면 iframe을 써야 하는 상황이 생각보다 자주 생긴다. 특히 예전에 순수 JS로 따로 만든 페이지나 게임 콘텐츠, 외부 위젯 같은 걸 안에 넣어야 할 때 iframe은 거의 유일한 선택지다. 내가 진행하던 프로젝트에도 게임 콘텐츠가 들어가는데, 이 게임은 HTML, CSS, JS로만 만들어진 독립형 구조였다. React 안에 바로 붙일 수 있는 구조가 아니라서 결국 iframe으로 감싸서 띄우는 방식으로 처리하게 됐다. 근데 문제는 거기서 끝이 아니었다. 그냥 보여주기만 하면 되는 거라면 iframe 태그 하나로 끝났겠지만, 실제로는 게임이 끝난 뒤 부모인 React에게 완료 신호를 보내거나, React 쪽에서 게임 안으로 데이터를 넘겨줘야 하는 상황이 계속 생겼다. 이번 글에서는 iframe과 React 사이에서 메시지를 주고받는 방식을 어떻게 짰는지, 그리고 이 방식이 임시방편으로는 쓸 만하지만 오래 가져갈 구조는 아니라고 보는 이유까지 적어본다. 언제 쓰냐면 React 앱 안에 외부 HTML(게임, 외부 콘텐츠 등)을 iframe으로 넣었을 때 iframe 안에서 일어난 사용자 행동(미션 완료, 버튼 클릭)을 React로 알려야 할 때 React에서 iframe 안으로 상태 변경이나 명령(시작, 정지 등)을 내려보내야 할 때 브라우저는 서로 다른 문서끼리 변수를 직접 건드리지 못하게 막아둔다. 그래서 iframe 안팎이 얘기를 주고받으려면 window.postMessage로 메시지를 보내고, 받는 쪽은 message 이벤트를 듣는 방법밖에 없다. 아래 코드 네 개가 그 조합의 전부다. React에서 iframe으로 메시지 보내기 React 쪽에서는 iframe에 ref를 걸어두고, contentWindow에 postMessage를 부른다. 게임이 다 로드된 다음에 보내야 하니까 onLoad에 걸었다. const AlphabetGame = () = { const sendMessage = (message: string) = { const win = iframeRef.current?.contentWindow; if (!win) return; win.postMessage({ type: message }, 'http://도메인주소'); console.log('Sent to iframe:', message); }; // iframe이 다 로드된 뒤에 보내야 안쪽에서 받을 수 있다 const handleIframeLoad = () = { sendMessage('react'); }; return ( div className='flex w-[100%] h-[100vh]' iframe ref={iframeRef} onLoad={handleIframeLoad} className='w-[100%] h-[100%]' draggable="false" src={'http://도메인주소/' + resrcFile.fpath + '/' + resrcFile.fname + '/index.html?id=' + id + ' lang=' + lang} /iframe /div ); } 처음에 type: "react" 메시지를 먼저 보내는 이유는 아래에서 다시 나온다. React에서 iframe이 보낸 메시지 받기 반대 방향은 window에 message 이벤트를 듣는 걸로 처리한다. 게임이 보낸 type에 따라 할 일을 나눴다. 미션이 끝났으면 이전 화면으로 돌아가고, 다음 활동을 달라고 하면 API를 부르는 식이다. useEffect(() = { const handleMessage = (event: MessageEvent) = { console.log(event.data); if (event.data?.type === "closeMission") { console.log("closeMission 수신됨"); navigate(-1); } else if (event.data?.type === "nextactivity") { getNextActivity().then((res) = { console.log(res); }); } else if (event.data?.type === "getMyInfo") { getMyInfo(user.id || '-1').then((res) = { console.log(res); }); } else if (event.data?.type === "addActivityLog") { addActivityLog(event.data?.id, event.data?.step).then((res) = { console.log(res); }); } }; window.addEventListener("message", handleMessage); return () = window.removeEventListener("message", handleMessage); }, []); 리스너는 컴포넌트가 사라질 때 꼭 떼야 한다. 안 떼면 화면을 오갈 때마다 리스너가 하나씩 쌓여서 메시지 하나에 같은 처리가 여러 번 돈다. iframe 안에서 React가 보낸 메시지 받기 게임 쪽은 React가 아니라 그냥 JS라서 window에 바로 리스너를 건다. 여기서는 React가 보낸 type이 "react"인지만 확인해서 변수에 담아둔다. window.addEventListener('message', function (event) { console.log('postMessage 받음:', event.data.type); // event.data가 null이나 undefined일 수 있으니 먼저 확인한다 if (event.data event.data.type === 'react') { _dataType = event.data.type; } console.log('postMessage 받음 끝'); }); iframe 안에서 React로 메시지 보내기 게임이 끝나면 부모 창, 그러니까 window.parent에 postMessage를 보낸다. 위에서 받아둔 _dataType이 "react"일 때만 보내게 했다. if (_dataType === 'react') { console.log("window.parent", window.parent); console.log("location.origin", location.origin); window.parent.postMessage( { type: "closeMission" }, "http://도메인주소" // 부모 origin을 정확히 적는다 ); console.log('postMessage 보냄:', { type: "closeMission", target: "http://도메인주소" }); } 주의할 점 postMessage의 두 번째 인자(targetOrigin)는 받는 쪽 주소를 정확히 적어야 한다. '*'로 두면 어느 사이트든 이 메시지를 받을 수 있어서 보안상 위험하다 받는 쪽도 event.origin을 확인해야 엉뚱한 곳에서 온 메시지를 걸러낼 수 있다 보내는 데이터는 { type: '...', payload: ... } 처럼 객체로 모양을 정해두는 게 좋다. 문자열로 보내면 나중에 종류가 늘어날 때 감당이 안 된다 메시지 종류가 많아지면 위처럼 if로 늘어놓지 말고 switch나 핸들러 객체로 정리하는 편이 낫다 왜 이 방식이 오래 쓸 구조는 아닌가 iframe과 postMessage 조합은 꽤 쓸 만한 수단이지만, 쓰다 보니 한계가 분명했다. 1. 보안 이슈 서로 다른 도메인 사이의 통신이기 때문에 targetOrigin을 정확히 설정하지 않으면 다른 사이트가 끼어들 여지가 생긴다. 무조건 정확한 origin을 적어야 하고, 받는 쪽에서도 확인해야 한다. 2. 상태 동기화가 어렵다 부모와 iframe이 각각 자기 상태를 따로 갖고 있어서, 맞추려면 메시지를 계속 보내고 받아야 한다. 구조가 커질수록 어느 쪽이 최신인지 헷갈리기 시작한다. 3. 디버깅이 불편하다 console.log로만 흐름을 따라가야 하고, 메시지 모양에 타입이 없으니 오타가 나도 그냥 조용히 아무 일도 안 일어난다. props나 hook으로 데이터를 넘기는 데 익숙한 React 개발자한테는 답답한 방식이다. 4. 결국 임시 연결이다 iframe으로 통신하는 구조는 임시방편일 뿐이고, 결국은 React로 완전히 옮기거나 외부 콘텐츠를 라이브러리 형태로 끌어오는 게 더 자연스럽다. 그런데도 왜 이렇게 구현했나 이 방식이 최선은 아니라는 건 알지만, 내가 맡은 프로젝트 구조상 iframe이 현실적인 선택지 였다. 기존 게임은 Webpack으로 번들링된 독립형 HTML/JS 구조였고 React 안으로 통합할 수 없는 구조였으며 프로젝트 일정상 전체 구조를 갈아엎는 것은 불가능했다 그래서 React에서 iframe으로 게임을 띄우고, postMessage로 통신만 주고받는 식으로 갔다. 그리고 React 쪽에서 type: "react" 메시지를 먼저 보내고, iframe 안에서 그걸 받은 뒤에야 부모에게 메시지를 보내도록 제한을 걸었다. 게임 파일은 그 자체로 열 수도 있는데, 그렇게 열렸을 때 부모도 없는 창에 대고 메시지를 보내지 않게 하려는 거였다. 위 코드에서 _dataType이 하는 일이 그거다. 나중에 만든 게임 CMS 화면이다. 목록에서 게임을 고르면 iframe 안에서 바로 실행된다. 같은 구조를 또 썼다 이 방식이 편해서 나중에 게임 CMS를 만들 때도 똑같이 iframe으로 붙였다. 그러면서 iframe이 왜 불편한지를 두 번째로 배웠다 . 첫째, 게임을 다시 시작시키려고 src를 같은 값으로 다시 넣었더니 아무 일도 안 일어났다. 브라우저 입장에서는 바뀐 게 없으니까. 결국 React key를 바꿔서 통째로 다시 만드는 걸로 해결했다. iframe key={reloadKey} // 이 값을 바꿔야 다시 로드된다 ref={iframeRef} src={gameSrc} allow="fullscreen; autoplay; gamepad; accelerometer; gyroscope" allowFullScreen / 둘째, 권한을 allow에 일일이 적어야 한다. 전체화면도, 소리 자동재생도, 게임패드도 안 적으면 iframe 안에서 그냥 안 된다. 게임에서 왜 소리가 안 나나 한참 찾았는데 이거였다. 셋째는 아직 못 고쳤다. ESC로 크게보기를 빠져나오게 해뒀는데, 키를 document에서 듣고 있어서 포커스가 iframe 안에 있으면 안 먹는다 . 게임을 하다가 ESC를 누르면 반응이 없고, 바깥을 한 번 클릭해야 먹는다. document.addEventListener('keydown', onKey) // iframe 안의 키는 여기 안 온다 이것도 postMessage로 안에서 밖으로 알려주면 되는데, 그러려면 게임 100개를 전부 다시 빌드해야 한다. 지금은 화면 바깥을 한 번 누르면 된다고 안내 문구로 알려두고, 게임 템플릿을 손볼 일이 생길 때 같이 넣으려고 한다.