본문으로 건너뛰기

Redux Essentials, 파트 8: RTK Query 고급 패턴

비공식 베타 번역

이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →

학습 내용
  • ID와 함께 태그를 사용해 캐시 무효화 및 재패칭 관리 방법
  • React 외부에서 RTK Query 캐시 작업 방법
  • 응답 데이터 조작 기법
  • 낙관적 업데이트 및 스트리밍 업데이트 구현 방법
필수 조건
  • RTK Query 설정 및 기본 사용법 이해를 위한 파트 7 완료

소개

파트 7: RTK Query 기본에서는 애플리케이션에서 데이터 패칭과 캐싱을 처리하기 위해 RTK Query API를 설정하고 사용하는 방법을 살펴보았습니다. Redux 스토어에 "API 슬라이스"를 추가하고, 게시물 데이터를 패치하기 위한 "쿼리" 엔드포인트를 정의하며, 새 게시물을 추가하는 "뮤테이션" 엔드포인트를 정의했습니다.

이번 섹션에서는 예제 애플리케이션의 다른 데이터 유형들도 RTK Query를 사용하도록 마이그레이션을 계속 진행하고, 고급 기능들을 활용해 코드베이스를 단순화하고 사용자 경험을 개선하는 방법을 살펴보겠습니다.

정보

이 섹션의 일부 변경사항은 반드시 필요한 것은 아닙니다. RTK Query의 기능을 시연하고 여러분이 필요할 때 사용할 수 있는 사항들을 보여주기 위해 포함되었습니다.

포스트 수정하기

이미 서버에 새 Post 항목을 저장하기 위한 뮤테이션 엔드포인트를 추가했으며, 이를 <AddPostForm>에서 사용했습니다. 다음으로 기존 포스트를 수정할 수 있도록 <EditPostForm>을 업데이트해야 합니다.

포스트 수정 폼 업데이트하기

새 포스트 추가와 마찬가지로 첫 단계는 API 슬라이스에 새 뮤테이션 엔드포인트를 정의하는 것입니다. 이는 포스트 추가를 위한 뮤테이션과 매우 유사하지만, 엔드포인트는 URL에 포스트 ID를 포함해야 하며 일부 필드를 업데이트 중임을 나타내기 위해 HTTP PATCH 요청을 사용해야 합니다.

features/api/apiSlice.ts
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post']
}),
getPost: builder.query<Post, string>({
query: postId => `/posts/${postId}`
}),
addNewPost: builder.mutation<Post, NewPost>({
query: initialPost => ({
url: '/posts',
method: 'POST',
body: initialPost
}),
invalidatesTags: ['Post']
}),
editPost: builder.mutation<Post, PostUpdate>({
query: post => ({
url: `posts/${post.id}`,
method: 'PATCH',
body: post
})
})
})
})

export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useEditPostMutation
} = apiSlice

이를 추가한 후에는 <EditPostForm>을 업데이트할 수 있습니다. 스토어에서 원본 Post 항목을 읽고, 이를 사용해 필드 수정을 위한 컴포넌트 상태를 초기화한 다음 업데이트된 변경사항을 서버로 전송해야 합니다. 현재는 selectPostByIdPost 항목을 읽고, 요청을 위해 postUpdated thunk를 수동으로 디스패치하고 있습니다.

<SinglePostPage>에서 사용했던 동일한 useGetPostQuery 훅을 사용해 스토어 캐시에서 Post 항목을 읽을 수 있으며, 변경사항 저장을 처리하기 위해 새 useEditPostMutation 훅을 사용할 것입니다. 원한다면 업데이트 진행 중에 스피너를 추가하고 폼 입력을 비활성화할 수도 있습니다.

features/posts/EditPostForm.tsx
import React from 'react'
import { useNavigate, useParams } from 'react-router-dom'

import { Spinner } from '@/components/Spinner'

import { useGetPostQuery, useEditPostMutation } from '@/features/api/apiSlice'

// omit form types

export const EditPostForm = () => {
const { postId } = useParams()
const navigate = useNavigate()

const { data: post } = useGetPostQuery(postId!)

const [updatePost, { isLoading }] = useEditPostMutation()

if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}

const onSavePostClicked = async (
e: React.FormEvent<EditPostFormElements>
) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value

if (title && content) {
await updatePost({ id: post.id, title, content })
navigate(`/posts/${postId}`)
}
}

// omit rendering
}

캐시 데이터 구독 수명

직접 시도해보고 어떤 일이 발생하는지 확인해 봅시다. 브라우저의 개발자 도구를 열고 네트워크 탭으로 이동한 다음 페이지를 새로고침하고 네트워크 탭을 비운 후 로그인하세요. 초기 데이터를 패치하기 위해 /posts로의 GET 요청이 표시될 것입니다. "View Post" 버튼을 클릭하면 해당 단일 포스트 항목을 반환하는 /posts/:postId로의 두 번째 요청이 표시됩니다.

이제 단일 포스트 페이지 내부에서 "Edit Post"를 클릭하세요. UI가 <EditPostForm>을 표시하도록 전환되지만 이번에는 개별 포스트에 대한 네트워크 요청이 없습니다. 왜 그럴까요?

RTK Query 네트워크 요청

RTK Query는 여러 컴포넌트가 동일한 데이터를 구독할 수 있도록 허용하며, 각 고유 데이터 세트가 한 번만 가져오도록 보장합니다. 내부적으로 RTK Query는 각 엔드포인트 + 캐시 키 조합에 대한 활성 "구독" 참조 카운터를 유지합니다. 컴포넌트 A가 useGetPostQuery(42)를 호출하면 해당 데이터가 가져와집니다. 이후 컴포넌트 B가 마운트되어 동일한 useGetPostQuery(42)를 호출하면 같은 데이터를 요청하는 것입니다. 이미 캐시 항목이 존재하므로 요청이 필요하지 않습니다. 두 훅 사용은 가져온 data 및 로딩 상태 플래그를 포함해 동일한 결과를 반환합니다.

활성 구독 수가 0으로 떨어지면 RTK Query는 내부 타이머를 시작합니다. 타이머가 만료되기 전에 새로운 데이터 구독이 추가되지 않으면 RTK Query는 해당 데이터를 캐시에서 자동으로 제거합니다. 앱이 더 이상 해당 데이터를 필요로 하지 않기 때문입니다. 그러나 타이머 만료 전에 새로운 구독이 추가되면 타이머가 취소되고, 이미 캐시된 데이터를 다시 가져올 필요 없이 사용됩니다.

이 경우 <SinglePostPage>가 마운트되어 특정 ID의 Post를 요청했습니다. "Edit Post"를 클릭하면 라우터에 의해 <SinglePostPage> 컴포넌트가 언마운트되고 구독이 제거됩니다. RTK Query는 즉시 "이 포스트 데이터 제거" 타이머를 시작했습니다. 하지만 <EditPostPage> 컴포넌트가 바로 마운트되어 동일한 캐시 키로 같은 Post 데이터를 구독했습니다. 따라서 RTK Query는 타이머를 취소하고 서버에서 다시 가져오는 대신 동일한 캐시된 데이터를 계속 사용합니다.

기본적으로 사용되지 않는 데이터는 60초 후 캐시에서 제거되지만, 이는 루트 API 슬라이스 정의에서 구성하거나 개별 엔드포인트 정의에서 keepUnusedDataFor 플래그를 사용해 재정의할 수 있습니다. 이 플래그는 캐시 수명을 초 단위로 지정합니다.

특정 항목 무효화하기

이제 <EditPostForm> 컴포넌트가 편집된 포스트를 서버에 저장할 수 있지만 문제가 있습니다. 편집 중 "Save Post"를 클릭하면 <SinglePostPage>로 돌아가지만 여전히 편집되지 않은 이전 데이터가 표시됩니다. <SinglePostPage>는 이전에 가져온 캐시된 Post 항목을 계속 사용하고 있습니다. 마찬가지로 메인 페이지로 돌아가 <PostsList>를 보면 역시 이전 데이터가 표시됩니다. 개별 Post 항목과 전체 포스트 목록을 모두 강제로 다시 가져올 방법이 필요합니다.

이전에 "태그"를 사용해 캐시된 데이터의 일부를 무효화하는 방법을 살펴보았습니다. getPosts 쿼리 엔드포인트가 'Post' 태그를 제공하도록 선언하고, addNewPost 뮤테이션 엔드포인트가 동일한 'Post' 태그를 무효화하도록 했습니다. 이렇게 하면 새 포스트를 추가할 때마다 getQuery 엔드포인트에서 전체 포스트 목록을 다시 가져오도록 강제합니다.

getPost 쿼리와 editPost 뮤테이션 모두에 'Post' 태그를 추가할 수 있지만, 이렇게 하면 다른 개별 포스트들도 모두 다시 가져오게 됩니다. 다행히 RTK Query는 데이터 무효화를 더 선택적으로 수행할 수 있는 특정 태그를 정의할 수 있게 합니다. 이러한 특정 태그는 {type: 'Post', id: 123} 형태입니다.

getPosts 쿼리는 문자열 배열인 providesTags 필드를 정의합니다. providesTags 필드는 resultarg를 받는 콜백 함수를 반환할 수도 있으며, 이는 가져온 데이터의 ID를 기반으로 태그 항목을 생성할 수 있게 합니다. 마찬가지로 invalidatesTags도 콜백이 될 수 있습니다.

올바른 동작을 얻으려면 각 엔드포인트에 적절한 태그를 설정해야 합니다:

  • getPosts: 전체 목록에 대한 일반적인 'Post' 태그와 수신된 각 포스트 객체에 대한 특정 {type: 'Post', id} 태그 제공

  • getPost: 개별 포스트 객체에 대한 특정 {type: 'Post', id} 객체 제공

  • addNewPost: 일반 'Post' 태그를 무효화하여 전체 목록을 재요청합니다

  • editPost: 특정 {type: 'Post', id} 태그를 무효화합니다. 이로 인해 getPost개별 포스트와 getPosts전체 포스트 목록이 모두 재요청됩니다. 이는 두 쿼리가 모두 해당 {type, id} 값과 일치하는 태그를 제공하기 때문입니다

