AxiosRefreshTokenAdapter
- Axios 인스턴스에 인터셉터를 추가하여, 응답 상태가 에러라면 토큰을 갱신하고 재요청합니다.
SimpleEventEmitter
가 필요합니다. (아래에 구현해둔 코드 있습니다)
import { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios';
import {
SimpleEventEmitter,
TListener,
TUnsubscribe,
} from '@/utils/simpleEventEmitter';
export interface IAxiosRequestConfigWithRefreshToken
extends AxiosRequestConfig {
refreshToken?: string | null;
}
export interface IAxiosErrorWithRefreshToken extends AxiosError {
config: IAxiosRequestConfigWithRefreshToken;
}
export interface IAxiosRefreshTokenAdapterOptions {
/**
* 토큰 갱신 요청을 보내는 함수입니다.
*
* @description
* 토큰 갱신 요청 후 응답으로 받은 엑세스 토큰을 반환합니다.
*/
refreshTokenFetcher: (
axiosError: IAxiosErrorWithRefreshToken
) => Promise<string>;
/**
* AxiosError 발생 시, 토큰 갱신이 필요한지 여부를 반환합니다.
*
* @description
* 토큰 갱신이 필요한지 여부를 반환합니다.
*/
tryRequestRefreshAccessTokenCondition: (
axiosError: IAxiosErrorWithRefreshToken
) => boolean;
/**
* AxiosError 발생 시, 에러를 반환할지 여부를 반환합니다.
*
* @description
* 에러를 반환할지 여부를 반환합니다.
* 보통 토큰 갱신이 실패했을 경우로 설정합니다.
*
* @example
* ```ts
* (axiosError) => err.config.url === '/refresh-access-token'
* ```
*/
failedRequestRefreshAccessTokenCondition: (
axiosError: IAxiosErrorWithRefreshToken
) => boolean;
}
/**
* Refresh Token을 구현하기 위한 Axios 어댑터입니다.
*
* @description
* Axios 인스턴스에 인터셉터를 추가하여, 응답 상태가 에러라면 토큰을 갱신하고 재요청합니다.
*/
export class AxiosRefreshTokenAdapter {
private readonly CACHE_MAX_AGE = 1000;
private cachedAccessToken: null | string = null;
private queue: Array<[(token: string) => void, () => void]> = [];
private isFetching = false;
private eventEmitter = new SimpleEventEmitter();
constructor(
private axiosInstance: AxiosInstance,
private options: IAxiosRefreshTokenAdapterOptions
) {
this.axiosInstance.interceptors.response.use(
undefined,
this.handleResponseReject.bind(this)
);
}
/**
* 응답 상태가 에러라면 토큰을 갱신하고 재요청합니다.
*/
private async handleResponseReject(err: AxiosError) {
const isTry = this.options.tryRequestRefreshAccessTokenCondition(err);
const isFailed = this.options.failedRequestRefreshAccessTokenCondition(err);
if (isFailed || !isTry) {
return Promise.reject(err);
}
try {
const accessToken = await this.getRefreshedAccessToken(err);
err.config.headers.Authorization = `Bearer ${accessToken}`;
return this.axiosInstance(err.config);
} catch (refreshErr) {
return Promise.reject(err);
}
}
/**
* 토큰을 갱신하고, 갱신된 토큰을 반환합니다.
*/
private async getRefreshedAccessToken(err: AxiosError) {
// 토큰이 캐시되어 있다면, 캐시된 토큰을 반환합니다.
if (this.cachedAccessToken) return this.cachedAccessToken;
// 이미 토큰을 갱신하는 중이라면, 토큰이 갱신될 때까지 재요청하지 않고 대기합니다.
if (this.isFetching)
return new Promise<string>((resolve, reject) =>
this.queue.push([resolve, reject])
);
this.isFetching = true;
try {
this.cachedAccessToken = await this.options.refreshTokenFetcher(err);
this.eventEmitter.emit('refreshed', this.cachedAccessToken);
// 토큰이 갱신되었으므로, 대기중인 모든 promise를 resolve합니다.
this.queue.forEach(([resolve]) => resolve(this.cachedAccessToken));
// CACHE_MAX_AGE 이후에 캐시된 토큰을 삭제합니다.
setTimeout(() => {
this.cachedAccessToken = null;
}, this.CACHE_MAX_AGE);
this.isFetching = false;
} catch (err) {
this.eventEmitter.emit('failed', err);
this.queue.forEach(([, reject]) => reject());
this.isFetching = false;
throw err;
}
return this.cachedAccessToken;
}
/**
* accessToken 토큰이 갱신될 때 호출될 콜백을 등록합니다.
*/
on(key: 'refreshed', listener: (accessToken: string) => void): TUnsubscribe;
/**
* 토큰 갱신이 실패했을 때 호출될 콜백을 등록합니다.
*/
on(key: 'failed', listener: (err: AxiosError) => void): TUnsubscribe;
on(key: string, listener: TListener) {
return this.eventEmitter.on(key, listener);
}
off(key: 'refreshed', listener: (accessToken: string) => void): void;
off(key: 'failed', listener: (err: AxiosError) => void): void;
off(key: string, listener: TListener) {
return this.eventEmitter.off(key, listener);
}
}
// ========================
// 사용 예제
// ========================
export const axiosRefreshTokenAdapter = new AxiosRefreshTokenAdapter(
axiosClient,
{
async refreshTokenFetcher(err) {
const refreshToken = getRefreshToken()
const response = await axios({
url: API_REFRESH_ACCESS_TOKEN,
method: 'POST',
headers: {
Authorization: refreshToken,
},
});
return response.data.accessToken;
},
tryRequestRefreshAccessTokenCondition(err) {
return err.response?.status === 401;
},
failedRequestRefreshAccessTokenCondition(err) {
return err.config.url === API_REFRESH_ACCESS_TOKEN;
},
}
);
SimpleEventEmitter
- 패키지 설치 없이 이벤트를 사용해야 할때 가볍게 사용 가능한 이벤트 처리 클래스입니다.
export type TListener = (value?: any) => void;
export type TUnsubscribe = () => void;
export class SimpleEventEmitter {
private events = new Map<string, TListener[]>();
on(key: string, listener: TListener): TUnsubscribe {
const listeners = this.events.get(key) ?? [];
this.events.set(key, [...listeners, listener]);
return () => this.off(key, listener);
}
off(key: string, listener: TListener) {
const listeners = this.events.get(key);
if (!listeners?.length) return;
this.events.set(
key,
listeners.filter((l) => l !== listener)
);
}
emit(key: string, value?: any) {
const listeners = this.events.get(key);
if (!listeners?.length) return;
listeners.forEach((listener) => listener(value));
}
}
useStorage
- 세션 또는 로컬 스토리지와 연동되는 상태가 필요할 때 사용합니다.
- 다른 브라우저 탭 또는 창, 다른 컴포넌트에서 사용해도 키가 같다면 상태가 같이 변경됩니다.
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { SimpleEventEmitter } from '@/util/simpleEventEmitter';
type TUseStorageOptions<T> = {
storageType?: 'localStorage' | 'sessionStorage';
initialValue?: T;
};
const emitter = new SimpleEventEmitter();
const useStorage = <T = null>(
key: string,
options: TUseStorageOptions<T> = {}
) => {
const { storageType = 'localStorage', initialValue = null } = options;
const storageKey = `useStorage.${key}`;
const getStorageValue = () => {
if (typeof window === 'undefined') return initialValue;
const storageValue = window[storageType].getItem(storageKey);
return storageValue ? JSON.parse(storageValue) : initialValue;
};
const [value, setValue] = useState<T>(getStorageValue());
const setStorageValue: Dispatch<SetStateAction<T>> = (newValue) => {
if (typeof newValue === 'function') {
newValue = (newValue as (prevState: T) => T)(value);
}
emitter.emit(storageKey, newValue);
window[storageType].setItem(storageKey, JSON.stringify(newValue));
setValue(newValue);
};
useEffect(() => {
const handler = (event: StorageEvent) => {
if (event?.key === storageKey) setValue(JSON.parse(event.newValue));
};
window.addEventListener('storage', handler);
const unsubscribe = emitter.on(storageKey, setValue);
return () => {
window.removeEventListener('storage', handler);
unsubscribe();
};
}, [storageKey]);
return [value, setStorageValue] as const;
};
export default useStorage;
Observer
- react에서 외부 상태가 필요할 때 사용합니다
export class Observer<T> {
listeners: Array<(value: T) => void> = []
value: T
constructor(value: T) {
this.value = value
}
subscribe(listener: (value: T) => void) {
this.listeners.push(listener)
return () => this.unsubscribe(listener)
}
unsubscribe(listener: (value: T) => void) {
this.listeners = this.listeners.filter(l => l !== listener)
}
get() {
return this.value
}
set(value: T) {
this.value = value
this.listeners.forEach(listener => listener(value))
}
}
// ========================
// alertDialog 함수 구현 예제 (shadcn/ui)
// ========================
interface GlobalAlertDialogStateOptions {
id?: number | string
_open?: boolean
title: React.ReactNode
description?: React.ReactNode
reverseActions?: boolean
onConfirm?: () => void | Promise<void>
onCancel?: () => void | Promise<void>
cancelText?: React.ReactNode
confirmText?: React.ReactNode
cancelButtonProps?: AsyncButtonProps
confirmButtonProps?: AsyncButtonProps
}
export const globalAlertDialogState = new Observer<GlobalAlertDialogStateOptions[]>([])
let globalAlertDialogId = 1
const createGlobalAlertDialogItem = (options: GlobalAlertDialogStateOptions) => {
const id = options.id ?? globalAlertDialogId++
globalAlertDialogState.set([...globalAlertDialogState.value, { id, _open: true, ...options }])
}
// 직접 사용하는 함수
export const alertDialog = Object.assign(createGlobalAlertDialogItem, {})
// app/layout.tsx에 배치
export const GlobalAlertDialog = () => {
const [items, setItems] = React.useState<GlobalAlertDialogStateOptions[]>([])
React.useEffect(() => {
return globalAlertDialogState.subscribe(items => {
setItems(items)
})
}, [])
const handleClickButton = (callback?: () => void | Promise<void>) => async () => {
await callback?.()
globalAlertDialogState.set(
items.map(item => {
if (item.id !== items[0].id) return item
return {
...item,
_open: false,
}
}),
)
// 애니메이션 효과를 위해 잠시 뒤 삭제
setTimeout(() => {
globalAlertDialogState.set(items.filter(item => item.id !== items[0].id))
}, 200)
}
return (
<>
{items.map(item => {
const actions = [
<AsyncButton
key="cancel"
variant={item.reverseActions ? 'default' : 'outline'}
onClick={handleClickButton(item.onCancel)}
shouldLoadingIconShow
{...item.cancelButtonProps}
>
{item.cancelButtonProps?.children ?? item.cancelText ?? '취소'}
</AsyncButton>,
<AsyncButton
key="confirm"
variant={item.reverseActions ? 'outline' : 'default'}
onClick={handleClickButton(item.onConfirm)}
shouldLoadingIconShow
{...item.confirmButtonProps}
>
{item.confirmButtonProps?.children ?? item.confirmText ?? '확인'}
</AsyncButton>,
]
return (
<AlertDialog open={item._open} key={item.id}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{item.title}</AlertDialogTitle>
{item.description && (
<AlertDialogDescription asChild={typeof item.description !== 'string'}>
{typeof item.description === 'string' ? (
item.description.split('\n').map((text, i) => (
<React.Fragment key={i}>
{text}
<br />
</React.Fragment>
))
) : (
<div>{item.description}</div>
)}
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
{item.reverseActions ? actions.reverse() : actions}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
})}
</>
)
}
댓글