[JS] iframe 간 통신 (MessageChannel API)

2021. 10. 8. 20:46업무관련

728x90
반응형

하나의 문서에는 하나의 window 를 가지게된다.

그렇다면, 서로 다른 iframe 에서 window 는 어떻게 구분될까?

📖 Browsing Context

Browsing Context 는 브라우저가 표시하는 환경이다.

브라우저의 각각의 Browsing Context 에는 고유 한 Session History 와 Document 를 가진다.

iframe 도 독립적인 Browsing Context 를 가진다.

각각 Browsing Context 는 연관된 WindowProxy 객체가 있다. WindowProxy 는 Window 를 가르키는 객체다.

​window.top: 최상위 브라우징 컨텍스트
window.parent: 부모 브라우징 컨텍스트
window.frameElement: 부모 브라우징 컨텍스트 Elements 를 반환한다.

 

📖 Iframe window

iframe 에 로드된 페이지는 부모 페이지와 별개의 페이지다.

때문에 부모 window context 와는 별개의 window context 를 가진다.

하나의 페이지에서는 여러 iframe 을 포함 할 수 있다.

 

전체 iframe 목록을 확인하기위해서 document.getElementsByTagName('iframe') 혹은 window.frames 으로 확인이 가능하다. 이때 window.frames 은 ArrayLike 인 frameList 를 반환한다.

각각의 부모 페이지에서 iframe 의 window 에 접근하기 위해서는 contentWindow 로 접근할 수 있다.

이 contentWindow 속성은 HTMLIFrameElement 의 Window 개체를 반환 하게 되며, 이 개체를 사용하여 iframe의 문서 및 내부 DOM에 액세스 및 조작할 수 있다.

document.getElementsByTagName('iframe')[0].contentWindow

window.frames[0].contentWindow // iframe의 DOM에 액세스 했다. 이제 여기서 node를 접근해서 조작하면 된다.

 

만약 iframe 에서 부모 window 에 접근해야 한다면 어떻게 해야할까?

각각의 iframe 의 window 는 window 로 접근이 가능하며, 부모 window 에 접근하기 위해서는 window.parent 로 접근이 가능하다.

이때 주의할점은 script 를 사용하기 때문에, sandbox 를 세팅했다면, “allow-scripts allow-same-origin” 설정이 필요하다.


📗 Iframe 과 Dom

브라우저에서 HTML 을 전달받으면, DOM 을 Parsing 하고, Render Tree 를 만들게 되고, RenderLayer Tree 로 화면을 표현하게 된다.

이때, Iframe 을 만나게 되면 어떻게 될까?

DOM Parsing 중 iframe 을 만나도 DOM Parsing 을 중단 시키지 않는다. Node 에 Iframe 을 추가하고, 새로운 Render Layer 를 만들게 된다.

부모 문서의 load 가 완료되고, iframe 에서 요청한 문서가 Load 되면, DOM parsing 및 Rendering 이 시작된다. 이때 script 가 실행되면, 같은 event loop 에서 동작하기 때문에 부모에서 실행되던 스크립트가 밀릴 수 있다.


📒 웹에서의 메시지 커뮤니케이션

가끔 개발을 하다보면 서로 다른 탭에 열린 같은 페이지들,

특정 페이지 내에서 iframe으로 열린 페이지처럼 서로 다른 브라우징 컨텍스트에서 실행되는 두 개의 독립된 스크립트 환경 간의 통신 을 해야 할 때가 있다.

일반적으로 메시지를 주고 받는 이벤트로 커뮤니케이션을 하게 되는데,

이 때 window.postMessage 를 많이 활용하게 된다.

하지만 window.postMessage는 구식적인 방법이다.

이 강의는 확장성이 좋고 강력한 도구인 MessageChannel API에 대해서도 집중적으로 소개한다


📖 window.postMessage

부모창에서 다른 도메인을 가진 iframe 페이지 표시하는 방법

 

[부모iframe에서 자식iframe으로 데이터를 전달]

targetWindow.postMessage(message, targetOrigin, [transfer]);

