<initialData와 prefetchQuery를 대하는 우리의 자세(feat. @tanstack-query)>

강민수·2023년 11월 24일
1

내가 생각하는 프로그래밍은

"잘못된 정보의 간극을 줄이는 것."

얼마 전, 회사에서 만난 react-query 이슈와 더불어 해결 방법을 한 번 공유해 보려고 한다.

1. 어? 이게 맞나? 의문에 대한 시작.

필자가 직접 겪은 부분은 문제는 아니지만, 그럼에도 불구하고, 이 문제를 추후 다시 겪기 전에 분명히 짚고 넘어가는 것이 중요하다고 생각한다. 그래서 이번 기회에 제대로 짚고 넘어가자.

참고로 이글은 어느 정도 tanstack-query에 대한 지식을 가지고 있어야 이해가 쉽다. 만약 이해가 잘 안 가는 부분으 tanstack-query 공식 문서를 먼저 살펴보길 바란다.

"initialdata가 있으면 queryFunction이 호출 되고 그에 따라 data refetching이 발생하는 게 맞습니다."

흠... 과연 그럴까? 일단 문제의 시작은 여기서부터 발생한다.
결론적으로 말하자면, 상황에 따라, 반은 맞고 반은 틀린 얘기다.

자세한 이유는 후술하겠다.

2. initialData에 대한 정의부터.

먼저, 공식문서부터 자세히 살펴보는 자세가 중요하다.

공식 문서에서 나온 initialData의 정의는 아래와 같다.

간단히 정리하면, react-query의 caching 기능을 활용해서 초기 첫 랜더링 시점의 데이터를 미리 주입해 놓는 것 이다.

이렇게 해 놓았을 때 장점은 초기 로딩 처리를 할 필요가 없어진다.
그래서 보통 코드 구성 시에 아래처럼 바로 지정해서 사용한다.

const result = useQuery({
  queryKey: ['todos'],
  queryFn: () => fetch('/todos'),
  initialData: initialTodos,
  staletime:0
})

하지만, 과연 이렇게만 하고 끝내는 게 맞을까?
예상하겠지만, 이렇게만 하면 물론 초기 데이터는 주입된다. 다만, 이렇게만 해서는 안 되는 usecase들이 분명 존재한다.

우리 회사는 여기에 데이터 패칭 옵션으로 staletime:0 을 주고 있었다.

이렇게 되면 사실상, initialData를 제대로 활용하지 못하는 꼴이 된다.

3. 정답 === 역시 공식 문서.

그게 무슨 말이냐면?
공식문서에 잘 설명되어 있다.

아래 내용을 살펴보자.

대략 요약해 보면, 결국 initialData 역시 staleTime에 의존해서 데이터 패칭이 된다. 즉, 회사의 코드처럼 staleTime이 영이면 기본적으로 바로 마운트 되자마자, data가 fetching되는 것을 크롬 네트워크 탭을 통해 확인할 수 있었다.

하지만, 여기서 끝일까? 아니다. react-query의 고유 기능인 캐싱을 제대로 활용할 수 없었다. 한 번 불러온 데이터는 재패칭이 일어나야 되지 않는 것이 기본적인 캐싱 활용이다. 그런데 똑같은 쿼리 키로 api 요청이 계속 가는 것이다.

결국 이는 잘못된 사용법이었다.

또한, 이 코드를 참조한 다른 코드에서는 staleTime을 아예 그래서 없앴더니 문제는 데이터 패칭 자체가 안 되었다.

그건 당연하다. 이 이유에 대해서는 소스코들 한 번 살펴보겠다.

4. 소스코드 분석.

1) 쿼리 코어에 대한 분석.

이건 앞서 설명했듯이, 반은 맞고 반은 틀리다고 한 부분이다.

아래는 Query에 대한 코어 기능이 담긴 Class다.
이 클래스를 잘 살펴보면, 우리가 주목할 점이 나온다.

export class Query<
  TQueryFnData = unknown,
  TError = DefaultError,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
> extends Removable {
  queryKey: TQueryKey
  queryHash: string
  options!: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
  state: QueryState<TData, TError>
  isFetchingOptimistic?: boolean

  #initialState: QueryState<TData, TError>
  #revertState?: QueryState<TData, TError>
  #cache: QueryCache
  #promise?: Promise<TData>
  #retryer?: Retryer<TData>
  #observers: Array<QueryObserver<any, any, any, any, any>>
  #defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
  #abortSignalConsumed: boolean

  constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
    super()

    this.#abortSignalConsumed = false
    this.#defaultOptions = config.defaultOptions
    this.#setOptions(config.options)
    this.#observers = []
    this.#cache = config.cache
    this.queryKey = config.queryKey
    this.queryHash = config.queryHash
    this.#initialState = config.state || getDefaultState(this.options)
    this.state = this.#initialState
    this.scheduleGc()
  }
  get meta(): QueryMeta | undefined {
    return this.options.meta
  }

