Navigation

RTK Query Re-renders

RTK Query가 만들어 주는 훅을 처음 쓸 때는 구조분해로 꺼낸 값만 구독한다고 생각하기 쉽습니다. 운영 백오피스를 만들 때 isError 하나만 쓰는 컴포넌트가 요청이 시작되고 끝날 때마다 다시 렌더링되는 것을 보고 원인을 따라간 적이 있습니다. 당시 노트를 Redux Toolkit 2.13.0 소스와 현재 공식 문서로 다시 확인했고 selectFromResult의 참조 함정과 여러 요청을 묶는 방법까지 함께 정리했습니다.

구조분해는 구독 범위를 바꾸지 않습니다

아래 컴포넌트는 isError만 읽습니다. 요청이 시작되면 pending 액션이, 끝나면 fulfilled나 rejected 액션이 캐시 항목을 바꿉니다. isError는 두 시점 모두 false인데도 로그는 매번 찍힙니다.

isError만 읽는 컴포넌트tsx
function ProfileError({ userId }: { userId: number }) {
  const { isError } = useGetProfileQuery(userId)
  console.count('ProfileError render')

  if (!isError) return null
  return <p role="alert">프로필을 불러오지 못했습니다.</p>
}

function SaveButton() {
  // 트리거만 꺼내도 결과 상태가 바뀔 때마다 다시 렌더링된다
  const [saveProfile] = useSaveProfileMutation()
  console.count('SaveButton render')

  return <button onClick={() => saveProfile({ name: 'new' })}>저장</button>
}

SWR 문서는 컴포넌트가 실제로 읽은 상태만 갱신하는 의존성 수집을 성능 기능으로 설명합니다. RTK Query 훅에는 그런 추적이 없습니다. 구조분해는 이미 만들어진 결과 객체에서 값을 꺼내는 문법일 뿐이고 무엇을 구독할지는 훅 안에서 먼저 정해집니다.

렌더링 횟수를 셀 때는 개발 모드의 StrictMode도 고려해야 합니다. StrictMode는 순수하지 않은 렌더링을 찾으려고 컴포넌트를 한 번 더 호출하므로 로그 개수를 그대로 렌더링 횟수로 읽으면 안 됩니다.

훅은 결과 객체 전체를 얕게 비교합니다

Redux Toolkit 2.13.0의 src/query/react/buildHooks.ts를 기준으로 정리했습니다. 처음 노트를 쓸 때는 RTK Query가 별도 저장소에 있던 시절의 소스를 읽었는데 현재 구조도 큰 틀은 같습니다.

그러니 같은 데이터로 다시 요청해도 렌더링은 일어납니다. 응답 데이터는 기본값인 structuralSharing 덕분에 내용이 같으면 이전 참조를 유지합니다. 그래도 isFetching이 바뀌고 요청 id와 시각도 새 값이 됩니다.

반대로 다른 엔드포인트나 다른 인자의 캐시가 바뀌는 것만으로는 렌더링되지 않습니다. 기본 결과 자체가 createSelector로 메모이제이션되어 있고 결과를 shallowEqual로 비교하기 때문에 이 컴포넌트의 캐시 항목이 그대로면 새 객체가 만들어지지 않습니다.

flowchart LR
    Action["pending, fulfilled 액션"] --> Entry["캐시 항목 갱신"]
    Entry --> Default["기본 결과: 캐시 항목과 상태 플래그"]
    Default --> Select["selectFromResult가 있으면 선택"]
    Select --> Compare["useSelector의 얕은 비교"]
    Compare -->|"필드 하나라도 참조가 다름"| Render["다시 렌더링"]
    Compare -->|"모든 필드가 같음"| Skip["렌더링 생략"]

mutation 훅도 같은 방식으로 결과를 shallowEqual로 비교합니다. 다만 트리거를 호출하면 훅 내부에서 요청 Promise를 state에 저장합니다. 결과를 전혀 구독하지 않아도 트리거를 부른 컴포넌트는 그때 한 번 다시 렌더링됩니다.

selectFromResult로 필요한 필드만 남깁니다

