단진
대체로 맑음
단진
  • 분류 전체보기 (164)
    • 개발 (114)
      • 회고 (25)
      • 개발과정 (28)
      • 개념 (15)
      • JavaScript (12)
      • TypeScript (12)
      • 알고리즘 (4)
      • GitHub (4)
      • 오류 (9)
      • TMI (5)
    • 일상 (15)
      • 사진 및 여행 (6)
      • 책 소개 (4)
      • 기타 TMI (5)
    • IT (16)
      • 개념 (5)
      • 데이터베이스 (6)
      • 딥러닝 (1)
      • TMI (4)
    • TMI (4)
      • 법률 TMI (4)
    • 보안 (15)
      • Dreamhack (5)
      • Root Me (10)
hELLO · Designed By 정상우.
단진

대체로 맑음

브라우저 탭 사이의 통신하기 (postMessage)
개발/JavaScript

브라우저 탭 사이의 통신하기 (postMessage)

2025. 9. 7. 12:11

 

브라우저에는 탭이라는 개념이 있다.

 

각 탭에 서로 다른 페이지를 띄울 수 있으며 탭 사이에는 어떠한 연관성이 없어 보인다.

 

브라우저 탭 사이에 데이터를 주고 받는게 가능할까?

 

브라우저 탭

브라우저에 있는 탭은 어떤 특징을 갖고 있을까?

 

같은 브라우저에 있을 뿐 실제로 별개로 동작하는 것일까?

 

https://developer.mozilla.org/ko/docs/Glossary/Browsing_context

 

브라우징 맥락

 

developer.mozilla.org

 

mdn의 문서에 따르면 다음과 같은 내용이 있다.

브라우징 맥락은 브라우저가 Document를 표시하는 환경을 말합니다. 오늘날에는 보통 탭이지만, 브라우저 창이나 페이지 내의 프레임도 가능합니다.

각 브라우징 맥락은 특정 출처, 활성화된 문서의 출처, 그리고 표시했던 모든 문서의 방문 기록을 가집니다.

브라우징 맥락 간 통신은 엄격히 제한됩니다. 같은 출처를 가진 브라우징 맥락끼리는 BroadcastChannel을 열어 사용할 수 있습니다.

 

mdn에 따르면 각 탭은 브라우징 맥락(Browsing context)을 갖는 것으로 이해할 수 있다.

 

또한, 이 브라우징 맥락 사이의 통신은 엄격히 제한되며 같은 출처를 가진 브라우징 맥락인 경우만 BroadcastChannel을 사용해 통신할 수 있다고 한다.

 

브라우저의 탭은 최상위 브라우징 컨텍스트로 각 탭은 자체 Document, Window, History, FrameTree, 자바스크립트 글로벌 객체를 가진다.

 

격리 환경

앞서 각 탭은 각자가 필요한 것을 스스로 갖고 있는 완전히 격리된 것으로 보인다.

 

크롬의 경우 사이트 격리 기술을 사용하여 아래와 같은 이점을 취하고 있다.

여러 웹사이트의 페이지가 항상 서로 다른 프로세스에 배치되며, 각 프로세스는 프로세스에서 수행할 수 있는 작업을 제한하는 샌드박스에서 실행됩니다. 또한 프로세스가 다른 사이트에서 특정 유형의 민감한 정보를 수신하지 못하도록 차단합니다

 

이에 따르면 (크롬의 경우) 각 탭은 항상 서로 다른 프로세스에 배치되며 각 프로세스는 격리되어 있음을 알 수 있다.

 

이렇게 프로세스 단위로 탭을 분리하게 되면 특정 탭이 다른 탭의 힙 메모리 등에 접근할 수 없게 된다.

 

따라서 각 탭은 완전한 격리 환경을 구성할 수 있으며 서로가 서로에게 영향을 줄 수 없기 때문에 안전한 브라우저 사용이 가능한 것이다.

 

