[modules] React + Data fetch handling
urTweet

[modules] React + Data fetch handling

반응형

고민하게 된 시점

  • 나의 최대 관심사는 뷰(View)를 작업하기 위함이다.
  • 컴포넌트의 역할은 단순히 데이터가 오면 뷰를 보여주기만 하면 된다.
  • 하지만, 여기서 다이나믹 뷰를 위해 비즈니스 로직을 컴포넌트 공간에서 작성하게 되면, 의존성 결합이 상당하다.
  • 오직 뷰만 작업했던 공간이, fetch("url")로 데이터 오고 가는 형태까지 작성하게 되었다.
function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState(
    'https://hn.algolia.com/api/v1/search?query=redux',
  );
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);
      try {
        const result = await axios(url);
        setData(result.data);
      } catch (error) {
        setIsError(true);
      }
      setIsLoading(false);
    };
    fetchData();
  }, [url]);

  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>
      {isError && <div>Something went wrong ...</div>}
      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.objectID}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Fragment>
  );
}

export default App;

이렇게 되면, 나는 한 공간에 2가지 관심사가 생기게 된다.

  • data fetch handling
  • view

컴포넌트 view 역할은 그 자체로 의미가 있는 독립된 형태이다. 하지만, data fetch handling 같은 경우 같은 url을 사용하는 컴포넌트가 여러 개 존재할 수 있다. 즉, 코드 유지보수 측면에서 api 호출 쪽에서 문제가 있거나, 변경을 해야 할 때, 컴포넌트에서 해당 api 사용하는 로직을 계속해서 검색으로 추적을 해야 할 것이다. 상당히 번거로우며, test코드가 없거나, js로만 작업을 하였을 때, 엄청난 공수 비용이 커질 것이다.

이러한 번거로움을 해결하기 위해 비즈니스 로직과 컴포넌트 다루는 로직을 분리를 하여, 컴포넌트는 비즈니스 로직을 감싼 함수를 요청하는 형태를 만들고 싶었다.
문제들을 해결하기 위해, 이전에는 Container, Representational 분리 방법이 존재하였다. 하지만, Container에서 component를 내려주는 props-render 구조는 props Drilling 효과가 초래하게 된다.

이후, hooks, 전역 sate (context api, redux)가 나오게 되어, 어느 컴포넌트 상관없이 훅스를 사용하여, 데이터 패칭을 인터페이스처럼 캡슐화 형태를 띄울 수 있게 되었다. 이로써, modules 폴더에서 hooks와 비즈니스 요청 함수들을 모듈화로 관리를 하게 되었다.

Fetch data

데이터를 불러오는 hooks를 만들기 위해 3가지 방법을 구상하였다.

  • useSelector, useDispatch로 global store redux 상태 관리
  • useFetch(useReducer)로 cache global data 상태 관리
  • useSWR(swr)로 revalidate 기능까지 할 수 있는 global data 상태 관리

redux(useSelector, useDispatch)

컴포넌트에서 useSelector, useDispatch를 사용하지 않고 custom hooks를 만들어서 사용하게 되면, 컴포넌트에서는 해당 훅스에서 리턴되는 data, error, status를 사용하기만 한다.

option1

