본문 바로가기

7/16 넥스터즈 3주차 (2) : 에러 처리 설계와 SWR

개발순서는 이게 먼저인데 작성하다보니 인증 구현을 먼저 작성했다 🙄

저번주에 Setup한 개발환경에서 본격적으로 구조 설계에 들어갔다.

이번에도 사심을 담아 사내에서 미리 사용할 구조를 설계한다는 기분으로 구현했다. 😏

사내에 이미 nextjs로 구현중인 프로젝트가 있는데 해당 코드를 많이 참고했다.

 

에러 처리

클라이언트 에러처리는 크게 세 부분으로 구분하였다.

 

1. 모든 페이지에 공통적으로 처리할 에러

2. 페이지 혹은 관심사별로 처리할 에러

3. 단일 컴포넌트 내 이벤트 핸들러 등에서 처리할 에러

 

들어가기 전에

httpRequest에서 발생하는 에러는 Axios Interceptor에서 처리하였다.

에러 클래스를 정의하고 http응답에서 내려주는 에러에 따라 다른 에러 인스턴스를 throw한다.

 

export class ApiError extends Error {
code?: ResponseCode
redirect?: (url: string) => string
}
export class CommonApiError extends ApiError {
code = RESPONSE.ERROR
message = '[SYSTEM] Api Error'
}
/**
* @example 리다이렉트되어야 하는 URL이 있는 에러 클래스
*/
export class RedirectArror extends ApiError {
code = RESPONSE.REDIRECT
message = '[SYSTEM] Api Error'
redirect = (url?: string) => url || ROUTES.ROOT
}
export class AccessTokenError extends ApiError {
code = RESPONSE.INVALID_ACCESS_TOKEN
message = '[SYSTEM] Access Token is Expired or Invalid'
redirect = (url?: string) => url || ROUTES.ROOT
}
export const isInstanceOfApiError = (e: unknown): e is ApiError =>
e instanceof ApiError
export const isInstanceOfCommonApiError = (e: unknown): e is CommonApiError =>
e instanceof CommonApiError
export const isInstanceOfRedirectArror = (e: unknown): e is RedirectArror =>
e instanceof RedirectArror
export const isInstanceOfAccessTokenError = (
e: unknown,
): e is AccessTokenError => e instanceof AccessTokenError
view raw ApiError.ts hosted with ❤ by GitHub
export function AuthInterceptor<T>(
res: AxiosResponse<Response<T>>,
): AxiosResponse {
const code = res.data.code
switch (code) {
case RESPONSE.INVALID_ACCESS_TOKEN:
throw new AccessTokenError()
case RESPONSE.ERROR:
throw new CommonApiError()
case RESPONSE.REDIRECT:
throw new RedirectArror()
default:
return res
}
}

 

1. 모든 페이지에 공통적으로 처리할 에러

- API 에러

- Access Token 검증 에러

 

위 두 에러는 모든 컴포넌트에서 공통적으로 처리하기 위해 __app.tsx를 Error Boundary처럼 사용하기로 하였다.

 

type AppProps = AppInitialProps
interface State {
error: Error | null
}
class Page extends App<AppProps> {
static async getInitialProps({
ctx,
Component: {getInitialProps: getComponentIntialProps},
}: AppContext): Promise<AppProps> {
try {
/* 쿠키 검증 중략 */
const props = await (getComponentIntialProps
? getComponentIntialProps(ctx)
: Promise.resolve({}))
const pageProps = {
...props,
token: letterLogin,
}
return {
pageProps,
}
} catch (error) {
if (isInstanceOfApiError(error) && ctx.req && ctx.res) {
apiServerErrorHandler(error, {req: ctx.req, res: ctx.res})
}
return {
pageProps: {error},
}
}
}
state: State = {
error: null,
}
static getDerivedStateFromError(error: Error) {
return {error}
}
componentDidCatch(error: Error, __: ErrorInfo) {
/** add common error */
if (
isInstanceOfCommonApiError(error) ||
isInstanceOfRedirectArror(error) ||
isInstanceOfAccessTokenError(error)
) {
return apiErrorHandler(error)
}
}
render() {
const {Component, pageProps} = this.props
const {error} = this.state
if (error) {
return <ErrorPage statusCode={500} />
}
return (
<>
{/* */}
</>
)
}
}
export default Page
view raw __app.tsx hosted with ❤ by GitHub

 

2. 페이지 혹은 관심사별로 처리할 에러

각 페이지마다 혹은 관심사별로 묶인 컴포넌트들에 한해서 처리해야 할 에러는 기본 Error Boundary를 사용한다.

1번에서 처리하는 에러는 동일한 처리를 위해 componentDidCatch문을 마찬가지로 추가하였다.

 

export interface FallbackProps {
error: Error
}
interface Props {
fallback: (args: FallbackProps) => ReactNode
children: ReactNode
withChildren?: boolean
}
interface State {
error: Error | null
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {error: null}
}
static getDerivedStateFromError(error: Error) {
return {error}
}
componentDidCatch(error: Error, __: ErrorInfo) {
if (
isInstanceOfCommonApiError(error) ||
isInstanceOfRedirectArror(error) ||
isInstanceOfAccessTokenError(error)
) {
return apiErrorHandler(error)
}
}
render() {
const {error} = this.state
const {children, fallback, withChildren = false} = this.props
return error ? (
<>
{fallback({error})}
{withChildren && children}
</>
) : (
children
)
}
}

 

