목차

개발 노트/FE

useQuery onComplete를 data 프로퍼티로 리팩토링하기

천만일 2022. 2. 6. 03:15

문제의 발견

회사에서 서비스의 사용자 리스트를 페이지당 100명씩 가져와서 보여주는 페이지를 작업 중이었습니다.

이 페이지는 사용자 데이터를 받아서 화면에 렌더링 되기까지 시간이 상당히 걸리는 문제가 있었는데, 그 이유에 대해 파헤쳐 보았습니다.

아래 이미지는 사용자 페이지의 간단한 구조입니다.

사용자 목록 페이지의 구조

먼저 기존에 데이터를 처리하던 방식은 다음과 같습니다.

(많은 부분을 생략했습니다..)

function UserList() {
  const [userList, setUserList] = useState([]);

  const { loading } = useQuery(userList를 불러오는 gql, {
    variables: {},
    onComplete: (res) => {
      setUserList(res에서 뽑아낸 userList);
    }
  })

  return (
    <>
      {loading ? (
        <로딩 컴포넌트 />
      ) : (
        userList.map((item) => (
          <UserListItem userData={item}/>
        ))
      )}
    </>
  )
}

위 코드의 작동 방식을 알아보겠습니다.

  1. state를 저장합니다
    userList는 우리가 일반적으로 알고 있는 것과 같이 useState([])[]로 초기화됩니다.
    useQuery의 loading은 useQuery 내부에 있는 useState를 통해 생성되는데, 이 useState()에는 함수가 매개변수로 선언되어 있습니다.
    useState에 익명 함수를 넘기는 방식을 Lazy initialization(게으른 초기화)라고 하는데 lazy initialization 함수는 state가 생성될 때에만 실행되고 리렌더링 될 때는 실행되지 않습니다.
    이를 근거로 loading 변수는 렌더링 이전에 true로 생성됨을 알 수 있습니다.

    참고 : Apollo-Client / useQuery 구현 코드

  2. 생성된 state로 렌더링을 진행합니다.
    위 코드에 따르면 <로딩 컴포넌트/>가 렌더링 될 것입니다

  3. 쿼리가 성공합니다.
    요청 쿼리가 서버로부터 정상적으로 반환되면, 서버로부터 받아온 데이터를 userList state에 저장합니다.
    이때, state의 값이 변경되면서 사용자 리스트가 렌더링됩니다.

이제부터 원인이 발생합니다..

이 사용자 리스트 페이지에는 페이지네이션이 적용되어 있습니다.
(그 외 정렬이나 필터링과 같은 다른 검색 옵션들도 적용이 되어있지요..)

이와 같은 검색 옵션 역시 state로 관리를 하고, 검색 옵션 state를 useQuery의 옵션에 variables 프로퍼티로 넣어주었습니다. (variables에 들어가는 객체는 GQL에 매핑됩니다.)
(저희는 skip, take를 사용합니다😃)

useQuery는 의존하는 값들의 변화가 생길 때마다 서버로 요청을 보냅니다.

이와 같은 상황에서 페이지가 변경되는 상황에 대해 알아보겠습니다.

원인 분석

  1. 현재 1 페이지에 있다고 가정합니다.
  2. 2 페이지로 이동하면 바뀐 페이지에 따라 useQuery의 variables가 변경됩니다.
  3. 쿼리를 호출하며 loading 값이 true가 됩니다.
  4. 쿼리가 완료되면 loading 값이 false가 됩니다.
  5. onComplete가 돌고 userList 상태에 새 데이터를 넣어줍니다.
  6. 새로운 userList가 나타납니다.

자.. 아무런 문제가 없는 것 같았습니다..만 문제가 있었습니다.

3번에서는 쿼리가 완료되면 loading 값이 false가 됩니다.

loading 값이 변경되었기 때문에 이 컴포넌트는 리렌더링합니다.

리렌더링할 때 userList 상태 값은 1 페이지에 대한 데이터일 것입니다.

그 이후에 onComplete 함수가 되며 userList 상태를 업데이트합니다.

상태가 변경되었기 때문에 다시 리렌더링됩니다.

지금 데이터는 1번만 다시 불러왔는데,, 2번의 리렌더링이 일어났지요..?

이것이 현재 userList 페이지의 문제점이었습니다.

해결 방법

data 프로퍼티

사실 해결 방법 무지 간단합니다.

useQuery 함수가 반환하는 객체에는 loading 뿐 아니라 data라는 property도 있다는 사실..!!

유저 데이터를 상태로 관리할 필요 없이, 쿼리가 완료되면 알아서 data라는 변수에 담겨 온다는 것입니다.

사실 공식문서 어디에도 onComplete로 반환된 데이터를 관리하는 내용은 없었습니다.

(참고) Apollo-Client 공식 문서 예제

제가 오기 전부터 사용 중이던 패턴이었는데 의심 없이 사용해오던 것이 문제였던 것 같습니다.

다음은 data를 활용한 코드입니다.

function UserList() {
  const { loading, data } = useQuery(userList를 불러오는 gql, {
    variables: { skip, take },
  })
  ...
}

타입 설정

위 코드에서는 data의 타입이 설정되어있지 않아서 컴포넌트에서 활용하기에 불편합니다.

그래서 useQuery에 제네릭으로 쿼리의 결과 타입을 넣어서 활용합니다.

function UserList() {
  const { loading, data : { userList } } = useQuery<{
    userList: User[]
  }>(userList를 불러오는 gql, {
    variables: { skip, take },
  })

  return (
    <>
      {loading ? (
        <로딩 컴포넌트 />
      ) : (
        userList.map((item: User) => (
          <UserListItem userData={item}/>
        ))
      )}
    </>
  )
}

피드백

  • 프론트엔드 최적화에 대해 막막하다는 느낌이 있었는데, 사소하지만 한 걸음 내디딘 것 같아서 좋았습니다.
  • 레거시를 보고 아무런 의심 없이 받아들이는 것이 아니라, 항상 의심하고 개선할 방법을 찾으려는 노력의 필요를 느꼈습니다.