엔진의 격리

대표적인 브라우저 크롬은 V8 엔진을 사용한다.

 

V8엔진에 따르면 엔진또한 VM 인스턴스로 격리되며 각각의 인스턴스는 개별 힙을 갖고 있다고 한다.

 

https://html.spec.whatwg.org/multipage/webappapis.html#event-loops

또한 이 문서에 따르면 각각의 Agent(에이전트는 ECMAScript 실행 컨텍스트, 실행 컨텍스트 스택, 실행 중인 실행 컨텍스트, 에이전트 레코드 및 실행 스레드 집합으로 구성된다. 실행 스레드를 제외한 에이전트의 구성 요소는 해당 에이전트에 독점적으로 속한다)들은 각각의 이벤트 루프를 갖는다고 한다.

 

그렇다면 엔진의 격리가 의미하는 것은 GC, 마이크로태스크 큐, 이벤트 루프가 컨텍스트마다 분리되어 동작한다는 것을 의미할 것이다.

 

이렇듯 각 탭은 완전히 격리 되어있기 때문에 아래와 같은 것들이 가능한 것이다.

  • 브라우저의 탭이 비활성화(숨김)된 경우 타이머가 완벽하게 동작하지 않음 (CPU 및 메모리 효율성을 위함)
    • https://developer.chrome.com/blog/timer-throttling-in-chrome-88?hl=ko
  • 사용자가 브라우저의 뒤로가기/앞으로 가기를 하려 할 때 페이지를 삭제하는 것이 아니라 임시로 남겨두되 실행중이던 JS의 실행을 중지하는 것. 다시 해당 페이지에 접근할 경우 중지되었던 부분에서 재시작
    • https://web.dev/articles/bfcache?hl=ko

이런 것들이 가능한 이유는 각 탭이 개별적으로 동작하며 서로의 리소스 등을 공유하지 않기 때문이다.

 

한 탭에서 JS의 실행이 중지되건, 타이머를 분당 한 번 확인하건 다른 탭에는 영향이 가지 않을 수 있는 이유다.

 

그렇다면 탭 사이의 통신이 가능할까?

결론은 "가능하다".

 

앞서 알아본 내용에 따르면 각 탭들은 완벽한 격리 환경이므로 둘 사이에 "절대" 영향을 주고받을 수 없을 것 처럼 보인다.

 

하지만 예상과 다르게 각 탭 사이에서 데이터를 주고 받을 수 있는 방법은 여러 개가 있다.

 

그런데 왜 우리는 탭 사이의 통신이 불가능 하다고 느끼는 것일까?

 

이에 대한 여러 이유가 있으며 대표적으로 아래와 같다.

  • 암묵적 공유가 없다: 탭은 서로의 힙에 손댈 수 없다. 공유를 원하면 반드시 저장소나 메시징 같은 “명시적 채널”을 열어야 한다.
  • 보안 모델: SOP와 프로세스 격리는 우발적·악의적 접근을 차단한다.
  • 스케줄링/생명주기 차이: 각 탭의 이벤트 루프·GC·BFCache·백그라운드 스로틀링이 달라 “동시에 같은 값을 본다”는 보장이 없다.
  • 프라이버시 강화 흐름: 저장소/캐시 파티셔닝으로 “같은 사용자 프로필”이라도 공유 범위가 줄어드는 추세.
  • 세션 단위 상태: sessionStorage, 페이지 내부 상태, window.name 등은 본질적으로 탭 한정.

 

위에서 언급한 "명시적 채널"을 사용한다면 통신이 가능하며 이는 대표적으로 로컬 스토리지, BroadcastChannel, postMessage 등이 있다.

 

localStorage + storage 이벤트

로컬 스토리지에 값을 넣고 해당 값이 변경되는 경우 이벤트 핸들러를 통해 새로 저장된 값을 꺼내올 수 있다.

 

