🖇
MessageChannel API를 활용해 iframe 서비스와 통신하기

iframe 서비스와의 커뮤니케이션을 MessageChannel API를 활용해 해결한 경험을 공유합니다.

November 29, 2020


FrontEnd JavaScript


지금으로부터 약 한달 전에 진행한 프론트엔드 컨퍼런스인 FEConf를 살펴보다가 눈에 띄는 세션을 하나 발견할 수 있었습니다. 바로 iframe을 활용하여 전혀 다른 서비스를 통합하기라는 제목의 세션이었습니다.

미리보기 하나의 웹 페이지(빨간색) 내에서 다른 도메인의 웹 페이지(파란색)를 iframe으로 호출해 사용하는 모습

마침 저는 최근에 회사에서 비슷한 업무를 진행했었습니다. 위의 사진을 보면 알 수 있듯이, 하나의 웹 페이지 내에서 다른 도메인의 웹 페이지를 iframe 태그를 이용해 호출하고 있습니다. 그리고 두 개의 웹 페이지는 커뮤니케이션을 할 수 있어야 했죠. 위의 사진을 예시로 들자면, 우측의 답변 템플릿 중 하나를 클릭하게 되면, 해당 템플릿의 내용이 좌측의 채팅 입력창에 입력이 되어야 했습니다.

저는 단순히 window.postMessage 를 쓰는 것보다 조금 더 우아하게 이 문제를 해결할 수 있는 방법이 있는지를 찾아보았습니다. 그러다가 MessageChannel API를 발견하게 되었습니다. 사실 처음 사용해보는 API이다보니 적절하게 사용했는지에 대해서는 확신하기가 어렵네요. 다만 주어진 시간 내에는 요구사항을 모두 충족하여 개발할 수 있었고, 혹시나 iframe 통신이 다음 번에도 필요할 수도 있을 것 같아서 재사용이 가능한 통신 모듈까지 함께 개발을 했습니다. 하지만 쓰다보니 좀 낡은 API라는 느낌이 없지 않아 있어서(…) 사실 구현 도중에 불편함도 있긴 했습니다.

아무튼 오늘은 이 API를 직접 사용하게 되면서 겪은 경험들을 정리해보고자 합니다. 이 포스트를 통해 iframe 통신을 가능하게 해주는 MessageChannel API에 대한 관심이 있으신 분들께 도움이 되기를 바랍니다.

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

상하 관계를 보다 명확히 하기 위해 iframe을 호출하는 쪽을 부모, iframe으로 호출되는 쪽을 자식이라는 단어로 사용하겠습니다.

가끔 개발을 하다보면 서로 다른 탭에 열린 같은 페이지들, 특정 페이지 내에서 iframe으로 열린 페이지처럼 서로 다른 브라우징 컨텍스트에서 실행되는 두 개의 독립된 스크립트 환경 간의 통신 을 해야 할 때가 있습니다. 일반적으로 메시지를 주고 받는 이벤트로 커뮤니케이션을 하게 되는데, 이 때 window.postMessage 를 많이 활용하게 됩니다.

부모에서는 데이터를 전송할 자식 iframe 내부의 window 객체에 접근하기 위해 iframe.contentWindow 를 셀렉터로 선택하고 postMessage 메소드를 호출해서 정보를 보내게 됩니다. 반대로 자식에서는 자신이 호출된 부모 페이지의 window 객체에 접근하기 위해 window.parent 를 선택하고, postMessage 를 호출하게 되죠. 아래는 예제 코드입니다.

// localhost:8000 에서 메시지를 자식에게 보내는 코드

const iframe = document.querySelector("iframe");

iframe.contentWindow.postMessage("안녕하세요", "http://localhost:4000");

// localhost:4000 에서 메시지를 부모에게 보내는 코드

window.parent.postMessage("반갑습니다", "http://localhost:8000");

한편 메시지를 수신하기 위해서는 일반적으로 이벤트 리스너를 활용하게 됩니다. 아래는 예제 코드입니다.

// localhost:4000 에서 실행되는 메시지를 받는 코드