features/api/apiSlice.ts
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: (result = [], error, arg) => [
'Post',
...result.map(({ id }) => ({ type: 'Post', id }) as const)
]
}),
getPost: builder.query<Post, string>({
query: postId => `/posts/${postId}`,
providesTags: (result, error, arg) => [{ type: 'Post', id: arg }]
}),
addNewPost: builder.mutation<Post, NewPost>({
query: initialPost => ({
url: '/posts',
method: 'POST',
body: initialPost
}),
invalidatesTags: ['Post']
}),
editPost: builder.mutation<Post, PostUpdate>({
query: post => ({
url: `posts/${post.id}`,
method: 'PATCH',
body: post
}),
invalidatesTags: (result, error, arg) => [{ type: 'Post', id: arg.id }]
})
})
})

이 콜백의 result 인자는 응답에 데이터가 없거나 오류가 발생한 경우 undefined가 될 수 있으므로 안전하게 처리해야 합니다. getPosts의 경우 기본 배열 인자 값을 사용해 매핑할 수 있으며, getPost는 이미 인자 ID를 기반으로 단일 항목 배열을 반환합니다. editPost의 경우 트리거 함수에 전달된 부분 포스트 객체에서 포스트 ID를 알 수 있으므로 해당 값을 읽어올 수 있습니다

이러한 변경 사항을 적용한 후 브라우저 개발자 도구의 네트워크 탭을 열고 포스트 수정을 다시 시도해 보겠습니다

RTK Query 무효화 및 재요청

수정된 포스트를 저장하면 두 개의 요청이 연속적으로 발생하는 것을 볼 수 있습니다

  • editPost 뮤테이션에서 발생하는 PATCH /posts/:postId

  • getPost 쿼리가 재요청되면서 발생하는 GET /posts/:postId

이후 메인 "포스트" 탭으로 돌아가면 다음도 확인할 수 있습니다

  • getPosts 쿼리가 재요청되면서 발생하는 GET /posts

태그를 통해 엔드포인트 간 관계를 제공했기 때문에 RTK Query는 편집 시 특정 ID의 태그가 무효화되면 개별 포스트와 포스트 목록을 재요청해야 한다는 것을 인지했습니다 - 추가 변경이 필요 없습니다! 한편 포스트를 수정하는 동안 getPosts 데이터의 캐시 제한 시간이 만료되어 캐시에서 제거되었습니다. 다시 <PostsList> 컴포넌트를 열면 RTK Query는 캐시에 데이터가 없음을 인지하고 재요청을 수행했습니다

여기에는 한 가지 주의사항이 있습니다. getPosts에 일반 'Post' 태그를 지정하고 addNewPost에서 무효화하면 실제로 모든 개별 포스트까지 강제로 재요청하게 됩니다. getPosts 엔드포인트의 포스트 목록만 재요청하려면 {type: 'Post', id: 'LIST'}처럼 임의 ID를 가진 추가 태그를 포함하고 해당 태그를 무효화하면 됩니다. RTK Query 문서에는 특정 일반/특정 태그 조합이 무효화될 때 발생하는 동작을 설명하는 표가 있습니다

정보

RTK Query에는 "조건부 요청", "지연 쿼리", "프리패칭"을 포함해 데이터 재요청 시기와 방법을 제어하는 다양한 옵션이 있으며, 쿼리 정의는 여러 방식으로 커스터마이징할 수 있습니다. 이러한 기능 사용법에 대한 자세한 내용은 RTK Query 사용 가이드 문서를 참조하세요:

토스트 알림 업데이트

포스트 추가를 위해 썽크를 디스패치하는 방식에서 RTK Query 뮤테이션으로 전환했을 때, addNewPost.fulfilled 액션이 더 이상 디스패치되지 않아 "새 포스트가 추가되었습니다" 토스트 메시지 동작이 실수로 비활성화되었습니다

다행히 이 문제는 간단히 해결할 수 있습니다. RTK Query는 내부적으로 실제로 createAsyncThunk를 사용하며, 요청이 발생할 때 Redux 액션을 디스패치하는 것을 이미 확인했습니다. 토스트 알림 리스너를 업데이트하여 RTKQ의 내부 액션이 디스패치되는지 모니터링하고, 이때 토스트 메시지를 표시할 수 있습니다.

createApi는 각 엔드포인트에 대해 자동으로 내부 썽크(thunk)를 생성합니다. 또한 RTK "매처" 함수도 자동으로 생성하는데, 이 함수는 액션 객체를 받아 특정 조건과 일치하면 true를 반환합니다. 이러한 매처는 startAppListening 내부처럼 액션이 주어진 조건과 일치하는지 확인이 필요한 모든 곳에서 사용할 수 있습니다. 또한 TypeScript 타입 가드 역할도 하여 action 객체의 TS 타입을 좁혀 해당 필드에 안전하게 접근할 수 있게 합니다.

현재 토스트 리스너는 actionCreator: addNewPost.fulfilled로 단일 특정 액션 타입을 감시하고 있습니다. 이를 matcher: apiSlice.endpoints.addNewPost.matchFulfilled로 업데이트하여 포스트 추가가 완료될 때를 감시하도록 변경하겠습니다:

features/posts/postsSlice.ts
import { createEntityAdapter, createSelector, createSlice, EntityState, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'

import type { RootState } from '@/app/store'
import { AppStartListening } from '@/app/listenerMiddleware'
import { createAppAsyncThunk } from '@/app/withTypes'

import { apiSlice } from '@/features/api/apiSlice'
import { logout } from '@/features/auth/authSlice'

// omit types, posts slice, and selectors

export const addPostsListeners = (startAppListening: AppStartListening) => {
startAppListening({
matcher: apiSlice.endpoints.addNewPost.matchFulfilled,
effect: async (action, listenerApi) => {

이제 포스트를 추가할 때 토스트 알림이 정상적으로 다시 표시되어야 합니다.

사용자 데이터 관리

포스트 데이터 관리를 RTK Query로 전환하는 작업을 마쳤습니다. 다음으로 사용자 목록을 전환하겠습니다.

데이터 가져오기와 읽기를 위한 RTK Query 훅 사용법은 이미 확인했으므로, 이번 섹션에서는 다른 접근 방식을 시도해 보겠습니다. Redux Toolkit의 다른 부분과 마찬가지로 RTK Query의 핵심 로직은 UI에 구애받지 않으며 React뿐만 아니라 모든 UI 레이어에서 사용할 수 있습니다.

일반적으로는 createApi가 생성하는 React 훅을 사용해야 합니다. 이 훅들은 많은 작업을 대신 처리해 주기 때문입니다. 하지만 설명을 위해, 여기서는 사용자 데이터를 처리할 때 RTK Query 코어 API만 사용하는 방법을 보여드리겠습니다.

사용자 수동으로 가져오기

현재 usersSlice.ts에서 fetchUsers 비동기 썽크를 정의하고 있으며, 사용자 목록을 최대한 빨리 사용할 수 있도록 main.tsx에서 이 썽크를 수동으로 디스패치하고 있습니다. RTK Query를 사용해 동일한 프로세스를 수행할 수 있습니다.

기존 엔드포인트와 유사하게 apiSlice.tsgetUsers 쿼리 엔드포인트를 정의하는 것부터 시작하겠습니다. 일관성을 위해 useGetUsersQuery 훅을 내보내겠지만, 당장은 사용하지 않을 것입니다.

features/api/apiSlice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

import type { Post, NewPost, PostUpdate } from '@/features/posts/postsSlice'
import type { User } from '@/features/users/usersSlice'

export type { Post }

export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints

getUsers: builder.query<User[], void>({
query: () => '/users'
})
})
})

export const {
useGetPostsQuery,
useGetPostQuery,
useGetUsersQuery,
useAddNewPostMutation,
useEditPostMutation
} = apiSlice

API 슬라이스 객체를 살펴보면, 정의한 각 엔드포인트에 대한 엔드포인트 객체가 포함된 endpoints 필드가 있습니다.

API 슬라이스 엔드포인트 내용

각 엔드포인트 객체에는 다음이 포함됩니다:

  • 루트 API 슬라이스 객체에서 내보낸 것과 동일한 기본 쿼리/뮤테이션 훅(단, useQuery 또는 useMutation으로 명명됨)

  • 쿼리 엔드포인트의 경우 "지연 쿼리(lazy queries)" 또는 부분 구독 같은 시나리오를 위한 추가 쿼리 훅 세트

  • 이 엔드포인트에 대한 요청으로 디스패치된 pending/fulfilled/rejected 액션을 확인하기 위한 "매처" 유틸리티 세트

  • 이 엔드포인트에 대한 요청을 트리거하는 initiate 썽크

  • 이 엔드포인트에 대한 캐시된 결과 데이터와 상태 항목을 검색할 수 있는 메모이즈된 셀렉터를 생성하는 select 함수

React 외부에서 사용자 목록을 가져오려면 인덱스 파일에서 getUsers.initiate() 썽크를 디스패치하면 됩니다:

main.tsx
// omit other imports
import { apiSlice } from './features/api/apiSlice'

async function main() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })

store.dispatch(apiSlice.endpoints.getUsers.initiate())

const root = createRoot(document.getElementById('root')!)

root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
}
main()

이 디스패치는 쿼리 훅 내부에서 자동으로 발생하지만, 필요한 경우 initiate 썽크를 디스패치하여 수동으로 시작할 수도 있습니다.

initiate()에 인자를 전달하지 않은 점에 주목하세요. 이는 getUsers 엔드포인트가 특정 쿼리 인자를 필요로 하지 않기 때문입니다. 개념적으로 이는 "이 캐시 항목의 쿼리 인자가 undefined이다"라고 말하는 것과 같습니다. 만약 인자가 필요했다면 dispatch(apiSlice.endpoints.getPokemon.initiate('pikachu'))처럼 썽크에 인자를 전달했을 것입니다.