한 쪽에서 로컬 스토리지에 setItem, removeItem, clear를 하게 되면 다른 쪽에서는 storage 이벤트를 수신할 수 있다. (발신 탭에는 발생하지 않음)

 

이는 동일 출처(same-origin)를 갖는 모든 곳에서 접근할 수 있으며 간단한 상태를 브로드캐스팅 하기에는 적절한 선택이 될 수 있다.

 

하지만, 문자열 데이터만 저장 가능하다는 단점이 있으며 (JSON 직렬화) 동기적 I/O API라 잦은 쓰기는 메인 스레드에 부담이 될 수 있다는 단점이 있다.

 

BroadcastChannel

https://developer.mozilla.org/ko/docs/Web/API/BroadcastChannel

 

BroadcastChannel

BroadcastChannel() 명명된 채널에 연결되는 객체를 생성합니다. 이 인터페이스는 부모인 EventTarget의 속성도 상속합니다. BroadcastChannel.name 읽기 전용 채널 이름 문자열을 반환합니다. 이 인터페이스는

developer.mozilla.org

new BroadcastChannel('name')로 참가할 수 있으며 channel.postMessage(data)로 모든 참가자에 전달할 수 있다.

 

이것의 사용 목적은 동일 출처의 탭·워커(Service/Dedicated/Shared) 간 “채널명” 단위 브로드캐스트다.

 

앞선 예시에서 볼 수 있듯이 동일 출처를 갖는 다수의 탭이 같은 채널에 참가한다면 단일 발신자가 복수의 탭에 메시지를 전달할 수 있다는 장점이 있다.

 

또한, 비교적 가볍다는 장점이 있으며 실시간 브로드캐스트에 적합하다는 장점이 있다.

 

하지만, 지원되지 않는 브라우저가 있을 수 있으며 동일 출처만 참가할 수 있다는 단점(높은 보안성을 의미하므로 단점으로 보기는 어려울 수 있음)이 있다.

 

postMessage

마지막으로 postMessage 방식이다.

 

postMessage 메소드는 window 객체에 포함되어 있기 때문에 window.postMessage와 같이 사용할 수 있다.

 

이 방식의 경우 참조 가능한 윈도우/프레임 간 P2P 통신(팝업 window.open, 부모-자식 iframe, opener/parent/top)이며 출처 상관없이 사용 가능하다는 장점이 있다.

 

하지만, 동일 출처 제약이 없기 때문에 반드시 targetOrigin을 특정하고, 수신 측에서 event.origin 검증 필요하기 때문에 '*'를 남용하지 않도록 주의해야 한다는 단점이 있다.

 

또한, “임의의 다른 탭”에는 직접 보낼 수 없으며(윈도우 참조가 있어야 함) 전역 브로드캐스트 용도엔 부적절하다는 단점도 있다.

 

이 방식의 경우 수명 결합의 개념이 있기 때문에 팝업/iframe이 닫히면 통신이 종료된다.

 

그럼에도 불구하고

단점이 많음에도 불구하고 postMessage를 사용하는 이유는 결국 동일 출처 조건을 만족하지 않아도 쉽게 다른 탭과 데이터를 주고 받을 수 있기 때문이다.

 

일반적으로, 다른 페이지 간의 스크립트는 각 페이지가 같은 프로토콜, 포트 번호와 호스트을 공유하고 있을 때에("동일 출처 정책"으로도 불려 집니다.) 서로 접근할 수 있습니다. window.postMessage() 는 이 제약 조건을 안전하게 우회하는 기능을 제공합니다.

 

mdn에 따르면 위와 postMessage 방식의 경우 동일 출처가 아니더라도 (호스트, 포트, 프로토콜 중 하나라도 다른 경우 동일 출처가 아님) 서로 메시지를 주고 받는 것이 가능하다고 한다.

 

하지만 postMessage 또한 탭에 대한 참조가 있어야 하며 자식 탭의 경우는 자신의 부모에게 온 메시지 라는 것을 확인하지 않으면 보안에 문제가 발생할 수 있다.

 

