Published on

useEffect -> React-Query + Suspense로 연락처 페이지 리팩토링하기 (+ SkeletonUI)

데모 발표를 위해 급하게 프로젝트를 진행하며 생략하거나 놓친 부분을 개선하고자 했는데, 최종 발표를 앞두고 시간적 여유가 생겨 연락처 페이지를 React-Query와 React v18에서 추가된 Suspense을 적용하여 리팩토링을 진행하고자 한다. 연락처 페이지를 최우선으로 선정한 이유는 가장 많은 API를 요청하고 있기 때문에 React-query와 Suspense를 적용하기에 가장 적합하다고 판단했기 때문이다.



필요한 API

  • 내 연락처 리스트
  • 즐겨찾기 되어있는 연락처 리스트
  • 친구 요청 대기(나에게 요청한 유저) 리스트
  • 모든 유저 리스트

기존 코드

  • refreshKey를 useEffect의 의존성 배열에 배치해 삭제, 즐겨찾기 등록 등의 변경사항이 발생했을 때 리렌더링 되되록 했다.
  • 부모 page 컴포넌트에서 비지니스 로직을 처리하고 자식 컴포넌트에 의존성을 주입했다.
useEffect(() => {
  getContactsList();
}, [refreshKey]);

useEffect(() => {
  getFriendList();
}, [refreshKey]);

useEffect(() => {
  getRequestList();
}, [refreshKey]);

useEffect(() => {
  getAllContacts();
}, [refreshKey]);

<ContactList
  contactsList={contactsList}
  handleOnFavorite={handleOnFavorite}
  handleDeleteContacts={handleDeleteContacts}
  openModal={openModal}
/>
<FavoritesList
  favoritesList={favoritesList}
  handleOnFavorite={handleOnFavorite}
  handleDeleteContacts={handleDeleteContacts}
  openModal={openModal}
/>
<FriendRequest
  requestList={requestList}
  handleOnAccept={handleOnAccept}
/>
<MainContacts
  userList={userList}
  handleAddContacts={handleAddContacts}
  handleChange={handleChange}
  handleSearchUser={handleSearchUser}
  isModalOpen={isModalOpen}
  closeModal={closeModal}
  modalInfo={modalInfo}
/>
...

기존 코드

  • 4개의 GET 요청 메서드를 useEffect hook으로 처리해 이상 없이 잘 동작했지만 단순하게 동작만 하는 코드를 만들었다.

  • 요청이 병렬적으로 처리되고는 있지만, 완료된 순서가 동일하지 않고 먼저 완료된 요청의 결과가 화면에 렌더링 된다. 물론 에러가 아니기 때문에 큰 상관은 없지만 4개의 데이터가 각기 다르게 렌더링 된다면 사용 경험에 방해가 되고 각 데이터가 유기적으로 작동하는 관계라고 생각했기 때문에 병렬 동작 + 동시 렌더링 즉, 요청 결과의 종료 시간과 관계없이 동시에 동작이 종료될 필요가 있다.

  • useEffect를 여러 번 사용해서 API 데이터 목록 요청을 처리했지만 불필요한 코드도 반복되고 데이터가 불필요하게 요청되고 있었다. 가독성과 유지/보수 측면에서 기존보다는 Promise.all을 사용하는 방법이 더 나았을 것 같다.

  • 비즈니스 로직이 분리 되어있지 않고 연락처/즐겨찾기의 경우 모듈화하면 컴포넌트를 사용할 수 있다.

  • hook을 각 컴포넌트에서 사용해 컴포넌트 간의 의존성을 줄일 필요가 있다.

  • 현재는 적은 양의 데이터로 인해 요청이 빠르게 처리되지만, 연락처의 경우 많은 양의 데이터로 인해 처리 시간이 오래 걸리는 상황이 발생할 수 있다고 생각한다. 따라서 사용자의 대기 시간이 길어지기 때문에 loading indicator 혹은 스켈레톤 UI 적용이 필요하다.