selectFromResult는 기본 결과를 받아 컴포넌트가 실제로 쓸 값만 돌려주는 옵션입니다. 비교 대상이 이 반환값으로 바뀌므로 반환한 필드가 그대로면 렌더링되지 않습니다.

필요한 필드만 구독tsx
const selectErrorOnly = ({ isError }: { isError: boolean }) => ({ isError })
const selectNothing = () => ({})

function ProfileError({ userId }: { userId: number }) {
  const { isError } = useGetProfileQuery(userId, {
    selectFromResult: selectErrorOnly,
  })
  return isError ? <p role="alert">프로필을 불러오지 못했습니다.</p> : null
}

function PostTitle({ postId }: { postId: number }) {
  const { post } = useGetPostsQuery(undefined, {
    selectFromResult: ({ data }) => ({
      post: data?.find((item) => item.id === postId),
    }),
  })
  return <li>{post?.title}</li>
}

function SaveButton() {
  const [saveProfile] = useSaveProfileMutation({ selectFromResult: selectNothing })
  return <button onClick={() => saveProfile({ name: 'new' })}>저장</button>
}

find는 캐시에 있는 항목의 참조를 그대로 돌려줍니다. 목록의 다른 게시물이 바뀌어도 고른 게시물의 참조가 같으면 렌더링되지 않습니다. 공식 문서도 큰 컬렉션의 한 항목을 고르면 같은 컬렉션의 다른 항목 변화는 무시한다고 설명합니다.

빈 객체를 돌려주는 selectNothing도 의도대로 동작합니다. 키가 없는 두 객체는 얕은 비교에서 같다고 판단되기 때문입니다. 다만 앞에서 본 대로 트리거를 호출하는 순간의 렌더링 한 번은 남습니다.

2.13.0의 useQuery에는 덜 알려진 차이가 하나 있습니다. selectFromResult를 넘기지 않으면 캐시 항목이 아직 없는 첫 렌더링에서도 isLoading을 true로 보정하는 기본 selector를 씁니다. 직접 넘기면 이 보정이 대체되어 첫 렌더링에서는 isUninitialized가 true, isLoading이 false로 들어옵니다. 로딩 표시를 직접 고른다면 isUninitialized도 함께 확인해야 합니다.

새 참조를 만들면 최적화가 사라집니다

selectFromResult 안에서 새 배열이나 객체를 만들면 얕은 비교가 매번 실패합니다. 실제로 자주 만나는 경우는 세 가지였습니다.

코드문제대응
posts: data ?? []데이터가 없을 때마다 새 빈 배열컴포넌트 밖에 빈 배열 상수
posts: data?.filter(...)같은 데이터에서도 새 배열메모이제이션한 selector
렌더링마다 새로 만드는 selectFromResult 함수내부 메모이제이션이 매번 초기화함수를 밖에 두거나 결과를 selector로 고정

두 번째가 가장 헷갈립니다. 2.13.0에서는 selectFromResult를 기본 결과 하나를 입력으로 받는 createSelector로 감쌉니다. 다시 요청할 때처럼 isFetching만 바뀌어도 이 함수는 다시 실행됩니다. 이때 filter가 새 배열을 돌려주면 데이터가 같아도 렌더링이 일어납니다.

세 번째도 같은 구조에서 나옵니다. 감싼 selector는 selectFromResult 함수의 참조에 따라 다시 만들어지므로 인라인 함수는 렌더링마다 새로 계산됩니다. 캐시의 참조를 그대로 꺼내는 함수라면 문제가 작지만 무거운 파생 계산이 들어 있다면 비용이 커집니다.

필터 결과를 메모이제이션한 selectortsx
import { createSelector } from '@reduxjs/toolkit'
import type { TypedUseQueryStateResult } from '@reduxjs/toolkit/query/react'

type PostsResult = TypedUseQueryStateResult<Post[], void, AppBaseQuery>
const emptyPosts: Post[] = []

const selectPostsByAuthor = createSelector(
  [(result: PostsResult) => result.data, (_: PostsResult, authorId: string) => authorId],
  (posts, authorId) => posts?.filter((post) => post.authorId === authorId) ?? emptyPosts,
)