여기서는 앱 설정 함수에서 데이터 프리페칭을 시작하기 위해 수동으로 썽크를 디스패치하고 있습니다. 실제로는 React-Router의 "데이터 로더"에서 프리페칭을 수행해 컴포넌트 렌더링 전에 요청을 시작하는 것이 좋습니다. (아이디어는 RTK 저장소의 React-Router 로더 토론 참조)

주의

RTKQ 요청 썽크를 수동으로 디스패치하면 구독 항목이 생성되지만, 이후 데이터 구독을 해제하는 것은 사용자의 책임입니다. 구독을 해제하지 않으면 데이터가 캐시에 영구적으로 남습니다. 이 경우 사용자 데이터가 항상 필요하므로 구독 해제를 생략할 수 있습니다.

사용자 데이터 선택하기

현재 selectAllUsersselectUserById 같은 셀렉터는 createEntityAdapter 사용자 어댑터에서 생성되었으며 state.users에서 데이터를 읽습니다. 페이지를 새로고침하면 state.users 슬라이스에 데이터가 없어 사용자 관련 표시가 모두 깨집니다. 이제 RTK Query 캐시에서 데이터를 가져오므로 해당 셀렉터를 캐시에서 읽는 동등한 셀렉터로 교체해야 합니다.

API 슬라이스 엔드포인트의 endpoint.select() 함수는 호출할 때마다 새로운 메모이즈드 셀렉터 함수를 생성합니다. select()는 캐시 키를 인자로 받으며, 이 키는 쿼리 훅이나 initiate() 썽크에 전달하는 인자와 반드시 동일해야 합니다. 생성된 셀렉터는 이 캐시 키를 사용해 스토어의 캐시 상태에서 반환할 캐시 결과를 정확히 식별합니다.

이 경우 getUsers 엔드포인트는 매개변수가 필요 없습니다—항전체 사용자 목록을 가져옵니다. 따라서 인자 없이 캐시 셀렉터를 생성할 수 있습니다(이는 undefined 캐시 키를 전달하는 것과 동일).

usersSlice.ts를 수정해 실제 usersSlice 호출 대신 RTKQ 쿼리 캐시를 기반으로 셀렉터를 만들 수 있습니다:

features/users/usersSlice.ts
import {
createEntityAdapter,
createSelector,
createSlice
} from '@reduxjs/toolkit'

import { client } from '@/api/client'

import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'

import { apiSlice } from '@/features/api/apiSlice'
import { selectCurrentUsername } from '@/features/auth/authSlice'

export interface User {
id: string
name: string
}

// omit `fetchUsers` and `usersSlice`

const emptyUsers: User[] = []

// Calling `someEndpoint.select(someArg)` generates a new selector that will return
// the query result object for a query with those parameters.
// To generate a selector for a specific query argument, call `select(theQueryArg)`.
// In this case, the users query has no params, so we don't pass anything to select()
export const selectUsersResult = apiSlice.endpoints.getUsers.select()

export const selectAllUsers = createSelector(
selectUsersResult,
usersResult => usersResult?.data ?? emptyUsers
)

export const selectUserById = createSelector(
selectAllUsers,
(state: RootState, userId: string) => userId,
(users, userId) => users.find(user => user.id === userId)
)

export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
if (currentUsername) {
return selectUserById(state, currentUsername)
}
}

/* Temporarily ignore adapter selectors - we'll come back to this later
export const { selectAll: selectAllUsers, selectById: selectUserById } = usersAdapter.getSelectors(
(state: RootState) => state.users,
)
*/

먼저 올바른 캐시 항목을 검색하는 방법을 아는 특정 selectUsersResult 셀렉터 인스턴스를 생성합니다.

초기 selectUsersResult 셀렉터가 있으면 기존 selectAllUsers 셀렉터를 캐시 결과에서 사용자 배열을 반환하는 셀렉터로 교체할 수 있습니다. 유효한 결과가 아직 없을 수 있으므로 emptyUsers 배열로 대체합니다. 또한 selectUserById를 해당 배열에서 올바른 사용자를 찾는 셀렉터로 교체합니다.

지금은 usersAdapter의 해당 셀렉터를 주석 처리하겠습니다—나중에 다시 사용하도록 변경할 예정입니다.

컴포넌트는 이미 selectAllUsers, selectUserById, selectCurrentUser를 임포트하고 있으므로 이 변경 사항은 즉시 적용됩니다! 페이지를 새로고침하고 게시글 목록/상세 보기를 클릭해보세요. 각 게시글과 <AddPostForm>의 드롭다운에 올바른 사용자 이름이 표시됩니다.

이것이 셀렉터 사용이 코드 유지보수성을 높이는 훌륭한 예시입니다! 컴포넌트는 이미 이 셀렉터를 호출하고 있으므로, 데이터가 기존 usersSlice 상태에서 오든 RTK Query 캐시 항목에서 오든 상관하지 않습니다. 셀렉터 구현을 변경했음에도 UI 컴포넌트를 전혀 수정하지 않아도 됐습니다.

usersSlice 상태가 전혀 사용되지 않으므로 const usersSlice = createSlice() 호출과 fetchUsers 썽크를 파일에서 삭제하고 스토어 설정에서 users: usersReducer를 제거해도 됩니다. 아직 postsSlice를 참조하는 코드가 일부 남아 있으므로 이를 완전히 제거할 수는 없습니다. 이 부분은 곧 다루겠습니다.

엔드포인트 분할 및 주입

지금까지 RTK Query는 일반적으로 애플리케이션당 단일 "API 슬라이스"를 가진다고 설명했습니다. 그리고 현재까지 모든 엔드포인트를 apiSlice.ts에 직접 정의했습니다. 하지만 규모가 큰 애플리케이션에서는 기능을 별도의 번들로 "코드 분할"하고, 해당 기능이 처음 사용될 때 "지연 로딩"하는 것이 일반적입니다. 일부 엔드포인트 정의를 코드 분할하거나 API 슬라이스 파일이 너무 커지는 것을 방지하기 위해 다른 파일로 옮기려면 어떻게 해야 할까요?

RTK Query는 apiSlice.injectEndpoints()로 엔드포인트 정의를 분할할 수 있습니다. 이를 통해 단일 API 슬라이스 인스턴스와 단일 미들웨어, 캐시 리듀서를 유지하면서 일부 엔드포인트 정의를 다른 파일로 이동할 수 있습니다. 이는 코드 분할 시나리오를 가능하게 하며, 필요에 따라 기능 폴더와 함께 일부 엔드포인트를 배치할 수 있습니다.

이 과정을 설명하기 위해 getUsers 엔드포인트를 apiSlice.ts가 아닌 usersSlice.ts에 주입하도록 변경해 보겠습니다.

이미 getUsers 엔드포인트에 접근하기 위해 apiSliceusersSlice.ts로 가져오고 있으므로, 여기서 apiSlice.injectEndpoints()를 호출하도록 전환할 수 있습니다.

features/users/usersSlice.ts
import { apiSlice } from '../api/apiSlice'

// This is the _same_ reference as `apiSlice`, but this has
// the TS types updated to include the injected endpoints
export const apiSliceWithUsers = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query<User[], void>({
query: () => '/users'
})
})
})

export const { useGetUsersQuery } = apiSliceWithUsers

export const selectUsersResult = apiSliceWithUsers.endpoints.getUsers.select()

injectEndpoints()추가 엔드포인트 정의를 포함하도록 원본 API 슬라이스 객체를 변경한 후 동일한 API 참조를 반환합니다. 또한 injectEndpoints의 반환값에는 주입된 엔드포인트의 추가 TS 타입이 포함됩니다.

따라서 업데이트된 TS 타입을 사용하고 모든 것이 올바르게 컴파일되도록 하기 위해 이를 새 변수에 다른 이름으로 저장해야 합니다. 여기서는 원본 apiSlice와 구분하기 위해 apiSliceWithUsers라고 부르겠습니다.

현재 getUsers 엔드포인트를 참조하는 유일한 파일은 진입점 파일로, 여기서 initiate 썽크를 디스패치합니다. 이를 업데이트하려면 확장된 API 슬라이스를 가져와야 합니다:

main.tsx
import { apiSliceWithUsers } from './features/users/usersSlice'

import { worker } from './api/server'

import './index.css'

// Wrap app rendering so we can wait for the mock API to initialize
async function start() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })

store.dispatch(apiSliceWithUsers.endpoints.getUsers.initiate())

const root = createRoot(document.getElementById('root')!)

root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
}

또는 슬라이스에서 액션 생성자를 했던 것처럼 엔드포인트 자체를 별도로 내보낼 수도 있습니다.

응답 데이터 조작

지금까지 모든 쿼리 엔드포인트는 서버의 응답 데이터를 그대로 캐시에 저장했습니다. getPostsgetUsers는 서버가 배열을 반환할 것으로 기대하며, getPost는 개별 Post 객체를 본문으로 기대합니다.

클라이언트는 서버 응답에서 데이터 일부를 추출하거나 캐싱 전에 데이터를 변환해야 하는 경우가 많습니다. 예를 들어 /getPost 요청이 {post: {id}}처럼 데이터가 중첩된 본문을 반환한다면 어떻게 해야 할까요?

개념적으로 처리할 수 있는 몇 가지 방법이 있습니다. 한 가지 옵션은 responseData.post 필드를 추출하여 전체 본문 대신 캐시에 저장하는 것입니다. 다른 방법은 전체 응답 데이터를 캐시에 저장하지만 컴포넌트가 필요한 특정 부분만 지정하도록 하는 것입니다.

응답 변환

엔드포인트는 캐시되기 전에 서버에서 받은 데이터를 추출하거나 수정할 수 있는 transformResponse 핸들러를 정의할 수 있습니다. 예를 들어 getPost{post: {id}}를 반환하는 경우 transformResponse: (responseData) => responseData.post로 설정하면 전체 응답 본문 대신 실제 Post 객체만 캐시합니다.

6부: 성능 및 정규화에서는 데이터를 정규화된 구조로 저장하는 것이 유용한 이유에 대해 논의했습니다. 특히, 배열을 순회하며 올바른 항목을 찾는 대신 ID를 기반으로 항목을 조회하고 업데이트할 수 있게 해줍니다.