3. 단일 컴포넌트 내 이벤트 핸들러 등에서 처리할 에러

단일 컴포넌트에서 사용될 에러는 단순한 try catch 문으로 작성한다.

 

const LoginButton = ({source, text}: {source: string; text: string}) => {
const handleLogin = async () => {
try {
const res = await withAxios<AuthorizeResponse>({
url: `/login/${source}/authorize`,
method: 'get',
params: {
redirect_uri: encodeURIComponent(
`${HOST_URL}${ROUTES.LOGIN.BRIDGE}/${source}`,
),
},
})
const {result} = res.data
const {redirectUrl} = result
window.location.replace(redirectUrl)
} catch (e) {
window.alert('시스템 오류가 발생했습니다.')
}
}
return <Button onClick={() => handleLogin()}>{text}로 로그인</Button>
}
view raw LoginButton.tsx hosted with ❤ by GitHub

 

정리

 

SWR+Axios

Data Fetching 라이브러리 SWR + Async HttpRequest 라이브러리 Axios의 조합을 사용하였다.

SWR 공식 홈페이지에서 자세한 가이드와 예시를 볼 수 있다. (한국어 버전도 있어서 쉽게 이해되었다 👍)

 

SWR GIthub에서 제공하는 예제 중 하나인 axios + typescript 예시를 따르면서 우리 프로젝트에 맞게 변형하였다.

 

withAxios.ts

Axios 인스턴스 설정이다.

먼저 기본 응답 구조를 code와 result를 갖는 객체로 구현하였다.

{
  code : 정상, 에러 응답 코드
  message : 에러 메세지
  result : 실제 사용할 데이터
}

 

code값에 따라 다른 에러 인스턴스를 throw하는 Interceptor를 생성 후 사용자 정의 Axios 인스턴스에 인자로 주입한다.

 

function AuthInterceptor<T>(res: AxiosResponse<Response<T>>): T {
const code = res.data.code
switch (code) {
case RESPONSE.INVALID_ACCESS_TOKEN:
throw new AccessTokenError()
case RESPONSE.ERROR:
throw new CommonApiError()
case RESPONSE.REDIRECT:
throw new RedirectArror()
default:
return res.data.result
}
}
export const withAxios = async <T>(request: RequestConfig): Promise<T> => {
const instance = axios.create()
instance.interceptors.response.use(AuthInterceptor)
const response = await instance.request<T, T>({
...request!,
baseURL: `${isSSR ? HOST_URL : ''}/api`,
})
return response
}
view raw withAxios.ts hosted with ❤ by GitHub

 

useRequest.ts

useSWR을 사용하는 hook으로 fetcher를 위에서 정의한 withAxios로 두었다. SWR의 key는 AxiosRequest 객체를 stringify한 문자열로 두었다.

 

interface SwrReturn<Data, Error>
extends Pick<
SWRResponse<Data, AxiosError<Error>>,
'isValidating' | 'revalidate' | 'error' | 'mutate'
> {
data: Data | undefined
response: Data | undefined
}
export interface SwrConfig<Data = unknown, Error = unknown>
extends Omit<SWRConfiguration<Data, AxiosError<Error>>, 'initialData'> {
initialData?: Data
}
export default function useRequest<Data = unknown, Error = unknown>(
request: RequestConfig,
{initialData, ...config}: SwrConfig<Data, Error> = {},
): SwrReturn<Data, Error> {
const {
data: response,
error,
isValidating,
revalidate,
mutate,
} = useSWR(
request && JSON.stringify(request),
() => withAxios<Data>(request),
{
...config,
initialData,
},
)
return {
data: response,
response,
error,
isValidating,
revalidate,
mutate,
}
}
view raw useRequest.ts hosted with ❤ by GitHub

 

실제 사용

useRequest의 사용 예시이다.

 

interface ProfileContextState {
profile: Partial<Profile> | undefined
reset: () => void
error: Error | undefined
}
const ProfileContext = createContext<ProfileContextState>(
{} as ProfileContextState,
)
export const ProfileProvider = ({
children,
token,
}: {
children: ReactNode
}) => {
const {
data: profile,
error,
mutate: refreshProfile,
} = useRequest<Profile>(
{
url: '/profile',
},
{
revalidateOnMount: true,
},
)
const value = useMemo(
() => ({
profile,
reset() {
refreshProfile()
},
error,
}),
[profile, refreshProfile, error],
)
return (
<ProfileContext.Provider value={value}>
{children}
</ProfileContext.Provider>
)
}
export const useProfileContext = () => {
const context = useContext(ProfileContext)
if (!context) {
throw new Error(`You need to wrap UserProvider.`)
}
return context
}

 

참고한 자료

Redux 를 넘어 SWR 로(2)