프론트엔드(Web)/React

리액트 데이터 통신 - 데이터 통신, Suspense와 ErrorBoundary, tanstack query

만능 엔터테이너 2024. 8. 28. 16:32
728x90
반응형
SMALL

데이터 통신

리액트에서 데이터 통신을 할 때는 axios 라이브러리나 fetch API를 사용하여 처리합니다. 여러가지 편의 기능은 axios 라이브러리가 더 뛰어나지만, 별도의 라이브러리를 설치해서 사용해야 한다는 단점이 있습니다. 따라서 일반적인 fetch API를 사용하여 데이터 통신을 하는 방법을 살펴봅니다.

사용법

기본

리액트에서 데이터 통신은 보통 컴포넌트 내부에서 useEffect() 훅을 사용하여 처리하는 경우가 대부분입니다.

import { useEffect, useState } from "react";
import MovieItem from "./MovieItem";

const DataFetch = () => {
  const [url, setUrl] = useState("");
  const [data, setData] = useState([]);
  const [isError, setIsError] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  useEffect(() => {
    setIsLoading(true);
    const url =
      "https://api.themoviedb.org/3/movie/now_playing?language=en-US&page=1";

    const options = {
      method: "GET",
      headers: {
        accept: "application/json",
        Authorization:
          "Bearer eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIzZDZjODUwZmVkZDY0YTUwN2U1MWNmYjIzMzVmMzA1YyIsIm5iZiI6MTcyMzYxNzQ5MC44Nzg3MDYsInN1YiI6IjYxMjViZDU5OGMzMTU5MDA2Mjk4OTUzNSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.GqrnWykIWJf9na46D3Us8bt3kw21bv7cdALKxhjU3Nw",
      },
    };

    fetch(url, options)
      .then((res) => res.json())
      .then((json) => setIsError(json))
      .catch(() => setIsError(true))
      .finally(() => setIsLoading(false));
  }, [url]);

  return (
    <>
      ...
    </>
  );
};
export default DataFetch;

 

그런데 이렇게하면 컴포넌트 내부에서 반복적으로 fetch 코드가 호출된다는 단점이 있습니다.

 

signal 추가

import { useEffect, useState } from "react";

const App = () => {
  const [data, setData] = useState([]);
  const [isError, setIsError] = useState(false);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);

    const controller = new AbortController(); // AbortController 생성
    const signal = controller.signal; // signal 가져오기
    const url = "https://jsonplaceholder.typicode.com/posts";

    const options = {
      method: "GET",
      signal, // signal 추가
    };

    fetch(url, options)
      .then((res) => res.json())
      .then((json) => setData(json)) // 데이터 상태 업데이트
      .catch((err) => {
        if (err.name === "AbortError") {
          console.log("Fetch aborted");
        } else {
          setIsError(true);
        }
      })
      .finally(() => setIsLoading(false));

    return () => {
      controller.abort(); // 컴포넌트가 언마운트되거나 useEffect가 재실행되기 전에 요청 취소
    };
  }, []); // url 의존성 제거, useEffect가 컴포넌트 마운트 시에만 실행되도록 수정

  return (
    <>
      {isLoading && <p>Loading...</p>}
      {isError && <p>Error occurred</p>}
      {!isLoading && !isError && <pre>{JSON.stringify(data, null, 2)}</pre>}
    </>
  );
};

export default App;

 


Suspense와 ErrorBoundary

개요

리액트 Suspense는 비동기적으로 로딩되는 컴포넌트를 렌더링할 때, 로딩 상태를 처리하고 사용자에게 더 나은 경험을 제공하기 위해 사용되는 기능입니다. 주로 데이터 패칭이나 코드 스플리팅 같은 비동기 작업을 관리할 때 활용됩니다.

예제

Suspens를 사용하지 않을 경우

Suspense를 사용하지 않는다면, 일반적인 데이터 패칭의 예제 코드는 아래와 같이 구현할 수 있습니다.

 

App.tsx

import { Suspense } from "react";
import UseSuspense from "./components/UseSuspense";
import UseFetch from "./components/UseFetch";

const App = () => {
  return (
    <>
      <UseFetch />
    </>
  );
};
export default App;

 

UseFetch.tsx

import { useEffect, useState } from "react";