현재 selectUserById 셀렉터는 올바른 User 객체를 찾기 위해 캐시된 사용자 배열을 순회해야 합니다. 만약 응답 데이터를 정규화된 방식으로 저장하도록 변환한다면, ID로 직접 사용자를 찾도록 단순화할 수 있습니다.

이전에는 정규화된 사용자 데이터를 관리하기 위해 usersSlice에서 createEntityAdapter를 사용했습니다. createEntityAdapterextendedApiSlice에 통합하고, 실제로 데이터가 캐시되기 전에 createEntityAdapter를 사용하여 데이터를 변환할 수 있습니다. 원래 있던 usersAdapter 라인들의 주석을 해제하고, 해당 업데이트 함수와 셀렉터를 다시 사용하겠습니다.

features/users/usersSlice.ts
import {
createSelector,
createEntityAdapter,
EntityState
} from '@reduxjs/toolkit'

import type { RootState } from '@/app/store'

import { apiSlice } from '@/features/api/apiSlice'
import { selectCurrentUsername } from '@/features/auth/authSlice'

export interface User {
id: string
name: string
}

const usersAdapter = createEntityAdapter<User>()
const initialState = usersAdapter.getInitialState()

// This is the _same_ reference as `apiSlice`, but this has
// the TS types updated to include the injected endpoints
export const apiSliceWithUsers = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query<EntityState<User, string>, void>({
query: () => '/users',
transformResponse(res: User[]) {
// Create a normalized state object containing all the user items
return usersAdapter.setAll(initialState, res)
}
})
})
})

export const { useGetUsersQuery } = apiSliceWithUsers

// Calling `someEndpoint.select(someArg)` generates a new selector that will return
// the query result object for a query with those parameters.
// To generate a selector for a specific query argument, call `select(theQueryArg)`.
// In this case, the users query has no params, so we don't pass anything to select()
export const selectUsersResult = apiSliceWithUsers.endpoints.getUsers.select()
const selectUsersData = createSelector(
selectUsersResult,
// Fall back to the empty entity state if no response yet.
result => result.data ?? initialState
)

export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
if (currentUsername) {
return selectUserById(state, currentUsername)
}
}

export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors(selectUsersData)

getUsers 엔드포인트에 transformResponse 옵션을 추가했습니다. 이 옵션은 전체 응답 데이터 본문을 인자로 받습니다(이 경우 User[] 배열). 그리고 캐시될 실제 데이터를 반환해야 합니다. usersAdapter.setAll(initialState, responseData)를 호출하면, 수신된 모든 항목을 포함하는 표준 {ids: [], entities: {}} 정규화된 데이터 구조를 반환합니다. 이제 캐시 항목의 data 필드의 실제 내용으로 EntityState<User, string> 데이터를 반환한다고 TS에 알려야 합니다.

adapter.getSelectors() 함수는 정규화된 데이터를 찾을 위치를 알 수 있도록 "입력 셀렉터"를 제공받아야 합니다. 이 경우 데이터는 RTK Query 캐시 리듀서 내부에 중첩되어 있으므로, 캐시 상태에서 올바른 필드를 선택합니다. 일관성을 유지하기 위해, 아직 데이터를 가져오지 않았다면 초기 빈 정규화 상태로 대체하는 selectUsersData 셀렉터를 작성할 수 있습니다.

정규화 캐시 대 문서 캐시

지금까지 한 작업과 그 중요성에 대해 잠시 돌아보는 것이 좋겠습니다.

Apollo와 같은 다른 데이터 페칭 라이브러리에서 "정규화 캐시"라는 용어를 들어본 적이 있을 것입니다. RTK Query는 "정규화 캐시"가 아닌 "문서 캐시" 접근 방식을 사용한다는 점을 이해하는 것이 중요합니다.

완전한 정규화 캐시는 항목 유형과 ID를 기반으로 모든 쿼리에서 유사한 항목을 중복 제거하려고 합니다. 예를 들어, getTodosgetTodo 엔드포인트가 있는 API 슬라이스가 있고, 우리 컴포넌트가 다음과 같은 쿼리를 만든다고 가정해 봅시다:

  • getTodos()

  • getTodos({filter: 'odd'})

  • getTodo({id: 1})

이러한 각 쿼리 결과에는 {id: 1}과 같은 Todo 객체가 포함될 것입니다.

완전한 정규화 중복 제거 캐시에서는 이 Todo 객체의 단일 복사본만 저장됩니다. 그러나 RTK Query는 각 쿼리 결과를 캐시에 독립적으로 저장합니다. 따라서 Redux 스토어에는 이 Todo의 세 개의 별도 복사본이 캐시됩니다. 그러나 모든 엔드포인트가 일관되게 동일한 태그(예: {type: 'Todo', id: 1})를 제공한다면, 해당 태그를 무효화하면 일치하는 모든 엔드포인트가 일관성을 위해 데이터를 다시 가져오도록 강제합니다.

RTK Query는 의도적으로 여러 요청에서 동일한 항목을 중복 제거하는 캐시를 구현하지 않습니다. 이에는 몇 가지 이유가 있습니다:

  • 완전한 정규화된 쿼리 간 공유 캐시는 해결하기 어려운 문제입니다

  • 현재로서는 이를 해결하려는 시간, 자원 또는 관심이 없습니다

  • 대부분의 경우, 데이터가 무효화될 때 단순히 다시 가져오는 것이 잘 작동하고 이해하기 쉽습니다

  • RTKQ의 주요 목표는 많은 사람들에게 큰 고통 지점인 "일부 데이터 가져오기"라는 일반적인 사용 사례를 해결하는 데 도움을 주는 것입니다

이 경우 getUsers 엔드포인트에 대한 응답 데이터를 {[id]: value} 형태의 룩업 테이블로 저장하여 정규화했습니다. 하지만 이는 '정규화된 캐시'와 동일하지 않습니다 - 여러 엔드포인트나 요청 간 결과를 중복 제거하기보다는 단일 응답 저장 방식만 변환한 것입니다.

결과에서 값 선택하기

기존 postsSlice에서 데이터를 읽는 마지막 컴포넌트는 현재 사용자 기반으로 포스트 목록을 필터링하는 <UserPage>입니다. 이미 useGetPostsQuery()로 전체 포스트 목록을 가져온 후 useMemo 내부에서 정렬하는 등 컴포넌트 내에서 변환하는 방법을 살펴보았습니다. 쿼리 훅은 selectFromResult 옵션을 제공하여 캐시된 상태의 일부를 선택하고, 선택된 조각이 변경될 때만 리렌더링할 수 있는 기능도 제공합니다.

useQuery 훅은 항상 캐시 키 인수를 첫 번째 매개변수로 받으며, 훅 옵션을 제공해야 할 경우 두 번째 매개변수로 전달해야 합니다(예: useSomeQuery(cacheKey, options)). getUsers 엔드포인트에는 실제 캐시 키 인수가 없습니다. 의미상으로 이는 undefined 캐시 키와 동일합니다. 따라서 훅에 옵션을 제공하려면 useGetUsersQuery(undefined, options)를 호출해야 합니다.

selectFromResult를 사용해 <UserPage>가 캐시에서 필터링된 포스트 목록만 읽도록 할 수 있습니다. 하지만 selectFromResult가 불필요한 리렌더링을 방지하려면 추출한 데이터가 올바르게 메모이제이션되도록 보장해야 합니다. 이를 위해 <UserPage> 컴포넌트가 렌더링할 때마다 재사용할 수 있는 새 셀렉터 인스턴스를 생성해야 합니다. 이렇게 하면 셀렉터가 입력 기반으로 결과를 메모이제이션합니다.

features/users/UserPage.tsx
import { Link, useParams } from 'react-router-dom'
import { createSelector } from '@reduxjs/toolkit'
import type { TypedUseQueryStateResult } from '@reduxjs/toolkit/query/react'

import { useAppSelector } from '@/app/hooks'

import { useGetPostsQuery, Post } from '@/features/api/apiSlice'

import { selectUserById } from './usersSlice'

// Create a TS type that represents "the result value passed
// into the `selectFromResult` function for this hook"
type GetPostSelectFromResultArg = TypedUseQueryStateResult<Post[], any, any>

const selectPostsForUser = createSelector(
(res: GetPostSelectFromResultArg) => res.data,
(res: GetPostSelectFromResultArg, userId: string) => userId,
(data, userId) => data?.filter(post => post.user === userId)
)

export const UserPage = () => {
const { userId } = useParams()

const user = useAppSelector(state => selectUserById(state, userId!))

// Use the same posts query, but extract only part of its data
const { postsForUser } = useGetPostsQuery(undefined, {
selectFromResult: result => ({
// Optional: Include all of the existing result fields like `isFetching`
...result,
// Include a field called `postsForUser` in the result object,
// which will be a filtered list of posts
postsForUser: selectPostsForUser(result, userId!)
})
})

// omit rendering logic
}

여기서 생성한 메모이제이션된 셀렉터 함수에는 중요한 차이점이 있습니다. 일반적으로 셀렉터는 전체 Redux state를 첫 번째 인자로 기대하며, state에서 값을 추출하거나 파생합니다. 하지만 이 경우 캐시에 보관된 'result' 값만 다룹니다. result 객체에는 필요한 실제 값이 있는 data 필드와 일부 요청 메타데이터 필드가 포함됩니다.

이 셀렉터가 일반적인 RootState 타입이 아닌 다른 것을 첫 번째 인자로 받기 때문에, TS에 해당 결과 값의 형태를 알려야 합니다. RTK Query 패키지는 useQuery 훅 반환 객체의 타입을 나타내는 TypedUseQueryStateResult TS 타입을 내보냅니다. 이를 사용해 결과에 Post[] 배열이 포함될 것으로 선언한 후 해당 타입을 사용해 셀렉터를 정의할 수 있습니다.

셀렉터와 다양한 인자 메모이제이션

