- 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을 ‘패턴’으로 쓰는 법
- 메시지 규칙을 타입으로 고정
- 자기 탭 이벤트를 안전하게 무시
- 컴포넌트는 “구독”만 하고, 채널 관리는 서비스가 책임
- 검증(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 |