RTK Query Re-renders
RTK Query가 만들어 주는 훅을 처음 쓸 때는 구조분해로 꺼낸 값만 구독한다고 생각하기 쉽습니다. 운영 백오피스를 만들 때 isError 하나만 쓰는 컴포넌트가 요청이 시작되고 끝날 때마다 다시 렌더링되는 것을 보고 원인을 따라간 적이 있습니다. 당시 노트를 Redux Toolkit 2.13.0 소스와 현재 공식 문서로 다시 확인했고 selectFromResult의 참조 함정과 여러 요청을 묶는 방법까지 함께 정리했습니다.
구조분해는 구독 범위를 바꾸지 않습니다
아래 컴포넌트는 isError만 읽습니다. 요청이 시작되면 pending 액션이, 끝나면 fulfilled나 rejected 액션이 캐시 항목을 바꿉니다. isError는 두 시점 모두 false인데도 로그는 매번 찍힙니다.
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가 별도 저장소에 있던 시절의 소스를 읽었는데 현재 구조도 큰 틀은 같습니다.
useQuery는useQuerySubscription과useQueryState를 합친 훅입니다. 앞의 훅은 요청과 캐시 구독을 맡고 뒤의 훅은 캐시에서 값을 읽습니다.useQueryState는 캐시 항목에isLoading,isFetching,isSuccess,isError,currentData같은 플래그를 더한 기본 결과를 만듭니다. 캐시 항목의status,requestId,startedTimeStamp,fulfilledTimeStamp도 이 객체에 펼쳐 들어갑니다.- 이 결과는
useSelector(selector, shallowEqual)로 구독합니다. 필드 하나라도 참조가 바뀌면 다시 렌더링됩니다.
그러니 같은 데이터로 다시 요청해도 렌더링은 일어납니다. 응답 데이터는 기본값인 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는 기본 결과를 받아 컴포넌트가 실제로 쓸 값만 돌려주는 옵션입니다. 비교 대상이 이 반환값으로 바뀌므로 반환한 필드가 그대로면 렌더링되지 않습니다.
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 함수의 참조에 따라 다시 만들어지므로 인라인 함수는 렌더링마다 새로 계산됩니다. 캐시의 참조를 그대로 꺼내는 함수라면 문제가 작지만 무거운 파생 계산이 들어 있다면 비용이 커집니다.
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 | 기존 항목 재사용 |
마지막 경우가 당시 구현이었습니다. 지금 다시 쓴다면 아래처럼 쓰겠습니다.
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))
},
)이 코드를 원래 노트와 비교하며 확인한 지점은 네 가지입니다.
initiate의subscribe기본값은true입니다. 구독을 추가한 채unsubscribe를 부르지 않으면 캐시 항목이 정리되지 않습니다. 값만 필요한 thunk라면subscribe: false를 주거나 반환된 객체의unsubscribe를finally에서 호출합니다. 구독이 없는 항목은keepUnusedDataFor(기본 60초) 뒤에 제거됩니다.initiate결과의unwrap()은 캐시 항목이 오류 상태면 그 오류를 던집니다. 하나라도 실패하면Promise.all이 거부되고 thunk는rejected가 됩니다. 원래 오류 값을 컴포넌트에 넘기려면rejectWithValue를 씁니다.- 원래 노트에는 요청 Promise를
new Promise(async (resolve) => { ... })로 한 번 더 감싸는 함수가 있었습니다. 안쪽 요청이 실패하면 바깥 Promise가 끝나지 않아Promise.all이 영원히 기다릴 수 있는 구조였습니다. 위 예제처럼then으로 이어 붙이면 실패도 그대로 전달됩니다. - 파일 내려받기를
extraReducers안에서 실행하는 방법도 적혀 있었는데 reducer에는 부수 효과를 두지 않습니다. 컴포넌트에서dispatch(exportReports(range)).unwrap()을 기다린 뒤 내려받기를 실행하는 쪽이 Redux 스타일 가이드와 맞습니다.
화면 조회처럼 결과를 계속 구독해야 한다면 thunk보다 훅의 조건부 요청이 낫습니다. useGetUserQuery(post?.ownerId ?? skipToken)처럼 쓰면 앞 응답이 오기 전에는 요청을 보내지 않고 각 결과도 따로 캐시됩니다.
측정 방법과 한계
먼저 렌더링이 실제 비용인지 확인합니다. React DevTools Profiler로 해당 컴포넌트의 렌더링 시간과 원인을 보고 Redux DevTools의 액션 기록과 시점을 맞춰 봅니다. 렌더링이 가볍다면 모든 훅에 selectFromResult를 붙여 읽기 어려운 코드를 만들 이유가 없습니다.
selectFromResult는 렌더링만 줄입니다. 구독과 요청 횟수는 그대로이므로 네트워크 비용을 줄이려면 refetchOnMountOrArgChange, 캐시 유지 시간, 태그 무효화 범위를 따로 봐야 합니다. 이 글의 동작 설명은 2.13.0 소스 기준이며 내부 구현은 버전마다 바뀔 수 있습니다. 공식 문서가 보장하는 계약은 “반환값을 얕게 비교한다”와 “반환값은 올바르게 메모이제이션되어야 한다” 두 가지입니다.
참고 자료
- RTK Query Queries: Selecting data from a query result
- Redux Essentials Part 8: Selecting Values from Results
- RTK Query Hooks API
- API Slices: Endpoints의 initiate
- Usage Without React Hooks
- Customizing Queries: Performing multiple requests with a single query
- Conditional Fetching
- createAsyncThunk
- Redux Style Guide: Reducers Must Not Have Side Effects
- Redux Toolkit v2.13.0 buildHooks.ts
- Redux Toolkit v2.13.0 buildInitiate.ts
- SWR Performance: Dependency Collection
- React StrictMode