RTK 2.x 및 Reselect 5.x 기준으로 메모이제이션된 셀렉터는 무한 캐시 크기를 가지므로 인자를 변경해도 이전 메모이제이션 결과를 계속 사용할 수 있습니다. RTK 1.x 또는 Reselect 4.x를 사용 중이라면 메모이제이션된 셀렉터의 기본 캐시 크기가 1임에 유의하세요. ID와 같은 다른 인자를 전달할 때 일관되게 메모이제이션되도록 보장하려면 컴포넌트별 고유 셀렉터 인스턴스를 생성해야 합니다.

selectFromResult 콜백은 원본 요청 메타데이터와 서버의 data를 포함하는 result 객체를 받으며, 추출되거나 파생된 일부 값을 반환해야 합니다. 쿼리 훅은 여기서 반환되는 객체에 추가로 refetch 메서드를 붙이므로, selectFromResult는 항상 내부에 필요한 필드가 포함된 객체를 반환해야 합니다.

result는 Redux 스토어에 보관되므로 변조할 수 없습니다—새 객체를 반환해야 합니다. 쿼리 훅은 반환된 객체에 대해 '얕은(shallow)' 비교를 수행하며, 필드 중 하나가 변경된 경우에만 컴포넌트를 다시 렌더링합니다. 이 컴포넌트에 필요한 특정 필드만 반환해 리렌더링을 최적화할 수 있습니다—다른 메타데이터 플래그가 필요 없다면 완전히 생략해도 됩니다. 필요한 경우 원본 result 값을 펼쳐 출력에 포함시킬 수 있습니다.

이 경우 postsForUser라는 필드를 호출할 것이며, 훅 결과에서 이 새 필드를 구조 분해할 수 있습니다. 매번 selectPostsForUser(result, userId)를 호출하면 필터링된 배열을 메모이제이션하고 가져온 데이터나 사용자 ID가 변경될 때만 재계산합니다.

변환 방식 비교

지금까지 응답 데이터를 변환하는 세 가지 다른 방식을 살펴보았습니다:

  • 캐시에 원본 응답을 유지하고 컴포넌트에서 전체 결과를 읽어 파생 값 계산

  • 캐시에 원본 응답을 유지하고 selectFromResult로 파생 결과 읽기

  • 캐시 저장 전에 응답 변환

이러한 접근 방식은 각각 다른 상황에서 유용합니다. 사용을 고려해야 할 시나리오는 다음과 같습니다:

  • transformResponse: 엔드포인트의 모든 소비자가 ID별 빠른 조회를 가능하게 하는 정규화 등 특정 포맷을 원할 때

  • selectFromResult: 엔드포인트 일부 소비자가 필터링된 목록 등 부분 데이터만 필요할 때

  • 컴포넌트별 / useMemo: 특정 컴포넌트만 캐시 데이터 변환이 필요할 때

고급 캐시 업데이트

게시물과 사용자 데이터 업데이트를 완료했으므로 남은 작업은 반응(reactions)과 알림(notifications)입니다. 이를 RTK Query로 전환하면 RTK Query 캐시 데이터 작업을 위한 고급 기법을 시도해볼 수 있으며 사용자 경험을 개선할 수 있습니다.

반응 지속성 추가

원래는 반응을 클라이언트 측에서만 추적하고 서버에 저장하지 않았습니다. 새 addReaction 뮤테이션을 추가하여 사용자가 반응 버튼을 클릭할 때마다 해당 Post를 서버에서 업데이트해보겠습니다.

features/api/apiSlice.ts
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints
addReaction: builder.mutation<
Post,
{ postId: string; reaction: ReactionName }
>({
query: ({ postId, reaction }) => ({
url: `posts/${postId}/reactions`,
method: 'POST',
// In a real app, we'd probably need to base this on user ID somehow
// so that a user can't do the same reaction more than once
body: { reaction }
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Post', id: arg.postId }
]
})
})
})

export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useEditPostMutation,
useAddReactionMutation
} = apiSlice

다른 뮤테이션과 유사하게 매개변수를 받아 서버에 요청하며 요청 본문에 데이터를 포함합니다. 이 예제 앱은 규모가 작으므로 반응 이름만 전달하고 서버에서 해당 게시물의 반응 유형 카운터를 증가시키도록 합니다.

클라이언트에서 데이터 변경을 보려면 이 게시물을 다시 가져와야 하므로 ID를 기반으로 특정 Post 항목을 무효화할 수 있습니다.

이제 <ReactionButtons>가 이 뮤테이션을 사용하도록 업데이트해보겠습니다.

features/posts/ReactionButtons.tsx
import { useAddReactionMutation } from '@/features/api/apiSlice'

import type { Post, ReactionName } from './postsSlice'

const reactionEmoji: Record<ReactionName, string> = {
thumbsUp: '👍',
tada: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}

interface ReactionButtonsProps {
post: Post
}

export const ReactionButtons = ({ post }: ReactionButtonsProps) => {
const [addReaction] = useAddReactionMutation()

const reactionButtons = Object.entries(reactionEmoji).map(
([stringName, emoji]) => {
// Ensure TS knows this is a _specific_ string type
const reaction = stringName as ReactionName
return (
<button
key={reaction}
type="button"
className="muted-button reaction-button"
onClick={() => {
addReaction({ postId: post.id, reaction })
}}
>
{emoji} {post.reactions[reaction]}
</button>
)
}
)

return <div>{reactionButtons}</div>
}

실제 동작을 확인해보세요! 메인 <PostsList>로 이동해 반응 중 하나를 클릭하면 어떻게 되는지 확인합니다.

게시물 목록 가져오기 동안 비활성화

문제가 발생했습니다. 하나의 게시물 업데이트에 대한 응답으로 전체 게시물 목록을 다시 가져왔기 때문에 전체 <PostsList> 컴포넌트가 회색으로 처리되었습니다. 모의 API 서버가 응답 전 2초 지연되도록 설정되어 있어 의도적으로 더 눈에 띄지만, 응답이 더 빨라져도 이는 좋은 사용자 경험이 아닙니다.

반응에 대한 낙관적 업데이트

반응 추가와 같은 작은 업데이트의 경우 전체 게시물 목록을 다시 가져올 필요가 없습니다. 대신 서버에서 발생할 것으로 예상되는 내용과 일치하도록 이미 캐시된 클라이언트 데이터를 업데이트할 수 있습니다. 또한 캐시를 즉시 업데이트하면 사용자가 버튼을 클릭할 때 응답을 기다릴 필요 없이 즉각적인 피드백을 받을 수 있습니다. 클라이언트 상태를 즉시 업데이트하는 이 접근 방식을 "낙관적 업데이트(optimistic update)" 라 하며 웹 앱에서 흔히 사용되는 패턴입니다.

RTK Query에는 클라이언트 측 캐시를 직접 업데이트하는 유틸리티가 포함되어 있습니다. 이는 RTK Query의 "요청 생명주기" 메서드와 결합해 낙관적 업데이트를 구현할 수 있습니다.

캐시 업데이트 유틸리티

API 슬라이스에는 추가 메서드가 api.util 아래에 연결되어 있습니다. 여기에는 캐시 수정을 위한 썽크(thunk)가 포함됩니다: 캐시 항목을 추가하거나 교체하는 upsertQueryData, 캐시 항목을 수정하는 updateQueryData. 이들은 썽크이므로 dispatch에 접근할 수 있는 모든 곳에서 사용할 수 있습니다.

특히 updateQueryData 유틸리티 썽크는 세 가지 인수를 받습니다: 업데이트할 엔드포인트 이름, 특정 캐시 항목을 식별하는 데 사용되는 캐시 키 인수, 캐시 데이터를 업데이트하는 콜백 함수입니다. updateQueryData는 Immer를 사용하므로 createSlice에서와 동일하게 초안 캐시 데이터를 '변경(mutate)'할 수 있습니다:

updateQueryData example
dispatch(
apiSlice.util.updateQueryData(endpointName, queryArg, draft => {
// mutate `draft` here like you would in a reducer
draft.value = 123
})
)

updateQueryData는 변경 사항의 패치 차이(patch diff)를 담은 액션 객체를 생성합니다. 이 액션을 dispatch하면, 반환되는 값은 patchResult 객체입니다. patchResult.undo()를 호출하면 패치 차이 변경 사항을 되돌리는 액션이 자동으로 디스패치됩니다.

onQueryStarted 라이프사이클

첫 번째로 살펴볼 라이프사이클 메서드는 onQueryStarted입니다. 이 옵션은 쿼리와 뮤테이션 모두에서 사용할 수 있습니다.

제공된 경우 onQueryStarted는 새 요청이 발생할 때마다 호출되어 요청에 대한 추가 로직을 실행할 수 있는 기회를 제공합니다.

비동기 썽크와 리스너 효과와 유사하게 onQueryStarted 콜백은 첫 번째 인수로 요청의 쿼리 arg 값을, 두 번째 인수로 lifecycleApi 객체를 받습니다. lifecycleApicreateAsyncThunk와 동일한 {dispatch, getState, extra, requestId} 값을 포함하며, 여기에 추가로 고유한 필드가 있습니다. 가장 중요한 것은 요청이 반환될 때 해결(resolve)되는 lifecycleApi.queryFulfilled Promise입니다.

낙관적 업데이트 구현하기

onQueryStarted 라이프사이클 내부에서 업데이트 유틸리티를 사용하면 요청 완료 전에 캐시를 업데이트하는 "낙관적" 업데이트나 요청 완료 후 업데이트하는 "비관적" 업데이트를 구현할 수 있습니다.

getPosts 캐시에서 특정 Post 항목을 찾아 반응(reaction) 카운터를 증가시키도록 "변경"하여 낙관적 업데이트를 구현할 수 있습니다. 동일한 포스트 ID에 대한 getPost 캐시에 개별 Post 객체 사본이 존재할 경우 해당 캐시 항목도 함께 업데이트해야 합니다.

기본적으로 요청이 성공할 것으로 가정합니다. 요청 실패 시 await lifecycleApi.queryFulfilled으로 실패를 포착(catch)한 후 패치 변경 사항을 취소(undo)하여 낙관적 업데이트를 되돌릴 수 있습니다.

