<useQuery에서 placeholderData와 keepPreviousData를 톺아보기>

강민수·2024년 2월 24일
2

아하 모먼트

목록 보기
3/4

1. Why에서 시작된 의문.

이번 글은 회사에서 react-query를 사용하면서 의문이 든 지점을 해결하는 것에서 시작한다.

첫 시작의 지점은 이러했다.

흠... 데이터를 패칭해오기 전... 첫 로드 이후의 깜빡임은 뭔가 로딩 처리를 한다고 쳐도, 쿼리 키를 토대로 계속 그 값을 유지할 수는 없는 건가? 계속 데이터가 없는 경우, 깜빡 거림은 제어할 수 없는 건가?

맞다. 사실은 위의 말이 어불성설인것은 이미 리액트 쿼리를 많이 써 봤다면, 누구나 다 이해할 것이다.

쿼리 키로 캐싱을 안 하는 건가?

맞다. 쿼리 키로 캐싱 자체는 된다. 하지만, 이 쿼리 키가 계속 바뀌는 경우라면 어떻게 하겠는가? 심지어 그에 따라 데이터의 전환이 이뤄지는 화면이라면? 당연하게도 계속 로딩과 재패칭이 일어날 것이다.

결국 그에 따라, 계속 로딩이 깜빡거리는 현상이 보이는 것처럼 사용자에게 다소 불편한 경험을 전해줄 수가 있다.

특히, 우리의 비즈니스적인 요구 사항은 대략적으로 이러했다.


날짜별로, 커스텀 된 date 컨트롤러가 있고, 그에 따라, 한 화면에서 날짜별로 다른 데이터를 랜더링 해줘야만 하는 상황이었다.

그래서, 앞선 것처럼 코드는 이와 같은 구조로 초기에는 짰었다.

(참고용 코드)

  const { data: recordData, isLoading: isLoadingRecord } = useQuery({
    queryFn: ({ headers }) =>
      getDailyRecord(headers, {recordDate: date }),
    queryKey: [API_GET_DAILY_RECORD_KEY, {recordDate: date }],
    enabled: !!date,
  });

이런 식으로 짜져 있다보니, 결국 사용자가 선택한 일자 별로, 키는 전부 달라질 것이고, 그에 따라 재패칭이 일어날 수밖에 없는 구조였다.
물론, 한 번 불러온 뒤에는 캐싱이 있기에, 초기 로딩 처리가 안 걸렸다.

다만, 키에 따라 새로운 데이터를 계속 변경하면서 불러올 경우, 로딩이 깜빡 깜빡 거리는 게 너무나도 빈번했다.

물론 그렇다고 구현하지 않을 수도 없는 입장에서, 이럴 땐 도저히 방법이 없는 가에 대한 의문이 품어졌다.

2. placeholderData: keepPreviousData.

결론적으로는, 단 이 한줄로 끝나는 일이다.

(참고용 코드)

import { keepPreviousData } from '@tanstack/react-query';

const { data: recordData, isLoading: isLoadingRecord } = useQuery({
    queryFn: ({ headers }) =>
      getDailyRecord(headers, {recordDate: date }),
    queryKey: [API_GET_DAILY_RECORD_KEY, {recordDate: date }],
    enabled: !!date,
    placeholderData: keepPreviousData,
  });

사실 결과가 허무했지만, 이미 이런 문제를 겪어본 팀 동료분과 공유해보니,

이런 방법이 있다는 것을 깨달았다.

역시.... 아직 "리액트 쿼리를 제대로 쓸 줄 모르는 구나"라는 것을 몸소 깨달았다.

하지만, 어떻게 이런 것이 가능한 거지?

라는 의문점이 남았고, 이에 따라, 하나씩 기본적인 원리를 파보기로 했다.

3. 원리 파악.

애초에, 원인이 된 지점은

이러했다.

import { keepPreviousData } from '@tanstack/react-query';

const { data: recordData, isLoading: isLoadingRecord } = useQuery({
    queryFn: ({ headers }) =>
      getDailyRecord(headers, {recordDate: date }),
    queryKey: [API_GET_DAILY_RECORD_KEY, {recordDate: date }],
    enabled: !!date,
    placeholderData: keepPreviousData,
  });

지금 저 코드에서 recordData가 리패치를 하는 순간.

undefined가 찍히게 되는 게 문제였다.
결국 date가 변경될 때마다, loading은 호출될 것이고, 그에 따라 사용자는 로딩 스켈레톤을 찰나의 순간에 마주하게 되는 것이었다.

그렇다면, 과연 placeholderData에 저 keepPreviousData를 넣어주면 어떻게 되는 거지?
그걸 살펴보고, console을 찍어보니 -> 첫 마운트시에만, undefined가 찍혔고 그 이후에는 기존 데이터(기존에 쿼리키의 데이터)를 유지한 채로 가지고 있었다.

그래서 사용자의 입장에서는 어 로딩 없이 그냥 바로 화면이 전환되네와 같은 느낌을 받을 수 있는 것이다.

