본문 바로가기

개발

React Query에서 데이터 reference를 관리하는 방법

이제 공식 명칭이 Tanstack Query인 듯하지만 편의상 리액트 쿼리라고 부르겠습니다.

Important Defaults

리액트 쿼리 가이드 - important defaults에는 다음과 같은 설명이 있다.

Query results by default are structurally shared to detect if data has actually changed and if not, the data reference remains unchanged to better help with value stabilization with regards to useMemo and useCallback.

 

쉽게 말해 쿼리 결과들에 대한 레퍼런스는 데이터가 실제로 변경됐을 때에만 변경된다는 의미다.

왜?

const a = [1, 2, 3]
const b = [1, 2, 3]

console.log(a === b) // ?

 

자바스크립트를 공부한 사람이라면 답이 false라는 것을 알 것이다.

 

만약 리액트 쿼리가 어떤 API 요청에 대한 응답 결과를 매번 단순 리턴해준다면 어떻게 될까? 아무런 변경이 없더라도 레퍼런스가 달라지므로 리액트에서 이것은 '다른' 데이터다.

 

그렇게 되면 불필요한 리랜더링이 발생하거나 useMemo, useCallback이 무의미해지는 경우가 생길테고, 이것을 방지하기 위해 리액트 쿼리는 기본적으로 데이터가 '실제로' 변경됐을 때만 레퍼런스도 변경한다는 것이다.

어떻게?

핵심은 위 설명에도 나온 structurally shared라는 개념이다. 찾아보니 structural sharing으로도 불리는 듯하다.

이 링크에 개념부터 구현 방법까지 잘 설명되어 있는데, 가장 직관적으로 이해하기 좋은 것은 아래 그림이다.

 

structural sharing

 

users, Catalog라는 프로퍼티를 가진 Library가 상태로 관리되고 있는데, user1의 정보가 변경되었다고 가정해보자.

 

이 때 다음 상태값(NextLibrary)은 변경되지 않은 것에 대한 레퍼런스는 그대로 가져가되 변경된 것(users, user1)만 새로운 레퍼런스를 참조한다.

 

그림에서 Catalog는 물론, 변경되지 않은 유저(user0)에 대한 레퍼런스까지도 그대로 유지되는 것을 볼 수 있다.

React Query에서의 structural sharing

실제로 React Query에서는 어떻게 하고 있는지 궁금해서 열어봤다. 코드 링크

 

생각보단 코드가 어렵지 않아서 아래 설명을 보기 전에 먼저 한번 살펴보는 것을 추천한다. 어려운 코드 읽는거 못하는 사람(=나)도 볼만했으니 아마 누구든 파악할 수 있을 것이다.

 

귀찮다면... 아래 코드에 한줄 한줄 나름대로 설명을 달아보았다.

 

/**
 * This function returns `a` if `b` is deeply equal.
 * If not, it will replace any deeply equal children of `b` with those of `a`.
 * This can be used for structural sharing between JSON values for example.
 */

// a, b가 deeply equal하다면 a를 리턴한다.
// 아니라면, b의 child 중 a의 child와 deeply equal한 것을 a의 child로 대체한다.
// JSON 값들에 대한 structural sharing에 사용된다.

// a가 기존 데이터, b가 새로 받아온 데이터라고 생각하면 이해하기 편함
export function replaceEqualDeep(a: any, b: any): any {
  if (a === b) { // a, b가 primitive 타입이고 값이 같을 때 (혹은 같은 레퍼런스)
    return a
  }

  const array = isPlainArray(a) && isPlainArray(b) // 둘 다 배열

  // 둘 다 배열이거나 둘 다 plain object면
  if (array || (isPlainObject(a) && isPlainObject(b))) {
    // a, b의 length(배열) 또는 property(key) 갯수
    const aSize = array ? a.length : Object.keys(a).length
    const bItems = array ? b : Object.keys(b)
    const bSize = bItems.length

    // 복사본을 저장할 배열 또는 object
    const copy: any = array ? [] : {}

    // child 중 deep equal한 갯수
    let equalItems = 0

    // b의 child를 iterate
    for (let i = 0; i < bSize; i++) {
      const key = array ? i : bItems[i]
      copy[key] = replaceEqualDeep(a[key], b[key]) // child에 대해 재귀 호출
      // a[key], b[key]가 deep equal하다면 결과는 a[key]
      if (copy[key] === a[key]) {
        equalItems++
      }
    }

    // 자식 수가 같고, 모두 deep equal하다면 a, b는 deep equal하므로 a 리턴,
    // 그렇지 않다면 copy 리턴.
    // 위 copy[key] = replaceEqualDeep(a[key], b[key]) 덕분에 deep equal한 자식은 기존(a[key]) 레퍼런스 유지
    return aSize === bSize && equalItems === aSize ? a : copy
  }

  // 둘의 값이 다르거나 데이터 구조가 다른 것이므로 b(새로운 값) 리턴
  return b
}

 

이런 방식으로 리액트 쿼리는 데이터가 실제로 변경되었을 때만 레퍼런스가 변경되도록 한다.

 

사실 이런거 몰라도 리액트 쿼리를 사용하는 데는 큰 지장이 없겠지만, 상태 관리나 데이터 불변성 같은 개념에 대해 좀 더 깊이있게 생각해볼 수 있었다. 앞으로 복잡한 상태를 관리하는 코드를 짤 때도 한번 활용해보면 좋을 것 같다.