서버 컴포넌트를 사용하면서 겪었던 DX 해소하기

데브현·2025년 4월 13일
31

프론트엔드 모음집

목록 보기
16/17
post-thumbnail

서버 컴포넌트와 SSR을 사용하면서 좋은 점도 많았지만, DX가 좋지 않았던 단점들이 존재했다. 어떠한 문제들을 겪었었고 어떻게 문제를 풀어냈는지 작성해 보려고 한다.

1️⃣ 서버에서 Fetch하는 영역 명확히 분리하기

앱 라우터 하위에 Home 페이지 컴포넌트가 존재하고 여기에는 서버에서 Fetch하는 컴포넌트가 존재한다.
간단하게 서버에서 todo를 fetch하고 이를 TodoComponent(Client 컴포넌트)에 넘겨주는 간단한 코드이다.

// /app/home/page.tsx

export default async function Home() {
  return (
    <div className={styles.page}>
      <main className={styles.main}>
        <Suspense fallback={<>loading...</>}>
          <FetchTodoComponent />
        </Suspense>
      </main>
    </div>
  );
}


export const fetchTodo = (): Promise<TodoResponse> => {
  return fetch("https://jsonplaceholder.typicode.com/todos/1").then(
    (response) => {
      return response.json();
    }
  );
};

const FetchTodoComponent = async () => {
  const todos = await fetchTodo();
  return <TodoComponent todos={todos} />;
};
// app/home/components/TodoComponent/index.tsx

'use client'

export const TodoComponent = ({ todos }: { todos: TodoResponse }) => {
  return (
    <>
      <div>{todos.id}</div>
      <div>{todos.title}</div>
      <div>{todos.userId}</div>
    </>
  );
};

여기서는 그렇게 크게 DX가 불편하다고 느끼지 못할 수 있다.
그러나 서버에서 fetch해야 하는 코드가 많아진다면?, 직렬/병렬 호출해야 하는 API들이 많아진다면?, 서비스의 기능들이 점점 커져 요구사항이 많아진다면?
위의 방식만으로는 불필요하게 컴포넌트를 만들게 된다.

코드로 예를 들어 보자.

const FetchTodoListComponent = async () => {
  // const todos = await fetchTodo();
  const [todos, comments, bookmark] = await Promise.all([
    fetchTodo,
    fetchComment,
    fetchBookmark,
  ]);

  return (
    <>
      <TodoComponent todos={todos} />
      <CommentComponent comments={comments} />
      <BookMarkComponent bookmark={bookmark} />
    </>
  );
};

이런 식으로 Todo에 대한 정보뿐만 아니라 댓글, 즐겨찾기 등 서비스의 기능들이 확장되었다.
그러면 서버에서 Fetch해야 할 것들이 많아져 컴포넌트 하나에서 해야할 일들이 많아지는 것이다.

그럼 이것을 모아놓은 FetchTodoListComponent 컴포넌트는 단순히 서버에서 fetch하기 위해 한번 Wrapping하는 컴포넌트이고 굉장히 결합도가 높아진 컴포넌트로 변질되었다.

이후에도 요구사항이 투두뿐만 아니라 다른 API들도 호출해야 한다면 그럴 때는 어떻게 컴포넌트를 관리할 것인지, 컴포넌트 네이밍은 무엇으로 할지도 어려워진다.

여기서 fetch하는 책임을 다른 컴포넌트에 넘긴다면 위의 상황은 해소가 된다.

🪡 해결하기

이를 코드로 구현해보자.

type PromiseValues = readonly (() => Promise<unknown>)[] | [];

type PromiseAllAwaitedReturnType<T extends PromiseValues> = {
  -readonly [K in keyof T]: Awaited<ReturnType<T[K]>>;
};

type Props<T extends PromiseValues> = {
  fetchFunctions: T;
  children: (results: PromiseAllAwaitedReturnType<T>) => JSX.Element;
};

export const FetchBoundary = async <T extends PromiseValues>({
  fetchFunctions,
  children,
}: Props<T>) => {
  try {
    const results = (await Promise.all(
      fetchFunctions.map((fetchFunction) => fetchFunction())
    )) as PromiseAllAwaitedReturnType<T>;

    return children(results);
  } catch (error: unknown) {
    throw error;
  }
};

FetchBoundary라는 컴포넌트를 만들었다.
이 컴포넌트의 역할은 서버에서 Fetch가 필요한 함수들을 전달받으면 호출 후에 응답값을 children에 그대로 값을 전달해주는 역할을 한다. (Render Props 구조 활용)