즉, placeholderData 옵션을 활용함으로서, 새로운 쿼리키로 재패치하기 전까지 이전 값을 유지할 데이터를 만들어주는 개념이었다.

이에 대한 자세한 설명은 역시 공식 문서에도 잘 나와있었다.

다시 요약해 보면, 즉 해당 쿼리 키에 해당하는 데이터가 없을 때, 그걸 비워두기 싫다면 placeholderData 옵션에 값을 넣어주면 되는 것이다.

자세한 내용은 공식 문서 링크 참조(https://tanstack.com/query/latest/docs/framework/react/guides/placeholder-query-data)

4. RFC 톺아보기.

그렇다면 저 keepPreviousData는 뭔가?

그래서 준비했다. 역시 tanstackquery에서 이 부분에 대해 rfc로 잘 설명해 뒀다.
(역시 가끔은 이런 부분은 디스커션에도 잘 나와있다)

import { keepPreviousData } from '@tanstack/react-query'

useQuery({
  queryKey: ["post", id],
  queryFn: () => fetchPost(id),
  placeholderData: keepPreviousData
})

위의 rfc discussion에서 원작자인 tkdoo의 말을 빌려보겠다.

Placeholderdata 옵션은 결국 가짜 데이터를 현재 바뀐 데이터 패칭의 데이터를 다 불러오기 전까지 보여주는 행위인 것이다.
이걸 keepPreviousData를 활용해서 기존 캐시된 데이터를 활용하겠다고 선언해 주는 것이다.
이는 아마도 react의 useDifferedValue라는 훅을 참조했다고 전한다.

결국 위의 내용처럼 ui의 업데이트를 일시적으로 뒤로 미뤄둘 때 쓰는 거 같다.

다만 여기서 끝나면, 아쉬움이 커서 결국, 이를 어떻게 코드적으로 core단에서 움직이는 지 구조체를 뜯어봤다.

5. core-pack 구조 톺아보기.

1)queryObserver.ts


  getOptimisticResult(
    options: DefaultedQueryObserverOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryData,
      TQueryKey
    >,
  ): QueryObserverResult<TData, TError> {
    const query = this.#client.getQueryCache().build(this.#client, options)

    const result = this.createResult(query, options)

    if (shouldAssignObserverCurrentProperties(this, result)) {
      // this assigns the optimistic result to the current Observer
      // because if the query function changes, useQuery will be performing
      // an effect where it would fetch again.
      // When the fetch finishes, we perform a deep data cloning in order
      // to reuse objects references. This deep data clone is performed against
      // the `observer.currentResult.data` property
      // When QueryKey changes, we refresh the query and get new `optimistic`
      // result, while we leave the `observer.currentResult`, so when new data
      // arrives, it finds the old `observer.currentResult` which is related
      // to the old QueryKey. Which means that currentResult and selectData are
      // out of sync already.
      // To solve this, we move the cursor of the currentResult every time
      // an observer reads an optimistic value.

      // When keeping the previous data, the result doesn't change until new
      // data arrives.
      this.#currentResult = result
      this.#currentResultOptions = this.options
      this.#currentResultState = this.#currentQuery.state
    }
    return result
  }

위처럼, 쿼리는 하나의 옵저버 구조로 되어있다.
그래서 data를 감지하고 그걸 보통 data에 넣어주는 방식인데, 저기 조건문 처리에서 볼 수 있듯이,
placeholderData에 데이터가 채워져있으면, 그걸 토대로 data가 새롭게 채워질 동안에 그려주는 것을 코드로 확인해 볼 수 있었다.

