Data Fetching with ux

김 주현·2023년 11월 23일
0

API 통신을 진행할 때 유저에게 어떻게 피드백을 해줄 수 있을지 고민하다가 쓰는 포스팅입니다. 더 좋은 구조가 있을 것 같은데,, 일단 여기까지!


상황

  • 정해진 레이아웃에 콘텐츠 내용만 바뀔 예정
  • 콘텐츠 내용은 임의의 더미 목록
  • 버튼을 누르면 서버에서 새로운 목록을 가져와서 교체

원하는 UX

최초 로딩 여부에 따른 UI

  1. 초기 로딩은 Skeleton UI로 띄우기
  2. 이후 로딩은 Greyed Color로 Disabled 표현하기

제일 처음 데이터를 가져올 땐 어떤 데이터가 나올지 형태만 잡아주는 것이 좋을 것 같았습니다. 즉, placeholder가 필요하다고 판단했어요.

이후 placeholder가 제거되고 데이터가 렌더되었을 때, 다시 데이터를 로드한다고 placeholder를 띄우는 건 좋지 않은 UX라고 생각했어요.

왜 그렇게 생각했냐!

  • 이미 데이터가 렌더되었으므로, 어떤 레이아웃이 나올지 다시 보여줄 필요가 없다고 생각했어요.
  • 기존의 데이터가 사라지고 -> Skeleton UI가 표시되고 -> 새로운 데이터로 보여주는 과정이, 사용자에 필요 이상의 화면전환을 제공해 눈이 피로할 것이라 생각했어요.

따라서 두 번째 로드부턴 텍스트의 색깔만 바꿔줘서 처리가 되고 있다는 간접 피드백을 주는 게 낫다고 생각했고, 버튼 트리거에 Loading Text를 표시해줘서 직접 피드백을 주기로 했어요.

구조

또한 데이터 통신에 대해서도 관심사가 딱딱 구분이 됐으면 좋겠다는 생각이 있었어요. 데이터를 직접 받아오고 관리하는 Container, 데이터를 보여주는 Presenter, 거기에 오류를 관리하는 Error Boundary가 있었으면 좋겠어요.

먼저 컴포넌트 구조는 다음과 같이 작성했어요.

컴포넌트 구조

const MainPage = () => {
  const [currentPage, setCurrentPage] = useState(1);
  const [todos, setTodos] = useState([] as Todo[]);

  const { isLoading, error, refetch } = useFetch(
    `https://jsonplaceholder.typicode.com/todos?_page=${currentPage}&_limit=5`,
    {
      onSuccess: (data) => setTodos(data as Todo[]),
    }
  );

  return (
    \<ErrorBoundary fallbackRender={GlobalErrorComponent}>
      {error ? (
        \<ErrorComponent onRetry={() => refetch()} />
      ) : (
        \<AwaitedTodoList
          isLoading={isLoading}
          todos={todos}
          onNextPage={() => setCurrentPage((prev) => prev + 1)}
        />
      )}
    \</ErrorBoundary>
  );
};```

MainPage는 Container로 정했습니다. API 통신에 필요한 State와, Fetching Status를 받아와서 어떤 컴포넌트를 보여줄지 결정합니다.

useFetch는 후술하겠지만, API 통신하는 부분입니다. 현재 어떤 Fetching State인지를 받아올 수 있고, 그에 따라 어떤 컴포넌트를 보여줄지 분기하고 있습니다.

Fetching과정에서 일어나는 Error는 ErrorComponent에서 처리해주고 있고, 그 외의 에러는 ErrorBoundary에서 처리해요.

AwaitedTodoList 역시 후술하겠지만~ isLoading과 todos에 따라서 어떤 로딩을 보여줄지 결정해주는 컴포넌트에요.

나중에 규모가 커지면 react-query나 뭐 그런 서버사이드 상태를 관리하는 라이브러리로 마이그레이드 할 수도 있을 것 같아요.

useFetch

currentPage가 변경될 때마다 useFetch는 데이터를 가져옵니다. 코드는 다음과 같이 짰어요.

useFetch

const useFetch = (
 url: string,
 callbacks?: {
   onSuccess?: (data: unknown) => void;
 }
) => {
 const [isLoading, setIsLoading] = useState(false);
 const [error, setError] = useState<Error | null>(null);

 async function fetching() {
   setIsLoading(true);

   try {
     const response = await fetch(url);
     const jsonRaw = await response.json();

     setIsLoading(false);
     setError(null);

     if (callbacks?.onSuccess) {
       callbacks?.onSuccess(jsonRaw);
     }
   } catch (e) {
     setIsLoading(false);
     setError(new Error(e));
   }
 }

 useEffect(() => {
   fetching();
 }, [url]);

 return { isLoading, error, refetch: fetching };
};```

