Server Component 실전에서 써먹기 (2편 - 서버컴포넌트 with 에러바운더리)

Sming·2023년 6월 7일
15
post-thumbnail

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를 이용한 서버 <-> 클라이언트 컴포넌트 연결

두번째 시도했던것은 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인데요. 여러가지 통신의 방법중에서 왜 Server Sent Event를 선택하였을까요?

클라이언트의 트리거로 인하여 발생하는 http요청, 서버 <-> 클라이언트 양방향 실시간 통신의 websocket는 현재 상황과 맞지 않아서 Server Sent Event를 선택했습니다.

구현은 nextjs api route를 통하여 sse서버를 만들었습니다.

SSE Server

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);
    }
  }
}

event source받는 Server ErrorBoundary

'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}</>;
};

event 관리자

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를 사용하게 되는 셈이죠.

마지막 도전 - 그냥 memory..ㅎ

SSE코드에서 이제 SSE부분만 제거를 하였습니다. 실제로 이제는 key와 그 key에 해당하는 데이터만 존재하는 객체만 있을 뿐이죠.

동작 하는 로직은 다음과 같습니다.

  • 처음 default값은 isError: false로 되어있음
  • Server Component(ISR,SSG) 에서 빌드타임에 fetch후 컴포넌트를 그림
  • fetch에서 에러가 나와서 catch문으로 가게됨.
  • catch문에서 isError값을 true로 바꿔줌 -> (isError는 그냥 코드상에 존재하는 객체이다.)
  • 이제 javascript를 다운로드 완료하고 ErrorBoundary부분이 동작 (ErrorBoundary에서 isError면 fallback 보여줌)

이렇게 될시 만약 isr 을 이용하여 5분마다 revalidate를 해주게 된다면 fetch를 하여서 에러가 발생할 시 isError값이 true가 되고 이 true값은 다음 revalidate값까지 계속 유지가 됩니다. (빌드타임때 변경이 되었기 때문에)

그러면 어짜피 5분동안은 갱신이 되지않기때문에 계속 일관된 fallback을 보여줄 수 있는것이죠.

ssr 을 이용할때는 새로고침할때마다 isError가 false, true인지 체크해서 보여주도록 합니다.

이렇게 될시 정말 간단하게 객체 하나를 이용해서 server component의 에러상태를 관리하고 그로인한 client component fallback이 가능하게 합니다.

profile
딩구르르

1개의 댓글

comment-user-thumbnail
2023년 6월 8일

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

답글 달기