window.addEventListener("message", (event) => {
  // 메시지의 오리진을 체크하여 신뢰할 수 있는 메시지인지를 확인
  if (event.origin !== "http://localhost:8000") {
    return;
  }

  // 필요한 로직들 처리하기
}

간단한 사용 방식에다가 별도의 도메인 제약도 없고, 오래 전에 나온 API인만큼 거의 대부분의 브라우저에서도 동작하기 때문에 일반적으로 많이 사용됩니다.

하지만 저는 혹시나 이 방법 말고 다른 방법들이 있는지를 찾아보게 되었습니다. 저 API를 마지막으로 써봤던 것이 꽤 오래전이었는데, 그 사이에 새로 나온 API가 있다면 그것을 사용하고 싶었거든요. 그러다가 구글에서 작성한 웹에서의 메시지 버스라는 문서를 발견했습니다.

해당 내용은 Broadcast Channel API에 대한 글이지만, 본문 내용 중에 웹 소켓(WebSocket), 공유 워커(Shared Worker), MessageChannel API, window.postMessage 와 비교한 단락이 있습니다. 그리고 특정 기술이 다른 어떤 것을 대체하는 것이 아니라, 각자의 기술은 저마다의 구현 목적이 있다고도 이야기합니다. 이 중에서 몇 개의 눈여겨 볼 만한 내용을 아래에 정리해보았습니다.

window.postMessage

  • 특정 window에 단방향으로 일회성 메시지를 보내는 용도
  • 다른 도메인으로 메시지를 보낼 수도 있고 받을 수도 있음
  • 따라서 검증된 도메인에서부터 전송된 메시지만을 실행시킬 수 있는 검증 코드 구현이 필요함

BroadcastChannel API

  • 일대다 커뮤니케이션 용도
  • 같은 도메인(Same Origin)에서 동작하기 때문에 메시지가 어디에서부터 전송되었는지를 검증하는 코드가 필요 없음
  • 다른 창이나 탭에서 일어난 유저의 행동을 감지하는 목적(예: 특정 탭에서 로그인/로그아웃을 했을 때 그것이 다른 탭에도 즉각적으로 반영되어야 하는 경우)
  • 서비스 워커에서도 사용 가능

공유 워커(SharedWorkers)

  • 일반적인 서비스 워커와는 다르게, 같은 도메인(Same Origin)에서 접근한다면 스크립트 내용을 공유할 수 있는 워커
  • 서비스 워커인만큼 일반적인 브라우저 JavaScript 런타임과 별도로 실행되는 JavaScript 환경
  • BroadcastChannel 과 유사하지만 조금 더 복잡한 환경에서 사용하는 용도(예: 하나의 서버와 다양한 클라이언트 사이에서의 상태 공유 등)

MessageChannel API

  • 일대일 커뮤니케이션 용도
  • 지속적인 양방향 커뮤니케이션이 필요할 때 유용
  • 다른 도메인으로 메시지를 보낼 수도 있고 받을 수도 있음
  • 따라서 검증된 도메인에서부터 전송된 메시지만을 실행시킬 수 있는 검증 코드 구현이 필요함
  • 초기화 과정에서 window.postMessage 를 사용함
  • 서비스 워커에서도 사용 가능

보시면 아시겠지만 각 API마다 목적이나 사용 방법이 조금씩 다른 것을 확인할 수 있습니다. 우리는 이들 중 iframe에서의 통신을 가능하게 하는 window.postMessage, 그리고 MessageChannel API에 대해 집중적으로 알아볼 예정입니다.

2단계 도메인(Second-level domain) 영역이 같은 경우에서의 통신이라면, (ex: foo.example.combar.example.com) 아래의 JavaScript 코드를 실행하여 브라우저에게 같은 오리진이라는 것을 명시적으로 선언할 수 있습니다. 때문에 같은 오리진이 아니어서 발생하는 제약들을 무시하게 할 수 있죠.

document.domain = "example.com";

MessageChannel API

MessageChannel API는 두 개의 클라이언트 사이에서 양방향으로 메시지를 주고 받을 수 있는 메시지 채널을 생성하는 웹 API입니다. window.postMessage를 쓰게 되면 해당 window 객체에 직접적으로 메시지가 넘어가지만, MessageChannel은 직접적으로 window 객체를 통하는 방식이 아닌, 중간에 한 번 메시지를 중개해서 넘겨주는 역할을 하게 됩니다. 일반적으로 비동기 통신이 포함된 웹 어플리케이션을 만들 때 HTTP 통신 모듈을 감싸는 미들웨어를 만드는 것처럼, 마치 iframe 간 통신을 래핑하는 미들웨어 같은 역할을 담당할 수 있게 되는 것이죠.

미리보기 그냥 window.postMessage vs Message Channel

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

const messageChannel = new MessageChannel();

미리보기 messageChannel의 스펙… 단촐하기 짝이 없다

구현 스펙을 보게 되면 port1, port2 라는 프로퍼티만 존재하는 것을 확인할 수 있습니다. 조금 대충 지은 듯한 네이밍이지만… 실제로 표준 스펙 이름이라는 것이 놀랍습니다. 아무튼 이 프로퍼티는 MessagePort라는 인터페이스로 구현되어 있는데요, 각각의 포트는 postMessage 메소드와 onmessage 이벤트 핸들러를 갖고 있기 때문에 소켓 같은 느낌으로 생각하면 될 것 같습니다. (실제로 소켓은 아닙니다!)

대충 어떤 느낌인지 감이 오시나요? 1대1 메시지 채널에서 통신할 두 개의 컨텍스트를 port1, port2 에 넣어주는 것입니다. 만약 port1.postMessage() 메소드를 호출하게 되면 port2.onmessage() 콜백 함수가 호출되고, port2.postMessage 메소드를 호출하게 되면 port1.onmessage() 콜백 함수가 호출이 되는 방식이죠.

MessageChannel을 생성하는 코드

MDN에서 설명하는 구현 예제를 보면 일반적으로 아래와 같은 방식으로 구현하는 것을 확인할 수 있습니다.

// localhost:8000 에서 실행 중인 부모 코드
const { port1, port2 } = new MessageChannel();

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

iframe.contentWindow.postMessage(initialMessage, targetOrigin, transfer);

port1.onmessage = (e: MessageEvent) => {
  console.log(e.data);
};

postMessage = (data: any) => {
  port1.postMessage(data);
};

간단한 코드이지만 부가적으로 설명을 덧붙여 보겠습니다.

  • 우선 해당 코드를 localhost:8000 에서 실행시켰다고 가정해봅시다. 그리고 iframe 으로 열려있는 localhost:4000 과 1:1 메시지 통신을 하려고 하는 상황입니다.
    • 따라서 localhost:8000 은 부모, localhost:4000 은 자식인 상태입니다.
  • 메시지를 시작하고자 하는 브라우징 컨텍스트에서 new MessageChannel() 생성자로 메시지 채널을 생성했습니다.
  • 위에서 이야기했듯이 channel 에는 port1, port2 프로퍼티가 있습니다.
    • port1은 부모, port2는 자식과 연결시킬 예정입니다.
  • 그 후, postMessage 에 싣고자 하는 아규먼트들을 각각의 변수에 담았습니다.
    • initialMessage보내고자 하는 정보입니다. JavaScript 자체적인 알고리즘으로 해당 객체를 복사해서 보내기 때문에, 직렬화(Serialize)가 가능해야 합니다. 때문에 재귀 객체가 들어있는 JSON 같은 경우에는 실어서 보낼 수가 없겠죠. 실어서 보낼 수 있는 타입은 정해져 있고, 이 곳에서 확인할 수 있습니다.
    • 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회에 한해서 postMessageport2 를 넘겨주게 됩니다. 그 후에는 port1port2 가 연결되었기 때문에, 더 이상 window.postMessage에 의존하지 않고 독자적인 메시지 채널을 통해 커뮤니케이션이 가능해지죠.

MessageChannel에 참여하는 코드

지금까지는 MessageChannel 을 생성하는 쪽의 코드를 살펴보았고, 지금부터는 이미 생성된 MessageChannel 에 참여하는 코드를 살펴보겠습니다.

/* localhost:4000 에서 실행 중인 자식 코드 */
window.addEventListener("message", handleMessage, false);

// port2 초기화를 위해 1회성으로 실행되는 코드
handleMessage = (e: MessageEvent) => {
  if (e.origin !== "localhost:8000") {
    return;
  }

  [port2] = e.ports || [];

  if (!port2) {
    return;
  }

  // port2 초기화 이후에는 이 콜백을 통해 메시지를 처리함
  port2.onmessage = (event: MessageEvent) => {
    console.log(event.data);
  };
};

postMessage = (data: any) => {
  port2.postMessage(data);
};
  • 우선 메시지 채널을 생성한 부모에게서부터 port2 를 받기 위해 전역적인 이벤트 리스너를 등록합니다.
    • 전역적으로 등록한 이벤트 리스너이기 때문에 localhost:8000 뿐만 아니라 열려있는 다른 탭, iframe, 그리고 확장 프로그램으로부터 발송되는 메시지 이벤트들이 모두 수신될 것입니다. 따라서 필요한 이벤트만 선별하여 수신하기 위해, 이벤트의 오리진(e.origin)을 체크하여 불필요한 이벤트가 수신되지 않게 처리해야 합니다.
  • MessageEvent 인터페이스에는 읽기 전용의 ports 프로퍼티가 포함되어 있습니다. 우리가 아까 부모에게서 portMessage 의 세 번째 파라미터로 넘겨준 transfer 가 여기로 넘어오게 되죠. 배열 구조 분해 할당으로 port2 를 수신할 수 있습니다.
  • port2가 있다는 것을 확신할 수 있다면 port2.postMessage 메소드와 port2.onmessage 이벤트 리스너를 활용할 수 있습니다. 그러면 지금부터는 더 이상 window.postMessage 를 쓸 필요가 없습니다.

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

이 방법을 그대로 적용한 것은 아니지만 동작하는 예시가 있으니 살펴보셔도 좋을 것 같습니다.

장단점

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

하나의 어플리케이션에서 쓰기 꼭 iframe에서 실행시키지 않아도 괜찮아요!

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

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

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

또 다른 장점은 바로 자체적으로 버퍼가 내장되어 있다는 것입니다. 그러니까 연결되지 않은 포트에 데이터를 전송하더라도, 그것이 임시로 저장되어 있다가 포트가 연결된 후 onmessage 가 알아서 순차적으로 실행이 된다는 것입니다. 포트의 연결 여부를 아주 엄격하게 알아야 하는 것이 아니라면, 이 기능은 꽤나 편리할 것 같습니다.

혹시 어떤 의미인지 이해가 가시나요? 아래 예제 코드를 살펴봅시다.

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

버퍼 자체 버퍼가 내장되어 있어요!

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

이벤트 EventDispatch 도 가능합니다.

그렇다고 해서 장점만 있는 것은 아닙니다. 우선 어떻게 해서든 window.postMessageport2 를 넘겨주어야 하기 때문에, postMessage에 완전하게 독립적인 코드가 아니게 됩니다. 일회성으로 사용되는 초기화 코드가 필요한 것이 조금 찝찝하죠. 또한 한 쪽에서는 MessageChannel을 생성해야 하고, 다른 한쪽에서는 해당 채널의 포트를 연결하는 로직이 들어가기 때문에 채널 생성의 역할에 따라 양쪽의 코드가 조금 달라져야 합니다.

만약 React나 Vue 같은 프레임워크를 함께 쓰고 있다면 하나의 웹 어플리케이션 내에서도 통신이 가능하다는 것은 거의 쓸모가 없는 장점입니다. 왜냐하면 자체적인 상태 관리 라이브러리를 쓰거나 내장된 이벤트 버스를 활용하는 것이 더 효율적이기 때문이죠. 만약 일대일 메시지 커뮤니케이션이 지속적으로 필요한 것이 아니라면, 오히려 단순한 접근 방식인 window.postMessage 를 쓰는 것이 오히려 쉬운 길이 될 수도 있을 것 같습니다.

하지만 만약 서비스 워커와 통신을 해야 하거나, 웹소켓 없이 다른 window 와 지속적인 통신이 필요한 경우라면 충분히 도입해볼만한 기술이라고 생각합니다. 예제 코드를 그대로 써도 좋지만 한 번 래핑해서 재사용성을 높인 모듈 형태로 구현해 사용한다면, 더욱 좋을 것 같습니다.

참고자료