💡 현재 상황에 React-Query + Suspense를 적용하기에 가장 적합하다 !


React-Query 라이브러리를 적용해 코드 간결화 및 효율적으로 서버 데이터를 관리

  • 여러 번 useEffect 훅이 불필요하게 사용되고 있다.
    📌 useEffect -> useQuery 훅으로 대체

  • 연락처 추가/삭제, 즐겨찾기 등의 데이터 변경 발생 시 refreshKey를 사용해 리렌더링하던 과정
    📌 쿼리 무효화(Invalidation)를 사용해 리패칭


Suspense

/* ContactPage */
const ContactPage = () => {
  return (
    <MainContacts />  // 모든 연락처 리스트
  )
}


/* ContactPage > MainContacts */

const MainContacts = () => {
  const [contactsList, setContactList] = useState([]);

  useEffect(() => {
    전체회원의 데이터를 요청하는 함수();
  }, [])

  const 전체회원의 데이터를 요청하는 함수 = async () => {
    setContactList(await axios.get(url));
  }

  return(
    {contactsList.map(() => {
      ...
    })
    }
  )
}
  • 기존에는 비동기 작업 발생 시 loading indicator 혹은 스켈레톤 UI가 존재하지 않았다.
    📌 사용자 경험 방해 -> Suspense fallback으로 SkeletonUI 적용

  • 위 코드는 기존 코드와 유사하게 작성된 방식으로 MainContacts 컴포넌트의 경우
  1. 부모 컴포넌트 ContactPage에서의 렌더링이 된다.
  2. 이후 자식 컴포넌트 MainContacts가 렌더링 되고 전체 회원의 데이터를 요청하는 함수(Start fetching)가 실행된다.
  3. 서버에서 데이터를 응답받고(Finish fetching)
  4. setContactList(Start rendering)를 통해 컴포넌트가 리렌더링 되고 map 반복문을 통해 화면에 전체 유저의 리스트를 확인하게 된다.

폭포(waterfall)처럼 순차적으로 부모 컴포넌트가 렌더링 되고 자식 컴포넌트의 모든 유저 리스트를 보기 전까지 사용자들은 비어있는 자식 컴포넌트의 화면을 보게 된다. (처리 시간이 길어질수록 이 대기 시간은 길어질 것이다.)

위 방법에서 대기하는 사용자들의 경험 향상을 위해 loading이라는 state를 따로 사용하는 방법도 존재한다. (아래 코드는 이해를 loading indicator 작동 이해를 위한 코드이다.)

function ContactPage() {
  const [contactList, setContactList] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetchContact().then(contact => setContactList(contact).finally(setLoading));
  }, [])

  if(loading) {
    return <p>Loading profile</p>
  }

  return (
    <div>
      <ContactList />
      // 부모(ContactPage)의 비동기 처리가 끝나지 않으면
      // 자식 컴포넌트(ContactList) 렌더링 발생 x -> 동시성 보장 x
    </div>
  )
}

위 방법의 경우 loading indicator가 존재하는 것 뿐 근본적인 해결책이 될 수는 없다. React 18v에서 추가된 Suspense를 적용하면 위 문제를 해결할 수 있다.


차이점

/* ContactPage */

const ContactPage = () => {
  return (
    <React.Suspense fallback="Loading">
      <MainContacts />  // 모든 연락처 리스트
      <ContactList />  // 내 연락처 리스트
    </React.Suspense>
  )
}


/* ContactPage > MainContacts */