features/api/apiSlice.ts
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints

addReaction: builder.mutation<
Post,
{ postId: string; reaction: ReactionName }
>({
query: ({ postId, reaction }) => ({
url: `posts/${postId}/reactions`,
method: 'POST',
// In a real app, we'd probably need to base this on user ID somehow
// so that a user can't do the same reaction more than once
body: { reaction }
}),
// The `invalidatesTags` line has been removed,
// since we're now doing optimistic updates
async onQueryStarted({ postId, reaction }, lifecycleApi) {
// `updateQueryData` requires the endpoint name and cache key arguments,
// so it knows which piece of cache state to update
const getPostsPatchResult = lifecycleApi.dispatch(
apiSlice.util.updateQueryData('getPosts', undefined, draft => {
// The `draft` is Immer-wrapped and can be "mutated" like in createSlice
const post = draft.find(post => post.id === postId)
if (post) {
post.reactions[reaction]++
}
})
)

// We also have another copy of the same data in the `getPost` cache
// entry for this post ID, so we need to update that as well
const getPostPatchResult = lifecycleApi.dispatch(
apiSlice.util.updateQueryData('getPost', postId, draft => {
draft.reactions[reaction]++
})
)

try {
await lifecycleApi.queryFulfilled
} catch {
getPostsPatchResult.undo()
getPostPatchResult.undo()
}
}
})
})
})

이 경우 반응(reaction) 버튼 클릭 시 포스트를 재요청하지 않도록 방금 추가한 invalidatesTags 줄을 제거했습니다.

이제 반응 버튼을 빠르게 여러 번 클릭하면 UI에서 숫자가 매번 증가하는 것을 볼 수 있으며, 네트워크 탭에서 각 개별 요청이 서버로 전송되는 것도 확인할 수 있습니다.

때로는 뮤테이션 요청이 서버 응답으로 의미 있는 데이터(예: 임시 클라이언트 측 ID를 대체할 최종 항목 ID)를 반환합니다. const res = await lifecycleApi.queryFulfilled를 먼저 수행하면 응답 데이터를 사용하여 "비관적" 업데이트로 캐시를 적용할 수 있습니다.

알림을 위한 스트리밍 업데이트

마지막 기능은 알림 탭입니다. 6편에서 이 기능을 처음 구현할 때 "실제 앱에서는 무언가 발생할 때마다 서버가 클라이언트에 업데이트를 푸시할 것"이라고 설명했습니다. 당시에는 "알림 새로고침" 버튼을 추가하고 HTTP GET 요청으로 더 많은 알림 항목을 가져오는 방식으로 이 기능을 모방했습니다.

앱이 서버에서 데이터를 가져오기 위해 초기 요청을 보낸 후, 지속적인 업데이트를 수신하기 위해 Websocket 연결을 여는 것은 일반적입니다. RTK Query의 라이프사이클 메서드는 이러한 "스트리밍 업데이트"를 캐시 데이터에 구현할 수 있는 여지를 제공합니다.

우리는 이미 낙관적(또는 비관적) 업데이트를 구현할 수 있게 해주는 onQueryStarted 라이프사이클을 살펴보았습니다. 추가적으로, RTK Query는 onCacheEntryAdded 엔드포인트 라이프사이클 핸들러를 제공하며, 이는 스트리밍 업데이트를 구현하기에 이상적인 장소입니다. 이 기능을 활용해 알림 관리를 더 현실적으로 구현해 보겠습니다.

onCacheEntryAdded 라이프사이클

onQueryStarted와 마찬가지로 onCacheEntryAdded 라이프사이클 메서드는 쿼리와 뮤테이션 모두에서 사용할 수 있습니다.

onCacheEntryAdded는 새로운 캐시 항목(엔드포인트 + 직렬화된 쿼리 인자)이 캐시에 추가될 때마다 호출됩니다. 이는 요청이 발생할 때마다 실행되는 onQueryStarted보다 덜 빈번하게 실행됨을 의미합니다.

onQueryStarted와 유사하게 onCacheEntryAdded는 두 가지 매개변수를 받습니다. 첫 번째는 일반적인 쿼리 args 값입니다. 두 번째는 약간 다른 lifecycleApi{dispatch, getState, extra, requestId}를 포함하며, updateCachedData 유틸리티도 함께 제공됩니다. 이는 api.util.updateQueryData의 변형 형태로, 사용할 올바른 엔드포인트 이름과 쿼리 인자를 이미 인지하고 있으며 디스패치 작업도 대신 처리해 줍니다.

또한 기다릴 수 있는 두 가지 추가 Promise가 있습니다:

  • cacheDataLoaded: 수신된 첫 번째 캐시 값으로 해결(resolve)되며, 일반적으로 실제 값이 캐시에 존재하는지 확인한 후 추가 로직을 수행하는 데 사용됩니다

  • cacheEntryRemoved : 캐시 항목이 제거될 때 해결됩니다(즉, 더 이상 구독자가 없고 캐시 항목이 가비지 컬렉션된 경우)

데이터에 대한 구독자가 1명 이상 활성 상태인 동안 캐시 항목은 유지됩니다. 구독자 수가 0이 되고 캐시 수명 타이머가 만료되면 캐시 항목이 제거되며 cacheEntryRemoved가 해결됩니다. 일반적인 사용 패턴은 다음과 같습니다:

  • 즉시 await cacheDataLoaded

  • Websocket 같은 서버 측 데이터 구독 생성

  • 업데이트 수신 시 updateCachedData로 캐시 값 "변경(mutate)"

  • 마지막에 await cacheEntryRemoved

  • 이후 구독 정리

이로 인해 onCacheEntryAdded는 UI가 특정 데이터를 필요로 하는 동안 계속 실행해야 하는 장기 실행 로직을 배치하기에 적합합니다. 채팅 앱이 채팅 채널의 초기 메시지를 가져오고, 시간 경과에 따라 추가 메시지를 수신하기 위해 Websocket 구독을 사용하며, 사용자가 채널을 닫을 때 Websocket 연결을 해제하는 경우가 대표적인 예시입니다.

알림 가져오기

이 작업을 몇 단계로 나누어 진행해야 합니다.

먼저 알림을 위한 새 엔드포인트를 설정하고, HTTP 요청 대신 Websocket을 통해 알림을 전송하도록 모의 백엔드를 트리거하는 fetchNotificationsWebsocket 썽크(thunk)의 대체물을 추가합니다.

getUsers에서 했던 것처럼 notificationsSlicegetNotifications 엔드포인트를 주입할 것입니다. 이는 가능함을 보여주기 위함입니다.

features/notifications/notificationsSlices.ts
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'

import { client } from '@/api/client'
import { forceGenerateNotifications } from '@/api/server'

import type { AppThunk, RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'

import { apiSlice } from '@/features/api/apiSlice'

// omit types and `fetchNotifications` thunk

export const apiSliceWithNotifications = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query<ServerNotification[], void>({
query: () => '/notifications'
})
})
})

export const { useGetNotificationsQuery } = apiSliceWithNotifications

getNotifications는 서버에서 수신한 ServerNotification 객체를 저장할 표준 쿼리 엔드포인트입니다.

그런 다음 <Navbar>에서 새로운 쿼리 훅을 사용하여 알림을 자동으로 가져올 수 있습니다. 이 경우 ServerNotification 객체만 반환되며, 우리가 추가했던 {read, isNew} 필드를 포함한 ClientNotification 객체는 반환되지 않습니다. 따라서 일시적으로 notification.new 확인을 비활성화해야 합니다:

features/notifications/NotificationsList.tsx
// omit other imports

import { allNotificationsRead, useGetNotificationsQuery } from './notificationsSlice'

