
세상이 빠르게 변하고 있다.
전에는 여러 CSS 라이브러리와 Next.js가 나오면서 혼란을 주더니,
지금은 AI가 급변하고 있다.
항상 트렌드를 잘 파악해야지..
토큰 만료 후 어떻게 처리할까?
예전에는 토큰 만료 후처리를 인터셉터(Interceptor) 방식으로 구현했다.
하지만 뭔가 다른 방법이 있지 않을까 싶었다.
관련하여 찾던 도중, 이벤트 버스(event bus)를 활용하여 토큰 만료 후처리를 구현할 수 있는 것을 확인하였다.
대체적으로 토큰 만료 후처리를 인터셉트를 통해 구현한 내용이 담긴 아티클이 많았고, AI도 인터셉터가 더 좋을 수도 있다고 하였다.
하지만, 사이드 프로젝트이니 새롭게 학습하고, 도입하는 것에 좋은 경험이 될 것이라 생각하여 인터셉터 방식을 사용하기로 결정하였다.
이벤트 버스란?
이벤트 버스는 여러 컴포넌트나 모듈이 서로 직접 알지 않고도 이벤트를 발행(Publish)하고 구독(Subscribe)하여 데이터를 주고받을 수 있게 해주는 중앙 통신 중개자이다.
즉, 이벤트를 발행하고, 이벤트를 구독한 쪽에서 반응하는 구조다.
🚌 이벤트 버스(Event Bus)의 동작 흐름
1️⃣ 이벤트 구독
리스너(Listener)가 특정 이벤트를 받겠다고 이벤트 버스에 등록
2️⃣ 이벤트 발생
시스템의 어딘가에서 특정 이벤트(Event)가 발생
3️⃣ 이벤트 전파
이벤트 버스가 해당 이벤트를 구독 중인 리스너들에게 알림을 전송
4️⃣ 이벤트 처리
알림을 받은 리스너가 자신에게 할당된 로직을 실행
이벤트 버스는 위와 같은 흐름으로 동작한다.
🚀 이벤트 버스와 상태 관리 라이브러리
신기하게도 이벤트 버스의 발행-구독(Pub-Sub) 패턴은 상태 관리 라이브러리에서도 비슷한 흐름으로 동작한다.
예를 들어 리액트에서 흔히 사용하는 상태 관리 라이브러리(Zustand, Redux 등)에 이 흐름을 대입해 보면 다음과 같다.
이벤트 버스 ➔ 전역 상태를 보관하는 저장소(Store)
이벤트 구독 ➔ 컴포넌트가 저장소의 상태를 감시하는 것 (useStore / subscribe)
이벤트 발생 ➔ 유저 액션으로 데이터 변경을 요청하는 것 (Action / Dispatch)
이벤트 처리 ➔ 데이터가 바뀌어 화면을 다시 그리는 것 (리렌더링)
상태 관리 라이브러리가 이벤트를 구독한다는 점은 모던 리액트 Deep Dive에서도 언급된 내용이 있어, 책을 참고 작성하였다.
결국 이벤트 '변화가 생겼을 때 중앙에서 신호를 감지하고, 이를 리스너에게 전파하여 각자 할 일을 처리하게 만든다'는 동일한 특성을 가지고 있다.