url이 변경될 때마다 fetch를 시도해요. 그 과정에서 isLoading, error State를 관리합니다. refetch가 존재하는 이유는 Error가 발생했을 때 재시도를 제공하기 위함이에요.

지금은 저렇게 url을 받아서 직접 fetch를 해주지만, 만약 나중에 규모가 커진다면 createTodo, updateTodo 이런 식으로 따로 API 호출 함수를 빼서 Wrapper을 씌워 사용하는 방식으로 가지 않을까 싶은 생각!

또한, response가 받아와진다면 콜백함수 onSuccess를 호출하게끔 했는데, 반대로 error를 받았을 때 따로 콜백함수 onError 같은 게 없습니다. 그 이유는,, onError를 호출하게 되면 error에 대한 state를 useFetch를 쓰는 상위 컴포넌트에서 관리하는 형태가 되어버리더라구요. 그래서 Hook안에서 관리할 수 있도록 했습니다.

AwaitedTodoList

AwaitedTodoList 구조

const AwaitedTodoList = ({ isLoading, todos, onNextPage }: AwaitedTodoListProp) => {
  const wouldShowInitialLoading = todos.length === 0 && isLoading;
  const todoListColor = isLoading ? 'gray' : 'black';
  const buttonText = isLoading ? 'Loading...' : 'Next';

  return (
    < div>
      < div>
        {wouldShowInitialLoading ? 'Loading...' : < TodoList color={todoListColor} todos={todos} />}
      < /div>

      < div>
        < button onClick={onNextPage} disabled={isLoading}>
          {buttonText}
        < /button>
      < /div>
    < /div>
  );
};```

그리고 사실상 오늘 구현하고자 하는 목표의 제일 포인트 되는 부분!

최초 로딩과 이후 로딩을 구분하며 표시하는 UI를 달리하는 건데, 그 부분이 wouldShowInitialLoading입니다. 최초 로딩을 판단하는 것을 주어진 todos의 length가 0이고, 현재 isLoading이 true일 때라고 판단해주었어요.

이렇게하면 데이터를 최초 불러왔을 때, 아무런 데이터가 없었다면, 그 이후 로딩 역시 최초 로딩이라고 간주하게 된다. 그런 의미로 보면 '최초'는 아니지만,, 데이터가 없었다면 어떤 레이아웃이 뜨는지 알 수 없으므로 placeholder를 띄우는 게 맞다는 생각이 들었습니다. 물론 지금은 Skeleton UI가 아닌 Loading...만 띄우고 있지만(ㅋㅋ)

만약 최초 로딩이 아니면, TodoList의 color prop에 회색을 넘겨서 로딩 표시를 하게 했어요.

+) 처음엔 TodoList의 Prop으로 isLoading을 주고 내부에서 분기해줬었는데, 다시 생각해보니 TodoList는 받아온 그대로 표시만 해주어야 하는 컴포넌트이므로, color값을 isLoading Flag가 있는 AwaitedTodoList에서 정해서 줘야한다고 생각합니당

그 다음, 버튼 역시 Loading 상태에 따라서 disabled 처리를 해주었어요.

이렇게 분리를 해놓으니 isLoading 값에 따라서 달라지는 것들은 AwaitedTodoList에서 담당을 해주게 되고, Render에 필요한 UI 값들은 자식 컴포넌트가 담당하게 되었습니다! 본래 의도했던 Container와 Presenter의 구분이 잘 되는 부분이라고 생각합니다.


정리

UX 접근

  • 만약 최초 로딩과 이후의 로딩에 따른 UI를 구분하고 싶다면, 포인트는 '최초 로딩'을 구분하는 것. 최초 로딩을 어떻게 판단할 것이 중요하다.
  • 로딩 상태에 따라 어떤 게 달라지는지 잘 구분해주어야 하고, 그 구분에 따라 컴포넌트도 분리시켜주면 좋다.

데이터 접근

  • Data Fetching을 시도할 때부터 로딩 상태, 에러에 따른 로직을 구분해두어야 나중에 구조가 복잡해지지 않는다.
profile
FE개발자 가보자고🥳

0개의 댓글

관련 채용 정보