const MainContacts = () => {
  const [contactsList, setContactList] = useState([]);

  useEffect(() => {
    전체회원의 데이터를 요청하는 함수();
  }, [])

  const 전체회원의 데이터를 요청하는 함수 = async () => {
    setContactList(await axios.get(url));
  }

  return(
    {contactsList.map(() => {
      ...
    })
    }
  )
}
  • 첫 번째로(1) 부모 컴포넌트 ContactPage가 렌더링이 시작된다.
  • 두 번재로(2) 자식 컴포넌트 MainContacts에서 전체회원의 데이터를 요청하는 함수가 실행된다.(Start fetching)
  • 동시에(2) 자식 컴포넌트가 렌더링이 시작되고(Start rendering) MainContacts에서 전체 회원의 데이터를 요청하는 함수의 실행이 종료되지 않았으면 React에 완료되지 않은 상태를 알리고 다음 동작으로 넘어가게 된다.

    이 과정에서 react는 Suspense에 등록된 이 컴포넌트를 계속 주시하며 fallback의 설정한 Loading 텍스트를 렌더링 하여 표시하다가 동작이 종료되면 MainContacts 컴포넌트를 리렌더링 해서 화면에 표시하게 된다.

React-Query에서 Suspense 사용하기

  • React-Query에서도 Suspense를 지원하기 때문에 useQuery에서 옵션으로 간단하게 사용할 수 있다.
// 전체 유저 목록
const fetchAllContacts = (userId) => ({
  queryKey: ["users", userId],
  queryFn: () => getUserData(userId),
  suspense: true, ⭐️
  staleTime: 50000,
});

suspense: true로 설정하면 Suspense 기능 사용이 가능하다.


성과

Suspense의 자식 컴포넌트 MainContacts, FavoritesList, ContactList는 동작이 종료되는 시점은 다르지만 같은 Suspense의 자식 컴포넌트로 위치하기 때문에 작업이 모두 종료될 때까지 Loading 텍스트가 화면에 렌더링 된다.

Suspense + React-Query를 적용해 처음 목표했던 loading indicator 적용, 동시 처리의 목적을 달성할 수 있었다.



+ SkeletonUI 적용

  • loading indicator를 사용해도 좋지만 SkeletonUI를 적용하면 더 나은 사용자 경험을 제공할 수 있다고 생각한다.

  • 짧은 처리 작업에 적용할 경우 오히려 역효과를 줄 수 있다. 따라서 SkeletonUI가 충분히 노출될 만큼 시간이 걸릴 것으로 예상되는 연락처 페이지에 적용하기에 적합하다.


코드 형태

<React.Suspense fallback={<SkeletonUI count={3} />}>
  <ContactList />   // 내 연락처 리스트
  <FriendRequest /> // 연락처 추가 승인 요청 대기 리스트
  <MainContacts />  // 모든 연락처 리스트
</React.Suspense>
  • 각 컴포넌트가 렌더링 될 동안 fallback으로 SkeletonUI를 배치하면 작업이 진행되는 동안 먼저 렌더링 되고 작업이 종료되면 종료된 컴포넌트의 내용이 리렌더링 되어 적용된다.

  • SkeletonUI 컴포넌트를 따로 만들고 count props로 SkeletonUI 형태로 표시될 데이터의 개수를 지정한다. 연락처 리스트와 승인 요청 리스트는 3개의 개수가 표시되고, 모든 연락처 리스트의 경우 12(count * 4)개의 SkeletonUI를 표시한다.




🚀 검색 기능 적용 방안에 대한 고민

🤷‍♂️ useQuery 훅으로 가져온 두 가지 데이터를 하나의 userList에 적용하기

{userList?.map((item) => {
  return (
    <S.ContactDiv key={item.id}>
      <S.ProfileImg src={item.profile || "./new.png"} />
      <S.NameText>{item.username}</S.NameText>
      <S.ButtonBox onClick={() => handleAddContacts(item.id)}>
        <S.Button>
          <BsFillPersonPlusFill />
        </S.Button>
      </S.ButtonBox>
    </S.ContactDiv>
  );
})}

기존처럼 단순하게 userList라는 state에 서버에서 response 받은 데이터를 setState 하게 된다면 간단하게 해결될 문제지만 useQuery 훅으로 데이터를 관리하는 방법에 대한 고민이 많았다. 많은 아티클을 검색해 보며 내가 생각해낸 방법은 두 가지 데이터 요청 훅에 각각 다른 queryKey를 설정하고 userList 변수의 값을 삼항 연산자를 통해 각각 관리하고자 했다.