export default function useListReadPost() {
  const dispatch = useDispatch();

  const { status, error } = useAppSelector(fetchStatusSelector.byFetchAction(postAction.fetchListReadPost));
  const data = useAppSelector(postSelector.listData);
  const { curPage, rowsPerPage, isMoreRead, totalCount } = useAppSelector(postSelector.state);

  const fetch = useCallback(
    (query: ListReadPostUrlQuery, mode: ViewMode) => {
      if (query.page) {
        dispatch(postAction.fetchListReadPost.request(query, { isLoadMore: mode === 'infinite' }));
      }
    },
    [dispatch],
  );

  return { status, data, error, curPage, rowsPerPage, isMoreRead, totalCount, fetch };
}
const InfiniteListRead = () => {
  const router = useRouter();
  const postListReadPageFilter = useMemo(() => new PostListReadPageFilter(router.query), [router.query]);
  const { query } = postListReadPageFilter;

  const { data: myData } = useReadMyUser();
  const { status, data: postListData, curPage, isMoreRead, fetch: fetchListPost } = useListReadPost();

  const handleNextPage = useCallback(() => {
    if (isMoreRead) {
      const { pageSize, hashtag, mode } = query;
      fetchListPost({ page: curPage + 1, pageSize, hashtag }, mode);
    }
  }, [curPage, fetchListPost, isMoreRead, query]);

  useEndReachScroll({ callback: handleNextPage });

  return (
    <StyledViewWrapper>
      <Space className="wrapper" direction="vertical" size={10}>
        {myData && !query.hashtag && (
          <>
            <PostForm />
            <StyledFormBlock />
          </>
        )}
        {postListData.map((post) => (
          <PostCard key={post.id} data={post} />
        ))}
        // ...

초기 fetch는 SSR에서 이미 데이터가 로드된 상태이다.
해당 hooks에는 status, data, error, fetch를 기본적으로 불러오고, 그 이외의 response에서 option 데이터를 추가로 return을 해주었다. 컴포넌트 이제 view 관점으로 데이터를 붙이기만 하면 된다. 데이터 핸들링은 함수 요청 fetch만 요청하면 된다.

여기서 fetch까지 자동으로 호출할 수 있게끔 query props로 전달하여 useEffect로 데이터 핸들링하게 되면 캡슐화가 완벽하게 될 수 있지 않을까?

option2

interface IProps {
  mode?: 'infinite' | 'page';
  query?: ListReadPostUrlQuery;
}

export default function useListReadPost({ mode, query }: IProps) {
  const dispatch = useDispatch();

  const { status, error } = useAppSelector(fetchStatusSelector.byFetchAction(postAction.fetchListReadPost));
  const data = useAppSelector(postSelector.listData);
  const { curPage, rowsPerPage, isMoreRead, totalCount } = useAppSelector(postSelector.state);

  useEffect(() => {
    if (query?.page) dispatch(postAction.fetchListReadPost.request(query, { isLoadMore: mode === 'infinite' }));
  }, [dispatch, query, mode]);

  return { status, data, error, curPage, rowsPerPage, isMoreRead, totalCount };
}
const InfiniteListRead = () => {
  const router = useRouter();
  const postListReadPageFilter = useMemo(() => new PostListReadPageFilter(router.query), [router.query]);
  const { query } = postListReadPageFilter;
  const [fetchQuery, setFetchQuery] = useState(query);

  const { data: myData } = useReadMyUser();
  const { status, data: postListData, isMoreRead } = useListReadPost({ mode: fetchQuery.mode, query: fetchQuery });

  const handleNextPage = useCallback(() => {
    if (isMoreRead) {
      setFetchQuery((prev) => ({ ...prev, page: prev.page + 1 }));
    }
  }, [isMoreRead]);

  useEndReachScroll({ callback: handleNextPage });

  // ...

query, mode변화 감지에 따라, fetch가 동작을 하게 된다. 해당 문제점에서는 여러 컴포넌트에서 해당 hooks를 사용하게 되면, api 요청이 쌓이게 될 것이다. (takeEvery, takeLatest)

image

불필요한 api 요청은 삼가야 하기 때문에 saga에서 data fetch 요청을 debounce로 동시 호출을 막게 했다.

function* watchListRead() {
  yield debounce(
    300,
    postAction.fetchListReadPost.request,
    createFetchSaga(postAction.fetchListReadPost, requestListReadPost),
  );
}

image

하지만, 디버깅 시 request가 다중으로 쌓이게 되어 보기가 힘들 수 있다.

image

option2의 단점

  1. 모든 dataFetch는 debounce를 무조건 걸어야 하기 때문에, 깜빡하고 debounce를 안 걸어 두면 네트워크 디버깅을 보지 않는 이상 반복 호출이 쌓여 있을 것이다.
  2. takeEvery, takeLatest의 사용 의미가 없어진다. debounce의 조건으로 해당 시간 동안 호출이 멈춰야 하므로 무조건 한 번의 호출로 끝낸다.
  3. debounce 시간 동안 멈춤 현상이 일어난다. (loading indicator도 안 나타난다.)

선택은 개발자 몫

현재, urTweet v.1.1.0에서는 option2 방식에서 초기 데이터 불러왔는지 검사하여 다중 호출을 해결하는 방향으로 갔다.

export default function useListReadPost({ mode, filter }: IProps) {
  const dispatch = useDispatch();
  const { status, data: result, error } = useFetchStatus<ListReadPostRes>(postAction.listReadPost.TYPE);
  const data = useAppSelector(postSelector.listData);

  const isInitFetch = useRef(!!data.length);
  const isMoreRead = useMemo(() => !!result?.resData?.nextPage || false, [result?.resData?.nextPage]);
  const totalCount = useMemo(() => result?.resData?.totalCount || 0, [result?.resData?.totalCount]);

  useEffect(() => {
    if (!isInitFetch.current) {
      if (filter) dispatch(postAction.listReadPost.request(filter, { isLoadMore: mode === 'infinite' }));
    } else {
      isInitFetch.current = false;
    }
  }, [dispatch, filter, mode]);

  return { status, data, error, isMoreRead, totalCount };
}

이렇게 하면, 초기 렌더링을 SSR에서 받은 데이터로 사용하여 debounce를 사용하지 않아도 된다. 하지만 이 원리에서 CSR에서 넘어올 때 문제가 발생된다. 무조건 초기 렌더링 하는 fetch 로직을 작성해야 하는 것이다.

v.1.2.0에서는 option1 방식으로 선택했다. 아무래도 debounce 시간 동안 멈춤 현상이 버벅거림 현상으로 초래되었고, 초기 렌더링 로직 작성을 피하기 위해 fetch 호출 함수를 리턴해주는 방식으로 변경했다.

하지만, option2의 단점이라고 느끼지 않는다면 option2도 적용해도 좋다. debounce 시간을 상수 코드로 관리하면 더 편해질 거 같다. (debounce 300으로 설정해도 버벅거림 현상은 남아 있다.)

  • option1 view
    KakaoTalk_20211122_165529439
  • option2 view
    KakaoTalk_20211122_165530715

나의 추구하는 방향은 option2처럼 hooks에서 자동으로 데이터가 오고 가는 형태를 원했다, fetch를 컴포넌트 handler에서 관리하는 것이 이중 작업이 느낌이 들었고, useEffect로 fetch 함수를 호출하는 로직을 작성하는 순간 컴포넌트에서 data fetch handling을 하고 있는 느낌이 들었다.

useFetch

import { useEffect, useReducer, useRef } from 'react'

interface State<T> {
  data?: T
  error?: Error
}

type Cache<T> = { [url: string]: T }

// discriminated union type
type Action<T> =
  | { type: 'loading' }
  | { type: 'fetched'; payload: T }
  | { type: 'error'; payload: Error }

function useFetch<T = unknown>(url?: string, options?: RequestInit): State<T> {
  const cache = useRef<Cache<T>>({})

  // Used to prevent state update if the component is unmounted
  const cancelRequest = useRef<boolean>(false)

  const initialState: State<T> = {
    error: undefined,
    data: undefined,
  }

  // Keep state logic separated
  const fetchReducer = (state: State<T>, action: Action<T>): State<T> => {
    switch (action.type) {
      case 'loading':
        return { ...initialState }
      case 'fetched':
        return { ...initialState, data: action.payload }
      case 'error':
        return { ...initialState, error: action.payload }
      default:
        return state
    }
  }

  const [state, dispatch] = useReducer(fetchReducer, initialState)

  useEffect(() => {
    // Do nothing if the url is not given
    if (!url) return

    const fetchData = async () => {
      dispatch({ type: 'loading' })

      // If a cache exists for this url, return it
      if (cache.current[url]) {
        dispatch({ type: 'fetched', payload: cache.current[url] })
        return
      }

      try {
        const response = await fetch(url, options)
        if (!response.ok) {
          throw new Error(response.statusText)
        }

        const data = (await response.json()) as T
        cache.current[url] = data
        if (cancelRequest.current) return

        dispatch({ type: 'fetched', payload: data })
      } catch (error) {
        if (cancelRequest.current) return

        dispatch({ type: 'error', payload: error as Error })
      }
    }

    void fetchData()

    // Use the cleanup function for avoiding a possibly...
    // ...state update after the component was unmounted
    return () => {
      cancelRequest.current = true
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [url])

  return state
}

export default useFetch
import React from 'react'

import { useFetch } from 'usehooks-ts'

const url = `http://jsonplaceholder.typicode.com/posts`

interface Post {
  userId: number
  id: number
  title: string
  body: string
}

export default function Component() {
  const { data, error } = useFetch<Post[]>(url)

  if (error) return <p>There is an error.</p>
  if (!data) return <p>Loading...</p>
  return <p>{data[0].title}</p>
}

해당 코드는 검색을 통해 가져왔다. 내가 원하는 코드 방향이 유사하여 활용도가 높아 보였다. 해당 로직은 useReducer로 cache로 정보를 저장할 수 있었으며, 해당 url query가 존재하면 그대로 데이터를 리턴해주면서 hooks안에서 모든 data fetch handling이 가능했다. redux에서 작성했던 로직 보다 훨씬 작성할 것이 간단하고 코드가 직관적이었다. 즉, middleware를 타고 타고, saga, reducer까지 들여다보는 일이 없어진 것이다.

단점으로써, 과거 데이터가 계속 보존되어 이것을 업데이트하는 코드가 필요했다. 즉 캐시 유지 기간이 필요했다.

swr

import useSWR from 'swr';

import { getDataFetcher } from '@utils/fetcher';

import { GET_LIST_READ_POST_API, ListReadPostResData, ListReadPostUrlQuery } from '../api';

export async function getDataFetcher(url: string) {
  return axios.get(url).then((response) => response.data.resData);
}

export default function useListReadPost(query: ListReadPostUrlQuery) {
  const result = useSWR<ListReadPostResData>(GET_LIST_READ_POST_API(query), getDataFetcher);

  return {
    ...result,
    data: result.data?.list,
    curPage: result.data?.curPage,
    totalCount: result.data?.totalCount || 0,
  };
}
const PaginationRead = () => {
  const router = useRouter();
  const postListReadPageFilter = useMemo(() => new PostListReadPageFilter(router.query), [router.query]);
  const { query } = postListReadPageFilter;

  const {
    isValidating,
    data: postListData,
    totalCount,
  } = useListReadPost({
    page: query.page,
    pageSize: query.pageSize,
    hashtag: query.hashtag,
  });

  const handleChangePage = useCallback(
    (page: number) => {
      postListReadPageFilter.search({ page });
    },
    [postListReadPageFilter],
  );

  return (
    <StyledViewWrapper>
      <Space className="wrapper" direction="vertical" size={10}>
        {postListData?.map((post) => (
          <PostCard key={post.id} data={post} />
        ))}
        {!isValidating && !postListData?.length && (
          <StyledCenter>
            <Empty description="조회하신 결과가 없습니다." />
          </StyledCenter>
        )}
        // ...

useSWR은 앞서 말한 useFetch와 비슷한 흐름으로 호출을 통해 { isValidating, data, error }가 기본적으로 리턴이 된다.
useSWR을 사용하여 키(url) 값으로 getDataFetcher함수에게 매개 인자로 전달되는 순간 리턴되는 value를 SWR cache 저장소에서 { key: value } 형식으로 저장하게 된다. 또한 SWRConfig를 통해 내가 원하는 시간대에 다시 data fetch가 가능해졌다. useSWR에서 key, fetch 함수 , SWR option(local) 정보를 모두 클로저 형태로 저장이 되어 모든 함수가 muate가 되기 때문이다. 앞서 말한 redux , useFetch 단점을 보안할 수 있게 되었다.

단점으로

redux 미들웨어에서 작업했던 api call(like CRUD)이 컴포넌트에 작성된다. 하지만 모든 request 요청은 함수로 감싸고 있어 한번 캡슐화가 진행되었다고 생각하면 된다. api call 이후의 side-effect 또한 해당 컴포넌트에서 처리하게 된다. 여기서 미들웨어의 큰 역할이 사라지는 것이 아닐까 생각할지 모르지만, fetch data 업데이터 처리(mutate)를 제외하면 거의 사용성이 떨어진다. side-effect 또한 해당 컴포넌트의 state update가 대부분이다.

  • mutate (optimistic)
export function useFetchRemovePostMutate() {
  const mutate = useMatchMutate();
  const successMutate = useCallback(
    async (postId: number) => {
      const postResListData = (postResListData: ListReadPostResData) => {
        const clonePostResListData = cloneDeep(postResListData);
        clonePostResListData.list = clonePostResListData.list?.filter((_) => _.id !== postId);
        return clonePostResListData;
      };

      const postData = () => {
        return null;
      };

      await Promise.all([
        mutate(new RegExp('^/posts'), postResListData, false),
        mutate(new RegExp('^\\$inf\\$/posts'), (_: ListReadPostResData[]) => _.map(postResListData), false),
        mutate(new RegExp(`^${GET_READ_POST_API({ postId })}`), postData, false),
      ]);
    },
    [mutate],
  );

  return { successMutate };
}
const PostCard = ({ data, initCommentListOpen = false }: IProps) => {
  const { successMutate: fetchCreatePostSuccessMutate } = useFetchCreatePostMutate();
  const { successMutate: fetchLikePostSuccessMutate } = useFetchLikePostMutate();
  const { successMutate: fetchUnLikePPostSuccessMutate } = useFetchUnLikePostMutate();
  const { successMutate: fetchRemovePostSuccessMutate } = useFetchRemovePostMutate();
  const { data: myData } = useReadMyUser();

// ...

  const handleRemoveConfirmPost = useCallback(() => {
    confirm({
      title: '정말로 삭제 하시겠습니까?',
      icon: <ExclamationCircleOutlined />,
      content: '삭제시 해당 컨텐츠는 복구 불가 합니다.',
      async onOk() {
        try {
          const {
            data: { resData },
          } = await requestRemovePost({ postId: data.id });
          await fetchRemovePostSuccessMutate(resData.PostId);
        } catch (error) {
          if (isCustomAxiosError(error)) {
            message.error(JSON.stringify(error.response.data.resMsg));
          }
        }
      },
    });
  }, [data.id, fetchRemovePostSuccessMutate]);

결론, SWR 🚀

나의 선택은 swr로 선택하게 되었다.
내가 원하는 캡슐화 방향이 맞으며, fetch 로직이 hooks 안으로 들어가게 되어 컴포넌트는 더 이상 fetch라는 별개 함수를 호출하지 않고, 오 로직 query를 props로 보내주기만 하면 된다. 또한 redux 보다 쉽게 코드 작성이 간편해져 생산성 또한 높아졌다, 실무에서도 redux에서 swr 도입 이후 api가 나오자마자 빠르게 data fetch handling을 구축할 수 있었다.
redux는 saga, slice, normalize까지 작성해야 데이터를 컴포넌트에서 다룰 수 있었는데, swr은 hooks 하나만 작성하면 되기 때문에 view코드에 더 힘을 쏟을 수 있었다.

참고

컴포넌트 + fetch data
useFetch
swr

code view

github

 

GitHub - yjkwon07/urTweet: React 라이브러리를 사용하면서 유지보수 및 생산성을 높이기 위해, 폴더, 데

React 라이브러리를 사용하면서 유지보수 및 생산성을 높이기 위해, 폴더, 데이터 구조화 modules를 정의 - GitHub - yjkwon07/urTweet: React 라이브러리를 사용하면서 유지보수 및 생산성을 높이기 위해, 폴

github.com

 

반응형

'urTweet' 카테고리의 다른 글

[setting] Atomic Design?  (0) 2021.11.20
[setting] React 컴포넌트 분리와 폴더 구조화  (4) 2021.11.13
[helper] Redux + Redux-saga  (0) 2021.11.12
[setting] Next + Redux  (0) 2021.11.11
[setting] Next + Typescript Environment Setup  (0) 2021.11.11