바로,

this.#initialState = config.state || getDefaultState(this.options)

이 부분이다. 이 부분은 초기 값이 설정되어 있다면, 그 값을 따르고, 아니면 get해오겠다는 소리다.

이후 get 해오는 부분은 아래와 같다.
간단히 살펴보면, function이면 function으로 get 해오고, 값이면 그냥 값으로 그대로 받는다.

function getDefaultState<
  TQueryFnData,
  TError,
  TData,
  TQueryKey extends QueryKey,
>(
  options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): QueryState<TData, TError> {
  const data =
    typeof options.initialData === 'function'
      ? (options.initialData as InitialDataFunction<TData>)()
      : options.initialData

  const hasData = typeof data !== 'undefined'

  const initialDataUpdatedAt = hasData
    ? typeof options.initialDataUpdatedAt === 'function'
      ? (options.initialDataUpdatedAt as () => number | undefined)()
      : options.initialDataUpdatedAt
    : 0

  return {
    data,
    dataUpdateCount: 0,
    dataUpdatedAt: hasData ? initialDataUpdatedAt ?? Date.now() : 0,
    error: null,
    errorUpdateCount: 0,
    errorUpdatedAt: 0,
    fetchFailureCount: 0,
    fetchFailureReason: null,
    fetchMeta: null,
    isInvalidated: false,
    status: hasData ? 'success' : 'pending',
    fetchStatus: 'idle',
  }
}

이래서 공식 문서에서 initialData를 설정하고, 어떻게 커스텀으로 설정하는 지가 이해가 갔다.

5. back to 공식.

공식 문서를 다시 살펴보면, 기본적으로 초기 값을 설정해 두고 그 값을 업데이트 하기 위해서는 initialUpdatedAt과 staleTime을 설정해서 개발자가 특정한 시점에 업데이트 하도록 만들 수 있다.

1) initialDataUpdatedAt

x// Show initialTodos immediately, but won't refetch until another interaction event is encountered after 1000 ms
const result = useQuery({
  queryKey: ['todos'],
  queryFn: () => fetch('/todos'),
  initialData: initialTodos,
  staleTime: 60 * 1000, // 1 minute
  // This could be 10 seconds ago or 10 minutes ago
  initialDataUpdatedAt: initialTodosUpdatedTimestamp, // eg. 1608412420052
})

이런식으로 하면, 1분이 지나면 stale이 되어서 바로 refetch가 가능한 것으로 된다. 또한, initialDataUpdatedAt에 따라 해당 타임 스탬프 시점이 되면 refetch가 가능하게 된다.

하지만, 이렇게만 해서는 사실상 특정 조건에 따른 커스텀이 어렵다고 생각이 들었다. 그래서 살펴보니 다른 방법도 역시 존재했다.

2) conditioning initialData.

const result = useQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetch(`/todos/${todoId}`),
  initialData: () => {
    // Get the query state
    const state = queryClient.getQueryState(['todos'])

    // If the query exists and has data that is no older than 10 seconds...
    if (state && Date.now() - state.dataUpdatedAt <= 10 * 1000) {
      // return the individual todo
      return state.data.find((d) => d.id === todoId)
    }

    // Otherwise, return undefined and let it fetch from a hard loading state!
  },
})

우리 회사의 경우, 시점을 정하기가 모호해서, 사용자의 버튼 기반 액션이 이루어지기 때문에.
위처럼 initialData를 조건 처리해 주는 게 더 좋은 방식이었다.

6. solution.