// 전체 유저 목록
const fetchAllContacts = (userId) => ({
  queryKey: ["users", userId],
  queryFn: () => getUserData(userId),
  suspense: true,
  staleTime: 50000,
});

// 연락처 검색
const fetchSearchContacts = (username) => ({
  queryKey: ["search", username],
  queryFn: () => getSearchContacts(username),
  suspense: true,
  enabled: false,
  staleTime: 50000,
});


/* 모든 연락처 리스트 컴포넌트 MainContacts.js */
const { data: allUsers } = useQuery(fetchAllUser(userId));
const { data: searchResults} = useQuery(
  fetchSearchContacts(keyword),
);

const userList = keyword && searchResults ? searchResults : allUsers;

결과는 실패했다.

  • ❌ 컴포넌트가 mount 되면서 검색 훅이 실행되어 에러를 발생시킨다.

나의 해결 방법 - useQuery 반환 객체 refetch 함수 + enabled 옵션

처음 컴포넌트가 mount 되었을 때 실행되는 것을 방지하기 위해 useQuery 옵션 중 하나인 enabled를 false로 설정해 자동으로 실행되지 않도록 했다.

그리고 useQuery에서 반환되는 refetch(위 코드에서는 더 직관성을 위해 refetchSearchResults로 작명했다.)를 사용해 검색 버튼이 클릭되었을 때 handleSearchClick 이벤트를 동작시키고 검색 결과를 강제로 가져오도록 refetch(refetchSearchResults로) 함수를 호출해 줬다.

// 연락처 검색
const fetchSearchContacts = (username) => ({
  queryKey: ["search", username],
  queryFn: () => getSearchContacts(username),
  suspense: true,
  enabled: false,   ⭐️
  staleTime: 50000,
});

/* 연락처 리스트 컴포넌트 ContactsPage.js */
const { data: allUsers } = useQuery(fetchAllUser(userId));
const { data: searchResults, refetch: refetchSearchResults ⭐️ } = useQuery(
  fetchSearchContacts(keyword),
);

const handleSearchClick = () => {
  if (keyword.trim() === '') {
    alert('검색어를 입력해주세요.');

    return;
  }

  refetchSearchResults(); ⭐️
};

const userList = keyword && searchResults ? searchResults : allUsers;

결과




마치며

Suspense 관련 공식 문서, 아티클을 보고 이해를 위해 여러 방법들을 테스트하며 어느 정도 감을 잡을 수 있었던 것 같다. 기존에 loading indicator를 적용할 때 loading state를 사용하거나 useQuery에서 제공하는 isLoading을 사용했으나 Suspense에서 제공하는 비동기 처리 관리 기능으로 코드도 간결해지고 waterfall 현상도 해결하는 이점을 얻었다.

React-Query와 Suspense를 테스트 프로젝트에서 사용은 해봤지만 직접 프로젝트에 적용하게 되니 생각 보다 고전했던 것 같다. 적용 과정을 포스팅하기 위해 참고한 자료들을 몇 번이나 다시 보며 정리하고 테스트 과정을 거치며 결국 원하는 결과를 얻을 수 있었고 계속 사용해 보며 손으로 익히는 과정이 필요할 것 같다.

당장은 연락처 페이지만 적용했으나 앞으로도 단계적으로 React-Query를 적용할 계획이고 Suspense, loading indicator, SkeletonUI는 잘못 사용할 경우 역효과를 불러올 수 있기 때문에 충분한 고민 후 적용할 것 같다. 이번 포스팅을 진행하며 그동안 웹 페이지를 이용하며 느낀 사용자 경험을 위한 부분들이 단순히 만든 것이 아닌 여러 분석을 통한 치밀한 설계였다는 것에 감탄했고 중요성을 다시금 깨닫는 좋은 계기가 되었다고 생각한다.






Reference