const UseFetch = () => {
  const [isLading, setIslading] = useState(false);
  const [isError, setIsError] = useState(false);
  const [data, setData] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setIslading(true);
        const res = await fetch("https://jsonplaceholder.typicode.com/posts");
        const data = await res.json();
        setData(data);
        setIslading(false);
      } catch (e) {
        setIsError(true);
      }
    };

    fetchData();
  }, []);

  if (isLading) {
    return <p>Loading...</p>;
  }

  if (isError) {
    return <p>Error</p>;
  }

  return (
    <>
      <h1 className="text-2xl font-bold">Posts</h1>
      <ul>
        {data.map((item: any) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </>
  );
};

export default UseFetch;

 

이런 방식도 나쁜 방식은 아니지만, 코드가 단계적으로 작성되어 있어서 조금 지저분해 보입니다. 참고로 이러한 형식의 코드를 ‘명령형 코드(프로그래밍)’라고 합니다.

 

Suspense를 사용할 경우

Suspense를 사용하면 위와 같은 코드를 아래와 같이 개선할 수 있습니다.

App.tsx

import { Suspense } from "react";
import UseSuspense from "./components/UseSuspense";
import ErrorBoundary from "./components/ErrorBoundary";

const App = () => {
  return (
    <>
      <ErrorBoundary fallback={<p>Error..</p>}>
        <Suspense fallback={<p>Loading...</p>}>
          <UseSuspense />
        </Suspense>
      </ErrorBoundary>
    </>
  );
};
export default App;

 

UseSuspense.tsx

import wrapPromise from "../hooks/wrapPromise";

const wrap = wrapPromise(
  fetch("https://jsonplaceholder.typicode.com/posts").then((res) =>
    res.json().then((data) => data)
  )
);

const UseSuspense = () => {
  const data = wrap.read();
  return (
    <>
      <h1 className="text-2xl font-bold">Posts</h1>
      <ul>
        {data.map((item: any) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </>
  );
};

export default UseSuspense;

 

wrapPromise.ts

export default function wrapPromise(promise: Promise<any>) {
  let status = "pending";
  let response: any;

  const suspender = promise.then(
    (res) => {
      status = "success";
      response = res;
    },
    (err) => {
      status = "error";
      response = err;
    }
  );

  const handler: { [key: string]: () => any } = {
    pending: () => {
      throw suspender;
    },
    error: () => {
      throw response;
    },
    default: () => response,
  };

  const read = () => {
    const result = handler[status] ? handler[status]() : handler.default();
    return result;
  };

  return { read };
}

 

components/ErrorBoundary.tsx

https://react.dev/reference/react/Component

 

Component – React

The library for web and native user interfaces

react.dev

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

interface Props {
  children: ReactNode;
  fallback: ReactNode; // fallback UI를 위한 prop 추가
}

interface State {
  hasError: boolean;
}

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

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

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error("Uncaught error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // fallback prop으로 받은 UI를 렌더링
      return this.props.fallback;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

이러한 코드 방식을 ‘선언적 코드(프로그래밍)’ 이라고 합니다.


Tanstack Query

TanStack Query v5는 TypeScript/JavaScript, React, Solid, Vue, Svelte, Angular를 위한 강력한 비동기 상태 관리 도구입니다. 복잡한 상태 관리, 수동으로 데이터를 다시 가져오는 작업, 끝없는 비동기 코드의 문제를 덜어줍니다. TanStack Query는 선언적이며 항상 최신 상태로 자동으로 관리되는 쿼리와 변이를 제공하여 개발자와 사용자 모두의 경험을 향상시킵니다.

설치

npm i @tanstack/react-query

 

공급

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </StrictMode>
);

 

기본 개념 이해하기

데이터 패칭 기본

아래의 코드는 탄스택 쿼리를 사용해서 데이터를 패칭하는 기본적인 예제를 보여주고 있는 코드입니다. 이때, data, isError, error, isLoading과 같은 값을 구조 분해 할당을 통해서 받아서 사용할 수 있다는 점에 주목합시다.

import { useQuery } from "@tanstack/react-query";

const HomeView = () => {
  const { data, isError, error, isLoading } = useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      return await fetch("https://jsonplaceholder.typicode.com/posts").then(
        (res) => res.json()
      );
    },
  });

  if (isLoading) {
    return <h1>Loading...</h1>;
  }

  if (isError) {
    return <h1>Error: {error.message}</h1>;
  }

  return (
    <>
      <h1>HomeView Component</h1>
      <pre>
        <code>{JSON.stringify(data, null, 2)}</code>
      </pre>
    </>
  );
};
export default HomeView;

 

이렇게 구현된 예제 코드를 실행해서 확인해본 다음에 네트워크 탭을 열고 다른 탭을 갔다가 다시 돌아오면 탄스택 쿼리는 내부적으로 데이터 패칭을 계속 하고 있음을 확인해 볼 수 있습니다. 이는 탄스택 쿼리의 기본 동작입니다. 다른 탭에 이동했다가 원래의 탭으로 돌아오면 데이터를 새롭게 패칭합니다.

 

 