function AuthorPosts({ authorId }: { authorId: string }) {
  const { posts, isFetching } = useGetPostsQuery(undefined, {
    selectFromResult: (result) => ({
      posts: selectPostsByAuthor(result, authorId),
      isFetching: result.isFetching,
    }),
  })

  return <PostList posts={posts} dimmed={isFetching} />
}

selectPostsByAuthor는 data와 authorId가 같으면 이전 배열을 돌려줍니다. RTK 2.x와 함께 쓰는 Reselect 5는 weakMapMemoize를 기본으로 써서 인자가 달라져도 이전 결과를 보관합니다. RTK 1.x와 Reselect 4에서는 캐시 크기가 1이어서 컴포넌트마다 selector 인스턴스를 따로 만들어야 했습니다.

여러 요청은 어디에서 묶을지 먼저 고릅니다

같은 백오피스의 통계 화면에는 유형별 목록 여러 개를 받아 유형마다 시트 하나씩 만든 뒤 엑셀 파일 하나로 내려받는 기능이 있었습니다. 당시 노트 제목은 “순서가 보장되어야 하는 요청”이었지만 코드는 요청을 동시에 보내고 모두 끝나기를 기다렸습니다. 필요한 것은 실행 순서가 아니라 “모든 응답이 모인 뒤에 처리”였습니다. 이 둘을 먼저 나누면 선택지가 분명해집니다.

상황방법캐시
화면에 차례로 보이는 조회두 번째 훅에 skipToken을 넘겼다가 앞 응답이 오면 인자 전달엔드포인트별 항목 유지
두 요청의 결과가 사실상 한 데이터엔드포인트의 queryFn에서 baseQuery를 차례로 호출항목 하나
사용자 동작 한 번에 기존 엔드포인트 여러 개 사용createAsyncThunk 안에서 initiate dispatch기존 항목 재사용

마지막 경우가 당시 구현이었습니다. 지금 다시 쓴다면 아래처럼 쓰겠습니다.

기존 엔드포인트를 묶는 thunkts
export const exportReports = createAsyncThunk(
  'reports/export',
  async (range: DateRange, { dispatch }) => {
    // 앞 응답이 다음 요청의 인자라면 그 응답을 먼저 기다린다
    const kinds = await dispatch(
      reportsApi.endpoints.getReportKinds.initiate(range, { subscribe: false }),
    ).unwrap()

    const fetchSheet = (kind: string) =>
      dispatch(
        reportsApi.endpoints.getReport.initiate({ kind, ...range }, { subscribe: false }),
      )
        .unwrap()
        .then((rows) => ({ kind, rows }))

    // 서로 독립인 요청은 함께 보내고 모두 끝날 때까지 기다린다
    return Promise.all(kinds.map(fetchSheet))
  },
)

이 코드를 원래 노트와 비교하며 확인한 지점은 네 가지입니다.

화면 조회처럼 결과를 계속 구독해야 한다면 thunk보다 훅의 조건부 요청이 낫습니다. useGetUserQuery(post?.ownerId ?? skipToken)처럼 쓰면 앞 응답이 오기 전에는 요청을 보내지 않고 각 결과도 따로 캐시됩니다.

측정 방법과 한계

먼저 렌더링이 실제 비용인지 확인합니다. React DevTools Profiler로 해당 컴포넌트의 렌더링 시간과 원인을 보고 Redux DevTools의 액션 기록과 시점을 맞춰 봅니다. 렌더링이 가볍다면 모든 훅에 selectFromResult를 붙여 읽기 어려운 코드를 만들 이유가 없습니다.

selectFromResult는 렌더링만 줄입니다. 구독과 요청 횟수는 그대로이므로 네트워크 비용을 줄이려면 refetchOnMountOrArgChange, 캐시 유지 시간, 태그 무효화 범위를 따로 봐야 합니다. 이 글의 동작 설명은 2.13.0 소스 기준이며 내부 구현은 버전마다 바뀔 수 있습니다. 공식 문서가 보장하는 계약은 “반환값을 얕게 비교한다”와 “반환값은 올바르게 메모이제이션되어야 한다” 두 가지입니다.

참고 자료