예제

아래 예제를 확인해보자.

import { useEffect, useRef, useState } from 'react';

function App() {
  const [childMessage, setChildMessage] = useState('');
  const childRef = useRef<Window | null>(null);

  const receiver = (event: MessageEvent<{ type: string; text: string }>) => {
    if (event.data.type === 'childMessage') {
      setChildMessage(event.data.text);
    }
  };

  useEffect(() => {
    window.addEventListener('message', receiver);

    return () => {
      window.removeEventListener('message', receiver);
    };
  }, []);

  return (
    <>
      <h1>This is Mother</h1>
      <button
        onClick={() => {
          const child = window.open('http://localhost:3001');
          childRef.current = child;
        }}
      >
        call child
      </button>
      <button
        onClick={() => {
          childRef.current?.postMessage({ type: 'motherMessage', text: 'Hello from Mother' }, '*');
        }}
      >
        send message to child
      </button>
      <p>{childMessage}</p>
    </>
  );
}

export default App;

 

위와 같이 구성된 간단한 부모 페이지(포트 3000)가 있다고 가정해보자.

 

이 페이지의 역할은 자식을 생성하고 자식에게 메시지를 보내는 것이다.

 

window.open의 경우 생성된 window의 참조를 반환한다.

 

이 참조를 사용해서 대상 윈도우에 메시지를 보낼 수 있는 것이다.

 

import { useEffect, useState } from 'react';

function App() {
  const [motherMessage, setMotherMessage] = useState('');

  const receiver = (event: MessageEvent<{ type: string; text: string }>) => {
    if (event.data.type === 'motherMessage') {
      setMotherMessage(event.data.text);
    }
  };

  useEffect(() => {
    window.addEventListener('message', receiver);

    return () => {
      window.removeEventListener('message', receiver);
    };
  }, []);

  return (
    <>
      <h1>This is child</h1>
      <p>{motherMessage}</p>
      <button
        onClick={() => {
          window.opener.postMessage({ type: 'childMessage', text: 'Hello from Child' });
        }}
      >
        call mother
      </button>
    </>
  );
}

export default App;

 

위와 같이 구성된 자식 탭이 있을 때 (포트 3001) 이 탭에서는 window.opener를 통해 자신을 open한 window에 접근할 수 있게 된다.

 

두 탭은 포트가 서로 다르기 때문에 동일 출처 정책을 위반한다.

부모 탭에서 call child 버튼을 클릭하면 새로운 탭이 열리게 된다. (3001 포트)

 

이렇게 3001 포트인 자식 페이지가 열리게 된다.

 

이제 부모 탭에서 send message to child 버튼을 클릭하면 자신의 자식에게 메시지를 보낼 수 있게 된다.

 

자식탭에서 이 메시지를 수신한 뒤 처리할 수 있는 모습이다.

 

그렇다면 반대로는?

앞서 window.opener를 통해 부모 window에 접근할 수 있으며 window.opener.postMessage와 같이 메시지를 전송할 수 있다.

 

그런데 자식 탭에서 call mother 버튼을 클릭해도 부모 탭에서는 아무런 변화를 볼 수 없다.

 

postMessage는 아래와 같이 다른 인자를 받을 수 있다.

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

 

 

여기서 두 번째 인자에 해당하는 targetOrigin에 어느 위치로 데이터를 보낼지 명시해야 한다.

 

예제에서 부모 탭의 경우 postMessage의 두 번째 인자로 '*' 와일드 카드를 설정했기 때문에 문제 없이 보낼 수 있었던 것이다.

window.opener.postMessage({ type: 'childMessage', text: 'Hello from Child' }, 'http://localhost:3000');

 

자식 페이지도 이와 같이 자신의 부모 즉 자신을 open한 오리진 정보가 있어야 제대로 메시지를 보낼 수 있다.

 

