본문 바로가기

개발 공부/Angular

탭 간 상태 동기화

 

  • A 탭에서 로그아웃했는데 B 탭은 계속 로그인 상태
  • 구독 해지/변경이 다른 탭에 반영되지 않아 기능이 계속 열림
  • 본인 인증을 한 탭에서 완료했는데 다른 탭은 “인증 필요” 상태로 멈춤

 


SPA

SPA(Single Page Application)는 페이지 전체를 새로 로드하지 않고, 하나의 HTML 문서 위에서 JavaScript로 화면을 갱신하는 구조입니다.

 

Angular의 동작은 다음과 같습니다.

  • 최초 진입: HTML/JS/CSS 번들 로드
  • 이후 화면 전환: Router + 컴포넌트 교체
  • 상태 유지: 브라우저 새로고침이 없는 한 메모리 상태가 계속 살아있음

 

같은 사이트라도 탭을 2개 열면 Angular 앱은 2번 실행되고,

  • 메모리 상태(스토어, 서비스 싱글톤, 캐시)
  • 라우터 상태
  • 인터셉터, 가드, 주입된 서비스 인스턴스

서로 공유되지 않습니다.

그래서 “한 탭에서 일어난 인증/상태 변화”를 다른 탭에 알려주려면 명시적인 탭 간 통신이 필요합니다.

 

 


 

멀티 탭 상태 분열이 만드는 진짜 문제

상태 분열은 이렇게 나타납니다.

탭 A: 로그아웃 / 구독 ❌ / 포인트 0
탭 B: 로그인 유지 / 구독 ⭕ / 포인트 3,000

 

여기서 위험한 케이스는 단순 표시 오류가 아니라,

  • 보안: 로그아웃/정지 이후에도 다른 탭에서 API 호출이 계속 발생
  • 비즈니스: 구독 해지 후 기능이 열려있거나, 포인트 중복 사용
  • 데이터 무결성: 같은 계정으로 중복 액션이 겹쳐 처리(race)될 가능성

즉, “탭 간 동기화”는 기능이 아니라 운영 안정성에 가깝습니다.

 


 

흔한 해결책과 한계

LocalStorage + storage 이벤트

window.addEventListener('storage', (event) => {
  if (event.key === 'logout') {
    // 로그아웃 처리
  }
});

 

한계:

  • 같은 탭에서는 storage 이벤트가 발생하지 않음
  • ❌ 문자열 기반(직렬화/파싱) → 타입 안정성 없음
  • ❌ “상태 이벤트”와 “캐시 변경”이 뒤섞이기 쉬움

LocalStorage는 원래 저장소이지 통신 레이어가 아닙니다.

 


BroadcastChannel API 

BroadcastChannel은 같은 origin에 열린 탭/창/iframe 사이에서 메시지를 주고받는 Web API입니다.

  • 채널 이름으로 그룹을 만들고
  • postMessage()로 보내면
  • 같은 채널을 열어둔 모든 컨텍스트가 message 이벤트로 수신합니다.
const channel = new BroadcastChannel('auth');

channel.onmessage = (event) => {
  console.log(event.data);
};

channel.postMessage({ type: 'LOGIN', payload: true });

 

 

하지만 “그냥” 쓰면, 곧바로 전역 이벤트 버스 지옥이 됩니다.

  • type/payload 규칙이 흐트러짐
  • 잘못된 데이터가 흘러다님
  • 자기 자신이 보낸 이벤트를 다시 처리
  • open/close 수명 주기 관리 누락

그래서 필요한 건 패턴입니다.



BroadcastChannel을 ‘패턴’으로 쓰는 법

  1. 메시지 규칙을 타입으로 고정
  2. 자기 탭 이벤트를 안전하게 무시
  3. 컴포넌트는 “구독”만 하고, 채널 관리는 서비스가 책임
  4. 검증(Validation)로 “잘못된 전파”를 초기에 차단

메시지 스키마부터 고정하기 (타입/도메인 중심)

이벤트 타입

export enum BroadcastType {
  Login = 'LOGIN',
  Verification = 'VERIFICATION',
  SubscriptionUpdate = 'SUBSCRIPTION_UPDATE',
  FeatureUpdate = 'FEATURE_UPDATE',
  PointChanged = 'POINT_CHANGED'
}

 

  • 디버깅 시 로그가 읽힘
  • 서버/다른 앱과 통신할 때 확장하기 쉬움

 

payload 타입을 이벤트별로 맵핑

export type BroadcastPayloadMap = {
  [BroadcastType.Login]: boolean; // true=login, false=logout
  [BroadcastType.Verification]: null | {
    isVerified: boolean;
    isAdultVerification: boolean;
    isDuplicated: boolean;
  };
  [BroadcastType.SubscriptionUpdate]: { subscriptionId: number };
  [BroadcastType.FeatureUpdate]: Record<string, unknown>;
  [BroadcastType.PointChanged]: { point: number; delta?: number };
};

export type BroadcastMessage<T extends BroadcastType = BroadcastType> = {
  type: T;
  postTabId: string;
  payload: BroadcastPayloadMap[T];
  sentAt: number; // 디버깅/정렬용
};

여기까지 하면 “payload 아무거나”가 불가능해지고,
이벤트별 계약이 코드로 고정됩니다.

 


 

Angular 서비스 구현 (수명 주기/검증/Observable)

import { Injectable, NgZone } from '@angular/core';
import { Observable, Subject } from 'rxjs';

const randomId = (len = 10) =>
  Math.random().toString(36).slice(2, 2 + len);