function sendMessageIframe(){
  var iframe = document.getElementById('child_iframe').contentWindow; 
  iframe.postMessage({ parentData : 'test parent data', 'http://123.com');
}

 

[부모iframe이 보낸 데이터를 자식iframe이 받아서 활용]

window.addEventListener('message', function(e) {}); 를 이용

window.addEventListener('message', function(e) {
  console.log('child message');
  console.log(e.data); // { parentData : 'test parent data' }
  console.log("e.origin : " + e.origin); //http://abc.com(부모창 도메인)

  if(e.data.parentData === 'test parent data'){
	// ...처리 로직 (자식창 함수 호출)
  }
});

 

[자식창iframe에서 부모iframe으로 데이터를 전달]

window.onload = function() {

  console.log('child load');  
  
  //targetWindow.postMessage(message, targetOrigin, [transfer]);
  window.parent.postMessage({ childData : 'test data' }, 'http://abc.com');
  
  //모든 도메인에 대해서 허용하고자 하는 경우 targetOrigin(두번째인자) 인자를 '*'로 작성
  //보안을 위해 추천하지 않음
  //window.parent.postMessage({ childData : 'test data' }, '*');
  
};

[자식iframe이 보낸 데이터를 부모iframe이 받아서 활용]

window.addEventListener('message', function(e) {
  console.log('parent message');
  console.log(e.data); // { childData : 'test data' }
  console.log("e.origin : " + e.origin); //http://123.com(자식창 도메인)

  if(e.data.childData === 'test data'){
	// ...처리 로직 (부모창 함수 호출)
  }
});

 

다른 도메인 iframe사용하기

일반적으로 부모창에 서로 다른 도메인을 가진 iframe을 추가하게 되면 아래와 같이 에러가 발생하면서 자식창이 화면에 표시가 안된다.

 

에러 메세지 : Refused to display 'http://localhost:8181/' in a frame because it set 'X-Frame-Options' to 'sameorigin'.

이는 디폴트가 같은 도메인에서만 호출을 허용하도록 설정이 되어 있기 때문이다.

 

그래서 아래와 같이 다른 도메인에서 호출해도 화면에 표시가 되도록 iframe 서버의 로직을 수정한다.

자식창의 서버 로직을 수정해야 한다.

//iframe src URL 호출하는 JAVA 로직

@RequestMapping(value = "/iframe.do")
public String iframe(Model model, HttpServletResponse response) throws Exception {

  //... 처리 로직...

  //http://abc.com  : 부모창 도메인
  response.setHeader("X-Frame-Options", "ALLOW-FROM http://abc.com");

  return "jspPage";
}

저는 특정 URL에 대해서만 허용하기 위해 위와 같이 처리했는데, 자식창의 모든 URL에 대해서 설정을 변경하려면 서버에서 환경설정을 변경하면 된다. ('X-Frame-Options' to 'sameorigin'. 으로 구글 검색하면 관련된 내용이 많이 나온다. )


📖 MessageChannel API

MessageChannel API는 두 개의 클라이언트 사이에서 양방향으로 메시지를 주고 받을 수 있는 메시지 채널을 생성하는 웹 API이다.

 

window.postMessage를 쓰게 되면 해당 window 객체에 직접적으로 메시지가 넘어가지만,

MessageChannel은 직접적으로 window 객체를 통하는 방식이 아닌, 중간에 한 번 메시지를 중개해서 넘겨주는 역할을 하게 된다.

일반적으로 비동기 통신이 포함된 웹 어플리케이션을 만들 때 HTTP 통신 모듈을 감싸는 미들웨어를 만드는 것처럼, 마치 iframe 간 통신을 래핑하는 미들웨어 같은 역할을 담당할 수 있게 되는 것이디.

 

​​

우선 아래와 같은 방식으로 선언해서 사용할 수 있다.

const messageChannel = new MessageChannel();

구현 스펙을 보게 되면 port1, port2 라는 프로퍼티만 존재하는 것을 확인할 수 있다.

이 프로퍼티는 MessagePort라는 인터페이스로 구현되어 있는데,

각각의 포트는 postMessage 메소드와 onmessage 이벤트 핸들러를 갖고 있기 때문에 소켓 같은 느낌으로 생각하면 될 것 같다. (실제로 소켓은 아니다!)

1대1 메시지 채널에서 통신할 두 개의 컨텍스트를 port1, port2 에 넣어주는 것이다.

만약 port1.postMessage() 메소드를 호출하게 되면 port2.onmessage() 콜백 함수가 호출되고, port2.postMessage 메소드를 호출하게 되면 port1.onmessage() 콜백 함수가 호출이 되는 방식인 셈이다.

MessageChannel을 생성하는 코드

/* localhost:8000 에서 실행 중인 부모 코드 */

const { port1, port2 } = new MessageChannel(); 
// 구조분해 문법. MessageChannel객체에는 port1, port2 프로퍼티가 있어서 이것만 따로 변수로 빼서
// 사용. 변수이름 port1 port2 바꾸면 안됨. 모르면 구조분해 공부하고 올것

const initialMessage = "안녕하세요";
const targetOrigin = "localhost:4000" || "*";
const transfer = [port2];

iframe.addEventListener("load", function() {  //iframe문서가 load될때가지 기다림.
   //로드 되고 나서야 iframe이랑 포트 연결함
   iframe.contentWindow.postMessage(initialMessage, targetOrigin, transfer);
});

port1.onmessage = (e) => { // 메세지 받을떄
  console.log(e.data);
};

port1.postMessage(data); //메세지 보낼떄

우선 해당 코드를 localhost:8000 에서 실행시켰다고 가정해보자.

그리고 iframe 으로 열려있는 localhost:4000 과 1:1 메시지 통신을 하려고 하는 상황이다.

따라서 localhost:8000 은 부모, localhost:4000 은 자식인 상태이다.

메시지를 시작하고자 하는 브라우징 컨텍스트에서 new MessageChannel() 생성자로 메시지 채널을 생성했다.

위에서 이야기했듯이 channel 에는 port1, port2 프로퍼티가 있다.

port1은 부모, port2는 자식과 연결시킬 예정이다.

그 후, postMessage 에 싣고자 하는 아규먼트들을 각각의 변수에 담으면 된다.

  • initialMessage 는 보내고자 하는 정보다.
  • targetOrigin 은 보내고자 하는 위치다. "example.com" 처럼 도메인 오리진을 입력한다. 주소를 설정할 때 "*" 를 사용하면 열려있는 모든 탭과 iframe에 정보를 보낼 수 있다만, 보안상 취약한 방법이기 때문에 권장되지 않는다.
  • transfer 는 옵셔널한 아규먼트로, 넘겨주고자 하는 전송 가능한 객체(Transferable) 인터페이스를 배열 형태로 넘겨준다. 여기에서는 MessageChannel 생성자에 의해 생성된 port2 를 다른 브라우징 컨텍스트에 넘겨주기 위한 용도다. 넘겨준 이후에는 port2 의 소유권이 다른 브라우징 컨텍스트에 완전히 넘어가기 때문에, 현재 브라우징 컨텍스트 내에서 더 이상 사용할 수 없다.

통신하고자 하는 브라우징 컨텍스트에 postMessage 를 호출한다.

이 때 통신하고자 하는 브라우징 컨텍스트는 부모에서 자식(iframe.contentWindow)이 될 수도 있고, 자식에서 부모(window.parent)가 될 수도 있다.

현재 예시 같은 경우에는 부모에게서 자식으로 메시지 채널을 호출하는 것이기 때문에, 전자(iframe.contentWindow)의 방식을 사용했다.

그 후 다른 브라우징 컨텍스트에서 메시지가 발송되었을 때 이를 듣기 위한 onmessage() 이벤트 리스너를 설정해준다. 현재 예시에서는 자식에게서 부모에게 메시지가 발송되었을 때 console.log 로 출력하는 예시다.

만약 자식과 메시지 채널이 연결되었다면, 그 후에는 port1.postMessage 을 이용해 메시지를 보낼 수 있다.

 

MessageChannel 사용 예시라고 했는데 왜 postMessage 를 쓰는 이유

통신을 위한 MessageChannel 을 만들었는데, port2 의 정보를 어떻게든 다른 브라우징 컨텍스트에 넘겨주어야 하기 떄문에, 그래서 최초 1회에 한해서 postMessage로 port2 를 넘겨주게 되는 것이다.

그 후에는 port1과 port2 가 연결되었기 때문에, 더 이상 window.postMessage 에 의존하지 않고 독자적인 메시지 채널을 통해 커뮤니케이션이 가능해진다.

 


MessageChannel에 참여하는 코드

지금까지는 MessageChannel 을 생성하는 쪽의 코드를 살펴보았고,

지금부터는 이미 생성된 MessageChannel 에 참여하는 코드를 살펴보겠다.

/* localhost:4000 에서 실행 중인 자식 코드 */

window.addEventListener("message", function(e) {
  if (e.origin !== "localhost:8000") return //도메인 체크. 안써도됨.

  [port2] = e.ports || []; /* 아까 부모에서 배열값으로 보냈으니까 구조분해로 port2변수에 저장
                              e.ports[0]해도 됨. */
  if (!port2) return;

  // port2 초기화 이후에는 이 콜백을 통해 메시지를 처리함
  port2.onmessage = onMessage;
});

function onMessage(e){
   console.log(e);
}

port2.postMessage(data);

 

우선 메시지 채널을 생성한 부모에게서부터 port2 를 받기 위해 전역적인 이벤트 리스너를 등록한다.

전역적으로 등록한 이벤트 리스너이기 때문에 localhost:8000 뿐만 아니라 열려있는 다른 탭, iframe, 그리고 확장 프로그램으로부터 발송되는 메시지 이벤트들이 모두 수신될 것이다. 따라서 필요한 이벤트만 선별하여 수신하기 위해, 이벤트의 오리진(e.origin)을 체크하여 불필요한 이벤트가 수신되지 않게 처리해야 한다.

MessageEvent 인터페이스에는 읽기 전용의 ports 프로퍼티가 포함되어 있다. 우리가 아까 부모에게서 portMessage 의 세 번째 파라미터로 넘겨준 transfer 가 여기로 넘어오게 된다.

배열 구조 분해 할당으로 port2 를 수신할 수 있다.

port2가 있다는 것을 확신할 수 있다면 port2.postMessage 메소드와 port2.onmessage 이벤트 리스너를 활용할 수 있다. 그러면 지금부터는 더 이상 window.postMessage 를 쓸 필요가 없는 것이다.


부모frame 자식frame 통신 예제)

 

