Next.js의 Streaming을 이용한 렌더링과 서버 컴포넌트에서의 데이터 패칭 에러 처리

고기호·2024년 10월 11일
1

만원 챌린지

목록 보기
2/3
post-custom-banner

Next.js Streaming

기존에 구현하던 렌더링의 순서


Next.js 프로젝트를 교육때 이후로 한 번도 안써봐서 일단 손이 가는 대로 프로젝트를 구현하고 있었다.

  1. 서버 사이드에서 UI를 렌더링하고 클라이언트로 보낸다.
  2. 클라이언트에서 HTML과 CSS를 이용해 UI를 그린다.
  3. Hydration을 이용해 인터렉션이 가능케한다.
  4. useEffect()로 비동기 데이터 패칭을 하고, 바인딩한다.

그렇다보니 이렇게 사용자에게 비동기 데이터 패칭해서 가져온 데이터를 보여주는데까지의 시간이 너무 길었다. 그래서 Next.js의 streaming을 이용해 사용자가 보여지기까지의 과정을 좀 더 효율적으로 바꿔보려했다.

Suspense을 이용한 렌더링 최적화


Next.js에서 React의 Suspense를 이용하면 위에서 말한 과정의 4번을 크게 개선할 수 있다. 참고로 React 18 부터 서버 컴포넌트에도 Suspense가 적용됐다고 한다.

서버 데이터 패칭은 서버 UI가 그려지기 전에 이루어진다(프리 패칭). 그리고 Suspense를 이용하면 streaming을 이용해 비동기 데이터 패칭이 완료되기 전에 이미 완성된 UI를 사용자가 먼저 볼 수 있다. 그리고 비동기 데이터 패칭이 완료되면 Suspense의 Fallback대신 데이터 바인딩이 완료된 컴포넌트를 볼 수 있다.

따라서 플로우를 보면
1. 서버 사이드에서 프리 패칭을 하고, UI를 렌더링하고, 클라이언트로 보낸다
2. 클라이언트에서 HTML과 CSS를 이용해 UI를 그린다.
3. Hydration을 이용해 인터렉션이 가능케한다.

이렇게 세 단계로 줄고, 서버에서 프리 패칭한 데이터가 바인딩된 컴포넌트는 2번 이후에 아무때나 보여지게 된다.

따라서 클라이언트에서 useEffect()로 비동기 데이터 패칭을 하는 시간을 엄청나게 줄일 수 있게 된다.

구현하기

import Expense from "./Expense";
import { ExpenseData } from "@/types/expense";
import { getExpensesByDate } from "@/apis/services/expense";
import { Suspense } from "react";

export async function ExpenseContainerServerComponent() {
    const expenses = await getExpensesByDate();

    return (
        <div>
            <ul className="flex flex-col gap-2">
                {expenses.map((expense: ExpenseData) => (
                    <Expense key={expense.id} expense={expense} />
                ))}
            </ul>
        </div>
    );
}

export default async function ExpenseContainer() {
    return (
        <Suspense fallback={<div>데이터를 불러오는 중 입니다...</div>}>
            <ExpenseContainerServerComponent />
        </Suspense>
    );
}

서버 컴포넌트에서의 데이터 패칭 에러 처리

error.js의 한계

error.js는 페이지 단위로 에러를 처리해주기 때문에 하나의 컴포넌트에서 에러 발생시 비효율적인 상황이 발생할 수 있다. 예를들어 비동기 데이터 패칭 컴포넌트에서 데이터 패칭이 실패했다고 가정하자. 그럼 해당 컴포넌트만 회복시켜주면 되는데 error.js는 페이지 전체에 영향을 준다. 따라서 개인적으로 에러 바운더리와 error.js 두개를 상황에 맞게 나누어 써야한다 생각한다.

하지만 서버 컴포넌트에서 에러 바운더리를 사용할 수 없다.


"use client";

import { Component, ErrorInfo, ReactNode } from "react";

type Props = {
    children: ReactNode;
    onReset: () => void;
};

type State = {
    hasError: boolean;
};