@Injectable({ providedIn: 'root' })
export class TabBroadcastService {
  private channel?: BroadcastChannel;
  private isOpen = false;

  /** 이 탭을 식별하는 id (자기 메시지 무시용) */
  readonly tabId = randomId(10);

  /** 수신 스트림 */
  private readonly incoming$ = new Subject<BroadcastMessage>();

  constructor(private zone: NgZone) {}

  open(name: string): void {
    if (this.isOpen) return;

    this.channel = new BroadcastChannel(name);
    this.isOpen = true;

    // BroadcastChannel 이벤트는 Zone 밖에서 들어올 수 있어
    // Angular change detection이 안 돌 수 있습니다.
    // 그래서 zone.run으로 감싸는 패턴을 권장합니다.
    this.channel.onmessage = (event) => {
      const msg = event.data as BroadcastMessage;
      this.zone.run(() => {
        if (!this.isValid(msg)) return;
        if (msg.postTabId === this.tabId) return; // 자기 메시지 무시
        this.incoming$.next(msg);
      });
    };
  }

  close(): void {
    if (!this.channel || !this.isOpen) return;
    this.channel.close();
    this.channel = undefined;
    this.isOpen = false;
  }

  messages(): Observable<BroadcastMessage> {
    return this.incoming$.asObservable();
  }

  post<T extends BroadcastType>(type: T, payload: BroadcastPayloadMap[T]): void {
    if (!this.channel || !this.isOpen) return;

    const msg: BroadcastMessage<T> = {
      type,
      postTabId: this.tabId,
      payload,
      sentAt: Date.now()
    };

    if (!this.isValid(msg)) {
      throw new Error('Invalid broadcast message (type/payload).');
    }

    this.channel.postMessage(msg);
  }

  /** 런타임 검증: 타입은 컴파일 타임, 이건 방어 코드 */
  private isValid(message: any): message is BroadcastMessage {
    if (!message || typeof message !== 'object') return false;
    if (typeof message.type !== 'string') return false;
    if (typeof message.postTabId !== 'string' || !message.postTabId) return false;
    if (typeof message.sentAt !== 'number') return false;

    switch (message.type) {
      case BroadcastType.Login:
        return typeof message.payload === 'boolean';
      case BroadcastType.Verification:
        return (
          message.payload === null ||
          (typeof message.payload === 'object' &&
            typeof message.payload.isVerified === 'boolean' &&
            typeof message.payload.isAdultVerification === 'boolean' &&
            typeof message.payload.isDuplicated === 'boolean')
        );
      case BroadcastType.SubscriptionUpdate:
        return (
          typeof message.payload === 'object' &&
          typeof message.payload.subscriptionId === 'number'
        );
      case BroadcastType.FeatureUpdate:
        return typeof message.payload === 'object' && message.payload !== null;
      case BroadcastType.PointChanged:
        return (
          typeof message.payload === 'object' &&
          message.payload !== null &&
          typeof message.payload.point === 'number'
        );
      default:
        return false;
    }
  }
}
  • 메시지 구조가 통일됨
  • 자기 탭 메시지는 기본적으로 무시
  • 런타임 검증으로 “잘못된 payload 전파”를 즉시 차단
  • 컴포넌트는 messages()만 구독하면 됨

 


 

어디에서 open/close ? 

대부분의 경우 채널은 앱 실행 동안 열어두는 게 단순합니다.

AppComponent에서 열기

@Component({
  selector: 'app-root',
  template: '<router-outlet />'
})
export class AppComponent {
  constructor(private tabBus: TabBroadcastService) {
    this.tabBus.open('my-app-channel');
  }
}
  • 앱이 켜질 때 한 번 open
  • 특별한 이유가 없다면 close는 굳이 하지 않아도 됨

로그아웃 시에만 분리하고 싶다면,

  • 로그아웃 처리 시 tabBus.close()
  • 로그인 시 tabBus.open()

처럼 운용해도 됩니다.

 


 

예제 1: “한 탭 로그아웃 → 모든 탭 강제 로그아웃”

로그아웃 트리거 탭

this.tabBus.post(BroadcastType.Login, false);

다른 탭에서 수신 처리

this.tabBus.messages().subscribe((msg) => {
  if (msg.type !== BroadcastType.Login) return;

  if (msg.payload === false) {
    // 토큰 제거, 스토어 초기화, 로그인 페이지 이동 등
    this.authService.forceLogout('다른 탭에서 로그아웃됨');
  }
});

 

 

예제 2: 본인 인증 완료를 다른 탭에 전파

인증은 종종 새 창/새 탭에서 처리됩니다.

  • 인증 탭에서 완료
  • 원래 작업 탭들은 “인증 완료” 이벤트만 받으면 됨

인증 완료 탭

this.tabBus.post(BroadcastType.Verification, {
  isVerified: true,
  isAdultVerification: true,
  isDuplicated: false
});

원래 탭(들)

this.tabBus.messages().subscribe((msg) => {
  if (msg.type !== BroadcastType.Verification) return;

  if (msg.payload === null) {
    this.toast.error('본인 인증에 실패했습니다.');
    return;
  }

  // 상태 업데이트
  this.userStore.patch({ verified: msg.payload.isVerified });
});

'개발 공부 > Angular' 카테고리의 다른 글

RxJS pipe  (1) 2026.01.25
Angular validationForm 2 예시  (0) 2025.12.16
Angular validationForm  (0) 2025.12.14
앵귤러 메타데이터 관리 (2)  (0) 2025.12.02
앵귤러 메타데이터 관리 (1)  (0) 2025.11.30