부모에게서 자식으로, 자식에게서 다시 부모로 메시지가 전송되는 예시


📖 MessageChannel API 장단점

장점

위에서는 MessageChannel API를 iframe이나 서비스 워커 통신을 위한 용도로만 언급했다.

하지만 관련 자료를 찾아보다가 이를 응용해서 하나의 웹 어플리케이션 내에서도 통신이 가능하다는 것을 확인할 수 있다. 그러니까 port2 를 어떻게든 다른 곳으로 넘겨줄 수기만 있다면, 하나의 어플리케이션 내에서도 전역적인 일대일 이벤트 버스 같은 용도로 충분히 사용할 수 있다는 뜻이다.

위의 코드는 아래와 같이 실행시켰다.

(꼭 iframe에서 할 필요는 없다. port1과 port2가 같은 컨텍스트면 혼잣말 이중인격 정도로 통신한다 보면 된다.)

  1. port2 에서 '안녕하세요' 를 보냅니다.
  2. port1 에서 '안녕하세요' 를 받습니다.
  3. port1 에서 '반갑습니다' 를 보냅니다.
  4. port2 에서 '반갑습니다' 를 받습니다.

콘솔에서 실행시킨 결과는 역시 실행 순서와 같다는 것을 확인할 수 있다.

또 다른 장점은 바로 자체적으로 버퍼가 내장되어 있다는 것이다.

