React에서 useToast()를 커스텀 훅으로 만들어쓰고 있었다.
const toast = useToast();
toast({
type: "success",
content: "저장되었습니다!",
});
혹은 컴포넌트 내부에서만 호출 할 수 있다는 제약때문에 React 외부에서는 토스트를 띄울 수 없다.
axios intercepter에서 토큰만료 안내를 할 때는 브라우저 alert를 사용해야했다.
axios.interceptors.response.use(
(res) => res,
async (error: AuthAxiosError) => {
...
const isTokenExpired =
status === 401
if (isTokenExpired && error.config.url === REFRESH_API_URL) {
...
alert("세션이 만료되어 로그인 페이지로 이동합니다.");
logout();
...
}
},
);
우리팀의 제품은 일관된 UI를 지향하는데, React의 제약에 체념한 상태였다.
utils폴더안에 toast.ts를 다음과 같이 만들자.
// toast.ts
const toast = {
showToast: (newToastOption: ToastOptionType) => {
console.warn("ToastProvider가 아직 초기화되지 않았습니다.");
},
_setFunctions(add: (newToastOption: ToastOptionType) => void) {
this.showToast = add;
},
};
export default toast;
이 toast 객체는 React 바깥을 포함한 모든 전역에서 접근 가능하다. 초기에는 아무 기능도 없지만, 아래와 같이 Provider가 마운트되면서 내부 함수를 주입한다.
// toast.ts
const toast = {
showToast: (_newToastOption: ToastOptionType) => {
console.warn("ToastProvider가 아직 초기화되지 않았습니다.");
},
_setFunctions(add: (newToastOption: ToastOptionType) => void) {
this.showToast = add;
},
};
// Provider.tsx
export const Provider= ({...}:ToastProps) => {
...
useEffect(()=>{
toast._setFunctions(showToast);
return ()=>{
toast._setFunctions(()=>{ console.warn("초기화")});
}
},[])
}
useToast 는 함수를 반환하는데 이 함수는 생성될 때 주변 환경을 기억한다. (주변환경 = toast, setToast)
toast._setFunction는 this가 해당 객체(toast)를 가리킨다.
덕분에 toast.showToast에 React setter를 주입 할 수 있다.
// utils/handleError.ts
import toast from "@/utils/toast";
export function handleError(message: string) {
toast.showToast({
type: "error",
content: `에러 발생: ${message}`,
});
}
React 커스텀 훅은 범위에 제한이 있다. 그 한계를 벗아나기 위해서는 클로저를 활용하는 것이 좋은 해결책이 될 수 있다.
useToast가 반환하는 함수는 useToast가 선언될때의 setState를 갖고 있기 때문에 react 외부의 toast 객체로 넘겨져도 setState를 실행할 수 있는 것이다.
당시에는 근본적인 해결책은 react외부에서 setState를 일으켜야 하는 것이고 그러다보니까 zustand는 오히려 react에 해당하는 내 방향성과 대척점에 있는 영역이라고 생각했다.
나중에 zustand getState로 쉽게 해결할수있다는걸 알게되었다.
한마디로 '몰랐음'
내 입장에서는 클로저를 이렇게까지 활용한다고? 라며 자뻑에 빠지게 되는 사건이었다.
cleanup을 하지 않으면, Provider가 언마운트 - 마운트 되는 사이에 옛 toast 함수가 작동하지만 렌더링할 Provider는 언마운트되어 있는 상황이 있을 수 있다.
이 내용때문에 2차 작업을 하게 되었다.
ToastProvider를 Route 외부에서 렌더링시키고 있었기 때문에 SPA특성상 ToastProvider는 언마운트되는 상황이 없을것이라 생각했다.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ToastProvider } from "@/components/ToastProvider";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ToastProvider>
<App />
</ToastProvider>
</StrictMode>
);
새로고침이나 페이지 이동등 어플리케이션 자체가 언마운트 될때만 ToastProvider가 언마운트 되는데 이때는 toast고 뭐고 다 내려가는 상황이니까 신경 안썼는데, Strict 모드에서 마운트 -> 언마운트 -> 마운트 과정을 거치기 때문에 clean up이 필요하다는걸 알게 되었다.
또한 의미상 React 외부의 객체에 React 내부에서 가져온 setter를 주입하는 것이기 때문에, Provider가 언마운트 되면 연결된 toast 객체까지 cleanup 하기로 추가 하였다.
const ToastInjector = () => {
const showToast = useToast();
useEffect(() => {
toast._setFunctions(showToast);
// cleanup 함수 추가
return () => {
toast._setFunctions(() => {
throw new Error("Toast functions are not available");
});
};
}, [showToast]);
return null;
};
처음 구현은 toast._setFunctions(showToast) 를 통해 react setter를 전역 toast 객체에 주입하는 방식이었다.
이 방식은 React 외부에서 setState를 일으켜 toast를 호출하게 해주는 목적은 달성했지만 몇가지 문제점이 있었다.
React 외부에서는 단순히 아래 코드가 정상동작 하려면 ToastProvider가 이미 마운트 되어야 한다.
toast.showToast({
type: "error",
content: "에러 발생"
});
ToastProvider가 UI 렌더링도 하고 외부 객체도 바꾸는 2가지 책임을 갖고 있다.
Event emitter는 쉽게 말하면 이벤트 발행자와 구독자 사이의 브릿지이다.
Event Emitter에 대한 설명보다는 토스트 관리 아키텍처에 대해 설명할 예정
기존에는 toast.showToast에 react setter를 주입했다.
const toast = {
showToast: (_newToastOption: ToastOptionType) => {
console.warn("ToastProvider가 아직 초기화되지 않았습니다.");
},
_setFunctions(add: (newToastOption: ToastOptionType) => void) {
this.showToast = add;
},
};
export default toast;
event emitter 기반으로 변경하면서는 내부구현이 함수 주입에서 이벤트 발행으로 변경되면서 더이상 Provider의 라이프사이클에 의해 교체되지 않고 항상 toastEmitter.emit을 호출하게 되었다.
export const toast = {
showToast: (toastOption: ToastOptionType) => {
toastEmitter.emit(toastOption);
},
};
외부에선 여전히 아래처럼 사용한다.
toast.showToast({
type: "success",
children: "데이터를 성공적으로 가져왔습니다"
})
const ToastProvider = () => {
const showToast = useToast();
...
useEffect(() => {
const unsubscribe = toastEmitter.on((toast) => {
showToast(toast);
});
return unsubscribe;
}, [showToast]);
return <>....{children}</>
};
Provider가 마운트되면 toast 이벤트를 구독하고, 언마운트되면 구독을 해제한다.
toast를 구독하고 있는 동안(마운트 되어있을때)에는 외부에서 toastEvent를 발행하면 ToastProvider에서 setToast가 발생하여 toast가 렌더링된다.
클로저 방식에서는 Provider가 toast객체에 setter를 주입하면서 외부의 전역 객체를 교체했다면, Event Emitter 방식에서는 Provider가 전역 이벤트를 구독한다.
이렇게 했을때는 toast.showToast가 항상 고정되어 있고 Provider는 이벤트를 구독만 하기 때문에 이벤트 발행과 UI 렌더링의 책임이 명확하다.
나중에 getState를 알게 되었다. zustand는 react 외부에서 store를 쓰기 때문에 그냥 store에서 가져오면 된다. (useStore는 외부 store를 react에서 구독하기 위한 훅)
toast객체를 아래와 같이 넣어주면 ToastProvider에서는 아무것도 구독할 필요 없이 그냥 토스트만 렌더링 해주면 된다.
export const toast = {
showToast: (toastOption: ToastOptionType) => {
// toastEmitter.emit(event);
useToastStore.getState().setToastOption(toastOption);
},
};
// toast.ts
const toast = {
showToast: (_newToastOption: ToastOptionType) => {
console.warn("ToastProvider가 아직 초기화되지 않았습니다.");
},
_setFunctions(add: (newToastOption: ToastOptionType) => void) {
this.showToast = add;
},
};
// Provider.tsx
export const Provider= ({...}:ToastProps) => {
...
useEffect(()=>{
toast._setFunctions(showToast);
return ()=>{
toast._setFunctions(()=>{ console.warn("초기화")});
}
},[])
}