위의 타입이 복잡해 보일 수 있겠지만, 이는 Promise.all의 타입을 그대로 가져왔다.

Promise.all을 사용하여 results를 children에 넘겨줄 때 fetch의 순서대로 응답 타입을 정확하게 추론할 수 있게 된다.

그럼 이를 활용한 코드로 바꿔보자.

export default async function Home() {
  return (
    <div className={styles.page}>
      <main className={styles.main}>
        <Suspense>
          <FetchBoundary fetchFunctions={[fetchTodo, fetchComment, fetchBookmark]}>
            {([todos, comments, bookmark]) => {
              return (
                <>
                  <TodoComponent todos={todos} />
                  <CommentComponent comments={comments} />
                  <BookMarkComponent bookmark={bookmark} />
                </>
              );
            }}
          </FetchBoundary>
          
          // fetch가 추가, 삭제, 변경에도 명확한 구분이 가능해짐
          <FetchBoundary fetchFunctions={[fetchSomeA, fetchSomeB]}>
            {([a, b, c]) => {
              return (
                <>
                  ....
                </>
              );
            }}
          </FetchBoundary>
        </Suspense>
      </main>
    </div>
  );
}

FetchBoundaryfetch하는 책임을 넘겨주니 개발자는 유연한 대응이 가능해졌다.
즉, 불필요하게 컴포넌트를 만들지 않아도 되며, 코드의 변경에 있어서 대처가 쉬워지게 된 것이다.

2️⃣ 서버에서 발생한 에러도 받아보기

서버환경에서 API 호출 중에 에러가 발생할 수 있다. 아래와 같이 fetch함수를 강제로 에러를 발생시키도록 바꿔보자.
(임시로 JSON.stringify 형태로 상태 코드와 메시지를 전달하도록 하였다.)

export const fetchTodo = async (): Promise<TodoResponse> => {
  try {
    const res = await fetch("https://jsonplaceholder.typicode.com/todos/1");

    const data = await res.json();
    throw new Error(
      JSON.stringify({
        statusCode: 400,
        errorMessage: "필수 파라미터가 누락되었습니다.",
      })
    );

    // return data;
  } catch (err: unknown) {
    throw err;
  }
};

이렇게 에러가 발생하면 Next에서는 digest로 Hash 처리해서 내려주게 된다.

여기서 발생한 에러에 대해서 에러 바운더리 컴포넌트로 캐치하도록 하였다.

// components/ErrorBoundary.tsx
"use client";

import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary";
import { isCustomError } from "../lib/ServerError";

export function ErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <ReactErrorBoundary
      fallbackRender={({ error, resetErrorBoundary }) => {
        return (
          <div className="text-red-600 p-4">
            <h2>🚨 알수 없는 에러 발생!</h2>
            <p>name: {error.name}</p>
            <p>name: {error.message}</p>
            <p>digest: {error.digest}</p>

            <button
              className="mt-4 px-3 py-1 bg-blue-500 text-white rounded"
              onClick={resetErrorBoundary}
            >
              다시 시도
            </button>
          </div>
        );
      }}
    >
      {children}
    </ReactErrorBoundary>
  );
}

export default async function Home() {
  return (
    <div className={styles.page}>
      <main className={styles.main}>
        // 에러 바운더리 추가
        <ErrorBoundary>
          <Suspense fallback={}>
            <FetchBoundary fetchFunctions={[fetchTodo]}>
              ....
            </FetchBoundary>
          </Suspense>
        </ErrorBoundary>
      </main>
    </div>
  );
}

next buildnext start했을 때 나오는 에러 화면을 보면 정보들을 볼 수 없어진다.

즉, 위의 FetchBoundary에서 던진 에러는 클라이언트에서 에러에 대한 모든 정보(Error stack 등)를 알 수 없게 된다.
민감한 정보를 전달하지 않기 위해서이지만 개발하다 보면 모든 에러에 대한 정보가 필요한 때가 생긴다.

🪡 해결하기

그러면 어떻게 처리해야 할까?🤔
서버에서 발생한 에러에 대해서 커스텀하게 저장해야 하는 과정이 있어야 서버에서 발생한 에러를 가져올 수 있다.

export class CustomError extends Error {
  name: string = "ServerError";
  statusCode: number = 500;
  message: string = "Unknown server error";