재요청 기본

탄스택 쿼리는 데이터 요청을 실패하면 기본 3번의 데이터 재요청을 합니다.

// api 경로를 임의의 주소로 변경
const { data, isError, error, isLoading } = useQuery({
  queryKey: ["posts"],
  queryFn: async () => {
    return await fetch("http://localhost:3000").then((res) => res.json());
  },
});

 

 

🔒 retry 옵션을 통해서 횟수 조정 가능

// 이제 기본적으로 5번 실패할 때 까지 기다림(로딩 상태가 유지됨)
const { data, isError, error, isLoading } = useQuery({
  queryKey: ["posts"],
  queryFn: async () => {
    return await fetch("http://localhost:3000").then((res) => res.json());
  },
  retry: 5,
});

캐시 기본

탄스택 쿼리는 기본적으로 데이터를 캐싱합니다. 데이터 캐싱을 확인해보기 위해서 아래와 같이 서버를 구성합니다.

// server 디렉토리
mkdir server
cd server
npm init -y
npm install cors express nodemon
// server/server.js

const express = require("express");
const cors = require("cors");

const app = express();
const port = 3000;

app.use(cors());

app.get("/", async (req, res) => {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  res.json({ randomNumber: Math.floor(Math.random() * (100 - 1 + 1)) + 1 });
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});
nodemon server.js

이제 API의 경로를 우리가 방금 만든 ‘http://localhost:3000’ 으로 해서 요청을 해봅니다. 그러면 우리는 두 가지 사실을 알 수 있습니다.

  1. 탭을 이동했다가 돌아오면 매번 새로운 데이터를 요청하는 구나
  2. 화면에 보여주는 데이터는 캐시된 데이터이고, 백엔드에서 데이터 요청을 한 뒤 새로운 캐시 데이터를 저장하는 구나

이를 확인하기 위해 아래 영상을 봅시다.

처음에 데이터를 요청할 때는 서버에 작성된 딜레이 때문에 2초의 딜레이를 가지고 데이터가 표시됩니다.

하지만 그 이후 요청에는 캐시된 데이터를 사용자 화면에 보여주고, 백엔드에서 데이터 패칭이 이루어지고 새로운 데이터가 캐시에 들어갑니다. 그래서 사용자는 처음에만 로딩을 보고 그 이후에는 로딩을 보지 않습니다.

 

 

계속 데이터 패칭을 하는 이유?

탄스택 쿼리는 내부적으로 설정된 staleTime 값을 사용해서 캐시에 저장된 데이터의 유지 시간을 확인합니다. 기본 값은 0입니다. 그래서 매번 새로운 데이터를 요청해서 새로운 캐시를 설정하는겁니다. 만약에 아래처럼 staleTime값을 조정하면 지정된 밀리초 동안 데이터 패칭을 하지 않습니다.

const { data, isError, error, isLoading } = useQuery({
  queryKey: ["posts"],
  queryFn: async () => {
    return await fetch("http://localhost:3000").then((res) => res.json());
  },
  staleTime: 1000 * 10, // 10 seconds
  retry: 5,
});

 

캐시 시간 조작하기

gctime 옵션으로 캐시 타임을 지정할 수 있습니다. 캐시 타임은 해당 쿼리가 비활성화 되었을 때 얼마만큼의 시간만에 캐시 데이터를 삭제할지 정하는 겁니다. 아래는 5초 동안 쿼리를 활성화 하지 않을시 캐시 데이터를 삭제합니다. 캐시데이터를 삭제하면 staleTime이 남아 있어도 데이터는 다시 요청됩니다.

const { data, isError, error, isLoading } = useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      return await fetch("http://localhost:3000").then((res) => res.json());
    },
    staleTime: 1000 * 60, // 60 seconds
    cacheTime: 1000 * 5, // 5 seconds
    retry: 5,
  });

 

위처럼 설정하면 원래 60초 동안 데이터 재패칭은 하지 않아야 합니다. 하지만 캐시가 5초만에 삭제가 되었기 때문에 다른 페이지에 갔다가 돌아오면 다시 데이터를 요청합니다.

 

 

 

gcTime의 기본 값은 5분입니다.

 

[스나이퍼 팩토리 2기 과정]

본 학습 자료 및 코드는 수코딩님의 자료를 이용하였습니다. [수코딩(https://www.sucoding.kr)] 출처

728x90
반응형
LIST