그러니까 연결되지 않은 포트에 데이터를 전송하더라도, 그것이 임시로 저장되어 있다가 포트가 연결된 후 onmessage 가 알아서 순차적으로 실행이 된다는 것이다.

포트의 연결 여부를 아주 엄격하게 알아야 하는 것이 아니라면, 이 기능은 꽤나 편리할 것 같다

아래 예제 코드를 살펴봅시다.

port1에서 세 개의 데이터를 연속으로 보냈지만, 제일 마지막 줄에 port2.onmessage 는 아직 등록되지 않은 상태이다. 약 5초 간의 간격을 두고 등록되었지만, onmessage 가 정상적인 순서대로 출력된 것을 확인할 수 있다.

 

마지막으로 MessageChannel API는 EventTarget 기반이기 때문에 Event 를 보내는 것도 가능하다!

 

단점

우선 어떻게 해서든 window.postMessage 로 port2 를 넘겨주어야 하기 때문에, postMessage에 완전하게 독립적인 코드가 아니게 된다.

일회성으로 사용되는 초기화 코드가 필요한 것이 조금 찝찝하죠. 또한 한 쪽에서는 MessageChannel을 생성해야 하고, 다른 한쪽에서는 해당 채널의 포트를 연결하는 로직이 들어가기 때문에 채널 생성의 역할에 따라 양쪽의 코드가 조금 달라져야 한다. (약간 복잡해진다.)

일대일 메시지 커뮤니케이션이 지속적으로 필요한 것이 아니라면, 오히려 단순한 접근 방식인 window.postMessage 를 쓰는 것이 오히려 쉬운 길이 될 수도 있을 것 같다.

728x90
반응형