  constructor(error: unknown, name?: string) {
    super();
    this.name = name || this.name;
    // Error 객체인 경우
    if (error instanceof Error) {
      try {
        // JSON으로 stringify 해서 에러를 던졌으므로 parsing 처리
        const parsed = JSON.parse(error.message);
        if (parsed && typeof parsed === "object") {
          this.message = parsed.errorMessage || parsed.message || this.message;
          this.statusCode = parsed.statusCode || this.statusCode;
        } else {
          this.message = error.message;
        }
      } catch {
        this.message = error.message;
      }
    }

  }

  getCustomError() {
    return {
      statusCode: this.statusCode,
      message: this.message,
      stack: this.stack,
    };
  }
}

export const isCustomError = (error: unknown): error is CustomError => {
  return error instanceof CustomError;
};

직접 커스텀 에러로 만들어줘서 던져주도록 처리합니다.


"use client";
// ErrorBoundary가 잡을 수 있도록 던져줌
export const Error = ({ error }: Props) => {
  throw error; 
};


export const FetchBoundary = async <T extends PromiseValues>({
  fetchFunctions,
  children,
}: Props<T>) => {
  try {
    const results = (await Promise.all(
      fetchFunctions.map((fetchFunction) => fetchFunction())
    )) as PromiseAllAwaitedReturnType<T>;

    return children(results);
  } catch (error: unknown) {

    const errorData = new CustomError(error, "server error").getCustomError();

    return <Error error={errorData} />;
    // throw error;
  }
};
// components/ErrorBoundary.tsx
"use client";

import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary";

export function ErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <ReactErrorBoundary
      fallbackRender={({ error, resetErrorBoundary }) => {
        return (
          <div className="text-red-600 p-4">
            <h2>🚨 에러 발생!</h2>
            <p>name: {error.name}</p>
            <p>message: {error.message}</p>
            <p>statusCode: {error.statusCode}</p>

            {error.stack !== undefined && (
              <details className="text-sm mt-2 whitespace-pre-wrap">
                {JSON.stringify(error.stack, null, 2)}
              </details>
            )}

            <button
              className="mt-4 px-3 py-1 bg-blue-500 text-white rounded"
              onClick={resetErrorBoundary}
            >
              다시 시도
            </button>
          </div>
        );
      }}
    >
      {children}
    </ReactErrorBoundary>
  );
}

즉 빌드 환경에서도 서버에서 발생한 에러를 받아볼 수 있게 된다.
(물론, 클라이언트에 모든 에러정보를 보여주면 안되겠지만,,)

3️⃣ 스켈레톤을 무조건 보여주지 않는 방법찾기