export const NotificationsList = () => {
const dispatch = useAppDispatch()
const { data: notifications = [] } = useGetNotificationsQuery()

useLayoutEffect(() => {
dispatch(allNotificationsRead())
})

const renderedNotifications = notifications.map((notification) => {
const notificationClassname = classnames('notification', {
// new: notification.isNew,
})
}

// omit rendering
}

"Notifications" 탭으로 이동하면 몇 개의 항목이 표시되지만 새 알림임을 나타내는 색상은 적용되지 않습니다. 한편 "Refresh Notifications" 버튼을 클릭하면 "읽지 않은 알림" 카운터가 계속 증가합니다. 이는 두 가지 이유 때문입니다. 버튼은 여전히 state.notifications 슬라이스에 항목을 저장하는 원래 fetchNotifications thunk를 트리거하고 있습니다. 또한 <NotificationsList> 컴포넌트는 재렌더링되지 않습니다(이 컴포넌트는 state.notifications 슬라이스가 아닌 useGetNotificationsQuery 훅의 캐시 데이터에 의존함). 따라서 useLayoutEffect가 실행되지 않으며 allNotificationsRead가 디스패치되지 않습니다.

클라이언트 측 상태 추적

다음 단계는 알림의 "읽음" 상태를 추적하는 방식을 재고하는 것입니다.

이전에는 fetchNotifications thunk에서 가져온 ServerNotification 객체를 리듀서에서 {read, isNew} 필드를 추가해 저장했습니다. 이제는 ServerNotification 객체를 RTK Query 캐시에 저장하고 있습니다.

수동 캐시 업데이트를 더 수행할 수 있습니다. transformResponse를 사용해 추가 필드를 포함시킨 다음, 사용자가 알림을 볼 때 캐시 자체를 수정하는 작업을 할 수 있습니다.

대신, 우리가 이미 해오던 방식의 다른 형태를 시도해 보겠습니다: notificationsSlice 내부에서 읽음 상태를 추적하는 것입니다.

개념적으로 우리가 진정으로 원하는 것은 각 알림 항목의 {read, isNew} 상태를 추적하는 것입니다. 쿼리 훅이 알림을 가져온 시점을 알고 알림 ID에 접근할 수 있다면, 슬라이스에서 이 상태를 추적하고 수신한 각 알림에 대한 "메타데이터" 항목을 유지할 수 있습니다.

다행히도 가능합니다! RTK Query는 createAsyncThunk 같은 표준 Redux Toolkit 조각으로 구성되어 있어 요청이 완료될 때마다 결과와 함께 fulfilled 액션을 디스패치합니다. notificationsSlice에서 이를 수신하기만 하면 되며, createSlice.extraReducers가 바로 그 액션을 처리할 위치임을 알고 있습니다.

그런데 무엇을 수신할까요? 이는 RTKQ 엔드포인트이므로 asyncThunk.fulfilled/pending 액션 생성자에 접근할 수 없어 builder.addCase()에 전달할 수 없습니다.

RTK Query 엔드포인트는 matchFulfilled 매처 함수를 노출하므로, extraReducers 내부에서 이 함수를 사용해 해당 엔드포인트의 fulfilled 액션을 수신할 수 있습니다. (builder.addCase() 대신 builder.addMatcher()를 사용해야 함에 유의하세요).

따라서 ClientNotification을 새로운 NotificationMetadata 유형으로 변경하고, getNotifications 쿼리 액션을 수신하며, 슬라이스에 전체 알림 대신 "메타데이터만" 객체를 저장하도록 합니다.

이 과정에서 notificationsAdaptermetadataAdapter로 이름을 바꾸고, 명확성을 위해 모든 notification 변수를 metadata로 교체합니다. 변경 사항이 많아 보이지만 대부분 변수명 변경입니다.

또한 엔터티 어댑터 selectEntities 셀렉터를 selectMetadataEntities로 내보냅니다. UI에서 ID별로 이 메타데이터 객체를 조회해야 하며, 컴포넌트에서 조회 테이블을 사용할 수 있으면 더 쉬워집니다.

features/notifications/notificationsSlice.ts
// omit imports and thunks

// Replaces `ClientNotification`, since we just need these fields
export interface NotificationMetadata {
// Add an `id` field, since this is now a standalone object
id: string
read: boolean
isNew: boolean
}

export const fetchNotifications = createAppAsyncThunk(
'notifications/fetchNotifications',
async (_unused, thunkApi) => {
// Deleted timestamp lookups - we're about to remove this thunk anyway
const response = await client.get<ServerNotification[]>(
`/fakeApi/notifications`
)
return response.data
}
)

// Renamed from `notificationsAdapter`, and we don't need sorting
const metadataAdapter = createEntityAdapter<NotificationMetadata>()

const initialState = metadataAdapter.getInitialState()

const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
// Rename to `metadata`
Object.values(state.entities).forEach(metadata => {
metadata.read = true
})
}
},
extraReducers(builder) {
// Listen for the endpoint `matchFulfilled` action with `addMatcher`
builder.addMatcher(
apiSliceWithNotifications.endpoints.getNotifications.matchFulfilled,
(state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsMetadata: NotificationMetadata[] =
action.payload.map(notification => ({
// Give the metadata object the same ID as the notification
id: notification.id,
read: false,
isNew: true
}))

// Rename to `metadata`
Object.values(state.entities).forEach(metadata => {
// Any notifications we've read are no longer new
metadata.isNew = !metadata.read
})

metadataAdapter.upsertMany(state, notificationsMetadata)
}
)
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

// Rename the selector
export const {
selectAll: selectAllNotificationsMetadata,
selectEntities: selectMetadataEntities
} = metadataAdapter.getSelectors(
(state: RootState) => state.notifications
)

export const selectUnreadNotificationsCount = (state: RootState) => {
const allMetadata = selectAllNotificationsMetadata(state)
const unreadNotifications = allMetadata.filter(metadata => !metadata.read)
return unreadNotifications.length
}

그런 다음 이 메타데이터 조회 테이블을 <NotificationsList>에서 읽어, 렌더링하는 각 알림에 대한 올바른 메타데이터 객체를 조회하고 isNew 확인을 다시 활성화해 올바른 스타일링을 표시할 수 있습니다:

features/notifications/NotificationsList.tsx
import { allNotificationsRead, useGetNotificationsQuery, selectMetadataEntities } from './notificationsSlice'

export const NotificationsList = () => {
const dispatch = useAppDispatch()
const { data: notifications = [] } = useGetNotificationsQuery()
const notificationsMetadata = useAppSelector(selectMetadataEntities)

useLayoutEffect(() => {
dispatch(allNotificationsRead())
})

const renderedNotifications = notifications.map((notification) => {

// Get the metadata object matching this notification
const metadata = notificationsMetadata[notification.id]
const notificationClassname = classnames('notification', {
// re-enable the `isNew` check for styling
new: metadata.isNew,
})

// omit rendering
}
}

이제 "Notifications" 탭을 보면 새 알림이 올바르게 스타일링됩니다... 하지만 여전히 추가 알림은 표시되지 않으며, 읽음으로 표시되지도 않습니다.

웹소켓을 통한 알림 푸시

서버 푸시로 알림을 수신하기 위해 전환을 완료하려면 몇 단계가 더 남았습니다.

다음 단계는 "알림 새로 고침" 버튼을 HTTP 요청을 통해 가져오는 비동기 썽크에서, 웹소켓을 통해 알림을 보내도록 모의 백엔드를 강제하는 방식으로 전환하는 것입니다.

src/api/server.ts 파일에는 모의 HTTP 서버와 유사한 모의 웹소켓 서버가 이미 구성되어 있습니다. 실제 백엔드(또는 다른 사용자!)가 없기 때문에, 여전히 새 알림을 보낼 시점을 수동으로 모의 서버에 알려야 합니다. 이를 위해 server.tsforceGenerateNotifications 함수를 내보내며, 이 함수는 백엔드가 웹소켓을 통해 알림 항목을 푸시하도록 강제합니다.

기존 fetchNotifications 비동기 썽크를 fetchNotificationsWebsocket 썽크로 대체할 것입니다. fetchNotificationsWebsocket은 기존 fetchNotifications 비동기 썽크와 유사한 작업을 수행합니다. 그러나 이 경우 실제 HTTP 요청을 만들지 않으므로 await 호출이나 반환할 페이로드가 없습니다. 서버 측 푸시 알림을 모의하기 위해 특별히 내보낸 server.ts 함수를 호출하기만 하면 됩니다.

이 때문에 fetchNotificationsWebsocketcreateAsyncThunk를 사용할 필요조차 없습니다. 일반적인 수동 작성 썽크이므로 AppThunk 타입을 사용하여 썽크 함수의 타입을 설명하고 (dispatch, getState)에 올바른 타입을 부여할 수 있습니다.

"최신 타임스탬프" 확인을 구현하려면 알림 캐시 항목에서 읽을 수 있는 셀렉터도 추가해야 합니다. 사용자 슬라이스에서 본 패턴과 동일한 방식을 사용합니다.

features/notifications/notificationsSlice.ts
import {
createEntityAdapter,
createSlice,
createSelector
} from '@reduxjs/toolkit'

import { forceGenerateNotifications } from '@/api/server'
import type { AppThunk, RootState } from '@/app/store'

import { apiSlice } from '@/features/api/apiSlice'

// omit types and API slice setup

export const { useGetNotificationsQuery } = apiSliceWithNotifications

export const fetchNotificationsWebsocket =
(): AppThunk => (dispatch, getState) => {
const allNotifications = selectNotificationsData(getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification?.date ?? ''
// Hardcode a call to the mock server to simulate a server push scenario over websockets
forceGenerateNotifications(latestTimestamp)
}

const emptyNotifications: ServerNotification[] = []

export const selectNotificationsResult =
apiSliceWithNotifications.endpoints.getNotifications.select()

const selectNotificationsData = createSelector(
selectNotificationsResult,
notificationsResult => notificationsResult.data ?? emptyNotifications
)

// omit slice and selectors

그런 다음 <Navbar>fetchNotificationsWebsocket을 대신 디스패치하도록 교체할 수 있습니다:

components/Navbar.tsx
import {
fetchNotificationsWebsocket,
selectUnreadNotificationsCount,
} from '@/features/notifications/notificationsSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'

import { UserIcon } from './UserIcon'

export const Navbar = () => {
// omit hooks

if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}

const fetchNewNotifications = () => {
dispatch(fetchNotificationsWebsocket())
}

거의 다 왔습니다! RTK Query를 통해 초기 알림을 가져오고, 클라이언트 측에서 읽음 상태를 추적하며, 웹소켓을 통해 새 알림을 강제로 보내는 인프라도 설정했습니다. 하지만 지금 "알림 새로 고침"을 클릭하면 오류가 발생합니다. 아직 웹소켓 처리가 구현되지 않았기 때문입니다!

따라서 실제 스트리밍 업데이트 로직을 구현해 보겠습니다.

스트리밍 업데이트 구현

이 앱에서는 개념적으로 사용자가 로그인하자마자 알림을 확인하고 향후 들어오는 모든 알림 업데이트를 즉시 수신 대기하기를 원합니다. 사용자가 로그아웃하면 수신 대기를 중단해야 합니다.

<Navbar>는 사용자가 로그인한 후에만 렌더링되며, 그 동안 계속 렌더링된다는 것을 알고 있습니다. 따라서 캐시 구독을 유지하기에 좋은 장소입니다. 해당 컴포넌트에서 useGetNotificationsQuery() 훅을 렌더링하여 이를 수행할 수 있습니다.

components/Navbar.tsx
// omit other imports
import {
fetchNotificationsWebsocket,
selectUnreadNotificationsCount,
useGetNotificationsQuery
} from '@/features/notifications/notificationsSlice'

export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)

// Trigger initial fetch of notifications and keep the websocket open to receive updates
useGetNotificationsQuery()

// omit rest of the component
}

마지막 단계는 실제로 getNotifications 엔드포인트에 onCacheEntryAdded 라이프사이클 핸들러를 추가하고 웹소켓 작업 로직을 추가하는 것입니다.

이 경우 새 웹소켓을 생성하고, 소켓에서 들어오는 메시지를 구독하며, 해당 메시지에서 알림을 읽고 추가 데이터로 RTKQ 캐시 항목을 업데이트합니다. 이는 onQueryStarted에서 낙관적 업데이트를 수행한 것과 개념적으로 유사합니다.

여기서 마주칠 또 다른 문제가 있습니다. 웹소켓을 통해 들어오는 알림을 수신하는 경우 명시적인 "요청 성공" 액션이 디스패치되지 않지만, 들어오는 모든 알림에 대한 새 알림 메타데이터 항목을 생성해야 합니다.

이 문제를 해결하기 위해 "더 많은 알림을 수신했다"는 신호만을 보내는 새로운 Redux 액션 타입을 생성하고, 웹소켓 핸들러 내부에서 이를 디스패치할 것입니다. 그런 다음 notificationsSliceisAnyOf 매처 유틸리티를 사용하여 엔드포인트 액션과 이 다른 액션을 모두 수신하도록 업데이트하고, 두 경우 모두 동일한 메타데이터 로직을 수행할 수 있습니다.

features/notifications/notificationsSlice.ts
import {
createEntityAdapter,
createSlice,
createSelector,
createAction,
isAnyOf
} from '@reduxjs/toolkit'
// omit imports and other code

const notificationsReceived = createAction<ServerNotification[]>('notifications/notificationsReceived')

export const apiSliceWithNotifications = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query<ServerNotification[], void>({
query: () => '/notifications',
async onCacheEntryAdded(arg, lifecycleApi) {
// create a websocket connection when the cache subscription starts
const ws = new WebSocket('ws://localhost')
try {
// wait for the initial query to resolve before proceeding
await lifecycleApi.cacheDataLoaded

// when data is received from the socket connection to the server,
// update our query result with the received message
const listener = (event: MessageEvent<string>) => {
const message: {
type: 'notifications'
payload: ServerNotification[]
} = JSON.parse(event.data)
switch (message.type) {
case 'notifications': {
lifecycleApi.updateCachedData(draft => {
// Insert all received notifications from the websocket
// into the existing RTKQ cache array
draft.push(...message.payload)
draft.sort((a, b) => b.date.localeCompare(a.date))
})

// Dispatch an additional action so we can track "read" state
lifecycleApi.dispatch(notificationsReceived(message.payload))
break
}
default:
break
}
}

ws.addEventListener('message', listener)
} catch {
// no-op in case `cacheEntryRemoved` resolves before `cacheDataLoaded`,
// in which case `cacheDataLoaded` will throw
}
// cacheEntryRemoved will resolve when the cache subscription is no longer active
await lifecycleApi.cacheEntryRemoved
// perform cleanup steps once the `cacheEntryRemoved` promise resolves
ws.close()
}
})
})
})

