1편에서 다 작성하기에는 Server Component의 에러 처리부분이 길어 질것 같아 2편으로 옮겼습니다.
에러처리에서는 단순히 어떻게 하는지 최종 과정만이 있는것이 아니라 저의 삽질 과정이 포함되어있는 글입니다 ㅎㅎ
만약 서버 컴포넌트에서 에러가 발생하면 어떻게 동작할까요? 과연 우리는 에러를 잡을 수 있을까요? 아쉽지만 저희는 서버 컴포넌트에서 발생하는 에러를 잡아서 클라이언트에서의 특정 동작을 할 수 없습니다.
말그대로 서버에서 발생하기 때문에 에러가 발생하면 아예 빌드가 터져버릴것입니다.
그렇기 때문에 서버 컴포넌트를 부르는 부분에서 try/catch
를 통하여 잡아주는것이 필요합니다.
export const getListData = async () => {
try {
const response = await fetch(`${HOME_API.API_GET_LIST}`);
const serverData = await response.json();
return serverData;
} catch (e) {
if (e instanceof Error) {
return [];
}
}
};
이렇게 작성을 하면 서버에서 에러가 나오면 빈 배열을 return을 하여 실제 화면에는 이 list가 아무것도 안나오는 것 처럼 보이게 되겠죠.
하지만 ErrorBoundary
에서 특정 client fallback을 보여주고 싶을 때 서버 컴포넌트에서 처리할 수 있을까요? 당연히 할 수 없습니다. 저희가 할 수 있는 최선은 에러가 터지지 않게 빈 배열
로 내보내는것이 다 이기 때문이죠.
처음에는 야매로 도전을 했습니다. server component의 데이터를 받아와서 읽어오는 ul tag
의 자식요소 갯수를 세어서 0개이면 그것을 에러로 판단하여 그것을 기준으로 fallback
을 보여주도록 하였습니다.
const [isActive, setIsActive] = useState(false);
useEffect(() => {
const element = document.querySelector(`${listClassName}`) as HTMLUListElement;
const childCount = element.childElementCount;
if (childCount === 0) {
setIsActive(true);
}
}), []);
이렇게 할시 동작은 당연히 되었습니다. 하지만 배열로 오는 response만 처리할 수 있다는 점과 전혀 확장성이 없는 로직이죠.
당장의 급한불은 끌 수 있지만 이대로 이러한 로직을 작성할시 다음 서버컴포넌트를 작성할떄는 또 그에 맞도록 로직을 변경해야 될지 모르기 때문에 방법을 변경하기로 합니다.
두번째 시도했던것은 zustand
를 이용하여 서버 컴포넌트와 클라이언트 컴포넌트를 연결하는것입니다.
서버 컴포넌트에서는 use...
과 같은 훅과 react의 모든 문법들은 이용하지 못합니다. 그렇기 때문에 react에서 사용가능한 (React.createContext로 만들어진) 상태관리 라이브러리들은 서버 컴포넌트에서 이용할 수 없습니다. 예를 들어 recoil, jotai등은 이용할 수가 없죠.
그렇다면 redux
, zustand
같은 vanilla js에서도 돌아가는 상태관리 라이브러리들은 server component에서도 이용이 가능하였습니다.
번들크기를 고려하여 zustand
를 선택하였습니다.
export const useStore = create(() => ({
isError: false
}))
이렇게 useStore
에 isError: false로 초기화를 해줍니다.
export const getListData = async () => {
try {
const response = await fetch(`${HOME_API.API_GET_LIST}`);
const serverData = await response.json();
return serverData;
} catch (e) {
if (e instanceof Error) {
useStore.setState({isError: true});
return [];
}
}
};
그런 후에 아까의 api 호출하는 부분에서 catch에 걸리면 isError
를 true로 바꾸도록 설정해줍니다. zustand이기때문에 서버에서도 이러한 것이 가능한거죠.
'use client'
export const ServerErrorBoundary = ({children, fallback}: {children: React.ReactNode, fallback: React.ReactNode}) => {
const isError = useStore.getState();
if(isError) {
return <>{fallback}</>
}
return <>{children}</>
}
그 후에 ServerErrorBoundary
라는것을 만들어서 아까만든 상태를 받아서 isError일 경우에는 fallback
, 아니면 children
을 내보내도록 말이죠.
이것은 client component
이기 때문에 fallback에도 자유자재로 client component
를 이용할 수 있습니다.
하지만 서버와 클라이언트 컴포넌트끼리 이러한 상태관리 툴로 나누는것이 맞을까요..? 이는 예상치 못한 동작을 야기시킬수도 있을것 같았습니다. 예를 들어 타이밍 문제 같은것도 있을 수 있고요..
저는 서버와 클라이언트는 통신
을 통하여 데이터를 전달받는게 맞다고 생각하여 그 다음 과정인 SSE
로 넘어가게 됩니다.
벌써 세번째 도전중입니다.. 이번에 시도해본것은 Server Sent Event인데요. 여러가지 통신의 방법중에서 왜 Server Sent Event
를 선택하였을까요?
클라이언트의 트리거로 인하여 발생하는 http요청, 서버 <-> 클라이언트 양방향 실시간 통신의 websocket는 현재 상황과 맞지 않아서 Server Sent Event를 선택했습니다.
구현은 nextjs api route
를 통하여 sse
서버를 만들었습니다.
import { NextRequest } from 'next/server';
import { eventEmitterObj, Ssekeys } from '@/server/sse';
export const dynamic = 'force-dynamic';
export async function GET(
req: NextRequest,
{ params }: { params: { type: (typeof Ssekeys)[number] } },
) {
const { type } = params;
const eventEmitterInstance = eventEmitterObj[type];
try {
const eventListener = (data: any) => {
const eventData = `data: ${JSON.stringify(data)}\n\n`;
eventEmitterInstance.data = eventData;
};
if (eventEmitterInstance.eventEmitter.listeners(`event-${type}`)[0]) {
eventEmitterInstance.eventEmitter.removeListener(
`event-${type}`,
eventEmitterInstance.eventEmitter.listeners(`event-${type}`)[0] as (...args: any) => any[],
);
}
eventEmitterInstance.eventEmitter.on(`event-${type}`, eventListener);
const realResponse = new Response(eventEmitterInstance.data, {
headers: {
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
'Cache-Control': 'no-cache, no-transform',
},
});
return realResponse;
} catch (e) {
if (e instanceof Error) {
console.log(e);
}
}
}
'use client';
import { useEffect, useState } from 'react';
import { sseActiveNames, sseErrorNames, Ssekeys } from '../sse';
export const ServerErrorBoundary = ({
targetName,
children,
fallback,
}: {
targetName: (typeof Ssekeys)[number];
children?: React.ReactNode;
fallback?: React.ReactNode;
}) => {
const [hasError, setHasError] = useState(false);
useEffect(() => {
const eventSource = new EventSource(`/home/api/sse/${targetName}`);
eventSource.onmessage = async (event) => {
if (event.data) {
const data = JSON.parse(event.data);
if (sseErrorNames[targetName] in data) {
setHasError(true);
}
}
eventSource.close();
};
}, [targetName]);
if (hasError) {
return <>{fallback}</>;
}
return <>{children}</>;
};
import { EventEmitter } from 'events';
export const Ssekeys = [
'test',
'dddd'
] as const;
const entries = Ssekeys.map((key) => [
key,
{ data: `data: ${JSON.stringify({})}\n\n`, eventEmitter: new EventEmitter() },
]);
export const eventEmitterObj = Object.fromEntries(entries) as Record<
(typeof Ssekeys)[number],
{ data: string; eventEmitter: EventEmitter }
>;
export const sseEventNames = Object.fromEntries(
Ssekeys.map((key) => [key, `event-${key}`]),
) as Record<(typeof Ssekeys)[number], `event-${(typeof Ssekeys)[number]}`>;
export const sseErrorNames = Object.fromEntries(
Ssekeys.map((key) => [key, `error-${key}`]),
) as Record<(typeof Ssekeys)[number], `error-${(typeof Ssekeys)[number]}`>;
export const sseActiveNames = Object.fromEntries(
Ssekeys.map((key) => [key, `isActive-${key}`]),
) as Record<(typeof Ssekeys)[number], `isActive-${(typeof Ssekeys)[number]}`>;
// 이벤트 발생 함수 -> catch문 내부에서 이를 호출하면 됨.
export const emitErrorEvent = (targetName: (typeof Ssekeys)[number], e: Error) => {
eventEmitterObj[targetName].eventEmitter.emit(sseEventNames[targetName], {
[sseErrorNames[targetName]]: e,
});
};
제가 생각한 과정은 다음과 같습니다.
에러가 발생하면 catch
문에서 emitErrorEvent
이벤트를 통해서 에러의 상태를 바꿉니다.
이제 SSE server에서 그 에러상태를 client에 내보냅니다.
client (Server ErrorBoundary)
에서 서버가 던져준 에러정보를 받아서 이를 통하여 fallback
을 보여주도록 합니다.
하지만 사실 SSE 서버가 서버 컴포넌트보다 늦게 초기화되기때문에 첫 빌드를 할때 동작을 하지 않습니다.
또한 현재 memory에 존재하는 값을 통하여 sse에게 에러 상태를 전달하고 있습니다. 사실 server component에서 발생하는 에러는 빌드 타임
에서 발생하는것입니다. window
객체같은 공유할 수 있는 객체에 넣어도 절대 참조를 할 수 없습니다.
빌드 타임에 나오는 에러에 대한것을 sse server
가 던질 수 있도록 하려면 반드시 memory (코드의 상수)
에서 참조해야되기 때문이죠. (위의 코드에서 eventEmitterObj
)
처음에 SSE
로 하면 딱이겠다. 라는 생각으로 하다가 메모리를 참조하는 방법밖에 없다보니 실제로는 SSE
를 사용할 필요가 없는 코드가 되었습니다. 오히려 괜히 eventStream
으로 불필요한 네트워크 payload를 사용하게 되는 셈이죠.
SSE
코드에서 이제 SSE
부분만 제거를 하였습니다. 실제로 이제는 key와 그 key에 해당하는 데이터만 존재하는 객체만 있을 뿐이죠.
동작 하는 로직은 다음과 같습니다.
isError: false
로 되어있음이렇게 될시 만약 isr
을 이용하여 5분마다 revalidate를 해주게 된다면 fetch를 하여서 에러가 발생할 시 isError값이 true가 되고 이 true값은 다음 revalidate값까지 계속 유지가 됩니다. (빌드타임때 변경이 되었기 때문에)
그러면 어짜피 5분동안은 갱신이 되지않기때문에 계속 일관된 fallback을 보여줄 수 있는것이죠.
ssr
을 이용할때는 새로고침할때마다 isError가 false, true인지 체크해서 보여주도록 합니다.
이렇게 될시 정말 간단하게 객체 하나를 이용해서 server component의 에러상태를 관리하고 그로인한 client component fallback이 가능하게 합니다.
Thank you for posting that it could be just the thing to give inspiration to someone who needs it! Keep up the great work! My Balance Now