API를 호출하는 동안 UX를 챙기기 위해 스켈레톤이 자주 사용되곤 한다. 그러나 스켈레톤을 항상 노출하는 것이 올바른 UX는 아니다. (관련 글 => https://tech.kakaopay.com/post/skeleton-ui-idea/)

CSR만 적용하는 서비스라면 위의 블로그의 코드로 모든게 커버가 가능하다. 하지만 SSR을 사용하는 경우에는 위의 코드로는 Deferred가 동작하지 않는다..!

코드로 살펴보자.

다음과 같이 1000ms이내(확인이 쉽도록 1초로 했다. 블로그 글에 따르면 250ms로 적용해야 한다.)면 노출하지 않고, 이후라면 children을 노출하는 컴포넌트라고 해보자.

'use client'

export const DeferredComponent = ({
  children,
  deferredMs = 1000,
}: {
  fallback?: React.ReactNode;
  deferredMs?: number;
  children: React.ReactNode;
}) => {
  const [showSkeleton, setShowSkeleton] = useState(false);

  useEffect(() => {
    const timeId = setTimeout(() => {
      setShowSkeleton(true);
    }, deferredMs);

    return () => clearTimeout(timeId);
  }, [deferredMs]);

  if (!showSkeleton) {
    return <div
        style={{ backgroundColor: "red", height: "300px", width: "300px" }}
      ></div>;
  }

  return <>{children}</>;
};

마치 Deferred 컴포넌트를 Suspense fallback의 스켈레톤에 감싸면 1초 후에 스켈레톤이 노출될 것 같이 보인다.

export default async function Home() {
  return (
    <div className={styles.page}>
      <main className={styles.main}>
        <ErrorBoundary>
          <Suspense
            fallback={
              <DeferredComponent>
                <div>loading..</div>
              </DeferredComponent>
            }
          >
            <FetchBoundary fetchFunctions={[fetchTodo]}>
              {([todos]) => {
                return (
                  <>
                    <TodoComponent todos={todos} />
                  </>
                );
              }}
            </FetchBoundary>
          </Suspense>
        </ErrorBoundary>
      </main>
    </div>
  );
}

그러나 이렇게 하게 되면 Deferred컴포넌트 내부의 showSkeleton는 false이고, useEffect가 동작하지 않기에 Deferred에 그릴 컴포넌트'만' 그려진다.
이유는 당연히 서버 컴포넌트에서 fetch 하는 것을 Suspense에서 잡았기 때문에 fallback에는 정해진 컴포넌트가 내려가게 되는 것이다.

위의 코드로 의도했던 동작은 1초 후 스켈레톤이 노출되는 것을 의도했다. 그치만 전혀 스켈레톤이 노출되지 않는다.
그러면 서버에서 요청한 api에 대해서는 Deferred를 적용하지 못하는 것인가? 아니다.

🪡 해결하기

이를 해결하면 다음과 같이 코드를 고치면 된다.

"use client";

import { Suspense, useEffect, useState } from "react";

export const DeferredSuspenseComponent = ({
  fallback = <div>loading...</div>,
  children,
  deferredMs = 1000,
}: {
  fallback?: React.ReactNode;
  deferredMs?: number;
  children: React.ReactNode;
}) => {
  const [showSkeleton, setShowSkeleton] = useState(false);

  useEffect(() => {
    const timeId = setTimeout(() => {
      setShowSkeleton(true);
    }, deferredMs);

    return () => clearTimeout(timeId);
  }, [deferredMs]);

  return (
    <Suspense
      fallback={showSkeleton ? fallback : <div
        style={{ backgroundColor: "red", height: "300px", width: "300px" }}
      ></div>}
    >
      {children}
    </Suspense>
  );
};

Suspsense에 넣는 fallback 컴포넌트를 컨트롤하여 deferredMs가 지난 이후에 실제로 그릴 스켈레톤 컴포넌트를 그리면 된다.

export default async function Home() {
  return (
    <div className={styles.page}>
      <main className={styles.main}>
        <ErrorBoundary>
          <DeferredSuspenseComponent>
            <FetchBoundary fetchFunctions={[fetchTodo]}>
              {([todos]) => {
                return (
                  <>
                    <TodoComponent todos={todos} />
                  </>
                );
              }}
            </FetchBoundary>
          </DeferredSuspenseComponent>
        </ErrorBoundary>
      </main>
    </div>
  );
}

(API 응답을 의도적으로 3초로 늘려서 테스트)

이렇게 코드를 변경하면 의도한 대로 1초 동안은 스켈레톤이 노출되지 않고, 1초 뒤에 스켈레톤이 노출된다.

🎸 자잘하게 불편했던 DX 요소들

이외에도 여기서는 자세히 다루지 않았지만 몇 가지가 있었다.

🪼 App Router의 shallow routing 미지원

🐠 Error Boundary Reset 처리

  • Error Boundary에서 캐치 후에 reset을 할 때 단순히 reset만 하면 서버에서 fetch한 에러는 다시 요청해 내려주지 않는다. (관련 Discussion)

  • 아래 코드처럼 startTransition를 활용하여 reset과 동시에 router를 refresh 처리해야 한다.

<button
  onClick={() => {
    startTransition(() => {
      router.refresh();
      reset();
    });
  }}
>
  reload
</button>

searchParams로 상태를 관리하고 있다면 refresh로만 처리하면 기존 상태가 초기화되어버린다. 그래서 router.replace로 현재 searchParams를 그대로 가져오는 방식으로 별도 처리를 하였다.

  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const query = searchParams.toString();
  const url = query ? `${pathname}?${query}` : pathname;

  startTransition(() => {
    router.replace(url);
    reset();
  });

마지막으로 위에서 언급한 것들은 tanstack-query의 <HydrationBoundary />를 활용하면 어느 정도 해소된다.
그것과 관련된 글은 추후에 시간이 되면 작성하겠다. 🙇🏻‍♂️

더 좋은 방법이나, 글에 잘못된 점이 있으면 편하게 지적해주세요. 🙌

profile
I am a front-end developer with 4 years of experience who believes that there is nothing I cannot do.

2개의 댓글

comment-user-thumbnail
2025년 4월 14일

좋은 글 잘 읽었습니다. 1번의 경우 토스의 Suspensive와 비슷하네요!

1개의 답글