Engineering note · Javascript / Browser API

브라우저 탭과 창 사이에서 상태와 메시지 동기화하기

2026년 07월 13일이현수
태그javascriptbrowserweb-apibroadcast-channel

localStorage의 storage 이벤트, BroadcastChannel, window.postMessage와 Service Worker 중계를 전달 범위에 따라 비교하고 로그아웃 상태를 여러 탭에 반영하는 방법을 정리한다.

같은 사이트여도 탭 사이에 통신이 필요할 때가 있다. 한 탭에서 로그아웃하거나 데이터를 변경한 뒤 다른 탭에도 알려야 하는 상황이다. 메시지를 전달하는 방법은 대상 범위에 따라 나뉜다. 여러 탭에 알릴 때는 storage 이벤트나 BroadcastChannel을 사용하고, Origin이 다른 팝업이나 iframe과 통신할 때는 window.postMessage()를 사용한다. Worker를 Same-Origin 컨텍스트의 중계 지점으로 사용할 수도 있다.

방법전달 대상Window reference데이터 저장Cross-Origin
storage 이벤트Same-Origin의 다른 탭과 창필요 없음localStorage에 남음불가
BroadcastChannel같은 Storage Partition과 Origin을 공유하는 탭, 창, iframe, Worker필요 없음남지 않음불가
window.postMessage()Window reference가 있는 팝업이나 iframe필요남지 않음가능
Service Worker 중계Worker가 제어하거나 조회한 클라이언트Worker가 조회남지 않음Same-Origin

localStorage 변경 감지

ts
localStorage.setItem(
  'session:state',
  JSON.stringify({ status: 'signed-out', changedAt: Date.now() }),
)

applySignedOutState()

어떤 탭에서 로그아웃을 하면,

ts
window.addEventListener('storage', (event) => {
  if (event.storageArea !== localStorage) return
  if (event.key !== 'session:state' || event.newValue === null) return

  const state = JSON.parse(event.newValue)

  if (state.status === 'signed-out') {
    applySignedOutState()
  }
})

storage 이벤트로 다른 탭에서 받을 수 있다. 값을 바꾼 탭에는 이벤트가 전달되지 않는다. 이벤트에는 key, oldValue, newValue, storageArea, 트리거된 url이 담긴다. 일회성 메시지보다 로그인 상태나 사용자 설정처럼 저장할 값의 동기화할 때 사용하는 게 좋다. 같은 값을 다시 저장하면 변경이 없어 이벤트를 발송하지 않아서 로직 실행이 불가능하다.

BroadcastChannel

ts
const channel = new BroadcastChannel('session-events')

channel.addEventListener('message', (event) => {
  if (event.data?.type === 'SIGNED_OUT') {
    applySignedOutState()
  }
})

function signOut() {
  applySignedOutState()
  channel.postMessage({ type: 'SIGNED_OUT' })
}

window.addEventListener('pagehide', () => channel.close(), { once: true })

같은 이름으로 만든 채널은 같은 도메인의 창, 탭, iframe과 Worker 모두 메세지를 받을 수 있다.

BroadcastChannel 다이어그램

메시지를 보낸 BroadcastChannel 객체는 자신의 메시지를 다시 받진 않는다. 채널을 더 쓰지 않을 때 close()를 호출하면 해당 객체가 채널에서 빠진다.

window.postMessage

ts
const authWindow = window.open('https://auth.example.com/login')

authWindow?.postMessage(
  { type: 'SESSION_REQUEST' },
  'https://auth.example.com',
)

window.open()으로 연 팝업에 메세지를 보낸다. 같은 도메인이 아니어도 사용할 수 있다는 점이 강점이다.

ts
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://auth.example.com') return
  if (event.source !== authWindow) return
  if (event.data?.type !== 'SESSION_RESULT') return

  updateSession(event.data.session)
})

message 이벤트로 동일하게 받지만 다른 도메인도 가능하다는 게 BroadcastChannel 과 다른 점이다. 대신 수신 측에서는 origin만 확인하고 끝내지 않고, 예상한 Window가 맞는지 source와 메시지 구조 등등 종합적으로 검토해야 보안에 문제가 없을듯.

송신할 때 targetOrigin*로 두면 메시지를 받을 문서의 Origin을 제한하지 못한다. 대상 Origin을 알고 있다면 scheme, host, port를 포함한 정확한 값을 지정하는 게 좋다.

사용자가 주소창이나 북마크로 직접 연 임의의 탭은 Window reference가 없으므로 전달이 불가능하다. 이 경우는 BroadcastChannel이나 storage 이벤트를 쓰는 게 맞다.

Service Worker를 활용한 통신

Service Worker를 이미 등록한 앱에서 푸시나 백그라운드 이벤트를 열려 있는 탭에 알릴 때 사용할 수 있다.

ts
// page.ts
navigator.serviceWorker.controller?.postMessage({
  type: 'BROADCAST',
  payload: { type: 'CONTENT_UPDATED' },
})

navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data?.type === 'CONTENT_UPDATED') {
    refreshContent()
  }
})

서비스 워커에 메세지를 보내고, message 이벤트로 받는다.

js
// service-worker.js
self.addEventListener('message', (event) => {
  if (event.data?.type !== 'BROADCAST') return

  event.waitUntil(
    self.clients.matchAll({ type: 'window' }).then((clientList) => {
      for (const client of clientList) {
        if (client.id !== event.source?.id) {
          client.postMessage(event.data.payload)
        }
      }
    }),
  )
})

clients.matchAll()은 기본적으로 현재 Service Worker가 제어하는 모든 클라이언트를 반환한다. includeUncontrolled: true를 지정하면 같은 도메인이지만 아직 워커의 제어를 받지 않은 클라이언트까지 조회할 수 있다.

탭 통신만을 위해 Service Worker를 새로 도입하면 등록과 활성화, 제어 범위와 갱신 수명 주기까지 관리해서 비추