그래서 결국 코드를 다음과 같이 수정했다.

 const isInitial = useMemo(
    () =>
      initialPageNumber === pageNumber &&
      initialStartDate === dateRange.startDate &&
      initialEndDate === dateRange.endDate &&
      initialSearchOption === searchOption &&
      initialSearchKeyword === searchKeyword,
    [
      dateRange.endDate,
      dateRange.startDate,
      initialEndDate,
      initialPageNumber,
      initialSearchKeyword,
      initialSearchOption,
      initialStartDate,
      pageNumber,
      searchKeyword,
      searchOption,
    ]
  );

  const queryClient = useQueryClient();
  const { data } = usePrivateQuery({
    queryKey: [
      API_GET_LIST_KEY,
      {
        searchOption,
        status,
        pageNumber,
        diagnosisTimeFrom: dayjs(dateRange.startDate).startOf('date').valueOf(),
        diagnosisTimeTo: dayjs(dateRange.endDate).endOf('date').valueOf(),
        searchKeyword,
      },
    ],
    queryFn: ({ headers }) =>
      getList(headers, {
        searchOption,
        status,
        pageNumber,
        pageSize: 5,
        diagnosisTimeFrom: dayjs(dateRange.startDate).startOf('date').valueOf(),
        diagnosisTimeTo: dayjs(dateRange.endDate).endOf('date').valueOf(),
        searchKeyword,
      }),
    initialData: () =>
      isInitial
        ? initialData
        : queryClient.getQueryData([
            API_GET_TELEMEDICINE_LIST_KEY,
            {
              searchOption,
              status,
              pageNumber,
              diagnosisTimeFrom: dayjs(dateRange.startDate).startOf('date').valueOf(),
              diagnosisTimeTo: dayjs(dateRange.endDate).endOf('date').valueOf(),
              searchKeyword,
            },
          ]),
    placeholderData: keepPreviousData,
  });

간단히 설명하자면, 초기 값이 처음 ui 상태와 일치하는 것을 조건문으로 만든다.
이후, 분기처리에 따라 만약 초기 값이라면 intialData를 그대로 받아서 사용하고, 아니라면, queryClient.getQueryData를 호출한다.

이를 토대로, 초기 첫 마운트 시에는 initialData를 서버에서 만든 데이터로 사용했다.

이후 값이 바뀌면 초기 값이 아닌 queryFn의 함수로 재패칭 해서 데이터를 업데이트 시켰다.
또한, 아까 문제가 되었던 캐싱 부분도 역시나 queryClient 단에서 호출하다보니 바로 캐싱이 잘 적용되었다.

6. preFetchQuery와 결론.

하지만, 이는 우리 팀은 결국 preFetchQuery를 쓰기로 했다.

왜냐면, 사실 initialData는 저렇게 데이터가 가변적으로 변하는 상황에서는 유용하지 못한 기능이었다.

다음은 필자가 chat-gpt에게 initialData와 prefetchQuery를 언제 사용하면 좋을 지에 대한 답변이다.

네, 만약 사용자의 조작에 따라 데이터를 업데이트하고 싶다면 prefetch와 hydration을 사용하는 것이 더 적합할 수 있습니다.

  • Prefetching:
    • prefetch를 사용하면 미리 데이터를 불러와서 캐시할 수 있습니다. 이는 페이지를 렌더링하기 전에 데이터를 미리 불러와 두는 것입니다.
    • 사용자가 특정 조작을 했을 때 해당 데이터를 미리 불러올 수 있습니다.
  • Hydration:
    hydration은 서버에서 초기 데이터를 가져와서 클라이언트 측에서 사용하는 것을 의미합니다. Next.js에서는 이를 통해 초기 렌더링 시에 서버에서 가져온 데이터를 사용할 수 있습니다.
    hydration을 사용하면 클라이언트 측에서도 서버에서 가져온 초기 데이터를 사용할 수 있습니다.
    두 가지 방법은 서로 다른 상황에 적합합니다. 일반적으로는 다음과 같이 사용됩니다:
  • initialData:
    • 서버에서 가져온 데이터를 초기 렌더링에 사용하고, 이후에는 React Query를 통해 데이터를 관리할 때 사용됩니다.
    • 초기 데이터가 변경될 일이 없고 고정된 데이터를 사용해야 할 때 유용합니다.
  • prefetch와 hydration:
    사용자의 조작에 따라 데이터를 업데이트하고 싶거나, 동적으로 변경되는 데이터를 사용해야 할 때 유용합니다.
    사용자의 조작에 따라 데이터를 미리 불러오거나 서버에서 가져온 초기 데이터를 클라이언트 측에서 사용해야 할 때 적합합니다.
    따라서 상황에 따라 적절한 방법을 선택하면 됩니다.

prefetch에 대한 자세한 구현법은 생략하겠다.(검색 및 공식 문서 참조하면 많이 나온다. ㅎㅎ)

이번 기회를 토대로 그동안 묶은 채증이었던 이 두 가지 개념에 대한 고민을 확실히 해결할 수 있었다.

역시 명확한 개념과 공식 문서 그리고 라이브러리의 구현 코드를 뜯어보는 것이 명확한 개념애 대한 정답인 거 같다. ㅎㅎ

참조 링크

https://tanstack.com/query/v4/docs/react/guides/initial-query-data

https://github.com/TanStack/query/blob/main/packages/query-core/src/query.ts#L611

profile
개발도 예능처럼 재미지게~

0개의 댓글