class ErrorBoundary extends Component<Props, State> {
    constructor(props: Props) {
        super(props);
        this.state = { hasError: false };
    }

    static getDerivedStateFromError(error: Error) {
        return { hasError: true };
    }

    componentDidCatch(error: Error, errorInfo: ErrorInfo) {
        console.log(error.message, errorInfo);
    }

    handleReset = () => {
        this.setState({ hasError: false });
        this.props.onReset();
    };

    render() {
        if (this.state.hasError) {
            return (
                <div>
                    <h1>데이터를 불러오지 못했습니다...</h1>
                    <button onClick={this.handleReset}>Reset</button>
                </div>
            );
        }

        return this.props.children;
    }
}

export default ErrorBoundary;

에러 바운더리 코드를 보면 클라이언트에서 라이프 사이클을 이용하는 방식이기 때문에 'use client'가 필수다. 따라서 서버에서 에러 바운더리를 사용하지 못한다.

다른 방법을 찾아보자


이것 저것 삽질하다가 한 문서를 찾았고, 나름 해석해서 구현을 해봤다.
https://github.com/reactjs/rfcs/blob/ba9bd5744cb922184ec9390515910cd104a30c6e/text/0215-server-errors-in-react-18.md

  1. 서버 사이드에서 데이터 패칭 에러가 발생한다
  2. try catch 문을 이용해 에러 발생시 클라이언트에 보여질 Fallback 컴포넌트를 만들어준다.
  3. 해당 컴포넌트에서 회복 로직을 처리해준다.

구현

"use client";

import { Suspense } from "react";
import { ExpenseContainerOnServer } from "./ExpenseContainerOnServer";

export default function ExpenseContainerOnClient() {
    return (
        <Suspense fallback={<div>데이터를 불러오는 중 입니다...</div>}>
            <ExpenseContainerOnServer />
        </Suspense>
    );
}
import { ExpenseData } from "@/types/expense";
import Expense from "./Expense";
import { getExpensesByDate } from "@/apis/services/expense";
import ExpenseContainerFetchErrorFallback from "./ExpenseContainerFetchErrorFallBack";

export async function ExpenseContainerOnServer() {
    try {
        const expenses = await getExpensesByDate();

        throw new Error("에러 발생");

        return (
            <div>
                <ul className="flex flex-col gap-2">
                    {expenses.map((expense: ExpenseData) => (
                        <Expense key={expense.id} expense={expense} />
                    ))}
                </ul>
            </div>
        );
    } catch (error) {
        return <ExpenseContainerFetchErrorFallback />;
    }
}
import { getExpensesByDate } from "@/apis/services/expense";
import { ExpenseData } from "@/types/expense";
import { useState } from "react";
import Expense from "./Expense";

export default function ExpenseContainerFetchErrorFallback() {
    const [expenses, setExpenses] = useState<ExpenseData[]>([]);
    const [isLoading, setIsLoading] = useState(false);
    const [isError, setIsError] = useState(true);

    const handleReset = () => {
        setIsLoading(true);
        setIsError(false);

        getExpensesByDate()
            .then((response) => {
                if (response) {
                    setExpenses(response);
                }
            })
            .catch(() => {
                setIsError(true);
            })
            .finally(() => {
                setIsLoading(false);
            });
    };

    if (isError) {
        return (
            <div>
                <div>데이터를 가져오지 못했습니다.</div>
                <button onClick={handleReset}>재요청</button>
            </div>
        );
    }

    if (isLoading) {
        return <div>데이터를 불러오는 중 입니다...</div>;
    }

    return (
        <ul className="flex flex-col gap-2">
            {expenses.map((expense: ExpenseData) => (
                <Expense key={expense.id} expense={expense} />
            ))}
        </ul>
    );
}

Reference

https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
https://github.com/reactjs/rfcs/blob/ba9bd5744cb922184ec9390515910cd104a30c6e/text/0215-server-errors-in-react-18.md

profile
웹 개발자 고기호입니다.
post-custom-banner

0개의 댓글