export const { useGetNotificationsQuery } = apiSliceWithNotifications

const matchNotificationsReceived = isAnyOf(
notificationsReceived,
apiSliceWithNotifications.endpoints.getNotifications.matchFulfilled,
)

// omit other code

const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: { /* omit reducers */ },
extraReducers(builder) {
builder.addMatcher(matchNotificationsReceived, (state, action) => {
// omit logic
}
},
})

캐시 항목이 추가되면 모의 서버 백엔드에 연결할 새 WebSocket 인스턴스를 생성합니다.

lifecycleApi.cacheDataLoaded Promise가 해결될 때까지 기다리며, 이 시점에 요청이 완료되었고 실제 데이터를 사용할 수 있음을 알 수 있습니다.

웹소켓에서 들어오는 메시지를 구독해야 합니다. 콜백 함수는 웹소켓 MessageEvent를 수신하며, event.data에는 백엔드에서 전송된 JSON 직렬화된 알림 데이터가 문자열 형태로 포함되어 있습니다.

해당 메시지를 수신하면 내용을 파싱하고, 파싱된 객체가 우리가 찾는 메시지 유형과 일치하는지 확인합니다. 일치할 경우 lifecycleApi.updateCachedData()를 호출하여 새 알림을 기존 캐시 항목에 추가하고 올바른 순서로 재정렬합니다.

마지막으로 lifecycleApi.cacheEntryRemoved 프로미스를 기다려 웹소켓을 닫고 정리해야 할 시점을 파악할 수 있습니다.

라이프사이클 메서드에서 웹소켓을 반드시 생성해야 하는 것은 아닙니다. 애플리케이션 구조에 따라 앱 설정 과정에서 미리 생성되었을 수 있으며, 다른 모듈 파일이나 별도의 Redux 미들웨어에 존재할 수 있습니다. 여기서 중요한 것은 onCacheEntryAdded 라이프사이클을 사용해 데이터 수신 시작 시점을 인지하고, 결과를 캐시 항목에 삽입하며, 캐시 항목이 제거될 때 정리하는 방법입니다.

이제 완료되었습니다! "알림 새로고침"을 클릭하면 읽지 않은 알림 수가 증가하고, "알림" 탭을 클릭하면 읽음/안 읽음 상태가 적절하게 강조표시될 것입니다.

정리

마지막 단계로 추가 정리를 수행할 수 있습니다. postsSlice.tscreateSlice 호출은 더 이상 사용되지 않으므로, 슬라이스 객체와 관련 셀렉터 + 타입을 삭제하고 Redux 스토어에서 postsReducer를 제거합니다. addPostsListeners 함수와 타입은 해당 코드를 배치하기 적합한 위치이므로 남겨둡니다.

학습 내용 요약

이로써 애플리케이션을 RTK Query로 전환하는 작업을 완료했습니다! 모든 데이터 패칭이 RTKQ를 사용하도록 전환되었으며, 낙관적 업데이트와 스트리밍 업데이트를 추가해 사용자 경험을 개선했습니다.

보셨듯이 RTK Query는 캐시 데이터 관리 방식을 제어하는 강력한 옵션을 제공합니다. 이러한 옵션을 당장 모두 사용하지 않더라도, 특정 애플리케이션 동작을 구현하는 데 필요한 유연성과 핵심 기능을 제공합니다.

작동 중인 전체 애플리케이션을 마지막으로 살펴보겠습니다:

요약
  • 세부 캐시 무효화 및 재패칭을 위해 특정 캐시 태그 사용 가능
    • 캐시 태그는 'Post' 또는 {type: 'Post', id} 형태일 수 있음
    • 엔드포인트는 결과 및 인자 캐시 키를 기반으로 캐시 태그 제공/무효화 가능
  • RTK Query API는 UI 독립적이며 React 외부에서 사용 가능
    • 엔드포인트 객체에는 요청 시작, 결과 셀렉터 생성, 요청 액션 객체 매칭 기능이 포함됨
  • 응답 데이터를 필요에 따라 다양한 방식으로 변환 가능
    • 엔드포인트는 transformResponse 콜백으로 캐싱 전 데이터 수정 가능
    • 훅에 selectFromResult 옵션을 제공해 데이터 추출/변환 가능
    • 컴포넌트는 전체 값을 읽고 useMemo로 변환 가능
  • 향상된 사용자 경험을 위한 캐시 데이터 조작 고급 옵션 제공
    • onQueryStarted 라이프사이클로 요청 반환 전 즉시 캐시 업데이트(낙관적 업데이트)
    • onCacheEntryAdded 라이프사이클로 서버 푸시 연결 기반 캐시 지속 업데이트(스트리밍 업데이트)
    • RTKQ 엔드포인트는 matchFulfilled 매처를 통해 RTKQ 엔드포인트 액션을 수신하고 슬라이스 상태 업데이트 같은 추가 로직 실행 가능

다음 단계

축하합니다, Redux Essentials 튜토리얼을 완료하셨습니다! 이제 Redux Toolkit과 React-Redux의 개념, Redux 로직 작성 및 구성 방법, React와 함께 사용하는 Redux 데이터 흐름, configureStorecreateSlice 같은 API 사용법을 확실히 이해하셨을 것입니다. 또한 RTK Query가 데이터 패칭 및 캐시 사용 과정을 어떻게 단순화하는지도 알게 되셨습니다.

RTK Query 사용법에 대한 자세한 내용은 RTK Query 사용 가이드 문서API 레퍼런스를 참고하세요.

지금까지 이 튜토리얼에서 다룬 개념들은 React와 Redux를 사용해 여러분만의 애플리케이션을 구축하기 시작하기에 충분할 것입니다. 이제는 이러한 개념을 확고히 하고 실제 동작 방식을 확인하기 위해 직접 프로젝트 작업을 해볼 좋은 시기입니다. 어떤 프로젝트를 만들지 고민된다면 이 앱 프로젝트 아이디어 목록에서 영감을 얻으세요.

Redux Essentials 튜토리얼은 "Redux가 어떻게 작동하는지"나 "왜 이런 방식으로 작동하는지"보다는 "Redux를 올바르게 사용하는 방법"에 초점을 맞추고 있습니다. 특히 Redux Toolkit은 더 높은 수준의 추상화와 유틸리티 집합으로, RTK의 추상화가 실제로 여러분을 위해 무엇을 하는지 이해하는 것이 중요합니다. "Redux Fundamentals" 튜토리얼을 읽으면 "수동으로" Redux 코드를 작성하는 방법과 Redux Toolkit을 Redux 로직 작성의 기본 방식으로 권장하는 이유를 이해하는 데 도움이 됩니다.

Using Redux 섹션에는 리듀서 구조화 방법과 같은 여러 중요한 개념에 대한 정보가 있으며, 스타일 가이드 페이지에는 권장 패턴과 모범 사례에 대한 중요한 정보가 담겨 있습니다.

Redux가 존재하는지, 어떤 문제를 해결하려는지, 어떻게 사용해야 하는지에 대해 더 알고 싶다면 Redux 관리자 Mark Erikson의 포스트인 The Tao of Redux, Part 1: Implementation and IntentThe Tao of Redux, Part 2: Practice and Philosophy를 참고하세요.

Redux 관련 질문에 대한 도움이 필요하다면 Discord의 Reactiflux 서버에 있는 #redux 채널에 참여하세요.

이 튜토리얼을 읽어주셔서 감사합니다. Redux로 애플리케이션을 구축하는 즐거운 시간이 되시길 바랍니다!