2)keepPreviousData.js

  /**
   * @param {import('jscodeshift').ObjectExpression} objectExpression
   * @returns {import('jscodeshift').ObjectProperty | undefined}
   */
  const getKeepPreviousDataProperty = (objectExpression) => {
    return objectExpression.properties.find(isKeepPreviousDataObjectProperty)
  }

  let shouldAddKeepPreviousDataImport = false

  const replacer = (path, resolveTargetArgument, transformNode) => {
    const node = path.node
    const { start, end } = getNodeLocation(node)

    try {
      const targetArgument = resolveTargetArgument(node)

      if (targetArgument && utils.isObjectExpression(targetArgument)) {
        const isPlaceholderDataPropertyPresent =
          hasPlaceholderDataProperty(targetArgument)

        if (hasPlaceholderDataProperty(targetArgument)) {
          throw new AlreadyHasPlaceholderDataProperty(node, filePath)
        }

        const keepPreviousDataProperty =
          getKeepPreviousDataProperty(targetArgument)

        const keepPreviousDataPropertyHasTrueValue =
          isObjectPropertyHasTrueBooleanLiteralValue(keepPreviousDataProperty)

        if (!keepPreviousDataPropertyHasTrueValue) {
          utils.warn(
            `The usage in file "${filePath}" at line ${start}:${end} already contains a "keepPreviousData" property but its value is not "true". Please migrate this usage manually.`,
          )

          return node
        }

        if (keepPreviousDataPropertyHasTrueValue) {
          // Removing the `keepPreviousData` property from the object.
          const mutableObjectExpressionProperties =
            filterKeepPreviousDataProperty(targetArgument)

          if (!isPlaceholderDataPropertyPresent) {
            shouldAddKeepPreviousDataImport = true

            // When the `placeholderData` property is not present, the `placeholderData: keepPreviousData` property will be added.
            mutableObjectExpressionProperties.push(
              createPlaceholderDataObjectProperty(),
            )
          }

          return transformNode(
            node,
            jscodeshift.objectExpression(mutableObjectExpressionProperties),
          )
        }
      }

      utils.warn(
        `The usage in file "${filePath}" at line ${start}:${end} could not be transformed, because the first parameter is not an object expression. Please migrate this usage manually.`,
      )

      return node
    } catch (error) {
      utils.warn(
        error.name === AlreadyHasPlaceholderDataProperty.name
          ? error.message
          : `An unknown error occurred while processing the "${filePath}" file. Please review this file, because the codemod couldn't be applied.`,
      )

      return node
    }
  }

  createUseQueryLikeTransformer({ jscodeshift, utils, root }).execute(
    config.hooks,
    (path) => {
      const resolveTargetArgument = (node) => node.arguments[0] ?? null
      const transformNode = (node, transformedArgument) =>
        jscodeshift.callExpression(node.original.callee, [transformedArgument])

      return replacer(path, resolveTargetArgument, transformNode)
    },
  )

  createQueryClientTransformer({ jscodeshift, utils, root }).execute(
    config.queryClientMethods,
    (path) => {
      const resolveTargetArgument = (node) => node.arguments[1] ?? null
      const transformNode = (node, transformedArgument) => {
        return jscodeshift.callExpression(node.original.callee, [
          node.arguments[0],
          transformedArgument,
          ...node.arguments.slice(2, 0),
        ])
      }

      return replacer(path, resolveTargetArgument, transformNode)
    },
  )


module.exports = (file, api) => {
  const jscodeshift = api.jscodeshift
  const root = jscodeshift(file.source)
  const utils = createUtilsObject({ root, jscodeshift })
  const filePath = file.path

  const dependencies = { jscodeshift, utils, root, filePath }

  const { shouldAddKeepPreviousDataImport } = transformUsages({
    ...dependencies,
    config: {
      hooks: ['useInfiniteQuery', 'useQueries', 'useQuery'],
      queryClientMethods: ['setQueryDefaults'],
    },
  })

  if (shouldAddKeepPreviousDataImport) {
    root
      .find(jscodeshift.ImportDeclaration, {
        source: {
          value: '@tanstack/react-query',
        },
      })
      .replaceWith(({ node: mutableNode }) => {
        mutableNode.specifiers = [
          jscodeshift.importSpecifier(
            jscodeshift.identifier('keepPreviousData'),
          ),
          ...mutableNode.specifiers,
        ]

        return mutableNode
      })
  }

  return root.toSource({ quote: 'single', lineTerminator: '\n' })
}

여기는 바로 위에서 임포트 해온, KeepPreviousData 함수 실행 코드다.
이 코드 전체를 다 해석할 수는 없었지만, 대략적으로 훑어 보았을 때는, 결국 root 노드에서 find해서(아마 특정 쿼리 키에 담긴 데이터일 것으로 추정) 그 값을 토대로 KeepPreviosData라는 키를 가진 항목이 있으면 그걸 반환해 주는 것으로 예상해 볼 수 있었다.

보다 자세한 내용은 텐스텍 쿼리의 깃허브 코어팩 링크(https://github.com/TanStack/query/blob/main/packages/query-codemods/src/v5/keep-previous-data/keep-previous-data.js) 참조 바란다

6. 결론

결론적으로 역시 상황에 맞게 library를 활용하고, 그때 적절한 옵션을 사용해서 구현하는 것이 중요한 점을 다시 깨달았다.

물론, 이런 고민을 토대로, 다시 tanstack-query는 어떻게 react의 어떤 개념을 차용했고, 또 그들은 어떤 코어 구조로 코드를 짰는 지를 다시 돌아볼 수 있어서 너무나 값진 배움이었다고 생각한다.

이렇게 정리해 보고 나니, 더욱 머리에 명확하게 구조와 원리가 그려지니.

이런 맛이 참 재밌는 거 같다. 코딩이라는 것은... 점점..

이렇게 이번 아하 모먼트는 정리해 보고, 다음 아하 모먼트로 다시 돌아오겠다.

참조 링크
https://tanstack.com/query/latest/docs/framework/react/guides/placeholder-query-data
https://github.com/TanStack/query/discussions/4426
https://github.com/TanStack/query/blob/main/packages/query-codemods/src/v5/keep-previous-data/keep-previous-data.js
https://github.com/TanStack/query/blob/main/packages/query-core/src/query.ts

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

0개의 댓글