토큰 만료 상황에서 이벤트 버스 구현하기
토큰 만료가 일어나면 "만료되었다는 사실"만 전파하면 된다.
우선, 간단한 이벤트 버스를 만들어보자.
type Listener = () => void;
const listeners = new Set<Listener>();
export function subscribe(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function emit() {
listeners.forEach((listener) => listener());
}
여기서 각 함수는 다음과 같은 역할을 담당한다.
subscribe: 이벤트를 감시할 리스너를 등록한다. (반환된 함수를 실행하면 등록이 해제된다.)
emit: 이벤트가 발생했음을 알리고, 등록된 모든 리스너를 실행한다.
그리고 React 컴포넌트에서 이렇게 쓸 수 있다.
useEffect(() => {
const unsubscribe = subscribe(() => {
console.log('이벤트 발생!');
});
return unsubscribe;
}, []);
useEffect 안에서 구독하고, 컴포넌트가 언마운트될 때 return에서 구독을 해제한다.
이벤트 버스 - 프로젝트 적용기
프로젝트에서는 어떻게 사용했을까?
프로젝트에서는 토큰 만료 뿐만 아니라 다른 이벤트도 전역으로 알려야 했다.
export type AuthBlockReason =
| 'AUTH_EXPIRED' // 토큰이 만료되었고, 재발급에도 실패한 상태
| 'TERMS_PENDING' // 약관 동의가 필요한 상태
| 'PROFILE_PENDING'; // 프로필 입력이 필요한 상태
API 응답에 따라 각 상황에 맞는 처리 하도록 분리하였다.
// 이벤트가 발생했을 때 실행할 함수 타입
type Listener = () => void;
// 이벤트 종류별로 listener 목록을 저장하는 객체
// 각 key는 이벤트 종류이고, value는 해당 이벤트를 구독 중인 함수들의 배열이다.
const listeners: Record<AuthBlockReason, Listener[]> = {
AUTH_EXPIRED: [],
TERMS_PENDING: [],
PROFILE_PENDING: [],
};
// 특정 인증 차단 이벤트를 구독하는 함수
// 예: AUTH_EXPIRED 이벤트가 발생했을 때 실행할 listener를 등록한다.
export function onAuthBlocked(
reason: AuthBlockReason,
listener: Listener,
) {
// 해당 reason에 해당하는 listener 목록에 새 listener를 추가한다.
listeners[reason].push(listener);
// 구독 해제 함수 반환
// 보통 useEffect의 cleanup 함수에서 호출된다.
return () => {
// 등록했던 listener만 제거한다.
// 컴포넌트 언마운트 후에도 listener가 남아 있는 것을 방지한다.
listeners[reason] = listeners[reason].filter((l) => l !== listener);
};
}
// 특정 인증 차단 이벤트가 발생했음을 알리는 함수
// 예: 토큰 재발급 실패 시 notifyAuthBlocked('AUTH_EXPIRED') 호출
export function notifyAuthBlocked(reason: AuthBlockReason) {
// 해당 reason을 구독 중인 모든 listener를 실행한다.
listeners[reason].forEach((listener) => listener());
}
❓ 왜 listeners를 배열로 두었을까?
listeners를 배열로 둔 이유는 하나의 이벤트에 여러 처리 함수를 등록할 수 있게 하기 위해서다.
예를 들어 AUTH_EXPIRED 이벤트가 발생했을 때 인증 정보 정리, 페이지 이동, 토스트 표시처럼 여러 후처리가 필요할 수 있다.
이때 listener를 하나만 저장하면 하나의 처리만 등록할 수 있지만, 배열로 관리하면 같은 이벤트에 등록된 처리 함수들을 순서대로 실행할 수 있다.
핵심은 다음과 같다.
onAuthBlocked: 특정 reason에 대한 listener를 등록한다.
notifyAuthBlocked: 특정 reason이 발생했음을 알리고 listener를 실행한다.
그리고 API 계층에서 요청 중 에러가 발생했을 때 이벤트를 발행한다.
예를 들어, 401 응답이 발생했고, access token 재발급에도 실패하면 AUTH_EXPIRED(토큰 만료) 이벤트가 발행한다.
if (e.code === 401 && authMode === 'required' && !retry) {
retry = true;
const ok = await refreshAccessToken();
if (!ok) {
notifyAuthBlocked('AUTH_EXPIRED');
throw e;
}
return await doRequest();
}
여기서 중요한 점은 API 계층이 직접 라우팅하지 않는다는 것이다.
API 계층은 그저 말한다.
"토큰이 만료되었습니다."
"온보딩이 필요합니다."
그러면 이 이벤트를 구독하고 있는 쪽에서 실제 처리를 담당한다.
화면 계층에서는 어떻게 구현되어 있을까?
프로젝트에서는 인증이 필요한 페이지들을 감싸는 AuthGuard 레이어를 만들었다.
AuthGuard는 인증 만료나 온보딩 리다이렉트처럼 전역적으로 필요한 화면 처리를 담당한다.

예를 들어 코드 상에서 AUTH_EXPIRED(토큰 만료) 이벤트는 이렇게 구독한다.
useEffect(() => {
const unsubscribe = onAuthBlocked('AUTH_EXPIRED', () => {
// 다른 처리들 ...
resetAuthBlocked(['AUTH_EXPIRED']);
});
return unsubscribe;
}, []);
이벤트 버스를 직접 구현해보니
이벤트 버스는 생각지 못한 문제를 해결하는 등 여러 장점을 가지고 있었다.
1️⃣ 컴포넌트 외부에서 useNavigate를 사용할 수 없는 문제를 해결할 수 있다.
결국 페이지를 이동시키려면 window.location을 사용해야만 했다.
이로 인해 useNavigate가 제공하는 SPA 특유의 부드러운 전환과 상태(State) 전달 같은 장점들을 활용할 수 없었다.
신호를 전달받은 화면 계층은 리액트 컨텍스트 내부이므로, useNavigate 등의 훅을 사용하거나 상태 처리를 수행할 수 있다.
2️⃣ 불필요한 props 드릴링 방지
부모-자식 관계가 아닌, 멀리 떨어져 있거나 전혀 다른 트리에 있는 컴포넌트들끼리도 쉽게 데이터를 전달할 수 있다.
덕분에 중간 컴포넌트들의 코드가 지저분해지는 것을 막고 깔끔한 컴포넌트 구조를 유지하게 된다.
3️⃣ 불필요한 리렌더링 방지
전역 상태(Context)를 사용할 때 발생하는 불필요한 하위 컴포넌트 리렌더링을 방지할 수 있다.
리액트 Context API는 값이 하나만 바뀌어도 전체 하위 컴포넌트가 다시 그려지는 성능 낭비가 발생한다. 반면 이벤트 버스는 오직 자신이 구독한 특정 이벤트가 터졌을 때만 리스너를 실행하므로, 관련 없는 컴포넌트의 불필요한 리렌더링을 방지한다.
마무리
새로운 방법을 터득하고 구현하는 것은 스스로를 즐겁게 하고, 나아가 개발자로서 한 단계 성장하게 만든다.
사실 이미 경험해 본 익숙한 인터셉터 방식을 선택했다면 구현은 훨씬 더 간단했을지도 모른다.
하지만 사이드 프로젝트였기에 원하는 방향과 아키텍처를 고집하며 마음껏 시도해 볼 수 있었다.
정답을 그대로 베끼는 것이 아니라, API 레이어의 순수성을 어떻게 지킬 것인가라는 본질적인 문제를 고민하고 나만의 해결책을 찾아낸 과정 자체가 큰 수확이었다.
이번에 구현한 이벤트 버스 패턴이 완벽한 정답은 아닐지라도, 개발의 시야를 넓혀준 값진 경험임은 분명하다.
새로운 기술과 패턴을 즐겁게 탐구하며 더 재미있는 프로젝트들을 맞이하고 싶다
'React' 카테고리의 다른 글
| [React, JS] 라이브러리 없이 캘린더 구현하기 (0) | 2024.02.21 |
|---|---|
| [React] useNavigate와 useLocation으로 페이지 간 데이터 전달하기 (0) | 2023.01.05 |