origin을 잘 설정한다면 위와 같이 부모 탭에서 메시지를 수신할 수 있다.

 

참고로 postMessage에서 targetWindow의 스키마, 호스트이름, 포트가 targetOrigin과 다르다면 메시지가 전송되지 않는다.

 

postMessage 안전하게 사용하기

postMessage는 동일 출처 정책을 우회할 수 있지만 최소한의 보안 조치는 필요하다.

 

위와 같이 postMessage 자체가 제공하는 보안 조치도 있지만 이벤트 핸들러 단위의 보안 조치도 적절히 요구된다.

 

MessageEvent 내부에는 origin이라는 값이 있다.

 

이 값을 통해 이 이벤트가 어떤 origin에서 전송된 것인지 알 수 있다.

 

그렇다면 아래와 같이 구성할 수 있을 것이다.

const receiver = (event: MessageEvent<{ type: string; text: string }>) => {
  if (event.origin !== 'http://localhost:3001') return;
  if (event.data.type === 'childMessage') {
    setChildMessage(event.data.text);
  }
};

 

이렇게 한다면 자신의 자식인 origin이 아닌 경우는 핸들러가 실행되지 않을 것이다.

 

물론 자식의 이벤트 핸들러에도 자신의 부모로 부터 온 이벤트인지 확인하는 과정이 필요할 것이다.

 

추가 - 전송 가능한 데이터 타입

postMessage 예제를 보면 일반적인 객체를 그대로 보내고 있는 것 처럼 보인다.

 

이 객체는 localStorage와 달리 직렬화가 필요 없는 것인가?

 

이 과정은 postMessage가 자동으로 하기 때문에 우리가 JSON.stringify()와 같은 방법으로 직접 직렬화를 해주지 않아도 된다.

https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API/Structured_clone_algorithm

 

structured clone algorithm이라는 기술이 적용되어 있으며 기회가 된다면 다시 다루도록 하겠다.

 

결론은 postMessage의 경우 위의 알고리즘이 적용되어 있어 데이터를 그냥 넣어도 문제가 없다는 것이다.

 

결론

브라우저의 탭은 완전히 격리된 환경이다.

 

이렇게 격리된 탭 사이에 데이터를 주고 받을 수 있는 방법이 여러 개 있다.

 

대부분은 동일 출처 정책을 만족해야 하지만 postMessage의 경우는 동일 출처 정책을 우회할 수 있다.

 

하지만 이렇게 자유도가 높은 만큼 개발자가 개발하며 보안에도 신경써야 한다.


[참고자료]

https://developer.mozilla.org/ko/docs/Glossary/Browsing_context

https://html.spec.whatwg.org/multipage/document-sequences.html#browsing-context

https://developer.chrome.com/blog/site-isolation?hl=ko

https://v8.dev/docs/embed#isolate

https://developer.mozilla.org/ko/docs/Web/API/Window/postMessage

https://developer.mozilla.org/ko/docs/Web/Security/Same-origin_policy

https://developer.mozilla.org/ko/docs/Web/API/MessageEvent

 

 

 

 

 

저작자표시 비영리 변경금지 (새창열림)

'개발 > JavaScript' 카테고리의 다른 글

[JavaScript] 커링(Currying)  (0) 2023.12.23
[JavaScript] 클로저  (0) 2023.09.16
[JavaScript] querySelectorAll VS getElementsByClassName  (0) 2023.09.10
[JavaScript] 이벤트 버블링과 캡처링  (0) 2023.09.03
[JavaScript] addEventListener과 onclick의 차이  (0) 2023.09.03
    '개발/JavaScript' 카테고리의 다른 글
    • [JavaScript] 커링(Currying)
    • [JavaScript] 클로저
    • [JavaScript] querySelectorAll VS getElementsByClassName
    • [JavaScript] 이벤트 버블링과 캡처링
    단진
    단진

    티스토리툴바